unc_primitives/
signable_message.rs

1use borsh::{BorshDeserialize, BorshSerialize};
2use unc_crypto::{Signature, Signer};
3use unc_primitives_core::hash::hash;
4use unc_primitives_core::types::AccountId;
5
6// These numbers are picked to be compatible with the current protocol and how
7// transactions are defined in it. Introducing this is no protocol change. This
8// is just a forward-looking implementation detail of meta transactions.
9//
10// We plan to establish a standard that makes this an official
11// specification in the wider ecosystem. Note that should not change the
12// protocol in any way, unless we have to change meta transaction implementation
13// details to adhere to the future standard.
14//
15// TODO: consider making these public once there is an approved standard.
16const MIN_ON_CHAIN_DISCRIMINANT: u32 = 1 << 30;
17const MAX_ON_CHAIN_DISCRIMINANT: u32 = (1 << 31) - 1;
18const MIN_OFF_CHAIN_DISCRIMINANT: u32 = 1 << 31;
19const MAX_OFF_CHAIN_DISCRIMINANT: u32 = u32::MAX;
20
21const UIP_META_TRANSACTIONS: u32 = 366;
22
23/// Used to distinguish message types that are sign by account keys, to avoid an
24/// abuse of signed messages as something else.
25///
26/// This prefix must be be at the first four bytes of a message body that is
27/// signed under this signature scheme.
28///
29/// The scheme is a draft introduced to avoid security issues with the
30/// implementation of meta transactions but will eventually be
31/// standardized that solves the problem more generally.
32#[derive(
33    Debug,
34    Clone,
35    Copy,
36    PartialEq,
37    Eq,
38    PartialOrd,
39    Ord,
40    Hash,
41    BorshSerialize,
42    BorshDeserialize,
43    serde::Serialize,
44    serde::Deserialize,
45)]
46pub struct MessageDiscriminant {
47    /// The unique prefix, serialized in little-endian by borsh.
48    discriminant: u32,
49}
50
51/// A wrapper around a message that should be signed using this scheme.
52///
53/// Only used for constructing a signature, not used to transmit messages. The
54/// discriminant prefix is implicit and should be known by the receiver based on
55/// the context in which the message is received.
56#[derive(BorshSerialize)]
57pub struct SignableMessage<'a, T> {
58    pub discriminant: MessageDiscriminant,
59    pub msg: &'a T,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
63#[non_exhaustive]
64pub enum SignableMessageType {
65    /// A delegate action, intended for a relayer to included it in an action list of a transaction.
66    DelegateAction,
67}
68
69#[derive(thiserror::Error, Debug)]
70#[non_exhaustive]
71pub enum ReadDiscriminantError {
72    #[error("does not fit any known categories")]
73    UnknownMessageType,
74    #[error("UIP {0} does not have a known on-chain use")]
75    UnknownOnChainNep(u32),
76    #[error("UIP {0} does not have a known off-chain use")]
77    UnknownOffChainNep(u32),
78    #[error("discriminant is in the range for transactions")]
79    TransactionFound,
80}
81
82#[derive(thiserror::Error, Debug)]
83#[non_exhaustive]
84pub enum CreateDiscriminantError {
85    #[error("nep number {0} is too big")]
86    NepTooLarge(u32),
87}
88
89impl<'a, T: BorshSerialize> SignableMessage<'a, T> {
90    pub fn new(msg: &'a T, ty: SignableMessageType) -> Self {
91        let discriminant = ty.into();
92        Self { discriminant, msg }
93    }
94
95    pub fn sign(&self, signer: &dyn Signer) -> Signature {
96        let bytes = borsh::to_vec(&self).expect("Failed to deserialize");
97        let hash = hash(&bytes);
98        signer.sign(hash.as_bytes())
99    }
100}
101
102impl MessageDiscriminant {
103    /// Create a discriminant for an on-chain actionable message that was introduced in the specified UIP.
104    ///
105    /// Allows creating discriminants currently unknown in this crate, which can
106    /// be useful to prototype new standards. For example, when the client
107    /// project still relies on an older version of this crate while nightly
108    pub fn new_on_chain(nep: u32) -> Result<Self, CreateDiscriminantError> {
109        // unchecked arithmetic: these are constants
110        if nep > MAX_ON_CHAIN_DISCRIMINANT - MIN_ON_CHAIN_DISCRIMINANT {
111            Err(CreateDiscriminantError::NepTooLarge(nep))
112        } else {
113            Ok(Self {
114                // unchecked arithmetic: just checked range
115                discriminant: MIN_ON_CHAIN_DISCRIMINANT + nep,
116            })
117        }
118    }
119
120    /// Create a discriminant for an off-chain message that was introduced in the specified UIP.
121    ///
122    /// Allows creating discriminants currently unknown in this crate, which can
123    /// be useful to prototype new standards. For example, when the client
124    /// project still relies on an older version of this crate while nightly
125    pub fn new_off_chain(nep: u32) -> Result<Self, CreateDiscriminantError> {
126        // unchecked arithmetic: these are constants
127        if nep > MAX_OFF_CHAIN_DISCRIMINANT - MIN_OFF_CHAIN_DISCRIMINANT {
128            Err(CreateDiscriminantError::NepTooLarge(nep))
129        } else {
130            Ok(Self {
131                // unchecked arithmetic: just checked range
132                discriminant: MIN_OFF_CHAIN_DISCRIMINANT + nep,
133            })
134        }
135    }
136
137    /// Returns the raw integer value of the discriminant as an integer value.
138    pub fn raw_discriminant(&self) -> u32 {
139        self.discriminant
140    }
141
142    /// Whether this discriminant marks a traditional `SignedTransaction`.
143    pub fn is_transaction(&self) -> bool {
144        // Backwards compatibility with transaction that were defined before this standard:
145        // Transaction begins with `AccountId`, which is just a `String` in
146        // borsh serialization, which starts with the length of the underlying
147        // byte vector in little endian u32.
148        // Currently allowed AccountIds are between 2 and 64 bytes.
149        self.discriminant >= AccountId::MIN_LEN as u32
150            && self.discriminant <= AccountId::MAX_LEN as u32
151    }
152
153    /// If this discriminant marks a message intended for on-chain use, return
154    /// the UIP in which the message type was introduced.
155    pub fn on_chain_nep(&self) -> Option<u32> {
156        if self.discriminant < MIN_ON_CHAIN_DISCRIMINANT
157            || self.discriminant > MAX_ON_CHAIN_DISCRIMINANT
158        {
159            None
160        } else {
161            // unchecked arithmetic: just checked it is in range
162            let nep = self.discriminant - MIN_ON_CHAIN_DISCRIMINANT;
163            Some(nep)
164        }
165    }
166
167    /// If this discriminant marks a message intended for off-chain use, return
168    /// the UIP in which the message type was introduced.
169    ///
170    /// clippy: MAX_OFF_CHAIN_DISCRIMINANT currently is u32::MAX which makes the
171    /// comparison pointless, however I think it helps code readability to have
172    /// it spelled out anyway
173    #[allow(clippy::absurd_extreme_comparisons)]
174    pub fn off_chain_nep(&self) -> Option<u32> {
175        if self.discriminant < MIN_OFF_CHAIN_DISCRIMINANT
176            || self.discriminant > MAX_OFF_CHAIN_DISCRIMINANT
177        {
178            None
179        } else {
180            // unchecked arithmetic: just checked it is in range
181            let nep = self.discriminant - MIN_OFF_CHAIN_DISCRIMINANT;
182            Some(nep)
183        }
184    }
185}
186
187impl TryFrom<MessageDiscriminant> for SignableMessageType {
188    type Error = ReadDiscriminantError;
189
190    fn try_from(discriminant: MessageDiscriminant) -> Result<Self, Self::Error> {
191        if discriminant.is_transaction() {
192            Err(Self::Error::TransactionFound)
193        } else if let Some(nep) = discriminant.on_chain_nep() {
194            match nep {
195                UIP_META_TRANSACTIONS => Ok(Self::DelegateAction),
196                _ => Err(Self::Error::UnknownOnChainNep(nep)),
197            }
198        } else if let Some(nep) = discriminant.off_chain_nep() {
199            Err(Self::Error::UnknownOffChainNep(nep))
200        } else {
201            Err(Self::Error::UnknownMessageType)
202        }
203    }
204}
205
206impl From<SignableMessageType> for MessageDiscriminant {
207    fn from(ty: SignableMessageType) -> Self {
208        // unwrapping here is ok, we know the constant UIP numbers used are in range
209        match ty {
210            SignableMessageType::DelegateAction => {
211                MessageDiscriminant::new_on_chain(UIP_META_TRANSACTIONS).unwrap()
212            }
213        }
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use unc_crypto::{InMemorySigner, KeyType, PublicKey};
220    use unc_primitives_core::account::id::AccountIdRef;
221
222    use super::*;
223    use crate::action::delegate::{DelegateAction, SignedDelegateAction};
224
225    // Note: this is currently a simplified copy of unc-primitives::test_utils::create_user_test_signer
226    // TODO: consider whether it’s worth re-unifying them? it’s test-only code anyway.
227    fn create_user_test_signer(account_id: &AccountIdRef) -> InMemorySigner {
228        InMemorySigner::from_seed(account_id.to_owned(), KeyType::ED25519, account_id.as_str())
229    }
230
231    // happy path for signature
232    #[test]
233    fn nep_366_ok() {
234        let sender_id: AccountId = "alice.unc".parse().unwrap();
235        let receiver_id: AccountId = "bob.unc".parse().unwrap();
236        let signer = create_user_test_signer(&sender_id);
237
238        let delegate_action = delegate_action(sender_id, receiver_id, signer.public_key());
239        let signable = SignableMessage::new(&delegate_action, SignableMessageType::DelegateAction);
240        let signed = SignedDelegateAction { signature: signable.sign(&signer), delegate_action };
241
242        assert!(signed.verify());
243    }
244
245    // Try to use a wrong nep number in UIP signature verification.
246    #[test]
247    fn nep_366_wrong_nep() {
248        let sender_id: AccountId = "alice.unc".parse().unwrap();
249        let receiver_id: AccountId = "bob.unc".parse().unwrap();
250        let signer = create_user_test_signer(&sender_id);
251
252        let delegate_action = delegate_action(sender_id, receiver_id, signer.public_key());
253        let wrong_nep = 777;
254        let signable = SignableMessage {
255            discriminant: MessageDiscriminant::new_on_chain(wrong_nep).unwrap(),
256            msg: &delegate_action,
257        };
258        let signed = SignedDelegateAction { signature: signable.sign(&signer), delegate_action };
259
260        assert!(!signed.verify());
261    }
262
263    // Try to use a wrong message type in UIP-366 signature verification.
264    #[test]
265    fn nep_366_wrong_msg_type() {
266        let sender_id: AccountId = "alice.unc".parse().unwrap();
267        let receiver_id: AccountId = "bob.unc".parse().unwrap();
268        let signer = create_user_test_signer(&sender_id);
269
270        let delegate_action = delegate_action(sender_id, receiver_id, signer.public_key());
271        let correct_nep = 366;
272        // here we use it as an off-chain only signature
273        let wrong_discriminant = MessageDiscriminant::new_off_chain(correct_nep).unwrap();
274        let signable = SignableMessage { discriminant: wrong_discriminant, msg: &delegate_action };
275        let signed = SignedDelegateAction { signature: signable.sign(&signer), delegate_action };
276
277        assert!(!signed.verify());
278    }
279
280    fn delegate_action(
281        sender_id: AccountId,
282        receiver_id: AccountId,
283        public_key: PublicKey,
284    ) -> DelegateAction {
285        let delegate_action = DelegateAction {
286            sender_id,
287            receiver_id,
288            actions: vec![],
289            nonce: 0,
290            max_block_height: 1000,
291            public_key,
292        };
293        delegate_action
294    }
295}