taler-docs

Documentation for GNU Taler components, APIs and protocols
Log | Files | Refs | README | LICENSE

commit 3aa9420469d0f3c55ac6b5863dc2e7b43d95e32d
parent a7c04285a4d1c0398a2fbb72f972b3fd7abcb2ec
Author: Iván Ávalos <avalos@disroot.org>
Date:   Tue, 11 Aug 2026 15:45:09 +0200

dd92: document the seed-derived reserve increment family

Diffstat:
Mdesign-documents/092-incremental-backup-sync.rst | 163++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
1 file changed, 121 insertions(+), 42 deletions(-)

diff --git a/design-documents/092-incremental-backup-sync.rst b/design-documents/092-incremental-backup-sync.rst @@ -897,9 +897,12 @@ before a cycle ran restores in its spent state. Restoring the coin also recomputes the wallet's *coin availability* rows (the counts the balance reads) from the restored coins, so a restored wallet shows the same balance as the wallet that made the backup. The -withdrawal groups, the reserves store and the refresh groups are still not -backed up: restored coins are spendable and count towards the balance, but -cannot be recouped, and pending withdrawals are not restored. +reserves (and with them the ability to recoup a restored coin) are backed +up by the ``add-reserve`` family; the withdrawal groups and the refresh +groups are still not backed up, so pending withdrawals are not restored +and a restored refreshed coin cannot be recouped-refreshed until the +withdrawal and refresh families land (recoup of a withdrawn coin works, +see the recoup discussion under "Add a reserve"). .. ts:def:: AddCoinInc @@ -1062,8 +1065,9 @@ User initiates a withdrawal. instructedAmount: AmountString; } -.. TODO: store reserves in backup? (see the proposed "add-reserve" - increment type below; withdrawal-start is blocked on it) +.. TODO: withdrawal-start references the reserve by [exchangeBaseUrl, + reservePub] (see the "add-reserve" increment type below); the + withdrawal families are blocked on it. * **Primary key:** ``[withdrawalGroupId]`` * **Deletion groups:** ``[withdrawals]`` @@ -1141,43 +1145,115 @@ Store all ``failReason`` in the database. .. TODO: withdrawal (soft) deletion as increment? (can't be easily deleted because of coin references) +Set the reserve seed +~~~~~~~~~~~~~~~~~~~~ + +The wallet derives every reserve key pair from a single wallet-level seed +(32 random bytes), so that the backup carries no per-reserve key material: +the private key of reserve ``i`` is re-derived as + +.. code-block:: text + + reservePriv_i = KDF(32, reserveSeed, "taler-reserve-key-salt", i) + +and the public key from the private one (``eddsa_get_public``). The seed +itself is wallet state and travels in the backup like the wallet root key; +this increment is what the backup carries it as. It is created lazily at +the first reserve created after this feature ships, so wallets that predate +it do not grow a seed until they create their next reserve. Reserves +created before the seed existed keep their random key pairs and are backed +up with the ``reservePriv`` fallback of ``add-reserve`` below. + +.. ts:def:: SetReserveSeedInc + + interface SetReserveSeedInc { + type: "set-reserve-seed"; + seed: string; + } + +* **Primary key:** ``[]`` (a singleton, like ``set-donau-info``) +* **Deletion groups:** ``[reserve-seed]`` + +Merge strategy +++++++++++++++ + +Last write wins. + +The ``set-reserve-seed`` section of an increment set is applied before the +``add-reserve`` section, so that a wallet deriving a reserve key pair on +restore already has the seed. + Add a reserve ~~~~~~~~~~~~~ -*Proposed* -- a reserve is created by the wallet for every withdrawal and -for the merge capability of P2P payments, and its key pair lives in the -wallet's ``reserves`` object store (see the ``WalletReserve`` record in -``db.ts``). The increment type below is what that store should map to; it -is **not implemented yet** and its schema is a proposal. +A reserve is created by the wallet for every withdrawal and for the merge +capability of P2P payments, and its key pair lives in the wallet's +``reserves`` object store (see the ``WalletReserve`` record in ``db.ts``). +The increment carries the record's identity -- the exchange and the +reserve's derivation index -- and, for the reserves that predate the seed, +the private key. .. ts:def:: AddReserveInc interface AddReserveInc { type: "add-reserve"; exchangeBaseUrl: string; - reservePub: EddsaPublicKey; - reservePriv: EddsaPrivateKey; + reserveIndex: number; + // Only for reserves created before the reserve seed existed, whose + // keys are random and cannot be re-derived. + reservePriv?: EddsaPrivateKey; } -* **Primary key:** ``[exchangeBaseUrl, reservePub]`` -* **Deletion groups:** ``[reserves, exchanges]`` +* **Primary key:** ``[exchangeBaseUrl, reserveIndex]`` +* **Deletion groups:** ``[reserves]`` Merge strategy ++++++++++++++ -No merge is required: the key pair of a reserve is generated once and never -changes. +Last write wins: the identity of a reserve never changes, and a re-recorded +increment (e.g. by the full collection pass) carries the same index and the +same key material. + +The public key of the reserve is *not* carried: it is derived from the +private key on restore (``eddsa_get_public``), whether the private key was +re-derived from the seed or restored from ``reservePriv``. The restored +record therefore has the same ``reservePub`` as the wallet that created the +reserve, which is what the other increments reference it by (see below). +The ``WalletReserve`` record gains ``exchangeBaseUrl``, ``reserveIndex`` +and the ``reserveSeedDerived`` marker (which decides whether the full +collection pass emits the index-only form or the index-plus-private-key +form); the exchange base URL is required by the increment and was missing +from the record (see the ``FIXME: Should reference exchange.`` comment in +``db.ts`` and the redundant ``exchangeBaseUrl`` of +``WithdrawalGroupRecord``). -The increment carries only the key material and the exchange it belongs to. The remaining fields of ``WalletReserve`` (``status``, the KYC thresholds, ``kycAccessToken``, ``amlReview``) are all derivable by querying the exchange and are deliberately not backed up, so that a restored wallet -re-derives them instead of trusting stale state. Note that the current -``WalletReserve`` record does not store the exchange base URL; the -``FIXME: Should reference exchange.`` comment in ``db.ts`` and the -redundant ``exchangeBaseUrl`` of ``WithdrawalGroupRecord`` both point at -this gap. The increment requires the field, so adding the increment type -is paired with fixing the record. +re-derives them instead of trusting stale state. + +Recoup +++++++ + +The reserve increment is what keeps recoup working on a restored wallet. +The recoup request itself is signed by the *coin*: the coin record +(``add-coin``) carries the coin private key, the blinding key and the +denomination signature the request needs, and the request names the reserve +only by its public key, which the coin source carries. After the exchange +confirms the recoup, the wallet queries the reserve's balance and withdraws +it back into coins; that re-withdrawal needs the reserve *private* key, +which is exactly what ``add-reserve`` restores. The recoup of a refreshed +coin (``recoup-refresh``) likewise needs only the coin records -- the +refreshed coin plus the old coin the refresh source names -- so no +refresh-group data is involved. + +The upcoming batch recoup protocol (``vRECOUP``, see ``api-exchange.rst``) +adds, per coin, the Clause-Schnorr blinding data +(``cs_session_nonce`` and the ``cs_r_pubs`` of the exchange's +``/blinding-prepare``) for post-quantum denominations. The wallet does not +store that data anywhere yet; when it does, the ``add-coin`` increment must +carry it (as optional fields). That is a coin-family extension; the +reserve side of a post-quantum recoup stays as described above. Why the schema matters to the other increment types ++++++++++++++++++++++++++++++++++++++++++++++++++++ @@ -1186,25 +1262,22 @@ The ``reserves`` store is referenced, directly or through its row id, by the withdrawal groups (``reservePub``/``reservePriv``), the coin sources (``WithdrawCoinSource.reservePub``, used for recouping), the exchange entries (``currentMergeReserveRowId``) and the peer-pull-credit records -(``mergeReserveRowId``). The absence of a reserve increment type blocks -the increment types that touch those records: +(``mergeReserveRowId``): * ``withdrawal-start`` is the most obvious case: the wallet's ``WithdrawalGroupRecord`` embeds the reserve key pair and the exchange - base URL, and the design above had an open TODO asking whether the - reserve goes into the backup at all. With ``add-reserve``, a - ``withdrawal-start`` increment can reference the reserve by - ``[exchangeBaseUrl, reservePub]`` instead of carrying the key pair, - avoiding duplication -- though carrying it inline would also work, since - the withdrawal record already stores it redundantly. -* ``add-coin`` / ``sign-coin`` / ``spend-coin`` reference the reserve - through the withdrawal coin source's ``reservePub``, without which a - restored coin cannot be recouped; and their deletion group includes - ``withdrawals``, so the cascade only makes sense once withdrawals are - backed up. -* ``peer-pull-credit`` stores ``mergeReserveRowId``, a row id into the - ``reserves`` store; without the reserve records the id is dangling on - restore. + base URL. With ``add-reserve``, a ``withdrawal-start`` increment can + reference the reserve by ``[exchangeBaseUrl, reservePub]`` instead of + carrying the key pair, avoiding duplication. +* ``add-coin`` / ``spend-coin`` reference the reserve through the + withdrawal coin source's ``reservePub``; the restored reserve record is + what makes the restored coin recoupable (see above). +* The exchange entries and the peer-pull-credit records reference the + merge reserve by a *row id* into the ``reserves`` store, which is not + portable across wallets. The ``add-exchange`` increment does not carry + the ``currentMergeReserveRowId`` pointer, so a restored exchange entry + starts without one; the merge reserve remains findable by its public + key, and re-linking the pointer on restore is a follow-up. The increment types that do **not** depend on the reserves store -- the deposit, merchant-payment, peer-push-credit, peer-push-debit and @@ -2281,9 +2354,15 @@ Definition of done (``add-coin`` / ``spend-coin``, carrying the per-coin key material the wallet database stores) is implemented, and restoring a coin recomputes the coin-availability rows so that the balance matches across wallets. - The withdrawal families and the reserves store are still blocked on the - "reserves" increment type (proposed above), so restored coins cannot be - recouped and pending withdrawals are not restored; the token family is + The reserve family (``set-reserve-seed`` / ``add-reserve``, with the + seed-derived key pairs and the ``reservePriv`` fallback for reserves + that predate the seed) is designed as described above; once implemented, + it restores the reserves' key material, which is what makes a restored + coin recoupable (see the recoup discussion under "Add a reserve"). + The withdrawal families are still blocked on the ``withdrawal-start`` + increment (referencing the reserve by ``[exchangeBaseUrl, reservePub]``) + and the refresh groups are not backed up, so pending withdrawals are not + restored; the token family is blocked on a schema redesign: the increments in this document model seed-derived tokens, while the wallet database now stores per-token key material, so restoring from the documented increments could not produce