aboutsummaryrefslogtreecommitdiff
path: root/packages
diff options
context:
space:
mode:
authorFlorian Dold <florian@dold.me>2023-06-19 12:02:43 +0200
committerFlorian Dold <florian@dold.me>2023-06-19 12:02:43 +0200
commitbcff03949b40d0d37069bdb7af941061e367a093 (patch)
tree2c39d704d1400a35443df95eb84dc99df8094c39 /packages
parented01d407e7e224960337614385676dcf1ae6ca8d (diff)
downloadwallet-core-bcff03949b40d0d37069bdb7af941061e367a093.tar.xz
wallet-core: implement coin selection repair for p2p payments
Diffstat (limited to 'packages')
-rw-r--r--packages/taler-wallet-core/src/operations/pay-peer-common.ts12
-rw-r--r--packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts253
-rw-r--r--packages/taler-wallet-core/src/operations/pay-peer-push-debit.ts104
3 files changed, 283 insertions, 86 deletions
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<OperationAttemptResult> {
+ 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<OperationAttemptResult> {
+ 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: