taler-rust

GNU Taler code in Rust. Largely core banking integrations.
Log | Files | Refs | Submodules | README | LICENSE

commit f254d533d053b7f85c3caf1bc67346f2ccd105f2
parent cc7dc52133342763c69867fc3c47166f4ffe01b7
Author: Antoine A <>
Date:   Thu, 21 May 2026 17:13:56 +0200

common: improve apis, test routines and error handling

Diffstat:
Mcommon/taler-api/src/api.rs | 45++++++++++++++++++++++++++-------------------
Acommon/taler-api/src/api/prepared.rs | 142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/taler-api/src/api/revenue.rs | 8++++----
Dcommon/taler-api/src/api/transfer.rs | 112-------------------------------------------------------------------------------
Mcommon/taler-api/src/api/wire.rs | 78+++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mcommon/taler-api/src/constants.rs | 8+++++---
Mcommon/taler-api/src/crypto.rs | 2+-
Mcommon/taler-api/src/db.rs | 6+-----
Mcommon/taler-api/src/error.rs | 17+++++++++++++----
Mcommon/taler-api/src/extract.rs | 31+++++++++++++++++++++++++++++--
Mcommon/taler-api/src/subject.rs | 26+++++++++++++++-----------
Mcommon/taler-api/src/test.rs | 25+++++++++++++------------
Mcommon/taler-api/src/test/api.rs | 61++++++++++++++++++++++++-------------------------------------
Mcommon/taler-api/src/test/db.rs | 32+++++++++++++++++---------------
Acommon/taler-common/src/api.rs | 248+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/taler-common/src/api/params.rs | 171+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/taler-common/src/api/prepared.rs | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/taler-common/src/api/revenue.rs | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/taler-common/src/api/wire.rs | 210+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dcommon/taler-common/src/api_common.rs | 240-------------------------------------------------------------------------------
Dcommon/taler-common/src/api_params.rs | 171-------------------------------------------------------------------------------
Dcommon/taler-common/src/api_revenue.rs | 49-------------------------------------------------
Dcommon/taler-common/src/api_transfer.rs | 113-------------------------------------------------------------------------------
Dcommon/taler-common/src/api_wire.rs | 207-------------------------------------------------------------------------------
Mcommon/taler-common/src/lib.rs | 6+-----
Mcommon/taler-macros/Cargo.toml | 2++
Mcommon/taler-macros/src/lib.rs | 108++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcommon/taler-test-utils/src/lib.rs | 1+
Mcommon/taler-test-utils/src/routine.rs | 447++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mcommon/taler-test-utils/src/server.rs | 33+++++++++++++++++++++++++++++++--
Mtaler-cyclos/src/api.rs | 197++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mtaler-cyclos/src/bin/cyclos-harness.rs | 8+++++---
Mtaler-cyclos/src/db.rs | 34+++++++++++++++++++---------------
Mtaler-magnet-bank/src/api.rs | 197+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Mtaler-magnet-bank/src/bin/magnet-bank-harness.rs | 8+++++---
Mtaler-magnet-bank/src/db.rs | 46+++++++++++++++++++++++++---------------------
36 files changed, 1860 insertions(+), 1395 deletions(-)

