mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
feat(cashu): NUT-13 deterministic secrets + NUT-09 restore endpoint
Foundation for "lose your kind:7375 token events → still recover the
funds from a seed" — currently a permanent funds loss because secrets
are purely random and the mint won't return proofs the wallet can't
prove ownership of. NUT-13 fixes that by deriving every (secret, r)
pair from a seed + per-keyset counter, and NUT-09 gives the wallet a
way to ask the mint "which of these blinded messages have you signed?"
so a fresh wallet can reconstruct historic proofs.
What's here:
- `CashuDeterministic` (quartz commonMain) — full NUT-13 derivation
for both v00 and v01 keyset id formats. The two cases need different
algorithms:
v00 (8-byte id): BIP-32 hardened derivation at
`m/129372'/0'/{keyset_id_int}'/{counter}'/{0|1}`
keyset_id_int = int.from_bytes(keyset_id) % (2^31 - 1)
v01 (33-byte id): HMAC-SHA256 directly off the seed —
base = "Cashu_KDF_HMAC_SHA256" || keyset_id || counter_be64
secret = HMAC(seed, base || 0x00)
r = HMAC(seed, base || 0x01)
The v01 swap is necessary because a 33-byte id can't faithfully
encode into a 31-bit BIP-32 child index without lossy collapse.
First byte of the id selects the branch (`00` → v00, `01` → v01).
- `RestoreRequestDto` / `RestoreResponseDto` + `/v1/restore` on the
mint HTTP client (NUT-09). Wallet sends a batch of blinded
messages; mint echoes back the subset it has previously signed +
the issued signatures. Caller is responsible for the scan loop
(try counters [0..N], stop after M consecutive empty batches).
Tests against the verbatim NUT-13 spec vectors (mnemonic = "half
depart obvious quality work element tank gorilla view sugar picture
humble", both v00 and v01 keysets, counters 0–4):
- v00: 5 counters × {secret, blinding} = 10 vector checks
- v01: 2 counters × {secret, blinding} = 4 vector checks (samples)
- 4 sanity properties: leaf 0 ≠ leaf 1, counter advances change
output, different keysets produce different secrets, secretAsAscii
is exactly 64 lowercase hex chars
- 3 keysetIdToInt edge cases: zero, small, fits-in-31-bits
Initial implementation had v00 working but v01 producing wrong output
— spent some time confirming the spec actually swaps algorithm based
on version byte (cashu-ts and nutshell both do; spec text doesn't
make this obvious). The cashubtc/nutshell `_derive_secret_hmac_sha256`
is the authoritative reference for the v01 path.
Not yet wired into CashuMintOperations — that's the next commit
(per-keyset counter persistence, deterministic blinding on mint/swap,
the actual restore() driver loop, UI for "recover from seed"). This
commit lands the math primitives + endpoint plumbing so the
follow-up is integration only, no further protocol decisions.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
This commit is contained in:
+22
@@ -106,6 +106,28 @@ data class BlindedMessageDto(
|
||||
@SerialName("B_") val bTick: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* NUT-09 restore request: send a batch of blinded messages we may have
|
||||
* previously asked the mint to sign. The mint echoes back the subset of
|
||||
* those it has indeed signed so we can unblind them.
|
||||
*/
|
||||
@Serializable
|
||||
data class RestoreRequestDto(
|
||||
val outputs: List<BlindedMessageDto>,
|
||||
)
|
||||
|
||||
/**
|
||||
* NUT-09 restore response: parallel arrays of original outputs the mint
|
||||
* recognised plus the signatures it issued for them. Length of [outputs]
|
||||
* == length of [signatures] and indexes correspond; the mint omits any
|
||||
* blinded messages it never signed.
|
||||
*/
|
||||
@Serializable
|
||||
data class RestoreResponseDto(
|
||||
val outputs: List<BlindedMessageDto>,
|
||||
val signatures: List<BlindSignatureDto>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BlindSignatureDto(
|
||||
val amount: Long,
|
||||
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
/*
|
||||
* 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.seed
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip06KeyDerivation.Bip32SeedDerivation
|
||||
import com.vitorpamplona.quartz.nip06KeyDerivation.Hardener
|
||||
import com.vitorpamplona.quartz.nip06KeyDerivation.KeyPath
|
||||
import com.vitorpamplona.quartz.utils.mac.MacInstance
|
||||
|
||||
/**
|
||||
* NUT-13 deterministic secret and blinding-factor derivation.
|
||||
*
|
||||
* Without NUT-13, every secret and blinding factor is freshly random, which
|
||||
* means losing the kind:7375 token events (rogue relay, NIP-09 from a
|
||||
* compromised key) is permanent loss of funds: the mint has the proofs but
|
||||
* we can't ask for them back because we don't know what we asked for. With
|
||||
* NUT-13, every (secret, r) pair is derived deterministically from a single
|
||||
* seed plus a per-keyset counter, so the wallet can re-derive past secrets
|
||||
* and ask the mint to return any blind signatures it still holds via
|
||||
* NUT-09 /v1/restore.
|
||||
*
|
||||
* The seed input is the standard BIP-39 root seed (64 bytes from
|
||||
* [Bip39Mnemonics.toSeed]).
|
||||
*
|
||||
* Two derivation paths, branching on keyset-id version (first byte):
|
||||
*
|
||||
* - **v00 keysets** (8-byte id, hex starts with `00`): BIP-32 hardened
|
||||
* derivation at
|
||||
* `m/129372'/0'/{keyset_id_int}'/{counter}'/{leaf}`
|
||||
* where leaf is 0 for the secret, 1 for the blinding factor.
|
||||
* `keyset_id_int` is the keyset's hex parsed as a big-endian integer
|
||||
* mod 2^31 - 1 (fits a 31-bit BIP-32 hardened child index).
|
||||
*
|
||||
* - **v01 keysets** (33-byte id, hex starts with `01`): HMAC-SHA256
|
||||
* derivation directly off the seed — BIP-32 hardened indices are 32
|
||||
* bits, which can't faithfully encode a 33-byte id, so the protocol
|
||||
* swaps to HMAC for v01. Spec:
|
||||
* ```
|
||||
* base = "Cashu_KDF_HMAC_SHA256" || keyset_id || counter_be64
|
||||
* secret = HMAC_SHA256(seed, base || 0x00)
|
||||
* r = HMAC_SHA256(seed, base || 0x01)
|
||||
* ```
|
||||
*
|
||||
* 129372' is the UTF-8 codepoint for 🥜 ("peanuts"); coin-type 0' is fixed
|
||||
* by the spec.
|
||||
*
|
||||
* Output: the 32-byte private key at the derived path is the secret /
|
||||
* blinding factor directly. Per NUT-13 §1, the SECRET — which goes into
|
||||
* the kind:7375 token JSON — is the lowercase hex string of those 32 bytes
|
||||
* (utf-8 encoded), NOT the raw bytes. [secretBytes] returns the raw bytes;
|
||||
* [secretAsAscii] wraps the hex encoding the wallet ships on the wire.
|
||||
*/
|
||||
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()
|
||||
|
||||
/**
|
||||
* 32 raw bytes of the derived secret. The Cashu protocol uses these
|
||||
* bytes as the ASCII-hex string they encode to — see [secretAsAscii].
|
||||
*/
|
||||
fun secretBytes(
|
||||
seed: ByteArray,
|
||||
keysetId: String,
|
||||
counter: Long,
|
||||
): ByteArray = derive(seed, keysetId, counter, leaf = 0L)
|
||||
|
||||
/**
|
||||
* NUT-13 §1: "The secret is the UTF-8 encoded hex-string". This is
|
||||
* what goes into the kind:7375 proof `secret` field and what the
|
||||
* mint hashes in hash-to-curve.
|
||||
*/
|
||||
fun secretAsAscii(
|
||||
seed: ByteArray,
|
||||
keysetId: String,
|
||||
counter: Long,
|
||||
): ByteArray = bytesToLowercaseHex(secretBytes(seed, keysetId, counter)).encodeToByteArray()
|
||||
|
||||
/**
|
||||
* 32 raw bytes of the BDHKE blinding factor `r`. Used as the second
|
||||
* argument to [com.vitorpamplona.quartz.nip60Cashu.bdhke.Bdhke.blind].
|
||||
*/
|
||||
fun blindingFactor(
|
||||
seed: ByteArray,
|
||||
keysetId: String,
|
||||
counter: Long,
|
||||
): ByteArray = derive(seed, keysetId, counter, leaf = 1L)
|
||||
|
||||
/**
|
||||
* Spec: `int.from_bytes(bytes.fromhex(keyset_id_hex), "big") % (2^31 - 1)`.
|
||||
* Modulo by Mersenne-31 keeps the result inside a 32-bit BIP-32 hardened
|
||||
* index (top bit is the hardened flag, so only 31 bits are usable).
|
||||
*/
|
||||
fun keysetIdToInt(keysetId: String): Long {
|
||||
// BigInteger isn't available in commonMain; the keyset id is at most
|
||||
// 33 bytes today (1 version byte + 32 hex chars for v01). Compute the
|
||||
// mod-(2^31-1) incrementally — `acc = (acc * 256 + byte) % M` keeps
|
||||
// every intermediate in Long range.
|
||||
val bytes = keysetId.hexToByteArray()
|
||||
val mersenne31 = 0x7FFFFFFFL
|
||||
var acc = 0L
|
||||
for (b in bytes) {
|
||||
acc = (acc * 256L + (b.toLong() and 0xFFL)) % mersenne31
|
||||
}
|
||||
return acc
|
||||
}
|
||||
|
||||
private fun derive(
|
||||
seed: ByteArray,
|
||||
keysetId: String,
|
||||
counter: Long,
|
||||
leaf: Long,
|
||||
): ByteArray {
|
||||
require(seed.isNotEmpty()) { "Seed cannot be empty" }
|
||||
require(counter >= 0L) { "Counter must be non-negative" }
|
||||
require(leaf == 0L || leaf == 1L) { "Leaf must be 0 (secret) or 1 (blinding factor)" }
|
||||
|
||||
val keysetIdBytes = keysetId.hexToByteArray()
|
||||
// First byte is the version flag — v00 keysets use BIP-32 hardened
|
||||
// derivation; v01 uses HMAC-SHA256 because a 33-byte keyset id
|
||||
// doesn't fit a 31-bit BIP-32 child index without lossy modular
|
||||
// collapse. Treat unknown / missing version as v00 for forwards-
|
||||
// compat with any short legacy ids.
|
||||
val isV01 = keysetIdBytes.isNotEmpty() && keysetIdBytes[0].toInt() == 0x01
|
||||
return if (isV01) {
|
||||
deriveV01(seed, keysetIdBytes, counter, leaf)
|
||||
} else {
|
||||
deriveV00(seed, keysetId, counter, leaf)
|
||||
}
|
||||
}
|
||||
|
||||
private fun deriveV00(
|
||||
seed: ByteArray,
|
||||
keysetId: String,
|
||||
counter: Long,
|
||||
leaf: Long,
|
||||
): ByteArray {
|
||||
val keysetInt = keysetIdToInt(keysetId)
|
||||
val path =
|
||||
KeyPath(
|
||||
listOf(
|
||||
Hardener.hardened(CASHU_PURPOSE),
|
||||
Hardener.hardened(CASHU_COIN_TYPE),
|
||||
Hardener.hardened(keysetInt),
|
||||
Hardener.hardened(counter),
|
||||
leaf,
|
||||
),
|
||||
)
|
||||
val derivation = Bip32SeedDerivation()
|
||||
val master = derivation.generate(seed)
|
||||
return derivation.derivePrivateKey(master, path)
|
||||
}
|
||||
|
||||
private fun deriveV01(
|
||||
seed: ByteArray,
|
||||
keysetIdBytes: ByteArray,
|
||||
counter: Long,
|
||||
leaf: Long,
|
||||
): ByteArray {
|
||||
// base = "Cashu_KDF_HMAC_SHA256" || keyset_id || counter_be64
|
||||
val counterBytes = ByteArray(8)
|
||||
for (i in 0 until 8) {
|
||||
counterBytes[7 - i] = ((counter ushr (i * 8)) and 0xFFL).toByte()
|
||||
}
|
||||
val baseLen = V01_HMAC_PREFIX.size + keysetIdBytes.size + counterBytes.size
|
||||
val message = ByteArray(baseLen + 1)
|
||||
V01_HMAC_PREFIX.copyInto(message, 0)
|
||||
keysetIdBytes.copyInto(message, V01_HMAC_PREFIX.size)
|
||||
counterBytes.copyInto(message, V01_HMAC_PREFIX.size + keysetIdBytes.size)
|
||||
message[baseLen] = leaf.toByte() // 0x00 for secret, 0x01 for blinding factor
|
||||
|
||||
val mac = MacInstance("HmacSHA256", seed)
|
||||
mac.update(message)
|
||||
return mac.doFinal()
|
||||
}
|
||||
|
||||
/**
|
||||
* Lowercase 0-9a-f without StringBuilder allocs in inner loops —
|
||||
* matches what the spec demands (`bytes.hex()` in the Python ref impl).
|
||||
*/
|
||||
private fun bytesToLowercaseHex(bytes: ByteArray): String {
|
||||
val chars = CharArray(bytes.size * 2)
|
||||
for (i in bytes.indices) {
|
||||
val v = bytes[i].toInt() and 0xFF
|
||||
chars[i * 2] = HEX_CHARS[v ushr 4]
|
||||
chars[i * 2 + 1] = HEX_CHARS[v and 0xF]
|
||||
}
|
||||
return String(chars)
|
||||
}
|
||||
|
||||
private val HEX_CHARS = charArrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f')
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* 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.seed
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip06KeyDerivation.Bip39Mnemonics
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotEquals
|
||||
|
||||
/**
|
||||
* NUT-13 test vectors verbatim from
|
||||
* https://github.com/cashubtc/nuts/blob/main/tests/13-tests.md
|
||||
* Identical inputs MUST produce identical outputs — these vectors are the
|
||||
* cross-wallet recovery contract.
|
||||
*/
|
||||
class CashuDeterministicTest {
|
||||
private val mnemonic = "half depart obvious quality work element tank gorilla view sugar picture humble"
|
||||
private val seed by lazy { Bip39Mnemonics.toSeed(mnemonic, passphrase = "") }
|
||||
|
||||
// ============================================================
|
||||
// Version 1 keyset (00 prefix, 16 hex chars)
|
||||
// ============================================================
|
||||
|
||||
private val v1KeysetId = "009a1f293253e41e"
|
||||
|
||||
@Test
|
||||
fun v1Counter0() {
|
||||
assertEquals(
|
||||
"485875df74771877439ac06339e284c3acfcd9be7abf3bc20b516faeadfe77ae",
|
||||
CashuDeterministic.secretBytes(seed, v1KeysetId, 0L).toHexKey(),
|
||||
)
|
||||
assertEquals(
|
||||
"ad00d431add9c673e843d4c2bf9a778a5f402b985b8da2d5550bf39cda41d679",
|
||||
CashuDeterministic.blindingFactor(seed, v1KeysetId, 0L).toHexKey(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun v1Counter1() {
|
||||
assertEquals(
|
||||
"8f2b39e8e594a4056eb1e6dbb4b0c38ef13b1b2c751f64f810ec04ee35b77270",
|
||||
CashuDeterministic.secretBytes(seed, v1KeysetId, 1L).toHexKey(),
|
||||
)
|
||||
assertEquals(
|
||||
"967d5232515e10b81ff226ecf5a9e2e2aff92d66ebc3edf0987eb56357fd6248",
|
||||
CashuDeterministic.blindingFactor(seed, v1KeysetId, 1L).toHexKey(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun v1Counter2() {
|
||||
assertEquals(
|
||||
"bc628c79accd2364fd31511216a0fab62afd4a18ff77a20deded7b858c9860c8",
|
||||
CashuDeterministic.secretBytes(seed, v1KeysetId, 2L).toHexKey(),
|
||||
)
|
||||
assertEquals(
|
||||
"b20f47bb6ae083659f3aa986bfa0435c55c6d93f687d51a01f26862d9b9a4899",
|
||||
CashuDeterministic.blindingFactor(seed, v1KeysetId, 2L).toHexKey(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun v1Counter3() {
|
||||
assertEquals(
|
||||
"59284fd1650ea9fa17db2b3acf59ecd0f2d52ec3261dd4152785813ff27a33bf",
|
||||
CashuDeterministic.secretBytes(seed, v1KeysetId, 3L).toHexKey(),
|
||||
)
|
||||
assertEquals(
|
||||
"fb5fca398eb0b1deb955a2988b5ac77d32956155f1c002a373535211a2dfdc29",
|
||||
CashuDeterministic.blindingFactor(seed, v1KeysetId, 3L).toHexKey(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun v1Counter4() {
|
||||
assertEquals(
|
||||
"576c23393a8b31cc8da6688d9c9a96394ec74b40fdaf1f693a6bb84284334ea0",
|
||||
CashuDeterministic.secretBytes(seed, v1KeysetId, 4L).toHexKey(),
|
||||
)
|
||||
assertEquals(
|
||||
"5f09bfbfe27c439a597719321e061e2e40aad4a36768bb2bcc3de547c9644bf9",
|
||||
CashuDeterministic.blindingFactor(seed, v1KeysetId, 4L).toHexKey(),
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Version 2 keyset (01 prefix, 33 bytes / 66 hex chars)
|
||||
// ============================================================
|
||||
|
||||
private val v2KeysetId = "015ba18a8adcd02e715a58358eb618da4a4b3791151a4bee5e968bb88406ccf76a"
|
||||
|
||||
@Test
|
||||
fun v2Counter0() {
|
||||
assertEquals(
|
||||
"db5561a07a6e6490f8dadeef5be4e92f7cebaecf2f245356b5b2a4ec40687298",
|
||||
CashuDeterministic.secretBytes(seed, v2KeysetId, 0L).toHexKey(),
|
||||
)
|
||||
assertEquals(
|
||||
"6d26181a3695e32e9f88b80f039ba1ae2ab5a200ad4ce9dbc72c6d3769f2b035",
|
||||
CashuDeterministic.blindingFactor(seed, v2KeysetId, 0L).toHexKey(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun v2Counter4() {
|
||||
assertEquals(
|
||||
"5e89fc5d30d0bf307ddf0a3ac34aa7a8ee3702169dafa3d3fe1d0cae70ecd5ef",
|
||||
CashuDeterministic.secretBytes(seed, v2KeysetId, 4L).toHexKey(),
|
||||
)
|
||||
assertEquals(
|
||||
"5550337312d223ba62e3f75cfe2ab70477b046d98e3e71804eade3956c7b98cf",
|
||||
CashuDeterministic.blindingFactor(seed, v2KeysetId, 4L).toHexKey(),
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Cross-checks — sanity properties
|
||||
// ============================================================
|
||||
|
||||
@Test
|
||||
fun secretAndBlindingDiffer() {
|
||||
// Leaf index 0 vs 1 must produce different outputs even for the same
|
||||
// (seed, keyset, counter) — otherwise the same value is used as both
|
||||
// the secret AND the blinding factor, a security disaster.
|
||||
assertNotEquals(
|
||||
CashuDeterministic.secretBytes(seed, v1KeysetId, 0L).toHexKey(),
|
||||
CashuDeterministic.blindingFactor(seed, v1KeysetId, 0L).toHexKey(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun counterAdvanceChangesOutput() {
|
||||
assertNotEquals(
|
||||
CashuDeterministic.secretBytes(seed, v1KeysetId, 0L).toHexKey(),
|
||||
CashuDeterministic.secretBytes(seed, v1KeysetId, 1L).toHexKey(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun differentKeysetsProduceDifferentSecrets() {
|
||||
assertNotEquals(
|
||||
CashuDeterministic.secretBytes(seed, v1KeysetId, 0L).toHexKey(),
|
||||
CashuDeterministic.secretBytes(seed, v2KeysetId, 0L).toHexKey(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun secretAsAsciiIsLowercaseHexOfBytes() {
|
||||
val bytes = CashuDeterministic.secretBytes(seed, v1KeysetId, 0L)
|
||||
val ascii = CashuDeterministic.secretAsAscii(seed, v1KeysetId, 0L)
|
||||
assertEquals(bytes.toHexKey(), ascii.decodeToString())
|
||||
// Belt-and-braces: result must be exactly 64 lowercase hex chars (32 bytes * 2).
|
||||
assertEquals(64, ascii.size)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// keysetIdToInt — the modular-reduction helper
|
||||
// ============================================================
|
||||
|
||||
@Test
|
||||
fun keysetIdToIntZero() {
|
||||
assertEquals(0L, CashuDeterministic.keysetIdToInt("00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keysetIdToIntSmall() {
|
||||
// 0x12 = 18
|
||||
assertEquals(18L, CashuDeterministic.keysetIdToInt("12"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keysetIdToIntFitsIn31Bits() {
|
||||
// All 0xFF bytes ⇒ value mod (2^31 - 1) is always < 2^31 - 1.
|
||||
val v1 = CashuDeterministic.keysetIdToInt("ffffffffffffffff")
|
||||
val v2 = CashuDeterministic.keysetIdToInt("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff")
|
||||
// Strict less than 2^31 - 1.
|
||||
assertEquals(true, v1 in 0..0x7FFFFFFEL)
|
||||
assertEquals(true, v2 in 0..0x7FFFFFFEL)
|
||||
}
|
||||
}
|
||||
+2
@@ -88,6 +88,8 @@ class MintHttpClient(
|
||||
|
||||
suspend fun checkState(request: CheckStateRequestDto): CheckStateResponseDto = post("/v1/checkstate", request, CheckStateRequestDto.serializer())
|
||||
|
||||
suspend fun restore(request: RestoreRequestDto): RestoreResponseDto = post("/v1/restore", request, RestoreRequestDto.serializer())
|
||||
|
||||
private suspend inline fun <reified R> get(path: String): R =
|
||||
withContext(Dispatchers.IO) {
|
||||
val url = baseUrl + path
|
||||
|
||||
Reference in New Issue
Block a user