implement deposits
This commit is contained in:
parent
f884193b1a
commit
5f3c02d31a
@ -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!).",
|
||||
|
@ -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") {
|
||||
|
@ -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));
|
||||
}
|
@ -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",
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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.
|
||||
*/
|
||||
|
420
packages/taler-wallet-core/src/operations/deposits.ts
Normal file
420
packages/taler-wallet-core/src/operations/deposits.ts
Normal file
@ -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 };
|
||||
}
|
@ -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.
|
||||
@ -162,6 +167,49 @@ export async function getTotalPaymentCost(
|
||||
return Amounts.sum(costs).amount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
|
@ -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;
|
||||
},
|
||||
);
|
||||
|
@ -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}`);
|
||||
|
||||
|
@ -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[] = [];
|
||||
|
@ -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(
|
||||
|
@ -131,3 +131,11 @@ export interface DerivedTipPlanchet {
|
||||
coinPriv: string;
|
||||
coinPub: string;
|
||||
}
|
||||
|
||||
export interface SignTrackTransactionRequest {
|
||||
contractTermsHash: string;
|
||||
wireHash: string;
|
||||
coinPub: string;
|
||||
merchantPriv: string;
|
||||
merchantPub: string;
|
||||
}
|
||||
|
@ -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>> {
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
);
|
||||
|
||||
/**
|
||||
@ -227,6 +229,16 @@ export interface PendingWithdrawOperation {
|
||||
numCoinsTotal: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
@ -484,7 +484,7 @@ export class ContractTerms {
|
||||
/**
|
||||
* Extra data, interpreted by the mechant only.
|
||||
*/
|
||||
extra: any;
|
||||
extra?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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()))
|
||||
|
@ -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");
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user