From bcff03949b40d0d37069bdb7af941061e367a093 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Mon, 19 Jun 2023 12:02:43 +0200 Subject: wallet-core: implement coin selection repair for p2p payments --- .../src/operations/pay-peer-common.ts | 12 +- .../src/operations/pay-peer-pull-debit.ts | 253 ++++++++++++++------- .../src/operations/pay-peer-push-debit.ts | 104 ++++++++- 3 files changed, 283 insertions(+), 86 deletions(-) (limited to 'packages') diff --git a/packages/taler-wallet-core/src/operations/pay-peer-common.ts b/packages/taler-wallet-core/src/operations/pay-peer-common.ts index 72e48cb03..4856fbe36 100644 --- a/packages/taler-wallet-core/src/operations/pay-peer-common.ts +++ b/packages/taler-wallet-core/src/operations/pay-peer-common.ts @@ -158,6 +158,12 @@ export async function queryCoinInfosForSelection( return infos; } +export interface PeerCoinRepair { + exchangeBaseUrl: string; + coinPubs: CoinPublicKeyString[]; + contribs: AmountJson[]; +} + export interface PeerCoinSelectionRequest { instructedAmount: AmountJson; @@ -165,11 +171,7 @@ export interface PeerCoinSelectionRequest { * Instruct the coin selection to repair this coin * selection instead of selecting completely new coins. */ - repair?: { - exchangeBaseUrl: string; - coinPubs: CoinPublicKeyString[]; - contribs: AmountJson[]; - }; + repair?: PeerCoinRepair; } export async function selectPeerCoins( diff --git a/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts index 2be21c68d..280ad567f 100644 --- a/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts +++ b/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts @@ -29,6 +29,7 @@ import { TalerError, TalerErrorCode, TalerPreciseTimestamp, + TalerProtocolViolationError, TransactionAction, TransactionMajorState, TransactionMinorState, @@ -44,7 +45,11 @@ import { j2s, parsePayPullUri, } from "@gnu-taler/taler-util"; -import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; +import { + HttpResponse, + readSuccessResponseJsonOrThrow, + readTalerErrorResponse, +} from "@gnu-taler/taler-util/http"; import { InternalWalletState, PeerPullDebitRecordStatus, @@ -62,6 +67,7 @@ import { } from "../util/retries.js"; import { runOperationWithErrorReporting, spendCoins } from "./common.js"; import { + PeerCoinRepair, codecForExchangePurseStatus, getTotalPeerPaymentCost, queryCoinInfosForSelection, @@ -76,6 +82,84 @@ import { checkLogicInvariant } from "../util/invariants.js"; const logger = new Logger("pay-peer-pull-debit.ts"); +async function handlePurseCreationConflict( + ws: InternalWalletState, + peerPullInc: PeerPullPaymentIncomingRecord, + resp: HttpResponse, +): Promise { + const pursePub = peerPullInc.pursePub; + const errResp = await readTalerErrorResponse(resp); + if (errResp.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS) { + await failPeerPullDebitTransaction(ws, pursePub); + return OperationAttemptResult.finishedEmpty(); + } + + // FIXME: Properly parse! + const brokenCoinPub = (errResp as any).coin_pub; + logger.trace(`excluded broken coin pub=${brokenCoinPub}`); + + if (!brokenCoinPub) { + // FIXME: Details! + throw new TalerProtocolViolationError(); + } + + const instructedAmount = Amounts.parseOrThrow( + peerPullInc.contractTerms.amount, + ); + + const sel = peerPullInc.coinSel; + if (!sel) { + throw Error("invalid state (coin selection expected)"); + } + + const repair: PeerCoinRepair = { + coinPubs: sel.coinPubs, + contribs: sel.contributions.map((x) => Amounts.parseOrThrow(x)), + exchangeBaseUrl: peerPullInc.exchangeBaseUrl, + }; + + const coinSelRes = await selectPeerCoins(ws, { instructedAmount, repair }); + + if (coinSelRes.type == "failure") { + // FIXME: Details! + throw Error( + "insufficient balance to re-select coins to repair double spending", + ); + } + + const totalAmount = await getTotalPeerPaymentCost( + ws, + coinSelRes.result.coins, + ); + + await ws.db + .mktx((x) => [x.peerPullPaymentIncoming]) + .runReadWrite(async (tx) => { + const myPpi = await tx.peerPullPaymentIncoming.get( + peerPullInc.peerPullPaymentIncomingId, + ); + if (!myPpi) { + return; + } + switch (myPpi.status) { + case PeerPullDebitRecordStatus.PendingDeposit: + case PeerPullDebitRecordStatus.SuspendedDeposit: { + const sel = coinSelRes.result; + myPpi.coinSel = { + coinPubs: sel.coins.map((x) => x.coinPub), + contributions: sel.coins.map((x) => x.contribution), + totalCost: Amounts.stringify(totalAmount), + }; + break; + } + default: + return; + } + await tx.peerPullPaymentIncoming.put(myPpi); + }); + return OperationAttemptResult.finishedEmpty(); +} + async function processPeerPullDebitPendingDeposit( ws: InternalWalletState, peerPullInc: PeerPullPaymentIncomingRecord, @@ -118,81 +202,98 @@ async function processPeerPullDebitPendingDeposit( method: "POST", body: depositPayload, }); - if (httpResp.status === HttpStatusCode.Gone) { - const transitionInfo = await ws.db - .mktx((x) => [ - x.peerPullPaymentIncoming, - x.refreshGroups, - x.denominations, - x.coinAvailability, - x.coins, - ]) - .runReadWrite(async (tx) => { - const pi = await tx.peerPullPaymentIncoming.get( - peerPullPaymentIncomingId, - ); - if (!pi) { - throw Error("peer pull payment not found anymore"); - } - if (pi.status !== PeerPullDebitRecordStatus.PendingDeposit) { - return; - } - const oldTxState = computePeerPullDebitTransactionState(pi); - - const currency = Amounts.currencyOf(pi.totalCostEstimated); - const coinPubs: CoinRefreshRequest[] = []; - - if (!pi.coinSel) { - throw Error("invalid db state"); - } - - for (let i = 0; i < pi.coinSel.coinPubs.length; i++) { - coinPubs.push({ - amount: pi.coinSel.contributions[i], - coinPub: pi.coinSel.coinPubs[i], - }); - } - - const refresh = await createRefreshGroup( - ws, - tx, - currency, - coinPubs, - RefreshReason.AbortPeerPushDebit, - ); - - pi.status = PeerPullDebitRecordStatus.AbortingRefresh; - pi.abortRefreshGroupId = refresh.refreshGroupId; - const newTxState = computePeerPullDebitTransactionState(pi); - await tx.peerPullPaymentIncoming.put(pi); - return { oldTxState, newTxState }; - }); - notifyTransition(ws, transactionId, transitionInfo); - } else { - const resp = await readSuccessResponseJsonOrThrow(httpResp, codecForAny()); - logger.trace(`purse deposit response: ${j2s(resp)}`); - - const transitionInfo = await ws.db - .mktx((x) => [x.peerPullPaymentIncoming]) - .runReadWrite(async (tx) => { - const pi = await tx.peerPullPaymentIncoming.get( - peerPullPaymentIncomingId, - ); - if (!pi) { - throw Error("peer pull payment not found anymore"); - } - if (pi.status !== PeerPullDebitRecordStatus.PendingDeposit) { - return; - } - const oldTxState = computePeerPullDebitTransactionState(pi); - pi.status = PeerPullDebitRecordStatus.DonePaid; - const newTxState = computePeerPullDebitTransactionState(pi); - await tx.peerPullPaymentIncoming.put(pi); - return { oldTxState, newTxState }; - }); - notifyTransition(ws, transactionId, transitionInfo); + switch (httpResp.status) { + case HttpStatusCode.Ok: { + const resp = await readSuccessResponseJsonOrThrow( + httpResp, + codecForAny(), + ); + logger.trace(`purse deposit response: ${j2s(resp)}`); + + const transitionInfo = await ws.db + .mktx((x) => [x.peerPullPaymentIncoming]) + .runReadWrite(async (tx) => { + const pi = await tx.peerPullPaymentIncoming.get( + peerPullPaymentIncomingId, + ); + if (!pi) { + throw Error("peer pull payment not found anymore"); + } + if (pi.status !== PeerPullDebitRecordStatus.PendingDeposit) { + return; + } + const oldTxState = computePeerPullDebitTransactionState(pi); + pi.status = PeerPullDebitRecordStatus.DonePaid; + const newTxState = computePeerPullDebitTransactionState(pi); + await tx.peerPullPaymentIncoming.put(pi); + return { oldTxState, newTxState }; + }); + notifyTransition(ws, transactionId, transitionInfo); + break; + } + case HttpStatusCode.Gone: { + const transitionInfo = await ws.db + .mktx((x) => [ + x.peerPullPaymentIncoming, + x.refreshGroups, + x.denominations, + x.coinAvailability, + x.coins, + ]) + .runReadWrite(async (tx) => { + const pi = await tx.peerPullPaymentIncoming.get( + peerPullPaymentIncomingId, + ); + if (!pi) { + throw Error("peer pull payment not found anymore"); + } + if (pi.status !== PeerPullDebitRecordStatus.PendingDeposit) { + return; + } + const oldTxState = computePeerPullDebitTransactionState(pi); + + const currency = Amounts.currencyOf(pi.totalCostEstimated); + const coinPubs: CoinRefreshRequest[] = []; + + if (!pi.coinSel) { + throw Error("invalid db state"); + } + + for (let i = 0; i < pi.coinSel.coinPubs.length; i++) { + coinPubs.push({ + amount: pi.coinSel.contributions[i], + coinPub: pi.coinSel.coinPubs[i], + }); + } + + const refresh = await createRefreshGroup( + ws, + tx, + currency, + coinPubs, + RefreshReason.AbortPeerPushDebit, + ); + + pi.status = PeerPullDebitRecordStatus.AbortingRefresh; + pi.abortRefreshGroupId = refresh.refreshGroupId; + const newTxState = computePeerPullDebitTransactionState(pi); + await tx.peerPullPaymentIncoming.put(pi); + return { oldTxState, newTxState }; + }); + notifyTransition(ws, transactionId, transitionInfo); + break; + } + case HttpStatusCode.Conflict: { + return handlePurseCreationConflict(ws, peerPullInc, httpResp); + } + default: { + const errResp = await readTalerErrorResponse(httpResp); + return { + type: OperationAttemptResultType.Error, + errorDetail: errResp, + }; + } } - return { type: OperationAttemptResultType.Finished, result: undefined, @@ -434,7 +535,7 @@ export async function preparePeerPullDebit( const getPurseUrl = new URL(`purses/${pursePub}/merge`, exchangeBaseUrl); - const purseHttpResp = await ws.http.get(getPurseUrl.href); + const purseHttpResp = await ws.http.fetch(getPurseUrl.href); const purseStatus = await readSuccessResponseJsonOrThrow( purseHttpResp, diff --git a/packages/taler-wallet-core/src/operations/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/operations/pay-peer-push-debit.ts index 2835a1f64..33d317c6f 100644 --- a/packages/taler-wallet-core/src/operations/pay-peer-push-debit.ts +++ b/packages/taler-wallet-core/src/operations/pay-peer-push-debit.ts @@ -28,6 +28,7 @@ import { TalerError, TalerErrorCode, TalerPreciseTimestamp, + TalerProtocolViolationError, TalerUriAction, TransactionAction, TransactionMajorState, @@ -47,8 +48,13 @@ import { getTotalPeerPaymentCost, codecForExchangePurseStatus, queryCoinInfosForSelection, + PeerCoinRepair, } from "./pay-peer-common.js"; -import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; +import { + HttpResponse, + readSuccessResponseJsonOrThrow, + readTalerErrorResponse, +} from "@gnu-taler/taler-util/http"; import { PeerPushPaymentInitiationRecord, PeerPushPaymentInitiationStatus, @@ -97,6 +103,73 @@ export async function checkPeerPushDebit( }; } +async function handlePurseCreationConflict( + ws: InternalWalletState, + peerPushInitiation: PeerPushPaymentInitiationRecord, + resp: HttpResponse, +): Promise { + const pursePub = peerPushInitiation.pursePub; + const errResp = await readTalerErrorResponse(resp); + if (errResp.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS) { + await failPeerPushDebitTransaction(ws, pursePub); + return OperationAttemptResult.finishedEmpty(); + } + + // FIXME: Properly parse! + const brokenCoinPub = (errResp as any).coin_pub; + logger.trace(`excluded broken coin pub=${brokenCoinPub}`); + + if (!brokenCoinPub) { + // FIXME: Details! + throw new TalerProtocolViolationError(); + } + + const instructedAmount = Amounts.parseOrThrow(peerPushInitiation.amount); + + const repair: PeerCoinRepair = { + coinPubs: peerPushInitiation.coinSel.coinPubs, + contribs: peerPushInitiation.coinSel.contributions.map((x) => + Amounts.parseOrThrow(x), + ), + exchangeBaseUrl: peerPushInitiation.exchangeBaseUrl, + }; + + const coinSelRes = await selectPeerCoins(ws, { instructedAmount, repair }); + + if (coinSelRes.type == "failure") { + // FIXME: Details! + throw Error( + "insufficient balance to re-select coins to repair double spending", + ); + } + + await ws.db + .mktx((x) => [x.peerPushPaymentInitiations]) + .runReadWrite(async (tx) => { + const myPpi = await tx.peerPushPaymentInitiations.get( + peerPushInitiation.pursePub, + ); + if (!myPpi) { + return; + } + switch (myPpi.status) { + case PeerPushPaymentInitiationStatus.PendingCreatePurse: + case PeerPushPaymentInitiationStatus.SuspendedCreatePurse: { + const sel = coinSelRes.result; + myPpi.coinSel = { + coinPubs: sel.coins.map((x) => x.coinPub), + contributions: sel.coins.map((x) => x.contribution), + } + break; + } + default: + return; + } + await tx.peerPushPaymentInitiations.put(myPpi); + }); + return OperationAttemptResult.finishedEmpty(); +} + async function processPeerPushDebitCreateReserve( ws: InternalWalletState, peerPushInitiation: PeerPushPaymentInitiationRecord, @@ -175,6 +248,27 @@ async function processPeerPushDebitCreateReserve( logger.info(`resp: ${j2s(resp)}`); + switch (httpResp.status) { + case HttpStatusCode.Ok: + break; + case HttpStatusCode.Forbidden: { + // FIXME: Store this error! + await failPeerPushDebitTransaction(ws, pursePub); + return OperationAttemptResult.finishedEmpty(); + } + case HttpStatusCode.Conflict: { + // Handle double-spending + return handlePurseCreationConflict(ws, peerPushInitiation, resp); + } + default: { + const errResp = await readTalerErrorResponse(resp); + return { + type: OperationAttemptResultType.Error, + errorDetail: errResp, + }; + } + } + if (httpResp.status !== HttpStatusCode.Ok) { // FIXME: do proper error reporting throw Error("got error response from exchange"); @@ -710,17 +804,17 @@ export async function failPeerPushDebitTransaction( switch (pushDebitRec.status) { case PeerPushPaymentInitiationStatus.AbortingRefresh: case PeerPushPaymentInitiationStatus.SuspendedAbortingRefresh: - // FIXME: We also need to abort the refresh group! - newStatus = PeerPushPaymentInitiationStatus.Aborted; + // FIXME: What to do about the refresh group? + newStatus = PeerPushPaymentInitiationStatus.Failed; break; case PeerPushPaymentInitiationStatus.AbortingDeletePurse: case PeerPushPaymentInitiationStatus.SuspendedAbortingDeletePurse: - newStatus = PeerPushPaymentInitiationStatus.Aborted; - break; case PeerPushPaymentInitiationStatus.PendingReady: case PeerPushPaymentInitiationStatus.SuspendedReady: case PeerPushPaymentInitiationStatus.SuspendedCreatePurse: case PeerPushPaymentInitiationStatus.PendingCreatePurse: + newStatus = PeerPushPaymentInitiationStatus.Failed; + break; case PeerPushPaymentInitiationStatus.Done: case PeerPushPaymentInitiationStatus.Aborted: case PeerPushPaymentInitiationStatus.Failed: -- cgit v1.2.3