Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Construction derive endpoint #94

Merged
merged 5 commits into from
Jan 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
353 changes: 352 additions & 1 deletion Cargo.lock

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,18 @@ derive_more = { version = "1.0.0", features = ["full"] }
dotenv = "0.15.0"
erased-serde = "0.4.5"
futures = "0.3.31"
hex = "0.4.3"
http = "1.1.0"
http-body-util = "0.1.2"
mime = "0.3.17"
mina-signer = { git = "https://github.com/o1-labs/proof-systems", rev = "872c8f2" }
o1-utils = { git = "https://github.com/o1-labs/proof-systems", rev = "872c8f2" }
paste = "1.0.15"
pretty_assertions = "1.4.1"
reqwest = { version = "0.12.5", features = ["json", "blocking"] }
serde = { version = "1.0.204", features = ["derive"] }
serde_json = { version = "1.0.121" }
sha2 = "0.10.8"
sqlx = { version = "0.8.0", features = ["runtime-tokio", "postgres", "json"] }
strum = "0.26.3"
strum_macros = "0.26.4"
Expand Down
110 changes: 106 additions & 4 deletions src/api/construction_derive.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,114 @@
use anyhow::Result;
use coinbase_mesh::models::{ConstructionDeriveRequest, ConstructionDeriveResponse};
use coinbase_mesh::models::{AccountIdentifier, ConstructionDeriveRequest, ConstructionDeriveResponse};
use mina_signer::{BaseField, CompressedPubKey};
use o1_utils::FieldHelpers;
use serde_json::{json, Value};
use sha2::Digest;

use crate::MinaMesh;
use crate::{util::DEFAULT_TOKEN_ID, MinaMesh, MinaMeshError};

