implement deposits

This commit is contained in:
Florian Dold 2021-01-18 23:35:41 +01:00
parent f884193b1a
commit 5f3c02d31a
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
22 changed files with 975 additions and 97 deletions

View File

@ -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!).",

View File

@ -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") {

View File

@ -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));
}

View File

@ -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",

View File

@ -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,

View File

@ -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);
}

View File

@ -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.
*/

View 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 };
}

View File

@ -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,

View File

@ -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;
},
);

View File

@ -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}`);

View File

@ -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[] = [];

View File

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

View File

@ -131,3 +131,11 @@ export interface DerivedTipPlanchet {
coinPriv: string;
coinPub: string;
}
export interface SignTrackTransactionRequest {
contractTermsHash: string;
wireHash: string;
coinPub: string;
merchantPriv: string;
merchantPub: string;
}

View File

@ -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>> {

View File

@ -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

View File

@ -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.
*/

View File

@ -484,7 +484,7 @@ export class ContractTerms {
/**
* Extra data, interpreted by the mechant only.
*/
extra: any;
extra?: any;
}
/**

View File

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

View File

@ -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");

View File

@ -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,

View File

@ -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>
);
}
}