Skip to main content

seedelf_core/
note.rs

1//! A note on a payment from the Cardano account: CIP-20's transaction
2//! message, metadata label 674 holding `{ "msg": [lines] }`, which wallets
3//! and explorers show with the transaction. Anyone can read it, for good.
4//!
5//! Pallas's builder can't stage metadata (`pallas-txbuilder` 0.35 writes
6//! `None` for the auxiliary data), so, as with [`crate::staking`], a built
7//! transaction is decoded, given the note and the hash of it that its body
8//! must carry, and encoded again with its new hash: patched first, signed
9//! after.
10
11use anyhow::{Result, anyhow, bail};
12use pallas_crypto::hash::Hasher;
13use pallas_primitives::conway::{self, AuxiliaryData};
14use pallas_primitives::{Fragment, KeyValuePairs, Metadatum, Nullable};
15use pallas_txbuilder::BuiltTransaction;
16
17use crate::build::tx_id;
18
19/// CIP-20's metadata label.
20pub const NOTE_LABEL: u64 = 674;
21
22/// The longest note, in characters: one of CIP-20's lines, as Lace allows.
23pub const MAX_NOTE_CHARS: usize = 64;
24
25/// The ledger's longest metadata text, in bytes. A note longer than this
26/// (possible outside ASCII) is split into several of CIP-20's lines.
27const MAX_LINE_BYTES: usize = 64;
28
29/// A note: one line of text, at most [`MAX_NOTE_CHARS`] characters.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct Note {
32    text: String,
33    lines: Vec<String>,
34}
35
36impl Note {
37    /// `text` as a note, trimmed; `None` when that leaves nothing. Refused:
38    /// tabs, line breaks and other control characters, and more than
39    /// [`MAX_NOTE_CHARS`] characters.
40    pub fn new(text: &str) -> Result<Option<Note>> {
41        let text = text.trim();
42        if text.is_empty() {
43            return Ok(None);
44        }
45        if text.chars().any(char::is_control) {
46            bail!("A note is one line of text, with no tabs or line breaks");
47        }
48        let chars = text.chars().count();
49        if chars > MAX_NOTE_CHARS {
50            bail!("A note is at most {MAX_NOTE_CHARS} characters, not {chars}");
51        }
52        Ok(Some(Note {
53            text: text.to_string(),
54            lines: split(text),
55        }))
56    }
57
58    /// CIP-20's lines, in order: one, unless the text is over 64 bytes.
59    pub fn lines(&self) -> &[String] {
60        &self.lines
61    }
62
63    /// The text as given, trimmed: what the review shows. Wallets reading it
64    /// from the chain join the lines with spaces, so a line cut between
65    /// characters (no space to cut at) reads there with one.
66    pub fn text(&self) -> &str {
67        &self.text
68    }
69
70    /// `{674: {"msg": [lines]}}`, as a plain metadata map.
71    fn auxiliary_data(&self) -> AuxiliaryData {
72        let msg = Metadatum::Array(self.lines.iter().cloned().map(Metadatum::Text).collect());
73        let message = Metadatum::Map(KeyValuePairs::Def(vec![(
74            Metadatum::Text("msg".to_string()),
75            msg,
76        )]));
77        AuxiliaryData::Shelley(KeyValuePairs::Def(vec![(NOTE_LABEL, message)]))
78    }
79
80    /// Sets the note, and its hash in the body, in `built`, an unsigned
81    /// transaction, and returns it with its new hash.
82    pub fn patch(&self, mut built: BuiltTransaction) -> Result<BuiltTransaction> {
83        if built.signatures.is_some() {
84            bail!("A transaction is patched before it's signed, not after");
85        }
86        let mut tx = conway::Tx::decode_fragment(&built.tx_bytes.0)
87            .map_err(|e| anyhow!("The built transaction isn't a Conway transaction: {e}"))?;
88        if matches!(tx.auxiliary_data, Nullable::Some(_))
89            || tx.transaction_body.auxiliary_data_hash.is_some()
90        {
91            bail!("The transaction already has metadata");
92        }
93        let data = self.auxiliary_data();
94        tx.transaction_body.auxiliary_data_hash =
95            Some(Hasher::<256>::hash_cbor(&data).to_vec().into());
96        tx.auxiliary_data = Nullable::Some(data);
97        let bytes = tx
98            .encode_fragment()
99            .map_err(|e| anyhow!("Failed to encode the transaction: {e}"))?;
100        built.tx_hash.0 = *tx_id(&bytes)?;
101        built.tx_bytes.0 = bytes;
102        Ok(built)
103    }
104}
105
106/// `text` in lines of at most [`MAX_LINE_BYTES`] bytes, cut at the last
107/// space that fits when there is one, else between characters.
108fn split(text: &str) -> Vec<String> {
109    let mut lines = Vec::new();
110    let mut rest = text;
111    while rest.len() > MAX_LINE_BYTES {
112        let mut cut = MAX_LINE_BYTES;
113        while !rest.is_char_boundary(cut) {
114            cut -= 1;
115        }
116        let (line, next) = match rest[..cut].rfind(' ') {
117            Some(space) if space > 0 => (&rest[..space], &rest[space + 1..]),
118            _ => (&rest[..cut], &rest[cut..]),
119        };
120        lines.push(line.to_string());
121        rest = next;
122    }
123    lines.push(rest.to_string());
124    lines
125}