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())