taler-docs

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

commit 0524afc73f0f286cc39b3e2a2f7c12689c9fb052
parent 2d68c5478a5c920b1fb34c9eea4c51984f709a17
Author: Özgür Kesim <oec@codeblau.de>
Date:   Mon, 14 Sep 2026 17:14:01 +0200

exchange API: specify the batch recoup protocol (vRECOUP)

Rewrite the WIP specification of /recoup-withdraw and /recoup-refresh
into the batch protocol from issue #9828: a request identifies one
withdraw or refresh operation by its commitment (planchets_h resp. rc),
discloses the secrets of the coins to recoup and provides the envelope
hashes of the other coins of the signed batch, so that the exchange can
recompute the commitment and prove inclusion without storing per-coin
envelopes.  CS nonces and R-values are derived server-side from the
stored seeds.  Responses carry one signed confirmation per recouped
coin.  Batches are all-or-nothing and idempotent.

Also:
- complete the RECOUP-WITHDRAW, RECOUP-REFRESH and RECOUP-REFRESH-RECEIVER
  coin history entries (planchets_h/rc and coin_index instead of the
  undefined h_commitment; drop the redundant new_coin_* fields) and add
  planchets_h to the reserve RECOUP entry;
- describe the vRECOUP changes in the version history and the recoup
  section intro; point /keys' RecoupDenoms at the new endpoints;
- fix the description of h_planchets in TALER_WithdrawRequestPS to
  match the implementation (hash over the kappa batch hashes when
  max_age is set), define AgeCommitmentHash and CsBlindingKeySecret;
- DD37: add a proposal for the wallet team on how recoup transactions
  group coins per originating operation under the batch protocol.

Diffstat:
Mcore/api-common.rst | 38+++++++++++++++++++++++++++-----------
Mcore/api-exchange.rst | 22++++++++++++++++++----
Mcore/exchange/get-coins-COIN_PUB-history.rst | 81++++++++++++++++++++++++++++++++++++-------------------------------------------
Mcore/exchange/get-keys.rst | 6+++---
Mcore/exchange/get-reserves-RESERVE_PUB-history.rst | 6++++++
Mcore/exchange/post-recoup-refresh.rst | 208++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mcore/exchange/post-recoup-withdraw.rst | 312++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Mdesign-documents/037-wallet-transactions-lifecycle.rst | 34+++++++++++++++++++++++++++++++++-
8 files changed, 484 insertions(+), 223 deletions(-)

