diff options
22 files changed, 975 insertions, 97 deletions
diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts index e4f1ccb50..7f32b8446 100644 --- a/packages/taler-wallet-cli/src/index.ts +++ b/packages/taler-wallet-cli/src/index.ts @@ -503,6 +503,37 @@ backupCli      });    }); +const depositCli = walletCli.subcommand("depositArgs", "deposit", { +  help: "Subcommands for depositing money to payto:// accounts", +}); + +depositCli +  .subcommand("createDepositArgs", "create") +  .requiredArgument("amount", clk.STRING) +  .requiredArgument("targetPayto", clk.STRING) +  .action(async (args) => { +    await withWallet(args, async (wallet) => { +      const resp = await wallet.createDepositGroup({ +        amount: args.createDepositArgs.amount, +        depositPaytoUri: args.createDepositArgs.targetPayto, +      }); +      console.log(`Created deposit ${resp.depositGroupId}`); +      await wallet.runPending(); +    }); +  }); + +depositCli +  .subcommand("trackDepositArgs", "track") +  .requiredArgument("depositGroupId", clk.STRING) +  .action(async (args) => { +    await withWallet(args, async (wallet) => { +      const resp = await wallet.trackDepositGroup({ +        depositGroupId: args.trackDepositArgs.depositGroupId, +      }); +      console.log(JSON.stringify(resp, undefined, 2)); +    }); +  }); +  const advancedCli = walletCli.subcommand("advancedArgs", "advanced", {    help:      "Subcommands for advanced operations (only use if you know what you're doing!).", diff --git a/packages/taler-wallet-cli/src/integrationtests/harness.ts b/packages/taler-wallet-cli/src/integrationtests/harness.ts index b6b82213d..eb14b32b9 100644 --- a/packages/taler-wallet-cli/src/integrationtests/harness.ts +++ b/packages/taler-wallet-cli/src/integrationtests/harness.ts @@ -78,6 +78,10 @@ import {    AbortPayWithRefundRequest,    openPromise,    parsePaytoUri, +  CreateDepositGroupRequest, +  CreateDepositGroupResponse, +  TrackDepositGroupRequest, +  TrackDepositGroupResponse,  } from "taler-wallet-core";  import { URL } from "url";  import axios, { AxiosError } from "axios"; @@ -873,6 +877,9 @@ export class ExchangeService implements ExchangeServiceInterface {      config.setString("exchangedb-postgres", "config", e.database); +    config.setString("taler-exchange-secmod-eddsa", "lookahead_sign", "20 s"); +    config.setString("taler-exchange-secmod-rsa", "lookahead_sign", "20 s"); +      const exchangeMasterKey = createEddsaKeyPair();      config.setString( @@ -1017,13 +1024,7 @@ export class ExchangeService implements ExchangeServiceInterface {        this.globalState,        "exchange-offline",        "taler-exchange-offline", -      [ -        "-c", -        this.configFilename, -        "download", -        "sign", -        "upload", -      ], +      ["-c", this.configFilename, "download", "sign", "upload"],      );      const accounts: string[] = []; @@ -1049,13 +1050,7 @@ export class ExchangeService implements ExchangeServiceInterface {          this.globalState,          "exchange-offline",          "taler-exchange-offline", -        [ -          "-c", -          this.configFilename, -          "enable-account", -          acc, -          "upload", -        ], +        ["-c", this.configFilename, "enable-account", acc, "upload"],        );      } @@ -1615,6 +1610,16 @@ export class WalletCli {      throw new OperationFailedError(resp.error);    } +  async createDepositGroup( +    req: CreateDepositGroupRequest, +  ): Promise<CreateDepositGroupResponse> { +    const resp = await this.apiRequest("createDepositGroup", req); +    if (resp.type === "response") { +      return resp.result as CreateDepositGroupResponse; +    } +    throw new OperationFailedError(resp.error); +  } +    async abortFailedPayWithRefund(      req: AbortPayWithRefundRequest,    ): Promise<void> { @@ -1714,6 +1719,16 @@ export class WalletCli {      throw new OperationFailedError(resp.error);    } +  async trackDepositGroup( +    req: TrackDepositGroupRequest, +  ): Promise<TrackDepositGroupResponse> { +    const resp = await this.apiRequest("trackDepositGroup", req); +    if (resp.type === "response") { +      return resp.result as TrackDepositGroupResponse; +    } +    throw new OperationFailedError(resp.error); +  } +    async runIntegrationTest(args: IntegrationTestArgs): Promise<void> {      const resp = await this.apiRequest("runIntegrationTest", args);      if (resp.type === "response") { diff --git a/packages/taler-wallet-cli/src/integrationtests/test-deposit.ts b/packages/taler-wallet-cli/src/integrationtests/test-deposit.ts new file mode 100644 index 000000000..3e59a6cce --- /dev/null +++ b/packages/taler-wallet-cli/src/integrationtests/test-deposit.ts @@ -0,0 +1,65 @@ +/* + This file is part of GNU Taler + (C) 2020 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE.  See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Imports. + */ +import { GlobalTestState } from "./harness"; +import { +  createSimpleTestkudosEnvironment, +  withdrawViaBank, +} from "./helpers"; + +/** + * Run test for basic, bank-integrated withdrawal and payment. + */ +export async function runDepositTest(t: GlobalTestState) { +  // Set up test environment + +  const { +    wallet, +    bank, +    exchange, +    merchant, +  } = await createSimpleTestkudosEnvironment(t); + +  // Withdraw digital cash into the wallet. + +  await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" }); + +  await wallet.runUntilDone(); + +  const { depositGroupId } = await wallet.createDepositGroup({ +    amount: "TESTKUDOS:10", +    depositPaytoUri: "payto://x-taler-bank/localhost/foo", +  }); + +  await wallet.runUntilDone(); + +  const transactions = await wallet.getTransactions(); +  console.log("transactions", JSON.stringify(transactions, undefined, 2)); +  t.assertDeepEqual(transactions.transactions[0].type, "withdrawal"); +  t.assertDeepEqual(transactions.transactions[1].type, "deposit"); +  // The raw amount is what ends up on the bank account, which includes +  // deposit and wire fees. +  t.assertDeepEqual(transactions.transactions[1].amountRaw, "TESTKUDOS:9.79"); + +  const trackResult = wallet.trackDepositGroup({ +    depositGroupId, +  }) + +  console.log(JSON.stringify(trackResult, undefined, 2)); +} diff --git a/packages/taler-wallet-cli/src/integrationtests/test-revocation.ts b/packages/taler-wallet-cli/src/integrationtests/test-revocation.ts index 052045302..a77797314 100644 --- a/packages/taler-wallet-cli/src/integrationtests/test-revocation.ts +++ b/packages/taler-wallet-cli/src/integrationtests/test-revocation.ts @@ -82,11 +82,6 @@ async function createTestEnvironment(      database: db.connStr,    }); -  exchange.changeConfig((config) => { -    config.setString("taler-exchange-secmod-eddsa", "lookahead_sign", "20 s"); -    config.setString("taler-exchange-secmod-rsa", "lookahead_sign", "20 s"); -  }); -    const exchangeBankAccount = await bank.createExchangeAccount(      "MyExchange",      "x", diff --git a/packages/taler-wallet-cli/src/integrationtests/testrunner.ts b/packages/taler-wallet-cli/src/integrationtests/testrunner.ts index 04e803b74..d20bf1895 100644 --- a/packages/taler-wallet-cli/src/integrationtests/testrunner.ts +++ b/packages/taler-wallet-cli/src/integrationtests/testrunner.ts @@ -49,6 +49,7 @@ import { runWithdrawalBankIntegratedTest } from "./test-withdrawal-bank-integrat  import M from "minimatch";  import { runMerchantExchangeConfusionTest } from "./test-merchant-exchange-confusion";  import { runLibeufinBasicTest } from "./test-libeufin-basic"; +import { runDepositTest } from "./test-deposit";  /**   * Test runner. @@ -64,6 +65,7 @@ interface TestMainFunction {  const allTests: TestMainFunction[] = [    runBankApiTest,    runClaimLoopTest, +  runDepositTest,    runExchangeManagementTest,    runFeeRegressionTest,    runLibeufinBasicTest, diff --git a/packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts b/packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts index ef149823c..d7eddd699 100644 --- a/packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts +++ b/packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts @@ -43,6 +43,7 @@ import {    DerivedTipPlanchet,    DeriveRefreshSessionRequest,    DeriveTipRequest, +  SignTrackTransactionRequest,  } from "../../types/cryptoTypes";  const logger = new Logger("cryptoApi.ts"); @@ -326,6 +327,10 @@ export class CryptoApi {      return this.doRpc<DerivedTipPlanchet>("createTipPlanchet", 1, req);    } +  signTrackTransaction(req: SignTrackTransactionRequest): Promise<string> { +    return this.doRpc<string>("signTrackTransaction", 1, req); +  } +    hashString(str: string): Promise<string> {      return this.doRpc<string>("hashString", 1, str);    } diff --git a/packages/taler-wallet-core/src/crypto/workers/cryptoImplementation.ts b/packages/taler-wallet-core/src/crypto/workers/cryptoImplementation.ts index 1f44d6277..87fad8634 100644 --- a/packages/taler-wallet-core/src/crypto/workers/cryptoImplementation.ts +++ b/packages/taler-wallet-core/src/crypto/workers/cryptoImplementation.ts @@ -72,11 +72,13 @@ import {    DerivedTipPlanchet,    DeriveRefreshSessionRequest,    DeriveTipRequest, +  SignTrackTransactionRequest,  } from "../../types/cryptoTypes";  const logger = new Logger("cryptoImplementation.ts");  enum SignaturePurpose { +  MERCHANT_TRACK_TRANSACTION = 1103,    WALLET_RESERVE_WITHDRAW = 1200,    WALLET_COIN_DEPOSIT = 1201,    MASTER_DENOMINATION_KEY_VALIDITY = 1025, @@ -211,6 +213,16 @@ export class CryptoImplementation {      return tipPlanchet;    } +  signTrackTransaction(req: SignTrackTransactionRequest): string { +    const p = buildSigPS(SignaturePurpose.MERCHANT_TRACK_TRANSACTION) +      .put(decodeCrock(req.contractTermsHash)) +      .put(decodeCrock(req.wireHash)) +      .put(decodeCrock(req.merchantPub)) +      .put(decodeCrock(req.coinPub)) +      .build(); +      return encodeCrock(eddsaSign(p, decodeCrock(req.merchantPriv))); +  } +    /**     * Create and sign a message to recoup a coin.     */ diff --git a/packages/taler-wallet-core/src/operations/deposits.ts b/packages/taler-wallet-core/src/operations/deposits.ts new file mode 100644 index 000000000..50921a170 --- /dev/null +++ b/packages/taler-wallet-core/src/operations/deposits.ts @@ -0,0 +1,420 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE.  See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/> + */ + +import { +  Amounts, +  CreateDepositGroupRequest, +  guardOperationException, +  Logger, +  NotificationType, +  TalerErrorDetails, +} from ".."; +import { kdf } from "../crypto/primitives/kdf"; +import { +  encodeCrock, +  getRandomBytes, +  stringToBytes, +} from "../crypto/talerCrypto"; +import { DepositGroupRecord, Stores } from "../types/dbTypes"; +import { ContractTerms } from "../types/talerTypes"; +import { CreateDepositGroupResponse, TrackDepositGroupRequest, TrackDepositGroupResponse } from "../types/walletTypes"; +import { +  buildCodecForObject, +  Codec, +  codecForString, +  codecOptional, +} from "../util/codec"; +import { canonicalJson } from "../util/helpers"; +import { readSuccessResponseJsonOrThrow } from "../util/http"; +import { parsePaytoUri } from "../util/payto"; +import { initRetryInfo, updateRetryInfoTimeout } from "../util/retries"; +import { +  codecForTimestamp, +  durationFromSpec, +  getTimestampNow, +  Timestamp, +  timestampAddDuration, +  timestampTruncateToSecond, +} from "../util/time"; +import { URL } from "../util/url"; +import { +  applyCoinSpend, +  extractContractData, +  generateDepositPermissions, +  getCoinsForPayment, +  getEffectiveDepositAmount, +  getTotalPaymentCost, +} from "./pay"; +import { InternalWalletState } from "./state"; + +/** + * Logger. + */ +const logger = new Logger("deposits.ts"); + +interface DepositSuccess { +  // Optional base URL of the exchange for looking up wire transfers +  // associated with this transaction.  If not given, +  // the base URL is the same as the one used for this request. +  // Can be used if the base URL for /transactions/ differs from that +  // for /coins/, i.e. for load balancing.  Clients SHOULD +  // respect the transaction_base_url if provided.  Any HTTP server +  // belonging to an exchange MUST generate a 307 or 308 redirection +  // to the correct base URL should a client uses the wrong base +  // URL, or if the base URL has changed since the deposit. +  transaction_base_url?: string; + +  // timestamp when the deposit was received by the exchange. +  exchange_timestamp: Timestamp; + +  // the EdDSA signature of TALER_DepositConfirmationPS using a current +  // signing key of the exchange affirming the successful +  // deposit and that the exchange will transfer the funds after the refund +  // deadline, or as soon as possible if the refund deadline is zero. +  exchange_sig: string; + +  // 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: string; +} + +const codecForDepositSuccess = (): Codec<DepositSuccess> => +  buildCodecForObject<DepositSuccess>() +    .property("exchange_pub", codecForString()) +    .property("exchange_sig", codecForString()) +    .property("exchange_timestamp", codecForTimestamp) +    .property("transaction_base_url", codecOptional(codecForString())) +    .build("DepositSuccess"); + +function hashWire(paytoUri: string, salt: string): string { +  const r = kdf( +    64, +    stringToBytes(paytoUri + "\0"), +    stringToBytes(salt + "\0"), +    stringToBytes("merchant-wire-signature"), +  ); +  return encodeCrock(r); +} + +async function resetDepositGroupRetry( +  ws: InternalWalletState, +  depositGroupId: string, +): Promise<void> { +  await ws.db.mutate(Stores.depositGroups, depositGroupId, (x) => { +    if (x.retryInfo.active) { +      x.retryInfo = initRetryInfo(); +    } +    return x; +  }); +} + +async function incrementDepositRetry( +  ws: InternalWalletState, +  depositGroupId: string, +  err: TalerErrorDetails | undefined, +): Promise<void> { +  await ws.db.runWithWriteTransaction([Stores.depositGroups], async (tx) => { +    const r = await tx.get(Stores.depositGroups, depositGroupId); +    if (!r) { +      return; +    } +    if (!r.retryInfo) { +      return; +    } +    r.retryInfo.retryCounter++; +    updateRetryInfoTimeout(r.retryInfo); +    r.lastError = err; +    await tx.put(Stores.depositGroups, r); +  }); +  if (err) { +    ws.notify({ type: NotificationType.DepositOperationError, error: err }); +  } +} + +export async function processDepositGroup( +  ws: InternalWalletState, +  depositGroupId: string, +  forceNow = false, +): Promise<void> { +  await ws.memoProcessDeposit.memo(depositGroupId, async () => { +    const onOpErr = (e: TalerErrorDetails): Promise<void> => +      incrementDepositRetry(ws, depositGroupId, e); +    return await guardOperationException( +      async () => await processDepositGroupImpl(ws, depositGroupId, forceNow), +      onOpErr, +    ); +  }); +} + +async function processDepositGroupImpl( +  ws: InternalWalletState, +  depositGroupId: string, +  forceNow: boolean = false, +): Promise<void> { +  if (forceNow) { +    await resetDepositGroupRetry(ws, depositGroupId); +  } +  const depositGroup = await ws.db.get(Stores.depositGroups, depositGroupId); +  if (!depositGroup) { +    logger.warn(`deposit group ${depositGroupId} not found`); +    return; +  } +  if (depositGroup.timestampFinished) { +    logger.trace(`deposit group ${depositGroupId} already finished`); +    return; +  } + +  const contractData = extractContractData( +    depositGroup.contractTermsRaw, +    depositGroup.contractTermsHash, +    "", +  ); + +  const depositPermissions = await generateDepositPermissions( +    ws, +    depositGroup.payCoinSelection, +    contractData, +  ); + +  for (let i = 0; i < depositPermissions.length; i++) { +    if (depositGroup.depositedPerCoin[i]) { +      continue; +    } +    const perm = depositPermissions[i]; +    const url = new URL(`/coins/${perm.coin_pub}/deposit`, perm.exchange_url); +    const httpResp = await ws.http.postJson(url.href, { +      contribution: Amounts.stringify(perm.contribution), +      wire: depositGroup.wire, +      h_wire: depositGroup.contractTermsRaw.h_wire, +      h_contract_terms: depositGroup.contractTermsHash, +      ub_sig: perm.ub_sig, +      timestamp: depositGroup.contractTermsRaw.timestamp, +      wire_transfer_deadline: +        depositGroup.contractTermsRaw.wire_transfer_deadline, +      refund_deadline: depositGroup.contractTermsRaw.refund_deadline, +      coin_sig: perm.coin_sig, +      denom_pub_hash: perm.h_denom, +      merchant_pub: depositGroup.merchantPub, +    }); +    await readSuccessResponseJsonOrThrow(httpResp, codecForDepositSuccess()); +    await ws.db.runWithWriteTransaction([Stores.depositGroups], async (tx) => { +      const dg = await tx.get(Stores.depositGroups, depositGroupId); +      if (!dg) { +        return; +      } +      dg.depositedPerCoin[i] = true; +      await tx.put(Stores.depositGroups, dg); +    }); +  } + +  await ws.db.runWithWriteTransaction([Stores.depositGroups], async (tx) => { +    const dg = await tx.get(Stores.depositGroups, depositGroupId); +    if (!dg) { +      return; +    } +    let allDeposited = true; +    for (const d of depositGroup.depositedPerCoin) { +      if (!d) { +        allDeposited = false; +      } +    } +    if (allDeposited) { +      dg.timestampFinished = getTimestampNow(); +      await tx.put(Stores.depositGroups, dg); +    } +  }); +} + + +export async function trackDepositGroup( +  ws: InternalWalletState, +  req: TrackDepositGroupRequest, +): Promise<TrackDepositGroupResponse> { +  const responses: { +    status: number; +    body: any; +  }[] = []; +  const depositGroup = await ws.db.get( +    Stores.depositGroups, +    req.depositGroupId, +  ); +  if (!depositGroup) { +    throw Error("deposit group not found"); +  } +  const contractData = extractContractData( +    depositGroup.contractTermsRaw, +    depositGroup.contractTermsHash, +    "", +  ); + +  const depositPermissions = await generateDepositPermissions( +    ws, +    depositGroup.payCoinSelection, +    contractData, +  ); + +  const wireHash = depositGroup.contractTermsRaw.h_wire; + +  for (const dp of depositPermissions) { +    const url = new URL( +      `/deposits/${wireHash}/${depositGroup.merchantPub}/${depositGroup.contractTermsHash}/${dp.coin_pub}`, +      dp.exchange_url, +    ); +    const sig = await ws.cryptoApi.signTrackTransaction({ +      coinPub: dp.coin_pub, +      contractTermsHash: depositGroup.contractTermsHash, +      merchantPriv: depositGroup.merchantPriv, +      merchantPub: depositGroup.merchantPub, +      wireHash, +    }); +    url.searchParams.set("merchant_sig", sig); +    const httpResp = await ws.http.get(url.href); +    const body = await httpResp.json(); +    responses.push({ +      body, +      status: httpResp.status, +    }); +  } +  return { +    responses, +  }; +} + +export async function createDepositGroup( +  ws: InternalWalletState, +  req: CreateDepositGroupRequest, +): Promise<CreateDepositGroupResponse> { +  const p = parsePaytoUri(req.depositPaytoUri); +  if (!p) { +    throw Error("invalid payto URI"); +  } + +  const amount = Amounts.parseOrThrow(req.amount); + +  const allExchanges = await ws.db.iter(Stores.exchanges).toArray(); +  const exchangeInfos: { url: string; master_pub: string }[] = []; +  for (const e of allExchanges) { +    if (!e.details) { +      continue; +    } +    if (e.details.currency != amount.currency) { +      continue; +    } +    exchangeInfos.push({ +      master_pub: e.details.masterPublicKey, +      url: e.baseUrl, +    }); +  } + +  const timestamp = getTimestampNow(); +  const timestampRound = timestampTruncateToSecond(timestamp); +  const noncePair = await ws.cryptoApi.createEddsaKeypair(); +  const merchantPair = await ws.cryptoApi.createEddsaKeypair(); +  const wireSalt = encodeCrock(getRandomBytes(64)); +  const wireHash = hashWire(req.depositPaytoUri, wireSalt); +  const contractTerms: ContractTerms = { +    auditors: [], +    exchanges: exchangeInfos, +    amount: req.amount, +    max_fee: Amounts.stringify(amount), +    max_wire_fee: Amounts.stringify(amount), +    wire_method: p.targetType, +    timestamp: timestampRound, +    merchant_base_url: "", +    summary: "", +    nonce: noncePair.pub, +    wire_transfer_deadline: timestampRound, +    order_id: "", +    h_wire: wireHash, +    pay_deadline: timestampAddDuration( +      timestampRound, +      durationFromSpec({ hours: 1 }), +    ), +    merchant: { +      name: "", +    }, +    merchant_pub: merchantPair.pub, +    refund_deadline: { t_ms: 0 }, +  }; + +  const contractTermsHash = await ws.cryptoApi.hashString( +    canonicalJson(contractTerms), +  ); + +  const contractData = extractContractData( +    contractTerms, +    contractTermsHash, +    "", +  ); + +  const payCoinSel = await getCoinsForPayment(ws, contractData); + +  if (!payCoinSel) { +    throw Error("insufficient funds"); +  } + +  const totalDepositCost = await getTotalPaymentCost(ws, payCoinSel); + +  const depositGroupId = encodeCrock(getRandomBytes(32)); + +  const effectiveDepositAmount = await getEffectiveDepositAmount( +    ws, +    p.targetType, +    payCoinSel, +  ); + +  const depositGroup: DepositGroupRecord = { +    contractTermsHash, +    contractTermsRaw: contractTerms, +    depositGroupId, +    noncePriv: noncePair.priv, +    noncePub: noncePair.pub, +    timestampCreated: timestamp, +    timestampFinished: undefined, +    payCoinSelection: payCoinSel, +    depositedPerCoin: payCoinSel.coinPubs.map((x) => false), +    merchantPriv: merchantPair.priv, +    merchantPub: merchantPair.pub, +    totalPayCost: totalDepositCost, +    effectiveDepositAmount, +    wire: { +      payto_uri: req.depositPaytoUri, +      salt: wireSalt, +    }, +    retryInfo: initRetryInfo(true), +    lastError: undefined, +  }; + +  await ws.db.runWithWriteTransaction( +    [ +      Stores.depositGroups, +      Stores.coins, +      Stores.refreshGroups, +      Stores.denominations, +    ], +    async (tx) => { +      await applyCoinSpend(ws, tx, payCoinSel); +      await tx.put(Stores.depositGroups, depositGroup); +    }, +  ); + +  await ws.db.put(Stores.depositGroups, depositGroup); + +  return { depositGroupId }; +} diff --git a/packages/taler-wallet-core/src/operations/pay.ts b/packages/taler-wallet-core/src/operations/pay.ts index ee42d347e..d8168acdf 100644 --- a/packages/taler-wallet-core/src/operations/pay.ts +++ b/packages/taler-wallet-core/src/operations/pay.ts @@ -36,6 +36,8 @@ import {    DenominationRecord,    PayCoinSelection,    AbortStatus, +  AllowedExchangeInfo, +  AllowedAuditorInfo,  } from "../types/dbTypes";  import { NotificationType } from "../types/notifications";  import { @@ -43,6 +45,7 @@ import {    codecForContractTerms,    CoinDepositPermission,    codecForMerchantPayResponse, +  ContractTerms,  } from "../types/talerTypes";  import {    ConfirmPayResult, @@ -72,7 +75,8 @@ import {    durationMin,    isTimestampExpired,    durationMul, -  durationAdd, +  Timestamp, +  timestampIsBetween,  } from "../util/time";  import { strcmp, canonicalJson } from "../util/helpers";  import { @@ -88,6 +92,7 @@ import {    updateRetryInfoTimeout,    getRetryDuration,  } from "../util/retries"; +import { TransactionHandle } from "../util/query";  /**   * Logger. @@ -163,6 +168,49 @@ export async function getTotalPaymentCost(  }  /** + * Get the amount that will be deposited on the merchant's bank + * account, not considering aggregation. + */ +export async function getEffectiveDepositAmount( +  ws: InternalWalletState, +  wireType: string, +  pcs: PayCoinSelection, +): Promise<AmountJson> { +  const amt: AmountJson[] = []; +  const fees: AmountJson[] = []; +  const exchangeSet: Set<string> = new Set(); +  for (let i = 0; i < pcs.coinPubs.length; i++) { +    const coin = await ws.db.get(Stores.coins, pcs.coinPubs[i]); +    if (!coin) { +      throw Error("can't calculate deposit amountt, coin not found"); +    } +    const denom = await ws.db.get(Stores.denominations, [ +      coin.exchangeBaseUrl, +      coin.denomPubHash, +    ]); +    if (!denom) { +      throw Error("can't find denomination to calculate deposit amount"); +    } +    amt.push(pcs.coinContributions[i]); +    fees.push(denom.feeDeposit); +    exchangeSet.add(coin.exchangeBaseUrl); +  } +  for (const exchangeUrl of exchangeSet.values()) { +    const exchange = await ws.db.get(Stores.exchanges, exchangeUrl); +    if (!exchange?.wireInfo) { +      continue; +    } +    const fee = exchange.wireInfo.feesForType[wireType].find((x) => { +      return timestampIsBetween(getTimestampNow(), x.startStamp, x.endStamp); +    })?.wireFee; +    if (fee) { +      fees.push(fee); +    } +  } +  return Amounts.sub(Amounts.sum(amt).amount, Amounts.sum(fees).amount).amount; +} + +/**   * Given a list of available coins, select coins to spend under the merchant's   * constraints.   * @@ -277,17 +325,36 @@ export function isSpendableCoin(    return true;  } +export interface CoinSelectionRequest { +  amount: AmountJson; +  allowedAuditors: AllowedAuditorInfo[]; +  allowedExchanges: AllowedExchangeInfo[]; + +  /** +   * Timestamp of the contract. +   */ +  timestamp: Timestamp; + +  wireMethod: string; + +  wireFeeAmortization: number; + +  maxWireFee: AmountJson; + +  maxDepositFee: AmountJson; +} +  /**   * Select coins from the wallet's database that can be used   * to pay for the given contract.   *   * If payment is impossible, undefined is returned.   */ -async function getCoinsForPayment( +export async function getCoinsForPayment(    ws: InternalWalletState, -  contractData: WalletContractData, +  req: CoinSelectionRequest,  ): Promise<PayCoinSelection | undefined> { -  const remainingAmount = contractData.amount; +  const remainingAmount = req.amount;    const exchanges = await ws.db.iter(Stores.exchanges).toArray(); @@ -303,7 +370,7 @@ async function getCoinsForPayment(      }      // is the exchange explicitly allowed? -    for (const allowedExchange of contractData.allowedExchanges) { +    for (const allowedExchange of req.allowedExchanges) {        if (allowedExchange.exchangePub === exchangeDetails.masterPublicKey) {          isOkay = true;          break; @@ -312,7 +379,7 @@ async function getCoinsForPayment(      // is the exchange allowed because of one of its auditors?      if (!isOkay) { -      for (const allowedAuditor of contractData.allowedAuditors) { +      for (const allowedAuditor of req.allowedAuditors) {          for (const auditor of exchangeDetails.auditors) {            if (auditor.auditor_pub === allowedAuditor.auditorPub) {              isOkay = true; @@ -374,11 +441,8 @@ async function getCoinsForPayment(      }      let wireFee: AmountJson | undefined; -    for (const fee of exchangeFees.feesForType[contractData.wireMethod] || []) { -      if ( -        fee.startStamp <= contractData.timestamp && -        fee.endStamp >= contractData.timestamp -      ) { +    for (const fee of exchangeFees.feesForType[req.wireMethod] || []) { +      if (fee.startStamp <= req.timestamp && fee.endStamp >= req.timestamp) {          wireFee = fee.wireFee;          break;        } @@ -386,12 +450,9 @@ async function getCoinsForPayment(      let customerWireFee: AmountJson; -    if (wireFee) { -      const amortizedWireFee = Amounts.divide( -        wireFee, -        contractData.wireFeeAmortization, -      ); -      if (Amounts.cmp(contractData.maxWireFee, amortizedWireFee) < 0) { +    if (wireFee && req.wireFeeAmortization) { +      const amortizedWireFee = Amounts.divide(wireFee, req.wireFeeAmortization); +      if (Amounts.cmp(req.maxWireFee, amortizedWireFee) < 0) {          customerWireFee = amortizedWireFee;        } else {          customerWireFee = Amounts.getZero(currency); @@ -405,7 +466,7 @@ async function getCoinsForPayment(        acis,        remainingAmount,        customerWireFee, -      contractData.maxDepositFee, +      req.maxDepositFee,      );      if (res) {        return res; @@ -414,6 +475,37 @@ async function getCoinsForPayment(    return undefined;  } +export async function applyCoinSpend( +  ws: InternalWalletState, +  tx: TransactionHandle< +    | typeof Stores.coins +    | typeof Stores.refreshGroups +    | typeof Stores.denominations +  >, +  coinSelection: PayCoinSelection, +) { +  for (let i = 0; i < coinSelection.coinPubs.length; i++) { +    const coin = await tx.get(Stores.coins, coinSelection.coinPubs[i]); +    if (!coin) { +      throw Error("coin allocated for payment doesn't exist anymore"); +    } +    coin.status = CoinStatus.Dormant; +    const remaining = Amounts.sub( +      coin.currentAmount, +      coinSelection.coinContributions[i], +    ); +    if (remaining.saturated) { +      throw Error("not enough remaining balance on coin for payment"); +    } +    coin.currentAmount = remaining.amount; +    await tx.put(Stores.coins, coin); +  } +  const refreshCoinPubs = coinSelection.coinPubs.map((x) => ({ +    coinPub: x, +  })); +  await createRefreshGroup(ws, tx, refreshCoinPubs, RefreshReason.Pay); +} +  /**   * Record all information that is necessary to   * pay for a proposal in the wallet's database. @@ -480,26 +572,7 @@ async function recordConfirmPay(          await tx.put(Stores.proposals, p);        }        await tx.put(Stores.purchases, t); -      for (let i = 0; i < coinSelection.coinPubs.length; i++) { -        const coin = await tx.get(Stores.coins, coinSelection.coinPubs[i]); -        if (!coin) { -          throw Error("coin allocated for payment doesn't exist anymore"); -        } -        coin.status = CoinStatus.Dormant; -        const remaining = Amounts.sub( -          coin.currentAmount, -          coinSelection.coinContributions[i], -        ); -        if (remaining.saturated) { -          throw Error("not enough remaining balance on coin for payment"); -        } -        coin.currentAmount = remaining.amount; -        await tx.put(Stores.coins, coin); -      } -      const refreshCoinPubs = coinSelection.coinPubs.map((x) => ({ -        coinPub: x, -      })); -      await createRefreshGroup(ws, tx, refreshCoinPubs, RefreshReason.Pay); +      await applyCoinSpend(ws, tx, coinSelection);      },    ); @@ -609,6 +682,50 @@ function getPayRequestTimeout(purchase: PurchaseRecord): Duration {    );  } +export function extractContractData( +  parsedContractTerms: ContractTerms, +  contractTermsHash: string, +  merchantSig: string, +): WalletContractData { +  const amount = Amounts.parseOrThrow(parsedContractTerms.amount); +  let maxWireFee: AmountJson; +  if (parsedContractTerms.max_wire_fee) { +    maxWireFee = Amounts.parseOrThrow(parsedContractTerms.max_wire_fee); +  } else { +    maxWireFee = Amounts.getZero(amount.currency); +  } +  return { +    amount, +    contractTermsHash: contractTermsHash, +    fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "", +    merchantBaseUrl: parsedContractTerms.merchant_base_url, +    merchantPub: parsedContractTerms.merchant_pub, +    merchantSig, +    orderId: parsedContractTerms.order_id, +    summary: parsedContractTerms.summary, +    autoRefund: parsedContractTerms.auto_refund, +    maxWireFee, +    payDeadline: parsedContractTerms.pay_deadline, +    refundDeadline: parsedContractTerms.refund_deadline, +    wireFeeAmortization: parsedContractTerms.wire_fee_amortization || 1, +    allowedAuditors: parsedContractTerms.auditors.map((x) => ({ +      auditorBaseUrl: x.url, +      auditorPub: x.auditor_pub, +    })), +    allowedExchanges: parsedContractTerms.exchanges.map((x) => ({ +      exchangeBaseUrl: x.url, +      exchangePub: x.master_pub, +    })), +    timestamp: parsedContractTerms.timestamp, +    wireMethod: parsedContractTerms.wire_method, +    wireInfoHash: parsedContractTerms.h_wire, +    maxDepositFee: Amounts.parseOrThrow(parsedContractTerms.max_fee), +    merchant: parsedContractTerms.merchant, +    products: parsedContractTerms.products, +    summaryI18n: parsedContractTerms.summary_i18n, +  }; +} +  async function processDownloadProposalImpl(    ws: InternalWalletState,    proposalId: string, @@ -714,6 +831,12 @@ async function processDownloadProposalImpl(      throw new OperationFailedAndReportedError(err);    } +  const contractData = extractContractData( +    parsedContractTerms, +    contractTermsHash, +    proposalResp.sig, +  ); +    await ws.db.runWithWriteTransaction(      [Stores.proposals, Stores.purchases],      async (tx) => { @@ -724,44 +847,8 @@ async function processDownloadProposalImpl(        if (p.proposalStatus !== ProposalStatus.DOWNLOADING) {          return;        } -      const amount = Amounts.parseOrThrow(parsedContractTerms.amount); -      let maxWireFee: AmountJson; -      if (parsedContractTerms.max_wire_fee) { -        maxWireFee = Amounts.parseOrThrow(parsedContractTerms.max_wire_fee); -      } else { -        maxWireFee = Amounts.getZero(amount.currency); -      }        p.download = { -        contractData: { -          amount, -          contractTermsHash: contractTermsHash, -          fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "", -          merchantBaseUrl: parsedContractTerms.merchant_base_url, -          merchantPub: parsedContractTerms.merchant_pub, -          merchantSig: proposalResp.sig, -          orderId: parsedContractTerms.order_id, -          summary: parsedContractTerms.summary, -          autoRefund: parsedContractTerms.auto_refund, -          maxWireFee, -          payDeadline: parsedContractTerms.pay_deadline, -          refundDeadline: parsedContractTerms.refund_deadline, -          wireFeeAmortization: parsedContractTerms.wire_fee_amortization || 1, -          allowedAuditors: parsedContractTerms.auditors.map((x) => ({ -            auditorBaseUrl: x.url, -            auditorPub: x.auditor_pub, -          })), -          allowedExchanges: parsedContractTerms.exchanges.map((x) => ({ -            exchangeBaseUrl: x.url, -            exchangePub: x.master_pub, -          })), -          timestamp: parsedContractTerms.timestamp, -          wireMethod: parsedContractTerms.wire_method, -          wireInfoHash: parsedContractTerms.h_wire, -          maxDepositFee: Amounts.parseOrThrow(parsedContractTerms.max_fee), -          merchant: parsedContractTerms.merchant, -          products: parsedContractTerms.products, -          summaryI18n: parsedContractTerms.summary_i18n, -        }, +        contractData,          contractTermsRaw: proposalResp.contract_terms,        };        if ( @@ -1210,7 +1297,7 @@ export async function preparePayForUri(   *   * Accesses the database and the crypto worker.   */ -async function generateDepositPermissions( +export async function generateDepositPermissions(    ws: InternalWalletState,    payCoinSel: PayCoinSelection,    contractData: WalletContractData, diff --git a/packages/taler-wallet-core/src/operations/pending.ts b/packages/taler-wallet-core/src/operations/pending.ts index cc693a49d..bae281937 100644 --- a/packages/taler-wallet-core/src/operations/pending.ts +++ b/packages/taler-wallet-core/src/operations/pending.ts @@ -445,6 +445,34 @@ async function gatherRecoupPending(    });  } +async function gatherDepositPending( +  tx: TransactionHandle<typeof Stores.depositGroups>, +  now: Timestamp, +  resp: PendingOperationsResponse, +  onlyDue = false, +): Promise<void> { +  await tx.iter(Stores.depositGroups).forEach((dg) => { +    if (dg.timestampFinished) { +      return; +    } +    resp.nextRetryDelay = updateRetryDelay( +      resp.nextRetryDelay, +      now, +      dg.retryInfo.nextRetry, +    ); +    if (onlyDue && dg.retryInfo.nextRetry.t_ms > now.t_ms) { +      return; +    } +    resp.pendingOperations.push({ +      type: PendingOperationType.Deposit, +      givesLifeness: true, +      depositGroupId: dg.depositGroupId, +      retryInfo: dg.retryInfo, +      lastError: dg.lastError, +    }); +  }); +} +  export async function getPendingOperations(    ws: InternalWalletState,    { onlyDue = false } = {}, @@ -462,6 +490,7 @@ export async function getPendingOperations(        Stores.purchases,        Stores.recoupGroups,        Stores.planchets, +      Stores.depositGroups,      ],      async (tx) => {        const walletBalance = await getBalancesInsideTransaction(ws, tx); @@ -479,6 +508,7 @@ export async function getPendingOperations(        await gatherTipPending(tx, now, resp, onlyDue);        await gatherPurchasePending(tx, now, resp, onlyDue);        await gatherRecoupPending(tx, now, resp, onlyDue); +      await gatherDepositPending(tx, now, resp, onlyDue);        return resp;      },    ); diff --git a/packages/taler-wallet-core/src/operations/refund.ts b/packages/taler-wallet-core/src/operations/refund.ts index 13df438e4..28d48d5ba 100644 --- a/packages/taler-wallet-core/src/operations/refund.ts +++ b/packages/taler-wallet-core/src/operations/refund.ts @@ -600,6 +600,7 @@ async function processPurchaseQueryRefundImpl(        `orders/${purchase.download.contractData.orderId}/refund`,        purchase.download.contractData.merchantBaseUrl,      ); +          logger.trace(`making refund request to ${requestUrl.href}`); diff --git a/packages/taler-wallet-core/src/operations/state.ts b/packages/taler-wallet-core/src/operations/state.ts index 645ad8ad3..ce52affe4 100644 --- a/packages/taler-wallet-core/src/operations/state.ts +++ b/packages/taler-wallet-core/src/operations/state.ts @@ -41,6 +41,7 @@ export class InternalWalletState {    memoGetBalance: AsyncOpMemoSingle<BalancesResponse> = new AsyncOpMemoSingle();    memoProcessRefresh: AsyncOpMemoMap<void> = new AsyncOpMemoMap();    memoProcessRecoup: AsyncOpMemoMap<void> = new AsyncOpMemoMap(); +  memoProcessDeposit: AsyncOpMemoMap<void> = new AsyncOpMemoMap();    cryptoApi: CryptoApi;    listeners: NotificationListener[] = []; diff --git a/packages/taler-wallet-core/src/operations/transactions.ts b/packages/taler-wallet-core/src/operations/transactions.ts index c7e6a9c53..d49031551 100644 --- a/packages/taler-wallet-core/src/operations/transactions.ts +++ b/packages/taler-wallet-core/src/operations/transactions.ts @@ -96,6 +96,7 @@ export async function getTransactions(        Stores.withdrawalGroups,        Stores.planchets,        Stores.recoupGroups, +      Stores.depositGroups,      ],      // Report withdrawals that are currently in progress.      async (tx) => { @@ -203,6 +204,28 @@ export async function getTransactions(          });        }); +      tx.iter(Stores.depositGroups).forEachAsync(async (dg) => { +        const amount = Amounts.parseOrThrow(dg.contractTermsRaw.amount); +        if (shouldSkipCurrency(transactionsRequest, amount.currency)) { +          return; +        } + +        transactions.push({ +          type: TransactionType.Deposit, +          amountRaw: Amounts.stringify(dg.effectiveDepositAmount), +          amountEffective: Amounts.stringify(dg.totalPayCost), +          pending: !dg.timestampFinished, +          timestamp: dg.timestampCreated, +          targetPaytoUri: dg.wire.payto_uri, +          transactionId: makeEventId( +            TransactionType.Deposit, +            dg.depositGroupId, +          ), +          depositGroupId: dg.depositGroupId, +          ...(dg.lastError ? { error: dg.lastError } : {}), +        }); +      }); +        tx.iter(Stores.purchases).forEachAsync(async (pr) => {          if (            shouldSkipCurrency( diff --git a/packages/taler-wallet-core/src/types/cryptoTypes.ts b/packages/taler-wallet-core/src/types/cryptoTypes.ts index eb18d83fc..9b67b5963 100644 --- a/packages/taler-wallet-core/src/types/cryptoTypes.ts +++ b/packages/taler-wallet-core/src/types/cryptoTypes.ts @@ -131,3 +131,11 @@ export interface DerivedTipPlanchet {    coinPriv: string;    coinPub: string;  } + +export interface SignTrackTransactionRequest { +  contractTermsHash: string; +  wireHash: string; +  coinPub: string; +  merchantPriv: string; +  merchantPub: string; +} diff --git a/packages/taler-wallet-core/src/types/dbTypes.ts b/packages/taler-wallet-core/src/types/dbTypes.ts index e0d137535..bc7d7728d 100644 --- a/packages/taler-wallet-core/src/types/dbTypes.ts +++ b/packages/taler-wallet-core/src/types/dbTypes.ts @@ -32,6 +32,7 @@ import {    Product,    InternationalizedString,    AmountString, +  ContractTerms,  } from "./talerTypes";  import { Index, Store } from "../util/query"; @@ -1481,6 +1482,54 @@ export interface BackupProviderRecord {    lastError: TalerErrorDetails | undefined;  } +/** + * Group of deposits made by the wallet. + */ +export interface DepositGroupRecord { +  depositGroupId: string; + +  merchantPub: string; +  merchantPriv: string; + +  noncePriv: string; +  noncePub: string; + +  /** +   * Wire information used by all deposits in this +   * deposit group. +   */ +  wire: { +    payto_uri: string; +    salt: string; +  }; + +  /** +   * Verbatim contract terms. +   */ +  contractTermsRaw: ContractTerms; + +  contractTermsHash: string; + +  payCoinSelection: PayCoinSelection; + +  totalPayCost: AmountJson; + +  effectiveDepositAmount: AmountJson; + +  depositedPerCoin: boolean[]; + +  timestampCreated: Timestamp; + +  timestampFinished: Timestamp | undefined; + +  lastError: TalerErrorDetails | undefined; + +  /** +   * Retry info. +   */ +  retryInfo: RetryInfo; +} +  class ExchangesStore extends Store<"exchanges", ExchangeRecord> {    constructor() {      super("exchanges", { keyPath: "baseUrl" }); @@ -1657,6 +1706,12 @@ class BackupProvidersStore extends Store<    }  } +class DepositGroupsStore extends Store<"depositGroups", DepositGroupRecord> { +  constructor() { +    super("depositGroups", { keyPath: "depositGroupId" }); +  } +} +  /**   * The stores and indices for the wallet database.   */ @@ -1683,6 +1738,7 @@ export const Stores = {    planchets: new PlanchetsStore(),    bankWithdrawUris: new BankWithdrawUrisStore(),    backupProviders: new BackupProvidersStore(), +  depositGroups: new DepositGroupsStore(),  };  export class MetaConfigStore extends Store<"metaConfig", ConfigRecord<any>> { diff --git a/packages/taler-wallet-core/src/types/notifications.ts b/packages/taler-wallet-core/src/types/notifications.ts index 8601c65b3..edfb377b9 100644 --- a/packages/taler-wallet-core/src/types/notifications.ts +++ b/packages/taler-wallet-core/src/types/notifications.ts @@ -60,6 +60,7 @@ export enum NotificationType {    PendingOperationProcessed = "pending-operation-processed",    ProposalRefused = "proposal-refused",    ReserveRegisteredWithBank = "reserve-registered-with-bank", +  DepositOperationError = "deposit-operation-error",  }  export interface ProposalAcceptedNotification { @@ -193,6 +194,11 @@ export interface RecoupOperationErrorNotification {    error: TalerErrorDetails;  } +export interface DepositOperationErrorNotification { +  type: NotificationType.DepositOperationError; +  error: TalerErrorDetails; +} +  export interface ReserveOperationErrorNotification {    type: NotificationType.ReserveOperationError;    error: TalerErrorDetails; @@ -256,6 +262,7 @@ export type WalletNotification =    | WithdrawalGroupCreatedNotification    | CoinWithdrawnNotification    | RecoupOperationErrorNotification +  | DepositOperationErrorNotification    | InternalErrorNotification    | PendingOperationProcessedNotification    | ProposalRefusedNotification diff --git a/packages/taler-wallet-core/src/types/pendingTypes.ts b/packages/taler-wallet-core/src/types/pendingTypes.ts index 18d9a2fa4..d41d2a977 100644 --- a/packages/taler-wallet-core/src/types/pendingTypes.ts +++ b/packages/taler-wallet-core/src/types/pendingTypes.ts @@ -40,6 +40,7 @@ export enum PendingOperationType {    TipChoice = "tip-choice",    TipPickup = "tip-pickup",    Withdraw = "withdraw", +  Deposit = "deposit",  }  /** @@ -60,6 +61,7 @@ export type PendingOperationInfo = PendingOperationInfoCommon &      | PendingTipPickupOperation      | PendingWithdrawOperation      | PendingRecoupOperation +    | PendingDepositOperation    );  /** @@ -228,6 +230,16 @@ export interface PendingWithdrawOperation {  }  /** + * Status of an ongoing deposit operation. + */ +export interface PendingDepositOperation { +  type: PendingOperationType.Deposit; +  lastError: TalerErrorDetails | undefined; +  retryInfo: RetryInfo; +  depositGroupId: string; +} + +/**   * Fields that are present in every pending operation.   */  export interface PendingOperationInfoCommon { diff --git a/packages/taler-wallet-core/src/types/talerTypes.ts b/packages/taler-wallet-core/src/types/talerTypes.ts index 80aa1fe37..f3749afe7 100644 --- a/packages/taler-wallet-core/src/types/talerTypes.ts +++ b/packages/taler-wallet-core/src/types/talerTypes.ts @@ -484,7 +484,7 @@ export class ContractTerms {    /**     * Extra data, interpreted by the mechant only.     */ -  extra: any; +  extra?: any;  }  /** diff --git a/packages/taler-wallet-core/src/types/transactionsTypes.ts b/packages/taler-wallet-core/src/types/transactionsTypes.ts index 0a683f298..81dc78039 100644 --- a/packages/taler-wallet-core/src/types/transactionsTypes.ts +++ b/packages/taler-wallet-core/src/types/transactionsTypes.ts @@ -94,7 +94,8 @@ export type Transaction =    | TransactionPayment    | TransactionRefund    | TransactionTip -  | TransactionRefresh; +  | TransactionRefresh +  | TransactionDeposit;  export enum TransactionType {    Withdrawal = "withdrawal", @@ -102,6 +103,7 @@ export enum TransactionType {    Refund = "refund",    Refresh = "refresh",    Tip = "tip", +  Deposit = "deposit",  }  export enum WithdrawalType { @@ -308,6 +310,31 @@ interface TransactionRefresh extends TransactionCommon {    amountEffective: AmountString;  } +/** + * Deposit transaction, which effectively sends + * money from this wallet somewhere else. + */ +interface TransactionDeposit extends TransactionCommon { +  type: TransactionType.Deposit; + +  depositGroupId: string; + +  /** +   * Target for the deposit. +   */ +  targetPaytoUri: string; + +  /** +   * Raw amount that is being deposited +   */ +  amountRaw: AmountString; + +  /** +   * Effective amount that is being deposited +   */ +  amountEffective: AmountString; +} +  export const codecForTransactionsRequest = (): Codec<TransactionsRequest> =>    buildCodecForObject<TransactionsRequest>()      .property("currency", codecOptional(codecForString())) diff --git a/packages/taler-wallet-core/src/types/walletTypes.ts b/packages/taler-wallet-core/src/types/walletTypes.ts index 235ea11f1..f195918ac 100644 --- a/packages/taler-wallet-core/src/types/walletTypes.ts +++ b/packages/taler-wallet-core/src/types/walletTypes.ts @@ -1006,3 +1006,38 @@ export const codecForAbortPayWithRefundRequest = (): Codec<    buildCodecForObject<AbortPayWithRefundRequest>()      .property("proposalId", codecForString())      .build("AbortPayWithRefundRequest"); + +export interface CreateDepositGroupRequest { +  depositPaytoUri: string; +  amount: string; +} + +export const codecForCreateDepositGroupRequest = (): Codec< +  CreateDepositGroupRequest +> => +  buildCodecForObject<CreateDepositGroupRequest>() +    .property("amount", codecForAmountString()) +    .property("depositPaytoUri", codecForString()) +    .build("CreateDepositGroupRequest"); + +export interface CreateDepositGroupResponse { +  depositGroupId: string; +} + +export interface TrackDepositGroupRequest { +  depositGroupId: string; +} + +export interface TrackDepositGroupResponse { +  responses: { +    status: number; +    body: any; +  }[]; +} + +export const codecForTrackDepositGroupRequest = (): Codec< +  TrackDepositGroupRequest +> => +  buildCodecForObject<TrackDepositGroupRequest>() +    .property("depositGroupId", codecForAmountString()) +    .build("TrackDepositGroupRequest"); diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index 65b816cc3..51987c349 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -53,6 +53,7 @@ import {    CoinSourceType,    RefundState,    MetaStores, +  DepositGroupRecord,  } from "./types/dbTypes";  import { CoinDumpJson, WithdrawUriInfoResponse } from "./types/talerTypes";  import { @@ -96,6 +97,12 @@ import {    codecForAbortPayWithRefundRequest,    ApplyRefundResponse,    RecoveryLoadRequest, +  codecForCreateDepositGroupRequest, +  CreateDepositGroupRequest, +  CreateDepositGroupResponse, +  codecForTrackDepositGroupRequest, +  TrackDepositGroupRequest, +  TrackDepositGroupResponse,  } from "./types/walletTypes";  import { Logger } from "./util/logging"; @@ -173,6 +180,11 @@ import {    BackupInfo,    loadBackupRecovery,  } from "./operations/backup"; +import { +  createDepositGroup, +  processDepositGroup, +  trackDepositGroup, +} from "./operations/deposits";  const builtinCurrencies: CurrencyRecord[] = [    { @@ -299,6 +311,9 @@ export class Wallet {        case PendingOperationType.ExchangeCheckRefresh:          await autoRefresh(this.ws, pending.exchangeBaseUrl);          break; +      case PendingOperationType.Deposit: +        await processDepositGroup(this.ws, pending.depositGroupId); +        break;        default:          assertUnreachable(pending);      } @@ -972,6 +987,12 @@ export class Wallet {      return addBackupProvider(this.ws, req);    } +  async createDepositGroup( +    req: CreateDepositGroupRequest, +  ): Promise<CreateDepositGroupResponse> { +    return createDepositGroup(this.ws, req); +  } +    async runBackupCycle(): Promise<void> {      return runBackupCycle(this.ws);    } @@ -980,6 +1001,12 @@ export class Wallet {      return getBackupInfo(this.ws);    } +  async trackDepositGroup( +    req: TrackDepositGroupRequest, +  ): Promise<TrackDepositGroupResponse> { +    return trackDepositGroup(this.ws, req); +  } +    /**     * Implementation of the "wallet-core" API.     */ @@ -1141,6 +1168,13 @@ export class Wallet {          await runBackupCycle(this.ws);          return {};        } +      case "createDepositGroup": { +        const req = codecForCreateDepositGroupRequest().decode(payload); +        return await createDepositGroup(this.ws, req); +      } +      case "trackDepositGroup": +        const req = codecForTrackDepositGroupRequest().decode(payload); +        return trackDepositGroup(this.ws, req);      }      throw OperationFailedError.fromCode(        TalerErrorCode.WALLET_CORE_API_OPERATION_UNKNOWN, diff --git a/packages/taler-wallet-webextension/src/pages/popup.tsx b/packages/taler-wallet-webextension/src/pages/popup.tsx index 8d8d5a85d..9c8a8f75a 100644 --- a/packages/taler-wallet-webextension/src/pages/popup.tsx +++ b/packages/taler-wallet-webextension/src/pages/popup.tsx @@ -457,6 +457,18 @@ function TransactionItem(props: { tx: Transaction }): JSX.Element {            pending={tx.pending}          ></TransactionLayout>        ); +    case TransactionType.Deposit: +      return ( +        <TransactionLayout +          amount={tx.amountEffective} +          debitCreditIndicator={"debit"} +          title="Refresh" +          subtitle={`to ${tx.targetPaytoUri}`} +          timestamp={tx.timestamp} +          iconPath="/static/img/ri-refresh-line.svg" +          pending={tx.pending} +        ></TransactionLayout> +      );    }  }  | 
