api.rs (20559B)
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 ¶ms, 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 ¶ms, 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 ¶ms, 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_macros::db_test; 403 use taler_test_utils::{ 404 Router, 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 db::{self, TxIn, TxOutKind}, 416 payto::{FullCyclosPayto, cyclos_payto}, 417 }; 418 419 static PAYTO: LazyLock<FullCyclosPayto> = LazyLock::new(|| { 420 cyclos_payto("payto://cyclos/localhost/7762070814178012479?receiver-name=Smith") 421 }); 422 static EXCHANGE: LazyLock<PaytoURI> = LazyLock::new(|| PAYTO.as_uri()); 423 static UNKNOWN: LazyLock<PaytoURI> = LazyLock::new(|| { 424 payto("payto://cyclos/localhost/7762070814178012478?receiver-name=Unknown") 425 }); 426 427 async fn setup(pool: &PgPool) -> Router { 428 let api = Arc::new(CyclosApi::start( 429 pool.clone(), 430 CompactString::const_new("localhost"), 431 PAYTO.clone(), 432 Currency::TEST, 433 )); 434 Router::new() 435 .wire_gateway(api.clone(), AuthMethod::None) 436 .prepared_transfer(api.clone()) 437 .revenue(api.clone(), AuthMethod::None) 438 .observability(api, AuthMethod::None) 439 .finalize() 440 } 441 442 #[db_test] 443 async fn config(pool: PgPool) { 444 let server = setup(&pool).await; 445 server 446 .get("/taler-wire-gateway/config") 447 .await 448 .assert_ok_json::<WireConfig>(); 449 server 450 .get("/taler-prepared-transfer/config") 451 .await 452 .assert_ok_json::<PreparedTransferConfig>(); 453 server 454 .get("/taler-revenue/config") 455 .await 456 .assert_ok_json::<RevenueConfig>(); 457 server 458 .get("/taler-observability/config") 459 .await 460 .assert_ok_json::<Config>(); 461 server.get("/taler-observability/metrics").await.assert_ok(); 462 } 463 464 #[db_test] 465 async fn transfer(pool: PgPool) { 466 let server = setup(&pool).await; 467 transfer_routine( 468 &server.prefix("/taler-wire-gateway"), 469 TransferState::pending, 470 &EXCHANGE, 471 ) 472 .await; 473 } 474 475 static CODE: AtomicI64 = AtomicI64::new(0); 476 477 async fn r#in(db: &PgPool, subject: Option<IncomingKey>) { 478 let now = Timestamp::now(); 479 db::register_tx_in( 480 &mut db.acquire().await.unwrap(), 481 &TxIn { 482 transfer_id: CODE.fetch_add(1, Ordering::Relaxed), 483 tx_id: None, 484 amount: decimal("10"), 485 subject: "subject".to_owned(), 486 debtor_id: 31000163100000000, 487 debtor_name: "Name".into(), 488 valued_at: Timestamp::now(), 489 }, 490 &subject, 491 &now, 492 ) 493 .await 494 .unwrap(); 495 } 496 497 async fn in_malformed(db: &PgPool) { 498 r#in(db, None).await 499 } 500 501 async fn in_talerable(db: &PgPool) { 502 r#in(db, Some(IncomingKey::reserve(EddsaPublicKey::rand()))).await 503 } 504 505 async fn out(db: &PgPool, kind: &TxOutKind) { 506 let i = CODE.fetch_add(1, Ordering::Relaxed); 507 let now = Timestamp::now(); 508 db::register_tx_out( 509 &mut db.acquire().await.unwrap(), 510 &db::TxOut { 511 transfer_id: i, 512 tx_id: if i % 2 == 0 { Some(i % 2) } else { None }, 513 amount: decimal("10"), 514 subject: "subject".to_owned(), 515 creditor_id: 31000163100000000, 516 creditor_name: "Name".into(), 517 valued_at: now, 518 }, 519 kind, 520 &now, 521 ) 522 .await 523 .unwrap(); 524 } 525 526 async fn out_talerable(db: &PgPool) { 527 out(db, &TxOutKind::Talerable(OutgoingSubject::rand())).await 528 } 529 530 async fn out_bounce(db: &PgPool) { 531 out(db, &TxOutKind::Bounce(CODE.load(Ordering::Relaxed))).await 532 } 533 534 async fn out_malformed(db: &PgPool) { 535 out(db, &TxOutKind::Simple).await 536 } 537 538 #[db_test] 539 async fn outgoing_history(db: PgPool) { 540 let db = &db; 541 let server = setup(db).await; 542 543 out_history_routine( 544 &server.prefix("/taler-wire-gateway"), 545 tasks!({ out_talerable(db).await }), 546 tasks!( 547 { out_bounce(db).await }, 548 { out_malformed(db).await }, 549 { in_malformed(db).await }, 550 { in_talerable(db).await } 551 ), 552 ) 553 .await; 554 } 555 556 #[db_test] 557 async fn admin_add_incoming(db: PgPool) { 558 let server = setup(&db).await; 559 admin_add_incoming_routine( 560 &server.prefix("/taler-wire-gateway"), 561 &server.prefix("/taler-prepared-transfer"), 562 &EXCHANGE, 563 &EXCHANGE, 564 ) 565 .await; 566 } 567 568 #[db_test] 569 async fn in_history(db: PgPool) { 570 let db = &db; 571 let server = setup(db).await; 572 in_history_routine( 573 &server.prefix("/taler-wire-gateway"), 574 &server.prefix("/taler-prepared-transfer"), 575 &EXCHANGE, 576 &EXCHANGE, 577 tasks!({ in_talerable(db).await }), 578 tasks!( 579 { out_malformed(db).await }, 580 { out_talerable(db).await }, 581 { out_bounce(db).await }, 582 { in_malformed(db).await } 583 ), 584 ) 585 .await; 586 } 587 588 #[db_test] 589 async fn revenue(db: PgPool) { 590 let db = &db; 591 let server = setup(db).await; 592 revenue_routine( 593 &server.prefix("/taler-wire-gateway"), 594 &server.prefix("/taler-revenue"), 595 &EXCHANGE, 596 tasks!({ in_malformed(db).await }, { in_talerable(db).await },), 597 tasks!({ out_malformed(db).await }, { out_talerable(db).await }, { 598 out_bounce(db).await 599 }), 600 ) 601 .await; 602 } 603 604 async fn check_in(pool: &PgPool) -> Vec<Status> { 605 sqlx::query( 606 " 607 SELECT pending_recurrent_in.authorization_pub IS NOT NULL, bounced.tx_in_id IS NOT NULL, type, metadata 608 FROM tx_in 609 LEFT JOIN taler_in USING (tx_in_id) 610 LEFT JOIN pending_recurrent_in USING (tx_in_id) 611 LEFT JOIN bounced USING (tx_in_id) 612 ORDER BY tx_in.tx_in_id 613 ", 614 ) 615 .try_map(|r: PgRow| { 616 Ok( 617 if r.try_get_flag(0)? { 618 Status::Pending 619 } else if r.try_get_flag(1)? { 620 Status::Bounced 621 } else { 622 match r.try_get(2)? { 623 None => Status::Simple, 624 Some(IncomingType::reserve) => Status::Reserve(r.try_get(3)?), 625 Some(IncomingType::kyc) => Status::Kyc(r.try_get(3)?), 626 Some(e) => unreachable!("{e:?}") 627 } 628 } 629 ) 630 }) 631 .fetch_all(pool) 632 .await 633 .unwrap() 634 } 635 636 #[db_test] 637 async fn registration(pool: PgPool) { 638 let server = setup(&pool).await; 639 registration_routine( 640 &server.prefix("/taler-wire-gateway"), 641 &server.prefix("/taler-prepared-transfer"), 642 &EXCHANGE, 643 &EXCHANGE, 644 &UNKNOWN, 645 || check_in(&pool), 646 ) 647 .await; 648 } 649 }