taler-rust

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

api.rs (20647B)


      1 /*
      2   This file is part of TALER
      3   Copyright (C) 2025-2026 Taler Systems SA
      4 
      5   TALER is free software; you can redistribute it and/or modify it under the
      6   terms of the GNU Affero General Public License as published by the Free Software
      7   Foundation; either version 3, or (at your option) any later version.
      8 
      9   TALER is distributed in the hope that it will be useful, but WITHOUT ANY
     10   WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
     11   A PARTICULAR PURPOSE.  See the GNU Affero General Public License for more details.
     12 
     13   You should have received a copy of the GNU Affero General Public License along with
     14   TALER; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
     15 */
     16 
     17 use compact_str::CompactString;
     18 use jiff::Timestamp;
     19 use prometheus_client::{metrics::gauge::Gauge, registry::Registry};
     20 use sqlx::PgPool;
     21 use taler_api::{
     22     api::{
     23         TalerApi, observability::Observability, prepared::PreparedTransfer, revenue::Revenue,
     24         wire::WireGateway,
     25     },
     26     error::{ApiResult, failure_code},
     27     subject::{IncomingKey, fmt_in_subject},
     28 };
     29 use taler_common::{
     30     api::{
     31         params::{History, Page},
     32         prepared::{
     33             RegistrationRequest, RegistrationResponse, SubjectFormat, TransferSubject,
     34             Unregistration,
     35         },
     36         revenue::RevenueIncomingHistory,
     37         wire::{
     38             AddIncomingRequest, AddIncomingResponse, AddKycauthRequest, AddMappedRequest,
     39             IncomingHistory, OutgoingHistory, TransferList, TransferRequest, TransferResponse,
     40             TransferState, TransferStatus,
     41         },
     42     },
     43     db::IncomingType,
     44     error_code::ErrorCode,
     45     types::{amount::Currency, time::TalerTimestamp},
     46 };
     47 use tokio::sync::watch::Sender;
     48 
     49 use crate::{
     50     db::{self, AddIncomingResult, Transfer, TxInAdmin},
     51     payto::{CyclosPayto, FullCyclosPayto},
     52 };
     53 
     54 pub struct CyclosApi {
     55     pub pool: sqlx::PgPool,
     56     pub currency: Currency,
     57     pub payto: FullCyclosPayto,
     58     pub in_channel: Sender<i64>,
     59     pub taler_in_channel: Sender<i64>,
     60     pub out_channel: Sender<i64>,
     61     pub taler_out_channel: Sender<i64>,
     62     pub root: CompactString,
     63     metrics: Metrics,
     64     registry: Registry,
     65 }
     66 
     67 #[derive(Default)]
     68 struct Metrics {
     69     db_access: Gauge,
     70 }
     71 
     72 impl Metrics {
     73     pub fn registry(&self) -> Registry {
     74         let mut registry = Registry::default();
     75 
     76         registry.register(
     77             "db_access",
     78             "Whether the last database metrics refresh succeeded",
     79             self.db_access.clone(),
     80         );
     81         registry
     82     }
     83 
     84     pub async fn sync(&self, db: &PgPool) {
     85         let test = sqlx::query("SELECT 1").fetch_one(db).await.is_ok();
     86         self.db_access.set(if test { 1 } else { 0 });
     87     }
     88 }
     89 
     90 impl CyclosApi {
     91     pub fn start(
     92         pool: sqlx::PgPool,
     93         root: CompactString,
     94         payto: FullCyclosPayto,
     95         currency: Currency,
     96     ) -> Self {
     97         let in_channel = Sender::new(0);
     98         let taler_in_channel = Sender::new(0);
     99         let out_channel = Sender::new(0);
    100         let taler_out_channel = Sender::new(0);
    101 
    102         let metrics = Metrics::default();
    103 
    104         let tmp = Self {
    105             pool: pool.clone(),
    106             payto,
    107             currency,
    108             root,
    109             in_channel: in_channel.clone(),
    110             taler_in_channel: taler_in_channel.clone(),
    111             out_channel: out_channel.clone(),
    112             taler_out_channel: taler_out_channel.clone(),
    113             registry: metrics.registry(),
    114             metrics,
    115         };
    116         tokio::spawn(db::notification_listener(
    117             pool,
    118             in_channel,
    119             taler_in_channel,
    120             out_channel,
    121             taler_out_channel,
    122         ));
    123         tmp
    124     }
    125 }
    126 
    127 impl TalerApi for CyclosApi {
    128     fn currency(&self) -> Currency {
    129         self.currency
    130     }
    131 
    132     fn implementation(&self) -> &'static str {
    133         "urn:net:taler:specs:taler-cyclos:taler-rust"
    134     }
    135 }
    136 
    137 impl WireGateway for CyclosApi {
    138     async fn transfer(&self, req: TransferRequest) -> ApiResult<TransferResponse> {
    139         let creditor = FullCyclosPayto::try_from(&req.credit_account)?;
    140         let result = db::make_transfer(
    141             &self.pool,
    142             &Transfer {
    143                 request_uid: req.request_uid,
    144                 amount: req.amount.decimal(),
    145                 exchange_base_url: req.exchange_base_url,
    146                 metadata: req.metadata,
    147                 wtid: req.wtid,
    148                 creditor_id: *creditor.id,
    149                 creditor_name: creditor.name,
    150             },
    151             &Timestamp::now(),
    152         )
    153         .await?;
    154         match result {
    155             db::TransferResult::Success { id, initiated_at } => Ok(TransferResponse {
    156                 timestamp: initiated_at.into(),
    157                 row_id: id,
    158             }),
    159             db::TransferResult::RequestUidReuse => {
    160                 Err(failure_code(ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED))
    161             }
    162             db::TransferResult::WtidReuse => {
    163                 Err(failure_code(ErrorCode::BANK_TRANSFER_WTID_REUSED))
    164             }
    165         }
    166     }
    167 
    168     async fn transfer_page(
    169         &self,
    170         page: Page,
    171         status: Option<TransferState>,
    172     ) -> ApiResult<TransferList> {
    173         Ok(TransferList {
    174             transfers: db::transfer_page(&self.pool, &status, &self.currency, &self.root, &page)
    175                 .await?,
    176             debit_account: self.payto.as_uri(),
    177         })
    178     }
    179 
    180     async fn transfer_by_id(&self, id: u64) -> ApiResult<Option<TransferStatus>> {
    181         Ok(db::transfer_by_id(&self.pool, id, &self.currency, &self.root).await?)
    182     }
    183 
    184     async fn outgoing_history(&self, params: History) -> ApiResult<OutgoingHistory> {
    185         Ok(OutgoingHistory {
    186             outgoing_transactions: db::outgoing_history(
    187                 &self.pool,
    188                 &params,
    189                 &self.currency,
    190                 &self.root,
    191                 || self.taler_out_channel.subscribe(),
    192             )
    193             .await?,
    194             debit_account: self.payto.as_uri(),
    195         })
    196     }
    197 
    198     async fn incoming_history(&self, params: History) -> ApiResult<IncomingHistory> {
    199         Ok(IncomingHistory {
    200             incoming_transactions: db::incoming_history(
    201                 &self.pool,
    202                 &params,
    203                 &self.currency,
    204                 &self.root,
    205                 || self.taler_in_channel.subscribe(),
    206             )
    207             .await?,
    208             credit_account: self.payto.as_uri(),
    209         })
    210     }
    211 
    212     async fn add_incoming_reserve(
    213         &self,
    214         req: AddIncomingRequest,
    215     ) -> ApiResult<AddIncomingResponse> {
    216         let debtor = FullCyclosPayto::try_from(&req.debit_account)?;
    217         let res = db::register_tx_in_admin(
    218             &self.pool,
    219             &TxInAdmin {
    220                 amount: req.amount.decimal(),
    221                 subject: format!("Admin incoming {}", req.reserve_pub),
    222                 debtor_id: *debtor.id,
    223                 debtor_name: debtor.name,
    224                 metadata: IncomingKey::reserve(req.reserve_pub),
    225             },
    226             &Timestamp::now(),
    227         )
    228         .await?;
    229         match res {
    230             AddIncomingResult::Success {
    231                 row_id, valued_at, ..
    232             } => Ok(AddIncomingResponse {
    233                 row_id,
    234                 timestamp: valued_at.into(),
    235             }),
    236             AddIncomingResult::ReservePubReuse => {
    237                 Err(failure_code(ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT))
    238             }
    239             AddIncomingResult::UnknownMapping | AddIncomingResult::MappingReuse => {
    240                 unreachable!("mapping unused")
    241             }
    242         }
    243     }
    244 
    245     async fn add_incoming_kyc(&self, req: AddKycauthRequest) -> ApiResult<AddIncomingResponse> {
    246         let debtor = FullCyclosPayto::try_from(&req.debit_account)?;
    247         let res = db::register_tx_in_admin(
    248             &self.pool,
    249             &TxInAdmin {
    250                 amount: req.amount.decimal(),
    251                 subject: format!("Admin incoming KYC:{}", req.account_pub),
    252                 debtor_id: *debtor.id,
    253                 debtor_name: debtor.name,
    254                 metadata: IncomingKey::kyc(req.account_pub),
    255             },
    256             &Timestamp::now(),
    257         )
    258         .await?;
    259         match res {
    260             AddIncomingResult::Success {
    261                 row_id, valued_at, ..
    262             } => Ok(AddIncomingResponse {
    263                 row_id,
    264                 timestamp: valued_at.into(),
    265             }),
    266             AddIncomingResult::ReservePubReuse => {
    267                 Err(failure_code(ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT))
    268             }
    269             AddIncomingResult::UnknownMapping | AddIncomingResult::MappingReuse => {
    270                 unreachable!("mapping unused")
    271             }
    272         }
    273     }
    274 
    275     async fn add_incoming_mapped(&self, req: AddMappedRequest) -> ApiResult<AddIncomingResponse> {
    276         let debtor = FullCyclosPayto::try_from(&req.debit_account)?;
    277         let res = db::register_tx_in_admin(
    278             &self.pool,
    279             &TxInAdmin {
    280                 amount: req.amount.decimal(),
    281                 subject: format!("Admin incoming MAP:{}", req.authorization_pub),
    282                 debtor_id: *debtor.id,
    283                 debtor_name: debtor.name,
    284                 metadata: IncomingKey::map(req.authorization_pub),
    285             },
    286             &Timestamp::now(),
    287         )
    288         .await?;
    289         match res {
    290             AddIncomingResult::Success {
    291                 row_id, valued_at, ..
    292             } => Ok(AddIncomingResponse {
    293                 row_id,
    294                 timestamp: valued_at.into(),
    295             }),
    296             AddIncomingResult::ReservePubReuse => {
    297                 Err(failure_code(ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT))
    298             }
    299             AddIncomingResult::UnknownMapping => {
    300                 Err(failure_code(ErrorCode::BANK_TRANSFER_MAPPING_UNKNOWN))
    301             }
    302             AddIncomingResult::MappingReuse => {
    303                 Err(failure_code(ErrorCode::BANK_TRANSFER_MAPPING_REUSED))
    304             }
    305         }
    306     }
    307 
    308     fn support_account_check(&self) -> bool {
    309         false
    310     }
    311 }
    312 
    313 impl Revenue for CyclosApi {
    314     async fn history(&self, params: History) -> ApiResult<RevenueIncomingHistory> {
    315         Ok(RevenueIncomingHistory {
    316             incoming_transactions: db::revenue_history(
    317                 &self.pool,
    318                 &params,
    319                 &self.currency,
    320                 &self.root,
    321                 || self.in_channel.subscribe(),
    322             )
    323             .await?,
    324             credit_account: self.payto.as_uri(),
    325         })
    326     }
    327 }
    328 
    329 impl PreparedTransfer for CyclosApi {
    330     fn supported_formats(&self) -> &[SubjectFormat] {
    331         &[SubjectFormat::SIMPLE]
    332     }
    333 
    334     async fn registration(&self, req: RegistrationRequest) -> ApiResult<RegistrationResponse> {
    335         let creditor = CyclosPayto::try_from(&req.credit_account)?;
    336         if *creditor != *self.payto {
    337             return Err(failure_code(ErrorCode::BANK_UNKNOWN_CREDITOR));
    338         }
    339         match db::transfer_register(&self.pool, &req).await? {
    340             db::RegistrationResult::Success => {
    341                 let simple = TransferSubject::Simple {
    342                     credit_amount: req.credit_amount,
    343                     subject: if req.authorization_pub == req.account_pub && !req.recurrent {
    344                         fmt_in_subject(req.r#type.into(), &req.account_pub).to_string()
    345                     } else {
    346                         fmt_in_subject(IncomingType::map, &req.authorization_pub).to_string()
    347                     },
    348                 };
    349                 ApiResult::Ok(RegistrationResponse {
    350                     subjects: vec![simple],
    351                     expiration: TalerTimestamp::Never,
    352                 })
    353             }
    354             db::RegistrationResult::ReservePubReuse => {
    355                 ApiResult::Err(failure_code(ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT))
    356             }
    357         }
    358     }
    359 
    360     async fn unregistration(&self, req: Unregistration) -> ApiResult<bool> {
    361         Ok(db::transfer_unregister(&self.pool, &req).await?)
    362     }
    363 }
    364 
    365 impl Observability for CyclosApi {
    366     async fn metrics(&self) -> ApiResult<&Registry> {
    367         self.metrics.sync(&self.pool).await;
    368         Ok(&self.registry)
    369     }
    370 }
    371 
    372 #[cfg(test)]
    373 mod test {
    374     use std::sync::{
    375         Arc, LazyLock,
    376         atomic::{AtomicI64, Ordering},
    377     };
    378 
    379     use compact_str::CompactString;
    380     use jiff::Timestamp;
    381     use sqlx::{PgPool, Row as _, postgres::PgRow};
    382     use taler_api::{
    383         api::TalerRouter as _,
    384         auth::AuthMethod,
    385         db::TypeHelper as _,
    386         subject::{IncomingKey, OutgoingSubject},
    387     };
    388     use taler_common::{
    389         api::{
    390             EddsaPublicKey,
    391             observability::Config,
    392             prepared::PreparedTransferConfig,
    393             revenue::RevenueConfig,
    394             wire::{TransferState, WireConfig},
    395         },
    396         db::IncomingType,
    397         types::{
    398             amount::{Currency, decimal},
    399             payto::{PaytoURI, payto},
    400         },
    401     };
    402     use taler_test_utils::{
    403         Router,
    404         db::db_test_setup,
    405         routine::{
    406             Status, admin_add_incoming_routine, in_history_routine, out_history_routine,
    407             registration_routine, revenue_routine, transfer_routine,
    408         },
    409         server::TestServer as _,
    410         tasks,
    411     };
    412 
    413     use crate::{
    414         api::CyclosApi,
    415         constants::CONFIG_SOURCE,
    416         db::{self, TxIn, TxOutKind},
    417         payto::{FullCyclosPayto, cyclos_payto},
    418     };
    419 
    420     static PAYTO: LazyLock<FullCyclosPayto> = LazyLock::new(|| {
    421         cyclos_payto("payto://cyclos/localhost/7762070814178012479?receiver-name=Smith")
    422     });
    423     static EXCHANGE: LazyLock<PaytoURI> = LazyLock::new(|| PAYTO.as_uri());
    424     static UNKNOWN: LazyLock<PaytoURI> = LazyLock::new(|| {
    425         payto("payto://cyclos/localhost/7762070814178012478?receiver-name=Unknown")
    426     });
    427 
    428     async fn setup() -> (Router, PgPool) {
    429         let (_, pool) = db_test_setup(CONFIG_SOURCE).await;
    430         let api = Arc::new(CyclosApi::start(
    431             pool.clone(),
    432             CompactString::const_new("localhost"),
    433             PAYTO.clone(),
    434             Currency::TEST,
    435         ));
    436         let server = Router::new()
    437             .wire_gateway(api.clone(), AuthMethod::None)
    438             .prepared_transfer(api.clone())
    439             .revenue(api.clone(), AuthMethod::None)
    440             .observability(api, AuthMethod::None)
    441             .finalize();
    442 
    443         (server, pool)
    444     }
    445 
    446     #[tokio::test]
    447     async fn config() {
    448         let (server, _) = setup().await;
    449         server
    450             .get("/taler-wire-gateway/config")
    451             .await
    452             .assert_ok_json::<WireConfig>();
    453         server
    454             .get("/taler-prepared-transfer/config")
    455             .await
    456             .assert_ok_json::<PreparedTransferConfig>();
    457         server
    458             .get("/taler-revenue/config")
    459             .await
    460             .assert_ok_json::<RevenueConfig>();
    461         server
    462             .get(format!("/taler-observability/config"))
    463             .await
    464             .assert_ok_json::<Config>();
    465         server
    466             .get(format!("/taler-observability/metrics"))
    467             .await
    468             .assert_ok();
    469     }
    470 
    471     #[tokio::test]
    472     async fn transfer() {
    473         let (server, _) = setup().await;
    474         transfer_routine(
    475             &server.prefix("/taler-wire-gateway"),
    476             TransferState::pending,
    477             &EXCHANGE,
    478         )
    479         .await;
    480     }
    481 
    482     static CODE: AtomicI64 = AtomicI64::new(0);
    483 
    484     async fn r#in(db: &PgPool, subject: Option<IncomingKey>) {
    485         let now = Timestamp::now();
    486         db::register_tx_in(
    487             &mut db.acquire().await.unwrap(),
    488             &TxIn {
    489                 transfer_id: CODE.fetch_add(1, Ordering::Relaxed),
    490                 tx_id: None,
    491                 amount: decimal("10"),
    492                 subject: "subject".to_owned(),
    493                 debtor_id: 31000163100000000,
    494                 debtor_name: "Name".into(),
    495                 valued_at: Timestamp::now(),
    496             },
    497             &subject,
    498             &now,
    499         )
    500         .await
    501         .unwrap();
    502     }
    503 
    504     async fn in_malformed(db: &PgPool) {
    505         r#in(db, None).await
    506     }
    507 
    508     async fn in_talerable(db: &PgPool) {
    509         r#in(db, Some(IncomingKey::reserve(EddsaPublicKey::rand()))).await
    510     }
    511 
    512     async fn out(db: &PgPool, kind: &TxOutKind) {
    513         let i = CODE.fetch_add(1, Ordering::Relaxed);
    514         let now = Timestamp::now();
    515         db::register_tx_out(
    516             &mut db.acquire().await.unwrap(),
    517             &db::TxOut {
    518                 transfer_id: i,
    519                 tx_id: if i % 2 == 0 { Some(i % 2) } else { None },
    520                 amount: decimal("10"),
    521                 subject: "subject".to_owned(),
    522                 creditor_id: 31000163100000000,
    523                 creditor_name: "Name".into(),
    524                 valued_at: now,
    525             },
    526             kind,
    527             &now,
    528         )
    529         .await
    530         .unwrap();
    531     }
    532 
    533     async fn out_talerable(db: &PgPool) {
    534         out(db, &TxOutKind::Talerable(OutgoingSubject::rand())).await
    535     }
    536 
    537     async fn out_bounce(db: &PgPool) {
    538         out(db, &TxOutKind::Bounce(CODE.load(Ordering::Relaxed))).await
    539     }
    540 
    541     async fn out_malformed(db: &PgPool) {
    542         out(db, &TxOutKind::Simple).await
    543     }
    544 
    545     #[tokio::test]
    546     async fn outgoing_history() {
    547         let (server, db) = &setup().await;
    548 
    549         out_history_routine(
    550             &server.prefix("/taler-wire-gateway"),
    551             tasks!({ out_talerable(db).await }),
    552             tasks!(
    553                 { out_bounce(db).await },
    554                 { out_malformed(db).await },
    555                 { in_malformed(db).await },
    556                 { in_talerable(db).await }
    557             ),
    558         )
    559         .await;
    560     }
    561 
    562     #[tokio::test]
    563     async fn admin_add_incoming() {
    564         let (server, _) = setup().await;
    565         admin_add_incoming_routine(
    566             &server.prefix("/taler-wire-gateway"),
    567             &server.prefix("/taler-prepared-transfer"),
    568             &EXCHANGE,
    569             &EXCHANGE,
    570         )
    571         .await;
    572     }
    573 
    574     #[tokio::test]
    575     async fn in_history() {
    576         let (server, db) = &setup().await;
    577         in_history_routine(
    578             &server.prefix("/taler-wire-gateway"),
    579             &server.prefix("/taler-prepared-transfer"),
    580             &EXCHANGE,
    581             &EXCHANGE,
    582             tasks!({ in_talerable(db).await }),
    583             tasks!(
    584                 { out_malformed(db).await },
    585                 { out_talerable(db).await },
    586                 { out_bounce(db).await },
    587                 { in_malformed(db).await }
    588             ),
    589         )
    590         .await;
    591     }
    592 
    593     #[tokio::test]
    594     async fn revenue() {
    595         let (server, db) = &setup().await;
    596         revenue_routine(
    597             &server.prefix("/taler-wire-gateway"),
    598             &server.prefix("/taler-revenue"),
    599             &EXCHANGE,
    600             tasks!({ in_malformed(db).await }, { in_talerable(db).await },),
    601             tasks!({ out_malformed(db).await }, { out_talerable(db).await }, {
    602                 out_bounce(db).await
    603             }),
    604         )
    605         .await;
    606     }
    607 
    608     async fn check_in(pool: &PgPool) -> Vec<Status> {
    609         sqlx::query(
    610             "
    611             SELECT pending_recurrent_in.authorization_pub IS NOT NULL, bounced.tx_in_id IS NOT NULL, type, metadata
    612             FROM tx_in
    613                 LEFT JOIN taler_in USING (tx_in_id)
    614                 LEFT JOIN pending_recurrent_in USING (tx_in_id)
    615                 LEFT JOIN bounced USING (tx_in_id)
    616             ORDER BY tx_in.tx_in_id
    617         ",
    618         )
    619         .try_map(|r: PgRow| {
    620             Ok(
    621                 if r.try_get_flag(0)? {
    622                     Status::Pending
    623                 } else if r.try_get_flag(1)? {
    624                     Status::Bounced
    625                 } else {
    626                     match r.try_get(2)? {
    627                         None => Status::Simple,
    628                         Some(IncomingType::reserve) => Status::Reserve(r.try_get(3)?),
    629                         Some(IncomingType::kyc) => Status::Kyc(r.try_get(3)?),
    630                         Some(e) => unreachable!("{e:?}")
    631                     }
    632                 }
    633             )
    634         })
    635         .fetch_all(pool)
    636         .await
    637         .unwrap()
    638     }
    639 
    640     #[tokio::test]
    641     async fn registration() {
    642         let (server, pool) = setup().await;
    643         registration_routine(
    644             &server.prefix("/taler-wire-gateway"),
    645             &server.prefix("/taler-prepared-transfer"),
    646             &EXCHANGE,
    647             &EXCHANGE,
    648             &UNKNOWN,
    649             || check_in(&pool),
    650         )
    651         .await;
    652     }
    653 }