Skip to main content

ark_core/asset/
packet.rs

1//! Arkade Asset V1 packet encoding.
2//!
3//! Implements the binary encoding format as specified in the Arkade Asset V1 specification.
4//! The packet is embedded in a Bitcoin transaction via an OP_RETURN output.
5
6use crate::asset::AssetId;
7use crate::Error;
8use bitcoin::TxOut;
9
10/// TLV type byte for the asset packet.
11const ASSET_PACKET_TYPE: u8 = 0x00;
12
13/// Presence byte bits for Group optional fields.
14const PRESENCE_ASSET_ID: u8 = 0x01;
15const PRESENCE_CONTROL_ASSET: u8 = 0x02;
16const PRESENCE_METADATA: u8 = 0x04;
17
18/// A complete asset packet containing one or more asset groups.
19///
20/// This is a transaction output with an `OP_RETURN` script.
21#[derive(Clone, Debug)]
22pub struct Packet {
23    pub groups: Vec<AssetGroup>,
24}
25
26impl Packet {
27    /// Encode this packet into its binary representation.
28    pub fn encode(&self) -> Vec<u8> {
29        let mut buf = Vec::new();
30        encode_uvarint(&mut buf, self.groups.len() as u64);
31        for group in &self.groups {
32            group.encode(&mut buf);
33        }
34        buf
35    }
36
37    /// Wrap this packet into an OP_RETURN TxOut with the ARK magic bytes and TLV envelope.
38    pub fn to_txout(&self) -> TxOut {
39        crate::extension::packet_txout(ASSET_PACKET_TYPE, &self.encode())
40    }
41}
42
43/// Reference to a control asset.
44#[derive(Clone, Debug)]
45pub enum AssetRef {
46    /// Reference an existing asset by its full ID.
47    ById(AssetId),
48    /// Reference a group in the same transaction by index.
49    ByGroup(u16),
50}
51
52impl AssetRef {
53    fn encode(&self, buf: &mut Vec<u8>) {
54        match self {
55            AssetRef::ById(asset_id) => {
56                buf.push(0x01); // BY_ID
57                asset_id.encode(buf);
58            }
59            AssetRef::ByGroup(gidx) => {
60                buf.push(0x02); // BY_GROUP
61                buf.extend_from_slice(&gidx.to_le_bytes());
62            }
63        }
64    }
65}
66
67/// A single asset group within a packet.
68#[derive(Clone, Debug)]
69pub struct AssetGroup {
70    /// If `None`, this is a fresh asset issuance. The asset ID will be derived from
71    /// `(this_txid, group_index)`.
72    pub asset_id: Option<AssetId>,
73    /// Control asset reference. Only valid for issuances (when `asset_id` is `None`).
74    pub control_asset: Option<AssetRef>,
75    /// Metadata key-value pairs attached to the asset group.
76    pub metadata: Option<Metadata>,
77    /// Asset inputs consumed by this group.
78    pub inputs: Vec<AssetInput>,
79    /// Asset outputs produced by this group.
80    pub outputs: Vec<AssetOutput>,
81}
82
83impl AssetGroup {
84    fn encode(&self, buf: &mut Vec<u8>) {
85        // Compute presence byte
86        let mut presence: u8 = 0;
87        if self.asset_id.is_some() {
88            presence |= PRESENCE_ASSET_ID;
89        }
90        if self.control_asset.is_some() {
91            presence |= PRESENCE_CONTROL_ASSET;
92        }
93        if self.metadata.is_some() {
94            presence |= PRESENCE_METADATA;
95        }
96        buf.push(presence);
97
98        // Encode optional fields in order
99        if let Some(asset_id) = &self.asset_id {
100            asset_id.encode(buf);
101        }
102        if let Some(control_asset) = &self.control_asset {
103            control_asset.encode(buf);
104        }
105        if let Some(metadata) = &self.metadata {
106            encode_metadata(buf, metadata);
107        }
108
109        // Encode inputs
110        encode_uvarint(buf, self.inputs.len() as u64);
111        for input in &self.inputs {
112            input.encode(buf);
113        }
114
115        // Encode outputs
116        encode_uvarint(buf, self.outputs.len() as u64);
117        for output in &self.outputs {
118            output.encode(buf);
119        }
120    }
121}
122
123/// A local asset input referencing a transaction input by index.
124#[derive(Clone, Debug)]
125pub struct AssetInput {
126    /// Index into the transaction's inputs.
127    pub input_index: u16,
128    /// Amount of asset from this input.
129    pub amount: u64,
130}
131
132impl AssetInput {
133    fn encode(&self, buf: &mut Vec<u8>) {
134        buf.push(0x01); // LOCAL
135        buf.extend_from_slice(&self.input_index.to_le_bytes());
136        encode_uvarint(buf, self.amount);
137    }
138}
139
140/// A local asset output referencing a transaction output by index.
141#[derive(Clone, Debug)]
142pub struct AssetOutput {
143    /// Index into the transaction's outputs.
144    pub output_index: u16,
145    /// Amount of asset to this output.
146    pub amount: u64,
147}
148
149impl AssetOutput {
150    fn encode(&self, buf: &mut Vec<u8>) {
151        buf.push(0x01); // LOCAL
152        buf.extend_from_slice(&self.output_index.to_le_bytes());
153        encode_uvarint(buf, self.amount);
154    }
155}
156
157/// Key-value metadata map.
158pub type Metadata = Vec<(String, String)>;
159
160/// Encode a metadata map: count, then for each entry: key_len + key + value_len + value.
161fn encode_metadata(buf: &mut Vec<u8>, metadata: &[(String, String)]) {
162    encode_uvarint(buf, metadata.len() as u64);
163    for (key, value) in metadata {
164        encode_uvarint(buf, key.len() as u64);
165        buf.extend_from_slice(key.as_bytes());
166        encode_uvarint(buf, value.len() as u64);
167        buf.extend_from_slice(value.as_bytes());
168    }
169}
170
171/// Helper to add an asset packet as an OP_RETURN output to an existing PSBT.
172///
173/// The P2A (anchor) output must remain the last output. This function inserts
174/// the asset packet output before it.
175pub fn add_asset_packet_to_psbt(psbt: &mut bitcoin::Psbt, packet: &Packet) -> Result<(), Error> {
176    if packet.groups.is_empty() {
177        return Err(Error::ad_hoc(
178            "asset packet must contain at least one group",
179        ));
180    }
181
182    crate::extension::add_packet_to_psbt(psbt, ASSET_PACKET_TYPE, &packet.encode())
183        .map_err(Error::ad_hoc)?;
184
185    Ok(())
186}
187
188/// Encode a uvarint (LEB128 unsigned variable-length integer).
189///
190/// This matches Go's `binary.PutUvarint` / protobuf unsigned varint encoding.
191fn encode_uvarint(buf: &mut Vec<u8>, mut value: u64) {
192    loop {
193        let mut byte = (value & 0x7f) as u8;
194        value >>= 7;
195        if value != 0 {
196            byte |= 0x80;
197        }
198        buf.push(byte);
199        if value == 0 {
200            break;
201        }
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use bitcoin::hex::DisplayHex;
209
210    #[test]
211    fn test_encode_uvarint() {
212        let mut buf = Vec::new();
213        encode_uvarint(&mut buf, 0);
214        assert_eq!(buf, vec![0x00]);
215
216        buf.clear();
217        encode_uvarint(&mut buf, 127);
218        assert_eq!(buf, vec![0x7f]);
219
220        buf.clear();
221        encode_uvarint(&mut buf, 128);
222        assert_eq!(buf, vec![0x80, 0x01]);
223
224        buf.clear();
225        encode_uvarint(&mut buf, 300);
226        assert_eq!(buf, vec![0xac, 0x02]);
227    }
228
229    #[test]
230    fn test_fresh_issuance_no_control() {
231        // Fresh asset, no control, 1000 units to output 0
232        let packet = Packet {
233            groups: vec![AssetGroup {
234                asset_id: None,
235                control_asset: None,
236                metadata: None,
237                inputs: vec![],
238                outputs: vec![AssetOutput {
239                    output_index: 0,
240                    amount: 1000,
241                }],
242            }],
243        };
244
245        let encoded = packet.encode();
246        // Should start with group count = 1
247        assert_eq!(encoded[0], 0x01);
248        // Presence byte = 0 (no asset_id, no control_asset, no metadata)
249        assert_eq!(encoded[1], 0x00);
250        // Input count = 0
251        assert_eq!(encoded[2], 0x00);
252        // Output count = 1
253        assert_eq!(encoded[3], 0x01);
254    }
255
256    #[test]
257    fn test_fresh_issuance_with_control_by_group() {
258        // Control asset group + issued asset group referencing it
259        let packet = Packet {
260            groups: vec![
261                // Group 0: control asset (fresh, no control ref)
262                AssetGroup {
263                    asset_id: None,
264                    control_asset: None,
265                    metadata: None,
266                    inputs: vec![],
267                    outputs: vec![AssetOutput {
268                        output_index: 0,
269                        amount: 1,
270                    }],
271                },
272                // Group 1: issued asset referencing group 0 as control
273                AssetGroup {
274                    asset_id: None,
275                    control_asset: Some(AssetRef::ByGroup(0)),
276                    metadata: None,
277                    inputs: vec![],
278                    outputs: vec![AssetOutput {
279                        output_index: 0,
280                        amount: 1000,
281                    }],
282                },
283            ],
284        };
285
286        let encoded = packet.encode();
287        // Group count = 2
288        assert_eq!(encoded[0], 0x02);
289    }
290
291    #[test]
292    fn test_to_txout() {
293        let packet = Packet {
294            groups: vec![AssetGroup {
295                asset_id: None,
296                control_asset: None,
297                metadata: None,
298                inputs: vec![],
299                outputs: vec![AssetOutput {
300                    output_index: 0,
301                    amount: 100,
302                }],
303            }],
304        };
305
306        let txout = packet.to_txout();
307        assert_eq!(txout.value, bitcoin::Amount::ZERO);
308
309        // Script should start with OP_RETURN (0x6a)
310        let script_bytes = txout.script_pubkey.as_bytes();
311        assert_eq!(script_bytes[0], 0x6a);
312
313        // After push byte, should have ARK magic
314        // push_len byte, then 0x41 0x52 0x4b
315        let data_start = 2; // 0x6a + push_len
316        assert_eq!(
317            &script_bytes[data_start..data_start + 3],
318            &crate::extension::MAGIC_BYTES
319        );
320    }
321
322    #[test]
323    fn test_asset_id_display_matches_from_str_format() {
324        let asset_id = AssetId {
325            txid: "58534acb681218c0fda8f6b6ae3b4cb5d8897e7c5fcba5792621c368b3db479c"
326                .parse()
327                .unwrap(),
328            group_index: 0,
329        };
330
331        let encoded = asset_id.to_string();
332        assert_eq!(
333            encoded,
334            "58534acb681218c0fda8f6b6ae3b4cb5d8897e7c5fcba5792621c368b3db479c0000"
335        );
336        assert_eq!(encoded.parse::<AssetId>().unwrap(), asset_id);
337    }
338
339    #[test]
340    fn test_asset_id_display_matches_from_str_format_for_non_zero_group() {
341        let asset_id = AssetId {
342            txid: "58534acb681218c0fda8f6b6ae3b4cb5d8897e7c5fcba5792621c368b3db479c"
343                .parse()
344                .unwrap(),
345            group_index: 1,
346        };
347
348        let encoded = asset_id.to_string();
349        assert_eq!(
350            encoded,
351            "58534acb681218c0fda8f6b6ae3b4cb5d8897e7c5fcba5792621c368b3db479c0100"
352        );
353        assert_eq!(encoded.parse::<AssetId>().unwrap(), asset_id);
354    }
355
356    #[test]
357    fn test_asset_id_binary_encoding_uses_txid_display_byte_order() {
358        let asset_id = AssetId {
359            txid: "58534acb681218c0fda8f6b6ae3b4cb5d8897e7c5fcba5792621c368b3db479c"
360                .parse()
361                .unwrap(),
362            group_index: 1,
363        };
364
365        let mut buf = Vec::new();
366        asset_id.encode(&mut buf);
367
368        assert_eq!(
369            buf.to_lower_hex_string(),
370            "58534acb681218c0fda8f6b6ae3b4cb5d8897e7c5fcba5792621c368b3db479c0100"
371        );
372    }
373}