-refunds for deposit aborts

This commit is contained in:
Florian Dold 2023-04-24 20:24:23 +02:00
parent 974cd02066
commit e4407f8259
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
6 changed files with 397 additions and 25 deletions

View File

@ -2120,3 +2120,47 @@ export interface MerchantUsingTemplateDetails {
summary?: string; summary?: string;
amount?: AmountString; amount?: AmountString;
} }
export interface ExchangeRefundRequest {
// Amount to be refunded, can be a fraction of the
// coin's total deposit value (including deposit fee);
// must be larger than the refund fee.
refund_amount: AmountString;
// SHA-512 hash of the contact of the merchant with the customer.
h_contract_terms: HashCodeString;
// 64-bit transaction id of the refund transaction between merchant and customer.
rtransaction_id: number;
// EdDSA public key of the merchant.
merchant_pub: EddsaPublicKeyString;
// EdDSA signature of the merchant over a
// TALER_RefundRequestPS with purpose
// TALER_SIGNATURE_MERCHANT_REFUND
// affirming the refund.
merchant_sig: EddsaPublicKeyString;
}
export interface ExchangeRefundSuccessResponse {
// The EdDSA :ref:signature (binary-only) with purpose
// TALER_SIGNATURE_EXCHANGE_CONFIRM_REFUND over
// a TALER_RecoupRefreshConfirmationPS
// using a current signing key of the
// exchange affirming the successful refund.
exchange_sig: EddsaSignatureString;
// Public EdDSA key of the exchange that was used to generate the signature.
// Should match one of the exchange's signing keys from /keys. It is given
// explicitly as the client might otherwise be confused by clock skew as to
// which signing key was used.
exchange_pub: EddsaPublicKeyString;
}
export const codecForExchangeRefundSuccessResponse =
(): Codec<ExchangeRefundSuccessResponse> =>
buildCodecForObject<ExchangeRefundSuccessResponse>()
.property("exchange_pub", codecForString())
.property("exchange_sig", codecForString())
.build("ExchangeRefundSuccessResponse");

View File

@ -668,6 +668,7 @@ export enum RefreshReason {
PayPeerPull = "pay-peer-pull", PayPeerPull = "pay-peer-pull",
Refund = "refund", Refund = "refund",
AbortPay = "abort-pay", AbortPay = "abort-pay",
AbortDeposit = "abort-deposit",
Recoup = "recoup", Recoup = "recoup",
BackupRestored = "backup-restored", BackupRestored = "backup-restored",
Scheduled = "scheduled", Scheduled = "scheduled",

View File

@ -856,6 +856,7 @@ export enum RefreshOperationStatus {
Pending = 10 /* ACTIVE_START */, Pending = 10 /* ACTIVE_START */,
Finished = 50 /* DORMANT_START */, Finished = 50 /* DORMANT_START */,
FinishedWithError = 51 /* DORMANT_START + 1 */, FinishedWithError = 51 /* DORMANT_START + 1 */,
Suspended = 52 /* DORMANT_START + 2 */,
} }
export enum DepositGroupOperationStatus { export enum DepositGroupOperationStatus {
@ -1649,6 +1650,19 @@ export enum DepositOperationStatus {
Aborting = 11 /* OperationStatusRange.ACTIVE_START + 1 */, Aborting = 11 /* OperationStatusRange.ACTIVE_START + 1 */,
} }
export interface DepositTrackingInfo {
// Raw wire transfer identifier of the deposit.
wireTransferId: string;
// When was the wire transfer given to the bank.
timestampExecuted: TalerProtocolTimestamp;
// Total amount transfer for this wtid (including fees)
amountRaw: AmountString;
// Wire fee amount for this exchange
wireFee: AmountString;
exchangePub: string;
}
/** /**
* Group of deposits made by the wallet. * Group of deposits made by the wallet.
*/ */
@ -1711,17 +1725,7 @@ export interface DepositGroupRecord {
// FIXME: Do we need this and should it be in this object store? // FIXME: Do we need this and should it be in this object store?
trackingState?: { trackingState?: {
[signature: string]: { [signature: string]: DepositTrackingInfo;
// Raw wire transfer identifier of the deposit.
wireTransferId: string;
// When was the wire transfer given to the bank.
timestampExecuted: TalerProtocolTimestamp;
// Total amount transfer for this wtid (including fees)
amountRaw: AmountString;
// Wire fee amount for this exchange
wireFee: AmountString;
exchangePub: string;
};
}; };
} }

