Skip to content

Commit 1fbcf98

Browse files
committed
Add async callback traits and bridge methods
Introduce async alternatives for the five receiver validation callbacks (CanBroadcast, IsScriptOwned, IsOutputKnown, ProcessPsbt, TransactionExists) behind the `async_callbacks` feature flag. Languages like Dart are inherently async and cannot block inside a sync callback. The bridge methods accept async callback traits and use `tokio::task::block_in_place` + `Handle::current().block_on()` to call them from within the core library's sync closure API, avoiding breaking changes to the core.
1 parent 59cadf7 commit 1fbcf98

2 files changed

Lines changed: 167 additions & 0 deletions

File tree

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
//! Async callback wrappers for receiver checklist.
2+
//!
3+
//! The core payjoin library takes synchronous closures for receiver
4+
//! validation callbacks (broadcast checking, script ownership, etc.).
5+
//! Languages like Dart are inherently async and cannot block inside a
6+
//! sync callback to perform wallet or network operations.
7+
//!
8+
//! This module provides async trait alternatives for each callback.
9+
//! The bridge methods accept an async callback and use
10+
//! `block_in_place` + `Handle::current().block_on()` to drive the
11+
//! future from within the core's sync closure.
12+
//!
13+
//! This is different from the `save_async` / persistence path. The
14+
//! core library already exposes first-class async persistence via
15+
//! `AsyncSessionPersister`, so `save_async` can be a true `async fn`
16+
//! that awaits the persister directly—no bridging needed. Validation
17+
//! callbacks, on the other hand, are consumed by core as `FnMut`
18+
//! closures with no async variant, so we must bridge here in the FFI
19+
//! layer.
20+
21+
use std::str::FromStr;
22+
use std::sync::{Arc, RwLock};
23+
24+
use payjoin::bitcoin::psbt::Psbt;
25+
26+
use super::{
27+
MaybeInputsOwned, MaybeInputsOwnedTransition, MaybeInputsSeen, MaybeInputsSeenTransition,
28+
Monitor, MonitorTransition, OutputsUnknown, OutputsUnknownTransition, PlainOutPoint,
29+
ProvisionalProposal, ProvisionalProposalTransition, UncheckedOriginalPayload,
30+
UncheckedOriginalPayloadTransition,
31+
};
32+
use crate::error::{FfiValidationError, ForeignError, ImplementationError};
33+
use crate::validation::validate_fee_rate_sat_per_kwu_opt;
34+
35+
fn block_on_async<F: std::future::Future>(f: F) -> F::Output {
36+
tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(f))
37+
}
38+
39+
#[uniffi::export(with_foreign)]
40+
#[async_trait::async_trait]
41+
pub trait CanBroadcastAsync: Send + Sync {
42+
async fn callback(&self, tx: Vec<u8>) -> Result<bool, ForeignError>;
43+
}
44+
45+
#[uniffi::export(with_foreign)]
46+
#[async_trait::async_trait]
47+
pub trait IsScriptOwnedAsync: Send + Sync {
48+
async fn callback(&self, script: Vec<u8>) -> Result<bool, ForeignError>;
49+
}
50+
51+
#[uniffi::export(with_foreign)]
52+
#[async_trait::async_trait]
53+
pub trait IsOutputKnownAsync: Send + Sync {
54+
async fn callback(&self, outpoint: PlainOutPoint) -> Result<bool, ForeignError>;
55+
}
56+
57+
#[uniffi::export(with_foreign)]
58+
#[async_trait::async_trait]
59+
pub trait ProcessPsbtAsync: Send + Sync {
60+
async fn callback(&self, psbt: String) -> Result<String, ForeignError>;
61+
}
62+
63+
#[uniffi::export(with_foreign)]
64+
#[async_trait::async_trait]
65+
pub trait TransactionExistsAsync: Send + Sync {
66+
async fn callback(&self, txid: String) -> Result<Option<Vec<u8>>, ForeignError>;
67+
}
68+
69+
#[uniffi::export]
70+
impl UncheckedOriginalPayload {
71+
pub fn check_broadcast_suitability_async(
72+
&self,
73+
min_fee_rate: Option<u64>,
74+
can_broadcast: Arc<dyn CanBroadcastAsync>,
75+
) -> Result<UncheckedOriginalPayloadTransition, FfiValidationError> {
76+
let min_fee_rate = validate_fee_rate_sat_per_kwu_opt(min_fee_rate)?;
77+
Ok(UncheckedOriginalPayloadTransition(Arc::new(RwLock::new(Some(
78+
self.0.clone().check_broadcast_suitability(min_fee_rate, |transaction| {
79+
block_on_async(
80+
can_broadcast
81+
.callback(payjoin::bitcoin::consensus::encode::serialize(transaction)),
82+
)
83+
.map_err(|e| ImplementationError::new(e).into())
84+
}),
85+
)))))
86+
}
87+
}
88+
89+
#[uniffi::export]
90+
impl MaybeInputsOwned {
91+
pub fn check_inputs_not_owned_async(
92+
&self,
93+
is_owned: Arc<dyn IsScriptOwnedAsync>,
94+
) -> MaybeInputsOwnedTransition {
95+
MaybeInputsOwnedTransition(Arc::new(RwLock::new(Some(
96+
self.0.clone().check_inputs_not_owned(&mut |input| {
97+
block_on_async(is_owned.callback(input.to_bytes()))
98+
.map_err(|e| ImplementationError::new(e).into())
99+
}),
100+
))))
101+
}
102+
}
103+
104+
#[uniffi::export]
105+
impl MaybeInputsSeen {
106+
pub fn check_no_inputs_seen_before_async(
107+
&self,
108+
is_known: Arc<dyn IsOutputKnownAsync>,
109+
) -> MaybeInputsSeenTransition {
110+
MaybeInputsSeenTransition(Arc::new(RwLock::new(Some(
111+
self.0.clone().check_no_inputs_seen_before(&mut |outpoint| {
112+
block_on_async(is_known.callback(PlainOutPoint::from(*outpoint)))
113+
.map_err(|e| ImplementationError::new(e).into())
114+
}),
115+
))))
116+
}
117+
}
118+
119+
#[uniffi::export]
120+
impl OutputsUnknown {
121+
pub fn identify_receiver_outputs_async(
122+
&self,
123+
is_receiver_output: Arc<dyn IsScriptOwnedAsync>,
124+
) -> OutputsUnknownTransition {
125+
OutputsUnknownTransition(Arc::new(RwLock::new(Some(
126+
self.0.clone().identify_receiver_outputs(&mut |input| {
127+
block_on_async(is_receiver_output.callback(input.to_bytes()))
128+
.map_err(|e| ImplementationError::new(e).into())
129+
}),
130+
))))
131+
}
132+
}
133+
134+
#[uniffi::export]
135+
impl ProvisionalProposal {
136+
pub fn finalize_proposal_async(
137+
&self,
138+
process_psbt: Arc<dyn ProcessPsbtAsync>,
139+
) -> ProvisionalProposalTransition {
140+
ProvisionalProposalTransition(Arc::new(RwLock::new(Some(
141+
self.0.clone().finalize_proposal(|pre_processed| {
142+
let psbt = block_on_async(process_psbt.callback(pre_processed.to_string()))
143+
.map_err(ImplementationError::new)?;
144+
Ok(Psbt::from_str(&psbt).map_err(ImplementationError::new)?)
145+
}),
146+
))))
147+
}
148+
}
149+
150+
#[uniffi::export]
151+
impl Monitor {
152+
pub fn monitor_async(
153+
&self,
154+
transaction_exists: Arc<dyn TransactionExistsAsync>,
155+
) -> MonitorTransition {
156+
MonitorTransition(Arc::new(RwLock::new(Some(self.0.clone().check_payment(|txid| {
157+
block_on_async(transaction_exists.callback(txid.to_string()))
158+
.and_then(|buf| buf.map(super::try_deserialize_tx).transpose())
159+
.map_err(|e| ImplementationError::new(e).into())
160+
})))))
161+
}
162+
}

payjoin-ffi/src/receive/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ use crate::{ClientResponse, OutputSubstitution, Request};
2525

2626
pub mod error;
2727

28+
#[cfg(feature = "async_callbacks")]
29+
mod async_callbacks;
30+
#[cfg(feature = "async_callbacks")]
31+
pub use async_callbacks::*;
32+
2833
macro_rules! impl_save_for_transition {
2934
($ty:ident, $next_state:ident) => {
3035
#[uniffi::export]

0 commit comments

Comments
 (0)