wallet-core: refresh when aborting payments

This commit is contained in:
Florian Dold 2023-01-11 17:12:08 +01:00
parent 5fc0cb7927
commit 143a4fe4ac
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
12 changed files with 202 additions and 82 deletions

View File

@ -47,6 +47,10 @@ export interface FaultInjectionRequestContext {
requestHeaders: Record<string, string | string[] | undefined>;
requestBody?: Buffer;
dropRequest: boolean;
// These are only used when the request is dropped
substituteResponseBody?: Buffer;
substituteResponseStatusCode?: number;
substituteResponseHeaders?: Record<string, string | string[] | undefined>;
}
export interface FaultInjectionResponseContext {
@ -101,7 +105,18 @@ export class FaultProxy {
}
if (faultReqContext.dropRequest) {
res.destroy();
if (faultReqContext.substituteResponseStatusCode) {
const statusCode = faultReqContext.substituteResponseStatusCode;
res.writeHead(
statusCode,
http.STATUS_CODES[statusCode],
faultReqContext.substituteResponseHeaders,
);
res.write(faultReqContext.substituteResponseBody);
res.end();
} else {
res.destroy();
}
return;
}

View File

@ -97,6 +97,7 @@ import { runAgeRestrictionsMixedMerchantTest } from "./test-age-restrictions-mix
import { runWalletCryptoWorkerTest } from "./test-wallet-cryptoworker.js";
import { runWithdrawalHighTest } from "./test-withdrawal-high.js";
import { runKycTest } from "./test-kyc.js";
import { runPaymentAbortTest } from "./test-payment-abort.js";
/**
* Test runner.
@ -159,6 +160,7 @@ const allTests: TestMainFunction[] = [
runPaymentIdempotencyTest,
runPaymentMultipleTest,
runPaymentTest,
runPaymentAbortTest,
runPaymentTransientTest,
runPaymentZeroTest,
runPayPaidTest,

View File

@ -49,6 +49,15 @@ import {
TransactionIdStr,
} from "./wallet-types.js";
export enum ExtendedStatus {
Pending = "pending",
Done = "done",
Aborting = "aborting",
Aborted = "aborted",
Failed = "failed",
KycRequired = "kyc-required",
}
export interface TransactionsRequest {
/**
* return only transactions in the given currency
@ -80,6 +89,8 @@ export interface TransactionCommon {
// main timestamp of the transaction
timestamp: TalerProtocolTimestamp;
extendedStatus: ExtendedStatus;
// true if the transaction is still pending, false otherwise
// If a transaction is not longer pending, its timestamp will be updated,
// but its transactionId will remain unchanged

View File

@ -538,7 +538,7 @@ export interface WalletDiagnostics {
export interface TalerErrorDetail {
code: TalerErrorCode;
when: string;
when?: string;
hint?: string;
[x: string]: unknown;
}
@ -1553,8 +1553,8 @@ export const codecForAcceptTipRequest = (): Codec<AcceptTipRequest> =>
.property("walletTipId", codecForString())
.build("AcceptTipRequest");
export interface AbortPayRequest {
proposalId: string;
export interface AbortTransactionRequest {
transactionId: string;
/**
* Move the payment immediately into an aborted state.
@ -1563,15 +1563,15 @@ export interface AbortPayRequest {
*
* Defaults to false.
*/
cancelImmediately?: boolean;
forceImmediateAbort?: boolean;
}
export const codecForAbortPayRequest =
(): Codec<AbortPayRequest> =>
buildCodecForObject<AbortPayRequest>()
.property("proposalId", codecForString())
.property("cancelImmediately", codecOptional(codecForBoolean()))
.build("AbortPayRequest");
export const codecForAbortTransaction =
(): Codec<AbortTransactionRequest> =>
buildCodecForObject<AbortTransactionRequest>()
.property("transactionId", codecForString())
.property("forceImmediateAbort", codecOptional(codecForBoolean()))
.build("AbortTransactionRequest");
export interface GetFeeForDepositRequest {
depositPaytoUri: string;

View File

@ -848,6 +848,13 @@ export enum RefreshOperationStatus {
FinishedWithError = 51 /* DORMANT_START + 1 */,
}
/**
* Additional information about the reason of a refresh.
*/
export interface RefreshReasonDetails {
proposalId?: string;
}
/**
* Group of refresh operations. The refreshed coins do not
* have to belong to the same exchange, but must have the same
@ -880,6 +887,11 @@ export interface RefreshGroupRecord {
*/
reason: RefreshReason;
/**
* Extra information depending on the reason.
*/
reasonDetails?: RefreshReasonDetails;
oldCoinPubs: string[];
// FIXME: Should this go into a separate
@ -2006,7 +2018,7 @@ export const WalletStoresV1 = {
),
},
),
exchangeSignkeys: describeStore(
exchangeSignKeys: describeStore(
"exchangeSignKeys",
describeContents<ExchangeSignkeysRecord>({
keyPath: ["exchangeDetailsRowId", "signkeyPub"],

View File

@ -89,7 +89,7 @@ export async function exportBackup(
x.config,
x.exchanges,
x.exchangeDetails,
x.exchangeSignkeys,
x.exchangeSignKeys,
x.coins,
x.contractTerms,
x.denominations,

View File

@ -675,7 +675,7 @@ export async function updateExchangeFromUrlHandler(
x.exchanges,
x.exchangeTos,
x.exchangeDetails,
x.exchangeSignkeys,
x.exchangeSignKeys,
x.denominations,
x.coins,
x.refreshGroups,

View File

@ -125,6 +125,7 @@ import {
} from "../util/retries.js";
import {
makeTransactionId,
runOperationWithErrorReporting,
spendCoins,
storeOperationError,
storeOperationPending,
@ -1135,9 +1136,9 @@ export function selectForced(
export type SelectPayCoinsResult =
| {
type: "failure";
insufficientBalanceDetails: PayMerchantInsufficientBalanceDetails;
}
type: "failure";
insufficientBalanceDetails: PayMerchantInsufficientBalanceDetails;
}
| { type: "success"; coinSel: PayCoinSelection };
/**
@ -1594,7 +1595,12 @@ export async function runPayForConfirmPay(
ws: InternalWalletState,
proposalId: string,
): Promise<ConfirmPayResult> {
const res = await processPurchasePay(ws, proposalId, { forceNow: true });
logger.trace("processing proposal for confirmPay");
const opId = RetryTags.byPaymentProposalId(proposalId);
const res = await runOperationWithErrorReporting(ws, opId, async () => {
return await processPurchasePay(ws, proposalId, { forceNow: true });
});
logger.trace(`processPurchasePay response type ${res.type}`);
switch (res.type) {
case OperationAttemptResultType.Finished: {
const purchase = await ws.db
@ -1623,9 +1629,10 @@ export async function runPayForConfirmPay(
const numRetry = opRetry?.retryInfo.retryCounter ?? 0;
if (
res.errorDetail.code ===
TalerErrorCode.WALLET_PAY_MERCHANT_SERVER_ERROR &&
TalerErrorCode.WALLET_PAY_MERCHANT_SERVER_ERROR &&
numRetry < maxRetry
) {
logger.trace("hiding transient error from caller");
// Pretend the operation is pending instead of reporting
// an error, but only up to maxRetry attempts.
await storeOperationPending(
@ -1638,20 +1645,11 @@ export async function runPayForConfirmPay(
transactionId: makeTransactionId(TransactionType.Payment, proposalId),
};
} else {
// FIXME: allocate error code!
await storeOperationError(
ws,
RetryTags.byPaymentProposalId(proposalId),
res.errorDetail,
);
throw Error("payment failed");
}
}
case OperationAttemptResultType.Pending:
await storeOperationPending(
ws,
`${PendingTaskType.Purchase}:${proposalId}`,
);
logger.trace("reporting pending as confirmPay response");
return {
type: ConfirmPayResultType.Pending,
transactionId: makeTransactionId(TransactionType.Payment, proposalId),
@ -1968,29 +1966,12 @@ export async function processPurchasePay(
result: undefined,
};
}
}
if (resp.status >= 400 && resp.status <= 499) {
const errDetails = await readUnexpectedResponseDetails(resp);
logger.warn(`server returned ${resp.status} response for /pay`);
logger.warn(j2s(errDetails));
await ws.db
.mktx((x) => [x.purchases])
.runReadWrite(async (tx) => {
const purch = await tx.purchases.get(proposalId);
if (!purch) {
return;
}
// FIXME: Should be some "PayPermanentlyFailed" and error info should be stored
purch.purchaseStatus = PurchaseStatus.PaymentAbortFinished;
await tx.purchases.put(purch);
});
throw makePendingOperationFailedError(
errDetails,
TransactionType.Payment,
proposalId,
);
}
if (resp.status >= 400 && resp.status <= 499) {
logger.trace("got generic 4xx from merchant");
const err = await readTalerErrorResponse(resp);
throwUnexpectedRequestError(resp, err);
}
const merchantResp = await readSuccessResponseJsonOrThrow(
@ -2395,16 +2376,18 @@ async function acceptRefunds(
}
}
const refreshCoinsPubs = Object.values(refreshCoinsMap);
logger.info(`refreshCoinMap ${j2s(refreshCoinsMap)}`);
if (refreshCoinsPubs.length > 0) {
await createRefreshGroup(
ws,
tx,
Amounts.currencyOf(refreshCoinsPubs[0].amount),
refreshCoinsPubs,
RefreshReason.Refund,
);
if (reason === RefundReason.AbortRefund) {
const refreshCoinsPubs = Object.values(refreshCoinsMap);
logger.info(`refreshCoinMap ${j2s(refreshCoinsMap)}`);
if (refreshCoinsPubs.length > 0) {
await createRefreshGroup(
ws,
tx,
Amounts.currencyOf(refreshCoinsPubs[0].amount),
refreshCoinsPubs,
RefreshReason.Refund,
);
}
}
// Are we done with querying yet, or do we need to do another round
@ -2808,12 +2791,21 @@ export async function processPurchaseQueryRefund(
return OperationAttemptResult.finishedEmpty();
}
export async function abortFailedPayWithRefund(
export async function abortPay(
ws: InternalWalletState,
proposalId: string,
cancelImmediately?: boolean,
): Promise<void> {
const opId = RetryTags.byPaymentProposalId(proposalId);
await ws.db
.mktx((x) => [x.purchases])
.mktx((x) => [
x.purchases,
x.refreshGroups,
x.denominations,
x.coinAvailability,
x.coins,
x.operationRetries,
])
.runReadWrite(async (tx) => {
const purchase = await tx.purchases.get(proposalId);
if (!purchase) {
@ -2828,10 +2820,30 @@ export async function abortFailedPayWithRefund(
purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund;
}
await tx.purchases.put(purchase);
await tx.operationRetries.delete(opId);
if (purchase.payInfo) {
const coinSel = purchase.payInfo.payCoinSelection;
const currency = Amounts.currencyOf(purchase.payInfo.totalPayCost);
const refreshCoins: CoinRefreshRequest[] = [];
for (let i = 0; i < coinSel.coinPubs.length; i++) {
refreshCoins.push({
amount: coinSel.coinContributions[i],
coinPub: coinSel.coinPubs[i],
});
}
await createRefreshGroup(
ws,
tx,
currency,
refreshCoins,
RefreshReason.AbortPay,
);
}
});
runOperationWithErrorReporting(ws, opId, async () => {
return await processPurchaseQueryRefund(ws, proposalId, {
forceNow: true,
});
processPurchaseQueryRefund(ws, proposalId, {
forceNow: true,
}).catch((e) => {
logger.trace(`error during refund processing after abort pay: ${e}`);
});
}

View File

@ -23,6 +23,7 @@ import {
Amounts,
constructPayPullUri,
constructPayPushUri,
ExtendedStatus,
Logger,
OrderShortInfo,
PaymentStatus,
@ -66,6 +67,7 @@ import {
import { processDepositGroup } from "./deposits.js";
import { getExchangeDetails } from "./exchanges.js";
import {
abortPay,
expectProposalDownload,
extractContractData,
processPurchasePay,
@ -352,6 +354,10 @@ function buildTransactionForPushPaymentDebit(
summary: contractTerms.summary,
},
frozen: false,
extendedStatus:
pi.status != PeerPushPaymentInitiationStatus.PurseCreated
? ExtendedStatus.Pending
: ExtendedStatus.Done,
pending: pi.status != PeerPushPaymentInitiationStatus.PurseCreated,
timestamp: pi.timestampCreated,
talerUri: constructPayPushUri({
@ -377,6 +383,7 @@ function buildTransactionForPullPaymentDebit(
exchangeBaseUrl: pi.exchangeBaseUrl,
frozen: false,
pending: false,
extendedStatus: ExtendedStatus.Done,
info: {
expiration: pi.contractTerms.purse_expiration,
summary: pi.contractTerms.summary,
@ -401,6 +408,7 @@ function buildTransactionForPullPaymentCredit(
amountEffective: Amounts.stringify(wsr.denomsSel.totalCoinValue),
amountRaw: Amounts.stringify(wsr.instructedAmount),
exchangeBaseUrl: wsr.exchangeBaseUrl,
extendedStatus: ExtendedStatus.Done,
pending: !wsr.timestampFinish,
timestamp: wsr.timestampStart,
info: {
@ -435,6 +443,9 @@ function buildTransactionForPushPaymentCredit(
expiration: wsr.wgInfo.contractTerms.purse_expiration,
summary: wsr.wgInfo.contractTerms.summary,
},
extendedStatus: wsr.timestampFinish
? ExtendedStatus.Done
: ExtendedStatus.Pending,
pending: !wsr.timestampFinish,
timestamp: wsr.timestampStart,
transactionId: makeTransactionId(
@ -464,6 +475,9 @@ function buildTransactionForBankIntegratedWithdraw(
bankConfirmationUrl: wsr.wgInfo.bankInfo.confirmUrl,
},
exchangeBaseUrl: wsr.exchangeBaseUrl,
extendedStatus: wsr.timestampFinish
? ExtendedStatus.Done
: ExtendedStatus.Pending,
pending: !wsr.timestampFinish,
timestamp: wsr.timestampStart,
transactionId: makeTransactionId(
@ -504,6 +518,9 @@ function buildTransactionForManualWithdraw(
exchangePaytoUris,
},
exchangeBaseUrl: withdrawalGroup.exchangeBaseUrl,
extendedStatus: withdrawalGroup.timestampFinish
? ExtendedStatus.Done
: ExtendedStatus.Pending,
pending: !withdrawalGroup.timestampFinish,
timestamp: withdrawalGroup.timestampStart,
transactionId: makeTransactionId(
@ -523,6 +540,9 @@ function buildTransactionForDeposit(
type: TransactionType.Deposit,
amountRaw: Amounts.stringify(dg.effectiveDepositAmount),
amountEffective: Amounts.stringify(dg.totalPayCost),
extendedStatus: dg.timestampFinished
? ExtendedStatus.Done
: ExtendedStatus.Pending,
pending: !dg.timestampFinished,
frozen: false,
timestamp: dg.timestampCreated,
@ -547,6 +567,9 @@ function buildTransactionForTip(
type: TransactionType.Tip,
amountEffective: Amounts.stringify(tipRecord.tipAmountEffective),
amountRaw: Amounts.stringify(tipRecord.tipAmountRaw),
extendedStatus: tipRecord.pickedUpTimestamp
? ExtendedStatus.Done
: ExtendedStatus.Pending,
pending: !tipRecord.pickedUpTimestamp,
frozen: false,
timestamp: tipRecord.acceptedTimestamp,
@ -654,6 +677,7 @@ async function buildTransactionForRefund(
purchaseRecord.refundAmountAwaiting === undefined
? undefined
: Amounts.stringify(purchaseRecord.refundAmountAwaiting),
extendedStatus: ExtendedStatus.Done,
pending: false,
frozen: false,
...(ort?.lastError ? { error: ort.lastError } : {}),
@ -710,6 +734,33 @@ async function buildTransactionForPurchase(
checkDbInvariant(!!timestamp);
checkDbInvariant(!!purchaseRecord.payInfo);
let status: ExtendedStatus;
switch (purchaseRecord.purchaseStatus) {
case PurchaseStatus.AbortingWithRefund:
status = ExtendedStatus.Aborting;
break;
case PurchaseStatus.Paid:
case PurchaseStatus.RepurchaseDetected:
status = ExtendedStatus.Done;
break;
case PurchaseStatus.DownloadingProposal:
case PurchaseStatus.QueryingRefund:
case PurchaseStatus.Proposed:
case PurchaseStatus.Paying:
status = ExtendedStatus.Pending;
break;
case PurchaseStatus.ProposalDownloadFailed:
status = ExtendedStatus.Failed;
break;
case PurchaseStatus.PaymentAbortFinished:
status = ExtendedStatus.Aborted;
break;
default:
// FIXME: Should we have some unknown status?
status = ExtendedStatus.Pending;
}
return {
type: TransactionType.Payment,
amountRaw: Amounts.stringify(contractData.amount),
@ -723,6 +774,7 @@ async function buildTransactionForPurchase(
status: purchaseRecord.timestampFirstSuccessfulPay
? PaymentStatus.Paid
: PaymentStatus.Accepted,
extendedStatus: status,
pending: purchaseRecord.purchaseStatus === PurchaseStatus.Paying,
refunds,
timestamp,
@ -1163,3 +1215,19 @@ export async function deleteTransaction(
throw Error(`can't delete a '${unknownTxType}' transaction`);
}
}
export async function abortTransaction(
ws: InternalWalletState,
transactionId: string,
forceImmediateAbort?: boolean,
): Promise<void> {
const { type, args: rest } = parseId("txn", transactionId);
if (type === TransactionType.Payment) {
const proposalId = rest[0];
await abortPay(ws, proposalId, forceImmediateAbort);
} else {
const unknownTxType: any = type;
throw Error(`can't abort a '${unknownTxType}' transaction`);
}
}

View File

@ -660,7 +660,6 @@ export class DbAccess<StoreMap> {
const storeNames: string[] = [];
const accessibleStores: { [x: string]: StoreWithIndexes<any, any, any> } =
{};
for (let i = 0; i < this.db.objectStoreNames.length; i++) {
const sn = this.db.objectStoreNames[i];
const swi = (this.stores as any)[sn] as StoreWithIndexes<any, any, any>;

View File

@ -24,7 +24,7 @@
* Imports.
*/
import {
AbortPayRequest as AbortPayRequest,
AbortTransactionRequest as AbortTransactionRequest,
AcceptBankIntegratedWithdrawalRequest,
AcceptExchangeTosRequest,
AcceptManualWithdrawalRequest,
@ -150,7 +150,7 @@ export enum WalletApiOperation {
GetExchangeTos = "getExchangeTos",
GetExchangeDetailedInfo = "getExchangeDetailedInfo",
RetryPendingNow = "retryPendingNow",
AbortPay = "abortPay",
AbortTransaction = "abortTransaction",
ConfirmPay = "confirmPay",
DumpCoins = "dumpCoins",
SetCoinSuspended = "setCoinSuspended",
@ -329,13 +329,13 @@ export type ConfirmPayOp = {
};
/**
* Abort a pending payment.
* Puts the payment into an "aborting" state
* that can be cancelled.
* Abort a transaction
*
* For payment transactions, it puts the payment into an "aborting" state.
*/
export type AbortPayOp = {
op: WalletApiOperation.AbortPay;
request: AbortPayRequest;
export type AbortTransactionOp = {
op: WalletApiOperation.AbortTransaction;
request: AbortTransactionRequest;
response: EmptyObject;
};
@ -829,7 +829,7 @@ export type WalletOperations = {
[WalletApiOperation.GetContractTermsDetails]: GetContractTermsDetailsOp;
[WalletApiOperation.WithdrawTestkudos]: WithdrawTestkudosOp;
[WalletApiOperation.ConfirmPay]: ConfirmPayOp;
[WalletApiOperation.AbortPay]: AbortPayOp;
[WalletApiOperation.AbortTransaction]: AbortTransactionOp;
[WalletApiOperation.GetBalances]: GetBalancesOp;
[WalletApiOperation.GetTransactions]: GetTransactionsOp;
[WalletApiOperation.GetTransactionById]: GetTransactionByIdOp;

View File

@ -25,7 +25,7 @@
import {
AbsoluteTime,
Amounts,
codecForAbortPayRequest,
codecForAbortTransaction,
codecForAcceptBankIntegratedWithdrawalRequest,
codecForAcceptExchangeTosRequest,
codecForAcceptManualWithdrawalRequet,
@ -180,7 +180,7 @@ import {
} from "./operations/exchanges.js";
import { getMerchantInfo } from "./operations/merchants.js";
import {
abortFailedPayWithRefund,
abortPay as abortPay,
applyRefund,
applyRefundFromPurchaseId,
confirmPay,
@ -217,6 +217,7 @@ import {
} from "./operations/testing.js";
import { acceptTip, prepareTip, processTip } from "./operations/tip.js";
import {
abortTransaction,
deleteTransaction,
getTransactionById,
getTransactions,
@ -1163,9 +1164,9 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
const req = codecForConfirmPayRequest().decode(payload);
return await confirmPay(ws, req.proposalId, req.sessionId);
}
case WalletApiOperation.AbortPay: {
const req = codecForAbortPayRequest().decode(payload);
await abortFailedPayWithRefund(ws, req.proposalId);
case WalletApiOperation.AbortTransaction: {
const req = codecForAbortTransaction().decode(payload);
await abortTransaction(ws, req.transactionId, req.forceImmediateAbort);
return {};
}
case WalletApiOperation.DumpCoins: {