Skip to main content

ark_core/
intent.rs

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