commit 003364ba8128a3994dc1d2cb99d9d240eaf19ae7
parent 133fdbb79b37bd8b27adec6c43bc15c0359ef2d4
Author: Antoine A <>
Date: Tue, 5 May 2026 19:11:13 +0200
bank: finish TAN logic
Diffstat:
9 files changed, 407 insertions(+), 36 deletions(-)
diff --git a/crates/libeufin-bank/src/api.rs b/crates/libeufin-bank/src/api.rs
@@ -24,6 +24,7 @@ use crate::config::BankCfg;
pub mod account;
pub mod tan;
pub mod token;
+pub mod tx;
pub struct BankState {
pub db: PgPool,
@@ -47,7 +48,7 @@ pub mod test {
error_code::ErrorCode,
types::{
amount::{Amount, Decimal},
- payto::{IbanPayto, Payto},
+ payto::{FullIbanPayto, FullPayto, IbanPayto, Payto},
},
};
use taler_test_utils::{
@@ -63,20 +64,21 @@ pub mod test {
account::{account_api, create_admin_account, rand_iban_payto},
tan::{ChallengeResponse, tan_api},
token::token_api,
+ tx::tx_api,
},
config::BankCfg,
db::{self, account::CreationResult},
mfa::TALER_CHALLENGE_IDS,
- payto::LibeufinId,
+ payto::{FullBankPayto, LibeufinId},
};
pub struct BankTestCtx {
- pub merchant_payto: IbanPayto,
- pub exchange_payto: IbanPayto,
- pub customer_payto: IbanPayto,
- pub unknown_payto: IbanPayto,
- pub tmp_payto: IbanPayto,
- pub admin_payto: IbanPayto,
+ pub merchant_payto: FullBankPayto,
+ pub exchange_payto: FullBankPayto,
+ pub customer_payto: FullBankPayto,
+ pub unknown_payto: FullBankPayto,
+ pub tmp_payto: FullBankPayto,
+ pub admin_payto: FullBankPayto,
pub db: PgPool,
pub server: Router,
tokens: BTreeMap<CompactString, String>,
@@ -210,17 +212,12 @@ pub mod test {
let server = token_api()
.merge(account_api())
+ .merge(tx_api())
.merge(tan_api())
.with_state(state.clone())
.finalize();
- let merchant_payto = rand_iban_payto();
- let exchange_payto = rand_iban_payto();
- let customer_payto = rand_iban_payto();
- let unknown_payto = rand_iban_payto();
- let tmp_payto = rand_iban_payto();
-
- db::account::create(
+ let merchant_payto = db::account::create(
&db,
&state.cfg.ctx,
&state.cfg.pw_crypto,
@@ -230,7 +227,7 @@ pub mod test {
None,
None,
None,
- LibeufinId::IBAN(merchant_payto.clone().into_inner()),
+ LibeufinId::IBAN(rand_iban_payto().into_inner()),
false,
false,
Decimal::new(10, 0).to_amount(&state.cfg.regional_currency),
@@ -240,8 +237,9 @@ pub mod test {
None,
)
.await
- .unwrap();
- db::account::create(
+ .unwrap()
+ .assert_success();
+ let exchange_payto = db::account::create(
&db,
&state.cfg.ctx,
&state.cfg.pw_crypto,
@@ -251,7 +249,7 @@ pub mod test {
None,
None,
None,
- LibeufinId::IBAN(exchange_payto.clone().into_inner()),
+ LibeufinId::IBAN(rand_iban_payto().into_inner()),
false,
false,
Decimal::new(10, 0).to_amount(&state.cfg.regional_currency),
@@ -261,8 +259,9 @@ pub mod test {
None,
)
.await
- .unwrap();
- db::account::create(
+ .unwrap()
+ .assert_success();
+ let customer_payto = db::account::create(
&db,
&state.cfg.ctx,
&state.cfg.pw_crypto,
@@ -272,7 +271,7 @@ pub mod test {
None,
None,
None,
- LibeufinId::IBAN(customer_payto.clone().into_inner()),
+ LibeufinId::IBAN(rand_iban_payto().into_inner()),
false,
false,
Decimal::new(10, 0).to_amount(&state.cfg.regional_currency),
@@ -282,7 +281,8 @@ pub mod test {
None,
)
.await
- .unwrap();
+ .unwrap()
+ .assert_success();
let res = create_admin_account(&db, &state.cfg, Some("admin-password"))
.await
@@ -297,9 +297,15 @@ pub mod test {
merchant_payto,
exchange_payto,
customer_payto,
- unknown_payto,
- tmp_payto,
- admin_payto: Payto::new(admin_payto.into_inner().expect_iban().unwrap().clone()),
+ unknown_payto: FullBankPayto::new(
+ LibeufinId::IBAN(rand_iban_payto().into_inner()),
+ "Unknown",
+ ),
+ tmp_payto: FullBankPayto::new(
+ LibeufinId::IBAN(rand_iban_payto().into_inner()),
+ "Unknown",
+ ),
+ admin_payto: admin_payto,
server,
db,
tokens: BTreeMap::new(),
diff --git a/crates/libeufin-bank/src/api/tan.rs b/crates/libeufin-bank/src/api/tan.rs
@@ -249,7 +249,7 @@ pub mod test {
use crate::{
TanChannel,
api::{
- tan::{Challenge, ChallengeRequestResponse, ChallengeResponse},
+ tan::{Challenge, ChallengeRequestResponse, ChallengeResponse, MAX_ACTIVE_CHALLENGES},
test::{MfaRequest, bank_setup_conf, tan_code},
},
mfa::TALER_CHALLENGE_IDS,
@@ -491,6 +491,64 @@ pub mod test {
ctx.fill_tan_info("merchant").await;
// TODO need transaction API
+ let tx_challenge = async || {
+ ctx.posta("/accounts/merchant/transactions")
+ .json(json!({
+ "payto_uri": format!("{}&message=tx&amount=KUDOS:0.1", ctx.customer_payto)
+ }))
+ .await
+ .assert_accepted_json::<ChallengeResponse>()
+ .challenges
+ .pop()
+ .unwrap()
+ };
+
+ let submit = async |c: &Challenge| {
+ ctx.posta(&format!("/accounts/merchant/challenge/{}", c.challenge_id))
+ .await
+ .assert_ok_json::<ChallengeRequestResponse>()
+ };
+
+ // Start a legitimate challenge and submit it
+ let old = tx_challenge().await;
+ submit(&old).await;
+ let code = tan_code(&old.tan_info);
+
+ // Challenge creation is not rate limited
+ for _ in 0..MAX_ACTIVE_CHALLENGES * 2 {
+ tx_challenge().await;
+ }
+
+ // Challenge submission is rate limited
+ for _ in 0..MAX_ACTIVE_CHALLENGES - 1 {
+ submit(&tx_challenge().await).await;
+ }
+ let c = tx_challenge().await;
+ ctx.posta(&format!("/accounts/merchant/challenge/{}", c.challenge_id))
+ .await
+ .assert_error(ErrorCode::BANK_TAN_RATE_LIMITED);
+
+ // Old already submitted challenge still works
+ submit(&old).await;
+ ctx.posta(&format!(
+ "/accounts/merchant/challenge/{}/confirm",
+ old.challenge_id
+ ))
+ .json(json!({ "tan": code }))
+ .await
+ .assert_no_content();
+
+ // Now an active challenge slot have been freed
+ submit(&c).await;
+
+ // We are still rate limited
+ let new = tx_challenge().await;
+ ctx.posta(&format!(
+ "/accounts/merchant/challenge/{}",
+ new.challenge_id
+ ))
+ .await
+ .assert_error(ErrorCode::BANK_TAN_RATE_LIMITED);
}
#[tokio::test]
diff --git a/crates/libeufin-bank/src/api/tx.rs b/crates/libeufin-bank/src/api/tx.rs
@@ -0,0 +1,155 @@
+/*
+* This file is part of LibEuFin.
+* Copyright (C) 2026 Taler Systems S.A.
+
+* LibEuFin 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.
+
+* LibEuFin 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 LibEuFin; see the file COPYING. If not, see
+* <http://www.gnu.org/licenses/>
+*/
+
+//! Data access logic for transactions
+
+use std::sync::Arc;
+
+use axum::{Json, Router, extract::State, response::IntoResponse, routing::post};
+use jiff::Timestamp;
+use serde::{Deserialize, Serialize};
+use taler_api::error::{bad_request, failure, failure_code};
+use taler_common::{api_common::ShortHashCode, error_code::ErrorCode, types::amount::Amount};
+
+use crate::{
+ api::BankState,
+ auth::UserRW,
+ db::tx::{TxResult, create},
+ mfa::{BankTx, MfaReq},
+ payto::{BankPayto, TransferBankPayto},
+};
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct TransactionCreateRequest {
+ pub payto_uri: TransferBankPayto,
+ pub amount: Option<Amount>,
+ pub request_uid: Option<ShortHashCode>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct TransactionCreateResponse {
+ pub row_id: u64,
+}
+
+pub fn tx_api() -> Router<Arc<BankState>> {
+ Router::new().route(
+ "/accounts/{username}/transactions",
+ post(
+ async |State(state): State<Arc<BankState>>,
+ MfaReq { mut auth, req, ctx }: MfaReq<UserRW, BankTx>| {
+ let subject = req
+ .payto_uri
+ .subject
+ .clone()
+ .ok_or_else(|| bad_request("Wire transfer lacks subject"))?;
+ let amount = req
+ .payto_uri
+ .amount
+ .or(req.amount)
+ .ok_or_else(|| bad_request("Wire transfer lacks amount"))?;
+
+ // TODO check regional currency
+ match create(
+ &state.db,
+ BankPayto::new(req.payto_uri.into_inner()),
+ &auth.username,
+ &subject,
+ amount,
+ &Timestamp::now(),
+ ctx.is_2fa(),
+ req.request_uid,
+ state.cfg.wire_transfer_fees,
+ state.cfg.min_amount,
+ state.cfg.max_amount,
+ )
+ .await?
+ {
+ TxResult::Success(row_id) => {
+ Ok(Json(TransactionCreateResponse { row_id }).into_response())
+ }
+ TxResult::UnknownCreditor => {
+ Err(failure_code(ErrorCode::BANK_UNKNOWN_CREDITOR))
+ }
+ TxResult::AdminCreditor => Err(failure_code(ErrorCode::BANK_ADMIN_CREDITOR)),
+ TxResult::UnknownDebtor => Err(failure_code(ErrorCode::BANK_UNKNOWN_DEBTOR)),
+ TxResult::BothPartySame => Err(failure_code(ErrorCode::BANK_SAME_ACCOUNT)),
+ TxResult::BalanceInsufficient => Err(failure(
+ ErrorCode::BANK_UNALLOWED_DEBIT,
+ "Insufficient funds",
+ )),
+ TxResult::BadAmount => Err(failure(
+ ErrorCode::BANK_UNALLOWED_DEBIT,
+ "Amount either to high or too low",
+ )),
+ TxResult::TanRequired => {
+ ctx.response_mfa(&mut auth, &state.db, &state.cfg.ctx).await
+ }
+ TxResult::RequestUidReuse => {
+ Err(failure_code(ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED))
+ }
+ }
+ },
+ ),
+ )
+}
+
+#[cfg(test)]
+pub mod test {
+
+ use axum::http::Method;
+ use taler_common::error_code::ErrorCode;
+ use taler_test_utils::{json, server::TestResponse};
+ use uuid::Uuid;
+
+ use crate::{
+ TanChannel,
+ api::{
+ tan::{Challenge, ChallengeRequestResponse, ChallengeResponse},
+ test::{Auth, MfaRequest, auth_routine, bank_setup_conf, tan_code},
+ tx::TransactionCreateResponse,
+ },
+ mfa::TALER_CHALLENGE_IDS,
+ };
+
+ #[tokio::test]
+ async fn create() {
+ let ctx = bank_setup_conf("test.conf").await;
+
+ auth_routine(
+ &ctx,
+ Method::POST,
+ "/accounts/customer/transactions",
+ Auth::User,
+ false,
+ )
+ .await;
+
+ let valid_req = json!({
+ "payto_uri": format!("{}&message=payout", ctx.exchange_payto),
+ "amount": "KUDOS:0.3"
+ });
+
+ // Check OK
+ let res: TransactionCreateResponse = ctx
+ .posta("/accounts/merchant/transactions")
+ .json(&valid_req)
+ .await
+ .assert_ok_json();
+ }
+}
diff --git a/crates/libeufin-bank/src/db.rs b/crates/libeufin-bank/src/db.rs
@@ -20,6 +20,6 @@
const SCHEMA: &str = "libeufin_bank";
pub mod account;
-
pub mod tan;
pub mod token;
+pub mod tx;
diff --git a/crates/libeufin-bank/src/db/account.rs b/crates/libeufin-bank/src/db/account.rs
@@ -45,6 +45,18 @@ pub enum CreationResult {
BonusBalanceInsufficient,
}
+impl CreationResult {
+ pub fn assert_success(self) -> FullBankPayto {
+ match self {
+ CreationResult::Success(full_payto) => full_payto,
+ CreationResult::UsernameReuse
+ | CreationResult::PayToReuse
+ | CreationResult::UnknownConversionClass
+ | CreationResult::BonusBalanceInsufficient => unreachable!(),
+ }
+ }
+}
+
/** Create new account */
pub async fn create(
db: &PgPool,
@@ -271,15 +283,13 @@ pub async fn reconfig(
allow_edit_cashout: bool,
) -> ApiResult<PatchResult> {
let AccountReconfiguration {
- contact_data,
cashout_payto_uri,
name,
is_public,
debit_threshold,
- tan_channel,
- tan_channels,
is_taler_exchange,
conversion_rate_class_id,
+ ..
} = req;
let mut tx = db.begin().await?;
diff --git a/crates/libeufin-bank/src/db/tx.rs b/crates/libeufin-bank/src/db/tx.rs
@@ -0,0 +1,135 @@
+/*
+* This file is part of LibEuFin.
+* Copyright (C) 2026 Taler Systems S.A.
+
+* LibEuFin 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.
+
+* LibEuFin 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 LibEuFin; see the file COPYING. If not, see
+* <http://www.gnu.org/licenses/>
+*/
+
+//! Data access logic for transactions
+
+use jiff::Timestamp;
+use sqlx::{PgPool, postgres::PgRow};
+use taler_api::{
+ db::{BindHelper, TypeHelper},
+ subject::{IncomingSubject, parse_incoming_unstructured},
+};
+use taler_common::{api_common::ShortHashCode, types::amount::Amount};
+
+use crate::payto::BankPayto;
+
+/** Result status of bank transaction creation */
+pub enum TxResult {
+ Success(u64),
+ UnknownCreditor,
+ AdminCreditor,
+ UnknownDebtor,
+ BothPartySame,
+ BalanceInsufficient,
+ BadAmount,
+ TanRequired,
+ RequestUidReuse,
+}
+
+/** Create a new transaction */
+pub async fn create(
+ db: &PgPool,
+ credit_account_payto: BankPayto,
+ debit_account_username: &str,
+ subject: &str,
+ amount: Amount,
+ timestamp: &Timestamp,
+ is2fa: bool,
+ request_uid: Option<ShortHashCode>,
+ wire_transfer_fees: Amount,
+ min_amount: Amount,
+ max_amount: Amount,
+) -> sqlx::Result<TxResult> {
+ let res = match parse_incoming_unstructured(subject) {
+ Ok(subject) => {
+ if IncomingSubject::AdminBalanceAdjust == subject {
+ Err(format!("unsupported admin balance adjust"))
+ } else {
+ Ok(subject)
+ }
+ }
+ Err(e) => Err(format!("malformed metadata - {e}")),
+ };
+ let (ty, metadata, cause) = match &res {
+ Ok(s) => (Some(s.ty()), Some(s.key()), None),
+ Err(e) => (None, None, Some(e)),
+ };
+ sqlx::query(
+ "
+ SELECT
+ out_creditor_not_found
+ ,out_debtor_not_found
+ ,out_same_account
+ ,out_balance_insufficient
+ ,out_bad_amount
+ ,out_request_uid_reuse
+ ,out_tan_required
+ ,out_credit_bank_account_id
+ ,out_debit_bank_account_id
+ ,out_credit_row_id
+ ,out_debit_row_id
+ ,out_creditor_is_exchange
+ ,out_debtor_is_exchange
+ ,out_creditor_admin
+ ,out_idempotent
+ FROM bank_transaction($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::taler_incoming_type,$12,$13)
+ ",
+ )
+ .bind(credit_account_payto.canonical())
+ .bind(debit_account_username)
+ .bind(subject)
+ .bind(amount)
+ .bind_timestamp(timestamp)
+ .bind(is2fa)
+ .bind(request_uid)
+ .bind(wire_transfer_fees)
+ .bind(min_amount)
+ .bind(max_amount)
+ .bind(ty)
+ .bind(metadata)
+ .bind(cause)
+ .try_map(|r: PgRow| {
+ Ok(if r.try_get_flag("out_creditor_not_found")? {
+ TxResult::UnknownCreditor
+ } else if r.try_get_flag("out_debtor_not_found")? {
+ TxResult::UnknownDebtor
+ } else if r.try_get_flag("out_same_account")? {
+ TxResult::BothPartySame
+ } else if r.try_get_flag("out_balance_insufficient")? {
+ TxResult::BalanceInsufficient
+ } else if r.try_get_flag("out_bad_amount")? {
+ TxResult::BadAmount
+ } else if r.try_get_flag("out_creditor_admin")? {
+ TxResult::AdminCreditor
+ } else if r.try_get_flag("out_request_uid_reuse")? {
+ TxResult::RequestUidReuse
+ } else if r.try_get_flag("out_idempotent")? {
+ TxResult::Success(r.try_get_u64("out_debit_row_id")?)
+ } else if r.try_get_flag("out_tan_required")? {
+ TxResult::TanRequired
+ } else {
+ TxResult::Success(r.try_get_u64("out_debit_row_id")?)
+ })
+ })
+ .fetch_one(db)
+ .await
+}
+
+// /** Get transaction [rowId] owned by [username] */
+//pub async fn get(db: &PgPool, id: u64, username: &str) -> sqlx::Result<Option<Ban>>
diff --git a/crates/libeufin-bank/src/mfa.rs b/crates/libeufin-bank/src/mfa.rs
@@ -46,6 +46,7 @@ use crate::{
BankState,
account::{AccountReconfiguration, TanInfo},
tan::{Challenge, ChallengeResponse},
+ tx::TransactionCreateRequest,
},
auth::{Auth, AuthScope},
db::account::BankInfo,
@@ -83,7 +84,6 @@ pub struct AccountReconfig;
impl MfaOperation for AccountReconfig {
const OP: Operation = Operation::account_reconfig;
-
type Body = AccountReconfiguration;
fn required_validation(body: &Self::Body, info: &BankInfo) -> ApiResult<Option<Tans>> {
@@ -91,6 +91,13 @@ impl MfaOperation for AccountReconfig {
}
}
+pub struct BankTx;
+
+impl MfaOperation for BankTx {
+ const OP: Operation = Operation::bank_transaction;
+ type Body = TransactionCreateRequest;
+}
+
fn mfa_body_hash(body: &[u8], salt: &Base32<16>) -> Base32<64> {
let mut digest = aws_lc_rs::digest::Context::new(&SHA512);
digest.update(salt.as_ref());
diff --git a/crates/libeufin-bank/src/payto.rs b/crates/libeufin-bank/src/payto.rs
@@ -27,7 +27,7 @@ use taler_common::{
error_code::ErrorCode,
types::{
iban::BIC,
- payto::{BankID, FullPayto, Payto, PaytoErr, PaytoImpl, PaytoURI},
+ payto::{BankID, FullPayto, Payto, PaytoErr, PaytoImpl, PaytoURI, TransferPayto},
},
};
@@ -80,6 +80,7 @@ pub enum LibeufinId {
pub type BankPayto = Payto<LibeufinId>;
pub type FullBankPayto = FullPayto<LibeufinId>;
+pub type TransferBankPayto = TransferPayto<LibeufinId>;
impl LibeufinId {
pub fn canonical(&self) -> String {
diff --git a/crates/libeufin-nexus/src/fetch.rs b/crates/libeufin-nexus/src/fetch.rs
@@ -581,12 +581,11 @@ pub async fn register_incoming(
}
} else {
match parse_incoming_unstructured(payment.subject.as_deref().unwrap_or_default()) {
- Ok(None) => bounce("missing public key").await?,
- Ok(Some(IncomingSubject::AdminBalanceAdjust)) => {
+ Ok(IncomingSubject::AdminBalanceAdjust) => {
let res = register_in(db, payment).await?;
log_res(res, "admin balance adjust", "");
}
- Ok(Some(subject)) => match register_in_talerable(db, payment, &subject).await? {
+ Ok(subject) => match register_in_talerable(db, payment, &subject).await? {
IncomingRegistrationResult::ReservePubReuse => bounce("reverse pub reuse").await?,
IncomingRegistrationResult::MappingReuse => bounce("mapping reuse").await?,
IncomingRegistrationResult::UnknownMapping => bounce("unknown mapping").await?,