commit 170bfe8078a7bfe23b4f97f6c66efe66ddb348bc
parent 468574f6a744916a895d3afe834b3df41b6189af
Author: Florian Dold <florian@dold.me>
Date: Thu, 22 May 2025 01:09:50 +0200
wallet-core: fix handling of scopes in p2p payments (should fix #9998)
Diffstat:
2 files changed, 181 insertions(+), 97 deletions(-)
diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts
@@ -35,6 +35,7 @@ import {
PreparePeerPullDebitResponse,
PurseConflict,
RefreshReason,
+ ScopeType,
SelectedProspectiveCoin,
TalerError,
TalerErrorCode,
@@ -96,7 +97,7 @@ import {
isUnsuccessfulTransaction,
parseTransactionIdentifier,
} from "./transactions.js";
-import { walletExchangeClient, WalletExecutionContext } from "./wallet.js";
+import { WalletExecutionContext, walletExchangeClient } from "./wallet.js";
const logger = new Logger("pay-peer-pull-debit.ts");
@@ -109,7 +110,7 @@ export class PeerPullDebitTransactionContext implements TransactionContext {
constructor(
public wex: WalletExecutionContext,
- public peerPullDebitId: string
+ public peerPullDebitId: string,
) {
this.transactionId = constructTransactionIdentifier({
tag: TransactionType.PeerPullDebit,
@@ -122,8 +123,8 @@ export class PeerPullDebitTransactionContext implements TransactionContext {
}
readonly store = "peerPullDebit";
- readonly recordId = this.peerPullDebitId
- readonly recordState = computePeerPullDebitTransactionState
+ readonly recordId = this.peerPullDebitId;
+ readonly recordState = computePeerPullDebitTransactionState;
readonly recordMeta = (rec: PeerPullPaymentIncomingRecord) => ({
transactionId: this.transactionId,
status: rec.status,
@@ -131,7 +132,9 @@ export class PeerPullDebitTransactionContext implements TransactionContext {
currency: Amounts.currencyOf(rec.amount),
exchanges: [rec.exchangeBaseUrl],
});
- updateTransactionMeta = (tx: WalletDbReadWriteTransaction<["peerPullDebit", "transactionsMeta"]>) => recordUpdateMeta(this, tx)
+ updateTransactionMeta = (
+ tx: WalletDbReadWriteTransaction<["peerPullDebit", "transactionsMeta"]>,
+ ) => recordUpdateMeta(this, tx);
/**
* Get the full transaction details for the transaction.
@@ -186,11 +189,9 @@ export class PeerPullDebitTransactionContext implements TransactionContext {
}
async deleteTransactionInTx(
- tx: WalletDbReadWriteTransaction<
- ["peerPullDebit", "transactionsMeta"]
- >,
+ tx: WalletDbReadWriteTransaction<["peerPullDebit", "transactionsMeta"]>,
): Promise<{ notifs: WalletNotification[] }> {
- return recordDelete(this, tx)
+ return recordDelete(this, tx);
}
async suspendTransaction(): Promise<void> {
@@ -212,7 +213,7 @@ export class PeerPullDebitTransactionContext implements TransactionContext {
default:
assertUnreachable(rec.status);
}
- })
+ });
this.wex.taskScheduler.stopShepherdTask(this.taskId);
}
@@ -256,7 +257,8 @@ export class PeerPullDebitTransactionContext implements TransactionContext {
}
async abortTransaction(reason?: TalerErrorDetail): Promise<void> {
- await recordTransition(this,
+ await recordTransition(
+ this,
{
extraStores: [
"coinAvailability",
@@ -264,7 +266,7 @@ export class PeerPullDebitTransactionContext implements TransactionContext {
"coins",
"denominations",
"refreshGroups",
- "refreshSessions"
+ "refreshSessions",
],
},
async (pi, tx) => {
@@ -322,6 +324,8 @@ async function handlePurseCreationConflict(
const brokenCoinPub = conflict.coin_pub;
logger.trace(`excluded broken coin pub=${brokenCoinPub}`);
const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount);
+ const currency = instructedAmount.currency;
+ const exchangeBaseUrl = peerPullInc.exchangeBaseUrl;
const sel = peerPullInc.coinSel;
checkDbInvariant(
@@ -343,6 +347,11 @@ async function handlePurseCreationConflict(
const coinSelRes = await selectPeerCoins(ctx.wex, {
instructedAmount,
repair,
+ restrictScope: {
+ type: ScopeType.Exchange,
+ currency,
+ url: exchangeBaseUrl,
+ },
});
switch (coinSelRes.type) {
@@ -376,10 +385,10 @@ async function handlePurseCreationConflict(
contributions: sel.coins.map((x) => x.contribution),
totalCost: Amounts.stringify(totalAmount),
};
- return TransitionResultType.Transition
+ return TransitionResultType.Transition;
}
default:
- return TransitionResultType.Stay
+ return TransitionResultType.Stay;
}
});
return TaskRunResult.progress();
@@ -390,26 +399,37 @@ async function processPeerPullDebitDialogProposed(
pullIni: PeerPullPaymentIncomingRecord,
): Promise<TaskRunResult> {
const ctx = new PeerPullDebitTransactionContext(wex, pullIni.peerPullDebitId);
- const exchangeClient = walletExchangeClient(pullIni.exchangeBaseUrl, wex)
- const resp = await exchangeClient.getPurseStatusAtDeposit(pullIni.pursePub, true);
+ const exchangeClient = walletExchangeClient(pullIni.exchangeBaseUrl, wex);
+ const resp = await exchangeClient.getPurseStatusAtDeposit(
+ pullIni.pursePub,
+ true,
+ );
switch (resp.case) {
case "ok":
break;
case HttpStatusCode.Gone:
// Exchange says that purse doesn't exist anymore => expired!
- await recordTransitionStatus(ctx, PeerPullDebitRecordStatus.DialogProposed, PeerPullDebitRecordStatus.Aborted);
+ await recordTransitionStatus(
+ ctx,
+ PeerPullDebitRecordStatus.DialogProposed,
+ PeerPullDebitRecordStatus.Aborted,
+ );
return TaskRunResult.finished();
case HttpStatusCode.NotFound:
- await ctx.failTransaction(resp.detail)
+ await ctx.failTransaction(resp.detail);
return TaskRunResult.finished();
default:
- assertUnreachable(resp)
+ assertUnreachable(resp);
}
if (isPurseDeposited(resp.body)) {
logger.info("purse completed by another wallet");
- await recordTransitionStatus(ctx, PeerPullDebitRecordStatus.DialogProposed, PeerPullDebitRecordStatus.Aborted);
+ await recordTransitionStatus(
+ ctx,
+ PeerPullDebitRecordStatus.DialogProposed,
+ PeerPullDebitRecordStatus.Aborted,
+ );
return TaskRunResult.finished();
}
@@ -426,11 +446,20 @@ async function processPeerPullDebitPendingDeposit(
);
const { pursePub, coinSel } = peerPullInc;
+ const exchangeBaseUrl = peerPullInc.exchangeBaseUrl;
+
+ // This can happen when there was a prospective coin selection.
if (coinSel == null) {
const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount);
+ const currency = instructedAmount.currency;
const coinSelRes = await selectPeerCoins(wex, {
instructedAmount,
+ restrictScope: {
+ type: ScopeType.Exchange,
+ currency,
+ url: exchangeBaseUrl,
+ },
});
if (logger.shouldLogTrace()) {
logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
@@ -447,6 +476,8 @@ async function processPeerPullDebitPendingDeposit(
},
);
case "prospective":
+ // Coin selection is *still* only prospective!
+ // FIXME: Really report this as error?
throw Error("insufficient balance (locked behind refresh)");
case "success":
coins = coinSelRes.result.coins;
@@ -458,7 +489,8 @@ async function processPeerPullDebitPendingDeposit(
const totalAmount = await getTotalPeerPaymentCost(wex, coins);
// FIXME: Missing notification here!
- const info = await recordTransition(ctx,
+ const info = await recordTransition(
+ ctx,
{
extraStores: [
"coinAvailability",
@@ -471,7 +503,10 @@ async function processPeerPullDebitPendingDeposit(
],
},
async (rec, tx) => {
- if (rec.status !== PeerPullDebitRecordStatus.PendingDeposit || rec.coinSel != null) {
+ if (
+ rec.status !== PeerPullDebitRecordStatus.PendingDeposit ||
+ rec.coinSel != null
+ ) {
return TransitionResultType.Stay;
}
await spendCoins(wex, tx, {
@@ -496,7 +531,7 @@ async function processPeerPullDebitPendingDeposit(
return TaskRunResult.backoff();
}
}
- const exchangeClient = walletExchangeClient(peerPullInc.exchangeBaseUrl, wex)
+ const exchangeClient = walletExchangeClient(peerPullInc.exchangeBaseUrl, wex);
// FIXME: We could skip batches that we've already submitted.
@@ -526,7 +561,10 @@ async function processPeerPullDebitPendingDeposit(
if (logger.shouldLogTrace()) {
logger.trace(`purse deposit payload: ${j2s(depositPayload)}`);
}
- const resp = await exchangeClient.depositIntoPurse(pursePub, depositPayload);
+ const resp = await exchangeClient.depositIntoPurse(
+ pursePub,
+ depositPayload,
+ );
switch (resp.case) {
case "ok":
continue;
@@ -543,14 +581,18 @@ async function processPeerPullDebitPendingDeposit(
return handlePurseCreationConflict(ctx, peerPullInc, resp.body);
case HttpStatusCode.Forbidden:
case HttpStatusCode.NotFound:
- await ctx.failTransaction(resp.detail)
+ await ctx.failTransaction(resp.detail);
return TaskRunResult.finished();
default:
- assertUnreachable(resp)
+ assertUnreachable(resp);
}
}
// All batches succeeded, we can transition!
- await recordTransitionStatus(ctx, PeerPullDebitRecordStatus.PendingDeposit, PeerPullDebitRecordStatus.Done);
+ await recordTransitionStatus(
+ ctx,
+ PeerPullDebitRecordStatus.PendingDeposit,
+ PeerPullDebitRecordStatus.Done,
+ );
return TaskRunResult.finished();
}
@@ -562,28 +604,32 @@ async function processPeerPullDebitAbortingRefresh(
const abortRefreshGroupId = peerPullInc.abortRefreshGroupId;
checkLogicInvariant(!!abortRefreshGroupId);
const ctx = new PeerPullDebitTransactionContext(wex, peerPullDebitId);
- await recordTransition(ctx, { extraStores: ["refreshGroups"] }, async (rec, tx) => {
- const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
- if (refreshGroup == null) {
- // Maybe it got manually deleted? Means that we should
- // just go into failed.
- logger.warn("no aborting refresh group found for deposit group");
- rec.status = PeerPullDebitRecordStatus.Failed;
- return TransitionResultType.Transition
- } else {
- switch (refreshGroup.operationStatus) {
- case RefreshOperationStatus.Finished:
- rec.status = PeerPullDebitRecordStatus.Aborted;
- return TransitionResultType.Transition
- case RefreshOperationStatus.Failed: {
- rec.status = PeerPullDebitRecordStatus.Failed;
- return TransitionResultType.Transition
+ await recordTransition(
+ ctx,
+ { extraStores: ["refreshGroups"] },
+ async (rec, tx) => {
+ const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
+ if (refreshGroup == null) {
+ // Maybe it got manually deleted? Means that we should
+ // just go into failed.
+ logger.warn("no aborting refresh group found for deposit group");
+ rec.status = PeerPullDebitRecordStatus.Failed;
+ return TransitionResultType.Transition;
+ } else {
+ switch (refreshGroup.operationStatus) {
+ case RefreshOperationStatus.Finished:
+ rec.status = PeerPullDebitRecordStatus.Aborted;
+ return TransitionResultType.Transition;
+ case RefreshOperationStatus.Failed: {
+ rec.status = PeerPullDebitRecordStatus.Failed;
+ return TransitionResultType.Transition;
+ }
+ default:
+ return TransitionResultType.Stay;
}
- default:
- return TransitionResultType.Stay
}
- }
- })
+ },
+ );
// FIXME: Shouldn't this be finished in some cases?!
return TaskRunResult.backoff();
}
@@ -598,7 +644,7 @@ export async function processPeerPullDebit(
const peerPullInc = await wex.db.runReadOnlyTx(
{ storeNames: ["peerPullDebit"] },
- async (tx) => tx.peerPullDebit.get(peerPullDebitId)
+ async (tx) => tx.peerPullDebit.get(peerPullDebitId),
);
if (!peerPullInc) {
throw Error("peer pull debit not found");
@@ -606,7 +652,7 @@ export async function processPeerPullDebit(
switch (peerPullInc.status) {
case PeerPullDebitRecordStatus.DialogProposed:
- return processPeerPullDebitDialogProposed(wex, peerPullInc)
+ return processPeerPullDebitDialogProposed(wex, peerPullInc);
case PeerPullDebitRecordStatus.PendingDeposit:
return processPeerPullDebitPendingDeposit(wex, peerPullInc);
case PeerPullDebitRecordStatus.AbortingRefresh:
@@ -633,7 +679,7 @@ export async function confirmPeerPullDebit(
const peerPullInc = await wex.db.runReadOnlyTx(
{ storeNames: ["peerPullDebit"] },
- async (tx) => tx.peerPullDebit.get(parsed.peerPullDebitId)
+ async (tx) => tx.peerPullDebit.get(parsed.peerPullDebitId),
);
if (peerPullInc == null) {
@@ -644,10 +690,18 @@ export async function confirmPeerPullDebit(
const ctx = new PeerPullDebitTransactionContext(wex, parsed.peerPullDebitId);
+ const exchangeBaseUrl = peerPullInc.exchangeBaseUrl;
+
const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount);
+ const currency = instructedAmount.currency;
const coinSelRes = await selectPeerCoins(wex, {
instructedAmount,
+ restrictScope: {
+ type: ScopeType.Exchange,
+ currency,
+ url: exchangeBaseUrl,
+ },
});
if (logger.shouldLogTrace()) {
logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
@@ -675,38 +729,42 @@ export async function confirmPeerPullDebit(
const totalAmount = await getTotalPeerPaymentCost(wex, coins);
- await recordTransition(ctx, {
- extraStores: [
- "coinAvailability",
- "coinHistory",
- "coins",
- "denominations",
- "exchanges",
- "refreshGroups",
- "refreshSessions",
- ]
- }, async (rec, tx) => {
- if (rec.status !== PeerPullDebitRecordStatus.DialogProposed) {
- return TransitionResultType.Stay;
- }
- if (coinSelRes.type == "success") {
- await spendCoins(wex, tx, {
- transactionId: ctx.transactionId,
- coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
- contributions: coinSelRes.result.coins.map((x) =>
- Amounts.parseOrThrow(x.contribution),
- ),
- refreshReason: RefreshReason.PayPeerPull,
- });
- rec.coinSel = {
- coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
- contributions: coinSelRes.result.coins.map((x) => x.contribution),
- totalCost: Amounts.stringify(totalAmount),
- };
- }
- rec.status = PeerPullDebitRecordStatus.PendingDeposit;
- return TransitionResultType.Transition
- })
+ await recordTransition(
+ ctx,
+ {
+ extraStores: [
+ "coinAvailability",
+ "coinHistory",
+ "coins",
+ "denominations",
+ "exchanges",
+ "refreshGroups",
+ "refreshSessions",
+ ],
+ },
+ async (rec, tx) => {
+ if (rec.status !== PeerPullDebitRecordStatus.DialogProposed) {
+ return TransitionResultType.Stay;
+ }
+ if (coinSelRes.type == "success") {
+ await spendCoins(wex, tx, {
+ transactionId: ctx.transactionId,
+ coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
+ contributions: coinSelRes.result.coins.map((x) =>
+ Amounts.parseOrThrow(x.contribution),
+ ),
+ refreshReason: RefreshReason.PayPeerPull,
+ });
+ rec.coinSel = {
+ coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
+ contributions: coinSelRes.result.coins.map((x) => x.contribution),
+ totalCost: Amounts.stringify(totalAmount),
+ };
+ }
+ rec.status = PeerPullDebitRecordStatus.PendingDeposit;
+ return TransitionResultType.Transition;
+ },
+ );
wex.taskScheduler.stopShepherdTask(ctx.taskId);
wex.taskScheduler.startShepherdTask(ctx.taskId);
@@ -788,7 +846,7 @@ export async function preparePeerPullDebit(
const exchangeBaseUrl = uri.exchangeBaseUrl;
const contractPriv = uri.contractPriv;
const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv)));
- const exchangeClient = walletExchangeClient(exchangeBaseUrl, wex)
+ const exchangeClient = walletExchangeClient(exchangeBaseUrl, wex);
const contractResp = await exchangeClient.getContract(contractPub);
switch (contractResp.case) {
@@ -796,9 +854,9 @@ export async function preparePeerPullDebit(
break;
case HttpStatusCode.NotFound:
// FIXME: appropriated error code
- throw Error("unknown P2P contract")
+ throw Error("unknown P2P contract");
default:
- assertUnreachable(contractResp)
+ assertUnreachable(contractResp);
}
const pursePub = contractResp.body.purse_pub;
@@ -819,15 +877,15 @@ export async function preparePeerPullDebit(
);
case HttpStatusCode.NotFound:
// FIXME: appropriated error code
- throw Error("unknown peer pull debit")
+ throw Error("unknown peer pull debit");
default:
- assertUnreachable(resp)
+ assertUnreachable(resp);
}
if (isPurseDeposited(resp.body)) {
logger.info("purse completed by another wallet");
// FIXME: appropriated error code
- throw Error("peer pull debit already completed")
+ throw Error("peer pull debit already completed");
}
const peerPullDebitId = encodeCrock(getRandomBytes(32));
@@ -848,9 +906,15 @@ export async function preparePeerPullDebit(
// FIXME: Why don't we compute the totalCost here?!
const instructedAmount = Amounts.parseOrThrow(contractTerms.amount);
+ const currency = Amounts.currencyOf(instructedAmount);
const coinSelRes = await selectPeerCoins(wex, {
instructedAmount,
+ restrictScope: {
+ type: ScopeType.Exchange,
+ currency,
+ url: exchangeBaseUrl,
+ },
});
if (logger.shouldLogTrace()) {
logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
@@ -877,11 +941,14 @@ export async function preparePeerPullDebit(
}
const totalAmount = await getTotalPeerPaymentCost(wex, coins);
- const currency = Amounts.currencyOf(totalAmount);
const ctx = new PeerPullDebitTransactionContext(wex, peerPullDebitId);
- await recordCreate(ctx,
- { extraStores: ["contractTerms"], label: "crate-transaction-peer-pull-credit" },
+ await recordCreate(
+ ctx,
+ {
+ extraStores: ["contractTerms"],
+ label: "create-transaction-peer-pull-credit",
+ },
async (tx) => {
await tx.contractTerms.put({
h: contractTermsHash,
@@ -897,10 +964,10 @@ export async function preparePeerPullDebit(
amount: contractTerms.amount,
status: PeerPullDebitRecordStatus.DialogProposed,
totalCostEstimated: Amounts.stringify(totalAmount),
- }
+ };
},
);
- wex.taskScheduler.startShepherdTask(ctx.taskId)
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
const scopeInfo = await wex.db.runAllStoresReadOnlyTx({}, (tx) => {
return getExchangeScopeInfo(tx, exchangeBaseUrl, currency);
diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.ts
@@ -442,7 +442,9 @@ async function handlePurseCreationConflict(
logger.trace(`excluded broken coin pub=${brokenCoinPub}`);
const instructedAmount = Amounts.parseOrThrow(peerPushInitiation.amount);
+ const currency = instructedAmount.currency;
const sel = peerPushInitiation.coinSel;
+ const exchangeBaseUrl = peerPushInitiation.exchangeBaseUrl;
checkDbInvariant(
!!sel,
@@ -460,9 +462,16 @@ async function handlePurseCreationConflict(
}
}
+ // FIXME: We don't handle the case where we would
+ // have sufficient funds at another exchange,
+ // but not at the one selected first. Tricky!
const coinSelRes = await selectPeerCoins(wex, {
- instructedAmount: Amounts.parseOrThrow(peerPushInitiation.amount),
- restrictScope: peerPushInitiation.restrictScope,
+ instructedAmount,
+ restrictScope: {
+ type: ScopeType.Exchange,
+ currency,
+ url: exchangeBaseUrl,
+ },
repair,
feesCoveredByCounterparty: false,
});
@@ -521,10 +530,17 @@ async function processPeerPushDebitCreateReserve(
);
}
+ const instructedAmount = Amounts.parseOrThrow(peerPushInitiation.amount);
+ const currency = instructedAmount.currency;
+
if (!peerPushInitiation.coinSel) {
const coinSelRes = await selectPeerCoins(wex, {
- instructedAmount: Amounts.parseOrThrow(peerPushInitiation.amount),
- restrictScope: peerPushInitiation.restrictScope,
+ instructedAmount,
+ restrictScope: {
+ type: ScopeType.Exchange,
+ currency,
+ url: exchangeBaseUrl,
+ },
feesCoveredByCounterparty: false,
});
@@ -973,6 +989,7 @@ export async function initiatePeerPushDebit(
async (tx) => {
const coinSelRes = await selectPeerCoinsInTx(wex, tx, {
instructedAmount,
+ // Any (single!) exchange that is in scope works.
restrictScope: req.restrictScope,
feesCoveredByCounterparty: false,
});