11use crate :: get_config;
22use async_trait:: async_trait;
3- use bcr_ebill_core:: { PublicKey , ServiceTraitBounds , util} ;
3+ use bcr_ebill_core:: {
4+ PublicKey , ServiceTraitBounds ,
5+ bill:: { InMempoolData , PaidData , PaymentState } ,
6+ util,
7+ } ;
48use bitcoin:: { Network , secp256k1:: Scalar } ;
59use log:: debug;
610use serde:: Deserialize ;
711use thiserror:: Error ;
12+ use tokio:: alias:: try_join;
13+ use tokio_with_wasm as tokio;
814
915/// Generic result type
1016pub type Result < T > = std:: result:: Result < T , super :: Error > ;
@@ -27,6 +33,10 @@ pub enum Error {
2733 /// all errors originating from dealing with private secp256k1 keys
2834 #[ error( "External Bitcoin Private Key error: {0}" ) ]
2935 PrivateKey ( String ) ,
36+
37+ /// all errors originating from dealing with invalid data from the API
38+ #[ error( "Got invalid data from the API" ) ]
39+ InvalidData ( String ) ,
3040}
3141
3242#[ cfg( test) ]
@@ -38,16 +48,17 @@ use mockall::automock;
3848pub trait BitcoinClientApi : ServiceTraitBounds {
3949 async fn get_address_info ( & self , address : & str ) -> Result < AddressInfo > ;
4050
41- #[ allow( dead_code) ]
4251 async fn get_transactions ( & self , address : & str ) -> Result < Transactions > ;
4352
44- #[ allow( dead_code) ]
4553 async fn get_last_block_height ( & self ) -> Result < u64 > ;
4654
47- #[ allow( dead_code) ]
48- fn get_first_transaction ( & self , transactions : & Transactions ) -> Option < Txid > ;
49-
50- async fn check_if_paid ( & self , address : & str , sum : u64 ) -> Result < ( bool , u64 ) > ;
55+ /// Checks payment by iterating over the transactions on the address in chronological order, until
56+ /// the target amount is filled, returning the respective payment status
57+ async fn check_payment_for_address (
58+ & self ,
59+ address : & str ,
60+ target_amount : u64 ,
61+ ) -> Result < PaymentState > ;
5162
5263 fn get_address_to_pay (
5364 & self ,
@@ -163,32 +174,17 @@ impl BitcoinClientApi for BitcoinClient {
163174 Ok ( height)
164175 }
165176
166- fn get_first_transaction ( & self , transactions : & Transactions ) -> Option < Txid > {
167- transactions. last ( ) . cloned ( )
168- }
169-
170- async fn check_if_paid ( & self , address : & str , sum : u64 ) -> Result < ( bool , u64 ) > {
171- debug ! ( "checking if btc address {address} is paid {sum}" ) ;
172- let info_about_address = self . get_address_info ( address) . await ?;
173-
174- // the received and spent sum need to add up to the sum
175- let received_sum = info_about_address. chain_stats . funded_txo_sum ; // balance on address
176- let spent_sum = info_about_address. chain_stats . spent_txo_sum ; // money already spent
177-
178- // Tx is still in mem_pool (0 if it's already on the chain)
179- let received_sum_mempool = info_about_address. mempool_stats . funded_txo_sum ;
180- let spent_sum_mempool = info_about_address. mempool_stats . spent_txo_sum ;
181-
182- let sum_chain_mempool: u64 =
183- received_sum + spent_sum + received_sum_mempool + spent_sum_mempool;
184- if sum_chain_mempool >= sum {
185- // if the received sum is higher than the sum we're looking
186- // to get, it's OK
187- Ok ( ( true , received_sum + spent_sum) ) // only return sum received on chain, so we don't
188- // return a sum if it's in mempool
189- } else {
190- Ok ( ( false , 0 ) )
191- }
177+ async fn check_payment_for_address (
178+ & self ,
179+ address : & str ,
180+ target_amount : u64 ,
181+ ) -> Result < PaymentState > {
182+ debug ! ( "checking if btc address {address} is paid {target_amount}" ) ;
183+ // in parallel, get current chain height, transactions and address info for the given address
184+ let ( chain_block_height, txs) =
185+ try_join ! ( self . get_last_block_height( ) , self . get_transactions( address) , ) ?;
186+
187+ payment_state_from_transactions ( chain_block_height, txs, address, target_amount)
192188 }
193189
194190 fn get_address_to_pay (
@@ -243,30 +239,218 @@ impl BitcoinClientApi for BitcoinClient {
243239 }
244240}
245241
242+ fn payment_state_from_transactions (
243+ chain_block_height : u64 ,
244+ txs : Transactions ,
245+ address : & str ,
246+ target_amount : u64 ,
247+ ) -> Result < PaymentState > {
248+ // no transactions - no payment
249+ if txs. is_empty ( ) {
250+ return Ok ( PaymentState :: NotFound ) ;
251+ }
252+
253+ let mut total = 0 ;
254+ let mut tx_filled = None ;
255+
256+ // sort from back to front (chronologically)
257+ for tx in txs. iter ( ) . rev ( ) {
258+ for vout in tx. vout . iter ( ) {
259+ // sum up outputs towards the address to check
260+ if vout. scriptpubkey_address == * address {
261+ total += vout. value ;
262+ }
263+ }
264+ // if the current transaction covers the amount, we save it and break
265+ if total >= target_amount {
266+ tx_filled = Some ( tx. to_owned ( ) ) ;
267+ break ;
268+ }
269+ }
270+
271+ match tx_filled {
272+ Some ( tx) => {
273+ // in mem pool
274+ if !tx. status . confirmed {
275+ debug ! ( "payment for {address} is in mem pool {}" , tx. txid) ;
276+ Ok ( PaymentState :: InMempool ( InMempoolData { tx_id : tx. txid } ) )
277+ } else {
278+ match (
279+ tx. status . block_height ,
280+ tx. status . block_time ,
281+ tx. status . block_hash ,
282+ ) {
283+ ( Some ( block_height) , Some ( block_time) , Some ( block_hash) ) => {
284+ let confirmations = chain_block_height - block_height + 1 ;
285+ let paid_data = PaidData {
286+ block_time,
287+ block_hash,
288+ confirmations,
289+ tx_id : tx. txid ,
290+ } ;
291+ if confirmations
292+ >= get_config ( ) . payment_config . num_confirmations_for_payment as u64
293+ {
294+ // paid and confirmed
295+ debug ! (
296+ "payment for {address} is paid and confirmed with {confirmations} confirmations"
297+ ) ;
298+ Ok ( PaymentState :: PaidConfirmed ( paid_data) )
299+ } else {
300+ // paid but not enough confirmations yet
301+ debug ! (
302+ "payment for {address} is paid and unconfirmed with {confirmations} confirmations"
303+ ) ;
304+ Ok ( PaymentState :: PaidUnconfirmed ( paid_data) )
305+ }
306+ }
307+ _ => {
308+ log:: error!(
309+ "Invalid data when checking payment for {address} - confirmed tx, but no metadata"
310+ ) ;
311+ Err ( Error :: InvalidData ( format ! ( "Invalid data when checking payment for {address} - confirmed tx, but no metadata" ) ) . into ( ) )
312+ }
313+ }
314+ }
315+ }
316+ None => {
317+ // not enough funds to cover amount
318+ debug ! (
319+ "Not enough funds to cover {target_amount} yet when checking payment for {address}: {total}"
320+ ) ;
321+ Ok ( PaymentState :: NotFound )
322+ }
323+ }
324+ }
325+
246326/// Fields documented at https://github.com/Blockstream/esplora/blob/master/API.md#addresses
247- #[ derive( Deserialize , Debug ) ]
327+ #[ derive( Deserialize , Debug , Clone ) ]
248328pub struct AddressInfo {
249329 pub chain_stats : Stats ,
250330 pub mempool_stats : Stats ,
251331}
252332
253- #[ derive( Deserialize , Debug ) ]
333+ #[ derive( Deserialize , Debug , Clone ) ]
254334pub struct Stats {
255335 pub funded_txo_sum : u64 ,
256336 pub spent_txo_sum : u64 ,
257337}
258338
259- pub type Transactions = Vec < Txid > ;
339+ pub type Transactions = Vec < Tx > ;
260340
261341/// Available fields documented at https://github.com/Blockstream/esplora/blob/master/API.md#transactions
262342#[ derive( Deserialize , Debug , Clone ) ]
263- # [ allow ( dead_code ) ]
264- pub struct Txid {
343+ pub struct Tx {
344+ pub txid : String ,
265345 pub status : Status ,
346+ pub vout : Vec < Vout > ,
347+ }
348+
349+ #[ derive( Deserialize , Debug , Clone ) ]
350+ pub struct Vout {
351+ pub value : u64 ,
352+ pub scriptpubkey_address : String ,
266353}
267354
268- #[ allow( dead_code) ]
269355#[ derive( Deserialize , Debug , Clone ) ]
270356pub struct Status {
271- pub block_height : u64 ,
357+ // Height of the block the tx is in
358+ pub block_height : Option < u64 > ,
359+ // Unix Timestamp
360+ pub block_time : Option < u64 > ,
361+ // Hash of the block the tx is in
362+ pub block_hash : Option < String > ,
363+ // Whether it's in the mempool (false), or in a block (true)
364+ pub confirmed : bool ,
365+ }
366+
367+ #[ cfg( test) ]
368+ pub mod tests {
369+ use crate :: tests:: tests:: init_test_cfg;
370+
371+ use super :: * ;
372+
373+ #[ test]
374+ fn test_payment_state_from_transactions ( ) {
375+ init_test_cfg ( ) ;
376+ let test_height = 4578915 ;
377+ let test_addr = "n4n9CNeCkgtEs8wukKEvWC78eEqK4A3E6d" ;
378+ let test_amount = 500 ;
379+ let mut test_tx = Tx {
380+ txid : "" . into ( ) ,
381+ status : Status {
382+ block_height : Some ( test_height - 7 ) ,
383+ block_time : Some ( 1731593927 ) ,
384+ block_hash : Some (
385+ "000000000061ad7b0d52af77e5a9dbcdc421bf00e93992259f16b2cf2693c4b1" . into ( ) ,
386+ ) ,
387+ confirmed : true ,
388+ } ,
389+ vout : vec ! [ Vout {
390+ value: 500 ,
391+ scriptpubkey_address: test_addr. to_owned( ) ,
392+ } ] ,
393+ } ;
394+
395+ let res_empty =
396+ payment_state_from_transactions ( test_height, vec ! [ ] , test_addr, test_amount) ;
397+ assert ! ( matches!( res_empty, Ok ( PaymentState :: NotFound ) ) ) ;
398+
399+ let res_paid_confirmed = payment_state_from_transactions (
400+ test_height,
401+ vec ! [ test_tx. clone( ) ] ,
402+ test_addr,
403+ test_amount,
404+ ) ;
405+ assert ! ( matches!(
406+ res_paid_confirmed,
407+ Ok ( PaymentState :: PaidConfirmed ( ..) )
408+ ) ) ;
409+
410+ test_tx. status . block_height = Some ( test_height - 1 ) ; // only 2 confirmations
411+ let res_paid_unconfirmed = payment_state_from_transactions (
412+ test_height,
413+ vec ! [ test_tx. clone( ) ] ,
414+ test_addr,
415+ test_amount,
416+ ) ;
417+ assert ! ( matches!(
418+ res_paid_unconfirmed,
419+ Ok ( PaymentState :: PaidUnconfirmed ( ..) )
420+ ) ) ;
421+
422+ test_tx. status . block_height = None ;
423+ test_tx. status . block_time = None ;
424+ test_tx. status . block_hash = None ;
425+ let res_paid_confirmed_no_data = payment_state_from_transactions (
426+ test_height,
427+ vec ! [ test_tx. clone( ) ] ,
428+ test_addr,
429+ test_amount,
430+ ) ;
431+ assert ! ( matches!(
432+ res_paid_confirmed_no_data,
433+ Err ( super :: super :: Error :: ExternalBitcoinApi ( Error :: InvalidData (
434+ ..
435+ ) ) )
436+ ) ) ;
437+
438+ test_tx. status . confirmed = false ;
439+ let res_in_mem_pool = payment_state_from_transactions (
440+ test_height,
441+ vec ! [ test_tx. clone( ) ] ,
442+ test_addr,
443+ test_amount,
444+ ) ;
445+ assert ! ( matches!( res_in_mem_pool, Ok ( PaymentState :: InMempool ( ..) ) ) ) ;
446+
447+ test_tx. vout [ 0 ] . value = 200 ;
448+ let res_not_filled = payment_state_from_transactions (
449+ test_height,
450+ vec ! [ test_tx. clone( ) ] ,
451+ test_addr,
452+ test_amount,
453+ ) ;
454+ assert ! ( matches!( res_not_filled, Ok ( PaymentState :: NotFound ) ) ) ;
455+ }
272456}
0 commit comments