From e43e7e21708ebac7c72bc2fab49e5be2efe0953c Mon Sep 17 00:00:00 2001 From: chavic Date: Sun, 13 Sep 2026 21:51:08 +0200 Subject: [PATCH] Restore Kotlin build errors and expand tests Keep compiler diagnostics visible while selecting the native library from Cargo JSON. Cover script and weight validation, persistence failures and suspension, and receiver pending-fallback closure through the generated bindings. Document Python 3 for generation outside Nix. --- payjoin-ffi/kotlin/CONTRIBUTING.md | 4 +- payjoin-ffi/kotlin/README.md | 2 +- .../kotlin/scripts/generate_bindings.sh | 2 +- .../org/payjoindevkit/ControlledPersisters.kt | 86 +++++ .../org/payjoindevkit/IntegrationTests.kt | 60 ++- .../payjoindevkit/PersistenceCallbackTests.kt | 356 ++++++++++++++++++ .../org/payjoindevkit/ValidationTests.kt | 25 ++ 7 files changed, 530 insertions(+), 5 deletions(-) create mode 100644 payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/ControlledPersisters.kt create mode 100644 payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/PersistenceCallbackTests.kt diff --git a/payjoin-ffi/kotlin/CONTRIBUTING.md b/payjoin-ffi/kotlin/CONTRIBUTING.md index b79bd6ca8..8ba9abe8f 100644 --- a/payjoin-ffi/kotlin/CONTRIBUTING.md +++ b/payjoin-ffi/kotlin/CONTRIBUTING.md @@ -25,5 +25,5 @@ Protocol `close` is renamed to `closeSession` only in `[bindings.kotlin.rename]` With nix, `nix develop .#kotlin` provides the pinned MSRV Rust toolchain, JDK 21, and `BITCOIND_EXE` (via `nixpkgs`, with `BITCOIND_SKIP_DOWNLOAD=1`), and is what CI uses. Without -nix, use a local JDK 21+ and the Gradle wrapper; `corepc-node` will download `bitcoind` on -first test run. +nix, install Python 3 for binding generation and use a local JDK 21+ and the Gradle wrapper; +`corepc-node` will download `bitcoind` on first test run. diff --git a/payjoin-ffi/kotlin/README.md b/payjoin-ffi/kotlin/README.md index c5807e467..d3bb5deee 100644 --- a/payjoin-ffi/kotlin/README.md +++ b/payjoin-ffi/kotlin/README.md @@ -22,7 +22,7 @@ Or `bash ./contrib/test.sh` from this directory (uses `Cargo-recent.lock`). `bitcoind` and `BITCOIND_SKIP_DOWNLOAD=1` is set, so no download happens. Outside the nix shell, Bitcoin Core is downloaded by corepc-node (`29_0`) on first run instead. -Without nix: Rust (see repo `rust-toolchain.toml` / MSRV 1.85), JDK 21+, and the Gradle wrapper in this directory. Network access is required the first time bitcoind is fetched. +Without nix: Rust (see repo `rust-toolchain.toml` / MSRV 1.85), JDK 21+, Python 3, and the Gradle wrapper in this directory. Python parses Cargo's native library output during generation. Network access is required the first time bitcoind is fetched. ## Stability diff --git a/payjoin-ffi/kotlin/scripts/generate_bindings.sh b/payjoin-ffi/kotlin/scripts/generate_bindings.sh index 1f33111d4..cb611cb2d 100755 --- a/payjoin-ffi/kotlin/scripts/generate_bindings.sh +++ b/payjoin-ffi/kotlin/scripts/generate_bindings.sh @@ -38,7 +38,7 @@ run_cargo() { # compiler-artifact filenames include target-dir, CARGO_BUILD_TARGET, and profile. NATIVE_LIB="$( - run_cargo build --message-format=json --profile "$PAYJOIN_FFI_PROFILE" -p payjoin-ffi | + run_cargo build --message-format=json-render-diagnostics --profile "$PAYJOIN_FFI_PROFILE" -p payjoin-ffi | python3 -c ' import json, os, sys diff --git a/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/ControlledPersisters.kt b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/ControlledPersisters.kt new file mode 100644 index 000000000..259fdd6bb --- /dev/null +++ b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/ControlledPersisters.kt @@ -0,0 +1,86 @@ +package org.payjoindevkit + +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.test.assertFalse +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.supervisorScope + +internal class ControlledPersister : JsonReceiverSessionPersister, JsonSenderSessionPersister { + val events = CopyOnWriteArrayList() + val calls = CopyOnWriteArrayList() + @Volatile var failure: String? = null + @Volatile var closed = false + + private fun before(operation: String) { + calls.add(operation) + if (failure == operation) throw ForeignException.InternalException("storage $operation failed") + } + + override fun save(event: String) { + before("save") + events.add(event) + } + + override fun load(): List { + before("load") + return events.toList() + } + + override fun closeSession() { + before("close") + closed = true + } +} + +internal class ControlledAsyncPersister : JsonReceiverSessionPersisterAsync, JsonSenderSessionPersisterAsync { + val events = CopyOnWriteArrayList() + val calls = CopyOnWriteArrayList() + @Volatile var failure: String? = null + @Volatile var closed = false + @Volatile private var gatedOperation: String? = null + @Volatile private var entered = CompletableDeferred() + @Volatile private var release = CompletableDeferred() + + private suspend fun before(operation: String) { + calls.add(operation) + if (gatedOperation == operation) { + entered.complete(Unit) + release.await() + } + if (failure == operation) throw ForeignException.InternalException("storage $operation failed") + } + + override suspend fun save(event: String) { + before("save") + events.add(event) + } + + override suspend fun load(): List { + before("load") + return events.toList() + } + + override suspend fun closeSession() { + before("close") + closed = true + } + + // Wait until Rust enters the callback, then prove the FFI call remains pending + // before releasing it. Callers bound the whole operation with withTimeout. + suspend fun gated(operation: String, block: suspend () -> T): T = supervisorScope { + entered = CompletableDeferred() + release = CompletableDeferred() + gatedOperation = operation + val call = async { block() } + try { + entered.await() + assertFalse(call.isCompleted, "FFI call returned before $operation callback resumed") + release.complete(Unit) + call.await() + } finally { + release.complete(Unit) + gatedOperation = null + } + } +} 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 3f5617e12..78a0521ae 100644 --- a/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/IntegrationTests.kt +++ b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/IntegrationTests.kt @@ -3,8 +3,13 @@ package org.payjoindevkit import java.util.HexFormat import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull import kotlin.test.assertTrue import kotlin.test.fail +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.boolean @@ -27,7 +32,54 @@ import kotlinx.serialization.json.jsonPrimitive */ class IntegrationTests { @Test - fun v2ToV2Payjoin() { + fun receiverPendingFallbackCloseSync() = runScenario { proposal, persister -> + proposal.cancel().use { transition -> + assertNotNull(transition.save(persister)).use { pending -> + assertTrue(pending.fallbackTx().isNotEmpty()) + replayReceiverEventLog(persister).use { + assertIs(it.state()) + } + assertFalse(persister.closed) + pending.closeSession().use { it.save(persister) } + assertTrue(persister.closed) + replayReceiverEventLog(persister).use { assertIs(it.state()) } + } + } + } + + @Test + fun receiverPendingFallbackCloseAsync() = runScenario { proposal, persister -> + runBlocking { + withTimeout(15_000) { + // Use the same persisted proposal history through the async interface. + val asyncPersister = ControlledAsyncPersister() + asyncPersister.events.addAll(persister.load()) + proposal.cancel().use { transition -> + assertNotNull(transition.saveAsync(asyncPersister)).use { pending -> + assertTrue(pending.fallbackTx().isNotEmpty()) + replayReceiverEventLogAsync(asyncPersister).use { + assertIs(it.state()) + } + assertFalse(asyncPersister.closed) + pending.closeSession().use { transition -> + asyncPersister.gated("close") { transition.saveAsync(asyncPersister) } + } + assertTrue(asyncPersister.closed) + replayReceiverEventLogAsync(asyncPersister).use { + assertIs(it.state()) + } + } + } + } + } + } + + @Test + fun v2ToV2Payjoin() = runScenario() + + private fun runScenario( + cancelReceiver: ((PayjoinProposal, InMemoryReceiverPersister) -> Unit)? = null, + ) { initTracing() TestServices.initialize().use { services -> services.waitForServicesReady() @@ -45,6 +97,7 @@ class IntegrationTests { ohttpKeys, senderRpc, receiverRpc, + cancelReceiver, ) } } @@ -61,6 +114,7 @@ class IntegrationTests { ohttpKeys: OhttpKeys, senderRpc: RpcClient, receiverRpc: RpcClient, + cancelReceiver: ((PayjoinProposal, InMemoryReceiverPersister) -> Unit)?, ) { val receiverAddress = Json.parseToJsonElement(rpc(receiverRpc, "getnewaddress")).jsonPrimitive.content val senderOutpoints = listOutpoints(senderRpc) @@ -95,6 +149,10 @@ class IntegrationTests { // Inside the Receiver: val payjoinProposal = waitForReceiverProposal(session, recvPersister, http, relay, receiverRpc) payjoinProposal.use { + if (cancelReceiver != null) { + cancelReceiver(payjoinProposal, recvPersister) + return + } payjoinProposal.createPostRequest(relay).useDisposable { posted -> val body = http.post(posted.request) payjoinProposal.processResponse(body, posted.clientResponse).use { transition -> diff --git a/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/PersistenceCallbackTests.kt b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/PersistenceCallbackTests.kt new file mode 100644 index 000000000..781eb2e01 --- /dev/null +++ b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/PersistenceCallbackTests.kt @@ -0,0 +1,356 @@ +package org.payjoindevkit + +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout + +class PersistenceCallbackTests { + @Test + fun receiverSaveFailurePropagates() { + val p = ControlledPersister().apply { failure = "save" } + receiverBuilder().use { builder -> + builder.build().use { transition -> + assertFailsWith { + transition.save(p) + }.also { + assertContains(it.toString(), "storage ${p.failure} failed") + } + } + } + assertEquals(listOf("save"), p.calls) + assertTrue(p.events.isEmpty()) + assertFalse(p.closed) + } + + @Test + fun senderSaveFailurePropagates() { + val p = ControlledPersister().apply { failure = "save" } + senderBuilder().use { builder -> + builder.buildRecommended(1000u).use { transition -> + assertFailsWith { + transition.save(p) + }.also { + assertContains(it.toString(), "storage ${p.failure} failed") + } + } + } + assertEquals(listOf("save"), p.calls) + assertTrue(p.events.isEmpty()) + assertFalse(p.closed) + } + + @Test + fun receiverLoadFailurePropagates() { + val p = ControlledPersister().apply { failure = "load" } + assertFailsWith { + replayReceiverEventLog(p) + }.also { + assertContains(it.toString(), "storage ${p.failure} failed") + } + assertEquals(listOf("load"), p.calls) + } + + @Test + fun senderLoadFailurePropagates() { + val p = ControlledPersister().apply { failure = "load" } + assertFailsWith { + replaySenderEventLog(p) + }.also { + assertContains(it.toString(), "storage ${p.failure} failed") + } + assertEquals(listOf("load"), p.calls) + } + + @Test + fun receiverCloseFailurePropagates() { + val p = ControlledPersister() + receiverBuilder().use { builder -> + builder.build().use { it.save(p) }.use { receiver -> + p.failure = "close" + receiver.cancel().use { transition -> + assertFailsWith { + transition.save(p) + }.also { + assertContains(it.toString(), "storage ${p.failure} failed") + } + } + } + } + assertEquals("close", p.calls.last()) + assertFalse(p.closed) + } + + @Test + fun senderCloseFailurePropagates() { + val p = ControlledPersister() + senderBuilder().use { builder -> + builder.buildRecommended(1000u).use { it.save(p) }.use { sender -> + sender.cancel().use { it.save(p) }.use { pending -> + p.failure = "close" + pending.closeSession().use { transition -> + assertFailsWith { + transition.save(p) + }.also { + assertContains(it.toString(), "storage ${p.failure} failed") + } + } + } + } + } + assertEquals("close", p.calls.last()) + assertFalse(p.closed) + } + + @Test + fun receiverAsyncSaveFailureAfterSuspensionPropagates(): Unit = runBlocking { + withTimeout(15_000) { + val p = ControlledAsyncPersister().apply { failure = "save" } + receiverBuilder().use { builder -> + builder.build().use { transition -> + p.gated("save") { + assertFailsWith { + transition.saveAsync(p) + }.also { + assertContains(it.toString(), "storage ${p.failure} failed") + } + } + } + } + assertTrue(p.events.isEmpty()) + assertFalse(p.closed) + } + } + + @Test + fun senderAsyncSaveFailureAfterSuspensionPropagates(): Unit = runBlocking { + withTimeout(15_000) { + val p = ControlledAsyncPersister().apply { failure = "save" } + senderBuilder().use { builder -> + builder.buildRecommended(1000u).use { transition -> + p.gated("save") { + assertFailsWith { + transition.saveAsync(p) + }.also { + assertContains(it.toString(), "storage ${p.failure} failed") + } + } + } + } + assertTrue(p.events.isEmpty()) + assertFalse(p.closed) + } + } + + @Test + fun receiverAsyncLoadFailureAfterSuspensionPropagates(): Unit = runBlocking { + withTimeout(15_000) { + val p = ControlledAsyncPersister().apply { failure = "load" } + p.gated("load") { + assertFailsWith { + replayReceiverEventLogAsync(p) + }.also { + assertContains(it.toString(), "storage ${p.failure} failed") + } + Unit + } + } + } + + @Test + fun senderAsyncLoadFailureAfterSuspensionPropagates(): Unit = runBlocking { + withTimeout(15_000) { + val p = ControlledAsyncPersister().apply { failure = "load" } + p.gated("load") { + assertFailsWith { + replaySenderEventLogAsync(p) + }.also { + assertContains(it.toString(), "storage ${p.failure} failed") + } + Unit + } + } + } + + @Test + fun receiverAsyncCloseFailureAfterSuspensionPropagates(): Unit = runBlocking { + withTimeout(15_000) { + val p = ControlledAsyncPersister() + receiverBuilder().use { builder -> + builder.build().use { it.saveAsync(p) }.use { receiver -> + p.failure = "close" + receiver.cancel().use { transition -> + p.gated("close") { + assertFailsWith { + transition.saveAsync(p) + }.also { + assertContains(it.toString(), "storage ${p.failure} failed") + } + } + } + } + } + assertFalse(p.closed) + } + } + + @Test + fun senderAsyncCloseFailureAfterSuspensionPropagates(): Unit = runBlocking { + withTimeout(15_000) { + val p = ControlledAsyncPersister() + senderBuilder().use { builder -> + builder.buildRecommended(1000u).use { it.saveAsync(p) }.use { sender -> + sender.cancel().use { it.saveAsync(p) }.use { pending -> + p.failure = "close" + pending.closeSession().use { transition -> + p.gated("close") { + assertFailsWith { + transition.saveAsync(p) + }.also { + assertContains(it.toString(), "storage ${p.failure} failed") + } + } + } + } + } + } + assertFalse(p.closed) + } + } + + @Test + fun receiverSaveLoadCloseResumeSuccessfully(): Unit = runBlocking { + withTimeout(15_000) { + val p = ControlledAsyncPersister() + receiverBuilder().use { builder -> + builder.build().use { transition -> + p.gated("save") { transition.saveAsync(p) } + }.use { receiver -> + val replay = p.gated("load") { replayReceiverEventLogAsync(p) } + replay.use { assertIs(it.state()) } + assertFalse(p.closed) + receiver.cancel().use { transition -> + assertNull(p.gated("close") { transition.saveAsync(p) }) + } + assertTrue(p.closed) + replayReceiverEventLogAsync(p).use { assertIs(it.state()) } + } + } + } + } + + @Test + fun senderSaveLoadCloseResumeSuccessfully(): Unit = runBlocking { + withTimeout(15_000) { + val p = ControlledAsyncPersister() + senderBuilder().use { builder -> + builder.buildRecommended(1000u).use { transition -> + p.gated("save") { transition.saveAsync(p) } + }.use { sender -> + val replay = p.gated("load") { replaySenderEventLogAsync(p) } + replay.use { assertIs(it.state()) } + sender.cancel().use { it.saveAsync(p) }.use { pending -> + assertFalse(p.closed) + pending.closeSession().use { transition -> + p.gated("close") { transition.saveAsync(p) } + } + } + assertTrue(p.closed) + replaySenderEventLogAsync(p).use { assertIs(it.state()) } + } + } + } + } + + @Test + fun receiverTransitionSaveFailurePropagates() { + val p = ControlledPersister() + receiverBuilder().use { builder -> + builder.build().use { it.save(p) }.use { receiver -> + p.failure = "save" + val before = p.events.size + receiver.cancel().use { transition -> + val ex = assertFailsWith { transition.save(p) } + assertContains(ex.toString(), "storage save failed") + } + assertEquals(before, p.events.size) + assertFalse(p.closed) + } + } + } + + @Test + fun senderTransitionSaveFailurePropagates() { + val p = ControlledPersister() + senderBuilder().use { builder -> + builder.buildRecommended(1000u).use { it.save(p) }.use { sender -> + p.failure = "save" + val before = p.events.size + sender.cancel().use { transition -> + val ex = assertFailsWith { transition.save(p) } + assertContains(ex.toString(), "storage save failed") + } + assertEquals(before, p.events.size) + assertFalse(p.closed) + } + } + } + + @Test + fun receiverAsyncTransitionSaveFailureAfterSuspensionPropagates(): Unit = runBlocking { + withTimeout(15_000) { + val p = ControlledAsyncPersister() + receiverBuilder().use { builder -> + builder.build().use { it.saveAsync(p) }.use { receiver -> + p.failure = "save" + val before = p.events.size + receiver.cancel().use { transition -> + p.gated("save") { + val ex = assertFailsWith { transition.saveAsync(p) } + assertContains(ex.toString(), "storage save failed") + } + } + assertEquals(before, p.events.size) + assertFalse(p.closed) + } + } + } + } + + @Test + fun senderAsyncTransitionSaveFailureAfterSuspensionPropagates(): Unit = runBlocking { + withTimeout(15_000) { + val p = ControlledAsyncPersister() + senderBuilder().use { builder -> + builder.buildRecommended(1000u).use { it.saveAsync(p) }.use { sender -> + p.failure = "save" + val before = p.events.size + sender.cancel().use { transition -> + p.gated("save") { + val ex = assertFailsWith { transition.saveAsync(p) } + assertContains(ex.toString(), "storage save failed") + } + } + assertEquals(before, p.events.size) + assertFalse(p.closed) + } + } + } + } +} + +private fun receiverBuilder(): ReceiverBuilder = OhttpKeys.decode(ohttpKeysData).use { + ReceiverBuilder("2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK", "https://example.com", it) +} + +private fun senderBuilder(): SenderBuilder = receiverBuilder().use { builder -> + builder.build().use { it.save(ControlledPersister()) }.use { receiver -> + receiver.pjUri().use { SenderBuilder(originalPsbt(), it) } + } +} diff --git a/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/ValidationTests.kt b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/ValidationTests.kt index 9bfc01e6a..b008dd803 100644 --- a/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/ValidationTests.kt +++ b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/ValidationTests.kt @@ -1,10 +1,33 @@ package org.payjoindevkit import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertIs class ValidationTests { + @Test + fun oversizedScriptPreservesNestedError() { + val ex = assertFailsWith { + InputPair(validInput(), PsbtInput(TxOut(1u, ByteArray(10_001) { 0x51 }), null, null), null) + } + val detail = assertIs(ex.v1) + assertEquals(10_001uL, detail.len) + assertEquals(10_000uL, detail.max) + } + + @Test + fun zeroAndExcessiveWeightPreserveNestedError() { + for (weight in listOf(0uL, 4_000_001uL)) { + val ex = assertFailsWith { + InputPair(validInput(), PsbtInput(TxOut(1u, byteArrayOf(0x6a)), null, null), Weight(weight)) + } + val detail = assertIs(ex.v1) + assertEquals(weight, detail.weightUnits) + assertEquals(4_000_000uL, detail.maxWu) + } + } + @Test fun receiverBuilderRejectsBadAddress() { val ohttpKeys = OhttpKeys.decode(ohttpKeysData) @@ -149,3 +172,5 @@ private fun v2PjUri(): PjUri { ohttpKeys, ).build().save(persister).pjUri() } + +private fun validInput() = TxIn(OutPoint("00".repeat(32), 0u), ByteArray(0), 0u, emptyList())