taler-docs

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

commit 2186f753d98149e1e10bc226b759c02ab2694c95
parent ca5a81e94c7c148f55841c38fcb69a3008310ec2
Author: Christian Grothoff <christian@grothoff.org>
Date:   Mon, 17 Aug 2026 20:16:54 +0200

dd92: update to latest wallet-core API

Diffstat:
Mdesign-documents/092-incremental-backup-sync.rst | 700+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
1 file changed, 580 insertions(+), 120 deletions(-)

diff --git a/design-documents/092-incremental-backup-sync.rst b/design-documents/092-incremental-backup-sync.rst @@ -759,6 +759,25 @@ backup with matching primary keys, a state-based CRDT “merge” strategy was carefuly devised for every top-level operation type in the block, so that wallets can deterministically agree on a consistent global state. +One rule cuts across all of the transaction families: **a transaction only +ever moves towards its end.** The wallets of a group work on the same +transactions at the same time, so an increment that would take a record +back to a state it has already moved past is describing an older view of +it, and only its origin block is recorded. The terminal states are ranked +rather than simply frozen, so that two wallets which reached *different* +ones both settle on the same one: + +.. code-block:: text + + done > failed > aborted > expired > (not terminal) + +Preferring ``done`` is deterministic, which is what convergence needs, and +it is also the truthful answer: a transaction that finished actually moved +the money. Without the rule, a wallet that completed a withdrawal would +pull in the abort another device had issued against the copy it restored, +and end up showing an abandoned transaction while holding the coins it +produced. + Add or update an exchange ~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -897,6 +916,20 @@ 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 +counts are always derived and never carried, which is what makes the +restore idempotent; only a coin that is spendable (status ``fresh``) +counts, matching what the wallet's own bookkeeping does with a suspended +one. + +For the two balances to agree, *every* change to whether a coin counts has +to reach the other wallets, not only spending: a coin melted into a +refresh, recouped from a revoked denomination, written off with its +denomination, or suspended by the user is reported with a ``spend-coin`` +increment carrying its new status. The section is the coin's terminal +update, whatever brought it about. A change that is not reported is the +one way the two devices can end up disagreeing about how much money the +user has, since a coin that is already backed up is never offered again by +the full collection pass. The reserves (and with them the ability to recoup a restored coin) are backed up by the ``add-reserve`` family, and the withdrawal family (``withdrawal-start`` / ``withdrawal-abort`` / ``withdrawal-done`` / @@ -906,11 +939,18 @@ up by the ``add-reserve`` family, and the withdrawal family the withdrawal transactions themselves and lets a restored wallet continue a pending one -- the bank's operation is keyed by that URI, and the reserve key pair and the coin seed are in the backup too; only an -expired bank operation cannot be resumed. The refresh groups are -still not backed up, so a restored refreshed coin cannot be -recouped-refreshed until the refresh family lands (recoup of a withdrawn -coin works, -see the recoup discussion under "Add a reserve"). +expired bank operation cannot be resumed. A refreshed coin's melt is +backed up by the refresh family below, so a restored coin can be +recouped-refreshed as well as recouped (see the recoup discussion under +"Add a reserve"). + +``exchangeWithdrawValues`` carries the blinding values the exchange +contributed to the withdraw, which a recoup has to replay. For an RSA +coin they are the constant ``{"cipher": "RSA"}``; for a Clause-Schnorr +coin they are the R-values, which nothing can re-derive, so they have to +travel in the increment. The field is optional because it was added +after the increment was first released: a coin from a wallet that +predates it is treated as RSA. .. ts:def:: AddCoinInc @@ -930,6 +970,7 @@ see the recoup discussion under "Add a reserve"). visible?: number; maxAge: number; ageCommitmentProof?: AgeCommitmentProof; + exchangeWithdrawValues?: ExchangeWithdrawValue; } .. ts:def:: CoinSource @@ -947,7 +988,13 @@ see the recoup discussion under "Add a reserve"). reservePub: string; } -.. TODO: RefreshCoinSource (backup refresh groups?) +.. ts:def:: RefreshCoinSource + + interface RefreshCoinSource { + type: "refresh"; + refreshGroupId: string; + oldCoinPub: string; + } * **Primary key:** ``[coinPub]`` * **Deletion groups:** ``[coins]`` @@ -981,6 +1028,7 @@ A signed coin is spent by the user. visible?: number; maxAge: number; ageCommitmentProof?: AgeCommitmentProof; + exchangeWithdrawValues?: ExchangeWithdrawValue; } * **Primary key:** ``[coinPub]`` @@ -989,19 +1037,58 @@ A signed coin is spent by the user. Add a token ~~~~~~~~~~~ -A token is generated by the wallet but not yet signed by the merchant. +A token is generated by the wallet but not yet signed by the merchant (the +wallet database calls this a *slate*). + +Like coins, tokens were originally designed as seed-derived: the increment +carried ``[secretSeed, choiceIndex, outputIndex]`` and the wallet +re-derived the key pair from it. The wallet database stores per-token key +material instead, so the increments carry the token as it stands, and the +token's *use* public key is the primary key of the family. The three +increments share one body, ``TokenIncBase``: + +.. ts:def:: TokenIncBase + + interface TokenIncBase { + // Purchase the token belongs to, and the position within its + // contract that produced it. + purchaseId: string; + transactionId?: string; + choiceIndex?: number; + outputIndex?: number; + repeatIndex?: number; + + merchantBaseUrl: string; + kind: MerchantContractTokenKind; + slug: string; + name: string; + description: string; + descriptionI18n?: InternationalizedString; + extraData: MerchantContractTokenDetails; + + tokenIssuePub: TokenIssuePublicKey; + tokenIssuePubHash: string; + tokenFamilyHash?: string; + validAfter: TalerProtocolTimestamp; + validBefore: TalerProtocolTimestamp; + + // The key material the wallet holds for this token. Nothing can + // reconstruct it, so it travels in the increment. + tokenUsePub: string; + tokenUsePriv: string; + tokenUseSig?: TokenUseSig; + tokenEv: TokenEnvelope; + tokenEvHash: string; + blindingKey: string; + } .. ts:def:: AddTokenInc - interface AddTokenInc { + interface AddTokenInc extends TokenIncBase { type: "add-token"; - secretSeed: string; - choiceIndex: number; - outputIndex: number; - contractTermsHash: HashCode; // blob } -* **Primary key:** ``[secretSeed, choiceIndex, outputIndex]`` +* **Primary key:** ``[tokenUsePub]`` * **Deletion groups:** ``[tokens]`` Merge strategy @@ -1012,20 +1099,18 @@ No merge is required, new tokens are unique. Sign a token ~~~~~~~~~~~~ -A token is signed by the merchant. +A token is signed by the merchant. Applying this increment also removes +the slate the token was issued from, the same way the wallet's own +issuance flow does. .. ts:def:: SignTokenInc - interface SignTokenInc { + interface SignTokenInc extends TokenIncBase { type: "sign-token"; - secretSeed: string; - choiceIndex: number; - outputIndex: number; - contractTermsHash: HashCode; // blob tokenIssueSig: UnblindedDenominationSignature; } -* **Primary key:** ``[secretSeed, choiceIndex, outputIndex]`` +* **Primary key:** ``[tokenUsePub]`` * **Deletion groups:** ``[tokens]`` Merge strategy @@ -1037,18 +1122,20 @@ the merchant, further attempts to sign it will fail. Spend a token ~~~~~~~~~~~~~ -A signed token is spent by the user. +A signed token is spent by the user. Only the fields the spend changes +travel; the increment updates a token that is already there and is skipped +when it is not. .. ts:def:: SpendTokenInc interface SpendTokenInc { type: "spend-token"; - secretSeed: string; - choiceIndex: number; - outputIndex: number; + tokenUsePub: string; + transactionId?: string; + tokenUseSig?: TokenUseSig; } -* **Primary key:** ``[secretSeed, choiceIndex, outputIndex]`` +* **Primary key:** ``[tokenUsePub]`` * **Deletion groups:** ``[tokens]`` Merge strategy @@ -1296,10 +1383,8 @@ entries (``currentMergeReserveRowId``) and the peer-pull-credit records 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 -peer-pull-debit families -- are implemented; see the "Definition of done" -section. +Every increment family in this document is implemented; see the +"Definition of done" section for what remains. Start a deposit ~~~~~~~~~~~~~~~ @@ -1891,6 +1976,241 @@ Merge strategy Store all ``failReason`` in the database. +Start a refresh +~~~~~~~~~~~~~~~ + +The wallet melts the remainder of one or more coins into fresh ones -- as +change after a payment, or to renew a coin whose denomination is about to +expire. + +The group carries the plan; how far it has got lives in the per-coin +sessions below. A restored group is what lets a wallet that melted a coin +and then lost the device still collect the change: the exchange holds the +first melt commitment, and a wallet that re-melted with a fresh seed could +not reveal against it. + +.. ts:def:: RefreshStartInc + + interface RefreshStartInc { + type: "refresh-start"; + refreshGroupId: string; + currency: string; + reason: string; + originatingTransactionId?: string; + oldCoinPubs: string[]; + inputPerCoin: AmountString[]; + expectedOutputPerCoin: AmountString[]; + timestampCreated: TalerPreciseTimestamp; + } + +* **Primary key:** ``[refreshGroupId]`` +* **Deletion groups:** ``[refreshes]`` + +Merge strategy +++++++++++++++ + +Last write wins: the plan of a refresh group never changes. + +Refresh session +~~~~~~~~~~~~~~~ + +The melt of one coin of a refresh group. + +Everything the reveal step needs -- the fresh coins' key material included +-- is derived from ``sessionPublicSeed`` together with the old coin and the +chosen denominations, all of which travel here, so this is the part of a +refresh that has to be backed up. + +.. ts:def:: RefreshSessionInc + + interface RefreshSessionInc { + type: "refresh-session"; + refreshGroupId: string; + coinIndex: number; + sessionPublicSeed?: string; + refreshProtocolVersion?: number; + amountRefreshOutput: AmountString; + newDenoms: { denomPubHash: string; count: number }[]; + norevealIndex?: number; + } + +* **Primary key:** ``[refreshGroupId, coinIndex]`` +* **Deletion groups:** ``[refreshes]`` + +Merge strategy +++++++++++++++ + +Last write wins: the session is written once, when the coin is melted. + +Refresh done +~~~~~~~~~~~~ + +Every coin of the group has been melted and the fresh coins collected. + +.. ts:def:: RefreshDoneInc + + interface RefreshDoneInc { + type: "refresh-done"; + refreshGroupId: string; + timestampFinished: TalerPreciseTimestamp; + } + +* **Primary key:** ``[refreshGroupId]`` +* **Deletion groups:** ``[refreshes]`` + +Refresh failed +~~~~~~~~~~~~~~ + +The refresh could not be completed. + +.. ts:def:: RefreshFailInc + + interface RefreshFailInc { + type: "refresh-fail"; + refreshGroupId: string; + failReason: TalerErrorDetail; + } + +* **Primary key:** ``[refreshGroupId]`` +* **Deletion groups:** ``[refreshes]`` + +Derived operations: refunds, recoups and denomination losses +------------------------------------------------------------ + +The three families below differ from every other one in this document: +the wallet does not start them, it *learns* about them. A refund is the +merchant's answer to a refund query, a recoup is forced by an exchange +revoking a denomination, and a denomination loss is what the wallet has to +write off when a denomination expires or is withdrawn from circulation. + +Any wallet holding the coins can ask the same question and get the same +answer, which is what decides how they are backed up: **only a finished +one travels, and it restores as finished.** Backing up a pending one +would hand the second device work on an operation it cannot see the whole +of -- it would go and query a merchant about a refund that is already +settled on the first device -- and would leave the user looking at an +operation that is long over elsewhere but "pending" here. A pending one +is simply not collected, and keeps no origin block, so a later pass offers +it up once it has finished. + +Refund +~~~~~~ + +A refund the merchant granted, as it finally stood. + +The refund *items* (one per coin) are deliberately not carried: nothing +outside the refund query itself reads them, the transaction is rendered +entirely from the group, and their identity is the merchant's +(``coin_pub``/``rtransaction_id``), so a wallet that does query gets the +same ones back. + +.. ts:def:: RefundInc + + interface RefundInc { + type: "refund"; + refundGroupId: string; + // The purchase this refunds; restored as the transaction it points + // at, and not applied at all when that purchase is not there. + proposalId: string; + outcome: DerivedOutcome; + amountRaw: AmountString; + amountEffective: AmountString; + timestampCreated: TalerPreciseTimestamp; + } + +.. ts:def:: DerivedOutcome + + // How one of the derived operations ended. A wire string rather than + // the wallet's numeric status enum, which is a database detail. + type DerivedOutcome = "done" | "failed" | "aborted" | "expired"; + +* **Primary key:** ``[refundGroupId]`` +* **Deletion groups:** ``[refunds, payments]`` + +Merge strategy +++++++++++++++ + +Last write wins: the increment describes one finished operation, and there +is nothing to reconcile field by field. + +Recoup +~~~~~~ + +Coins reclaimed from an exchange that revoked their denomination. + +What the recoup *did* to the coins reaches the other wallets as coin +increments; this is what makes the operation itself appear. Its per-coin +progress is not carried -- it describes a run the other wallet did not +make -- and a restored recoup is marked finished for every coin, so that +the second device does not go and re-submit somebody else's recoup. + +.. ts:def:: RecoupInc + + interface RecoupInc { + type: "recoup"; + recoupGroupId: string; + exchangeBaseUrl: string; + outcome: DerivedOutcome; + // The coins that were recouped, in the order the group listed them. + coinPubs: string[]; + timestampStarted: TalerPreciseTimestamp; + timestampFinished?: TalerPreciseTimestamp; + } + +* **Primary key:** ``[recoupGroupId]`` +* **Deletion groups:** ``[recoups, coins]`` + +Merge strategy +++++++++++++++ + +Last write wins. + +Denomination loss +~~~~~~~~~~~~~~~~~ + +A denomination the wallet had to write off, with the coins it cost. + +Unlike the two above this one is not merely history: until the other +wallets learn of it they keep the affected coins in their balance, and the +two devices disagree about how much money the user has. The coins +themselves carry the same news -- their status becomes ``denom-loss`` -- +and this is what makes the transaction appear. + +``denomLossEventId`` is **derived from the loss** rather than drawn at +random. Both wallets notice the same expiry on their own, each updating +the exchange and seeing the same denominations go; with random identifiers +the user would end up with the same loss listed twice. + +.. code-block:: text + + denom_loss_event_id = SHA512(exchange_base_url || 0 || event_type || 0 || + sorted(denom_pub_hashes) each || 0)[0:32] + +.. ts:def:: DenomLossInc + + interface DenomLossInc { + type: "denom-loss"; + denomLossEventId: string; + currency: string; + exchangeBaseUrl: string; + denomPubHashes: string[]; + // "denom-expired", "denom-vanished", "denom-revoked", + // "denom-unoffered". + eventType: string; + // "aborted" when the loss turned out to be reversible. + outcome: "done" | "aborted"; + amount: AmountString; + timestampCreated: TalerPreciseTimestamp; + } + +* **Primary key:** ``[denomLossEventId]`` +* **Deletion groups:** ``[denom-losses, denominations]`` + +Merge strategy +++++++++++++++ + +Last write wins. + Item deletion ------------- @@ -1945,6 +2265,34 @@ Backup process Collecting increments ~~~~~~~~~~~~~~~~~~~~~ +Recording runs inside the very transaction that performs the withdrawal, +the payment or the deposit, which is what makes wallet state and backup +state commit together -- and also means that anything the recording throws +takes that operation down with it. It must therefore be impossible for +the backup to fail an operation: the eager recording is an *optimisation*, +not the guarantee. A record whose increment never made it keeps its +``originBlocks`` unset, which is exactly what the full collection pass +looks for, so a failure costs a delay and nothing else. Recording, waking +the cycle and queueing a deletion all log and swallow; the critical-point +hold fails open. + +The same applies to key material the wallet *derives* for an operation. A +reserve key pair comes from the reserve seed, so a seed the wallet cannot +decode would otherwise block every withdrawal, permanently, since the seed +is stored. An unusable seed instead falls back to a random reserve key +pair, which the backup carries as ``reservePriv`` the way it does for +reserves that predate the seed, and the seed itself is left untouched -- +reserves already derived from it are named by their index, so replacing it +would make them underivable elsewhere. + +Stored key material is checked before it is decoded, because the two +Crockford base32 decoders a wallet may run on do not agree: the JavaScript +one ignores trailing padding bits that are not zero, while the native +(qtart) one rejects the string outright. A value decoded unchecked +therefore works in a browser extension and throws on a phone. Re-encoding +the decoded bytes and comparing settles it on either runtime, and is what +the restore path uses to refuse a malformed seed rather than store one. + Wallet transactions record what they changed by appending increments to a pending buffer, held in the wallet's backup configuration record. The recording happens **within the same database transaction that performs the @@ -2003,9 +2351,41 @@ longer reconstruct them. A cycle is triggered after the recording transaction commits; if the wallet stops before it runs, the increments simply stay pending until the next -cycle. Independently, a periodic task runs a cycle at a fixed interval, -covering increments whose trigger never fired, e.g. because the wallet was -offline or the operation has no critical point. +cycle. Independently, a periodic task runs a cycle every hour, covering +increments whose trigger never fired, e.g. because the wallet was offline +or the operation has no critical point. A cycle that could not reach the +provider is retried after five minutes, and one that is waiting for the +account payment to be prepared after thirty seconds -- the payment is what +unlocks every upload, so it is worth retrying as soon as the provider's +merchant backend recovers. + +Waking the cycle is not always enough. Past a critical point the wallet +has already revealed key material to somebody else -- the exchange has +signed the planchets, the purse exists and can be paid into -- and the +cycle runs concurrently, so the operation would go ahead regardless. +Those points therefore *hold*: the task returns to the scheduler and is +retried, and only proceeds once the pending buffer has reached the +provider. The hold is skipped when the account is unpaid, since no cycle +can drain the buffer until the user pays and freezing every such +transaction would be the worse failure. + +Each request for a cycle names how much is at stake, and the most urgent +reason asked for since the last cycle that reached the provider is what +decides how hard a *failing* cycle retries: + +* ``irrecoverable-secret`` -- key material a lost device would turn into + lost money. Retried after fifteen seconds: the transaction that + produced it is held until the buffer drains, so a longer wait is also + how long that transaction sits still. +* ``transaction-milestone`` -- a state the user would notice losing, but + one that can be reconstructed. +* ``account-payment`` -- the sync account's own payment moved; nothing of + the user's is at stake. + +The last two fall back to the ordinary five-minute retry. The urgency is +not persisted: after a restart the pending increments are still there and +the critical points ask again on their next retry, so it re-establishes +itself rather than having to be reconstructed. Full collection pass ~~~~~~~~~~~~~~~~~~~~ @@ -2081,22 +2461,36 @@ scheduling mechanisms described above drive them. activate?: boolean; } -``addBackupProvider`` registers a sync server: it fetches the provider's -``/config`` for its terms (protocol version, annual fee, storage limit), -stores a provider record, and -- when ``activate`` is set -- makes it the -active sync target and runs the first backup cycle. The cycle is what -settles the account payment: a sync account only exists once it has been -paid for, and the server rejects every upload (even at a zero annual fee) -until then. A zero-fee account is paid automatically; any other account -produces a payment transaction that the user confirms from the wallet. -The response either confirms the provider is ready or hands back a -``taler://pay/...`` URI for the account payment: +The cycle never *waits* for the account payment. Downloading the +provider's proposal and paying it are the purchase's own task, so the +cycle only ever looks at where that purchase has got to -- confirming it +when it is waiting for a decision, and otherwise leaving it alone -- and +comes back when the purchase transitions, or on its retry interval. Every +step is therefore idempotent and survives a wallet that stops in the +middle. + +``addBackupProvider`` registers a sync server: it stores a provider record +and -- when ``activate`` is set -- makes it the active sync target and +wakes the backup cycle. The request itself does not talk to the provider +and returns as soon as the record is written; an unreachable provider, or +one that is not a sync server, therefore shows up as a failing (and +retrying) cycle rather than as an error from this request. + +The first cycle is what learns the provider's terms (it fetches +``/config`` and reports the result with the ``terms-fetched`` phase of the +``backup-status`` notification) and what settles the account payment: a +sync account only exists once it has been paid for, and the server rejects +every upload (even at a zero annual fee) until then. A zero-fee account is +paid automatically; any other account produces a payment transaction that +the user confirms from the wallet, and the ``payment-required`` phase of +the notification carries its ``taler://pay/...`` URI. Clients follow all +of this through the notifications, not through this request's response: .. ts:def:: AddBackupProviderResponse - type AddBackupProviderResponse = - | { status: "ok" } - | { status: "payment-required"; talerUri?: string }; + interface AddBackupProviderResponse { + status: "ok"; + } ``removeBackupProvider`` takes a `RemoveBackupProviderRequest` naming the provider by base URL and returns an empty object. @@ -2128,12 +2522,26 @@ account on it: backupProviderBaseUrl: string; name: string; terms?: BackupProviderTerms; + + // Why the last cycle failed, when it did. Only for the active + // provider: the cycle statistics describe the wallet's last cycle, + // and that ran against the provider it syncs to. lastError?: TalerErrorDetail; lastSuccessfulBackupTimestamp?: TalerPreciseTimestamp; lastAttemptedBackupTimestamp?: TalerPreciseTimestamp; + + // Payment transactions opened for this account, most recent last. + paymentTransactionIds: string[]; + // Deprecated alias of paymentTransactionIds, with the same contents, + // for user interfaces built against an older wallet-core. paymentProposalIds: string[]; - backupProblem?: BackupProblem; paymentStatus: ProviderPaymentStatus; + + // What the provider reports it holds for the account, from the + // account status lookup. Absent until a cycle has managed to ask, + // and for providers older than sync protocol v4. + storageUsedBytes?: number; + blockCount?: number; } .. ts:def:: BackupProviderTerms @@ -2171,37 +2579,66 @@ what the user backs up out of band, and what a restoring wallet is fed. name: string; url: string; }[]; + + // The same data as a self-contained plain text, for writing down by + // hand or saving to a file. Produced here and *not* consumed by + // loadBackupRecovery, which reads the structured fields above. + paperKey?: string; } +The paper key is line-oriented, so that a line is the unit to copy, parse +and transpose: + +.. code-block:: text + + TALER-PAPERKEY:1 + KEY: GXDG VQKT ... (the root key, grouped in fours) + CHECK: a1b2c3d4 (first 8 hex digits of SHA-512(root key)) + PROVIDER: https://sync.example.com/ + URI: taler://restore/... (the machine-readable form, LSD0006 5.7) + +The ``URI`` line is the canonical machine form: a device restoring from a +scan or a file needs nothing but that line. The ``KEY`` / ``PROVIDER`` +lines are the human form, and the checksum catches a transcription error +before it silently restores a different -- empty -- sync group. + ``loadBackupRecovery`` feeds such a recovery document into a wallet, which -is how a second (or replacing) device joins the sync group. With the -default "theirs" strategy the wallet adopts the recovery's root key -- the -key all per-provider account keys are derived from -- adds the recovery's -providers, and drops its own sync pointers so that the next backup cycle -re-pulls the whole linked list. With "ours" it keeps its own root key and only -takes over the providers. +is how a second (or replacing) device joins the sync group. The wallet +adopts the recovery's root key -- the key every per-provider account key is +derived from, so adopting it *is* what joining the group means -- and adds +the recovery's providers. There is no "keep my own key" variant: a wallet +that kept its own key would derive different account keys and so would not +be in the group at all. + +Adopting another root key also detaches the wallet from the group it was +in: the blocks it stored are encrypted under a key it no longer has, and +the ``originBlocks`` lists that reference them are meaningless. Both are +cleared. That deliberately leaves the wallet's own records looking "never +backed up", which is what they are with respect to the group being joined: +the full collection pass then offers them up, instead of the pull's +"deleted iff absent from all origin blocks" sweep removing them for not +appearing in the new group's linked list. + +The providers are registered but not activated; the client activates one +with ``addBackupProvider`` (``activate: true``), and that is what starts +the cycle which pulls the backup. .. ts:def:: RecoveryLoadRequest interface RecoveryLoadRequest { recovery: BackupRecovery; - strategy?: RecoveryMergeStrategy; - } - -.. ts:def:: RecoveryMergeStrategy - - enum RecoveryMergeStrategy { - // Keep the local wallet root key, import and take over providers. - Ours = "ours"; - // Migrate to the wallet root key from the recovery information. - Theirs = "theirs"; } ``runBackupCycle`` runs a backup cycle now, instead of waiting for the periodic task. This is the dedicated "back up now" request; earlier -implementations triggered a cycle by re-adding the active provider. The -cycle runs synchronously and is serialized against any other cycle, and -the response reports what it did. +implementations triggered a cycle by re-adding the active provider. + +The request only *wakes* the cycle and returns an empty object +immediately: the cycle runs asynchronously (and is serialized against any +other cycle), reports its progress and outcome through the +``backup-status`` notifications, and persists its statistics for +``getBackupDiagnostics``. Clients track the cycle through those, not +through this request's response. .. ts:def:: RunBackupCycleRequest @@ -2213,13 +2650,7 @@ the response reports what it did. force?: boolean; } -.. ts:def:: RunBackupCycleResponse - - interface RunBackupCycleResponse { - stats: BackupCycleStats; - } - -The same statistics are persisted by the wallet after every cycle, +The statistics are persisted by the wallet after every cycle, whatever triggered it, and are reported by ``getBackupDiagnostics`` as the "last cycle" outcome. The ``outcome`` field says how the cycle ended: ``"ok"`` (including idle cycles with nothing to push), @@ -2231,6 +2662,9 @@ ended: ``"ok"`` (including idle cycles with nothing to push), timestamp: TalerPreciseTimestamp; // How the cycle ended: "ok", "payment-required" or "error". outcome: "ok" | "payment-required" | "error"; + // Why it failed, when the outcome is "error"; the same detail the + // notification carried, kept for a client that was not listening. + lastError?: TalerErrorDetail; // What the cycle pushed to the provider. pushed: { @@ -2310,10 +2744,10 @@ is in and, on the terminal phases, the outcome and the relevant counters. interface BackupStatusNotification { type: "backup-status"; providerBaseUrl: string; - // "started", "pulling" or "pushing" for the progress phases; the - // cycle ends in exactly one of "completed", "error" and + // "started", "pulling", "pushing" and "terms-fetched" are progress + // phases; the cycle ends in exactly one of "completed", "error" and // "payment-required". - phase: "started" | "pulling" | "pushing" | + phase: "started" | "pulling" | "pushing" | "terms-fetched" | "completed" | "error" | "payment-required"; // Number of increments packed into the block being pushed // (at "pushing"). @@ -2333,8 +2767,10 @@ is in and, on the terminal phases, the outcome and the relevant counters. The wallet emits ``started`` when a cycle begins, ``pulling`` before the linked list is fetched, ``pushing`` with the increment count before the -packed block (and its blobs) is uploaded, and a terminal phase when the -cycle ends: +packed block (and its blobs) is uploaded, ``terms-fetched`` when it has +read the provider's ``/config`` (which is where a newly added provider's +terms come from, so a client showing them refreshes on it), and a terminal +phase when the cycle ends: * ``completed`` -- the cycle ran without error and without requiring payment (``pulledBlocks`` / ``pushedBlockNonce`` carry the counters); @@ -2342,7 +2778,16 @@ cycle ends: may already have been prepared, and the UI should take the user to it; * ``error`` -- the cycle failed (with ``error`` as the reason); the wallet retries on its own schedule, so the notification is only for - the user interface. + the user interface. The reason is also persisted, and reported by + ``getBackupInfo`` as the active provider's ``lastError``, so a client + that was not listening at the time still sees it. + +A cycle whose pull applied anything additionally emits a ``balance-change`` +notification. The apply path writes coins and transactions straight into +the database, so none of the transaction state machines report them; the +``backup-status`` notification says a cycle finished, not that the +wallet's contents changed, and a client that refreshed on it alone would +show a restoring wallet as empty until something else happened. An earlier ``backup-error`` notification type (``BackupOperationError``) was part of a legacy backup proof of concept and has been removed in @@ -2355,53 +2800,68 @@ Definition of done * [ ] Design incremental sync. * [x] Design backup/restore schedules. * [x] Design wallet-core API. -* [ ] Wallet-core implementation (block and blob encoding, CRDT merge, sync - protocol client and signatures, increment collection, the scheduled backup - cycle with its pull/merge/apply half, the API request handlers, the account - payment flow and item deletion (retro-redaction of the ``originBlocks`` plus - the pull-side "deleted iff absent from all origin blocks" sweep) are done; - of the increment types, the exchange, global-trust, bank-account, donau and - denomination families as well as the deposit, merchant-payment, - peer-push-credit, peer-push-debit and peer-pull-debit families are - implemented. The contract terms of those transactions are backed up as - blobs (uploaded ahead of the referencing blocks with their reference-count - adjustments, and fetched and stored back into the contract-terms store on - the pull side); a transaction whose terms are not available is shown in a - reduced form instead of failing the transaction listing. The coin family - (``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 reserve family (``set-reserve-seed`` / ``add-reserve``, with the +* [x] Wallet-core implementation. The machinery -- block and blob + encoding, CRDT merge, the sync protocol client and its signatures, + increment collection, the scheduled backup cycle with its + pull/merge/apply half, the API request handlers, the account payment + flow, and item deletion (retro-redaction of the ``originBlocks`` plus + the pull-side "deleted iff absent from all origin blocks" sweep) -- is + done, and so is **every increment family in this document**: the + exchange, global-trust, bank-account, donau and denomination entities; + 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 implemented - (``withdrawal-start`` / ``withdrawal-abort`` / ``withdrawal-done`` / - ``withdrawal-fail``, referencing the reserve by - ``[exchangeBaseUrl, reservePub]`` and carrying the ``wgInfo`` with the - ``taler://withdraw`` URI of the bank's operation); a restored wallet - can continue a pending withdrawal (an expired bank operation is the - only thing that cannot be resumed). - The refresh groups are not backed up, so a restored refreshed coin - cannot be recouped-refreshed; 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 - spendable tokens). The ``runBackupCycle`` and ``getBackupDiagnostics`` - request handlers, the per-cycle statistics (persisted for the diagnostics - view), the forced full-collection pass and the ``backup-status`` - notifications are implemented. The native (sqlite) backend persists the - ``originBlocks`` of every backup-managed record; earlier native databases - are migrated on open). + that predate the seed), which is what makes a restored coin recoupable; + the withdrawal, deposit, merchant-payment, peer-push-credit, + peer-push-debit, peer-pull-debit and peer-pull-credit transaction + families; the refresh family, whose per-coin session seed lets a + restored wallet finish a melt instead of losing the change; and the coin + and token families, which carry the per-record key material the wallet + database stores (the seed-derived modelling of earlier drafts is gone + from both). + + Contract terms travel as blobs -- uploaded ahead of the blocks that + reference them, with their reference counts adjusted, and fetched and + stored back into the contract-terms store on the pull side; a + transaction whose terms are not available is shown in a reduced form + instead of failing the transaction listing. Restoring a coin recomputes + the coin-availability rows, so a restored wallet shows the same balance + as the wallet that made the backup, and a restored wallet can continue a + pending withdrawal (only an expired bank operation cannot be resumed). + ``runBackupCycle`` and ``getBackupDiagnostics``, the per-cycle + statistics, the forced full-collection pass and the ``backup-status`` + notifications are all in place, on both database backends: the native + (sqlite) schema stores the backup providers and blocks and the + ``originBlocks`` of every backup-managed record, and a wallet migrating + from the IndexedDB backend carries all three across. + + The three *derived* families -- refund, recoup and denomination loss -- + are implemented as finished facts, and every change to whether a coin + counts towards the balance (spend, refresh, recoup, denomination loss, + suspend) is reported as a coin increment, so two wallets converge on the + same balance rather than only on the same coins. A transaction can no + longer be taken back out of a terminal state by an increment describing + an older view of it. + + Known gaps, none of which loses money: refund *items* are not carried + (nothing outside the refund query reads them, and the merchant hands + back the same ones); the exchange entries and peer-pull-credit records + do not restore their ``currentMergeReserveRowId`` pointer, since it is a + row id local to one database; recoup transactions are backed up and + restored but the wallet does not yet render them as transactions; and a + wallet cannot join a sync group written by a *newer* wallet -- it + refuses the blocks rather than re-uploading a truncated view of them. * [x] Design sync API (+ auth). * [ ] Server-side implementation (partial: block GET/POST/PUT/DELETE, object store GET/POST with reference counting, /config and payments done; reconciliation mechanism still missing). -* [ ] UI/UX for backup and sync (done in the Android wallet: "back up now" - uses ``runBackupCycle``, with a force-full-backup control and a backup - diagnostics card exposed in developer mode, and a progress display driven - by the ``backup-status`` notifications). +* [x] UI/UX for backup and sync, in the Android wallet: adding and removing + a provider, the account payment prompt, the recovery as a QR code and as + a paper key (written down or saved to a file) with its import + counterpart, "back up now" through ``runBackupCycle`` with a + force-full-backup control and a diagnostics card in developer mode, and + a progress display driven by the ``backup-status`` notifications. The + web extension shows the cycle in its wallet-activity view, but has no + provider management user interface yet. Alternatives ============