depolymerization

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

api.rs (18938B)


      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_macros::db_test;
    419     use taler_test_utils::{
    420         routine::{
    421             Status, admin_add_incoming_routine, in_history_routine, out_history_routine,
    422             registration_routine, revenue_routine, transfer_routine,
    423         },
    424         server::TestServer,
    425         tasks,
    426     };
    427 
    428     use crate::{
    429         api::ServerState,
    430         db::{TxOut, TxOutKind, sync_out, test::rand_tx_id},
    431         payto::FullBtcPayto,
    432     };
    433 
    434     pub static EXCHANGE: LazyLock<FullBtcPayto> = LazyLock::new(|| {
    435         FullBtcPayto::from_str(
    436             "payto://bitcoin/1FfmbHfnpaZjKFvyi1okTjJJusN455paPH?receiver-name=Exchange",
    437         )
    438         .unwrap()
    439     });
    440 
    441     pub static CLIENT: LazyLock<FullBtcPayto> = LazyLock::new(|| {
    442         FullBtcPayto::from_str(
    443             "payto://bitcoin/1FfmbHfnpaZjKFvyi1okTjJJusN455paPH?receiver-name=Anonymous",
    444         )
    445         .unwrap()
    446     });
    447 
    448     pub static UNKNOWN: LazyLock<FullBtcPayto> = LazyLock::new(|| {
    449         FullBtcPayto::from_str(
    450             "payto://bitcoin/1Q2TWHE3GMdB6BZKafqwxXtWAWgFt5Jvm3?receiver-name=Unknown",
    451         )
    452         .unwrap()
    453     });
    454 
    455     async fn setup(db: &PgPool) -> Router {
    456         let api = ServerState::start(
    457             db.clone(),
    458             EXCHANGE.clone(),
    459             Currency::from_str("BTC").unwrap(),
    460         )
    461         .await;
    462         Router::new()
    463             .wire_gateway(api.clone(), AuthMethod::None)
    464             .prepared_transfer(api.clone())
    465             .revenue(api.clone(), AuthMethod::None)
    466             .observability(api, AuthMethod::None)
    467             .finalize()
    468     }
    469 
    470     #[db_test]
    471     async fn config(db: PgPool) {
    472         let server = setup(&db).await;
    473         server
    474             .get("/taler-wire-gateway/config")
    475             .await
    476             .assert_ok_json::<WireConfig>();
    477         server
    478             .get("/taler-revenue/config")
    479             .await
    480             .assert_ok_json::<RevenueConfig>();
    481         server
    482             .get("/taler-prepared-transfer/config")
    483             .await
    484             .assert_ok_json::<PreparedTransferConfig>();
    485         server
    486             .get("/taler-observability/config")
    487             .await
    488             .assert_ok_json::<Config>();
    489         server.get("/taler-observability/metrics").await.assert_ok();
    490     }
    491 
    492     #[db_test]
    493     async fn transfer(db: PgPool) {
    494         let server = setup(&db).await;
    495         transfer_routine(
    496             &server.prefix("/taler-wire-gateway"),
    497             TransferState::pending,
    498             &CLIENT.as_uri(),
    499         )
    500         .await;
    501     }
    502 
    503     #[db_test]
    504     async fn outgoing_history(db: PgPool) {
    505         let server = setup(&db).await;
    506         out_history_routine(
    507             &server.prefix("/taler-wire-gateway"),
    508             tasks!({
    509                 let sub = &OutgoingSubject::rand();
    510                 sync_out(
    511                     &db,
    512                     &TxOut {
    513                         id: rand_tx_id(),
    514                         replaces_txid: None,
    515                         amount: amount("BTC:10"),
    516                         credit_acc: &EXCHANGE.0,
    517                         block_time: Timestamp::now(),
    518                     },
    519                     &TxOutKind::Talerable {
    520                         wtid: &sub.wtid,
    521                         url: &sub.exchange_base_url,
    522                         metadata: sub.metadata.as_deref(),
    523                     },
    524                     true,
    525                 )
    526                 .await
    527                 .unwrap();
    528             }),
    529             tasks!(),
    530         )
    531         .await;
    532     }
    533 
    534     #[db_test]
    535     async fn incoming_history(db: PgPool) {
    536         let server = setup(&db).await;
    537         in_history_routine(
    538             &server.prefix("/taler-wire-gateway"),
    539             &server.prefix("/taler-prepared-transfer"),
    540             &CLIENT.as_uri(),
    541             &EXCHANGE.as_uri(),
    542             tasks!(),
    543             tasks!(),
    544         )
    545         .await;
    546     }
    547 
    548     #[db_test]
    549     async fn admin_add_incoming(db: PgPool) {
    550         let server = setup(&db).await;
    551         admin_add_incoming_routine(
    552             &server.prefix("/taler-wire-gateway"),
    553             &server.prefix("/taler-prepared-transfer"),
    554             &CLIENT.as_uri(),
    555             &EXCHANGE.as_uri(),
    556         )
    557         .await;
    558     }
    559 
    560     #[db_test]
    561     async fn revenue(db: PgPool) {
    562         let server = setup(&db).await;
    563         revenue_routine(
    564             &server.prefix("/taler-wire-gateway"),
    565             &server.prefix("/taler-revenue"),
    566             &EXCHANGE.as_uri(),
    567             tasks!(),
    568             tasks!(),
    569         )
    570         .await;
    571     }
    572 
    573     async fn check_in(pool: &PgPool) -> Vec<Status> {
    574         sqlx::query(
    575             "
    576             SELECT pending_recurrent_in.authorization_pub IS NOT NULL, bounced.reason IS NOT NULL, type, metadata
    577             FROM tx_in
    578                 LEFT JOIN taler_in USING (tx_in_id)
    579                 LEFT JOIN pending_recurrent_in USING (tx_in_id)
    580                 LEFT JOIN bounced USING (tx_in_id)
    581             ORDER BY tx_in.tx_in_id
    582         ",
    583         )
    584         .try_map(|r: PgRow| {
    585             Ok(
    586                 if r.try_get_flag(0)? {
    587                     Status::Pending
    588                 } else if r.try_get_flag(1)? {
    589                     Status::Bounced
    590                 } else {
    591                     match r.try_get(2)? {
    592                         None => Status::Simple,
    593                         Some(IncomingType::reserve) => Status::Reserve(r.try_get(3)?),
    594                         Some(IncomingType::kyc) => Status::Kyc(r.try_get(3)?),
    595                         Some(e) => unreachable!("{e:?}")
    596                     }
    597                 }
    598             )
    599         })
    600         .fetch_all(pool)
    601         .await
    602         .unwrap()
    603     }
    604 
    605     #[db_test]
    606     async fn registration(db: PgPool) {
    607         let server = setup(&db).await;
    608         registration_routine(
    609             &server.prefix("/taler-wire-gateway"),
    610             &server.prefix("/taler-prepared-transfer"),
    611             &CLIENT.as_uri(),
    612             &EXCHANGE.as_uri(),
    613             &UNKNOWN.as_uri(),
    614             || check_in(&db),
    615         )
    616         .await;
    617     }
    618 }