wallet-core: implement coin selection repair for p2p payments
This commit is contained in:
parent
ed01d407e7
commit
bcff03949b
@ -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(
|
||||
|
@ -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,7 +202,36 @@ async function processPeerPullDebitPendingDeposit(
|
||||
method: "POST",
|
||||
body: depositPayload,
|
||||
});
|
||||
if (httpResp.status === HttpStatusCode.Gone) {
|
||||
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,
|
||||
@ -168,31 +281,19 @@ async function processPeerPullDebitPendingDeposit(
|
||||
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");
|
||||
break;
|
||||
}
|
||||
if (pi.status !== PeerPullDebitRecordStatus.PendingDeposit) {
|
||||
return;
|
||||
case HttpStatusCode.Conflict: {
|
||||
return handlePurseCreationConflict(ws, peerPullInc, httpResp);
|
||||
}
|
||||
default: {
|
||||
const errResp = await readTalerErrorResponse(httpResp);
|
||||
return {
|
||||
type: OperationAttemptResultType.Error,
|
||||
errorDetail: errResp,
|
||||
};
|
||||
}
|
||||
const oldTxState = computePeerPullDebitTransactionState(pi);
|
||||
pi.status = PeerPullDebitRecordStatus.DonePaid;
|
||||
const newTxState = computePeerPullDebitTransactionState(pi);
|
||||
await tx.peerPullPaymentIncoming.put(pi);
|
||||
return { oldTxState, newTxState };
|
||||
});
|
||||
notifyTransition(ws, transactionId, transitionInfo);
|
||||
}
|
||||
|
||||
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,
|
||||
|
@ -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:
|
||||
|
Loading…
Reference in New Issue
Block a user