1
1
use crate :: get_config;
2
2
use 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
+ } ;
4
8
use bitcoin:: { Network , secp256k1:: Scalar } ;
5
9
use log:: debug;
6
10
use serde:: Deserialize ;
7
11
use thiserror:: Error ;
12
+ use tokio:: alias:: try_join;
13
+ use tokio_with_wasm as tokio;
8
14
9
15
/// Generic result type
10
16
pub type Result < T > = std:: result:: Result < T , super :: Error > ;
@@ -27,6 +33,10 @@ pub enum Error {
27
33
/// all errors originating from dealing with private secp256k1 keys
28
34
#[ error( "External Bitcoin Private Key error: {0}" ) ]
29
35
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 ) ,
30
40
}
31
41
32
42
#[ cfg( test) ]
@@ -38,16 +48,17 @@ use mockall::automock;
38
48
pub trait BitcoinClientApi : ServiceTraitBounds {
39
49
async fn get_address_info ( & self , address : & str ) -> Result < AddressInfo > ;
40
50
41
- #[ allow( dead_code) ]
42
51
async fn get_transactions ( & self , address : & str ) -> Result < Transactions > ;
43
52
44
- #[ allow( dead_code) ]
45
53
async fn get_last_block_height ( & self ) -> Result < u64 > ;
46
54
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 > ;
51
62
52
63
fn get_address_to_pay (
53
64
& self ,
@@ -163,32 +174,17 @@ impl BitcoinClientApi for BitcoinClient {
163
174
Ok ( height)
164
175
}
165
176
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)
192
188
}
193
189
194
190
fn get_address_to_pay (
@@ -243,30 +239,218 @@ impl BitcoinClientApi for BitcoinClient {
243
239
}
244
240
}
245
241
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
+
246
326
/// Fields documented at https://github.com/Blockstream/esplora/blob/master/API.md#addresses
247
- #[ derive( Deserialize , Debug ) ]
327
+ #[ derive( Deserialize , Debug , Clone ) ]
248
328
pub struct AddressInfo {
249
329
pub chain_stats : Stats ,
250
330
pub mempool_stats : Stats ,
251
331
}
252
332
253
- #[ derive( Deserialize , Debug ) ]
333
+ #[ derive( Deserialize , Debug , Clone ) ]
254
334
pub struct Stats {
255
335
pub funded_txo_sum : u64 ,
256
336
pub spent_txo_sum : u64 ,
257
337
}
258
338
259
- pub type Transactions = Vec < Txid > ;
339
+ pub type Transactions = Vec < Tx > ;
260
340
261
341
/// Available fields documented at https://github.com/Blockstream/esplora/blob/master/API.md#transactions
262
342
#[ derive( Deserialize , Debug , Clone ) ]
263
- # [ allow ( dead_code ) ]
264
- pub struct Txid {
343
+ pub struct Tx {
344
+ pub txid : String ,
265
345
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 ,
266
353
}
267
354
268
- #[ allow( dead_code) ]
269
355
#[ derive( Deserialize , Debug , Clone ) ]
270
356
pub 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
+ }
272
456
}
0 commit comments