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