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