commit e3ebd2afdb4dd8617de38c16e762c619df34a278
parent 5813d225627958049143956bb90a2170622737d5
Author: Florian Dold <florian@dold.me>
Date: Fri, 1 Aug 2025 16:50:29 +0200
-refactor
Diffstat:
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());