NUT-02: Keysets and fees
mandatory
A keyset is a set of public keys that the mint Bob
generates and shares with its users. It refers to the set of
public keys that each correspond to the amount values that the
mint supports (e.g. 1, 2, 4, 8, ...)
respectively.
Each keyset indicates its keyset id, the currency
unit, whether the keyset is active, and
an input_fee_ppk that determines the fees for
spending ecash from this keyset.
A mint can have multiple keysets at the same time. For example,
it could have one keyset for each currency unit that
it supports. Wallets should support multiple keysets. They must
respect the active and the input_fee_ppk
properties of the keysets they use.
Keyset properties
Keyset ID
The keyset id is the identifier for a specific
keyset and can be derived from the public keys and the metadata of
a keyset. Wallets SHOULD compute the keyset
id for a given keyset themselves to verify that the
mint is using the correct keyset in its responses.
The keyset id is part of each Proof
object which allows wallets to identify which mint and keyset it
was generated from. The keyset field id is also
contained in the BlindedMessages sent to the mint and
in the BlindSignatures returned from it. It is also
included in a serialized Cashu Token (see NUT-00).
Active keysets
Mints can have multiple keysets at the same time but
MUST have at least one active keyset
(see NUT-01). The active
property determines whether the mint allows generating new ecash
from this keyset. Proofs from inactive keysets with
active=false are still accepted as inputs but new
outputs (BlindedMessages and
BlindSignatures) MUST be from
active keysets only.
To rotate keysets, a mint can generate a new active keyset and
inactivate an old one. If the active flag of an old
keyset is set to false, no new ecash from this keyset
can be generated and the outstanding ecash supply of that keyset
can be taken out of circulation as wallets rotate their ecash to
active keysets.
Wallets SHOULD prioritize swaps with
Proofs from inactive keysets (see NUT-03) so they can quickly get rid of
them. Wallets CAN swap their entire balance from
an inactive keyset to an active one as soon as they detect that
the keyset was inactivated. When constructing outputs for a
transaction, wallets MUST choose only
active keysets (see NUT-00).
Fees
Keysets indicate the fee input_fee_ppk that is
charged when a Proof of that keyset is spent as an
input to a transaction. The fee is given in parts per thousand
(ppk) per input measured in the unit of the keyset.
The total fee for a transaction is the sum of all fees per input
rounded up to the next larger integer (that can be represented
with the keyset).
As an example, we construct a transaction spending 3 inputs
(Proofs) from a keyset with unit sat and
input_fee_ppk of 100. A fee of
100 ppk means 0.1 sat per input. The sum
of the individual fees is 300 ppk for this transaction. Rounded up
to the next smallest denomination, the mint charges
1 sat in total fees,
i.e. fees = ceil(0.3) == 1. In this case, the fees
for spending 1-10 inputs is 1 sat, 11-20 inputs is 2 sat and so
on.
Wallet transaction construction
When constructing a transaction with ecash inputs
(example: /v1/swap or
/v1/melt/{method}), wallets MUST add
fees to the inputs or, vice versa, subtract from the outputs. The
mint checks the following equation:
sum(inputs) - fees == sum(outputs)
Here, sum(inputs) and sum(outputs)
mean the sum of the amounts of the inputs and outputs
respectively. fees is calculated from the sum of each
input’s fee and rounded up to the next larger integer:
def fees(inputs: List[Proof]) -> int:
sum_fees = 0
for proof in inputs:
sum_fees += keysets[proof.id].input_fee_ppk
return (sum_fees + 999) // 1000
Here, the // operator in
(sum_fees + 999) // 1000 denotes an integer division
operator (aka floor division operator) that rounds down
sum_fees + 999 to the next lower integer.
Alternatively, we could round up the sum using a floating point
division with ceil(sum_fees / 1000) although it is
not recommended to do so due to the non-deterministic behavior of
floating point division.
Notice that since transactions can spend inputs from different
keysets, the sum considers the fee for each Proof
indexed by the keyset ID individually.
Deriving the keyset ID
Keyset ID V2
Keyset IDs are 33 byte hex strings with a version byte (two
hexadecimal characters). The currently used version byte is
01.
Keyset IDs are derived from public data. To derive the keyset ID of a keyset, execute the following steps:
1 - sort public keys by their amount in ascending numerical order
2 - concatenate each amount and its corresponding lowercase public key hex string (as "amount:publickey_hex") to a single byte array, separating each pair with a comma (",")
3 - add the lowercase UTF8-encoded unit string prefixed with "|unit:" to the byte array (e.g. "|unit:sat")
4 - If input_fee_ppk is specified and non-zero, add the UTF8-encoded string prefixed with "|input_fee_ppk:" (e.g. "|input_fee_ppk:100"). If input_fee_ppk is omitted, null, or 0, it MUST be omitted from the preimage.
5 - If a final expiration is specified, add the UTF8-encoded string prefixed with "|final_expiry:" (e.g. "|final_expiry:1896187313")
6 - HASH_SHA256 the concatenated byte array
7 - prefix it with a keyset ID version byte "01"
An example implementation in Python:
def derive_keyset_id_v2(keys: Dict[int, PublicKey], unit: str, input_fee_ppk: Optional[int], final_expiry: Optional[int]) -> str:
sorted_keys = dict(sorted(keys.items()))
keyset_id_bytes = b",".join(
[f"{k}:{v.serialize()}".encode("utf-8") for k, v in sorted_keys.items()]
)
keyset_id_bytes += f"|unit:{unit}".encode("utf-8")
if input_fee_ppk is not None and input_fee_ppk != 0:
keyset_id_bytes += f"|input_fee_ppk:{input_fee_ppk}".encode("utf-8")
if final_expiry is not None and final_expiry != 0:
keyset_id_bytes += f"|final_expiry:{final_expiry}".encode("utf-8")
return "01" + hashlib.sha256(keyset_id_bytes).hexdigest()
V1 Keysets (deprecated)
V1 keysets are 8 bytes long, including a version byte prefix
00.
1 - sort public keys by their amount in ascending order
2 - concatenate all public keys to one byte array
3 - HASH_SHA256 the concatenated public keys
4 - take the first 14 characters of the hex-encoded hash
5 - prefix it with a keyset ID version byte
An example implementation in Python:
def derive_keyset_id_v1(keys: Dict[int, PublicKey]) -> str:
sorted_keys = dict(sorted(keys.items()))
pubkeys_concat = b"".join([p.serialize() for p in sorted_keys.values()])
return "00" + hashlib.sha256(pubkeys_concat).hexdigest()[:14]
[!CAUTION] Wallet implementations should reject any attempt at importing new keysets whose IDs collide with any of the previously added keysets.
Keyset Final Expiry
A unix epoch number for a future point in time that represents the final expiry of the keyset. After the keyset’s final expiry, the Mint is no longer obliged to fulfill promises signed with the keys from that keyset.
This effectively implies that the Mint can irrevocably remove
all of the nullifiers (Y values/spent ecash)
associated with the expired keyset.
The final expiry is optional and MAY be
omitted or null if the keyset has no
final-expiry.
Example: Get mint keysets
A wallet can ask the mint for a list of all keysets via the
GET /v1/keysets endpoint.
Request of Alice:
GET https://mint.host:3338/v1/keysets
With curl:
curl -X GET https://mint.host:3338/v1/keysets
Response GetKeysetsResponse of
Bob:
{
"keysets": [
{
"id": <hex_str>,
"unit": <str>,
"active": <bool>,
"input_fee_ppk": <int|null>,
"final_expiry": <int|null>
},
...
]
}
Here, id is the keyset ID, unit is
the unit string (e.g. “sat”) of the keyset, active
indicates whether new ecash can be minted with this keyset, and
input_fee_ppk is the fee (per thousand units) to
spend one input spent from this keyset. If
input_fee_ppk is not given, we assume it to be
0.
Example response
{
"keysets": [
{
"id": "015ba18a8adcd02e715a58358eb618da4a4b3791151a4bee5e968bb88406ccf76a",
"unit": "sat",
"active": true,
"input_fee_ppk": 100,
"final_expiry": 2059210353
},
{
"id": "012fbb01a4e200c76df911eeba3b8fe1831202914b24664f4bccbd25852a6708f8",
"unit": "sat",
"active": false,
"input_fee_ppk": 0,
"final_expiry": null
}
]
}
Requesting public keys for a specific keyset
To receive the public keys of a specific keyset, a wallet can
call the GET /v1/keys/{keyset_id} endpoint where
keyset_id is the keyset ID.
Example
Request of Alice:
We request the keys for the keyset
015ba18a8adcd02e715a58358eb618da4a4b3791151a4bee5e968bb88406ccf76a.
GET https://mint.host:3338/v1/keys/015ba18a8adcd02e715a58358eb618da4a4b3791151a4bee5e968bb88406ccf76a
With curl:
curl -X GET https://mint.host:3338/v1/keys/015ba18a8adcd02e715a58358eb618da4a4b3791151a4bee5e968bb88406ccf76a
Response of Bob (same as NUT-01):
{
"keysets": [{
"id": "009a1f293253e41e",
"unit": "sat",
"active": true,
"input_fee_ppk": 100,
"keys": {
"1": "02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104",
"2": "03b0f36d6d47ce14df8a7be9137712c42bcdd960b19dd02f1d4a9703b1f31d7513",
"4": "0366be6e026e42852498efb82014ca91e89da2e7a5bd3761bdad699fa2aec9fe09",
"8": "0253de5237f189606f29d8a690ea719f74d65f617bb1cb6fbea34f2bc4f930016d",
...
},
}, ...
]
}
Wallet implementation notes
Wallets can request the list of keyset IDs from the mint upon
startup and load only tokens from its database that have a keyset
ID supported by the mint it interacts with. This also helps
wallets to determine whether the mint has added a new current
keyset or whether it has changed the active flag of
an existing one.
A useful flow is:
- If we don’t have any keys from this mint yet, get all keys:
GET /v1/keysand store them - Get all keysets with
GET /v1/keysets - For each new keyset returned here which we don’t have yet, get
it using
GET /v1/keys/{keyset_id}and store it - If any of the keysets has changed its
activeflag, update it in the db and use the keyset accordingly