wallet-core: remove old sync code, add stored backups skeleton

This commit is contained in:
Florian Dold 2023-08-30 18:01:18 +02:00
parent 0a4782a0da
commit a713d90c3c
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
10 changed files with 214 additions and 2954 deletions

File diff suppressed because it is too large Load Diff

View File

@ -2655,3 +2655,21 @@ export interface TransactionRecordFilter {
onlyState?: TransactionStateFilter; onlyState?: TransactionStateFilter;
onlyCurrency?: string; onlyCurrency?: string;
} }
export interface StoredBackupList {
storedBackups: {
name: string;
}[];
}
export interface CreateStoredBackupResponse {
name: string;
}
export interface RecoverStoredBackupRequest {
name: string;
}
export interface DeleteStoredBackupRequest {
name: string;
}

View File

@ -2769,6 +2769,24 @@ export const walletMetadataStore = {
), ),
}; };
export interface StoredBackupMeta {
name: string;
}
export interface StoredBackupData {
name: string;
data: any;
}
export const StoredBackupStores = {
backupMeta: describeStore(
"backupMeta",
describeContents<MetaConfigRecord>({ keyPath: "name" }),
{},
),
backupData: describeStore("backupData", describeContents<any>({}), {}),
};
export interface DbDumpRecord { export interface DbDumpRecord {
/** /**
* Key, serialized with structuredEncapsulated. * Key, serialized with structuredEncapsulated.
@ -2831,6 +2849,7 @@ export async function exportSingleDb(
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const tx = myDb.transaction(Array.from(myDb.objectStoreNames)); const tx = myDb.transaction(Array.from(myDb.objectStoreNames));
tx.addEventListener("complete", () => { tx.addEventListener("complete", () => {
myDb.close();
resolve(singleDbDump); resolve(singleDbDump);
}); });
// tslint:disable-next-line:prefer-for-of // tslint:disable-next-line:prefer-for-of
@ -3211,6 +3230,36 @@ function onMetaDbUpgradeNeeded(
); );
} }
function onStoredBackupsDbUpgradeNeeded(
db: IDBDatabase,
oldVersion: number,
newVersion: number,
upgradeTransaction: IDBTransaction,
) {
upgradeFromStoreMap(
StoredBackupStores,
db,
oldVersion,
newVersion,
upgradeTransaction,
);
}
export async function openStoredBackupsDatabase(
idbFactory: IDBFactory,
): Promise<DbAccess<typeof StoredBackupStores>> {
const backupsDbHandle = await openDatabase(
idbFactory,
TALER_WALLET_META_DB_NAME,
1,
() => {},
onStoredBackupsDbUpgradeNeeded,
);
const handle = new DbAccess(backupsDbHandle, StoredBackupStores);
return handle;
}
/** /**
* Return a promise that resolves * Return a promise that resolves
* to the taler wallet db. * to the taler wallet db.

View File

@ -1,586 +0,0 @@
/*
This file is part of GNU Taler
(C) 2020 Taler Systems SA
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
* Implementation of wallet backups (export/import/upload) and sync
* server management.
*
* @author Florian Dold <dold@taler.net>
*/
/**
* Imports.
*/
import {
AbsoluteTime,
Amounts,
BackupBackupProvider,
BackupBackupProviderTerms,
BackupCoin,
BackupCoinSource,
BackupCoinSourceType,
BackupDenomination,
BackupExchange,
BackupExchangeDetails,
BackupExchangeSignKey,
BackupExchangeWireFee,
BackupOperationStatus,
BackupPayInfo,
BackupProposalStatus,
BackupPurchase,
BackupRecoupGroup,
BackupRefreshGroup,
BackupRefreshOldCoin,
BackupRefreshSession,
BackupRefundItem,
BackupRefundState,
BackupTip,
BackupWgInfo,
BackupWgType,
BackupWithdrawalGroup,
BACKUP_VERSION_MAJOR,
BACKUP_VERSION_MINOR,
canonicalizeBaseUrl,
canonicalJson,
CoinStatus,
encodeCrock,
getRandomBytes,
hash,
Logger,
stringToBytes,
WalletBackupContentV1,
TalerPreciseTimestamp,
} from "@gnu-taler/taler-util";
import {
CoinSourceType,
ConfigRecordKey,
DenominationRecord,
PurchaseStatus,
RefreshCoinStatus,
WithdrawalGroupStatus,
WithdrawalRecordType,
} from "../../db.js";
import { InternalWalletState } from "../../internal-wallet-state.js";
import { assertUnreachable } from "../../util/assertUnreachable.js";
import { checkDbInvariant } from "../../util/invariants.js";
import { getWalletBackupState, provideBackupState } from "./state.js";
const logger = new Logger("backup/export.ts");
export async function exportBackup(
ws: InternalWalletState,
): Promise<WalletBackupContentV1> {
await provideBackupState(ws);
return ws.db
.mktx((x) => [
x.config,
x.exchanges,
x.exchangeDetails,
x.exchangeSignKeys,
x.coins,
x.contractTerms,
x.denominations,
x.purchases,
x.refreshGroups,
x.backupProviders,
x.rewards,
x.recoupGroups,
x.withdrawalGroups,
])
.runReadWrite(async (tx) => {
const bs = await getWalletBackupState(ws, tx);
const backupExchangeDetails: BackupExchangeDetails[] = [];
const backupExchanges: BackupExchange[] = [];
const backupCoinsByDenom: { [dph: string]: BackupCoin[] } = {};
const backupDenominationsByExchange: {
[url: string]: BackupDenomination[];
} = {};
const backupPurchases: BackupPurchase[] = [];
const backupRefreshGroups: BackupRefreshGroup[] = [];
const backupBackupProviders: BackupBackupProvider[] = [];
const backupTips: BackupTip[] = [];
const backupRecoupGroups: BackupRecoupGroup[] = [];
const backupWithdrawalGroups: BackupWithdrawalGroup[] = [];
await tx.withdrawalGroups.iter().forEachAsync(async (wg) => {
let info: BackupWgInfo;
switch (wg.wgInfo.withdrawalType) {
case WithdrawalRecordType.BankIntegrated:
info = {
type: BackupWgType.BankIntegrated,
exchange_payto_uri: wg.wgInfo.bankInfo.exchangePaytoUri,
taler_withdraw_uri: wg.wgInfo.bankInfo.talerWithdrawUri,
confirm_url: wg.wgInfo.bankInfo.confirmUrl,
timestamp_bank_confirmed:
wg.wgInfo.bankInfo.timestampBankConfirmed,
timestamp_reserve_info_posted:
wg.wgInfo.bankInfo.timestampReserveInfoPosted,
};
break;
case WithdrawalRecordType.BankManual:
info = {
type: BackupWgType.BankManual,
};
break;
case WithdrawalRecordType.PeerPullCredit:
info = {
type: BackupWgType.PeerPullCredit,
contract_priv: wg.wgInfo.contractPriv,
contract_terms: wg.wgInfo.contractTerms,
};
break;
case WithdrawalRecordType.PeerPushCredit:
info = {
type: BackupWgType.PeerPushCredit,
contract_terms: wg.wgInfo.contractTerms,
};
break;
case WithdrawalRecordType.Recoup:
info = {
type: BackupWgType.Recoup,
};
break;
default:
assertUnreachable(wg.wgInfo);
}
backupWithdrawalGroups.push({
raw_withdrawal_amount: Amounts.stringify(wg.rawWithdrawalAmount),
info,
timestamp_created: wg.timestampStart,
timestamp_finish: wg.timestampFinish,
withdrawal_group_id: wg.withdrawalGroupId,
secret_seed: wg.secretSeed,
exchange_base_url: wg.exchangeBaseUrl,
instructed_amount: Amounts.stringify(wg.instructedAmount),
effective_withdrawal_amount: Amounts.stringify(
wg.effectiveWithdrawalAmount,
),
reserve_priv: wg.reservePriv,
restrict_age: wg.restrictAge,
// FIXME: proper status conversion!
operation_status:
wg.status == WithdrawalGroupStatus.Finished
? BackupOperationStatus.Finished
: BackupOperationStatus.Pending,
selected_denoms_uid: wg.denomSelUid,
selected_denoms: wg.denomsSel.selectedDenoms.map((x) => ({
count: x.count,
denom_pub_hash: x.denomPubHash,
})),
});
});
await tx.rewards.iter().forEach((tip) => {
backupTips.push({
exchange_base_url: tip.exchangeBaseUrl,
merchant_base_url: tip.merchantBaseUrl,
merchant_tip_id: tip.merchantRewardId,
wallet_tip_id: tip.walletRewardId,
next_url: tip.next_url,
secret_seed: tip.secretSeed,
selected_denoms: tip.denomsSel.selectedDenoms.map((x) => ({
count: x.count,
denom_pub_hash: x.denomPubHash,
})),
timestamp_finished: tip.pickedUpTimestamp,
timestamp_accepted: tip.acceptedTimestamp,
timestamp_created: tip.createdTimestamp,
timestamp_expiration: tip.rewardExpiration,
tip_amount_raw: Amounts.stringify(tip.rewardAmountRaw),
selected_denoms_uid: tip.denomSelUid,
});
});
await tx.recoupGroups.iter().forEach((recoupGroup) => {
backupRecoupGroups.push({
recoup_group_id: recoupGroup.recoupGroupId,
timestamp_created: recoupGroup.timestampStarted,
timestamp_finish: recoupGroup.timestampFinished,
coins: recoupGroup.coinPubs.map((x, i) => ({
coin_pub: x,
recoup_finished: recoupGroup.recoupFinishedPerCoin[i],
})),
});
});
await tx.backupProviders.iter().forEach((bp) => {
let terms: BackupBackupProviderTerms | undefined;
if (bp.terms) {
terms = {
annual_fee: Amounts.stringify(bp.terms.annualFee),
storage_limit_in_megabytes: bp.terms.storageLimitInMegabytes,
supported_protocol_version: bp.terms.supportedProtocolVersion,
};
}
backupBackupProviders.push({
terms,
base_url: canonicalizeBaseUrl(bp.baseUrl),
pay_proposal_ids: bp.paymentProposalIds,
uids: bp.uids,
});
});
await tx.coins.iter().forEach((coin) => {
let bcs: BackupCoinSource;
switch (coin.coinSource.type) {
case CoinSourceType.Refresh:
bcs = {
type: BackupCoinSourceType.Refresh,
old_coin_pub: coin.coinSource.oldCoinPub,
refresh_group_id: coin.coinSource.refreshGroupId,
};
break;
case CoinSourceType.Reward:
bcs = {
type: BackupCoinSourceType.Reward,
coin_index: coin.coinSource.coinIndex,
wallet_tip_id: coin.coinSource.walletRewardId,
};
break;
case CoinSourceType.Withdraw:
bcs = {
type: BackupCoinSourceType.Withdraw,
coin_index: coin.coinSource.coinIndex,
reserve_pub: coin.coinSource.reservePub,
withdrawal_group_id: coin.coinSource.withdrawalGroupId,
};
break;
}
const coins = (backupCoinsByDenom[coin.denomPubHash] ??= []);
coins.push({
blinding_key: coin.blindingKey,
coin_priv: coin.coinPriv,
coin_source: bcs,
fresh: coin.status === CoinStatus.Fresh,
spend_allocation: coin.spendAllocation
? {
amount: coin.spendAllocation.amount,
id: coin.spendAllocation.id,
}
: undefined,
denom_sig: coin.denomSig,
});
});
await tx.denominations.iter().forEach((denom) => {
const backupDenoms = (backupDenominationsByExchange[
denom.exchangeBaseUrl
] ??= []);
backupDenoms.push({
coins: backupCoinsByDenom[denom.denomPubHash] ?? [],
denom_pub: denom.denomPub,
fee_deposit: Amounts.stringify(denom.fees.feeDeposit),
fee_refresh: Amounts.stringify(denom.fees.feeRefresh),
fee_refund: Amounts.stringify(denom.fees.feeRefund),
fee_withdraw: Amounts.stringify(denom.fees.feeWithdraw),
is_offered: denom.isOffered,
is_revoked: denom.isRevoked,
master_sig: denom.masterSig,
stamp_expire_deposit: denom.stampExpireDeposit,
stamp_expire_legal: denom.stampExpireLegal,
stamp_expire_withdraw: denom.stampExpireWithdraw,
stamp_start: denom.stampStart,
value: Amounts.stringify(DenominationRecord.getValue(denom)),
list_issue_date: denom.listIssueDate,
});
});
await tx.exchanges.iter().forEachAsync(async (ex) => {
const dp = ex.detailsPointer;
if (!dp) {
return;
}
backupExchanges.push({
base_url: ex.baseUrl,
currency: dp.currency,
master_public_key: dp.masterPublicKey,
update_clock: dp.updateClock,
});
});
await tx.exchangeDetails.iter().forEachAsync(async (ex) => {
// Only back up permanently added exchanges.
const wi = ex.wireInfo;
const wireFees: BackupExchangeWireFee[] = [];
Object.keys(wi.feesForType).forEach((x) => {
for (const f of wi.feesForType[x]) {
wireFees.push({
wire_type: x,
closing_fee: Amounts.stringify(f.closingFee),
end_stamp: f.endStamp,
sig: f.sig,
start_stamp: f.startStamp,
wire_fee: Amounts.stringify(f.wireFee),
});
}
});
checkDbInvariant(ex.rowId != null);
const exchangeSk =
await tx.exchangeSignKeys.indexes.byExchangeDetailsRowId.getAll(
ex.rowId,
);
let signingKeys: BackupExchangeSignKey[] = exchangeSk.map((x) => ({
key: x.signkeyPub,
master_sig: x.masterSig,
stamp_end: x.stampEnd,
stamp_expire: x.stampExpire,
stamp_start: x.stampStart,
}));
backupExchangeDetails.push({
base_url: ex.exchangeBaseUrl,
reserve_closing_delay: ex.reserveClosingDelay,
accounts: ex.wireInfo.accounts.map((x) => ({
payto_uri: x.payto_uri,
master_sig: x.master_sig,
})),
auditors: ex.auditors.map((x) => ({
auditor_pub: x.auditor_pub,
auditor_url: x.auditor_url,
denomination_keys: x.denomination_keys,
})),
master_public_key: ex.masterPublicKey,
currency: ex.currency,
protocol_version: ex.protocolVersionRange,
wire_fees: wireFees,
signing_keys: signingKeys,
global_fees: ex.globalFees.map((x) => ({
accountFee: Amounts.stringify(x.accountFee),
historyFee: Amounts.stringify(x.historyFee),
purseFee: Amounts.stringify(x.purseFee),
endDate: x.endDate,
historyTimeout: x.historyTimeout,
signature: x.signature,
purseLimit: x.purseLimit,
purseTimeout: x.purseTimeout,
startDate: x.startDate,
})),
tos_accepted_etag: ex.tosAccepted?.etag,
tos_accepted_timestamp: ex.tosAccepted?.timestamp,
denominations:
backupDenominationsByExchange[ex.exchangeBaseUrl] ?? [],
});
});
const purchaseProposalIdSet = new Set<string>();
await tx.purchases.iter().forEachAsync(async (purch) => {
const refunds: BackupRefundItem[] = [];
purchaseProposalIdSet.add(purch.proposalId);
// for (const refundKey of Object.keys(purch.refunds)) {
// const ri = purch.refunds[refundKey];
// const common = {
// coin_pub: ri.coinPub,
// execution_time: ri.executionTime,
// obtained_time: ri.obtainedTime,
// refund_amount: Amounts.stringify(ri.refundAmount),
// rtransaction_id: ri.rtransactionId,
// total_refresh_cost_bound: Amounts.stringify(
// ri.totalRefreshCostBound,
// ),
// };
// switch (ri.type) {
// case RefundState.Applied:
// refunds.push({ type: BackupRefundState.Applied, ...common });
// break;
// case RefundState.Failed:
// refunds.push({ type: BackupRefundState.Failed, ...common });
// break;
// case RefundState.Pending:
// refunds.push({ type: BackupRefundState.Pending, ...common });
// break;
// }
// }
let propStatus: BackupProposalStatus;
switch (purch.purchaseStatus) {
case PurchaseStatus.Done:
case PurchaseStatus.PendingQueryingAutoRefund:
case PurchaseStatus.PendingQueryingRefund:
propStatus = BackupProposalStatus.Paid;
break;
case PurchaseStatus.PendingPayingReplay:
case PurchaseStatus.PendingDownloadingProposal:
case PurchaseStatus.DialogProposed:
case PurchaseStatus.PendingPaying:
propStatus = BackupProposalStatus.Proposed;
break;
case PurchaseStatus.DialogShared:
propStatus = BackupProposalStatus.Shared;
break;
case PurchaseStatus.FailedClaim:
case PurchaseStatus.AbortedIncompletePayment:
propStatus = BackupProposalStatus.PermanentlyFailed;
break;
case PurchaseStatus.AbortingWithRefund:
case PurchaseStatus.AbortedProposalRefused:
propStatus = BackupProposalStatus.Refused;
break;
case PurchaseStatus.RepurchaseDetected:
propStatus = BackupProposalStatus.Repurchase;
break;
default: {
const error = purch.purchaseStatus;
throw Error(`purchase status ${error} is not handled`);
}
}
const payInfo = purch.payInfo;
let backupPayInfo: BackupPayInfo | undefined = undefined;
if (payInfo) {
backupPayInfo = {
pay_coins: payInfo.payCoinSelection.coinPubs.map((x, i) => ({
coin_pub: x,
contribution: Amounts.stringify(
payInfo.payCoinSelection.coinContributions[i],
),
})),
total_pay_cost: Amounts.stringify(payInfo.totalPayCost),
pay_coins_uid: payInfo.payCoinSelectionUid,
};
}
let contractTermsRaw = undefined;
if (purch.download) {
const contractTermsRecord = await tx.contractTerms.get(
purch.download.contractTermsHash,
);
if (contractTermsRecord) {
contractTermsRaw = contractTermsRecord.contractTermsRaw;
}
}
backupPurchases.push({
contract_terms_raw: contractTermsRaw,
auto_refund_deadline: purch.autoRefundDeadline,
merchant_pay_sig: purch.merchantPaySig,
pos_confirmation: purch.posConfirmation,
pay_info: backupPayInfo,
proposal_id: purch.proposalId,
refunds,
timestamp_accepted: purch.timestampAccept,
timestamp_first_successful_pay: purch.timestampFirstSuccessfulPay,
nonce_priv: purch.noncePriv,
merchant_sig: purch.download?.contractTermsMerchantSig,
claim_token: purch.claimToken,
merchant_base_url: purch.merchantBaseUrl,
order_id: purch.orderId,
proposal_status: propStatus,
repurchase_proposal_id: purch.repurchaseProposalId,
download_session_id: purch.downloadSessionId,
timestamp_proposed: purch.timestamp,
shared: purch.shared,
});
});
await tx.refreshGroups.iter().forEach((rg) => {
const oldCoins: BackupRefreshOldCoin[] = [];
for (let i = 0; i < rg.oldCoinPubs.length; i++) {
let refreshSession: BackupRefreshSession | undefined;
const s = rg.refreshSessionPerCoin[i];
if (s) {
refreshSession = {
new_denoms: s.newDenoms.map((x) => ({
count: x.count,
denom_pub_hash: x.denomPubHash,
})),
session_secret_seed: s.sessionSecretSeed,
noreveal_index: s.norevealIndex,
};
}
oldCoins.push({
coin_pub: rg.oldCoinPubs[i],
estimated_output_amount: Amounts.stringify(
rg.estimatedOutputPerCoin[i],
),
finished: rg.statusPerCoin[i] === RefreshCoinStatus.Finished,
input_amount: Amounts.stringify(rg.inputPerCoin[i]),
refresh_session: refreshSession,
});
}
backupRefreshGroups.push({
reason: rg.reason as any,
refresh_group_id: rg.refreshGroupId,
timestamp_created: rg.timestampCreated,
timestamp_finish: rg.timestampFinished,
old_coins: oldCoins,
});
});
const ts = TalerPreciseTimestamp.now();
if (!bs.lastBackupTimestamp) {
bs.lastBackupTimestamp = ts;
}
const backupBlob: WalletBackupContentV1 = {
schema_id: "gnu-taler-wallet-backup-content",
schema_version: BACKUP_VERSION_MAJOR,
minor_version: BACKUP_VERSION_MINOR,
exchanges: backupExchanges,
exchange_details: backupExchangeDetails,
wallet_root_pub: bs.walletRootPub,
backup_providers: backupBackupProviders,
current_device_id: bs.deviceId,
purchases: backupPurchases,
recoup_groups: backupRecoupGroups,
refresh_groups: backupRefreshGroups,
tips: backupTips,
timestamp: bs.lastBackupTimestamp,
trusted_auditors: {},
trusted_exchanges: {},
intern_table: {},
error_reports: [],
tombstones: [],
// FIXME!
withdrawal_groups: backupWithdrawalGroups,
};
// If the backup changed, we change our nonce and timestamp.
let h = encodeCrock(hash(stringToBytes(canonicalJson(backupBlob))));
if (h !== bs.lastBackupPlainHash) {
logger.trace(
`plain backup hash changed (from ${bs.lastBackupPlainHash}to ${h})`,
);
bs.lastBackupTimestamp = ts;
backupBlob.timestamp = ts;
bs.lastBackupPlainHash = encodeCrock(
hash(stringToBytes(canonicalJson(backupBlob))),
);
bs.lastBackupNonce = encodeCrock(getRandomBytes(32));
logger.trace(
`setting timestamp to ${AbsoluteTime.toIsoString(
AbsoluteTime.fromPreciseTimestamp(ts),
)} and nonce to ${bs.lastBackupNonce}`,
);
await tx.config.put({
key: ConfigRecordKey.WalletBackupState,
value: bs,
});
} else {
logger.trace("backup hash did not change");
}
return backupBlob;
});
}

View File

@ -1,874 +0,0 @@
/*
This file is part of GNU Taler
(C) 2020 Taler Systems SA
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import {
AgeRestriction,
AmountJson,
Amounts,
BackupCoin,
BackupCoinSourceType,
BackupDenomSel,
BackupPayInfo,
BackupProposalStatus,
BackupRefreshReason,
BackupRefundState,
BackupWgType,
codecForMerchantContractTerms,
CoinStatus,
DenomKeyType,
DenomSelectionState,
j2s,
Logger,
PayCoinSelection,
RefreshReason,
TalerProtocolTimestamp,
TalerPreciseTimestamp,
WalletBackupContentV1,
WireInfo,
} from "@gnu-taler/taler-util";
import {
CoinRecord,
CoinSource,
CoinSourceType,
DenominationRecord,
DenominationVerificationStatus,
ProposalDownloadInfo,
PurchaseStatus,
PurchasePayInfo,
RefreshCoinStatus,
RefreshSessionRecord,
WalletContractData,
WalletStoresV1,
WgInfo,
WithdrawalGroupStatus,
WithdrawalRecordType,
RefreshOperationStatus,
RewardRecordStatus,
} from "../../db.js";
import { InternalWalletState } from "../../internal-wallet-state.js";
import { assertUnreachable } from "../../util/assertUnreachable.js";
import { checkLogicInvariant } from "../../util/invariants.js";
import { GetReadOnlyAccess, GetReadWriteAccess } from "../../util/query.js";
import {
constructTombstone,
makeCoinAvailable,
TombstoneTag,
} from "../common.js";
import { getExchangeDetails } from "../exchanges.js";
import { extractContractData } from "../pay-merchant.js";
import { provideBackupState } from "./state.js";
const logger = new Logger("operations/backup/import.ts");
function checkBackupInvariant(b: boolean, m?: string): asserts b {
if (!b) {
if (m) {
throw Error(`BUG: backup invariant failed (${m})`);
} else {
throw Error("BUG: backup invariant failed");
}
}
}
/**
* Re-compute information about the coin selection for a payment.
*/
async function recoverPayCoinSelection(
tx: GetReadWriteAccess<{
exchanges: typeof WalletStoresV1.exchanges;
exchangeDetails: typeof WalletStoresV1.exchangeDetails;
coins: typeof WalletStoresV1.coins;
denominations: typeof WalletStoresV1.denominations;
}>,
contractData: WalletContractData,
payInfo: BackupPayInfo,
): Promise<PayCoinSelection> {
const coinPubs: string[] = payInfo.pay_coins.map((x) => x.coin_pub);
const coinContributions: AmountJson[] = payInfo.pay_coins.map((x) =>
Amounts.parseOrThrow(x.contribution),
);
const coveredExchanges: Set<string> = new Set();
let totalWireFee: AmountJson = Amounts.zeroOfAmount(contractData.amount);
let totalDepositFees: AmountJson = Amounts.zeroOfAmount(contractData.amount);
for (const coinPub of coinPubs) {
const coinRecord = await tx.coins.get(coinPub);
checkBackupInvariant(!!coinRecord);
const denom = await tx.denominations.get([
coinRecord.exchangeBaseUrl,
coinRecord.denomPubHash,
]);
checkBackupInvariant(!!denom);
totalDepositFees = Amounts.add(
totalDepositFees,
denom.fees.feeDeposit,
).amount;
if (!coveredExchanges.has(coinRecord.exchangeBaseUrl)) {
const exchangeDetails = await getExchangeDetails(
tx,
coinRecord.exchangeBaseUrl,
);
checkBackupInvariant(!!exchangeDetails);
let wireFee: AmountJson | undefined;
const feesForType = exchangeDetails.wireInfo.feesForType;
checkBackupInvariant(!!feesForType);
for (const fee of feesForType[contractData.wireMethod] || []) {
if (
fee.startStamp <= contractData.timestamp &&
fee.endStamp >= contractData.timestamp
) {
wireFee = Amounts.parseOrThrow(fee.wireFee);
break;
}
}
if (wireFee) {
totalWireFee = Amounts.add(totalWireFee, wireFee).amount;
}
coveredExchanges.add(coinRecord.exchangeBaseUrl);
}
}
let customerWireFee: AmountJson;
const amortizedWireFee = Amounts.divide(
totalWireFee,
contractData.wireFeeAmortization,
);
if (Amounts.cmp(contractData.maxWireFee, amortizedWireFee) < 0) {
customerWireFee = amortizedWireFee;
} else {
customerWireFee = Amounts.zeroOfAmount(contractData.amount);
}
const customerDepositFees = Amounts.sub(
totalDepositFees,
contractData.maxDepositFee,
).amount;
return {
coinPubs,
coinContributions: coinContributions.map((x) => Amounts.stringify(x)),
paymentAmount: Amounts.stringify(contractData.amount),
customerWireFees: Amounts.stringify(customerWireFee),
customerDepositFees: Amounts.stringify(customerDepositFees),
};
}
async function getDenomSelStateFromBackup(
tx: GetReadOnlyAccess<{ denominations: typeof WalletStoresV1.denominations }>,
currency: string,
exchangeBaseUrl: string,
sel: BackupDenomSel,
): Promise<DenomSelectionState> {
const selectedDenoms: {
denomPubHash: string;
count: number;
}[] = [];
let totalCoinValue = Amounts.zeroOfCurrency(currency);
let totalWithdrawCost = Amounts.zeroOfCurrency(currency);
for (const s of sel) {
const d = await tx.denominations.get([exchangeBaseUrl, s.denom_pub_hash]);
checkBackupInvariant(!!d);
totalCoinValue = Amounts.add(
totalCoinValue,
DenominationRecord.getValue(d),
).amount;
totalWithdrawCost = Amounts.add(
totalWithdrawCost,
DenominationRecord.getValue(d),
d.fees.feeWithdraw,
).amount;
}
return {
selectedDenoms,
totalCoinValue: Amounts.stringify(totalCoinValue),
totalWithdrawCost: Amounts.stringify(totalWithdrawCost),
};
}
export interface CompletedCoin {
coinPub: string;
coinEvHash: string;
}
/**
* Precomputed cryptographic material for a backup import.
*
* We separate this data from the backup blob as we want the backup
* blob to be small, and we can't compute it during the database transaction,
* as the async crypto worker communication would auto-close the database transaction.
*/
export interface BackupCryptoPrecomputedData {
rsaDenomPubToHash: Record<string, string>;
coinPrivToCompletedCoin: Record<string, CompletedCoin>;
proposalNoncePrivToPub: { [priv: string]: string };
proposalIdToContractTermsHash: { [proposalId: string]: string };
reservePrivToPub: Record<string, string>;
}
export async function importCoin(
ws: InternalWalletState,
tx: GetReadWriteAccess<{
coins: typeof WalletStoresV1.coins;
coinAvailability: typeof WalletStoresV1.coinAvailability;
denominations: typeof WalletStoresV1.denominations;
}>,
cryptoComp: BackupCryptoPrecomputedData,
args: {
backupCoin: BackupCoin;
exchangeBaseUrl: string;
denomPubHash: string;
},
): Promise<void> {
const { backupCoin, exchangeBaseUrl, denomPubHash } = args;
const compCoin = cryptoComp.coinPrivToCompletedCoin[backupCoin.coin_priv];
checkLogicInvariant(!!compCoin);
const existingCoin = await tx.coins.get(compCoin.coinPub);
if (!existingCoin) {
let coinSource: CoinSource;
switch (backupCoin.coin_source.type) {
case BackupCoinSourceType.Refresh:
coinSource = {
type: CoinSourceType.Refresh,
oldCoinPub: backupCoin.coin_source.old_coin_pub,
refreshGroupId: backupCoin.coin_source.refresh_group_id,
};
break;
case BackupCoinSourceType.Reward:
coinSource = {
type: CoinSourceType.Reward,
coinIndex: backupCoin.coin_source.coin_index,
walletRewardId: backupCoin.coin_source.wallet_tip_id,
};
break;
case BackupCoinSourceType.Withdraw:
coinSource = {
type: CoinSourceType.Withdraw,
coinIndex: backupCoin.coin_source.coin_index,
reservePub: backupCoin.coin_source.reserve_pub,
withdrawalGroupId: backupCoin.coin_source.withdrawal_group_id,
};
break;
}
const coinRecord: CoinRecord = {
blindingKey: backupCoin.blinding_key,
coinEvHash: compCoin.coinEvHash,
coinPriv: backupCoin.coin_priv,
denomSig: backupCoin.denom_sig,
coinPub: compCoin.coinPub,
exchangeBaseUrl,
denomPubHash,
status: backupCoin.fresh ? CoinStatus.Fresh : CoinStatus.Dormant,
coinSource,
// FIXME!
maxAge: AgeRestriction.AGE_UNRESTRICTED,
// FIXME!
ageCommitmentProof: undefined,
// FIXME!
spendAllocation: undefined,
};
if (coinRecord.status === CoinStatus.Fresh) {
await makeCoinAvailable(ws, tx, coinRecord);
} else {
await tx.coins.put(coinRecord);
}
}
}
export async function importBackup(
ws: InternalWalletState,
backupBlobArg: any,
cryptoComp: BackupCryptoPrecomputedData,
): Promise<void> {
await provideBackupState(ws);
logger.info(`importing backup ${j2s(backupBlobArg)}`);
return ws.db
.mktx((x) => [
x.config,
x.exchangeDetails,
x.exchanges,
x.coins,
x.coinAvailability,
x.denominations,
x.purchases,
x.refreshGroups,
x.backupProviders,
x.rewards,
x.recoupGroups,
x.withdrawalGroups,
x.tombstones,
x.depositGroups,
])
.runReadWrite(async (tx) => {
// FIXME: validate schema!
const backupBlob = backupBlobArg as WalletBackupContentV1;
// FIXME: validate version
for (const tombstone of backupBlob.tombstones) {
await tx.tombstones.put({
id: tombstone,
});
}
const tombstoneSet = new Set(
(await tx.tombstones.iter().toArray()).map((x) => x.id),
);
// FIXME: Validate that the "details pointer" is correct
for (const backupExchange of backupBlob.exchanges) {
const existingExchange = await tx.exchanges.get(
backupExchange.base_url,
);
if (existingExchange) {
continue;
}
// await tx.exchanges.put({
// baseUrl: backupExchange.base_url,
// detailsPointer: {
// currency: backupExchange.currency,
// masterPublicKey: backupExchange.master_public_key,
// updateClock: backupExchange.update_clock,
// },
// lastUpdate: undefined,
// nextUpdate: TalerPreciseTimestamp.now(),
// nextRefreshCheck: TalerPreciseTimestamp.now(),
// lastKeysEtag: undefined,
// lastWireEtag: undefined,
// });
}
for (const backupExchangeDetails of backupBlob.exchange_details) {
const existingExchangeDetails =
await tx.exchangeDetails.indexes.byPointer.get([
backupExchangeDetails.base_url,
backupExchangeDetails.currency,
backupExchangeDetails.master_public_key,
]);
if (!existingExchangeDetails) {
const wireInfo: WireInfo = {
accounts: backupExchangeDetails.accounts.map((x) => ({
master_sig: x.master_sig,
payto_uri: x.payto_uri,
})),
feesForType: {},
};
for (const fee of backupExchangeDetails.wire_fees) {
const w = (wireInfo.feesForType[fee.wire_type] ??= []);
w.push({
closingFee: Amounts.stringify(fee.closing_fee),
endStamp: fee.end_stamp,
sig: fee.sig,
startStamp: fee.start_stamp,
wireFee: Amounts.stringify(fee.wire_fee),
});
}
let tosAccepted = undefined;
if (
backupExchangeDetails.tos_accepted_etag &&
backupExchangeDetails.tos_accepted_timestamp
) {
tosAccepted = {
etag: backupExchangeDetails.tos_accepted_etag,
timestamp: backupExchangeDetails.tos_accepted_timestamp,
};
}
await tx.exchangeDetails.put({
exchangeBaseUrl: backupExchangeDetails.base_url,
wireInfo,
currency: backupExchangeDetails.currency,
auditors: backupExchangeDetails.auditors.map((x) => ({
auditor_pub: x.auditor_pub,
auditor_url: x.auditor_url,
denomination_keys: x.denomination_keys,
})),
masterPublicKey: backupExchangeDetails.master_public_key,
protocolVersionRange: backupExchangeDetails.protocol_version,
reserveClosingDelay: backupExchangeDetails.reserve_closing_delay,
tosCurrentEtag: backupExchangeDetails.tos_accepted_etag || "",
tosAccepted,
globalFees: backupExchangeDetails.global_fees.map((x) => ({
accountFee: Amounts.stringify(x.accountFee),
historyFee: Amounts.stringify(x.historyFee),
purseFee: Amounts.stringify(x.purseFee),
endDate: x.endDate,
historyTimeout: x.historyTimeout,
signature: x.signature,
purseLimit: x.purseLimit,
purseTimeout: x.purseTimeout,
startDate: x.startDate,
})),
});
}
for (const backupDenomination of backupExchangeDetails.denominations) {
if (backupDenomination.denom_pub.cipher !== DenomKeyType.Rsa) {
throw Error("unsupported cipher");
}
const denomPubHash =
cryptoComp.rsaDenomPubToHash[
backupDenomination.denom_pub.rsa_public_key
];
checkLogicInvariant(!!denomPubHash);
const existingDenom = await tx.denominations.get([
backupExchangeDetails.base_url,
denomPubHash,
]);
if (!existingDenom) {
const value = Amounts.parseOrThrow(backupDenomination.value);
await tx.denominations.put({
denomPub: backupDenomination.denom_pub,
denomPubHash: denomPubHash,
exchangeBaseUrl: backupExchangeDetails.base_url,
exchangeMasterPub: backupExchangeDetails.master_public_key,
fees: {
feeDeposit: Amounts.stringify(backupDenomination.fee_deposit),
feeRefresh: Amounts.stringify(backupDenomination.fee_refresh),
feeRefund: Amounts.stringify(backupDenomination.fee_refund),
feeWithdraw: Amounts.stringify(backupDenomination.fee_withdraw),
},
isOffered: backupDenomination.is_offered,
isRevoked: backupDenomination.is_revoked,
masterSig: backupDenomination.master_sig,
stampExpireDeposit: backupDenomination.stamp_expire_deposit,
stampExpireLegal: backupDenomination.stamp_expire_legal,
stampExpireWithdraw: backupDenomination.stamp_expire_withdraw,
stampStart: backupDenomination.stamp_start,
verificationStatus: DenominationVerificationStatus.VerifiedGood,
currency: value.currency,
amountFrac: value.fraction,
amountVal: value.value,
listIssueDate: backupDenomination.list_issue_date,
});
}
for (const backupCoin of backupDenomination.coins) {
await importCoin(ws, tx, cryptoComp, {
backupCoin,
denomPubHash,
exchangeBaseUrl: backupExchangeDetails.base_url,
});
}
}
}
for (const backupWg of backupBlob.withdrawal_groups) {
const reservePub = cryptoComp.reservePrivToPub[backupWg.reserve_priv];
checkLogicInvariant(!!reservePub);
const ts = constructTombstone({
tag: TombstoneTag.DeleteReserve,
reservePub,
});
if (tombstoneSet.has(ts)) {
continue;
}
const existingWg = await tx.withdrawalGroups.get(
backupWg.withdrawal_group_id,
);
if (existingWg) {
continue;
}
let wgInfo: WgInfo;
switch (backupWg.info.type) {
case BackupWgType.BankIntegrated:
wgInfo = {
withdrawalType: WithdrawalRecordType.BankIntegrated,
bankInfo: {
exchangePaytoUri: backupWg.info.exchange_payto_uri,
talerWithdrawUri: backupWg.info.taler_withdraw_uri,
confirmUrl: backupWg.info.confirm_url,
timestampBankConfirmed: backupWg.info.timestamp_bank_confirmed,
timestampReserveInfoPosted:
backupWg.info.timestamp_reserve_info_posted,
},
};
break;
case BackupWgType.BankManual:
wgInfo = {
withdrawalType: WithdrawalRecordType.BankManual,
};
break;
case BackupWgType.PeerPullCredit:
wgInfo = {
withdrawalType: WithdrawalRecordType.PeerPullCredit,
contractTerms: backupWg.info.contract_terms,
contractPriv: backupWg.info.contract_priv,
};
break;
case BackupWgType.PeerPushCredit:
wgInfo = {
withdrawalType: WithdrawalRecordType.PeerPushCredit,
contractTerms: backupWg.info.contract_terms,
};
break;
case BackupWgType.Recoup:
wgInfo = {
withdrawalType: WithdrawalRecordType.Recoup,
};
break;
default:
assertUnreachable(backupWg.info);
}
const instructedAmount = Amounts.parseOrThrow(
backupWg.instructed_amount,
);
await tx.withdrawalGroups.put({
withdrawalGroupId: backupWg.withdrawal_group_id,
exchangeBaseUrl: backupWg.exchange_base_url,
instructedAmount: Amounts.stringify(instructedAmount),
secretSeed: backupWg.secret_seed,
denomsSel: await getDenomSelStateFromBackup(
tx,
instructedAmount.currency,
backupWg.exchange_base_url,
backupWg.selected_denoms,
),
denomSelUid: backupWg.selected_denoms_uid,
rawWithdrawalAmount: Amounts.stringify(
backupWg.raw_withdrawal_amount,
),
effectiveWithdrawalAmount: Amounts.stringify(
backupWg.effective_withdrawal_amount,
),
reservePriv: backupWg.reserve_priv,
reservePub,
status: backupWg.timestamp_finish
? WithdrawalGroupStatus.Finished
: WithdrawalGroupStatus.PendingQueryingStatus, // FIXME!
timestampStart: backupWg.timestamp_created,
wgInfo,
restrictAge: backupWg.restrict_age,
senderWire: undefined, // FIXME!
timestampFinish: backupWg.timestamp_finish,
});
}
for (const backupPurchase of backupBlob.purchases) {
const ts = constructTombstone({
tag: TombstoneTag.DeletePayment,
proposalId: backupPurchase.proposal_id,
});
if (tombstoneSet.has(ts)) {
continue;
}
const existingPurchase = await tx.purchases.get(
backupPurchase.proposal_id,
);
let proposalStatus: PurchaseStatus;
switch (backupPurchase.proposal_status) {
case BackupProposalStatus.Paid:
proposalStatus = PurchaseStatus.Done;
break;
case BackupProposalStatus.Shared:
proposalStatus = PurchaseStatus.DialogShared;
break;
case BackupProposalStatus.Proposed:
proposalStatus = PurchaseStatus.DialogProposed;
break;
case BackupProposalStatus.PermanentlyFailed:
proposalStatus = PurchaseStatus.AbortedIncompletePayment;
break;
case BackupProposalStatus.Refused:
proposalStatus = PurchaseStatus.AbortedProposalRefused;
break;
case BackupProposalStatus.Repurchase:
proposalStatus = PurchaseStatus.RepurchaseDetected;
break;
default: {
const error: never = backupPurchase.proposal_status;
throw Error(`backup status ${error} is not handled`);
}
}
if (!existingPurchase) {
//const refunds: { [refundKey: string]: WalletRefundItem } = {};
// for (const backupRefund of backupPurchase.refunds) {
// const key = `${backupRefund.coin_pub}-${backupRefund.rtransaction_id}`;
// const coin = await tx.coins.get(backupRefund.coin_pub);
// checkBackupInvariant(!!coin);
// const denom = await tx.denominations.get([
// coin.exchangeBaseUrl,
// coin.denomPubHash,
// ]);
// checkBackupInvariant(!!denom);
// const common = {
// coinPub: backupRefund.coin_pub,
// executionTime: backupRefund.execution_time,
// obtainedTime: backupRefund.obtained_time,
// refundAmount: Amounts.stringify(backupRefund.refund_amount),
// refundFee: Amounts.stringify(denom.fees.feeRefund),
// rtransactionId: backupRefund.rtransaction_id,
// totalRefreshCostBound: Amounts.stringify(
// backupRefund.total_refresh_cost_bound,
// ),
// };
// switch (backupRefund.type) {
// case BackupRefundState.Applied:
// refunds[key] = {
// type: RefundState.Applied,
// ...common,
// };
// break;
// case BackupRefundState.Failed:
// refunds[key] = {
// type: RefundState.Failed,
// ...common,
// };
// break;
// case BackupRefundState.Pending:
// refunds[key] = {
// type: RefundState.Pending,
// ...common,
// };
// break;
// }
// }
const parsedContractTerms = codecForMerchantContractTerms().decode(
backupPurchase.contract_terms_raw,
);
const amount = Amounts.parseOrThrow(parsedContractTerms.amount);
const contractTermsHash =
cryptoComp.proposalIdToContractTermsHash[
backupPurchase.proposal_id
];
let maxWireFee: AmountJson;
if (parsedContractTerms.max_wire_fee) {
maxWireFee = Amounts.parseOrThrow(parsedContractTerms.max_wire_fee);
} else {
maxWireFee = Amounts.zeroOfCurrency(amount.currency);
}
const download: ProposalDownloadInfo = {
contractTermsHash,
contractTermsMerchantSig: backupPurchase.merchant_sig!,
currency: amount.currency,
fulfillmentUrl: backupPurchase.contract_terms_raw.fulfillment_url,
};
const contractData = extractContractData(
backupPurchase.contract_terms_raw,
contractTermsHash,
download.contractTermsMerchantSig,
);
let payInfo: PurchasePayInfo | undefined = undefined;
if (backupPurchase.pay_info) {
payInfo = {
payCoinSelection: await recoverPayCoinSelection(
tx,
contractData,
backupPurchase.pay_info,
),
payCoinSelectionUid: backupPurchase.pay_info.pay_coins_uid,
totalPayCost: Amounts.stringify(
backupPurchase.pay_info.total_pay_cost,
),
};
}
await tx.purchases.put({
proposalId: backupPurchase.proposal_id,
noncePriv: backupPurchase.nonce_priv,
noncePub:
cryptoComp.proposalNoncePrivToPub[backupPurchase.nonce_priv],
autoRefundDeadline: TalerProtocolTimestamp.never(),
timestampAccept: backupPurchase.timestamp_accepted,
timestampFirstSuccessfulPay:
backupPurchase.timestamp_first_successful_pay,
timestampLastRefundStatus: undefined,
merchantPaySig: backupPurchase.merchant_pay_sig,
posConfirmation: backupPurchase.pos_confirmation,
lastSessionId: undefined,
download,
//refunds,
claimToken: backupPurchase.claim_token,
downloadSessionId: backupPurchase.download_session_id,
merchantBaseUrl: backupPurchase.merchant_base_url,
orderId: backupPurchase.order_id,
payInfo,
refundAmountAwaiting: undefined,
repurchaseProposalId: backupPurchase.repurchase_proposal_id,
purchaseStatus: proposalStatus,
timestamp: backupPurchase.timestamp_proposed,
shared: backupPurchase.shared,
});
}
}
for (const backupRefreshGroup of backupBlob.refresh_groups) {
const ts = constructTombstone({
tag: TombstoneTag.DeleteRefreshGroup,
refreshGroupId: backupRefreshGroup.refresh_group_id,
});
if (tombstoneSet.has(ts)) {
continue;
}
const existingRg = await tx.refreshGroups.get(
backupRefreshGroup.refresh_group_id,
);
if (!existingRg) {
let reason: RefreshReason;
switch (backupRefreshGroup.reason) {
case BackupRefreshReason.AbortPay:
reason = RefreshReason.AbortPay;
break;
case BackupRefreshReason.BackupRestored:
reason = RefreshReason.BackupRestored;
break;
case BackupRefreshReason.Manual:
reason = RefreshReason.Manual;
break;
case BackupRefreshReason.Pay:
reason = RefreshReason.PayMerchant;
break;
case BackupRefreshReason.Recoup:
reason = RefreshReason.Recoup;
break;
case BackupRefreshReason.Refund:
reason = RefreshReason.Refund;
break;
case BackupRefreshReason.Scheduled:
reason = RefreshReason.Scheduled;
break;
}
const refreshSessionPerCoin: (RefreshSessionRecord | undefined)[] =
[];
for (const oldCoin of backupRefreshGroup.old_coins) {
const c = await tx.coins.get(oldCoin.coin_pub);
checkBackupInvariant(!!c);
const d = await tx.denominations.get([
c.exchangeBaseUrl,
c.denomPubHash,
]);
checkBackupInvariant(!!d);
if (oldCoin.refresh_session) {
const denomSel = await getDenomSelStateFromBackup(
tx,
d.currency,
c.exchangeBaseUrl,
oldCoin.refresh_session.new_denoms,
);
refreshSessionPerCoin.push({
sessionSecretSeed: oldCoin.refresh_session.session_secret_seed,
norevealIndex: oldCoin.refresh_session.noreveal_index,
newDenoms: oldCoin.refresh_session.new_denoms.map((x) => ({
count: x.count,
denomPubHash: x.denom_pub_hash,
})),
amountRefreshOutput: Amounts.stringify(denomSel.totalCoinValue),
});
} else {
refreshSessionPerCoin.push(undefined);
}
}
await tx.refreshGroups.put({
timestampFinished: backupRefreshGroup.timestamp_finish,
timestampCreated: backupRefreshGroup.timestamp_created,
refreshGroupId: backupRefreshGroup.refresh_group_id,
currency: Amounts.currencyOf(
backupRefreshGroup.old_coins[0].input_amount,
),
reason,
lastErrorPerCoin: {},
oldCoinPubs: backupRefreshGroup.old_coins.map((x) => x.coin_pub),
statusPerCoin: backupRefreshGroup.old_coins.map((x) =>
x.finished
? RefreshCoinStatus.Finished
: RefreshCoinStatus.Pending,
),
operationStatus: backupRefreshGroup.timestamp_finish
? RefreshOperationStatus.Finished
: RefreshOperationStatus.Pending,
inputPerCoin: backupRefreshGroup.old_coins.map(
(x) => x.input_amount,
),
estimatedOutputPerCoin: backupRefreshGroup.old_coins.map(
(x) => x.estimated_output_amount,
),
refreshSessionPerCoin,
});
}
}
for (const backupTip of backupBlob.tips) {
const ts = constructTombstone({
tag: TombstoneTag.DeleteReward,
walletTipId: backupTip.wallet_tip_id,
});
if (tombstoneSet.has(ts)) {
continue;
}
const existingTip = await tx.rewards.get(backupTip.wallet_tip_id);
if (!existingTip) {
const tipAmountRaw = Amounts.parseOrThrow(backupTip.tip_amount_raw);
const denomsSel = await getDenomSelStateFromBackup(
tx,
tipAmountRaw.currency,
backupTip.exchange_base_url,
backupTip.selected_denoms,
);
await tx.rewards.put({
acceptedTimestamp: backupTip.timestamp_accepted,
createdTimestamp: backupTip.timestamp_created,
denomsSel,
next_url: backupTip.next_url,
exchangeBaseUrl: backupTip.exchange_base_url,
merchantBaseUrl: backupTip.exchange_base_url,
merchantRewardId: backupTip.merchant_tip_id,
pickedUpTimestamp: backupTip.timestamp_finished,
secretSeed: backupTip.secret_seed,
rewardAmountEffective: Amounts.stringify(denomsSel.totalCoinValue),
rewardAmountRaw: Amounts.stringify(tipAmountRaw),
rewardExpiration: backupTip.timestamp_expiration,
walletRewardId: backupTip.wallet_tip_id,
denomSelUid: backupTip.selected_denoms_uid,
status: RewardRecordStatus.Done, // FIXME!
});
}
}
// We now process tombstones.
// The import code above should already prevent
// importing things that are tombstoned,
// but we do tombstone processing last just to be sure.
for (const tombstone of tombstoneSet) {
const [type, ...rest] = tombstone.split(":");
if (type === TombstoneTag.DeleteDepositGroup) {
await tx.depositGroups.delete(rest[0]);
} else if (type === TombstoneTag.DeletePayment) {
await tx.purchases.delete(rest[0]);
} else if (type === TombstoneTag.DeleteRefreshGroup) {
await tx.refreshGroups.delete(rest[0]);
} else if (type === TombstoneTag.DeleteRefund) {
// Nothing required, will just prevent display
// in the transactions list
} else if (type === TombstoneTag.DeleteReward) {
await tx.rewards.delete(rest[0]);
} else if (type === TombstoneTag.DeleteWithdrawalGroup) {
await tx.withdrawalGroups.delete(rest[0]);
} else {
logger.warn(`unable to process tombstone of type '${type}'`);
}
}
});
}

View File

@ -43,7 +43,6 @@ import {
TalerErrorDetail, TalerErrorDetail,
TalerPreciseTimestamp, TalerPreciseTimestamp,
URL, URL,
WalletBackupContentV1,
buildCodecForObject, buildCodecForObject,
buildCodecForUnion, buildCodecForUnion,
bytesToString, bytesToString,
@ -99,9 +98,8 @@ import {
TaskIdentifiers, TaskIdentifiers,
} from "../common.js"; } from "../common.js";
import { checkPaymentByProposalId, preparePayForUri } from "../pay-merchant.js"; import { checkPaymentByProposalId, preparePayForUri } from "../pay-merchant.js";
import { exportBackup } from "./export.js"; import { WalletStoresV1 } from "../../db.js";
import { BackupCryptoPrecomputedData, importBackup } from "./import.js"; import { GetReadOnlyAccess } from "../../util/query.js";
import { getWalletBackupState, provideBackupState } from "./state.js";
const logger = new Logger("operations/backup.ts"); const logger = new Logger("operations/backup.ts");
@ -131,7 +129,7 @@ const magic = "TLRWBK01";
*/ */
export async function encryptBackup( export async function encryptBackup(
config: WalletBackupConfState, config: WalletBackupConfState,
blob: WalletBackupContentV1, blob: any,
): Promise<Uint8Array> { ): Promise<Uint8Array> {
const chunks: Uint8Array[] = []; const chunks: Uint8Array[] = [];
chunks.push(stringToBytes(magic)); chunks.push(stringToBytes(magic));
@ -150,64 +148,6 @@ export async function encryptBackup(
return concatArrays(chunks); return concatArrays(chunks);
} }
/**
* Compute cryptographic values for a backup blob.
*
* FIXME: Take data that we already know from the DB.
* FIXME: Move computations into crypto worker.
*/
async function computeBackupCryptoData(
cryptoApi: TalerCryptoInterface,
backupContent: WalletBackupContentV1,
): Promise<BackupCryptoPrecomputedData> {
const cryptoData: BackupCryptoPrecomputedData = {
coinPrivToCompletedCoin: {},
rsaDenomPubToHash: {},
proposalIdToContractTermsHash: {},
proposalNoncePrivToPub: {},
reservePrivToPub: {},
};
for (const backupExchangeDetails of backupContent.exchange_details) {
for (const backupDenom of backupExchangeDetails.denominations) {
if (backupDenom.denom_pub.cipher !== DenomKeyType.Rsa) {
throw Error("unsupported cipher");
}
for (const backupCoin of backupDenom.coins) {
const coinPub = encodeCrock(
eddsaGetPublic(decodeCrock(backupCoin.coin_priv)),
);
const blindedCoin = rsaBlind(
hash(decodeCrock(backupCoin.coin_priv)),
decodeCrock(backupCoin.blinding_key),
decodeCrock(backupDenom.denom_pub.rsa_public_key),
);
cryptoData.coinPrivToCompletedCoin[backupCoin.coin_priv] = {
coinEvHash: encodeCrock(hash(blindedCoin)),
coinPub,
};
}
cryptoData.rsaDenomPubToHash[backupDenom.denom_pub.rsa_public_key] =
encodeCrock(hashDenomPub(backupDenom.denom_pub));
}
}
for (const backupWg of backupContent.withdrawal_groups) {
cryptoData.reservePrivToPub[backupWg.reserve_priv] = encodeCrock(
eddsaGetPublic(decodeCrock(backupWg.reserve_priv)),
);
}
for (const purch of backupContent.purchases) {
if (!purch.contract_terms_raw) continue;
const { h: contractTermsHash } = await cryptoApi.hashString({
str: canonicalJson(purch.contract_terms_raw),
});
const noncePub = encodeCrock(eddsaGetPublic(decodeCrock(purch.nonce_priv)));
cryptoData.proposalNoncePrivToPub[purch.nonce_priv] = noncePub;
cryptoData.proposalIdToContractTermsHash[purch.proposal_id] =
contractTermsHash;
}
return cryptoData;
}
function deriveAccountKeyPair( function deriveAccountKeyPair(
bc: WalletBackupConfState, bc: WalletBackupConfState,
providerUrl: string, providerUrl: string,
@ -262,7 +202,9 @@ async function runBackupCycleForProvider(
return TaskRunResult.finished(); return TaskRunResult.finished();
} }
const backupJson = await exportBackup(ws); //const backupJson = await exportBackup(ws);
// FIXME: re-implement backup
const backupJson = {};
const backupConfig = await provideBackupState(ws); const backupConfig = await provideBackupState(ws);
const encBackup = await encryptBackup(backupConfig, backupJson); const encBackup = await encryptBackup(backupConfig, backupJson);
const currentBackupHash = hash(encBackup); const currentBackupHash = hash(encBackup);
@ -441,9 +383,9 @@ async function runBackupCycleForProvider(
logger.info("conflicting backup found"); logger.info("conflicting backup found");
const backupEnc = new Uint8Array(await resp.bytes()); const backupEnc = new Uint8Array(await resp.bytes());
const backupConfig = await provideBackupState(ws); const backupConfig = await provideBackupState(ws);
const blob = await decryptBackup(backupConfig, backupEnc); // const blob = await decryptBackup(backupConfig, backupEnc);
const cryptoData = await computeBackupCryptoData(ws.cryptoApi, blob); // FIXME: Re-implement backup import with merging
await importBackup(ws, blob, cryptoData); // await importBackup(ws, blob, cryptoData);
await ws.db await ws.db
.mktx((x) => [x.backupProviders, x.operationRetries]) .mktx((x) => [x.backupProviders, x.operationRetries])
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {
@ -789,18 +731,6 @@ export interface BackupInfo {
providers: ProviderInfo[]; providers: ProviderInfo[];
} }
export async function importBackupPlain(
ws: InternalWalletState,
blob: any,
): Promise<void> {
// FIXME: parse
const backup: WalletBackupContentV1 = blob;
const cryptoData = await computeBackupCryptoData(ws.cryptoApi, backup);
await importBackup(ws, blob, cryptoData);
}
export enum ProviderPaymentType { export enum ProviderPaymentType {
Unpaid = "unpaid", Unpaid = "unpaid",
Pending = "pending", Pending = "pending",
@ -1036,23 +966,10 @@ export async function loadBackupRecovery(
} }
} }
export async function exportBackupEncrypted(
ws: InternalWalletState,
): Promise<Uint8Array> {
await provideBackupState(ws);
const blob = await exportBackup(ws);
const bs = await ws.db
.mktx((x) => [x.config])
.runReadOnly(async (tx) => {
return await getWalletBackupState(ws, tx);
});
return encryptBackup(bs, blob);
}
export async function decryptBackup( export async function decryptBackup(
backupConfig: WalletBackupConfState, backupConfig: WalletBackupConfState,
data: Uint8Array, data: Uint8Array,
): Promise<WalletBackupContentV1> { ): Promise<any> {
const rMagic = bytesToString(data.slice(0, 8)); const rMagic = bytesToString(data.slice(0, 8));
if (rMagic != magic) { if (rMagic != magic) {
throw Error("invalid backup file (magic tag mismatch)"); throw Error("invalid backup file (magic tag mismatch)");
@ -1068,12 +985,85 @@ export async function decryptBackup(
return JSON.parse(bytesToString(gunzipSync(dataCompressed))); return JSON.parse(bytesToString(gunzipSync(dataCompressed)));
} }
export async function importBackupEncrypted( export async function provideBackupState(
ws: InternalWalletState, ws: InternalWalletState,
data: Uint8Array, ): Promise<WalletBackupConfState> {
): Promise<void> { const bs: ConfigRecord | undefined = await ws.db
const backupConfig = await provideBackupState(ws); .mktx((stores) => [stores.config])
const blob = await decryptBackup(backupConfig, data); .runReadOnly(async (tx) => {
const cryptoData = await computeBackupCryptoData(ws.cryptoApi, blob); return await tx.config.get(ConfigRecordKey.WalletBackupState);
await importBackup(ws, blob, cryptoData); });
if (bs) {
checkDbInvariant(bs.key === ConfigRecordKey.WalletBackupState);
return bs.value;
}
// We need to generate the key outside of the transaction
// due to how IndexedDB works.
const k = await ws.cryptoApi.createEddsaKeypair({});
const d = getRandomBytes(5);
// FIXME: device ID should be configured when wallet is initialized
// and be based on hostname
const deviceId = `wallet-core-${encodeCrock(d)}`;
return await ws.db
.mktx((x) => [x.config])
.runReadWrite(async (tx) => {
let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
ConfigRecordKey.WalletBackupState,
);
if (!backupStateEntry) {
backupStateEntry = {
key: ConfigRecordKey.WalletBackupState,
value: {
deviceId,
walletRootPub: k.pub,
walletRootPriv: k.priv,
lastBackupPlainHash: undefined,
},
};
await tx.config.put(backupStateEntry);
}
checkDbInvariant(
backupStateEntry.key === ConfigRecordKey.WalletBackupState,
);
return backupStateEntry.value;
});
}
export async function getWalletBackupState(
ws: InternalWalletState,
tx: GetReadOnlyAccess<{ config: typeof WalletStoresV1.config }>,
): Promise<WalletBackupConfState> {
const bs = await tx.config.get(ConfigRecordKey.WalletBackupState);
checkDbInvariant(!!bs, "wallet backup state should be in DB");
checkDbInvariant(bs.key === ConfigRecordKey.WalletBackupState);
return bs.value;
}
export async function setWalletDeviceId(
ws: InternalWalletState,
deviceId: string,
): Promise<void> {
await provideBackupState(ws);
await ws.db
.mktx((x) => [x.config])
.runReadWrite(async (tx) => {
let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
ConfigRecordKey.WalletBackupState,
);
if (
!backupStateEntry ||
backupStateEntry.key !== ConfigRecordKey.WalletBackupState
) {
return;
}
backupStateEntry.value.deviceId = deviceId;
await tx.config.put(backupStateEntry);
});
}
export async function getWalletDeviceId(
ws: InternalWalletState,
): Promise<string> {
const bs = await provideBackupState(ws);
return bs.deviceId;
} }

View File

@ -14,96 +14,4 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/ */
import { encodeCrock, getRandomBytes } from "@gnu-taler/taler-util";
import {
ConfigRecord,
ConfigRecordKey,
WalletBackupConfState,
WalletStoresV1,
} from "../../db.js";
import { checkDbInvariant } from "../../util/invariants.js";
import { GetReadOnlyAccess } from "../../util/query.js";
import { InternalWalletState } from "../../internal-wallet-state.js";
export async function provideBackupState(
ws: InternalWalletState,
): Promise<WalletBackupConfState> {
const bs: ConfigRecord | undefined = await ws.db
.mktx((stores) => [stores.config])
.runReadOnly(async (tx) => {
return await tx.config.get(ConfigRecordKey.WalletBackupState);
});
if (bs) {
checkDbInvariant(bs.key === ConfigRecordKey.WalletBackupState);
return bs.value;
}
// We need to generate the key outside of the transaction
// due to how IndexedDB works.
const k = await ws.cryptoApi.createEddsaKeypair({});
const d = getRandomBytes(5);
// FIXME: device ID should be configured when wallet is initialized
// and be based on hostname
const deviceId = `wallet-core-${encodeCrock(d)}`;
return await ws.db
.mktx((x) => [x.config])
.runReadWrite(async (tx) => {
let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
ConfigRecordKey.WalletBackupState,
);
if (!backupStateEntry) {
backupStateEntry = {
key: ConfigRecordKey.WalletBackupState,
value: {
deviceId,
walletRootPub: k.pub,
walletRootPriv: k.priv,
lastBackupPlainHash: undefined,
},
};
await tx.config.put(backupStateEntry);
}
checkDbInvariant(
backupStateEntry.key === ConfigRecordKey.WalletBackupState,
);
return backupStateEntry.value;
});
}
export async function getWalletBackupState(
ws: InternalWalletState,
tx: GetReadOnlyAccess<{ config: typeof WalletStoresV1.config }>,
): Promise<WalletBackupConfState> {
const bs = await tx.config.get(ConfigRecordKey.WalletBackupState);
checkDbInvariant(!!bs, "wallet backup state should be in DB");
checkDbInvariant(bs.key === ConfigRecordKey.WalletBackupState);
return bs.value;
}
export async function setWalletDeviceId(
ws: InternalWalletState,
deviceId: string,
): Promise<void> {
await provideBackupState(ws);
await ws.db
.mktx((x) => [x.config])
.runReadWrite(async (tx) => {
let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
ConfigRecordKey.WalletBackupState,
);
if (
!backupStateEntry ||
backupStateEntry.key !== ConfigRecordKey.WalletBackupState
) {
return;
}
backupStateEntry.value.deviceId = deviceId;
await tx.config.put(backupStateEntry);
});
}
export async function getWalletDeviceId(
ws: InternalWalletState,
): Promise<string> {
const bs = await provideBackupState(ws);
return bs.deviceId;
}

View File

@ -106,7 +106,6 @@ import {
UserAttentionsResponse, UserAttentionsResponse,
ValidateIbanRequest, ValidateIbanRequest,
ValidateIbanResponse, ValidateIbanResponse,
WalletBackupContentV1,
WalletCoreVersion, WalletCoreVersion,
WalletCurrencyInfo, WalletCurrencyInfo,
WithdrawFakebankRequest, WithdrawFakebankRequest,
@ -116,6 +115,10 @@ import {
SharePaymentResult, SharePaymentResult,
GetCurrencyInfoRequest, GetCurrencyInfoRequest,
GetCurrencyInfoResponse, GetCurrencyInfoResponse,
StoredBackupList,
CreateStoredBackupResponse,
RecoverStoredBackupRequest,
DeleteStoredBackupRequest,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { AuditorTrustRecord, WalletContractData } from "./db.js"; import { AuditorTrustRecord, WalletContractData } from "./db.js";
import { import {
@ -195,7 +198,6 @@ export enum WalletApiOperation {
GenerateDepositGroupTxId = "generateDepositGroupTxId", GenerateDepositGroupTxId = "generateDepositGroupTxId",
CreateDepositGroup = "createDepositGroup", CreateDepositGroup = "createDepositGroup",
SetWalletDeviceId = "setWalletDeviceId", SetWalletDeviceId = "setWalletDeviceId",
ExportBackupPlain = "exportBackupPlain",
WithdrawFakebank = "withdrawFakebank", WithdrawFakebank = "withdrawFakebank",
ImportDb = "importDb", ImportDb = "importDb",
ExportDb = "exportDb", ExportDb = "exportDb",
@ -214,6 +216,10 @@ export enum WalletApiOperation {
TestingWaitTransactionsFinal = "testingWaitTransactionsFinal", TestingWaitTransactionsFinal = "testingWaitTransactionsFinal",
TestingWaitRefreshesFinal = "testingWaitRefreshesFinal", TestingWaitRefreshesFinal = "testingWaitRefreshesFinal",
GetScopedCurrencyInfo = "getScopedCurrencyInfo", GetScopedCurrencyInfo = "getScopedCurrencyInfo",
ListStoredBackups = "listStoredBackups",
CreateStoredBackup = "createStoredBackup",
DeleteStoredBackup = "deleteStoredBackup",
RecoverStoredBackup = "recoverStoredBackup",
} }
// group: Initialization // group: Initialization
@ -713,13 +719,28 @@ export type SetWalletDeviceIdOp = {
response: EmptyObject; response: EmptyObject;
}; };
/** export type ListStoredBackupsOp = {
* Export a backup JSON, mostly useful for testing. op: WalletApiOperation.ListStoredBackups;
*/
export type ExportBackupPlainOp = {
op: WalletApiOperation.ExportBackupPlain;
request: EmptyObject; request: EmptyObject;
response: WalletBackupContentV1; response: StoredBackupList;
};
export type CreateStoredBackupsOp = {
op: WalletApiOperation.CreateStoredBackup;
request: EmptyObject;
response: CreateStoredBackupResponse;
};
export type RecoverStoredBackupsOp = {
op: WalletApiOperation.RecoverStoredBackup;
request: RecoverStoredBackupRequest;
response: EmptyObject;
};
export type DeleteStoredBackupOp = {
op: WalletApiOperation.DeleteStoredBackup;
request: DeleteStoredBackupRequest;
response: EmptyObject;
}; };
// group: Peer Payments // group: Peer Payments
@ -1062,7 +1083,6 @@ export type WalletOperations = {
[WalletApiOperation.GenerateDepositGroupTxId]: GenerateDepositGroupTxIdOp; [WalletApiOperation.GenerateDepositGroupTxId]: GenerateDepositGroupTxIdOp;
[WalletApiOperation.CreateDepositGroup]: CreateDepositGroupOp; [WalletApiOperation.CreateDepositGroup]: CreateDepositGroupOp;
[WalletApiOperation.SetWalletDeviceId]: SetWalletDeviceIdOp; [WalletApiOperation.SetWalletDeviceId]: SetWalletDeviceIdOp;
[WalletApiOperation.ExportBackupPlain]: ExportBackupPlainOp;
[WalletApiOperation.ExportBackupRecovery]: ExportBackupRecoveryOp; [WalletApiOperation.ExportBackupRecovery]: ExportBackupRecoveryOp;
[WalletApiOperation.ImportBackupRecovery]: ImportBackupRecoveryOp; [WalletApiOperation.ImportBackupRecovery]: ImportBackupRecoveryOp;
[WalletApiOperation.RunBackupCycle]: RunBackupCycleOp; [WalletApiOperation.RunBackupCycle]: RunBackupCycleOp;
@ -1092,6 +1112,10 @@ export type WalletOperations = {
[WalletApiOperation.TestingWaitTransactionsFinal]: TestingWaitTransactionsFinal; [WalletApiOperation.TestingWaitTransactionsFinal]: TestingWaitTransactionsFinal;
[WalletApiOperation.TestingWaitRefreshesFinal]: TestingWaitRefreshesFinal; [WalletApiOperation.TestingWaitRefreshesFinal]: TestingWaitRefreshesFinal;
[WalletApiOperation.GetScopedCurrencyInfo]: GetScopedCurrencyInfoOp; [WalletApiOperation.GetScopedCurrencyInfo]: GetScopedCurrencyInfoOp;
[WalletApiOperation.CreateStoredBackup]: CreateStoredBackupsOp;
[WalletApiOperation.ListStoredBackups]: ListStoredBackupsOp;
[WalletApiOperation.DeleteStoredBackup]: DeleteStoredBackupOp;
[WalletApiOperation.RecoverStoredBackup]: RecoverStoredBackupsOp;
}; };
export type WalletCoreRequestType< export type WalletCoreRequestType<

View File

@ -120,6 +120,7 @@ import {
codecForSharePaymentRequest, codecForSharePaymentRequest,
GetCurrencyInfoResponse, GetCurrencyInfoResponse,
codecForGetCurrencyInfoRequest, codecForGetCurrencyInfoRequest,
CreateStoredBackupResponse,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { import {
HttpRequestLibrary, HttpRequestLibrary,
@ -139,6 +140,7 @@ import {
clearDatabase, clearDatabase,
exportDb, exportDb,
importDb, importDb,
openStoredBackupsDatabase,
openTalerDatabase, openTalerDatabase,
} from "./db.js"; } from "./db.js";
import { DevExperimentHttpLib, applyDevExperiment } from "./dev-experiments.js"; import { DevExperimentHttpLib, applyDevExperiment } from "./dev-experiments.js";
@ -158,7 +160,6 @@ import {
getUserAttentionsUnreadCount, getUserAttentionsUnreadCount,
markAttentionRequestAsRead, markAttentionRequestAsRead,
} from "./operations/attention.js"; } from "./operations/attention.js";
import { exportBackup } from "./operations/backup/export.js";
import { import {
addBackupProvider, addBackupProvider,
codecForAddBackupProviderRequest, codecForAddBackupProviderRequest,
@ -166,13 +167,12 @@ import {
codecForRunBackupCycle, codecForRunBackupCycle,
getBackupInfo, getBackupInfo,
getBackupRecovery, getBackupRecovery,
importBackupPlain,
loadBackupRecovery, loadBackupRecovery,
processBackupForProvider, processBackupForProvider,
removeBackupProvider, removeBackupProvider,
runBackupCycle, runBackupCycle,
setWalletDeviceId,
} from "./operations/backup/index.js"; } from "./operations/backup/index.js";
import { setWalletDeviceId } from "./operations/backup/state.js";
import { getBalanceDetail, getBalances } from "./operations/balance.js"; import { getBalanceDetail, getBalances } from "./operations/balance.js";
import { import {
TaskIdentifiers, TaskIdentifiers,
@ -1025,6 +1025,17 @@ export async function getClientFromWalletState(
return client; return client;
} }
async function createStoredBackup(
ws: InternalWalletState,
): Promise<CreateStoredBackupResponse> {
const backup = await exportDb(ws.idb);
const backupsDb = await openStoredBackupsDatabase(ws.idb);
const name = `backup-${new Date().getTime()}`;
backupsDb.mktxAll().runReadWrite(async (tx) => {});
throw Error("not implemented");
}
/** /**
* Implementation of the "wallet-core" API. * Implementation of the "wallet-core" API.
*/ */
@ -1041,6 +1052,14 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
// FIXME: Can we make this more type-safe by using the request/response type // FIXME: Can we make this more type-safe by using the request/response type
// definitions we already have? // definitions we already have?
switch (operation) { switch (operation) {
case WalletApiOperation.CreateStoredBackup:
return createStoredBackup(ws);
case WalletApiOperation.DeleteStoredBackup:
return {};
case WalletApiOperation.ListStoredBackups:
return {};
case WalletApiOperation.RecoverStoredBackup:
return {};
case WalletApiOperation.InitWallet: { case WalletApiOperation.InitWallet: {
logger.trace("initializing wallet"); logger.trace("initializing wallet");
ws.initCalled = true; ws.initCalled = true;
@ -1382,9 +1401,6 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
const req = codecForAcceptTipRequest().decode(payload); const req = codecForAcceptTipRequest().decode(payload);
return await acceptTip(ws, req.walletRewardId); return await acceptTip(ws, req.walletRewardId);
} }
case WalletApiOperation.ExportBackupPlain: {
return exportBackup(ws);
}
case WalletApiOperation.AddBackupProvider: { case WalletApiOperation.AddBackupProvider: {
const req = codecForAddBackupProviderRequest().decode(payload); const req = codecForAddBackupProviderRequest().decode(payload);
return await addBackupProvider(ws, req); return await addBackupProvider(ws, req);
@ -1535,9 +1551,7 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
await clearDatabase(ws.db.idbHandle()); await clearDatabase(ws.db.idbHandle());
return {}; return {};
case WalletApiOperation.Recycle: { case WalletApiOperation.Recycle: {
const backup = await exportBackup(ws); throw Error("not implemented");
await clearDatabase(ws.db.idbHandle());
await importBackupPlain(ws, backup);
return {}; return {};
} }
case WalletApiOperation.ExportDb: { case WalletApiOperation.ExportDb: {

View File

@ -139,7 +139,7 @@ async function runGarbageCollector(): Promise<void> {
if (!dbBeforeGc) { if (!dbBeforeGc) {
throw Error("no current db before running gc"); throw Error("no current db before running gc");
} }
const dump = await exportDb(dbBeforeGc.idbHandle()); const dump = await exportDb(indexedDB as any);
await deleteTalerDatabase(indexedDB as any); await deleteTalerDatabase(indexedDB as any);
logger.info("cleaned"); logger.info("cleaned");