diff --git a/core/api-common.rst b/core/api-common.rst @@ -561,6 +561,13 @@ Ages // age-restriction. type AgeMask = Integer; +.. ts:def:: AgeCommitmentHash + + // SHA-512 hash over the age commitment of a coin, that is over the + // vector of `Edx25519PublicKey` values, one per age group. + // Crockford `Base32` encoded. + type AgeCommitmentHash = string; + Versions ^^^^^^^^ @@ -840,9 +847,15 @@ Signatures .. ts:def:: DenominationBlindingKeySecret // Union, not (!) discriminated! - // (Note: CS Blinding Key secret is yet to be defined&added here). type DenominationBlindingKeySecret = - | RsaBlindingKeySecret; + | RsaBlindingKeySecret + | CsBlindingKeySecret; + +.. ts:def:: CsBlindingKeySecret + + // 32-byte secret from which the Clause-Schnorr blinding + // scalars of a coin are derived, Crockford `Base32` encoded. + type CsBlindingKeySecret = string; .. ts:def:: DenomCipher @@ -1438,18 +1451,21 @@ within the */ struct TALER_Amount fee; /** - * This is the running SHA512-hash over all - * `TALER_BlindedCoinHashP` values of the coins. + * If max_age was not set in the withdraw request, this is + * the running SHA512-hash over the n `TALER_BlindedCoinHashP` + * values of the coins, in the order of the request. * Note that each `TALER_BlindedCoinHashP` itself * captures the hash of the corresponding denomination's * public key. - * If max_age was set in the withdraw request, there will be - * n*κ many such values. The iteration MUST be first over - * all coins belonging to κ index=0, then all coins - * to κ index=1 etc: - * h[0][0]…h[0][n-1]h[1][0]…h[1][n-1] … h[κ-1][0]…h[κ-1][n-1] + * If max_age was set in the withdraw request, the n*κ coins + * form κ batches of n coins each, the first n coins belonging + * to κ index=0, the next n to κ index=1 etc. For each batch + * the running SHA512-hash over its n `TALER_BlindedCoinHashP` + * values is computed, and this value is the running + * SHA512-hash over the κ batch hashes: + * H( H(h[0][0]…h[0][n-1]) … H(h[κ-1][0]…h[κ-1][n-1]) ). * - * Note also that this value is required for /recoup and + * Note also that this value is required for /recoup-withdraw and * -- in case of a withdraw request with required age proof -- * in the subsequent call to /reveal-withdraw */ @@ -1491,7 +1507,7 @@ within the /** * Commitment made in the /withdraw request. * Also needed for the /reveal-withdraw endpoint (in case - * of required proof of age restriction) and for /recoup + * of required proof of age restriction) and for /recoup-withdraw */ struct TALER_HashBlindedPlanchetsP h_planchets; diff --git a/core/api-exchange.rst b/core/api-exchange.rst @@ -60,7 +60,11 @@ The currently implemented protocol version is **v41**. **Upcoming versions:** * ``vIMPORT``: external KYC/KYB data import -* ``vRECOUP``: improved recoup protocol +* ``vRECOUP``: batch recoup protocol: adds :http:post:`/recoup-withdraw </recoup-withdraw>` + and :http:post:`/recoup-refresh </recoup-refresh>`, removes + ``/coins/$COIN_PUB/recoup`` and ``/coins/$COIN_PUB/recoup-refresh``; + adds ``planchets_h``, ``rc`` and ``coin_index`` to the recoup entries + of coin and reserve histories * ``vATTEST``: KYC attestation support **Ideas for future version:** @@ -412,9 +416,19 @@ part of the :http:get:`/keys </keys>` response. If and only if this has happene coins that were signed with those denomination keys can be cashed in using this API. -For a recoup, a coin has to provide the necessary information to -identify the original transaction (either a withdraw or a refresh) it -became minted, and prove ownership of the coin itself. +Since protocol **vRECOUP**, coins are recouped in batches: all coins in one +request must originate from the same withdraw or refresh operation, which +the client identifies by its commitment (``planchets_h`` of the withdraw +request, or ``rc`` of the melt request). For the coins to be recouped, the +client discloses the secret material from which the exchange recomputes +their blinded envelopes; for the other coins of the same operation it +provides the hashes of the blinded envelopes. This allows the exchange to +recompute the commitment and thereby verify that the coins were part of the +operation, without having to keep individual envelopes in its database. +Coins from a withdraw operation are paid back to the reserve +(:http:post:`/recoup-withdraw </recoup-withdraw>`), coins from a refresh +operation to the old coin that was melted +(:http:post:`/recoup-refresh </recoup-refresh>`). .. include:: exchange/post-recoup-withdraw.rst diff --git a/core/exchange/get-coins-COIN_PUB-history.rst b/core/exchange/get-coins-COIN_PUB-history.rst @@ -251,20 +251,16 @@ } - .. note:: - - The `CoinRecoupWithdrawTransaction` interface defintion is WIP. - It will be fully specified and implemented with **vRECOUP**. - .. ts:def:: CoinRecoupWithdrawTransaction // This represents a transaction of a call to /recoup-withdraw // where the coin's residual value has been credited to the // original reserve, from which this coin was withdrawn. + // @since **vRECOUP** interface CoinRecoupWithdrawTransaction { type: "RECOUP-WITHDRAW"; - // Offset of this entry in the reserve history. + // Offset of this entry in the coin history. // Useful to request incremental histories via // the "start" query parameter. history_offset: Integer; @@ -294,14 +290,16 @@ // that is important to fix the coin's denomination. h_denom_pub: HashCode; - // Coin blinding key that was used in the original withdraw request. - coin_blind: DenominationBlindingKeyP; + // Coin blinding secret that was used in the original withdraw + // request and disclosed in the recoup request. + coin_blind: DenominationBlindingKeySecret; - // The hash of the withdraw commitment of the original withdraw - // request that this coin was part of - h_commitment: HashCode; + // The commitment ``planchets_h`` of the original withdraw + // request that this coin was part of, see `RecoupWithdrawRequest`. + planchets_h: HashCode; - // Coin's index in the original withdraw request, starting at 0 + // Index of this coin in the batch of coins the exchange signed + // in the original withdraw request, starting at 0. coin_index: Integer; // Reserve receiving the recoup. @@ -312,22 +310,17 @@ } - - .. note:: - - The `CoinRecoupRefreshTransaction` interface defintion is WIP. - It will be fully specified and implemented with **vRECOUP**. - .. ts:def:: CoinRecoupRefreshTransaction // This represents a transaction of a call to /recoup-refresh // where this coin was _part_ of the batch of coins whose // residual values were credited to the original coin, from - // which also this coin was refresh from. + // which also this coin was refreshed from. + // @since **vRECOUP** interface CoinRecoupRefreshTransaction { type: "RECOUP-REFRESH"; - // Offset of this entry in the reserve history. + // Offset of this entry in the coin history. // Useful to request incremental histories via // the "start" query parameter. history_offset: Integer; @@ -335,7 +328,7 @@ // The total amount of the coin's value absorbed // by this transaction. // The current coin value can thus be computed by - // subtracting this amounts from + // subtracting the amount from // the coin's denomination value. amount: Amount; @@ -348,12 +341,12 @@ exchange_pub: EddsaPublicKey; // The original coin, from which this coin was derived from - // in a call to /refresh, and which was then credited with + // in a call to /melt, and which was then credited with // the residual value of this coin in a call to /recoup-refresh. old_coin_pub: EddsaPublicKey; // Signature by the coin over a `TALER_RecoupRequestPS` - // with purpose ``TALER_SIGNATURE_WALLET_COIN_RECOUP``. + // with purpose ``TALER_SIGNATURE_WALLET_COIN_RECOUP_REFRESH``. coin_sig: EddsaSignature; // Hash of the public denomination key used to sign the coin. @@ -361,42 +354,34 @@ // that is important to fix the coin's denomination. h_denom_pub: HashCode; - // Coin blinding key that was used in the original refresh request. - coin_blind: DenominationBlindingKeyP; + // Coin blinding secret that was used in the original melt + // request and disclosed in the recoup request. + coin_blind: DenominationBlindingKeySecret; - // The hash of the refresh commitment of the original refresh - // request that this coin was derived from. - h_commitment: HashCode; + // The refresh commitment ``rc`` of the original melt request + // that this coin was derived from, see `RecoupRefreshRequest`. + rc: HashCode; - // Coin's index in the original refresh request, starting at 0 + // Index of this coin in the batch of coins the exchange signed + // in the original refresh operation, starting at 0. coin_index: Integer; - // Blinding factor of the revoked new coin. - new_coin_blinding_secret: DenominationBlindingKeySecret; - - // Blinded public key of the revoked new coin. - new_coin_ev: DenominationBlindingKeySecret; - // Date when the operation was made. timestamp: Timestamp; } - - .. note:: - - The `CoinRecoupRefreshReceiverTransaction` interface defintion is WIP. - It will be fully specified and implemented with **vRECOUP**. - .. ts:def:: CoinRecoupRefreshReceiverTransaction // This represents a transaction of a call to /recoup-refresh - // where this coin was the _receiver_ of the residual values - // from coins, that originated from a call to /refresh of this coin. + // where this coin was the _receiver_ of the residual value + // of a coin that originated from a call to /melt of this coin. + // There is one such entry per recouped coin. + // @since **vRECOUP** interface CoinRecoupRefreshReceiverTransaction { type: "RECOUP-REFRESH-RECEIVER"; - // Offset of this entry in the reserve history. + // Offset of this entry in the coin history. // Useful to request incremental histories via // the "start" query parameter. history_offset: Integer; @@ -418,6 +403,14 @@ // Public key of the private key used to create 'exchange_sig'. exchange_pub: EddsaPublicKey; + // Public key of the coin that was recouped, that is the coin + // derived from this coin whose residual value was credited. + coin_pub: CoinPublicKey; + + // The refresh commitment ``rc`` of the melt request of this + // coin from which the recouped coin was derived. + rc: HashCode; + } .. ts:def:: CoinPurseDepositTransaction diff --git a/core/exchange/get-keys.rst b/core/exchange/get-keys.rst @@ -528,14 +528,14 @@ interface RecoupDenoms { // Hash of the public key of the denomination that is being revoked under - // emergency protocol (see ``/recoup``). + // emergency protocol (see ``/recoup-withdraw`` and ``/recoup-refresh``). h_denom_pub: HashCode; // We do not include any signature here, as the primary use-case for // this emergency involves the exchange having lost its signing keys, // so such a signature here would be pretty worthless. However, the - // exchange will not honor ``/recoup`` requests unless they are for - // denomination keys listed here. + // exchange will not honor ``/recoup-withdraw`` or ``/recoup-refresh`` + // requests unless they are for denomination keys listed here. } A signing key in the ``signkeys`` list is a JSON object with the following fields: diff --git a/core/exchange/get-reserves-RESERVE_PUB-history.rst b/core/exchange/get-reserves-RESERVE_PUB-history.rst @@ -241,6 +241,12 @@ // Amount recouped to the reserve. amount: Amount; + + // The commitment ``planchets_h`` of the withdraw request + // from this reserve that the coin was part of, see + // `RecoupWithdrawRequest`. + // @since **vRECOUP** + planchets_h: HashCode; } .. ts:def:: ReserveHistoryRequestTransaction diff --git a/core/exchange/post-recoup-refresh.rst b/core/exchange/post-recoup-refresh.rst @@ -1,19 +1,34 @@ .. http:post:: /recoup-refresh - Demand that a batch of coins be refunded to the original coin, - from which the coins were originally refreshed. - The coins must have been originated from the same call to refresh, and be - a subset of that original batch. - The remaining amount on the coin will be credited to the original coin - that the coins were refreshed from, in the same refresh request. - - The base URL for coin related requests may differ from the main base URL of the + Demand that coins of revoked denominations, which originated from + one particular call to :http:post:`/melt </melt>` and + :http:post:`/reveal-melt </reveal-melt>`, be paid back to the old coin + they were refreshed from. + + All coins in one request MUST originate from the same refresh operation. + The client identifies that operation by the public key of the old coin and + the refresh commitment ``rc`` of the original melt request. As with + :http:post:`/recoup-withdraw </recoup-withdraw>`, the client discloses the + secret material of the coins to be recouped and provides the envelope + hashes of the other coins of the same operation, so that the exchange can + recompute the hash over the batch of coins it signed and compare it with + the commitment it stored. + + The residual value of each disclosed coin is credited to the old coin. + The old coin may then be spent or melted again, even if the deposit + expiration of its denomination has passed in the meantime (a "zombie" + coin). The refresh fee paid in the original operation is **not** + recouped. + + The exchange only accepts this request for coins of denominations that + are listed in the ``recoup`` field of :http:get:`/keys </keys>`. + + The base URL for this request may differ from the main base URL of the exchange. The exchange MUST return a 307 or 308 redirection to the correct base URL if this is the case. - Note that the original refresh fees will **not** be recouped. - - .. note:: This endpoint still Work-in-Progress. It will be implemented in **vRECOUP**, sometime after **v32**. + This endpoint was introduced in protocol **vRECOUP** and replaces the + former ``/coins/$COIN_PUB/recoup-refresh`` endpoint. **Request:** @@ -22,47 +37,66 @@ **Response:** :http:statuscode:`200 OK`: - The request was successful, and the response - is a `RecoupRefreshConfirmationResponse`. - Note that repeating exactly the same request - will again yield the same response, so if the - network goes down during the transaction or - before the client can commit the coin signature to disk, - the coin is not lost. + The request was successful and the response is a + `RecoupRefreshResponse`. Repeating exactly the same request + yields the same response, including the same timestamps and + signatures, so if the network goes down before the client can commit the + result to disk, the recoup is not lost. + :http:statuscode:`400 Bad Request`: + The request body is malformed or a parameter is invalid. + This response comes with a standard `ErrorDetail` response. + Possible error codes include ``TALER_EC_GENERIC_PARAMETER_MALFORMED`` + (also used if ``coin_data`` does not contain at least one disclosed + coin) and ``TALER_EC_EXCHANGE_RECOUP_REFRESH_BATCH_SIZE_MISMATCH`` + (the number of entries in ``coin_data`` differs from the number of + coins the exchange signed in the original refresh operation). :http:statuscode:`403 Forbidden`: - The coin's signature is invalid. + A signature is invalid. This response comes with a standard `ErrorDetail` response. Possible error codes include - ``TALER_EC_EXCHANGE_RECOUP_REFRESH_SIGNATURE_INVALID`` or - ``TALER_EC_EXCHANGE_DENOMINATION_SIGNATURE_INVALID``. + ``TALER_EC_EXCHANGE_RECOUP_REFRESH_SIGNATURE_INVALID`` (a ``coin_sig``) + or ``TALER_EC_EXCHANGE_DENOMINATION_SIGNATURE_INVALID`` + (a ``denom_sig``). :http:statuscode:`404 Not found`: - The denomination key is unknown, the blinded - coin is not known to have been withdrawn, - or the denomination is not eligible for recoup. - If the denomination key is unknown, the response will be - a `DenominationUnknownMessage`. - Possible error codes include - ``TALER_EC_EXCHANGE_RECOUP_REFRESH_MELT_NOT_FOUND`` or - ``TALER_EC_EXCHANGE_RECOUP_REFRESH_NOT_ELIGIBLE``. + One of the following reasons occurred: + + 1. A denomination key is unknown. The response is a + `DenominationUnknownMessage` with error code + ``TALER_EC_EXCHANGE_GENERIC_DENOMINATION_KEY_UNKNOWN``. + 2. The exchange has no record of a refresh operation for the given + ``rc``, or the operation found was not a refresh of + ``old_coin_pub``. This response comes with a standard `ErrorDetail` + response with error code + ``TALER_EC_EXCHANGE_RECOUP_REFRESH_MELT_NOT_FOUND``. :http:statuscode:`409 Conflict`: - The operation is not allowed as the coin has insufficient - residual value, or because the same public key of the coin has been - previously used with a different denomination. Which case it is - can be decided by looking at the error code - (usually ``TALER_EC_EXCHANGE_GENERIC_INSUFFICIENT_BALANCE``). - The response is a `DepositDoubleSpendError`. + One of the following reasons occurred: + + 1. The hash the exchange recomputed over the ``coin_data`` array does + not match the commitment of the refresh operation, so at least one + disclosed coin was not part of it. This response comes with a + standard `ErrorDetail` response with error code + ``TALER_EC_EXCHANGE_RECOUP_REFRESH_COMMITMENT_MISMATCH``. + 2. The denomination of a disclosed coin differs from the denomination + recorded for that position of the refresh operation. This + response comes with a standard `ErrorDetail` response with error code + ``TALER_EC_EXCHANGE_RECOUP_REFRESH_DENOMINATION_MISMATCH``. + 3. A disclosed coin has no residual value left, or the same coin + public key was previously used with a different denomination. + The response is a `DepositDoubleSpendError` with error code + ``TALER_EC_EXCHANGE_GENERIC_INSUFFICIENT_FUNDS`` or + ``TALER_EC_EXCHANGE_GENERIC_COIN_CONFLICTING_DENOMINATION_KEY``. + + In all of these cases nothing is credited, not even for the other + coins in the request. :http:statuscode:`410 Gone`: - The requested denomination key is no longer valid. - It is past the expiration or was not yet revoked. The response is a - `DenominationGoneMessage`. Clients must evaluate - the error code provided to understand which of the - cases this is and handle it accordingly. - Possible error codes include - ``TALER_EC_EXCHANGE_GENERIC_DENOMINATION_EXPIRED``. + The denomination of a disclosed coin is not eligible for recoup. + The response is a `DenominationGoneMessage` with error code + ``TALER_EC_EXCHANGE_GENERIC_DENOMINATION_EXPIRED`` (past the deposit + expiration) or ``TALER_EC_EXCHANGE_RECOUP_REFRESH_NOT_ELIGIBLE`` (the + denomination was not revoked). :http:statuscode:`412 Precondition Failed`: - The requested denomination key is not yet valid. - It is before the validity start time. The response is a - `DenominationGoneMessage` with + The denomination of a disclosed coin is not yet valid. + The response is a `DenominationGoneMessage` with error code ``TALER_EC_EXCHANGE_GENERIC_DENOMINATION_VALIDITY_IN_FUTURE``. :http:statuscode:`413 Request entity too large`: The uploaded body is to long, it exceeds the size limit. @@ -73,32 +107,86 @@ This response comes with a standard `ErrorDetail` response. Possible error codes include ``TALER_EC_EXCHANGE_RECOUP_REFRESH_BLINDING_FAILED``, - ``TALER_EC_GENERIC_DB_FETCH_FAILED``, or + ``TALER_EC_GENERIC_DB_FETCH_FAILED``, + ``TALER_EC_GENERIC_DB_COMMIT_FAILED``, or ``TALER_EC_GENERIC_DB_INVARIANT_FAILURE``. + :http:statuscode:`502 Bad gateway`: + The exchange could not reach its signing helper to create + the confirmation signatures. Returned with an error code of + ``TALER_EC_EXCHANGE_SIGNKEY_HELPER_UNAVAILABLE``. + Wallets should retry the request (with some delay) at a later time. + :http:statuscode:`503 Service Unavailable`: + The exchange currently has no signing keys available. + This response comes with a standard `ErrorDetail` response with + a code of ``TALER_EC_EXCHANGE_GENERIC_KEYS_MISSING``. **Details:** .. ts:def:: RecoupRefreshRequest interface RecoupRefreshRequest { - // Public key of the original coin that will receive the recoup. - // MUST be the same as the one from the original refresh request. - old_coin_pub: EddsaPublicKey; - - // The details about the coins: - // An array of either - // a) the hash code of a blinded coin envelope (not to be recouped) - // b) the disclosed coin details, in order to recoup it. - // From these, the hash of all coin envelopes - // from the original refresh can be reconstructed. + // Public key of the old coin that was melted. + // The recouped value is credited to this coin. + old_coin_pub: CoinPublicKey; + + // The refresh commitment of the original melt request, + // see `TALER_RefreshCommitmentP`. This identifies the + // refresh operation. + rc: HashCode; + + // Exactly one entry per coin the exchange signed in the + // refresh operation, that is the ``n`` coins of the batch at + // the ``noreveal_index`` returned in the `MeltResponse`, in the + // order of the original request. + // At least one entry MUST be a `RecoupedCoin` and its + // ``coin_sig`` MUST be created with purpose + // ``TALER_SIGNATURE_WALLET_COIN_RECOUP_REFRESH``. coin_data: RecoupCoinData[]; } + Note that for denominations of cipher type Clause-Schnorr the client + does not need to provide the session nonce or the R-value pair: the + exchange derives the nonce from the ``blinding_seed`` of the original + melt request and the coin's position in ``coin_data`` (exactly as the + wallet does for a melt), and it stored the R-values it returned in + :http:post:`/blinding-prepare </blinding-prepare>`. + + The exchange processes the request as + :http:post:`/recoup-withdraw </recoup-withdraw>` does, with the refresh + operation looked up under ``rc``, the hash over ``coin_data`` compared + against the hash of the batch at the ``noreveal_index``, and the + residual value of every disclosed coin credited to ``old_coin_pub`` + in one database transaction. + + .. ts:def:: RecoupRefreshResponse + + interface RecoupRefreshResponse { + // Public key of the old coin that was credited. + old_coin_pub: CoinPublicKey; + + // One confirmation per `RecoupedCoin` in the request, + // in the same order. + recoups: RecoupRefreshConfirmation[]; + } + + .. ts:def:: RecoupRefreshConfirmation + + interface RecoupRefreshConfirmation { + // Public key of the coin that was recouped. + coin_pub: CoinPublicKey; + + // Amount credited to the old coin for this coin, that is + // the coin's residual value at the time of the recoup. + amount: Amount; - .. ts:def:: RecoupRefreshConfirmationResponse + // Time when the exchange accepted the recoup of this coin. + timestamp: Timestamp; - interface RecoupRefreshConfirmationResponse { - // Public key of the old coin that will receive the recoup. - old_coin_pub: EddsaPublicKey; + // Signature over `TALER_RecoupRefreshConfirmationPS` with purpose + // ``TALER_SIGNATURE_EXCHANGE_CONFIRM_RECOUP_REFRESH``. + exchange_sig: EddsaSignature; + // Public key used to create ``exchange_sig``. + // Should match one of the exchange's signing keys from ``/keys``. + exchange_pub: EddsaPublicKey; } diff --git a/core/exchange/post-recoup-withdraw.rst b/core/exchange/post-recoup-withdraw.rst @@ -1,158 +1,270 @@ .. http:post:: /recoup-withdraw - Demand that a batch of coins be refunded to the reserve, - from which the coins were originally withdrawn. - The coins must have been originated from the same call to withdraw, and be - a subset of that original batch. - The remaining amount on the coin will be credited to the reserve - that the coins were withdrawn from, in the same withdraw request. + Demand that coins of revoked denominations, which originated from + one particular call to :http:post:`/withdraw </withdraw>`, be paid + back to the reserve they were withdrawn from. - Note that the original withdrawal fees will **not** be recouped. + All coins in one request MUST originate from the same withdraw operation. + The client identifies that operation by the public key of the reserve and + the commitment ``planchets_h`` of the original withdraw request. For each + coin that is to be recouped, the client discloses the secret material that + allows the exchange to recompute the coin's blinded envelope. For every + other coin of the same operation, the client only provides the hash of its + blinded envelope. From these values the exchange recomputes the hash over + the batch of coins it signed in the original operation and compares it with + the commitment it stored. This proves that each disclosed coin was part of + that withdraw operation without the exchange having to store individual + envelopes. - .. note:: This endpoint still Work-in-Progress. It will be implemented in **vRECOUP**, sometime after **v32**. + The residual value of each disclosed coin, that is its denomination's value + minus what has already been spent, is credited to the reserve. The + withdraw fee paid in the original operation is **not** recouped. + + The exchange only accepts this request for coins of denominations that + are listed in the ``recoup`` field of :http:get:`/keys </keys>`. + + This endpoint was introduced in protocol **vRECOUP** and replaces the + former ``/coins/$COIN_PUB/recoup`` endpoint. **Request:** The request body must be a `RecoupWithdrawRequest` object. - It provides sufficient information to - a) identify the originating withdraw request - b) proof that the coins to be recouped were part of that withdraw request - c) proof ownership of all coins requested to be recouped. - **Response:** :http:statuscode:`200 OK`: - The request was successful, and the response is a `ReserveSummary`. + The request was successful and the response is a + `RecoupWithdrawResponse`. Repeating exactly the same request + yields the same response, including the same timestamps and + signatures, so if the network goes down before the client can commit the + result to disk, the recoup is not lost. + :http:statuscode:`400 Bad Request`: + The request body is malformed or a parameter is invalid. + This response comes with a standard `ErrorDetail` response. + Possible error codes include ``TALER_EC_GENERIC_PARAMETER_MALFORMED`` + (also used if ``coin_data`` does not contain at least one disclosed + coin) and ``TALER_EC_EXCHANGE_RECOUP_WITHDRAW_BATCH_SIZE_MISMATCH`` + (the number of entries in ``coin_data`` differs from the number of + coins the exchange signed in the original withdraw operation). :http:statuscode:`403 Forbidden`: - A coin's signature is invalid - (``TALER_EC_EXCHANGE_RECOUP_SIGNATURE_INVALID``) - or the denomination signature is invalid - (``TALER_EC_EXCHANGE_DENOMINATION_SIGNATURE_INVALID``). + A signature is invalid. This response comes with a standard `ErrorDetail` response. + Possible error codes include + ``TALER_EC_EXCHANGE_RECOUP_SIGNATURE_INVALID`` (a ``coin_sig``) + or ``TALER_EC_EXCHANGE_DENOMINATION_SIGNATURE_INVALID`` + (a ``denom_sig``). :http:statuscode:`404 Not found`: - A denomination key is unknown, - the withdraw commitment is unknown - or a blinded coin is not known to have been withdrawn - (``TALER_EC_EXCHANGE_RECOUP_WITHDRAW_NOT_FOUND``). - If a denomination key is unknown, the response will be - a `DenominationUnknownMessage`. + One of the following reasons occurred: + + 1. A denomination key is unknown. The response is a + `DenominationUnknownMessage` with error code + ``TALER_EC_EXCHANGE_GENERIC_DENOMINATION_KEY_UNKNOWN``. + 2. The exchange has no record of a withdraw operation for the given + ``reserve_pub`` and ``planchets_h``. This response comes with a + standard `ErrorDetail` response with error code + ``TALER_EC_EXCHANGE_RECOUP_WITHDRAW_NOT_FOUND``. :http:statuscode:`409 Conflict`: - The operation is not allowed - as a coin has insufficient residual value, - or because the same public key of a coin - has been previously used with a different denomination. - Which case it is can be decided by looking at the error code - (usually ``TALER_EC_EXCHANGE_GENERIC_INSUFFICIENT_FUNDS``). - The response is a `DepositDoubleSpendError`. + One of the following reasons occurred: + + 1. The hash the exchange recomputed over the ``coin_data`` array does + not match the commitment of the withdraw operation, so at least one + disclosed coin was not part of it. This response comes with a + standard `ErrorDetail` response with error code + ``TALER_EC_EXCHANGE_RECOUP_WITHDRAW_COMMITMENT_MISMATCH``. + 2. The denomination of a disclosed coin differs from the denomination + recorded for that position of the withdraw operation. This + response comes with a standard `ErrorDetail` response with error code + ``TALER_EC_EXCHANGE_RECOUP_WITHDRAW_DENOMINATION_MISMATCH``. + 3. A disclosed coin has no residual value left, or the same coin + public key was previously used with a different denomination. + The response is a `DepositDoubleSpendError` with error code + ``TALER_EC_EXCHANGE_GENERIC_INSUFFICIENT_FUNDS`` or + ``TALER_EC_EXCHANGE_GENERIC_COIN_CONFLICTING_DENOMINATION_KEY``. + + In all of these cases nothing is credited, not even for the other + coins in the request. :http:statuscode:`410 Gone`: - A requested denomination key is not yet or no longer valid. - It either before the validity start - (``TALER_EC_EXCHANGE_GENERIC_DENOMINATION_VALIDITY_IN_FUTURE``), - past the expiration - (``TALER_EC_EXCHANGE_GENERIC_DENOMINATION_EXPIRED``) - or was not yet revoked - (``TALER_EC_EXCHANGE_RECOUP_NOT_ELIGIBLE``). - The response is a `DenominationGoneMessage`. - Clients must evaluate the error code provided - to understand which of the cases this is and handle it accordingly. + The denomination of a disclosed coin is not eligible for recoup. + The response is a `DenominationGoneMessage` with error code + ``TALER_EC_EXCHANGE_GENERIC_DENOMINATION_EXPIRED`` (past the deposit + expiration) or ``TALER_EC_EXCHANGE_RECOUP_NOT_ELIGIBLE`` (the + denomination was not revoked). + :http:statuscode:`412 Precondition Failed`: + The denomination of a disclosed coin is not yet valid. + The response is a `DenominationGoneMessage` with error code + ``TALER_EC_EXCHANGE_GENERIC_DENOMINATION_VALIDITY_IN_FUTURE``. + :http:statuscode:`413 Request entity too large`: + The uploaded body is to long, it exceeds the size limit. + Returned with an error code of + ``TALER_EC_GENERIC_UPLOAD_EXCEEDS_LIMIT``. :http:statuscode:`500 Internal Server Error`: The exchange encountered an internal error. This response comes with a standard `ErrorDetail` response. Possible error codes include ``TALER_EC_EXCHANGE_RECOUP_BLINDING_FAILED``, - ``TALER_EC_GENERIC_DB_FETCH_FAILED``, or + ``TALER_EC_GENERIC_DB_FETCH_FAILED``, + ``TALER_EC_GENERIC_DB_COMMIT_FAILED``, or ``TALER_EC_GENERIC_DB_INVARIANT_FAILURE``. + :http:statuscode:`502 Bad gateway`: + The exchange could not reach its signing helper to create + the confirmation signatures. Returned with an error code of + ``TALER_EC_EXCHANGE_SIGNKEY_HELPER_UNAVAILABLE``. + Wallets should retry the request (with some delay) at a later time. + :http:statuscode:`503 Service Unavailable`: + The exchange currently has no signing keys available. + This response comes with a standard `ErrorDetail` response with + a code of ``TALER_EC_EXCHANGE_GENERIC_KEYS_MISSING``. **Details:** .. ts:def:: RecoupWithdrawRequest interface RecoupWithdrawRequest { - // Public key of the reserve that will receive the recoup. - // MUST be the same as the one from the original withdraw. + // Public key of the reserve from which the coins were withdrawn. + // The recouped value is credited to this reserve. reserve_pub: EddsaPublicKey; - // The details about the coins: - // An array of either - // a) the hash code of a blinded coin envelope (not to be recouped) - // b) the disclosed coin details, in order to recoup it. - // From these, the hash of all coin envelopes - // from the original withdraw can be reconstructed. + // The commitment of the original withdraw request, as + // signed in the ``h_planchets`` field of `TALER_WithdrawRequestPS`. + // Together with ``reserve_pub``, this identifies the + // withdraw operation. + planchets_h: HashCode; + + // Exactly one entry per coin the exchange signed in the + // withdraw operation, in the order of the original request. + // If the withdraw request had ``max_age`` set, these are the + // ``n`` coins of the batch at the ``noreveal_index`` returned + // by the exchange, not all ``n*kappa`` candidates. + // At least one entry MUST be a `RecoupedCoin`. coin_data: RecoupCoinData[]; } .. ts:def:: RecoupCoinData - // This is either - // a) the hash code of a blinded coin envelope (not to be recouped) - // b) the disclosed coin details, in order to recoup it. + // Union discriminated by the ``type`` field. type RecoupCoinData = | NonRecoupedCoin - | RecoupDisclosedCoinDetails; + | RecoupedCoin; .. ts:def:: NonRecoupedCoin - interface NonRecoupedCoin { - type: "non_recouped_coin"; + // A coin of the original operation that is not to be recouped + // in this request. The client only provides the hash of its + // blinded envelope, which is all the exchange needs to + // recompute the commitment. + interface NonRecoupedCoin { + type: "hash"; - // This is the SHA512 hash code of a blinded coin envelope, - // including the corresponding denomination's hash. - // It is the output of the TALER_coin_ev_hash function - // from libtalerutil. - coin_ev: BlindedCoinEnvelopeHash; - }; + // Hash of the coin's blinded envelope, + // see `BlindedCoinEnvelopeHash`. + h_coin_ev: BlindedCoinEnvelopeHash; + } .. ts:def:: BlindedCoinEnvelopeHash - // The hash value of a blinded coin envelope, - // as it its generated by the function TALER_coin_ev_hash - // in libtalerutil. + // SHA-512 hash over the hash of the denomination public key followed + // by the canonical serialization of the blinded envelope (the cipher + // identifier in network byte order followed by the blinded message). + // This is the value computed by ``TALER_coin_ev_hash`` in + // ``libtalerutil``. The hash the exchange compares against a + // withdraw or refresh commitment is the SHA-512 hash over the + // concatenation of these values for all coins of a batch, in order + // (``TALER_wallet_blinded_planchets_hash`` in ``libtalerutil``). type BlindedCoinEnvelopeHash = HashCode; - .. ts:def:: RecoupDisclosedCoinDetails + .. ts:def:: RecoupedCoin - // This object provides all necessary coin data - // in order to call TALER_denom_blind and retrieve - // a blinded coin planchet, from which we can - // calculate the blinded coin envelope hash. - // It also contains the denomination's signature - // for the (unblinded) coin's public key, - // and the coin's signature to authorize the recoup request. - interface RecoupDisclosedCoinDetails { - type: "recoup_coin_details"; + // A coin of the original operation that is to be recouped. + // The client discloses the secrets from which the exchange can + // recompute the coin's blinded envelope, proves ownership of the + // coin and authorizes the recoup. + interface RecoupedCoin { + type: "recoup"; - // The coin's public key + // The coin's public key. coin_pub: CoinPublicKey; - // The blinding secret for this coin - // that was used during withdraw - coin_blinding_key_secret: DenominationBlindingKeySecret; + // Hash of the public key of the coin's denomination. + // It MUST be listed in the ``recoup`` field of ``/keys`` + // and MUST match the denomination at this position of the + // original withdraw request. + denom_pub_h: HashCode; - // The coin's commitment for age restriction, - // if the denomination had age restriction support. - age_commitment_h?: AgeCommitmentHash; + // Unblinded signature of the denomination key over the coin. + denom_sig: DenominationSignature; - // The blinding nonce that went into this coin's - // blinded envelope - cs_session_nonce?: HashCode; + // The blinding secret that was used to blind the coin + // in the original withdraw request. + coin_blind_key_secret: DenominationBlindingKeySecret; - // In case of Clause-Schnorr denomination, - // the blinding values that were provided - // for this coin, by the exchange, as response - // to a call to /blinding-prepare. - cs_r_pubs?: CSRPublicPair; + // The hash of the coin's age commitment. MUST be present + // if and only if the denomination supports age restriction. + h_age_commitment?: AgeCommitmentHash; - // Unblinded signature of the coins' public key, - // signed by the denomination key. - denom_pub_sig: DenominationSignature; + // Signature over `TALER_RecoupRequestPS` with purpose + // ``TALER_SIGNATURE_WALLET_COIN_RECOUP``, created with the + // coin's private key. + coin_sig: EddsaSignature; + } - // The denomination public key. - // This denomination MUST be eligible for recoup, - // i.e. being listed in the "recoup" section of /config. - denom_pub_h: HashCode; + Note that for denominations of cipher type Clause-Schnorr the client + does not need to provide the session nonce or the R-value pair: the + exchange derives the nonce from the ``blinding_seed`` of the original + withdraw request and the coin's position in ``coin_data`` (exactly as the + wallet does), and it stored the R-values it returned in + :http:post:`/blinding-prepare </blinding-prepare>`. - // Signature of `TALER_RecoupRequestPS`, - // created by this coin's private key. - coin_sig: EddsaSignature; + The exchange processes the request as follows: + + 1. It looks up the withdraw operation under ``reserve_pub`` and + ``planchets_h``, which yields the number ``n`` of signed coins, their + denominations and, where applicable, the blinding seed, the R-values and + the ``noreveal_index``. If ``coin_data`` does not have ``n`` entries, + the request is rejected. + 2. For each `RecoupedCoin` at position ``i``, it checks that the + denomination is eligible for recoup and equals the one recorded at + position ``i``, verifies ``denom_sig`` and ``coin_sig``, recomputes the + blinded envelope from ``coin_pub``, ``coin_blind_key_secret``, + ``h_age_commitment`` and the stored cipher-specific values, and hashes + it into a `BlindedCoinEnvelopeHash`. For each `NonRecoupedCoin` + it uses the ``h_coin_ev`` provided. + 3. It computes the hash over the resulting ``n`` values and compares it + to the hash of the batch it signed: ``planchets_h`` itself if the + withdraw request had no ``max_age``, and otherwise the hash of the batch + at the ``noreveal_index``. + 4. In one database transaction, it credits the residual value of every + disclosed coin to the reserve and records the recoup. If any coin + cannot be recouped, the whole request fails and nothing is credited. + + .. ts:def:: RecoupWithdrawResponse + + interface RecoupWithdrawResponse { + // Public key of the reserve that was credited. + reserve_pub: EddsaPublicKey; + + // One confirmation per `RecoupedCoin` in the request, + // in the same order. + recoups: RecoupWithdrawConfirmation[]; + } + + .. ts:def:: RecoupWithdrawConfirmation + + interface RecoupWithdrawConfirmation { + // Public key of the coin that was recouped. + coin_pub: CoinPublicKey; + + // Amount credited to the reserve for this coin, that is + // the coin's residual value at the time of the recoup. + amount: Amount; + + // Time when the exchange accepted the recoup of this coin. + timestamp: Timestamp; + + // Signature over `TALER_RecoupConfirmationPS` with purpose + // ``TALER_SIGNATURE_EXCHANGE_CONFIRM_RECOUP``. + exchange_sig: EddsaSignature; + + // Public key used to create ``exchange_sig``. + // Should match one of the exchange's signing keys from ``/keys``. + exchange_pub: EddsaPublicKey; } diff --git a/design-documents/037-wallet-transactions-lifecycle.rst b/design-documents/037-wallet-transactions-lifecycle.rst @@ -6,7 +6,7 @@ DD 37: Wallet Transaction Lifecycle :DD shepherd: TBD :Historical contributors: Sebastian, Özgür Kesim, Christian Grothoff, Florian Dold :First published: 2023-02-13 -:Last substantive change: 2026-09-07 +:Last substantive change: 2026-09-14 :Normative references: :doc:`../wallet/wallet-core` .. contents:: Table of Contents @@ -954,6 +954,38 @@ and action table is the same as refresh: ``pending/working`` offers ``retry`` and ``suspend``; ``suspended`` offers ``resume``; ``done`` and ``failed`` offer ``delete``. It is not modeled as user-abortable work. +.. note:: + + The following paragraphs are a proposal for the wallet team, written + together with the exchange protocol **vRECOUP** specification of + :http:post:`/recoup-withdraw </recoup-withdraw>` and + :http:post:`/recoup-refresh </recoup-refresh>`. wallet-core still + implements the per-coin recoup of earlier protocol versions. + +Since exchange protocol **vRECOUP** recoup is a batch operation on the coins of +one withdraw or one refresh operation. A recoup transaction therefore +groups the affected coins by the operation they originated from: one +transaction per withdraw request, credited to the reserve, and one per +refresh operation, credited to the old coin that was melted. All affected +coins of one group are sent in one request. The wallet must retain, for +every coin, the commitment of the operation it came from (``planchets_h`` +for a withdrawal, ``rc`` for a refresh), the coin's index in the signed +batch, and the envelope hashes of the other coins of that batch; the +exchange no longer stores per-coin envelopes and the wallet has to +provide them. + +The public state of the transaction is unchanged. ``pending/working`` +performs the batch request and, on success, records the confirmation +signatures per coin. A withdraw recoup is followed by a withdrawal +transaction that obtains fresh coins from the credited reserve; a refresh +recoup is followed by a refresh of the credited old coin, which may be a +zombie coin whose denomination is already past its deposit expiration. +Both follow-up transactions are ordinary transactions of their type. If the +exchange reports that a coin was already spent (``409 Conflict``), the +group fails as a whole and nothing is credited; the wallet should remove +the spent coin from the group and retry with the remaining coins, or +record a ``denom-loss`` for coins that cannot be recouped at all. + Transaction Type: Denomination Loss -----------------------------------