finish first complete end-to-end backup/sync test
This commit is contained in:
parent
ac89c3d277
commit
1392dc47c6
@ -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") {
|
||||||
|
@ -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);
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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.
|
||||||
*
|
*
|
||||||
|
@ -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[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
Loading…
Reference in New Issue
Block a user