-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;
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",
Refund = "refund",
AbortPay = "abort-pay",
AbortDeposit = "abort-deposit",
Recoup = "recoup",
BackupRestored = "backup-restored",
Scheduled = "scheduled",

View File

@ -856,6 +856,7 @@ export enum RefreshOperationStatus {
Pending = 10 /* ACTIVE_START */,
Finished = 50 /* DORMANT_START */,
FinishedWithError = 51 /* DORMANT_START + 1 */,
Suspended = 52 /* DORMANT_START + 2 */,
}
export enum DepositGroupOperationStatus {
@ -1649,6 +1650,19 @@ export enum DepositOperationStatus {
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.
*/
@ -1711,17 +1725,7 @@ export interface DepositGroupRecord {
// FIXME: Do we need this and should it be in this object store?
trackingState?: {
[signature: string]: {
// 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;
};
[signature: string]: DepositTrackingInfo;
};
}

View File

@ -190,7 +190,7 @@ export async function spendCoins(
tx,
Amounts.currencyOf(csi.contributions[0]),
refreshCoinPubs,
RefreshReason.PayMerchant,
csi.refreshReason,
{
originatingTransactionId: csi.allocationId,
},
@ -363,7 +363,7 @@ export enum TombstoneTag {
/**
* Create an event ID from the type and the primary key for the event.
*
*
* @deprecated use constructTransactionIdentifier instead
*/
export function makeTransactionId(

View File

@ -27,12 +27,14 @@ import {
codecForTackTransactionAccepted,
codecForTackTransactionWired,
CoinDepositPermission,
CoinRefreshRequest,
CreateDepositGroupRequest,
CreateDepositGroupResponse,
DepositGroupFees,
durationFromSpec,
encodeCrock,
ExchangeDepositRequest,
ExchangeRefundRequest,
getRandomBytes,
hashTruncate32,
hashWire,
@ -65,11 +67,14 @@ import {
} from "../db.js";
import { TalerError } from "@gnu-taler/taler-util";
import {
createRefreshGroup,
DepositOperationStatus,
DepositTrackingInfo,
getTotalRefreshCost,
KycPendingInfo,
KycUserType,
PendingTaskType,
RefreshOperationStatus,
} from "../index.js";
import { InternalWalletState } from "../internal-wallet-state.js";
import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
@ -88,6 +93,7 @@ import {
stopLongpolling,
} from "./transactions.js";
import { constructTaskIdentifier } from "../util/retries.js";
import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
/**
* Logger.
@ -126,6 +132,9 @@ export function computeDepositTransactionStatus(
}
}
logger.info(`num total ${numTotal}`);
logger.info(`num deposited ${numDeposited}`);
if (numKycRequired > 0) {
return {
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.
*/
@ -401,7 +588,7 @@ export async function processDepositGroup(
for (let i = 0; i < depositPermissions.length; i++) {
const perm = depositPermissions[i];
let updatedDeposit: boolean = false;
let didDeposit: boolean = false;
if (!depositGroup.depositedPerCoin[i]) {
const requestBody: ExchangeDepositRequest = {
@ -435,16 +622,15 @@ export async function processDepositGroup(
httpResp,
codecForDepositSuccess(),
);
updatedDeposit = true;
didDeposit = true;
}
let updatedTxStatus: DepositElementStatus | undefined = undefined;
type ValueOf<T> = T[keyof T];
let newWiredCoin:
| {
id: string;
value: ValueOf<NonNullable<DepositGroupRecord["trackingState"]>>;
value: DepositTrackingInfo;
}
| undefined;
@ -499,7 +685,7 @@ export async function processDepositGroup(
}
}
if (updatedTxStatus !== undefined || updatedDeposit) {
if (updatedTxStatus !== undefined || didDeposit) {
await ws.db
.mktx((x) => [x.depositGroups])
.runReadWrite(async (tx) => {
@ -507,8 +693,8 @@ export async function processDepositGroup(
if (!dg) {
return;
}
if (updatedDeposit !== undefined) {
dg.depositedPerCoin[i] = updatedDeposit;
if (didDeposit) {
dg.depositedPerCoin[i] = didDeposit;
}
if (updatedTxStatus !== undefined) {
dg.transactionPerCoin[i] = updatedTxStatus;
@ -526,7 +712,8 @@ export async function processDepositGroup(
dg.trackingState = {};
}
dg.trackingState[newWiredCoin.id] = newWiredCoin.value;
dg.trackingState[newWiredCoin.id] =
newWiredCoin.value;
}
await tx.depositGroups.put(dg);
});
@ -588,10 +775,12 @@ export async function processDepositGroup(
}
if (depositGroup.operationStatus === DepositOperationStatus.Aborting) {
// FIXME: Implement!
return OperationAttemptResult.pendingEmpty();
const abortRefreshGroupId = depositGroup.abortRefreshGroupId;
if (!abortRefreshGroupId) {
return refundDepositGroup(ws, depositGroup);
}
return waitForRefreshOnDepositGroup(ws, depositGroup);
}
return OperationAttemptResult.finishedEmpty();
}

View File

@ -49,6 +49,9 @@ import {
TalerErrorCode,
TalerErrorDetail,
TalerProtocolTimestamp,
TransactionMajorState,
TransactionState,
TransactionType,
URL,
} from "@gnu-taler/taler-util";
import { TalerCryptoInterface } from "../crypto/cryptoImplementation.js";
@ -80,13 +83,19 @@ import {
import { checkDbInvariant } from "../util/invariants.js";
import { GetReadWriteAccess } from "../util/query.js";
import {
constructTaskIdentifier,
OperationAttemptResult,
OperationAttemptResultType,
} from "../util/retries.js";
import { makeCoinAvailable } from "./common.js";
import { updateExchangeFromUrl } from "./exchanges.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");
@ -1115,3 +1124,128 @@ export async function autoRefresh(
});
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.");
}