wire.rs (26202B)
1 /* 2 * This file is part of LibEuFin. 3 * Copyright (C) 2026 Taler Systems S.A. 4 5 * LibEuFin is free software; you can redistribute it and/or modify 6 * it under the terms of the GNU Affero General Public License as 7 * published by the Free Software Foundation; either version 3, or 8 * (at your option) any later version. 9 10 * LibEuFin is distributed in the hope that it will be useful, but 11 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 12 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General 13 * Public License for more details. 14 15 * You should have received a copy of the GNU Affero General Public 16 * License along with LibEuFin; see the file COPYING. If not, see 17 * <http://www.gnu.org/licenses/> 18 */ 19 20 use std::sync::Arc; 21 22 use axum::{ 23 Json, Router, 24 extract::State, 25 response::{IntoResponse, NoContent, Response}, 26 routing::{get, post}, 27 }; 28 use jiff::Timestamp; 29 use serde::Deserialize; 30 use sqlx::PgPool; 31 use taler_api::{ 32 api::Validation, 33 constants::WIRE_GATEWAY_API_VERSION, 34 error::{ApiResult, failure_code}, 35 extract::{Path, Query, Req}, 36 subject::{IncomingKey, fmt_in_subject}, 37 }; 38 use taler_common::{ 39 api::{ 40 params::{HistoryParams, Page, PageParams}, 41 wire::{ 42 AddIncomingRequest, AddIncomingResponse, AddKycauthRequest, AddMappedRequest, 43 IncomingHistory, OutgoingHistory, TransferList, TransferRequest, TransferResponse, 44 TransferState, WireConfig, 45 }, 46 }, 47 error_code::ErrorCode, 48 types::{amount::Amount, payto::PaytoURI}, 49 }; 50 51 use crate::{ 52 api::{BankState, IMPLEMENTATION}, 53 auth::{UserAdminAuth, WireRAuth, WireRWAuth}, 54 db::{ 55 account::check_info, 56 exchange::{ 57 AddIncomingResult, TransferResult, add_incoming, incoming_history, outgoing_history, 58 page_transfer, transfer, transfer_by_id, 59 }, 60 }, 61 payto::BankPayto, 62 }; 63 64 #[derive(Debug, Clone, Deserialize)] 65 pub struct TransferParams { 66 #[serde(flatten)] 67 pub page: PageParams, 68 pub status: Option<TransferState>, 69 } 70 71 impl TransferParams { 72 pub fn check(self) -> ApiResult<Transfer> { 73 Ok(Transfer { 74 page: self.page.check()?, 75 status: self.status, 76 }) 77 } 78 } 79 80 pub struct Transfer { 81 pub page: Page, 82 pub status: Option<TransferState>, 83 } 84 85 #[derive(Debug, Clone, Deserialize)] 86 pub struct AccountCheckParams { 87 pub account: BankPayto, 88 } 89 90 async fn admin_add_incoming( 91 db: &PgPool, 92 username: &str, 93 amount: &Amount, 94 debtor: &PaytoURI, 95 metadata: &IncomingKey, 96 ) -> ApiResult<Response> { 97 let payto = BankPayto::try_from(debtor)?; 98 match add_incoming( 99 db, 100 amount, 101 &payto, 102 &format!( 103 "Admin incoming {}", 104 fmt_in_subject(metadata.ty, &metadata.key) 105 ), 106 username, 107 &Timestamp::now(), 108 metadata, 109 ) 110 .await? 111 { 112 AddIncomingResult::Success { id, timestamp, .. } => Ok(Json(AddIncomingResponse { 113 row_id: id, 114 timestamp: timestamp.into(), 115 }) 116 .into_response()), 117 AddIncomingResult::NotAnExchange => { 118 Err(failure_code(ErrorCode::BANK_ACCOUNT_IS_NOT_EXCHANGE)) 119 } 120 AddIncomingResult::UnknownExchange => Err(failure_code(ErrorCode::BANK_UNKNOWN_ACCOUNT)), 121 AddIncomingResult::UnknownDebtor => Err(failure_code(ErrorCode::BANK_UNKNOWN_DEBTOR)), 122 AddIncomingResult::BothPartyAreExchange => { 123 Err(failure_code(ErrorCode::BANK_ACCOUNT_IS_EXCHANGE)) 124 } 125 AddIncomingResult::ReservePubReuse => { 126 Err(failure_code(ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT)) 127 } 128 AddIncomingResult::UnknownMapping => { 129 Err(failure_code(ErrorCode::BANK_TRANSFER_MAPPING_UNKNOWN)) 130 } 131 AddIncomingResult::MappingReuse => { 132 Err(failure_code(ErrorCode::BANK_TRANSFER_MAPPING_REUSED)) 133 } 134 AddIncomingResult::BalanceInsufficient => { 135 Err(failure_code(ErrorCode::BANK_UNALLOWED_DEBIT)) 136 } 137 } 138 } 139 140 pub fn wire_api() -> Router<Arc<BankState>> { 141 Router::new() 142 .route( 143 "/accounts/{username}/taler-wire-gateway/config", 144 get(async |State(state): State<Arc<BankState>>| { 145 Json(WireConfig { 146 name: (), 147 version: WIRE_GATEWAY_API_VERSION, 148 implementation: Some(IMPLEMENTATION), 149 currency: state.cfg.regional_currency, 150 support_account_check: true, 151 }) 152 }), 153 ) 154 .route( 155 "/accounts/{username}/taler-wire-gateway/transfer", 156 post( 157 async |auth: WireRWAuth, 158 State(state): State<Arc<BankState>>, 159 Req(req): Req<TransferRequest>| { 160 req.check(&state.cfg.regional_currency)?; 161 let payto = BankPayto::try_from(&req.credit_account)?; 162 match transfer( 163 &state.db, 164 &auth.username, 165 &req, 166 &payto, 167 &Timestamp::now(), 168 state.cfg.fiat.is_some(), 169 ) 170 .await? 171 { 172 TransferResult::Success { id, timestamp } => Ok(Json(TransferResponse { 173 timestamp: timestamp.into(), 174 row_id: id, 175 })), 176 TransferResult::NotAnExchange => { 177 Err(failure_code(ErrorCode::BANK_ACCOUNT_IS_NOT_EXCHANGE)) 178 } 179 TransferResult::UnknownExchange => { 180 Err(failure_code(ErrorCode::BANK_UNKNOWN_ACCOUNT)) 181 } 182 TransferResult::BothPartyAreExchange => { 183 Err(failure_code(ErrorCode::BANK_ACCOUNT_IS_EXCHANGE)) 184 } 185 TransferResult::BalanceInsufficient => { 186 Err(failure_code(ErrorCode::BANK_UNALLOWED_DEBIT)) 187 } 188 TransferResult::ReserveUidReuse => { 189 Err(failure_code(ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED)) 190 } 191 TransferResult::WtidReuse => { 192 Err(failure_code(ErrorCode::BANK_TRANSFER_WTID_REUSED)) 193 } 194 TransferResult::AdminCreditor => { 195 Err(failure_code(ErrorCode::BANK_ADMIN_CREDITOR)) 196 } 197 } 198 }, 199 ), 200 ) 201 .route( 202 "/accounts/{username}/taler-wire-gateway/transfers", 203 get( 204 async |Query(params): Query<TransferParams>, 205 mut auth: WireRAuth, 206 State(state): State<Arc<BankState>>| { 207 let params = params.check()?; 208 let info = auth.bank_info(&state.db, &state.cfg.ctx).await?; 209 if !info.is_exchange { 210 return Err(failure_code(ErrorCode::BANK_ACCOUNT_IS_NOT_EXCHANGE)); 211 } 212 let transfers = page_transfer( 213 &state.db, 214 &state.cfg.ctx, 215 &state.cfg.regional_currency, 216 ¶ms.page, 217 info.bank_account_id, 218 params.status, 219 ) 220 .await?; 221 if transfers.is_empty() { 222 ApiResult::Ok(NoContent.into_response()) 223 } else { 224 Ok(Json(TransferList { 225 transfers, 226 debit_account: info.payto.as_uri(), 227 }) 228 .into_response()) 229 } 230 }, 231 ), 232 ) 233 .route( 234 "/accounts/{username}/taler-wire-gateway/transfers/{id}", 235 get( 236 async |Path((_, id)): Path<((), u64)>, 237 mut auth: WireRAuth, 238 State(state): State<Arc<BankState>>| { 239 let info = auth.bank_info(&state.db, &state.cfg.ctx).await?; 240 if !info.is_exchange { 241 return Err(failure_code(ErrorCode::BANK_ACCOUNT_IS_NOT_EXCHANGE)); 242 } 243 match transfer_by_id( 244 &state.db, 245 &state.cfg.ctx, 246 &state.cfg.regional_currency, 247 info.bank_account_id, 248 id, 249 ) 250 .await? 251 { 252 Some(t) => Ok(Json(t)), 253 None => Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND)), 254 } 255 }, 256 ), 257 ) 258 .route( 259 "/accounts/{username}/taler-wire-gateway/history/incoming", 260 get( 261 async |Query(params): Query<HistoryParams>, 262 mut auth: WireRAuth, 263 State(state): State<Arc<BankState>>| { 264 let params = params.check()?; 265 let info = auth.bank_info(&state.db, &state.cfg.ctx).await?; 266 if !info.is_exchange { 267 return Err(failure_code(ErrorCode::BANK_ACCOUNT_IS_NOT_EXCHANGE)); 268 } 269 let incoming_transactions = incoming_history( 270 &state.db, 271 &state.cfg.ctx, 272 &state.taler_in_channel, 273 &state.cfg.regional_currency, 274 ¶ms, 275 info.bank_account_id, 276 ) 277 .await?; 278 if incoming_transactions.is_empty() { 279 ApiResult::Ok(NoContent.into_response()) 280 } else { 281 Ok(Json(IncomingHistory { 282 credit_account: info.payto.as_uri(), 283 incoming_transactions, 284 }) 285 .into_response()) 286 } 287 }, 288 ), 289 ) 290 .route( 291 "/accounts/{username}/taler-wire-gateway/history/outgoing", 292 get( 293 async |Query(params): Query<HistoryParams>, 294 mut auth: WireRAuth, 295 State(state): State<Arc<BankState>>| { 296 let params = params.check()?; 297 let info = auth.bank_info(&state.db, &state.cfg.ctx).await?; 298 if !info.is_exchange { 299 return Err(failure_code(ErrorCode::BANK_ACCOUNT_IS_NOT_EXCHANGE)); 300 } 301 let outgoing_transactions = outgoing_history( 302 &state.db, 303 &state.cfg.ctx, 304 &state.taler_out_channel, 305 &state.cfg.regional_currency, 306 ¶ms, 307 info.bank_account_id, 308 ) 309 .await?; 310 if outgoing_transactions.is_empty() { 311 ApiResult::Ok(NoContent.into_response()) 312 } else { 313 Ok(Json(OutgoingHistory { 314 debit_account: info.payto.as_uri(), 315 outgoing_transactions, 316 }) 317 .into_response()) 318 } 319 }, 320 ), 321 ) 322 .route( 323 "/accounts/{username}/taler-wire-gateway/admin/add-incoming", 324 post( 325 async |auth: UserAdminAuth, 326 State(state): State<Arc<BankState>>, 327 Req(req): Req<AddIncomingRequest>| { 328 req.check(&state.cfg.regional_currency)?; 329 admin_add_incoming( 330 &state.db, 331 &auth.username, 332 &req.amount, 333 &req.debit_account, 334 &IncomingKey::reserve(req.reserve_pub), 335 ) 336 .await 337 }, 338 ), 339 ) 340 .route( 341 "/accounts/{username}/taler-wire-gateway/admin/add-kycauth", 342 post( 343 async |auth: UserAdminAuth, 344 State(state): State<Arc<BankState>>, 345 Req(req): Req<AddKycauthRequest>| { 346 req.check(&state.cfg.regional_currency)?; 347 admin_add_incoming( 348 &state.db, 349 &auth.username, 350 &req.amount, 351 &req.debit_account, 352 &IncomingKey::kyc(req.account_pub), 353 ) 354 .await 355 }, 356 ), 357 ) 358 .route( 359 "/accounts/{username}/taler-wire-gateway/admin/add-mapped", 360 post( 361 async |auth: UserAdminAuth, 362 State(state): State<Arc<BankState>>, 363 Req(req): Req<AddMappedRequest>| { 364 req.check(&state.cfg.regional_currency)?; 365 admin_add_incoming( 366 &state.db, 367 &auth.username, 368 &req.amount, 369 &req.debit_account, 370 &IncomingKey::map(req.authorization_pub), 371 ) 372 .await 373 }, 374 ), 375 ) 376 .route( 377 "/accounts/{username}/taler-wire-gateway/account/check", 378 get( 379 async |Query(params): Query<AccountCheckParams>, 380 mut auth: WireRAuth, 381 State(state): State<Arc<BankState>>| { 382 let info = auth.bank_info(&state.db, &state.cfg.ctx).await?; 383 if !info.is_exchange { 384 return Err(failure_code(ErrorCode::BANK_ACCOUNT_IS_NOT_EXCHANGE)); 385 } 386 match check_info(&state.db, ¶ms.account).await? { 387 Some(t) => Ok(Json(t)), 388 None => Err(failure_code(ErrorCode::BANK_UNKNOWN_ACCOUNT)), 389 } 390 }, 391 ), 392 ) 393 } 394 395 #[cfg(test)] 396 pub mod test { 397 use axum::http::Method; 398 use sqlx::postgres::PgConnectOptions; 399 use taler_api::subject::fmt_out_subject; 400 use taler_common::{ 401 api::{ 402 EddsaPublicKey, HashCode, ShortHashCode, 403 wire::{AccountInfo, TransferResponse, TransferState, TransferStatus}, 404 }, 405 db::IncomingType, 406 error_code::ErrorCode, 407 types::amount::amount, 408 }; 409 use taler_macros::db_test; 410 use taler_test_utils::{ 411 json, 412 routine::{ 413 admin_add_incoming_routine, in_history_routine, out_history_routine, transfer_routine, 414 }, 415 server::TestServer as _, 416 tasks, 417 }; 418 419 use crate::api::test::{Auth, bank_setup}; 420 421 #[db_test(raw)] 422 async fn transfer(db: PgConnectOptions) { 423 let ctx = bank_setup(db).await; 424 425 ctx.auth_routine( 426 Method::POST, 427 "/accounts/merchant/taler-wire-gateway/transfer", 428 Auth::UserOnly, 429 ) 430 .await; 431 ctx.auth_routine( 432 Method::GET, 433 "/accounts/merchant/taler-wire-gateway/transfers/32", 434 Auth::UserOnly, 435 ) 436 .await; 437 ctx.auth_routine( 438 Method::GET, 439 "/accounts/merchant/taler-wire-gateway/transfers", 440 Auth::UserOnly, 441 ) 442 .await; 443 444 let req = json!({ 445 "request_uid": HashCode::rand(), 446 "amount": "KUDOS:55", 447 "exchange_base_url": "http://exchange.example.com/", 448 "wtid": ShortHashCode::rand(), 449 "credit_account": ctx.merchant_payto 450 }); 451 452 ctx.posta("/accounts/exchange/taler-wire-gateway/transfer") 453 .json(&req) 454 .await 455 .assert_error(ErrorCode::BANK_UNALLOWED_DEBIT); 456 457 ctx.set_max_debt("exchange", "1000").await; 458 transfer_routine( 459 &ctx.admin_router() 460 .await 461 .prefix("/accounts/exchange/taler-wire-gateway"), 462 TransferState::success, 463 &ctx.customer_payto.as_uri(), 464 ) 465 .await; 466 467 let admin_req = json!(req + { 468 "credit_account" : ctx.admin_payto 469 }); 470 471 // Check conversion bounce 472 for _ in 0..2 { 473 ctx.posta("/accounts/exchange/taler-wire-gateway/transfer") 474 .json(&admin_req) 475 .await 476 .assert_ok(); 477 } 478 479 let ctx = ctx.swap_cfg("test_no_conversion.conf").await; 480 // Transfer works for common accounts 481 482 let req = json!(req + { 483 "request_uid": HashCode::rand(), 484 "wtid": ShortHashCode::rand(), 485 }); 486 for _ in 0..2 { 487 ctx.posta("/accounts/exchange/taler-wire-gateway/transfer") 488 .json(&req) 489 .await 490 .assert_ok(); 491 } 492 // But fails to admin accounts 493 ctx.posta("/accounts/exchange/taler-wire-gateway/transfer") 494 .json(json!(admin_req + { 495 "request_uid": HashCode::rand(), 496 "wtid": ShortHashCode::rand(), 497 })) 498 .await 499 .assert_error(ErrorCode::BANK_ADMIN_CREDITOR); 500 501 // Dot now fail for unknown account 502 let res = ctx 503 .posta("/accounts/exchange/taler-wire-gateway/transfer") 504 .json(json!(req + { 505 "request_uid": HashCode::rand(), 506 "wtid": ShortHashCode::rand(), 507 "credit_account": ctx.unknown_payto 508 })) 509 .await 510 .assert_ok_json::<TransferResponse>(); 511 let s: TransferStatus = ctx 512 .geta(format!( 513 "/accounts/exchange/taler-wire-gateway/transfers/{}", 514 res.row_id 515 )) 516 .await 517 .assert_ok_json(); 518 assert_eq!( 519 TransferStatus { 520 status: TransferState::permanent_failure, 521 status_msg: Some("Unknown account".into()), 522 amount: amount("KUDOS:55"), 523 exchange_base_url: s.exchange_base_url.clone(), 524 metadata: None, 525 wtid: s.wtid, 526 credit_account: s.credit_account.clone(), 527 timestamp: s.timestamp, 528 }, 529 s 530 ); 531 532 // Not an exchange 533 ctx.geta(format!( 534 "/accounts/merchant/taler-wire-gateway/transfers/{}", 535 res.row_id 536 )) 537 .await 538 .assert_error(ErrorCode::BANK_ACCOUNT_IS_NOT_EXCHANGE); 539 // Another account 540 ctx.patcha("/accounts/merchant") 541 .json(json!({ 542 "is_taler_exchange": true 543 })) 544 .await 545 .assert_no_content(); 546 ctx.geta(format!( 547 "/accounts/merchant/taler-wire-gateway/transfers/{}", 548 res.row_id 549 )) 550 .await 551 .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND); 552 } 553 554 #[db_test(raw)] 555 async fn outgoing_history(db: PgConnectOptions) { 556 let ctx = &bank_setup(db).await; 557 ctx.auth_routine( 558 Method::GET, 559 "/accounts/merchant/taler-wire-gateway/history/outgoing", 560 Auth::UserOnly, 561 ) 562 .await; 563 ctx.set_max_debt("exchange", "1000").await; 564 out_history_routine( 565 &ctx.admin_router() 566 .await 567 .prefix("/accounts/exchange/taler-wire-gateway"), 568 tasks!( 569 // Transactions using clean add incoming logic 570 { ctx.transfer("10", &ctx.customer_payto, None).await }, 571 // And with metadata 572 { 573 ctx.transfer("12", &ctx.customer_payto, Some("CON:ID".into())) 574 .await 575 } 576 ), 577 tasks!( 578 // // Failed transfer 579 { ctx.transfer("10", &ctx.unknown_payto, None).await }, 580 // Ignore manual outgoing transaction 581 { 582 ctx.tx_s( 583 "exchange", 584 "10", 585 "merchant", 586 fmt_out_subject( 587 &ShortHashCode::rand(), 588 "https://exchange.example.com/", 589 None, 590 ), 591 ) 592 .await 593 }, 594 // Ignore malformed incoming transaction 595 { ctx.tx("merchant", "10", "exchange",).await }, 596 // Ignore malformed outgoing transaction 597 { ctx.tx("exchange", "10", "merchant",).await }, 598 ), 599 ) 600 .await; 601 } 602 603 #[db_test(raw)] 604 async fn incoming_history(db: PgConnectOptions) { 605 let ctx = &bank_setup(db).await; 606 ctx.auth_routine( 607 Method::GET, 608 "/accounts/merchant/taler-wire-gateway/history/incoming", 609 Auth::UserOnly, 610 ) 611 .await; 612 ctx.set_max_debt("customer", "1000").await; 613 ctx.set_max_debt("merchant", "1000").await; 614 let admin = ctx.admin_router().await; 615 in_history_routine( 616 &admin.prefix("/accounts/exchange/taler-wire-gateway"), 617 &admin.prefix("/taler-prepared-transfer"), 618 &ctx.customer_payto.as_uri(), 619 &ctx.exchange_payto.as_uri(), 620 tasks!( 621 // Reserve transactions using raw bank transaction logic 622 { 623 ctx.tx_s( 624 "merchant", 625 "10", 626 "exchange", 627 format!("history test with {} reserve pub", EddsaPublicKey::rand()), 628 ) 629 .await 630 }, 631 // Reserve transactions using withdraw logic 632 { ctx.withdrawal("9").await }, 633 // KYC transactions using raw bank transaction logic 634 { 635 ctx.tx_s( 636 "merchant", 637 "10", 638 "exchange", 639 format!( 640 "history test with KYC:{} account pub", 641 EddsaPublicKey::rand() 642 ), 643 ) 644 .await 645 } 646 ), 647 tasks!( 648 // Ignore malformed incoming transaction 649 { ctx.tx("merchant", "10", "exchange",).await }, 650 // Ignore malformed outgoing transaction 651 { ctx.tx("exchange", "10", "merchant",).await }, 652 ), 653 ) 654 .await; 655 } 656 657 #[db_test(raw)] 658 async fn admin_add_incoming(db: PgConnectOptions) { 659 let ctx = bank_setup(db).await; 660 ctx.auth_routine( 661 Method::GET, 662 "/accounts/merchant/taler-wire-gateway/history/incoming", 663 Auth::UserOnly, 664 ) 665 .await; 666 for ty in IncomingType::entries { 667 let (path, key) = match ty { 668 IncomingType::reserve => ("incoming", "reserve_pub"), 669 IncomingType::kyc => ("kycauth", "account_pub"), 670 IncomingType::map => ("mapped", "authorization_pub"), 671 }; 672 let path = format!("/accounts/exchange/taler-wire-gateway/admin/add-{path}"); 673 ctx.auth_routine(Method::POST, &path, Auth::Admin).await; 674 let req = json!({ 675 "amount": "KUDOS:44", 676 "debit_account": ctx.customer_payto, 677 key: EddsaPublicKey::rand(), 678 }); 679 680 ctx.post_admin(path.replace("exchange", "merchant")) 681 .json(&req) 682 .await 683 .assert_error(ErrorCode::BANK_ACCOUNT_IS_NOT_EXCHANGE); 684 ctx.post_admin(&path) 685 .json(json!(req + { 686 "debit_account": ctx.exchange_payto 687 })) 688 .await 689 .assert_error(ErrorCode::BANK_ACCOUNT_IS_EXCHANGE); 690 if *ty != IncomingType::map { 691 ctx.post_admin(&path) 692 .json(json!(req + { 693 "amount": "KUDOS:44", 694 })) 695 .await 696 .assert_error(ErrorCode::BANK_UNALLOWED_DEBIT); 697 } 698 } 699 ctx.set_max_debt("customer", "1000").await; 700 let admin = ctx.admin_router().await; 701 admin_add_incoming_routine( 702 &admin.prefix("/accounts/exchange/taler-wire-gateway"), 703 &admin.prefix("/taler-prepared-transfer"), 704 &ctx.customer_payto.as_uri(), 705 &ctx.exchange_payto.as_uri(), 706 ) 707 .await; 708 } 709 710 #[db_test(raw)] 711 async fn account_check(db: PgConnectOptions) { 712 let ctx = bank_setup(db).await; 713 ctx.geta("/accounts/exchange/taler-wire-gateway/account/check") 714 .await 715 .assert_error(ErrorCode::GENERIC_PARAMETER_MISSING); 716 ctx.geta(format!( 717 "/accounts/exchange/taler-wire-gateway/account/check?account={}", 718 ctx.unknown_payto 719 )) 720 .await 721 .assert_error(ErrorCode::BANK_UNKNOWN_ACCOUNT); 722 ctx.geta(format!( 723 "/accounts/exchange/taler-wire-gateway/account/check?account={}", 724 ctx.merchant_payto 725 )) 726 .await 727 .assert_ok_json::<AccountInfo>(); 728 } 729 }