From d7e447428cb7f7e9a723db47db4c038ce2dbb5cc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 13:59:23 +0000 Subject: [PATCH] feat(cashu): wire NUT-13 deterministic secrets into the live mint flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier commit landed the math primitives + spec-vector tests. This commit threads them into the actual blind-message construction so every mint / swap / melt the wallet performs now uses NUT-13-derived secrets instead of pure randomness. Side effect: kind:7375 loss is no longer permanent funds loss — the wallet can re-derive past secrets from the seed and recover via NUT-09 /v1/restore (driver loop still to come). What's here: - `SecretFactory` strategy in quartz mintApi/. Two impls: RandomSecretFactory — pure random; the new default for tests and legacy callers that don't carry a seed. DeterministicSecretFactory — NUT-13 derivation via a (seedProvider, reserveCounter) pair. Seed is lazy (the wallet decrypts kind:17375 asynchronously); if the cache is empty, it falls back to random, preserving pre-NUT-13 behaviour during the warm-up window. - `CashuMintOperations` constructor now takes a SecretFactory (default: random). `secretOutputFor` delegates to it. The on-wire shape is identical either way — the mint can't tell which scheme we're using. - `CashuWalletOps` constructor takes a SecretFactory + a suspend seedWarmer callback. Every blinding op (mintProofs / meltToLightning / sendNutzap / redeemNutzap) calls seedWarmer() before constructing outputs so the seed cache is warm by the time the synchronous SecretFactory queries it. - `CashuDeterministic.deriveWalletSeed(p2pkPrivkey)` — derives the 64-byte NUT-13 master seed from the wallet's existing P2PK key via `HMAC-SHA512("Cashu-Wallet-Seed-v1", priv)`. No new field on kind:17375 needed; existing wallets become recoverable on first use. Distinct key-derivation domain so leaking a Cashu secret doesn't expose the P2PK key. - `CashuWalletState.cachedSeed` (@Volatile) + `ensureSeed()` (suspend) — derives once on first call (paying the signer round-trip for the P2PK key), caches for the wallet's lifetime. Pure function of the P2PK key, so the cache never invalidates. - `AccountSettings.cashuKeysetCounters` (`MutableMap`) + `reserveCashuCounters(keysetId, count)` — atomic, persistent read-modify-write that hands out a strictly-monotonic counter range. Two contracts the spec demands: never reuse a (seed, keysetId, counter) tuple, and persist the increment BEFORE the secret is used. Both satisfied — the saveAccountSettings call happens inside the @Synchronized block before reserveCashuCounters returns. - `peekCashuCounter(keysetId)` — read-only inspector for the upcoming NUT-09 restore driver loop (needs to know how high to scan). Approach (a) — derive seed from the existing P2PK key — was chosen over (b) — add a new BIP-39 mnemonic field to kind:17375 — because: - Zero migration: every existing wallet becomes recoverable on next op, no save-and-republish required. - The P2PK key is already the recovery-critical secret in kind:17375. Anyone with the kind:17375 can derive the seed and reconstruct everything; same threat model as today. - Cross-wallet recovery via mnemonic is a separate UX feature we can layer later by also storing/importing a mnemonic when the user wants that contract. Backwards compatibility: SecretFactory defaults to RandomSecretFactory on CashuMintOperations and CashuWalletOps, so existing direct constructions (mint-operations tests, ad-hoc helper code) keep their previous behaviour. Production goes through CashuWalletState, which injects the deterministic factory. https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP --- .../amethyst/model/AccountSettings.kt | 37 ++++++ .../model/nip60Cashu/CashuWalletOps.kt | 23 +++- .../model/nip60Cashu/CashuWalletState.kt | 54 ++++++++ .../nip60Cashu/mintApi/SecretFactory.kt | 119 ++++++++++++++++++ .../nip60Cashu/seed/CashuDeterministic.kt | 25 ++++ .../nip60Cashu/mintApi/CashuMintOperations.kt | 20 ++- 6 files changed, 271 insertions(+), 7 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/SecretFactory.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 482591ae49..5ac3b1047c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -206,6 +206,19 @@ class AccountSettings( var backupTrustProviderList: TrustProviderListEvent? = null, var backupCashuWallet: CashuWalletEvent? = null, var backupNutzapInfo: NutzapInfoEvent? = null, + /** + * NUT-13 deterministic-secret counter map, keyed by keyset id. The + * wallet derives every blind message from `(seed, keysetId, counter)`, + * incrementing the counter every time it consumes one; reusing a + * counter would expose the secret. Persisted here so the counter + * survives app restart even though the wallet's seed is also stored + * in kind:17375 (which would otherwise be the only persistence). + * + * Empty map = no NUT-13 usage yet (e.g. wallet created before this + * feature shipped). Per-keyset; the same counter under different + * keysets is fine because the derivation includes the keyset id. + */ + var cashuKeysetCounters: MutableMap = mutableMapOf(), val lastReadPerRoute: MutableStateFlow>> = MutableStateFlow(mapOf()), val hasDonatedInVersion: MutableStateFlow> = MutableStateFlow(setOf()), val dismissedPollNoteIds: MutableStateFlow> = MutableStateFlow(setOf()), @@ -794,6 +807,30 @@ class AccountSettings( } } + /** + * Reserve [count] consecutive NUT-13 counters for [keysetId], returning + * the first one. Caller derives `(secret, r)` from `(seed, keysetId, i)` + * for `i in [returned .. returned+count-1]`. Persisted immediately so + * a crash mid-mint doesn't reuse the same counter on next launch. + * + * Synchronized to make the read-modify-write atomic — two coroutines + * minting concurrently must each get their own counter range. + */ + @Synchronized + fun reserveCashuCounters( + keysetId: String, + count: Int, + ): Long { + require(count > 0) { "Counter reservation must be positive" } + val current = cashuKeysetCounters[keysetId] ?: 0L + cashuKeysetCounters[keysetId] = current + count.toLong() + saveAccountSettings() + return current + } + + /** Inspect the next counter for [keysetId] without consuming any. */ + fun peekCashuCounter(keysetId: String): Long = cashuKeysetCounters[keysetId] ?: 0L + fun updateNIPA3PaymentTargets(newNIPA3PaymentTargets: PaymentTargetsEvent?) { if (newNIPA3PaymentTargets == null || newNIPA3PaymentTargets.tags.isEmpty()) return diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt index 5e810d340c..b6fbe71335 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt @@ -41,6 +41,8 @@ import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintHttpClient import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintHttpException import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintProtocolException import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintQuoteBolt11ResponseDto +import com.vitorpamplona.quartz.nip60Cashu.mintApi.RandomSecretFactory +import com.vitorpamplona.quartz.nip60Cashu.mintApi.SecretFactory import com.vitorpamplona.quartz.nip60Cashu.p2pk.P2PK import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent import com.vitorpamplona.quartz.nip60Cashu.token.CashuProof @@ -77,12 +79,27 @@ class CashuWalletOps( private val signer: NostrSigner, private val publish: suspend (Event) -> Unit, private val okHttpClient: (String) -> OkHttpClient, + /** + * NUT-13 secret strategy. Defaults to random for backwards + * compatibility with tests that don't carry a seed. The wallet state + * supplies a [DeterministicSecretFactory] in production so kind:7375 + * loss is recoverable via NUT-09 /v1/restore. + */ + private val secretFactory: SecretFactory = RandomSecretFactory, + /** + * Suspend callback that ensures the NUT-13 seed is materialised in + * the caller's cache before any blinding op runs. The factory above + * reads that cache synchronously — without warming first, a fresh + * wallet falls back to random secrets for its very first mint. + * Default is a no-op for tests / random-only callers. + */ + private val seedWarmer: suspend () -> Unit = {}, ) { private val opsCache = ConcurrentHashMap() private fun ops(mintUrl: String): CashuMintOperations = opsCache.getOrPut(mintUrl.trimEnd('/')) { - CashuMintOperations(MintHttpClient(mintUrl, okHttpClient)) + CashuMintOperations(MintHttpClient(mintUrl, okHttpClient), secretFactory) } /** @@ -187,6 +204,7 @@ class CashuWalletOps( quoteEvent: CashuMintQuoteEvent, amountSats: Long, ): MintCompleted { + seedWarmer() val quoteId = quoteEvent.quoteId(signer) val minted = ops(mintUrl).mintProofs(quoteId, amountSats) @@ -246,6 +264,7 @@ class CashuWalletOps( quote: MeltQuoteBolt11ResponseDto, available: List, ): MeltCompleted { + seedWarmer() if (available.isEmpty()) throw IllegalStateException("No proofs available to spend") val ops = ops(mintUrl) @@ -459,6 +478,7 @@ class CashuWalletOps( available: List, ): NutzapSent { if (amountSats <= 0) throw IllegalArgumentException("Amount must be positive") + seedWarmer() val (selected, totalSelected) = selectProofsCovering(available, amountSats) if (totalSelected < amountSats) throw IllegalStateException("Insufficient balance for $mintUrl") @@ -556,6 +576,7 @@ class CashuWalletOps( walletPrivkeyHex: String, walletP2pkPubkeyHex: String, ): RedeemCompleted { + seedWarmer() val mintUrl = nutzap.mintUrl() ?: throw IllegalArgumentException("Nutzap has no mint tag") 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 6a44ab4d12..4306d60456 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 @@ -33,7 +33,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent +import com.vitorpamplona.quartz.nip60Cashu.mintApi.DeterministicSecretFactory import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent +import com.vitorpamplona.quartz.nip60Cashu.seed.CashuDeterministic import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent import com.vitorpamplona.quartz.nip60Cashu.token.TokenContent import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent @@ -97,6 +99,20 @@ class CashuWalletState( signer = signer, publish = ::publishEvent, okHttpClient = okHttpClient, + // NUT-13 wiring: the factory closure reads the cached seed at + // mint-op time. cachedSeed is populated by ensureSeed() — + // CashuWalletOps' seedWarmer below calls it before any blind + // op so the cache is warm. When the cache is empty (no wallet + // decrypted yet) the factory falls back to random, matching + // pre-NUT-13 behaviour. Counter allocation is synchronous + + // persistent via AccountSettings; an atomic read-modify-write + // makes concurrent mints safe. + secretFactory = + DeterministicSecretFactory( + seedProvider = ::cachedSeedOrNull, + reserveCounter = { keysetId -> settings.reserveCashuCounters(keysetId, count = 1) }, + ), + seedWarmer = { ensureSeed() }, ) // ============================================================ @@ -200,6 +216,44 @@ class CashuWalletState( runCatching { evt.privkey(signer) }.getOrNull() } + /** + * Cached NUT-13 master seed derived from the wallet's P2PK private + * key. Volatile + double-checked lazy init — the seed never changes + * for a given wallet (it's a pure function of the P2PK key, which is + * a constant in kind:17375), so once derived we hold it for the + * wallet's lifetime. Null until the first time something asks. + * + * Derivation: HMAC-SHA512("Cashu-Wallet-Seed-v1", p2pk_priv) — yields + * a 64-byte seed shaped like BIP-39's PBKDF2 output. We use HMAC + * rather than the raw private key so any downstream NIP-44-style + * leakage of secrets derived from `seed` doesn't compromise the + * P2PK key itself. + */ + @Volatile private var cachedSeed: ByteArray? = null + + /** + * Fetch (and cache on first call) the NUT-13 master seed. Returns + * null when the wallet hasn't decrypted its kind:17375 yet — the + * secret factory falls back to random in that window. Once the seed + * is cached, every subsequent mint operation gets deterministic + * secrets and the counter advances monotonically. + */ + private suspend fun ensureSeed(): ByteArray? { + cachedSeed?.let { return it } + val priv = walletPrivkeyHex() ?: return null + val seed = CashuDeterministic.deriveWalletSeed(priv.hexToByteArray()) + cachedSeed = seed + return seed + } + + /** + * Synchronous seed accessor for the [SecretFactory] thunk. Returns + * whatever's already in [cachedSeed] — does NOT trigger derivation + * (which is suspend). Callers must invoke [ensureSeed] before the + * mint op so the cache is warm by the time the factory queries it. + */ + private fun cachedSeedOrNull(): ByteArray? = cachedSeed + // ============================================================ // Lifecycle // ============================================================ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/SecretFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/SecretFactory.kt new file mode 100644 index 0000000000..30cb80a25b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/SecretFactory.kt @@ -0,0 +1,119 @@ +/* + * 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 com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip60Cashu.bdhke.Bdhke +import com.vitorpamplona.quartz.nip60Cashu.seed.CashuDeterministic + +/** + * A pair of secret + blinding factor for one BDHKE blind message. + * - [secretHex] is the lowercase hex of the 32-byte secret. NUT-00 + * specifies the secret is the UTF-8 encoding of this hex string, so + * consumers always call `secretHex.encodeToByteArray()` before + * passing to `Bdhke.blind` / `hashToCurve`. + * - [blindingFactor] is 32 raw bytes, a valid secp256k1 scalar. + */ +data class DerivedSecret( + val secretHex: String, + val blindingFactor: ByteArray, +) { + override fun equals(other: Any?): Boolean = + other is DerivedSecret && + other.secretHex == secretHex && + other.blindingFactor.contentEquals(blindingFactor) + + override fun hashCode(): Int = 31 * secretHex.hashCode() + blindingFactor.contentHashCode() +} + +/** + * Strategy for producing the (secret, r) pairs that go into BDHKE blind + * messages. Two impls today: + * - [RandomSecretFactory] — fresh randomness for each call. Default. + * Forwards-compatible with NUT-09 restore in the sense that the mint + * will still hand back the proofs if we have the secrets — but we + * won't have them after a wallet-event loss. + * - [DeterministicSecretFactory] — NUT-13 derivation from a seed plus + * a per-keyset counter. The wallet can re-derive past secrets after a + * catastrophic kind:7375 loss and recover via NUT-09 /v1/restore. + * + * The factory is keyset-aware because NUT-13 derivation depends on the + * keyset id (different keysets yield different secrets from the same + * seed+counter), and the counter is per-keyset. + */ +interface SecretFactory { + /** Mint one (secret, r) pair for use on the specified keyset. */ + fun nextSecret(keysetId: String): DerivedSecret +} + +/** + * Random secret + random blinding factor. No deterministic recovery — + * losing the kind:7375 token events is permanent funds loss. Suitable + * as a fallback when no seed is available (e.g. a wallet that + * pre-dates the NUT-13 wiring). + */ +object RandomSecretFactory : SecretFactory { + override fun nextSecret(keysetId: String): DerivedSecret { + val secret = Bdhke.randomSecret() + val r = Bdhke.randomScalar() + return DerivedSecret(secret.toHexKey(), r) + } +} + +/** + * NUT-13 deterministic secret factory. + * + * [seedProvider] is a thunk that returns the wallet's seed when + * available, null when the wallet hasn't decrypted its kind:17375 yet. + * Lazy-resolving instead of taking the seed up-front lets the factory + * be constructed at wallet-state init time (before any signer round- + * trip) — when the seed isn't ready, the factory transparently falls + * back to [fallback], preserving the no-NUT-13 invariant. + * + * [reserveCounter] is a thunk that atomically increments and returns the + * previous value for a given keyset id — that's the wallet's persistent + * counter state, NOT a fresh random index. + * + * Two reserveCounter contracts the caller MUST honour: + * 1. Returned counters are STRICTLY MONOTONIC per keyset id. Reusing one + * under the same (seed, keysetId) reuses the same (secret, r) pair — + * reveals the seed-derivation relationship and could let an observer + * correlate proofs across mints. + * 2. The increment is PERSISTED before the secret is actually used in a + * mint request. Otherwise a crash mid-mint reuses on next launch. + * + * `AccountSettings.reserveCashuCounters` satisfies both. + */ +class DeterministicSecretFactory( + private val seedProvider: () -> ByteArray?, + private val reserveCounter: (keysetId: String) -> Long, + private val fallback: SecretFactory = RandomSecretFactory, +) : SecretFactory { + override fun nextSecret(keysetId: String): DerivedSecret { + val seed = seedProvider() ?: return fallback.nextSecret(keysetId) + val counter = reserveCounter(keysetId) + // CashuDeterministic.secretBytes returns the raw 32 bytes; the + // hex form is what BDHKE/proof storage actually use. + val secretHex = CashuDeterministic.secretBytes(seed, keysetId, counter).toHexKey() + val r = CashuDeterministic.blindingFactor(seed, keysetId, counter) + return DerivedSecret(secretHex, r) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/seed/CashuDeterministic.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/seed/CashuDeterministic.kt index ab2e11e026..6c5cd9f8e1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/seed/CashuDeterministic.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/seed/CashuDeterministic.kt @@ -73,6 +73,31 @@ object CashuDeterministic { private const val CASHU_PURPOSE: Long = 129372L private const val CASHU_COIN_TYPE: Long = 0L private val V01_HMAC_PREFIX = "Cashu_KDF_HMAC_SHA256".encodeToByteArray() + private val WALLET_SEED_KEY = "Cashu-Wallet-Seed-v1".encodeToByteArray() + + /** + * Derive a 64-byte NUT-13 master seed from a wallet's P2PK private + * key. Useful for wallets that don't (yet) carry a BIP-39 mnemonic + * but do persist a long-lived private key — the kind:17375 wallet + * event is the canonical example. + * + * Derivation: `HMAC-SHA512("Cashu-Wallet-Seed-v1", p2pk_priv)`. + * - Output shape (64 bytes) matches BIP-39's PBKDF2-HMAC-SHA512 + * output so existing NUT-13 derivation paths accept it unchanged. + * - Using HMAC ensures any leakage of secrets derived from the seed + * doesn't reveal the P2PK key itself — distinct key-derivation + * domains. + * - The "v1" tag isolates this derivation from any future scheme + * we want to roll out without breaking existing wallets. + * + * Idempotent and pure; safe to recompute on every wallet load. + */ + fun deriveWalletSeed(p2pkPrivkey: ByteArray): ByteArray { + require(p2pkPrivkey.size == 32) { "P2PK private key must be 32 bytes" } + val mac = MacInstance("HmacSHA512", WALLET_SEED_KEY) + mac.update(p2pkPrivkey) + return mac.doFinal() + } /** * 32 raw bytes of the derived secret. The Cashu protocol uses these diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt index 603a3e861d..eea34b7188 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt @@ -38,6 +38,13 @@ import com.vitorpamplona.quartz.nip60Cashu.token.TokenContent */ class CashuMintOperations( private val client: MintHttpClient, + /** + * NUT-13 strategy for secret + blinding-factor generation. Defaults to + * pure-random; the wallet supplies a [DeterministicSecretFactory] when + * it has a seed available so future kind:7375 losses can be recovered + * via NUT-09 /v1/restore. + */ + private val secretFactory: SecretFactory = RandomSecretFactory, ) { /** * Step 1 of mint-from-LN: ask the mint for a bolt11 invoice for `amount` @@ -307,12 +314,13 @@ class CashuMintOperations( amount: Long, keyset: KeysetDto, ): BlindOutput { - val secret = Bdhke.randomSecret() - val r = Bdhke.randomScalar() - // NUT-00 spec: secret is a hex string of 32 random bytes. - val secretHex = secret.toHexKey() - val bTick = Bdhke.blind(secretHex.encodeToByteArray(), r) - return BlindOutput(amount, keyset.id, r, secretHex, bTick) + // NUT-00: secret is a UTF-8 hex string of 32 secret bytes. + // [secretFactory] decides whether those bytes are pure-random or + // NUT-13-derived from a wallet seed; either way the on-wire shape + // is identical so the mint can't tell which scheme we're using. + val derived = secretFactory.nextSecret(keyset.id) + val bTick = Bdhke.blind(derived.secretHex.encodeToByteArray(), derived.blindingFactor) + return BlindOutput(amount, keyset.id, derived.blindingFactor, derived.secretHex, bTick) } /**