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:
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