diff --git a/common/taler-api/src/api.rs b/common/taler-api/src/api.rs @@ -28,41 +28,48 @@ use axum::{ response::Response, }; use revenue::Revenue; -use taler_common::{error_code::ErrorCode, types::amount::Amount}; +use taler_common::{ + error_code::ErrorCode, + types::amount::{Amount, Currency}, +}; use tokio::signal; use tracing::{Level, info}; use wire::WireGateway; use crate::{ Listener, Serve, - api::transfer::PreparedTransfer, + api::prepared::PreparedTransfer, auth::{AuthMethod, AuthMiddlewareState}, error::{ApiResult, LoggedError, failure, failure_code}, }; +pub mod prepared; pub mod revenue; -pub mod transfer; pub mod wire; pub use axum::Router; +pub trait Validation { + fn check(&self, currency: &Currency) -> ApiResult<()>; +} + +fn check_currency(currency: &Currency, amount: &Amount) -> ApiResult<()> { + if &amount.currency != currency { + Err(failure( + ErrorCode::GENERIC_CURRENCY_MISMATCH, + format!( + "wrong currency expected {} got {}", + currency, amount.currency + ), + )) + } else { + Ok(()) + } +} + pub trait TalerApi: Send + Sync + 'static { - fn currency(&self) -> &str; + fn currency(&self) -> Currency; fn implementation(&self) -> &'static str; - fn check_currency(&self, amount: &Amount) -> ApiResult<()> { - let currency = self.currency(); - if amount.currency.as_ref() != currency { - Err(failure( - ErrorCode::GENERIC_CURRENCY_MISMATCH, - format!( - "wrong currency expected {} got {}", - currency, amount.currency - ), - )) - } else { - Ok(()) - } - } } pub trait RouterUtils { @@ -96,7 +103,7 @@ impl TalerRouter for Router { } fn prepared_transfer<T: PreparedTransfer>(self, api: Arc<T>) -> Self { - self.nest("/taler-prepared-transfer", transfer::router(api)) + self.nest("/taler-prepared-transfer", prepared::router(api)) } fn revenue<T: Revenue>(self, api: Arc<T>, auth: AuthMethod) -> Self { diff --git a/common/taler-api/src/api/prepared.rs b/common/taler-api/src/api/prepared.rs @@ -0,0 +1,142 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use std::{str::FromStr, sync::Arc}; + +use axum::{ + Json, Router, + extract::State, + http::StatusCode, + response::IntoResponse as _, + routing::{get, post}, +}; +use jiff::{SignedDuration, Timestamp}; +use taler_common::{ + api::prepared::{ + PreparedTransferConfig, RegistrationRequest, RegistrationResponse, SubjectFormat, + TransferSubject, Unregistration, + }, + db::IncomingType, + error_code::ErrorCode, + types::amount::Currency, +}; + +use super::TalerApi; +use crate::{ + api::{Validation, check_currency}, + constants::PREPARED_TRANSFER_API_VERSION, + crypto::check_eddsa_signature, + error::{ApiResult, failure, failure_code}, + extract::Req, + subject::fmt_in_subject, +}; + +pub trait PreparedTransfer: TalerApi { + fn supported_formats(&self) -> &[SubjectFormat]; + fn registration( + &self, + req: RegistrationRequest, + ) -> impl std::future::Future<Output = ApiResult<RegistrationResponse>> + Send; + fn unregistration( + &self, + req: Unregistration, + ) -> impl std::future::Future<Output = ApiResult<bool>> + Send; +} + +impl Validation for RegistrationRequest { + fn check(&self, currency: &Currency) -> ApiResult<()> { + if !check_eddsa_signature( + &self.authorization_pub, + self.account_pub.as_ref(), + &self.authorization_sig, + ) { + return Err(failure_code(ErrorCode::BANK_BAD_SIGNATURE)); + } + check_currency(currency, &self.credit_amount) + } +} + +impl Validation for Unregistration { + fn check(&self, _: &Currency) -> ApiResult<()> { + let timestamp = Timestamp::from_str(&self.timestamp).map_err(|e| { + failure(ErrorCode::GENERIC_JSON_INVALID, e.to_string()).with_path("timestamp") + })?; + if timestamp.duration_until(Timestamp::now()) > SignedDuration::from_mins(5) { + return Err(failure_code(ErrorCode::BANK_OLD_TIMESTAMP)); + } + + if !check_eddsa_signature( + &self.authorization_pub, + self.timestamp.as_ref(), + &self.authorization_sig, + ) { + return Err(failure_code(ErrorCode::BANK_BAD_SIGNATURE)); + } + Ok(()) + } +} + +pub fn simple_subject(req: RegistrationRequest) -> TransferSubject { + TransferSubject::Simple { + credit_amount: req.credit_amount, + subject: if req.authorization_pub == req.account_pub && !req.recurrent { + fmt_in_subject(req.r#type.into(), &req.account_pub).to_string() + } else { + fmt_in_subject(IncomingType::map, &req.authorization_pub).to_string() + }, + } +} + +pub fn router<I: PreparedTransfer>(state: Arc<I>) -> Router { + Router::new() + .route( + "/registration", + post( + async |State(state): State<Arc<I>>, Req(req): Req<RegistrationRequest>| { + req.check(&state.currency())?; + let res = state.registration(req).await?; + ApiResult::Ok(Json(res)) + }, + ), + ) + .route( + "/unregistration", + post( + async |State(state): State<Arc<I>>, Req(req): Req<Unregistration>| { + req.check(&state.currency())?; + if state.unregistration(req).await? { + ApiResult::Ok(StatusCode::NO_CONTENT) + } else { + Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND)) + } + }, + ), + ) + .route( + "/config", + get(async |State(state): State<Arc<I>>| { + Json(PreparedTransferConfig { + name: (), + version: PREPARED_TRANSFER_API_VERSION, + currency: state.currency(), + implementation: Some(state.implementation()), + supported_formats: state.supported_formats().to_vec(), + }) + .into_response() + }), + ) + .with_state(state) +} diff --git a/common/taler-api/src/api/revenue.rs b/common/taler-api/src/api/revenue.rs @@ -17,9 +17,9 @@ use std::sync::Arc; use axum::{Json, Router, extract::State, http::StatusCode, response::IntoResponse, routing::get}; -use taler_common::{ - api_params::{History, HistoryParams}, - api_revenue::{RevenueConfig, RevenueIncomingHistory}, +use taler_common::api::{ + params::{History, HistoryParams}, + revenue::{RevenueConfig, RevenueIncomingHistory}, }; use super::TalerApi; @@ -56,7 +56,7 @@ pub fn router<I: Revenue>(state: Arc<I>, auth: AuthMethod) -> Router { "/config", get(async |State(state): State<Arc<I>>| { Json(RevenueConfig { - name: "taler-revenue", + name: (), version: REVENUE_API_VERSION, currency: state.currency(), implementation: Some(state.implementation()), diff --git a/common/taler-api/src/api/transfer.rs b/common/taler-api/src/api/transfer.rs @@ -1,112 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2026 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ - -use std::{str::FromStr, sync::Arc}; - -use axum::{ - Json, Router, - extract::State, - http::StatusCode, - response::IntoResponse as _, - routing::{get, post}, -}; -use jiff::{SignedDuration, Timestamp}; -use taler_common::{ - api_transfer::{ - PreparedTransferConfig, RegistrationRequest, RegistrationResponse, SubjectFormat, - Unregistration, - }, - error_code::ErrorCode, -}; - -use super::TalerApi; -use crate::{ - constants::PREPARED_TRANSFER_API_VERSION, - crypto::check_eddsa_signature, - error::{ApiResult, failure, failure_code}, - extract::Req, -}; - -pub trait PreparedTransfer: TalerApi { - fn supported_formats(&self) -> &[SubjectFormat]; - fn registration( - &self, - req: RegistrationRequest, - ) -> impl std::future::Future<Output = ApiResult<RegistrationResponse>> + Send; - fn unregistration( - &self, - req: Unregistration, - ) -> impl std::future::Future<Output = ApiResult<()>> + Send; -} - -pub fn router<I: PreparedTransfer>(state: Arc<I>) -> Router { - Router::new() - .route( - "/registration", - post( - async |State(state): State<Arc<I>>, Req(req): Req<RegistrationRequest>| { - state.check_currency(&req.credit_amount)?; - if !check_eddsa_signature( - &req.authorization_pub, - req.account_pub.as_ref(), - &req.authorization_sig, - ) { - return Err(failure_code(ErrorCode::BANK_BAD_SIGNATURE)); - } - let res = state.registration(req).await?; - ApiResult::Ok(Json(res)) - }, - ), - ) - .route( - "/unregistration", - post( - async |State(state): State<Arc<I>>, Req(req): Req<Unregistration>| { - let timestamp = Timestamp::from_str(&req.timestamp).map_err(|e| { - failure(ErrorCode::GENERIC_JSON_INVALID, e.to_string()) - .with_path("timestamp") - })?; - if timestamp.duration_until(Timestamp::now()) > SignedDuration::from_mins(5) { - return Err(failure_code(ErrorCode::BANK_OLD_TIMESTAMP)); - } - - if !check_eddsa_signature( - &req.authorization_pub, - req.timestamp.as_ref(), - &req.authorization_sig, - ) { - return Err(failure_code(ErrorCode::BANK_BAD_SIGNATURE)); - } - state.unregistration(req).await?; - ApiResult::Ok(StatusCode::NO_CONTENT) - }, - ), - ) - .route( - "/config", - get(async |State(state): State<Arc<I>>| { - Json(PreparedTransferConfig { - name: "taler-prepared-transfer", - version: PREPARED_TRANSFER_API_VERSION, - currency: state.currency(), - implementation: Some(state.implementation()), - supported_formats: state.supported_formats().to_vec(), - }) - .into_response() - }), - ) - .with_state(state) -} diff --git a/common/taler-api/src/api/wire.rs b/common/taler-api/src/api/wire.rs @@ -25,21 +25,24 @@ use axum::{ }; use regex::Regex; use taler_common::{ - api_params::{AccountParams, History, HistoryParams, Page, TransferParams}, - api_wire::{ - AccountInfo, AddIncomingRequest, AddIncomingResponse, AddKycauthRequest, AddMappedRequest, - IncomingHistory, OutgoingHistory, TransferList, TransferRequest, TransferResponse, - TransferState, TransferStatus, WireConfig, + api::{ + params::{AccountParams, History, HistoryParams, Page, TransferParams}, + wire::{ + AccountInfo, AddIncomingRequest, AddIncomingResponse, AddKycauthRequest, + AddMappedRequest, IncomingHistory, OutgoingHistory, TransferList, TransferRequest, + TransferResponse, TransferState, TransferStatus, WireConfig, + }, }, error_code::ErrorCode, + types::amount::Currency, }; use super::TalerApi; use crate::{ - api::RouterUtils as _, + api::{RouterUtils as _, Validation, check_currency}, auth::AuthMethod, constants::WIRE_GATEWAY_API_VERSION, - error::{ApiResult, failure, failure_code, failure_status}, + error::{ApiResult, bad_request, failure_code, failure_status}, extract::{Path, Query, Req}, }; @@ -94,8 +97,39 @@ pub trait WireGateway: TalerApi { } } -static METADATA_PATTERN: LazyLock<Regex> = - LazyLock::new(|| Regex::new("^[a-zA-Z0-9-.:]{1, 40}$").unwrap()); +impl Validation for TransferRequest { + fn check(&self, currency: &Currency) -> ApiResult<()> { + static METADATA_PATTERN: LazyLock<Regex> = + LazyLock::new(|| Regex::new("^[a-zA-Z0-9-.:]{1, 40}$").unwrap()); + if let Some(metadata) = &self.metadata + && !METADATA_PATTERN.is_match(metadata) + { + return Err(bad_request(format_args!( + "metadata '{metadata}' is malformed, must match {}", + METADATA_PATTERN.as_str() + ))); + } + check_currency(currency, &self.amount) + } +} + +impl Validation for AddIncomingRequest { + fn check(&self, currency: &Currency) -> ApiResult<()> { + check_currency(currency, &self.amount) + } +} + +impl Validation for AddKycauthRequest { + fn check(&self, currency: &Currency) -> ApiResult<()> { + check_currency(currency, &self.amount) + } +} + +impl Validation for AddMappedRequest { + fn check(&self, currency: &Currency) -> ApiResult<()> { + check_currency(currency, &self.amount) + } +} pub fn router<I: WireGateway>(state: Arc<I>, auth: AuthMethod) -> Router { Router::new() @@ -103,18 +137,7 @@ pub fn router<I: WireGateway>(state: Arc<I>, auth: AuthMethod) -> Router { "/transfer", post( async |State(state): State<Arc<I>>, Req(req): Req<TransferRequest>| { - state.check_currency(&req.amount)?; - if let Some(metadata) = &req.metadata - && !METADATA_PATTERN.is_match(metadata) - { - return Err(failure( - ErrorCode::GENERIC_JSON_INVALID, - format!( - "metadata '{metadata}' is malformed, must match {}", - METADATA_PATTERN.as_str() - ), - )); - } + req.check(&state.currency())?; ApiResult::Ok(Json(state.transfer(req).await?)) }, ), @@ -138,10 +161,7 @@ pub fn router<I: WireGateway>(state: Arc<I>, auth: AuthMethod) -> Router { get(async |State(state): State<Arc<I>>, Path(id): Path<u64>| { match state.transfer_by_id(id).await? { Some(it) => Ok(Json(it)), - None => Err(failure( - ErrorCode::BANK_TRANSACTION_NOT_FOUND, - format!("Transfer '{id}' not found"), - )), + None => Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND)), } }), ) @@ -177,7 +197,7 @@ pub fn router<I: WireGateway>(state: Arc<I>, auth: AuthMethod) -> Router { "/admin/add-incoming", post( async |State(state): State<Arc<I>>, Req(req): Req<AddIncomingRequest>| { - state.check_currency(&req.amount)?; + req.check(&state.currency())?; ApiResult::Ok(Json(state.add_incoming_reserve(req).await?)) }, ), @@ -186,7 +206,7 @@ pub fn router<I: WireGateway>(state: Arc<I>, auth: AuthMethod) -> Router { "/admin/add-kycauth", post( async |State(state): State<Arc<I>>, Req(req): Req<AddKycauthRequest>| { - state.check_currency(&req.amount)?; + req.check(&state.currency())?; ApiResult::Ok(Json(state.add_incoming_kyc(req).await?)) }, ), @@ -195,7 +215,7 @@ pub fn router<I: WireGateway>(state: Arc<I>, auth: AuthMethod) -> Router { "/admin/add-mapped", post( async |State(state): State<Arc<I>>, Req(req): Req<AddMappedRequest>| { - state.check_currency(&req.amount)?; + req.check(&state.currency())?; ApiResult::Ok(Json(state.add_incoming_mapped(req).await?)) }, ), @@ -217,7 +237,7 @@ pub fn router<I: WireGateway>(state: Arc<I>, auth: AuthMethod) -> Router { "/config", get(async |State(state): State<Arc<I>>| { Json(WireConfig { - name: "taler-wire-gateway", + name: (), version: WIRE_GATEWAY_API_VERSION, currency: state.currency(), implementation: Some(state.implementation()), diff --git a/common/taler-api/src/constants.rs b/common/taler-api/src/constants.rs @@ -14,7 +14,9 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -pub const WIRE_GATEWAY_API_VERSION: &str = "5:0:0"; -pub const PREPARED_TRANSFER_API_VERSION: &str = "1:0:0"; -pub const REVENUE_API_VERSION: &str = "1:0:0"; +use taler_common::api::LibtoolVersion; + +pub const WIRE_GATEWAY_API_VERSION: LibtoolVersion = LibtoolVersion::new(5, 0, 0); +pub const PREPARED_TRANSFER_API_VERSION: LibtoolVersion = LibtoolVersion::new(1, 0, 0); +pub const REVENUE_API_VERSION: LibtoolVersion = LibtoolVersion::new(1, 0, 0); pub const MAX_BODY_LENGTH: usize = 4 * 1024; // 4kB diff --git a/common/taler-api/src/crypto.rs b/common/taler-api/src/crypto.rs @@ -15,7 +15,7 @@ */ use aws_lc_rs::signature::{self, Ed25519KeyPair, UnparsedPublicKey}; -use taler_common::api_common::{EddsaPublicKey, EddsaSignature}; +use taler_common::api::{EddsaPublicKey, EddsaSignature}; pub fn check_eddsa_signature(key: &EddsaPublicKey, msg: &[u8], sign: &EddsaSignature) -> bool { UnparsedPublicKey::new(&signature::ED25519, key.as_ref()) diff --git a/common/taler-api/src/db.rs b/common/taler-api/src/db.rs @@ -28,8 +28,7 @@ use sqlx::{ query::{Query, QueryScalar}, }; use taler_common::{ - api_common::SafeU64, - api_params::{History, Page, Pooling}, + api::params::{History, Page, Pooling}, types::{ amount::{Amount, Currency, Decimal}, iban::IBAN, @@ -322,9 +321,6 @@ pub trait TypeHelper { fn try_get_opt_u64<I: sqlx::ColumnIndex<Self>>(&self, index: I) -> sqlx::Result<Option<u64>> { self.try_get_opt_map(index, |signed: i64| signed.try_into()) } - fn try_get_safeu64<I: sqlx::ColumnIndex<Self>>(&self, index: I) -> sqlx::Result<SafeU64> { - self.try_get_map(index, |signed: i64| SafeU64::try_from(signed)) - } fn try_get_url<I: sqlx::ColumnIndex<Self>>(&self, index: I) -> sqlx::Result<Url> { self.try_get_parse(index) } diff --git a/common/taler-api/src/error.rs b/common/taler-api/src/error.rs @@ -23,7 +23,9 @@ use axum::{ response::{IntoResponse, Response}, }; use taler_common::{ - api_common::ErrorDetail, api_params::ParamsErr, error_code::ErrorCode, types::payto::PaytoErr, + api::{ErrorDetail, params::ParamsErr}, + error_code::ErrorCode, + types::payto::PaytoErr, }; pub type ApiResult<T> = Result<T, ApiError>; @@ -125,9 +127,16 @@ impl From<ParamsErr> for ApiError { impl From<serde_path_to_error::Error<serde_urlencoded::de::Error>> for ApiError { fn from(value: serde_path_to_error::Error<serde_urlencoded::de::Error>) -> Self { - failure(ErrorCode::GENERIC_PARAMETER_MALFORMED, value.inner()) - .with_path(value.path().to_string()) - .with_log(value.to_string()) + let fmt = value.to_string(); + if fmt.contains("missing") { + failure(ErrorCode::GENERIC_PARAMETER_MISSING, value.inner()) + .with_path(value.path().to_string()) + .with_log(fmt) + } else { + failure(ErrorCode::GENERIC_PARAMETER_MALFORMED, value.inner()) + .with_path(value.path().to_string()) + .with_log(fmt) + } } } diff --git a/common/taler-api/src/extract.rs b/common/taler-api/src/extract.rs @@ -15,9 +15,13 @@ */ use axum::{ - body::{Body, Bytes}, + body::{Body, Bytes, HttpBody}, extract::{FromRequest, FromRequestParts, Request}, - http::{HeaderMap, StatusCode, header, request::Parts}, + http::{ + HeaderMap, StatusCode, + header::{self, CONTENT_TYPE}, + request::Parts, + }, }; use http_body_util::BodyExt as _; use serde::de::DeserializeOwned; @@ -156,6 +160,29 @@ impl<T: DeserializeOwned> TryFrom<&Bytes> for Req<T> { } #[derive(Debug, Clone, Copy, Default)] +#[must_use] +pub struct OptReq<T>(pub Option<T>); + +impl<T, S> FromRequest<S> for OptReq<T> +where + T: DeserializeOwned, + S: Send + Sync, +{ + type Rejection = ApiError; + + async fn from_request(req: Request, _state: &S) -> Result<Self, Self::Rejection> { + let (parts, body) = req.into_parts(); + if !parts.headers.contains_key(CONTENT_TYPE) && body.size_hint().exact() == Some(0) { + Ok(Self(None)) + } else { + let bytes = decompressed_strict_body(&parts.headers, body).await?; + let req = Req::try_from(&bytes)?; + Ok(Self(Some(req.0))) + } + } +} + +#[derive(Debug, Clone, Copy, Default)] pub struct Path<T: DeserializeOwned + Send>(pub T); impl<T: serde::de::DeserializeOwned + Send, S: Sync + Send> FromRequestParts<S> for Path<T> { diff --git a/common/taler-api/src/subject.rs b/common/taler-api/src/subject.rs @@ -15,14 +15,14 @@ */ use std::{ - fmt::{Debug, Write as _}, + fmt::{Debug, Display, Write as _}, str::FromStr, }; use aws_lc_rs::digest::{SHA256, digest}; use compact_str::CompactString; use taler_common::{ - api_common::{EddsaPublicKey, ShortHashCode}, + api::{EddsaPublicKey, ShortHashCode}, db::IncomingType, encoding::base32::{Base32Error, CROCKFORD_ALPHABET}, types::url, @@ -156,23 +156,27 @@ pub fn parse_outgoing(subject: &str) -> Result<OutgoingSubject, OutgoingSubjectE } /// Format an outgoing subject -pub fn fmt_out_subject(wtid: &ShortHashCode, url: &Url, metadata: Option<&str>) -> String { +pub fn fmt_out_subject( + wtid: &ShortHashCode, + url: impl AsRef<str>, + metadata: Option<&str>, +) -> String { let mut buf = String::new(); if let Some(metadata) = metadata { buf.push_str(metadata); buf.push(' '); } - write!(&mut buf, "{wtid} {url}").unwrap(); + write!(&mut buf, "{wtid} {}", url.as_ref()).unwrap(); buf } /// Format an incoming subject -pub fn fmt_in_subject(ty: IncomingType, key: &EddsaPublicKey) -> String { - match ty { - IncomingType::reserve => format!("{key}"), - IncomingType::kyc => format!("KYC:{key}"), - IncomingType::map => format!("MAP:{key}"), - } +pub fn fmt_in_subject(ty: IncomingType, key: &EddsaPublicKey) -> impl Display { + std::fmt::from_fn(move |f| match ty { + IncomingType::reserve => write!(f, "{key}"), + IncomingType::kyc => write!(f, "KYC:{key}"), + IncomingType::map => write!(f, "MAP:{key}"), + }) } /** @@ -344,7 +348,7 @@ mod test { use std::str::FromStr as _; use taler_common::{ - api_common::{EddsaPublicKey, ShortHashCode}, + api::{EddsaPublicKey, ShortHashCode}, db::IncomingType, types::url, }; diff --git a/common/taler-api/src/test.rs b/common/taler-api/src/test.rs @@ -7,10 +7,12 @@ use axum::{ use serde_json::json; use sqlx::{PgPool, Row as _, postgres::PgRow}; use taler_common::{ - api_common::{HashCode, ShortHashCode}, - api_revenue::RevenueConfig, - api_transfer::PreparedTransferConfig, - api_wire::{OutgoingHistory, TransferRequest, TransferResponse, TransferState, WireConfig}, + api::{ + HashCode, ShortHashCode, + prepared::PreparedTransferConfig, + revenue::RevenueConfig, + wire::{TransferRequest, TransferResponse, TransferState, WireConfig}, + }, db::IncomingType, error_code::ErrorCode, types::{ @@ -23,8 +25,8 @@ use taler_common::{ use taler_test_utils::{ db::db_test_setup_manual, routine::{ - Status, admin_add_incoming_routine, in_history_routine, registration_routine, - revenue_routine, routine_pagination, transfer_routine, + Status, admin_add_incoming_routine, in_history_routine, out_history_routine, + registration_routine, revenue_routine, transfer_routine, }, server::TestServer, tasks, @@ -194,11 +196,9 @@ async fn transfer() { #[tokio::test] async fn outgoing_history() { - let (server, _) = setup().await; - let server = &server; - routine_pagination::<OutgoingHistory>( + let (server, _) = &setup().await; + out_history_routine( server, - "/taler-wire-gateway/history/outgoing", tasks!({ server .post("/taler-wire-gateway/transfer") @@ -212,6 +212,7 @@ async fn outgoing_history() { .await .assert_ok_json::<TransferResponse>(); }), + tasks!(), ) .await; } @@ -225,13 +226,13 @@ async fn admin_add_incoming() { #[tokio::test] async fn in_history() { let (server, _) = setup().await; - in_history_routine(&server, &PAYTO, true).await; + in_history_routine(&server, &PAYTO, true, tasks!(), tasks!()).await; } #[tokio::test] async fn revenue() { let (server, _) = setup().await; - revenue_routine(&server, &PAYTO, true).await; + revenue_routine(&server, &PAYTO, true, tasks!(), tasks!()).await; } #[tokio::test] diff --git a/common/taler-api/src/test/api.rs b/common/taler-api/src/test/api.rs @@ -17,15 +17,15 @@ use jiff::Timestamp; use sqlx::PgPool; use taler_common::{ - api_params::{History, Page}, - api_revenue::RevenueIncomingHistory, - api_transfer::{ - RegistrationRequest, RegistrationResponse, SubjectFormat, TransferSubject, Unregistration, - }, - api_wire::{ - AddIncomingRequest, AddIncomingResponse, AddKycauthRequest, AddMappedRequest, - IncomingHistory, OutgoingHistory, TransferList, TransferRequest, TransferResponse, - TransferState, TransferStatus, + api::{ + params::{History, Page}, + prepared::{RegistrationRequest, RegistrationResponse, SubjectFormat, Unregistration}, + revenue::RevenueIncomingHistory, + wire::{ + AddIncomingRequest, AddIncomingResponse, AddKycauthRequest, AddMappedRequest, + IncomingHistory, OutgoingHistory, TransferList, TransferRequest, TransferResponse, + TransferState, TransferStatus, + }, }, db::IncomingType, error_code::ErrorCode, @@ -38,9 +38,13 @@ use taler_common::{ use tokio::sync::watch::Sender; use crate::{ - api::{TalerApi, revenue::Revenue, transfer::PreparedTransfer, wire::WireGateway}, - error::{ApiResult, failure, failure_code}, - subject::fmt_in_subject, + api::{ + TalerApi, + prepared::{PreparedTransfer, simple_subject}, + revenue::Revenue, + wire::WireGateway, + }, + error::{ApiResult, failure_code}, test::db::{self, AddIncomingResult}, }; @@ -53,8 +57,8 @@ pub struct TestApi { } impl TalerApi for TestApi { - fn currency(&self) -> &str { - self.currency.as_ref() + fn currency(&self) -> Currency { + self.currency } fn implementation(&self) -> &'static str { @@ -220,34 +224,17 @@ impl PreparedTransfer for TestApi { async fn registration(&self, req: RegistrationRequest) -> ApiResult<RegistrationResponse> { match db::transfer_register(&self.pool, &req).await? { - db::RegistrationResult::Success => { - let simple = TransferSubject::Simple { - credit_amount: req.credit_amount, - subject: if req.authorization_pub == req.account_pub && !req.recurrent { - fmt_in_subject(req.r#type.into(), &req.account_pub) - } else { - fmt_in_subject(IncomingType::map, &req.authorization_pub) - }, - }; - ApiResult::Ok(RegistrationResponse { - subjects: vec![simple], - expiration: TalerTimestamp::Never, - }) - } + db::RegistrationResult::Success => ApiResult::Ok(RegistrationResponse { + subjects: vec![simple_subject(req)], + expiration: TalerTimestamp::Never, + }), db::RegistrationResult::ReservePubReuse => { ApiResult::Err(failure_code(ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT)) } } } - async fn unregistration(&self, req: Unregistration) -> ApiResult<()> { - if !db::transfer_unregister(&self.pool, &req).await? { - Err(failure( - ErrorCode::BANK_TRANSACTION_NOT_FOUND, - format!("Prepared transfer '{}' not found", req.authorization_pub), - )) - } else { - Ok(()) - } + async fn unregistration(&self, req: Unregistration) -> ApiResult<bool> { + Ok(db::transfer_unregister(&self.pool, &req).await?) } } diff --git a/common/taler-api/src/test/db.rs b/common/taler-api/src/test/db.rs @@ -17,13 +17,15 @@ use jiff::Timestamp; use sqlx::{PgPool, QueryBuilder, Row, postgres::PgRow}; use taler_common::{ - api_common::{EddsaPublicKey, SafeU64}, - api_params::{History, Page}, - api_revenue::RevenueIncomingBankTransaction, - api_transfer::{RegistrationRequest, Unregistration}, - api_wire::{ - IncomingBankTransaction, OutgoingBankTransaction, TransferListStatus, TransferRequest, - TransferResponse, TransferState, TransferStatus, + api::{ + EddsaPublicKey, + params::{History, Page}, + prepared::{RegistrationRequest, Unregistration}, + revenue::RevenueIncomingBankTransaction, + wire::{ + IncomingBankTransaction, OutgoingBankTransaction, TransferListStatus, TransferRequest, + TransferResponse, TransferState, TransferStatus, + }, }, db::IncomingType, types::{ @@ -84,7 +86,7 @@ pub async fn transfer(db: &PgPool, req: &TransferRequest) -> sqlx::Result<Transf TransferResult::WtidReuse } else { TransferResult::Success(TransferResponse { - row_id: r.try_get_safeu64("out_transfer_row_id")?, + row_id: r.try_get_u64("out_transfer_row_id")?, timestamp: r.try_get_timestamp("out_created_at")?.into(), }) }) @@ -124,7 +126,7 @@ pub async fn transfer_page( }, |r: PgRow| { Ok(TransferListStatus { - row_id: r.try_get_safeu64("transfer_id")?, + row_id: r.try_get_u64("transfer_id")?, status: r.try_get("status")?, amount: r.try_get_amount("amount", currency)?, credit_account: r.try_get_payto("credit_payto")?, @@ -208,7 +210,7 @@ pub async fn outgoing_revenue( debit_fee: None, wtid: r.try_get("wtid")?, credit_account: r.try_get_payto("credit_payto")?, - row_id: r.try_get_safeu64("transfer_id")?, + row_id: r.try_get_u64("transfer_id")?, date: r.try_get_timestamp("created_at")?.into(), exchange_base_url: r.try_get_url("exchange_base_url")?, metadata: r.try_get("metadata")?, @@ -219,7 +221,7 @@ pub async fn outgoing_revenue( } pub enum AddIncomingResult { - Success { id: SafeU64, created_at: Timestamp }, + Success { id: u64, created_at: Timestamp }, ReservePubReuse, UnknownMapping, MappingReuse, @@ -254,7 +256,7 @@ pub async fn add_incoming( AddIncomingResult::UnknownMapping } else{ AddIncomingResult::Success { - id: r.try_get_safeu64("out_tx_row_id")?, + id: r.try_get_u64("out_tx_row_id")?, created_at: r.try_get_timestamp("out_created_at")?, } }) @@ -295,7 +297,7 @@ pub async fn incoming_history( |r: PgRow| { Ok(match r.try_get("type")? { IncomingType::reserve => IncomingBankTransaction::Reserve { - row_id: r.try_get_safeu64("tx_in_id")?, + row_id: r.try_get_u64("tx_in_id")?, date: r.try_get_timestamp("created_at")?.into(), amount: r.try_get_amount("amount", currency)?, credit_fee: None, @@ -305,7 +307,7 @@ pub async fn incoming_history( authorization_sig: r.try_get("authorization_sig")?, }, IncomingType::kyc => IncomingBankTransaction::Kyc { - row_id: r.try_get_safeu64("tx_in_id")?, + row_id: r.try_get_u64("tx_in_id")?, date: r.try_get_timestamp("created_at")?.into(), amount: r.try_get_amount("amount", currency)?, credit_fee: None, @@ -348,7 +350,7 @@ pub async fn revenue_history( }, |r: PgRow| { Ok(RevenueIncomingBankTransaction { - row_id: r.try_get_safeu64("tx_in_id")?, + row_id: r.try_get_u64("tx_in_id")?, date: r.try_get_timestamp("created_at")?.into(), amount: r.try_get_amount("amount", currency)?, credit_fee: None, diff --git a/common/taler-common/src/api.rs b/common/taler-common/src/api.rs @@ -0,0 +1,248 @@ +/* + This file is part of TALER + Copyright (C) 2024, 2025, 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use std::{borrow::Cow, fmt::Display, num::ParseIntError, ops::Deref, str::FromStr}; + +use aws_lc_rs::{ + error::KeyRejected, + signature::{self, Ed25519KeyPair, KeyPair as _, ParsedPublicKey}, +}; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::value::RawValue; + +use crate::{encoding::base32::Base32Error, types::base32::Base32}; + +pub mod params; +pub mod prepared; +pub mod revenue; +pub mod wire; + +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + serde_with::DeserializeFromStr, + serde_with::SerializeDisplay, +)] +pub struct LibtoolVersion { + pub current: u32, + pub revision: u32, + pub age: u32, +} + +impl LibtoolVersion { + pub const fn new(current: u32, revision: u32, age: u32) -> Self { + assert!(age <= current); + Self { + current, + revision, + age, + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum LibtoolVersionError { + #[error("age exceeds current")] + AgeExceedsCurrent, + #[error("invalid format")] + InvalidFormat, + #[error(transparent)] + ParseIntError(#[from] ParseIntError), +} + +impl FromStr for LibtoolVersion { + type Err = LibtoolVersionError; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + let mut parts = s.split(':'); + + let current = parts + .next() + .ok_or(LibtoolVersionError::InvalidFormat)? + .parse::<u32>()?; + + let revision = match parts.next() { + Some(p) => p.parse::<u32>()?, + None => 0, + }; + + let age = match parts.next() { + Some(p) => p.parse::<u32>()?, + None => 0, + }; + + if parts.next().is_some() { + return Err(LibtoolVersionError::InvalidFormat); + } + + Ok(Self::new(current, revision, age)) + } +} + +impl Display for LibtoolVersion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}:{}:{}", self.current, self.revision, self.age) + } +} + +/// <https://docs.taler.net/core/api-common.html#tsref-type-ErrorDetail> +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ErrorDetail { + pub code: u16, + pub hint: Option<Box<str>>, + pub detail: Option<Box<str>>, + pub parameter: Option<Box<str>>, + pub path: Option<Box<str>>, + pub offset: Option<Box<str>>, + pub index: Option<Box<str>>, + pub object: Option<Box<str>>, + pub currency: Option<Box<str>>, + pub type_expected: Option<Box<str>>, + pub type_actual: Option<Box<str>>, + pub extra: Option<Box<RawValue>>, +} + +/// 64-byte hash code +pub type HashCode = Base32<64>; +/// 32-bytes hash code +pub type ShortHashCode = Base32<32>; +pub type WadId = Base32<24>; +pub type EddsaSignature = Base32<64>; + +/// EdDSA and ECDHE public keys always point on Curve25519 +/// and represented using the standard 256 bits Ed25519 compact format, +/// converted to Crockford Base32. +#[derive(Clone, PartialEq, Eq)] +pub struct EddsaPublicKey(Base32<32>); + +impl Deref for EddsaPublicKey { + type Target = Base32<32>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Serialize for EddsaPublicKey { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + self.0.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for EddsaPublicKey { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + let raw = Cow::<str>::deserialize(deserializer)?; + Self::from_str(&raw).map_err(serde::de::Error::custom) + } +} + +impl Display for EddsaPublicKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl std::fmt::Debug for EddsaPublicKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Display::fmt(&self.0, f) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum EddsaPublicKeyError { + #[error(transparent)] + Base32(#[from] Base32Error<32>), + #[error(transparent)] + Invalid(#[from] KeyRejected), +} + +impl FromStr for EddsaPublicKey { + type Err = EddsaPublicKeyError; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + let encoded = Base32::<32>::from_str(s)?; + Self::try_from(encoded) + } +} + +impl TryFrom<&[u8]> for EddsaPublicKey { + type Error = EddsaPublicKeyError; + + fn try_from(value: &[u8]) -> Result<Self, Self::Error> { + let encoded = Base32::try_from(value)?; + Self::try_from(encoded) + } +} + +impl TryFrom<[u8; 32]> for EddsaPublicKey { + type Error = EddsaPublicKeyError; + + fn try_from(value: [u8; 32]) -> Result<Self, Self::Error> { + let encoded = Base32::from(value); + Self::try_from(encoded) + } +} + +impl TryFrom<Base32<32>> for EddsaPublicKey { + type Error = EddsaPublicKeyError; + + fn try_from(value: Base32<32>) -> Result<Self, Self::Error> { + ParsedPublicKey::new(&signature::ED25519, value.as_ref())?; + Ok(Self(value)) + } +} + +impl EddsaPublicKey { + pub fn rand() -> EddsaPublicKey { + let signing_key = Ed25519KeyPair::generate().unwrap(); + let bytes: [u8; 32] = signing_key.public_key().as_ref().try_into().unwrap(); + Self(Base32::from(bytes)) + } +} + +impl sqlx::Type<sqlx::Postgres> for EddsaPublicKey { + fn type_info() -> sqlx::postgres::PgTypeInfo { + <Base32<32>>::type_info() + } +} + +impl<'q> sqlx::Encode<'q, sqlx::Postgres> for EddsaPublicKey { + fn encode_by_ref( + &self, + buf: &mut sqlx::postgres::PgArgumentBuffer, + ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> { + self.0.encode_by_ref(buf) + } +} + +impl<'r> sqlx::Decode<'r, sqlx::Postgres> for EddsaPublicKey { + fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result<Self, sqlx::error::BoxDynError> { + let raw = <Base32<32>>::decode(value)?; + Ok(Self(raw)) + } +} diff --git a/common/taler-common/src/api/params.rs b/common/taler-common/src/api/params.rs @@ -0,0 +1,171 @@ +/* + This file is part of TALER + Copyright (C) 2024, 2025, 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use serde::Deserialize; +use serde_with::{DisplayFromStr, serde_as}; + +use crate::{api::wire::TransferState, types::payto::PaytoURI}; + +#[derive(Debug, thiserror::Error)] +#[error("Param '{param}' {reason}")] +pub struct ParamsErr { + pub param: &'static str, + pub reason: String, +} + +pub fn param_err(param: &'static str, reason: String) -> ParamsErr { + ParamsErr { param, reason } +} + +#[serde_as] +#[derive(Debug, Clone, Deserialize)] +/// <https://docs.taler.net/core/api-common.html#row-id-pagination> +pub struct PageParams { + #[serde_as(as = "Option<DisplayFromStr>")] + #[serde(alias = "delta")] + pub limit: Option<i64>, + #[serde_as(as = "Option<DisplayFromStr>")] + #[serde(alias = "start")] + pub offset: Option<i64>, +} + +impl PageParams { + const MAX_PAGE_SIZE: i64 = 1024; + + pub fn check(self) -> Result<Page, ParamsErr> { + Self::check_custom(self, Self::MAX_PAGE_SIZE) + } + + pub fn check_custom(self, max_page_size: i64) -> Result<Page, ParamsErr> { + let limit = self.limit.unwrap_or(-20); + if limit == 0 { + return Err(param_err("limit", format!("must be non-zero got {limit}"))); + } else if limit > max_page_size { + return Err(param_err( + "limit", + format!("must be <= {max_page_size} for {limit}"), + )); + } + if let Some(offset) = self.offset + && offset < 0 + { + return Err(param_err( + "offset", + format!("must be positive got {offset}"), + )); + } + + Ok(Page { + limit, + offset: self.offset, + }) + } +} + +#[derive(Debug)] +pub struct Page { + pub limit: i64, + pub offset: Option<i64>, +} + +impl Default for Page { + fn default() -> Self { + Self { + limit: 20, + offset: None, + } + } +} + +impl Page { + pub fn backward(&self) -> bool { + self.limit < 0 + } +} + +#[serde_as] +#[derive(Debug, Clone, Deserialize)] +/// <https://docs.taler.net/core/api-common.html#long-polling> +pub struct PoolingParams { + #[serde_as(as = "Option<DisplayFromStr>")] + #[serde(alias = "long_poll_ms")] + pub timeout_ms: Option<u64>, +} + +#[derive(Debug, Default)] +pub struct Pooling { + pub timeout_ms: Option<u64>, +} + +impl PoolingParams { + pub const MAX_TIMEOUT_MS: u64 = 60 * 60 * 10; // 1H + + pub fn check(self) -> Result<Pooling, ParamsErr> { + Self::check_custom(self, Self::MAX_TIMEOUT_MS) + } + + pub fn check_custom(self, max_timeout_ms: u64) -> Result<Pooling, ParamsErr> { + let timeout_ms = self.timeout_ms.map(|it| it.min(max_timeout_ms)); + Ok(Pooling { timeout_ms }) + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct HistoryParams { + #[serde(flatten)] + pub pagination: PageParams, + #[serde(flatten)] + pub pooling: PoolingParams, +} + +impl HistoryParams { + pub fn check(self) -> Result<History, ParamsErr> { + Self::check_custom( + self, + PageParams::MAX_PAGE_SIZE, + PoolingParams::MAX_TIMEOUT_MS, + ) + } + + pub fn check_custom( + self, + max_page_size: i64, + max_timeout_ms: u64, + ) -> Result<History, ParamsErr> { + Ok(History { + page: self.pagination.check_custom(max_page_size)?, + pooling: self.pooling.check_custom(max_timeout_ms)?, + }) + } +} + +#[derive(Debug, Default)] +pub struct History { + pub page: Page, + pub pooling: Pooling, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct TransferParams { + #[serde(flatten)] + pub pagination: PageParams, + pub status: Option<TransferState>, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AccountParams { + pub account: PaytoURI, +} diff --git a/common/taler-common/src/api/prepared.rs b/common/taler-common/src/api/prepared.rs @@ -0,0 +1,115 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +//! Type for the Taler Wire Transfer Gateway HTTP API <https://docs.taler.net/core/api-bank-transfer.html#taler-prepared-transfer-http-api> + +use compact_str::CompactString; +use serde::{Deserialize, Serialize}; +use taler_macros::api_config; +use url::Url; + +use super::EddsaPublicKey; +use crate::{ + api::EddsaSignature, + db::IncomingType, + types::{ + amount::{Amount, Currency}, + timestamp::TalerTimestamp, + }, +}; + +/// <https://docs.taler.net/core/api-bank-transfer.html#tsref-type-SubjectFormat> +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[allow(non_camel_case_types)] +pub enum SubjectFormat { + SIMPLE, + URI, + CH_QR_BILL, +} + +/// <https://docs.taler.net/core/api-bank-transfer.html#tsref-type-PreparedTransferConfig> +#[api_config("taler-prepared-transfer")] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PreparedTransferConfig<'a> { + pub currency: Currency, + pub supported_formats: Vec<SubjectFormat>, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[allow(non_camel_case_types)] +pub enum TransferType { + reserve, + kyc, +} + +impl From<TransferType> for IncomingType { + fn from(value: TransferType) -> Self { + match value { + TransferType::reserve => IncomingType::reserve, + TransferType::kyc => IncomingType::kyc, + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum PublicKeyAlg { + EdDSA, +} + +/// <https://docs.taler.net/core/api-bank-transfer.html#tsref-type-RegistrationRequest> +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegistrationRequest { + pub credit_amount: Amount, + pub r#type: TransferType, + pub alg: PublicKeyAlg, + pub account_pub: EddsaPublicKey, + pub authorization_pub: EddsaPublicKey, + pub authorization_sig: EddsaSignature, + pub recurrent: bool, +} + +/// <https://docs.taler.net/core/api-bank-transfer.html#tsref-type-TransferSubject> +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "type")] +pub enum TransferSubject { + #[serde(rename = "SIMPLE")] + Simple { + credit_amount: Amount, + subject: String, + }, + #[serde(rename = "URI")] + Uri { credit_amount: Amount, uri: Url }, + #[serde(rename = "CH_QR_BILL")] + QrBill { + credit_amount: Amount, + qr_reference_number: String, + }, +} + +/// <https://docs.taler.net/core/api-bank-transfer.html#tsref-type-RegistrationResponse> +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RegistrationResponse { + pub subjects: Vec<TransferSubject>, + pub expiration: TalerTimestamp, +} + +/// <https://docs.taler.net/core/api-bank-transfer.html#tsref-type-Unregistration> +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Unregistration { + pub timestamp: CompactString, + pub authorization_pub: EddsaPublicKey, + pub authorization_sig: EddsaSignature, +} diff --git a/common/taler-common/src/api/revenue.rs b/common/taler-common/src/api/revenue.rs @@ -0,0 +1,51 @@ +/* + This file is part of TALER + Copyright (C) 2025, 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +//! Type for the Taler Wire Gateway HTTP API <https://docs.taler.net/core/api-bank-wire.html#taler-wire-gateway-http-api> + +use serde::{Deserialize, Serialize}; +use taler_macros::api_config; + +use crate::types::{ + amount::{Amount, Currency}, + payto::PaytoURI, + timestamp::TalerTimestamp, +}; + +/// <https://docs.taler.net/core/api-bank-revenue.html#tsref-type-RevenueConfig> +#[api_config("taler-revenue")] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RevenueConfig<'a> { + pub currency: Currency, +} + +/// <https://docs.taler.net/core/api-bank-revenue.html#tsref-type-RevenueIncomingHistory> +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RevenueIncomingHistory { + pub incoming_transactions: Vec<RevenueIncomingBankTransaction>, + pub credit_account: PaytoURI, +} + +/// <https://docs.taler.net/core/api-bank-revenue.html#tsref-type-RevenueIncomingBankTransaction> +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RevenueIncomingBankTransaction { + pub row_id: u64, + pub date: TalerTimestamp, + pub amount: Amount, + pub credit_fee: Option<Amount>, + pub debit_account: PaytoURI, + pub subject: String, +} diff --git a/common/taler-common/src/api/wire.rs b/common/taler-common/src/api/wire.rs @@ -0,0 +1,210 @@ +/* + This file is part of TALER + Copyright (C) 2024, 2025, 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +//! Type for the Taler Wire Gateway HTTP API <https://docs.taler.net/core/api-bank-wire.html#taler-wire-gateway-http-api> + +use compact_str::CompactString; +use serde::{Deserialize, Serialize}; +use taler_macros::api_config; +use url::Url; + +use super::{EddsaPublicKey, HashCode, ShortHashCode, WadId}; +use crate::{ + api::EddsaSignature, + types::{ + amount::{Amount, Currency}, + payto::PaytoURI, + timestamp::TalerTimestamp, + }, +}; + +/// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-WireConfig> +#[api_config("taler-wire-gateway")] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WireConfig<'a> { + pub currency: Currency, + pub support_account_check: bool, +} + +/// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-TransferResponse> +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransferResponse { + pub timestamp: TalerTimestamp, + pub row_id: u64, +} + +/// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-TransferRequest> +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TransferRequest { + pub request_uid: HashCode, + pub amount: Amount, + pub exchange_base_url: Url, + pub metadata: Option<CompactString>, + pub wtid: ShortHashCode, + pub credit_account: PaytoURI, +} + +/// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-TransferList> +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TransferList { + pub transfers: Vec<TransferListStatus>, + pub debit_account: PaytoURI, +} + +/// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-TransferListStatus> +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TransferListStatus { + pub row_id: u64, + pub status: TransferState, + pub amount: Amount, + pub credit_account: PaytoURI, + pub timestamp: TalerTimestamp, +} + +/// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-TransfertSatus> +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TransferStatus { + pub status: TransferState, + pub status_msg: Option<String>, + pub amount: Amount, + pub origin_exchange_url: String, + pub metadata: Option<CompactString>, + pub wtid: ShortHashCode, + pub credit_account: PaytoURI, + pub timestamp: TalerTimestamp, +} + +/// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-OutgoingHistory> +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutgoingHistory { + pub outgoing_transactions: Vec<OutgoingBankTransaction>, + pub debit_account: PaytoURI, +} + +/// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-OutgoingBankTransaction> +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct OutgoingBankTransaction { + pub row_id: u64, + pub date: TalerTimestamp, + pub amount: Amount, + pub debit_fee: Option<Amount>, + pub credit_account: PaytoURI, + pub wtid: ShortHashCode, + pub exchange_base_url: Url, + pub metadata: Option<CompactString>, +} + +/// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-IncomingHistory> +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IncomingHistory { + pub credit_account: PaytoURI, + pub incoming_transactions: Vec<IncomingBankTransaction>, +} + +/// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-IncomingBankTransaction> +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "type")] +pub enum IncomingBankTransaction { + #[serde(rename = "RESERVE")] + Reserve { + row_id: u64, + date: TalerTimestamp, + amount: Amount, + credit_fee: Option<Amount>, + debit_account: PaytoURI, + reserve_pub: EddsaPublicKey, + authorization_pub: Option<EddsaPublicKey>, + authorization_sig: Option<EddsaSignature>, + }, + #[serde(rename = "WAD")] + Wad { + row_id: u64, + date: TalerTimestamp, + amount: Amount, + debit_account: PaytoURI, + origin_exchange_url: Url, + wad_id: WadId, + }, + #[serde(rename = "KYCAUTH")] + Kyc { + row_id: u64, + date: TalerTimestamp, + amount: Amount, + credit_fee: Option<Amount>, + debit_account: PaytoURI, + account_pub: EddsaPublicKey, + authorization_pub: Option<EddsaPublicKey>, + authorization_sig: Option<EddsaSignature>, + }, +} + +/// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-AddIncomingRequest> +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AddIncomingRequest { + pub amount: Amount, + pub reserve_pub: EddsaPublicKey, + pub debit_account: PaytoURI, +} + +/// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-AddIncomingResponse> +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AddIncomingResponse { + pub row_id: u64, + pub timestamp: TalerTimestamp, +} + +/// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-AddKycauthRequest> +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AddKycauthRequest { + pub amount: Amount, + pub account_pub: EddsaPublicKey, + pub debit_account: PaytoURI, +} + +/// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-AddMappedRequest> +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AddMappedRequest { + pub amount: Amount, + pub authorization_pub: EddsaPublicKey, + pub debit_account: PaytoURI, +} + +/// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-AccountInfo> +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountInfo {} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, sqlx::Type)] +#[allow(non_camel_case_types)] +#[sqlx(type_name = "transfer_status")] +pub enum TransferState { + pending, + transient_failure, + permanent_failure, + late_failure, + success, +} + +impl AsRef<str> for TransferState { + fn as_ref(&self) -> &str { + match self { + TransferState::pending => "pending", + TransferState::transient_failure => "transient_failure", + TransferState::permanent_failure => "permanent_failure", + TransferState::late_failure => "late_failure", + TransferState::success => "success", + } + } +} diff --git a/common/taler-common/src/api_common.rs b/common/taler-common/src/api_common.rs @@ -1,240 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2024, 2026 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ - -use std::{borrow::Cow, fmt::Display, ops::Deref, str::FromStr}; - -use aws_lc_rs::{ - error::KeyRejected, - signature::{self, Ed25519KeyPair, KeyPair, ParsedPublicKey}, -}; -use serde::{Deserialize, Deserializer, Serialize, de::Error}; -use serde_json::value::RawValue; - -use crate::{encoding::base32::Base32Error, types::base32::Base32}; - -/// <https://docs.taler.net/core/api-common.html#tsref-type-ErrorDetail> -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ErrorDetail { - pub code: u16, - pub hint: Option<Box<str>>, - pub detail: Option<Box<str>>, - pub parameter: Option<Box<str>>, - pub path: Option<Box<str>>, - pub offset: Option<Box<str>>, - pub index: Option<Box<str>>, - pub object: Option<Box<str>>, - pub currency: Option<Box<str>>, - pub type_expected: Option<Box<str>>, - pub type_actual: Option<Box<str>>, - pub extra: Option<Box<RawValue>>, -} - -pub fn safe_u64(nb: u64) -> SafeU64 { - SafeU64::try_from(nb).expect("invalid safe u64") -} - -/// <https://docs.taler.net/core/api-common.html#tsref-type-SafeUint64> -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] -pub struct SafeU64(u64); - -impl Deref for SafeU64 { - type Target = u64; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -#[derive(Debug, thiserror::Error)] -pub enum SafeU64Error { - #[error("{0} unsafe, {0} > (2^53 - 1)")] - Unsafe(u64), - #[error("{0} is negative")] - Negative(i64), -} - -impl TryFrom<u64> for SafeU64 { - type Error = SafeU64Error; - - fn try_from(nb: u64) -> Result<Self, Self::Error> { - if nb < (1 << 53) - 1 { - Ok(SafeU64(nb)) - } else { - Err(SafeU64Error::Unsafe(nb)) - } - } -} - -impl TryFrom<i64> for SafeU64 { - type Error = SafeU64Error; - - fn try_from(nb: i64) -> Result<Self, Self::Error> { - u64::try_from(nb) - .map_err(|_| SafeU64Error::Negative(nb)) - .and_then(|it| it.try_into()) - } -} - -impl TryFrom<i32> for SafeU64 { - type Error = SafeU64Error; - - fn try_from(nb: i32) -> Result<Self, Self::Error> { - u64::try_from(nb) - .map_err(|_| SafeU64Error::Negative(nb as i64)) - .and_then(|it| it.try_into()) - } -} - -impl<'de> Deserialize<'de> for SafeU64 { - fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> - where - D: Deserializer<'de>, - { - SafeU64::try_from(u64::deserialize(deserializer)?).map_err(D::Error::custom) - } -} - -impl Display for SafeU64 { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.0.fmt(f) - } -} - -/// 64-byte hash code -pub type HashCode = Base32<64>; -/// 32-bytes hash code -pub type ShortHashCode = Base32<32>; -pub type WadId = Base32<24>; -pub type EddsaSignature = Base32<64>; - -/// EdDSA and ECDHE public keys always point on Curve25519 -/// and represented using the standard 256 bits Ed25519 compact format, -/// converted to Crockford Base32. -#[derive(Clone, PartialEq, Eq)] -pub struct EddsaPublicKey(Base32<32>); - -impl Deref for EddsaPublicKey { - type Target = Base32<32>; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl Serialize for EddsaPublicKey { - fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> - where - S: serde::Serializer, - { - self.0.serialize(serializer) - } -} - -impl<'de> Deserialize<'de> for EddsaPublicKey { - fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> - where - D: Deserializer<'de>, - { - let raw = Cow::<str>::deserialize(deserializer)?; - Self::from_str(&raw).map_err(D::Error::custom) - } -} - -impl Display for EddsaPublicKey { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.0.fmt(f) - } -} - -impl std::fmt::Debug for EddsaPublicKey { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - Display::fmt(&self.0, f) - } -} - -#[derive(Debug, thiserror::Error)] -pub enum EddsaPublicKeyError { - #[error(transparent)] - Base32(#[from] Base32Error<32>), - #[error(transparent)] - Invalid(#[from] KeyRejected), -} - -impl FromStr for EddsaPublicKey { - type Err = EddsaPublicKeyError; - - fn from_str(s: &str) -> Result<Self, Self::Err> { - let encoded = Base32::<32>::from_str(s)?; - Self::try_from(encoded) - } -} - -impl TryFrom<&[u8]> for EddsaPublicKey { - type Error = EddsaPublicKeyError; - - fn try_from(value: &[u8]) -> Result<Self, Self::Error> { - let encoded = Base32::try_from(value)?; - Self::try_from(encoded) - } -} - -impl TryFrom<[u8; 32]> for EddsaPublicKey { - type Error = EddsaPublicKeyError; - - fn try_from(value: [u8; 32]) -> Result<Self, Self::Error> { - let encoded = Base32::from(value); - Self::try_from(encoded) - } -} - -impl TryFrom<Base32<32>> for EddsaPublicKey { - type Error = EddsaPublicKeyError; - - fn try_from(value: Base32<32>) -> Result<Self, Self::Error> { - ParsedPublicKey::new(&signature::ED25519, value.as_ref())?; - Ok(Self(value)) - } -} - -impl EddsaPublicKey { - pub fn rand() -> EddsaPublicKey { - let signing_key = Ed25519KeyPair::generate().unwrap(); - let bytes: [u8; 32] = signing_key.public_key().as_ref().try_into().unwrap(); - Self(Base32::from(bytes)) - } -} - -impl sqlx::Type<sqlx::Postgres> for EddsaPublicKey { - fn type_info() -> sqlx::postgres::PgTypeInfo { - <Base32<32>>::type_info() - } -} - -impl<'q> sqlx::Encode<'q, sqlx::Postgres> for EddsaPublicKey { - fn encode_by_ref( - &self, - buf: &mut sqlx::postgres::PgArgumentBuffer, - ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> { - self.0.encode_by_ref(buf) - } -} - -impl<'r> sqlx::Decode<'r, sqlx::Postgres> for EddsaPublicKey { - fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result<Self, sqlx::error::BoxDynError> { - let raw = <Base32<32>>::decode(value)?; - Ok(Self(raw)) - } -} diff --git a/common/taler-common/src/api_params.rs b/common/taler-common/src/api_params.rs @@ -1,171 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2024, 2025, 2026 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ - -use serde::Deserialize; -use serde_with::{DisplayFromStr, serde_as}; - -use crate::{api_wire::TransferState, types::payto::PaytoURI}; - -#[derive(Debug, thiserror::Error)] -#[error("Param '{param}' {reason}")] -pub struct ParamsErr { - pub param: &'static str, - pub reason: String, -} - -pub fn param_err(param: &'static str, reason: String) -> ParamsErr { - ParamsErr { param, reason } -} - -#[serde_as] -#[derive(Debug, Clone, Deserialize)] -/// <https://docs.taler.net/core/api-common.html#row-id-pagination> -pub struct PageParams { - #[serde_as(as = "Option<DisplayFromStr>")] - #[serde(alias = "delta")] - pub limit: Option<i64>, - #[serde_as(as = "Option<DisplayFromStr>")] - #[serde(alias = "start")] - pub offset: Option<i64>, -} - -impl PageParams { - const MAX_PAGE_SIZE: i64 = 1024; - - pub fn check(self) -> Result<Page, ParamsErr> { - Self::check_custom(self, Self::MAX_PAGE_SIZE) - } - - pub fn check_custom(self, max_page_size: i64) -> Result<Page, ParamsErr> { - let limit = self.limit.unwrap_or(-20); - if limit == 0 { - return Err(param_err("limit", format!("must be non-zero got {limit}"))); - } else if limit > max_page_size { - return Err(param_err( - "limit", - format!("must be <= {max_page_size} for {limit}"), - )); - } - if let Some(offset) = self.offset - && offset < 0 - { - return Err(param_err( - "offset", - format!("must be positive got {offset}"), - )); - } - - Ok(Page { - limit, - offset: self.offset, - }) - } -} - -#[derive(Debug)] -pub struct Page { - pub limit: i64, - pub offset: Option<i64>, -} - -impl Default for Page { - fn default() -> Self { - Self { - limit: 20, - offset: None, - } - } -} - -impl Page { - pub fn backward(&self) -> bool { - self.limit < 0 - } -} - -#[serde_as] -#[derive(Debug, Clone, Deserialize)] -/// <https://docs.taler.net/core/api-common.html#long-polling> -pub struct PoolingParams { - #[serde_as(as = "Option<DisplayFromStr>")] - #[serde(alias = "long_poll_ms")] - pub timeout_ms: Option<u64>, -} - -#[derive(Debug, Default)] -pub struct Pooling { - pub timeout_ms: Option<u64>, -} - -impl PoolingParams { - pub const MAX_TIMEOUT_MS: u64 = 60 * 60 * 10; // 1H - - pub fn check(self) -> Result<Pooling, ParamsErr> { - Self::check_custom(self, Self::MAX_TIMEOUT_MS) - } - - pub fn check_custom(self, max_timeout_ms: u64) -> Result<Pooling, ParamsErr> { - let timeout_ms = self.timeout_ms.map(|it| it.min(max_timeout_ms)); - Ok(Pooling { timeout_ms }) - } -} - -#[derive(Debug, Clone, Deserialize)] -pub struct HistoryParams { - #[serde(flatten)] - pub pagination: PageParams, - #[serde(flatten)] - pub pooling: PoolingParams, -} - -impl HistoryParams { - pub fn check(self) -> Result<History, ParamsErr> { - Self::check_custom( - self, - PageParams::MAX_PAGE_SIZE, - PoolingParams::MAX_TIMEOUT_MS, - ) - } - - pub fn check_custom( - self, - max_page_size: i64, - max_timeout_ms: u64, - ) -> Result<History, ParamsErr> { - Ok(History { - page: self.pagination.check_custom(max_page_size)?, - pooling: self.pooling.check_custom(max_timeout_ms)?, - }) - } -} - -#[derive(Debug, Default)] -pub struct History { - pub page: Page, - pub pooling: Pooling, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct TransferParams { - #[serde(flatten)] - pub pagination: PageParams, - pub status: Option<TransferState>, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct AccountParams { - pub account: PaytoURI, -} diff --git a/common/taler-common/src/api_revenue.rs b/common/taler-common/src/api_revenue.rs @@ -1,49 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2025, 2026 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ - -//! Type for the Taler Wire Gateway HTTP API <https://docs.taler.net/core/api-bank-wire.html#taler-wire-gateway-http-api> - -use serde::{Deserialize, Serialize}; - -use super::api_common::SafeU64; -use crate::types::{amount::Amount, payto::PaytoURI, timestamp::TalerTimestamp}; - -/// <https://docs.taler.net/core/api-bank-revenue.html#tsref-type-RevenueConfig> -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RevenueConfig<'a> { - pub name: &'a str, - pub version: &'a str, - pub currency: &'a str, - pub implementation: Option<&'a str>, -} - -/// <https://docs.taler.net/core/api-bank-revenue.html#tsref-type-RevenueIncomingHistory> -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct RevenueIncomingHistory { - pub incoming_transactions: Vec<RevenueIncomingBankTransaction>, - pub credit_account: PaytoURI, -} - -/// <https://docs.taler.net/core/api-bank-revenue.html#tsref-type-RevenueIncomingBankTransaction> -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct RevenueIncomingBankTransaction { - pub row_id: SafeU64, - pub date: TalerTimestamp, - pub amount: Amount, - pub credit_fee: Option<Amount>, - pub debit_account: PaytoURI, - pub subject: String, -} diff --git a/common/taler-common/src/api_transfer.rs b/common/taler-common/src/api_transfer.rs @@ -1,113 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2026 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ - -//! Type for the Taler Wire Transfer Gateway HTTP API <https://docs.taler.net/core/api-bank-transfer.html#taler-prepared-transfer-http-api> - -use compact_str::CompactString; -use serde::{Deserialize, Serialize}; -use url::Url; - -use super::api_common::EddsaPublicKey; -use crate::{ - api_common::EddsaSignature, - db::IncomingType, - types::{amount::Amount, timestamp::TalerTimestamp}, -}; - -/// <https://docs.taler.net/core/api-bank-transfer.html#tsref-type-SubjectFormat> -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[allow(non_camel_case_types)] -pub enum SubjectFormat { - SIMPLE, - URI, - CH_QR_BILL, -} - -/// <https://docs.taler.net/core/api-bank-transfer.html#tsref-type-PreparedTransferConfig> -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PreparedTransferConfig<'a> { - pub name: &'a str, - pub version: &'a str, - pub currency: &'a str, - pub implementation: Option<&'a str>, - pub supported_formats: Vec<SubjectFormat>, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[allow(non_camel_case_types)] -pub enum TransferType { - reserve, - kyc, -} - -impl From<TransferType> for IncomingType { - fn from(value: TransferType) -> Self { - match value { - TransferType::reserve => IncomingType::reserve, - TransferType::kyc => IncomingType::kyc, - } - } -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -pub enum PublicKeyAlg { - EdDSA, -} - -/// <https://docs.taler.net/core/api-bank-transfer.html#tsref-type-RegistrationRequest> -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RegistrationRequest { - pub credit_amount: Amount, - pub r#type: TransferType, - pub alg: PublicKeyAlg, - pub account_pub: EddsaPublicKey, - pub authorization_pub: EddsaPublicKey, - pub authorization_sig: EddsaSignature, - pub recurrent: bool, -} - -/// <https://docs.taler.net/core/api-bank-transfer.html#tsref-type-TransferSubject> -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(tag = "type")] -pub enum TransferSubject { - #[serde(rename = "SIMPLE")] - Simple { - credit_amount: Amount, - subject: String, - }, - #[serde(rename = "URI")] - Uri { credit_amount: Amount, uri: Url }, - #[serde(rename = "CH_QR_BILL")] - QrBill { - credit_amount: Amount, - qr_reference_number: String, - }, -} - -/// <https://docs.taler.net/core/api-bank-transfer.html#tsref-type-RegistrationResponse> -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct RegistrationResponse { - pub subjects: Vec<TransferSubject>, - pub expiration: TalerTimestamp, -} - -/// <https://docs.taler.net/core/api-bank-transfer.html#tsref-type-Unregistration> -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Unregistration { - pub timestamp: CompactString, - pub authorization_pub: EddsaPublicKey, - pub authorization_sig: EddsaSignature, -} diff --git a/common/taler-common/src/api_wire.rs b/common/taler-common/src/api_wire.rs @@ -1,207 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2024, 2025, 2026 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ - -//! Type for the Taler Wire Gateway HTTP API <https://docs.taler.net/core/api-bank-wire.html#taler-wire-gateway-http-api> - -use compact_str::CompactString; -use serde::{Deserialize, Serialize}; -use url::Url; - -use super::api_common::{EddsaPublicKey, HashCode, SafeU64, ShortHashCode, WadId}; -use crate::{ - api_common::EddsaSignature, - types::{amount::Amount, payto::PaytoURI, timestamp::TalerTimestamp}, -}; - -/// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-WireConfig> -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WireConfig<'a> { - pub name: &'a str, - pub version: &'a str, - pub currency: &'a str, - pub implementation: Option<&'a str>, - pub support_account_check: bool, -} - -/// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-TransferResponse> -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TransferResponse { - pub timestamp: TalerTimestamp, - pub row_id: SafeU64, -} - -/// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-TransferRequest> -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct TransferRequest { - pub request_uid: HashCode, - pub amount: Amount, - pub exchange_base_url: Url, - pub metadata: Option<CompactString>, - pub wtid: ShortHashCode, - pub credit_account: PaytoURI, -} - -/// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-TransferList> -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct TransferList { - pub transfers: Vec<TransferListStatus>, - pub debit_account: PaytoURI, -} - -/// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-TransferListStatus> -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct TransferListStatus { - pub row_id: SafeU64, - pub status: TransferState, - pub amount: Amount, - pub credit_account: PaytoURI, - pub timestamp: TalerTimestamp, -} - -/// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-TransfertSatus> -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct TransferStatus { - pub status: TransferState, - pub status_msg: Option<String>, - pub amount: Amount, - pub origin_exchange_url: String, - pub metadata: Option<CompactString>, - pub wtid: ShortHashCode, - pub credit_account: PaytoURI, - pub timestamp: TalerTimestamp, -} - -/// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-OutgoingHistory> -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OutgoingHistory { - pub outgoing_transactions: Vec<OutgoingBankTransaction>, - pub debit_account: PaytoURI, -} - -/// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-OutgoingBankTransaction> -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct OutgoingBankTransaction { - pub row_id: SafeU64, - pub date: TalerTimestamp, - pub amount: Amount, - pub debit_fee: Option<Amount>, - pub credit_account: PaytoURI, - pub wtid: ShortHashCode, - pub exchange_base_url: Url, - pub metadata: Option<CompactString>, -} - -/// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-IncomingHistory> -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct IncomingHistory { - pub credit_account: PaytoURI, - pub incoming_transactions: Vec<IncomingBankTransaction>, -} - -/// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-IncomingBankTransaction> -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(tag = "type")] -pub enum IncomingBankTransaction { - #[serde(rename = "RESERVE")] - Reserve { - row_id: SafeU64, - date: TalerTimestamp, - amount: Amount, - credit_fee: Option<Amount>, - debit_account: PaytoURI, - reserve_pub: EddsaPublicKey, - authorization_pub: Option<EddsaPublicKey>, - authorization_sig: Option<EddsaSignature>, - }, - #[serde(rename = "WAD")] - Wad { - row_id: SafeU64, - date: TalerTimestamp, - amount: Amount, - debit_account: PaytoURI, - origin_exchange_url: Url, - wad_id: WadId, - }, - #[serde(rename = "KYCAUTH")] - Kyc { - row_id: SafeU64, - date: TalerTimestamp, - amount: Amount, - credit_fee: Option<Amount>, - debit_account: PaytoURI, - account_pub: EddsaPublicKey, - authorization_pub: Option<EddsaPublicKey>, - authorization_sig: Option<EddsaSignature>, - }, -} - -/// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-AddIncomingRequest> -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AddIncomingRequest { - pub amount: Amount, - pub reserve_pub: EddsaPublicKey, - pub debit_account: PaytoURI, -} - -/// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-AddIncomingResponse> -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AddIncomingResponse { - pub row_id: SafeU64, - pub timestamp: TalerTimestamp, -} - -/// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-AddKycauthRequest> -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AddKycauthRequest { - pub amount: Amount, - pub account_pub: EddsaPublicKey, - pub debit_account: PaytoURI, -} - -/// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-AddMappedRequest> -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AddMappedRequest { - pub amount: Amount, - pub authorization_pub: EddsaPublicKey, - pub debit_account: PaytoURI, -} - -/// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-AccountInfo> -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AccountInfo {} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, sqlx::Type)] -#[allow(non_camel_case_types)] -#[sqlx(type_name = "transfer_status")] -pub enum TransferState { - pending, - transient_failure, - permanent_failure, - late_failure, - success, -} - -impl AsRef<str> for TransferState { - fn as_ref(&self) -> &str { - match self { - TransferState::pending => "pending", - TransferState::transient_failure => "transient_failure", - TransferState::permanent_failure => "permanent_failure", - TransferState::late_failure => "late_failure", - TransferState::success => "success", - } - } -} diff --git a/common/taler-common/src/lib.rs b/common/taler-common/src/lib.rs @@ -22,11 +22,7 @@ use tracing_subscriber::util::SubscriberInitExt; use crate::log::taler_logger; -pub mod api_common; -pub mod api_params; -pub mod api_revenue; -pub mod api_transfer; -pub mod api_wire; +pub mod api; pub mod bench; pub mod cli; pub mod config; diff --git a/common/taler-macros/Cargo.toml b/common/taler-macros/Cargo.toml @@ -15,3 +15,4 @@ test = false [dependencies] quote = "1" syn = "2" +proc-macro2 = "1" +\ No newline at end of file diff --git a/common/taler-macros/src/lib.rs b/common/taler-macros/src/lib.rs @@ -16,7 +16,10 @@ use proc_macro::TokenStream; use quote::quote; -use syn::{Data, DeriveInput, Error, Expr, Lit, Meta, parse_macro_input}; +use syn::{ + Data, DeriveInput, Error, Expr, Field, Fields, ItemStruct, Lit, LitStr, Meta, parse::Parser, + parse_macro_input, +}; #[proc_macro_derive(EnumMeta, attributes(enum_meta, code))] pub fn derive_domain_code(input: TokenStream) -> TokenStream { @@ -224,3 +227,106 @@ pub fn derive_domain_code(input: TokenStream) -> TokenStream { TokenStream::from(expanded) } + +#[proc_macro_attribute] +pub fn api_config(attr: TokenStream, item: TokenStream) -> TokenStream { + // 1. Cleanly parse the string attribute argument + let api_name_lit = parse_macro_input!(attr as LitStr); + let api_name_value = api_name_lit.value(); + + // 2. Parse the target struct + let mut input_struct = parse_macro_input!(item as ItemStruct); + let struct_name = &input_struct.ident; + + // 3. Generate deterministic function names for serialization AND deserialization + let struct_lower = struct_name.to_string().to_lowercase(); + let serialize_fn_ident = syn::Ident::new( + &format!("_serialize_api_name_for_{}", struct_lower), + proc_macro2::Span::call_site(), + ); + let deserialize_fn_ident = syn::Ident::new( + &format!("_deserialize_api_name_for_{}", struct_lower), + proc_macro2::Span::call_site(), + ); + + // 4. Convert identifiers to string paths for Serde attributes + let serialize_fn_str = serialize_fn_ident.to_string(); + let deserialize_fn_str = deserialize_fn_ident.to_string(); + + let crate_path = if std::env::var("CARGO_CRATE_NAME").unwrap_or_default() == "taler_common" { + quote! { crate } + } else { + quote! { ::taler_common } + }; + + // 5. Inject the `name` field with BOTH serialization and deserialization hooks + if let Fields::Named(ref mut fields) = input_struct.fields { + fields.named.insert( + 0, + Field::parse_named + .parse2(quote! { + #[serde( + serialize_with = #serialize_fn_str, + deserialize_with = #deserialize_fn_str + )] + pub name: () + }) + .unwrap(), + ); + fields.named.insert( + 1, + Field::parse_named + .parse2(quote! { + pub version: #crate_path::api::LibtoolVersion + }) + .unwrap(), + ); + fields.named.insert( + 2, + Field::parse_named + .parse2(quote! { + pub implementation: Option<&'a str> + }) + .unwrap(), + ); + } else { + return syn::Error::new_spanned( + input_struct, + "#[api_config] only works on structs with named fields", + ) + .to_compile_error() + .into(); + } + + let expanded = quote! { + #input_struct + + #[doc(hidden)] + #[allow(non_snake_case)] + pub fn #serialize_fn_ident<S>(_: &(), s: S) -> ::std::result::Result<S::Ok, S::Error> + where + S: ::serde::Serializer + { + s.serialize_str(#api_name_value) + } + + #[doc(hidden)] + #[allow(non_snake_case)] + pub fn #deserialize_fn_ident<'de, D>(deserializer: D) -> ::std::result::Result<(), D::Error> + where + D: ::serde::Deserializer<'de>, + { + let s: ::std::string::String = ::serde::Deserialize::deserialize(deserializer)?; + if s == #api_name_value { + Ok(()) + } else { + Err(::serde::de::Error::custom(::std::format!( + "invalid API name: expected '{}', found '{}'", + #api_name_value, s + ))) + } + } + }; + + TokenStream::from(expanded) +} diff --git a/common/taler-test-utils/src/lib.rs b/common/taler-test-utils/src/lib.rs @@ -17,6 +17,7 @@ pub use axum::Router; use taler_common::log::taler_logger; use tracing_subscriber::util::SubscriberInitExt; + pub mod db; pub mod json; pub mod routine; diff --git a/common/taler-test-utils/src/routine.rs b/common/taler-test-utils/src/routine.rs @@ -29,13 +29,15 @@ use taler_api::{ subject::fmt_in_subject, }; use taler_common::{ - api_common::{EddsaPublicKey, HashCode, ShortHashCode}, - api_params::PageParams, - api_revenue::RevenueIncomingHistory, - api_transfer::{RegistrationResponse, TransferSubject, TransferType}, - api_wire::{ - IncomingBankTransaction, IncomingHistory, OutgoingHistory, TransferList, TransferRequest, - TransferResponse, TransferState, TransferStatus, + api::{ + EddsaPublicKey, HashCode, ShortHashCode, + params::PageParams, + prepared::{RegistrationResponse, TransferSubject, TransferType}, + revenue::RevenueIncomingHistory, + wire::{ + IncomingBankTransaction, IncomingHistory, OutgoingHistory, TransferList, + TransferRequest, TransferResponse, TransferState, TransferStatus, + }, }, db::IncomingType, error_code::ErrorCode, @@ -59,7 +61,7 @@ impl Page for IncomingHistory { .map(|it| match it { IncomingBankTransaction::Reserve { row_id, .. } | IncomingBankTransaction::Wad { row_id, .. } - | IncomingBankTransaction::Kyc { row_id, .. } => **row_id as i64, + | IncomingBankTransaction::Kyc { row_id, .. } => *row_id as i64, }) .collect() } @@ -69,7 +71,7 @@ impl Page for OutgoingHistory { fn ids(&self) -> Vec<i64> { self.outgoing_transactions .iter() - .map(|it| *it.row_id as i64) + .map(|it| it.row_id as i64) .collect() } } @@ -78,14 +80,14 @@ impl Page for RevenueIncomingHistory { fn ids(&self) -> Vec<i64> { self.incoming_transactions .iter() - .map(|it| *it.row_id as i64) + .map(|it| it.row_id as i64) .collect() } } impl Page for TransferList { fn ids(&self) -> Vec<i64> { - self.transfers.iter().map(|it| *it.row_id as i64).collect() + self.transfers.iter().map(|it| it.row_id as i64).collect() } } @@ -473,13 +475,15 @@ pub async fn transfer_routine( .assert_error(ErrorCode::GENERIC_JSON_INVALID); // Missing receiver-name - server + let res = server .post("/taler-wire-gateway/transfer") .json(&json!(transfer_req + { - "credit_account": credit_account.as_ref().as_str().split_once('?').unwrap().0 + "credit_account": credit_account.as_ref().as_str().split('?').next().unwrap() })) - .await - .assert_error(ErrorCode::GENERIC_PAYTO_URI_MALFORMED); + .await; + if !res.status.is_success() { + res.assert_error(ErrorCode::GENERIC_PAYTO_URI_MALFORMED); + } // TODO check bad payto // TODO Bad base URL @@ -682,50 +686,108 @@ pub struct Tasks<F: AsyncFnMut(usize)> { #[macro_export] macro_rules! tasks { - ( $( $(if $cond:expr =>)? $body:block ),* $(,)? ) => {{ - // Evaluate every condition exactly once, up front - let conditions = [ $( true $( && ($cond) )? ),* ]; + // Create new + ( $( $(if $cond:expr =>)? $body:block ),* $(,)? ) => { + $crate::tasks!(@build 0usize; + $( $(if $cond =>)? $body ),* + ) + }; - // Compact length = number of true conditions - let active_len = conditions.iter().filter(|&&c| c).count(); + // Append to existing + ( $existing:expr ; $( $(if $cond:expr =>)? $body:block ),* $(,)? ) => {{ + let mut existing = $existing; + let mut extra = $crate::tasks![ + $( $(if $cond =>)? $body ),* + ]; $crate::routine::Tasks { - len: active_len, + len: existing.len + extra.len, lambda: async move |i: usize| { - if active_len == 0 { - return + if existing.len == 0 && extra.len == 0 { + return; + } + + let i = i % (existing.len + extra.len); + + if i < existing.len { + (existing.lambda)(i).await; + } else { + (extra.lambda)(i - existing.len).await; } - let i = i % active_len; - let mut cond_idx = 0usize; - let mut current_idx = 0usize; - - $( - // If this specific block's condition passed... - if conditions[cond_idx] { - // ...and it matches the requested execution index - if i == current_idx { - (async $body).await; - return; - } - current_idx += 1; - } - cond_idx += 1; - )* - - let _ = (&mut current_idx, &mut cond_idx); // suppress lints - unreachable!("Index {i} out of bounds for {active_len} active tasks"); } } }}; + + // Internal builder + (@build $idx:expr; $( $(if $cond:expr =>)? $body:block ),* ) => {{ + let conditions = [ $( true $( && $cond )? ),* ]; + let len = conditions.iter().filter(|&&x| x).count(); + + $crate::routine::Tasks { + len, + lambda: async move |i: usize| { + if len == 0 { + return; + } + + let i = i % len; + let mut current = 0usize; + + $crate::tasks!( + @dispatch i, current, conditions, 0usize; + $( $(if $cond =>)? $body ),* + ); + + let _ = (i, &mut current); // suppress lints + + unreachable!() + } + } + }}; + + // Recursive dispatcher + (@dispatch $i:expr, $current:ident, $conditions:ident, $idx:expr;) => {}; + + (@dispatch + $i:expr, + $current:ident, + $conditions:ident, + $idx:expr; + $(if $cond:expr =>)? $body:block + $(, $($rest:tt)*)? + ) => {{ + if $conditions[$idx] { + if $i == $current { + (async $body).await; + return; + } + $current += 1; + } + + $crate::tasks!( + @dispatch + $i, + $current, + $conditions, + $idx + 1usize; + $($($rest)*)? + ); + }}; } /// Test standard behavior of the revenue endpoints -pub async fn revenue_routine(server: &Router, debit_acount: &PaytoURI, kyc: bool) { +pub async fn revenue_routine( + server: &Router, + debit_acount: &PaytoURI, + kyc: bool, + register: Tasks<impl AsyncFnMut(usize)>, + ignore: Tasks<impl AsyncFnMut(usize)>, +) { let currency = &get_currency(server).await; routine_history::<RevenueIncomingHistory>( server, "/taler-revenue/history", - tasks!( + tasks!(register; { server .post("/taler-wire-gateway/admin/add-incoming") @@ -749,155 +811,173 @@ pub async fn revenue_routine(server: &Router, debit_acount: &PaytoURI, kyc: bool .assert_ok_json::<TransferResponse>(); } ), - tasks!(), + ignore, + ) + .await; +} + +/// Test standard behavior of the outgoing history endpoint +pub async fn out_history_routine( + server: &Router, + register: Tasks<impl AsyncFnMut(usize)>, + ignore: Tasks<impl AsyncFnMut(usize)>, +) { + routine_history::<OutgoingHistory>( + server, + "/taler-wire-gateway/history/outgoing", + register, + ignore, ) .await; } /// Test standard behavior of the incoming history endpoint -pub async fn in_history_routine(server: &Router, debit_acount: &PaytoURI, kyc: bool) { +pub async fn in_history_routine( + server: &Router, + debit_acount: &PaytoURI, + kyc: bool, + register: Tasks<impl AsyncFnMut(usize)>, + ignored: Tasks<impl AsyncFnMut(usize)>, +) { let currency = &get_currency(server).await; - // History - // TODO check non taler some are ignored let mut key = Ed25519KeyPair::generate().unwrap(); - let tasks = tasks!( - { - server - .post("/taler-wire-gateway/admin/add-incoming") - .json(json!({ - "amount": format!("{currency}:1"), - "reserve_pub": EddsaPublicKey::rand(), - "debit_account": debit_acount, - })) - .await - .assert_ok_json::<TransferResponse>(); - }, - { - key = Ed25519KeyPair::generate().unwrap(); - let auth_pub = EddsaPublicKey::try_from(key.public_key().as_ref()).unwrap(); - let reserve_pub = EddsaPublicKey::rand(); - let amount = format!("{currency}:2"); - server - .post("/taler-prepared-transfer/registration") - .json(json!({ - "credit_amount": amount, - "type": "reserve", - "alg": "EdDSA", - "account_pub": reserve_pub, - "authorization_pub": auth_pub, - "authorization_sig": eddsa_sign(&key, reserve_pub.as_ref()), - "recurrent": true - })) - .await - .assert_ok_json::<RegistrationResponse>(); - server - .post("/taler-wire-gateway/admin/add-mapped") - .json(json!({ - "amount": amount, - "authorization_pub": auth_pub, - "debit_account": debit_acount, - })) - .await - .assert_ok_json::<TransferResponse>(); - server - .post("/taler-wire-gateway/admin/add-mapped") - .json(json!({ - "amount": amount, - "authorization_pub": auth_pub, - "debit_account": debit_acount, - })) - .await - .assert_ok_json::<TransferResponse>(); - }, - { - let auth_pub = EddsaPublicKey::try_from(key.public_key().as_ref()).unwrap(); - let reserve_pub = EddsaPublicKey::rand(); - server - .post("/taler-prepared-transfer/registration") - .json(json!({ - "credit_amount": format!("{currency}:3"), - "type": "reserve", - "alg": "EdDSA", - "account_pub": reserve_pub, - "authorization_pub": auth_pub, - "authorization_sig": eddsa_sign(&key, reserve_pub.as_ref()), - "recurrent": true - })) - .await - .assert_ok_json::<RegistrationResponse>(); - }, - if kyc => { - server - .post("/taler-wire-gateway/admin/add-kycauth") - .json(json!({ - "amount": format!("{currency}:4"), - "account_pub": EddsaPublicKey::rand(), - "debit_account": debit_acount, - })) - .await - .assert_ok_json::<TransferResponse>(); - }, - if kyc => { - key = Ed25519KeyPair::generate().unwrap(); - let auth_pub = EddsaPublicKey::try_from(key.public_key().as_ref()).unwrap(); - let account_pub = EddsaPublicKey::rand(); - let amount = format!("{currency}:5"); - server - .post("/taler-prepared-transfer/registration") - .json(json!({ - "credit_amount": amount, - "type": "kyc", - "alg": "EdDSA", - "account_pub": account_pub, - "authorization_pub": auth_pub, - "authorization_sig": eddsa_sign(&key, account_pub.as_ref()), - "recurrent": true - })) - .await - .assert_ok_json::<RegistrationResponse>(); - server - .post("/taler-wire-gateway/admin/add-mapped") - .json(json!({ - "amount": amount, - "authorization_pub": auth_pub, - "debit_account": debit_acount, - })) - .await - .assert_ok_json::<TransferResponse>(); - server - .post("/taler-wire-gateway/admin/add-mapped") - .json(json!({ - "amount": amount, - "authorization_pub": auth_pub, - "debit_account": debit_acount, - })) - .await - .assert_ok_json::<TransferResponse>(); - }, - if kyc => { - let auth_pub = EddsaPublicKey::try_from(key.public_key().as_ref()).unwrap(); - let account_pub = EddsaPublicKey::rand(); - server - .post("/taler-prepared-transfer/registration") - .json(json!({ - "credit_amount": format!("{currency}:6"), - "type": "kyc", - "alg": "EdDSA", - "account_pub": account_pub, - "authorization_pub": auth_pub, - "authorization_sig": eddsa_sign(&key, account_pub.as_ref()), - "recurrent": true - })) - .await - .assert_ok_json::<RegistrationResponse>(); - } - ); routine_history::<IncomingHistory>( server, "/taler-wire-gateway/history/incoming", - tasks, - tasks!(), + tasks!(register; + { + server + .post("/taler-wire-gateway/admin/add-incoming") + .json(json!({ + "amount": format!("{currency}:1"), + "reserve_pub": EddsaPublicKey::rand(), + "debit_account": debit_acount, + })) + .await + .assert_ok_json::<TransferResponse>(); + }, + { + key = Ed25519KeyPair::generate().unwrap(); + let auth_pub = EddsaPublicKey::try_from(key.public_key().as_ref()).unwrap(); + let reserve_pub = EddsaPublicKey::rand(); + let amount = format!("{currency}:2"); + server + .post("/taler-prepared-transfer/registration") + .json(json!({ + "credit_amount": amount, + "type": "reserve", + "alg": "EdDSA", + "account_pub": reserve_pub, + "authorization_pub": auth_pub, + "authorization_sig": eddsa_sign(&key, reserve_pub.as_ref()), + "recurrent": true + })) + .await + .assert_ok_json::<RegistrationResponse>(); + server + .post("/taler-wire-gateway/admin/add-mapped") + .json(json!({ + "amount": amount, + "authorization_pub": auth_pub, + "debit_account": debit_acount, + })) + .await + .assert_ok_json::<TransferResponse>(); + server + .post("/taler-wire-gateway/admin/add-mapped") + .json(json!({ + "amount": amount, + "authorization_pub": auth_pub, + "debit_account": debit_acount, + })) + .await + .assert_ok_json::<TransferResponse>(); + }, + { + let auth_pub = EddsaPublicKey::try_from(key.public_key().as_ref()).unwrap(); + let reserve_pub = EddsaPublicKey::rand(); + server + .post("/taler-prepared-transfer/registration") + .json(json!({ + "credit_amount": format!("{currency}:3"), + "type": "reserve", + "alg": "EdDSA", + "account_pub": reserve_pub, + "authorization_pub": auth_pub, + "authorization_sig": eddsa_sign(&key, reserve_pub.as_ref()), + "recurrent": true + })) + .await + .assert_ok_json::<RegistrationResponse>(); + }, + if kyc => { + server + .post("/taler-wire-gateway/admin/add-kycauth") + .json(json!({ + "amount": format!("{currency}:4"), + "account_pub": EddsaPublicKey::rand(), + "debit_account": debit_acount, + })) + .await + .assert_ok_json::<TransferResponse>(); + }, + if kyc => { + key = Ed25519KeyPair::generate().unwrap(); + let auth_pub = EddsaPublicKey::try_from(key.public_key().as_ref()).unwrap(); + let account_pub = EddsaPublicKey::rand(); + let amount = format!("{currency}:5"); + server + .post("/taler-prepared-transfer/registration") + .json(json!({ + "credit_amount": amount, + "type": "kyc", + "alg": "EdDSA", + "account_pub": account_pub, + "authorization_pub": auth_pub, + "authorization_sig": eddsa_sign(&key, account_pub.as_ref()), + "recurrent": true + })) + .await + .assert_ok_json::<RegistrationResponse>(); + server + .post("/taler-wire-gateway/admin/add-mapped") + .json(json!({ + "amount": amount, + "authorization_pub": auth_pub, + "debit_account": debit_acount, + })) + .await + .assert_ok_json::<TransferResponse>(); + server + .post("/taler-wire-gateway/admin/add-mapped") + .json(json!({ + "amount": amount, + "authorization_pub": auth_pub, + "debit_account": debit_acount, + })) + .await + .assert_ok_json::<TransferResponse>(); + }, + if kyc => { + let auth_pub = EddsaPublicKey::try_from(key.public_key().as_ref()).unwrap(); + let account_pub = EddsaPublicKey::rand(); + server + .post("/taler-prepared-transfer/registration") + .json(json!({ + "credit_amount": format!("{currency}:6"), + "type": "kyc", + "alg": "EdDSA", + "account_pub": account_pub, + "authorization_pub": auth_pub, + "authorization_sig": eddsa_sign(&key, account_pub.as_ref()), + "recurrent": true + })) + .await + .assert_ok_json::<RegistrationResponse>(); + } + ), + ignored, ) .await; } @@ -992,7 +1072,7 @@ pub async fn registration_routine<F1: Future<Output = Vec<Status>>>( for sub in res.subjects { if let TransferSubject::Simple { subject, .. } = sub { - assert_eq!(subject, fmt_in_subject(fmt, &auth_pub1)); + assert_eq!(subject, fmt_in_subject(fmt, &auth_pub1).to_string()); }; } }; @@ -1334,7 +1414,6 @@ pub async fn registration_routine<F1: Future<Output = Vec<Status>>>( .assert_no_content(); // Check bounce pending on deletion - check_in(&[ Reserve(acc_pub1.clone()), Reserve(acc_pub2.clone()), diff --git a/common/taler-test-utils/src/server.rs b/common/taler-test-utils/src/server.rs @@ -19,21 +19,25 @@ use std::{fmt::Debug, io::Write, pin::Pin}; use axum::{ Router, body::{Body, Bytes}, - extract::Query, + extract::{Query, Request, State}, http::{ HeaderMap, HeaderValue, Method, StatusCode, Uri, header::{self, AUTHORIZATION, AsHeaderName, IntoHeaderName}, + uri::PathAndQuery, }, + middleware::{self}, }; use flate2::{Compression, write::ZlibEncoder}; use http_body_util::BodyExt as _; use serde::{Deserialize, Serialize, de::DeserializeOwned}; -use taler_common::{api_common::ErrorDetail, encoding::base64, error_code::ErrorCode}; +use taler_common::{api::ErrorDetail, encoding::base64, error_code::ErrorCode}; use tower::ServiceExt as _; use tracing::warn; use url::Url; pub trait TestServer { + fn prefix(&self, prefix: &'static str) -> Self; + fn request(&self, method: Method, path: impl AsRef<str>) -> TestRequest; fn get(&self, path: impl AsRef<str>) -> TestRequest { @@ -50,6 +54,31 @@ pub trait TestServer { } impl TestServer for Router { + fn prefix(&self, prefix: &'static str) -> Self { + Router::new() + .fallback_service(self.clone().into_service()) + .layer(middleware::map_request_with_state( + prefix, + async |State(prefix): State<&'static str>, mut req: Request| { + let uri = req.uri().clone(); + let mut parts = uri.into_parts(); + + let path_and_query = parts.path_and_query.unwrap(); + let current_path = path_and_query.path(); + + let new_path_and_query = match path_and_query.query() { + Some(query) => format!("{prefix}{}?{}", current_path, query), + None => format!("{prefix}{}", current_path), + }; + + let new_pq = PathAndQuery::from_maybe_shared(new_path_and_query).unwrap(); + parts.path_and_query = Some(new_pq); + *req.uri_mut() = Uri::from_parts(parts).unwrap(); + req + }, + )) + } + fn request(&self, method: Method, path: impl AsRef<str>) -> TestRequest { let url = format!("https://example{}", path.as_ref()); TestRequest { diff --git a/taler-cyclos/src/api.rs b/taler-cyclos/src/api.rs @@ -17,21 +17,23 @@ use compact_str::CompactString; use jiff::Timestamp; use taler_api::{ - api::{TalerApi, revenue::Revenue, transfer::PreparedTransfer, wire::WireGateway}, - error::{ApiResult, failure, failure_code}, + api::{TalerApi, prepared::PreparedTransfer, revenue::Revenue, wire::WireGateway}, + error::{ApiResult, failure_code}, subject::{IncomingSubject, fmt_in_subject}, }; use taler_common::{ - api_common::safe_u64, - api_params::{History, Page}, - api_revenue::RevenueIncomingHistory, - api_transfer::{ - RegistrationRequest, RegistrationResponse, SubjectFormat, TransferSubject, Unregistration, - }, - api_wire::{ - AddIncomingRequest, AddIncomingResponse, AddKycauthRequest, AddMappedRequest, - IncomingHistory, OutgoingHistory, TransferList, TransferRequest, TransferResponse, - TransferState, TransferStatus, + api::{ + params::{History, Page}, + prepared::{ + RegistrationRequest, RegistrationResponse, SubjectFormat, TransferSubject, + Unregistration, + }, + revenue::RevenueIncomingHistory, + wire::{ + AddIncomingRequest, AddIncomingResponse, AddKycauthRequest, AddMappedRequest, + IncomingHistory, OutgoingHistory, TransferList, TransferRequest, TransferResponse, + TransferState, TransferStatus, + }, }, db::IncomingType, error_code::ErrorCode, @@ -88,8 +90,8 @@ impl CyclosApi { } impl TalerApi for CyclosApi { - fn currency(&self) -> &str { - self.currency.as_ref() + fn currency(&self) -> Currency { + self.currency } fn implementation(&self) -> &'static str { @@ -117,7 +119,7 @@ impl WireGateway for CyclosApi { match result { db::TransferResult::Success { id, initiated_at } => Ok(TransferResponse { timestamp: initiated_at.into(), - row_id: safe_u64(id), + row_id: id, }), db::TransferResult::RequestUidReuse => { Err(failure_code(ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED)) @@ -193,7 +195,7 @@ impl WireGateway for CyclosApi { AddIncomingResult::Success { row_id, valued_at, .. } => Ok(AddIncomingResponse { - row_id: safe_u64(row_id), + row_id, timestamp: valued_at.into(), }), AddIncomingResult::ReservePubReuse => { @@ -223,7 +225,7 @@ impl WireGateway for CyclosApi { AddIncomingResult::Success { row_id, valued_at, .. } => Ok(AddIncomingResponse { - row_id: safe_u64(row_id), + row_id, timestamp: valued_at.into(), }), AddIncomingResult::ReservePubReuse => { @@ -253,7 +255,7 @@ impl WireGateway for CyclosApi { AddIncomingResult::Success { row_id, valued_at, .. } => Ok(AddIncomingResponse { - row_id: safe_u64(row_id), + row_id, timestamp: valued_at.into(), }), AddIncomingResult::ReservePubReuse => { @@ -300,9 +302,9 @@ impl PreparedTransfer for CyclosApi { let simple = TransferSubject::Simple { credit_amount: req.credit_amount, subject: if req.authorization_pub == req.account_pub && !req.recurrent { - fmt_in_subject(req.r#type.into(), &req.account_pub) + fmt_in_subject(req.r#type.into(), &req.account_pub).to_string() } else { - fmt_in_subject(IncomingType::map, &req.authorization_pub) + fmt_in_subject(IncomingType::map, &req.authorization_pub).to_string() }, }; ApiResult::Ok(RegistrationResponse { @@ -316,15 +318,8 @@ impl PreparedTransfer for CyclosApi { } } - async fn unregistration(&self, req: Unregistration) -> ApiResult<()> { - if !db::transfer_unregister(&self.pool, &req).await? { - Err(failure( - ErrorCode::BANK_TRANSACTION_NOT_FOUND, - format!("Prepared transfer '{}' not found", req.authorization_pub), - )) - } else { - Ok(()) - } + async fn unregistration(&self, req: Unregistration) -> ApiResult<bool> { + Ok(db::transfer_unregister(&self.pool, &req).await?) } } @@ -339,12 +334,18 @@ mod test { use jiff::Timestamp; use sqlx::{PgPool, Row as _, postgres::PgRow}; use taler_api::{ - api::TalerRouter as _, auth::AuthMethod, db::TypeHelper as _, subject::OutgoingSubject, + api::TalerRouter as _, + auth::AuthMethod, + db::TypeHelper as _, + subject::{IncomingSubject, OutgoingSubject}, }; use taler_common::{ - api_revenue::RevenueConfig, - api_transfer::PreparedTransferConfig, - api_wire::{OutgoingHistory, TransferState, WireConfig}, + api::{ + EddsaPublicKey, + prepared::PreparedTransferConfig, + revenue::RevenueConfig, + wire::{TransferState, WireConfig}, + }, db::IncomingType, types::{ amount::{Currency, decimal}, @@ -355,8 +356,8 @@ mod test { Router, db::db_test_setup, routine::{ - Status, admin_add_incoming_routine, in_history_routine, registration_routine, - revenue_routine, routine_pagination, transfer_routine, + Status, admin_add_incoming_routine, in_history_routine, out_history_routine, + registration_routine, revenue_routine, transfer_routine, }, server::TestServer as _, tasks, @@ -365,7 +366,7 @@ mod test { use crate::{ api::CyclosApi, constants::CONFIG_SOURCE, - db::{self, TxOutKind}, + db::{self, TxIn, TxOutKind}, }; static ACCOUNT: LazyLock<PaytoURI> = @@ -411,33 +412,82 @@ mod test { transfer_routine(&server, TransferState::pending, &ACCOUNT).await; } + static CODE: AtomicI64 = AtomicI64::new(0); + + async fn r#in(db: &PgPool, subject: Option<IncomingSubject>) { + let now = Timestamp::now(); + db::register_tx_in( + &mut db.acquire().await.unwrap(), + &TxIn { + transfer_id: CODE.fetch_add(1, Ordering::Relaxed), + tx_id: None, + amount: decimal("10"), + subject: "subject".to_owned(), + debtor_id: 31000163100000000, + debtor_name: "Name".into(), + valued_at: Timestamp::now(), + }, + &subject, + &now, + ) + .await + .unwrap(); + } + + async fn in_malformed(db: &PgPool) { + r#in(db, None).await + } + + async fn in_talerable(db: &PgPool) { + r#in(db, Some(IncomingSubject::Reserve(EddsaPublicKey::rand()))).await + } + + async fn out(db: &PgPool, kind: &TxOutKind) { + let i = CODE.fetch_add(1, Ordering::Relaxed); + let now = Timestamp::now(); + db::register_tx_out( + &mut db.acquire().await.unwrap(), + &db::TxOut { + transfer_id: i, + tx_id: if i % 2 == 0 { Some(i % 2) } else { None }, + amount: decimal("10"), + subject: "subject".to_owned(), + creditor_id: 31000163100000000, + creditor_name: "Name".into(), + valued_at: now, + }, + kind, + &now, + ) + .await + .unwrap(); + } + + async fn out_talerable(db: &PgPool) { + out(db, &TxOutKind::Talerable(OutgoingSubject::rand())).await + } + + async fn out_bounce(db: &PgPool) { + out(db, &TxOutKind::Bounce(CODE.load(Ordering::Relaxed))).await + } + + async fn out_malformed(db: &PgPool) { + out(db, &TxOutKind::Simple).await + } + #[tokio::test] async fn outgoing_history() { - let (server, pool) = setup().await; - static CODE: AtomicI64 = AtomicI64::new(0); + let (server, db) = &setup().await; - routine_pagination::<OutgoingHistory>( + out_history_routine( &server, - "/taler-wire-gateway/history/outgoing", - tasks!({ - let i = CODE.fetch_add(1, Ordering::Relaxed); - db::register_tx_out( - &mut pool.acquire().await.unwrap(), - &db::TxOut { - transfer_id: i, - tx_id: if i % 2 == 0 { Some(i % 2) } else { None }, - amount: decimal("10"), - subject: "subject".to_owned(), - creditor_id: 31000163100000000, - creditor_name: "Name".into(), - valued_at: Timestamp::now(), - }, - &TxOutKind::Talerable(OutgoingSubject::rand()), - &Timestamp::now(), - ) - .await - .unwrap(); - }), + tasks!({ out_talerable(db).await }), + tasks!( + { out_bounce(db).await }, + { out_malformed(db).await }, + { in_malformed(db).await }, + { in_talerable(db).await } + ), ) .await; } @@ -450,14 +500,35 @@ mod test { #[tokio::test] async fn in_history() { - let (server, _) = setup().await; - in_history_routine(&server, &ACCOUNT, true).await; + let (server, db) = &setup().await; + in_history_routine( + &server, + &ACCOUNT, + true, + tasks!({ in_talerable(db).await }), + tasks!( + { out_malformed(db).await }, + { out_talerable(db).await }, + { out_bounce(db).await }, + { in_malformed(db).await } + ), + ) + .await; } #[tokio::test] async fn revenue() { - let (server, _) = setup().await; - revenue_routine(&server, &ACCOUNT, true).await; + let (server, db) = &setup().await; + revenue_routine( + &server, + &ACCOUNT, + true, + tasks!({ in_malformed(db).await }, { in_talerable(db).await },), + tasks!({ out_malformed(db).await }, { out_talerable(db).await }, { + out_bounce(db).await + }), + ) + .await; } async fn check_in(pool: &PgPool) -> Vec<Status> { diff --git a/taler-cyclos/src/bin/cyclos-harness.rs b/taler-cyclos/src/bin/cyclos-harness.rs @@ -26,9 +26,11 @@ use taler_api::notification::dummy_listen; use taler_build::long_version; use taler_common::{ CommonArgs, - api_common::{EddsaPublicKey, HashCode, ShortHashCode}, - api_params::{History, Page, Pooling}, - api_wire::{IncomingBankTransaction, TransferState}, + api::{ + EddsaPublicKey, HashCode, ShortHashCode, + params::{History, Page, Pooling}, + wire::{IncomingBankTransaction, TransferState}, + }, config::Config, taler_main, types::{ diff --git a/taler-cyclos/src/db.rs b/taler-cyclos/src/db.rs @@ -26,13 +26,15 @@ use taler_api::{ subject::{IncomingSubject, OutgoingSubject, fmt_out_subject}, }; use taler_common::{ - api_common::{HashCode, ShortHashCode}, - api_params::{History, Page}, - api_revenue::RevenueIncomingBankTransaction, - api_transfer::{RegistrationRequest, Unregistration}, - api_wire::{ - IncomingBankTransaction, OutgoingBankTransaction, TransferListStatus, TransferState, - TransferStatus, + api::{ + HashCode, ShortHashCode, + params::{History, Page}, + prepared::{RegistrationRequest, Unregistration}, + revenue::RevenueIncomingBankTransaction, + wire::{ + IncomingBankTransaction, OutgoingBankTransaction, TransferListStatus, TransferState, + TransferStatus, + }, }, config::Config, db::IncomingType, @@ -494,7 +496,7 @@ pub async fn transfer_page( }, |r: PgRow| { Ok(TransferListStatus { - row_id: r.try_get_safeu64(0)?, + row_id: r.try_get_u64(0)?, status: r.try_get(1)?, amount: r.try_get_amount(2, currency)?, credit_account: r.try_get_cyclos_fullpaytouri(3, 4, root)?, @@ -537,7 +539,7 @@ pub async fn outgoing_history( }, |r: PgRow| { Ok(OutgoingBankTransaction { - row_id: r.try_get_safeu64(0)?, + row_id: r.try_get_u64(0)?, amount: r.try_get_amount(1, currency)?, debit_fee: None, credit_account: r.try_get_cyclos_fullpaytouri(2, 3, root)?, @@ -585,7 +587,7 @@ pub async fn incoming_history( |r: PgRow| { Ok(match r.try_get(0)? { IncomingType::reserve => IncomingBankTransaction::Reserve { - row_id: r.try_get_safeu64(1)?, + row_id: r.try_get_u64(1)?, amount: r.try_get_amount(2, currency)?, credit_fee: None, debit_account: r.try_get_cyclos_fullpaytouri(3, 4, root)?, @@ -595,7 +597,7 @@ pub async fn incoming_history( authorization_sig: r.try_get(8)?, }, IncomingType::kyc => IncomingBankTransaction::Kyc { - row_id: r.try_get_safeu64(1)?, + row_id: r.try_get_u64(1)?, amount: r.try_get_amount(2, currency)?, credit_fee: None, debit_account: r.try_get_cyclos_fullpaytouri(3, 4, root)?, @@ -640,7 +642,7 @@ pub async fn revenue_history( }, |r: PgRow| { Ok(RevenueIncomingBankTransaction { - row_id: r.try_get_safeu64(0)?, + row_id: r.try_get_u64(0)?, date: r.try_get_timestamp(1)?.into(), amount: r.try_get_amount(2, currency)?, credit_fee: None, @@ -921,9 +923,11 @@ mod test { subject::{IncomingSubject, OutgoingSubject}, }; use taler_common::{ - api_common::{EddsaPublicKey, HashCode, ShortHashCode}, - api_params::{History, Page}, - api_wire::TransferState, + api::{ + EddsaPublicKey, HashCode, ShortHashCode, + params::{History, Page}, + wire::TransferState, + }, types::{ amount::{Currency, decimal}, url, diff --git a/taler-magnet-bank/src/api.rs b/taler-magnet-bank/src/api.rs @@ -16,25 +16,27 @@ use jiff::Timestamp; use taler_api::{ - api::{TalerApi, revenue::Revenue, transfer::PreparedTransfer, wire::WireGateway}, - error::{ApiResult, failure, failure_code}, + api::{TalerApi, prepared::PreparedTransfer, revenue::Revenue, wire::WireGateway}, + error::{ApiResult, failure_code}, subject::{IncomingSubject, fmt_in_subject}, }; use taler_common::{ - api_common::safe_u64, - api_params::{History, Page}, - api_revenue::RevenueIncomingHistory, - api_transfer::{ - RegistrationRequest, RegistrationResponse, SubjectFormat, TransferSubject, Unregistration, - }, - api_wire::{ - AddIncomingRequest, AddIncomingResponse, AddKycauthRequest, AddMappedRequest, - IncomingHistory, OutgoingHistory, TransferList, TransferRequest, TransferResponse, - TransferState, TransferStatus, + api::{ + params::{History, Page}, + prepared::{ + RegistrationRequest, RegistrationResponse, SubjectFormat, TransferSubject, + Unregistration, + }, + revenue::RevenueIncomingHistory, + wire::{ + AddIncomingRequest, AddIncomingResponse, AddKycauthRequest, AddMappedRequest, + IncomingHistory, OutgoingHistory, TransferList, TransferRequest, TransferResponse, + TransferState, TransferStatus, + }, }, db::IncomingType, error_code::ErrorCode, - types::{payto::PaytoURI, timestamp::TalerTimestamp, utils::date_to_utc_ts}, + types::{amount::Currency, payto::PaytoURI, timestamp::TalerTimestamp, utils::date_to_utc_ts}, }; use tokio::sync::watch::Sender; @@ -79,8 +81,8 @@ impl MagnetApi { } impl TalerApi for MagnetApi { - fn currency(&self) -> &str { - CURR.as_ref() + fn currency(&self) -> Currency { + CURR } fn implementation(&self) -> &'static str { @@ -107,7 +109,7 @@ impl WireGateway for MagnetApi { match result { db::TransferResult::Success { id, initiated_at } => Ok(TransferResponse { timestamp: initiated_at.into(), - row_id: safe_u64(id), + row_id: id, }), db::TransferResult::RequestUidReuse => { Err(failure_code(ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED)) @@ -173,7 +175,7 @@ impl WireGateway for MagnetApi { AddIncomingResult::Success { row_id, valued_at, .. } => Ok(AddIncomingResponse { - row_id: safe_u64(row_id), + row_id, timestamp: date_to_utc_ts(&valued_at).into(), }), AddIncomingResult::ReservePubReuse => { @@ -202,7 +204,7 @@ impl WireGateway for MagnetApi { AddIncomingResult::Success { row_id, valued_at, .. } => Ok(AddIncomingResponse { - row_id: safe_u64(row_id), + row_id, timestamp: date_to_utc_ts(&valued_at).into(), }), AddIncomingResult::ReservePubReuse => unreachable!("kyc"), @@ -229,7 +231,7 @@ impl WireGateway for MagnetApi { AddIncomingResult::Success { row_id, valued_at, .. } => Ok(AddIncomingResponse { - row_id: safe_u64(row_id), + row_id, timestamp: date_to_utc_ts(&valued_at).into(), }), AddIncomingResult::ReservePubReuse => { @@ -272,9 +274,9 @@ impl PreparedTransfer for MagnetApi { let simple = TransferSubject::Simple { credit_amount: req.credit_amount, subject: if req.authorization_pub == req.account_pub && !req.recurrent { - fmt_in_subject(req.r#type.into(), &req.account_pub) + fmt_in_subject(req.r#type.into(), &req.account_pub).to_string() } else { - fmt_in_subject(IncomingType::map, &req.authorization_pub) + fmt_in_subject(IncomingType::map, &req.authorization_pub).to_string() }, }; ApiResult::Ok(RegistrationResponse { @@ -288,15 +290,8 @@ impl PreparedTransfer for MagnetApi { } } - async fn unregistration(&self, req: Unregistration) -> ApiResult<()> { - if !db::transfer_unregister(&self.pool, &req).await? { - Err(failure( - ErrorCode::BANK_TRANSACTION_NOT_FOUND, - format!("Prepared transfer '{}' not found", req.authorization_pub), - )) - } else { - Ok(()) - } + async fn unregistration(&self, req: Unregistration) -> ApiResult<bool> { + Ok(db::transfer_unregister(&self.pool, &req).await?) } } @@ -311,12 +306,18 @@ mod test { use jiff::{Timestamp, Zoned}; use sqlx::{PgPool, Row as _, postgres::PgRow}; use taler_api::{ - api::TalerRouter as _, auth::AuthMethod, db::TypeHelper as _, subject::OutgoingSubject, + api::TalerRouter as _, + auth::AuthMethod, + db::TypeHelper as _, + subject::{IncomingSubject, OutgoingSubject}, }; use taler_common::{ - api_revenue::RevenueConfig, - api_transfer::PreparedTransferConfig, - api_wire::{OutgoingHistory, TransferState, WireConfig}, + api::{ + EddsaPublicKey, + prepared::PreparedTransferConfig, + revenue::RevenueConfig, + wire::{TransferState, WireConfig}, + }, db::IncomingType, types::{ amount::amount, @@ -327,8 +328,8 @@ mod test { Router, db::db_test_setup, routine::{ - Status, admin_add_incoming_routine, in_history_routine, registration_routine, - revenue_routine, routine_pagination, transfer_routine, + Status, admin_add_incoming_routine, in_history_routine, out_history_routine, + registration_routine, revenue_routine, transfer_routine, }, server::TestServer, tasks, @@ -338,7 +339,7 @@ mod test { FullHuPayto, api::MagnetApi, constants::CONFIG_SOURCE, - db::{self, TxOutKind}, + db::{self, TxIn, TxOutKind}, magnet_api::types::TxStatus, magnet_payto, }; @@ -388,32 +389,79 @@ mod test { .await; } + static CODE: AtomicU64 = AtomicU64::new(0); + + async fn r#in(db: &PgPool, subject: Option<IncomingSubject>) { + db::register_tx_in( + &mut db.acquire().await.unwrap(), + &TxIn { + code: CODE.fetch_add(1, Ordering::Relaxed), + amount: amount("EUR:10"), + subject: "subject".into(), + debtor: magnet_payto( + "payto://iban/HU30162000031000163100000000?receiver-name=name", + ), + value_date: Zoned::now().date(), + status: TxStatus::Completed, + }, + &subject, + &Timestamp::now(), + ) + .await + .unwrap(); + } + + async fn in_malformed(db: &PgPool) { + r#in(db, None).await + } + + async fn in_talerable(db: &PgPool) { + r#in(db, Some(IncomingSubject::Reserve(EddsaPublicKey::rand()))).await + } + + async fn out(db: &PgPool, kind: &TxOutKind) { + db::register_tx_out( + &mut db.acquire().await.unwrap(), + &db::TxOut { + code: CODE.fetch_add(1, Ordering::Relaxed), + amount: amount("EUR:10"), + subject: "subject".into(), + creditor: PAYTO.clone(), + value_date: Zoned::now().date(), + status: TxStatus::Completed, + }, + kind, + &Timestamp::now(), + ) + .await + .unwrap(); + } + + async fn out_talerable(db: &PgPool) { + out(db, &TxOutKind::Talerable(OutgoingSubject::rand())).await + } + + async fn out_bounce(db: &PgPool) { + out(db, &TxOutKind::Bounce(CODE.load(Ordering::Relaxed) as u32)).await + } + + async fn out_malformed(db: &PgPool) { + out(db, &TxOutKind::Simple).await + } + #[tokio::test] async fn outgoing_history() { - let (server, pool) = setup().await; - static CODE: AtomicU64 = AtomicU64::new(0); - routine_pagination::<OutgoingHistory>( + let (server, db) = &setup().await; + + out_history_routine( &server, - "/taler-wire-gateway/history/outgoing", - tasks!({ - let mut conn = pool.acquire().await.unwrap(); - let now = Zoned::now().date(); - db::register_tx_out( - &mut conn, - &db::TxOut { - code: CODE.fetch_add(1, Ordering::Relaxed), - amount: amount("EUR:10"), - subject: "subject".into(), - creditor: PAYTO.clone(), - value_date: now, - status: TxStatus::Completed, - }, - &TxOutKind::Talerable(OutgoingSubject::rand()), - &Timestamp::now(), - ) - .await - .unwrap(); - }), + tasks!({ out_talerable(db).await }), + tasks!( + { out_bounce(db).await }, + { out_malformed(db).await }, + { in_malformed(db).await }, + { in_talerable(db).await } + ), ) .await; } @@ -426,14 +474,35 @@ mod test { #[tokio::test] async fn in_history() { - let (server, _) = setup().await; - in_history_routine(&server, &ACCOUNT, true).await; + let (server, db) = &setup().await; + in_history_routine( + &server, + &ACCOUNT, + true, + tasks!({ in_talerable(db).await }), + tasks!( + { out_malformed(db).await }, + { out_talerable(db).await }, + { out_bounce(db).await }, + { in_malformed(db).await } + ), + ) + .await; } #[tokio::test] async fn revenue() { - let (server, _) = setup().await; - revenue_routine(&server, &ACCOUNT, true).await; + let (server, db) = &setup().await; + revenue_routine( + &server, + &ACCOUNT, + true, + tasks!({ in_malformed(db).await }, { in_talerable(db).await },), + tasks!({ out_malformed(db).await }, { out_talerable(db).await }, { + out_bounce(db).await + }), + ) + .await; } async fn check_in(pool: &PgPool) -> Vec<Status> { diff --git a/taler-magnet-bank/src/bin/magnet-bank-harness.rs b/taler-magnet-bank/src/bin/magnet-bank-harness.rs @@ -26,9 +26,11 @@ use taler_api::notification::dummy_listen; use taler_build::long_version; use taler_common::{ CommonArgs, - api_common::{EddsaPublicKey, HashCode, ShortHashCode}, - api_params::{History, Page, Pooling}, - api_wire::{IncomingBankTransaction, TransferState}, + api::{ + EddsaPublicKey, HashCode, ShortHashCode, + params::{History, Page, Pooling}, + wire::{IncomingBankTransaction, TransferState}, + }, config::Config, db::{dbinit, pool}, taler_main, diff --git a/taler-magnet-bank/src/db.rs b/taler-magnet-bank/src/db.rs @@ -26,13 +26,15 @@ use taler_api::{ subject::{IncomingSubject, OutgoingSubject, fmt_out_subject}, }; use taler_common::{ - api_common::{HashCode, ShortHashCode}, - api_params::{History, Page}, - api_revenue::RevenueIncomingBankTransaction, - api_transfer::{RegistrationRequest, Unregistration}, - api_wire::{ - IncomingBankTransaction, OutgoingBankTransaction, TransferListStatus, TransferState, - TransferStatus, + api::{ + HashCode, ShortHashCode, + params::{History, Page}, + prepared::{RegistrationRequest, Unregistration}, + revenue::RevenueIncomingBankTransaction, + wire::{ + IncomingBankTransaction, OutgoingBankTransaction, TransferListStatus, TransferState, + TransferStatus, + }, }, config::Config, db::IncomingType, @@ -312,7 +314,7 @@ pub async fn register_tx_out( serialized!({ let query = sqlx::query( " - SELECT out_result, out_tx_row_id + SELECT out_result, out_tx_row_id FROM register_tx_out($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ", ) @@ -366,7 +368,7 @@ pub async fn register_tx_out_failure( serialized!( sqlx::query( " - SELECT out_new, out_initiated_id + SELECT out_new, out_initiated_id FROM register_tx_out_failure($1, $2, $3) ", ) @@ -498,7 +500,7 @@ pub async fn transfer_page( credit_account, credit_name, initiated_at - FROM transfer + FROM transfer JOIN initiated USING (initiated_id) WHERE ", @@ -510,7 +512,7 @@ pub async fn transfer_page( }, |r: PgRow| { Ok(TransferListStatus { - row_id: r.try_get_safeu64(0)?, + row_id: r.try_get_u64(0)?, status: r.try_get(1)?, amount: r.try_get_amount(2, &CURR)?, credit_account: r.try_get_iban(3)?.as_full_uri(r.try_get(4)?), @@ -551,7 +553,7 @@ pub async fn outgoing_history( }, |r: PgRow| { Ok(OutgoingBankTransaction { - row_id: r.try_get_safeu64(0)?, + row_id: r.try_get_u64(0)?, amount: r.try_get_amount(1, &CURR)?, debit_fee: None, credit_account: r.try_get_iban(2)?.as_full_uri(r.try_get(3)?), @@ -597,7 +599,7 @@ pub async fn incoming_history( |r: PgRow| { Ok(match r.try_get(0)? { IncomingType::reserve => IncomingBankTransaction::Reserve { - row_id: r.try_get_safeu64(1)?, + row_id: r.try_get_u64(1)?, amount: r.try_get_amount(2, &CURR)?, credit_fee: None, debit_account: r.try_get_iban(3)?.as_full_uri(r.try_get(4)?), @@ -607,7 +609,7 @@ pub async fn incoming_history( authorization_sig: r.try_get(8)?, }, IncomingType::kyc => IncomingBankTransaction::Kyc { - row_id: r.try_get_safeu64(1)?, + row_id: r.try_get_u64(1)?, amount: r.try_get_amount(2, &CURR)?, credit_fee: None, debit_account: r.try_get_iban(3)?.as_full_uri(r.try_get(4)?), @@ -650,7 +652,7 @@ pub async fn revenue_history( }, |r: PgRow| { Ok(RevenueIncomingBankTransaction { - row_id: r.try_get_safeu64(0)?, + row_id: r.try_get_u64(0)?, date: r.try_get_timestamp(1)?.into(), amount: r.try_get_amount(2, &CURR)?, credit_fee: None, @@ -676,8 +678,8 @@ pub async fn transfer_by_id(db: &PgPool, id: u64) -> sqlx::Result<Option<Transfe credit_account, credit_name, initiated_at - FROM transfer - JOIN initiated USING (initiated_id) + FROM transfer + JOIN initiated USING (initiated_id) WHERE initiated_id = $1 ", ) @@ -707,7 +709,7 @@ pub async fn pending_batch( sqlx::query( " SELECT initiated_id, amount, subject, credit_account, credit_name - FROM initiated + FROM initiated WHERE magnet_code IS NULL AND status='pending' AND (last_submitted IS NULL OR last_submitted < $1) @@ -736,7 +738,7 @@ pub async fn initiated_by_code( sqlx::query( " SELECT initiated_id, amount, subject, credit_account, credit_name - FROM initiated + FROM initiated WHERE magnet_code IS $1 ", ) @@ -887,8 +889,10 @@ mod test { subject::{IncomingSubject, OutgoingSubject}, }; use taler_common::{ - api_common::{EddsaPublicKey, HashCode, ShortHashCode}, - api_params::{History, Page}, + api::{ + EddsaPublicKey, HashCode, ShortHashCode, + params::{History, Page}, + }, types::{ amount::{amount, decimal}, url,