summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFlorian Dold <florian@dold.me>2024-04-04 14:48:29 +0200
committerFlorian Dold <florian@dold.me>2024-04-04 14:48:37 +0200
commitcf31b263ae249979cea8d64ee524cfff4b50f04b (patch)
tree606dc5ceb3991472d5ab94bffffc5f158776ae0c
parentc7e68c254aa93778b8201227d6db1ac57e081e81 (diff)
downloadwallet-core-cf31b263ae249979cea8d64ee524cfff4b50f04b.tar.gz
wallet-core-cf31b263ae249979cea8d64ee524cfff4b50f04b.tar.bz2
wallet-core-cf31b263ae249979cea8d64ee524cfff4b50f04b.zip
wallet-core: allow payments to merchant with coins locked behind refresh
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-blocked-deposit.ts6
-rw-r--r--packages/taler-harness/src/integrationtests/testrunner.ts6
-rw-r--r--packages/taler-wallet-core/src/db.ts10
-rw-r--r--packages/taler-wallet-core/src/pay-merchant.ts193
-rw-r--r--packages/taler-wallet-core/src/testing.ts2
-rw-r--r--packages/taler-wallet-core/src/transactions.ts7
6 files changed, 178 insertions, 46 deletions
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-blocked-deposit.ts b/packages/taler-harness/src/integrationtests/test-wallet-blocked-deposit.ts
index cb9c54f1d..69b721789 100644
--- a/packages/taler-harness/src/integrationtests/test-wallet-blocked-deposit.ts
+++ b/packages/taler-harness/src/integrationtests/test-wallet-blocked-deposit.ts
@@ -49,7 +49,7 @@ const coinCommon = {
/**
* Run test for refreshe after a payment.
*/
-export async function runWalletBlockedDeposit(t: GlobalTestState) {
+export async function runWalletBlockedDepositTest(t: GlobalTestState) {
// Set up test environment
const coinConfigList: CoinConfig[] = [
@@ -114,8 +114,6 @@ export async function runWalletBlockedDeposit(t: GlobalTestState) {
});
console.log(`balance details: ${j2s(balDet)}`);
- // FIXME: Now check deposit/p2p/pay
-
const depositCheckResp = await w1.call(WalletApiOperation.PrepareDeposit, {
amount: "TESTKUDOS:18" as AmountString,
depositPaytoUri: userPayto,
@@ -149,4 +147,4 @@ export async function runWalletBlockedDeposit(t: GlobalTestState) {
await depositTrackCond;
}
-runWalletBlockedDeposit.suites = ["wallet"];
+runWalletBlockedDepositTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts
index 063aefa43..2934e36e3 100644
--- a/packages/taler-harness/src/integrationtests/testrunner.ts
+++ b/packages/taler-harness/src/integrationtests/testrunner.ts
@@ -90,6 +90,8 @@ import { runWalletBackupDoublespendTest } from "./test-wallet-backup-doublespend
import { runWalletBalanceNotificationsTest } from "./test-wallet-balance-notifications.js";
import { runWalletBalanceZeroTest } from "./test-wallet-balance-zero.js";
import { runWalletBalanceTest } from "./test-wallet-balance.js";
+import { runWalletBlockedDepositTest } from "./test-wallet-blocked-deposit.js";
+import { runWalletBlockedPayMerchantTest } from "./test-wallet-blocked-pay-merchant.js";
import { runWalletCliTerminationTest } from "./test-wallet-cli-termination.js";
import { runWalletConfigTest } from "./test-wallet-config.js";
import { runWalletCryptoWorkerTest } from "./test-wallet-cryptoworker.js";
@@ -101,7 +103,6 @@ import { runWalletGenDbTest } from "./test-wallet-gendb.js";
import { runWalletInsufficientBalanceTest } from "./test-wallet-insufficient-balance.js";
import { runWalletNotificationsTest } from "./test-wallet-notifications.js";
import { runWalletObservabilityTest } from "./test-wallet-observability.js";
-import { runWalletBlockedDeposit } from "./test-wallet-blocked-deposit.js";
import { runWalletRefreshTest } from "./test-wallet-refresh.js";
import { runWalletWirefeesTest } from "./test-wallet-wirefees.js";
import { runWallettestingTest } from "./test-wallettesting.js";
@@ -213,7 +214,8 @@ const allTests: TestMainFunction[] = [
runWalletWirefeesTest,
runDenomLostTest,
runWalletDenomExpireTest,
- runWalletBlockedDeposit,
+ runWalletBlockedDepositTest,
+ runWalletBlockedPayMerchantTest,
];
export interface TestRunSpec {
diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts
index 7b9dfa2a2..a675fa8dd 100644
--- a/packages/taler-wallet-core/src/db.ts
+++ b/packages/taler-wallet-core/src/db.ts
@@ -1226,9 +1226,15 @@ export interface DbCoinSelection {
}
export interface PurchasePayInfo {
- payCoinSelection: DbCoinSelection;
+ /**
+ * Undefined if payment is blocked by a pending refund.
+ */
+ payCoinSelection?: DbCoinSelection;
+ /**
+ * Undefined if payment is blocked by a pending refund.
+ */
+ payCoinSelectionUid?: string;
totalPayCost: AmountString;
- payCoinSelectionUid: string;
}
/**
diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts
index 25725052c..a2089d843 100644
--- a/packages/taler-wallet-core/src/pay-merchant.ts
+++ b/packages/taler-wallet-core/src/pay-merchant.ts
@@ -291,7 +291,7 @@ export class PayMerchantTransactionContext implements TransactionContext {
case PurchaseStatus.PendingPaying:
case PurchaseStatus.SuspendedPaying: {
purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund;
- if (purchase.payInfo) {
+ if (purchase.payInfo && purchase.payInfo.payCoinSelection) {
const coinSel = purchase.payInfo.payCoinSelection;
const currency = Amounts.currencyOf(
purchase.payInfo.totalPayCost,
@@ -519,7 +519,7 @@ async function failProposalPermanently(
function getPayRequestTimeout(purchase: PurchaseRecord): Duration {
return Duration.multiply(
{ d_ms: 15000 },
- 1 + (purchase.payInfo?.payCoinSelection.coinPubs.length ?? 0) / 5,
+ 1 + (purchase.payInfo?.payCoinSelection?.coinPubs.length ?? 0) / 5,
);
}
@@ -1130,6 +1130,9 @@ async function handleInsufficientFunds(
}
const payCoinSelection = payInfo.payCoinSelection;
+ if (!payCoinSelection) {
+ return;
+ }
await wex.db.runReadOnlyTx(["coins", "denominations"], async (tx) => {
for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
@@ -1155,9 +1158,16 @@ async function handleInsufficientFunds(
requiredMinimumAge: contractData.minimumAge,
});
- if (res.type !== "success") {
- logger.trace("insufficient funds for coin re-selection");
- return;
+ 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");
@@ -1288,6 +1298,8 @@ async function checkPaymentByProposalId(
restrictWireMethod: contractData.wireMethod,
});
+ let coins: SelectedProspectiveCoin[] | undefined = undefined;
+
switch (res.type) {
case "failure": {
logger.info("not allowing payment, insufficient coins");
@@ -1307,18 +1319,16 @@ async function checkPaymentByProposalId(
};
}
case "prospective":
- throw Error("insufficient balance (waiting on refresh)");
+ coins = res.result.prospectiveCoins;
+ break;
case "success":
+ coins = res.coinSel.coins;
break;
default:
assertUnreachable(res);
}
- const totalCost = await getTotalPaymentCost(
- wex,
- currency,
- res.coinSel.coins,
- );
+ const totalCost = await getTotalPaymentCost(wex, currency, coins);
logger.trace("costInfo", totalCost);
logger.trace("coinsForPayment", res);
@@ -1838,6 +1848,8 @@ export async function confirmPay(
forcedSelection: forcedCoinSel,
});
+ let coins: SelectedProspectiveCoin[] | undefined = undefined;
+
switch (selectCoinsResult.type) {
case "failure": {
// Should not happen, since checkPay should be called first
@@ -1847,9 +1859,11 @@ export async function confirmPay(
throw Error("insufficient balance");
}
case "prospective": {
- throw Error("insufficient balance (waiting on refresh)");
+ coins = selectCoinsResult.result.prospectiveCoins;
+ break;
}
case "success":
+ coins = selectCoinsResult.coinSel.coins;
break;
default:
assertUnreachable(selectCoinsResult);
@@ -1857,12 +1871,7 @@ export async function confirmPay(
logger.trace("coin selection result", selectCoinsResult);
- const coinSelection = selectCoinsResult.coinSel;
- const payCostInfo = await getTotalPaymentCost(
- wex,
- currency,
- coinSelection.coins,
- );
+ const payCostInfo = await getTotalPaymentCost(wex, currency, coins);
let sessionId: string | undefined;
if (sessionIdOverride) {
@@ -1894,29 +1903,37 @@ export async function confirmPay(
case PurchaseStatus.DialogShared:
case PurchaseStatus.DialogProposed:
p.payInfo = {
- payCoinSelection: {
- coinContributions: coinSelection.coins.map((x) => x.contribution),
- coinPubs: coinSelection.coins.map((x) => x.coinPub),
- },
- payCoinSelectionUid: encodeCrock(getRandomBytes(16)),
totalPayCost: Amounts.stringify(payCostInfo),
};
+ 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.lastSessionId = sessionId;
p.timestampAccept = timestampPreciseToDb(TalerPreciseTimestamp.now());
p.purchaseStatus = PurchaseStatus.PendingPaying;
await tx.purchases.put(p);
- await spendCoins(wex, tx, {
- //`txn:proposal:${p.proposalId}`
- allocationId: constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId: proposalId,
- }),
- coinPubs: coinSelection.coins.map((x) => x.coinPub),
- contributions: coinSelection.coins.map((x) =>
- Amounts.parseOrThrow(x.contribution),
- ),
- refreshReason: RefreshReason.PayMerchant,
- });
+ if (p.payInfo.payCoinSelection) {
+ const sel = p.payInfo.payCoinSelection;
+ await spendCoins(wex, tx, {
+ //`txn:proposal:${p.proposalId}`
+ allocationId: constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: proposalId,
+ }),
+ coinPubs: sel.coinPubs,
+ contributions: sel.coinContributions.map((x) =>
+ Amounts.parseOrThrow(x),
+ ),
+ refreshReason: RefreshReason.PayMerchant,
+ });
+ }
+
break;
case PurchaseStatus.Done:
case PurchaseStatus.PendingPaying:
@@ -2030,6 +2047,8 @@ async function processPurchasePay(
}
logger.trace(`processing purchase pay ${proposalId}`);
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+
const sessionId = purchase.lastSessionId;
logger.trace(`paying with session ID ${sessionId}`);
@@ -2078,6 +2097,109 @@ async function processPurchasePay(
}
}
+ const contractData = download.contractData;
+ const currency = Amounts.currencyOf(download.contractData.amount);
+
+ if (!payInfo.payCoinSelection) {
+ const selectCoinsResult = await selectPayCoins(wex, {
+ restrictExchanges: {
+ auditors: [],
+ exchanges: contractData.allowedExchanges,
+ },
+ restrictWireMethod: contractData.wireMethod,
+ contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
+ depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
+ wireFeeAmortization: 1, // FIXME #8653
+ prevPayCoins: [],
+ requiredMinimumAge: contractData.minimumAge,
+ });
+ 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": {
+ throw Error("insufficient balance (pending refresh)");
+ }
+ case "success":
+ break;
+ default:
+ assertUnreachable(selectCoinsResult);
+ }
+
+ logger.trace("coin selection result", selectCoinsResult);
+
+ const payCostInfo = await getTotalPaymentCost(
+ wex,
+ currency,
+ selectCoinsResult.coinSel.coins,
+ );
+
+ const transitionDone = await wex.db.runReadWriteTx(
+ [
+ "purchases",
+ "coins",
+ "refreshGroups",
+ "refreshSessions",
+ "denominations",
+ "coinAvailability",
+ ],
+ async (tx) => {
+ const p = await tx.purchases.get(proposalId);
+ if (!p) {
+ return false;
+ }
+ if (p.payInfo?.payCoinSelection) {
+ return false;
+ }
+ switch (p.purchaseStatus) {
+ case PurchaseStatus.DialogShared:
+ case PurchaseStatus.DialogProposed:
+ p.payInfo = {
+ totalPayCost: Amounts.stringify(payCostInfo),
+ payCoinSelection: {
+ coinContributions: selectCoinsResult.coinSel.coins.map(
+ (x) => x.contribution,
+ ),
+ coinPubs: selectCoinsResult.coinSel.coins.map((x) => x.coinPub),
+ },
+ };
+ p.payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(16));
+ p.purchaseStatus = PurchaseStatus.PendingPaying;
+ await tx.purchases.put(p);
+ const sel = p.payInfo.payCoinSelection;
+ await spendCoins(wex, tx, {
+ //`txn:proposal:${p.proposalId}`
+ allocationId: constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: proposalId,
+ }),
+ coinPubs: selectCoinsResult.coinSel.coins.map((x) => x.coinPub),
+ contributions: selectCoinsResult.coinSel.coins.map((x) =>
+ Amounts.parseOrThrow(x.contribution),
+ ),
+ refreshReason: RefreshReason.PayMerchant,
+ });
+ return true;
+ case PurchaseStatus.Done:
+ case PurchaseStatus.PendingPaying:
+ default:
+ break;
+ }
+ return false;
+ },
+ );
+
+ if (transitionDone) {
+ return TaskRunResult.progress();
+ } else {
+ return TaskRunResult.backoff();
+ }
+ }
+
if (!purchase.merchantPaySig) {
const payUrl = new URL(
`orders/${download.contractData.orderId}/pay`,
@@ -2132,6 +2254,7 @@ async function processPurchasePay(
TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_INSUFFICIENT_FUNDS
) {
// Do this in the background, as it might take some time
+ // FIXME: Why? We're already in a (background) task!
handleInsufficientFunds(wex, proposalId, err).catch(async (e) => {
logger.error("handling insufficient funds failed");
logger.error(`${e.toString()}`);
diff --git a/packages/taler-wallet-core/src/testing.ts b/packages/taler-wallet-core/src/testing.ts
index b192e7b70..32c0765b4 100644
--- a/packages/taler-wallet-core/src/testing.ts
+++ b/packages/taler-wallet-core/src/testing.ts
@@ -912,6 +912,6 @@ export async function testPay(
});
checkLogicInvariant(!!purchase);
return {
- numCoins: purchase.payInfo?.payCoinSelection.coinContributions.length ?? 0,
+ numCoins: purchase.payInfo?.payCoinSelection?.coinContributions.length ?? 0,
};
}
diff --git a/packages/taler-wallet-core/src/transactions.ts b/packages/taler-wallet-core/src/transactions.ts
index 463aa97ba..536f0de4b 100644
--- a/packages/taler-wallet-core/src/transactions.ts
+++ b/packages/taler-wallet-core/src/transactions.ts
@@ -1264,7 +1264,10 @@ export async function getTransactions(
const exchangesInTx: string[] = [];
const p = await tx.purchases.get(refundGroup.proposalId);
- if (!p || !p.payInfo) return; //refund with no payment
+ if (!p || !p.payInfo || !p.payInfo.payCoinSelection) {
+ //refund with no payment
+ return;
+ }
// FIXME: This is very slow, should become obsolete with materialized transactions.
for (const cp of p.payInfo.payCoinSelection.coinPubs) {
@@ -1410,7 +1413,7 @@ export async function getTransactions(
}
const exchangesInTx: string[] = [];
- for (const cp of purchase.payInfo.payCoinSelection.coinPubs) {
+ for (const cp of purchase.payInfo.payCoinSelection?.coinPubs ?? []) {
const c = await tx.coins.get(cp);
if (c?.exchangeBaseUrl) {
exchangesInTx.push(c.exchangeBaseUrl);