From 537cb481a510d03e0f3f72521770ec8a053e628d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Palmer?= Date: Fri, 27 Jun 2025 14:53:59 +0200 Subject: [PATCH 1/5] Rust SDK: add a boilerplate tezos-protocol package --- sdk/rust/Cargo.lock | 4 ++++ sdk/rust/Cargo.toml | 6 ++++++ sdk/rust/protocol/Cargo.toml | 8 ++++++++ sdk/rust/protocol/src/lib.rs | 14 ++++++++++++++ 4 files changed, 32 insertions(+) create mode 100644 sdk/rust/protocol/Cargo.toml create mode 100644 sdk/rust/protocol/src/lib.rs diff --git a/sdk/rust/Cargo.lock b/sdk/rust/Cargo.lock index 14d7b892cf05..077a32664006 100644 --- a/sdk/rust/Cargo.lock +++ b/sdk/rust/Cargo.lock @@ -1066,6 +1066,10 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "tezos-protocol" +version = "0.1.0" + [[package]] name = "tezos_crypto_rs" version = "0.6.0" diff --git a/sdk/rust/Cargo.toml b/sdk/rust/Cargo.toml index ddf062b64bf0..287a280e9db1 100644 --- a/sdk/rust/Cargo.toml +++ b/sdk/rust/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crypto", "encoding", "encoding-derive", + "protocol", ] [workspace.dependencies.tezos_data_encoding] @@ -18,6 +19,11 @@ version = "0.6.0" path = "./encoding-derive" default-features = false +[workspace.dependencies.tezos-protocol] +version = "0.1.0" +path = "./protocol" +default-features = false + [workspace.dependencies.nom] version = "7.1" diff --git a/sdk/rust/protocol/Cargo.toml b/sdk/rust/protocol/Cargo.toml new file mode 100644 index 000000000000..9375a68d9d04 --- /dev/null +++ b/sdk/rust/protocol/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "tezos-protocol" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] diff --git a/sdk/rust/protocol/src/lib.rs b/sdk/rust/protocol/src/lib.rs new file mode 100644 index 000000000000..7d12d9af8195 --- /dev/null +++ b/sdk/rust/protocol/src/lib.rs @@ -0,0 +1,14 @@ +pub fn add(left: usize, right: usize) -> usize { + left + right +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_works() { + let result = add(2, 2); + assert_eq!(result, 4); + } +} -- GitLab From 6745f79cc2bedae183cb097b8f723c53c845bc3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Palmer?= Date: Tue, 1 Jul 2025 11:39:30 +0200 Subject: [PATCH 2/5] Rust SDK: add a contract crate Duplicated from `src/kernel_sdk/encoding/src/contract.rs` --- sdk/rust/Cargo.lock | 5 + sdk/rust/Cargo.toml | 5 + sdk/rust/protocol/Cargo.toml | 5 +- sdk/rust/protocol/src/contract.rs | 283 ++++++++++++++++++++++++++++++ sdk/rust/protocol/src/lib.rs | 17 +- 5 files changed, 300 insertions(+), 15 deletions(-) create mode 100644 sdk/rust/protocol/src/contract.rs diff --git a/sdk/rust/Cargo.lock b/sdk/rust/Cargo.lock index 077a32664006..09a2f5187484 100644 --- a/sdk/rust/Cargo.lock +++ b/sdk/rust/Cargo.lock @@ -1069,6 +1069,11 @@ dependencies = [ [[package]] name = "tezos-protocol" version = "0.1.0" +dependencies = [ + "nom", + "tezos_crypto_rs", + "tezos_data_encoding", +] [[package]] name = "tezos_crypto_rs" diff --git a/sdk/rust/Cargo.toml b/sdk/rust/Cargo.toml index 287a280e9db1..7d54c7bea0ea 100644 --- a/sdk/rust/Cargo.toml +++ b/sdk/rust/Cargo.toml @@ -9,6 +9,11 @@ members = [ "protocol", ] +[workspace.dependencies.tezos_crypto_rs] +version = "0.6.0" +path = "./crypto" +default-features = false + [workspace.dependencies.tezos_data_encoding] version = "0.6.0" path = "./encoding" diff --git a/sdk/rust/protocol/Cargo.toml b/sdk/rust/protocol/Cargo.toml index 9375a68d9d04..5dbb9e91a07d 100644 --- a/sdk/rust/protocol/Cargo.toml +++ b/sdk/rust/protocol/Cargo.toml @@ -3,6 +3,7 @@ name = "tezos-protocol" version = "0.1.0" edition = "2021" -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - [dependencies] +tezos_data_encoding.workspace = true +tezos_crypto_rs.workspace = true +nom.workspace = true diff --git a/sdk/rust/protocol/src/contract.rs b/sdk/rust/protocol/src/contract.rs new file mode 100644 index 000000000000..4aa828924872 --- /dev/null +++ b/sdk/rust/protocol/src/contract.rs @@ -0,0 +1,283 @@ +// SPDX-FileCopyrightText: 2023-2024 TriliTech +// +// SPDX-License-Identifier: MIT + +//! Definitions relating to Layer-1 accounts. + +use nom::branch::alt; +use nom::bytes::complete::tag; +use nom::combinator::map; +use nom::sequence::delimited; +use nom::sequence::preceded; +use tezos_crypto_rs::base58::{FromBase58Check, FromBase58CheckError}; +use tezos_crypto_rs::hash::{ContractKt1Hash, Hash, HashTrait, HashType}; +use tezos_data_encoding::enc::{self, BinResult, BinWriter}; +use tezos_data_encoding::encoding::{Encoding, HasEncoding}; +use tezos_data_encoding::has_encoding; +use tezos_data_encoding::nom::{NomReader, NomResult}; + +use tezos_crypto_rs::public_key_hash::PublicKeyHash; + +/// Contract id - of either an implicit account or originated account. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Contract { + /// User account + Implicit(PublicKeyHash), + /// Smart contract account + Originated(ContractKt1Hash), +} + +impl Contract { + /// Converts from a *base58-encoded* string, checking for the prefix. + pub fn from_b58check(data: &str) -> Result { + let bytes = data.from_base58check()?; + match bytes { + _ if bytes.starts_with(HashType::ContractKt1Hash.base58check_prefix()) => { + Ok(Self::Originated(ContractKt1Hash::from_b58check(data)?)) + } + _ => Ok(Self::Implicit(PublicKeyHash::from_b58check(data)?)), + } + } + + /// Converts to a *base58-encoded* string, including the prefix. + pub fn to_b58check(&self) -> String { + match self { + Self::Implicit(pkh) => pkh.to_b58check(), + Self::Originated(kt1) => kt1.to_b58check(), + } + } +} + +impl From for Hash { + fn from(c: Contract) -> Self { + match c { + Contract::Implicit(pkh) => pkh.into(), + Contract::Originated(ckt1) => ckt1.into(), + } + } +} + +impl TryFrom for Contract { + type Error = FromBase58CheckError; + + fn try_from(value: String) -> Result { + Contract::from_b58check(value.as_str()) + } +} + +#[allow(clippy::from_over_into)] +impl Into for Contract { + fn into(self) -> String { + self.to_b58check() + } +} + +has_encoding!(Contract, CONTRACT_ENCODING, { Encoding::Custom }); + +impl NomReader<'_> for Contract { + fn nom_read(input: &[u8]) -> NomResult { + alt(( + map( + preceded(tag([0]), PublicKeyHash::nom_read), + Contract::Implicit, + ), + map( + delimited(tag([1]), ContractKt1Hash::nom_read, tag([0])), + Contract::Originated, + ), + ))(input) + } +} + +impl BinWriter for Contract { + fn bin_write(&self, output: &mut Vec) -> BinResult { + match self { + Self::Implicit(implicit) => { + enc::put_byte(&0, output); + BinWriter::bin_write(implicit, output) + } + Self::Originated(originated) => { + enc::put_byte(&1, output); + let mut bytes: Hash = originated.as_ref().to_vec(); + // Originated is padded + bytes.push(0); + enc::bytes(&mut bytes, output)?; + Ok(()) + } + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn tz1_b58check() { + let tz1 = "tz1RjtZUVeLhADFHDL8UwDZA6vjWWhojpu5w"; + + let pkh = Contract::from_b58check(tz1); + + assert!(matches!( + pkh, + Ok(Contract::Implicit(PublicKeyHash::Ed25519(_))) + )); + + let tz1_from_pkh = pkh.unwrap().to_b58check(); + + assert_eq!(tz1, &tz1_from_pkh); + } + + #[test] + fn tz2_b58check() { + let tz2 = "tz2VGBaXuS6rnaa5hpC92qkgadRJKdEbeGwc"; + + let pkh = Contract::from_b58check(tz2); + + assert!(matches!( + pkh, + Ok(Contract::Implicit(PublicKeyHash::Secp256k1(_))) + )); + + let tz2_from_pkh = pkh.unwrap().to_b58check(); + + assert_eq!(tz2, &tz2_from_pkh); + } + + #[test] + fn tz3_b58check() { + let tz3 = "tz3WEJYwJ6pPwVbSL8FrSoAXRmFHHZTuEnMA"; + + let pkh = Contract::from_b58check(tz3); + + assert!(matches!( + pkh, + Ok(Contract::Implicit(PublicKeyHash::P256(_))) + )); + + let tz3_from_pkh = pkh.unwrap().to_b58check(); + + assert_eq!(tz3, &tz3_from_pkh); + } + + #[test] + fn kt1_b58check() { + let kt1 = "KT1BuEZtb68c1Q4yjtckcNjGELqWt56Xyesc"; + + let pkh = Contract::from_b58check(kt1); + + assert!(matches!(pkh, Ok(Contract::Originated(_)))); + + let kt1_from_pkh = pkh.unwrap().to_b58check(); + + assert_eq!(kt1, &kt1_from_pkh); + } + + #[test] + fn tz1_encoding() { + let tz1 = "tz1KqTpEZ7Yob7QbPE4Hy4Wo8fHG8LhKxZSx"; + + let contract = Contract::from_b58check(tz1).expect("expected valid tz1 hash"); + + let mut bin = Vec::new(); + contract + .bin_write(&mut bin) + .expect("serialization should work"); + + let deserde_contract = NomReader::nom_read(bin.as_slice()) + .expect("deserialization should work") + .1; + + check_implicit_serialized(&bin, tz1); + + assert_eq!(contract, deserde_contract); + } + + #[test] + fn tz2_encoding() { + let tz2 = "tz2JmrN5LtfkYZFCQnWQtwpd9u7Fq3Dc4n6E"; + + let contract = Contract::from_b58check(tz2).expect("expected valid tz2 hash"); + + let mut bin = Vec::new(); + contract + .bin_write(&mut bin) + .expect("serialization should work"); + + let deserde_contract = NomReader::nom_read(bin.as_slice()) + .expect("deserialization should work") + .1; + + check_implicit_serialized(&bin, tz2); + + assert_eq!(contract, deserde_contract); + } + + #[test] + fn tz3_encoding() { + let tz3 = "tz3gKfNk1UgCKXd21gBVba5Z9kqY8m6J2g1n"; + + let contract = Contract::from_b58check(tz3).expect("expected valid tz3 hash"); + + let mut bin = Vec::new(); + contract + .bin_write(&mut bin) + .expect("serialization should work"); + + let deserde_contract = NomReader::nom_read(bin.as_slice()) + .expect("deserialization should work") + .1; + + check_implicit_serialized(&bin, tz3); + + assert_eq!(contract, deserde_contract); + } + + // Check encoding of originated contracts (aka smart-contract addresses) + #[test] + fn contract_encode_originated() { + let test = "KT1BuEZtb68c1Q4yjtckcNjGELqWt56Xyesc"; + let mut expected = vec![1]; + let mut bytes = Contract::from_b58check(test).unwrap().into(); + expected.append(&mut bytes); + expected.push(0); // padding + + let contract = Contract::from_b58check(test).unwrap(); + + let mut bin = Vec::new(); + contract.bin_write(&mut bin).unwrap(); + + assert_eq!(expected, bin); + } + + // Check decoding of originated contracts (aka smart-contract addresses) + #[test] + fn contract_decode_originated() { + let expected = "KT1BuEZtb68c1Q4yjtckcNjGELqWt56Xyesc"; + let mut test = vec![1]; + let mut bytes = Contract::from_b58check(expected).unwrap().into(); + test.append(&mut bytes); + test.push(0); // padding + + let expected_contract = Contract::from_b58check(expected).unwrap(); + + let (remaining_input, contract) = Contract::nom_read(test.as_slice()).unwrap(); + + assert!(remaining_input.is_empty()); + assert_eq!(expected_contract, contract); + } + + // Check that serialization of implicit PublicKeyHash is binary compatible + // with protocol encoding of implicit contract ids. + fn check_implicit_serialized(contract_bytes: &[u8], address: &str) { + let mut bin_pkh = Vec::new(); + PublicKeyHash::from_b58check(address) + .expect("expected valid implicit contract") + .bin_write(&mut bin_pkh) + .expect("serialization should work"); + + assert!(matches!( + contract_bytes, + [0_u8, rest @ ..] if rest == bin_pkh)); + } +} diff --git a/sdk/rust/protocol/src/lib.rs b/sdk/rust/protocol/src/lib.rs index 7d12d9af8195..b7268007c7ed 100644 --- a/sdk/rust/protocol/src/lib.rs +++ b/sdk/rust/protocol/src/lib.rs @@ -1,14 +1,5 @@ -pub fn add(left: usize, right: usize) -> usize { - left + right -} +// SPDX-FileCopyrightText: 2025 Functori +// +// SPDX-License-Identifier: MIT -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); - } -} +pub mod contract; -- GitLab From 88d1ba3db203a01f1ddb02167c6271cd043b9a79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Palmer?= Date: Fri, 27 Jun 2025 16:32:28 +0200 Subject: [PATCH 3/5] Rust SDK: add an entrypoint crate Duplicated from contrib/mir/src/ast/michelson_address/entrypoint.rs --- sdk/rust/Cargo.lock | 2 + sdk/rust/protocol/Cargo.toml | 2 + sdk/rust/protocol/src/entrypoint.rs | 478 ++++++++++++++++++++++++++++ sdk/rust/protocol/src/lib.rs | 1 + 4 files changed, 483 insertions(+) create mode 100644 sdk/rust/protocol/src/entrypoint.rs diff --git a/sdk/rust/Cargo.lock b/sdk/rust/Cargo.lock index 09a2f5187484..fd076ab89e0f 100644 --- a/sdk/rust/Cargo.lock +++ b/sdk/rust/Cargo.lock @@ -1070,9 +1070,11 @@ dependencies = [ name = "tezos-protocol" version = "0.1.0" dependencies = [ + "hex", "nom", "tezos_crypto_rs", "tezos_data_encoding", + "thiserror", ] [[package]] diff --git a/sdk/rust/protocol/Cargo.toml b/sdk/rust/protocol/Cargo.toml index 5dbb9e91a07d..3724a6366ed6 100644 --- a/sdk/rust/protocol/Cargo.toml +++ b/sdk/rust/protocol/Cargo.toml @@ -7,3 +7,5 @@ edition = "2021" tezos_data_encoding.workspace = true tezos_crypto_rs.workspace = true nom.workspace = true +thiserror = "1.0" +hex = "0.4" diff --git a/sdk/rust/protocol/src/entrypoint.rs b/sdk/rust/protocol/src/entrypoint.rs new file mode 100644 index 000000000000..19c59c85e8e9 --- /dev/null +++ b/sdk/rust/protocol/src/entrypoint.rs @@ -0,0 +1,478 @@ +/******************************************************************************/ +/* */ +/* SPDX-License-Identifier: MIT */ +/* Copyright (c) [2023] Serokell */ +/* */ +/******************************************************************************/ + +//! Structures and utilities for [Tezos +//! entrypoints](https://docs.tezos.com/smart-contracts/entrypoints). + +use nom::branch::alt; +use nom::bytes::complete::tag; +use nom::combinator::{map, map_res, verify}; +use nom::multi::length_data; +use nom::number::complete::u8 as nom_u8; +use nom::sequence::preceded; + +use tezos_data_encoding::enc::BinWriter; +use tezos_data_encoding::nom::{NomReader, NomResult}; + +/// Errors that can happen when parsing bytes. +#[derive(Debug, PartialEq, Eq, Clone, thiserror::Error)] +pub enum ByteReprError { + /// Input format is in some way unexpected, with the details explained in + /// the contained string. + #[error("wrong format: {0}")] + WrongFormat(String), +} + +/// Structure representing address entrypoint on a Tezos address, in other +/// words, the part after `%` in `KT1BRd2ka5q2cPRdXALtXD1QZ38CPam2j1ye%foo`. +/// Tezos entrypoints are ASCII strings of at most 31 characters long. +#[derive(Debug, Clone, Eq, PartialOrd, Ord, PartialEq, Hash)] +pub struct Entrypoint(String); + +impl std::fmt::Display for Entrypoint { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// NB: default entrypoint is represented as literal "default", because it +/// affects comparison for addresses. +pub const DEFAULT_EP_NAME: &str = "default"; +const MAX_EP_LEN: usize = 31; + +impl Default for Entrypoint { + fn default() -> Self { + Entrypoint(DEFAULT_EP_NAME.to_owned()) + } +} + +#[derive(Copy, Clone)] +enum EntrypointTag { + Default = 0, + Root = 1, + Do = 2, + SetDelegate = 3, + RemoveDelegate = 4, + Deposit = 5, + Stake = 6, + Unstake = 7, + FinalizeUnstake = 8, + SetDelegateParameters = 9, + Custom = 255, +} + +impl EntrypointTag { + #[allow(dead_code)] + fn from_str(name: &str) -> Self { + match name { + "" | DEFAULT_EP_NAME => Self::Default, + "root" => Self::Root, + "do" => Self::Do, + "set_delegate" => Self::SetDelegate, + "remove_delegate" => Self::RemoveDelegate, + "deposit" => Self::Deposit, + "stake" => Self::Stake, + "unstake" => Self::Unstake, + "finalize_unstake" => Self::FinalizeUnstake, + "set_delegate_parameters" => Self::SetDelegateParameters, + _ => Self::Custom, + } + } + + #[allow(dead_code)] + fn to_str(self) -> &'static str { + match self { + Self::Default => DEFAULT_EP_NAME, + Self::Root => "root", + Self::Do => "do", + Self::SetDelegate => "set_delegate", + Self::RemoveDelegate => "remove_delegate", + Self::Deposit => "deposit", + Self::Stake => "stake", + Self::Unstake => "unstake", + Self::FinalizeUnstake => "finalize_unstake", + Self::SetDelegateParameters => "set_delegate_parameters", + Self::Custom => "custom", + } + } +} + +impl BinWriter for Entrypoint { + fn bin_write(&self, output: &mut Vec) -> tezos_data_encoding::enc::BinResult { + let tag = EntrypointTag::from_str(&self.0); + output.push(tag as u8); + + if matches!(tag, EntrypointTag::Custom) { + let bytes = self.0.as_bytes(); + output.push(bytes.len() as u8); + output.extend_from_slice(bytes); + } + + Ok(()) + } +} + +impl NomReader<'_> for Entrypoint { + fn nom_read(input: &[u8]) -> NomResult { + alt(( + map(tag([EntrypointTag::Default as u8]), |_| { + Entrypoint::default() + }), + map(tag([EntrypointTag::Root as u8]), |_| { + Entrypoint("root".into()) + }), + map(tag([EntrypointTag::Do as u8]), |_| Entrypoint("do".into())), + map(tag([EntrypointTag::SetDelegate as u8]), |_| { + Entrypoint("set_delegate".into()) + }), + map(tag([EntrypointTag::RemoveDelegate as u8]), |_| { + Entrypoint("remove_delegate".into()) + }), + map(tag([EntrypointTag::Deposit as u8]), |_| { + Entrypoint("deposit".into()) + }), + map(tag([EntrypointTag::Stake as u8]), |_| { + Entrypoint("stake".into()) + }), + map(tag([EntrypointTag::Unstake as u8]), |_| { + Entrypoint("unstake".into()) + }), + map(tag([EntrypointTag::FinalizeUnstake as u8]), |_| { + Entrypoint("finalize_unstake".into()) + }), + map(tag([EntrypointTag::SetDelegateParameters as u8]), |_| { + Entrypoint("set_delegate_parameters".into()) + }), + preceded( + tag([EntrypointTag::Custom as u8]), + map( + verify( + map_res(length_data(nom_u8), std::str::from_utf8), + |s: &str| check_ep_name(s.as_bytes()).is_ok(), + ), + |s| Entrypoint(s.to_owned()), + ), + ), + ))(input) + } +} + +impl Entrypoint { + /// Returns `true` if entrypoint is the default entrypoint. + pub fn is_default(&self) -> bool { + self.0 == DEFAULT_EP_NAME + } + + /// Returns a reference to the entrypoint name as bytes. + pub fn as_bytes(&self) -> &[u8] { + self.0.as_bytes() + } + + /// Returns a reference to the entrypoint name as [str]. + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl TryFrom<&str> for Entrypoint { + type Error = ByteReprError; + fn try_from(s: &str) -> Result { + Entrypoint::try_from(s.to_owned()) + } +} + +impl TryFrom for Entrypoint { + type Error = ByteReprError; + fn try_from(s: String) -> Result { + if s.is_empty() { + Ok(Entrypoint::default()) + } else { + check_ep_name(s.as_bytes())?; + Ok(Entrypoint(s)) + } + } +} + +impl TryFrom<&[u8]> for Entrypoint { + type Error = ByteReprError; + fn try_from(s: &[u8]) -> Result { + if s.is_empty() { + Ok(Entrypoint::default()) + } else { + check_ep_name(s)?; + // SAFETY: we just checked all bytes are valid ASCII + let ep = Entrypoint(unsafe { std::str::from_utf8_unchecked(s).to_owned() }); + if ep.is_default() { + return Err(ByteReprError::WrongFormat( + "explicit default entrypoint is forbidden in binary encoding".to_owned(), + )); + } + Ok(ep) + } + } +} + +pub(crate) fn check_ep_name_len(ep: &[u8]) -> Result<(), ByteReprError> { + if ep.len() > MAX_EP_LEN { + return Err(ByteReprError::WrongFormat(format!( + "entrypoint name must be at most {} characters long, but it is {} characters long", + MAX_EP_LEN, + ep.len() + ))); + } + Ok(()) +} + +fn check_ep_name(ep: &[u8]) -> Result<(), ByteReprError> { + check_ep_name_len(ep)?; + let mut first_char = true; + for c in ep { + // direct encoding of the regex defined in + // https://tezos.gitlab.io/alpha/michelson.html#syntax + match c { + b'_' | b'0'..=b'9' | b'a'..=b'z' | b'A'..=b'Z' => Ok(()), + b'.' | b'%' | b'@' if !first_char => Ok(()), + c => Err(ByteReprError::WrongFormat(format!( + "forbidden byte in entrypoint name: {}", + hex::encode([*c]) + ))), + }?; + first_char = false; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default() { + assert_eq!( + Entrypoint::default(), + Entrypoint(DEFAULT_EP_NAME.to_owned()) + ) + } + + #[test] + fn test_from_str() { + assert_eq!(Entrypoint::try_from(""), Ok(Entrypoint::default())); + assert_eq!(Entrypoint::try_from("default"), Ok(Entrypoint::default())); + assert_eq!( + Entrypoint::try_from("foo"), + Ok(Entrypoint("foo".to_owned())) + ); + assert_eq!( + Entrypoint::try_from("foo.bar"), + Ok(Entrypoint("foo.bar".to_owned())) + ); + assert_eq!( + Entrypoint::try_from("q".repeat(31).as_str()), + Ok(Entrypoint("q".repeat(31))) + ); + // too long + assert!(matches!( + Entrypoint::try_from("q".repeat(32).as_str()), + Err(ByteReprError::WrongFormat(_)) + )); + // unicode + assert!(matches!( + Entrypoint::try_from("संसर"), + Err(ByteReprError::WrongFormat(_)) + )); + // forbidden character + assert!(matches!( + Entrypoint::try_from("!"), + Err(ByteReprError::WrongFormat(_)) + )); + } + + #[test] + fn test_from_string() { + // most of this is tested in test_from_str, as one delegates to the + // other, so only the basic tests here + assert_eq!( + Entrypoint::try_from("".to_owned()), + Ok(Entrypoint::default()) + ); + assert_eq!( + Entrypoint::try_from("default".to_owned()), + Ok(Entrypoint::default()) + ); + assert_eq!( + Entrypoint::try_from("foo".to_owned()), + Ok(Entrypoint("foo".to_owned())) + ); + } + + #[test] + fn test_from_bytes() { + // explicit default entrypoints are forbidden in binary + assert!(matches!( + Entrypoint::try_from(b"default" as &[u8]), + Err(ByteReprError::WrongFormat(_)) + )); + assert_eq!( + Entrypoint::try_from(b"" as &[u8]), + Ok(Entrypoint::default()) + ); + assert_eq!( + Entrypoint::try_from(b"foo" as &[u8]), + Ok(Entrypoint("foo".to_owned())) + ); + + assert_eq!( + Entrypoint::try_from(b"foo.bar" as &[u8]), + Ok(Entrypoint("foo.bar".to_owned())) + ); + assert_eq!( + Entrypoint::try_from("q".repeat(31).as_bytes()), + Ok(Entrypoint("q".repeat(31))) + ); + // too long + assert!(matches!( + Entrypoint::try_from("q".repeat(32).as_bytes()), + Err(ByteReprError::WrongFormat(_)) + )); + // unicode + assert!(matches!( + Entrypoint::try_from("संसर".as_bytes()), + Err(ByteReprError::WrongFormat(_)) + )); + // forbidden character + assert!(matches!( + Entrypoint::try_from(b"!" as &[u8]), + Err(ByteReprError::WrongFormat(_)) + )); + } + + #[test] + fn test_check_ep_name() { + assert_eq!(check_ep_name(&[b'q'; 31]), Ok(())); + + // more than 31 bytes + assert!(matches!( + check_ep_name(&[b'q'; 32]), + Err(ByteReprError::WrongFormat(_)) + )); + + // '.', '%', '@' are allowed + for i in ['.', '%', '@'] { + assert_eq!(check_ep_name(format!("foo{i}bar").as_bytes()), Ok(())); + + // but not as the first character + assert!(matches!( + check_ep_name(format!("{i}bar").as_bytes()), + Err(ByteReprError::WrongFormat(_)) + )); + } + + // ! is forbidden + assert!(matches!( + check_ep_name(b"foo!"), + Err(ByteReprError::WrongFormat(_)) + )); + + // unicode is forbidden + assert!(matches!( + check_ep_name("नमस्ते".as_bytes()), + Err(ByteReprError::WrongFormat(_)) + )); + } + + #[test] + fn test_nom_read_known_entrypoints() { + let tests = [ + (vec![EntrypointTag::Default as u8], Entrypoint::default()), + (vec![EntrypointTag::Root as u8], Entrypoint("root".into())), + (vec![EntrypointTag::Do as u8], Entrypoint("do".into())), + (vec![EntrypointTag::Stake as u8], Entrypoint("stake".into())), + ]; + + for (input, expected) in tests.iter() { + let result = Entrypoint::nom_read(input).unwrap(); + assert_eq!(&result.1, expected); + } + } + + #[test] + fn test_bin_write_known_entrypoints() { + let tests = [ + (Entrypoint::default(), vec![EntrypointTag::Default as u8]), + (Entrypoint("root".into()), vec![EntrypointTag::Root as u8]), + (Entrypoint("do".into()), vec![EntrypointTag::Do as u8]), + (Entrypoint("stake".into()), vec![EntrypointTag::Stake as u8]), + ]; + + for (entrypoint, expected) in tests.iter() { + let mut output = vec![]; + entrypoint.bin_write(&mut output).unwrap(); + assert_eq!(&output, expected); + } + } + + #[test] + fn test_nom_read_custom_entrypoint() { + let input = vec![EntrypointTag::Custom as u8, 5, b'h', b'e', b'l', b'l', b'o']; + let expected = Entrypoint("hello".into()); + + let result = Entrypoint::nom_read(&input).unwrap(); + assert_eq!(result.1, expected); + } + + #[test] + fn test_bin_write_custom_entrypoint() { + let entrypoint = Entrypoint("my_custom_ep".into()); + let mut output = vec![]; + entrypoint.bin_write(&mut output).unwrap(); + + let expected = { + let mut exp = vec![EntrypointTag::Custom as u8, 12]; // 12 bytes for "my_custom_ep" + exp.extend_from_slice(b"my_custom_ep"); + exp + }; + + assert_eq!(output, expected); + } + + #[test] + fn test_round_trip_custom_entrypoint() { + let original = Entrypoint("test_ep".into()); + let mut output = vec![]; + original.bin_write(&mut output).unwrap(); + + let parsed = Entrypoint::nom_read(&output).unwrap().1; + assert_eq!(original, parsed); + } + + #[test] + fn test_round_trip_all_known_entrypoints() { + let entrypoints = [ + EntrypointTag::Default, + EntrypointTag::Root, + EntrypointTag::Do, + EntrypointTag::SetDelegate, + EntrypointTag::RemoveDelegate, + EntrypointTag::Deposit, + EntrypointTag::Stake, + EntrypointTag::Unstake, + EntrypointTag::FinalizeUnstake, + EntrypointTag::SetDelegateParameters, + ]; + + for &ep_tag in entrypoints.iter() { + let ep_name = ep_tag.to_str(); + let ep = Entrypoint(ep_name.into()); + + let mut output = vec![]; + ep.bin_write(&mut output).unwrap(); + + let parsed = Entrypoint::nom_read(&output).unwrap().1; + assert_eq!(ep, parsed); + } + } +} diff --git a/sdk/rust/protocol/src/lib.rs b/sdk/rust/protocol/src/lib.rs index b7268007c7ed..8379d83ddbfd 100644 --- a/sdk/rust/protocol/src/lib.rs +++ b/sdk/rust/protocol/src/lib.rs @@ -3,3 +3,4 @@ // SPDX-License-Identifier: MIT pub mod contract; +pub mod entrypoint; -- GitLab From 0ddcd07f079396c083165992644d876c90fd9223 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Palmer?= Date: Fri, 13 Jun 2025 09:17:11 +0200 Subject: [PATCH 4/5] Rust SDK: add an operations crate Duplicated from `etherlink/kernel_latest/tezos/src/operation.rs` --- sdk/rust/protocol/src/lib.rs | 1 + sdk/rust/protocol/src/operation.rs | 231 +++++++++++++++++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 sdk/rust/protocol/src/operation.rs diff --git a/sdk/rust/protocol/src/lib.rs b/sdk/rust/protocol/src/lib.rs index 8379d83ddbfd..9ea68dcf4274 100644 --- a/sdk/rust/protocol/src/lib.rs +++ b/sdk/rust/protocol/src/lib.rs @@ -4,3 +4,4 @@ pub mod contract; pub mod entrypoint; +pub mod operation; diff --git a/sdk/rust/protocol/src/operation.rs b/sdk/rust/protocol/src/operation.rs new file mode 100644 index 000000000000..4c0580c4a48c --- /dev/null +++ b/sdk/rust/protocol/src/operation.rs @@ -0,0 +1,231 @@ +// SPDX-FileCopyrightText: 2025 Functori +// +// SPDX-License-Identifier: MIT + +//! Tezos operations +/// The whole module is inspired of `src/proto_alpha/lib_protocol/operation_repr.ml` to represent the operation +use crate::contract::Contract; +use crate::entrypoint::Entrypoint; +use tezos_crypto_rs::{public_key::PublicKey, public_key_hash::PublicKeyHash}; +use tezos_data_encoding::{enc::BinWriter, nom::NomReader, types::Narith}; + +#[derive(PartialEq, Debug, Clone, NomReader, BinWriter)] +#[encoding(tags = "u8")] +pub enum OperationContent { + #[encoding(tag = 107)] + Reveal(ManagerOperationContent), + #[encoding(tag = 108)] + Transaction(ManagerOperationContent), +} + +#[derive(PartialEq, Debug, Clone, NomReader, BinWriter)] +pub struct ManagerOperationContent { + pub source: PublicKeyHash, + pub fee: Narith, + pub counter: Narith, + pub gas_limit: Narith, + pub storage_limit: Narith, + pub operation: Op, +} + +#[derive(PartialEq, Debug, Clone, NomReader, BinWriter)] +pub struct RevealContent { + pub pk: PublicKey, +} + +#[derive(PartialEq, Debug, Clone, NomReader, BinWriter)] +pub struct TransactionContent { + pub amount: Narith, + pub destination: Contract, + pub parameters: Option, +} + +#[derive(PartialEq, Debug, Clone, NomReader, BinWriter)] +pub struct Parameter { + pub entrypoint: Entrypoint, + #[encoding(dynamic, bytes)] + pub value: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + /* + octez-codec encode "022-PsRiotum.operation.contents" from '{ + "kind": "reveal", + "source": "tz2WU9XW86EdgVQZrbPphjUZiRfXXssY9wEP", + "fee": "31", + "counter": "1005", + "gas_limit": "89", + "storage_limit": "7", + "public_key": "sppk7bo7kcRyjajZaAqEfqdtCNx3wgizhJPFqaEuisncbDFMgn6v4iP" + }' + */ + #[test] + fn reveal_encoding() { + let pk = + PublicKey::from_b58check("sppk7bo7kcRyjajZaAqEfqdtCNx3wgizhJPFqaEuisncbDFMgn6v4iP") + .unwrap(); + let pkh = PublicKeyHash::from_b58check("tz2WU9XW86EdgVQZrbPphjUZiRfXXssY9wEP").unwrap(); + let fee = 31.into(); + let counter = 1005.into(); + let gas_limit = 89.into(); + let storage_limit = 7.into(); + let operation = OperationContent::Reveal(ManagerOperationContent { + source: pkh, + fee, + counter, + gas_limit, + storage_limit, + operation: RevealContent { pk }, + }); + + let mut encoded_operation = Vec::new(); + operation.bin_write(&mut encoded_operation).unwrap(); + + let bytes = hex::decode("6b01f3023970264e14502daa1db4324527bc464fe0fd1fed0759070103480fcf4241d5903bd5b9a71db63fc6784dc9e686acf0dac9b4305d54cb642946").unwrap(); + assert_eq!(bytes, encoded_operation); + + let (bytes, decoded_operation) = OperationContent::nom_read(&encoded_operation).unwrap(); + assert_eq!(operation, decoded_operation); + assert!(bytes.is_empty()); + } + + /* + octez-codec encode "022-PsRiotum.operation.contents" from '{ + "kind": "transaction", + "source": "tz1KqTpEZ7Yob7QbPE4Hy4Wo8fHG8LhKxZSx", + "fee": "405", + "counter": "2", + "gas_limit": "1380", + "storage_limit": "0", + "amount": "1000000", + "destination": "KT1EY9XA4Z5tybQN5zmVUL5cntku1zTCBLTv", + "parameters": { + "entrypoint": "B", + "value": { + "string": "Hello" + } + } + }' + */ + #[test] + fn transaction_encoding() { + let operation = OperationContent::Transaction(ManagerOperationContent { + source: PublicKeyHash::from_b58check("tz1KqTpEZ7Yob7QbPE4Hy4Wo8fHG8LhKxZSx").unwrap(), + fee: 405_u64.into(), + counter: 2_u64.into(), + operation: TransactionContent { + amount: 1_000_000_u64.into(), + destination: Contract::from_b58check("KT1EY9XA4Z5tybQN5zmVUL5cntku1zTCBLTv") + .unwrap(), + parameters: Some(Parameter { + entrypoint: Entrypoint::try_from("B").unwrap(), + // mir::ast::Micheline::String("Hello".into()).encode(), + value: vec![0x01, 0x00, 0x00, 0x00, 0x05, 0x48, 0x65, 0x6c, 0x6c, 0x6f], + }), + }, + gas_limit: 1380_u64.into(), + storage_limit: 0_u64.into(), + }); + + let mut encoded_operation = Vec::new(); + operation.bin_write(&mut encoded_operation).unwrap(); + + let bytes = hex::decode("6c0002298c03ed7d454a101eb7022bc95f7e5f41ac78950302e40a00c0843d014151d57ddff98da8cd49f0f2cbf89465bcf267a400ffff01420000000a010000000548656c6c6f").unwrap(); + assert_eq!(bytes, encoded_operation); + + let (bytes, decoded_operation) = OperationContent::nom_read(&encoded_operation).unwrap(); + assert_eq!(operation, decoded_operation); + assert!(bytes.is_empty()); + } + + /* + octez-codec encode "022-PsRiotum.operation.contents" from '{ + "kind": "transaction", + "source": "tz1gjaF81ZRRvdzjobyfVNsAeSC6PScjfQwN", + "fee": "987", + "counter": "456", + "gas_limit": "0", + "storage_limit": "1405", + "amount": "10", + "destination": "tz1KqTpEZ7Yob7QbPE4Hy4Wo8fHG8LhKxZSx" + }' + */ + #[test] + fn transaction_transfer_encoding() { + let operation = OperationContent::Transaction(ManagerOperationContent { + source: PublicKeyHash::from_b58check("tz1gjaF81ZRRvdzjobyfVNsAeSC6PScjfQwN").unwrap(), + fee: 987.into(), + counter: 456.into(), + operation: TransactionContent { + amount: 10.into(), + destination: Contract::from_b58check("tz1KqTpEZ7Yob7QbPE4Hy4Wo8fHG8LhKxZSx") + .unwrap(), + parameters: None, + }, + gas_limit: 0.into(), + storage_limit: 1405.into(), + }); + + let mut encoded_operation = Vec::new(); + operation.bin_write(&mut encoded_operation).unwrap(); + + let bytes = hex::decode("6c00e7670f32038107a59a2b9cfefae36ea21f5aa63cdb07c80300fd0a0a000002298c03ed7d454a101eb7022bc95f7e5f41ac7800").unwrap(); + assert_eq!(bytes, encoded_operation); + + let (bytes, decoded_operation) = OperationContent::nom_read(&encoded_operation).unwrap(); + assert_eq!(operation, decoded_operation); + assert!(bytes.is_empty()); + } + + /* + octez-codec encode "022-PsRiotum.operation.contents" from '{ + "kind": "transaction", + "source": "tz3hqqamVC1G22LACFoMgcJeFKZgoGMFSfSn", + "fee": "7", + "counter": "4223", + "gas_limit": "0", + "storage_limit": "0", + "amount": "0", + "destination": "tz4Uzyxg26DJyM4pc1V2pUvLpdsR5jdyzYsZ", + "parameters": { + "entrypoint": "remove_delegate", + "value": { + "prim": "Unit" + } + } + }' + */ + #[test] + fn transaction_fixed_entrypoint_encoding() { + let operation = OperationContent::Transaction(ManagerOperationContent { + source: PublicKeyHash::from_b58check("tz3hqqamVC1G22LACFoMgcJeFKZgoGMFSfSn").unwrap(), + fee: 7.into(), + counter: 4223.into(), + operation: TransactionContent { + amount: 0.into(), + destination: Contract::from_b58check("tz4Uzyxg26DJyM4pc1V2pUvLpdsR5jdyzYsZ") + .unwrap(), + parameters: Some(Parameter { + entrypoint: Entrypoint::try_from("remove_delegate").unwrap(), + // Micheline::App(Prim::Unit, &[], NO_ANNS).encode(), + value: vec![0x03, 0x0b], + }), + }, + gas_limit: 0.into(), + storage_limit: 0.into(), + }); + + let mut encoded_operation = Vec::new(); + operation.bin_write(&mut encoded_operation).unwrap(); + + let bytes = hex::decode("6c02ebfd1371b542831b4be730161d08885c5312e44207ff200000000003db557924e5a295652eff2c1f141d5a5b72b9cc91ff0400000002030b").unwrap(); + assert_eq!(bytes, encoded_operation); + + let (bytes, decoded_operation) = OperationContent::nom_read(&encoded_operation).unwrap(); + assert_eq!(operation, decoded_operation); + assert!(bytes.is_empty()); + } +} -- GitLab From 1004219fb2963b8c605203663440aac0ea63e116 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Palmer?= Date: Tue, 1 Jul 2025 15:54:41 +0200 Subject: [PATCH 5/5] Rust SDK: update CHANGELOG.md and README.md --- sdk/rust/CHANGELOG.md | 4 ++++ sdk/rust/README.md | 1 + sdk/rust/protocol/README.md | 4 ++++ 3 files changed, 9 insertions(+) create mode 100644 sdk/rust/protocol/README.md diff --git a/sdk/rust/CHANGELOG.md b/sdk/rust/CHANGELOG.md index 1614bca34b50..ac932203862a 100644 --- a/sdk/rust/CHANGELOG.md +++ b/sdk/rust/CHANGELOG.md @@ -11,6 +11,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Add `EncryptedSecretKeyEd25519`, `EncryptedSecretKeySecp256k1`, `EncryptedSecretKeyP256` and `EncryptedSecretKeyBls` hashes - Add `ScriptExprHash` hash - Allow the unit type `()` as field in derived implementations of `NomReader` and `BinWriter`. +- Add a new package `tezos-protocol`, holding the Tezos protocol structures. +- Add `Contract` defining contract address. +- Add `Entrypoint` defining transaction entrypoint. +- Add `OperationContent`, `ManagerOperationContent`, `RevealContent`, `TransactionContent` defining operations contents for reveal and transaction. ### Changed diff --git a/sdk/rust/README.md b/sdk/rust/README.md index a03e92bb1192..0db5435df315 100644 --- a/sdk/rust/README.md +++ b/sdk/rust/README.md @@ -8,6 +8,7 @@ Namely: - [tezos_crypto_rs](./crypto/README.md) - [tezos_encoding](./tezos-encoding/README.md) - [tezos_encoding_derive](./tezos-encoding-derive/README.md) +- [tezos_protocol](./protocol/README.md) ## Setup diff --git a/sdk/rust/protocol/README.md b/sdk/rust/protocol/README.md new file mode 100644 index 000000000000..6f6ab1d470ea --- /dev/null +++ b/sdk/rust/protocol/README.md @@ -0,0 +1,4 @@ +Tezos Protocol +============ + +Component contains Tezos protocol structures, to match exposed structure in the Tezos protocol from [octez](https://gitlab.com/tezos/tezos). -- GitLab