depolymerization

wire gateway for Bitcoin/Ethereum
Log | Files | Refs | Submodules | README | LICENSE

commit f86929453485b03617e253d5ab4c30fb811635df
parent e5031ab9f90c5b7fea7c23f6ec55dbd80619bdf5
Author: Antoine A <>
Date:   Wed, 24 Nov 2021 15:28:29 +0100

Pass simple bank test

Diffstat:
MCargo.lock | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascript/test_bank.sh | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mwire-gateway/Cargo.toml | 6++++++
Awire-gateway/src/api_common.rs | 317+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Awire-gateway/src/api_wire.rs | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mwire-gateway/src/error_codes.rs | 2++
Mwire-gateway/src/main.rs | 445++++++++++++++++++++++++++-----------------------------------------------------
7 files changed, 698 insertions(+), 300 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3,6 +3,12 @@ version = 3 [[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] name = "argh" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -32,6 +38,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38de00daab4eac7d753e97697066238d67ce9d7e2d823ab4f72fe14af29f3f33" [[package]] +name = "async-compression" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443ccbb270374a2b1055fc72da40e1f237809cd6bb0e97e66d264cd138473a6" +dependencies = [ + "flate2", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", +] + +[[package]] name = "atty" version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -194,6 +213,15 @@ dependencies = [ ] [[package]] +name = "crc32fast" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3825b1e8580894917dc4468cb634a1b4e9745fddc854edad72d9c04644c0319f" +dependencies = [ + "cfg-if", +] + +[[package]] name = "criterion" version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -409,12 +437,34 @@ dependencies = [ ] [[package]] +name = "flate2" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6988e897c1c9c485f43b47a529cef42fde0547f9d8d41a7062518f1d8fc53f" +dependencies = [ + "cfg-if", + "crc32fast", + "libc", + "miniz_oxide", +] + +[[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] +name = "form_urlencoded" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191" +dependencies = [ + "matches", + "percent-encoding", +] + +[[package]] name = "futures-channel" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -622,6 +672,12 @@ dependencies = [ ] [[package]] +name = "matches" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" + +[[package]] name = "memchr" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -637,6 +693,16 @@ dependencies = [ ] [[package]] +name = "miniz_oxide" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92518e98c078586bc6c934028adcca4c92a53d6a958196de835170a01d84e4b" +dependencies = [ + "adler", + "autocfg", +] + +[[package]] name = "mio" version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -752,6 +818,12 @@ dependencies = [ ] [[package]] +name = "percent-encoding" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" + +[[package]] name = "pin-project-lite" version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1057,6 +1129,18 @@ dependencies = [ ] [[package]] +name = "serde_urlencoded" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edfa57a7f8d9c1d260a549e7224100f6c43d43f9103e06dd8b4095a9b2b43ce9" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] name = "serde_with" version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1379,10 +1463,13 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" name = "wire-gateway" version = "0.1.0" dependencies = [ + "async-compression", "base32", "hyper", + "rand", "serde", "serde_json", + "serde_urlencoded", "serde_with", "thiserror", "tokio", diff --git a/script/test_bank.sh b/script/test_bank.sh @@ -0,0 +1,55 @@ +#!/bin/bash + +set -eu + +# Cleanup to run whenever we exit +function cleanup() +{ + for n in `jobs -p` + do + kill $n 2> /dev/null || true + done + wait +} + +# Install cleanup handler (except for kill -9) +trap cleanup EXIT + +echo "OK" + +BANK_ENDPOINT=http://localhost:8080/ + +echo -n "Making wire transfer to exchange ..." + +taler-exchange-wire-gateway-client \ + -b $BANK_ENDPOINT \ + -S 0ZSX8SH0M30KHX8K3Y1DAMVGDQV82XEF9DG1HC4QMQ3QWYT4AF00 \ + -D payto://x-taler-bank/localhost:8899/user \ + -a TESTKUDOS:4 > /dev/null +echo " OK" + +echo -n "Requesting exchange incoming transaction list ..." + +taler-exchange-wire-gateway-client -b $BANK_ENDPOINT -i | grep TESTKUDOS:4 > /dev/null + +echo " OK" + +echo -n "Making wire transfer from exchange..." + +taler-exchange-wire-gateway-client \ + -b $BANK_ENDPOINT \ + -S 0ZSX8SH0M30KHX8K3Y1DAMVGDQV82XEF9DG1HC4QMQ3QWYT4AF00 \ + -C payto://x-taler-bank/$BANK_ENDPOINT/merchant \ + -a TESTKUDOS:2 > /dev/null +echo " OK" + + +echo -n "Requesting exchange's outgoing transaction list..." + +taler-exchange-wire-gateway-client -b $BANK_ENDPOINT -o | grep TESTKUDOS:2 > /dev/null + +echo " OK" + +echo "All tests passed" + +exit 0 diff --git a/wire-gateway/Cargo.toml b/wire-gateway/Cargo.toml @@ -16,7 +16,13 @@ serde = { version = "1.0.130", features = ["derive"] } serde_with = "1.11.0" # JSON serialization serde_json = "1.0.71" +# Url query serialization +serde_urlencoded = "0.7.0" # Crockford’s base32 base32 = "0.4.0" # Error macros thiserror = "1.0.30" +# Async friendly compression +async-compression = { version = "0.3.8", features = ["tokio", "zlib"] } +# Rng +rand = { version = "0.8.4", features = ["getrandom"] } diff --git a/wire-gateway/src/api_common.rs b/wire-gateway/src/api_common.rs @@ -0,0 +1,317 @@ +use std::{ + fmt::Display, + num::ParseIntError, + str::FromStr, + time::{Duration, SystemTime}, +}; + +use serde::{de::Error, ser::SerializeStruct, Deserialize, Deserializer}; +use serde_json::Value; + +/// <https://docs.taler.net/core/api-common.html#tsref-type-ErrorDetail> +#[derive(Debug, Clone, serde::Serialize)] +struct ErrorDetail { + code: i64, + hint: Option<String>, + detail: Option<String>, + parameter: Option<String>, + path: Option<String>, + offset: Option<String>, + index: Option<String>, + object: Option<String>, + currency: Option<String>, + type_expected: Option<String>, + type_actual: Option<String>, +} + +/// <https://docs.taler.net/core/api-common.html#tsref-type-Timestamp> +#[derive(Debug, Clone, Copy)] +pub enum Timestamp { + Never, + Time(SystemTime), +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct TimestampImpl { + t_ms: Value, +} + +impl Timestamp { + pub fn now() -> Self { + Self::Time(SystemTime::now()) + } +} + +impl<'de> Deserialize<'de> for Timestamp { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + let tmp = TimestampImpl::deserialize(deserializer)?; + match tmp.t_ms { + Value::Number(ms) => { + if let Some(since_epoch_ms) = ms.as_u64() { + Ok(Self::Time( + SystemTime::UNIX_EPOCH + Duration::from_millis(since_epoch_ms), + )) + } else { + Err(todo!()) + } + } + Value::String(str) if str == "never" => Ok(Self::Never), + it => Err(todo!()), + } + } +} + +impl serde::Serialize for Timestamp { + fn serialize<S>(&self, se: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + let mut se_struct = se.serialize_struct("Timestamp", 1)?; + match self { + Timestamp::Never => se_struct.serialize_field("t_ms", "never")?, + Timestamp::Time(time) => se_struct.serialize_field( + "t_ms", + &(time + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs() as u128 + * 1000), + )?, + }; + + se_struct.end() + } +} + +/// <https://docs.taler.net/core/api-common.html#tsref-type-SafeUint64> +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)] +pub struct SafeUint64(u64); + +#[derive(Debug, thiserror::Error)] +#[error("{0} unsafe, {0} > (2^53 - 1)")] +pub struct UnsafeUint64(u64); + +impl TryFrom<u64> for SafeUint64 { + type Error = UnsafeUint64; + + fn try_from(nb: u64) -> Result<Self, Self::Error> { + if nb < (1 << 53) - 1 { + Ok(SafeUint64(nb)) + } else { + Err(UnsafeUint64(nb)) + } + } +} + +impl<'de> Deserialize<'de> for SafeUint64 { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + Ok(SafeUint64::try_from(u64::deserialize(deserializer)?).map_err(D::Error::custom)?) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ParseSafeUint64Error { + #[error(transparent)] + Unsafe(#[from] UnsafeUint64), + #[error(transparent)] + Format(#[from] ParseIntError), +} + +impl FromStr for SafeUint64 { + type Err = ParseSafeUint64Error; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + Ok(SafeUint64::try_from(s.parse::<u64>()?)?) + } +} + +/// <https://docs.taler.net/core/api-common.html#tsref-type-Amount> +#[derive( + Debug, Clone, PartialEq, Eq, serde_with::DeserializeFromStr, serde_with::SerializeDisplay, +)] +pub struct Amount { + currency: String, + value: u64, + fraction: u32, +} + +impl Amount { + pub fn new(currency: impl Into<String>, value: u64, fraction: u32) -> Self { + Self { + currency: currency.into(), + value, + fraction, + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ParseAmountError { + #[error("Invalid amount format")] + FormatAmount, + #[error("Amount overflow")] + AmountOverflow, + #[error(transparent)] + Format(#[from] ParseIntError), +} + +impl FromStr for Amount { + type Err = ParseAmountError; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + let (currency, amount) = s + .trim() + .split_once(':') + .ok_or(ParseAmountError::FormatAmount)?; + if currency.len() > 12 { + return Err(ParseAmountError::FormatAmount); + } + let (value, fraction) = amount.split_once('.').unwrap_or((amount, "")); + + let value: u64 = value.parse().map_err(|_| ParseAmountError::FormatAmount)?; + if value > 1 << 52 { + return Err(ParseAmountError::FormatAmount); + } + + if fraction.len() > 8 { + return Err(ParseAmountError::FormatAmount); + } + let fraction: u32 = if fraction.is_empty() { + 0 + } else { + fraction + .parse::<u32>() + .map_err(|_| ParseAmountError::FormatAmount)? + * 10_u32.pow((8 - fraction.len()) as u32) + }; + + Ok(Self { + currency: currency.to_string(), + value, + fraction, + }) + } +} + +impl Display for Amount { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!( + "{}:{}.{:08}", + self.currency, self.value, self.fraction + )) + } +} + +#[test] +fn test_amount() { + const TALER_AMOUNT_FRAC_BASE: u32 = 100000000; + // https://git.taler.net/exchange.git/tree/src/util/test_amount.c + + const INVALID_AMOUNTS: [&str; 6] = [ + "EUR:4a", // non-numeric, + "EUR:4.4a", // non-numeric + "EUR:4.a4", // non-numeric + ":4.a4", // no currency + "EUR:4.123456789", // precision to high + "EUR:1234567890123456789012345678901234567890123456789012345678901234567890", // value to big + ]; + + for str in INVALID_AMOUNTS { + let amount = Amount::from_str(str); + assert!(amount.is_err(), "invalid {} got {:?}", str, amount); + } + + let valid_amounts: Vec<(&str, Amount)> = vec![ + ("EUR:4", Amount::new("EUR", 4, 0)), // without fraction + ( + "eur:0.02", + Amount::new("eur", 0, TALER_AMOUNT_FRAC_BASE / 100 * 2), + ), // leading zero fraction + ( + " eur:4.12", + Amount::new("eur", 4, TALER_AMOUNT_FRAC_BASE / 100 * 12), + ), // leading space and fraction + ( + " *LOCAL:4444.1000", + Amount::new("*LOCAL", 4444, TALER_AMOUNT_FRAC_BASE / 10), + ), // local currency + ]; + for (str, goal) in valid_amounts { + let amount = Amount::from_str(str); + assert!(amount.is_ok(), "Valid {} got {:?}", str, amount); + assert_eq!( + *amount.as_ref().unwrap(), + goal, + "Expected {:?} got {:?} for {}", + goal, + amount, + str + ); + let amount = amount.unwrap(); + let str = amount.to_string(); + assert_eq!(amount, Amount::from_str(&str).unwrap(), "{:?}", str); + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ParseBase32Error { + #[error("Invalid Crockford’s base32 format")] + Format, + #[error("Invalid length: expected {0} got {1}")] + Length(usize, usize), +} + +#[derive(Debug, Clone)] +pub struct Base32<const L: usize>([u8; L]); + +impl<const L: usize> FromStr for Base32<L> { + type Err = ParseBase32Error; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + let bytes = + base32::decode(base32::Alphabet::Crockford, s).ok_or(ParseBase32Error::Format)?; + let exact: [u8; L] = bytes + .try_into() + .map_err(|vec: Vec<u8>| ParseBase32Error::Length(L, vec.len()))?; + Ok(Self(exact)) + } +} + +impl<const L: usize> Display for Base32<L> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&base32::encode(base32::Alphabet::Crockford, &self.0)) + } +} + +impl<const L: usize> serde::Serialize for Base32<L> { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de, const L: usize> Deserialize<'de> for Base32<L> { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + Ok(Base32::from_str(&String::deserialize(deserializer)?).map_err(D::Error::custom)?) + } +} + +/// EdDSA and ECDHE public keys always point on Curve25519 +/// and represented using the standard 256 bits Ed25519 compact format, +/// converted to Crockford Base32. +pub type EddsaPublicKey = Base32<32>; +/// 64-byte hash code +pub type HashCode = Base32<64>; +/// 32-bytes hash code +pub type ShortHashCode = Base32<32>; diff --git a/wire-gateway/src/api_wire.rs b/wire-gateway/src/api_wire.rs @@ -0,0 +1,86 @@ +use crate::api_common::{Amount, EddsaPublicKey, HashCode, SafeUint64, ShortHashCode, Timestamp}; + +/// <https://docs.taler.net/core/api-wire.html#tsref-type-TransferResponse> +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct TransferResponse { + pub timestamp: Timestamp, + pub row_id: SafeUint64, +} + +/// <https://docs.taler.net/core/api-wire.html#tsref-type-TransferRequest> +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct TransferRequest { + pub request_uid: HashCode, + pub amount: Amount, + pub exchange_base_url: String, + pub wtid: ShortHashCode, + pub credit_account: String, +} + +/// <https://docs.taler.net/core/api-wire.html#tsref-type-OutgoingHistory> +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct OutgoingHistory { + pub outgoing_transactions: Vec<OutgoingBankTransaction>, +} + +/// <https://docs.taler.net/core/api-wire.html#tsref-type-OutgoingBankTransaction> +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct OutgoingBankTransaction { + pub row_id: SafeUint64, + pub date: Timestamp, + pub amount: Amount, + pub credit_account: String, + pub debit_account: String, + pub wtid: ShortHashCode, + pub exchange_base_url: String, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct IncomingHistory { + pub incoming_transactions: Vec<IncomingBankTransaction>, +} +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(tag = "type")] +pub enum IncomingBankTransaction { + #[serde(rename = "RESERVE")] + IncomingReserveTransaction { + row_id: SafeUint64, + date: Timestamp, + amount: Amount, + credit_account: String, + debit_account: String, + reserve_pub: EddsaPublicKey, + }, + #[serde(rename = "WAD")] + IncomingWadTransaction { + row_id: SafeUint64, + date: Timestamp, + amount: Amount, + credit_account: String, + debit_account: String, + origin_exchange_url: String, + // TODO wad_id: WadId, + }, +} + +/// <https://docs.taler.net/core/api-wire.html#tsref-type-AddIncomingRequest> +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct AddIncomingRequest { + pub amount: Amount, + pub reserve_pub: EddsaPublicKey, + pub debit_account: String, +} + +/// <https://docs.taler.net/core/api-wire.html#tsref-type-AddIncomingResponse> +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct AddIncomingResponse { + pub row_id: SafeUint64, + pub timestamp: Timestamp, +} + +#[derive(Debug, Clone, serde::Deserialize)] +pub struct HistoryParams { + pub start: Option<u64>, + pub delta: i64, + pub long_pool_ms: Option<u64>, +} diff --git a/wire-gateway/src/error_codes.rs b/wire-gateway/src/error_codes.rs @@ -23,6 +23,8 @@ /// Error codes used by GNU Taler #[derive(Debug, Copy, Clone, PartialEq, Eq)] +#[allow(non_camel_case_types)] +#[allow(dead_code)] pub enum ErrorCode { /** Special code to indicate success (no error). diff --git a/wire-gateway/src/main.rs b/wire-gateway/src/main.rs @@ -1,12 +1,27 @@ -use common_types::{Amount, HashCode, SafeUint64, ShortHashCode, TimeStamp}; +use api_common::{Amount, SafeUint64, ShortHashCode, Timestamp}; +use api_wire::{OutgoingBankTransaction, OutgoingHistory}; +use async_compression::tokio::bufread::ZlibDecoder; use hyper::{ + header, + http::request::Parts, service::{make_service_fn, service_fn}, Body, Error, Method, Request, Response, Server, StatusCode, }; +use tokio::{io::AsyncReadExt, sync::Mutex}; + +use crate::api_wire::{ + AddIncomingRequest, AddIncomingResponse, HistoryParams, IncomingBankTransaction, + IncomingHistory, TransferRequest, TransferResponse, +}; + +mod error_codes; #[tokio::main] async fn main() { - let state = ServerState {}; + let state = ServerState { + incoming: Mutex::new(Vec::new()), + outgoing: Mutex::new(Vec::new()), + }; let state: &'static ServerState = Box::leak(Box::new(state)); let addr = ([0, 0, 0, 0], 8080).into(); @@ -26,323 +41,153 @@ async fn main() { } } -struct ServerState; - -/// https://docs.taler.net/core/api-common.html#tsref-type-ErrorDetail -#[derive(Debug, Clone, serde::Serialize)] -struct ErrorDetail { - code: i64, - hint: Option<String>, - detail: Option<String>, - parameter: Option<String>, - path: Option<String>, - offset: Option<String>, - index: Option<String>, - object: Option<String>, - currency: Option<String>, - type_expected: Option<String>, - type_actual: Option<String>, +struct IncomingTransaction { + row_id: SafeUint64, + date: Timestamp, + amount: Amount, + reserve_pub: ShortHashCode, + debit_account: String, } -pub mod common_types { - use std::{ - num::ParseIntError, - str::FromStr, - time::{Duration, SystemTime}, - }; - - /// https://docs.taler.net/core/api-common.html#tsref-type-Timestamp - #[derive(Debug, Clone, Copy)] - pub enum TimeStamp { - Never, - Time(SystemTime), - } - - #[derive(Debug, thiserror::Error)] - pub enum ParseTimeStampError { - #[error("Unknown timestamp {0}")] - Unknown(String), - #[error(transparent)] - Format(#[from] ParseIntError), - } - - impl FromStr for TimeStamp { - type Err = ParseTimeStampError; - - fn from_str(s: &str) -> Result<Self, Self::Err> { - if s.is_ascii() { - if s == "never" { - Ok(Self::Never) - } else { - Err(ParseTimeStampError::Unknown(s.to_string())) - } - } else { - let since_epoch_ms: u64 = s.parse()?; - Ok(Self::Time( - SystemTime::UNIX_EPOCH + Duration::from_millis(since_epoch_ms), - )) - } - } - } - - /// https://docs.taler.net/core/api-common.html#tsref-type-SafeUint64 - #[derive(Debug, Clone)] - pub struct SafeUint64(u64); - - #[derive(Debug, thiserror::Error)] - pub enum ParseSafeUint64Error { - #[error("{0} unsafe, {0} > (2^53 - 1)")] - Unsafe(u64), - #[error(transparent)] - Format(#[from] ParseIntError), - } - - impl FromStr for SafeUint64 { - type Err = ParseSafeUint64Error; - - fn from_str(s: &str) -> Result<Self, Self::Err> { - let nb: u64 = s.parse()?; - if nb < (2 << 52) { - Ok(SafeUint64(nb)) - } else { - Err(ParseSafeUint64Error::Unsafe(nb)) - } - } - } - - /// TODO https://docs.taler.net/core/api-common.html#tsref-type-Amount - #[derive(Debug, Clone, PartialEq, Eq)] - pub struct Amount { - currency: String, - value: u64, - fraction: u32, - } - - impl Amount { - pub fn new(currency: impl Into<String>, value: u64, fraction: u32) -> Self { - Self { - currency: currency.into(), - value, - fraction, - } - } - } - - #[derive(Debug, thiserror::Error)] - pub enum ParseAmountError { - #[error("Invalid amount format")] - FormatAmount, - #[error("Amount overflow")] - AmountOverflow, - #[error(transparent)] - Format(#[from] ParseIntError), - } - - impl FromStr for Amount { - type Err = ParseAmountError; - - fn from_str(s: &str) -> Result<Self, Self::Err> { - let (currency, amount) = s - .trim() - .split_once(':') - .ok_or(ParseAmountError::FormatAmount)?; - if currency.len() > 12 { - return Err(ParseAmountError::FormatAmount); - } - let (value, fraction) = amount.split_once('.').unwrap_or((amount, "")); - - let value: u64 = value.parse().map_err(|_| ParseAmountError::FormatAmount)?; - if value > 1 << 52 { - return Err(ParseAmountError::FormatAmount); - } - - if fraction.len() > 8 { - return Err(ParseAmountError::FormatAmount); - } - let fraction: u32 = if fraction.is_empty() { - 0 - } else { - fraction - .parse::<u32>() - .map_err(|_| ParseAmountError::FormatAmount)? - * 10_u32.pow((8 - fraction.len()) as u32) - }; - - Ok(Self { - currency: currency.to_string(), - value, - fraction, - }) - } - } - - #[test] - fn test_amount() { - const TALER_AMOUNT_FRAC_BASE: u32 = 100000000; - // https://git.taler.net/exchange.git/tree/src/util/test_amount.c - - const INVALID_AMOUNTS: [&str; 6] = [ - "EUR:4a", // non-numeric, - "EUR:4.4a", // non-numeric - "EUR:4.a4", // non-numeric - ":4.a4", // no currency - "EUR:4.123456789", // precision to high - "EUR:1234567890123456789012345678901234567890123456789012345678901234567890", // value to big - ]; - - for str in INVALID_AMOUNTS { - let amount = Amount::from_str(str); - assert!(amount.is_err(), "invalid {} got {:?}", str, amount); - } - - let valid_amounts: Vec<(&str, Amount)> = vec![ - ("EUR:4", Amount::new("EUR", 4, 0)), // without fraction - ( - "eur:0.02", - Amount::new("eur", 0, TALER_AMOUNT_FRAC_BASE / 100 * 2), - ), // leading zero fraction - ( - " eur:4.12", - Amount::new("eur", 4, TALER_AMOUNT_FRAC_BASE / 100 * 12), - ), // leading space and fraction - ( - " *LOCAL:4444.1000", - Amount::new("*LOCAL", 4444, TALER_AMOUNT_FRAC_BASE / 10), - ), // local currency - ]; - for (str, goal) in valid_amounts { - let amount = Amount::from_str(str); - assert!(amount.is_ok(), "Valid {} got {:?}", str, amount); - assert_eq!( - *amount.as_ref().unwrap(), - goal, - "Expected {:?} got {:?} for {}", - goal, - amount, - str - ); - } - } - - #[derive(Debug, thiserror::Error)] - pub enum ParseHashError { - #[error("Invalid Crockford’s base32 format")] - Base32, - #[error("Invalid length: expected {0} got {1}")] - Length(usize, usize), - } - - /// 64-byte hash code - #[derive(Debug, Clone)] - pub struct HashCode([u8; 64]); - - impl FromStr for HashCode { - type Err = ParseHashError; - - fn from_str(s: &str) -> Result<Self, Self::Err> { - let bytes = - base32::decode(base32::Alphabet::Crockford, s).ok_or(ParseHashError::Base32)?; - let exact: [u8; 64] = bytes - .try_into() - .map_err(|vec: Vec<u8>| ParseHashError::Length(64, vec.len()))?; - Ok(Self(exact)) - } - } +struct OutgoingTransaction { + row_id: SafeUint64, + date: Timestamp, + amount: Amount, + wtid: ShortHashCode, + credit_account: String, +} - /// 32-bytes hash code - #[derive(Debug, Clone)] - pub struct ShortHashCode([u8; 32]); +struct ServerState { + incoming: Mutex<Vec<IncomingTransaction>>, + outgoing: Mutex<Vec<OutgoingTransaction>>, +} - impl FromStr for ShortHashCode { - type Err = ParseHashError; +pub mod api_common; +pub mod api_wire; - fn from_str(s: &str) -> Result<Self, Self::Err> { - let bytes = - base32::decode(base32::Alphabet::Crockford, s).ok_or(ParseHashError::Base32)?; - let exact: [u8; 32] = bytes - .try_into() - .map_err(|vec: Vec<u8>| ParseHashError::Length(32, vec.len()))?; - Ok(Self(exact)) - } - } +struct ServerError { + status: StatusCode, + detail: error_codes::ErrorCode, } -/// https://docs.taler.net/core/api-wire.html#tsref-type-TransferResponse -#[derive(Debug, Clone, serde::Deserialize)] -struct TransferResponse { - #[serde(with = "serde_with::rust::display_fromstr")] - timestamp: TimeStamp, - #[serde(with = "serde_with::rust::display_fromstr")] - row_id: SafeUint64, +async fn parse_json<J: serde::de::DeserializeOwned>(parts: &Parts, body: Body) -> J { + let bytes = hyper::body::to_bytes(body).await.unwrap(); + let mut buf = Vec::new(); + let decompressed = if parts + .headers + .get(header::CONTENT_ENCODING) + .map(|it| it == "deflate") + .unwrap_or(false) + { + let mut decoder = ZlibDecoder::new(bytes.as_ref()); + decoder.read_to_end(&mut buf).await.unwrap(); + &buf + } else { + bytes.as_ref() + }; + + serde_json::from_slice(&decompressed).unwrap() } -/// https://docs.taler.net/core/api-wire.html#tsref-type-TransferRequest -#[derive(Debug, Clone, serde::Deserialize)] -struct TransferRequest { - #[serde(with = "serde_with::rust::display_fromstr")] - request_uid: HashCode, - #[serde(with = "serde_with::rust::display_fromstr")] - amount: Amount, - exchange_base_url: String, - #[serde(with = "serde_with::rust::display_fromstr")] - wtid: ShortHashCode, - credit_account: String, +async fn json_response<J: serde::Serialize>(status: StatusCode, json: &J) -> Response<Body> { + let json = serde_json::to_vec(json).unwrap(); + /* let mut encoder = ZlibEncoder::new(Vec::new()); + encoder.write_all(&json).await.unwrap(); + encoder.shutdown().await.unwrap(); + let encoded = encoder.into_inner();*/ + Response::builder() + .status(status) + .header(header::CONTENT_TYPE, "application/json") + //.header(header::CONTENT_ENCODING, "deflate") + .body(Body::from(json)) + .unwrap() } async fn router( req: Request<Body>, state: &'static ServerState, ) -> Result<Response<Body>, Response<Body>> { - dbg!(&req); - let response = match req.uri().path() { - "/transfer" if req.method() == Method::POST => Response::default(), - "/history/incoming" if req.method() == Method::GET => Response::default(), - "/history/outgoing" if req.method() == Method::GET => Response::default(), + let (parts, body) = req.into_parts(); + let response = match parts.uri.path() { + "/transfer" if parts.method == Method::POST => { + let request: TransferRequest = parse_json(&parts, body).await; + let mut guard = state.outgoing.lock().await; + let row_id = SafeUint64::try_from(guard.len() as u64 + 1).unwrap(); + let timestamp = Timestamp::now(); + guard.push(OutgoingTransaction { + row_id, + date: timestamp, + amount: request.amount, + wtid: request.wtid, + credit_account: request.credit_account, + }); + json_response(StatusCode::OK, &TransferResponse { timestamp, row_id }).await + } + "/history/incoming" if parts.method == Method::GET => { + let params: HistoryParams = + serde_urlencoded::from_str(parts.uri.query().unwrap_or("")).unwrap(); + let guard = state.incoming.lock().await; + let transactions: Vec<IncomingBankTransaction> = guard + .iter() + .map(|tx| IncomingBankTransaction::IncomingReserveTransaction { + row_id: tx.row_id, + date: tx.date, + amount: tx.amount.clone(), + credit_account: String::new(), + debit_account: tx.debit_account.clone(), + reserve_pub: tx.reserve_pub.clone(), + }) + .collect(); + json_response( + StatusCode::OK, + &IncomingHistory { + incoming_transactions: transactions, + }, + ) + .await + } + "/history/outgoing" if parts.method == Method::GET => { + let params: HistoryParams = + serde_urlencoded::from_str(parts.uri.query().unwrap_or("")).unwrap(); + let guard = state.outgoing.lock().await; + let transactions: Vec<OutgoingBankTransaction> = guard + .iter() + .map(|tx| OutgoingBankTransaction { + row_id: tx.row_id, + date: tx.date, + amount: tx.amount.clone(), + credit_account: tx.credit_account.clone(), + wtid: tx.wtid.clone(), + debit_account: String::new(), + exchange_base_url: String::new(), + }) + .collect(); + json_response( + StatusCode::OK, + &OutgoingHistory { + outgoing_transactions: transactions, + }, + ) + .await + } + "/admin/add-incoming" if parts.method == Method::POST => { + let request: AddIncomingRequest = parse_json(&parts, body).await; + let mut guard = state.incoming.lock().await; + let row_id = SafeUint64::try_from(guard.len() as u64 + 1).unwrap(); + let timestamp = Timestamp::now(); + guard.push(IncomingTransaction { + row_id, + date: timestamp, + amount: request.amount, + reserve_pub: request.reserve_pub, + debit_account: request.debit_account, + }); + json_response(StatusCode::OK, &AddIncomingResponse { timestamp, row_id }).await + } _ => Response::builder() .status(StatusCode::NOT_FOUND) .body(Body::empty()) .unwrap(), }; return Ok(response); - /* - let mut parts = req.uri().path().splitn(4, '/').skip(1); - let prefix = parts.next().unwrap(); - let first = parts.next().unwrap_or(""); - - if prefix != "api" { - return Err(StatusCode::NOT_FOUND.into()); - } - - match parts.next() { - None => match first { - "pros" => match *req.method() { - Method::GET => get_pros(Ctx::new(req, state)).await, - Method::PUT => put_pros(Ctx::new(req, state)).await, - _ => Err(StatusCode::NOT_FOUND.into()), - }, - "recommendations" => match *req.method() { - Method::POST => post_recommendation(Ctx::new(req, state)).await, - Method::GET => get_recommendation(Ctx::new(req, state)).await, - _ => Err(StatusCode::NOT_FOUND.into()), - }, - "login" if req.method() == Method::POST => admin::login(Ctx::new(req, state)).await, - "logout" if req.method() == Method::POST => { - admin::logout(&Ctx::new(req, state), false).await - } - "logout_all" if req.method() == Method::POST => { - admin::logout(&Ctx::new(req, state), true).await - } - "admin" if req.method() == Method::GET => admin::admin(&Ctx::new(req, state)).await, - "mail" if req.method() == Method::POST => mail::send_mail(Ctx::new(req, state)).await, - "mail_template" if req.method() == Method::POST => { - mail::send_mail_template(Ctx::new(req, state)).await - } - "rate" if req.method() == Method::GET => limit(&req, state).await, - _ => Err(StatusCode::NOT_FOUND.into()), - }, - Some(second) => match first { - "img" if req.method() == Method::GET => img(state, second).await, - _ => Err(StatusCode::NOT_FOUND.into()), - }, - }*/ }