diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 376eb247b8..10aec0aa2f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -3401,10 +3401,13 @@ class Account( init { Log.d("AccountRegisterObservers", "Init") - // Bridge CashuWalletOps's publish callback to our `sendLiterallyEverywhere` - // so the state object can push events to relays + cache without holding - // a direct reference back to Account. - cashuWalletState.publishDelegate = { event -> sendLiterallyEverywhere(event) } + // Start the Cashu wallet state observers AFTER all field initializers + // complete — auto-redeem can fire as soon as start() returns, and it + // calls back into sendLiterallyEverywhere which depends on + // followPlusAllMineWithIndex (initialized after cashuWalletState). + // Doing this in start() rather than in the state's own init { } closes + // the race where a publish would land on a half-built Account. + cashuWalletState.start { event -> sendLiterallyEverywhere(event) } // Restore Marmot MLS group state on startup if (marmotManager != null) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt index c975cb54e2..3dae04c837 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt @@ -152,7 +152,23 @@ class CashuWalletState( private val jobs = mutableListOf() private var currentSubscription: CashuWalletQueryState? = null - init { + @Volatile private var started = false + + /** + * Wire the publish bridge and begin observing the cache + relay flows. + * + * Account must call this from its own `init { }` block (after all field + * initializers complete) — that guarantees `sendLiterallyEverywhere` and + * its dependencies (`followPlusAllMineWithIndex`, etc.) are fully + * constructed before the first auto-redeem might fire. Calling start() + * inside the state's own `init { }` would race: the collectors could + * publish via a half-built Account. + */ + fun start(publish: suspend (Event) -> Unit) { + if (started) return + started = true + this.publish = publish + // Backfill from cache once. scope.launch(Dispatchers.Default) { val initial = scanCacheForOwnEvents() @@ -427,13 +443,13 @@ class CashuWalletState( // ============================================================ /** - * Bridge for [CashuWalletOps.publish]. Concrete `Account` plugs in its - * `sendLiterallyEverywhere` via the constructor-time wiring. We keep this - * delegate field separate to avoid an Account ↔ State direct dependency. + * Set exactly once in [start]; before that, every coroutine that could + * call [publishEvent] is gated behind `started` so the no-op default is + * never observed by produced events. */ - var publishDelegate: suspend (Event) -> Unit = { /* set by Account */ } + private var publish: suspend (Event) -> Unit = { error("CashuWalletState.start() not called") } private suspend fun publishEvent(event: Event) { - publishDelegate(event) + publish(event) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintExceptionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintExceptionTest.kt new file mode 100644 index 0000000000..a2d2101588 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintExceptionTest.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip60Cashu.mintApi + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class MintExceptionTest { + @Test + fun httpExceptionPreservesDetail() { + val e = + MintHttpException( + httpStatus = 400, + detail = "amount too small", + code = 11000, + message = "amount too small", + ) + assertEquals(400, e.httpStatus) + assertEquals("amount too small", e.detail) + assertEquals(11000, e.code) + assertEquals("amount too small", e.message) + } + + @Test + fun httpExceptionAllowsNullDetail() { + val e = MintHttpException(httpStatus = 500, detail = null, code = null, message = "HTTP 500") + assertNull(e.detail) + assertNull(e.code) + assertEquals("HTTP 500", e.message) + } + + @Test + fun protocolExceptionCarriesMessage() { + val e = MintProtocolException("Melt not completed (state=UNPAID)") + assertEquals("Melt not completed (state=UNPAID)", e.message) + } + + @Test + fun bothAreRuntimeExceptions() { + // describeMintError lives in amethyst-layer, but at the quartz level we + // can at least confirm both exceptions are runtime — callers don't need + // to declare them. + assertTrue(MintHttpException(200, null, null, "m") is RuntimeException) + assertTrue(MintProtocolException("m") is RuntimeException) + } +}