diff --git a/packages/taler-util/src/backupTypes.ts b/packages/taler-util/src/backupTypes.ts index 777086599..620f476ad 100644 --- a/packages/taler-util/src/backupTypes.ts +++ b/packages/taler-util/src/backupTypes.ts @@ -909,6 +909,8 @@ export interface BackupPurchase { /** * Signature on the contract terms. + * + * FIXME: Better name needed. */ merchant_sig?: string; diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index b019be67a..ec11f4d47 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -1085,18 +1085,16 @@ export enum PurchaseStatus { Paid = OperationStatusRange.DORMANT_START + 5, } +/** + * Partial information about the downloaded proposal. + * Only contains data that is relevant for indexing on the + * "purchases" object stores. + */ export interface ProposalDownload { - /** - * The contract that was offered by the merchant. - */ - contractTermsRaw: any; - - /** - * Extracted / parsed data from the contract terms. - * - * FIXME: Do we need to store *all* that data in duplicate? - */ - contractData: WalletContractData; + contractTermsHash: string; + fulfillmentUrl?: string; + currency: string; + contractTermsMerchantSig: string; } export interface PurchasePayInfo { @@ -1723,6 +1721,7 @@ export interface PeerPullPaymentInitiationRecord { * Contract terms for the other party. * * FIXME: Nail down type! + * FIXME: Put in contractTerms store */ contractTerms: any; } @@ -1819,6 +1818,18 @@ export interface CoinAvailabilityRecord { freshCoinCount: number; } +export interface ContractTermsRecord { + /** + * Contract terms hash. + */ + h: string; + + /** + * Contract terms JSON. + */ + contractTermsRaw: any; +} + /** * Schema definition for the IndexedDB * wallet database. @@ -1937,13 +1948,8 @@ export const WalletStoresV1 = { byStatus: describeIndex("byStatus", "purchaseStatus"), byFulfillmentUrl: describeIndex( "byFulfillmentUrl", - "download.contractData.fulfillmentUrl", + "download.fulfillmentUrl", ), - // FIXME: Deduplicate! - byMerchantUrlAndOrderId: describeIndex("byMerchantUrlAndOrderId", [ - "download.contractData.merchantBaseUrl", - "download.contractData.orderId", - ]), byUrlAndOrderId: describeIndex("byUrlAndOrderId", [ "merchantBaseUrl", "orderId", @@ -2088,6 +2094,13 @@ export const WalletStoresV1 = { }), {}, ), + contractTerms: describeStore( + "contractTerms", + describeContents({ + keyPath: "h", + }), + {}, + ), }; /** diff --git a/packages/taler-wallet-core/src/operations/backup/export.ts b/packages/taler-wallet-core/src/operations/backup/export.ts index d16b344f6..2e2a1c4b4 100644 --- a/packages/taler-wallet-core/src/operations/backup/export.ts +++ b/packages/taler-wallet-core/src/operations/backup/export.ts @@ -88,6 +88,7 @@ export async function exportBackup( x.exchanges, x.exchangeDetails, x.coins, + x.contractTerms, x.denominations, x.purchases, x.refreshGroups, @@ -353,7 +354,7 @@ export async function exportBackup( const purchaseProposalIdSet = new Set(); - await tx.purchases.iter().forEach((purch) => { + await tx.purchases.iter().forEachAsync(async (purch) => { const refunds: BackupRefundItem[] = []; purchaseProposalIdSet.add(purch.proposalId); for (const refundKey of Object.keys(purch.refunds)) { @@ -418,8 +419,18 @@ export async function exportBackup( }; } + let contractTermsRaw = undefined; + if (purch.download) { + const contractTermsRecord = await tx.contractTerms.get( + purch.download.contractTermsHash, + ); + if (contractTermsRecord) { + contractTermsRaw = contractTermsRecord.contractTermsRaw; + } + } + backupPurchases.push({ - contract_terms_raw: purch.download?.contractTermsRaw, + contract_terms_raw: contractTermsRaw, auto_refund_deadline: purch.autoRefundDeadline, merchant_pay_sig: purch.merchantPaySig, pay_info: backupPayInfo, @@ -428,7 +439,7 @@ export async function exportBackup( timestamp_accepted: purch.timestampAccept, timestamp_first_successful_pay: purch.timestampFirstSuccessfulPay, nonce_priv: purch.noncePriv, - merchant_sig: purch.download?.contractData.merchantSig, + merchant_sig: purch.download?.contractTermsMerchantSig, claim_token: purch.claimToken, merchant_base_url: purch.merchantBaseUrl, order_id: purch.orderId, diff --git a/packages/taler-wallet-core/src/operations/backup/import.ts b/packages/taler-wallet-core/src/operations/backup/import.ts index bb5fe56e2..3ee3680fe 100644 --- a/packages/taler-wallet-core/src/operations/backup/import.ts +++ b/packages/taler-wallet-core/src/operations/backup/import.ts @@ -64,6 +64,7 @@ import { checkLogicInvariant } from "../../util/invariants.js"; import { GetReadOnlyAccess, GetReadWriteAccess } from "../../util/query.js"; import { makeCoinAvailable, makeEventId, TombstoneTag } from "../common.js"; import { getExchangeDetails } from "../exchanges.js"; +import { extractContractData } from "../pay-merchant.js"; import { provideBackupState } from "./state.js"; const logger = new Logger("operations/backup/import.ts"); @@ -630,49 +631,25 @@ export async function importBackup( maxWireFee = Amounts.getZero(amount.currency); } const download: ProposalDownload = { - contractData: { - amount, - contractTermsHash: contractTermsHash, - fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "", - merchantBaseUrl: parsedContractTerms.merchant_base_url, - merchantPub: parsedContractTerms.merchant_pub, - merchantSig: backupPurchase.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.auditor_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, - deliveryDate: parsedContractTerms.delivery_date, - deliveryLocation: parsedContractTerms.delivery_location, - }, - contractTermsRaw: backupPurchase.contract_terms_raw, + contractTermsHash, + contractTermsMerchantSig: backupPurchase.merchant_sig!, + currency: amount.currency, + fulfillmentUrl: backupPurchase.contract_terms_raw.fulfillment_url, }; + const contractData = extractContractData( + backupPurchase.contract_terms_raw, + contractTermsHash, + download.contractTermsMerchantSig, + ); + let payInfo: PurchasePayInfo | undefined = undefined; if (backupPurchase.pay_info) { payInfo = { coinDepositPermissions: undefined, payCoinSelection: await recoverPayCoinSelection( tx, - download.contractData, + contractData, backupPurchase.pay_info, ), payCoinSelectionUid: backupPurchase.pay_info.pay_coins_uid, diff --git a/packages/taler-wallet-core/src/operations/pay-merchant.ts b/packages/taler-wallet-core/src/operations/pay-merchant.ts index d590177c2..e805c0ea1 100644 --- a/packages/taler-wallet-core/src/operations/pay-merchant.ts +++ b/packages/taler-wallet-core/src/operations/pay-merchant.ts @@ -115,6 +115,7 @@ import { throwUnexpectedRequestError, } from "../util/http.js"; import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js"; +import { GetReadOnlyAccess } from "../util/query.js"; import { OperationAttemptResult, OperationAttemptResultType, @@ -256,12 +257,34 @@ function getPayRequestTimeout(purchase: PurchaseRecord): Duration { * (Async since in the future this will query the DB.) */ export async function expectProposalDownload( + ws: InternalWalletState, p: PurchaseRecord, -): Promise { +): Promise<{ + contractData: WalletContractData; + contractTermsRaw: any; +}> { if (!p.download) { throw Error("expected proposal to be downloaded"); } - return p.download; + const download = p.download; + return await ws.db + .mktx((x) => [x.contractTerms]) + .runReadOnly(async (tx) => { + const contractTerms = await tx.contractTerms.get( + download.contractTermsHash, + ); + if (!contractTerms) { + throw Error("contract terms not found"); + } + return { + contractData: extractContractData( + contractTerms.contractTermsRaw, + download.contractTermsHash, + download.contractTermsMerchantSig, + ), + contractTermsRaw: contractTerms.contractTermsRaw, + }; + }); } export function extractContractData( @@ -494,7 +517,7 @@ export async function processDownloadProposal( logger.trace(`extracted contract data: ${j2s(contractData)}`); await ws.db - .mktx((x) => [x.purchases]) + .mktx((x) => [x.purchases, x.contractTerms]) .runReadWrite(async (tx) => { const p = await tx.purchases.get(proposalId); if (!p) { @@ -504,9 +527,15 @@ export async function processDownloadProposal( return; } p.download = { - contractData, - contractTermsRaw: proposalResp.contract_terms, + contractTermsHash, + contractTermsMerchantSig: contractData.merchantSig, + currency: contractData.amount.currency, + fulfillmentUrl: contractData.fulfillmentUrl, }; + await tx.contractTerms.put({ + h: contractTermsHash, + contractTermsRaw: proposalResp.contract_terms, + }); if ( fulfillmentUrl && (fulfillmentUrl.startsWith("http://") || @@ -636,7 +665,7 @@ async function storeFirstPaySuccess( ): Promise { const now = AbsoluteTime.toTimestamp(AbsoluteTime.now()); await ws.db - .mktx((x) => [x.purchases]) + .mktx((x) => [x.purchases, x.contractTerms]) .runReadWrite(async (tx) => { const purchase = await tx.purchases.get(proposalId); @@ -655,7 +684,18 @@ async function storeFirstPaySuccess( purchase.timestampFirstSuccessfulPay = now; purchase.lastSessionId = sessionId; purchase.merchantPaySig = paySig; - const protoAr = purchase.download!.contractData.autoRefund; + const dl = purchase.download; + checkDbInvariant(!!dl); + const contractTermsRecord = await tx.contractTerms.get( + dl.contractTermsHash, + ); + checkDbInvariant(!!contractTermsRecord); + const contractData = extractContractData( + contractTermsRecord.contractTermsRaw, + dl.contractTermsHash, + dl.contractTermsMerchantSig, + ); + const protoAr = contractData.autoRefund; if (protoAr) { const ar = Duration.fromTalerProtocolDuration(protoAr); logger.info("auto_refund present"); @@ -739,7 +779,7 @@ async function handleInsufficientFunds( throw new TalerProtocolViolationError(); } - const { contractData } = proposal.download!; + const { contractData } = await expectProposalDownload(ws, proposal); const prevPayCoins: PreviousPayCoins = []; @@ -1254,11 +1294,7 @@ export async function checkPaymentByProposalId( throw Error("existing proposal is in wrong state"); } } - const d = proposal.download; - if (!d) { - logger.error("bad proposal", proposal); - throw Error("proposal is in invalid state"); - } + const d = await expectProposalDownload(ws, proposal); const contractData = d.contractData; const merchantSig = d.contractData.merchantSig; if (!merchantSig) { @@ -1338,7 +1374,7 @@ export async function checkPaymentByProposalId( // FIXME: This does not surface the original error throw Error("submitting pay failed"); } - const download = await expectProposalDownload(purchase); + const download = await expectProposalDownload(ws, purchase); return { status: PreparePayResultType.AlreadyConfirmed, contractTerms: download.contractTermsRaw, @@ -1349,7 +1385,7 @@ export async function checkPaymentByProposalId( proposalId, }; } else if (!purchase.timestampFirstSuccessfulPay) { - const download = await expectProposalDownload(purchase); + const download = await expectProposalDownload(ws, purchase); return { status: PreparePayResultType.AlreadyConfirmed, contractTerms: download.contractTermsRaw, @@ -1364,7 +1400,7 @@ export async function checkPaymentByProposalId( purchase.purchaseStatus === PurchaseStatus.Paid || purchase.purchaseStatus === PurchaseStatus.QueryingRefund || purchase.purchaseStatus === PurchaseStatus.QueryingAutoRefund; - const download = await expectProposalDownload(purchase); + const download = await expectProposalDownload(ws, purchase); return { status: PreparePayResultType.AlreadyConfirmed, contractTerms: download.contractTermsRaw, @@ -1392,11 +1428,9 @@ export async function getContractTermsDetails( throw Error(`proposal with id ${proposalId} not found`); } - if (!proposal.download || !proposal.download.contractData) { - throw Error("proposal is in invalid state"); - } + const d = await expectProposalDownload(ws, proposal); - return proposal.download.contractData; + return d.contractData; } /** @@ -1516,12 +1550,13 @@ export async function runPayForConfirmPay( .runReadOnly(async (tx) => { return tx.purchases.get(proposalId); }); - if (!purchase?.download) { + if (!purchase) { throw Error("purchase record not available anymore"); } + const d = await expectProposalDownload(ws, purchase); return { type: ConfirmPayResultType.Done, - contractTerms: purchase.download.contractTermsRaw, + contractTerms: d.contractTermsRaw, transactionId: makeEventId(TransactionType.Payment, proposalId), }; } @@ -1599,7 +1634,7 @@ export async function confirmPay( throw Error(`proposal with id ${proposalId} not found`); } - const d = proposal.download; + const d = await expectProposalDownload(ws, proposal); if (!d) { throw Error("proposal is in invalid state"); } @@ -1810,7 +1845,7 @@ export async function processPurchasePay( const payInfo = purchase.payInfo; checkDbInvariant(!!payInfo, "payInfo"); - const download = await expectProposalDownload(purchase); + const download = await expectProposalDownload(ws, purchase); if (!purchase.merchantPaySig) { const payUrl = new URL( `orders/${download.contractData.orderId}/pay`, @@ -2007,7 +2042,7 @@ export async function prepareRefund( const purchase = await ws.db .mktx((x) => [x.purchases]) .runReadOnly(async (tx) => { - return tx.purchases.indexes.byMerchantUrlAndOrderId.get([ + return tx.purchases.indexes.byUrlAndOrderId.get([ parseResult.merchantBaseUrl, parseResult.orderId, ]); @@ -2020,10 +2055,10 @@ export async function prepareRefund( } const awaiting = await queryAndSaveAwaitingRefund(ws, purchase); - const summary = await calculateRefundSummary(purchase); + const summary = await calculateRefundSummary(ws, purchase); const proposalId = purchase.proposalId; - const { contractData: c } = await expectProposalDownload(purchase); + const { contractData: c } = await expectProposalDownload(ws, purchase); return { proposalId, @@ -2380,9 +2415,10 @@ async function acceptRefunds( } async function calculateRefundSummary( + ws: InternalWalletState, p: PurchaseRecord, ): Promise { - const download = await expectProposalDownload(p); + const download = await expectProposalDownload(ws, p); let amountRefundGranted = Amounts.getZero( download.contractData.amount.currency, ); @@ -2456,7 +2492,7 @@ export async function applyRefund( const purchase = await ws.db .mktx((x) => [x.purchases]) .runReadOnly(async (tx) => { - return tx.purchases.indexes.byMerchantUrlAndOrderId.get([ + return tx.purchases.indexes.byUrlAndOrderId.get([ parseResult.merchantBaseUrl, parseResult.orderId, ]); @@ -2513,8 +2549,8 @@ export async function applyRefundFromPurchaseId( throw Error("purchase no longer exists"); } - const summary = await calculateRefundSummary(purchase); - const download = await expectProposalDownload(purchase); + const summary = await calculateRefundSummary(ws, purchase); + const download = await expectProposalDownload(ws, purchase); return { contractTermsHash: download.contractData.contractTermsHash, @@ -2542,7 +2578,7 @@ async function queryAndSaveAwaitingRefund( purchase: PurchaseRecord, waitForAutoRefund?: boolean, ): Promise { - const download = await expectProposalDownload(purchase); + const download = await expectProposalDownload(ws, purchase); const requestUrl = new URL( `orders/${download.contractData.orderId}`, download.contractData.merchantBaseUrl, @@ -2621,7 +2657,7 @@ export async function processPurchaseQueryRefund( return OperationAttemptResult.finishedEmpty(); } - const download = await expectProposalDownload(purchase); + const download = await expectProposalDownload(ws, purchase); if (purchase.timestampFirstSuccessfulPay) { if ( diff --git a/packages/taler-wallet-core/src/operations/transactions.ts b/packages/taler-wallet-core/src/operations/transactions.ts index d8069436a..6955d7b17 100644 --- a/packages/taler-wallet-core/src/operations/transactions.ts +++ b/packages/taler-wallet-core/src/operations/transactions.ts @@ -48,6 +48,7 @@ import { WalletRefundItem, WithdrawalGroupRecord, WithdrawalRecordType, + WalletContractData, } from "../db.js"; import { InternalWalletState } from "../internal-wallet-state.js"; import { checkDbInvariant } from "../util/invariants.js"; @@ -55,7 +56,11 @@ import { RetryTags } from "../util/retries.js"; import { makeEventId, TombstoneTag } from "./common.js"; import { processDepositGroup } from "./deposits.js"; import { getExchangeDetails } from "./exchanges.js"; -import { expectProposalDownload, processPurchasePay } from "./pay-merchant.js"; +import { + expectProposalDownload, + extractContractData, + processPurchasePay, +} from "./pay-merchant.js"; import { processRefreshGroup } from "./refresh.js"; import { processTip } from "./tip.js"; import { @@ -199,7 +204,7 @@ export async function getTransactionById( }), ); - const download = await expectProposalDownload(purchase); + const download = await expectProposalDownload(ws, purchase); const cleanRefunds = filteredRefunds.filter( (x): x is WalletRefundItem => !!x, @@ -214,7 +219,12 @@ export async function getTransactionById( const payOpId = RetryTags.forPay(purchase); const payRetryRecord = await tx.operationRetries.get(payOpId); - return buildTransactionForPurchase(purchase, refunds, payRetryRecord); + return buildTransactionForPurchase( + purchase, + contractData, + refunds, + payRetryRecord, + ); }); } else if (type === TransactionType.Refresh) { const refreshGroupId = rest[0]; @@ -268,14 +278,19 @@ export async function getTransactionById( ), ); if (t) throw Error("deleted"); - const download = await expectProposalDownload(purchase); + const download = await expectProposalDownload(ws, purchase); const contractData = download.contractData; const refunds = mergeRefundByExecutionTime( [theRefund], Amounts.getZero(contractData.amount.currency), ); - return buildTransactionForRefund(purchase, refunds[0], undefined); + return buildTransactionForRefund( + purchase, + contractData, + refunds[0], + undefined, + ); }); } else if (type === TransactionType.PeerPullDebit) { const peerPullPaymentIncomingId = rest[0]; @@ -572,12 +587,10 @@ function mergeRefundByExecutionTime( async function buildTransactionForRefund( purchaseRecord: PurchaseRecord, + contractData: WalletContractData, refundInfo: MergedRefundInfo, ort?: OperationRetryRecord, ): Promise { - const download = await expectProposalDownload(purchaseRecord); - const contractData = download.contractData; - const info: OrderShortInfo = { merchant: contractData.merchant, orderId: contractData.orderId, @@ -617,11 +630,10 @@ async function buildTransactionForRefund( async function buildTransactionForPurchase( purchaseRecord: PurchaseRecord, + contractData: WalletContractData, refundsInfo: MergedRefundInfo[], ort?: OperationRetryRecord, ): Promise { - const download = await expectProposalDownload(purchaseRecord); - const contractData = download.contractData; const zero = Amounts.getZero(contractData.amount.currency); const info: OrderShortInfo = { @@ -689,7 +701,8 @@ async function buildTransactionForPurchase( proposalId: purchaseRecord.proposalId, info, frozen: - purchaseRecord.purchaseStatus === PurchaseStatus.PaymentAbortFinished ?? false, + purchaseRecord.purchaseStatus === PurchaseStatus.PaymentAbortFinished ?? + false, ...(ort?.lastError ? { error: ort.lastError } : {}), }; } @@ -715,6 +728,7 @@ export async function getTransactions( x.peerPushPaymentInitiations, x.planchets, x.purchases, + x.contractTerms, x.recoupGroups, x.tips, x.tombstones, @@ -814,18 +828,28 @@ export async function getTransactions( if (!purchase.payInfo) { return; } + if (shouldSkipCurrency(transactionsRequest, download.currency)) { + return; + } + const contractTermsRecord = await tx.contractTerms.get( + download.contractTermsHash, + ); + if (!contractTermsRecord) { + return; + } if ( - shouldSkipCurrency( - transactionsRequest, - download.contractData.amount.currency, - ) + shouldSkipSearch(transactionsRequest, [ + contractTermsRecord?.contractTermsRaw?.summary || "", + ]) ) { return; } - const contractData = download.contractData; - if (shouldSkipSearch(transactionsRequest, [contractData.summary])) { - return; - } + + const contractData = extractContractData( + contractTermsRecord?.contractTermsRaw, + download.contractTermsHash, + download.contractTermsMerchantSig, + ); const filteredRefunds = await Promise.all( Object.values(purchase.refunds).map(async (r) => { @@ -847,19 +871,29 @@ export async function getTransactions( const refunds = mergeRefundByExecutionTime( cleanRefunds, - Amounts.getZero(contractData.amount.currency), + Amounts.getZero(download.currency), ); refunds.forEach(async (refundInfo) => { transactions.push( - await buildTransactionForRefund(purchase, refundInfo, undefined), + await buildTransactionForRefund( + purchase, + contractData, + refundInfo, + undefined, + ), ); }); const payOpId = RetryTags.forPay(purchase); const payRetryRecord = await tx.operationRetries.get(payOpId); transactions.push( - await buildTransactionForPurchase(purchase, refunds, payRetryRecord), + await buildTransactionForPurchase( + purchase, + contractData, + refunds, + payRetryRecord, + ), ); });