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