taler-typescript-core

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

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:
Mpackages/taler-wallet-core/src/pay-peer-pull-debit.ts | 253++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mpackages/taler-wallet-core/src/pay-peer-push-debit.ts | 25+++++++++++++++++++++----
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, });