diff --git a/Cargo-minimal.lock b/Cargo-minimal.lock index 22fabf0a3..783db3931 100644 --- a/Cargo-minimal.lock +++ b/Cargo-minimal.lock @@ -2451,6 +2451,7 @@ dependencies = [ name = "payjoin-ffi" version = "0.24.0" dependencies = [ + "async-trait", "bdk", "bitcoin-ohttp", "getrandom 0.2.15", @@ -4055,7 +4056,7 @@ dependencies = [ [[package]] name = "uniffi-dart" version = "0.1.0" -source = "git+https://github.com/Uniffi-Dart/uniffi-dart.git?rev=5bdcc79#5bdcc790c3fc99845e7b4a61d7a4f6e1460896e9" +source = "git+https://github.com/Uniffi-Dart/uniffi-dart.git?rev=f830323#f830323646fb6fbca89f9798dcf425f339f166ca" dependencies = [ "anyhow", "camino", @@ -4125,7 +4126,7 @@ dependencies = [ [[package]] name = "uniffi_dart_macro" version = "0.1.0" -source = "git+https://github.com/Uniffi-Dart/uniffi-dart.git?rev=5bdcc79#5bdcc790c3fc99845e7b4a61d7a4f6e1460896e9" +source = "git+https://github.com/Uniffi-Dart/uniffi-dart.git?rev=f830323#f830323646fb6fbca89f9798dcf425f339f166ca" dependencies = [ "futures", "proc-macro2", diff --git a/Cargo-recent.lock b/Cargo-recent.lock index 22fabf0a3..783db3931 100644 --- a/Cargo-recent.lock +++ b/Cargo-recent.lock @@ -2451,6 +2451,7 @@ dependencies = [ name = "payjoin-ffi" version = "0.24.0" dependencies = [ + "async-trait", "bdk", "bitcoin-ohttp", "getrandom 0.2.15", @@ -4055,7 +4056,7 @@ dependencies = [ [[package]] name = "uniffi-dart" version = "0.1.0" -source = "git+https://github.com/Uniffi-Dart/uniffi-dart.git?rev=5bdcc79#5bdcc790c3fc99845e7b4a61d7a4f6e1460896e9" +source = "git+https://github.com/Uniffi-Dart/uniffi-dart.git?rev=f830323#f830323646fb6fbca89f9798dcf425f339f166ca" dependencies = [ "anyhow", "camino", @@ -4125,7 +4126,7 @@ dependencies = [ [[package]] name = "uniffi_dart_macro" version = "0.1.0" -source = "git+https://github.com/Uniffi-Dart/uniffi-dart.git?rev=5bdcc79#5bdcc790c3fc99845e7b4a61d7a4f6e1460896e9" +source = "git+https://github.com/Uniffi-Dart/uniffi-dart.git?rev=f830323#f830323646fb6fbca89f9798dcf425f339f166ca" dependencies = [ "futures", "proc-macro2", diff --git a/payjoin-ffi/Cargo.toml b/payjoin-ffi/Cargo.toml index 8ac6c0bb2..44c898b8c 100644 --- a/payjoin-ffi/Cargo.toml +++ b/payjoin-ffi/Cargo.toml @@ -22,6 +22,7 @@ name = "uniffi-bindgen" path = "uniffi-bindgen.rs" [dependencies] +async-trait = "0.1" getrandom = "0.2" lazy_static = "1.5.0" ohttp = { package = "bitcoin-ohttp", version = "0.6.0" } @@ -32,7 +33,7 @@ serde_json = "1.0.142" thiserror = "2.0.14" tokio = { version = "1.47.1", features = ["full"], optional = true } uniffi = { version = "0.30.0", features = ["cli"] } -uniffi-dart = { git = "https://github.com/Uniffi-Dart/uniffi-dart.git", rev = "5bdcc79", optional = true } +uniffi-dart = { git = "https://github.com/Uniffi-Dart/uniffi-dart.git", rev = "f830323", optional = true } url = "2.5.4" # getrandom is ignored here because it's required by the wasm_js feature diff --git a/payjoin-ffi/dart/test/test_payjoin_unit_test.dart b/payjoin-ffi/dart/test/test_payjoin_unit_test.dart index 945330f7b..673f0a203 100644 --- a/payjoin-ffi/dart/test/test_payjoin_unit_test.dart +++ b/payjoin-ffi/dart/test/test_payjoin_unit_test.dart @@ -50,6 +50,54 @@ class InMemorySenderPersister implements payjoin.JsonSenderSessionPersister { } } +class InMemoryReceiverPersisterAsync + implements payjoin.JsonReceiverSessionPersisterAsync { + final String id; + final List events = []; + bool closed = false; + + InMemoryReceiverPersisterAsync(this.id); + + @override + Future save(String event) async { + events.add(event); + } + + @override + Future> load() async { + return events; + } + + @override + Future close() async { + closed = true; + } +} + +class InMemorySenderPersisterAsync + implements payjoin.JsonSenderSessionPersisterAsync { + final String id; + final List events = []; + bool closed = false; + + InMemorySenderPersisterAsync(this.id); + + @override + Future save(String event) async { + events.add(event); + } + + @override + Future> load() async { + return events; + } + + @override + Future close() async { + closed = true; + } +} + void main() { group('Test URIs', () { test('Test todo url encoded', () { @@ -153,4 +201,56 @@ void main() { ); }); }); + + group("Test Async Persistence", () { + test("Test receiver async persistence", () async { + var persister = InMemoryReceiverPersisterAsync("1"); + await payjoin.ReceiverBuilder( + "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", + "https://example.com", + payjoin.OhttpKeys.decode( + Uint8List.fromList( + hex.decode( + "01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003", + ), + ), + ), + ).build().saveAsync(persister); + final result = await payjoin.replayReceiverEventLogAsync(persister); + expect( + result, + isA(), + reason: "persistence should return a replay result", + ); + }); + + test("Test sender async persistence", () async { + var receiver_persister = InMemoryReceiverPersisterAsync("1"); + var receiver = await payjoin.ReceiverBuilder( + "2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK", + "https://example.com", + payjoin.OhttpKeys.decode( + Uint8List.fromList( + hex.decode( + "01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003", + ), + ), + ), + ).build().saveAsync(receiver_persister); + var uri = receiver.pjUri(); + + var sender_persister = InMemorySenderPersisterAsync("1"); + var psbt = + "cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA="; + final result = await payjoin.SenderBuilder( + psbt, + uri, + ).buildRecommended(1000).saveAsync(sender_persister); + expect( + result, + isA(), + reason: "persistence should return a reply key", + ); + }); + }); } diff --git a/payjoin-ffi/javascript/package-lock.json b/payjoin-ffi/javascript/package-lock.json index 8c287bac5..914d86a0c 100644 --- a/payjoin-ffi/javascript/package-lock.json +++ b/payjoin-ffi/javascript/package-lock.json @@ -588,7 +588,7 @@ }, "node_modules/uniffi-bindgen-react-native": { "version": "0.29.3-1", - "resolved": "git+ssh://git@github.com/spacebear21/uniffi-bindgen-react-native.git#723c0c77c45db1eb74e832a8814e4ac36d07fc28", + "resolved": "git+ssh://git@github.com/spacebear21/uniffi-bindgen-react-native.git#e2225d22d9e3246057abba59cc8ca24a038ba01c", "dev": true, "license": "MPL-2.0", "bin": { diff --git a/payjoin-ffi/javascript/test/unit.test.ts b/payjoin-ffi/javascript/test/unit.test.ts index d1f50ae69..f024934fc 100644 --- a/payjoin-ffi/javascript/test/unit.test.ts +++ b/payjoin-ffi/javascript/test/unit.test.ts @@ -54,6 +54,54 @@ class InMemorySenderPersister { } } +class InMemoryReceiverPersisterAsync { + id: number; + events: any[]; + closed: boolean; + + constructor(id: number) { + this.id = id; + this.events = []; + this.closed = false; + } + + async save(event: any): Promise { + this.events.push(event); + } + + async load(): Promise { + return this.events; + } + + async close(): Promise { + this.closed = true; + } +} + +class InMemorySenderPersisterAsync { + id: number; + events: any[]; + closed: boolean; + + constructor(id: number) { + this.id = id; + this.events = []; + this.closed = false; + } + + async save(event: any): Promise { + this.events.push(event); + } + + async load(): Promise { + return this.events; + } + + async close(): Promise { + this.closed = true; + } +} + describe("URI tests", () => { test("URL encoded payjoin parameter", () => { const uri = @@ -172,6 +220,76 @@ describe("Persistence tests", () => { }); }); +describe("Async Persistence tests", () => { + test("receiver async persistence", async () => { + const persister = new InMemoryReceiverPersisterAsync(1); + const address = "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4"; + const ohttpKeys = payjoin.OhttpKeys.decode( + new Uint8Array([ + 0x01, 0x00, 0x16, 0x04, 0xba, 0x48, 0xc4, 0x9c, 0x3d, 0x4a, + 0x92, 0xa3, 0xad, 0x00, 0xec, 0xc6, 0x3a, 0x02, 0x4d, 0xa1, + 0x0c, 0xed, 0x02, 0x18, 0x0c, 0x73, 0xec, 0x12, 0xd8, 0xa7, + 0xad, 0x2c, 0xc9, 0x1b, 0xb4, 0x83, 0x82, 0x4f, 0xe2, 0xbe, + 0xe8, 0xd2, 0x8b, 0xfe, 0x2e, 0xb2, 0xfc, 0x64, 0x53, 0xbc, + 0x4d, 0x31, 0xcd, 0x85, 0x1e, 0x8a, 0x65, 0x40, 0xe8, 0x6c, + 0x53, 0x82, 0xaf, 0x58, 0x8d, 0x37, 0x09, 0x57, 0x00, 0x04, + 0x00, 0x01, 0x00, 0x03, + ]).buffer, + ); + + const builder = new payjoin.ReceiverBuilder( + address, + "https://example.com", + ohttpKeys, + ); + await builder.build().saveAsync(persister); + + const result = await payjoin.replayReceiverEventLogAsync(persister); + const state = result.state(); + + assert.strictEqual( + state.tag, + "Initialized", + "State should be Initialized", + ); + }); + + test("sender async persistence", async () => { + const persister = new InMemoryReceiverPersisterAsync(1); + const address = "2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK"; + const ohttpKeys = payjoin.OhttpKeys.decode( + new Uint8Array([ + 0x01, 0x00, 0x16, 0x04, 0xba, 0x48, 0xc4, 0x9c, 0x3d, 0x4a, + 0x92, 0xa3, 0xad, 0x00, 0xec, 0xc6, 0x3a, 0x02, 0x4d, 0xa1, + 0x0c, 0xed, 0x02, 0x18, 0x0c, 0x73, 0xec, 0x12, 0xd8, 0xa7, + 0xad, 0x2c, 0xc9, 0x1b, 0xb4, 0x83, 0x82, 0x4f, 0xe2, 0xbe, + 0xe8, 0xd2, 0x8b, 0xfe, 0x2e, 0xb2, 0xfc, 0x64, 0x53, 0xbc, + 0x4d, 0x31, 0xcd, 0x85, 0x1e, 0x8a, 0x65, 0x40, 0xe8, 0x6c, + 0x53, 0x82, 0xaf, 0x58, 0x8d, 0x37, 0x09, 0x57, 0x00, 0x04, + 0x00, 0x01, 0x00, 0x03, + ]).buffer, + ); + + const receiver = await new payjoin.ReceiverBuilder( + address, + "https://example.com", + ohttpKeys, + ) + .build() + .saveAsync(persister); + const uri = receiver.pjUri(); + + const senderPersister = new InMemorySenderPersisterAsync(1); + const psbt = + "cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA="; + const withReplyKey = await new payjoin.SenderBuilder(psbt, uri) + .buildRecommended(BigInt(1000)) + .saveAsync(senderPersister); + + assert.ok(withReplyKey, "Sender should be created successfully"); + }); +}); + describe("Validation", () => { test("receiver builder rejects bad address", () => { assert.throws(() => { diff --git a/payjoin-ffi/python/test/test_payjoin_unit_test.py b/payjoin-ffi/python/test/test_payjoin_unit_test.py index f08803a84..1f8710197 100644 --- a/payjoin-ffi/python/test/test_payjoin_unit_test.py +++ b/payjoin-ffi/python/test/test_payjoin_unit_test.py @@ -48,22 +48,6 @@ def close(self): self.closed = True -class TestReceiverPersistence(unittest.TestCase): - def test_receiver_persistence(self): - persister = InMemoryReceiverPersister(1) - payjoin.ReceiverBuilder( - "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", - "https://example.com", - payjoin.OhttpKeys.decode( - bytes.fromhex( - "01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003" - ) - ), - ).build().save(persister) - result = payjoin.replay_receiver_event_log(persister) - self.assertTrue(result.state().is_INITIALIZED()) - - class InMemorySenderPersister(payjoin.JsonSenderSessionPersister): def __init__(self, id): self.id = id @@ -80,6 +64,54 @@ def close(self): self.closed = True +class InMemoryReceiverPersisterAsync(payjoin.JsonReceiverSessionPersisterAsync): + def __init__(self, id): + self.id = id + self.events = [] + self.closed = False + + async def save(self, event: str): + self.events.append(event) + + async def load(self): + return self.events + + async def close(self): + self.closed = True + + +class InMemorySenderPersisterAsync(payjoin.JsonSenderSessionPersisterAsync): + def __init__(self, id): + self.id = id + self.events = [] + self.closed = False + + async def save(self, event: str): + self.events.append(event) + + async def load(self): + return self.events + + async def close(self): + self.closed = True + + +class TestReceiverPersistence(unittest.TestCase): + def test_receiver_persistence(self): + persister = InMemoryReceiverPersister(1) + payjoin.ReceiverBuilder( + "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", + "https://example.com", + payjoin.OhttpKeys.decode( + bytes.fromhex( + "01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003" + ) + ), + ).build().save(persister) + result = payjoin.replay_receiver_event_log(persister) + self.assertTrue(result.state().is_INITIALIZED()) + + class TestSenderPersistence(unittest.TestCase): def test_sender_persistence(self): # Create a receiver to just get the pj uri @@ -106,5 +138,63 @@ def test_sender_persistence(self): ) +class TestReceiverAsyncPersistence(unittest.TestCase): + def test_receiver_async_persistence(self): + import asyncio + + async def run_test(): + persister = InMemoryReceiverPersisterAsync(1) + await ( + payjoin.ReceiverBuilder( + "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", + "https://example.com", + payjoin.OhttpKeys.decode( + bytes.fromhex( + "01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003" + ) + ), + ) + .build() + .save_async(persister) + ) + result = await payjoin.replay_receiver_event_log_async(persister) + self.assertTrue(result.state().is_INITIALIZED()) + + asyncio.run(run_test()) + + +class TestSenderAsyncPersistence(unittest.TestCase): + def test_sender_async_persistence(self): + import asyncio + + async def run_test(): + # Create a receiver to just get the pj uri + persister = InMemoryReceiverPersisterAsync(1) + receiver = await ( + payjoin.ReceiverBuilder( + "2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK", + "https://example.com", + payjoin.OhttpKeys.decode( + bytes.fromhex( + "01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003" + ) + ), + ) + .build() + .save_async(persister) + ) + uri = receiver.pj_uri() + + persister = InMemorySenderPersisterAsync(1) + psbt = "cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA=" + with_reply_key = await ( + payjoin.SenderBuilder(psbt, uri) + .build_recommended(1000) + .save_async(persister) + ) + + asyncio.run(run_test()) + + if __name__ == "__main__": unittest.main() diff --git a/payjoin-ffi/src/receive/mod.rs b/payjoin-ffi/src/receive/mod.rs index c3c091a9b..6d900c597 100644 --- a/payjoin-ffi/src/receive/mod.rs +++ b/payjoin-ffi/src/receive/mod.rs @@ -39,6 +39,24 @@ macro_rules! impl_save_for_transition { .map_err(|e| ReceiverPersistedError::from(ImplementationError::new(e)))?; Ok(res.into()) } + + pub async fn save_async( + &self, + persister: Arc, + ) -> Result<$next_state, ReceiverPersistedError> { + let adapter = AsyncCallbackPersisterAdapter::new(persister); + // Extract value while holding the lock, then drop the guard before await + let value = { + let mut inner = self.0.write().expect("Lock should not be poisoned"); + inner.take().expect("Already saved or moved") + }; + + let res = value + .save_async(&adapter) + .await + .map_err(|e| ReceiverPersistedError::from(ImplementationError::new(e)))?; + Ok(res.into()) + } } }; } @@ -152,6 +170,15 @@ pub fn replay_receiver_event_log( Ok(ReplayResult { state: state.into(), session_history: session_history.into() }) } +#[uniffi::export] +pub async fn replay_receiver_event_log_async( + persister: Arc, +) -> Result { + let adapter = AsyncCallbackPersisterAdapter::new(persister); + let (state, session_history) = payjoin::receive::v2::replay_event_log_async(&adapter).await?; + Ok(ReplayResult { state: state.into(), session_history: session_history.into() }) +} + /// Represents the status of a session that can be inferred from the information in the session /// event log. #[derive(uniffi::Object)] @@ -215,6 +242,20 @@ impl InitialReceiveTransition { let res = value.save(&adapter)?; Ok(res.into()) } + + pub async fn save_async( + &self, + persister: Arc, + ) -> Result { + let adapter = AsyncCallbackPersisterAdapter::new(persister); + let value = { + let mut inner = self.0.write().expect("Lock should not be poisoned"); + inner.take().expect("Already saved or moved") + }; + + let res = value.save_async(&adapter).await?; + Ok(res.into()) + } } #[derive(Clone, Debug, uniffi::Object)] @@ -429,6 +470,20 @@ impl InitializedTransition { let res = value.save(&adapter).map_err(ReceiverPersistedError::from)?; Ok(res.into()) } + + pub async fn save_async( + &self, + persister: Arc, + ) -> Result { + let adapter = AsyncCallbackPersisterAdapter::new(persister); + let value = { + let mut inner = self.0.write().expect("Lock should not be poisoned"); + inner.take().expect("Already saved or moved") + }; + + let res = value.save_async(&adapter).await.map_err(ReceiverPersistedError::from)?; + Ok(res.into()) + } } #[derive(uniffi::Enum)] @@ -1160,6 +1215,20 @@ impl HasReplyableErrorTransition { value.save(&adapter).map_err(ReceiverPersistedError::from)?; Ok(()) } + + pub async fn save_async( + &self, + persister: Arc, + ) -> Result<(), ReceiverPersistedError> { + let adapter = AsyncCallbackPersisterAdapter::new(persister); + let value = { + let mut inner = self.0.write().expect("Lock should not be poisoned"); + inner.take().expect("Already saved or moved") + }; + + value.save_async(&adapter).await.map_err(ReceiverPersistedError::from)?; + Ok(()) + } } #[uniffi::export] @@ -1186,8 +1255,6 @@ impl HasReplyableError { #[uniffi::export(with_foreign)] pub trait TransactionExists: Send + Sync { - // TODO: Is there an ffi exported txid type that we can use here? - // TODO: Is there a ffi type for the serialized tx? fn callback(&self, txid: String) -> Result>, ForeignError>; } @@ -1221,6 +1288,20 @@ impl MonitorTransition { value.save(&adapter).map_err(ReceiverPersistedError::from)?; Ok(()) } + + pub async fn save_async( + &self, + persister: Arc, + ) -> Result<(), ReceiverPersistedError> { + let adapter = AsyncCallbackPersisterAdapter::new(persister); + let value = { + let mut inner = self.0.write().expect("Lock should not be poisoned"); + inner.take().expect("Already saved or moved") + }; + + value.save_async(&adapter).await.map_err(ReceiverPersistedError::from)?; + Ok(()) + } } #[derive(uniffi::Object)] @@ -1302,3 +1383,71 @@ impl payjoin::persist::SessionPersister for CallbackPersisterAdapter { fn close(&self) -> Result<(), Self::InternalStorageError> { self.callback_persister.close() } } + +/// Async session persister that should save and load events as JSON strings. +#[uniffi::export(with_foreign)] +#[async_trait::async_trait] +pub trait JsonReceiverSessionPersisterAsync: Send + Sync { + async fn save(&self, event: String) -> Result<(), ForeignError>; + async fn load(&self) -> Result, ForeignError>; + async fn close(&self) -> Result<(), ForeignError>; +} + +/// Adapter for the [JsonReceiverSessionPersisterAsync] trait to use the save and load callbacks. +struct AsyncCallbackPersisterAdapter { + callback_persister: Arc, +} + +impl AsyncCallbackPersisterAdapter { + pub fn new(callback_persister: Arc) -> Self { + Self { callback_persister } + } +} + +impl payjoin::persist::AsyncSessionPersister for AsyncCallbackPersisterAdapter { + type SessionEvent = payjoin::receive::v2::SessionEvent; + type InternalStorageError = ForeignError; + + fn save_event( + &self, + event: Self::SessionEvent, + ) -> impl std::future::Future> + Send { + let uni_event: ReceiverSessionEvent = event.into(); + let persister = self.callback_persister.clone(); + async move { + let json = + uni_event.to_json().map_err(|e| ForeignError::InternalError(e.to_string()))?; + persister.save(json).await + } + } + + fn load( + &self, + ) -> impl std::future::Future< + Output = Result< + Box + Send>, + Self::InternalStorageError, + >, + > + Send { + let persister = self.callback_persister.clone(); + async move { + let res = persister.load().await?; + let events: Vec<_> = res + .into_iter() + .map(|event| { + ReceiverSessionEvent::from_json(event) + .map_err(|e| ForeignError::InternalError(e.to_string())) + .map(Into::into) + }) + .collect::, _>>()?; + Ok(Box::new(events.into_iter()) as Box + Send>) + } + } + + fn close( + &self, + ) -> impl std::future::Future> + Send { + let persister = self.callback_persister.clone(); + async move { persister.close().await } + } +} diff --git a/payjoin-ffi/src/send/mod.rs b/payjoin-ffi/src/send/mod.rs index 612c916a8..9c3d4eb3f 100644 --- a/payjoin-ffi/src/send/mod.rs +++ b/payjoin-ffi/src/send/mod.rs @@ -28,6 +28,21 @@ macro_rules! impl_save_for_transition { let res = value.save(&adapter).map_err(SenderPersistedError::from)?; Ok(res.into()) } + + pub async fn save_async( + &self, + persister: Arc, + ) -> Result<$next_state, SenderPersistedError> { + let adapter = AsyncCallbackPersisterAdapter::new(persister); + // Extract value while holding the lock, then drop the guard before await + let value = { + let mut inner = self.0.write().expect("Lock should not be poisoned"); + inner.take().expect("Already saved or moved") + }; + + let res = value.save_async(&adapter).await.map_err(SenderPersistedError::from)?; + Ok(res.into()) + } } }; } @@ -132,6 +147,15 @@ pub fn replay_sender_event_log( Ok(SenderReplayResult { state: state.into(), session_history: session_history.into() }) } +#[uniffi::export] +pub async fn replay_sender_event_log_async( + persister: Arc, +) -> Result { + let adapter = AsyncCallbackPersisterAdapter::new(persister); + let (state, session_history) = payjoin::send::v2::replay_event_log_async(&adapter).await?; + Ok(SenderReplayResult { state: state.into(), session_history: session_history.into() }) +} + /// Represents the status of a session that can be inferred from the information in the session /// event log. #[derive(uniffi::Object)] @@ -210,6 +234,23 @@ impl InitialSendTransition { let res = value.save(&adapter).map_err(|e| ForeignError::InternalError(e.to_string()))?; Ok(res.into()) } + + pub async fn save_async( + &self, + persister: Arc, + ) -> Result { + let adapter = AsyncCallbackPersisterAdapter::new(persister); + let value = { + let mut inner = self.0.write().expect("Lock should not be poisoned"); + inner.take().expect("Already saved or moved") + }; + + let res = value + .save_async(&adapter) + .await + .map_err(|e| ForeignError::InternalError(e.to_string()))?; + Ok(res.into()) + } } ///Builder for sender-side payjoin parameters @@ -349,6 +390,20 @@ impl WithReplyKeyTransition { let res = value.save(&adapter).map_err(SenderPersistedError::from)?; Ok(res.into()) } + + pub async fn save_async( + &self, + persister: Arc, + ) -> Result { + let adapter = AsyncCallbackPersisterAdapter::new(persister); + let value = { + let mut inner = self.0.write().expect("Lock should not be poisoned"); + inner.take().expect("Already saved or moved") + }; + + let res = value.save_async(&adapter).await.map_err(SenderPersistedError::from)?; + Ok(res.into()) + } } #[uniffi::export] @@ -554,3 +609,71 @@ impl payjoin::persist::SessionPersister for CallbackPersisterAdapter { fn close(&self) -> Result<(), Self::InternalStorageError> { self.callback_persister.close() } } + +/// Async session persister that should save and load events as JSON strings. +#[uniffi::export(with_foreign)] +#[async_trait::async_trait] +pub trait JsonSenderSessionPersisterAsync: Send + Sync { + async fn save(&self, event: String) -> Result<(), ForeignError>; + async fn load(&self) -> Result, ForeignError>; + async fn close(&self) -> Result<(), ForeignError>; +} + +/// Adapter for the [JsonSenderSessionPersisterAsync] trait to use the save and load callbacks. +struct AsyncCallbackPersisterAdapter { + callback_persister: Arc, +} + +impl AsyncCallbackPersisterAdapter { + pub fn new(callback_persister: Arc) -> Self { + Self { callback_persister } + } +} + +impl payjoin::persist::AsyncSessionPersister for AsyncCallbackPersisterAdapter { + type SessionEvent = payjoin::send::v2::SessionEvent; + type InternalStorageError = ForeignError; + + fn save_event( + &self, + event: Self::SessionEvent, + ) -> impl std::future::Future> + Send { + let uni_event: SenderSessionEvent = event.into(); + let persister = self.callback_persister.clone(); + async move { + let json = + uni_event.to_json().map_err(|e| ForeignError::InternalError(e.to_string()))?; + persister.save(json).await + } + } + + fn load( + &self, + ) -> impl std::future::Future< + Output = Result< + Box + Send>, + Self::InternalStorageError, + >, + > + Send { + let persister = self.callback_persister.clone(); + async move { + let res = persister.load().await?; + let events: Vec<_> = res + .into_iter() + .map(|event| { + SenderSessionEvent::from_json(event) + .map_err(|e| ForeignError::InternalError(e.to_string())) + .map(Into::into) + }) + .collect::, _>>()?; + Ok(Box::new(events.into_iter()) as Box + Send>) + } + } + + fn close( + &self, + ) -> impl std::future::Future> + Send { + let persister = self.callback_persister.clone(); + async move { persister.close().await } + } +}