/// https://github.com/MinaProtocol/mina/blob/985eda49bdfabc046ef9001d3c406e688bc7ec45/src/app/rosetta/lib/construction.ml#L162
impl MinaMesh {
pub async fn construction_derive(&self, request: ConstructionDeriveRequest) -> Result<ConstructionDeriveResponse> {
pub async fn construction_derive(
&self,
request: ConstructionDeriveRequest,
) -> Result<ConstructionDeriveResponse, MinaMeshError> {
// Validate the network identifier
self.validate_network(&request.network_identifier).await?;
Ok(ConstructionDeriveResponse::new())

// Decode the hex_bytes payload into an address
let compressed_pk = to_public_key_compressed(&request.public_key.hex_bytes)?;
let address = compressed_pk.into_address();

// Decode the token ID from metadata (if present)
let token_id = decode_token_id(request.metadata)?;

// Construct the account identifier
let account_identifier = AccountIdentifier {
address: address.clone(),
sub_account: None,
metadata: Some(json!({ "token_id": token_id })),
};

// Build the response
Ok(ConstructionDeriveResponse {
address: None,
account_identifier: Some(Box::new(account_identifier)),
metadata: None,
})
}
}

/// Converts a hex string into a compressed public key
/// https://github.com/MinaProtocol/mina/blob/985eda49bdfabc046ef9001d3c406e688bc7ec45/src/lib/rosetta_coding/coding.ml#L128
fn to_public_key_compressed(hex: &str) -> Result<CompressedPubKey, MinaMeshError> {
if hex.len() != 64 {
return Err(MinaMeshError::MalformedPublicKey("Invalid length for hex".to_string()));
}

// Decode the hex string
let mut bytes =
hex::decode(hex).map_err(|_| MinaMeshError::MalformedPublicKey("Invalid hex encoding".to_string()))?;
// Reverse the bytes
bytes.reverse();

// Convert bytes to bits
let mut bits: Vec<bool> = bytes.iter().flat_map(|byte| (0 .. 8).rev().map(move |i| (byte >> i) & 1 == 1)).collect();

// Extract the `is_odd` bit
let is_odd = bits.remove(0);

// Reverse the remaining bits
bits.reverse();

// Create the x-coordinate as a BaseField element
let x =
BaseField::from_bits(&bits).map_err(|_| MinaMeshError::MalformedPublicKey("Invalid x-coordinate".to_string()))?;

// Construct the compressed public key
Ok(CompressedPubKey { x, is_odd })
}

/// Decodes the token ID from metadata, or returns a default value if not
/// present
fn decode_token_id(metadata: Option<Value>) -> Result<String, MinaMeshError> {
if let Some(meta) = metadata {
if let Some(token_id) = meta.get("token_id") {
let token_id_str =
token_id.as_str().ok_or_else(|| MinaMeshError::MalformedPublicKey("Invalid token_id format".to_string()))?;

// Validate the token ID format (e.g., base58)
validate_base58_token_id(token_id_str)?;

return Ok(token_id_str.to_string());
}
}
// Default token ID if not present
Ok(DEFAULT_TOKEN_ID.to_string())
}

/// Validates the token ID format (base58 and checksum)
/// https://github.com/MinaProtocol/mina/blob/985eda49bdfabc046ef9001d3c406e688bc7ec45/src/lib/base58_check/base58_check.ml
fn validate_base58_token_id(token_id: &str) -> Result<(), MinaMeshError> {
// Decode the token ID using base58
let bytes = bs58::decode(token_id)
.with_alphabet(bs58::Alphabet::BITCOIN)
.into_vec()
.map_err(|_| MinaMeshError::MalformedPublicKey("Token_id not valid base58".to_string()))?;

// Check the length (e.g., must include version and checksum)
if bytes.len() < 5 {
return Err(MinaMeshError::MalformedPublicKey("Token_id too short".to_string()));
}

// Split into payload and checksum
let (payload, checksum) = bytes.split_at(bytes.len() - 4);

// Recompute checksum
let computed_checksum = sha2::Sha256::digest(sha2::Sha256::digest(payload));
if &computed_checksum[.. 4] != checksum {
return Err(MinaMeshError::MalformedPublicKey("Token_id checksum mismatch".to_string()));
}

Ok(())
}
1 change: 1 addition & 0 deletions src/create_router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ create_handler!(search_transactions, SearchTransactionsRequest);
async fn handle_available_endpoints() -> impl IntoResponse {
Json([
"/account/balance",
"/construction/derive",
"/block",
"/mempool",
"/mempool/transaction",
Expand Down
13 changes: 8 additions & 5 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ pub enum MinaMeshError {
BlockMissing(Option<i64>, Option<String>),

#[error("Malformed public key")]
MalformedPublicKey,
MalformedPublicKey(String),

#[error("Cannot convert operations to valid transaction")]
OperationsNotValid(Vec<PartialReason>),
Expand Down Expand Up @@ -119,7 +119,7 @@ impl MinaMeshError {
MinaMeshError::InvariantViolation,
MinaMeshError::TransactionNotFound("Transaction ID".to_string()),
MinaMeshError::BlockMissing(Some(-1), Some("test_hash".to_string())),
MinaMeshError::MalformedPublicKey,
MinaMeshError::MalformedPublicKey("Error message".to_string()),
MinaMeshError::OperationsNotValid(vec![]),
MinaMeshError::UnsupportedOperationForConstruction,
MinaMeshError::SignatureMissing,
Expand Down Expand Up @@ -151,7 +151,7 @@ impl MinaMeshError {
MinaMeshError::InvariantViolation => 7,
MinaMeshError::TransactionNotFound(_) => 8,
MinaMeshError::BlockMissing(_, _) => 9,
MinaMeshError::MalformedPublicKey => 10,
MinaMeshError::MalformedPublicKey(_) => 10,
MinaMeshError::OperationsNotValid(_) => 11,
MinaMeshError::UnsupportedOperationForConstruction => 12,
MinaMeshError::SignatureMissing => 13,
Expand Down Expand Up @@ -218,6 +218,9 @@ impl MinaMeshError {
),
"transaction": tx,
}),
MinaMeshError::MalformedPublicKey(err) => json!({
"error": err,
}),
MinaMeshError::BlockMissing(index, hash) => {
let block_identifier = match (index, hash) {
(Some(idx), Some(hsh)) => format!("index={}, hash={}", idx, hsh),
Expand Down Expand Up @@ -264,7 +267,7 @@ impl MinaMeshError {
MinaMeshError::InvariantViolation => "An internal invariant was violated.".to_string(),
MinaMeshError::TransactionNotFound(_) => "The specified transaction could not be found.".to_string(),
MinaMeshError::BlockMissing(_, _) => "The specified block could not be found.".to_string(),
MinaMeshError::MalformedPublicKey => "The provided public key is malformed.".to_string(),
MinaMeshError::MalformedPublicKey(_) => "The provided public key is malformed.".to_string(),
MinaMeshError::OperationsNotValid(_) => {
"We could not convert those operations to a valid transaction.".to_string()
}
Expand Down Expand Up @@ -310,7 +313,7 @@ impl IntoResponse for MinaMeshError {
MinaMeshError::InvariantViolation => StatusCode::INTERNAL_SERVER_ERROR,
MinaMeshError::TransactionNotFound(_) => StatusCode::NOT_FOUND,
MinaMeshError::BlockMissing(_, _) => StatusCode::NOT_FOUND,
MinaMeshError::MalformedPublicKey => StatusCode::BAD_REQUEST,
MinaMeshError::MalformedPublicKey(_) => StatusCode::BAD_REQUEST,
MinaMeshError::OperationsNotValid(_) => StatusCode::BAD_REQUEST,
MinaMeshError::UnsupportedOperationForConstruction => StatusCode::BAD_REQUEST,
MinaMeshError::SignatureMissing => StatusCode::BAD_REQUEST,
Expand Down
6 changes: 6 additions & 0 deletions tests/compare_to_ocaml.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,9 @@ async fn block_not_found() -> Result<()> {
let (subpath, reqs) = fixtures::block_not_found();
assert_responses_contain(subpath, &reqs, "\"message\": \"Block not found").await
}

#[tokio::test]
async fn construction_derive() -> Result<()> {
let (subpath, reqs) = fixtures::construction_derive();
assert_responses_eq(subpath, &reqs).await
}
80 changes: 80 additions & 0 deletions tests/construction_derive.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
use anyhow::Result;
use insta::assert_debug_snapshot;
use mina_mesh::{
models::{ConstructionDeriveRequest, CurveType::Tweedle, PublicKey},
test::network_id,
MinaMeshConfig,
};
use serde_json::json;

#[tokio::test]
async fn construction_derive_success() -> Result<()> {
let mina_mesh = MinaMeshConfig::from_env().to_mina_mesh().await?;
let request = ConstructionDeriveRequest::new(
network_id(),
PublicKey::new(
// cspell:disable-next-line
"3C2B5B48C22DC8B8C9D2C9D76A2CEAAF02BEABB364301726C3F8E989653AF513".to_string(),
Tweedle,
),
);
let response = mina_mesh.construction_derive(request).await;
assert!(response.is_ok());
assert_debug_snapshot!(response);
Ok(())
}

#[tokio::test]
async fn construction_derive_fail() -> Result<()> {
let mina_mesh = MinaMeshConfig::from_env().to_mina_mesh().await?;
let request = ConstructionDeriveRequest::new(
network_id(),
PublicKey::new(
// cspell:disable-next-line
"12345".to_string(),
Tweedle,
),
);
let response = mina_mesh.construction_derive(request).await;
assert!(response.is_err());
assert_debug_snapshot!(response);
Ok(())
}

#[tokio::test]
async fn construction_derive_token_id() -> Result<()> {
let mina_mesh = MinaMeshConfig::from_env().to_mina_mesh().await?;
let request = ConstructionDeriveRequest {
network_identifier: Box::new(network_id()),
public_key: Box::new(PublicKey::new(
// cspell:disable-next-line
"fad1d3e31aede102793fb2cce62b4f1e71a214c94ce18ad5756eba67ef398390".to_string(),
Tweedle,
)),
// cspell:disable-next-line
metadata: Some(json!({ "token_id": "weihj2SSP7Z96acs56ygP64Te6wauzvWWfAPHKb1gzqem9J4Ne" })),
};
let response = mina_mesh.construction_derive(request).await;
assert!(response.is_ok());
assert_debug_snapshot!(response);
Ok(())
}

#[tokio::test]
async fn construction_derive_token_id_fail() -> Result<()> {
let mina_mesh = MinaMeshConfig::from_env().to_mina_mesh().await?;
let request = ConstructionDeriveRequest {
network_identifier: Box::new(network_id()),
public_key: Box::new(PublicKey::new(
// cspell:disable-next-line
"fad1d3e31aede102793fb2cce62b4f1e71a214c94ce18ad5756eba67ef398390".to_string(),
Tweedle,
)),
// cspell:disable-next-line
metadata: Some(json!({ "token_id": "fake" })),
};
let response = mina_mesh.construction_derive(request).await;
assert!(response.is_err());
assert_debug_snapshot!(response);
Ok(())
}
8 changes: 7 additions & 1 deletion tests/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,13 @@ async fn test_error_properties() {
true,
StatusCode::NOT_FOUND,
),
(MalformedPublicKey, 10, "The provided public key is malformed.", false, StatusCode::BAD_REQUEST),
(
MalformedPublicKey("error message".to_string()),
10,
"The provided public key is malformed.",
false,
StatusCode::BAD_REQUEST,
),
(
OperationsNotValid(vec![]),
11,
Expand Down
56 changes: 56 additions & 0 deletions tests/fixtures/construction_derive.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
use mina_mesh::{
models::{ConstructionDeriveRequest, CurveType::Tweedle, PublicKey},
test::network_id,
};
use serde_json::json;

use super::CompareGroup;

fn token_ids() -> Vec<String> {
vec![
"wSHV2S4qX9jFsLjQo8r1BsMLH2ZRKsZx6EJd1sbozGPieEC4Jf".to_string(),
"wfG3GivPMttpt6nQnPuX9eDPnoyA5RJZY23LTc4kkNkCRH2gUd".to_string(),
"xosVXFFDvDiKvHSDAaHvrTSRtoa5Graf2J7LM5Smb4GNTrT2Hn".to_string(),
"wXqDrUzWtK58CaWCzN2g3zseU275dhSnRtBthcroeqT6HGKkos".to_string(),
"xBxjFpJkbWpbGua7Lf36S1NLhffFoEChyP3pz6SYKnx7dFCTwg".to_string(),
"wnGm7B94xkhANu5vZJPjLojRvqWypPPJBTZd1x8rsrFX1iF1Cr".to_string(),
"wU9TAr8n2djpTPMEmqyzMFyf3DA1hVfgC1xuNgf8b8bGZz18Ri7".to_string(),
"xNUPtFCWXyv23Rj4jWmEbcX2Hfiu3JSeDynkFq3SmMheTZwSdR".to_string(),
"xLpobAxWSYZeyKuiEb4kzHHYQKn6X1vKmFR4Dmz9TCADLrYTD1".to_string(),
"xvoiUpngKPVLAqjqKb5qXQvQBmk9DncPEaGJXzehoRSNrDB45r".to_string(),
"weihj2SSP7Z96acs56ygP64Te6wauzvWWfAPHKb1gzqem9J4Ne".to_string(),
"y96qmT865fCMGGHdKAQ448uUwqs7dEfqnGBGVrv3tiRKTC2hxE".to_string(),
]
}

fn public_keys() -> Vec<String> {
vec![
"7e406ca640115a8c44ece6ef5d0c56af343b1a993d8c871648ab7980ecaf8230".to_string(),
"fad1d3e31aede102793fb2cce62b4f1e71a214c94ce18ad5756eba67ef398390".to_string(),
]
}

fn construction_derive_payloads() -> Vec<Box<dyn erased_serde::Serialize>> {
let token_ids = token_ids();
let public_keys = public_keys();

let mut payloads = Vec::new();

for token_id in token_ids.iter() {
for public_key in public_keys.iter() {
let payload = ConstructionDeriveRequest {
network_identifier: Box::new(network_id()),
public_key: Box::new(PublicKey::new(public_key.clone(), Tweedle)),
metadata: Some(json!({ "token_id": token_id })),
};
payloads.push(Box::new(payload) as Box<dyn erased_serde::Serialize>);
}
}

payloads
}

pub fn construction_derive<'a>() -> CompareGroup<'a> {
let payloads = construction_derive_payloads();
("/construction/derive", payloads)
}
2 changes: 2 additions & 0 deletions tests/fixtures/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ use erased_serde::Serialize as ErasedSerialize;

mod account_balance;
mod block;
mod construction_derive;
mod mempool;
mod network;
mod search_transactions;

pub use account_balance::*;
pub use block::*;
pub use construction_derive::*;
pub use mempool::*;
pub use network::*;
pub use search_transactions::*;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
source: tests/construction_derive.rs
expression: response
---
Err(
MalformedPublicKey(
"Invalid length for hex",
),
)
Loading
Loading