From c785c8b292a74884ade8938edfa4f65330f6dc02 Mon Sep 17 00:00:00 2001 From: jolah1 Date: Thu, 3 Sep 2026 10:51:10 +0100 Subject: [PATCH] Test persister load and close in FFI bindings #1287 added save() and save_async() coverage to the bindings but left load() and close() untested. Nothing pinned the binding layer's contract with a foreign persister: that a terminal save reaches through to close(), and that load() is a read the runtime can repeat rather than one that consumes the log. Cover the receiver and the sender in dart, javascript, python and csharp, sync and async. The two reach their terminal state by different routes: cancelling an initialized receiver is itself terminal, while cancelling a sender only moves it to a pending fallback and the session closes a step later, when the broadcast transition is saved. Assert the persister is open beforehand, closed afterwards, and that load() still returns every event. --- payjoin-ffi/csharp/UnitTests.cs | 101 ++++++++++++++ .../dart/test/test_payjoin_unit_test.dart | 127 ++++++++++++++++++ payjoin-ffi/javascript/test/unit.test.ts | 121 +++++++++++++++++ .../python/test/test_payjoin_unit_test.py | 122 +++++++++++++++++ 4 files changed, 471 insertions(+) diff --git a/payjoin-ffi/csharp/UnitTests.cs b/payjoin-ffi/csharp/UnitTests.cs index 655286fa5..e6b9dd053 100644 --- a/payjoin-ffi/csharp/UnitTests.cs +++ b/payjoin-ffi/csharp/UnitTests.cs @@ -254,6 +254,107 @@ public async Task SenderCancelFromWithReplyKeyAsync() } } +public class PersisterLoadAndCloseTests +{ + private static readonly byte[] OhttpKeysData = new byte[] + { + 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, + }; + + [Fact] + public void ReceiverLoadAndClose() + { + var persister = new InMemoryReceiverPersister(); + var address = "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4"; + var ohttpKeys = OhttpKeys.Decode(OhttpKeysData); + + var initialized = new ReceiverBuilder(address, "https://example.com", ohttpKeys) + .Build() + .Save(persister); + Assert.Equal(1, persister.Load().Length); + Assert.False(persister.Closed); + + // Cancelling is terminal, so saving it must close the persister. + initialized.Cancel().Save(persister); + + Assert.True(persister.Closed); + Assert.Equal(2, persister.Load().Length); + } + + [Fact] + public async Task ReceiverLoadAndCloseAsync() + { + var persister = new InMemoryReceiverPersisterAsync(); + var address = "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4"; + var ohttpKeys = OhttpKeys.Decode(OhttpKeysData); + + var initialized = await new ReceiverBuilder(address, "https://example.com", ohttpKeys) + .Build() + .SaveAsync(persister); + Assert.Equal(1, (await persister.Load()).Length); + Assert.False(persister.Closed); + + await initialized.Cancel().SaveAsync(persister); + + Assert.True(persister.Closed); + Assert.Equal(2, (await persister.Load()).Length); + } + + [Fact] + public void SenderLoadAndClose() + { + var address = "2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK"; + var ohttpKeys = OhttpKeys.Decode(OhttpKeysData); + var receiver = new ReceiverBuilder(address, "https://example.com", ohttpKeys) + .Build() + .Save(new InMemoryReceiverPersister()); + + var persister = new InMemorySenderPersister(); + var withReplyKey = new SenderBuilder(PayjoinMethods.OriginalPsbt(), receiver.PjUri()) + .BuildRecommended(1000) + .Save(persister); + var pendingFallback = withReplyKey.Cancel().Save(persister); + Assert.Equal(2, persister.Load().Length); + Assert.False(persister.Closed); + + // Closing the fallback is terminal, so saving it must close the persister. + pendingFallback.Close().Save(persister); + + Assert.True(persister.Closed); + Assert.Equal(3, persister.Load().Length); + } + + [Fact] + public async Task SenderLoadAndCloseAsync() + { + var address = "2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK"; + var ohttpKeys = OhttpKeys.Decode(OhttpKeysData); + var receiver = await new ReceiverBuilder(address, "https://example.com", ohttpKeys) + .Build() + .SaveAsync(new InMemoryReceiverPersisterAsync()); + + var persister = new InMemorySenderPersisterAsync(); + var withReplyKey = await new SenderBuilder(PayjoinMethods.OriginalPsbt(), receiver.PjUri()) + .BuildRecommended(1000) + .SaveAsync(persister); + var pendingFallback = await withReplyKey.Cancel().SaveAsync(persister); + Assert.Equal(2, (await persister.Load()).Length); + Assert.False(persister.Closed); + + await pendingFallback.Close().SaveAsync(persister); + + Assert.True(persister.Closed); + Assert.Equal(3, (await persister.Load()).Length); + } +} + public class ValidationTests { private static readonly byte[] OhttpKeysData = new byte[] diff --git a/payjoin-ffi/dart/test/test_payjoin_unit_test.dart b/payjoin-ffi/dart/test/test_payjoin_unit_test.dart index 31e8325b3..6b0d626ad 100644 --- a/payjoin-ffi/dart/test/test_payjoin_unit_test.dart +++ b/payjoin-ffi/dart/test/test_payjoin_unit_test.dart @@ -253,6 +253,133 @@ void main() { }); }); + group("Test Persister Load And Close", () { + test("Test receiver load and close", () { + var persister = InMemoryReceiverPersister(); + var initialized = payjoin.ReceiverBuilder( + address: "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", + directory: "https://example.com", + ohttpKeys: payjoin.OhttpKeys.decode( + bytes: Uint8List.fromList( + hex.decode( + "01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003", + ), + ), + ), + ).build().save(persister: persister); + expect(persister.load().length, 1); + expect(persister.closed, isFalse); + + // Cancelling is terminal, so saving it must close the persister. + initialized.cancel().save(persister: persister); + + expect(persister.closed, isTrue); + expect( + persister.load().length, + 2, + reason: "load should still return every event after close", + ); + }); + + test("Test receiver load and close async", () async { + var persister = InMemoryReceiverPersisterAsync(); + var initialized = await payjoin.ReceiverBuilder( + address: "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", + directory: "https://example.com", + ohttpKeys: payjoin.OhttpKeys.decode( + bytes: Uint8List.fromList( + hex.decode( + "01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003", + ), + ), + ), + ).build().saveAsync(persister: persister); + expect((await persister.load()).length, 1); + expect(persister.closed, isFalse); + + await initialized.cancel().saveAsync(persister: persister); + + expect(persister.closed, isTrue); + expect( + (await persister.load()).length, + 2, + reason: "load should still return every event after close", + ); + }); + + test("Test sender load and close", () { + // Create a receiver to just get the pj uri + var receiver = payjoin.ReceiverBuilder( + address: "2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK", + directory: "https://example.com", + ohttpKeys: payjoin.OhttpKeys.decode( + bytes: Uint8List.fromList( + hex.decode( + "01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003", + ), + ), + ), + ).build().save(persister: InMemoryReceiverPersister()); + + var persister = InMemorySenderPersister(); + var withReplyKey = payjoin.SenderBuilder( + psbt: payjoin.originalPsbt(), + uri: receiver.pjUri(), + ).buildRecommended(minFeeRateSatPerKwu: 1000).save(persister: persister); + var pendingFallback = withReplyKey.cancel().save(persister: persister); + expect(persister.load().length, 2); + expect(persister.closed, isFalse); + + // Closing the fallback is terminal, so saving it must close the persister. + pendingFallback!.close().save(persister: persister); + + expect(persister.closed, isTrue); + expect( + persister.load().length, + 3, + reason: "load should still return every event after close", + ); + }); + + test("Test sender load and close async", () async { + // Create a receiver to just get the pj uri + var receiver = await payjoin.ReceiverBuilder( + address: "2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK", + directory: "https://example.com", + ohttpKeys: payjoin.OhttpKeys.decode( + bytes: Uint8List.fromList( + hex.decode( + "01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003", + ), + ), + ), + ).build().saveAsync(persister: InMemoryReceiverPersisterAsync()); + + var persister = InMemorySenderPersisterAsync(); + var withReplyKey = + await payjoin.SenderBuilder( + psbt: payjoin.originalPsbt(), + uri: receiver.pjUri(), + ) + .buildRecommended(minFeeRateSatPerKwu: 1000) + .saveAsync(persister: persister); + var pendingFallback = await withReplyKey.cancel().saveAsync( + persister: persister, + ); + expect((await persister.load()).length, 2); + expect(persister.closed, isFalse); + + await pendingFallback!.close().saveAsync(persister: persister); + + expect(persister.closed, isTrue); + expect( + (await persister.load()).length, + 3, + reason: "load should still return every event after close", + ); + }); + }); + group("Test Async Persistence", () { test("Test receiver async persistence", () async { var persister = InMemoryReceiverPersisterAsync(); diff --git a/payjoin-ffi/javascript/test/unit.test.ts b/payjoin-ffi/javascript/test/unit.test.ts index 89386421e..7ca76af31 100644 --- a/payjoin-ffi/javascript/test/unit.test.ts +++ b/payjoin-ffi/javascript/test/unit.test.ts @@ -284,6 +284,127 @@ function runUnitTests(name: string, payjoin: typeof nodejsPayjoin) { }); }); + describe(`[${name}] Persister load and close tests`, () => { + test("receiver load and close", () => { + const persister = new InMemoryReceiverPersister(); + const ohttpKeys = payjoin.OhttpKeys.decode(OHTTP_KEYS); + + const initialized = new payjoin.ReceiverBuilder( + "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", + "https://example.com", + ohttpKeys, + ) + .build() + .save(persister); + assert.strictEqual(persister.load().length, 1); + assert.strictEqual(persister.closed, false); + + // Cancelling is terminal, so saving it must close the persister. + initialized.cancel().save(persister); + + assert.strictEqual(persister.closed, true); + assert.strictEqual( + persister.load().length, + 2, + "load should still return every event after close", + ); + }); + + test("receiver load and close async", async () => { + const persister = new InMemoryReceiverPersisterAsync(); + const ohttpKeys = payjoin.OhttpKeys.decode(OHTTP_KEYS); + + const initialized = await new payjoin.ReceiverBuilder( + "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", + "https://example.com", + ohttpKeys, + ) + .build() + .saveAsync(persister); + assert.strictEqual((await persister.load()).length, 1); + assert.strictEqual(persister.closed, false); + + await initialized.cancel().saveAsync(persister); + + assert.strictEqual(persister.closed, true); + assert.strictEqual( + (await persister.load()).length, + 2, + "load should still return every event after close", + ); + }); + + test("sender load and close", () => { + const receiverPersister = new InMemoryReceiverPersister(); + const ohttpKeys = payjoin.OhttpKeys.decode(OHTTP_KEYS); + const uri = new payjoin.ReceiverBuilder( + "2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK", + "https://example.com", + ohttpKeys, + ) + .build() + .save(receiverPersister) + .pjUri(); + + const persister = new InMemorySenderPersister(); + const pendingFallback = new payjoin.SenderBuilder( + ORIGINAL_PSBT, + uri, + ) + .buildRecommended(BigInt(1000)) + .save(persister) + .cancel() + .save(persister); + assert.strictEqual(persister.load().length, 2); + assert.strictEqual(persister.closed, false); + + // Closing the fallback is terminal, so saving it must close the + // persister. + pendingFallback.close().save(persister); + + assert.strictEqual(persister.closed, true); + assert.strictEqual( + persister.load().length, + 3, + "load should still return every event after close", + ); + }); + + test("sender load and close async", async () => { + const receiverPersister = new InMemoryReceiverPersisterAsync(); + const ohttpKeys = payjoin.OhttpKeys.decode(OHTTP_KEYS); + const receiver = await new payjoin.ReceiverBuilder( + "2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK", + "https://example.com", + ohttpKeys, + ) + .build() + .saveAsync(receiverPersister); + + const persister = new InMemorySenderPersisterAsync(); + const withReplyKey = await new payjoin.SenderBuilder( + ORIGINAL_PSBT, + receiver.pjUri(), + ) + .buildRecommended(BigInt(1000)) + .saveAsync(persister); + const pendingFallback = await withReplyKey + .cancel() + .saveAsync(persister); + assert.strictEqual((await persister.load()).length, 2); + assert.strictEqual(persister.closed, false); + + await pendingFallback.close().saveAsync(persister); + + assert.strictEqual(persister.closed, true); + assert.strictEqual( + (await persister.load()).length, + 3, + "load should still return every event after close", + ); + }); + }); + describe(`[${name}] Async Persistence tests`, () => { test("receiver async persistence", async () => { const persister = new InMemoryReceiverPersisterAsync(); diff --git a/payjoin-ffi/python/test/test_payjoin_unit_test.py b/payjoin-ffi/python/test/test_payjoin_unit_test.py index 61b5c0780..1504089e8 100644 --- a/payjoin-ffi/python/test/test_payjoin_unit_test.py +++ b/payjoin-ffi/python/test/test_payjoin_unit_test.py @@ -139,6 +139,128 @@ async def run_test(): asyncio.run(run_test()) +class TestPersisterLoadAndClose(unittest.TestCase): + def test_receiver_load_and_close(self): + persister = InMemoryReceiverPersister() + initialized = ( + payjoin.ReceiverBuilder( + "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", + "https://example.com", + payjoin.OhttpKeys.decode( + bytes.fromhex( + "01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003" + ) + ), + ) + .build() + .save(persister) + ) + self.assertEqual(len(persister.load()), 1) + self.assertFalse(persister.closed) + + # Cancelling is terminal, so saving it must close the persister. + initialized.cancel().save(persister) + + self.assertTrue(persister.closed) + self.assertEqual(len(persister.load()), 2) + + def test_receiver_load_and_close_async(self): + import asyncio + + async def run_test(): + persister = InMemoryReceiverPersisterAsync() + initialized = await ( + payjoin.ReceiverBuilder( + "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", + "https://example.com", + payjoin.OhttpKeys.decode( + bytes.fromhex( + "01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003" + ) + ), + ) + .build() + .save_async(persister) + ) + self.assertEqual(len(await persister.load()), 1) + self.assertFalse(persister.closed) + + await initialized.cancel().save_async(persister) + + self.assertTrue(persister.closed) + self.assertEqual(len(await persister.load()), 2) + + asyncio.run(run_test()) + + def test_sender_load_and_close(self): + # Create a receiver to just get the pj uri + receiver = ( + payjoin.ReceiverBuilder( + "2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK", + "https://example.com", + payjoin.OhttpKeys.decode( + bytes.fromhex( + "01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003" + ) + ), + ) + .build() + .save(InMemoryReceiverPersister()) + ) + + persister = InMemorySenderPersister() + with_reply_key = ( + payjoin.SenderBuilder(payjoin.original_psbt(), receiver.pj_uri()) + .build_recommended(1000) + .save(persister) + ) + pending_fallback = with_reply_key.cancel().save(persister) + self.assertEqual(len(persister.load()), 2) + self.assertFalse(persister.closed) + + # Closing the fallback is terminal, so saving it must close the persister. + pending_fallback.close().save(persister) + + self.assertTrue(persister.closed) + self.assertEqual(len(persister.load()), 3) + + def test_sender_load_and_close_async(self): + import asyncio + + async def run_test(): + # Create a receiver to just get the pj uri + receiver = await ( + payjoin.ReceiverBuilder( + "2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK", + "https://example.com", + payjoin.OhttpKeys.decode( + bytes.fromhex( + "01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003" + ) + ), + ) + .build() + .save_async(InMemoryReceiverPersisterAsync()) + ) + + persister = InMemorySenderPersisterAsync() + with_reply_key = await ( + payjoin.SenderBuilder(payjoin.original_psbt(), receiver.pj_uri()) + .build_recommended(1000) + .save_async(persister) + ) + pending_fallback = await with_reply_key.cancel().save_async(persister) + self.assertEqual(len(await persister.load()), 2) + self.assertFalse(persister.closed) + + await pending_fallback.close().save_async(persister) + + self.assertTrue(persister.closed) + self.assertEqual(len(await persister.load()), 3) + + asyncio.run(run_test()) + + class TestReceiverCancel(unittest.TestCase): def test_receiver_cancel(self): persister = InMemoryReceiverPersister()