taler-rust

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

commit 166a13dc2b4971a6b83d70dbc5d1f3fc1f01d08b
parent ed4a25edcac070f7a57d0c2bd6bfc26195b6f6aa
Author: Antoine A <>
Date:   Wed, 25 Mar 2026 19:00:54 +0100

common: add Wire Transfer Gateway logic and tests

Diffstat:
Mcommon/taler-api/db/taler-api-0001.sql | 26+++++++++++++++++++++++---
Mcommon/taler-api/db/taler-api-procedures.sql | 181++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcommon/taler-api/src/api.rs | 7+++++++
Acommon/taler-api/src/api/transfer.rs | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/taler-api/src/constants.rs | 3++-
Acommon/taler-api/src/crypto.rs | 29+++++++++++++++++++++++++++++
Mcommon/taler-api/src/db.rs | 10----------
Mcommon/taler-api/src/lib.rs | 3++-
Mcommon/taler-api/src/subject.rs | 24+++++++++++++-----------
Mcommon/taler-api/tests/api.rs | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcommon/taler-api/tests/common/db.rs | 69++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mcommon/taler-api/tests/common/mod.rs | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcommon/taler-api/tests/security.rs | 2+-
Acommon/taler-common/src/api_transfer.rs | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/taler-common/src/db.rs | 11++++++++++-
Mcommon/taler-common/src/error_code.rs | 481+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mcommon/taler-common/src/lib.rs | 1+
Mcommon/taler-test-utils/Cargo.toml | 2++
Mcommon/taler-test-utils/src/routine.rs | 417++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcommon/taler-test-utils/src/server.rs | 4++++
Mtaler-cyclos/src/db.rs | 3++-
Mtaler-magnet-bank/src/db.rs | 3++-
22 files changed, 1546 insertions(+), 146 deletions(-)

