aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFlorian Dold <florian@dold.me>2023-04-24 20:24:23 +0200
committerFlorian Dold <florian@dold.me>2023-04-24 20:30:01 +0200
commite4407f825960554659af276d88eb54cc4e5fde9f (patch)
tree9ce7e6841254debd508e5489908ccac7518449f4
parent974cd02066edc1a5b8279931d6b019f667051409 (diff)
-refunds for deposit aborts
-rw-r--r--packages/taler-util/src/taler-types.ts44
-rw-r--r--packages/taler-util/src/wallet-types.ts1
-rw-r--r--packages/taler-wallet-core/src/db.ts26
-rw-r--r--packages/taler-wallet-core/src/operations/common.ts4
-rw-r--r--packages/taler-wallet-core/src/operations/deposits.ts211
-rw-r--r--packages/taler-wallet-core/src/operations/refresh.ts136
6 files changed, 397 insertions, 25 deletions
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<ExchangeRefundSuccessResponse> =>
+ buildCodecForObject<ExchangeRefundSuccessResponse>()
+ .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,
@@ -352,6 +361,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.
*/
export async function processDepositGroup(
@@ -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();
}
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<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.");
+}