taler-rust

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

commit 6c313f6bf54c394179988f761e87cbbbf27290b9
parent a21fa61c42d0c20807af46ef435f529f87889edd
Author: Antoine A <>
Date:   Wed, 22 Jan 2025 15:43:43 +0100

magnet:  magnet bank payto and better errors

Diffstat:
MCargo.lock | 29+++++++++++++++--------------
Mcommon/taler-api/Cargo.toml | 1+
Mcommon/taler-api/src/error.rs | 45+++++++++++++++++++++++++++++++++++++++------
Acommon/taler-api/src/json.rs | 128+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/taler-api/src/lib.rs | 129++++---------------------------------------------------------------------------
Mcommon/taler-common/src/api_params.rs | 33++++++++++++++++++++-------------
Mcommon/taler-common/src/types/payto.rs | 12++++++++++--
Mcommon/test-utils/src/routine.rs | 14++++++++------
Mwire-gateway/magnet-bank/db/schema.sql | 40++++++++++++++++++++++++++--------------
Mwire-gateway/magnet-bank/src/db.rs | 147++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mwire-gateway/magnet-bank/src/dev.rs | 21+++++++++++++--------
Mwire-gateway/magnet-bank/src/lib.rs | 19++++++++++++++++++-
Mwire-gateway/magnet-bank/src/magnet.rs | 2+-
Mwire-gateway/magnet-bank/src/wire_gateway.rs | 10+++++++---
Mwire-gateway/magnet-bank/tests/api.rs | 15+++++++++++----
15 files changed, 404 insertions(+), 241 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -142,9 +142,9 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "axum" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d6fd624c75e18b3b4c6b9caf42b1afe24437daaee904069137d8bab077be8b8" +checksum = "efea76243612a2436fb4074ba0cf3ba9ea29efdeb72645d8fc63f116462be1de" dependencies = [ "axum-core", "bytes", @@ -176,12 +176,12 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1362f362fd16024ae199c1970ce98f9661bf5ef94b9808fee734bc3698b733" +checksum = "eab1b0df7cded837c40dacaa2e1c33aa17c84fc3356ae67b5645f1e83190753e" dependencies = [ "bytes", - "futures-util", + "futures-core", "http 1.2.0", "http-body", "http-body-util", @@ -540,9 +540,9 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crunchy" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" +checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" [[package]] name = "crypto-bigint" @@ -1360,13 +1360,13 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] name = "is-terminal" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "261f68e344040fbd0edea105bef17c66edf46f984ddb1115b775ce31be948f4b" +checksum = "3f187290c0ed3dfe3f7c85bedddd320949b68fc86ca0ceb71adfb05b3dc3af2a" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1392,9 +1392,9 @@ checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" [[package]] name = "jiff" -version = "0.1.24" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2bb0c2e28117985a4d90e3bc70092bc8f226f434c7ec7e23dd9ff99c5c5721a" +checksum = "fb73dbeee753ae9411475ddd8861765fa7f25fe1eebf180c24e1bbabef3fbdcd" dependencies = [ "log", "portable-atomic", @@ -2076,9 +2076,9 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.43" +version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a78891ee6bf2340288408954ac787aa063d8e8817e9f53abb37c695c6d834ef6" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ "bitflags", "errno", @@ -2552,6 +2552,7 @@ dependencies = [ "listenfd", "serde", "serde_json", + "serde_path_to_error", "sqlx", "taler-common", "test-utils", diff --git a/common/taler-api/Cargo.toml b/common/taler-api/Cargo.toml @@ -20,6 +20,7 @@ serde.workspace = true tracing.workspace = true tracing-subscriber.workspace = true serde_json.workspace = true +serde_path_to_error.workspace = true axum.workspace = true url.workspace = true thiserror.workspace = true diff --git a/common/taler-api/src/error.rs b/common/taler-api/src/error.rs @@ -1,6 +1,6 @@ /* This file is part of TALER - Copyright (C) 2024 Taler Systems SA + Copyright (C) 2024-2025 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 @@ -19,7 +19,9 @@ use axum::{ response::{IntoResponse, Response}, Json, }; -use taler_common::{api_common::ErrorDetail, api_params::ParamsError, error_code::ErrorCode}; +use taler_common::{ + api_common::ErrorDetail, api_params::ParamsErr, error_code::ErrorCode, types::payto::PaytoErr, +}; pub type ApiResult<T> = Result<T, ApiError>; @@ -28,6 +30,7 @@ pub struct ApiError { hint: Option<String>, log: Option<String>, status: Option<StatusCode>, + path: Option<String>, } impl From<sqlx::Error> for ApiError { @@ -50,17 +53,44 @@ impl From<sqlx::Error> for ApiError { hint: None, status: Some(status), log: Some(format!("db: {value}")), + path: None, + } + } +} + +impl From<PaytoErr> for ApiError { + fn from(value: PaytoErr) -> Self { + Self { + code: ErrorCode::GENERIC_PAYTO_URI_MALFORMED, + hint: Some(value.to_string()), + log: None, + status: None, + path: None, } } } -impl From<ParamsError> for ApiError { - fn from(value: ParamsError) -> Self { +impl From<ParamsErr> for ApiError { + fn from(value: ParamsErr) -> Self { Self { code: ErrorCode::GENERIC_PARAMETER_MALFORMED, - hint: Some(value.0), + hint: Some(value.to_string()), + log: None, + status: None, + path: Some(value.param.to_owned()), + } + } +} + +impl From<serde_path_to_error::Error<serde_json::Error>> for ApiError { + fn from(value: serde_path_to_error::Error<serde_json::Error>) -> Self { + let e = value.inner(); + Self { + code: ErrorCode::GENERIC_JSON_INVALID, + hint: Some(e.to_string()), log: None, status: None, + path: Some(value.path().to_string()), } } } @@ -82,7 +112,7 @@ impl IntoResponse for ApiError { hint: self.hint, detail: None, parameter: None, - path: None, + path: self.path, offset: None, index: None, object: None, @@ -102,6 +132,7 @@ pub fn failure_code(code: ErrorCode) -> ApiError { hint: None, log: None, status: None, + path: None, } } @@ -111,6 +142,7 @@ pub fn failure(code: ErrorCode, hint: impl Into<String>) -> ApiError { hint: Some(hint.into()), log: None, status: None, + path: None, } } @@ -120,5 +152,6 @@ pub fn failure_status(code: ErrorCode, hint: impl Into<String>, status: StatusCo hint: Some(hint.into()), log: None, status: Some(status), + path: None, } } diff --git a/common/taler-api/src/json.rs b/common/taler-api/src/json.rs @@ -0,0 +1,128 @@ +use axum::{ + body::Bytes, + extract::{FromRequest, Request}, + http::header, +}; +use http_body_util::BodyExt as _; +use serde::de::DeserializeOwned; +use taler_common::error_code::ErrorCode; + +use crate::{ + constants::MAX_BODY_LENGTH, + error::{failure, ApiError}, +}; + +#[derive(Debug, Clone, Copy, Default)] +#[must_use] +pub struct Req<T>(pub T); + +impl<T, S> FromRequest<S> for Req<T> +where + T: DeserializeOwned, + S: Send + Sync, +{ + type Rejection = ApiError; + + async fn from_request(req: Request, _state: &S) -> Result<Self, Self::Rejection> { + // TODO UNSUPPORTED_MEDIA_TYPE & UNPROCESSABLE_ENTITY + // Check content type + match req + .headers() + .get(header::CONTENT_TYPE) + .map(|it| it == "application/json") + { + Some(true) => {} + Some(false) => { + return Err(failure( + ErrorCode::GENERIC_JSON_INVALID, + "Bad Content-Type header", + )) + } + None => { + return Err(failure( + ErrorCode::GENERIC_JSON_INVALID, + "Missing Content-Type header", + )) + } + } + + // Check content length if present and wellformed + if let Some(lenght) = req + .headers() + .get(header::CONTENT_LENGTH) + .and_then(|it| it.to_str().ok()) + .and_then(|it| it.parse::<usize>().ok()) + { + if lenght > MAX_BODY_LENGTH { + return Err(failure( + ErrorCode::GENERIC_JSON_INVALID, + format!("Body is suspiciously big > {MAX_BODY_LENGTH}B"), + )); + } + } + + // Check compression + let compressed = if let Some(encoding) = req.headers().get(header::CONTENT_ENCODING) { + if encoding == "deflate" { + true + } else { + return Err(failure( + ErrorCode::GENERIC_COMPRESSION_INVALID, + format!( + "Unsupported encoding '{}'", + String::from_utf8_lossy(encoding.as_bytes()) + ), + )); + } + } else { + false + }; + + // Buffer body + let (_, body) = req.into_parts(); + let body = http_body_util::Limited::new(body, MAX_BODY_LENGTH); + let bytes = match body.collect().await { + Ok(chunks) => chunks.to_bytes(), + Err(it) => match it.downcast::<http_body_util::LengthLimitError>() { + Ok(_) => { + return Err(failure( + ErrorCode::GENERIC_JSON_INVALID, + format!("Body is suspiciously big > {MAX_BODY_LENGTH}B"), + )) + } + Err(err) => { + return Err(failure( + ErrorCode::GENERIC_UNEXPECTED_REQUEST_ERROR, + format!("Failed to read body: {}", err), + )) + } + }, + }; + + let bytes = if compressed { + let mut buf = vec![0; MAX_BODY_LENGTH]; + match libdeflater::Decompressor::new().zlib_decompress(&bytes, &mut buf) { + Ok(it) => Bytes::copy_from_slice(&buf[..it]), + Err(it) => match it { + libdeflater::DecompressionError::BadData => { + return Err(failure( + ErrorCode::GENERIC_COMPRESSION_INVALID, + "Failed to decompress body: invalid gzip", + )) + } + libdeflater::DecompressionError::InsufficientSpace => { + return Err(failure( + ErrorCode::GENERIC_JSON_INVALID, + format!("Body is suspiciously big > {MAX_BODY_LENGTH}B"), + )) + } + }, + } + } else { + bytes + }; + let mut de = serde_json::de::Deserializer::from_slice(&bytes); + let parsed = serde_path_to_error::deserialize(&mut de)?; + Ok(Req(parsed)) + } +} diff --git a/common/taler-api/src/lib.rs b/common/taler-api/src/lib.rs @@ -28,19 +28,17 @@ use std::{ use auth::{auth_middleware, AuthMethod}; use axum::{ - body::Bytes, - extract::{FromRequest, Path, Query, Request, State}, - http::{header, StatusCode}, + extract::{Path, Query, Request, State}, + http::StatusCode, middleware::{self, Next}, response::{IntoResponse, Response}, routing::{get, post}, Json, Router, }; -use constants::{MAX_BODY_LENGTH, MAX_PAGE_SIZE, MAX_TIMEOUT_MS, WIRE_GATEWAY_API_VERSION}; -use error::{failure, failure_code, ApiError, ApiResult}; -use http_body_util::BodyExt; +use constants::{MAX_PAGE_SIZE, MAX_TIMEOUT_MS, WIRE_GATEWAY_API_VERSION}; +use error::{failure, failure_code, ApiResult}; +use json::Req; use listenfd::ListenFd; -use serde::de::DeserializeOwned; use taler_common::{ api_params::{History, HistoryParams, Page, TransferParams}, api_wire::{ @@ -61,125 +59,10 @@ pub mod auth; mod constants; pub mod db; pub mod error; +pub mod json; pub mod notification; pub mod subject; -#[derive(Debug, Clone, Copy, Default)] -#[must_use] -pub struct Req<T>(pub T); - -impl<T, S> FromRequest<S> for Req<T> -where - T: DeserializeOwned, - S: Send + Sync, -{ - type Rejection = ApiError; - - async fn from_request(req: Request, _state: &S) -> Result<Self, Self::Rejection> { - // TODO UNSUPPORTED_MEDIA_TYPE & UNPROCESSABLE_ENTITY - // Check content type - match req - .headers() - .get(header::CONTENT_TYPE) - .map(|it| it == "application/json") - { - Some(true) => {} - Some(false) => { - return Err(failure( - ErrorCode::GENERIC_JSON_INVALID, - "Bad Content-Type header", - )) - } - None => { - return Err(failure( - ErrorCode::GENERIC_JSON_INVALID, - "Missing Content-Type header", - )) - } - } - - // Check content length if present and wellformed - if let Some(lenght) = req - .headers() - .get(header::CONTENT_LENGTH) - .and_then(|it| it.to_str().ok()) - .and_then(|it| it.parse::<usize>().ok()) - { - if lenght > MAX_BODY_LENGTH { - return Err(failure( - ErrorCode::GENERIC_JSON_INVALID, - format!("Body is suspiciously big > {MAX_BODY_LENGTH}B"), - )); - } - } - - // Check compression - let compressed = if let Some(encoding) = req.headers().get(header::CONTENT_ENCODING) { - if encoding == "deflate" { - true - } else { - return Err(failure( - ErrorCode::GENERIC_COMPRESSION_INVALID, - format!( - "Unsupported encoding '{}'", - String::from_utf8_lossy(encoding.as_bytes()) - ), - )); - } - } else { - false - }; - - // Buffer body - let (_, body) = req.into_parts(); - let body = http_body_util::Limited::new(body, MAX_BODY_LENGTH); - let bytes = match body.collect().await { - Ok(chunks) => chunks.to_bytes(), - Err(it) => match it.downcast::<http_body_util::LengthLimitError>() { - Ok(_) => { - return Err(failure( - ErrorCode::GENERIC_JSON_INVALID, - format!("Body is suspiciously big > {MAX_BODY_LENGTH}B"), - )) - } - Err(err) => { - return Err(failure( - ErrorCode::GENERIC_UNEXPECTED_REQUEST_ERROR, - format!("Failed to read body: {}", err), - )) - } - }, - }; - - let bytes = if compressed { - let mut buf = vec![0; MAX_BODY_LENGTH]; - match libdeflater::Decompressor::new().zlib_decompress(&bytes, &mut buf) { - Ok(it) => Bytes::copy_from_slice(&buf[..it]), - Err(it) => match it { - libdeflater::DecompressionError::BadData => { - return Err(failure( - ErrorCode::GENERIC_COMPRESSION_INVALID, - "Failed to decompress body: invalid gzip", - )) - } - libdeflater::DecompressionError::InsufficientSpace => { - return Err(failure( - ErrorCode::GENERIC_JSON_INVALID, - format!("Body is suspiciously big > {MAX_BODY_LENGTH}B"), - )) - } - }, - } - } else { - bytes - }; - match serde_json::de::from_slice(&bytes) { - Ok(it) => Ok(Req(it)), - Err(err) => Err(failure(ErrorCode::GENERIC_JSON_INVALID, format!("{err}"))), - } - } -} - pub trait WireGatewayImpl: Send + Sync { fn name(&self) -> &str; fn currency(&self) -> &str; diff --git a/common/taler-common/src/api_params.rs b/common/taler-common/src/api_params.rs @@ -20,8 +20,15 @@ use serde_with::{serde_as, DisplayFromStr}; use crate::api_wire::TransferState; #[derive(Debug, thiserror::Error)] -#[error("{0}")] -pub struct ParamsError(pub String); +#[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)] @@ -36,22 +43,22 @@ pub struct PageParams { } impl PageParams { - pub fn check(self, max_page_size: i64) -> Result<Page, ParamsError> { + pub fn check(self, max_page_size: i64) -> Result<Page, ParamsErr> { let limit = self.limit.unwrap_or(-20); if limit == 0 { - return Err(ParamsError(format!( - "Param 'limit' must be non-zero got {limit}" - ))); + return Err(param_err("limit", format!("must be non-zero got {limit}"))); } else if limit > max_page_size { - return Err(ParamsError(format!( - "Param 'limit' must be <= {max_page_size} for {limit}" - ))); + return Err(param_err( + "limit", + format!("must be <= {max_page_size} for {limit}"), + )); } if let Some(offset) = self.offset { if offset < 0 { - return Err(ParamsError(format!( - "Param 'offset' must be positive got {offset}" - ))); + return Err(param_err( + "offset", + format!("must be positive got {offset}"), + )); } } @@ -93,7 +100,7 @@ pub struct HistoryParams { } impl HistoryParams { - pub fn check(self, max_page_size: i64, max_timeout_ms: u64) -> Result<History, ParamsError> { + pub fn check(self, max_page_size: i64, max_timeout_ms: u64) -> Result<History, ParamsErr> { let timeout_ms = self.timeout_ms.map(|it| it.min(max_timeout_ms)); Ok(History { page: self.pagination.check(max_page_size)?, diff --git a/common/taler-common/src/types/payto.rs b/common/taler-common/src/types/payto.rs @@ -14,8 +14,11 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -use serde::{de::DeserializeOwned, Deserialize}; -use std::{fmt::Debug, str::FromStr}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use std::{ + fmt::{Debug, Display}, + str::FromStr, +}; use url::Url; /// Parse a payto URI, panic if malformed @@ -39,6 +42,11 @@ impl Payto { let de = serde_urlencoded::Deserializer::new(url::form_urlencoded::parse(query)); serde_path_to_error::deserialize(de).map_err(PaytoErr::Query) } + + pub fn from_parts(domain_path: impl Display, query: impl Serialize) -> Self { + let query = serde_urlencoded::to_string(query).unwrap(); + payto(format!("payto://{domain_path}?{query}")) + } } impl AsRef<Url> for Payto { diff --git a/common/test-utils/src/routine.rs b/common/test-utils/src/routine.rs @@ -469,13 +469,15 @@ async fn add_incoming_routine( })) .await .assert_error(ErrorCode::GENERIC_JSON_INVALID); - /* + // Bad payto kind - client.postA("/taler-wire-gateway/admin/$path") { - json(valid_req) { - "debit_account" to "payto://x-taler-bank/bank.hostname.test/bar" - } - }.assertBadRequest()*/ + server + .post(path) + .json(&json!(valid_req + { + "debit_account": "http://email@test.com" + })) + .await + .assert_error(ErrorCode::GENERIC_JSON_INVALID); } /// Test standard behavior of the admin add incoming endpoints diff --git a/wire-gateway/magnet-bank/db/schema.sql b/wire-gateway/magnet-bank/db/schema.sql @@ -21,7 +21,8 @@ CREATE TABLE tx_in( magnet_code INT8 UNIQUE, amount taler_amount NOT NULL, subject TEXT NOT NULL, - debit_payto TEXT NOT NULL, + debit_account TEXT NOT NULL, + debit_name TEXT NOT NULL, created INT8 NOT NULL ); COMMENT ON TABLE tx_in IS 'Incoming transactions'; @@ -31,7 +32,8 @@ CREATE TABLE tx_out( magnet_code INT8 UNIQUE, amount taler_amount NOT NULL, subject TEXT NOT NULL, - credit_payto TEXT NOT NULL, + credit_account TEXT NOT NULL, + credit_name TEXT NOT NULL, created INT8 NOT NULL ); COMMENT ON TABLE tx_in IS 'Outgoing transactions'; @@ -76,7 +78,8 @@ CREATE TABLE initiated( initiated_id INT8 PRIMARY KEY GENERATED ALWAYS AS IDENTITY, amount taler_amount NOT NULL, subject TEXT NOT NULL, - credit_payto TEXT NOT NULL, + credit_account TEXT NOT NULL, + credit_name TEXT NOT NULL, status transfer_status NOT NULL DEFAULT 'pending', status_msg TEXT, magnet_code INT8 UNIQUE, @@ -99,7 +102,8 @@ CREATE FUNCTION register_tx_in( IN in_code INT8, IN in_amount taler_amount, IN in_subject TEXT, - IN in_debit_payto TEXT, + IN in_debit_account TEXT, + IN in_debit_name TEXT, IN in_timestamp INT8, IN in_type incoming_type, IN in_metadata BYTEA, @@ -117,7 +121,7 @@ SELECT tx_in_id, created INTO out_tx_row_id, out_timestamp FROM tx_in WHERE (in_code IS NOT NULL AND magnet_code = in_code) -- Magnet transaction - OR (in_code IS NULL AND amount = in_amount AND debit_payto = in_debit_payto AND subject = in_subject); -- Admin transaction + OR (in_code IS NULL AND amount = in_amount AND debit_account = in_debit_account AND subject = in_subject); -- Admin transaction out_new = NOT found; IF NOT out_new THEN out_reserve_pub_reuse=false; @@ -136,13 +140,15 @@ INSERT INTO tx_in ( magnet_code, amount, subject, - debit_payto, + debit_account, + debit_name, created ) VALUES ( in_code, in_amount, in_subject, - in_debit_payto, + in_debit_account, + in_debit_name, in_timestamp ) RETURNING tx_in_id, created @@ -170,7 +176,8 @@ CREATE FUNCTION register_tx_out( IN in_code INT8, IN in_amount taler_amount, IN in_subject TEXT, - IN in_credit_payto TEXT, + IN in_credit_account TEXT, + IN in_credit_name TEXT, IN in_timestamp INT8, IN in_wtid BYTEA, IN in_origin_exchange_url TEXT, @@ -193,13 +200,15 @@ IF out_new THEN magnet_code, amount, subject, - credit_payto, + credit_account, + credit_name, created ) VALUES ( in_code, in_amount, in_subject, - in_credit_payto, + in_credit_account, + in_credit_name, in_timestamp ) RETURNING tx_out_id, created @@ -231,7 +240,8 @@ CREATE FUNCTION taler_transfer( IN in_subject TEXT, IN in_amount taler_amount, IN in_exchange_base_url TEXT, - IN in_credit_payto TEXT, + IN in_credit_account TEXT, + IN in_credit_name TEXT, IN in_timestamp INT8, -- Error return OUT out_request_uid_reuse BOOLEAN, @@ -244,7 +254,7 @@ LANGUAGE plpgsql AS $$ BEGIN -- Check for idempotence and conflict SELECT (amount != in_amount - OR credit_payto != in_credit_payto + OR credit_account != in_credit_account OR exchange_base_url != in_exchange_base_url OR wtid != in_wtid) ,transfer.initiated_id, created @@ -265,12 +275,14 @@ END IF; INSERT INTO initiated ( amount, subject, - credit_payto, + credit_account, + credit_name, created ) VALUES ( in_amount, in_subject, - in_credit_payto, + in_credit_account, + in_credit_name, in_timestamp ) RETURNING initiated_id, created INTO out_tx_row_id, out_timestamp; diff --git a/wire-gateway/magnet-bank/src/db.rs b/wire-gateway/magnet-bank/src/db.rs @@ -27,11 +27,11 @@ use taler_common::{ IncomingBankTransaction, OutgoingBankTransaction, TransferListStatus, TransferRequest, TransferState, TransferStatus, }, - types::{amount::Amount, payto::Payto, timestamp::Timestamp}, + types::{amount::Amount, timestamp::Timestamp}, }; use tokio::sync::watch::{Receiver, Sender}; -use crate::constant::CURRENCY; +use crate::{constant::CURRENCY, MagnetPayto}; pub async fn notification_listener( pool: PgPool, @@ -61,7 +61,7 @@ pub struct TxIn { pub code: u64, pub amount: Amount, pub subject: String, - pub debtor: Payto, + pub debtor: MagnetPayto, pub timestamp: Timestamp, } @@ -83,7 +83,7 @@ pub struct TxOut { pub code: u64, pub amount: Amount, pub subject: String, - pub creditor: Payto, + pub creditor: MagnetPayto, pub timestamp: Timestamp, } @@ -104,7 +104,7 @@ impl Display for TxOut { pub struct TxInAdmin { pub amount: Amount, pub subject: String, - pub debit_payto: Payto, + pub debtor: MagnetPayto, pub timestamp: Timestamp, pub metadata: IncomingSubject, } @@ -127,7 +127,7 @@ pub struct Initiated { pub id: u64, pub amount: Amount, pub subject: String, - pub creditor: Payto, + pub creditor: MagnetPayto, } impl Display for Initiated { @@ -159,12 +159,13 @@ pub async fn register_tx_in_admin(db: &PgPool, tx: &TxInAdmin) -> sqlx::Result<A sqlx::query( " SELECT out_reserve_pub_reuse, out_new, out_tx_row_id, out_timestamp - FROM register_tx_in(NULL, ($1, $2)::taler_amount, $3, $4, $5, $6, $7) + FROM register_tx_in(NULL, ($1, $2)::taler_amount, $3, $4, $5, $6, $7, $8) ", ) .bind_amount(&tx.amount) .bind(&tx.subject) - .bind(tx.debit_payto.raw()) + .bind(&tx.debtor.number) + .bind(&tx.debtor.name) .bind_timestamp(&tx.timestamp) .bind(tx.metadata.ty()) .bind(tx.metadata.key()) @@ -191,13 +192,14 @@ pub async fn register_tx_in( sqlx::query( " SELECT out_reserve_pub_reuse, out_new, out_tx_row_id, out_timestamp - FROM register_tx_in($1, ($2, $3)::taler_amount, $4, $5, $6, $7, $8) + FROM register_tx_in($1, ($2, $3)::taler_amount, $4, $5, $6, $7, $8, $9) ", ) .bind(tx.code as i64) .bind_amount(&tx.amount) .bind(&tx.subject) - .bind(tx.debtor.raw()) + .bind(&tx.debtor.number) + .bind(&tx.debtor.name) .bind_timestamp(&tx.timestamp) .bind(subject.as_ref().map(|it| it.ty())) .bind(subject.as_ref().map(|it| it.key())) @@ -224,13 +226,14 @@ pub async fn register_tx_out( sqlx::query( " SELECT out_new, out_tx_row_id, out_timestamp - FROM register_tx_out($1, ($2, $3)::taler_amount, $4, $5, $6, $7, $8) + FROM register_tx_out($1, ($2, $3)::taler_amount, $4, $5, $6, $7, $8, $9) ", ) .bind(tx.code as i64) .bind_amount(&tx.amount) .bind(&tx.subject) - .bind(tx.creditor.raw()) + .bind(&tx.creditor.number) + .bind(&tx.creditor.name) .bind_timestamp(&tx.timestamp) .bind(subject.as_ref().map(|it| it.0.as_ref())) .bind(subject.as_ref().map(|it| it.1.as_str())) @@ -255,13 +258,14 @@ pub enum TransferResult { pub async fn make_transfer<'a>( db: impl PgExecutor<'a>, req: &TransferRequest, + creditor: &MagnetPayto, timestamp: &Timestamp, ) -> sqlx::Result<TransferResult> { let subject = format!("{} {}", req.wtid, req.exchange_base_url); sqlx::query( " SELECT out_request_uid_reuse, out_wtid_reuse, out_tx_row_id, out_timestamp - FROM taler_transfer($1, $2, $3, ($4, $5)::taler_amount, $6, $7, $8) + FROM taler_transfer($1, $2, $3, ($4, $5)::taler_amount, $6, $7, $8, $9) ", ) .bind(req.request_uid.as_ref()) @@ -269,7 +273,8 @@ pub async fn make_transfer<'a>( .bind(&subject) .bind_amount(&req.amount) .bind(req.exchange_base_url.as_str()) - .bind(req.credit_account.raw()) + .bind(&creditor.number) + .bind(&creditor.name) .bind_timestamp(timestamp) .try_map(|r: PgRow| { Ok(if r.try_get(0)? { @@ -304,7 +309,8 @@ pub async fn transfer_page<'a>( status, (amount).val as amount_val, (amount).frac as amount_frac, - credit_payto, + credit_account, + credit_name, created FROM transfer JOIN initiated USING (initiated_id) @@ -321,8 +327,12 @@ pub async fn transfer_page<'a>( row_id: r.try_get_safeu64(0)?, status: r.try_get(1)?, amount: r.try_get_amount_i(2, CURRENCY)?, - credit_account: r.try_get_payto(4)?, - timestamp: r.try_get_timestamp(5)?, + credit_account: MagnetPayto { + number: r.try_get(4)?, + name: r.try_get(4)?, + } + .as_payto(), + timestamp: r.try_get_timestamp(6)?, }) }, ) @@ -346,7 +356,8 @@ pub async fn outgoing_history( tx_out_id, (amount).val as amount_val, (amount).frac as amount_frac, - credit_payto, + credit_account, + credit_name, created, exchange_base_url, wtid @@ -360,10 +371,14 @@ pub async fn outgoing_history( Ok(OutgoingBankTransaction { row_id: r.try_get_safeu64(0)?, amount: r.try_get_amount_i(1, CURRENCY)?, - credit_account: r.try_get_payto(3)?, - date: r.try_get_timestamp(4)?, - exchange_base_url: r.try_get_url(5)?, - wtid: r.try_get_base32(6)?, + credit_account: MagnetPayto { + number: r.try_get(3)?, + name: r.try_get(4)?, + } + .as_payto(), + date: r.try_get_timestamp(5)?, + exchange_base_url: r.try_get_url(6)?, + wtid: r.try_get_base32(7)?, }) }, ) @@ -384,12 +399,13 @@ pub async fn incoming_history( QueryBuilder::new( " SELECT + type, tx_in_id, (amount).val as amount_val, (amount).frac as amount_frac, - debit_payto, + debit_account, + debit_name, created, - type, metadata FROM taler_in JOIN tx_in USING (tx_in_id) @@ -398,20 +414,28 @@ pub async fn incoming_history( ) }, |r: PgRow| { - Ok(match r.try_get(5)? { + Ok(match r.try_get(0)? { IncomingType::reserve => IncomingBankTransaction::Reserve { - row_id: r.try_get_safeu64(0)?, - amount: r.try_get_amount_i(1, CURRENCY)?, - debit_account: r.try_get_payto(3)?, - date: r.try_get_timestamp(4)?, - reserve_pub: r.try_get_base32(6)?, + row_id: r.try_get_safeu64(1)?, + amount: r.try_get_amount_i(2, CURRENCY)?, + debit_account: MagnetPayto { + number: r.try_get(4)?, + name: r.try_get(5)?, + } + .as_payto(), + date: r.try_get_timestamp(6)?, + reserve_pub: r.try_get_base32(7)?, }, IncomingType::kyc => IncomingBankTransaction::Kyc { - row_id: r.try_get_safeu64(0)?, - amount: r.try_get_amount_i(1, CURRENCY)?, - debit_account: r.try_get_payto(3)?, - date: r.try_get_timestamp(4)?, - account_pub: r.try_get_base32(6)?, + row_id: r.try_get_safeu64(1)?, + amount: r.try_get_amount_i(2, CURRENCY)?, + debit_account: MagnetPayto { + number: r.try_get(4)?, + name: r.try_get(5)?, + } + .as_payto(), + date: r.try_get_timestamp(6)?, + account_pub: r.try_get_base32(7)?, }, IncomingType::wad => { unimplemented!("WAD is not yet supported") @@ -435,7 +459,8 @@ pub async fn transfer_by_id<'a>( (amount).frac as amount_frac, exchange_base_url, wtid, - credit_payto, + credit_account, + credit_name, created FROM transfer JOIN initiated USING (initiated_id) @@ -450,8 +475,12 @@ pub async fn transfer_by_id<'a>( amount: r.try_get_amount_i(2, CURRENCY)?, origin_exchange_url: r.try_get(4)?, wtid: r.try_get_base32(5)?, - credit_account: r.try_get_payto(6)?, - timestamp: r.try_get_timestamp(7)?, + credit_account: MagnetPayto { + number: r.try_get(6)?, + name: r.try_get(7)?, + } + .as_payto(), + timestamp: r.try_get_timestamp(8)?, }) }) .fetch_optional(db) @@ -464,7 +493,7 @@ pub async fn pending_batch<'a>( ) -> sqlx::Result<Vec<Initiated>> { sqlx::query( " - SELECT initiated_id, (amount).val, (amount).frac, subject, credit_payto + SELECT initiated_id, (amount).val, (amount).frac, subject, credit_account, credit_name FROM initiated WHERE magnet_code IS NULL AND (last_submitted IS NULL OR last_submitted < $1) LIMIT 100 @@ -476,7 +505,10 @@ pub async fn pending_batch<'a>( id: r.try_get_u64(0)?, amount: r.try_get_amount_i(1, CURRENCY)?, subject: r.try_get(3)?, - creditor: r.try_get_payto(4)?, + creditor: MagnetPayto { + number: r.try_get(4)?, + name: r.try_get(5)?, + }, }) }) .fetch_all(db) @@ -547,6 +579,7 @@ mod test { self, make_transfer, register_tx_in, register_tx_in_admin, register_tx_out, AddIncomingResult, RegisteredTx, TransferResult, TxIn, TxOut, }, + MagnetPayto, }; use super::TxInAdmin; @@ -581,7 +614,10 @@ mod test { code: code, amount: amount("EUR:10"), subject: "subject".to_owned(), - debtor: payto("payto://magnet-bank/todo"), + debtor: MagnetPayto { + number: "number".to_owned(), + name: "name".to_owned(), + }, timestamp: Timestamp::now_stable(), }; // Insert @@ -685,7 +721,10 @@ mod test { let tx = TxInAdmin { amount: amount("EUR:10"), subject: "subject".to_owned(), - debit_payto: payto("payto://magnet-bank/todo"), + debtor: MagnetPayto { + number: "number".to_owned(), + name: "name".to_owned(), + }, timestamp: Timestamp::now_stable(), metadata: IncomingSubject::Reserve(EddsaPublicKey::rand()), }; @@ -765,7 +804,10 @@ mod test { code, amount: amount("EUR:10"), subject: "subject".to_owned(), - creditor: payto("payto://magnet-bank/todo"), + creditor: MagnetPayto { + number: "number".to_owned(), + name: "name".to_owned(), + }, timestamp: Timestamp::now_stable(), }; // Insert @@ -872,10 +914,14 @@ mod test { wtid: ShortHashCode::rand(), credit_account: payto("payto://magnet-bank/todo"), }; + let payto = MagnetPayto { + number: "number".to_owned(), + name: "name".to_owned(), + }; let timestamp = Timestamp::now_stable(); // Insert assert_eq!( - make_transfer(&mut db, &req, &timestamp) + make_transfer(&mut db, &req, &payto, &timestamp) .await .expect("transfer"), TransferResult::Success { @@ -885,7 +931,7 @@ mod test { ); // Idempotent assert_eq!( - make_transfer(&mut db, &req, &Timestamp::now()) + make_transfer(&mut db, &req, &payto, &Timestamp::now()) .await .expect("transfer"), TransferResult::Success { @@ -901,6 +947,7 @@ mod test { wtid: ShortHashCode::rand(), ..req.clone() }, + &payto, &Timestamp::now() ) .await @@ -915,6 +962,7 @@ mod test { request_uid: HashCode::rand(), ..req.clone() }, + &payto, &Timestamp::now() ) .await @@ -930,6 +978,7 @@ mod test { wtid: ShortHashCode::rand(), ..req }, + &payto, &timestamp ) .await @@ -970,6 +1019,10 @@ mod test { async fn batch() { let (mut db, _) = setup().await; let start = Timestamp::now(); + let magnet_payto = MagnetPayto { + number: "number".to_owned(), + name: "name".to_owned(), + }; // Empty db let pendings = db::pending_batch(&mut db, &start) @@ -988,7 +1041,8 @@ mod test { wtid: ShortHashCode::rand(), credit_account: payto("payto://magnet-bank/todo"), }, - &Timestamp::now(), + &magnet_payto, + &&Timestamp::now(), ) .await .expect("transfer"); @@ -1009,6 +1063,7 @@ mod test { wtid: ShortHashCode::rand(), credit_account: payto("payto://magnet-bank/todo"), }, + &magnet_payto, &Timestamp::now(), ) .await diff --git a/wire-gateway/magnet-bank/src/dev.rs b/wire-gateway/magnet-bank/src/dev.rs @@ -20,9 +20,8 @@ use taler_common::{ config::Config, types::{ amount::Amount, - payto::{payto, FullPayto, Payto}, + payto::{FullPayto, Payto}, timestamp::Timestamp, - url, }, }; use tracing::info; @@ -75,10 +74,10 @@ pub async fn dev(cfg: Config, cmd: DevCmd) -> anyhow::Result<()> { let res = client.list_accounts().await?; for partner in res.partners { for account in partner.bank_accounts { - let mut payto = url(&format!("payto://magnet-bank/{}", account.number)); - payto - .query_pairs_mut() - .append_pair("receiver-name", &partner.partner.name); + let payto = MagnetPayto { + number: account.number, + name: partner.partner.name.clone(), + }; info!("{} {} {payto}", account.code, account.currency.symbol); } } @@ -105,7 +104,10 @@ pub async fn dev(cfg: Config, cmd: DevCmd) -> anyhow::Result<()> { code: tx.code, amount: amount.parse().unwrap(), subject: tx.subject, - debtor: payto("payto://magnet-bank/todo"), + debtor: MagnetPayto { + number: tx.bank_account, + name: tx.bank_account_owner, + }, timestamp: Timestamp::from(tx.value_date), }; info!("in {tx}"); @@ -115,7 +117,10 @@ pub async fn dev(cfg: Config, cmd: DevCmd) -> anyhow::Result<()> { code: tx.code, amount: amount.parse().unwrap(), subject: tx.subject, - creditor: payto("payto://magnet-bank/todo"), + creditor: MagnetPayto { + number: tx.bank_account, + name: tx.bank_account_owner, + }, timestamp: Timestamp::from(tx.value_date), }; info!("out {tx}"); diff --git a/wire-gateway/magnet-bank/src/lib.rs b/wire-gateway/magnet-bank/src/lib.rs @@ -14,7 +14,7 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -use taler_common::types::payto::{Payto, PaytoErr}; +use taler_common::types::payto::{FullPayto, Payto, PaytoErr}; pub mod config; pub mod constant; @@ -27,8 +27,23 @@ pub mod wire_gateway; #[derive(Debug, Clone, PartialEq, Eq)] pub struct MagnetPayto { pub number: String, + pub name: String, } +impl MagnetPayto { + pub fn as_payto(&self) -> Payto { + Payto::from_parts( + format_args!("{MAGNET_BANK}/{}", self.number), + [("receiver-name", &self.name)], + ) + } +} + +impl std::fmt::Display for MagnetPayto { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.as_payto().fmt(f) + } +} #[derive(Debug, thiserror::Error)] pub enum MagnetPaytoErr { #[error("missing Magnet Bank account number in path")] @@ -57,8 +72,10 @@ impl TryFrom<&Payto> for MagnetPayto { if segments.next().is_some() { return Err(PaytoErr::TooLong(MAGNET_BANK)); } + let full: FullPayto = value.query()?; Ok(Self { number: account.to_owned(), + name: full.receiver_name, }) } } diff --git a/wire-gateway/magnet-bank/src/magnet.rs b/wire-gateway/magnet-bank/src/magnet.rs @@ -236,7 +236,7 @@ pub struct Transaction { #[serde(rename = "bankszamla")] pub bank_account: String, #[serde(rename = "bankszamlaTulajdonos")] - pub bank_acount_owner: String, + pub bank_account_owner: String, #[serde(rename = "deviza")] pub currency: amount::Currency, #[serde(rename = "osszeg")] diff --git a/wire-gateway/magnet-bank/src/wire_gateway.rs b/wire-gateway/magnet-bank/src/wire_gateway.rs @@ -35,6 +35,7 @@ use tokio::sync::watch::Sender; use crate::{ constant::CURRENCY, db::{self, AddIncomingResult, TxInAdmin}, + MagnetPayto, }; pub struct MagnetWireGateway { @@ -85,7 +86,8 @@ impl WireGatewayImpl for MagnetWireGateway { } async fn transfer(&self, req: TransferRequest) -> ApiResult<TransferResponse> { - let result = db::make_transfer(&self.pool, &req, &Timestamp::now()).await?; + let creditor = MagnetPayto::try_from(&req.credit_account)?; + let result = db::make_transfer(&self.pool, &req, &creditor, &Timestamp::now()).await?; match result { db::TransferResult::Success { id, timestamp } => Ok(TransferResponse { timestamp, @@ -138,12 +140,13 @@ impl WireGatewayImpl for MagnetWireGateway { &self, req: AddIncomingRequest, ) -> ApiResult<AddIncomingResponse> { + let debtor = MagnetPayto::try_from(&req.debit_account)?; let res = db::register_tx_in_admin( &self.pool, &TxInAdmin { amount: req.amount, subject: format!("Admin incoming {}", req.reserve_pub), - debit_payto: req.debit_account, + debtor, timestamp: Timestamp::now(), metadata: IncomingSubject::Reserve(req.reserve_pub), }, @@ -162,12 +165,13 @@ impl WireGatewayImpl for MagnetWireGateway { } async fn add_incoming_kyc(&self, req: AddKycauthRequest) -> ApiResult<AddKycauthResponse> { + let debtor = MagnetPayto::try_from(&req.debit_account)?; let res = db::register_tx_in_admin( &self.pool, &TxInAdmin { amount: req.amount, subject: format!("Admin incoming KYC:{}", req.account_pub), - debit_payto: req.debit_account, + debtor, timestamp: Timestamp::now(), metadata: IncomingSubject::Kyc(req.account_pub), }, diff --git a/wire-gateway/magnet-bank/tests/api.rs b/wire-gateway/magnet-bank/tests/api.rs @@ -16,7 +16,7 @@ use std::sync::Arc; -use magnet_bank::{db, wire_gateway::MagnetWireGateway}; +use magnet_bank::{db, wire_gateway::MagnetWireGateway, MagnetPayto}; use sqlx::PgPool; use taler_api::{auth::AuthMethod, standard_layer, subject::OutgoingSubject}; use taler_common::{ @@ -50,7 +50,7 @@ async fn transfer() { transfer_routine( &server, TransferState::pending, - &payto("payto://magnet-bank/todo"), + &payto("payto://magnet-bank/account?receiver-name=John+Smith"), ) .await; } @@ -78,7 +78,10 @@ async fn outgoing_history() { code: i as u64, amount: amount("EUR:10"), subject: "subject".to_owned(), - creditor: payto("payto://magnet-bank/todo"), + creditor: MagnetPayto { + number: "number".to_owned(), + name: "name".to_owned(), + }, timestamp: Timestamp::now_stable(), }, &Some(OutgoingSubject( @@ -97,5 +100,9 @@ async fn outgoing_history() { #[tokio::test] async fn admin_add_incoming() { let (server, _) = setup().await; - admin_add_incoming_routine(&server, &payto("payto://magnet-bank/todo")).await; + admin_add_incoming_routine( + &server, + &payto("payto://magnet-bank/account?receiver-name=John+Smith"), + ) + .await; }