taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit e3ebd2afdb4dd8617de38c16e762c619df34a278
parent 5813d225627958049143956bb90a2170622737d5
Author: Florian Dold <florian@dold.me>
Date:   Fri,  1 Aug 2025 16:50:29 +0200

-refactor

Diffstat:
Mpackages/taler-wallet-core/src/pay-merchant.ts | 324+++++++++++++++++++++++++++++++++++++++++--------------------------------------
1 file changed, 168 insertions(+), 156 deletions(-)

diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -33,6 +33,7 @@ import { AmountString, assertUnreachable, checkDbInvariant, + checkLogicInvariant, CheckPayTemplateReponse, CheckPayTemplateRequest, ChoiceSelectionDetail, @@ -75,6 +76,7 @@ import { parsePayTemplateUri, parsePayUri, parseTalerUri, + PayCoinSelection, PaymentInsufficientBalanceDetails, PayWalletData, PreparePayResult, @@ -159,6 +161,7 @@ import { timestampProtocolToDb, TokenRecord, WalletDbAllStoresReadOnlyTransaction, + WalletDbAllStoresReadWriteTransaction, WalletDbReadOnlyTransaction, WalletDbReadWriteTransaction, WalletDbStoresArr, @@ -950,6 +953,21 @@ function getPayRequestTimeout(purchase: PurchaseRecord): Duration { ); } +export async function expectProposalDownloadByIdInTx( + wex: WalletExecutionContext, + tx: WalletDbReadOnlyTransaction<["contractTerms", "purchases"]>, + proposalId: string, +): Promise<{ + contractData: WalletContractData; + contractTermsRaw: any; +}> { + const rec = await tx.purchases.get(proposalId); + if (!rec) { + throw Error("purchase record not found"); + } + return await expectProposalDownloadInTx(wex, tx, rec); +} + export async function expectProposalDownloadInTx( wex: WalletExecutionContext, tx: WalletDbReadOnlyTransaction<["contractTerms"]>, @@ -981,7 +999,7 @@ export async function expectProposalDownloadInTx( /** * Return the proposal download data for a purchase, throw if not available. */ -export async function expectProposalDownload( +async function expectProposalDownload( wex: WalletExecutionContext, p: PurchaseRecord, ): Promise<{ @@ -1612,6 +1630,140 @@ async function storePayReplaySuccess( }); } +function setCoinSel(rec: PurchaseRecord, coinSel: PayCoinSelection): void { + checkLogicInvariant(!!rec.payInfo); + rec.payInfo.payCoinSelection = { + coinContributions: coinSel.coins.map((x) => x.contribution), + coinPubs: coinSel.coins.map((x) => x.coinPub), + }; + rec.payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(32)); + rec.exchanges = [...new Set(coinSel.coins.map((x) => x.exchangeBaseUrl))]; + rec.exchanges.sort(); +} + +async function reselectCoinsTx( + tx: WalletDbAllStoresReadWriteTransaction, + ctx: PayMerchantTransactionContext, +): Promise<void> { + const p = await tx.purchases.get(ctx.proposalId); + if (!p) { + return; + } + if (!p.payInfo) { + return; + } + + const { contractData } = await expectProposalDownloadByIdInTx( + ctx.wex, + tx, + ctx.proposalId, + ); + + const { available, amountRaw, maxFee } = ContractTermsUtil.extractAmounts( + contractData, + p.choiceIndex, + ); + if (!available) { + throw Error("choice index not specified for contract v1"); + } + + const prevPayCoins: PreviousPayCoins = []; + const prevTokensPubs: string[] = []; + + const payCoinSelection = p.payInfo.payCoinSelection; + const payTokenSelection = p.payInfo.payTokenSelection; + + if (payCoinSelection) { + 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(ctx.wex, tx, { + restrictExchanges: { + auditors: [], + exchanges: contractData.exchanges.map((ex) => ({ + exchangeBaseUrl: ex.url, + exchangePub: ex.master_pub, + })), + }, + restrictWireMethod: contractData.wire_method, + contractTermsAmount: Amounts.parseOrThrow(amountRaw), + depositFeeLimit: Amounts.parseOrThrow(maxFee), + prevPayCoins, + requiredMinimumAge: contractData.minimum_age, + }); + + switch (res.type) { + case "failure": + logger.trace("insufficient funds for coin re-selection"); + return; + case "prospective": + return; + case "success": + break; + default: + assertUnreachable(res); + } + setCoinSel(p, res.coinSel); + } + + if (payTokenSelection) { + prevTokensPubs.push(...payTokenSelection.tokenPubs); + + if (p.choiceIndex === undefined) throw Error("assertion failed"); + + if (contractData.version !== MerchantContractVersion.V1) + throw Error("assertion failed"); + + const res = await selectPayTokensInTx(tx, { + proposalId: p.proposalId, + choiceIndex: p.choiceIndex, + contractTerms: contractData, + }); + + switch (res.type) { + case "failure": + logger.trace("insufficient tokens for token re-selection"); + return; + break; + case "success": + break; + default: + assertUnreachable(res); + } + + p.payInfo.payTokenSelection = { + tokenPubs: res.tokens.map((t) => t.tokenUsePub), + }; + } + + await tx.purchases.put(p); + await ctx.updateTransactionMeta(tx); + + if (p.payInfo.payCoinSelection) { + await spendCoins(ctx.wex, tx, { + transactionId: ctx.transactionId, + coinPubs: p.payInfo.payCoinSelection.coinPubs, + contributions: p.payInfo.payCoinSelection.coinContributions.map((x) => + Amounts.parseOrThrow(x), + ), + refreshReason: RefreshReason.PayMerchant, + }); + } + + if (p.payInfo.payTokenSelection) { + await spendTokens(tx, { + transactionId: ctx.transactionId, + tokenPubs: p.payInfo.payTokenSelection.tokenPubs, + }); + } +} + /** * Handle a 409 Conflict or 400 Bad Request response from the merchant. * @@ -1687,143 +1839,15 @@ async function handleInsufficientFunds( throw Error(`unsupported error code: ${err.code}`); } - const prevPayCoins: PreviousPayCoins = []; - const prevTokensPubs: string[] = []; - - const payInfo = proposal.payInfo; - if (!payInfo) { + if (!proposal.payInfo) { return TaskRunResult.backoff(); } - const payCoinSelection = payInfo.payCoinSelection; - const payTokenSelection = payInfo.payTokenSelection; - // FIXME: Above code should go into the transaction. // TODO: also do token re-selection. await wex.db.runAllStoresReadWriteTx({}, async (tx) => { - const p = await tx.purchases.get(proposalId); - if (!p) { - return; - } - const payInfo = p.payInfo; - if (!payInfo) { - return; - } - - const { contractData } = await expectProposalDownloadInTx( - wex, - tx, - proposal, - ); - - const { available, amountRaw, maxFee } = ContractTermsUtil.extractAmounts( - contractData, - p.choiceIndex, - ); - if (!available) { - throw Error("choice index not specified for contract v1"); - } - - if (payCoinSelection) { - 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.exchanges.map((ex) => ({ - exchangeBaseUrl: ex.url, - exchangePub: ex.master_pub, - })), - }, - restrictWireMethod: contractData.wire_method, - contractTermsAmount: Amounts.parseOrThrow(amountRaw), - depositFeeLimit: Amounts.parseOrThrow(maxFee), - prevPayCoins, - requiredMinimumAge: contractData.minimum_age, - }); - - 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), - coinPubs: res.coinSel.coins.map((x) => x.coinPub), - }; - payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(32)); - p.exchanges = [ - ...new Set(res.coinSel.coins.map((x) => x.exchangeBaseUrl)), - ]; - p.exchanges.sort(); - } - - if (payTokenSelection) { - prevTokensPubs.push(...payTokenSelection.tokenPubs); - - if (p.choiceIndex === undefined) throw Error("assertion failed"); - - if (contractData.version !== MerchantContractVersion.V1) - throw Error("assertion failed"); - - const res = await selectPayTokensInTx(tx, { - proposalId: p.proposalId, - choiceIndex: p.choiceIndex, - contractTerms: contractData, - }); - - switch (res.type) { - case "failure": - logger.trace("insufficient tokens for token re-selection"); - return; - break; - case "success": - break; - default: - assertUnreachable(res); - } - - payInfo.payTokenSelection = { - tokenPubs: res.tokens.map((t) => t.tokenUsePub), - }; - } - - await tx.purchases.put(p); - await ctx.updateTransactionMeta(tx); - - if (payInfo.payCoinSelection) { - await spendCoins(wex, tx, { - transactionId: ctx.transactionId, - coinPubs: payInfo.payCoinSelection.coinPubs, - contributions: payInfo.payCoinSelection.coinContributions.map((x) => - Amounts.parseOrThrow(x), - ), - refreshReason: RefreshReason.PayMerchant, - }); - } - - if (payInfo.payTokenSelection) { - await spendTokens(tx, { - transactionId: ctx.transactionId, - tokenPubs: payInfo.payTokenSelection.tokenPubs, - }); - } + await reselectCoinsTx(tx, ctx); }); wex.ws.notify({ @@ -2683,19 +2707,19 @@ async function calculateDefaultChoice( const choice = contractTerms.choices[i]; const details = choiceDetails[i]; if ( - Amounts.isZero(choice.amount) - && choice.inputs.length === 1 - && choice.outputs.length === 1 - && !details.tokenDetails?.tokensUnexpected - && choice.inputs[0].type === MerchantContractInputType.Token - && choice.outputs[0].type === MerchantContractOutputType.Token - && (choice.inputs[0].count ?? 1) === 1 - && (choice.outputs[0].count ?? 1) === 1 - && choice.inputs[0].token_family_slug === - choice.inputs[0].token_family_slug + Amounts.isZero(choice.amount) && + choice.inputs.length === 1 && + choice.outputs.length === 1 && + !details.tokenDetails?.tokensUnexpected && + choice.inputs[0].type === MerchantContractInputType.Token && + choice.outputs[0].type === MerchantContractOutputType.Token && + (choice.inputs[0].count ?? 1) === 1 && + (choice.outputs[0].count ?? 1) === 1 && + choice.inputs[0].token_family_slug === + choice.inputs[0].token_family_slug ) { - automaticExecution = details.status === - ChoiceSelectionDetailType.PaymentPossible; + automaticExecution = + details.status === ChoiceSelectionDetailType.PaymentPossible; automaticExecutableIndex = i; break; } @@ -2913,19 +2937,7 @@ export async function confirmPay( }; } if (selectCoinsResult.type === "success") { - p.payInfo.payCoinSelection = { - coinContributions: selectCoinsResult.coinSel.coins.map( - (x) => x.contribution, - ), - coinPubs: selectCoinsResult.coinSel.coins.map((x) => x.coinPub), - }; - p.payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(16)); - p.exchanges = [ - ...new Set( - selectCoinsResult.coinSel.coins.map((x) => x.exchangeBaseUrl), - ), - ]; - p.exchanges.sort(); + setCoinSel(p, selectCoinsResult.coinSel); } p.lastSessionId = sessionId; p.timestampAccept = timestampPreciseToDb(TalerPreciseTimestamp.now());