Skip to main content

runar_lang/sdk/
calling.rs

1//! Transaction construction for contract method invocation.
2
3use super::types::Utxo;
4use super::deployment::{
5    to_little_endian_32, to_little_endian_64, encode_varint, reverse_hex,
6    build_p2pkh_script_from_address,
7};
8
9/// A contract output specification (script + satoshis).
10pub struct ContractOutput {
11    pub script: String,
12    pub satoshis: i64,
13}
14
15/// An additional contract input with its own unlocking script (for merge).
16pub struct AdditionalContractInput {
17    pub utxo: Utxo,
18    pub unlocking_script: String,
19}
20
21/// Extended options for `build_call_transaction`.
22pub struct CallTxOptions {
23    /// Multiple contract outputs (replaces single newLockingScript).
24    pub contract_outputs: Option<Vec<ContractOutput>>,
25    /// Additional contract inputs with their own unlocking scripts (for merge).
26    pub additional_contract_inputs: Option<Vec<AdditionalContractInput>>,
27}
28
29/// Build a raw transaction that spends a contract UTXO (method call).
30///
31/// The transaction:
32/// - Input 0: the current contract UTXO with the given unlocking script.
33/// - Additional contract inputs (if provided via options): with their own unlock scripts.
34/// - Additional P2PKH funding inputs if provided.
35/// - Contract outputs (multi-output or single continuation).
36/// - Last output (optional): change.
37///
38/// Returns the transaction hex (with unlocking script for input 0 already
39/// placed), the total input count, and the change amount.
40pub fn build_call_transaction(
41    current_utxo: &Utxo,
42    unlocking_script: &str,
43    new_locking_script: Option<&str>,
44    new_satoshis: Option<i64>,
45    change_address: Option<&str>,
46    change_script: Option<&str>,
47    additional_utxos: Option<&[Utxo]>,
48    fee_rate: Option<i64>,
49) -> (String, usize, i64) {
50    build_call_transaction_ext(
51        current_utxo,
52        unlocking_script,
53        new_locking_script,
54        new_satoshis,
55        change_address,
56        change_script,
57        additional_utxos,
58        fee_rate,
59        None,
60    )
61}
62
63/// Extended version of `build_call_transaction` with support for multi-output
64/// and additional contract inputs.
65pub fn build_call_transaction_ext(
66    current_utxo: &Utxo,
67    unlocking_script: &str,
68    new_locking_script: Option<&str>,
69    new_satoshis: Option<i64>,
70    change_address: Option<&str>,
71    change_script: Option<&str>,
72    additional_utxos: Option<&[Utxo]>,
73    fee_rate: Option<i64>,
74    options: Option<&CallTxOptions>,
75) -> (String, usize, i64) {
76    let extra_contract_inputs = options
77        .and_then(|o| o.additional_contract_inputs.as_ref())
78        .map(|v| v.as_slice())
79        .unwrap_or(&[]);
80    let p2pkh_utxos = additional_utxos.unwrap_or(&[]);
81
82    // Collect all input UTXOs for total calculation
83    let mut all_utxos = vec![current_utxo.clone()];
84    for ci in extra_contract_inputs {
85        all_utxos.push(ci.utxo.clone());
86    }
87    all_utxos.extend_from_slice(p2pkh_utxos);
88
89    let total_input: i64 = all_utxos.iter().map(|u| u.satoshis).sum();
90
91    // Determine contract outputs: multi-output takes priority over single
92    let contract_outputs: Vec<ContractOutput> = if let Some(cos) = options.and_then(|o| o.contract_outputs.as_ref()) {
93        // Already provided externally — borrow as references for output
94        cos.iter().map(|co| ContractOutput { script: co.script.clone(), satoshis: co.satoshis }).collect()
95    } else if let Some(nls) = new_locking_script {
96        vec![ContractOutput {
97            script: nls.to_string(),
98            satoshis: new_satoshis.unwrap_or(current_utxo.satoshis),
99        }]
100    } else {
101        vec![]
102    };
103
104    let contract_output_sats: i64 = contract_outputs.iter().map(|o| o.satoshis).sum();
105
106    // Estimate fee using actual script sizes
107    let unlock_byte_len = unlocking_script.len() / 2;
108    let input0_size = 32 + 4 + varint_byte_size(unlock_byte_len) + unlock_byte_len as i64 + 4;
109    let mut extra_contract_inputs_size: i64 = 0;
110    for ci in extra_contract_inputs {
111        let ci_byte_len = ci.unlocking_script.len() / 2;
112        extra_contract_inputs_size += 32 + 4 + varint_byte_size(ci_byte_len) + ci_byte_len as i64 + 4;
113    }
114    let p2pkh_inputs_size = p2pkh_utxos.len() as i64 * 148;
115    let inputs_size = input0_size + extra_contract_inputs_size + p2pkh_inputs_size;
116
117    let mut outputs_size: i64 = 0;
118    for co in &contract_outputs {
119        let co_byte_len = co.script.len() / 2;
120        outputs_size += 8 + varint_byte_size(co_byte_len) + co_byte_len as i64;
121    }
122    let has_change_target = change_address.is_some() || change_script.is_some();
123    if has_change_target {
124        outputs_size += 34; // P2PKH change
125    }
126    let estimated_size = 10 + inputs_size + outputs_size;
127    let rate = fee_rate.filter(|&r| r > 0).unwrap_or(100);
128    let fee = (estimated_size * rate + 999) / 1000;
129
130    let change = total_input - contract_output_sats - fee;
131
132    // Build raw transaction
133    let mut tx = String::new();
134
135    // Version (4 bytes LE)
136    tx.push_str(&to_little_endian_32(1));
137
138    // Input count
139    tx.push_str(&encode_varint(all_utxos.len() as u64));
140
141    // Input 0: primary contract UTXO with unlocking script
142    tx.push_str(&reverse_hex(&current_utxo.txid));
143    tx.push_str(&to_little_endian_32(current_utxo.output_index));
144    tx.push_str(&encode_varint(unlock_byte_len as u64));
145    tx.push_str(unlocking_script);
146    tx.push_str("ffffffff");
147
148    // Additional contract inputs (with their own unlocking scripts)
149    for ci in extra_contract_inputs {
150        tx.push_str(&reverse_hex(&ci.utxo.txid));
151        tx.push_str(&to_little_endian_32(ci.utxo.output_index));
152        let ci_byte_len = ci.unlocking_script.len() / 2;
153        tx.push_str(&encode_varint(ci_byte_len as u64));
154        tx.push_str(&ci.unlocking_script);
155        tx.push_str("ffffffff");
156    }
157
158    // P2PKH funding inputs (unsigned)
159    for utxo in p2pkh_utxos {
160        tx.push_str(&reverse_hex(&utxo.txid));
161        tx.push_str(&to_little_endian_32(utxo.output_index));
162        tx.push_str("00"); // empty scriptSig
163        tx.push_str("ffffffff");
164    }
165
166    // Output count
167    let mut num_outputs = contract_outputs.len() as u64;
168    if change > 0 && has_change_target {
169        num_outputs += 1;
170    }
171    tx.push_str(&encode_varint(num_outputs));
172
173    // Contract outputs
174    for co in &contract_outputs {
175        tx.push_str(&to_little_endian_64(co.satoshis));
176        tx.push_str(&encode_varint((co.script.len() / 2) as u64));
177        tx.push_str(&co.script);
178    }
179
180    // Change output
181    if change > 0 && has_change_target {
182        let actual_change_script = if let Some(cs) = change_script {
183            cs.to_string()
184        } else if let Some(addr) = change_address {
185            build_p2pkh_script_from_address(addr)
186        } else {
187            String::new()
188        };
189        tx.push_str(&to_little_endian_64(change));
190        tx.push_str(&encode_varint((actual_change_script.len() / 2) as u64));
191        tx.push_str(&actual_change_script);
192    }
193
194    // Locktime
195    tx.push_str(&to_little_endian_32(0));
196
197    let change_amount = if change > 0 { change } else { 0 };
198    (tx, all_utxos.len(), change_amount)
199}
200
201fn varint_byte_size(n: usize) -> i64 {
202    if n < 0xfd { 1 }
203    else if n <= 0xffff { 3 }
204    else if n <= 0xffff_ffff { 5 }
205    else { 9 }
206}
207
208// ---------------------------------------------------------------------------
209// Tests
210// ---------------------------------------------------------------------------
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    fn make_utxo(satoshis: i64, index: u32) -> Utxo {
217        Utxo {
218            txid: "aabbccdd".repeat(8),
219            output_index: index,
220            satoshis,
221            script: format!("76a914{}88ac", "00".repeat(20)),
222        }
223    }
224
225    fn parse_tx_hex(hex: &str) -> ParsedTx {
226        let mut offset = 0;
227
228        fn read_bytes<'a>(hex: &'a str, offset: &mut usize, n: usize) -> &'a str {
229            let start = *offset;
230            *offset += n * 2;
231            &hex[start..*offset]
232        }
233
234        fn read_u32_le(hex: &str, offset: &mut usize) -> u32 {
235            let h = read_bytes(hex, offset, 4);
236            let mut bytes = [0u8; 4];
237            for i in 0..4 {
238                bytes[i] = u8::from_str_radix(&h[i * 2..i * 2 + 2], 16).unwrap();
239            }
240            u32::from_le_bytes(bytes)
241        }
242
243        fn read_u64_le(hex: &str, offset: &mut usize) -> u64 {
244            let lo = read_u32_le(hex, offset) as u64;
245            let hi = read_u32_le(hex, offset) as u64;
246            lo | (hi << 32)
247        }
248
249        fn read_varint(hex: &str, offset: &mut usize) -> u64 {
250            let first = u8::from_str_radix(read_bytes(hex, offset, 1), 16).unwrap();
251            if first < 0xfd {
252                first as u64
253            } else if first == 0xfd {
254                let h = read_bytes(hex, offset, 2);
255                let lo = u8::from_str_radix(&h[0..2], 16).unwrap() as u64;
256                let hi = u8::from_str_radix(&h[2..4], 16).unwrap() as u64;
257                lo | (hi << 8)
258            } else {
259                panic!("unsupported varint");
260            }
261        }
262
263        let version = read_u32_le(hex, &mut offset);
264        let input_count = read_varint(hex, &mut offset) as usize;
265
266        let mut inputs = Vec::new();
267        for _ in 0..input_count {
268            let prev_txid = read_bytes(hex, &mut offset, 32).to_string();
269            let prev_index = read_u32_le(hex, &mut offset);
270            let script_len = read_varint(hex, &mut offset) as usize;
271            let script = read_bytes(hex, &mut offset, script_len).to_string();
272            let sequence = read_u32_le(hex, &mut offset);
273            inputs.push(ParsedInput {
274                prev_txid,
275                prev_index,
276                script,
277                sequence,
278            });
279        }
280
281        let output_count = read_varint(hex, &mut offset) as usize;
282        let mut outputs = Vec::new();
283        for _ in 0..output_count {
284            let satoshis = read_u64_le(hex, &mut offset) as i64;
285            let script_len = read_varint(hex, &mut offset) as usize;
286            let script = read_bytes(hex, &mut offset, script_len).to_string();
287            outputs.push(ParsedOutput { satoshis, script });
288        }
289
290        let locktime = read_u32_le(hex, &mut offset);
291
292        ParsedTx {
293            version,
294            input_count,
295            inputs,
296            output_count,
297            outputs,
298            locktime,
299        }
300    }
301
302    #[derive(Debug)]
303    #[allow(dead_code)]
304    struct ParsedTx {
305        version: u32,
306        input_count: usize,
307        inputs: Vec<ParsedInput>,
308        output_count: usize,
309        outputs: Vec<ParsedOutput>,
310        locktime: u32,
311    }
312
313    #[derive(Debug)]
314    #[allow(dead_code)]
315    struct ParsedInput {
316        prev_txid: String,
317        prev_index: u32,
318        script: String,
319        sequence: u32,
320    }
321
322    #[derive(Debug)]
323    #[allow(dead_code)]
324    struct ParsedOutput {
325        satoshis: i64,
326        script: String,
327    }
328
329    fn reverse_hex_helper(hex: &str) -> String {
330        let pairs: Vec<&str> = (0..hex.len()).step_by(2).map(|i| &hex[i..i + 2]).collect();
331        pairs.iter().rev().copied().collect()
332    }
333
334    #[test]
335    fn version_1_locktime_0() {
336        let utxo = make_utxo(100_000, 0);
337        let (tx_hex, _, _) = build_call_transaction(&utxo, "51", None, None, None, None, None, None);
338        let parsed = parse_tx_hex(&tx_hex);
339        assert_eq!(parsed.version, 1);
340        assert_eq!(parsed.locktime, 0);
341    }
342
343    #[test]
344    fn valid_hex_output() {
345        let utxo = make_utxo(100_000, 0);
346        let (tx_hex, _, _) = build_call_transaction(&utxo, "51", None, None, None, None, None, None);
347        assert!(!tx_hex.is_empty());
348        assert!(tx_hex.chars().all(|c| c.is_ascii_hexdigit()));
349    }
350
351    #[test]
352    fn embeds_unlocking_script_in_input_0() {
353        let utxo = make_utxo(100_000, 0);
354        let (tx_hex, _, _) = build_call_transaction(&utxo, "aabb", None, None, None, None, None, None);
355        let parsed = parse_tx_hex(&tx_hex);
356        assert_eq!(parsed.inputs[0].script, "aabb");
357    }
358
359    #[test]
360    fn all_sequences_ffffffff() {
361        let utxo = make_utxo(100_000, 0);
362        let additional = vec![make_utxo(50_000, 1), make_utxo(30_000, 2)];
363        let change_script = format!("76a914{}88ac", "ff".repeat(20));
364        let (tx_hex, _, _) = build_call_transaction(
365            &utxo, "51", None, None, Some("changeaddr"), Some(&change_script), Some(&additional), None,
366        );
367        let parsed = parse_tx_hex(&tx_hex);
368        for input in &parsed.inputs {
369            assert_eq!(input.sequence, 0xffff_ffff);
370        }
371    }
372
373    #[test]
374    fn reversed_txid_in_wire_format() {
375        let utxo = make_utxo(100_000, 0);
376        let (tx_hex, _, _) = build_call_transaction(&utxo, "51", None, None, None, None, None, None);
377        let parsed = parse_tx_hex(&tx_hex);
378        assert_eq!(parsed.inputs[0].prev_txid, reverse_hex_helper(&utxo.txid));
379    }
380
381    #[test]
382    fn single_input_no_additional() {
383        let utxo = make_utxo(100_000, 0);
384        let (tx_hex, input_count, _) = build_call_transaction(&utxo, "51", None, None, None, None, None, None);
385        let parsed = parse_tx_hex(&tx_hex);
386        assert_eq!(input_count, 1);
387        assert_eq!(parsed.input_count, 1);
388    }
389
390    #[test]
391    fn additional_utxos_have_empty_scriptsig() {
392        let utxo = make_utxo(100_000, 0);
393        let additional = vec![make_utxo(50_000, 1), make_utxo(30_000, 2)];
394        let change_script = format!("76a914{}88ac", "ff".repeat(20));
395        let (tx_hex, input_count, _) = build_call_transaction(
396            &utxo, "51", None, None, Some("changeaddr"), Some(&change_script), Some(&additional), None,
397        );
398        let parsed = parse_tx_hex(&tx_hex);
399        assert_eq!(input_count, 3);
400        assert_eq!(parsed.inputs[0].script, "51");
401        assert_eq!(parsed.inputs[1].script, "");
402        assert_eq!(parsed.inputs[2].script, "");
403    }
404
405    #[test]
406    fn correct_output_index_reference() {
407        let utxo = make_utxo(100_000, 3);
408        let (tx_hex, _, _) = build_call_transaction(&utxo, "51", None, None, None, None, None, None);
409        let parsed = parse_tx_hex(&tx_hex);
410        assert_eq!(parsed.inputs[0].prev_index, 3);
411    }
412
413    #[test]
414    fn stateful_output_with_new_locking_script() {
415        let utxo = make_utxo(100_000, 0);
416        let new_ls = format!("76a914{}88ac", "dd".repeat(20));
417        let change_script = format!("76a914{}88ac", "ff".repeat(20));
418        let (tx_hex, _, _) = build_call_transaction(
419            &utxo, "51", Some(&new_ls), Some(50_000), Some("changeaddr"), Some(&change_script), None, None,
420        );
421        let parsed = parse_tx_hex(&tx_hex);
422        assert_eq!(parsed.outputs[0].script, new_ls);
423        assert_eq!(parsed.outputs[0].satoshis, 50_000);
424    }
425
426    #[test]
427    fn defaults_to_current_utxo_satoshis() {
428        let utxo = make_utxo(75_000, 0);
429        let change_script = format!("76a914{}88ac", "ff".repeat(20));
430        let (tx_hex, _, _) = build_call_transaction(
431            &utxo, "00", Some("51"), None, Some("changeaddr"), Some(&change_script), None, None,
432        );
433        let parsed = parse_tx_hex(&tx_hex);
434        assert_eq!(parsed.outputs[0].satoshis, 75_000);
435    }
436
437    #[test]
438    fn change_calculation() {
439        let utxo = make_utxo(100_000, 0);
440        let change_script = format!("76a914{}88ac", "ff".repeat(20));
441        let (tx_hex, _, _) = build_call_transaction(
442            &utxo, "00", Some("51"), Some(50_000), Some("changeaddr"), Some(&change_script), None, None,
443        );
444        let parsed = parse_tx_hex(&tx_hex);
445        // txSize: input0(32+4+1+1+4=42) + contractOut(8+1+1=10) + changeOut(34) + overhead(10) = 96
446        // Fee: ceil(96 * 100 / 1000) = 10
447        // Change = 100000 - 50000 - 10 = 49990
448        assert_eq!(parsed.output_count, 2);
449        assert_eq!(parsed.outputs[0].satoshis, 50_000);
450        assert_eq!(parsed.outputs[1].satoshis, 49_990);
451        assert_eq!(parsed.outputs[1].script, change_script);
452    }
453
454    #[test]
455    fn omits_change_when_zero() {
456        // txSize: input0(42) + contractOut(10) + changeOut(34) + overhead(10) = 96
457        // Fee: ceil(96 * 100 / 1000) = 10
458        let utxo = make_utxo(50_010, 0);
459        let change_script = format!("76a914{}88ac", "ff".repeat(20));
460        let (tx_hex, _, _) = build_call_transaction(
461            &utxo, "00", Some("51"), Some(50_000), Some("changeaddr"), Some(&change_script), None, None,
462        );
463        let parsed = parse_tx_hex(&tx_hex);
464        assert_eq!(parsed.output_count, 1);
465        assert_eq!(parsed.outputs[0].satoshis, 50_000);
466    }
467
468    #[test]
469    fn stateless_change_only() {
470        let utxo = make_utxo(100_000, 0);
471        let change_script = format!("76a914{}88ac", "ff".repeat(20));
472        let (tx_hex, _, _) = build_call_transaction(
473            &utxo, "51", None, None, Some("changeaddr"), Some(&change_script), None, None,
474        );
475        let parsed = parse_tx_hex(&tx_hex);
476        // txSize: input0(42) + changeOut(34) + overhead(10) = 86
477        // Fee: ceil(86 * 100 / 1000) = 9
478        // Change: 100000 - 0 - 9 = 99991
479        assert_eq!(parsed.output_count, 1);
480        assert_eq!(parsed.outputs[0].script, change_script);
481        assert_eq!(parsed.outputs[0].satoshis, 99_991);
482    }
483
484    #[test]
485    fn stateless_no_outputs_when_change_zero() {
486        // txSize: input0(42) + changeOut(34) + overhead(10) = 86
487        // Fee: ceil(86 * 100 / 1000) = 9
488        let utxo = make_utxo(9, 0);
489        let change_script = format!("76a914{}88ac", "ff".repeat(20));
490        let (tx_hex, _, _) = build_call_transaction(
491            &utxo, "51", None, None, Some("changeaddr"), Some(&change_script), None, None,
492        );
493        let parsed = parse_tx_hex(&tx_hex);
494        assert_eq!(parsed.output_count, 0);
495    }
496
497    #[test]
498    fn accumulates_additional_utxos() {
499        let utxo = make_utxo(50_000, 0);
500        let additional = vec![make_utxo(30_000, 1)];
501        let change_script = format!("76a914{}88ac", "ff".repeat(20));
502        let (tx_hex, _, _) = build_call_transaction(
503            &utxo, "00", Some("51"), Some(40_000), Some("changeaddr"), Some(&change_script), Some(&additional), None,
504        );
505        let parsed = parse_tx_hex(&tx_hex);
506        // txSize: input0(42) + additional(148) + contractOut(10) + changeOut(34) + overhead(10) = 244
507        // Fee: ceil(244 * 100 / 1000) = 25
508        // Total input: 80000, Change: 80000 - 40000 - 25 = 39975
509        assert_eq!(parsed.output_count, 2);
510        assert_eq!(parsed.outputs[0].satoshis, 40_000);
511        assert_eq!(parsed.outputs[1].satoshis, 39_975);
512    }
513}