mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
feat(cashu): wire NUT-13 deterministic secrets into the live mint flow
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<keysetId, Long>`)
+ `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
This commit is contained in:
@@ -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<String, Long> = mutableMapOf(),
|
||||
val lastReadPerRoute: MutableStateFlow<Map<String, MutableStateFlow<Long>>> = MutableStateFlow(mapOf()),
|
||||
val hasDonatedInVersion: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
|
||||
val dismissedPollNoteIds: MutableStateFlow<Set<String>> = 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
|
||||
|
||||
|
||||
+22
-1
@@ -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<String, CashuMintOperations>()
|
||||
|
||||
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<TokenEntry>,
|
||||
): MeltCompleted {
|
||||
seedWarmer()
|
||||
if (available.isEmpty()) throw IllegalStateException("No proofs available to spend")
|
||||
|
||||
val ops = ops(mintUrl)
|
||||
@@ -459,6 +478,7 @@ class CashuWalletOps(
|
||||
available: List<TokenEntry>,
|
||||
): 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")
|
||||
|
||||
+54
@@ -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
|
||||
// ============================================================
|
||||
|
||||
+119
@@ -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)
|
||||
}
|
||||
}
|
||||
+25
@@ -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
|
||||
|
||||
+14
-6
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user