From c2f229b1ee7d8bb0652d761bd594f0253d54937a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 13:23:40 +0000 Subject: [PATCH] feat(cashu): NUT-13 deterministic secrets + NUT-09 restore endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../quartz/nip60Cashu/mintApi/MintApiDtos.kt | 22 ++ .../nip60Cashu/seed/CashuDeterministic.kt | 211 ++++++++++++++++++ .../nip60Cashu/seed/CashuDeterministicTest.kt | 199 +++++++++++++++++ .../nip60Cashu/mintApi/MintHttpClient.kt | 2 + 4 files changed, 434 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/seed/CashuDeterministic.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/seed/CashuDeterministicTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintApiDtos.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintApiDtos.kt index 865c3d4087..1800db5a5e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintApiDtos.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintApiDtos.kt @@ -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, +) + +/** + * 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, + val signatures: List, +) + @Serializable data class BlindSignatureDto( val amount: Long, 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 new file mode 100644 index 0000000000..ab2e11e026 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/seed/CashuDeterministic.kt @@ -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') +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/seed/CashuDeterministicTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/seed/CashuDeterministicTest.kt new file mode 100644 index 0000000000..2a221c7cfb --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/seed/CashuDeterministicTest.kt @@ -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) + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintHttpClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintHttpClient.kt index 228222c9d0..419467af4f 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintHttpClient.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintHttpClient.kt @@ -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 get(path: String): R = withContext(Dispatchers.IO) { val url = baseUrl + path