View File

@ -190,7 +190,7 @@ export async function spendCoins(
tx, tx,
Amounts.currencyOf(csi.contributions[0]), Amounts.currencyOf(csi.contributions[0]),
refreshCoinPubs, refreshCoinPubs,
RefreshReason.PayMerchant, csi.refreshReason,
{ {
originatingTransactionId: csi.allocationId, originatingTransactionId: csi.allocationId,
}, },

View File

@ -27,12 +27,14 @@ import {
codecForTackTransactionAccepted, codecForTackTransactionAccepted,
codecForTackTransactionWired, codecForTackTransactionWired,
CoinDepositPermission, CoinDepositPermission,
CoinRefreshRequest,
CreateDepositGroupRequest, CreateDepositGroupRequest,
CreateDepositGroupResponse, CreateDepositGroupResponse,
DepositGroupFees, DepositGroupFees,
durationFromSpec, durationFromSpec,
encodeCrock, encodeCrock,
ExchangeDepositRequest, ExchangeDepositRequest,
ExchangeRefundRequest,
getRandomBytes, getRandomBytes,
hashTruncate32, hashTruncate32,
hashWire, hashWire,
@ -65,11 +67,14 @@ import {
} from "../db.js"; } from "../db.js";
import { TalerError } from "@gnu-taler/taler-util"; import { TalerError } from "@gnu-taler/taler-util";
import { import {
createRefreshGroup,
DepositOperationStatus, DepositOperationStatus,
DepositTrackingInfo,
getTotalRefreshCost, getTotalRefreshCost,
KycPendingInfo, KycPendingInfo,
KycUserType, KycUserType,
PendingTaskType, PendingTaskType,
RefreshOperationStatus,
} from "../index.js"; } from "../index.js";
import { InternalWalletState } from "../internal-wallet-state.js"; import { InternalWalletState } from "../internal-wallet-state.js";
import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
@ -88,6 +93,7 @@ import {
stopLongpolling, stopLongpolling,
} from "./transactions.js"; } from "./transactions.js";
import { constructTaskIdentifier } from "../util/retries.js"; import { constructTaskIdentifier } from "../util/retries.js";
import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
/** /**
* Logger. * Logger.
@ -126,6 +132,9 @@ export function computeDepositTransactionStatus(
} }
} }
logger.info(`num total ${numTotal}`);
logger.info(`num deposited ${numDeposited}`);
if (numKycRequired > 0) { if (numKycRequired > 0) {
return { return {
major: TransactionMajorState.Pending, major: TransactionMajorState.Pending,
@ -351,6 +360,184 @@ async function checkDepositKycStatus(
} }
} }
/**
* Check whether the refresh associated with the
* aborting deposit group is done.
*
* If done, mark the deposit transaction as aborted.
*
* Otherwise continue waiting.
*
* FIXME: Wait for the refresh group notifications instead of periodically
* checking the refresh group status.
* FIXME: This is just one transaction, can't we do this in the initial
* transaction of processDepositGroup?
*/
async function waitForRefreshOnDepositGroup(
ws: InternalWalletState,
depositGroup: DepositGroupRecord,
): Promise<OperationAttemptResult> {
const abortRefreshGroupId = depositGroup.abortRefreshGroupId;
checkLogicInvariant(!!abortRefreshGroupId);
// FIXME: Emit notification on state transition!
const res = await ws.db
.mktx((x) => [x.refreshGroups, x.depositGroups])
.runReadWrite(async (tx) => {
const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
let newOpState: DepositOperationStatus | undefined;
if (!refreshGroup) {
// Maybe it got manually deleted? Means that we should
// just go into aborted.
logger.warn("no aborting refresh group found for deposit group");
newOpState = DepositOperationStatus.Aborted;
} else {
if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) {
newOpState = DepositOperationStatus.Aborted;
} else if (
refreshGroup.operationStatus ===
RefreshOperationStatus.FinishedWithError
) {
newOpState = DepositOperationStatus.Aborted;
}
}
if (newOpState) {
const newDg = await tx.depositGroups.get(depositGroup.depositGroupId);
if (!newDg) {
return;
}
const oldDepositTxStatus = computeDepositTransactionStatus(newDg);
newDg.operationStatus = newOpState;
const newDepositTxStatus = computeDepositTransactionStatus(newDg);
await tx.depositGroups.put(newDg);
return { oldDepositTxStatus, newDepositTxStatus };
}
return undefined;
});
if (res) {
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Deposit,
depositGroupId: depositGroup.depositGroupId,
});
ws.notify({
type: NotificationType.TransactionStateTransition,
transactionId,
oldTxState: res.oldDepositTxStatus,
newTxState: res.newDepositTxStatus,
});
return OperationAttemptResult.pendingEmpty();
} else {
return OperationAttemptResult.pendingEmpty();
}
}
async function refundDepositGroup(
ws: InternalWalletState,
depositGroup: DepositGroupRecord,
): Promise<OperationAttemptResult> {
const newTxPerCoin = [...depositGroup.transactionPerCoin];
for (let i = 0; i < depositGroup.transactionPerCoin.length; i++) {
const st = depositGroup.transactionPerCoin[i];
switch (st) {
case DepositElementStatus.RefundFailed:
case DepositElementStatus.RefundSuccess:
break;
default: {
const coinPub = depositGroup.payCoinSelection.coinPubs[i];
const coinExchange = await ws.db
.mktx((x) => [x.coins])
.runReadOnly(async (tx) => {
const coinRecord = await tx.coins.get(coinPub);
checkDbInvariant(!!coinRecord);
return coinRecord.exchangeBaseUrl;
});
const refundAmount = depositGroup.payCoinSelection.coinContributions[i];
// We use a constant refund transaction ID, since there can
// only be one refund.
const rtid = 1;
const sig = await ws.cryptoApi.signRefund({
coinPub,
contractTermsHash: depositGroup.contractTermsHash,
merchantPriv: depositGroup.merchantPriv,
merchantPub: depositGroup.merchantPub,
refundAmount: refundAmount,
rtransactionId: rtid,
});
const refundReq: ExchangeRefundRequest = {
h_contract_terms: depositGroup.contractTermsHash,
merchant_pub: depositGroup.merchantPub,
merchant_sig: sig.sig,
refund_amount: refundAmount,
rtransaction_id: rtid,
};
const refundUrl = new URL(`coins/${coinPub}/refund`, coinExchange);
const httpResp = await ws.http.fetch(refundUrl.href, {
method: "POST",
body: refundReq,
});
let newStatus: DepositElementStatus;
if (httpResp.status === 200) {
// FIXME: validate response
newStatus = DepositElementStatus.RefundSuccess;
} else {
// FIXME: Store problem somewhere!
newStatus = DepositElementStatus.RefundFailed;
}
// FIXME: Handle case where refund request needs to be tried again
newTxPerCoin[i] = newStatus;
break;
}
}
}
let isDone = true;
for (let i = 0; i < newTxPerCoin.length; i++) {
if (
newTxPerCoin[i] != DepositElementStatus.RefundFailed ||
newTxPerCoin[i] != DepositElementStatus.RefundSuccess
) {
isDone = false;
}
}
const currency = Amounts.currencyOf(depositGroup.totalPayCost);
await ws.db
.mktx((x) => [
x.depositGroups,
x.refreshGroups,
x.coins,
x.denominations,
x.coinAvailability,
])
.runReadWrite(async (tx) => {
const newDg = await tx.depositGroups.get(depositGroup.depositGroupId);
if (!newDg) {
return;
}
newDg.transactionPerCoin = newTxPerCoin;
const refreshCoins: CoinRefreshRequest[] = [];
for (let i = 0; i < newTxPerCoin.length; i++) {
refreshCoins.push({
amount: depositGroup.payCoinSelection.coinContributions[i],
coinPub: depositGroup.payCoinSelection.coinPubs[i],
});
}
if (isDone) {
const rgid = await createRefreshGroup(
ws,
tx,
currency,
refreshCoins,
RefreshReason.AbortDeposit,
);
newDg.abortRefreshGroupId = rgid.refreshGroupId;
}
await tx.depositGroups.put(newDg);
});
return OperationAttemptResult.pendingEmpty();
}
/** /**
* Process a deposit group that is not in its final state yet. * Process a deposit group that is not in its final state yet.
*/ */
@ -401,7 +588,7 @@ export async function processDepositGroup(
for (let i = 0; i < depositPermissions.length; i++) { for (let i = 0; i < depositPermissions.length; i++) {
const perm = depositPermissions[i]; const perm = depositPermissions[i];
let updatedDeposit: boolean = false; let didDeposit: boolean = false;
if (!depositGroup.depositedPerCoin[i]) { if (!depositGroup.depositedPerCoin[i]) {
const requestBody: ExchangeDepositRequest = { const requestBody: ExchangeDepositRequest = {
@ -435,16 +622,15 @@ export async function processDepositGroup(
httpResp, httpResp,
codecForDepositSuccess(), codecForDepositSuccess(),
); );
updatedDeposit = true; didDeposit = true;
} }
let updatedTxStatus: DepositElementStatus | undefined = undefined; let updatedTxStatus: DepositElementStatus | undefined = undefined;
type ValueOf<T> = T[keyof T];
let newWiredCoin: let newWiredCoin:
| { | {
id: string; id: string;
value: ValueOf<NonNullable<DepositGroupRecord["trackingState"]>>; value: DepositTrackingInfo;
} }
| undefined; | undefined;
@ -499,7 +685,7 @@ export async function processDepositGroup(
} }
} }
if (updatedTxStatus !== undefined || updatedDeposit) { if (updatedTxStatus !== undefined || didDeposit) {
await ws.db await ws.db
.mktx((x) => [x.depositGroups]) .mktx((x) => [x.depositGroups])
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {
@ -507,8 +693,8 @@ export async function processDepositGroup(
if (!dg) { if (!dg) {
return; return;
} }
if (updatedDeposit !== undefined) { if (didDeposit) {
dg.depositedPerCoin[i] = updatedDeposit; dg.depositedPerCoin[i] = didDeposit;
} }
if (updatedTxStatus !== undefined) { if (updatedTxStatus !== undefined) {
dg.transactionPerCoin[i] = updatedTxStatus; dg.transactionPerCoin[i] = updatedTxStatus;
@ -526,7 +712,8 @@ export async function processDepositGroup(
dg.trackingState = {}; dg.trackingState = {};
} }
dg.trackingState[newWiredCoin.id] = newWiredCoin.value; dg.trackingState[newWiredCoin.id] =
newWiredCoin.value;
} }
await tx.depositGroups.put(dg); await tx.depositGroups.put(dg);
}); });
@ -588,10 +775,12 @@ export async function processDepositGroup(
} }
if (depositGroup.operationStatus === DepositOperationStatus.Aborting) { if (depositGroup.operationStatus === DepositOperationStatus.Aborting) {
// FIXME: Implement! const abortRefreshGroupId = depositGroup.abortRefreshGroupId;
return OperationAttemptResult.pendingEmpty(); if (!abortRefreshGroupId) {
return refundDepositGroup(ws, depositGroup);
}
return waitForRefreshOnDepositGroup(ws, depositGroup);
} }
return OperationAttemptResult.finishedEmpty(); return OperationAttemptResult.finishedEmpty();
} }

