gnunet-svn
[Top][All Lists]
Advanced

[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]

[taler-wallet-core] branch master updated: -refunds for deposit aborts


From: gnunet
Subject: [taler-wallet-core] branch master updated: -refunds for deposit aborts
Date: Mon, 24 Apr 2023 20:30:07 +0200

This is an automated email from the git hooks/post-receive script.

dold pushed a commit to branch master
in repository wallet-core.

The following commit(s) were added to refs/heads/master by this push:
     new e4407f825 -refunds for deposit aborts
e4407f825 is described below

commit e4407f825960554659af276d88eb54cc4e5fde9f
Author: Florian Dold <florian@dold.me>
AuthorDate: Mon Apr 24 20:24:23 2023 +0200

    -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(-)

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,
@@ -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();
 }
 
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.");
+}

-- 
To stop receiving notification emails like this one, please contact
gnunet@gnunet.org.



reply via email to

[Prev in Thread] Current Thread [Next in Thread]