Skip to main content

ark_core/
arknote.rs

1//! ArkNote: a transferable off-chain value token.
2//!
3//! An ArkNote encodes a preimage and a value. Anyone who knows the preimage can
4//! spend the note by revealing it. This enables simple bearer-token-style transfers.
5
6use crate::intent;
7use crate::script::arknote_script;
8use crate::script::tr_script_pubkey;
9use crate::Error;
10use crate::UNSPENDABLE_KEY;
11use bitcoin::hashes::sha256;
12use bitcoin::hashes::Hash;
13use bitcoin::key::Secp256k1;
14use bitcoin::taproot::LeafVersion;
15use bitcoin::taproot::TaprootBuilder;
16use bitcoin::Amount;
17use bitcoin::OutPoint;
18use bitcoin::PublicKey;
19use bitcoin::ScriptBuf;
20use bitcoin::Sequence;
21use bitcoin::TxOut;
22use bitcoin::Txid;
23use std::fmt;
24
25/// Default human-readable prefix for ArkNote string encoding.
26pub const DEFAULT_HRP: &str = "arknote";
27
28/// Length of the preimage in bytes.
29pub const PREIMAGE_LENGTH: usize = 32;
30
31/// Length of the value field in bytes (u32 big-endian).
32const VALUE_LENGTH: usize = 4;
33
34/// Total length of an encoded ArkNote payload.
35const ARKNOTE_LENGTH: usize = PREIMAGE_LENGTH + VALUE_LENGTH;
36
37/// Fake outpoint vout used for ArkNotes (they don't correspond to real UTXOs).
38pub const FAKE_VOUT: u32 = 0;
39
40/// ArkNote is a bearer token that can be redeemed by revealing its preimage.
41///
42/// The note encodes:
43/// - A 32-byte preimage (the secret)
44/// - A value in satoshis (up to u32::MAX)
45///
46/// The on-chain representation is a hash-locked taproot script that checks
47/// `SHA256(witness) == hash(preimage)`.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct ArkNote {
50    preimage: [u8; PREIMAGE_LENGTH],
51    value: Amount,
52    hrp: String,
53}
54
55impl ArkNote {
56    /// Create a new ArkNote with the default HRP.
57    pub fn new(preimage: [u8; PREIMAGE_LENGTH], value: Amount) -> Result<Self, Error> {
58        Self::new_with_hrp(preimage, value, DEFAULT_HRP.to_string())
59    }
60
61    /// Create a new ArkNote with a custom HRP.
62    pub fn new_with_hrp(
63        preimage: [u8; PREIMAGE_LENGTH],
64        value: Amount,
65        hrp: String,
66    ) -> Result<Self, Error> {
67        // Validate that value fits in u32
68        if value.to_sat() > u32::MAX as u64 {
69            return Err(Error::ad_hoc(format!(
70                "value {} exceeds maximum of {} sats",
71                value.to_sat(),
72                u32::MAX
73            )));
74        }
75
76        Ok(Self {
77            preimage,
78            value,
79            hrp,
80        })
81    }
82
83    /// Parse an ArkNote from its string representation.
84    pub fn from_string(s: &str) -> Result<Self, Error> {
85        Self::from_string_with_hrp(s, DEFAULT_HRP)
86    }
87
88    /// Parse an ArkNote from its string representation with a custom HRP.
89    pub fn from_string_with_hrp(s: &str, hrp: &str) -> Result<Self, Error> {
90        let s = s.trim();
91
92        if !s.starts_with(hrp) {
93            return Err(Error::ad_hoc(format!(
94                "invalid prefix: expected '{}', got '{}'",
95                hrp,
96                &s[..hrp.len().min(s.len())]
97            )));
98        }
99
100        let encoded = &s[hrp.len()..];
101        let decoded = bs58::decode(encoded)
102            .into_vec()
103            .map_err(|e| Error::ad_hoc(format!("invalid base58: {e}")))?;
104
105        if decoded.len() != ARKNOTE_LENGTH {
106            return Err(Error::ad_hoc(format!(
107                "invalid payload length: expected {}, got {}",
108                ARKNOTE_LENGTH,
109                decoded.len()
110            )));
111        }
112
113        let mut preimage = [0u8; PREIMAGE_LENGTH];
114        preimage.copy_from_slice(&decoded[..PREIMAGE_LENGTH]);
115
116        let value_bytes: [u8; 4] = decoded[PREIMAGE_LENGTH..]
117            .try_into()
118            .map_err(|_| Error::ad_hoc("invalid value bytes"))?;
119        let value = Amount::from_sat(u32::from_be_bytes(value_bytes) as u64);
120
121        Self::new_with_hrp(preimage, value, hrp.to_string())
122    }
123
124    /// Encode the ArkNote to its string representation.
125    pub fn to_encoded_string(&self) -> String {
126        self.to_string()
127    }
128
129    /// Get the preimage.
130    pub fn preimage(&self) -> &[u8; PREIMAGE_LENGTH] {
131        &self.preimage
132    }
133
134    /// Get the preimage hash.
135    pub fn preimage_hash(&self) -> sha256::Hash {
136        sha256::Hash::hash(&self.preimage)
137    }
138
139    /// Get the value in satoshis.
140    pub fn value(&self) -> Amount {
141        self.value
142    }
143
144    /// Get the HRP.
145    pub fn hrp(&self) -> &str {
146        &self.hrp
147    }
148
149    /// Get the script that locks this note (spendable by revealing the preimage).
150    pub fn script(&self) -> ScriptBuf {
151        arknote_script(&self.preimage_hash())
152    }
153
154    /// Get a synthetic txid derived from the preimage hash.
155    ///
156    /// This is used to create a unique identifier for the note in the VTXO system.
157    pub fn txid(&self) -> Txid {
158        Txid::from_byte_array(*self.preimage_hash().as_byte_array())
159    }
160
161    /// Get a synthetic outpoint for this note.
162    pub fn outpoint(&self) -> OutPoint {
163        OutPoint::new(self.txid(), FAKE_VOUT)
164    }
165
166    /// Convert this ArkNote to an intent input for settlement.
167    ///
168    /// The note creates a fake VTXO with a hash-lock script. When settling,
169    /// the preimage is revealed as the witness instead of a signature.
170    pub fn to_intent_input(&self) -> Result<intent::Input, Error> {
171        let secp = Secp256k1::new();
172
173        let unspendable_key: PublicKey = UNSPENDABLE_KEY
174            .parse()
175            .map_err(|e| Error::ad_hoc(format!("invalid unspendable key: {e}")))?;
176        let (unspendable_xonly, _) = unspendable_key.inner.x_only_public_key();
177
178        let note_script = self.script();
179
180        // Build taproot tree with single leaf (the note script)
181        let spend_info = TaprootBuilder::new()
182            .add_leaf(0, note_script.clone())
183            .map_err(|e| Error::ad_hoc(format!("failed to add leaf: {e:?}")))?
184            .finalize(&secp, unspendable_xonly)
185            .map_err(|e| Error::ad_hoc(format!("failed to finalize taproot: {e:?}")))?;
186
187        let control_block = spend_info
188            .control_block(&(note_script.clone(), LeafVersion::TapScript))
189            .ok_or_else(|| Error::ad_hoc("failed to get control block for note script"))?;
190
191        let script_pubkey = tr_script_pubkey(&spend_info);
192
193        Ok(intent::Input::new_with_extra_witness(
194            self.outpoint(),
195            Sequence::MAX,
196            None,
197            TxOut {
198                value: self.value,
199                script_pubkey,
200            },
201            vec![note_script.clone()],
202            (note_script, control_block),
203            false, // not onchain
204            false, // not swept
205            vec![self.preimage.to_vec()],
206        ))
207    }
208}
209
210impl fmt::Display for ArkNote {
211    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
212        let mut payload = Vec::with_capacity(ARKNOTE_LENGTH);
213        payload.extend_from_slice(&self.preimage);
214        payload.extend_from_slice(&(self.value.to_sat() as u32).to_be_bytes());
215
216        write!(f, "{}{}", self.hrp, bs58::encode(payload).into_string())
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    fn hex_to_array32(hex: &str) -> [u8; 32] {
225        let bytes = hex::decode(hex).expect("valid hex");
226        bytes.try_into().expect("32 bytes")
227    }
228
229    #[test]
230    fn roundtrip_encoding() {
231        let preimage =
232            hex_to_array32("11d2a03264d0efd311d2a03264d0efd311d2a03264d0efd311d2a03264d0efd3");
233        let value = Amount::from_sat(900_000);
234
235        let note = ArkNote::new(preimage, value).unwrap();
236        let encoded = note.to_string();
237        let decoded = ArkNote::from_string(&encoded).unwrap();
238
239        assert_eq!(decoded.preimage(), &preimage);
240        assert_eq!(decoded.value(), value);
241    }
242
243    #[test]
244    fn test_vectors() {
245        // Test vectors matching TypeScript SDK
246        let cases = [
247            (
248                "arknote",
249                "arknote8rFzGqZsG9RCLripA6ez8d2hQEzFKsqCeiSnXhQj56Ysw7ZQT",
250                "11d2a03264d0efd311d2a03264d0efd311d2a03264d0efd311d2a03264d0efd3",
251                900_000u64,
252            ),
253            (
254                "arknote",
255                "arknoteSkB92YpWm4Q2ijQHH34cqbKkCZWszsiQgHVjtNeFF2Cwp59D",
256                "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
257                1_828_932u64,
258            ),
259            (
260                "noteark",
261                "noteark8rFzGqZsG9RCLripA6ez8d2hQEzFKsqCeiSnXhQj56Ysw7ZQT",
262                "11d2a03264d0efd311d2a03264d0efd311d2a03264d0efd311d2a03264d0efd3",
263                900_000u64,
264            ),
265        ];
266
267        for (hrp, note_str, preimage_hex, expected_sats) in cases {
268            let note = ArkNote::from_string_with_hrp(note_str, hrp).unwrap();
269
270            assert_eq!(note.preimage(), &hex_to_array32(preimage_hex));
271            assert_eq!(note.value(), Amount::from_sat(expected_sats));
272            assert_eq!(note.hrp(), hrp);
273
274            // Roundtrip
275            let reconstructed = ArkNote::new_with_hrp(
276                hex_to_array32(preimage_hex),
277                Amount::from_sat(expected_sats),
278                hrp.to_string(),
279            )
280            .unwrap();
281            assert_eq!(reconstructed.to_string(), note_str);
282        }
283    }
284
285    #[test]
286    fn invalid_prefix() {
287        let result = ArkNote::from_string("wrongprefix123456789");
288        assert!(result.is_err());
289        assert!(result.unwrap_err().to_string().contains("invalid prefix"));
290    }
291
292    #[test]
293    fn invalid_base58() {
294        let result = ArkNote::from_string("arknote!!!invalid!!!");
295        assert!(result.is_err());
296        assert!(result.unwrap_err().to_string().contains("base58"));
297    }
298
299    #[test]
300    fn value_overflow() {
301        let preimage = [0u8; 32];
302        let result = ArkNote::new(preimage, Amount::from_sat(u64::MAX));
303        assert!(result.is_err());
304        assert!(result.unwrap_err().to_string().contains("exceeds maximum"));
305    }
306
307    #[test]
308    fn script_is_hash_lock() {
309        let preimage = [0x42u8; 32];
310        let note = ArkNote::new(preimage, Amount::from_sat(1000)).unwrap();
311        let script = note.script();
312
313        // Should be: OP_SHA256 <32-byte hash> OP_EQUAL
314        let bytes = script.as_bytes();
315        assert_eq!(bytes[0], bitcoin::opcodes::all::OP_SHA256.to_u8());
316        assert_eq!(bytes[1], 0x20); // push 32 bytes
317        assert_eq!(bytes[34], bitcoin::opcodes::all::OP_EQUAL.to_u8());
318    }
319
320    #[test]
321    fn whitespace_handling() {
322        let note_str = "  arknote8rFzGqZsG9RCLripA6ez8d2hQEzFKsqCeiSnXhQj56Ysw7ZQT  ";
323        let note = ArkNote::from_string(note_str).unwrap();
324        assert_eq!(note.value(), Amount::from_sat(900_000));
325    }
326}