From 124dcafdf980b2ad2e6c848a2de59f349dd88aaa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 18:18:38 +0000 Subject: [PATCH] fix(cashu): re-sign on adopt so a deleted wallet can't be re-deleted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The find-or-create wizard can surface a wallet the user previously DELETED — a relay that missed the kind:5 still serves the kind:17375 to the crawl. adoptDiscoveredWallet rebroadcast that event verbatim (same id, same created_at), which loses to the prior NIP-09 deletion two ways: DeletionEvent.build emits both an `e` tag (old id) and an `a` tag (the replaceable 17375:pubkey: address), so relays reject the duplicate id and re-delete every version with created_at <= the deletion's the moment the kind:5 propagates back — on relays and in our own LocalCache. The "reactivated" wallet would then silently vanish. Adopt now re-signs a FRESH kind:17375 + kind:10019 (via publishWalletEvents) with the discovered wallet's own mints and P2PK key. A new id escapes the `e`-tag delete and created_at=now escapes the `a`-tag delete, while the same key + mints preserve the nutzap address and all recoverable funds (the NUT-13 seed derives from the key, not the event id). Falls back to a verbatim rebroadcast only if the wallet can't be decrypted. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EqmMR2QiULS5QGosSgSQAe --- .../model/nip60Cashu/CashuWalletState.kt | 50 ++++++++++++++----- 1 file changed, 38 insertions(+), 12 deletions(-) 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 591c7cb0ba..9ff58c3534 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 @@ -1146,25 +1146,51 @@ class CashuWalletState( } /** - * Adopt a discovered wallet as the account's live + main wallet: consume - * it into [LocalCache] (so [applyEvents] indexes it and persists the - * on-disk backup, exactly like the launch-time restore) and rebroadcast - * it — plus any discovered kind:10019 — to the user's outbox via the - * publish bridge, so the next launch finds it without another crawl. + * Adopt a discovered wallet as the account's live + main wallet by + * **re-signing a fresh** kind:17375 + kind:10019 with the discovered + * wallet's own mints and P2PK key. * - * kind:17375 is replaceable, so rebroadcasting the user's own newest - * event is all it takes to make it canonical. + * We deliberately do NOT rebroadcast the discovered event verbatim. The + * crawl may surface a wallet the user already DELETED: the user's own + * NIP-09 kind:5 (from [CashuWalletOps.deleteWallet]) carries both an `e` + * tag (the old event id) and an `a` tag (the replaceable `17375:pubkey:` + * address). Re-publishing the same event loses on both — relays that + * honored the deletion reject the duplicate id, and the `a`-tag rule + * re-deletes any version with `created_at <= deletion.created_at` the + * moment the kind:5 propagates back (on relays and in our own LocalCache). + * + * A freshly-signed event sidesteps both: a new id isn't covered by the + * `e` tag, and `created_at = now` is newer than the past deletion so it + * survives the `a`-tag rule. Same key + mints means the same nutzap + * address and the same recoverable funds (the NUT-13 seed is derived from + * the key, independent of the event id). [nutzapInfo] is unused in this + * path — publishWalletEvents re-issues a fresh kind:10019 advertising our + * current inbox relays — but is kept for the decrypt-failure fallback. */ suspend fun adoptDiscoveredWallet( wallet: CashuWalletEvent, nutzapInfo: NutzapInfoEvent? = null, ) { check(started) { NOT_STARTED_MESSAGE } - LocalCache.justConsumeMyOwnEvent(wallet) - publishEvent(wallet) - if (nutzapInfo != null) { - LocalCache.justConsumeMyOwnEvent(nutzapInfo) - publishEvent(nutzapInfo) + val config = decryptDiscoveredWallet(wallet) + if (config != null && config.mints.isNotEmpty()) { + ops.publishWalletEvents( + mints = config.mints, + p2pkPrivkeyHex = config.privkeyHex, + nutzapRelays = inboxRelaysFlow.value.toList(), + ) + } else { + // Couldn't decrypt (shouldn't happen for a wallet the wizard + // surfaced as valid) — fall back to rebroadcasting the raw event so + // it's at least findable. This keeps the original id/created_at and + // so remains vulnerable to a prior deletion, but it's the best we + // can do without the plaintext to re-sign from. + LocalCache.justConsumeMyOwnEvent(wallet) + publishEvent(wallet) + if (nutzapInfo != null) { + LocalCache.justConsumeMyOwnEvent(nutzapInfo) + publishEvent(nutzapInfo) + } } }