From e4407f825960554659af276d88eb54cc4e5fde9f Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Mon, 24 Apr 2023 20:24:23 +0200 Subject: -refunds for deposit aborts --- packages/taler-util/src/taler-types.ts | 44 +++++ packages/taler-util/src/wallet-types.ts | 1 + packages/taler-wallet-core/src/db.ts | 26 +-- .../taler-wallet-core/src/operations/common.ts | 4 +- .../taler-wallet-core/src/operations/deposits.ts | 211 +++++++++++++++++++-- .../taler-wallet-core/src/operations/refresh.ts | 136 ++++++++++++- 6 files changed, 397 insertions(+), 25 deletions(-) (limited to 'packages') diff --git a/packages/taler-util/src/taler-types.ts b/packages/taler-util/src/taler-types.ts index 48eb49d22..ab5951112 100644 --- a/packages/taler-util/src/taler-types.ts +++ b/packages/taler-util/src/taler-types.ts @@ -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 => + buildCodecForObject() + .property("exchange_pub", codecForString()) + .property("exchange_sig", codecForString()) + .build("ExchangeRefundSuccessResponse"); diff --git a/packages/taler-util/src/wallet-types.ts b/packages/taler-util/src/wallet-types.ts index 151934f2c..ec53541a5 100644 --- a/packages/taler-util/src/wallet-types.ts +++ b/packages/taler-util/src/wallet-types.ts @@ -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", diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index a8c103265..9b250cede 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -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; }; } diff --git a/packages/taler-wallet-core/src/operations/common.ts b/packages/taler-wallet-core/src/operations/common.ts index b3dc0804f..539632b02 100644 --- a/packages/taler-wallet-core/src/operations/common.ts +++ b/packages/taler-wallet-core/src/operations/common.ts @@ -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( diff --git a/packages/taler-wallet-core/src/operations/deposits.ts b/packages/taler-wallet-core/src/operations/deposits.ts index 14d1f9e3f..e1d699775 100644 --- a/packages/taler-wallet-core/src/operations/deposits.ts +++ b/packages/taler-wallet-core/src/operations/deposits.ts @@ -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 { + 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 { + 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[keyof T]; let newWiredCoin: | { id: string; - value: ValueOf>; + 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(); } diff --git a/packages/taler-wallet-core/src/operations/refresh.ts b/packages/taler-wallet-core/src/operations/refresh.ts index 3122c9685..748c929c2 100644 --- a/packages/taler-wallet-core/src/operations/refresh.ts +++ b/packages/taler-wallet-core/src/operations/refresh.ts @@ -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 { + 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 { + 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 { + throw Error("can't abort refresh groups."); +} -- cgit v1.2.3