diff --git a/packages/taler-wallet-core/src/operations/backup.ts b/packages/taler-wallet-core/src/operations/backup.ts index f071b6d08..fdccd23c1 100644 --- a/packages/taler-wallet-core/src/operations/backup.ts +++ b/packages/taler-wallet-core/src/operations/backup.ts @@ -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; + coinPrivToCompletedCoin: Record; + proposalNoncePrivToProposalPub: { [priv: string]: string }; + proposalIdToContractTermsHash: { [proposalId: string]: string }; + reservePrivToPub: Record; +} + +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, + sel: BackupDenomSel, +): Promise { + throw Error("not implemented"); +} + export async function importBackup( ws: InternalWalletState, backupRequest: BackupRequest, + cryptoComp: BackupCryptoPrecomputedData, ): Promise { 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), diff --git a/packages/taler-wallet-core/src/operations/pay.ts b/packages/taler-wallet-core/src/operations/pay.ts index c374cfe4a..ecbe37a64 100644 --- a/packages/taler-wallet-core/src/operations/pay.ts +++ b/packages/taler-wallet-core/src/operations/pay.ts @@ -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, }; } diff --git a/packages/taler-wallet-core/src/types/backupTypes.ts b/packages/taler-wallet-core/src/types/backupTypes.ts index d40d4fa6c..0b7f93c69 100644 --- a/packages/taler-wallet-core/src/types/backupTypes.ts +++ b/packages/taler-wallet-core/src/types/backupTypes.ts @@ -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. diff --git a/packages/taler-wallet-core/src/types/dbTypes.ts b/packages/taler-wallet-core/src/types/dbTypes.ts index 7ba3b8604..5b05e2874 100644 --- a/packages/taler-wallet-core/src/types/dbTypes.ts +++ b/packages/taler-wallet-core/src/types/dbTypes.ts @@ -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 { 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; diff --git a/packages/taler-wallet-core/src/types/pendingTypes.ts b/packages/taler-wallet-core/src/types/pendingTypes.ts new file mode 100644 index 000000000..18d9a2fa4 --- /dev/null +++ b/packages/taler-wallet-core/src/types/pendingTypes.ts @@ -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 + */ + +/** + * 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; +} diff --git a/packages/taler-wallet-core/src/types/transactionsTypes.ts b/packages/taler-wallet-core/src/types/transactionsTypes.ts new file mode 100644 index 000000000..0a683f298 --- /dev/null +++ b/packages/taler-wallet-core/src/types/transactionsTypes.ts @@ -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 + */ + +/** + * 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 => + buildCodecForObject() + .property("currency", codecOptional(codecForString())) + .property("search", codecOptional(codecForString())) + .build("TransactionsRequest"); + +// FIXME: do full validation here! +export const codecForTransactionsResponse = (): Codec => + buildCodecForObject() + .property("transactions", codecForList(codecForAny())) + .build("TransactionsResponse"); + +export const codecForOrderShortInfo = (): Codec => + buildCodecForObject() + .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");