From 7c905c5d518f1ccb06908ac1476805cf4d0d2840 Mon Sep 17 00:00:00 2001 From: xstoicunicornx Date: Tue, 29 Sep 2026 19:46:20 +0000 Subject: [PATCH] Test non-blocking receive interface in Kotlin The FFI bindings for the two-phase validation API landed in 3d6305df with integration tests for C#, Dart, JavaScript, and Python. Kotlin was left out. Run the Kotlin v2-to-v2 integration test in both callback and nonblocking transition modes, matching the other bindings. Checklist and marked checklist item handles are closed after use, consistent with how the rest of the test manages uniffi objects. --- .../org/payjoindevkit/IntegrationTests.kt | 103 ++++++++++++++---- 1 file changed, 82 insertions(+), 21 deletions(-) diff --git a/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/IntegrationTests.kt b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/IntegrationTests.kt index 78a0521ae..16e855c6e 100644 --- a/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/IntegrationTests.kt +++ b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/IntegrationTests.kt @@ -23,9 +23,10 @@ import kotlinx.serialization.json.jsonPrimitive /** * Full BIP 77 v2↔v2 round trip against the in-process directory, OHTTP relay, * and bitcoind. Mirrors `payjoin-ffi/python/test/test_payjoin_integration_test.py` - * (`test_integration_v2_to_v2`): same RPC sequence, same receiver checklist, + * (`test_integration_v2_to_v2_*`): same RPC sequence, same receiver checklist, * same final assertions, plus an explicit check that the broadcast transaction - * spends coins from both wallets. + * spends coins from both wallets. The round trip runs once with validation + * callbacks and once with the nonblocking extract/apply checklist API. * * Every [TestServices] call takes a global runtime mutex and blocks, so sender * and receiver are driven strictly in sequence. @@ -75,9 +76,13 @@ class IntegrationTests { } @Test - fun v2ToV2Payjoin() = runScenario() + fun v2ToV2PayjoinCallback() = runScenario(mode = TransitionMode.Callback) + + @Test + fun v2ToV2PayjoinNonblocking() = runScenario(mode = TransitionMode.Nonblocking) private fun runScenario( + mode: TransitionMode = TransitionMode.Callback, cancelReceiver: ((PayjoinProposal, InMemoryReceiverPersister) -> Unit)? = null, ) { initTracing() @@ -98,6 +103,7 @@ class IntegrationTests { senderRpc, receiverRpc, cancelReceiver, + mode, ) } } @@ -115,6 +121,7 @@ class IntegrationTests { senderRpc: RpcClient, receiverRpc: RpcClient, cancelReceiver: ((PayjoinProposal, InMemoryReceiverPersister) -> Unit)?, + mode: TransitionMode, ) { val receiverAddress = Json.parseToJsonElement(rpc(receiverRpc, "getnewaddress")).jsonPrimitive.content val senderOutpoints = listOutpoints(senderRpc) @@ -147,7 +154,7 @@ class IntegrationTests { pollingForProposal.use { // ********************** // Inside the Receiver: - val payjoinProposal = waitForReceiverProposal(session, recvPersister, http, relay, receiverRpc) + val payjoinProposal = waitForReceiverProposal(session, recvPersister, http, relay, receiverRpc, mode) payjoinProposal.use { if (cancelReceiver != null) { cancelReceiver(payjoinProposal, recvPersister) @@ -207,6 +214,7 @@ class IntegrationTests { http: TestHttp, relay: String, receiverRpc: RpcClient, + mode: TransitionMode, ): PayjoinProposal { val deadline = System.nanoTime() + POLL_TIMEOUT_NS var attempts = 0 @@ -214,7 +222,7 @@ class IntegrationTests { attempts += 1 val original = pollReceiver(session, recvPersister, http, relay) if (original != null) { - return original.use { processUncheckedProposal(it, recvPersister, receiverRpc) } + return original.use { processUncheckedProposal(it, recvPersister, receiverRpc, mode) } } Thread.sleep(POLL_SLEEP_MS) } @@ -225,55 +233,86 @@ class IntegrationTests { proposal: UncheckedOriginalPayload, recvPersister: InMemoryReceiverPersister, receiverRpc: RpcClient, + mode: TransitionMode, ): PayjoinProposal { - val maybeInputsOwned = proposal.checkBroadcastSuitability(null, MempoolAcceptanceCallback(receiverRpc)) - .use { it.save(recvPersister) } - return maybeInputsOwned.use { processMaybeInputsOwned(it, recvPersister, receiverRpc) } + val transition = when (mode) { + TransitionMode.Callback -> proposal.checkBroadcastSuitability(null, MempoolAcceptanceCallback(receiverRpc)) + TransitionMode.Nonblocking -> { + val canBroadcast = MempoolAcceptanceCallback(receiverRpc) + .callback(proposal.extractTxToCheckBroadcastSuitability()) + proposal.applyBroadcastSuitability(null, canBroadcast) + } + } + val maybeInputsOwned = transition.use { it.save(recvPersister) } + return maybeInputsOwned.use { processMaybeInputsOwned(it, recvPersister, receiverRpc, mode) } } private fun processMaybeInputsOwned( proposal: MaybeInputsOwned, recvPersister: InMemoryReceiverPersister, receiverRpc: RpcClient, + mode: TransitionMode, ): PayjoinProposal { - val maybeInputsSeen = proposal.checkInputsNotOwned(IsInputOwnedCallback(receiverRpc)) - .use { it.save(recvPersister) } - return maybeInputsSeen.use { processMaybeInputsSeen(it, recvPersister, receiverRpc) } + val transition = when (mode) { + TransitionMode.Callback -> proposal.checkInputsNotOwned(IsInputOwnedCallback(receiverRpc)) + TransitionMode.Nonblocking -> proposal.inputsOwnedChecklist().useAll { checklist -> + checklist.map { it.mark(IsInputOwnedCallback(receiverRpc).callback(it.value())) } + .useAll { proposal.applyInputsOwnedChecklist(it) } + } + } + val maybeInputsSeen = transition.use { it.save(recvPersister) } + return maybeInputsSeen.use { processMaybeInputsSeen(it, recvPersister, receiverRpc, mode) } } private fun processMaybeInputsSeen( proposal: MaybeInputsSeen, recvPersister: InMemoryReceiverPersister, receiverRpc: RpcClient, + mode: TransitionMode, ): PayjoinProposal { - val outputsUnknown = proposal.checkNoInputsSeenBefore(CheckInputsNotSeenCallback()) - .use { it.save(recvPersister) } - return outputsUnknown.use { processOutputsUnknown(it, recvPersister, receiverRpc) } + val transition = when (mode) { + TransitionMode.Callback -> proposal.checkNoInputsSeenBefore(CheckInputsNotSeenCallback()) + TransitionMode.Nonblocking -> proposal.inputsSeenChecklist().useAll { checklist -> + checklist.map { it.mark(CheckInputsNotSeenCallback().callback(it.value())) } + .useAll { proposal.applyInputsSeenChecklist(it) } + } + } + val outputsUnknown = transition.use { it.save(recvPersister) } + return outputsUnknown.use { processOutputsUnknown(it, recvPersister, receiverRpc, mode) } } private fun processOutputsUnknown( proposal: OutputsUnknown, recvPersister: InMemoryReceiverPersister, receiverRpc: RpcClient, + mode: TransitionMode, ): PayjoinProposal { - val wantsOutputs = proposal.identifyReceiverOutputs(IsScriptOwnedCallback(receiverRpc)) - .use { it.save(recvPersister) } - return wantsOutputs.use { processWantsOutputs(it, recvPersister, receiverRpc) } + val transition = when (mode) { + TransitionMode.Callback -> proposal.identifyReceiverOutputs(IsScriptOwnedCallback(receiverRpc)) + TransitionMode.Nonblocking -> proposal.outputsOwnedChecklist().useAll { checklist -> + checklist.map { it.mark(IsScriptOwnedCallback(receiverRpc).callback(it.value())) } + .useAll { proposal.applyOutputsOwnedChecklist(it) } + } + } + val wantsOutputs = transition.use { it.save(recvPersister) } + return wantsOutputs.use { processWantsOutputs(it, recvPersister, receiverRpc, mode) } } private fun processWantsOutputs( proposal: WantsOutputs, recvPersister: InMemoryReceiverPersister, receiverRpc: RpcClient, + mode: TransitionMode, ): PayjoinProposal { val wantsInputs = proposal.commitOutputs().use { it.save(recvPersister) } - return wantsInputs.use { processWantsInputs(it, recvPersister, receiverRpc) } + return wantsInputs.use { processWantsInputs(it, recvPersister, receiverRpc, mode) } } private fun processWantsInputs( proposal: WantsInputs, recvPersister: InMemoryReceiverPersister, receiverRpc: RpcClient, + mode: TransitionMode, ): PayjoinProposal { val inputs = getInputs(receiverRpc) val wantsFeeRange = try { @@ -283,24 +322,33 @@ class IntegrationTests { } finally { inputs.forEach { it.close() } } - return wantsFeeRange.use { processWantsFeeRange(it, recvPersister, receiverRpc) } + return wantsFeeRange.use { processWantsFeeRange(it, recvPersister, receiverRpc, mode) } } private fun processWantsFeeRange( proposal: WantsFeeRange, recvPersister: InMemoryReceiverPersister, receiverRpc: RpcClient, + mode: TransitionMode, ): PayjoinProposal { val provisional = proposal.applyFeeRange(1u, 10u).use { it.save(recvPersister) } - return provisional.use { processProvisionalProposal(it, recvPersister, receiverRpc) } + return provisional.use { processProvisionalProposal(it, recvPersister, receiverRpc, mode) } } private fun processProvisionalProposal( proposal: ProvisionalProposal, recvPersister: InMemoryReceiverPersister, receiverRpc: RpcClient, + mode: TransitionMode, ): PayjoinProposal { - return proposal.finalizeProposal(ProcessPsbtCallback(receiverRpc)).use { it.save(recvPersister) } + val transition = when (mode) { + TransitionMode.Callback -> proposal.finalizeProposal(ProcessPsbtCallback(receiverRpc)) + TransitionMode.Nonblocking -> { + val signedPsbt = ProcessPsbtCallback(receiverRpc).callback(proposal.psbtToSign()) + proposal.finalizeSignedProposal(signedPsbt) + } + } + return transition.use { it.save(recvPersister) } } private fun waitForSenderProposal( @@ -377,11 +425,24 @@ class IntegrationTests { } } +/** Drives receiver validation through callbacks or through the extract/apply checklist API. */ +private enum class TransitionMode { + Callback, + Nonblocking, +} + private const val POLL_SLEEP_MS = 250L private const val POLL_TIMEOUT_NS = 30_000_000_000L private data class OutpointRef(val txid: String, val vout: UInt) +private inline fun List.useAll(block: (List) -> R): R = + try { + block(this) + } finally { + forEach { it.close() } + } + private fun rpc(client: RpcClient, method: String, vararg params: String?): String = client.call(method, params.toList())