summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFlorian Dold <florian@dold.me>2021-01-18 23:35:41 +0100
committerFlorian Dold <florian@dold.me>2021-01-18 23:35:41 +0100
commit5f3c02d31a223add55a32b20f4a289210cbb4f15 (patch)
treed91ded55692aea1294c0565328515f120559ab6a
parentf884193b1adf0861f710c6ab1bb94ea2073ade65 (diff)
downloadwallet-core-5f3c02d31a223add55a32b20f4a289210cbb4f15.tar.gz
wallet-core-5f3c02d31a223add55a32b20f4a289210cbb4f15.tar.bz2
wallet-core-5f3c02d31a223add55a32b20f4a289210cbb4f15.zip
implement deposits
-rw-r--r--packages/taler-wallet-cli/src/index.ts31
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/harness.ts43
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-deposit.ts65
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-revocation.ts5
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/testrunner.ts2
-rw-r--r--packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts5
-rw-r--r--packages/taler-wallet-core/src/crypto/workers/cryptoImplementation.ts12
-rw-r--r--packages/taler-wallet-core/src/operations/deposits.ts420
-rw-r--r--packages/taler-wallet-core/src/operations/pay.ts239
-rw-r--r--packages/taler-wallet-core/src/operations/pending.ts30
-rw-r--r--packages/taler-wallet-core/src/operations/refund.ts1
-rw-r--r--packages/taler-wallet-core/src/operations/state.ts1
-rw-r--r--packages/taler-wallet-core/src/operations/transactions.ts23
-rw-r--r--packages/taler-wallet-core/src/types/cryptoTypes.ts8
-rw-r--r--packages/taler-wallet-core/src/types/dbTypes.ts56
-rw-r--r--packages/taler-wallet-core/src/types/notifications.ts7
-rw-r--r--packages/taler-wallet-core/src/types/pendingTypes.ts12
-rw-r--r--packages/taler-wallet-core/src/types/talerTypes.ts2
-rw-r--r--packages/taler-wallet-core/src/types/transactionsTypes.ts29
-rw-r--r--packages/taler-wallet-core/src/types/walletTypes.ts35
-rw-r--r--packages/taler-wallet-core/src/wallet.ts34
-rw-r--r--packages/taler-wallet-webextension/src/pages/popup.tsx12
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>
+ );
}
}