Skip to content

Commit dcea21e

Browse files
authored
add confirmations config flag, rewrite payment handling, remove gloo,rewrite payment, payment state in db, adapt bill data (#603)
1 parent f901161 commit dcea21e

File tree

37 files changed

+1391
-592
lines changed

37 files changed

+1391
-592
lines changed

.github/workflows/wasm_release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ jobs:
4949
- name: Install Rust toolchain
5050
uses: actions-rust-lang/setup-rust-toolchain@v1
5151
with:
52-
toolchain: 1.87.0
52+
toolchain: 1.89.0
5353

5454
- name: Install wasm-pack
5555
run: |

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1+
# 0.4.4
2+
3+
* Add `num_confirmations_for_payment` config flag and a `payment_config` part of the api config, to configure the amount of confirmations needed until an on-chain payment is considered `paid`
4+
* Rewrite payment logic to iterate transactions and calculate payment state based on the first transaction that covers the amount
5+
* We now are also able to differentiate between a payment not being sent, being in the mem pool, being paid and unconfirmed and paid and confirmed
6+
* Add payment state for sell, recourse and bill payments to DB (breaking DB change - reset IndexedDB)
7+
* Restructure `BillCurrentWaitingState` to remove duplication (breaking API change - check `index.d.ts`)
8+
* Add info for if a payment is in the mempool with it's transaction id, as well as how many confirmations it has, in the bill data (breaking DB change - reset IndexedDB)
9+
* Removed the `gloo` dependency, since it's going to be archived
10+
* Add chain propagation for company chains and identity chain
11+
112
# 0.4.3
213

314
* Add endpoints to fetch files as base64 for identity, contacts, companies and bills

Cargo.toml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[workspace.package]
2-
version = "0.4.3"
2+
version = "0.4.4"
33
edition = "2024"
44
license = "MIT"
55

@@ -30,11 +30,13 @@ chrono = { version = "0.4", default-features = false, features = [
3030
"serde",
3131
"clock",
3232
] }
33-
tokio = { version = "1.43", default-features = false, features = [
33+
tokio = { version = "1.46", default-features = false, features = [
3434
"rt",
3535
"sync",
36+
"macros",
37+
"time",
3638
] }
37-
tokio_with_wasm = { version = "0.8", features = ["rt", "sync"] }
39+
tokio_with_wasm = { version = "0.8", features = ["rt", "sync", "macros", "time"] }
3840
async-trait = "0.1"
3941
serde_json = "1"
4042
serde = { version = "1", default-features = false, features = ["derive"] }
@@ -57,6 +59,7 @@ miniscript = { version = "12.3" }
5759
base64 = "0.22"
5860
mockall = "0.13.1"
5961
bcr-ebill-core = { path = "./crates/bcr-ebill-core" }
62+
bcr-ebill-api = { path = "./crates/bcr-ebill-api" }
6063
bcr-ebill-persistence = { path = "./crates/bcr-ebill-persistence" }
6164
bcr-ebill-transport = { path = "./crates/bcr-ebill-transport" }
6265
surrealdb = { version = "2.3", default-features = false }

crates/bcr-ebill-api/src/external/bitcoin.rs

Lines changed: 224 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
use crate::get_config;
22
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+
};
48
use bitcoin::{Network, secp256k1::Scalar};
59
use log::debug;
610
use serde::Deserialize;
711
use thiserror::Error;
12+
use tokio::alias::try_join;
13+
use tokio_with_wasm as tokio;
814

915
/// Generic result type
1016
pub 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;
3848
pub 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)]
248328
pub struct AddressInfo {
249329
pub chain_stats: Stats,
250330
pub mempool_stats: Stats,
251331
}
252332

253-
#[derive(Deserialize, Debug)]
333+
#[derive(Deserialize, Debug, Clone)]
254334
pub 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)]
270356
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+
}
272456
}

crates/bcr-ebill-api/src/lib.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ pub struct Config {
2929
pub data_dir: String,
3030
pub nostr_config: NostrConfig,
3131
pub mint_config: MintConfig,
32+
pub payment_config: PaymentConfig,
3233
}
3334

3435
static CONFIG: OnceLock<Config> = OnceLock::new();
@@ -45,6 +46,13 @@ impl Config {
4546
}
4647
}
4748

49+
/// Payment specific configuration
50+
#[derive(Debug, Clone, Default)]
51+
pub struct PaymentConfig {
52+
/// Amount of confirmations until we consider an on-chain payment as paid
53+
pub num_confirmations_for_payment: usize,
54+
}
55+
4856
/// Nostr specific configuration
4957
#[derive(Debug, Clone, Default)]
5058
pub struct NostrConfig {

0 commit comments

Comments
 (0)