Skip to main content

iris_wasm/
tx.rs

1use alloc::format;
2use alloc::string::{String, ToString};
3use alloc::vec::Vec;
4use std::collections::BTreeMap;
5
6use iris_crypto::PrivateKey;
7use iris_grpc_proto::pb::common::v1 as pb_v1;
8use iris_grpc_proto::pb::common::v2 as pb;
9use iris_nockchain_types::{
10    builder::{MissingUnlocks, TxBuilder},
11    note::{Name, Note, Version},
12    tx::RawTx,
13    v0,
14    v1::{self, NockchainTx, NoteData, RawTxV1, SeedV1 as Seed, SpendCondition},
15    BlockHeight, Nicks, Source, SpendBuilder, TxEngineSettings,
16};
17use iris_ztd::{cue, Digest, ZSet, U256};
18use serde::{Deserialize, Serialize};
19use wasm_bindgen::prelude::*;
20
21// ============================================================================
22// Wasm Types - Adapters and Helpers
23// ============================================================================
24
25#[wasm_bindgen]
26pub fn digest_to_hex(d: Digest) -> String {
27    d.to_string()
28}
29
30#[wasm_bindgen]
31pub fn hex_to_digest(s: &str) -> Result<Digest, JsValue> {
32    s.try_into().map_err(|e: &str| JsValue::from_str(e))
33}
34
35#[wasm_bindgen]
36pub fn digest_to_protobuf(d: Digest) -> pb_v1::Hash {
37    d.into()
38}
39
40#[wasm_bindgen]
41pub fn digest_from_protobuf(value: pb_v1::Hash) -> Result<Digest, JsValue> {
42    value
43        .try_into()
44        .map_err(|e| JsValue::from_str(&format!("{}", e)))
45}
46
47#[wasm_bindgen]
48pub fn note_hash(note: Note) -> Digest {
49    use iris_ztd::Hashable;
50    note.hash()
51}
52
53#[wasm_bindgen]
54pub fn note_to_protobuf(note: Note) -> pb::Note {
55    note.into()
56}
57
58#[wasm_bindgen]
59pub fn note_from_protobuf(value: pb::Note) -> Result<Note, JsValue> {
60    value
61        .try_into()
62        .map_err(|e| JsValue::from_str(&format!("{}", e)))
63}
64
65#[wasm_bindgen(js_name = nockchainTxToRaw)]
66pub fn nockchain_tx_to_raw(tx: NockchainTx) -> RawTx {
67    RawTx::V1(tx.to_raw_tx())
68}
69
70#[wasm_bindgen(js_name = rawTxToNockchainTx)]
71pub fn raw_tx_to_nockchain_tx(tx: RawTxV1) -> NockchainTx {
72    tx.to_nockchain_tx()
73}
74
75#[wasm_bindgen(js_name = rawTxToProtobuf)]
76pub fn raw_tx_to_protobuf(tx: RawTxV1) -> pb::RawTransaction {
77    tx.into()
78}
79
80#[wasm_bindgen(js_name = rawTxFromProtobuf)]
81pub fn raw_tx_from_protobuf(tx: pb::RawTransaction) -> Result<RawTx, JsValue> {
82    tx.try_into()
83        .map_err(|e| JsValue::from_str(&format!("{}", e)))
84}
85
86#[wasm_bindgen(js_name = rawTxOutputs)]
87pub fn raw_tx_outputs(tx: RawTx) -> Vec<Note> {
88    tx.outputs()
89}
90
91// Helper to create V1 note
92#[wasm_bindgen]
93pub fn create_note_v1(
94    version: Version,
95    origin_page: BlockHeight,
96    name: Name,
97    note_data: NoteData,
98    assets: Nicks,
99) -> Result<Note, JsValue> {
100    let internal = Note::V1(v1::NoteV1::new(
101        version,
102        origin_page,
103        name,
104        note_data,
105        assets,
106    ));
107    Ok(internal)
108}
109
110// Helper to create V0 note
111#[wasm_bindgen]
112pub fn create_note_v0(
113    origin_page: BlockHeight,
114    sig_m: u64,
115    sig_pubkeys: Vec<js_sys::Uint8Array>,
116    source_hash: Digest,
117    is_coinbase: bool,
118    timelock: Option<v0::Timelock>,
119    assets: Nicks,
120) -> Result<Note, JsValue> {
121    use iris_crypto::PublicKey;
122    // use iris_ztd::Hashable; // import Hashable trait if needed? No, Name::new_v0 needs traits probably.
123
124    // Parse public keys from byte arrays
125    let pubkeys: Result<ZSet<PublicKey>, JsValue> = sig_pubkeys
126        .iter()
127        .map(|arr| {
128            let bytes = arr.to_vec();
129            if bytes.len() != 97 {
130                return Err(JsValue::from_str(&format!(
131                    "Public key must be 97 bytes, got {}",
132                    bytes.len()
133                )));
134            }
135            let mut arr = [0u8; 97];
136            arr.copy_from_slice(&bytes);
137            Ok(PublicKey::from_be_bytes(&arr))
138        })
139        .collect();
140    let pubkeys = pubkeys?;
141
142    let sig = v0::Sig { m: sig_m, pubkeys };
143
144    let source = Source {
145        hash: source_hash,
146        is_coinbase,
147    };
148
149    let timelock_intent = v0::TimelockIntent { tim: timelock };
150
151    let name = Name::new_v0(sig.clone(), source, timelock_intent);
152
153    let internal = Note::V0(v0::NoteV0::new(
154        Version::V0,
155        origin_page,
156        timelock_intent,
157        name,
158        sig,
159        source,
160        assets,
161    ));
162    Ok(internal)
163}
164
165#[derive(Serialize, Deserialize, tsify::Tsify)]
166#[tsify(into_wasm_abi, from_wasm_abi)]
167pub struct TxNotes {
168    pub notes: Vec<Note>,
169    pub spend_conditions: Vec<SpendCondition>,
170}
171
172// ============================================================================
173// Wasm Transaction Builder
174// ============================================================================
175
176#[wasm_bindgen(js_name = TxBuilder)]
177pub struct WasmTxBuilder {
178    builder: TxBuilder,
179}
180
181#[wasm_bindgen(js_class = TxBuilder)]
182impl WasmTxBuilder {
183    /// Create an empty transaction builder
184    #[wasm_bindgen(constructor)]
185    pub fn new(settings: TxEngineSettings) -> Self {
186        Self {
187            builder: TxBuilder::new(settings),
188        }
189    }
190
191    /// Reconstruct a builder from raw transaction and its input notes.
192    #[wasm_bindgen(js_name = fromTx)]
193    pub fn from_tx(
194        tx: RawTx,
195        notes: Vec<Note>,
196        spend_conditions: Vec<SpendCondition>,
197        settings: TxEngineSettings,
198    ) -> Result<Self, JsValue> {
199        if notes.len() != spend_conditions.len() {
200            return Err(JsValue::from_str(
201                "notes and spend_conditions must have the same length",
202            ));
203        }
204
205        let internal_notes: Result<BTreeMap<Name, (Note, Option<SpendCondition>)>, String> = notes
206            .into_iter()
207            .zip(spend_conditions)
208            .map(|(n, sc)| Ok((n.name(), (n, Some(sc)))))
209            .collect();
210        let internal_notes = internal_notes.map_err(|e| JsValue::from_str(&e))?;
211
212        let builder =
213            TxBuilder::from_tx(tx, internal_notes, settings).map_err(|e| e.to_string())?;
214
215        Ok(Self { builder })
216    }
217
218    #[allow(clippy::too_many_arguments)]
219    #[wasm_bindgen(js_name = simpleSpend)]
220    pub fn simple_spend(
221        &mut self,
222        notes: Vec<Note>,
223        spend_conditions: Vec<SpendCondition>,
224        recipient: Digest,
225        gift: Nicks,
226        fee_override: Option<Nicks>,
227        refund_pkh: Digest,
228        include_lock_data: bool,
229    ) -> Result<(), JsValue> {
230        if notes.len() != spend_conditions.len() {
231            return Err(JsValue::from_str(
232                "notes and spend_conditions must have the same length",
233            ));
234        }
235
236        let internal_notes: Vec<(Note, Option<SpendCondition>)> = notes
237            .into_iter()
238            .zip(spend_conditions)
239            .map(|(n, sc)| (n, Some(sc)))
240            .collect();
241
242        self.builder
243            .simple_spend_base(
244                internal_notes,
245                recipient,
246                gift,
247                refund_pkh,
248                include_lock_data,
249            )
250            .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
251
252        if let Some(fee) = fee_override {
253            self.builder
254                .set_fee_and_balance_refund(fee, false, include_lock_data)
255        } else {
256            self.builder.recalc_and_set_fee(include_lock_data)
257        }
258        .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
259
260        Ok(())
261    }
262
263    /// Append a `SpendBuilder` to this transaction
264    pub fn spend(&mut self, spend: WasmSpendBuilder) -> Option<WasmSpendBuilder> {
265        self.builder.spend(spend.into()).map(|v| v.into())
266    }
267
268    #[wasm_bindgen(js_name = setFeeAndBalanceRefund)]
269    pub fn set_fee_and_balance_refund(
270        &mut self,
271        fee: Nicks,
272        adjust_fee: bool,
273        include_lock_data: bool,
274    ) -> Result<(), JsValue> {
275        self.builder
276            .set_fee_and_balance_refund(fee, adjust_fee, include_lock_data)
277            .map_err(|e| e.to_string())?;
278        Ok(())
279    }
280
281    #[wasm_bindgen(js_name = recalcAndSetFee)]
282    pub fn recalc_and_set_fee(&mut self, include_lock_data: bool) -> Result<(), JsValue> {
283        self.builder
284            .recalc_and_set_fee(include_lock_data)
285            .map_err(|e| e.to_string())?;
286        Ok(())
287    }
288
289    #[wasm_bindgen(js_name = addPreimage)]
290    pub fn add_preimage(&mut self, preimage_jam: &[u8]) -> Result<Option<Digest>, JsValue> {
291        let preimage = cue(preimage_jam).ok_or("Unable to cue preimage jam")?;
292        Ok(self.builder.add_preimage(preimage))
293    }
294
295    #[wasm_bindgen]
296    pub fn sign(&mut self, signing_key_bytes: &[u8]) -> Result<(), JsValue> {
297        if signing_key_bytes.len() != 32 {
298            return Err(JsValue::from_str("Private key must be 32 bytes"));
299        }
300        let signing_key = PrivateKey(U256::from_be_slice(signing_key_bytes));
301
302        self.builder.sign(&signing_key);
303
304        Ok(())
305    }
306
307    #[wasm_bindgen]
308    pub fn validate(&mut self) -> Result<(), JsValue> {
309        self.builder
310            .validate()
311            .map_err(|v| JsValue::from_str(&v.to_string()))?;
312
313        Ok(())
314    }
315
316    #[wasm_bindgen(js_name = curFee)]
317    pub fn cur_fee(&self) -> Nicks {
318        self.builder.cur_fee()
319    }
320
321    #[wasm_bindgen(js_name = calcFee)]
322    pub fn calc_fee(&self) -> Nicks {
323        self.builder.calc_fee()
324    }
325
326    #[wasm_bindgen(js_name = allNotes)]
327    pub fn all_notes(&self) -> Result<TxNotes, JsValue> {
328        let mut ret = TxNotes {
329            notes: vec![],
330            spend_conditions: vec![],
331        };
332        for (note, spend_condition) in self.builder.all_notes().into_values() {
333            ret.notes.push(note);
334            if let Some(sc) = spend_condition {
335                ret.spend_conditions.push(sc);
336            }
337        }
338        Ok(ret)
339    }
340
341    #[wasm_bindgen]
342    pub fn build(&self) -> Result<NockchainTx, JsValue> {
343        Ok(self.builder.build())
344    }
345
346    #[wasm_bindgen(js_name = allSpends)]
347    pub fn all_spends(&self) -> Vec<WasmSpendBuilder> {
348        self.builder
349            .all_spends()
350            .values()
351            .map(WasmSpendBuilder::from_internal)
352            .collect()
353    }
354}
355
356// ============================================================================
357// Wasm Spend Builder
358// ============================================================================
359
360#[wasm_bindgen(js_name = SpendBuilder)]
361pub struct WasmSpendBuilder {
362    builder: SpendBuilder,
363}
364
365#[wasm_bindgen(js_class = SpendBuilder)]
366impl WasmSpendBuilder {
367    /// Create a new `SpendBuilder` with a given note and spend condition
368    #[wasm_bindgen(constructor)]
369    pub fn new(
370        note: Note,
371        spend_condition: Option<SpendCondition>,
372        refund_lock: Option<SpendCondition>,
373    ) -> Result<Self, JsValue> {
374        Ok(Self {
375            builder: SpendBuilder::new(note, spend_condition, refund_lock)
376                .map_err(|e| JsValue::from_str(&e.to_string()))?,
377        })
378    }
379
380    pub fn fee(&mut self, fee: Nicks) {
381        self.builder.fee(fee);
382    }
383
384    #[wasm_bindgen(js_name = computeRefund)]
385    pub fn compute_refund(&mut self, include_lock_data: bool) {
386        self.builder.compute_refund(include_lock_data);
387    }
388
389    #[wasm_bindgen(js_name = curRefund)]
390    pub fn cur_refund(&self) -> Option<Seed> {
391        self.builder.cur_refund().cloned()
392    }
393
394    #[wasm_bindgen(js_name = isBalanced)]
395    pub fn is_balanced(&self) -> bool {
396        self.builder.is_balanced()
397    }
398
399    pub fn seed(&mut self, seed: Seed) -> Result<(), JsValue> {
400        self.builder.seed(seed);
401        Ok(())
402    }
403
404    #[wasm_bindgen(js_name = invalidateSigs)]
405    pub fn invalidate_sigs(&mut self) {
406        self.builder.invalidate_sigs();
407    }
408
409    #[wasm_bindgen(js_name = missingUnlocks)]
410    pub fn missing_unlocks(&self) -> Result<Vec<MissingUnlocks>, JsValue> {
411        // MissingUnlocks is now Tsify, so we can return Vec<MissingUnlocks>
412        Ok(self.builder.missing_unlocks())
413    }
414
415    #[wasm_bindgen(js_name = addPreimage)]
416    pub fn add_preimage(&mut self, preimage_jam: &[u8]) -> Result<Option<Digest>, JsValue> {
417        let preimage = cue(preimage_jam).ok_or("Unable to cue preimage jam")?;
418        Ok(self.builder.add_preimage(preimage))
419    }
420
421    pub fn sign(&mut self, signing_key_bytes: &[u8]) -> Result<bool, JsValue> {
422        if signing_key_bytes.len() != 32 {
423            return Err(JsValue::from_str("Private key must be 32 bytes"));
424        }
425        let signing_key = PrivateKey(U256::from_be_slice(signing_key_bytes));
426        Ok(self.builder.sign(&signing_key))
427    }
428
429    fn from_internal(internal: &SpendBuilder) -> Self {
430        Self {
431            builder: internal.clone(),
432        }
433    }
434}
435
436impl From<SpendBuilder> for WasmSpendBuilder {
437    fn from(builder: SpendBuilder) -> Self {
438        Self { builder }
439    }
440}
441
442impl From<WasmSpendBuilder> for SpendBuilder {
443    fn from(value: WasmSpendBuilder) -> Self {
444        value.builder
445    }
446}