diff --git a/common/taler-api/db/taler-api-0001.sql b/common/taler-api/db/taler-api-0001.sql @@ -75,8 +75,28 @@ CREATE TABLE transfer ( COMMENT ON TABLE transfer IS 'Wire Gateway transfers'; CREATE TABLE bounced( - tx_in_id INT8 NOT NULL UNIQUE REFERENCES tx_in(tx_in_id) ON DELETE CASCADE, - tx_out_id INT8 NOT NULL UNIQUE REFERENCES tx_out(tx_out_id) ON DELETE CASCADE, - reason TEXT NOT NULL + tx_in_id INT8 NOT NULL UNIQUE REFERENCES tx_in(tx_in_id) ON DELETE CASCADE ); COMMENT ON TABLE tx_in IS 'Bounced transaction'; + +CREATE TABLE prepared_in ( + type incoming_type NOT NULL, + account_pub BYTEA NOT NULL CHECK (LENGTH(account_pub)=32), + authorization_pub BYTEA UNIQUE NOT NULL CHECK (LENGTH(authorization_pub)=32), + authorization_sig BYTEA NOT NULL CHECK (LENGTH(authorization_sig)=64), + recurrent BOOLEAN NOT NULL, + registered_at INT8 NOT NULL, + tx_in_id INT8 UNIQUE REFERENCES tx_in(tx_in_id) ON DELETE CASCADE +); +COMMENT ON TABLE prepared_in IS 'Prepared incoming transaction'; +CREATE UNIQUE INDEX prepared_in_unique_reserve_pub + ON prepared_in (account_pub) WHERE type = 'reserve'; + +CREATE TABLE pending_recurrent_in( + tx_in_id INT8 NOT NULL UNIQUE REFERENCES tx_in(tx_in_id) ON DELETE CASCADE, + authorization_pub BYTEA NOT NULL REFERENCES prepared_in(authorization_pub) +); +CREATE INDEX pending_recurrent_inc_auth_pub + ON pending_recurrent_in (authorization_pub); +COMMENT ON TABLE pending_recurrent_in IS 'Pending recurrent incoming transaction'; + diff --git a/common/taler-api/db/taler-api-procedures.sql b/common/taler-api/db/taler-api-procedures.sql @@ -121,18 +121,41 @@ CREATE FUNCTION add_incoming( IN in_now INT8, -- Error status OUT out_reserve_pub_reuse BOOLEAN, + OUT out_mapping_reuse BOOLEAN, + OUT out_unknown_mapping BOOLEAN, -- Success return OUT out_tx_row_id INT8, OUT out_created_at INT8 ) LANGUAGE plpgsql AS $$ +DECLARE +local_pending BOOLEAN; +local_authorization_pub BYTEA; +local_authorization_sig BYTEA; BEGIN +local_pending=false; + +-- Resolve mapping logic +IF in_type = 'map' THEN + SELECT type, account_pub, authorization_pub, authorization_sig, + tx_in_id IS NOT NULL AND NOT recurrent, + tx_in_id IS NOT NULL AND recurrent + INTO in_type, in_account_pub, local_authorization_pub, local_authorization_sig, out_mapping_reuse, local_pending + FROM prepared_in + WHERE authorization_pub = in_account_pub; + out_unknown_mapping = NOT FOUND; + IF out_unknown_mapping OR out_mapping_reuse THEN + RETURN; + END IF; +END IF; + -- Check conflict -SELECT in_type = 'reserve'::incoming_type AND EXISTS(SELECT FROM taler_in WHERE account_pub = in_account_pub AND type = 'reserve') +SELECT NOT local_pending AND in_type = 'reserve'::incoming_type AND EXISTS(SELECT FROM taler_in WHERE account_pub = in_account_pub AND type = 'reserve') INTO out_reserve_pub_reuse; IF out_reserve_pub_reuse THEN RETURN; END IF; + -- Register incoming transaction out_created_at=in_now; INSERT INTO tx_in ( @@ -146,16 +169,152 @@ INSERT INTO tx_in ( in_now, in_subject ) RETURNING tx_in_id INTO out_tx_row_id; -INSERT INTO taler_in ( - tx_in_id, +IF local_pending THEN + -- Delay talerable registration until mapping again + INSERT INTO pending_recurrent_in (tx_in_id, authorization_pub) + VALUES (out_tx_row_id, local_authorization_pub); +ELSE + IF local_authorization_pub IS NOT NULL THEN + UPDATE prepared_in + SET tx_in_id = out_tx_row_id + WHERE authorization_pub = local_authorization_pub; + END IF; + INSERT INTO taler_in ( + tx_in_id, + type, + account_pub + ) VALUES ( + out_tx_row_id, + in_type, + in_account_pub + ); + -- Notify new incoming transaction + PERFORM pg_notify('incoming_tx', out_tx_row_id || ''); +END IF; + +END $$; +COMMENT ON FUNCTION add_incoming IS 'Create an incoming taler transaction and register it'; + +CREATE FUNCTION register_prepared_transfers ( + IN in_type incoming_type, + IN in_account_pub BYTEA, + IN in_authorization_pub BYTEA, + IN in_authorization_sig BYTEA, + IN in_recurrent BOOLEAN, + IN in_timestamp INT8, + -- Error status + OUT out_reserve_pub_reuse BOOLEAN +) +LANGUAGE plpgsql AS $$ +DECLARE + talerable_tx INT8; + idempotent BOOLEAN; +BEGIN + +-- Check idempotency +SELECT type = in_type + AND account_pub = in_account_pub + AND recurrent = in_recurrent +INTO idempotent +FROM prepared_in +WHERE authorization_pub = in_authorization_pub; + +-- Check idempotency and delay garbage collection +IF FOUND AND idempotent THEN + UPDATE prepared_in + SET registered_at=in_timestamp + WHERE authorization_pub=in_authorization_pub; + RETURN; +END IF; + +-- Check reserve pub reuse +out_reserve_pub_reuse=in_type = 'reserve' AND ( + EXISTS(SELECT FROM taler_in WHERE account_pub = in_account_pub AND type = 'reserve') + OR EXISTS(SELECT FROM prepared_in WHERE account_pub = in_account_pub AND type = 'reserve' AND authorization_pub != in_authorization_pub) +); +IF out_reserve_pub_reuse THEN + RETURN; +END IF; + +IF in_recurrent THEN + -- Finalize one pending right now + WITH moved_tx AS ( + DELETE FROM pending_recurrent_in + WHERE tx_in_id = ( + SELECT tx_in_id + FROM pending_recurrent_in + JOIN tx_in USING (tx_in_id) + WHERE authorization_pub = in_authorization_pub + ORDER BY created_at ASC + LIMIT 1 + ) + RETURNING tx_in_id + ) + INSERT INTO taler_in (tx_in_id, type, account_pub, authorization_pub, authorization_sig) + SELECT moved_tx.tx_in_id, in_type, in_account_pub, in_authorization_pub, in_authorization_sig + FROM moved_tx + RETURNING tx_in_id INTO talerable_tx; + IF talerable_tx IS NOT NULL THEN + PERFORM pg_notify('incoming_tx', talerable_tx::text); + END IF; +ELSE + -- Bounce all pending + WITH bounced AS ( + DELETE FROM pending_recurrent_in + WHERE authorization_pub = in_authorization_pub + RETURNING tx_in_id + ) + INSERT INTO bounced (tx_in_id) + SELECT tx_in_id FROM bounced; +END IF; + +-- Upsert registration +INSERT INTO prepared_in ( type, - account_pub + account_pub, + authorization_pub, + authorization_sig, + recurrent, + registered_at, + tx_in_id ) VALUES ( - out_tx_row_id, in_type, - in_account_pub -); --- Notify new incoming transaction -PERFORM pg_notify('incoming_tx', out_tx_row_id || ''); + in_account_pub, + in_authorization_pub, + in_authorization_sig, + in_recurrent, + in_timestamp, + talerable_tx +) ON CONFLICT (authorization_pub) +DO UPDATE SET + type = EXCLUDED.type, + account_pub = EXCLUDED.account_pub, + recurrent = EXCLUDED.recurrent, + registered_at = EXCLUDED.registered_at, + tx_in_id = EXCLUDED.tx_in_id, + authorization_sig = EXCLUDED.authorization_sig; END $$; -COMMENT ON FUNCTION add_incoming IS 'Create an incoming taler transaction and register it'; -\ No newline at end of file + +CREATE FUNCTION delete_prepared_transfers ( + IN in_authorization_pub BYTEA, + IN in_timestamp INT8, + OUT out_found BOOLEAN +) +LANGUAGE plpgsql AS $$ +BEGIN + +-- Bounce all pending +WITH bounced AS ( + DELETE FROM pending_recurrent_in + WHERE authorization_pub = in_authorization_pub + RETURNING tx_in_id +) +INSERT INTO bounced (tx_in_id) +SELECT tx_in_id FROM bounced; + +-- Delete registration +DELETE FROM prepared_in +WHERE authorization_pub = in_authorization_pub; +out_found = FOUND; + +END $$; +\ No newline at end of file diff --git a/common/taler-api/src/api.rs b/common/taler-api/src/api.rs @@ -35,11 +35,13 @@ use wire::WireGateway; use crate::{ Listener, Serve, + api::transfer::WireTransferGateway, auth::{AuthMethod, AuthMiddlewareState}, error::{ApiResult, failure, failure_code}, }; pub mod revenue; +pub mod transfer; pub mod wire; pub use axum::Router; @@ -78,6 +80,7 @@ impl<S: Send + Clone + Sync + 'static> RouterUtils for Router<S> { pub trait TalerRouter { fn wire_gateway<T: WireGateway>(self, api: Arc<T>, auth: AuthMethod) -> Self; + fn wire_transfer_gateway<T: WireTransferGateway>(self, api: Arc<T>) -> Self; fn revenue<T: Revenue>(self, api: Arc<T>, auth: AuthMethod) -> Self; fn finalize(self) -> Self; fn serve( @@ -92,6 +95,10 @@ impl TalerRouter for Router { self.nest("/taler-wire-gateway", wire::router(api, auth)) } + fn wire_transfer_gateway<T: WireTransferGateway>(self, api: Arc<T>) -> Self { + self.nest("/taler-wire-transfer-gateway", transfer::router(api)) + } + fn revenue<T: Revenue>(self, api: Arc<T>, auth: AuthMethod) -> Self { self.nest("/taler-revenue", revenue::router(api, auth)) } diff --git a/common/taler-api/src/api/transfer.rs b/common/taler-api/src/api/transfer.rs @@ -0,0 +1,110 @@ +/* + 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::{ + RegistrationRequest, RegistrationResponse, SubjectFormat, Unregistration, + WireTransferConfig, + }, + error_code::ErrorCode, +}; + +use crate::{ + constants::WIRE_GATEWAY_API_VERSION, + crypto::check_eddsa_signature, + error::{ApiResult, failure, failure_code}, + json::Req, +}; + +use super::TalerApi; + +pub trait WireTransferGateway: 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: WireTransferGateway>(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)) + }, + ) + .delete( + 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(WireTransferConfig { + name: "taler-wire-transfer-gateway", + version: WIRE_GATEWAY_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/constants.rs b/common/taler-api/src/constants.rs @@ -1,6 +1,6 @@ /* This file is part of TALER - Copyright (C) 2024-2025 Taler Systems SA + 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 @@ -15,6 +15,7 @@ */ pub const WIRE_GATEWAY_API_VERSION: &str = "4:0:0"; +pub const WIRE_GATEWAY_TRANSFER_API_VERSION: &str = "1:0:0"; pub const REVENUE_API_VERSION: &str = "1:0:0"; pub const MAX_PAGE_SIZE: i64 = 1024; pub const MAX_TIMEOUT_MS: u64 = 60 * 60 * 10; // 1H diff --git a/common/taler-api/src/crypto.rs b/common/taler-api/src/crypto.rs @@ -0,0 +1,29 @@ +/* + 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 aws_lc_rs::signature::{self, Ed25519KeyPair, UnparsedPublicKey}; +use taler_common::api_common::{EddsaPublicKey, EddsaSignature}; + +pub fn check_eddsa_signature(key: &EddsaPublicKey, msg: &[u8], sign: &EddsaSignature) -> bool { + UnparsedPublicKey::new(&signature::ED25519, key.as_ref()) + .verify(msg, sign.as_ref()) + .is_ok() +} + +pub fn eddsa_sign(key: &Ed25519KeyPair, msg: &[u8]) -> EddsaSignature { + let signature = key.sign(msg); + EddsaSignature::try_from(signature.as_ref()).unwrap() +} diff --git a/common/taler-api/src/db.rs b/common/taler-api/src/db.rs @@ -39,16 +39,6 @@ use tokio::sync::watch::Receiver; use url::Url; pub type PgQueryBuilder<'b> = QueryBuilder<'b, Postgres>; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type)] -#[allow(non_camel_case_types)] -#[sqlx(type_name = "incoming_type")] -pub enum IncomingType { - reserve, - kyc, - map, -} - /* ------ Serialization ----- */ #[macro_export] diff --git a/common/taler-api/src/lib.rs b/common/taler-api/src/lib.rs @@ -1,6 +1,6 @@ /* This file is part of TALER - Copyright (C) 2024-2025 Taler Systems SA + 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 @@ -24,6 +24,7 @@ pub mod api; pub mod auth; pub mod config; pub mod constants; +pub mod crypto; pub mod db; pub mod error; pub mod json; diff --git a/common/taler-api/src/subject.rs b/common/taler-api/src/subject.rs @@ -23,13 +23,14 @@ use aws_lc_rs::digest::{SHA256, digest}; use compact_str::CompactString; use taler_common::{ api_common::{EddsaPublicKey, ShortHashCode}, - types::base32::{Base32Error, CROCKFORD_ALPHABET}, - types::url, + db::IncomingType, + types::{ + base32::{Base32Error, CROCKFORD_ALPHABET}, + url, + }, }; use url::Url; -use crate::db::IncomingType; - #[derive(Debug, Clone, PartialEq, Eq)] pub enum IncomingSubject { Reserve(EddsaPublicKey), @@ -340,14 +341,15 @@ pub fn subject_is_qr_bill(reference: &str) -> bool { mod test { use std::str::FromStr as _; - use taler_common::{api_common::{EddsaPublicKey, ShortHashCode}, types::url}; - - use crate::{ + use taler_common::{ + api_common::{EddsaPublicKey, ShortHashCode}, db::IncomingType, - subject::{ - IncomingSubject, IncomingSubjectErr, OutgoingSubject, fmt_out_subject, mod10_recursive, - parse_incoming_unstructured, parse_outgoing, subject_fmt_qr_bill, subject_is_qr_bill, - }, + types::url, + }; + + use crate::subject::{ + IncomingSubject, IncomingSubjectErr, OutgoingSubject, fmt_out_subject, mod10_recursive, + parse_incoming_unstructured, parse_outgoing, subject_fmt_qr_bill, subject_is_qr_bill, }; #[test] diff --git a/common/taler-api/tests/api.rs b/common/taler-api/tests/api.rs @@ -16,24 +16,35 @@ use axum::http::StatusCode; use common::setup; +use jiff::Timestamp; +use sqlx::{PgPool, Row, postgres::PgRow}; +use taler_api::db::TypeHelper as _; use taler_common::{ - api_common::{HashCode, ShortHashCode}, + api_common::{EddsaPublicKey, HashCode, ShortHashCode}, api_revenue::RevenueConfig, + api_transfer::WireTransferConfig, api_wire::{OutgoingHistory, TransferResponse, TransferState, WireConfig}, + db::IncomingType, error_code::ErrorCode, types::{amount::amount, payto::payto, url}, }; use taler_test_utils::{ json, - routine::{admin_add_incoming_routine, revenue_routine, routine_pagination, transfer_routine}, + routine::{ + Status, admin_add_incoming_routine, registration_routine, revenue_routine, + routine_pagination, transfer_routine, + }, server::TestServer as _, }; +use tracing::warn; + +use crate::common::db::{AddIncomingResult, add_incoming}; mod common; #[tokio::test] async fn errors() { - let server = setup().await; + let (server, _) = setup().await; server .get("/unknown") .await @@ -46,12 +57,16 @@ async fn errors() { #[tokio::test] async fn config() { - let server = setup().await; + let (server, _) = setup().await; server .get("/taler-wire-gateway/config") .await .assert_ok_json::<WireConfig>(); server + .get("/taler-wire-transfer-gateway/config") + .await + .assert_ok_json::<WireTransferConfig>(); + server .get("/taler-revenue/config") .await .assert_ok_json::<RevenueConfig>(); @@ -59,13 +74,13 @@ async fn config() { #[tokio::test] async fn transfer() { - let server = setup().await; + let (server, _) = setup().await; transfer_routine(&server, TransferState::success, &payto("payto://test")).await; } #[tokio::test] async fn outgoing_history() { - let server = setup().await; + let (server, _) = setup().await; routine_pagination::<OutgoingHistory, _>( &server, "/taler-wire-gateway/history/outgoing", @@ -94,22 +109,113 @@ async fn outgoing_history() { #[tokio::test] async fn admin_add_incoming() { - let server = setup().await; + let (server, _) = setup().await; admin_add_incoming_routine(&server, &payto("payto://test"), true).await; } #[tokio::test] async fn revenue() { - let server = setup().await; + let (server, _) = setup().await; revenue_routine(&server, &payto("payto://test"), true).await; } #[tokio::test] async fn account_check() { - let server = setup().await; + let (server, _) = setup().await; server .get("/taler-wire-gateway/account/check") .query("account", "payto://test") .await .assert_status(StatusCode::NOT_IMPLEMENTED); } + +async fn check_in(pool: &PgPool) -> Vec<Status> { + sqlx::query( + " + SELECT pending_recurrent_in.authorization_pub IS NOT NULL, bounced.tx_in_id IS NOT NULL, type, taler_in.account_pub + FROM tx_in + LEFT JOIN taler_in USING (tx_in_id) + LEFT JOIN pending_recurrent_in USING (tx_in_id) + LEFT JOIN bounced USING (tx_in_id) + ORDER BY tx_in.tx_in_id + ", + ) + .try_map(|r: PgRow| { + Ok( + if r.try_get_flag(0)? { + Status::Pending + } else if r.try_get_flag(1)? { + Status::Bounced + } else { + match r.try_get(2)? { + None => Status::Simple, + Some(IncomingType::reserve) => Status::Reserve(r.try_get(3)?), + Some(IncomingType::kyc) => Status::Kyc(r.try_get(3)?), + Some(e) => unreachable!("{e:?}") + } + } + ) + }) + .fetch_all(pool) + .await + .unwrap() +} + +async fn register_mapped(pool: &PgPool, account_pub: &EddsaPublicKey) { + let reason = match add_incoming( + pool, + &amount("EUR:42"), + &payto("payto://test"), + "lol", + &Timestamp::now(), + IncomingType::map, + account_pub, + ) + .await + .unwrap() + { + AddIncomingResult::Success { .. } => return, + AddIncomingResult::ReservePubReuse => "reserve pub reuse", + AddIncomingResult::UnknownMapping => "unknown mapping", + AddIncomingResult::MappingReuse => "mapping reuse", + }; + warn!("Bounce {reason}"); + sqlx::query( + " + WITH tx_in AS ( + INSERT INTO tx_in ( + amount, + debit_payto, + created_at, + subject + ) VALUES ( + (32, 0), + 'payto', + 0, + 'subject' + ) RETURNING tx_in_id + ) + INSERT INTO bounced (tx_in_id) + SELECT tx_in_id FROM tx_in + ", + ) + .execute(pool) + .await + .unwrap(); +} + +#[tokio::test] +async fn registration() { + let (server, pool) = setup().await; + registration_routine( + &server, + &payto("payto://test"), + || check_in(&pool), + |account_pub| { + let account_pub = account_pub.clone(); + let pool = &pool; + async move { register_mapped(pool, &account_pub).await } + }, + ) + .await; +} diff --git a/common/taler-api/tests/common/db.rs b/common/taler-api/tests/common/db.rs @@ -17,7 +17,7 @@ use jiff::Timestamp; use sqlx::{PgPool, QueryBuilder, Row, postgres::PgRow}; use taler_api::{ - db::{BindHelper, IncomingType, TypeHelper, history, page}, + db::{BindHelper, TypeHelper, history, page}, serialized, subject::fmt_out_subject, }; @@ -25,10 +25,12 @@ 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, }, + db::IncomingType, types::{ amount::{Amount, Currency}, payto::PaytoURI, @@ -63,7 +65,7 @@ pub async fn transfer(db: &PgPool, req: &TransferRequest) -> sqlx::Result<Transf sqlx::query( " SELECT out_request_uid_reuse, out_wtid_reuse, out_transfer_row_id, out_created_at - FROM taler_transfer($1, $2, $3, $4, $5, $6, $7, $8) + FROM taler_transfer($1,$2,$3,$4,$5,$6,$7,$8) ", ) .bind(&req.amount) @@ -218,6 +220,8 @@ pub async fn outgoing_revenue( pub enum AddIncomingResult { Success { id: SafeU64, created_at: Timestamp }, ReservePubReuse, + UnknownMapping, + MappingReuse, } pub async fn add_incoming( @@ -226,26 +230,28 @@ pub async fn add_incoming( debit_account: &PaytoURI, subject: &str, timestamp: &Timestamp, - kind: IncomingType, - key: &EddsaPublicKey, + ty: IncomingType, + account_pub: &EddsaPublicKey, ) -> sqlx::Result<AddIncomingResult> { serialized!( sqlx::query( - " - SELECT out_reserve_pub_reuse, out_tx_row_id, out_created_at - FROM add_incoming($1, $2, $3, $4, $5, $6) - ", + "SELECT out_reserve_pub_reuse, out_mapping_reuse, out_unknown_mapping, out_tx_row_id, out_created_at + FROM add_incoming($1,$2,$3,$4,$5,$6)", ) .bind(amount) .bind(subject) .bind(debit_account.raw()) - .bind(kind) - .bind(key) + .bind(ty) + .bind(account_pub) .bind_timestamp(timestamp) .try_map(|r: PgRow| { Ok(if r.try_get_flag("out_reserve_pub_reuse")? { AddIncomingResult::ReservePubReuse - } else { + } else if r.try_get_flag("out_mapping_reuse")? { + AddIncomingResult::MappingReuse + } else if r.try_get_flag("out_unknown_mapping")? { + AddIncomingResult::UnknownMapping + } else{ AddIncomingResult::Success { id: r.try_get_safeu64("out_tx_row_id")?, created_at: r.try_get_timestamp("out_created_at")?, @@ -352,3 +358,44 @@ pub async fn revenue_history( ) .await } + +pub enum RegistrationResult { + Success, + ReservePubReuse, +} + +pub async fn transfer_register( + db: &PgPool, + req: &RegistrationRequest, +) -> sqlx::Result<RegistrationResult> { + let ty: IncomingType = req.r#type.into(); + serialized!( + sqlx::query( + "SELECT out_reserve_pub_reuse FROM register_prepared_transfers($1,$2,$3,$4,$5,$6)" + ) + .bind(ty) + .bind(&req.account_pub) + .bind(&req.authorization_pub) + .bind(&req.authorization_sig) + .bind(req.recurrent) + .bind_timestamp(&Timestamp::now()) + .try_map(|r: PgRow| { + Ok(if r.try_get_flag("out_reserve_pub_reuse")? { + RegistrationResult::ReservePubReuse + } else { + RegistrationResult::Success + }) + }) + .fetch_one(db) + ) +} + +pub async fn transfer_unregister(db: &PgPool, req: &Unregistration) -> sqlx::Result<bool> { + serialized!( + sqlx::query("SELECT out_found FROM delete_prepared_transfers($1,$2)") + .bind(&req.authorization_pub) + .bind_timestamp(&Timestamp::now()) + .try_map(|r: PgRow| r.try_get_flag("out_found")) + .fetch_one(db) + ) +} diff --git a/common/taler-api/tests/common/mod.rs b/common/taler-api/tests/common/mod.rs @@ -20,21 +20,28 @@ use db::notification_listener; use jiff::Timestamp; use sqlx::PgPool; use taler_api::{ - api::{Router, TalerApi, TalerRouter as _, revenue::Revenue, wire::WireGateway}, + api::{ + Router, TalerApi, TalerRouter as _, revenue::Revenue, transfer::WireTransferGateway, + wire::WireGateway, + }, auth::AuthMethod, - db::IncomingType, - error::{ApiResult, failure}, + error::{ApiResult, failure, failure_code}, + subject::fmt_in_subject, }; use taler_common::{ api_params::{History, Page}, api_revenue::RevenueIncomingHistory, + api_transfer::{ + RegistrationRequest, RegistrationResponse, SubjectFormat, TransferSubject, Unregistration, + }, api_wire::{ AddIncomingRequest, AddIncomingResponse, AddKycauthRequest, AddKycauthResponse, IncomingHistory, OutgoingHistory, TransferList, TransferRequest, TransferResponse, TransferState, TransferStatus, }, + db::IncomingType, error_code::ErrorCode, - types::{amount::Currency, payto::payto}, + types::{amount::Currency, payto::payto, timestamp::TalerTimestamp}, }; use taler_test_utils::db::db_test_setup_manual; use tokio::sync::watch::Sender; @@ -135,6 +142,9 @@ impl WireGateway for TestApi { ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT, "reserve_pub used already".to_owned(), )), + db::AddIncomingResult::UnknownMapping | db::AddIncomingResult::MappingReuse => { + unreachable!("mapping not used") + } } } @@ -158,6 +168,9 @@ impl WireGateway for TestApi { ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT, "reserve_pub used already".to_owned(), )), + db::AddIncomingResult::UnknownMapping | db::AddIncomingResult::MappingReuse => { + unreachable!("mapping not used") + } } } @@ -179,6 +192,45 @@ impl Revenue for TestApi { } } +impl WireTransferGateway for TestApi { + fn supported_formats(&self) -> &[SubjectFormat] { + &[SubjectFormat::SIMPLE] + } + + 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::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(()) + } + } +} + pub fn test_api(pool: PgPool, currency: Currency) -> Router { let outgoing_channel = Sender::new(0); let incoming_channel = Sender::new(0); @@ -196,10 +248,14 @@ pub fn test_api(pool: PgPool, currency: Currency) -> Router { let state = Arc::new(wg); Router::new() .wire_gateway(state.clone(), AuthMethod::None) + .wire_transfer_gateway(state.clone()) .revenue(state, AuthMethod::None) } -pub async fn setup() -> Router { +pub async fn setup() -> (Router, PgPool) { let (_, pool) = db_test_setup_manual("db".as_ref(), "taler-api").await; - test_api(pool.clone(), "EUR".parse().unwrap()).finalize() + ( + test_api(pool.clone(), "EUR".parse().unwrap()).finalize(), + pool, + ) } diff --git a/common/taler-api/tests/security.rs b/common/taler-api/tests/security.rs @@ -33,7 +33,7 @@ mod common; #[tokio::test] async fn body_parsing() { - let server = setup().await; + let (server, _) = setup().await; let eur: Currency = "EUR".parse().unwrap(); let normal_body = TransferRequest { request_uid: Base32::rand(), diff --git a/common/taler-common/src/api_transfer.rs b/common/taler-common/src/api_transfer.rs @@ -0,0 +1,114 @@ +/* + 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-wire-transfer.html#taler-wire-transfer-gateway-http-api> + +use compact_str::CompactString; +use url::Url; + +use crate::{ + api_common::EddsaSignature, + db::IncomingType, + types::{amount::Amount, timestamp::TalerTimestamp}, +}; + +use super::api_common::EddsaPublicKey; +use serde::{Deserialize, Serialize}; + +/// <https://docs.taler.net/core/api-bank-wire-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-wire-transfer.html#tsref-type-WireTransferConfig> +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WireTransferConfig<'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-wire-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-wire-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-wire-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-wire-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/db.rs b/common/taler-common/src/db.rs @@ -1,6 +1,6 @@ /* This file is part of TALER - Copyright (C) 2025 Taler Systems SA + 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 @@ -27,6 +27,15 @@ use sqlx::{ use sqlx::{PgConnection, postgres::PgRow}; use tracing::{debug, info}; +#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type)] +#[allow(non_camel_case_types)] +#[sqlx(type_name = "incoming_type")] +pub enum IncomingType { + reserve, + kyc, + map, +} + /* ----- Pool ----- */ pub async fn pool(cfg: PgConnectOptions, schema: &str) -> sqlx::Result<PgPool> { diff --git a/common/taler-common/src/error_code.rs b/common/taler-common/src/error_code.rs @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - Copyright (C) 2024-2025 Taler Systems SA + Copyright (C) 2024-2026 Taler Systems SA GNU Taler is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published @@ -23,99 +23,105 @@ /// Error codes used by GNU Taler #[derive(Debug, Copy, Clone, PartialEq, Eq)] -#[allow(non_camel_case_types, dead_code, clippy::zero_prefixed_literal)] +#[allow(non_camel_case_types, dead_code)] #[repr(u16)] pub enum ErrorCode { /// Special code to indicate success (no error). - NONE = 0000, + NONE = 0, /// An error response did not include an error code in the format expected by the client. Most likely, the server does not speak the GNU Taler protocol. Check the URL and/or the network connection to the server. - INVALID = 0001, + INVALID = 1, /// An internal failure happened on the client side. Details should be in the local logs. Check if you are using the latest available version or file a report with the developers. - GENERIC_CLIENT_INTERNAL_ERROR = 0002, + GENERIC_CLIENT_INTERNAL_ERROR = 2, /// The client does not support the protocol version advertised by the server. - GENERIC_CLIENT_UNSUPPORTED_PROTOCOL_VERSION = 0003, + GENERIC_CLIENT_UNSUPPORTED_PROTOCOL_VERSION = 3, /// The response we got from the server was not in the expected format. Most likely, the server does not speak the GNU Taler protocol. Check the URL and/or the network connection to the server. - GENERIC_INVALID_RESPONSE = 0010, + GENERIC_INVALID_RESPONSE = 10, /// The operation timed out. Trying again might help. Check the network connection. - GENERIC_TIMEOUT = 0011, + GENERIC_TIMEOUT = 11, /// The protocol version given by the server does not follow the required format. Most likely, the server does not speak the GNU Taler protocol. Check the URL and/or the network connection to the server. - GENERIC_VERSION_MALFORMED = 0012, + GENERIC_VERSION_MALFORMED = 12, /// The service responded with a reply that was in the right data format, but the content did not satisfy the protocol. Please file a bug report. - GENERIC_REPLY_MALFORMED = 0013, + GENERIC_REPLY_MALFORMED = 13, /// There is an error in the client-side configuration, for example an option is set to an invalid value. Check the logs and fix the local configuration. - GENERIC_CONFIGURATION_INVALID = 0014, + GENERIC_CONFIGURATION_INVALID = 14, /// The client made a request to a service, but received an error response it does not know how to handle. Please file a bug report. - GENERIC_UNEXPECTED_REQUEST_ERROR = 0015, + GENERIC_UNEXPECTED_REQUEST_ERROR = 15, /// The token used by the client to authorize the request does not grant the required permissions for the request. Check the requirements and obtain a suitable authorization token to proceed. - GENERIC_TOKEN_PERMISSION_INSUFFICIENT = 0016, + GENERIC_TOKEN_PERMISSION_INSUFFICIENT = 16, /// The HTTP method used is invalid for this endpoint. This is likely a bug in the client implementation. Check if you are using the latest available version and/or file a report with the developers. - GENERIC_METHOD_INVALID = 0020, + GENERIC_METHOD_INVALID = 20, /// There is no endpoint defined for the URL provided by the client. Check if you used the correct URL and/or file a report with the developers of the client software. - GENERIC_ENDPOINT_UNKNOWN = 0021, + GENERIC_ENDPOINT_UNKNOWN = 21, /// The JSON in the client's request was malformed. This is likely a bug in the client implementation. Check if you are using the latest available version and/or file a report with the developers. - GENERIC_JSON_INVALID = 0022, + GENERIC_JSON_INVALID = 22, /// Some of the HTTP headers provided by the client were malformed and caused the server to not be able to handle the request. This is likely a bug in the client implementation. Check if you are using the latest available version and/or file a report with the developers. - GENERIC_HTTP_HEADERS_MALFORMED = 0023, + GENERIC_HTTP_HEADERS_MALFORMED = 23, /// The payto:// URI provided by the client is malformed. Check that you are using the correct syntax as of RFC 8905 and/or that you entered the bank account number correctly. - GENERIC_PAYTO_URI_MALFORMED = 0024, + GENERIC_PAYTO_URI_MALFORMED = 24, /// A required parameter in the request was missing. This is likely a bug in the client implementation. Check if you are using the latest available version and/or file a report with the developers. - GENERIC_PARAMETER_MISSING = 0025, + GENERIC_PARAMETER_MISSING = 25, /// A parameter in the request was malformed. This is likely a bug in the client implementation. Check if you are using the latest available version and/or file a report with the developers. - GENERIC_PARAMETER_MALFORMED = 0026, + GENERIC_PARAMETER_MALFORMED = 26, /// The reserve public key was malformed. - GENERIC_RESERVE_PUB_MALFORMED = 0027, + GENERIC_RESERVE_PUB_MALFORMED = 27, /// The body in the request could not be decompressed by the server. This is likely a bug in the client implementation. Check if you are using the latest available version and/or file a report with the developers. - GENERIC_COMPRESSION_INVALID = 0028, + GENERIC_COMPRESSION_INVALID = 28, /// A segment in the path of the URL provided by the client is malformed. Check that you are using the correct encoding for the URL. - GENERIC_PATH_SEGMENT_MALFORMED = 0029, + GENERIC_PATH_SEGMENT_MALFORMED = 29, /// The currency involved in the operation is not acceptable for this server. Check your configuration and make sure the currency specified for a given service provider is one of the currencies supported by that provider. - GENERIC_CURRENCY_MISMATCH = 0030, + GENERIC_CURRENCY_MISMATCH = 30, /// The URI is longer than the longest URI the HTTP server is willing to parse. If you believe this was a legitimate request, contact the server administrators and/or the software developers to increase the limit. - GENERIC_URI_TOO_LONG = 0031, + GENERIC_URI_TOO_LONG = 31, /// The body is too large to be permissible for the endpoint. If you believe this was a legitimate request, contact the server administrators and/or the software developers to increase the limit. - GENERIC_UPLOAD_EXCEEDS_LIMIT = 0032, + GENERIC_UPLOAD_EXCEEDS_LIMIT = 32, + /// A parameter in the request was given that must not be present. This is likely a bug in the client implementation. Check if you are using the latest available version and/or file a report with the developers. + GENERIC_PARAMETER_EXTRA = 33, /// The service refused the request due to lack of proper authorization. Accessing this endpoint requires an access token from the account owner. - GENERIC_UNAUTHORIZED = 0040, + GENERIC_UNAUTHORIZED = 40, /// The service refused the request as the given authorization token is unknown. You should request a valid access token from the account owner. - GENERIC_TOKEN_UNKNOWN = 0041, + GENERIC_TOKEN_UNKNOWN = 41, /// The service refused the request as the given authorization token expired. You should request a fresh authorization token from the account owner. - GENERIC_TOKEN_EXPIRED = 0042, + GENERIC_TOKEN_EXPIRED = 42, /// The service refused the request as the given authorization token is invalid or malformed. You should check that you have the right credentials. - GENERIC_TOKEN_MALFORMED = 0043, + GENERIC_TOKEN_MALFORMED = 43, /// The service refused the request due to lack of proper rights on the resource. You may need different credentials to be allowed to perform this operation. - GENERIC_FORBIDDEN = 0044, + GENERIC_FORBIDDEN = 44, /// The service failed initialize its connection to the database. The system administrator should check that the service has permissions to access the database and that the database is running. - GENERIC_DB_SETUP_FAILED = 0050, + GENERIC_DB_SETUP_FAILED = 50, /// The service encountered an error event to just start the database transaction. The system administrator should check that the database is running. - GENERIC_DB_START_FAILED = 0051, + GENERIC_DB_START_FAILED = 51, /// The service failed to store information in its database. The system administrator should check that the database is running and review the service logs. - GENERIC_DB_STORE_FAILED = 0052, + GENERIC_DB_STORE_FAILED = 52, /// The service failed to fetch information from its database. The system administrator should check that the database is running and review the service logs. - GENERIC_DB_FETCH_FAILED = 0053, + GENERIC_DB_FETCH_FAILED = 53, /// The service encountered an unrecoverable error trying to commit a transaction to the database. The system administrator should check that the database is running and review the service logs. - GENERIC_DB_COMMIT_FAILED = 0054, + GENERIC_DB_COMMIT_FAILED = 54, /// The service encountered an error event to commit the database transaction, even after repeatedly retrying it there was always a conflicting transaction. This indicates a repeated serialization error; it should only happen if some client maliciously tries to create conflicting concurrent transactions. It could also be a sign of a missing index. Check if you are using the latest available version and/or file a report with the developers. - GENERIC_DB_SOFT_FAILURE = 0055, + GENERIC_DB_SOFT_FAILURE = 55, /// The service's database is inconsistent and violates service-internal invariants. Check if you are using the latest available version and/or file a report with the developers. - GENERIC_DB_INVARIANT_FAILURE = 0056, + GENERIC_DB_INVARIANT_FAILURE = 56, /// The HTTP server experienced an internal invariant failure (bug). Check if you are using the latest available version and/or file a report with the developers. - GENERIC_INTERNAL_INVARIANT_FAILURE = 0060, + GENERIC_INTERNAL_INVARIANT_FAILURE = 60, /// The service could not compute a cryptographic hash over some JSON value. Check if you are using the latest available version and/or file a report with the developers. - GENERIC_FAILED_COMPUTE_JSON_HASH = 0061, + GENERIC_FAILED_COMPUTE_JSON_HASH = 61, /// The service could not compute an amount. Check if you are using the latest available version and/or file a report with the developers. - GENERIC_FAILED_COMPUTE_AMOUNT = 0062, + GENERIC_FAILED_COMPUTE_AMOUNT = 62, /// The HTTP server had insufficient memory to parse the request. Restarting services periodically can help, especially if Postgres is using excessive amounts of memory. Check with the system administrator to investigate. - GENERIC_PARSER_OUT_OF_MEMORY = 0070, + GENERIC_PARSER_OUT_OF_MEMORY = 70, /// The HTTP server failed to allocate memory. Restarting services periodically can help, especially if Postgres is using excessive amounts of memory. Check with the system administrator to investigate. - GENERIC_ALLOCATION_FAILURE = 0071, + GENERIC_ALLOCATION_FAILURE = 71, /// The HTTP server failed to allocate memory for building JSON reply. Restarting services periodically can help, especially if Postgres is using excessive amounts of memory. Check with the system administrator to investigate. - GENERIC_JSON_ALLOCATION_FAILURE = 0072, + GENERIC_JSON_ALLOCATION_FAILURE = 72, /// The HTTP server failed to allocate memory for making a CURL request. Restarting services periodically can help, especially if Postgres is using excessive amounts of memory. Check with the system administrator to investigate. - GENERIC_CURL_ALLOCATION_FAILURE = 0073, + GENERIC_CURL_ALLOCATION_FAILURE = 73, /// The backend could not locate a required template to generate an HTML reply. The system administrator should check if the resource files are installed in the correct location and are readable to the service. - GENERIC_FAILED_TO_LOAD_TEMPLATE = 0074, + GENERIC_FAILED_TO_LOAD_TEMPLATE = 74, /// The backend could not expand the template to generate an HTML reply. The system administrator should investigate the logs and check if the templates are well-formed. - GENERIC_FAILED_TO_EXPAND_TEMPLATE = 0075, + GENERIC_FAILED_TO_EXPAND_TEMPLATE = 75, + /// The requested feature is not implemented by the server. The system administrator of the server may try to update the software or build it with other options to enable the feature. + GENERIC_FEATURE_NOT_IMPLEMENTED = 76, + /// The operating system failed to allocate required resources. Restarting services periodically can help, especially if Postgres is using excessive amounts of memory. Check with the system administrator to investigate. + GENERIC_OS_RESOURCE_ALLOCATION_FAILURE = 77, /// Exchange is badly configured and thus cannot operate. EXCHANGE_GENERIC_BAD_CONFIGURATION = 1000, /// Operation specified unknown for this endpoint. @@ -204,6 +210,18 @@ pub enum ErrorCode { EXCHANGE_GENERIC_AML_PROGRAM_RECURSION_DETECTED = 1042, /// A check against sanction lists failed. This is indicative of an internal error in the sanction list processing logic. This needs to be investigated by the exchange operator. EXCHANGE_GENERIC_KYC_SANCTION_LIST_CHECK_FAILED = 1043, + /// The process to generate a PDF from a template failed. A likely cause is a syntactic error in the template. This needs to be investigated by the exchange operator. + EXCHANGE_GENERIC_TYPST_TEMPLATE_FAILURE = 1044, + /// A process to combine multiple PDFs into one larger document failed. A likely cause is a resource exhaustion problem on the server. This needs to be investigated by the exchange operator. + EXCHANGE_GENERIC_PDFTK_FAILURE = 1045, + /// The process to generate a PDF from a template crashed. A likely cause is a bug in the Typst software. This needs to be investigated by the exchange operator. + EXCHANGE_GENERIC_TYPST_CRASH = 1046, + /// The process to combine multiple PDFs into a larger document crashed. A likely cause is a bug in the pdftk software. This needs to be investigated by the exchange operator. + EXCHANGE_GENERIC_PDFTK_CRASH = 1047, + /// One of the binaries needed to generate the PDF is not installed. If this feature is required, the system administrator should make sure Typst and pdftk are both installed. + EXCHANGE_GENERIC_NO_TYPST_OR_PDFTK = 1048, + /// The exchange is not aware of the given target account. The specified account is not a customer of this service. + EXCHANGE_GENERIC_TARGET_ACCOUNT_UNKNOWN = 1049, /// The exchange did not find information about the specified transaction in the database. EXCHANGE_DEPOSITS_GET_NOT_FOUND = 1100, /// The wire hash of given to a "/deposits/" handler was malformed. @@ -250,7 +268,7 @@ pub enum ErrorCode { EXCHANGE_WITHDRAW_REVEAL_INVALID_HASH = 1164, /// The maximum age in the commitment is too large for the reserve EXCHANGE_WITHDRAW_MAXIMUM_AGE_TOO_LARGE = 1165, - /// The batch withdraw included a planchet that was already withdrawn. This is not allowed. + /// The withdraw operation included the same planchet more than once. This is not allowed. EXCHANGE_WITHDRAW_IDEMPOTENT_PLANCHET = 1175, /// The signature made by the coin over the deposit permission is not valid. EXCHANGE_DEPOSIT_COIN_SIGNATURE_INVALID = 1205, @@ -402,6 +420,8 @@ pub enum ErrorCode { EXCHANGE_SIGNKEY_HELPER_BUG = 1751, /// The helper refuses to sign with the key, because it is too early: the validity period has not yet started. EXCHANGE_SIGNKEY_HELPER_TOO_EARLY = 1752, + /// The signatures from the master exchange public key are missing, thus the exchange cannot currently sign its API responses. The exchange operator must use taler-exchange-offline to sign the current key material. + EXCHANGE_SIGNKEY_HELPER_OFFLINE_MISSING = 1753, /// The purse expiration time is in the past at the time of its creation. EXCHANGE_RESERVES_PURSE_EXPIRATION_BEFORE_NOW = 1775, /// The purse expiration time is set to never, which is not allowed. @@ -602,10 +622,12 @@ pub enum ErrorCode { MERCHANT_GENERIC_INSTANCE_UNKNOWN = 2000, /// The start and end-times in the wire fee structure leave a hole. This is not allowed. MERCHANT_GENERIC_HOLE_IN_WIRE_FEE_STRUCTURE = 2001, - /// The merchant was unable to obtain a valid answer to /wire from the exchange. - MERCHANT_GENERIC_EXCHANGE_WIRE_REQUEST_FAILED = 2002, + /// The master key of the exchange does not match the one configured for this merchant. As a result, we refuse to do business with this exchange. The administrator should check if they configured the exchange correctly in the merchant backend. + MERCHANT_GENERIC_EXCHANGE_MASTER_KEY_MISMATCH = 2002, /// The product category is not known to the backend. MERCHANT_GENERIC_CATEGORY_UNKNOWN = 2003, + /// The unit referenced in the request is not known to the backend. + MERCHANT_GENERIC_UNIT_UNKNOWN = 2004, /// The proposal is not known to the backend. MERCHANT_GENERIC_ORDER_UNKNOWN = 2005, /// The order provided to the backend could not be completed, because a product to be completed via inventory data is not actually in our inventory. @@ -654,6 +676,30 @@ pub enum ErrorCode { MERCHANT_GENERIC_TOKEN_KEY_UNKNOWN = 2027, /// The merchant backend is not configured to support the DONAU protocol. MERCHANT_GENERIC_DONAU_NOT_CONFIGURED = 2028, + /// The public signing key given in the exchange response is not in the current keys response. It is possible that the operation will succeed later after the merchant has downloaded an updated keys response. + MERCHANT_EXCHANGE_SIGN_PUB_UNKNOWN = 2029, + /// The merchant backend does not support the requested feature. + MERCHANT_GENERIC_FEATURE_NOT_AVAILABLE = 2030, + /// This operation requires multi-factor authorization and the respective instance does not have a sufficient number of factors that could be validated configured. You need to ask the system administrator to perform this operation. + MERCHANT_GENERIC_MFA_MISSING = 2031, + /// A donation authority (Donau) provided an invalid response. This should be analyzed by the administrator. Trying again later may help. + MERCHANT_GENERIC_DONAU_INVALID_RESPONSE = 2032, + /// The unit referenced in the request is builtin and cannot be modified or deleted. + MERCHANT_GENERIC_UNIT_BUILTIN = 2033, + /// The report ID provided to the backend is not known to the backend. + MERCHANT_GENERIC_REPORT_UNKNOWN = 2034, + /// The report ID provided to the backend is not known to the backend. + MERCHANT_GENERIC_REPORT_GENERATOR_UNCONFIGURED = 2035, + /// The product group ID provided to the backend is not known to the backend. + MERCHANT_GENERIC_PRODUCT_GROUP_UNKNOWN = 2036, + /// The money pod ID provided to the backend is not known to the backend. + MERCHANT_GENERIC_MONEY_POT_UNKNOWN = 2037, + /// The session ID provided to the backend is not known to the backend. + MERCHANT_GENERIC_SESSION_UNKNOWN = 2038, + /// The merchant does not have a charity associated with the selected Donau. As a result, it cannot generate the requested donation receipt. This could happen if the charity was removed from the backend between order creation and payment. + MERCHANT_GENERIC_DONAU_CHARITY_UNKNOWN = 2039, + /// The merchant does not expect any transfer with the given ID and can thus not return any details about it. + MERCHANT_GENERIC_EXPECTED_TRANSFER_UNKNOWN = 2040, /// The exchange failed to provide a valid answer to the tracking request, thus those details are not in the response. MERCHANT_GET_ORDERS_EXCHANGE_TRACKING_FAILURE = 2100, /// The merchant backend failed to construct the request for tracking to the exchange, thus tracking details are not in the response. @@ -664,8 +710,22 @@ pub enum ErrorCode { MERCHANT_GET_ORDERS_ID_INVALID_TOKEN = 2105, /// The contract terms hash used to authenticate the client is invalid for this order. MERCHANT_GET_ORDERS_ID_INVALID_CONTRACT_HASH = 2106, - /// The contract terms version is not invalid. + /// The contract terms version is not understood by the merchant backend. Most likely the merchant backend was downgraded to a version incompatible with the content of the database. MERCHANT_GET_ORDERS_ID_INVALID_CONTRACT_VERSION = 2107, + /// The provided TAN code is invalid for this challenge. + MERCHANT_TAN_CHALLENGE_FAILED = 2125, + /// The backend is not aware of the specified MFA challenge. + MERCHANT_TAN_CHALLENGE_UNKNOWN = 2126, + /// There have been too many attempts to solve the challenge. A new TAN must be requested. + MERCHANT_TAN_TOO_MANY_ATTEMPTS = 2127, + /// The backend failed to launch a helper process required for the multi-factor authentication step. The backend operator should check the logs and fix the Taler merchant backend configuration. + MERCHANT_TAN_MFA_HELPER_EXEC_FAILED = 2128, + /// The challenge was already solved. Thus, we refuse to send it again. + MERCHANT_TAN_CHALLENGE_SOLVED = 2129, + /// It is too early to request another transmission of the challenge. The client should wait and see if they received the previous challenge. + MERCHANT_TAN_TOO_EARLY = 2130, + /// There have been too many attempts to solve MFA. The client may attempt again in the future. + MERCHANT_MFA_FORBIDDEN = 2131, /// The exchange responded saying that funds were insufficient (for example, due to double-spending). MERCHANT_POST_ORDERS_ID_PAY_INSUFFICIENT_FUNDS = 2150, /// The denomination key used for payment is not listed among the denomination keys of the exchange. @@ -702,8 +762,8 @@ pub enum ErrorCode { MERCHANT_POST_ORDERS_ID_PAY_REFUNDED = 2167, /// According to our database, we have refunded more than we were paid (which should not be possible). MERCHANT_POST_ORDERS_ID_PAY_REFUNDS_EXCEED_PAYMENTS = 2168, - /// Legacy stuff. Remove me with protocol v1. - DEAD_QQQ_PAY_MERCHANT_POST_ORDERS_ID_ABORT_REFUND_REFUSED_PAYMENT_COMPLETE = 2169, + /// The refund request is too late because it is past the wire transfer deadline of the order. The merchant must find a different way to pay back the money to the customer. + MERCHANT_PRIVATE_POST_REFUND_AFTER_WIRE_DEADLINE = 2169, /// The payment failed at the exchange. MERCHANT_POST_ORDERS_ID_PAY_EXCHANGE_FAILED = 2170, /// The payment required a minimum age but one of the coins (of a denomination with support for age restriction) did not provide any age_commitment. @@ -734,6 +794,10 @@ pub enum ErrorCode { MERCHANT_POST_ORDERS_ID_PAY_TOKEN_INVALID = 2183, /// The payment violates a transaction limit configured at the given exchange. The wallet has a bug in that it failed to check exchange limits during coin selection. Please report the bug to your wallet developer. MERCHANT_POST_ORDERS_ID_PAY_EXCHANGE_TRANSACTION_LIMIT_VIOLATION = 2184, + /// The donation amount provided in the BKPS does not match the amount of the order choice. + MERCHANT_POST_ORDERS_ID_PAY_DONATION_AMOUNT_MISMATCH = 2185, + /// Some of the exchanges involved refused the request for reasons related to legitimization. The wallet should try with coins of different exchanges. The merchant should check if they have some legitimization process pending at the exchange. + MERCHANT_POST_ORDERS_ID_PAY_EXCHANGE_LEGALLY_REFUSED = 2186, /// The contract hash does not match the given order ID. MERCHANT_POST_ORDERS_ID_PAID_CONTRACT_HASH_MISMATCH = 2200, /// The signature of the merchant is not valid for the given contract hash. @@ -768,6 +832,12 @@ pub enum ErrorCode { MERCHANT_EXCHANGE_TRANSFERS_TRANSIENT_FAILURE = 2263, /// The response from the exchange was unacceptable and should be reviewed with an auditor. MERCHANT_EXCHANGE_TRANSFERS_HARD_FAILURE = 2264, + /// The merchant backend failed to reach the banking gateway to shorten the wire transfer subject. This probably means that the banking gateway of the exchange is currently down. Contact the exchange operator or simply retry again later. + MERCHANT_POST_ACCOUNTS_KYCAUTH_BANK_GATEWAY_UNREACHABLE = 2275, + /// The merchant backend failed to reach the banking gateway to shorten the wire transfer subject. This probably means that the banking gateway of the exchange is currently down. Contact the exchange operator or simply retry again later. + MERCHANT_POST_ACCOUNTS_EXCHANGE_TOO_OLD = 2276, + /// The merchant backend failed to reach the specified exchange. This probably means that the exchange is currently down. Contact the exchange operator or simply retry again later. + MERCHANT_POST_ACCOUNTS_KYCAUTH_EXCHANGE_UNREACHABLE = 2277, /// We could not claim the order because the backend is unaware of it. MERCHANT_POST_ORDERS_ID_CLAIM_NOT_FOUND = 2300, /// We could not claim the order because someone else claimed it first. @@ -846,10 +916,16 @@ pub enum ErrorCode { MERCHANT_PRIVATE_POST_TRANSFERS_ACCOUNT_NOT_FOUND = 2555, /// The backend could not delete the transfer as the echange already replied to our inquiry about it and we have integrated the result. MERCHANT_PRIVATE_DELETE_TRANSFERS_ALREADY_CONFIRMED = 2556, - /// The backend was previously informed about a wire transfer with the same ID but a different amount. Multiple wire transfers with the same ID are not allowed. If the new amount is correct, the old transfer should first be deleted. + /// The backend could not persist the wire transfer due to the state of the backend. This usually means that a wire transfer with the same wire transfer subject but a different amount was previously submitted to the backend. MERCHANT_PRIVATE_POST_TRANSFERS_CONFLICTING_SUBMISSION = 2557, + /// The target bank account given by the exchange is not (or no longer) known at the merchant instance. + MERCHANT_EXCHANGE_TRANSFERS_TARGET_ACCOUNT_UNKNOWN = 2558, /// The amount transferred differs between what was submitted and what the exchange claimed. MERCHANT_EXCHANGE_TRANSFERS_CONFLICTING_TRANSFERS = 2563, + /// The report ID provided to the backend is not known to the backend. + MERCHANT_REPORT_GENERATOR_FAILED = 2570, + /// Failed to fetch the data for the report from the backend. + MERCHANT_REPORT_FETCH_FAILED = 2571, /// The merchant backend cannot create an instance under the given identifier as one already exists. Use PATCH to modify the existing entry. MERCHANT_PRIVATE_POST_INSTANCES_ALREADY_EXISTS = 2600, /// The merchant backend cannot create an instance because the authentication configuration field is malformed. @@ -864,6 +940,8 @@ pub enum ErrorCode { MERCHANT_PRIVATE_ACCOUNT_DELETE_UNKNOWN_ACCOUNT = 2626, /// The bank account specified in the request already exists at the merchant. MERCHANT_PRIVATE_ACCOUNT_EXISTS = 2627, + /// The bank account specified is not acceptable for this exchange. The exchange either does not support the wire method or something else about the specific account. Consult the exchange account constraints and specify a different bank account if you want to use this exchange. + MERCHANT_PRIVATE_ACCOUNT_NOT_ELIGIBLE_FOR_EXCHANGE = 2628, /// The product ID exists. MERCHANT_PRIVATE_POST_PRODUCTS_CONFLICT_PRODUCT_EXISTS = 2650, /// A category with the same name exists already. @@ -878,8 +956,14 @@ pub enum ErrorCode { MERCHANT_PRIVATE_PATCH_PRODUCTS_TOTAL_SOLD_REDUCED = 2663, /// The lock request is for more products than we have left (unlocked) in stock. MERCHANT_PRIVATE_POST_PRODUCTS_LOCK_INSUFFICIENT_STOCKS = 2670, - /// The deletion request is for a product that is locked. + /// The deletion request is for a product that is locked. The product cannot be deleted until the existing offer to expires. MERCHANT_PRIVATE_DELETE_PRODUCTS_CONFLICTING_LOCK = 2680, + /// The proposed name for the product group is already in use. You should select a different name. + MERCHANT_PRIVATE_PRODUCT_GROUP_CONFLICTING_NAME = 2690, + /// The proposed name for the money pot is already in use. You should select a different name. + MERCHANT_PRIVATE_MONEY_POT_CONFLICTING_NAME = 2691, + /// The total amount in the money pot is different from the amount required by the request. The client should fetch the current pot total and retry with the latest amount to succeed. + MERCHANT_PRIVATE_MONEY_POT_CONFLICTING_TOTAL = 2692, /// The requested wire method is not supported by the exchange. MERCHANT_PRIVATE_POST_RESERVES_UNSUPPORTED_WIRE_METHOD = 2700, /// The requested exchange does not allow rewards. @@ -908,6 +992,12 @@ pub enum ErrorCode { MERCHANT_POST_USING_TEMPLATES_NO_AMOUNT = 2862, /// Subject not given in the using template and in the template contract. There is a conflict. MERCHANT_POST_USING_TEMPLATES_NO_SUMMARY = 2863, + /// The selected template has a different type than the one specified in the request of the client. This may happen if the template was updated since the last time the client fetched it. The client should re-fetch the current template and send a request of the correct type. + MERCHANT_POST_USING_TEMPLATES_WRONG_TYPE = 2864, + /// The selected template does not allow one of the specified products to be included in the order. This may happen if the template was updated since the last time the client fetched it. The client should re-fetch the current template and send a request of the correct type. + MERCHANT_POST_USING_TEMPLATES_WRONG_PRODUCT = 2865, + /// The selected combination of products does not allow the backend to compute a price for the order in any of the supported currencies. This may happen if the template was updated since the last time the client fetched it or if the wallet assembled an unsupported combination of products. The site administrator might want to specify additional prices for products, while the client should re-fetch the current template and send a request with a combination of products for which prices exist in the same currency. + MERCHANT_POST_USING_TEMPLATES_NO_CURRENCY = 2866, /// The webhook ID elready exists. MERCHANT_PRIVATE_POST_WEBHOOKS_CONFLICT_WEBHOOK_EXISTS = 2900, /// The webhook serial elready exists. @@ -1030,6 +1120,20 @@ pub enum ErrorCode { BANK_UPDATE_ABORT_CONFLICT = 5153, /// The wtid for a request to transfer funds has already been used, but with a different request unpaid. BANK_TRANSFER_WTID_REUSED = 5154, + /// A non-admin user has tried to set their conversion rate class + BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS = 5155, + /// The referenced conversion rate class was not found + BANK_CONVERSION_RATE_CLASS_UNKNOWN = 5156, + /// The client tried to use an already taken name. + BANK_NAME_REUSE = 5157, + /// This subject format is not supported. + BANK_UNSUPPORTED_SUBJECT_FORMAT = 5158, + /// The derived subject is already used. + BANK_DERIVATION_REUSE = 5159, + /// The provided signature is invalid. + BANK_BAD_SIGNATURE = 5160, + /// The provided timestamp is too old. + BANK_OLD_TIMESTAMP = 5161, /// The sync service failed find the account in its database. SYNC_ACCOUNT_UNKNOWN = 6100, /// The SHA-512 hash provided in the If-None-Match header is malformed. @@ -1080,8 +1184,6 @@ pub enum ErrorCode { WALLET_INVALID_TALER_PAY_URI = 7008, /// The signature on a coin by the exchange's denomination key is invalid after unblinding it. WALLET_EXCHANGE_COIN_SIGNATURE_INVALID = 7009, - /// The exchange does not know about the reserve (yet), and thus withdrawal can't progress. - WALLET_EXCHANGE_WITHDRAW_RESERVE_UNKNOWN_AT_EXCHANGE = 7010, /// The wallet core service is not available. WALLET_CORE_NOT_AVAILABLE = 7011, /// The bank has aborted a withdrawal operation, and thus a withdrawal can't complete. @@ -1150,6 +1252,16 @@ pub enum ErrorCode { WALLET_TRANSACTION_ABANDONED_BY_USER = 7043, /// A payment was attempted, but the merchant claims the order is gone (likely expired). WALLET_PAY_MERCHANT_ORDER_GONE = 7044, + /// The wallet does not have an entry for the requested exchange. + WALLET_EXCHANGE_ENTRY_NOT_FOUND = 7045, + /// The wallet is not able to process the request due to the transaction's state. + WALLET_REQUEST_TRANSACTION_STATE_UNSUPPORTED = 7046, + /// A transaction could not be processed due to an unrecoverable protocol violation. + WALLET_TRANSACTION_PROTOCOL_VIOLATION = 7047, + /// A parameter in the request is malformed or missing. + WALLET_CORE_API_BAD_REQUEST = 7048, + /// The order could not be found. Maybe the merchant deleted it. + WALLET_MERCHANT_ORDER_NOT_FOUND = 7049, /// We encountered a timeout with our payment backend. ANASTASIS_GENERIC_BACKEND_TIMEOUT = 8000, /// The backend requested payment, but the request is malformed. @@ -1304,6 +1416,8 @@ pub enum ErrorCode { DONAU_DONATION_RECEIPT_SIGNATURE_INVALID = 8616, /// The client reused a unique donor identifier nonce, which is not allowed. DONAU_DONOR_IDENTIFIER_NONCE_REUSE = 8617, + /// A charity with the same public key is already registered. + DONAU_CHARITY_PUB_EXISTS = 8618, /// A generic error happened in the LibEuFin nexus. See the enclose details JSON for more information. LIBEUFIN_NEXUS_GENERIC_ERROR = 9000, /// An uncaught exception happened in the LibEuFin nexus service. @@ -1336,6 +1450,8 @@ pub enum ErrorCode { CHALLENGER_INVALID_PIN = 9758, /// The token cannot be valid as no address was ever provided by the client. CHALLENGER_MISSING_ADDRESS = 9759, + /// The client is not allowed to change the address being validated. + CHALLENGER_CLIENT_FORBIDDEN_READ_ONLY = 9760, /// End of error code range. END = 9999, } @@ -1434,6 +1550,10 @@ impl ErrorCode { 413, "The body is too large to be permissible for the endpoint. If you believe this was a legitimate request, contact the server administrators and/or the software developers to increase the limit.", ), + GENERIC_PARAMETER_EXTRA => ( + 400, + "A parameter in the request was given that must not be present. This is likely a bug in the client implementation. Check if you are using the latest available version and/or file a report with the developers.", + ), GENERIC_UNAUTHORIZED => ( 401, "The service refused the request due to lack of proper authorization. Accessing this endpoint requires an access token from the account owner.", @@ -1518,6 +1638,14 @@ impl ErrorCode { 500, "The backend could not expand the template to generate an HTML reply. The system administrator should investigate the logs and check if the templates are well-formed.", ), + GENERIC_FEATURE_NOT_IMPLEMENTED => ( + 501, + "The requested feature is not implemented by the server. The system administrator of the server may try to update the software or build it with other options to enable the feature.", + ), + GENERIC_OS_RESOURCE_ALLOCATION_FAILURE => ( + 500, + "The operating system failed to allocate required resources. Restarting services periodically can help, especially if Postgres is using excessive amounts of memory. Check with the system administrator to investigate.", + ), EXCHANGE_GENERIC_BAD_CONFIGURATION => { (500, "Exchange is badly configured and thus cannot operate.") } @@ -1676,6 +1804,30 @@ impl ErrorCode { 500, "A check against sanction lists failed. This is indicative of an internal error in the sanction list processing logic. This needs to be investigated by the exchange operator.", ), + EXCHANGE_GENERIC_TYPST_TEMPLATE_FAILURE => ( + 500, + "The process to generate a PDF from a template failed. A likely cause is a syntactic error in the template. This needs to be investigated by the exchange operator.", + ), + EXCHANGE_GENERIC_PDFTK_FAILURE => ( + 500, + "A process to combine multiple PDFs into one larger document failed. A likely cause is a resource exhaustion problem on the server. This needs to be investigated by the exchange operator.", + ), + EXCHANGE_GENERIC_TYPST_CRASH => ( + 500, + "The process to generate a PDF from a template crashed. A likely cause is a bug in the Typst software. This needs to be investigated by the exchange operator.", + ), + EXCHANGE_GENERIC_PDFTK_CRASH => ( + 500, + "The process to combine multiple PDFs into a larger document crashed. A likely cause is a bug in the pdftk software. This needs to be investigated by the exchange operator.", + ), + EXCHANGE_GENERIC_NO_TYPST_OR_PDFTK => ( + 501, + "One of the binaries needed to generate the PDF is not installed. If this feature is required, the system administrator should make sure Typst and pdftk are both installed.", + ), + EXCHANGE_GENERIC_TARGET_ACCOUNT_UNKNOWN => ( + 404, + "The exchange is not aware of the given target account. The specified account is not a customer of this service.", + ), EXCHANGE_DEPOSITS_GET_NOT_FOUND => ( 404, "The exchange did not find information about the specified transaction in the database.", @@ -1765,8 +1917,8 @@ impl ErrorCode { "The maximum age in the commitment is too large for the reserve", ), EXCHANGE_WITHDRAW_IDEMPOTENT_PLANCHET => ( - 409, - "The batch withdraw included a planchet that was already withdrawn. This is not allowed.", + 400, + "The withdraw operation included the same planchet more than once. This is not allowed.", ), EXCHANGE_DEPOSIT_COIN_SIGNATURE_INVALID => ( 403, @@ -2055,6 +2207,10 @@ impl ErrorCode { 400, "The helper refuses to sign with the key, because it is too early: the validity period has not yet started.", ), + EXCHANGE_SIGNKEY_HELPER_OFFLINE_MISSING => ( + 500, + "The signatures from the master exchange public key are missing, thus the exchange cannot currently sign its API responses. The exchange operator must use taler-exchange-offline to sign the current key material.", + ), EXCHANGE_RESERVES_PURSE_EXPIRATION_BEFORE_NOW => ( 400, "The purse expiration time is in the past at the time of its creation.", @@ -2432,13 +2588,17 @@ impl ErrorCode { 0, "The start and end-times in the wire fee structure leave a hole. This is not allowed.", ), - MERCHANT_GENERIC_EXCHANGE_WIRE_REQUEST_FAILED => ( + MERCHANT_GENERIC_EXCHANGE_MASTER_KEY_MISMATCH => ( 502, - "The merchant was unable to obtain a valid answer to /wire from the exchange.", + "The master key of the exchange does not match the one configured for this merchant. As a result, we refuse to do business with this exchange. The administrator should check if they configured the exchange correctly in the merchant backend.", ), MERCHANT_GENERIC_CATEGORY_UNKNOWN => { (404, "The product category is not known to the backend.") } + MERCHANT_GENERIC_UNIT_UNKNOWN => ( + 404, + "The unit referenced in the request is not known to the backend.", + ), MERCHANT_GENERIC_ORDER_UNKNOWN => (404, "The proposal is not known to the backend."), MERCHANT_GENERIC_PRODUCT_UNKNOWN => ( 404, @@ -2522,6 +2682,54 @@ impl ErrorCode { 501, "The merchant backend is not configured to support the DONAU protocol.", ), + MERCHANT_EXCHANGE_SIGN_PUB_UNKNOWN => ( + 0, + "The public signing key given in the exchange response is not in the current keys response. It is possible that the operation will succeed later after the merchant has downloaded an updated keys response.", + ), + MERCHANT_GENERIC_FEATURE_NOT_AVAILABLE => ( + 501, + "The merchant backend does not support the requested feature.", + ), + MERCHANT_GENERIC_MFA_MISSING => ( + 403, + "This operation requires multi-factor authorization and the respective instance does not have a sufficient number of factors that could be validated configured. You need to ask the system administrator to perform this operation.", + ), + MERCHANT_GENERIC_DONAU_INVALID_RESPONSE => ( + 502, + "A donation authority (Donau) provided an invalid response. This should be analyzed by the administrator. Trying again later may help.", + ), + MERCHANT_GENERIC_UNIT_BUILTIN => ( + 409, + "The unit referenced in the request is builtin and cannot be modified or deleted.", + ), + MERCHANT_GENERIC_REPORT_UNKNOWN => ( + 404, + "The report ID provided to the backend is not known to the backend.", + ), + MERCHANT_GENERIC_REPORT_GENERATOR_UNCONFIGURED => ( + 501, + "The report ID provided to the backend is not known to the backend.", + ), + MERCHANT_GENERIC_PRODUCT_GROUP_UNKNOWN => ( + 404, + "The product group ID provided to the backend is not known to the backend.", + ), + MERCHANT_GENERIC_MONEY_POT_UNKNOWN => ( + 404, + "The money pod ID provided to the backend is not known to the backend.", + ), + MERCHANT_GENERIC_SESSION_UNKNOWN => ( + 404, + "The session ID provided to the backend is not known to the backend.", + ), + MERCHANT_GENERIC_DONAU_CHARITY_UNKNOWN => ( + 404, + "The merchant does not have a charity associated with the selected Donau. As a result, it cannot generate the requested donation receipt. This could happen if the charity was removed from the backend between order creation and payment.", + ), + MERCHANT_GENERIC_EXPECTED_TRANSFER_UNKNOWN => ( + 404, + "The merchant does not expect any transfer with the given ID and can thus not return any details about it.", + ), MERCHANT_GET_ORDERS_EXCHANGE_TRACKING_FAILURE => ( 200, "The exchange failed to provide a valid answer to the tracking request, thus those details are not in the response.", @@ -2542,9 +2750,37 @@ impl ErrorCode { 403, "The contract terms hash used to authenticate the client is invalid for this order.", ), - MERCHANT_GET_ORDERS_ID_INVALID_CONTRACT_VERSION => { - (403, "The contract terms version is not invalid.") + MERCHANT_GET_ORDERS_ID_INVALID_CONTRACT_VERSION => ( + 500, + "The contract terms version is not understood by the merchant backend. Most likely the merchant backend was downgraded to a version incompatible with the content of the database.", + ), + MERCHANT_TAN_CHALLENGE_FAILED => { + (409, "The provided TAN code is invalid for this challenge.") } + MERCHANT_TAN_CHALLENGE_UNKNOWN => ( + 404, + "The backend is not aware of the specified MFA challenge.", + ), + MERCHANT_TAN_TOO_MANY_ATTEMPTS => ( + 429, + "There have been too many attempts to solve the challenge. A new TAN must be requested.", + ), + MERCHANT_TAN_MFA_HELPER_EXEC_FAILED => ( + 502, + "The backend failed to launch a helper process required for the multi-factor authentication step. The backend operator should check the logs and fix the Taler merchant backend configuration.", + ), + MERCHANT_TAN_CHALLENGE_SOLVED => ( + 410, + "The challenge was already solved. Thus, we refuse to send it again.", + ), + MERCHANT_TAN_TOO_EARLY => ( + 429, + "It is too early to request another transmission of the challenge. The client should wait and see if they received the previous challenge.", + ), + MERCHANT_MFA_FORBIDDEN => ( + 403, + "There have been too many attempts to solve MFA. The client may attempt again in the future.", + ), MERCHANT_POST_ORDERS_ID_PAY_INSUFFICIENT_FUNDS => ( 409, "The exchange responded saying that funds were insufficient (for example, due to double-spending).", @@ -2614,9 +2850,10 @@ impl ErrorCode { 500, "According to our database, we have refunded more than we were paid (which should not be possible).", ), - DEAD_QQQ_PAY_MERCHANT_POST_ORDERS_ID_ABORT_REFUND_REFUSED_PAYMENT_COMPLETE => { - (0, "Legacy stuff. Remove me with protocol v1.") - } + MERCHANT_PRIVATE_POST_REFUND_AFTER_WIRE_DEADLINE => ( + 410, + "The refund request is too late because it is past the wire transfer deadline of the order. The merchant must find a different way to pay back the money to the customer.", + ), MERCHANT_POST_ORDERS_ID_PAY_EXCHANGE_FAILED => { (502, "The payment failed at the exchange.") } @@ -2675,6 +2912,14 @@ impl ErrorCode { 400, "The payment violates a transaction limit configured at the given exchange. The wallet has a bug in that it failed to check exchange limits during coin selection. Please report the bug to your wallet developer.", ), + MERCHANT_POST_ORDERS_ID_PAY_DONATION_AMOUNT_MISMATCH => ( + 409, + "The donation amount provided in the BKPS does not match the amount of the order choice.", + ), + MERCHANT_POST_ORDERS_ID_PAY_EXCHANGE_LEGALLY_REFUSED => ( + 451, + "Some of the exchanges involved refused the request for reasons related to legitimization. The wallet should try with coins of different exchanges. The merchant should check if they have some legitimization process pending at the exchange.", + ), MERCHANT_POST_ORDERS_ID_PAID_CONTRACT_HASH_MISMATCH => { (400, "The contract hash does not match the given order ID.") } @@ -2740,6 +2985,18 @@ impl ErrorCode { 200, "The response from the exchange was unacceptable and should be reviewed with an auditor.", ), + MERCHANT_POST_ACCOUNTS_KYCAUTH_BANK_GATEWAY_UNREACHABLE => ( + 502, + "The merchant backend failed to reach the banking gateway to shorten the wire transfer subject. This probably means that the banking gateway of the exchange is currently down. Contact the exchange operator or simply retry again later.", + ), + MERCHANT_POST_ACCOUNTS_EXCHANGE_TOO_OLD => ( + 502, + "The merchant backend failed to reach the banking gateway to shorten the wire transfer subject. This probably means that the banking gateway of the exchange is currently down. Contact the exchange operator or simply retry again later.", + ), + MERCHANT_POST_ACCOUNTS_KYCAUTH_EXCHANGE_UNREACHABLE => ( + 502, + "The merchant backend failed to reach the specified exchange. This probably means that the exchange is currently down. Contact the exchange operator or simply retry again later.", + ), MERCHANT_POST_ORDERS_ID_CLAIM_NOT_FOUND => ( 404, "We could not claim the order because the backend is unaware of it.", @@ -2891,12 +3148,24 @@ impl ErrorCode { ), MERCHANT_PRIVATE_POST_TRANSFERS_CONFLICTING_SUBMISSION => ( 409, - "The backend was previously informed about a wire transfer with the same ID but a different amount. Multiple wire transfers with the same ID are not allowed. If the new amount is correct, the old transfer should first be deleted.", + "The backend could not persist the wire transfer due to the state of the backend. This usually means that a wire transfer with the same wire transfer subject but a different amount was previously submitted to the backend.", + ), + MERCHANT_EXCHANGE_TRANSFERS_TARGET_ACCOUNT_UNKNOWN => ( + 0, + "The target bank account given by the exchange is not (or no longer) known at the merchant instance.", ), MERCHANT_EXCHANGE_TRANSFERS_CONFLICTING_TRANSFERS => ( 0, "The amount transferred differs between what was submitted and what the exchange claimed.", ), + MERCHANT_REPORT_GENERATOR_FAILED => ( + 501, + "The report ID provided to the backend is not known to the backend.", + ), + MERCHANT_REPORT_FETCH_FAILED => ( + 502, + "Failed to fetch the data for the report from the backend.", + ), MERCHANT_PRIVATE_POST_INSTANCES_ALREADY_EXISTS => ( 409, "The merchant backend cannot create an instance under the given identifier as one already exists. Use PATCH to modify the existing entry.", @@ -2925,6 +3194,10 @@ impl ErrorCode { 409, "The bank account specified in the request already exists at the merchant.", ), + MERCHANT_PRIVATE_ACCOUNT_NOT_ELIGIBLE_FOR_EXCHANGE => ( + 409, + "The bank account specified is not acceptable for this exchange. The exchange either does not support the wire method or something else about the specific account. Consult the exchange account constraints and specify a different bank account if you want to use this exchange.", + ), MERCHANT_PRIVATE_POST_PRODUCTS_CONFLICT_PRODUCT_EXISTS => { (409, "The product ID exists.") } @@ -2951,9 +3224,22 @@ impl ErrorCode { 410, "The lock request is for more products than we have left (unlocked) in stock.", ), - MERCHANT_PRIVATE_DELETE_PRODUCTS_CONFLICTING_LOCK => { - (409, "The deletion request is for a product that is locked.") - } + MERCHANT_PRIVATE_DELETE_PRODUCTS_CONFLICTING_LOCK => ( + 409, + "The deletion request is for a product that is locked. The product cannot be deleted until the existing offer to expires.", + ), + MERCHANT_PRIVATE_PRODUCT_GROUP_CONFLICTING_NAME => ( + 409, + "The proposed name for the product group is already in use. You should select a different name.", + ), + MERCHANT_PRIVATE_MONEY_POT_CONFLICTING_NAME => ( + 409, + "The proposed name for the money pot is already in use. You should select a different name.", + ), + MERCHANT_PRIVATE_MONEY_POT_CONFLICTING_TOTAL => ( + 409, + "The total amount in the money pot is different from the amount required by the request. The client should fetch the current pot total and retry with the latest amount to succeed.", + ), MERCHANT_PRIVATE_POST_RESERVES_UNSUPPORTED_WIRE_METHOD => ( 409, "The requested wire method is not supported by the exchange.", @@ -3007,6 +3293,18 @@ impl ErrorCode { 409, "Subject not given in the using template and in the template contract. There is a conflict.", ), + MERCHANT_POST_USING_TEMPLATES_WRONG_TYPE => ( + 409, + "The selected template has a different type than the one specified in the request of the client. This may happen if the template was updated since the last time the client fetched it. The client should re-fetch the current template and send a request of the correct type.", + ), + MERCHANT_POST_USING_TEMPLATES_WRONG_PRODUCT => ( + 409, + "The selected template does not allow one of the specified products to be included in the order. This may happen if the template was updated since the last time the client fetched it. The client should re-fetch the current template and send a request of the correct type.", + ), + MERCHANT_POST_USING_TEMPLATES_NO_CURRENCY => ( + 409, + "The selected combination of products does not allow the backend to compute a price for the order in any of the supported currencies. This may happen if the template was updated since the last time the client fetched it or if the wallet assembled an unsupported combination of products. The site administrator might want to specify additional prices for products, while the client should re-fetch the current template and send a request with a combination of products for which prices exist in the same currency.", + ), MERCHANT_PRIVATE_POST_WEBHOOKS_CONFLICT_WEBHOOK_EXISTS => { (409, "The webhook ID elready exists.") } @@ -3209,6 +3507,18 @@ impl ErrorCode { 409, "The wtid for a request to transfer funds has already been used, but with a different request unpaid.", ), + BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS => ( + 409, + "A non-admin user has tried to set their conversion rate class", + ), + BANK_CONVERSION_RATE_CLASS_UNKNOWN => { + (409, "The referenced conversion rate class was not found") + } + BANK_NAME_REUSE => (409, "The client tried to use an already taken name."), + BANK_UNSUPPORTED_SUBJECT_FORMAT => (409, "This subject format is not supported."), + BANK_DERIVATION_REUSE => (409, "The derived subject is already used."), + BANK_BAD_SIGNATURE => (409, "The provided signature is invalid."), + BANK_OLD_TIMESTAMP => (409, "The provided timestamp is too old."), SYNC_ACCOUNT_UNKNOWN => ( 404, "The sync service failed find the account in its database.", @@ -3303,10 +3613,6 @@ impl ErrorCode { 0, "The signature on a coin by the exchange's denomination key is invalid after unblinding it.", ), - WALLET_EXCHANGE_WITHDRAW_RESERVE_UNKNOWN_AT_EXCHANGE => ( - 404, - "The exchange does not know about the reserve (yet), and thus withdrawal can't progress.", - ), WALLET_CORE_NOT_AVAILABLE => (0, "The wallet core service is not available."), WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK => ( 0, @@ -3424,6 +3730,25 @@ impl ErrorCode { 0, "A payment was attempted, but the merchant claims the order is gone (likely expired).", ), + WALLET_EXCHANGE_ENTRY_NOT_FOUND => ( + 0, + "The wallet does not have an entry for the requested exchange.", + ), + WALLET_REQUEST_TRANSACTION_STATE_UNSUPPORTED => ( + 0, + "The wallet is not able to process the request due to the transaction's state.", + ), + WALLET_TRANSACTION_PROTOCOL_VIOLATION => ( + 0, + "A transaction could not be processed due to an unrecoverable protocol violation.", + ), + WALLET_CORE_API_BAD_REQUEST => { + (0, "A parameter in the request is malformed or missing.") + } + WALLET_MERCHANT_ORDER_NOT_FOUND => ( + 0, + "The order could not be found. Maybe the merchant deleted it.", + ), ANASTASIS_GENERIC_BACKEND_TIMEOUT => { (504, "We encountered a timeout with our payment backend.") } @@ -3688,6 +4013,10 @@ impl ErrorCode { 409, "The client reused a unique donor identifier nonce, which is not allowed.", ), + DONAU_CHARITY_PUB_EXISTS => ( + 404, + "A charity with the same public key is already registered.", + ), LIBEUFIN_NEXUS_GENERIC_ERROR => ( 0, "A generic error happened in the LibEuFin nexus. See the enclose details JSON for more information.", @@ -3742,6 +4071,10 @@ impl ErrorCode { 409, "The token cannot be valid as no address was ever provided by the client.", ), + CHALLENGER_CLIENT_FORBIDDEN_READ_ONLY => ( + 403, + "The client is not allowed to change the address being validated.", + ), END => (0, "End of error code range."), } } diff --git a/common/taler-common/src/lib.rs b/common/taler-common/src/lib.rs @@ -25,6 +25,7 @@ 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 cli; pub mod config; diff --git a/common/taler-test-utils/Cargo.toml b/common/taler-test-utils/Cargo.toml @@ -23,3 +23,5 @@ tracing-subscriber.workspace = true sqlx.workspace = true http-body-util.workspace = true url.workspace = true +aws-lc-rs.workspace = true +jiff.workspace = true diff --git a/common/taler-test-utils/src/routine.rs b/common/taler-test-utils/src/routine.rs @@ -20,17 +20,21 @@ use std::{ time::{Duration, Instant}, }; +use aws_lc_rs::signature::{Ed25519KeyPair, KeyPair as _}; use axum::Router; +use jiff::{SignedDuration, Timestamp}; use serde::{Deserialize, de::DeserializeOwned}; -use taler_api::db::IncomingType; +use taler_api::{crypto::eddsa_sign, 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, TransferList, TransferRequest, TransferResponse, TransferState, TransferStatus, }, + db::IncomingType, error_code::ErrorCode, types::{amount::amount, base32::Base32, payto::PaytoURI, url}, }; @@ -260,7 +264,7 @@ fn assert_history_ids<'de, T: Deserialize<'de>>( } // Get currency from config -async fn get_currency(server: &Router) -> String { +async fn get_wire_currency(server: &Router) -> String { let config = server .get("/taler-wire-gateway/config") .await @@ -275,7 +279,7 @@ pub async fn transfer_routine( default_status: TransferState, credit_account: &PaytoURI, ) { - let currency = &get_currency(server).await; + let currency = &get_wire_currency(server).await; let default_amount = amount(format!("{currency}:42")); let request_uid = HashCode::rand(); let wtid = ShortHashCode::rand(); @@ -548,7 +552,7 @@ async fn add_incoming_routine( /// Test standard behavior of the revenue endpoints pub async fn revenue_routine(server: &Router, debit_acount: &PaytoURI, kyc: bool) { - let currency = &get_currency(server).await; + let currency = &get_wire_currency(server).await; routine_history( server, @@ -591,7 +595,7 @@ pub async fn revenue_routine(server: &Router, debit_acount: &PaytoURI, kyc: bool /// Test standard behavior of the admin add incoming endpoints pub async fn admin_add_incoming_routine(server: &Router, debit_acount: &PaytoURI, kyc: bool) { - let currency = &get_currency(server).await; + let currency = &get_wire_currency(server).await; // History // TODO check non taler some are ignored @@ -643,3 +647,406 @@ pub async fn admin_add_incoming_routine(server: &Router, debit_acount: &PaytoURI add_incoming_routine(server, currency, IncomingType::kyc, debit_acount).await; } } + +// Get currency from config +async fn get_transfer_currency(server: &Router) -> String { + let config = server + .get("/taler-wire-transfer-gateway/config") + .await + .assert_ok_json::<serde_json::Value>(); + let currency = config["currency"].as_str().unwrap(); + currency.to_owned() +} + +#[derive(Debug, PartialEq, Eq)] +pub enum Status { + Simple, + Pending, + Bounced, + Incomplete, + Reserve(EddsaPublicKey), + Kyc(EddsaPublicKey), +} + +/// Test standard registration behavior of the registration endpoints +pub async fn registration_routine<F1: Future<Output = Vec<Status>>, F2: Future<Output = ()>>( + server: &Router, + account: &PaytoURI, + mut in_status: impl FnMut() -> F1, + mut register: impl FnMut(&EddsaPublicKey) -> F2, +) { + pub use Status::*; + let mut check_in = async |state: &[Status]| { + let current = in_status().await; + assert_eq!(state, current); + }; + + let currency = &get_transfer_currency(server).await; + let amount = amount(format!("{currency}:42")); + let key_pair = Ed25519KeyPair::generate().unwrap(); + let auth_pub = EddsaPublicKey::try_from(key_pair.public_key().as_ref()).unwrap(); + let req = json!({ + "credit_amount": amount, + "type": "reserve", + "alg": "EdDSA", + "account_pub": auth_pub, + "authorization_pub": auth_pub, + "authorization_sig": eddsa_sign(&key_pair, auth_pub.as_ref()), + "recurrent": false + }); + + let routine = async |ty: TransferType, + account_pub: &EddsaPublicKey, + auth_pub: &EddsaPublicKey, + recurrent: bool, + expected: &str| { + let req = json!(req + { + "type": ty, + "account_pub": account_pub, + "authorization_pub": auth_pub, + "authorization_sig": eddsa_sign(&key_pair, account_pub.as_ref()), + "recurrent": recurrent + }); + // Valid + let res = server + .post("/taler-wire-transfer-gateway/registration") + .json(&req) + .await + .assert_ok_json::<RegistrationResponse>(); + + // Idempotent + assert_eq!( + res, + server + .post("/taler-wire-transfer-gateway/registration") + .json(&req) + .await + .assert_ok_json::<RegistrationResponse>() + ); + + assert!(!res.subjects.is_empty()); + + for sub in res.subjects { + if let TransferSubject::Simple { subject, .. } = sub { + assert_eq!(subject, expected); + }; + } + }; + for ty in [TransferType::reserve, TransferType::kyc] { + routine( + ty, + &auth_pub, + &auth_pub, + false, + &fmt_in_subject(ty.into(), &auth_pub), + ) + .await; + routine( + ty, + &auth_pub, + &auth_pub, + true, + &fmt_in_subject(IncomingType::map, &auth_pub), + ) + .await; + } + + let account_pub = EddsaPublicKey::rand(); + for ty in [TransferType::reserve, TransferType::kyc] { + routine( + ty, + &account_pub, + &auth_pub, + false, + &fmt_in_subject(IncomingType::map, &auth_pub), + ) + .await; + routine( + ty, + &account_pub, + &auth_pub, + true, + &fmt_in_subject(IncomingType::map, &auth_pub), + ) + .await; + } + + // Bad signature + server + .post("/taler-wire-transfer-gateway/registration") + .json(&json!(req + { + "authorization_sig": eddsa_sign(&key_pair, "lol".as_bytes()), + })) + .await + .assert_error(ErrorCode::BANK_BAD_SIGNATURE); + + // Reserve pub reuse + server + .post("/taler-wire-transfer-gateway/registration") + .json(&json!(req + { + "account_pub": account_pub, + "authorization_sig": eddsa_sign(&key_pair, account_pub.as_ref()), + })) + .await + .assert_ok_json::<RegistrationResponse>(); + { + let key_pair = Ed25519KeyPair::generate().unwrap(); + let auth_pub = EddsaPublicKey::try_from(key_pair.public_key().as_ref()).unwrap(); + server + .post("/taler-wire-transfer-gateway/registration") + .json(&json!(req + { + "account_pub": account_pub, + "authorization_pub": auth_pub, + "authorization_sig": eddsa_sign(&key_pair, account_pub.as_ref()), + })) + .await + .assert_error(ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT); + } + + // Non recurrent accept one then bounce + server + .post("/taler-wire-transfer-gateway/registration") + .json(&json!(req + { + "account_pub": account_pub, + "authorization_sig": eddsa_sign(&key_pair, account_pub.as_ref()), + })) + .await + .assert_ok_json::<RegistrationResponse>(); + register(&auth_pub).await; + check_in(&[Reserve(account_pub.clone())]).await; + register(&auth_pub).await; + check_in(&[Reserve(account_pub.clone()), Bounced]).await; + + // Recurrent accept one and delay others + let new_key = EddsaPublicKey::rand(); + server + .post("/taler-wire-transfer-gateway/registration") + .json(&json!(req + { + "account_pub": new_key, + "authorization_sig": eddsa_sign(&key_pair, new_key.as_ref()), + "recurrent": true + })) + .await + .assert_ok_json::<RegistrationResponse>(); + for _ in 0..5 { + register(&auth_pub).await; + } + check_in(&[ + Reserve(account_pub.clone()), + Bounced, + Reserve(new_key.clone()), + Pending, + Pending, + Pending, + Pending, + ]) + .await; + + // Complete pending on recurrent update + let kyc_key = EddsaPublicKey::rand(); + server + .post("/taler-wire-transfer-gateway/registration") + .json(&json!(req + { + "type": "kyc", + "account_pub": kyc_key, + "authorization_sig": eddsa_sign(&key_pair, kyc_key.as_ref()), + "recurrent": true + })) + .await + .assert_ok_json::<RegistrationResponse>(); + server + .post("/taler-wire-transfer-gateway/registration") + .json(&json!(req + { + "account_pub": kyc_key, + "authorization_sig": eddsa_sign(&key_pair, kyc_key.as_ref()), + "recurrent": true + })) + .await + .assert_ok_json::<RegistrationResponse>(); + check_in(&[ + Reserve(account_pub.clone()), + Bounced, + Reserve(new_key.clone()), + Kyc(kyc_key.clone()), + Reserve(kyc_key.clone()), + Pending, + Pending, + ]) + .await; + + // Kyc key reuse keep pending ones + server + .post("/taler-wire-gateway/admin/add-kycauth") + .json(&json!({ + "amount": amount, + "account_pub": kyc_key, + "debit_account": account, + })) + .await + .assert_ok_json::<TransferResponse>(); + check_in(&[ + Reserve(account_pub.clone()), + Bounced, + Reserve(new_key.clone()), + Kyc(kyc_key.clone()), + Reserve(kyc_key.clone()), + Pending, + Pending, + Kyc(kyc_key.clone()), + ]) + .await; + + // Switching to non recurrent cancel pending + let auth_pair = Ed25519KeyPair::generate().unwrap(); + let last_pub = EddsaPublicKey::try_from(auth_pair.public_key().as_ref()).unwrap(); + server + .post("/taler-wire-transfer-gateway/registration") + .json(&json!(req + { + "account_pub": last_pub, + "authorization_pub": last_pub, + "authorization_sig": eddsa_sign(&auth_pair, last_pub.as_ref()), + "recurrent": true + })) + .await + .assert_ok_json::<RegistrationResponse>(); + for _ in 0..3 { + register(&last_pub).await; + } + check_in(&[ + Reserve(account_pub.clone()), + Bounced, + Reserve(new_key.clone()), + Kyc(kyc_key.clone()), + Reserve(kyc_key.clone()), + Pending, + Pending, + Kyc(kyc_key.clone()), + Reserve(last_pub.clone()), + Pending, + Pending, + ]) + .await; + server + .post("/taler-wire-transfer-gateway/registration") + .json(&json!(req + { + "type": "kyc", + "account_pub": last_pub, + "authorization_pub": last_pub, + "authorization_sig": eddsa_sign(&auth_pair, last_pub.as_ref()), + "recurrent": false + })) + .await + .assert_ok_json::<RegistrationResponse>(); + check_in(&[ + Reserve(account_pub.clone()), + Bounced, + Reserve(new_key.clone()), + Kyc(kyc_key.clone()), + Reserve(kyc_key.clone()), + Pending, + Pending, + Kyc(kyc_key.clone()), + Reserve(last_pub.clone()), + Bounced, + Bounced, + ]) + .await; + + // Unregistration + let now = Timestamp::now().to_string(); + let un_req = json!({ + "timestamp": now, + "authorization_pub": last_pub, + "authorization_sig": eddsa_sign(&auth_pair, now.as_bytes()), + }); + + // Known + server + .delete("/taler-wire-transfer-gateway/registration") + .json(&un_req) + .await + .assert_no_content(); + + // Idempotent + server + .delete("/taler-wire-transfer-gateway/registration") + .json(&un_req) + .await + .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND); + + // Bad signature + server + .delete("/taler-wire-transfer-gateway/registration") + .json(&json!(un_req + { + "authorization_sig": eddsa_sign(&auth_pair, "lol".as_bytes()), + })) + .await + .assert_error(ErrorCode::BANK_BAD_SIGNATURE); + + // Old timestamp + let now = (Timestamp::now() - SignedDuration::from_mins(10)).to_string(); + server + .delete("/taler-wire-transfer-gateway/registration") + .json(&json!({ + "timestamp": now, + "authorization_pub": last_pub, + "authorization_sig": eddsa_sign(&auth_pair, now.as_bytes()), + })) + .await + .assert_error(ErrorCode::BANK_OLD_TIMESTAMP); + + // Check bounce pending on deletion + server + .post("/taler-wire-transfer-gateway/registration") + .json(&json!(req + { + "type": "kyc", + "account_pub": last_pub, + "authorization_pub": last_pub, + "authorization_sig": eddsa_sign(&auth_pair, last_pub.as_ref()), + "recurrent": true + })) + .await + .assert_ok_json::<RegistrationResponse>(); + for _ in 0..3 { + register(&last_pub).await; + } + check_in(&[ + Reserve(account_pub.clone()), + Bounced, + Reserve(new_key.clone()), + Kyc(kyc_key.clone()), + Reserve(kyc_key.clone()), + Pending, + Pending, + Kyc(kyc_key.clone()), + Reserve(last_pub.clone()), + Bounced, + Bounced, + Kyc(last_pub.clone()), + Pending, + Pending, + ]) + .await; + server + .delete("/taler-wire-transfer-gateway/registration") + .json(&un_req) + .await + .assert_no_content(); + check_in(&[ + Reserve(account_pub.clone()), + Bounced, + Reserve(new_key.clone()), + Kyc(kyc_key.clone()), + Reserve(kyc_key.clone()), + Pending, + Pending, + Kyc(kyc_key.clone()), + Reserve(last_pub.clone()), + Bounced, + Bounced, + Kyc(last_pub.clone()), + Bounced, + Bounced, + ]) + .await; +} diff --git a/common/taler-test-utils/src/server.rs b/common/taler-test-utils/src/server.rs @@ -43,6 +43,10 @@ pub trait TestServer { fn post(&self, path: &str) -> TestRequest { self.method(Method::POST, path) } + + fn delete(&self, path: &str) -> TestRequest { + self.method(Method::DELETE, path) + } } impl TestServer for Router { diff --git a/taler-cyclos/src/db.rs b/taler-cyclos/src/db.rs @@ -21,7 +21,7 @@ use jiff::Timestamp; use serde::{Serialize, de::DeserializeOwned}; use sqlx::{PgConnection, PgPool, QueryBuilder, Row, postgres::PgRow}; use taler_api::{ - db::{BindHelper, IncomingType, TypeHelper, history, page}, + db::{BindHelper, TypeHelper, history, page}, serialized, subject::{IncomingSubject, OutgoingSubject, fmt_out_subject}, }; @@ -34,6 +34,7 @@ use taler_common::{ TransferStatus, }, config::Config, + db::IncomingType, types::{ amount::{Currency, Decimal}, payto::{PaytoImpl as _, PaytoURI}, diff --git a/taler-magnet-bank/src/db.rs b/taler-magnet-bank/src/db.rs @@ -21,7 +21,7 @@ use jiff::{Timestamp, civil::Date, tz::TimeZone}; use serde::{Serialize, de::DeserializeOwned}; use sqlx::{PgConnection, PgPool, QueryBuilder, Row, postgres::PgRow}; use taler_api::{ - db::{BindHelper, IncomingType, TypeHelper, history, page}, + db::{BindHelper, TypeHelper, history, page}, serialized, subject::{IncomingSubject, OutgoingSubject, fmt_out_subject}, }; @@ -34,6 +34,7 @@ use taler_common::{ TransferStatus, }, config::Config, + db::IncomingType, types::{ amount::{Amount, Decimal}, payto::PaytoImpl as _,