-
Notifications
You must be signed in to change notification settings - Fork 0
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
A simple mockup of L2 logic without proofs or signatures. #6
Changes from 3 commits
275b8eb
33f63eb
b78dc74
005d393
3a878e9
7acd1b9
be8e4ae
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,6 +3,7 @@ resolver = "2" | |
|
||
members = [ | ||
"kairos-cli", | ||
"kairos-server", | ||
] | ||
|
||
[workspace.package] | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
[package] | ||
name = "kairos-server" | ||
version.workspace = true | ||
edition.workspace = true | ||
|
||
[lib] | ||
|
||
[[bin]] | ||
name = "kairos-server" | ||
|
||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html | ||
|
||
[dependencies] | ||
axum = { version = "0.7", features = ["tracing"]} | ||
thiserror = "1.0" | ||
anyhow = "1.0" | ||
serde = { version = "1.0", features = ["derive"] } | ||
serde_json = "1.0" | ||
tokio = { version = "1.35", features = ["full", "tracing", "macros"] } | ||
tracing = "0.1" | ||
tracing-subscriber = "0.3" | ||
|
||
[dev-dependencies] | ||
proptest = "1.4" | ||
axum-test-helper = "0.3" |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
use std::{ | ||
fmt, | ||
ops::{Deref, DerefMut}, | ||
}; | ||
|
||
use axum::{ | ||
http::StatusCode, | ||
response::{IntoResponse, Response}, | ||
}; | ||
|
||
#[derive(Debug)] | ||
pub struct AppErr { | ||
error: anyhow::Error, | ||
status: Option<StatusCode>, | ||
} | ||
|
||
impl AppErr { | ||
pub fn set_status(err: impl Into<Self>, status: StatusCode) -> Self { | ||
let mut err = err.into(); | ||
err.status = Some(status); | ||
err | ||
} | ||
} | ||
|
||
impl IntoResponse for AppErr { | ||
fn into_response(self) -> Response { | ||
( | ||
self.status.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), | ||
format!("{}", self.error), | ||
) | ||
.into_response() | ||
} | ||
} | ||
|
||
impl Deref for AppErr { | ||
type Target = anyhow::Error; | ||
fn deref(&self) -> &Self::Target { | ||
&self.error | ||
} | ||
} | ||
|
||
impl DerefMut for AppErr { | ||
fn deref_mut(&mut self) -> &mut Self::Target { | ||
&mut self.error | ||
} | ||
} | ||
|
||
impl fmt::Display for AppErr { | ||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | ||
write!( | ||
f, | ||
"{}: {}", | ||
self.status.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), | ||
self.error | ||
) | ||
} | ||
} | ||
|
||
impl std::error::Error for AppErr {} | ||
|
||
impl From<anyhow::Error> for AppErr { | ||
fn from(error: anyhow::Error) -> Self { | ||
Self { | ||
error, | ||
status: None, | ||
} | ||
} | ||
} |
Avi-D-coder marked this conversation as resolved.
Show resolved
Hide resolved
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
pub mod errors; | ||
pub mod routes; | ||
pub mod state; | ||
|
||
use axum::{routing::post, Router}; | ||
use routes::*; | ||
use state::LockedBatchState; | ||
|
||
pub use errors::AppErr; | ||
|
||
type PublicKey = String; | ||
|
||
pub fn app_router(state: LockedBatchState) -> Router { | ||
Router::new() | ||
.route("/api/v1/mock/deposit", post(deposit)) | ||
.route("/api/v1/mock/withdraw", post(withdraw)) | ||
.route("/api/v1/transfer", post(transfer)) | ||
.with_state(state) | ||
} |
Avi-D-coder marked this conversation as resolved.
Show resolved
Hide resolved
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
use std::net::SocketAddr; | ||
|
||
use kairos_server::state::BatchState; | ||
|
||
#[tokio::main] | ||
async fn main() { | ||
tracing_subscriber::fmt::init(); | ||
let axum_port: u16 = std::env::var("SERVER_PORT").map_or(8000, |x| { | ||
Avi-D-coder marked this conversation as resolved.
Show resolved
Hide resolved
|
||
x.parse().unwrap_or_else(|e| { | ||
format!("Failed to parse SERVER_PORT: {}", e) | ||
.parse() | ||
.unwrap() | ||
}) | ||
}); | ||
|
||
let app = kairos_server::app_router(BatchState::new()); | ||
|
||
let axum_addr = SocketAddr::from(([127, 0, 0, 1], axum_port)); | ||
tracing::info!("starting http server"); | ||
let listener = tokio::net::TcpListener::bind(axum_addr).await.unwrap(); | ||
axum::serve(listener, app).await.unwrap(); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
use std::ops::Deref; | ||
|
||
use anyhow::anyhow; | ||
use axum::{extract::State, http::StatusCode, Json}; | ||
use serde::{Deserialize, Serialize}; | ||
|
||
use crate::{state::LockedBatchState, AppErr, PublicKey}; | ||
|
||
#[derive(Serialize, Deserialize)] | ||
pub struct Deposit { | ||
pub public_key: PublicKey, | ||
pub amount: u64, | ||
} | ||
|
||
pub async fn deposit( | ||
state: State<LockedBatchState>, | ||
Json(Deposit { public_key, amount }): Json<Deposit>, | ||
) -> Result<(), AppErr> { | ||
tracing::info!("TODO: verifying deposit"); | ||
|
||
tracing::info!("TODO: adding deposit to batch"); | ||
|
||
let mut state = state.deref().write().await; | ||
let account = state.balances.entry(public_key.clone()); | ||
|
||
let prior_balance = account.or_insert(0); | ||
let updated_balance = prior_balance.checked_add(amount).ok_or_else(|| { | ||
AppErr::set_status( | ||
anyhow!("deposit would overflow account"), | ||
StatusCode::CONFLICT, | ||
) | ||
})?; | ||
|
||
tracing::info!( | ||
"Updated account public_key={} balance={}", | ||
public_key, | ||
updated_balance | ||
); | ||
|
||
Ok(()) | ||
} |
Avi-D-coder marked this conversation as resolved.
Show resolved
Hide resolved
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
pub mod deposit; | ||
pub mod transfer; | ||
pub mod withdraw; | ||
|
||
pub use deposit::deposit; | ||
pub use transfer::transfer; | ||
pub use withdraw::withdraw; |
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. General comment in respect of the handlers. Typically they should be a thin layer, the business logic should be handled by an inner layer that can be tested outside of the network context. But it's early days. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Another general comment in respect of the handlers. The input/output request/response types could be wrapped in generic ServerRequest & ServerResponse types. These types would provide a contextual envelope. For an example of what I mean, check out the core external types exposed in my paradox implementation. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I'd suggest using a middle layer there. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,108 @@ | ||
use anyhow::anyhow; | ||
use axum::{extract::State, http::StatusCode, Json}; | ||
use serde::{Deserialize, Serialize}; | ||
|
||
use crate::{state::LockedBatchState, AppErr, PublicKey}; | ||
|
||
#[derive(Serialize, Deserialize)] | ||
pub struct Transfer { | ||
pub from: PublicKey, | ||
pub to: PublicKey, | ||
pub amount: u64, | ||
} | ||
|
||
#[derive(Serialize, Deserialize)] | ||
pub struct TransferRequest { | ||
transfer: Transfer, | ||
signature: String, | ||
} | ||
|
||
pub async fn transfer( | ||
State(state): State<LockedBatchState>, | ||
Json(TransferRequest { | ||
transfer, | ||
signature: _, | ||
}): Json<TransferRequest>, | ||
) -> Result<(), AppErr> { | ||
if transfer.amount == 0 { | ||
return Err(AppErr::set_status( | ||
anyhow!("transfer amount must be greater than 0"), | ||
StatusCode::BAD_REQUEST, | ||
)); | ||
} | ||
|
||
tracing::info!("TODO: verifying transfer signature"); | ||
|
||
// We pre-check this read-only to error early without acquiring the write lock. | ||
// This prevents a DoS attack exploiting the write lock. | ||
tracing::info!("verifying transfer sender has sufficient funds"); | ||
check_sender_funds(&state, &transfer).await?; | ||
|
||
let mut state = state.write().await; | ||
let from_balance = state.balances.get_mut(&transfer.from).ok_or_else(|| { | ||
AppErr::set_status( | ||
anyhow!( | ||
"Sender no longer has an account. | ||
The sender just removed all their funds." | ||
), | ||
StatusCode::CONFLICT, | ||
) | ||
})?; | ||
|
||
*from_balance = from_balance.checked_sub(transfer.amount).ok_or_else(|| { | ||
AppErr::set_status( | ||
anyhow!( | ||
"Sender no longer has sufficient funds, balance={}, transfer_amount={}. | ||
The sender just moved their funds in a concurrent request", | ||
from_balance, | ||
transfer.amount | ||
), | ||
StatusCode::CONFLICT, | ||
) | ||
})?; | ||
|
||
let to_balance = state | ||
.balances | ||
.entry(transfer.to.clone()) | ||
.or_insert_with(|| { | ||
tracing::info!("creating new account for receiver"); | ||
0 | ||
}); | ||
|
||
*to_balance = to_balance.checked_add(transfer.amount).ok_or_else(|| { | ||
AppErr::set_status(anyhow!("Receiver balance overflow"), StatusCode::CONFLICT) | ||
})?; | ||
|
||
Ok(()) | ||
} | ||
|
||
async fn check_sender_funds(state: &LockedBatchState, transfer: &Transfer) -> Result<(), AppErr> { | ||
let state = state.read().await; | ||
let from_balance = state.balances.get(&transfer.from).ok_or_else(|| { | ||
AppErr::set_status( | ||
anyhow!("Sender does not have an account"), | ||
StatusCode::BAD_REQUEST, | ||
) | ||
})?; | ||
|
||
from_balance.checked_sub(transfer.amount).ok_or_else(|| { | ||
AppErr::set_status( | ||
anyhow!( | ||
"Sender does not have sufficient funds, balance={}, transfer_amount={}", | ||
from_balance, | ||
transfer.amount | ||
), | ||
StatusCode::FORBIDDEN, | ||
) | ||
})?; | ||
|
||
let to_balance = state.balances.get(&transfer.to).unwrap_or(&0); | ||
if to_balance.checked_add(transfer.amount).is_none() { | ||
return Err(AppErr::set_status( | ||
anyhow!("Receiver balance overflow"), | ||
StatusCode::CONFLICT, | ||
)); | ||
} | ||
|
||
Ok(()) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,87 @@ | ||
use anyhow::anyhow; | ||
use axum::{extract::State, http::StatusCode, Json}; | ||
use serde::{Deserialize, Serialize}; | ||
|
||
use crate::{state::LockedBatchState, AppErr, PublicKey}; | ||
|
||
#[derive(Serialize, Deserialize)] | ||
pub struct Withdrawal { | ||
pub public_key: PublicKey, | ||
pub signature: String, | ||
pub amount: u64, | ||
} | ||
|
||
pub async fn withdraw( | ||
State(state): State<LockedBatchState>, | ||
Json(withdrawal): Json<Withdrawal>, | ||
) -> Result<(), AppErr> { | ||
tracing::info!("TODO: verifying withdrawal signature"); | ||
|
||
tracing::info!("verifying withdrawal sender has sufficient funds"); | ||
check_sender_funds(&state, &withdrawal).await?; | ||
|
||
tracing::info!("TODO: adding withdrawal to batch"); | ||
|
||
let mut state = state.write().await; | ||
let from_balance = state | ||
.balances | ||
.get_mut(&withdrawal.public_key) | ||
.ok_or_else(|| { | ||
AppErr::set_status( | ||
anyhow!( | ||
"Sender no longer has an account. | ||
The sender just removed all their funds." | ||
), | ||
StatusCode::CONFLICT, | ||
) | ||
})?; | ||
|
||
let updated_balance = from_balance.checked_sub(withdrawal.amount).ok_or_else(|| { | ||
AppErr::set_status( | ||
anyhow!( | ||
"Sender no longer has sufficient funds, balance={}, withdrawal_amount={}. | ||
The sender just moved their funds in a concurrent request", | ||
from_balance, | ||
withdrawal.amount | ||
), | ||
StatusCode::CONFLICT, | ||
) | ||
})?; | ||
|
||
*from_balance = updated_balance; | ||
|
||
if updated_balance == 0 { | ||
state.balances.remove(&withdrawal.public_key); | ||
} | ||
|
||
tracing::info!( | ||
"Updated account public_key={} balance={}", | ||
withdrawal.public_key, | ||
updated_balance | ||
); | ||
|
||
Ok(()) | ||
} | ||
|
||
async fn check_sender_funds( | ||
state: &LockedBatchState, | ||
withdrawal: &Withdrawal, | ||
) -> Result<(), AppErr> { | ||
let state = state.read().await; | ||
let from_balance = state.balances.get(&withdrawal.public_key).ok_or_else(|| { | ||
AppErr::set_status(anyhow!("Withdrawer has no account."), StatusCode::CONFLICT) | ||
})?; | ||
|
||
if *from_balance < withdrawal.amount { | ||
return Err(AppErr::set_status( | ||
anyhow!( | ||
"Withdrawer has insufficient funds, balance={}, withdrawal_amount={}.", | ||
from_balance, | ||
withdrawal.amount | ||
), | ||
StatusCode::FORBIDDEN, | ||
)); | ||
} | ||
|
||
Ok(()) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The types that flow in/out of server to/from an sdk could be factored out to a dedicated crate ... I began this process in my paradox implementation.
https://github.com/cspr-rad/paradox-4/tree/main/node/types/src/external