Skip to main content

ark_core/
intent.rs

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