finish first complete end-to-end backup/sync test

This commit is contained in:
Florian Dold 2021-03-10 17:11:59 +01:00
parent ac89c3d277
commit 1392dc47c6
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
9 changed files with 429 additions and 215 deletions

View File

@ -82,6 +82,7 @@ import {
CreateDepositGroupResponse, CreateDepositGroupResponse,
TrackDepositGroupRequest, TrackDepositGroupRequest,
TrackDepositGroupResponse, TrackDepositGroupResponse,
RecoveryLoadRequest,
} from "@gnu-taler/taler-wallet-core"; } from "@gnu-taler/taler-wallet-core";
import { URL } from "url"; import { URL } from "url";
import axios, { AxiosError } from "axios"; import axios, { AxiosError } from "axios";
@ -102,6 +103,7 @@ import { CoinConfig } from "./denomStructures";
import { import {
AddBackupProviderRequest, AddBackupProviderRequest,
BackupInfo, BackupInfo,
BackupRecovery,
} from "@gnu-taler/taler-wallet-core/src/operations/backup"; } from "@gnu-taler/taler-wallet-core/src/operations/backup";
const exec = util.promisify(require("child_process").exec); const exec = util.promisify(require("child_process").exec);
@ -1887,6 +1889,22 @@ export class WalletCli {
throw new OperationFailedError(resp.error); throw new OperationFailedError(resp.error);
} }
async exportBackupRecovery(): Promise<BackupRecovery> {
const resp = await this.apiRequest("exportBackupRecovery", {});
if (resp.type === "response") {
return resp.result as BackupRecovery;
}
throw new OperationFailedError(resp.error);
}
async importBackupRecovery(req: RecoveryLoadRequest): Promise<void> {
const resp = await this.apiRequest("importBackupRecovery", req);
if (resp.type === "response") {
return;
}
throw new OperationFailedError(resp.error);
}
async runBackupCycle(): Promise<void> { async runBackupCycle(): Promise<void> {
const resp = await this.apiRequest("runBackupCycle", {}); const resp = await this.apiRequest("runBackupCycle", {});
if (resp.type === "response") { if (resp.type === "response") {

View File

@ -19,7 +19,6 @@
*/ */
import axios from "axios"; import axios from "axios";
import { Configuration, URL } from "@gnu-taler/taler-wallet-core"; import { Configuration, URL } from "@gnu-taler/taler-wallet-core";
import { getRandomIban, getRandomString } from "./helpers";
import * as fs from "fs"; import * as fs from "fs";
import * as util from "util"; import * as util from "util";
import { import {
@ -87,6 +86,8 @@ export class SyncService {
config.setString("sync", "port", `${sc.httpPort}`); config.setString("sync", "port", `${sc.httpPort}`);
config.setString("sync", "db", "postgres"); config.setString("sync", "db", "postgres");
config.setString("syncdb-postgres", "config", sc.database); config.setString("syncdb-postgres", "config", sc.database);
config.setString("sync", "payment_backend_url", sc.paymentBackendUrl);
config.setString("sync", "upload_limit_mb", `${sc.uploadLimitMb}`);
config.write(cfgFilename); config.write(cfgFilename);
return new SyncService(gc, sc, cfgFilename); return new SyncService(gc, sc, cfgFilename);

View File

@ -17,9 +17,12 @@
/** /**
* Imports. * Imports.
*/ */
import { GlobalTestState, BankApi, BankAccessApi } from "./harness"; import { GlobalTestState, BankApi, BankAccessApi, WalletCli } from "./harness";
import { createSimpleTestkudosEnvironment } from "./helpers"; import {
import { codecForBalancesResponse } from "@gnu-taler/taler-wallet-core"; createSimpleTestkudosEnvironment,
makeTestPayment,
withdrawViaBank,
} from "./helpers";
import { SyncService } from "./sync"; import { SyncService } from "./sync";
/** /**
@ -28,7 +31,13 @@ import { SyncService } from "./sync";
export async function runWalletBackupBasicTest(t: GlobalTestState) { export async function runWalletBackupBasicTest(t: GlobalTestState) {
// Set up test environment // Set up test environment
const { commonDb, merchant, wallet, bank, exchange } = await createSimpleTestkudosEnvironment(t); const {
commonDb,
merchant,
wallet,
bank,
exchange,
} = await createSimpleTestkudosEnvironment(t);
const sync = await SyncService.create(t, { const sync = await SyncService.create(t, {
currency: "TESTKUDOS", currency: "TESTKUDOS",
@ -69,5 +78,48 @@ export async function runWalletBackupBasicTest(t: GlobalTestState) {
{ {
const bi = await wallet.getBackupInfo(); const bi = await wallet.getBackupInfo();
console.log(bi); console.log(bi);
t.assertDeepEqual(
bi.providers[0].paymentStatus.type,
"insufficient-balance",
);
}
await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:10" });
await wallet.runBackupCycle();
{
const bi = await wallet.getBackupInfo();
console.log(bi);
}
await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:5" });
await wallet.runBackupCycle();
{
const bi = await wallet.getBackupInfo();
console.log(bi);
}
const backupRecovery = await wallet.exportBackupRecovery();
const wallet2 = new WalletCli(t, "wallet2");
// Check that the second wallet is a fresh wallet.
{
const bal = await wallet2.getBalances();
t.assertTrue(bal.balances.length === 0);
}
await wallet2.importBackupRecovery({ recovery: backupRecovery });
await wallet2.runBackupCycle();
// Check that now the old balance is available!
{
const bal = await wallet2.getBalances();
t.assertTrue(bal.balances.length === 1);
console.log(bal);
} }
} }

View File

@ -15,68 +15,47 @@
*/ */
import { import {
Stores,
Amounts,
CoinSourceType,
CoinStatus,
RefundState,
AbortStatus, AbortStatus,
ProposalStatus,
getTimestampNow,
encodeCrock,
stringToBytes,
getRandomBytes,
AmountJson, AmountJson,
Amounts,
codecForContractTerms, codecForContractTerms,
CoinSource, CoinSource,
CoinSourceType,
CoinStatus,
DenominationStatus, DenominationStatus,
DenomSelectionState, DenomSelectionState,
ExchangeUpdateStatus, ExchangeUpdateStatus,
ExchangeWireInfo, ExchangeWireInfo,
getTimestampNow,
PayCoinSelection, PayCoinSelection,
ProposalDownload, ProposalDownload,
ProposalStatus,
RefreshReason, RefreshReason,
RefreshSessionRecord, RefreshSessionRecord,
RefundState,
ReserveBankInfo, ReserveBankInfo,
ReserveRecordStatus, ReserveRecordStatus,
Stores,
TransactionHandle, TransactionHandle,
WalletContractData, WalletContractData,
WalletRefundItem, WalletRefundItem,
} from "../.."; } from "../..";
import { hash } from "../../crypto/primitives/nacl-fast";
import { import {
WalletBackupContentV1,
BackupExchange,
BackupCoin,
BackupDenomination,
BackupReserve,
BackupPurchase,
BackupProposal,
BackupRefreshGroup,
BackupBackupProvider,
BackupTip,
BackupRecoupGroup,
BackupWithdrawalGroup,
BackupBackupProviderTerms,
BackupCoinSource,
BackupCoinSourceType, BackupCoinSourceType,
BackupExchangeWireFee,
BackupRefundItem,
BackupRefundState,
BackupProposalStatus,
BackupRefreshOldCoin,
BackupRefreshSession,
BackupDenomSel, BackupDenomSel,
BackupProposalStatus,
BackupPurchase,
BackupRefreshReason, BackupRefreshReason,
BackupRefundState,
WalletBackupContentV1,
} from "../../types/backupTypes"; } from "../../types/backupTypes";
import { canonicalizeBaseUrl, canonicalJson, j2s } from "../../util/helpers"; import { j2s } from "../../util/helpers";
import { checkDbInvariant, checkLogicInvariant } from "../../util/invariants"; import { checkDbInvariant, checkLogicInvariant } from "../../util/invariants";
import { Logger } from "../../util/logging"; import { Logger } from "../../util/logging";
import { initRetryInfo } from "../../util/retries"; import { initRetryInfo } from "../../util/retries";
import { InternalWalletState } from "../state"; import { InternalWalletState } from "../state";
import { provideBackupState } from "./state"; import { provideBackupState } from "./state";
const logger = new Logger("operations/backup/import.ts"); const logger = new Logger("operations/backup/import.ts");
function checkBackupInvariant(b: boolean, m?: string): asserts b { function checkBackupInvariant(b: boolean, m?: string): asserts b {
@ -230,6 +209,9 @@ export async function importBackup(
cryptoComp: BackupCryptoPrecomputedData, cryptoComp: BackupCryptoPrecomputedData,
): Promise<void> { ): Promise<void> {
await provideBackupState(ws); await provideBackupState(ws);
logger.info(`importing backup ${j2s(backupBlobArg)}`);
return ws.db.runWithWriteTransaction( return ws.db.runWithWriteTransaction(
[ [
Stores.config, Stores.config,

View File

@ -27,7 +27,11 @@
import { InternalWalletState } from "../state"; import { InternalWalletState } from "../state";
import { WalletBackupContentV1 } from "../../types/backupTypes"; import { WalletBackupContentV1 } from "../../types/backupTypes";
import { TransactionHandle } from "../../util/query"; import { TransactionHandle } from "../../util/query";
import { ConfigRecord, Stores } from "../../types/dbTypes"; import {
BackupProviderRecord,
ConfigRecord,
Stores,
} from "../../types/dbTypes";
import { checkDbInvariant, checkLogicInvariant } from "../../util/invariants"; import { checkDbInvariant, checkLogicInvariant } from "../../util/invariants";
import { codecForAmountString } from "../../util/amounts"; import { codecForAmountString } from "../../util/amounts";
import { import {
@ -41,7 +45,13 @@ import {
stringToBytes, stringToBytes,
} from "../../crypto/talerCrypto"; } from "../../crypto/talerCrypto";
import { canonicalizeBaseUrl, canonicalJson, j2s } from "../../util/helpers"; import { canonicalizeBaseUrl, canonicalJson, j2s } from "../../util/helpers";
import { getTimestampNow, Timestamp } from "../../util/time"; import {
durationAdd,
durationFromSpec,
getTimestampNow,
Timestamp,
timestampAddDuration,
} from "../../util/time";
import { URL } from "../../util/url"; import { URL } from "../../util/url";
import { AmountString } from "../../types/talerTypes"; import { AmountString } from "../../types/talerTypes";
import { import {
@ -70,7 +80,7 @@ import {
} from "../../types/walletTypes"; } from "../../types/walletTypes";
import { CryptoApi } from "../../crypto/workers/cryptoApi"; import { CryptoApi } from "../../crypto/workers/cryptoApi";
import { secretbox, secretbox_open } from "../../crypto/primitives/nacl-fast"; import { secretbox, secretbox_open } from "../../crypto/primitives/nacl-fast";
import { confirmPay, preparePayForUri } from "../pay"; import { checkPaymentByProposalId, confirmPay, preparePayForUri } from "../pay";
import { exportBackup } from "./export"; import { exportBackup } from "./export";
import { BackupCryptoPrecomputedData, importBackup } from "./import"; import { BackupCryptoPrecomputedData, importBackup } from "./import";
import { import {
@ -79,6 +89,7 @@ import {
getWalletBackupState, getWalletBackupState,
WalletBackupConfState, WalletBackupConfState,
} from "./state"; } from "./state";
import { PaymentStatus } from "../../types/transactionsTypes";
const logger = new Logger("operations/backup.ts"); const logger = new Logger("operations/backup.ts");
@ -216,6 +227,179 @@ function deriveBlobSecret(bc: WalletBackupConfState): Uint8Array {
); );
} }
interface BackupForProviderArgs {
backupConfig: WalletBackupConfState;
provider: BackupProviderRecord;
currentBackupHash: ArrayBuffer;
encBackup: ArrayBuffer;
backupJson: WalletBackupContentV1;
/**
* Should we attempt one more upload after trying
* to pay?
*/
retryAfterPayment: boolean;
}
async function runBackupCycleForProvider(
ws: InternalWalletState,
args: BackupForProviderArgs,
): Promise<void> {
const {
backupConfig,
provider,
currentBackupHash,
encBackup,
backupJson,
} = args;
const accountKeyPair = deriveAccountKeyPair(backupConfig, provider.baseUrl);
logger.trace(`trying to upload backup to ${provider.baseUrl}`);
const syncSig = await ws.cryptoApi.makeSyncSignature({
newHash: encodeCrock(currentBackupHash),
oldHash: provider.lastBackupHash,
accountPriv: encodeCrock(accountKeyPair.eddsaPriv),
});
logger.trace(`sync signature is ${syncSig}`);
const accountBackupUrl = new URL(
`/backups/${encodeCrock(accountKeyPair.eddsaPub)}`,
provider.baseUrl,
);
const resp = await ws.http.fetch(accountBackupUrl.href, {
method: "POST",
body: encBackup,
headers: {
"content-type": "application/octet-stream",
"sync-signature": syncSig,
"if-none-match": encodeCrock(currentBackupHash),
...(provider.lastBackupHash
? {
"if-match": provider.lastBackupHash,
}
: {}),
},
});
logger.trace(`sync response status: ${resp.status}`);
if (resp.status === HttpResponseStatus.PaymentRequired) {
logger.trace("payment required for backup");
logger.trace(`headers: ${j2s(resp.headers)}`);
const talerUri = resp.headers.get("taler");
if (!talerUri) {
throw Error("no taler URI available to pay provider");
}
const res = await preparePayForUri(ws, talerUri);
let proposalId = res.proposalId;
let doPay: boolean = false;
switch (res.status) {
case PreparePayResultType.InsufficientBalance:
// FIXME: record in provider state!
logger.warn("insufficient balance to pay for backup provider");
proposalId = res.proposalId;
break;
case PreparePayResultType.PaymentPossible:
doPay = true;
break;
case PreparePayResultType.AlreadyConfirmed:
break;
}
// FIXME: check if the provider is overcharging us!
await ws.db.runWithWriteTransaction(
[Stores.backupProviders],
async (tx) => {
const provRec = await tx.get(Stores.backupProviders, provider.baseUrl);
checkDbInvariant(!!provRec);
const ids = new Set(provRec.paymentProposalIds);
ids.add(proposalId);
provRec.paymentProposalIds = Array.from(ids).sort();
provRec.currentPaymentProposalId = proposalId;
await tx.put(Stores.backupProviders, provRec);
},
);
if (doPay) {
const confirmRes = await confirmPay(ws, proposalId);
switch (confirmRes.type) {
case ConfirmPayResultType.Pending:
logger.warn("payment not yet finished yet");
break;
}
}
if (args.retryAfterPayment) {
await runBackupCycleForProvider(ws, {
...args,
retryAfterPayment: false,
});
}
return;
}
if (resp.status === HttpResponseStatus.NoContent) {
await ws.db.runWithWriteTransaction(
[Stores.backupProviders],
async (tx) => {
const prov = await tx.get(Stores.backupProviders, provider.baseUrl);
if (!prov) {
return;
}
prov.lastBackupHash = encodeCrock(currentBackupHash);
prov.lastBackupTimestamp = getTimestampNow();
prov.lastBackupClock = backupJson.clocks[backupJson.current_device_id];
prov.lastError = undefined;
await tx.put(Stores.backupProviders, prov);
},
);
return;
}
if (resp.status === HttpResponseStatus.Conflict) {
logger.info("conflicting backup found");
const backupEnc = new Uint8Array(await resp.bytes());
const backupConfig = await provideBackupState(ws);
const blob = await decryptBackup(backupConfig, backupEnc);
const cryptoData = await computeBackupCryptoData(ws.cryptoApi, blob);
await importBackup(ws, blob, cryptoData);
await ws.db.runWithWriteTransaction(
[Stores.backupProviders],
async (tx) => {
const prov = await tx.get(Stores.backupProviders, provider.baseUrl);
if (!prov) {
return;
}
prov.lastBackupHash = encodeCrock(hash(backupEnc));
prov.lastBackupClock = blob.clocks[blob.current_device_id];
prov.lastBackupTimestamp = getTimestampNow();
prov.lastError = undefined;
await tx.put(Stores.backupProviders, prov);
},
);
logger.info("processed existing backup");
return;
}
// Some other response that we did not expect!
logger.error("parsing error response");
const err = await readTalerErrorResponse(resp);
logger.error(`got error response from backup provider: ${j2s(err)}`);
await ws.db.runWithWriteTransaction([Stores.backupProviders], async (tx) => {
const prov = await tx.get(Stores.backupProviders, provider.baseUrl);
if (!prov) {
return;
}
prov.lastError = err;
await tx.put(Stores.backupProviders, prov);
});
}
/** /**
* Do one backup cycle that consists of: * Do one backup cycle that consists of:
* 1. Exporting a backup and try to upload it. * 1. Exporting a backup and try to upload it.
@ -233,142 +417,14 @@ export async function runBackupCycle(ws: InternalWalletState): Promise<void> {
const currentBackupHash = hash(encBackup); const currentBackupHash = hash(encBackup);
for (const provider of providers) { for (const provider of providers) {
const accountKeyPair = deriveAccountKeyPair(backupConfig, provider.baseUrl); await runBackupCycleForProvider(ws, {
logger.trace(`trying to upload backup to ${provider.baseUrl}`); provider,
backupJson,
const syncSig = await ws.cryptoApi.makeSyncSignature({ backupConfig,
newHash: encodeCrock(currentBackupHash), encBackup,
oldHash: provider.lastBackupHash, currentBackupHash,
accountPriv: encodeCrock(accountKeyPair.eddsaPriv), retryAfterPayment: true,
}); });
logger.trace(`sync signature is ${syncSig}`);
const accountBackupUrl = new URL(
`/backups/${encodeCrock(accountKeyPair.eddsaPub)}`,
provider.baseUrl,
);
const resp = await ws.http.fetch(accountBackupUrl.href, {
method: "POST",
body: encBackup,
headers: {
"content-type": "application/octet-stream",
"sync-signature": syncSig,
"if-none-match": encodeCrock(currentBackupHash),
...(provider.lastBackupHash
? {
"if-match": provider.lastBackupHash,
}
: {}),
},
});
logger.trace(`sync response status: ${resp.status}`);
if (resp.status === HttpResponseStatus.PaymentRequired) {
logger.trace("payment required for backup");
logger.trace(`headers: ${j2s(resp.headers)}`);
const talerUri = resp.headers.get("taler");
if (!talerUri) {
throw Error("no taler URI available to pay provider");
}
const res = await preparePayForUri(ws, talerUri);
let proposalId: string | undefined;
switch (res.status) {
case PreparePayResultType.InsufficientBalance:
// FIXME: record in provider state!
logger.warn("insufficient balance to pay for backup provider");
break;
case PreparePayResultType.PaymentPossible:
case PreparePayResultType.AlreadyConfirmed:
proposalId = res.proposalId;
break;
}
if (!proposalId) {
continue;
}
const p = proposalId;
await ws.db.runWithWriteTransaction(
[Stores.backupProviders],
async (tx) => {
const provRec = await tx.get(
Stores.backupProviders,
provider.baseUrl,
);
checkDbInvariant(!!provRec);
const ids = new Set(provRec.paymentProposalIds);
ids.add(p);
provRec.paymentProposalIds = Array.from(ids);
await tx.put(Stores.backupProviders, provRec);
},
);
const confirmRes = await confirmPay(ws, proposalId);
switch (confirmRes.type) {
case ConfirmPayResultType.Pending:
logger.warn("payment not yet finished yet");
break;
}
}
if (resp.status === HttpResponseStatus.NoContent) {
await ws.db.runWithWriteTransaction(
[Stores.backupProviders],
async (tx) => {
const prov = await tx.get(Stores.backupProviders, provider.baseUrl);
if (!prov) {
return;
}
prov.lastBackupHash = encodeCrock(currentBackupHash);
prov.lastBackupTimestamp = getTimestampNow();
prov.lastBackupClock =
backupJson.clocks[backupJson.current_device_id];
prov.lastError = undefined;
await tx.put(Stores.backupProviders, prov);
},
);
continue;
}
if (resp.status === HttpResponseStatus.Conflict) {
logger.info("conflicting backup found");
const backupEnc = new Uint8Array(await resp.bytes());
const backupConfig = await provideBackupState(ws);
const blob = await decryptBackup(backupConfig, backupEnc);
const cryptoData = await computeBackupCryptoData(ws.cryptoApi, blob);
await importBackup(ws, blob, cryptoData);
await ws.db.runWithWriteTransaction(
[Stores.backupProviders],
async (tx) => {
const prov = await tx.get(Stores.backupProviders, provider.baseUrl);
if (!prov) {
return;
}
prov.lastBackupHash = encodeCrock(hash(backupEnc));
prov.lastBackupClock = blob.clocks[blob.current_device_id];
prov.lastBackupTimestamp = getTimestampNow();
prov.lastError = undefined;
await tx.put(Stores.backupProviders, prov);
},
);
logger.info("processed existing backup");
continue;
}
// Some other response that we did not expect!
logger.error("parsing error response");
const err = await readTalerErrorResponse(resp);
logger.error(`got error response from backup provider: ${j2s(err)}`);
await ws.db.runWithWriteTransaction(
[Stores.backupProviders],
async (tx) => {
const prov = await tx.get(Stores.backupProviders, provider.baseUrl);
if (!prov) {
return;
}
prov.lastError = err;
},
);
} }
} }
@ -462,8 +518,15 @@ export interface ProviderInfo {
lastRemoteClock?: number; lastRemoteClock?: number;
lastBackupTimestamp?: Timestamp; lastBackupTimestamp?: Timestamp;
paymentProposalIds: string[]; paymentProposalIds: string[];
paymentStatus: ProviderPaymentStatus;
} }
export type ProviderPaymentStatus =
| ProviderPaymentPaid
| ProviderPaymentInsufficientBalance
| ProviderPaymentUnpaid
| ProviderPaymentPending;
export interface BackupInfo { export interface BackupInfo {
walletRootPub: string; walletRootPub: string;
deviceId: string; deviceId: string;
@ -483,6 +546,71 @@ export async function importBackupPlain(
await importBackup(ws, blob, cryptoData); await importBackup(ws, blob, cryptoData);
} }
export enum ProviderPaymentType {
Unpaid = "unpaid",
Pending = "pending",
InsufficientBalance = "insufficient-balance",
Paid = "paid",
}
export interface ProviderPaymentUnpaid {
type: ProviderPaymentType.Unpaid;
}
export interface ProviderPaymentInsufficientBalance {
type: ProviderPaymentType.InsufficientBalance;
}
export interface ProviderPaymentPending {
type: ProviderPaymentType.Pending;
}
export interface ProviderPaymentPaid {
type: ProviderPaymentType.Paid;
paidUntil: Timestamp;
}
async function getProviderPaymentInfo(
ws: InternalWalletState,
provider: BackupProviderRecord,
): Promise<ProviderPaymentStatus> {
if (!provider.currentPaymentProposalId) {
return {
type: ProviderPaymentType.Unpaid,
};
}
const status = await checkPaymentByProposalId(
ws,
provider.currentPaymentProposalId,
);
if (status.status === PreparePayResultType.InsufficientBalance) {
return {
type: ProviderPaymentType.InsufficientBalance,
};
}
if (status.status === PreparePayResultType.PaymentPossible) {
return {
type: ProviderPaymentType.Pending,
};
}
if (status.status === PreparePayResultType.AlreadyConfirmed) {
if (status.paid) {
return {
type: ProviderPaymentType.Paid,
paidUntil: timestampAddDuration(
status.contractTerms.timestamp,
durationFromSpec({ years: 1 }),
),
};
} else {
return {
type: ProviderPaymentType.Pending,
};
}
}
throw Error("not reached");
}
/** /**
* Get information about the current state of wallet backups. * Get information about the current state of wallet backups.
*/ */
@ -490,19 +618,24 @@ export async function getBackupInfo(
ws: InternalWalletState, ws: InternalWalletState,
): Promise<BackupInfo> { ): Promise<BackupInfo> {
const backupConfig = await provideBackupState(ws); const backupConfig = await provideBackupState(ws);
const providers = await ws.db.iter(Stores.backupProviders).toArray(); const providerRecords = await ws.db.iter(Stores.backupProviders).toArray();
return { const providers: ProviderInfo[] = [];
deviceId: backupConfig.deviceId, for (const x of providerRecords) {
lastLocalClock: backupConfig.clocks[backupConfig.deviceId], providers.push({
walletRootPub: backupConfig.walletRootPub,
providers: providers.map((x) => ({
active: x.active, active: x.active,
lastRemoteClock: x.lastBackupClock, lastRemoteClock: x.lastBackupClock,
syncProviderBaseUrl: x.baseUrl, syncProviderBaseUrl: x.baseUrl,
lastBackupTimestamp: x.lastBackupTimestamp, lastBackupTimestamp: x.lastBackupTimestamp,
paymentProposalIds: x.paymentProposalIds, paymentProposalIds: x.paymentProposalIds,
lastError: x.lastError, lastError: x.lastError,
})), paymentStatus: await getProviderPaymentInfo(ws, x),
});
}
return {
deviceId: backupConfig.deviceId,
lastLocalClock: backupConfig.clocks[backupConfig.deviceId],
walletRootPub: backupConfig.walletRootPub,
providers,
}; };
} }

View File

@ -1150,36 +1150,11 @@ async function submitPay(
}; };
} }
/** export async function checkPaymentByProposalId(
* Check if a payment for the given taler://pay/ URI is possible.
*
* If the payment is possible, the signature are already generated but not
* yet send to the merchant.
*/
export async function preparePayForUri(
ws: InternalWalletState, ws: InternalWalletState,
talerPayUri: string, proposalId: string,
sessionId?: string,
): Promise<PreparePayResult> { ): Promise<PreparePayResult> {
const uriResult = parsePayUri(talerPayUri);
if (!uriResult) {
throw OperationFailedError.fromCode(
TalerErrorCode.WALLET_INVALID_TALER_PAY_URI,
`invalid taler://pay URI (${talerPayUri})`,
{
talerPayUri,
},
);
}
let proposalId = await startDownloadProposal(
ws,
uriResult.merchantBaseUrl,
uriResult.orderId,
uriResult.sessionId,
uriResult.claimToken,
);
let proposal = await ws.db.get(Stores.proposals, proposalId); let proposal = await ws.db.get(Stores.proposals, proposalId);
if (!proposal) { if (!proposal) {
throw Error(`could not get proposal ${proposalId}`); throw Error(`could not get proposal ${proposalId}`);
@ -1238,7 +1213,7 @@ export async function preparePayForUri(
}; };
} }
if (purchase.lastSessionId !== uriResult.sessionId) { if (purchase.lastSessionId !== sessionId) {
logger.trace( logger.trace(
"automatically re-submitting payment with different session ID", "automatically re-submitting payment with different session ID",
); );
@ -1247,7 +1222,7 @@ export async function preparePayForUri(
if (!p) { if (!p) {
return; return;
} }
p.lastSessionId = uriResult.sessionId; p.lastSessionId = sessionId;
await tx.put(Stores.purchases, p); await tx.put(Stores.purchases, p);
}); });
const r = await guardOperationException( const r = await guardOperationException(
@ -1292,6 +1267,39 @@ export async function preparePayForUri(
} }
} }
/**
* Check if a payment for the given taler://pay/ URI is possible.
*
* If the payment is possible, the signature are already generated but not
* yet send to the merchant.
*/
export async function preparePayForUri(
ws: InternalWalletState,
talerPayUri: string,
): Promise<PreparePayResult> {
const uriResult = parsePayUri(talerPayUri);
if (!uriResult) {
throw OperationFailedError.fromCode(
TalerErrorCode.WALLET_INVALID_TALER_PAY_URI,
`invalid taler://pay URI (${talerPayUri})`,
{
talerPayUri,
},
);
}
let proposalId = await startDownloadProposal(
ws,
uriResult.merchantBaseUrl,
uriResult.orderId,
uriResult.sessionId,
uriResult.claimToken,
);
return checkPaymentByProposalId(ws, proposalId, uriResult.sessionId);
}
/** /**
* Generate deposit permissions for a purchase. * Generate deposit permissions for a purchase.
* *

View File

@ -1462,8 +1462,19 @@ export interface BackupProviderRecord {
lastBackupTimestamp?: Timestamp; lastBackupTimestamp?: Timestamp;
/**
* Proposal that we're currently trying to pay for.
*
* (Also included in paymentProposalIds.)
*/
currentPaymentProposalId?: string; currentPaymentProposalId?: string;
/**
* Proposals that were used to pay (or attempt to pay) the provider.
*
* Stored to display a history of payments to the provider, and
* to make sure that the wallet isn't overpaying.
*/
paymentProposalIds: string[]; paymentProposalIds: string[];
/** /**

View File

@ -59,7 +59,7 @@ export function canonicalizeBaseUrl(url: string): string {
*/ */
export function canonicalJson(obj: any): string { export function canonicalJson(obj: any): string {
// Check for cycles, etc. // Check for cycles, etc.
JSON.stringify(obj); obj = JSON.parse(JSON.stringify(obj));
if (typeof obj === "string" || typeof obj === "number" || obj === null) { if (typeof obj === "string" || typeof obj === "number" || obj === null) {
return JSON.stringify(obj); return JSON.stringify(obj);
} }

View File

@ -22,7 +22,7 @@
/** /**
* Imports. * Imports.
*/ */
import { TalerErrorCode } from "."; import { codecForAny, TalerErrorCode } from ".";
import { CryptoWorkerFactory } from "./crypto/workers/cryptoApi"; import { CryptoWorkerFactory } from "./crypto/workers/cryptoApi";
import { import {
addBackupProvider, addBackupProvider,
@ -1159,6 +1159,15 @@ export class Wallet {
await runBackupCycle(this.ws); await runBackupCycle(this.ws);
return {}; return {};
} }
case "exportBackupRecovery": {
const resp = await getBackupRecovery(this.ws);
return resp;
}
case "importBackupRecovery": {
const req = codecForAny().decode(payload);
await loadBackupRecovery(this.ws, req);
return {};
}
case "getBackupInfo": { case "getBackupInfo": {
const resp = await getBackupInfo(this.ws); const resp = await getBackupInfo(this.ws);
return resp; return resp;