depolymerization

wire gateway for Bitcoin/Ethereum
Log | Files | Refs | Submodules | README | LICENSE

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, &params, &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, &params, &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, &params, &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, &params, &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 }