Skip to main content

ark_core/
intent.rs

1use crate::Error;
2use crate::ErrorContext;
3use crate::VTXO_CONDITION_KEY;
4use crate::VTXO_TAPROOT_KEY;
5use bitcoin::absolute;
6use bitcoin::base64;
7use bitcoin::base64::Engine;
8use bitcoin::hashes::sha256;
9use bitcoin::hashes::Hash;
10use bitcoin::opcodes::all::*;
11use bitcoin::psbt;
12use bitcoin::psbt::PsbtSighashType;
13use bitcoin::secp256k1;
14use bitcoin::secp256k1::schnorr;
15use bitcoin::secp256k1::PublicKey;
16use bitcoin::sighash::Prevouts;
17use bitcoin::sighash::SighashCache;
18use bitcoin::taproot;
19use bitcoin::transaction::Version;
20use bitcoin::Amount;
21use bitcoin::OutPoint;
22use bitcoin::Psbt;
23use bitcoin::ScriptBuf;
24use bitcoin::Sequence;
25use bitcoin::TapLeafHash;
26use bitcoin::TapSighashType;
27use bitcoin::Transaction;
28use bitcoin::TxIn;
29use bitcoin::TxOut;
30use bitcoin::Txid;
31use bitcoin::Witness;
32use bitcoin::XOnlyPublicKey;
33use serde::Deserialize;
34use serde::Serialize;
35
36#[derive(Clone, Debug)]
37pub struct Input {
38    // The TXID of this outpoint is a hash of the TXID of the actual outpoint.
39    outpoint: OutPoint,
40    // Related to OP_CSV (such as unilateral exit for all VTXOs).
41    sequence: Sequence,
42    // Related to OP_CLTV (such as the timelock in a HTLC).
43    locktime: absolute::LockTime,
44    witness_utxo: TxOut,
45    // We do not serialize this.
46    tapscripts: Vec<ScriptBuf>,
47    spend_info: (ScriptBuf, taproot::ControlBlock),
48    is_onchain: bool,
49    is_swept: bool,
50    /// Extra witness elements for spending (e.g., preimage for ArkNotes).
51    /// When set, these are used instead of generating a signature.
52    extra_witness: Option<Vec<Vec<u8>>>,
53}
54
55impl Input {
56    pub fn new(
57        outpoint: OutPoint,
58        sequence: Sequence,
59        locktime: Option<absolute::LockTime>,
60        witness_utxo: TxOut,
61        tapscripts: Vec<ScriptBuf>,
62        spend_info: (ScriptBuf, taproot::ControlBlock),
63        is_onchain: bool,
64        is_swept: bool,
65    ) -> Self {
66        Self {
67            outpoint,
68            sequence,
69            locktime: locktime.unwrap_or(absolute::LockTime::ZERO),
70            witness_utxo,
71            tapscripts,
72            spend_info,
73            is_onchain,
74            is_swept,
75            extra_witness: None,
76        }
77    }
78
79    /// Create an input with extra witness elements (e.g., for ArkNotes).
80    pub fn new_with_extra_witness(
81        outpoint: OutPoint,
82        sequence: Sequence,
83        locktime: Option<absolute::LockTime>,
84        witness_utxo: TxOut,
85        tapscripts: Vec<ScriptBuf>,
86        spend_info: (ScriptBuf, taproot::ControlBlock),
87        is_onchain: bool,
88        is_swept: bool,
89        extra_witness: Vec<Vec<u8>>,
90    ) -> Self {
91        Self {
92            outpoint,
93            sequence,
94            locktime: locktime.unwrap_or(absolute::LockTime::ZERO),
95            witness_utxo,
96            tapscripts,
97            spend_info,
98            is_onchain,
99            is_swept,
100            extra_witness: Some(extra_witness),
101        }
102    }
103
104    pub fn script_pubkey(&self) -> &ScriptBuf {
105        &self.witness_utxo.script_pubkey
106    }
107
108    pub fn amount(&self) -> Amount {
109        self.witness_utxo.value
110    }
111
112    pub fn spend_info(&self) -> &(ScriptBuf, taproot::ControlBlock) {
113        &self.spend_info
114    }
115
116    pub fn outpoint(&self) -> OutPoint {
117        self.outpoint
118    }
119
120    pub fn tapscripts(&self) -> &[ScriptBuf] {
121        &self.tapscripts
122    }
123
124    pub fn is_swept(&self) -> bool {
125        self.is_swept
126    }
127
128    pub fn extra_witness(&self) -> Option<&[Vec<u8>]> {
129        self.extra_witness.as_deref()
130    }
131}
132
133#[derive(Debug, Clone)]
134pub enum Output {
135    /// An output created when boarding.
136    Offchain(TxOut),
137    /// An output created when offboarding.
138    Onchain(TxOut),
139}
140
141#[derive(Debug, Clone)]
142pub struct Intent {
143    pub proof: Psbt,
144    message: IntentMessage,
145}
146
147impl Intent {
148    pub fn new(proof: Psbt, message: IntentMessage) -> Self {
149        Self { proof, message }
150    }
151
152    pub fn serialize_proof(&self) -> String {
153        let base64 = base64::engine::GeneralPurpose::new(
154            &base64::alphabet::STANDARD,
155            base64::engine::GeneralPurposeConfig::new(),
156        );
157
158        let bytes = self.proof.serialize();
159
160        base64.encode(&bytes)
161    }
162
163    pub fn serialize_message(&self) -> Result<String, Error> {
164        self.message.encode()
165    }
166}
167
168pub fn make_intent<SV, SO>(
169    sign_for_vtxo_fn: SV,
170    sign_for_onchain_fn: SO,
171    inputs: Vec<Input>,
172    outputs: Vec<Output>,
173    message: IntentMessage,
174) -> Result<Intent, Error>
175where
176    SV: Fn(
177        &mut psbt::Input,
178        secp256k1::Message,
179    ) -> Result<Vec<(schnorr::Signature, XOnlyPublicKey)>, Error>,
180    SO: Fn(
181        &mut psbt::Input,
182        secp256k1::Message,
183    ) -> Result<(schnorr::Signature, XOnlyPublicKey), Error>,
184{
185    let (mut proof_psbt, fake_input) = build_proof_psbt(&message, &inputs, &outputs)?;
186
187    for (i, proof_input) in proof_psbt.inputs.iter_mut().enumerate() {
188        if i == 0 {
189            let (script, control_block) = inputs[0].spend_info.clone();
190
191            proof_input
192                .tap_scripts
193                .insert(control_block, (script, taproot::LeafVersion::TapScript));
194        } else {
195            let (script, control_block) = inputs[i - 1].spend_info.clone();
196
197            let tap_tree = taptree::TapTree(inputs[i - 1].tapscripts.clone());
198            let bytes = tap_tree
199                .encode()
200                .map_err(Error::ad_hoc)
201                .with_context(|| format!("failed to encode taptree for input {i}"))?;
202
203            proof_input.unknown.insert(
204                psbt::raw::Key {
205                    type_value: 222,
206                    key: VTXO_TAPROOT_KEY.to_vec(),
207                },
208                bytes,
209            );
210            proof_input
211                .tap_scripts
212                .insert(control_block, (script, taproot::LeafVersion::TapScript));
213        };
214    }
215
216    let prevouts = proof_psbt
217        .inputs
218        .iter()
219        .filter_map(|i| i.witness_utxo.clone())
220        .collect::<Vec<_>>();
221
222    let inputs = [inputs, vec![fake_input]].concat();
223
224    for (i, proof_input) in proof_psbt.inputs.iter_mut().enumerate() {
225        let input = inputs
226            .iter()
227            .find(|input| input.outpoint == proof_psbt.unsigned_tx.input[i].previous_output)
228            .expect("witness utxo");
229
230        let prevouts = Prevouts::All(&prevouts);
231
232        let (_, (script, leaf_version)) =
233            proof_input.tap_scripts.first_key_value().expect("a value");
234
235        let leaf_hash = TapLeafHash::from_script(script, *leaf_version);
236
237        let tap_sighash = SighashCache::new(&proof_psbt.unsigned_tx)
238            .taproot_script_spend_signature_hash(i, &prevouts, leaf_hash, TapSighashType::Default)
239            .map_err(Error::crypto)
240            .with_context(|| format!("failed to compute sighash for proof of funds input {i}"))?;
241
242        let msg = secp256k1::Message::from_digest(tap_sighash.to_raw_hash().to_byte_array());
243
244        // Check if this input has extra witness (e.g., preimage for ArkNotes)
245        if let Some(extra_witness) = input.extra_witness() {
246            // Encode the witness elements and add to PSBT unknown field.
247            // Format: [num_elements] [len1] [elem1] [len2] [elem2] ...
248            let encoded = encode_witness(extra_witness);
249            proof_input.unknown.insert(
250                psbt::raw::Key {
251                    type_value: 222,
252                    key: VTXO_CONDITION_KEY.to_vec(),
253                },
254                encoded,
255            );
256        } else {
257            // Normal signing flow
258            let sigs = match input.is_onchain {
259                true => vec![sign_for_onchain_fn(proof_input, msg)?],
260                false => sign_for_vtxo_fn(proof_input, msg)?,
261            };
262
263            for (sig, pk) in sigs {
264                let sig = taproot::Signature {
265                    signature: sig,
266                    sighash_type: TapSighashType::Default,
267                };
268                proof_input.tap_script_sigs.insert((pk, leaf_hash), sig);
269            }
270        }
271    }
272
273    Ok(Intent {
274        proof: proof_psbt,
275        message,
276    })
277}
278
279pub(crate) fn build_proof_psbt(
280    message: &IntentMessage,
281    inputs: &[Input],
282    outputs: &[Output],
283) -> Result<(Psbt, Input), Error> {
284    if inputs.is_empty() {
285        return Err(Error::ad_hoc("missing inputs"));
286    }
287
288    let message = message
289        .encode()
290        .map_err(Error::ad_hoc)
291        .context("failed to encode intent message")?;
292
293    let first_input = inputs[0].clone();
294    let script_pubkey = first_input.witness_utxo.script_pubkey.clone();
295
296    let to_spend_tx = {
297        let hash = message_hash(message.as_bytes());
298
299        let script_sig = ScriptBuf::builder()
300            .push_opcode(OP_PUSHBYTES_0)
301            .push_slice(hash.as_byte_array())
302            .into_script();
303
304        let output = TxOut {
305            value: Amount::ZERO,
306            script_pubkey,
307        };
308
309        Transaction {
310            version: Version::non_standard(0),
311            lock_time: absolute::LockTime::ZERO,
312            input: vec![TxIn {
313                previous_output: OutPoint {
314                    txid: Txid::all_zeros(),
315                    vout: 0xFFFFFFFF,
316                },
317                script_sig,
318                sequence: Sequence::ZERO,
319                witness: Witness::default(),
320            }],
321            output: vec![output],
322        }
323    };
324
325    let fake_outpoint = OutPoint {
326        txid: to_spend_tx.compute_txid(),
327        vout: 0,
328    };
329
330    let to_sign_psbt = {
331        let mut to_sign_inputs = Vec::with_capacity(inputs.len() + 1);
332
333        to_sign_inputs.push(TxIn {
334            previous_output: fake_outpoint,
335            script_sig: ScriptBuf::new(),
336            sequence: first_input.sequence,
337            witness: Witness::default(),
338        });
339
340        for input in inputs.iter() {
341            to_sign_inputs.push(TxIn {
342                previous_output: input.outpoint,
343                script_sig: ScriptBuf::new(),
344                sequence: input.sequence,
345                witness: Witness::default(),
346            });
347        }
348
349        let outputs = match outputs.len() {
350            0 => vec![TxOut {
351                value: Amount::ZERO,
352                script_pubkey: ScriptBuf::new_op_return([]),
353            }],
354            _ => outputs
355                .iter()
356                .map(|o| match o {
357                    Output::Offchain(txout) | Output::Onchain(txout) => txout.clone(),
358                })
359                .collect::<Vec<_>>(),
360        };
361
362        let tx = Transaction {
363            version: Version::TWO,
364            lock_time: inputs
365                .iter()
366                .map(|i| i.locktime)
367                .max_by(|a, b| a.to_consensus_u32().cmp(&b.to_consensus_u32()))
368                .unwrap_or(absolute::LockTime::ZERO),
369            input: to_sign_inputs,
370            output: outputs,
371        };
372
373        let mut psbt = Psbt::from_unsigned_tx(tx)
374            .map_err(Error::ad_hoc)
375            .context("failed to build proof of funds PSBT")?;
376
377        psbt.inputs[0].witness_utxo = Some(to_spend_tx.output[0].clone());
378        psbt.inputs[0].sighash_type = Some(PsbtSighashType::from_u32(1));
379        psbt.inputs[0].witness_script = Some(inputs[0].spend_info.0.clone());
380
381        for (i, input) in inputs.iter().enumerate() {
382            psbt.inputs[i + 1].witness_utxo = Some(input.witness_utxo.clone());
383            psbt.inputs[i + 1].sighash_type = Some(PsbtSighashType::from_u32(1));
384            psbt.inputs[i + 1].witness_script = Some(input.spend_info.0.clone());
385        }
386
387        psbt
388    };
389
390    let mut first_input_modified = first_input;
391    first_input_modified.outpoint = fake_outpoint;
392
393    Ok((to_sign_psbt, first_input_modified))
394}
395
396fn message_hash(message: &[u8]) -> sha256::Hash {
397    const TAG: &[u8] = b"ark-intent-proof-message";
398
399    let hashed_tag = sha256::Hash::hash(TAG);
400
401    let mut v = Vec::new();
402    v.extend_from_slice(hashed_tag.as_byte_array());
403    v.extend_from_slice(hashed_tag.as_byte_array());
404    v.extend_from_slice(message);
405
406    sha256::Hash::hash(&v)
407}
408
409#[derive(Serialize, Deserialize, Debug, Clone)]
410#[serde(tag = "type")]
411pub enum IntentMessage {
412    #[serde(rename = "register")]
413    Register {
414        onchain_output_indexes: Vec<usize>,
415        valid_at: u64,
416        expire_at: u64,
417        #[serde(rename = "cosigners_public_keys")]
418        own_cosigner_pks: Vec<PublicKey>,
419    },
420    #[serde(rename = "delete")]
421    Delete { expire_at: u64 },
422    #[serde(rename = "estimate-intent-fee")]
423    EstimateIntentFee {
424        onchain_output_indexes: Vec<usize>,
425        valid_at: u64,
426        expire_at: u64,
427        #[serde(rename = "cosigners_public_keys")]
428        own_cosigner_pks: Vec<PublicKey>,
429    },
430    #[serde(rename = "get-pending-tx")]
431    GetPendingTx { expire_at: u64 },
432}
433
434impl IntentMessage {
435    pub fn encode(&self) -> Result<String, Error> {
436        serde_json::to_string(self)
437            .map_err(Error::ad_hoc)
438            .context("failed to serialize intent message to JSON")
439    }
440}
441
442/// Encode witness elements in the format used by PSBT condition witness field.
443///
444/// Format: [num_elements as varint] [len1 as varint] [elem1] [len2 as varint] [elem2] ...
445fn encode_witness(elements: &[Vec<u8>]) -> Vec<u8> {
446    let mut result = Vec::new();
447
448    // Write number of elements as compact size
449    write_compact_size(&mut result, elements.len() as u64);
450
451    // Write each element with its length prefix
452    for elem in elements {
453        write_compact_size(&mut result, elem.len() as u64);
454        result.extend_from_slice(elem);
455    }
456
457    result
458}
459
460/// Write a compact size uint (Bitcoin's variable-length integer encoding).
461fn write_compact_size(w: &mut Vec<u8>, val: u64) {
462    if val < 253 {
463        w.push(val as u8);
464    } else if val < 0x10000 {
465        w.push(253);
466        w.extend_from_slice(&(val as u16).to_le_bytes());
467    } else if val < 0x100000000 {
468        w.push(254);
469        w.extend_from_slice(&(val as u32).to_le_bytes());
470    } else {
471        w.push(255);
472        w.extend_from_slice(&val.to_le_bytes());
473    }
474}
475
476pub(crate) mod taptree {
477    use bitcoin::ScriptBuf;
478    use std::io::Write;
479    use std::io::{self};
480
481    pub struct TapTree(pub Vec<ScriptBuf>);
482
483    impl TapTree {
484        pub fn encode(&self) -> io::Result<Vec<u8>> {
485            let mut tapscripts_bytes = Vec::new();
486            for tapscript in &self.0 {
487                // write depth (always 1)
488                tapscripts_bytes.push(1);
489
490                // write leaf version (base leaf version: 0xc0)
491                tapscripts_bytes.push(0xc0);
492
493                // write script
494                write_compact_size_uint(&mut tapscripts_bytes, tapscript.len() as u64)?;
495                tapscripts_bytes.extend(tapscript.as_bytes());
496            }
497
498            Ok(tapscripts_bytes)
499        }
500
501        #[cfg(test)]
502        pub fn decode(data: &[u8]) -> io::Result<Self> {
503            use std::io::Cursor;
504            use std::io::Read;
505
506            let mut buf = Cursor::new(data);
507            let mut leaves = Vec::new();
508
509            // Read leaves until we run out of data
510            while buf.position() < data.len() as u64 {
511                // depth : ignore
512                let mut depth = [0u8; 1];
513                buf.read_exact(&mut depth)?;
514
515                // leaf version : ignore, we assume base tapscript
516                let mut lv = [0u8; 1];
517                buf.read_exact(&mut lv)?;
518
519                // script length
520                let script_len = read_compact_size_uint(&mut buf)? as usize;
521
522                // script bytes
523                let mut script_bytes = vec![0u8; script_len];
524                buf.read_exact(&mut script_bytes)?;
525
526                leaves.push(ScriptBuf::from_bytes(script_bytes));
527            }
528
529            Ok(TapTree(leaves))
530        }
531    }
532
533    // Write compact size uint to writer
534    fn write_compact_size_uint<W: Write>(w: &mut W, val: u64) -> io::Result<()> {
535        if val < 253 {
536            w.write_all(&[val as u8])
537        } else if val < 0x10000 {
538            w.write_all(&[253])?;
539            w.write_all(&(val as u16).to_le_bytes())
540        } else if val < 0x100000000 {
541            w.write_all(&[254])?;
542            w.write_all(&(val as u32).to_le_bytes())
543        } else {
544            w.write_all(&[255])?;
545            w.write_all(&val.to_le_bytes())
546        }
547    }
548
549    #[cfg(test)]
550    // Read compact size uint from reader
551    fn read_compact_size_uint<R: io::Read>(r: &mut R) -> io::Result<u64> {
552        let mut first = [0u8; 1];
553        r.read_exact(&mut first)?;
554        match first[0] {
555            253 => {
556                let mut buf = [0u8; 2];
557                r.read_exact(&mut buf)?;
558                Ok(u16::from_le_bytes(buf) as u64)
559            }
560            254 => {
561                let mut buf = [0u8; 4];
562                r.read_exact(&mut buf)?;
563                Ok(u32::from_le_bytes(buf) as u64)
564            }
565            255 => {
566                let mut buf = [0u8; 8];
567                r.read_exact(&mut buf)?;
568                Ok(u64::from_le_bytes(buf))
569            }
570            v => Ok(v as u64),
571        }
572    }
573
574    #[cfg(test)]
575    mod tests {
576        use super::*;
577        use bitcoin::opcodes::OP_FALSE;
578        use bitcoin::opcodes::OP_TRUE;
579
580        #[test]
581        fn tap_tree_encode_decode_roundtrip() {
582            let scripts = vec![ScriptBuf::builder().push_opcode(OP_TRUE).into_script()];
583
584            let tree = TapTree(scripts.clone());
585            let encoded = tree.encode().unwrap();
586            let decoded = TapTree::decode(&encoded).unwrap();
587            assert_eq!(decoded.0, scripts);
588        }
589
590        #[test]
591        fn tap_tree_multiple_leaves() {
592            let scripts = vec![
593                ScriptBuf::builder().push_opcode(OP_TRUE).into_script(),
594                ScriptBuf::builder().push_opcode(OP_FALSE).into_script(),
595            ];
596            let tree = TapTree(scripts.clone());
597            let encoded = tree.encode().unwrap();
598            let decoded = TapTree::decode(&encoded).unwrap();
599            assert_eq!(decoded.0, scripts);
600        }
601    }
602}
603
604#[cfg(test)]
605mod tests {
606    use super::*;
607    use std::str::FromStr;
608
609    #[test]
610    fn intent_message_register_serialization() {
611        let pk = PublicKey::from_str(
612            "027b763fdd0d6d96d1ce6fb95e09e381fdae2bcbe3ed7d1a2bd95702524d5dcd8a",
613        )
614        .unwrap();
615        let msg = IntentMessage::Register {
616            onchain_output_indexes: vec![],
617            valid_at: 1762861934,
618            expire_at: 1762862054,
619            own_cosigner_pks: vec![pk],
620        };
621        let encoded = msg.encode().unwrap();
622        assert_eq!(
623            encoded,
624            r#"{"type":"register","onchain_output_indexes":[],"valid_at":1762861934,"expire_at":1762862054,"cosigners_public_keys":["027b763fdd0d6d96d1ce6fb95e09e381fdae2bcbe3ed7d1a2bd95702524d5dcd8a"]}"#
625        );
626    }
627
628    #[test]
629    fn intent_message_estimate_fee_serialization() {
630        let pk = PublicKey::from_str(
631            "027b763fdd0d6d96d1ce6fb95e09e381fdae2bcbe3ed7d1a2bd95702524d5dcd8a",
632        )
633        .unwrap();
634        let msg = IntentMessage::EstimateIntentFee {
635            onchain_output_indexes: vec![],
636            valid_at: 1762861934,
637            expire_at: 1762862054,
638            own_cosigner_pks: vec![pk],
639        };
640        let encoded = msg.encode().unwrap();
641        assert_eq!(
642            encoded,
643            r#"{"type":"estimate-intent-fee","onchain_output_indexes":[],"valid_at":1762861934,"expire_at":1762862054,"cosigners_public_keys":["027b763fdd0d6d96d1ce6fb95e09e381fdae2bcbe3ed7d1a2bd95702524d5dcd8a"]}"#
644        );
645    }
646
647    #[test]
648    fn intent_message_delete_serialization() {
649        let msg = IntentMessage::Delete {
650            expire_at: 1762862054,
651        };
652        let encoded = msg.encode().unwrap();
653        assert_eq!(encoded, r#"{"type":"delete","expire_at":1762862054}"#);
654    }
655
656    #[test]
657    fn intent_message_get_pending_tx_serialization() {
658        let msg = IntentMessage::GetPendingTx {
659            expire_at: 1762862054,
660        };
661        let encoded = msg.encode().unwrap();
662        assert_eq!(
663            encoded,
664            r#"{"type":"get-pending-tx","expire_at":1762862054}"#
665        );
666    }
667}