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