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 ¶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_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 }