backup import WIP

This commit is contained in:
Florian Dold 2020-12-21 13:23:07 +01:00
parent 84d5b5e5ef
commit 95568395ce
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
6 changed files with 1183 additions and 57 deletions

View File

@ -31,6 +31,7 @@ import {
BackupCoinSource,
BackupCoinSourceType,
BackupDenomination,
BackupDenomSel,
BackupExchange,
BackupExchangeWireFee,
BackupProposal,
@ -39,6 +40,7 @@ import {
BackupRecoupGroup,
BackupRefreshGroup,
BackupRefreshOldCoin,
BackupRefreshReason,
BackupRefreshSession,
BackupRefundItem,
BackupRefundState,
@ -50,15 +52,24 @@ import {
import { TransactionHandle } from "../util/query";
import {
AbortStatus,
CoinSource,
CoinSourceType,
CoinStatus,
ConfigRecord,
DenominationStatus,
DenomSelectionState,
ExchangeUpdateStatus,
ExchangeWireInfo,
ProposalDownload,
ProposalStatus,
RefreshSessionRecord,
RefundState,
ReserveBankInfo,
ReserveRecordStatus,
Stores,
} from "../types/dbTypes";
import { checkDbInvariant } from "../util/invariants";
import { Amounts, codecForAmountString } from "../util/amounts";
import { checkDbInvariant, checkLogicInvariant } from "../util/invariants";
import { AmountJson, Amounts, codecForAmountString } from "../util/amounts";
import {
decodeCrock,
eddsaGetPublic,
@ -71,7 +82,11 @@ import {
import { canonicalizeBaseUrl, canonicalJson, j2s } from "../util/helpers";
import { getTimestampNow, Timestamp } from "../util/time";
import { URL } from "../util/url";
import { AmountString, TipResponse } from "../types/talerTypes";
import {
AmountString,
codecForContractTerms,
ContractTerms,
} from "../types/talerTypes";
import {
buildCodecForObject,
Codec,
@ -85,6 +100,8 @@ import {
import { Logger } from "../util/logging";
import { gzipSync } from "fflate";
import { kdf } from "../crypto/primitives/kdf";
import { initRetryInfo } from "../util/retries";
import { RefreshReason } from "../types/walletTypes";
interface WalletBackupConfState {
deviceId: string;
@ -207,7 +224,7 @@ export async function exportBackup(
timestamp_start: wg.timestampStart,
timestamp_finish: wg.timestampFinish,
withdrawal_group_id: wg.withdrawalGroupId,
secret_seed: wg.secretSeed
secret_seed: wg.secretSeed,
});
});
@ -425,7 +442,7 @@ export async function exportBackup(
backupPurchases.push({
clock_created: 1,
contract_terms_raw: purch.contractTermsRaw,
contract_terms_raw: purch.download.contractTermsRaw,
auto_refund_deadline: purch.autoRefundDeadline,
merchant_pay_sig: purch.merchantPaySig,
pay_coins: purch.payCoinSelection.coinPubs.map((x, i) => ({
@ -478,6 +495,9 @@ export async function exportBackup(
timestamp: prop.timestamp,
contract_terms_raw: prop.download?.contractTermsRaw,
download_session_id: prop.downloadSessionId,
merchant_base_url: prop.merchantBaseUrl,
order_id: prop.orderId,
merchant_sig: prop.download?.contractData.merchantSig,
});
});
@ -572,9 +592,47 @@ export async function encryptBackup(
throw Error("not implemented");
}
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.
*/
interface BackupCryptoPrecomputedData {
denomPubToHash: Record<string, string>;
coinPrivToCompletedCoin: Record<string, CompletedCoin>;
proposalNoncePrivToProposalPub: { [priv: string]: string };
proposalIdToContractTermsHash: { [proposalId: string]: string };
reservePrivToPub: Record<string, string>;
}
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");
}
}
}
function getDenomSelStateFromBackup(
tx: TransactionHandle<typeof Stores.denominations>,
sel: BackupDenomSel,
): Promise<DenomSelectionState> {
throw Error("not implemented");
}
export async function importBackup(
ws: InternalWalletState,
backupRequest: BackupRequest,
cryptoComp: BackupCryptoPrecomputedData,
): Promise<void> {
await provideBackupState(ws);
return ws.db.runWithWriteTransaction(
@ -593,8 +651,439 @@ export async function importBackup(
Stores.withdrawalGroups,
],
async (tx) => {
// FIXME: validate schema!
const backupBlob = backupRequest.backupBlob as WalletBackupContentV1;
});
// FIXME: validate version
for (const backupExchange of backupBlob.exchanges) {
const existingExchange = await tx.get(
Stores.exchanges,
backupExchange.base_url,
);
if (!existingExchange) {
const wireInfo: ExchangeWireInfo = {
accounts: backupExchange.accounts.map((x) => ({
master_sig: x.master_sig,
payto_uri: x.payto_uri,
})),
feesForType: {},
};
for (const fee of backupExchange.wire_fees) {
const w = (wireInfo.feesForType[fee.wire_type] ??= []);
w.push({
closingFee: Amounts.parseOrThrow(fee.closing_fee),
endStamp: fee.end_stamp,
sig: fee.sig,
startStamp: fee.start_stamp,
wireFee: Amounts.parseOrThrow(fee.wire_fee),
});
}
await tx.put(Stores.exchanges, {
addComplete: true,
baseUrl: backupExchange.base_url,
builtIn: false,
updateReason: undefined,
permanent: true,
retryInfo: initRetryInfo(),
termsOfServiceAcceptedEtag: backupExchange.tos_etag_accepted,
termsOfServiceText: undefined,
termsOfServiceLastEtag: backupExchange.tos_etag_last,
updateStarted: getTimestampNow(),
updateStatus: ExchangeUpdateStatus.FetchKeys,
wireInfo,
details: {
currency: backupExchange.currency,
auditors: backupExchange.auditors.map((x) => ({
auditor_pub: x.auditor_pub,
auditor_url: x.auditor_url,
denomination_keys: x.denomination_keys,
})),
lastUpdateTime: { t_ms: "never" },
masterPublicKey: backupExchange.master_public_key,
nextUpdateTime: { t_ms: "never" },
protocolVersion: backupExchange.protocol_version,
signingKeys: backupExchange.signing_keys.map((x) => ({
key: x.key,
master_sig: x.master_sig,
stamp_end: x.stamp_end,
stamp_expire: x.stamp_expire,
stamp_start: x.stamp_start,
})),
},
});
}
for (const backupDenomination of backupExchange.denominations) {
const denomPubHash =
cryptoComp.denomPubToHash[backupDenomination.denom_pub];
checkLogicInvariant(!!denomPubHash);
const existingDenom = await tx.get(Stores.denominations, [
backupExchange.base_url,
denomPubHash,
]);
if (!existingDenom) {
await tx.put(Stores.denominations, {
denomPub: backupDenomination.denom_pub,
denomPubHash: denomPubHash,
exchangeBaseUrl: backupExchange.base_url,
feeDeposit: Amounts.parseOrThrow(backupDenomination.fee_deposit),
feeRefresh: Amounts.parseOrThrow(backupDenomination.fee_refresh),
feeRefund: Amounts.parseOrThrow(backupDenomination.fee_refund),
feeWithdraw: Amounts.parseOrThrow(
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,
status: DenominationStatus.VerifiedGood,
value: Amounts.parseOrThrow(backupDenomination.value),
});
}
for (const backupCoin of backupDenomination.coins) {
const compCoin =
cryptoComp.coinPrivToCompletedCoin[backupCoin.coin_priv];
checkLogicInvariant(!!compCoin);
const existingCoin = await tx.get(Stores.coins, 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,
};
break;
case BackupCoinSourceType.Tip:
coinSource = {
type: CoinSourceType.Tip,
coinIndex: backupCoin.coin_source.coin_index,
walletTipId: 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;
}
await tx.put(Stores.coins, {
blindingKey: backupCoin.blinding_key,
coinEvHash: compCoin.coinEvHash,
coinPriv: backupCoin.coin_priv,
currentAmount: Amounts.parseOrThrow(backupCoin.current_amount),
denomSig: backupCoin.denom_sig,
coinPub: compCoin.coinPub,
suspended: false,
exchangeBaseUrl: backupExchange.base_url,
denomPub: backupDenomination.denom_pub,
denomPubHash,
status: backupCoin.fresh
? CoinStatus.Fresh
: CoinStatus.Dormant,
coinSource,
});
}
}
}
for (const backupReserve of backupExchange.reserves) {
const reservePub =
cryptoComp.reservePrivToPub[backupReserve.reserve_priv];
checkLogicInvariant(!!reservePub);
const existingReserve = await tx.get(Stores.reserves, reservePub);
const instructedAmount = Amounts.parseOrThrow(
backupReserve.instructed_amount,
);
if (!existingReserve) {
let bankInfo: ReserveBankInfo | undefined;
if (backupReserve.bank_info) {
bankInfo = {
exchangePaytoUri: backupReserve.bank_info.exchange_payto_uri,
statusUrl: backupReserve.bank_info.status_url,
confirmUrl: backupReserve.bank_info.confirm_url,
};
}
await tx.put(Stores.reserves, {
currency: instructedAmount.currency,
instructedAmount,
exchangeBaseUrl: backupExchange.base_url,
reservePub,
reservePriv: backupReserve.reserve_priv,
requestedQuery: false,
bankInfo,
timestampCreated: backupReserve.timestamp_created,
timestampBankConfirmed:
backupReserve.bank_info?.timestamp_bank_confirmed,
timestampReserveInfoPosted:
backupReserve.bank_info?.timestamp_reserve_info_posted,
senderWire: backupReserve.sender_wire,
retryInfo: initRetryInfo(false),
lastError: undefined,
lastSuccessfulStatusQuery: { t_ms: "never" },
initialWithdrawalGroupId:
backupReserve.initial_withdrawal_group_id,
initialWithdrawalStarted:
backupReserve.withdrawal_groups.length > 0,
// FIXME!
reserveStatus: ReserveRecordStatus.QUERYING_STATUS,
initialDenomSel: await getDenomSelStateFromBackup(
tx,
backupReserve.initial_selected_denoms,
),
});
}
for (const backupWg of backupReserve.withdrawal_groups) {
const existingWg = await tx.get(
Stores.withdrawalGroups,
backupWg.withdrawal_group_id,
);
if (!existingWg) {
await tx.put(Stores.withdrawalGroups, {
denomsSel: await getDenomSelStateFromBackup(
tx,
backupWg.selected_denoms,
),
exchangeBaseUrl: backupExchange.base_url,
lastError: undefined,
rawWithdrawalAmount: Amounts.parseOrThrow(
backupWg.raw_withdrawal_amount,
),
reservePub,
retryInfo: initRetryInfo(false),
secretSeed: backupWg.secret_seed,
timestampStart: backupWg.timestamp_start,
timestampFinish: backupWg.timestamp_finish,
withdrawalGroupId: backupWg.withdrawal_group_id,
});
}
}
}
}
for (const backupProposal of backupBlob.proposals) {
const existingProposal = await tx.get(
Stores.proposals,
backupProposal.proposal_id,
);
if (!existingProposal) {
let download: ProposalDownload | undefined;
let proposalStatus: ProposalStatus;
switch (backupProposal.proposal_status) {
case BackupProposalStatus.Proposed:
if (backupProposal.contract_terms_raw) {
proposalStatus = ProposalStatus.PROPOSED;
} else {
proposalStatus = ProposalStatus.DOWNLOADING;
}
break;
case BackupProposalStatus.Refused:
proposalStatus = ProposalStatus.REFUSED;
break;
case BackupProposalStatus.Repurchase:
proposalStatus = ProposalStatus.REPURCHASE;
break;
case BackupProposalStatus.PermanentlyFailed:
proposalStatus = ProposalStatus.PERMANENTLY_FAILED;
break;
}
if (backupProposal.contract_terms_raw) {
checkDbInvariant(!!backupProposal.merchant_sig);
const parsedContractTerms = codecForContractTerms().decode(
backupProposal.contract_terms_raw,
);
const amount = Amounts.parseOrThrow(parsedContractTerms.amount);
const contractTermsHash =
cryptoComp.proposalIdToContractTermsHash[
backupProposal.proposal_id
];
let maxWireFee: AmountJson;
if (parsedContractTerms.max_wire_fee) {
maxWireFee = Amounts.parseOrThrow(
parsedContractTerms.max_wire_fee,
);
} else {
maxWireFee = Amounts.getZero(amount.currency);
}
download = {
contractData: {
amount,
contractTermsHash: contractTermsHash,
fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "",
merchantBaseUrl: parsedContractTerms.merchant_base_url,
merchantPub: parsedContractTerms.merchant_pub,
merchantSig: backupProposal.merchant_sig,
orderId: parsedContractTerms.order_id,
summary: parsedContractTerms.summary,
autoRefund: parsedContractTerms.auto_refund,
maxWireFee,
payDeadline: parsedContractTerms.pay_deadline,
refundDeadline: parsedContractTerms.refund_deadline,
wireFeeAmortization:
parsedContractTerms.wire_fee_amortization || 1,
allowedAuditors: parsedContractTerms.auditors.map((x) => ({
auditorBaseUrl: x.url,
auditorPub: x.master_pub,
})),
allowedExchanges: parsedContractTerms.exchanges.map((x) => ({
exchangeBaseUrl: x.url,
exchangePub: x.master_pub,
})),
timestamp: parsedContractTerms.timestamp,
wireMethod: parsedContractTerms.wire_method,
wireInfoHash: parsedContractTerms.h_wire,
maxDepositFee: Amounts.parseOrThrow(
parsedContractTerms.max_fee,
),
merchant: parsedContractTerms.merchant,
products: parsedContractTerms.products,
summaryI18n: parsedContractTerms.summary_i18n,
},
contractTermsRaw: backupProposal.contract_terms_raw,
};
}
await tx.put(Stores.proposals, {
claimToken: backupProposal.claim_token,
lastError: undefined,
merchantBaseUrl: backupProposal.merchant_base_url,
timestamp: backupProposal.timestamp,
orderId: backupProposal.order_id,
noncePriv: backupProposal.nonce_priv,
noncePub:
cryptoComp.proposalNoncePrivToProposalPub[
backupProposal.nonce_priv
],
proposalId: backupProposal.proposal_id,
repurchaseProposalId: backupProposal.repurchase_proposal_id,
retryInfo: initRetryInfo(false),
download,
proposalStatus,
});
}
}
for (const backupPurchase of backupBlob.purchases) {
const existingPurchase = await tx.get(
Stores.purchases,
backupPurchase.proposal_id,
);
if (!existingPurchase) {
await tx.put(Stores.purchases, {});
}
}
for (const backupRefreshGroup of backupBlob.refresh_groups) {
const existingRg = await tx.get(
Stores.refreshGroups,
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.Pay;
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) {
if (oldCoin.refresh_session) {
const denomSel = await getDenomSelStateFromBackup(
tx,
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: denomSel.totalCoinValue,
});
} else {
refreshSessionPerCoin.push(undefined);
}
}
await tx.put(Stores.refreshGroups, {
timestampFinished: backupRefreshGroup.timestamp_finished,
timestampCreated: backupRefreshGroup.timestamp_started,
refreshGroupId: backupRefreshGroup.refresh_group_id,
reason,
lastError: undefined,
lastErrorPerCoin: {},
oldCoinPubs: backupRefreshGroup.old_coins.map((x) => x.coin_pub),
finishedPerCoin: backupRefreshGroup.old_coins.map(
(x) => x.finished,
),
inputPerCoin: backupRefreshGroup.old_coins.map((x) =>
Amounts.parseOrThrow(x.input_amount),
),
estimatedOutputPerCoin: backupRefreshGroup.old_coins.map((x) =>
Amounts.parseOrThrow(x.estimated_output_amount),
),
refreshSessionPerCoin,
retryInfo: initRetryInfo(false),
});
}
}
for (const backupTip of backupBlob.tips) {
const existingTip = await tx.get(Stores.tips, backupTip.wallet_tip_id);
if (!existingTip) {
const denomsSel = await getDenomSelStateFromBackup(
tx,
backupTip.selected_denoms,
);
await tx.put(Stores.tips, {
acceptedTimestamp: backupTip.timestamp_accepted,
createdTimestamp: backupTip.timestamp_created,
denomsSel,
exchangeBaseUrl: backupTip.exchange_base_url,
lastError: undefined,
merchantBaseUrl: backupTip.exchange_base_url,
merchantTipId: backupTip.merchant_tip_id,
pickedUpTimestamp: backupTip.timestam_picked_up,
retryInfo: initRetryInfo(false),
secretSeed: backupTip.secret_seed,
tipAmountEffective: denomsSel.totalCoinValue,
tipAmountRaw: Amounts.parseOrThrow(backupTip.tip_amount_raw),
tipExpiration: backupTip.timestamp_expiration,
walletTipId: backupTip.wallet_tip_id,
});
}
}
},
);
}
function deriveAccountKeyPair(
@ -607,7 +1096,6 @@ function deriveAccountKeyPair(
stringToBytes("taler-sync-account-key-salt"),
stringToBytes(providerUrl),
);
return {
eddsaPriv: privateKey,
eddsaPub: eddsaGetPublic(privateKey),

View File

@ -441,8 +441,7 @@ async function recordConfirmPay(
const payCostInfo = await getTotalPaymentCost(ws, coinSelection);
const t: PurchaseRecord = {
abortStatus: AbortStatus.None,
contractTermsRaw: d.contractTermsRaw,
contractData: d.contractData,
download: d,
lastSessionId: sessionId,
payCoinSelection: coinSelection,
totalPayCost: payCostInfo,
@ -763,7 +762,7 @@ async function processDownloadProposalImpl(
products: parsedContractTerms.products,
summaryI18n: parsedContractTerms.summary_i18n,
},
contractTermsRaw: JSON.stringify(proposalResp.contract_terms),
contractTermsRaw: proposalResp.contract_terms,
};
if (
fulfillmentUrl &&
@ -877,7 +876,7 @@ async function storeFirstPaySuccess(
purchase.payRetryInfo = initRetryInfo(false);
purchase.merchantPaySig = paySig;
if (isFirst) {
const ar = purchase.contractData.autoRefund;
const ar = purchase.download.contractData.autoRefund;
if (ar) {
logger.info("auto_refund present");
purchase.refundQueryRequested = true;
@ -938,8 +937,8 @@ async function submitPay(
if (!purchase.merchantPaySig) {
const payUrl = new URL(
`orders/${purchase.contractData.orderId}/pay`,
purchase.contractData.merchantBaseUrl,
`orders/${purchase.download.contractData.orderId}/pay`,
purchase.download.contractData.merchantBaseUrl,
).href;
const reqBody = {
@ -986,10 +985,10 @@ async function submitPay(
logger.trace("got success from pay URL", merchantResp);
const merchantPub = purchase.contractData.merchantPub;
const merchantPub = purchase.download.contractData.merchantPub;
const valid: boolean = await ws.cryptoApi.isValidPaymentSignature(
merchantResp.sig,
purchase.contractData.contractTermsHash,
purchase.download.contractData.contractTermsHash,
merchantPub,
);
@ -1002,12 +1001,12 @@ async function submitPay(
await storeFirstPaySuccess(ws, proposalId, sessionId, merchantResp.sig);
} else {
const payAgainUrl = new URL(
`orders/${purchase.contractData.orderId}/paid`,
purchase.contractData.merchantBaseUrl,
`orders/${purchase.download.contractData.orderId}/paid`,
purchase.download.contractData.merchantBaseUrl,
).href;
const reqBody = {
sig: purchase.merchantPaySig,
h_contract: purchase.contractData.contractTermsHash,
h_contract: purchase.download.contractData.contractTermsHash,
session_id: sessionId ?? "",
};
const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], () =>
@ -1047,7 +1046,7 @@ async function submitPay(
return {
type: ConfirmPayResultType.Done,
contractTerms: JSON.parse(purchase.contractTermsRaw),
contractTerms: purchase.download.contractTermsRaw,
};
}
@ -1120,7 +1119,7 @@ export async function preparePayForUri(
logger.info("not confirming payment, insufficient coins");
return {
status: PreparePayResultType.InsufficientBalance,
contractTerms: JSON.parse(d.contractTermsRaw),
contractTerms: d.contractTermsRaw,
proposalId: proposal.proposalId,
amountRaw: Amounts.stringify(d.contractData.amount),
};
@ -1132,7 +1131,7 @@ export async function preparePayForUri(
return {
status: PreparePayResultType.PaymentPossible,
contractTerms: JSON.parse(d.contractTermsRaw),
contractTerms: d.contractTermsRaw,
proposalId: proposal.proposalId,
amountEffective: Amounts.stringify(totalCost),
amountRaw: Amounts.stringify(res.paymentAmount),
@ -1161,20 +1160,20 @@ export async function preparePayForUri(
}
return {
status: PreparePayResultType.AlreadyConfirmed,
contractTerms: JSON.parse(purchase.contractTermsRaw),
contractTermsHash: purchase.contractData.contractTermsHash,
contractTerms: purchase.download.contractTermsRaw,
contractTermsHash: purchase.download.contractData.contractTermsHash,
paid: true,
amountRaw: Amounts.stringify(purchase.contractData.amount),
amountRaw: Amounts.stringify(purchase.download.contractData.amount),
amountEffective: Amounts.stringify(purchase.totalPayCost),
proposalId,
};
} else if (!purchase.timestampFirstSuccessfulPay) {
return {
status: PreparePayResultType.AlreadyConfirmed,
contractTerms: JSON.parse(purchase.contractTermsRaw),
contractTermsHash: purchase.contractData.contractTermsHash,
contractTerms: purchase.download.contractTermsRaw,
contractTermsHash: purchase.download.contractData.contractTermsHash,
paid: false,
amountRaw: Amounts.stringify(purchase.contractData.amount),
amountRaw: Amounts.stringify(purchase.download.contractData.amount),
amountEffective: Amounts.stringify(purchase.totalPayCost),
proposalId,
};
@ -1182,12 +1181,12 @@ export async function preparePayForUri(
const paid = !purchase.paymentSubmitPending;
return {
status: PreparePayResultType.AlreadyConfirmed,
contractTerms: JSON.parse(purchase.contractTermsRaw),
contractTermsHash: purchase.contractData.contractTermsHash,
contractTerms: purchase.download.contractTermsRaw,
contractTermsHash: purchase.download.contractData.contractTermsHash,
paid,
amountRaw: Amounts.stringify(purchase.contractData.amount),
amountRaw: Amounts.stringify(purchase.download.contractData.amount),
amountEffective: Amounts.stringify(purchase.totalPayCost),
...(paid ? { nextUrl: purchase.contractData.orderId } : {}),
...(paid ? { nextUrl: purchase.download.contractData.orderId } : {}),
proposalId,
};
}

View File

@ -33,11 +33,15 @@
* aren't exported yet (and not even implemented in wallet-core).
* 6. Returning money to own bank account isn't supported/exported yet.
* 7. Peer-to-peer payments aren't supported yet.
* 8. Next update time / next refresh time isn't backed up yet.
*
* Questions:
* 1. What happens when two backups are merged that have
* the same coin in different refresh groups?
* => Both are added, one will eventually fail
* 2. Should we make more information forgettable? I.e. is
* the coin selection still relevant for a purchase after the coins
* are legally expired?
*
* General considerations / decisions:
* 1. Information about previously occurring errors and
@ -74,6 +78,8 @@ type DeviceIdString = string;
*/
type ClockValue = number;
type RawContractTerms = any;
/**
* Content of the backup.
*
@ -544,10 +550,7 @@ export interface BackupRefreshSession {
/**
* Hased denominations of the newly requested coins.
*/
new_denoms: {
count: number;
denom_pub_hash: string;
}[];
new_denoms: BackupDenomSel;
/**
* Seed used to derive the planchets and
@ -654,10 +657,7 @@ export interface BackupWithdrawalGroup {
/**
* Multiset of denominations selected for withdrawal.
*/
selected_denoms: {
denom_pub_hash: string;
count: number;
}[];
selected_denoms: BackupDenomSel;
}
export enum BackupRefundState {
@ -747,7 +747,14 @@ export interface BackupPurchase {
/**
* Contract terms we got from the merchant.
*/
contract_terms_raw: string;
contract_terms_raw: RawContractTerms;
/**
* Signature on the contract terms.
*
* Must be present if contract_terms_raw is present.
*/
merchant_sig?: string;
/**
* Private key for the nonce. Might eventually be used
@ -889,6 +896,14 @@ export interface BackupDenomination {
coins: BackupCoin[];
}
/**
* Denomination selection.
*/
export type BackupDenomSel = {
denom_pub_hash: string;
count: number;
}[];
export interface BackupReserve {
/**
* The reserve private key.
@ -961,10 +976,7 @@ export interface BackupReserve {
* Denominations selected for the initial withdrawal.
* Stored here to show costs before withdrawal has begun.
*/
initial_selected_denoms: {
denom_pub_hash: string;
count: number;
}[];
initial_selected_denoms: BackupDenomSel;
/**
* Groups of withdrawal operations for this reserve. Typically just one.
@ -1126,10 +1138,6 @@ export enum BackupProposalStatus {
* but the user needs to accept/reject it.
*/
Proposed = "proposed",
/**
* The user has accepted the proposal.
*/
Accepted = "accepted",
/**
* The user has rejected the proposal.
*/
@ -1150,16 +1158,33 @@ export enum BackupProposalStatus {
* Proposal by a merchant.
*/
export interface BackupProposal {
/**
* Base URL of the merchant that proposed the purchase.
*/
merchant_base_url: string;
/**
* Downloaded data from the merchant.
*/
contract_terms_raw?: string;
contract_terms_raw?: RawContractTerms;
/**
* Signature on the contract terms.
*
* Must be present if contract_terms_raw is present.
*/
merchant_sig?: string;
/**
* Unique ID when the order is stored in the wallet DB.
*/
proposal_id: string;
/**
* Merchant-assigned order ID of the proposal.
*/
order_id: string;
/**
* Timestamp of when the record
* was created.

View File

@ -753,7 +753,7 @@ export interface ProposalDownload {
/**
* The contract that was offered by the merchant.
*/
contractTermsRaw: string;
contractTermsRaw: any;
contractData: WalletContractData;
}
@ -1200,14 +1200,9 @@ export interface PurchaseRecord {
noncePub: string;
/**
* Contract terms we got from the merchant.
* Downloaded and parsed proposal data.
*/
contractTermsRaw: string;
/**
* Parsed contract terms.
*/
contractData: WalletContractData;
download: ProposalDownload;
/**
* Deposit permissions, available once the user has accepted the payment.
@ -1291,6 +1286,9 @@ export interface ConfigRecord<T> {
value: T;
}
/**
* FIXME: Eliminate this in favor of DenomSelectionState.
*/
export interface DenominationSelectionInfo {
totalCoinValue: AmountJson;
totalWithdrawCost: AmountJson;
@ -1303,6 +1301,9 @@ export interface DenominationSelectionInfo {
}[];
}
/**
* Selected denominations withn some extra info.
*/
export interface DenomSelectionState {
totalCoinValue: AmountJson;
totalWithdrawCost: AmountJson;

View File

@ -0,0 +1,276 @@
/*
This file is part of GNU Taler
(C) 2019 GNUnet e.V.
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/>
*/
/**
* Type and schema definitions for pending operations in the wallet.
*/
/**
* Imports.
*/
import { TalerErrorDetails, BalancesResponse } from "./walletTypes";
import { ReserveRecordStatus } from "./dbTypes";
import { Timestamp, Duration } from "../util/time";
import { RetryInfo } from "../util/retries";
export enum PendingOperationType {
Bug = "bug",
ExchangeUpdate = "exchange-update",
ExchangeCheckRefresh = "exchange-check-refresh",
Pay = "pay",
ProposalChoice = "proposal-choice",
ProposalDownload = "proposal-download",
Refresh = "refresh",
Reserve = "reserve",
Recoup = "recoup",
RefundQuery = "refund-query",
TipChoice = "tip-choice",
TipPickup = "tip-pickup",
Withdraw = "withdraw",
}
/**
* Information about a pending operation.
*/
export type PendingOperationInfo = PendingOperationInfoCommon &
(
| PendingBugOperation
| PendingExchangeUpdateOperation
| PendingExchangeCheckRefreshOperation
| PendingPayOperation
| PendingProposalChoiceOperation
| PendingProposalDownloadOperation
| PendingRefreshOperation
| PendingRefundQueryOperation
| PendingReserveOperation
| PendingTipChoiceOperation
| PendingTipPickupOperation
| PendingWithdrawOperation
| PendingRecoupOperation
);
/**
* The wallet is currently updating information about an exchange.
*/
export interface PendingExchangeUpdateOperation {
type: PendingOperationType.ExchangeUpdate;
stage: ExchangeUpdateOperationStage;
reason: string;
exchangeBaseUrl: string;
lastError: TalerErrorDetails | undefined;
}
/**
* The wallet should check whether coins from this exchange
* need to be auto-refreshed.
*/
export interface PendingExchangeCheckRefreshOperation {
type: PendingOperationType.ExchangeCheckRefresh;
exchangeBaseUrl: string;
}
/**
* Some interal error happened in the wallet. This pending operation
* should *only* be reported for problems in the wallet, not when
* a problem with a merchant/exchange/etc. occurs.
*/
export interface PendingBugOperation {
type: PendingOperationType.Bug;
message: string;
details: any;
}
/**
* Current state of an exchange update operation.
*/
export enum ExchangeUpdateOperationStage {
FetchKeys = "fetch-keys",
FetchWire = "fetch-wire",
FinalizeUpdate = "finalize-update",
}
export enum ReserveType {
/**
* Manually created.
*/
Manual = "manual",
/**
* Withdrawn from a bank that has "tight" Taler integration
*/
TalerBankWithdraw = "taler-bank-withdraw",
}
/**
* Status of processing a reserve.
*
* Does *not* include the withdrawal operation that might result
* from this.
*/
export interface PendingReserveOperation {
type: PendingOperationType.Reserve;
retryInfo: RetryInfo | undefined;
stage: ReserveRecordStatus;
timestampCreated: Timestamp;
reserveType: ReserveType;
reservePub: string;
bankWithdrawConfirmUrl?: string;
}
/**
* Status of an ongoing withdrawal operation.
*/
export interface PendingRefreshOperation {
type: PendingOperationType.Refresh;
lastError?: TalerErrorDetails;
refreshGroupId: string;
finishedPerCoin: boolean[];
retryInfo: RetryInfo;
}
/**
* Status of downloading signed contract terms from a merchant.
*/
export interface PendingProposalDownloadOperation {
type: PendingOperationType.ProposalDownload;
merchantBaseUrl: string;
proposalTimestamp: Timestamp;
proposalId: string;
orderId: string;
lastError?: TalerErrorDetails;
retryInfo: RetryInfo;
}
/**
* User must choose whether to accept or reject the merchant's
* proposed contract terms.
*/
export interface PendingProposalChoiceOperation {
type: PendingOperationType.ProposalChoice;
merchantBaseUrl: string;
proposalTimestamp: Timestamp;
proposalId: string;
}
/**
* The wallet is picking up a tip that the user has accepted.
*/
export interface PendingTipPickupOperation {
type: PendingOperationType.TipPickup;
tipId: string;
merchantBaseUrl: string;
merchantTipId: string;
}
/**
* The wallet has been offered a tip, and the user now needs to
* decide whether to accept or reject the tip.
*/
export interface PendingTipChoiceOperation {
type: PendingOperationType.TipChoice;
tipId: string;
merchantBaseUrl: string;
merchantTipId: string;
}
/**
* The wallet is signing coins and then sending them to
* the merchant.
*/
export interface PendingPayOperation {
type: PendingOperationType.Pay;
proposalId: string;
isReplay: boolean;
retryInfo: RetryInfo;
lastError: TalerErrorDetails | undefined;
}
/**
* The wallet is querying the merchant about whether any refund
* permissions are available for a purchase.
*/
export interface PendingRefundQueryOperation {
type: PendingOperationType.RefundQuery;
proposalId: string;
retryInfo: RetryInfo;
lastError: TalerErrorDetails | undefined;
}
export interface PendingRecoupOperation {
type: PendingOperationType.Recoup;
recoupGroupId: string;
retryInfo: RetryInfo;
lastError: TalerErrorDetails | undefined;
}
/**
* Status of an ongoing withdrawal operation.
*/
export interface PendingWithdrawOperation {
type: PendingOperationType.Withdraw;
lastError: TalerErrorDetails | undefined;
retryInfo: RetryInfo;
withdrawalGroupId: string;
numCoinsWithdrawn: number;
numCoinsTotal: number;
}
/**
* Fields that are present in every pending operation.
*/
export interface PendingOperationInfoCommon {
/**
* Type of the pending operation.
*/
type: PendingOperationType;
/**
* Set to true if the operation indicates that something is really in progress,
* as opposed to some regular scheduled operation or a permanent failure.
*/
givesLifeness: boolean;
/**
* Retry info, not available on all pending operations.
* If it is available, it must have the same name.
*/
retryInfo?: RetryInfo;
}
/**
* Response returned from the pending operations API.
*/
export interface PendingOperationsResponse {
/**
* List of pending operations.
*/
pendingOperations: PendingOperationInfo[];
/**
* Current wallet balance, including pending balances.
*/
walletBalance: BalancesResponse;
/**
* When is the next pending operation due to be re-tried?
*/
nextRetryDelay: Duration;
/**
* Does this response only include pending operations that
* are due to be executed right now?
*/
onlyDue: boolean;
}

View File

@ -0,0 +1,337 @@
/*
This file is part of GNU Taler
(C) 2019 Taler Systems S.A.
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/>
*/
/**
* Type and schema definitions for the wallet's transaction list.
*
* @author Florian Dold
* @author Torsten Grote
*/
/**
* Imports.
*/
import { Timestamp } from "../util/time";
import {
AmountString,
Product,
InternationalizedString,
MerchantInfo,
codecForInternationalizedString,
codecForMerchantInfo,
codecForProduct,
} from "./talerTypes";
import {
Codec,
buildCodecForObject,
codecOptional,
codecForString,
codecForList,
codecForAny,
} from "../util/codec";
import { TalerErrorDetails } from "./walletTypes";
export interface TransactionsRequest {
/**
* return only transactions in the given currency
*/
currency?: string;
/**
* if present, results will be limited to transactions related to the given search string
*/
search?: string;
}
export interface TransactionsResponse {
// a list of past and pending transactions sorted by pending, timestamp and transactionId.
// In case two events are both pending and have the same timestamp,
// they are sorted by the transactionId
// (lexically ascending and locale-independent comparison).
transactions: Transaction[];
}
export interface TransactionCommon {
// opaque unique ID for the transaction, used as a starting point for paginating queries
// and for invoking actions on the transaction (e.g. deleting/hiding it from the history)
transactionId: string;
// the type of the transaction; different types might provide additional information
type: TransactionType;
// main timestamp of the transaction
timestamp: Timestamp;
// true if the transaction is still pending, false otherwise
// If a transaction is not longer pending, its timestamp will be updated,
// but its transactionId will remain unchanged
pending: boolean;
// Raw amount of the transaction (exclusive of fees or other extra costs)
amountRaw: AmountString;
// Amount added or removed from the wallet's balance (including all fees and other costs)
amountEffective: AmountString;
error?: TalerErrorDetails;
}
export type Transaction =
| TransactionWithdrawal
| TransactionPayment
| TransactionRefund
| TransactionTip
| TransactionRefresh;
export enum TransactionType {
Withdrawal = "withdrawal",
Payment = "payment",
Refund = "refund",
Refresh = "refresh",
Tip = "tip",
}
export enum WithdrawalType {
TalerBankIntegrationApi = "taler-bank-integration-api",
ManualTransfer = "manual-transfer",
}
export type WithdrawalDetails =
| WithdrawalDetailsForManualTransfer
| WithdrawalDetailsForTalerBankIntegrationApi;
interface WithdrawalDetailsForManualTransfer {
type: WithdrawalType.ManualTransfer;
/**
* Payto URIs that the exchange supports.
*
* Already contains the amount and message.
*/
exchangePaytoUris: string[];
}
interface WithdrawalDetailsForTalerBankIntegrationApi {
type: WithdrawalType.TalerBankIntegrationApi;
/**
* Set to true if the bank has confirmed the withdrawal, false if not.
* An unconfirmed withdrawal usually requires user-input and should be highlighted in the UI.
* See also bankConfirmationUrl below.
*/
confirmed: boolean;
/**
* If the withdrawal is unconfirmed, this can include a URL for user
* initiated confirmation.
*/
bankConfirmationUrl?: string;
}
// This should only be used for actual withdrawals
// and not for tips that have their own transactions type.
interface TransactionWithdrawal extends TransactionCommon {
type: TransactionType.Withdrawal;
/**
* Exchange of the withdrawal.
*/
exchangeBaseUrl: string;
/**
* Amount that got subtracted from the reserve balance.
*/
amountRaw: AmountString;
/**
* Amount that actually was (or will be) added to the wallet's balance.
*/
amountEffective: AmountString;
withdrawalDetails: WithdrawalDetails;
}
export enum PaymentStatus {
/**
* Explicitly aborted after timeout / failure
*/
Aborted = "aborted",
/**
* Payment failed, wallet will auto-retry.
* User should be given the option to retry now / abort.
*/
Failed = "failed",
/**
* Paid successfully
*/
Paid = "paid",
/**
* User accepted, payment is processing.
*/
Accepted = "accepted",
}
export interface TransactionPayment extends TransactionCommon {
type: TransactionType.Payment;
/**
* Additional information about the payment.
*/
info: OrderShortInfo;
/**
* Wallet-internal end-to-end identifier for the payment.
*/
proposalId: string;
/**
* How far did the wallet get with processing the payment?
*/
status: PaymentStatus;
/**
* Amount that must be paid for the contract
*/
amountRaw: AmountString;
/**
* Amount that was paid, including deposit, wire and refresh fees.
*/
amountEffective: AmountString;
}
export interface OrderShortInfo {
/**
* Order ID, uniquely identifies the order within a merchant instance
*/
orderId: string;
/**
* Hash of the contract terms.
*/
contractTermsHash: string;
/**
* More information about the merchant
*/
merchant: MerchantInfo;
/**
* Summary of the order, given by the merchant
*/
summary: string;
/**
* Map from IETF BCP 47 language tags to localized summaries
*/
summary_i18n?: InternationalizedString;
/**
* List of products that are part of the order
*/
products: Product[] | undefined;
/**
* URL of the fulfillment, given by the merchant
*/
fulfillmentUrl?: string;
/**
* Plain text message that should be shown to the user
* when the payment is complete.
*/
fulfillmentMessage?: string;
/**
* Translations of fulfillmentMessage.
*/
fulfillmentMessage_i18n?: InternationalizedString;
}
interface TransactionRefund extends TransactionCommon {
type: TransactionType.Refund;
// ID for the transaction that is refunded
refundedTransactionId: string;
// Additional information about the refunded payment
info: OrderShortInfo;
// Amount that has been refunded by the merchant
amountRaw: AmountString;
// Amount will be added to the wallet's balance after fees and refreshing
amountEffective: AmountString;
}
interface TransactionTip extends TransactionCommon {
type: TransactionType.Tip;
// Raw amount of the tip, without extra fees that apply
amountRaw: AmountString;
// Amount will be (or was) added to the wallet's balance after fees and refreshing
amountEffective: AmountString;
merchantBaseUrl: string;
}
// A transaction shown for refreshes that are not associated to other transactions
// such as a refresh necessary before coin expiration.
// It should only be returned by the API if the effective amount is different from zero.
interface TransactionRefresh extends TransactionCommon {
type: TransactionType.Refresh;
// Exchange that the coins are refreshed with
exchangeBaseUrl: string;
// Raw amount that is refreshed
amountRaw: AmountString;
// Amount that will be paid as fees for the refresh
amountEffective: AmountString;
}
export const codecForTransactionsRequest = (): Codec<TransactionsRequest> =>
buildCodecForObject<TransactionsRequest>()
.property("currency", codecOptional(codecForString()))
.property("search", codecOptional(codecForString()))
.build("TransactionsRequest");
// FIXME: do full validation here!
export const codecForTransactionsResponse = (): Codec<TransactionsResponse> =>
buildCodecForObject<TransactionsResponse>()
.property("transactions", codecForList(codecForAny()))
.build("TransactionsResponse");
export const codecForOrderShortInfo = (): Codec<OrderShortInfo> =>
buildCodecForObject<OrderShortInfo>()
.property("contractTermsHash", codecForString())
.property("fulfillmentMessage", codecOptional(codecForString()))
.property(
"fulfillmentMessage_i18n",
codecOptional(codecForInternationalizedString()),
)
.property("fulfillmentUrl", codecOptional(codecForString()))
.property("merchant", codecForMerchantInfo())
.property("orderId", codecForString())
.property("products", codecOptional(codecForList(codecForProduct())))
.property("summary", codecForString())
.property("summary_i18n", codecOptional(codecForInternationalizedString()))
.build("OrderShortInfo");