View File

@ -49,6 +49,9 @@ import {
TalerErrorCode, TalerErrorCode,
TalerErrorDetail, TalerErrorDetail,
TalerProtocolTimestamp, TalerProtocolTimestamp,
TransactionMajorState,
TransactionState,
TransactionType,
URL, URL,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { TalerCryptoInterface } from "../crypto/cryptoImplementation.js"; import { TalerCryptoInterface } from "../crypto/cryptoImplementation.js";
@ -80,13 +83,19 @@ import {
import { checkDbInvariant } from "../util/invariants.js"; import { checkDbInvariant } from "../util/invariants.js";
import { GetReadWriteAccess } from "../util/query.js"; import { GetReadWriteAccess } from "../util/query.js";
import { import {
constructTaskIdentifier,
OperationAttemptResult, OperationAttemptResult,
OperationAttemptResultType, OperationAttemptResultType,
} from "../util/retries.js"; } from "../util/retries.js";
import { makeCoinAvailable } from "./common.js"; import { makeCoinAvailable } from "./common.js";
import { updateExchangeFromUrl } from "./exchanges.js"; import { updateExchangeFromUrl } from "./exchanges.js";
import { selectWithdrawalDenominations } from "../util/coinSelection.js"; import { selectWithdrawalDenominations } from "../util/coinSelection.js";
import { isWithdrawableDenom, WalletConfig } from "../index.js"; import {
isWithdrawableDenom,
PendingTaskType,
WalletConfig,
} from "../index.js";
import { constructTransactionIdentifier } from "./transactions.js";
const logger = new Logger("refresh.ts"); const logger = new Logger("refresh.ts");
@ -1115,3 +1124,128 @@ export async function autoRefresh(
}); });
return OperationAttemptResult.finishedEmpty(); return OperationAttemptResult.finishedEmpty();
} }
export function computeRefreshTransactionStatus(
rg: RefreshGroupRecord,
): TransactionState {
switch (rg.operationStatus) {
case RefreshOperationStatus.Finished:
return {
major: TransactionMajorState.Done,
};
case RefreshOperationStatus.FinishedWithError:
return {
major: TransactionMajorState.Failed,
};
case RefreshOperationStatus.Pending:
return {
major: TransactionMajorState.Pending,
};
case RefreshOperationStatus.Suspended:
return {
major: TransactionMajorState.Suspended,
};
}
}
export async function suspendRefreshGroup(
ws: InternalWalletState,
refreshGroupId: string,
): Promise<void> {
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Refresh,
refreshGroupId,
});
const retryTag = constructTaskIdentifier({
tag: PendingTaskType.Refresh,
refreshGroupId,
});
let res = await ws.db
.mktx((x) => [x.refreshGroups])
.runReadWrite(async (tx) => {
const dg = await tx.refreshGroups.get(refreshGroupId);
if (!dg) {
logger.warn(
`can't suspend refresh group, refreshGroupId=${refreshGroupId} not found`,
);
return undefined;
}
const oldState = computeRefreshTransactionStatus(dg);
switch (dg.operationStatus) {
case RefreshOperationStatus.Finished:
return undefined;
case RefreshOperationStatus.Pending: {
dg.operationStatus = RefreshOperationStatus.Suspended;
await tx.refreshGroups.put(dg);
return {
oldTxState: oldState,
newTxState: computeRefreshTransactionStatus(dg),
};
}
case RefreshOperationStatus.Suspended:
return undefined;
}
return undefined;
});
if (res) {
ws.notify({
type: NotificationType.TransactionStateTransition,
transactionId,
oldTxState: res.oldTxState,
newTxState: res.newTxState,
});
}
}
export async function resumeRefreshGroup(
ws: InternalWalletState,
refreshGroupId: string,
): Promise<void> {
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Refresh,
refreshGroupId,
});
let res = await ws.db
.mktx((x) => [x.refreshGroups])
.runReadWrite(async (tx) => {
const dg = await tx.refreshGroups.get(refreshGroupId);
if (!dg) {
logger.warn(
`can't resume refresh group, refreshGroupId=${refreshGroupId} not found`,
);
return;
}
const oldState = computeRefreshTransactionStatus(dg);
switch (dg.operationStatus) {
case RefreshOperationStatus.Finished:
return;
case RefreshOperationStatus.Pending: {
return;
}
case RefreshOperationStatus.Suspended:
dg.operationStatus = RefreshOperationStatus.Pending;
await tx.refreshGroups.put(dg);
return {
oldTxState: oldState,
newTxState: computeRefreshTransactionStatus(dg),
};
}
return undefined;
});
ws.latch.trigger();
if (res) {
ws.notify({
type: NotificationType.TransactionStateTransition,
transactionId,
oldTxState: res.oldTxState,
newTxState: res.newTxState,
});
}
}
export async function abortRefreshGroup(
ws: InternalWalletState,
refreshGroupId: string,
): Promise<void> {
throw Error("can't abort refresh groups.");
}