diff options
author | Florian Dold <florian@dold.me> | 2024-06-13 18:19:20 +0200 |
---|---|---|
committer | Florian Dold <florian@dold.me> | 2024-06-13 18:22:23 +0200 |
commit | 10aa5e767a35d39d6612f5e4addf4e04f3241a42 (patch) | |
tree | 70d62dbbe02a9257e0af988a09de8cc0ee9ba557 | |
parent | 955b957ef6d7d27d444d363cf80ea8942233f97c (diff) | |
download | wallet-core-10aa5e767a35d39d6612f5e4addf4e04f3241a42.tar.xz |
wallet-core: introduce coin history store, spend coins transactionally
-rw-r--r-- | packages/taler-wallet-core/src/coinSelection.ts | 373 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/db.ts | 26 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/deposits.ts | 65 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/pay-merchant.ts | 284 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/pay-peer-common.ts | 77 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/pay-peer-push-debit.ts | 81 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/transactions.ts | 9 |
7 files changed, 508 insertions, 407 deletions
diff --git a/packages/taler-wallet-core/src/coinSelection.ts b/packages/taler-wallet-core/src/coinSelection.ts index db6384c93..51316a21f 100644 --- a/packages/taler-wallet-core/src/coinSelection.ts +++ b/packages/taler-wallet-core/src/coinSelection.ts @@ -252,6 +252,88 @@ async function internalSelectPayCoins( }; } +export async function selectPayCoinsInTx( + wex: WalletExecutionContext, + tx: WalletDbReadOnlyTransaction< + [ + "coinAvailability", + "denominations", + "refreshGroups", + "exchanges", + "exchangeDetails", + "coins", + ] + >, + req: SelectPayCoinRequestNg, +): Promise<SelectPayCoinsResult> { + if (logger.shouldLogTrace()) { + logger.trace(`selecting coins for ${j2s(req)}`); + } + + const materialAvSel = await internalSelectPayCoins(wex, tx, req, false); + + if (!materialAvSel) { + const prospectiveAvSel = await internalSelectPayCoins(wex, tx, req, true); + + if (prospectiveAvSel) { + const prospectiveCoins: SelectedProspectiveCoin[] = []; + for (const avKey of Object.keys(prospectiveAvSel.sel)) { + const mySel = prospectiveAvSel.sel[avKey]; + for (const contrib of mySel.contributions) { + prospectiveCoins.push({ + denomPubHash: mySel.denomPubHash, + contribution: Amounts.stringify(contrib), + exchangeBaseUrl: mySel.exchangeBaseUrl, + }); + } + } + return { + type: "prospective", + result: { + prospectiveCoins, + customerDepositFees: Amounts.stringify( + prospectiveAvSel.tally.customerDepositFees, + ), + customerWireFees: Amounts.stringify( + prospectiveAvSel.tally.customerWireFees, + ), + }, + } satisfies SelectPayCoinsResult; + } + + return { + type: "failure", + insufficientBalanceDetails: await reportInsufficientBalanceDetails( + wex, + tx, + { + restrictExchanges: req.restrictExchanges, + instructedAmount: req.contractTermsAmount, + requiredMinimumAge: req.requiredMinimumAge, + wireMethod: req.restrictWireMethod, + depositPaytoUri: req.depositPaytoUri, + }, + ), + } satisfies SelectPayCoinsResult; + } + + const coinSel = await assembleSelectPayCoinsSuccessResult( + tx, + materialAvSel.sel, + materialAvSel.coinRes, + materialAvSel.tally, + ); + + if (logger.shouldLogTrace()) { + logger.trace(`coin selection: ${j2s(coinSel)}`); + } + + return { + type: "success", + coinSel, + }; +} + /** * Select coins to spend under the merchant's constraints. * @@ -263,10 +345,6 @@ export async function selectPayCoins( wex: WalletExecutionContext, req: SelectPayCoinRequestNg, ): Promise<SelectPayCoinsResult> { - if (logger.shouldLogTrace()) { - logger.trace(`selecting coins for ${j2s(req)}`); - } - return await wex.db.runReadOnlyTx( { storeNames: [ @@ -279,73 +357,7 @@ export async function selectPayCoins( ], }, async (tx) => { - const materialAvSel = await internalSelectPayCoins(wex, tx, req, false); - - if (!materialAvSel) { - const prospectiveAvSel = await internalSelectPayCoins( - wex, - tx, - req, - true, - ); - - if (prospectiveAvSel) { - const prospectiveCoins: SelectedProspectiveCoin[] = []; - for (const avKey of Object.keys(prospectiveAvSel.sel)) { - const mySel = prospectiveAvSel.sel[avKey]; - for (const contrib of mySel.contributions) { - prospectiveCoins.push({ - denomPubHash: mySel.denomPubHash, - contribution: Amounts.stringify(contrib), - exchangeBaseUrl: mySel.exchangeBaseUrl, - }); - } - } - return { - type: "prospective", - result: { - prospectiveCoins, - customerDepositFees: Amounts.stringify( - prospectiveAvSel.tally.customerDepositFees, - ), - customerWireFees: Amounts.stringify( - prospectiveAvSel.tally.customerWireFees, - ), - }, - } satisfies SelectPayCoinsResult; - } - - return { - type: "failure", - insufficientBalanceDetails: await reportInsufficientBalanceDetails( - wex, - tx, - { - restrictExchanges: req.restrictExchanges, - instructedAmount: req.contractTermsAmount, - requiredMinimumAge: req.requiredMinimumAge, - wireMethod: req.restrictWireMethod, - depositPaytoUri: req.depositPaytoUri, - }, - ), - } satisfies SelectPayCoinsResult; - } - - const coinSel = await assembleSelectPayCoinsSuccessResult( - tx, - materialAvSel.sel, - materialAvSel.coinRes, - materialAvSel.tally, - ); - - if (logger.shouldLogTrace()) { - logger.trace(`coin selection: ${j2s(coinSel)}`); - } - - return { - type: "success", - coinSel, - }; + return selectPayCoinsInTx(wex, tx, req); }, ); } @@ -910,7 +922,10 @@ async function selectPayCandidates( coinAvail.exchangeBaseUrl, coinAvail.denomPubHash, ]); - checkDbInvariant(!!denom, `denomination of a coin is missing hash: ${coinAvail.denomPubHash}`); + checkDbInvariant( + !!denom, + `denomination of a coin is missing hash: ${coinAvail.denomPubHash}`, + ); if (denom.isRevoked) { logger.trace("denom is revoked"); continue; @@ -1131,17 +1146,127 @@ async function internalSelectPeerCoins( }; } -export async function selectPeerCoins( +export async function selectPeerCoinsInTx( wex: WalletExecutionContext, + tx: WalletDbReadOnlyTransaction< + [ + "exchanges", + "contractTerms", + "coins", + "coinAvailability", + "denominations", + "refreshGroups", + "exchangeDetails", + ] + >, req: PeerCoinSelectionRequest, ): Promise<SelectPeerCoinsResult> { const instructedAmount = req.instructedAmount; if (Amounts.isZero(instructedAmount)) { // Other parts of the code assume that we have at least // one coin to spend. - throw new Error("amount of zero not allowed"); + throw new Error("peer-to-peer payment with amount of zero not supported"); } + const exchanges = await tx.exchanges.iter().toArray(); + const currency = Amounts.currencyOf(instructedAmount); + for (const exch of exchanges) { + if (exch.detailsPointer?.currency !== currency) { + continue; + } + const exchWire = await getExchangeWireDetailsInTx(tx, exch.baseUrl); + if (!exchWire) { + continue; + } + const globalFees = getGlobalFees(exchWire); + if (!globalFees) { + continue; + } + + const avRes = await internalSelectPeerCoins(wex, tx, req, exchWire, false); + + if (!avRes) { + // Try to see if we can do a prospective selection + const prospectiveAvRes = await internalSelectPeerCoins( + wex, + tx, + req, + exchWire, + true, + ); + if (prospectiveAvRes) { + const prospectiveCoins: SelectedProspectiveCoin[] = []; + for (const avKey of Object.keys(prospectiveAvRes.sel)) { + const mySel = prospectiveAvRes.sel[avKey]; + for (const contrib of mySel.contributions) { + prospectiveCoins.push({ + denomPubHash: mySel.denomPubHash, + contribution: Amounts.stringify(contrib), + exchangeBaseUrl: mySel.exchangeBaseUrl, + }); + } + } + const maxExpirationDate = await computeCoinSelMaxExpirationDate( + wex, + tx, + prospectiveAvRes.sel, + ); + return { + type: "prospective", + result: { + prospectiveCoins, + depositFees: prospectiveAvRes.tally.customerDepositFees, + exchangeBaseUrl: exch.baseUrl, + maxExpirationDate, + }, + }; + } + } else if (avRes) { + const r = await assembleSelectPayCoinsSuccessResult( + tx, + avRes.sel, + avRes.resCoins, + avRes.tally, + ); + + const maxExpirationDate = await computeCoinSelMaxExpirationDate( + wex, + tx, + avRes.sel, + ); + + return { + type: "success", + result: { + coins: r.coins, + depositFees: Amounts.parseOrThrow(r.customerDepositFees), + exchangeBaseUrl: exch.baseUrl, + maxExpirationDate, + }, + }; + } + } + const insufficientBalanceDetails = await reportInsufficientBalanceDetails( + wex, + tx, + { + restrictExchanges: undefined, + instructedAmount: req.instructedAmount, + requiredMinimumAge: undefined, + wireMethod: undefined, + depositPaytoUri: undefined, + }, + ); + return { + type: "failure", + insufficientBalanceDetails, + }; +} + +export async function selectPeerCoins( + wex: WalletExecutionContext, + req: PeerCoinSelectionRequest, +): Promise<SelectPeerCoinsResult> { return await wex.db.runReadWriteTx( { storeNames: [ @@ -1155,105 +1280,7 @@ export async function selectPeerCoins( ], }, async (tx): Promise<SelectPeerCoinsResult> => { - const exchanges = await tx.exchanges.iter().toArray(); - const currency = Amounts.currencyOf(instructedAmount); - for (const exch of exchanges) { - if (exch.detailsPointer?.currency !== currency) { - continue; - } - const exchWire = await getExchangeWireDetailsInTx(tx, exch.baseUrl); - if (!exchWire) { - continue; - } - const globalFees = getGlobalFees(exchWire); - if (!globalFees) { - continue; - } - - const avRes = await internalSelectPeerCoins( - wex, - tx, - req, - exchWire, - false, - ); - - if (!avRes) { - // Try to see if we can do a prospective selection - const prospectiveAvRes = await internalSelectPeerCoins( - wex, - tx, - req, - exchWire, - true, - ); - if (prospectiveAvRes) { - const prospectiveCoins: SelectedProspectiveCoin[] = []; - for (const avKey of Object.keys(prospectiveAvRes.sel)) { - const mySel = prospectiveAvRes.sel[avKey]; - for (const contrib of mySel.contributions) { - prospectiveCoins.push({ - denomPubHash: mySel.denomPubHash, - contribution: Amounts.stringify(contrib), - exchangeBaseUrl: mySel.exchangeBaseUrl, - }); - } - } - const maxExpirationDate = await computeCoinSelMaxExpirationDate( - wex, - tx, - prospectiveAvRes.sel, - ); - return { - type: "prospective", - result: { - prospectiveCoins, - depositFees: prospectiveAvRes.tally.customerDepositFees, - exchangeBaseUrl: exch.baseUrl, - maxExpirationDate, - }, - }; - } - } else if (avRes) { - const r = await assembleSelectPayCoinsSuccessResult( - tx, - avRes.sel, - avRes.resCoins, - avRes.tally, - ); - - const maxExpirationDate = await computeCoinSelMaxExpirationDate( - wex, - tx, - avRes.sel, - ); - - return { - type: "success", - result: { - coins: r.coins, - depositFees: Amounts.parseOrThrow(r.customerDepositFees), - exchangeBaseUrl: exch.baseUrl, - maxExpirationDate, - }, - }; - } - } - const insufficientBalanceDetails = await reportInsufficientBalanceDetails( - wex, - tx, - { - restrictExchanges: undefined, - instructedAmount: req.instructedAmount, - requiredMinimumAge: undefined, - wireMethod: undefined, - depositPaytoUri: undefined, - }, - ); - return { - type: "failure", - insufficientBalanceDetails, - }; + return selectPeerCoinsInTx(wex, tx, req); }, ); } diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index 5c381eea7..0ce838fd2 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -912,6 +912,27 @@ export interface CoinRecord { ageCommitmentProof: AgeCommitmentProof | undefined; } +export type HistoryItem = + | { + type: "withdraw"; + transactionId: TransactionIdStr; + } + | { type: "spend"; transactionId: TransactionIdStr; amount: AmountString } + | { type: "refresh"; transactionId: TransactionIdStr; amount: AmountString } + | { type: "recoup"; transactionId: TransactionIdStr; amount: AmountString } + | { type: "refund"; transactionId: TransactionIdStr; amount: AmountString }; + +/** + * History event for a coin from the wallet's perspective. + */ +export interface CoinHistoryRecord { + coinPub: string; + + timestamp: DbPreciseTimestamp; + + item: HistoryItem; +} + /** * Coin allocation, i.e. what a coin has been used for. */ @@ -2423,6 +2444,11 @@ export const WalletStoresV1 = { }), }, ), + coinHistory: describeStoreV2({ + storeName: "coinHistory", + recordCodec: passthroughCodec<CoinHistoryRecord>(), + keyPath: ["coinPub", "timestamp"], + }), coins: describeStore( "coins", describeContents<CoinRecord>({ diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts index 2004c12cb..23b52ac5c 100644 --- a/packages/taler-wallet-core/src/deposits.ts +++ b/packages/taler-wallet-core/src/deposits.ts @@ -72,7 +72,7 @@ import { stringToBytes, } from "@gnu-taler/taler-util"; import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; -import { selectPayCoins } from "./coinSelection.js"; +import { selectPayCoins, selectPayCoinsInTx } from "./coinSelection.js"; import { PendingTaskType, TaskIdStr, @@ -979,40 +979,12 @@ async function processDepositGroupPendingDeposit( if (!depositGroup.payCoinSelection) { logger.info("missing coin selection for deposit group, selecting now"); - // FIXME: Consider doing the coin selection inside the txn - const payCoinSel = await selectPayCoins(wex, { - restrictExchanges: { - auditors: [], - exchanges: contractData.allowedExchanges, - }, - restrictWireMethod: contractData.wireMethod, - contractTermsAmount: Amounts.parseOrThrow(contractData.amount), - depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), - prevPayCoins: [], - }); - - switch (payCoinSel.type) { - case "success": - logger.info("coin selection success"); - break; - case "failure": - logger.info("coin selection failure"); - throw TalerError.fromDetail( - TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE, - { - insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails, - }, - ); - case "prospective": - logger.info("coin selection prospective"); - throw Error("insufficient balance (waiting on pending refresh)"); - default: - assertUnreachable(payCoinSel); - } const transitionDone = await wex.db.runReadWriteTx( { storeNames: [ + "exchanges", + "exchangeDetails", "depositGroups", "coins", "coinAvailability", @@ -1029,6 +1001,37 @@ async function processDepositGroupPendingDeposit( if (dg.statusPerCoin) { return false; } + const payCoinSel = await selectPayCoinsInTx(wex, tx, { + restrictExchanges: { + auditors: [], + exchanges: contractData.allowedExchanges, + }, + restrictWireMethod: contractData.wireMethod, + contractTermsAmount: Amounts.parseOrThrow(contractData.amount), + depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), + prevPayCoins: [], + }); + + switch (payCoinSel.type) { + case "success": + logger.info("coin selection success"); + break; + case "failure": + logger.info("coin selection failure"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE, + { + insufficientBalanceDetails: + payCoinSel.insufficientBalanceDetails, + }, + ); + case "prospective": + logger.info("coin selection prospective"); + throw Error("insufficient balance (waiting on pending refresh)"); + default: + assertUnreachable(payCoinSel); + } + dg.payCoinSelection = { coinContributions: payCoinSel.coinSel.coins.map( (x) => x.contribution, diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts index ee154252f..993b12dd1 100644 --- a/packages/taler-wallet-core/src/pay-merchant.ts +++ b/packages/taler-wallet-core/src/pay-merchant.ts @@ -101,7 +101,11 @@ import { readUnexpectedResponseDetails, throwUnexpectedRequestError, } from "@gnu-taler/taler-util/http"; -import { PreviousPayCoins, selectPayCoins } from "./coinSelection.js"; +import { + PreviousPayCoins, + selectPayCoins, + selectPayCoinsInTx, +} from "./coinSelection.js"; import { constructTaskIdentifier, PendingTaskType, @@ -472,33 +476,42 @@ export async function getTotalPaymentCost( return wex.db.runReadOnlyTx( { storeNames: ["coins", "denominations"] }, async (tx) => { - const costs: AmountJson[] = []; - for (let i = 0; i < pcs.length; i++) { - const denom = await tx.denominations.get([ - pcs[i].exchangeBaseUrl, - pcs[i].denomPubHash, - ]); - if (!denom) { - throw Error( - "can't calculate payment cost, denomination for coin not found", - ); - } - const amountLeft = Amounts.sub(denom.value, pcs[i].contribution).amount; - const refreshCost = await getTotalRefreshCost( - wex, - tx, - DenominationRecord.toDenomInfo(denom), - amountLeft, - ); - costs.push(Amounts.parseOrThrow(pcs[i].contribution)); - costs.push(refreshCost); - } - const zero = Amounts.zeroOfCurrency(currency); - return Amounts.sum([zero, ...costs]).amount; + return getTotalPaymentCostInTx(wex, tx, currency, pcs); }, ); } +export async function getTotalPaymentCostInTx( + wex: WalletExecutionContext, + tx: WalletDbReadOnlyTransaction<["coins", "denominations"]>, + currency: string, + pcs: SelectedProspectiveCoin[], +): Promise<AmountJson> { + const costs: AmountJson[] = []; + for (let i = 0; i < pcs.length; i++) { + const denom = await tx.denominations.get([ + pcs[i].exchangeBaseUrl, + pcs[i].denomPubHash, + ]); + if (!denom) { + throw Error( + "can't calculate payment cost, denomination for coin not found", + ); + } + const amountLeft = Amounts.sub(denom.value, pcs[i].contribution).amount; + const refreshCost = await getTotalRefreshCost( + wex, + tx, + DenominationRecord.toDenomInfo(denom), + amountLeft, + ); + costs.push(Amounts.parseOrThrow(pcs[i].contribution)); + costs.push(refreshCost); + } + const zero = Amounts.zeroOfCurrency(currency); + return Amounts.sum([zero, ...costs]).amount; +} + async function failProposalPermanently( wex: WalletExecutionContext, proposalId: string, @@ -533,13 +546,10 @@ function getPayRequestTimeout(purchase: PurchaseRecord): Duration { ); } -/** - * Return the proposal download data for a purchase, throw if not available. - */ -export async function expectProposalDownload( +export async function expectProposalDownloadInTx( wex: WalletExecutionContext, + tx: WalletDbReadOnlyTransaction<["contractTerms"]>, p: PurchaseRecord, - parentTx?: WalletDbReadOnlyTransaction<["contractTerms"]>, ): Promise<{ contractData: WalletContractData; contractTermsRaw: any; @@ -549,31 +559,35 @@ export async function expectProposalDownload( } const download = p.download; - async function getFromTransaction( - tx: Exclude<typeof parentTx, undefined>, - ): Promise<ReturnType<typeof expectProposalDownload>> { - 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, - }; + 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, + }; +} - if (parentTx) { - return getFromTransaction(parentTx); - } +/** + * Return the proposal download data for a purchase, throw if not available. + */ +export async function expectProposalDownload( + wex: WalletExecutionContext, + p: PurchaseRecord, +): Promise<{ + contractData: WalletContractData; + contractTermsRaw: any; +}> { return await wex.db.runReadOnlyTx( { storeNames: ["contractTerms"] }, - getFromTransaction, + async (tx) => { + return expectProposalDownloadInTx(wex, tx, p); + }, ); } @@ -1148,8 +1162,6 @@ async function handleInsufficientFunds( throw new TalerProtocolViolationError(); } - const { contractData } = await expectProposalDownload(wex, proposal); - const prevPayCoins: PreviousPayCoins = []; const payInfo = proposal.payInfo; @@ -1162,49 +1174,14 @@ async function handleInsufficientFunds( return; } - await wex.db.runReadOnlyTx( - { storeNames: ["coins", "denominations"] }, - async (tx) => { - for (let i = 0; i < payCoinSelection.coinPubs.length; i++) { - const coinPub = payCoinSelection.coinPubs[i]; - const contrib = payCoinSelection.coinContributions[i]; - prevPayCoins.push({ - coinPub, - contribution: Amounts.parseOrThrow(contrib), - }); - } - }, - ); - - const res = await selectPayCoins(wex, { - restrictExchanges: { - auditors: [], - exchanges: contractData.allowedExchanges, - }, - restrictWireMethod: contractData.wireMethod, - contractTermsAmount: Amounts.parseOrThrow(contractData.amount), - depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), - prevPayCoins, - requiredMinimumAge: contractData.minimumAge, - }); - - switch (res.type) { - case "failure": - logger.trace("insufficient funds for coin re-selection"); - return; - case "prospective": - return; - case "success": - break; - default: - assertUnreachable(res); - } - - logger.trace("re-selected coins"); + // FIXME: Above code should go into the transaction. await wex.db.runReadWriteTx( { storeNames: [ + "contractTerms", + "exchanges", + "exchangeDetails", "purchases", "coins", "coinAvailability", @@ -1222,6 +1199,46 @@ async function handleInsufficientFunds( if (!payInfo) { return; } + + const { contractData } = await expectProposalDownloadInTx( + wex, + tx, + proposal, + ); + + for (let i = 0; i < payCoinSelection.coinPubs.length; i++) { + const coinPub = payCoinSelection.coinPubs[i]; + const contrib = payCoinSelection.coinContributions[i]; + prevPayCoins.push({ + coinPub, + contribution: Amounts.parseOrThrow(contrib), + }); + } + + const res = await selectPayCoinsInTx(wex, tx, { + restrictExchanges: { + auditors: [], + exchanges: contractData.allowedExchanges, + }, + restrictWireMethod: contractData.wireMethod, + contractTermsAmount: Amounts.parseOrThrow(contractData.amount), + depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), + prevPayCoins, + requiredMinimumAge: contractData.minimumAge, + }); + + switch (res.type) { + case "failure": + logger.trace("insufficient funds for coin re-selection"); + return; + case "prospective": + return; + case "success": + break; + default: + assertUnreachable(res); + } + // Convert to DB format payInfo.payCoinSelection = { coinContributions: res.coinSel.coins.map((x) => x.contribution), @@ -1936,44 +1953,6 @@ export async function confirmPay( const currency = Amounts.currencyOf(contractData.amount); - const selectCoinsResult = await selectPayCoins(wex, { - restrictExchanges: { - auditors: [], - exchanges: contractData.allowedExchanges, - }, - restrictWireMethod: contractData.wireMethod, - contractTermsAmount: Amounts.parseOrThrow(contractData.amount), - depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), - prevPayCoins: [], - requiredMinimumAge: contractData.minimumAge, - forcedSelection: forcedCoinSel, - }); - - let coins: SelectedProspectiveCoin[] | undefined = undefined; - - switch (selectCoinsResult.type) { - case "failure": { - // Should not happen, since checkPay should be called first - // FIXME: Actually, this should be handled gracefully, - // and the status should be stored in the DB. - logger.warn("not confirming payment, insufficient coins"); - throw Error("insufficient balance"); - } - case "prospective": { - coins = selectCoinsResult.result.prospectiveCoins; - break; - } - case "success": - coins = selectCoinsResult.coinSel.coins; - break; - default: - assertUnreachable(selectCoinsResult); - } - - logger.trace("coin selection result", selectCoinsResult); - - const payCostInfo = await getTotalPaymentCost(wex, currency, coins); - let sessionId: string | undefined; if (sessionIdOverride) { sessionId = sessionIdOverride; @@ -1988,6 +1967,8 @@ export async function confirmPay( const transitionInfo = await wex.db.runReadWriteTx( { storeNames: [ + "exchanges", + "exchangeDetails", "purchases", "coins", "refreshGroups", @@ -2001,6 +1982,50 @@ export async function confirmPay( if (!p) { return; } + + const selectCoinsResult = await selectPayCoinsInTx(wex, tx, { + restrictExchanges: { + auditors: [], + exchanges: contractData.allowedExchanges, + }, + restrictWireMethod: contractData.wireMethod, + contractTermsAmount: Amounts.parseOrThrow(contractData.amount), + depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), + prevPayCoins: [], + requiredMinimumAge: contractData.minimumAge, + forcedSelection: forcedCoinSel, + }); + + let coins: SelectedProspectiveCoin[] | undefined = undefined; + + switch (selectCoinsResult.type) { + case "failure": { + // Should not happen, since checkPay should be called first + // FIXME: Actually, this should be handled gracefully, + // and the status should be stored in the DB. + logger.warn("not confirming payment, insufficient coins"); + throw Error("insufficient balance"); + } + case "prospective": { + coins = selectCoinsResult.result.prospectiveCoins; + break; + } + case "success": + coins = selectCoinsResult.coinSel.coins; + break; + default: + assertUnreachable(selectCoinsResult); + } + + logger.trace("coin selection result", selectCoinsResult); + + const payCostInfo = await getTotalPaymentCostInTx( + wex, + tx, + currency, + coins, + ); + const oldTxState = computePayMerchantTransactionState(p); switch (p.purchaseStatus) { case PurchaseStatus.DialogShared: @@ -2036,7 +2061,6 @@ export async function confirmPay( refreshReason: RefreshReason.PayMerchant, }); } - break; case PurchaseStatus.Done: case PurchaseStatus.PendingPaying: diff --git a/packages/taler-wallet-core/src/pay-peer-common.ts b/packages/taler-wallet-core/src/pay-peer-common.ts index a1729ced7..636dd4156 100644 --- a/packages/taler-wallet-core/src/pay-peer-common.ts +++ b/packages/taler-wallet-core/src/pay-peer-common.ts @@ -31,7 +31,11 @@ import { codecOptional, } from "@gnu-taler/taler-util"; import { SpendCoinDetails } from "./crypto/cryptoImplementation.js"; -import { DbPeerPushPaymentCoinSelection, ReserveRecord } from "./db.js"; +import { + DbPeerPushPaymentCoinSelection, + ReserveRecord, + WalletDbReadOnlyTransaction, +} from "./db.js"; import { getTotalRefreshCost } from "./refresh.js"; import { WalletExecutionContext, getDenomInfo } from "./wallet.js"; @@ -74,6 +78,38 @@ export async function queryCoinInfosForSelection( return infos; } +export async function getTotalPeerPaymentCostInTx( + wex: WalletExecutionContext, + tx: WalletDbReadOnlyTransaction<["coins", "denominations"]>, + pcs: SelectedProspectiveCoin[], +): Promise<AmountJson> { + const costs: AmountJson[] = []; + for (let i = 0; i < pcs.length; i++) { + const denomInfo = await getDenomInfo( + wex, + tx, + pcs[i].exchangeBaseUrl, + pcs[i].denomPubHash, + ); + if (!denomInfo) { + throw Error( + "can't calculate payment cost, denomination for coin not found", + ); + } + const amountLeft = Amounts.sub(denomInfo.value, pcs[i].contribution).amount; + const refreshCost = await getTotalRefreshCost( + wex, + tx, + denomInfo, + amountLeft, + ); + costs.push(Amounts.parseOrThrow(pcs[i].contribution)); + costs.push(refreshCost); + } + const zero = Amounts.zeroOfAmount(pcs[0].contribution); + return Amounts.sum([zero, ...costs]).amount; +} + export async function getTotalPeerPaymentCost( wex: WalletExecutionContext, pcs: SelectedProspectiveCoin[], @@ -81,34 +117,7 @@ export async function getTotalPeerPaymentCost( return wex.db.runReadOnlyTx( { storeNames: ["coins", "denominations"] }, async (tx) => { - const costs: AmountJson[] = []; - for (let i = 0; i < pcs.length; i++) { - const denomInfo = await getDenomInfo( - wex, - tx, - pcs[i].exchangeBaseUrl, - pcs[i].denomPubHash, - ); - if (!denomInfo) { - throw Error( - "can't calculate payment cost, denomination for coin not found", - ); - } - const amountLeft = Amounts.sub( - denomInfo.value, - pcs[i].contribution, - ).amount; - const refreshCost = await getTotalRefreshCost( - wex, - tx, - denomInfo, - amountLeft, - ); - costs.push(Amounts.parseOrThrow(pcs[i].contribution)); - costs.push(refreshCost); - } - const zero = Amounts.zeroOfAmount(pcs[0].contribution); - return Amounts.sum([zero, ...costs]).amount; + return getTotalPeerPaymentCostInTx(wex, tx, pcs); }, ); } @@ -143,7 +152,10 @@ export async function getMergeReserveInfo( checkDbInvariant(!!ex, `no exchange record for ${req.exchangeBaseUrl}`); if (ex.currentMergeReserveRowId != null) { const reserve = await tx.reserves.get(ex.currentMergeReserveRowId); - checkDbInvariant(!!reserve, `reserver ${ex.currentMergeReserveRowId} missing in db`); + checkDbInvariant( + !!reserve, + `reserver ${ex.currentMergeReserveRowId} missing in db`, + ); return reserve; } const reserve: ReserveRecord = { @@ -151,7 +163,10 @@ export async function getMergeReserveInfo( reservePub: newReservePair.pub, }; const insertResp = await tx.reserves.put(reserve); - checkDbInvariant(typeof insertResp.key === "number", `reserve key is not a number`); + checkDbInvariant( + typeof insertResp.key === "number", + `reserve key is not a number`, + ); reserve.rowId = insertResp.key; ex.currentMergeReserveRowId = reserve.rowId; await tx.exchanges.put(ex); diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.ts index f8e6adb3c..9c31de06a 100644 --- a/packages/taler-wallet-core/src/pay-peer-push-debit.ts +++ b/packages/taler-wallet-core/src/pay-peer-push-debit.ts @@ -51,7 +51,11 @@ import { readSuccessResponseJsonOrThrow, readTalerErrorResponse, } from "@gnu-taler/taler-util/http"; -import { PreviousPayCoins, selectPeerCoins } from "./coinSelection.js"; +import { + PreviousPayCoins, + selectPeerCoins, + selectPeerCoinsInTx, +} from "./coinSelection.js"; import { PendingTaskType, TaskIdStr, @@ -73,6 +77,7 @@ import { import { codecForExchangePurseStatus, getTotalPeerPaymentCost, + getTotalPeerPaymentCostInTx, queryCoinInfosForSelection, } from "./pay-peer-common.js"; import { createRefreshGroup, waitRefreshFinal } from "./refresh.js"; @@ -1089,39 +1094,6 @@ export async function initiatePeerPushDebit( const contractKeyPair = await wex.cryptoApi.createEddsaKeypair({}); - const coinSelRes = await selectPeerCoins(wex, { - instructedAmount, - }); - - let coins: SelectedProspectiveCoin[] | undefined = undefined; - - switch (coinSelRes.type) { - case "failure": - throw TalerError.fromDetail( - TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, - { - insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, - }, - ); - case "prospective": - coins = coinSelRes.result.prospectiveCoins; - break; - case "success": - coins = coinSelRes.result.coins; - break; - default: - assertUnreachable(coinSelRes); - } - - const sel = coinSelRes.result; - - logger.info(`selected p2p coins (push):`); - logger.trace(`${j2s(coinSelRes)}`); - - const totalAmount = await getTotalPeerPaymentCost(wex, coins); - - logger.info(`computed total peer payment cost`); - const pursePub = pursePair.pub; const ctx = new PeerPushDebitTransactionContext(wex, pursePub); @@ -1130,10 +1102,11 @@ export async function initiatePeerPushDebit( const contractEncNonce = encodeCrock(getRandomBytes(24)); - const transitionInfo = await wex.db.runReadWriteTx( + const res = await wex.db.runReadWriteTx( { storeNames: [ "exchanges", + "exchangeDetails", "contractTerms", "coins", "coinAvailability", @@ -1144,6 +1117,33 @@ export async function initiatePeerPushDebit( ], }, async (tx) => { + const coinSelRes = await selectPeerCoinsInTx(wex, tx, { + instructedAmount, + }); + + let coins: SelectedProspectiveCoin[] | undefined = undefined; + + switch (coinSelRes.type) { + case "failure": + throw TalerError.fromDetail( + TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, + { + insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, + }, + ); + case "prospective": + coins = coinSelRes.result.prospectiveCoins; + break; + case "success": + coins = coinSelRes.result.coins; + break; + default: + assertUnreachable(coinSelRes); + } + + const sel = coinSelRes.result; + + const totalAmount = await getTotalPeerPaymentCostInTx(wex, tx, coins); const ppi: PeerPushDebitRecord = { amount: Amounts.stringify(instructedAmount), contractPriv: contractKeyPair.priv, @@ -1191,12 +1191,15 @@ export async function initiatePeerPushDebit( const newTxState = computePeerPushDebitTransactionState(ppi); return { - oldTxState: { major: TransactionMajorState.None }, - newTxState, + transitionInfo: { + oldTxState: { major: TransactionMajorState.None }, + newTxState, + }, + exchangeBaseUrl: coinSelRes.result.exchangeBaseUrl, }; }, ); - notifyTransition(wex, transactionId, transitionInfo); + notifyTransition(wex, transactionId, res.transitionInfo); wex.ws.notify({ type: NotificationType.BalanceChange, hintTransactionId: transactionId, @@ -1208,7 +1211,7 @@ export async function initiatePeerPushDebit( contractPriv: contractKeyPair.priv, mergePriv: mergePair.priv, pursePub: pursePair.pub, - exchangeBaseUrl: coinSelRes.result.exchangeBaseUrl, + exchangeBaseUrl: res.exchangeBaseUrl, transactionId: constructTransactionIdentifier({ tag: TransactionType.PeerPushDebit, pursePub: pursePair.pub, diff --git a/packages/taler-wallet-core/src/transactions.ts b/packages/taler-wallet-core/src/transactions.ts index 7782d09ba..7f766f1b0 100644 --- a/packages/taler-wallet-core/src/transactions.ts +++ b/packages/taler-wallet-core/src/transactions.ts @@ -99,7 +99,7 @@ import { computePayMerchantTransactionActions, computePayMerchantTransactionState, computeRefundTransactionState, - expectProposalDownload, + expectProposalDownloadInTx, extractContractData, PayMerchantTransactionContext, RefundTransactionContext, @@ -306,7 +306,7 @@ export async function getTransactionById( async (tx) => { const purchase = await tx.purchases.get(proposalId); if (!purchase) throw Error("not found"); - const download = await expectProposalDownload(wex, purchase, tx); + const download = await expectProposalDownloadInTx(wex, tx, purchase); const contractData = download.contractData; const payOpId = TaskIdentifiers.forPay(purchase); const payRetryRecord = await tx.operationRetries.get(payOpId); @@ -744,7 +744,10 @@ function buildTransactionForBankIntegratedWithdraw( ? undefined : Amounts.currencyOf(wg.instructedAmount); const currency = wg.wgInfo.bankInfo.currency ?? instructedCurrency; - checkDbInvariant(currency !== undefined, "wg uninitialized (missing currency)"); + checkDbInvariant( + currency !== undefined, + "wg uninitialized (missing currency)", + ); const txState = computeWithdrawalTransactionStatus(wg); const zero = Amounts.stringify(Amounts.zeroOfCurrency(currency)); |