Skip to content

Commit

Permalink
feat: start stateless token translation
Browse files Browse the repository at this point in the history
  • Loading branch information
Kodylow committed Apr 27, 2024
1 parent 1eeb284 commit 4bf43c0
Show file tree
Hide file tree
Showing 6 changed files with 164 additions and 22 deletions.
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions clientd-stateless/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,6 @@ clap = { version = "3", features = ["derive", "env"] }
multimint = { version = "0.3.2" }
# multimint = { path = "../multimint" }
axum-otel-metrics = "0.8.0"
base64 = "0.22.0"
hex = "0.4.3"
fedimint-tbs = "0.3.0"
1 change: 1 addition & 0 deletions clientd-stateless/examples/cashu_encoding.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

9 changes: 8 additions & 1 deletion clientd-stateless/src/router/handlers/info.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
use std::collections::BTreeMap;
use std::env;
use std::str::FromStr;

use axum::extract::State;
use axum::Json;
use fedimint_core::config::FederationId;
use serde::Serialize;

use crate::error::AppError;
Expand Down Expand Up @@ -42,7 +45,11 @@ pub struct CashuNUT06InfoResponse {
pub async fn handle_info(
State(state): State<AppState>,
) -> Result<Json<CashuNUT06InfoResponse>, AppError> {
let client = state.get_cashu_client().await?;
let federation_id = env::var("FEDIMINT_CLIENTD_ACTIVE_FEDERATION_ID")
.map_err(|e| anyhow::anyhow!("Failed to get active federation id: {}", e))?;
let client = state
.get_client(FederationId::from_str(&federation_id)?)
.await?;

let config = client.get_config();
let mut nuts = BTreeMap::new();
Expand Down
148 changes: 148 additions & 0 deletions clientd-stateless/src/router/handlers/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,20 @@
use std::collections::BTreeMap;
use std::fmt;
use std::str::FromStr;

use anyhow::anyhow;
use base64::Engine;
use bitcoin::secp256k1::{Secp256k1, SecretKey};
use bitcoin::KeyPair;
use fedimint_core::api::InviteCode;
use fedimint_core::config::FederationIdPrefix;
use fedimint_core::db::DatabaseValue;
use fedimint_core::module::registry::ModuleDecoderRegistry;
use fedimint_core::{Amount, TieredMulti};
use fedimint_mint_client::{OOBNotes, SpendableNote};
use serde::de::Error;
use serde::{Deserialize, Serialize};
use tbs::Signature;

pub mod check;
pub mod info;
Expand All @@ -8,6 +24,138 @@ pub mod melt;
pub mod mint;
pub mod swap;

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Proof {
// Amount unassociated with the unit
amount: u64,
// keyset id -> FederationId
id: String,
// secret -> hex encoded spend key's secret key
secret: String,
// signature -> hex encoded BLS signature
#[allow(non_snake_case)]
C: String,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Token {
mint: String,
proofs: Vec<Proof>,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct TokenV3 {
pub token: Vec<Token>,
pub unit: Option<String>,
pub memo: Option<String>,
}

impl TokenV3 {
/// Serializes the `Token` struct to a base64 URL-safe string without
/// padding and with the version prefix.
pub fn serialize(&self) -> Result<String, serde_json::Error> {
let json = serde_json::to_string(self)
.map_err(|e| serde_json::Error::custom(format!("Failed to serialize token: {}", e)))?;
let base64_token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json.as_bytes());
Ok(format!("cashuA{}", base64_token))
}

/// Deserializes a base64 URL-safe string without padding (with version
/// prefix) back to a `Token` struct.
pub fn deserialize(encoded: &str) -> Result<Self, serde_json::Error> {
if !encoded.starts_with("cashuA") {
return Err(serde_json::Error::custom("Invalid token format"));
}
let base64_token = &encoded[6..];
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(base64_token.as_bytes())
.map_err(|e| {
serde_json::Error::custom(format!("Failed to decode base64 token: {}", e))
})?;
let json = String::from_utf8(bytes).map_err(|e| {
serde_json::Error::custom(format!("Failed to decode base64 token: {}", e))
})?;
serde_json::from_str(&json)
}

pub fn from_oobnotes(notes: OOBNotes, invite_code: InviteCode) -> Result<Self, anyhow::Error> {
let mut token = TokenV3 {
token: vec![],
// Always msats
unit: Some("msat".to_string()),
// Federation Invite Code
memo: Some(invite_code.to_string()),
};
for (amount, note) in notes.notes().iter() {
let mut proofs = vec![];
for spendable_note in note.iter() {
let proof = Proof {
amount: amount.msats,
// stick the federation id prefix here instead of keyset
id: notes.federation_id_prefix().to_string(),
secret: hex::encode(spendable_note.spend_key.secret_key().to_bytes()),
C: hex::encode(spendable_note.signature.to_bytes()),
};
proofs.push(proof);
}
token.token.push(Token {
mint: notes.federation_id_prefix().to_string(),
proofs,
});
}
Ok(token)
}

fn to_oobnotes(&self, modules: &ModuleDecoderRegistry) -> Result<OOBNotes, anyhow::Error> {
let federation_id_prefix = match self.token.first().map(|t| &t.proofs[0].id) {
Some(id) => FederationIdPrefix::from_str(id)?,
None => return Err(anyhow!("No token found")),
};
let secp = Secp256k1::new();
let mut notes_map = BTreeMap::<Amount, Vec<SpendableNote>>::new();
for t in self.token.iter() {
for proof in t.proofs.iter() {
let signature_bytes = hex::decode(&proof.C)
.map_err(|e| anyhow!("Failed to decode spendable note signature: {}", e))?;
let signature = Signature::from_bytes(&signature_bytes, modules)?;
let secret_key_bytes = hex::decode(&proof.secret)
.map_err(|e| anyhow!("Failed to decode spendable note spend key: {}", e))?;
let sk = SecretKey::from_bytes(&secret_key_bytes, modules)
.map_err(|e| anyhow!("Failed to decode spendable note spend key: {}", e))?;
let spend_key = KeyPair::from_secret_key(&secp, &sk);
let spendable_note = SpendableNote {
signature,
spend_key,
};
let amount = Amount::from_msats(proof.amount);
notes_map.entry(amount).or_default().push(spendable_note);
}
}
let tiered_notes = TieredMulti::new(notes_map);
Ok(OOBNotes::new(federation_id_prefix, tiered_notes))
}
}

impl FromStr for TokenV3 {
type Err = serde_json::Error;

/// Parses a string to create a `Token` struct.
/// Assumes the string is a base64 URL-safe encoded JSON of the `Token` with
/// `cashuA` prefix.
fn from_str(s: &str) -> Result<Self, Self::Err> {
TokenV3::deserialize(s)
}
}

impl fmt::Display for TokenV3 {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
match self.serialize() {
Ok(serialized) => write!(f, "{}", serialized),
Err(_) => Err(fmt::Error),
}
}
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Unit {
Expand Down
22 changes: 1 addition & 21 deletions clientd-stateless/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,13 @@ use crate::error::AppError;
#[derive(Debug, Clone)]
pub struct AppState {
pub multimint: MultiMint,
pub cashu_mint: Option<FederationId>,
}

impl AppState {
pub async fn new(fm_db_path: PathBuf) -> Result<Self> {
let clients = MultiMint::new(fm_db_path).await?;
clients.update_gateway_caches(true).await?;
Ok(Self {
multimint: clients,
cashu_mint: None,
})
Ok(Self { multimint: clients })
}

// Helper function to get a specific client from the state or default
Expand All @@ -37,22 +33,6 @@ impl AppState {
}
}

pub async fn get_cashu_client(&self) -> Result<ClientHandleArc, AppError> {
match self.cashu_mint {
Some(client) => match self.multimint.get(&client).await {
Some(client) => Ok(client),
None => Err(AppError::new(
StatusCode::BAD_REQUEST,
anyhow!("No cashu client found for federation id"),
)),
},
None => Err(AppError::new(
StatusCode::BAD_REQUEST,
anyhow!("No cashu client set"),
)),
}
}

pub async fn get_client_by_prefix(
&self,
federation_id_prefix: &FederationIdPrefix,
Expand Down

0 comments on commit 4bf43c0

Please sign in to comment.