aboutsummaryrefslogtreecommitdiff
path: root/packages/taler-wallet-core
diff options
context:
space:
mode:
Diffstat (limited to 'packages/taler-wallet-core')
-rw-r--r--packages/taler-wallet-core/package.json2
-rw-r--r--packages/taler-wallet-core/src/backup/index.ts2
-rw-r--r--packages/taler-wallet-core/src/balance.ts29
-rw-r--r--packages/taler-wallet-core/src/coinSelection.test.ts185
-rw-r--r--packages/taler-wallet-core/src/coinSelection.ts381
-rw-r--r--packages/taler-wallet-core/src/common.ts4
-rw-r--r--packages/taler-wallet-core/src/crypto/cryptoImplementation.ts43
-rw-r--r--packages/taler-wallet-core/src/crypto/cryptoTypes.ts11
-rw-r--r--packages/taler-wallet-core/src/db.ts101
-rw-r--r--packages/taler-wallet-core/src/dbless.ts8
-rw-r--r--packages/taler-wallet-core/src/denomSelection.ts6
-rw-r--r--packages/taler-wallet-core/src/deposits.ts762
-rw-r--r--packages/taler-wallet-core/src/exchanges.ts294
-rw-r--r--packages/taler-wallet-core/src/host-common.ts28
-rw-r--r--packages/taler-wallet-core/src/host-impl.node.ts10
-rw-r--r--packages/taler-wallet-core/src/host-impl.qtart.ts15
-rw-r--r--packages/taler-wallet-core/src/instructedAmountConversion.test.ts143
-rw-r--r--packages/taler-wallet-core/src/instructedAmountConversion.ts150
-rw-r--r--packages/taler-wallet-core/src/kyc.ts252
-rw-r--r--packages/taler-wallet-core/src/pay-merchant.ts347
-rw-r--r--packages/taler-wallet-core/src/pay-peer-common.ts1
-rw-r--r--packages/taler-wallet-core/src/pay-peer-pull-credit.ts177
-rw-r--r--packages/taler-wallet-core/src/pay-peer-pull-debit.ts72
-rw-r--r--packages/taler-wallet-core/src/pay-peer-push-credit.ts195
-rw-r--r--packages/taler-wallet-core/src/pay-peer-push-debit.ts115
-rw-r--r--packages/taler-wallet-core/src/recoup.ts2
-rw-r--r--packages/taler-wallet-core/src/refresh.ts24
-rw-r--r--packages/taler-wallet-core/src/shepherd.ts8
-rw-r--r--packages/taler-wallet-core/src/testing.ts31
-rw-r--r--packages/taler-wallet-core/src/transactions.ts286
-rw-r--r--packages/taler-wallet-core/src/wallet-api-types.ts112
-rw-r--r--packages/taler-wallet-core/src/wallet.ts152
-rw-r--r--packages/taler-wallet-core/src/withdraw.ts332
33 files changed, 2984 insertions, 1296 deletions
diff --git a/packages/taler-wallet-core/package.json b/packages/taler-wallet-core/package.json
index a4e1d11ca..9471902e0 100644
--- a/packages/taler-wallet-core/package.json
+++ b/packages/taler-wallet-core/package.json
@@ -1,6 +1,6 @@
{
"name": "@gnu-taler/taler-wallet-core",
- "version": "0.12.12",
+ "version": "0.13.10",
"description": "",
"engines": {
"node": ">=0.18.0"
diff --git a/packages/taler-wallet-core/src/backup/index.ts b/packages/taler-wallet-core/src/backup/index.ts
index c5febd278..a9274f74b 100644
--- a/packages/taler-wallet-core/src/backup/index.ts
+++ b/packages/taler-wallet-core/src/backup/index.ts
@@ -325,7 +325,7 @@ async function runBackupCycleForProvider(
}
// const opId = TaskIdentifiers.forBackup(prov);
// await scheduleRetryInTx(ws, tx, opId);
- prov.currentPaymentProposalId = result.proposalId;
+ prov.currentPaymentTransactionId = result.transactionId;
prov.shouldRetryFreshProposal = false;
prov.state = {
tag: BackupProviderStateTag.Retrying,
diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts
index 396efda1f..20eb71a7f 100644
--- a/packages/taler-wallet-core/src/balance.ts
+++ b/packages/taler-wallet-core/src/balance.ts
@@ -449,14 +449,13 @@ export async function getBalancesInsideTransaction(
for (const [e, x] of Object.entries(perExchange)) {
const currency = Amounts.currencyOf(dgRecord.amount);
switch (dgRecord.operationStatus) {
- case DepositOperationStatus.SuspendedKyc:
- case DepositOperationStatus.PendingKyc:
+ case DepositOperationStatus.SuspendedAggregateKyc:
+ case DepositOperationStatus.PendingAggregateKyc:
await balanceStore.setFlagOutgoingKyc(currency, e);
}
-
switch (dgRecord.operationStatus) {
- case DepositOperationStatus.SuspendedKyc:
- case DepositOperationStatus.PendingKyc:
+ case DepositOperationStatus.SuspendedAggregateKyc:
+ case DepositOperationStatus.PendingAggregateKyc:
case DepositOperationStatus.PendingTrack:
case DepositOperationStatus.SuspendedAborting:
case DepositOperationStatus.SuspendedDeposit:
@@ -552,12 +551,12 @@ export interface PaymentBalanceDetails {
balanceAgeAcceptable: AmountJson;
/**
- * Balance of type "merchant-acceptable" (see balance.ts for definition).
+ * Balance of type "receiver-acceptable" (see balance.ts for definition).
*/
balanceReceiverAcceptable: AmountJson;
/**
- * Balance of type "merchant-depositable" (see balance.ts for definition).
+ * Balance of type "receiver-depositable" (see balance.ts for definition).
*/
balanceReceiverDepositable: AmountJson;
@@ -568,7 +567,11 @@ export interface PaymentBalanceDetails {
*/
balanceExchangeDepositable: AmountJson;
- maxEffectiveSpendAmount: AmountJson;
+ /**
+ * Estimated maximum amount that the wallet could pay for, under the assumption
+ * that the merchant pays absolutely no fees.
+ */
+ maxMerchantEffectiveDepositAmount: AmountJson;
}
export async function getPaymentBalanceDetails(
@@ -610,7 +613,7 @@ export async function getPaymentBalanceDetailsInTx(
balanceAgeAcceptable: Amounts.zeroOfCurrency(req.currency),
balanceReceiverAcceptable: Amounts.zeroOfCurrency(req.currency),
balanceReceiverDepositable: Amounts.zeroOfCurrency(req.currency),
- maxEffectiveSpendAmount: Amounts.zeroOfCurrency(req.currency),
+ maxMerchantEffectiveDepositAmount: Amounts.zeroOfCurrency(req.currency),
balanceExchangeDepositable: Amounts.zeroOfCurrency(req.currency),
};
@@ -719,13 +722,13 @@ export async function getPaymentBalanceDetailsInTx(
merchantExchangeAcceptable &&
merchantExchangeDepositable
) {
- d.maxEffectiveSpendAmount = Amounts.add(
- d.maxEffectiveSpendAmount,
+ d.maxMerchantEffectiveDepositAmount = Amounts.add(
+ d.maxMerchantEffectiveDepositAmount,
Amounts.mult(ca.value, ca.freshCoinCount).amount,
).amount;
- d.maxEffectiveSpendAmount = Amounts.sub(
- d.maxEffectiveSpendAmount,
+ d.maxMerchantEffectiveDepositAmount = Amounts.sub(
+ d.maxMerchantEffectiveDepositAmount,
Amounts.mult(denom.feeDeposit, ca.freshCoinCount).amount,
).amount;
}
diff --git a/packages/taler-wallet-core/src/coinSelection.test.ts b/packages/taler-wallet-core/src/coinSelection.test.ts
index c7cb2857e..4984552f8 100644
--- a/packages/taler-wallet-core/src/coinSelection.test.ts
+++ b/packages/taler-wallet-core/src/coinSelection.test.ts
@@ -15,17 +15,21 @@
*/
import {
AbsoluteTime,
+ AmountJson,
AmountString,
Amounts,
DenomKeyType,
+ DenominationPubKey,
Duration,
+ TalerProtocolTimestamp,
j2s,
} from "@gnu-taler/taler-util";
import test from "ava";
import {
- AvailableDenom,
+ AvailableCoinsOfDenom,
CoinSelectionTally,
emptyTallyForPeerPayment,
+ testing_getMaxDepositAmountForAvailableCoins,
testing_selectGreedy,
} from "./coinSelection.js";
@@ -42,7 +46,9 @@ const inThePast = AbsoluteTime.toProtocolTimestamp(
test("p2p: should select the coin", (t) => {
const instructedAmount = Amounts.parseOrThrow("LOCAL:2");
- const tally = emptyTallyForPeerPayment(instructedAmount);
+ const tally = emptyTallyForPeerPayment({
+ instructedAmount,
+ });
t.log(`tally before: ${j2s(tally)}`);
const coins = testing_selectGreedy(
{
@@ -76,7 +82,9 @@ test("p2p: should select the coin", (t) => {
test("p2p: should select 3 coins", (t) => {
const instructedAmount = Amounts.parseOrThrow("LOCAL:20");
- const tally = emptyTallyForPeerPayment(instructedAmount);
+ const tally = emptyTallyForPeerPayment({
+ instructedAmount,
+ });
const coins = testing_selectGreedy(
{
wireFeesPerExchange: {},
@@ -108,7 +116,9 @@ test("p2p: should select 3 coins", (t) => {
test("p2p: can't select since the instructed amount is too high", (t) => {
const instructedAmount = Amounts.parseOrThrow("LOCAL:60");
- const tally = emptyTallyForPeerPayment(instructedAmount);
+ const tally = emptyTallyForPeerPayment({
+ instructedAmount,
+ });
const coins = testing_selectGreedy(
{
wireFeesPerExchange: {},
@@ -138,6 +148,7 @@ test("pay: select one coin to pay with fee", (t) => {
customerWireFees: zero,
wireFeeCoveredForExchange: new Set<string>(),
lastDepositFee: zero,
+ totalDepositFees: zero,
} satisfies CoinSelectionTally;
const coins = testing_selectGreedy(
{
@@ -180,7 +191,7 @@ function createCandidates(
numAvailable: number;
fromExchange: string;
}[],
-): AvailableDenom[] {
+): AvailableCoinsOfDenom[] {
return ar.map((r, idx) => {
return {
denomPub: {
@@ -269,7 +280,9 @@ test("p2p: regression STATER", (t) => {
},
];
const instructedAmount = Amounts.parseOrThrow("STATER:1");
- const tally = emptyTallyForPeerPayment(instructedAmount);
+ const tally = emptyTallyForPeerPayment({
+ instructedAmount,
+ });
const res = testing_selectGreedy(
{
wireFeesPerExchange: {},
@@ -279,3 +292,163 @@ test("p2p: regression STATER", (t) => {
);
t.assert(!!res);
});
+
+function makeCurrencyHelper(currency: string) {
+ return (sx: TemplateStringsArray, ...vx: any[]) => {
+ const s = String.raw({ raw: sx }, ...vx);
+ return Amounts.parseOrThrow(`${currency}:${s}`);
+ };
+}
+
+type TestCoin = [AmountJson, number];
+
+const kudos = makeCurrencyHelper("kudos");
+
+function defaultFeeConfig(
+ value: AmountJson,
+ totalAvailable: number,
+): AvailableCoinsOfDenom {
+ return {
+ denomPub: undefined as any as DenominationPubKey,
+ denomPubHash: "123",
+ feeDeposit: `KUDOS:0.01`,
+ feeRefresh: `KUDOS:0.01`,
+ feeRefund: `KUDOS:0.01`,
+ feeWithdraw: `KUDOS:0.01`,
+ exchangeBaseUrl: "2",
+ maxAge: 0,
+ numAvailable: totalAvailable,
+ stampExpireDeposit: TalerProtocolTimestamp.never(),
+ stampExpireLegal: TalerProtocolTimestamp.never(),
+ stampExpireWithdraw: TalerProtocolTimestamp.never(),
+ stampStart: TalerProtocolTimestamp.never(),
+ value: Amounts.stringify(value),
+ };
+}
+
+test("deposit max 35", (t) => {
+ const coinList: TestCoin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = testing_getMaxDepositAmountForAvailableCoins(
+ {
+ currency: "KUDOS",
+ },
+ {
+ coinAvailability: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ currentWireFeePerExchange: {
+ "2": kudos`0`,
+ },
+ },
+ );
+ t.is(Amounts.stringifyValue(result.rawAmount), "34.9");
+ t.is(Amounts.stringifyValue(result.effectiveAmount), "35");
+});
+
+test("deposit max 35 with wirefee", (t) => {
+ const coinList: TestCoin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = testing_getMaxDepositAmountForAvailableCoins(
+ {
+ currency: "KUDOS",
+ },
+ {
+ coinAvailability: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ currentWireFeePerExchange: {
+ "2": kudos`1`,
+ },
+ },
+ );
+ t.is(Amounts.stringifyValue(result.rawAmount), "33.9");
+ t.is(Amounts.stringifyValue(result.effectiveAmount), "35");
+});
+
+test("deposit max repeated denom", (t) => {
+ const coinList: TestCoin[] = [
+ [kudos`2`, 1],
+ [kudos`2`, 1],
+ [kudos`5`, 1],
+ ];
+ const result = testing_getMaxDepositAmountForAvailableCoins(
+ {
+ currency: "KUDOS",
+ },
+ {
+ coinAvailability: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ currentWireFeePerExchange: {
+ "2": kudos`0`,
+ },
+ },
+ );
+ t.is(Amounts.stringifyValue(result.rawAmount), "8.97");
+ t.is(Amounts.stringifyValue(result.effectiveAmount), "9");
+});
+
+test("demo: deposit max after withdraw raw 25", (t) => {
+ const coinList: TestCoin[] = [
+ [kudos`0.1`, 8],
+ [kudos`1`, 0],
+ [kudos`2`, 2],
+ [kudos`5`, 0],
+ [kudos`10`, 2],
+ ];
+ const result = testing_getMaxDepositAmountForAvailableCoins(
+ {
+ currency: "KUDOS",
+ },
+ {
+ coinAvailability: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ currentWireFeePerExchange: {
+ "2": kudos`0.01`,
+ },
+ },
+ );
+ t.is(Amounts.stringifyValue(result.effectiveAmount), "24.8");
+ t.is(Amounts.stringifyValue(result.rawAmount), "24.67");
+
+ // 8 x 0.1
+ // 2 x 0.2
+ // 2 x 10.0
+ // total effective 24.8
+ // deposit fee 12 x 0.01 = 0.12
+ // wire fee 0.01
+ // total raw: 24.8 - 0.13 = 24.67
+
+ // current wallet impl fee 0.14
+});
+
+test("demo: deposit max after withdraw raw 13", (t) => {
+ const coinList: TestCoin[] = [
+ [kudos`0.1`, 8],
+ [kudos`1`, 0],
+ [kudos`2`, 1],
+ [kudos`5`, 0],
+ [kudos`10`, 1],
+ ];
+ const result = testing_getMaxDepositAmountForAvailableCoins(
+ {
+ currency: "KUDOS",
+ },
+ {
+ coinAvailability: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ currentWireFeePerExchange: {
+ "2": kudos`0.01`,
+ },
+ },
+ );
+ t.is(Amounts.stringifyValue(result.effectiveAmount), "12.8");
+ t.is(Amounts.stringifyValue(result.rawAmount), "12.69");
+
+ // 8 x 0.1
+ // 1 x 0.2
+ // 1 x 10.0
+ // total effective 12.8
+ // deposit fee 10 x 0.01 = 0.10
+ // wire fee 0.01
+ // total raw: 12.8 - 0.11 = 12.69
+
+ // current wallet impl fee 0.14
+});
diff --git a/packages/taler-wallet-core/src/coinSelection.ts b/packages/taler-wallet-core/src/coinSelection.ts
index 51316a21f..de051d52e 100644
--- a/packages/taler-wallet-core/src/coinSelection.ts
+++ b/packages/taler-wallet-core/src/coinSelection.ts
@@ -26,25 +26,30 @@
import { GlobalIDB } from "@gnu-taler/idb-bridge";
import {
AbsoluteTime,
- AccountRestriction,
AgeRestriction,
AllowedAuditorInfo,
AllowedExchangeInfo,
AmountJson,
Amounts,
+ checkAccountRestriction,
checkDbInvariant,
checkLogicInvariant,
CoinStatus,
DenominationInfo,
ExchangeGlobalFees,
ForcedCoinSel,
- InternationalizedString,
+ GetMaxDepositAmountRequest,
+ GetMaxDepositAmountResponse,
+ GetMaxPeerPushDebitAmountRequest,
+ GetMaxPeerPushDebitAmountResponse,
j2s,
Logger,
parsePaytoUri,
PayCoinSelection,
PaymentInsufficientBalanceDetails,
ProspectivePayCoinSelection,
+ ScopeInfo,
+ ScopeType,
SelectedCoin,
SelectedProspectiveCoin,
strcmp,
@@ -54,6 +59,7 @@ import { getPaymentBalanceDetailsInTx } from "./balance.js";
import { getAutoRefreshExecuteThreshold } from "./common.js";
import { DenominationRecord, WalletDbReadOnlyTransaction } from "./db.js";
import {
+ checkExchangeInScopeTx,
ExchangeWireDetails,
getExchangeWireDetailsInTx,
} from "./exchanges.js";
@@ -86,6 +92,8 @@ export interface CoinSelectionTally {
customerDepositFees: AmountJson;
+ totalDepositFees: AmountJson;
+
customerWireFees: AmountJson;
wireFeeCoveredForExchange: Set<string>;
@@ -152,6 +160,10 @@ function tallyFees(
dfRemaining,
).amount;
tally.lastDepositFee = feeDeposit;
+ tally.totalDepositFees = Amounts.add(
+ tally.totalDepositFees,
+ feeDeposit,
+ ).amount;
}
export type SelectPayCoinsResult =
@@ -180,19 +192,32 @@ async function internalSelectPayCoins(
| { sel: SelResult; coinRes: SelectedCoin[]; tally: CoinSelectionTally }
| undefined
> {
+ let restrictWireMethod;
+ if (req.depositPaytoUri) {
+ const parsedPayto = parsePaytoUri(req.depositPaytoUri);
+ if (!parsedPayto) {
+ throw Error("invalid payto URI");
+ }
+ restrictWireMethod = parsedPayto.targetType;
+ if (restrictWireMethod !== req.restrictWireMethod) {
+ logger.warn(`conflicting payto URI and wire method restriction`);
+ }
+ } else {
+ restrictWireMethod = req.restrictWireMethod;
+ }
+
const { contractTermsAmount, depositFeeLimit } = req;
- const [candidateDenoms, wireFeesPerExchange] = await selectPayCandidates(
- wex,
- tx,
- {
- restrictExchanges: req.restrictExchanges,
- instructedAmount: req.contractTermsAmount,
- restrictWireMethod: req.restrictWireMethod,
- depositPaytoUri: req.depositPaytoUri,
- requiredMinimumAge: req.requiredMinimumAge,
- includePendingCoins,
- },
- );
+ const candidateRes = await selectPayCandidates(wex, tx, {
+ currency: Amounts.currencyOf(req.contractTermsAmount),
+ restrictExchanges: req.restrictExchanges,
+ restrictWireMethod: req.restrictWireMethod,
+ depositPaytoUri: req.depositPaytoUri,
+ requiredMinimumAge: req.requiredMinimumAge,
+ includePendingCoins,
+ });
+
+ const wireFeesPerExchange = candidateRes.currentWireFeePerExchange;
+ const candidateDenoms = candidateRes.coinAvailability;
if (logger.shouldLogTrace()) {
logger.trace(
@@ -210,6 +235,7 @@ async function internalSelectPayCoins(
amountDepositFeeLimitRemaining: depositFeeLimit,
customerDepositFees: Amounts.zeroOfCurrency(currency),
customerWireFees: Amounts.zeroOfCurrency(currency),
+ totalDepositFees: Amounts.zeroOfCurrency(currency),
wireFeeCoveredForExchange: new Set(),
lastDepositFee: Amounts.zeroOfCurrency(currency),
};
@@ -452,6 +478,7 @@ async function assembleSelectPayCoinsSuccessResult(
coins: coinRes,
customerDepositFees: Amounts.stringify(tally.customerDepositFees),
customerWireFees: Amounts.stringify(tally.customerWireFees),
+ totalDepositFees: Amounts.stringify(tally.totalDepositFees),
};
}
@@ -493,12 +520,16 @@ export async function reportInsufficientBalanceDetails(
let missingGlobalFees = false;
const exchWire = await getExchangeWireDetailsInTx(tx, exch.baseUrl);
if (!exchWire) {
+ // No wire details about the exchange known, skip!
+ continue;
+ }
+ const globalFees = getGlobalFees(exchWire);
+ if (!globalFees) {
missingGlobalFees = true;
- } else {
- const globalFees = getGlobalFees(exchWire);
- if (!globalFees) {
- missingGlobalFees = true;
- }
+ }
+ if (exchWire.currency !== Amounts.currencyOf(req.instructedAmount)) {
+ // Do not report anything for an exchange with a different currency.
+ continue;
}
const exchDet = await getPaymentBalanceDetailsInTx(wex, tx, {
restrictExchanges: {
@@ -510,7 +541,7 @@ export async function reportInsufficientBalanceDetails(
],
auditors: [],
},
- restrictWireMethods: req.wireMethod ? [req.wireMethod] : [],
+ restrictWireMethods: req.wireMethod ? [req.wireMethod] : undefined,
currency: Amounts.currencyOf(req.instructedAmount),
minAge: req.requiredMinimumAge ?? 0,
depositPaytoUri: req.depositPaytoUri,
@@ -529,7 +560,7 @@ export async function reportInsufficientBalanceDetails(
exchDet.balanceReceiverDepositable,
),
maxEffectiveSpendAmount: Amounts.stringify(
- exchDet.maxEffectiveSpendAmount,
+ exchDet.maxMerchantEffectiveDepositAmount,
),
missingGlobalFees,
};
@@ -549,7 +580,9 @@ export async function reportInsufficientBalanceDetails(
balanceReceiverDepositable: Amounts.stringify(
details.balanceReceiverDepositable,
),
- maxEffectiveSpendAmount: Amounts.stringify(details.maxEffectiveSpendAmount),
+ maxEffectiveSpendAmount: Amounts.stringify(
+ details.maxMerchantEffectiveDepositAmount,
+ ),
perExchange,
};
}
@@ -590,7 +623,7 @@ export interface SelectGreedyRequest {
function selectGreedy(
req: SelectGreedyRequest,
- candidateDenoms: AvailableDenom[],
+ candidateDenoms: AvailableCoinsOfDenom[],
tally: CoinSelectionTally,
): SelResult | undefined {
const selectedDenom: SelResult = {};
@@ -653,7 +686,7 @@ function selectGreedy(
function selectForced(
req: SelectPayCoinRequestNg,
- candidateDenoms: AvailableDenom[],
+ candidateDenoms: AvailableCoinsOfDenom[],
): SelResult | undefined {
const selectedDenom: SelResult = {};
@@ -695,31 +728,6 @@ function selectForced(
return selectedDenom;
}
-export function checkAccountRestriction(
- paytoUri: string,
- restrictions: AccountRestriction[],
-): { ok: boolean; hint?: string; hintI18n?: InternationalizedString } {
- for (const myRestriction of restrictions) {
- switch (myRestriction.type) {
- case "deny":
- return { ok: false };
- case "regex": {
- const regex = new RegExp(myRestriction.payto_regex);
- if (!regex.test(paytoUri)) {
- return {
- ok: false,
- hint: myRestriction.human_hint,
- hintI18n: myRestriction.human_hint_i18n,
- };
- }
- }
- }
- }
- return {
- ok: true,
- };
-}
-
export interface SelectPayCoinRequestNg {
restrictExchanges: ExchangeRestrictionSpec | undefined;
restrictWireMethod: string;
@@ -739,7 +747,7 @@ export interface SelectPayCoinRequestNg {
depositPaytoUri?: string;
}
-export type AvailableDenom = DenominationInfo & {
+export type AvailableCoinsOfDenom = DenominationInfo & {
maxAge: number;
numAvailable: number;
};
@@ -820,7 +828,7 @@ function checkExchangeAccepted(
}
interface SelectPayCandidatesRequest {
- instructedAmount: AmountJson;
+ currency: string;
restrictWireMethod: string | undefined;
depositPaytoUri?: string;
restrictExchanges: ExchangeRestrictionSpec | undefined;
@@ -834,18 +842,23 @@ interface SelectPayCandidatesRequest {
includePendingCoins: boolean;
}
+export interface PayCoinCandidates {
+ coinAvailability: AvailableCoinsOfDenom[];
+ currentWireFeePerExchange: Record<string, AmountJson>;
+}
+
async function selectPayCandidates(
wex: WalletExecutionContext,
tx: WalletDbReadOnlyTransaction<
["exchanges", "coinAvailability", "exchangeDetails", "denominations"]
>,
req: SelectPayCandidatesRequest,
-): Promise<[AvailableDenom[], Record<string, AmountJson>]> {
+): Promise<PayCoinCandidates> {
// FIXME: Use the existing helper (from balance.ts) to
// get acceptable exchanges.
logger.shouldLogTrace() &&
logger.trace(`selecting available coin candidates for ${j2s(req)}`);
- const denoms: AvailableDenom[] = [];
+ const denoms: AvailableCoinsOfDenom[] = [];
const exchanges = await tx.exchanges.iter().toArray();
const wfPerExchange: Record<string, AmountJson> = {};
for (const exchange of exchanges) {
@@ -854,7 +867,7 @@ async function selectPayCandidates(
exchange.baseUrl,
);
// 1. exchange has same currency
- if (exchangeDetails?.currency !== req.instructedAmount.currency) {
+ if (exchangeDetails?.currency !== req.currency) {
logger.shouldLogTrace() &&
logger.trace(`skipping ${exchange.baseUrl} due to currency mismatch`);
continue;
@@ -961,7 +974,10 @@ async function selectPayCandidates(
Amounts.cmp(o1.feeDeposit, o2.feeDeposit) ||
strcmp(o1.denomPubHash, o2.denomPubHash),
);
- return [denoms, wfPerExchange];
+ return {
+ coinAvailability: denoms,
+ currentWireFeePerExchange: wfPerExchange,
+ };
}
export interface PeerCoinSelectionDetails {
@@ -975,7 +991,9 @@ export interface PeerCoinSelectionDetails {
/**
* How much of the deposit fees is the customer paying?
*/
- depositFees: AmountJson;
+ customerDepositFees: AmountJson;
+
+ totalDepositFees: AmountJson;
maxExpirationDate: TalerProtocolTimestamp;
}
@@ -988,7 +1006,9 @@ export interface ProspectivePeerCoinSelectionDetails {
/**
* How much of the deposit fees is the customer paying?
*/
- depositFees: AmountJson;
+ customerDepositFees: AmountJson;
+
+ totalDepositFees: AmountJson;
maxExpirationDate: TalerProtocolTimestamp;
}
@@ -1006,6 +1026,19 @@ export interface PeerCoinSelectionRequest {
instructedAmount: AmountJson;
/**
+ * Are deposit fees covered by the counterparty?
+ *
+ * Defaults to false.
+ */
+ feesCoveredByCounterparty?: boolean;
+
+ /**
+ * Restrict the scope of funds that can be spent via the given
+ * scope info.
+ */
+ restrictScope?: ScopeInfo;
+
+ /**
* Instruct the coin selection to repair this coin
* selection instead of selecting completely new coins.
*/
@@ -1045,17 +1078,21 @@ export async function computeCoinSelMaxExpirationDate(
}
export function emptyTallyForPeerPayment(
- instructedAmount: AmountJson,
+ req: PeerCoinSelectionRequest,
): CoinSelectionTally {
+ const instructedAmount = req.instructedAmount;
const currency = instructedAmount.currency;
const zero = Amounts.zeroOfCurrency(currency);
return {
amountPayRemaining: instructedAmount,
customerDepositFees: zero,
lastDepositFee: zero,
- amountDepositFeeLimitRemaining: zero,
+ amountDepositFeeLimitRemaining: req.feesCoveredByCounterparty
+ ? instructedAmount
+ : zero,
customerWireFees: zero,
wireFeeCoveredForExchange: new Set(),
+ totalDepositFees: zero,
};
}
@@ -1098,7 +1135,7 @@ async function internalSelectPeerCoins(
| undefined
> {
const candidatesRes = await selectPayCandidates(wex, tx, {
- instructedAmount: req.instructedAmount,
+ currency: Amounts.currencyOf(req.instructedAmount),
restrictExchanges: {
auditors: [],
exchanges: [
@@ -1111,11 +1148,11 @@ async function internalSelectPeerCoins(
restrictWireMethod: undefined,
includePendingCoins,
});
- const candidates = candidatesRes[0];
+ const candidates = candidatesRes.coinAvailability;
if (logger.shouldLogTrace()) {
logger.trace(`peer payment candidate coins: ${j2s(candidates)}`);
}
- const tally = emptyTallyForPeerPayment(req.instructedAmount);
+ const tally = emptyTallyForPeerPayment(req);
const resCoins: SelectedCoin[] = [];
await maybeRepairCoinSelection(wex, tx, req.repair ?? [], resCoins, tally, {
@@ -1157,6 +1194,8 @@ export async function selectPeerCoinsInTx(
"denominations",
"refreshGroups",
"exchangeDetails",
+ "globalCurrencyExchanges",
+ "globalCurrencyAuditors",
]
>,
req: PeerCoinSelectionRequest,
@@ -1178,6 +1217,19 @@ export async function selectPeerCoinsInTx(
if (!exchWire) {
continue;
}
+ const isInScope = req.restrictScope
+ ? await checkExchangeInScopeTx(wex, tx, exch.baseUrl, req.restrictScope)
+ : true;
+ if (!isInScope) {
+ continue;
+ }
+ if (
+ req.restrictScope &&
+ req.restrictScope.type === ScopeType.Exchange &&
+ req.restrictScope.url !== exch.baseUrl
+ ) {
+ continue;
+ }
const globalFees = getGlobalFees(exchWire);
if (!globalFees) {
continue;
@@ -1215,7 +1267,8 @@ export async function selectPeerCoinsInTx(
type: "prospective",
result: {
prospectiveCoins,
- depositFees: prospectiveAvRes.tally.customerDepositFees,
+ customerDepositFees: prospectiveAvRes.tally.customerDepositFees,
+ totalDepositFees: prospectiveAvRes.tally.totalDepositFees,
exchangeBaseUrl: exch.baseUrl,
maxExpirationDate,
},
@@ -1239,7 +1292,8 @@ export async function selectPeerCoinsInTx(
type: "success",
result: {
coins: r.coins,
- depositFees: Amounts.parseOrThrow(r.customerDepositFees),
+ customerDepositFees: Amounts.parseOrThrow(r.customerDepositFees),
+ totalDepositFees: Amounts.parseOrThrow(r.totalDepositFees),
exchangeBaseUrl: exch.baseUrl,
maxExpirationDate,
},
@@ -1277,6 +1331,8 @@ export async function selectPeerCoins(
"denominations",
"refreshGroups",
"exchangeDetails",
+ "globalCurrencyExchanges",
+ "globalCurrencyAuditors",
],
},
async (tx): Promise<SelectPeerCoinsResult> => {
@@ -1284,3 +1340,200 @@ export async function selectPeerCoins(
},
);
}
+
+function getMaxDepositAmountForAvailableCoins(
+ req: GetMaxDepositAmountRequest,
+ candidateRes: PayCoinCandidates,
+): GetMaxDepositAmountResponse {
+ const wireFeeCoveredForExchange = new Set<string>();
+
+ let amountEffective = Amounts.zeroOfCurrency(req.currency);
+ let fees = Amounts.zeroOfCurrency(req.currency);
+
+ for (const cc of candidateRes.coinAvailability) {
+ if (!wireFeeCoveredForExchange.has(cc.exchangeBaseUrl)) {
+ const wireFee =
+ candidateRes.currentWireFeePerExchange[cc.exchangeBaseUrl];
+ // Wire fee can be null if max deposit amount is computed
+ // without restricting the wire method.
+ if (wireFee != null) {
+ fees = Amounts.add(fees, wireFee).amount;
+ }
+ wireFeeCoveredForExchange.add(cc.exchangeBaseUrl);
+ }
+
+ amountEffective = Amounts.add(
+ amountEffective,
+ Amounts.mult(cc.value, cc.numAvailable).amount,
+ ).amount;
+
+ fees = Amounts.add(
+ fees,
+ Amounts.mult(cc.feeDeposit, cc.numAvailable).amount,
+ ).amount;
+ }
+
+ return {
+ effectiveAmount: Amounts.stringify(amountEffective),
+ rawAmount: Amounts.stringify(Amounts.sub(amountEffective, fees).amount),
+ };
+}
+
+/**
+ * Only used for unit testing getMaxDepositAmountForAvailableCoins.
+ */
+export const testing_getMaxDepositAmountForAvailableCoins =
+ getMaxDepositAmountForAvailableCoins;
+
+export async function getMaxDepositAmount(
+ wex: WalletExecutionContext,
+ req: GetMaxDepositAmountRequest,
+): Promise<GetMaxDepositAmountResponse> {
+ logger.trace(`getting max deposit amount for: ${j2s(req)}`);
+ return await wex.db.runReadOnlyTx(
+ {
+ storeNames: [
+ "exchanges",
+ "coinAvailability",
+ "denominations",
+ "exchangeDetails",
+ ],
+ },
+ async (tx): Promise<GetMaxDepositAmountResponse> => {
+ let restrictWireMethod: string | undefined = undefined;
+ if (req.depositPaytoUri) {
+ const p = parsePaytoUri(req.depositPaytoUri);
+ if (!p) {
+ throw Error("invalid payto URI");
+ }
+ restrictWireMethod = p.targetType;
+ }
+ const candidateRes = await selectPayCandidates(wex, tx, {
+ currency: req.currency,
+ restrictExchanges: undefined,
+ restrictWireMethod,
+ depositPaytoUri: req.depositPaytoUri,
+ requiredMinimumAge: undefined,
+ includePendingCoins: true,
+ });
+ return getMaxDepositAmountForAvailableCoins(req, candidateRes);
+ },
+ );
+}
+
+function getMaxPeerPushDebitAmountForAvailableCoins(
+ req: GetMaxDepositAmountRequest,
+ exchangeBaseUrl: string,
+ candidateRes: PayCoinCandidates,
+): GetMaxPeerPushDebitAmountResponse {
+ let amountEffective = Amounts.zeroOfCurrency(req.currency);
+ let fees = Amounts.zeroOfCurrency(req.currency);
+
+ for (const cc of candidateRes.coinAvailability) {
+ amountEffective = Amounts.add(
+ amountEffective,
+ Amounts.mult(cc.value, cc.numAvailable).amount,
+ ).amount;
+
+ fees = Amounts.add(
+ fees,
+ Amounts.mult(cc.feeDeposit, cc.numAvailable).amount,
+ ).amount;
+ }
+
+ return {
+ exchangeBaseUrl,
+ effectiveAmount: Amounts.stringify(amountEffective),
+ rawAmount: Amounts.stringify(Amounts.sub(amountEffective, fees).amount),
+ };
+}
+
+export async function getMaxPeerPushDebitAmount(
+ wex: WalletExecutionContext,
+ req: GetMaxPeerPushDebitAmountRequest,
+): Promise<GetMaxPeerPushDebitAmountResponse> {
+ logger.trace(`getting max deposit amount for: ${j2s(req)}`);
+
+ return await wex.db.runReadOnlyTx(
+ {
+ storeNames: [
+ "exchanges",
+ "coinAvailability",
+ "denominations",
+ "exchangeDetails",
+ "globalCurrencyExchanges",
+ "globalCurrencyAuditors",
+ ],
+ },
+ async (tx): Promise<GetMaxPeerPushDebitAmountResponse> => {
+ let result: GetMaxDepositAmountResponse | undefined = undefined;
+ const currency = req.currency;
+ const exchanges = await tx.exchanges.iter().toArray();
+ for (const exch of exchanges) {
+ if (exch.detailsPointer?.currency !== currency) {
+ continue;
+ }
+ const exchWire = await getExchangeWireDetailsInTx(tx, exch.baseUrl);
+ if (!exchWire) {
+ continue;
+ }
+ const isInScope = req.restrictScope
+ ? await checkExchangeInScopeTx(
+ wex,
+ tx,
+ exch.baseUrl,
+ req.restrictScope,
+ )
+ : true;
+ if (!isInScope) {
+ continue;
+ }
+ if (
+ req.restrictScope &&
+ req.restrictScope.type === ScopeType.Exchange &&
+ req.restrictScope.url !== exch.baseUrl
+ ) {
+ continue;
+ }
+ const globalFees = getGlobalFees(exchWire);
+ if (!globalFees) {
+ continue;
+ }
+
+ const candidatesRes = await selectPayCandidates(wex, tx, {
+ currency,
+ restrictExchanges: {
+ auditors: [],
+ exchanges: [
+ {
+ exchangeBaseUrl: exchWire.exchangeBaseUrl,
+ exchangePub: exchWire.masterPublicKey,
+ },
+ ],
+ },
+ restrictWireMethod: undefined,
+ includePendingCoins: true,
+ });
+
+ const myExchangeRes = getMaxPeerPushDebitAmountForAvailableCoins(
+ req,
+ exchWire.exchangeBaseUrl,
+ candidatesRes,
+ );
+
+ if (!result) {
+ result = myExchangeRes;
+ } else if (Amounts.cmp(result.rawAmount, myExchangeRes.rawAmount) < 0) {
+ result = myExchangeRes;
+ }
+ }
+ if (!result) {
+ return {
+ effectiveAmount: Amounts.stringify(Amounts.zeroOfCurrency(currency)),
+ rawAmount: Amounts.stringify(Amounts.zeroOfCurrency(currency)),
+ };
+ }
+ return result;
+ },
+ );
+}
diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts
index 02dca20e8..d9438d19c 100644
--- a/packages/taler-wallet-core/src/common.ts
+++ b/packages/taler-wallet-core/src/common.ts
@@ -799,10 +799,10 @@ export const TransitionResult = {
export interface TransactionContext {
get taskId(): TaskIdStr | undefined;
get transactionId(): TransactionIdStr;
- abortTransaction(): Promise<void>;
+ abortTransaction(reason?: TalerErrorDetail): Promise<void>;
suspendTransaction(): Promise<void>;
resumeTransaction(): Promise<void>;
- failTransaction(): Promise<void>;
+ failTransaction(reason?: TalerErrorDetail): Promise<void>;
deleteTransaction(): Promise<void>;
lookupFullTransaction(
tx: WalletDbAllStoresReadOnlyTransaction,
diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts
index 69081d234..c1a4ca455 100644
--- a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts
+++ b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts
@@ -105,6 +105,8 @@ import {
EncryptContractResponse,
SignCoinHistoryRequest,
SignCoinHistoryResponse,
+ SignContractTermsHashRequest,
+ SignContractTermsHashResponse,
SignDeletePurseRequest,
SignDeletePurseResponse,
SignPurseMergeRequest,
@@ -170,7 +172,7 @@ export interface TalerCryptoInterface {
req: ContractTermsValidationRequest,
): Promise<ValidationResult>;
- createEddsaKeypair(req: {}): Promise<EddsaKeypair>;
+ createEddsaKeypair(req: {}): Promise<EddsaKeyPairStrings>;
eddsaGetPublic(req: EddsaGetPublicRequest): Promise<EddsaGetPublicResponse>;
@@ -265,6 +267,10 @@ export interface TalerCryptoInterface {
signWalletKycAuth(
req: SignWalletKycAuthRequest,
): Promise<SignWalletKycAuthResponse>;
+
+ signContractTermsHash(
+ req: SignContractTermsHashRequest,
+ ): Promise<SignContractTermsHashResponse>;
}
/**
@@ -333,10 +339,12 @@ export const nullCrypto: TalerCryptoInterface = {
): Promise<ValidationResult> {
throw new Error("Function not implemented.");
},
- createEddsaKeypair: function (req: unknown): Promise<EddsaKeypair> {
+ createEddsaKeypair: function (req: unknown): Promise<EddsaKeyPairStrings> {
throw new Error("Function not implemented.");
},
- eddsaGetPublic: function (req: EddsaGetPublicRequest): Promise<EddsaKeypair> {
+ eddsaGetPublic: function (
+ req: EddsaGetPublicRequest,
+ ): Promise<EddsaKeyPairStrings> {
throw new Error("Function not implemented.");
},
unblindDenominationSignature: function (
@@ -469,6 +477,11 @@ export const nullCrypto: TalerCryptoInterface = {
): Promise<SignWalletKycAuthResponse> {
throw new Error("Function not implemented.");
},
+ signContractTermsHash: function (
+ req: SignContractTermsHashRequest,
+ ): Promise<SignContractTermsHashResponse> {
+ throw new Error("Function not implemented.");
+ },
};
export type WithArg<X> = X extends (req: infer T) => infer R
@@ -519,6 +532,7 @@ export interface SpendCoinDetails {
coinPub: string;
coinPriv: string;
contribution: AmountString;
+ feeDeposit: AmountString;
denomPubHash: string;
denomSig: UnblindedSignature;
ageCommitmentProof: AgeCommitmentProof | undefined;
@@ -600,7 +614,7 @@ export interface WireAccountValidationRequest {
creditRestrictions?: any[];
}
-export interface EddsaKeypair {
+export interface EddsaKeyPairStrings {
priv: string;
pub: string;
}
@@ -1081,7 +1095,9 @@ export const nativeCryptoR: TalerCryptoInterfaceR = {
/**
* Create a new EdDSA key pair.
*/
- async createEddsaKeypair(tci: TalerCryptoInterfaceR): Promise<EddsaKeypair> {
+ async createEddsaKeypair(
+ tci: TalerCryptoInterfaceR,
+ ): Promise<EddsaKeyPairStrings> {
const eddsaPriv = encodeCrock(getRandomBytes(32));
const eddsaPubRes = await tci.eddsaGetPublic(tci, {
priv: eddsaPriv,
@@ -1095,7 +1111,7 @@ export const nativeCryptoR: TalerCryptoInterfaceR = {
async eddsaGetPublic(
tci: TalerCryptoInterfaceR,
req: EddsaGetPublicRequest,
- ): Promise<EddsaKeypair> {
+ ): Promise<EddsaKeyPairStrings> {
return {
priv: req.priv,
pub: encodeCrock(eddsaGetPublic(decodeCrock(req.priv))),
@@ -1815,6 +1831,21 @@ export const nativeCryptoR: TalerCryptoInterfaceR = {
sig: sigResp.sig,
};
},
+ async signContractTermsHash(
+ tci: TalerCryptoInterfaceR,
+ req: SignContractTermsHashRequest,
+ ): Promise<SignContractTermsHashResponse> {
+ const sigData = buildSigPS(TalerSignaturePurpose.MERCHANT_CONTRACT)
+ .put(decodeCrock(req.contractTermsHash))
+ .build();
+ const sigRes = await tci.eddsaSign(tci, {
+ msg: encodeCrock(sigData),
+ priv: req.merchantPriv,
+ });
+ return {
+ sig: sigRes.sig,
+ };
+ },
};
export interface EddsaSignRequest {
diff --git a/packages/taler-wallet-core/src/crypto/cryptoTypes.ts b/packages/taler-wallet-core/src/crypto/cryptoTypes.ts
index f6d17cb51..d866025f2 100644
--- a/packages/taler-wallet-core/src/crypto/cryptoTypes.ts
+++ b/packages/taler-wallet-core/src/crypto/cryptoTypes.ts
@@ -33,9 +33,11 @@ import {
AmountString,
CoinEnvelope,
DenominationPubKey,
+ EddsaPrivateKeyString,
EddsaPublicKeyString,
EddsaSignatureString,
ExchangeProtocolVersion,
+ HashCodeString,
RefreshPlanchetInfo,
TalerProtocolTimestamp,
UnblindedSignature,
@@ -244,6 +246,15 @@ export interface SignWalletKycAuthResponse {
sig: string;
}
+export interface SignContractTermsHashRequest {
+ merchantPriv: EddsaPrivateKeyString;
+ contractTermsHash: HashCodeString;
+}
+
+export interface SignContractTermsHashResponse {
+ sig: string;
+}
+
export interface SignPurseMergeRequest {
mergeTimestamp: TalerProtocolTimestamp;
diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts
index c48a43f11..bcb9a284f 100644
--- a/packages/taler-wallet-core/src/db.ts
+++ b/packages/taler-wallet-core/src/db.ts
@@ -29,6 +29,7 @@ import {
} from "@gnu-taler/idb-bridge";
import {
AbsoluteTime,
+ AccountLimit,
AgeCommitmentProof,
AmountString,
Amounts,
@@ -61,6 +62,7 @@ import {
UnblindedSignature,
WireInfo,
WithdrawalExchangeAccountDetails,
+ ZeroLimitedOperation,
codecForAny,
j2s,
stringifyScopeInfo,
@@ -273,6 +275,9 @@ export const OPERATION_STATUS_NONFINAL_FIRST = 0x0100_0000;
*/
export const OPERATION_STATUS_NONFINAL_LAST = 0x0210_ffff;
+export const OPERATION_STATUS_DONE_FIRST = 0x0500_0000;
+export const OPERATION_STATUS_DONE_LAST = 0x0500_ffff;
+
/**
* Status of a withdrawal.
*/
@@ -414,6 +419,8 @@ export interface ReserveBankInfo {
currency: string | undefined;
externalConfirmation?: boolean;
+
+ senderWire?: string;
}
/**
@@ -624,6 +631,10 @@ export interface ExchangeDetailsRecord {
ageMask?: number;
walletBalanceLimits?: AmountString[];
+
+ hardLimits?: AccountLimit[];
+
+ zeroLimits?: ZeroLimitedOperation[];
}
export interface ExchangeDetailsPointer {
@@ -1062,6 +1073,8 @@ export interface RefreshGroupRecord {
timestampCreated: DbPreciseTimestamp;
+ failReason?: TalerErrorDetail;
+
/**
* Timestamp when the refresh session finished.
*/
@@ -1279,6 +1292,9 @@ export interface PurchaseRecord {
purchaseStatus: PurchaseStatus;
+ abortReason?: TalerErrorDetail;
+ failReason?: TalerErrorDetail;
+
/**
* Private key for the nonce.
*/
@@ -1414,6 +1430,7 @@ export const enum WithdrawalRecordType {
export interface WgInfoBankIntegrated {
withdrawalType: WithdrawalRecordType.BankIntegrated;
+
/**
* Extra state for when this is a withdrawal involving
* a Taler-integrated bank.
@@ -1466,11 +1483,6 @@ export type WgInfo =
export type KycUserType = "individual" | "business";
-export interface KycPendingInfo {
- paytoHash: string;
- requirementRow: number;
-}
-
/**
* Group of withdrawal operations that need to be executed.
* (Either for a normal withdrawal or from a reward.)
@@ -1486,9 +1498,7 @@ export interface WithdrawalGroupRecord {
wgInfo: WgInfo;
- kycPending?: KycPendingInfo;
-
- kycUrl?: string;
+ kycPaytoHash?: string;
kycAccessToken?: string;
@@ -1533,14 +1543,6 @@ export interface WithdrawalGroupRecord {
status: WithdrawalGroupStatus;
/**
- * Wire information (as payto URI) for the bank account that
- * transferred funds for this reserve.
- *
- * FIXME: Doesn't this belong to the bankAccounts object store?
- */
- senderWire?: string;
-
- /**
* Restrict withdrawals from this reserve to this age.
*/
restrictAge?: number;
@@ -1588,6 +1590,9 @@ export interface WithdrawalGroupRecord {
* FIXME: Should this not also include a timestamp for more logical merging?
*/
denomSelUid: string;
+
+ abortReason?: TalerErrorDetail;
+ failReason?: TalerErrorDetail;
}
export interface BankWithdrawUriRecord {
@@ -1710,7 +1715,7 @@ export interface BackupProviderRecord {
*
* FIXME: Make this part of a proper BackupProviderState?
*/
- currentPaymentProposalId?: string;
+ currentPaymentTransactionId?: string;
shouldRetryFreshProposal: boolean;
@@ -1732,20 +1737,30 @@ export interface BackupProviderRecord {
export enum DepositOperationStatus {
PendingDeposit = 0x0100_0000,
+ SuspendedDeposit = 0x0110_0000,
+
PendingTrack = 0x0100_0001,
- PendingKyc = 0x0100_0002,
+ SuspendedTrack = 0x0110_0001,
- Aborting = 0x0103_0000,
+ PendingAggregateKyc = 0x0100_0002,
+ SuspendedAggregateKyc = 0x0110_0002,
- SuspendedDeposit = 0x0110_0000,
- SuspendedTrack = 0x0110_0001,
- SuspendedKyc = 0x0110_0002,
+ PendingDepositKyc = 0x0100_0003,
+ SuspendedDepositKyc = 0x0110_0003,
+
+ PendingDepositKycAuth = 0x0100_0005,
+ SuspendedDepositKycAuth = 0x0110_0005,
+ Aborting = 0x0103_0000,
SuspendedAborting = 0x0113_0000,
Finished = 0x0500_0000,
- Failed = 0x0501_0000,
- Aborted = 0x0503_0000,
+
+ FailedDeposit = 0x0501_0000,
+
+ FailedTrack = 0x0501_0001,
+
+ AbortedDeposit = 0x0503_0000,
}
export interface DepositTrackingInfo {
@@ -1829,6 +1844,9 @@ export interface DepositGroupRecord {
*/
abortRefreshGroupId?: string;
+ abortReason?: TalerErrorDetail;
+ failReason?: TalerErrorDetail;
+
kycInfo?: DepositKycInfo;
// FIXME: Do we need this and should it be in this object store?
@@ -1838,8 +1856,7 @@ export interface DepositGroupRecord {
}
export interface DepositKycInfo {
- kycUrl: string;
- requirementRow: number;
+ accessToken?: string;
paytoHash: string;
exchangeBaseUrl: string;
}
@@ -1894,10 +1911,22 @@ export interface PeerPushDebitRecord {
exchangeBaseUrl: string;
/**
+ * Restricted scope for this transaction.
+ *
+ * Relevant for coin reselection.
+ */
+ restrictScope?: ScopeInfo;
+
+ /**
* Instructed amount.
*/
amount: AmountString;
+ /**
+ * Effective amount.
+ *
+ * (Called totalCost for historical reasons.)
+ */
totalCost: AmountString;
coinSel?: DbPeerPushPaymentCoinSelection;
@@ -1939,6 +1968,9 @@ export interface PeerPushDebitRecord {
abortRefreshGroupId?: string;
+ abortReason?: TalerErrorDetail;
+ failReason?: TalerErrorDetail;
+
/**
* Status of the peer push payment initiation.
*/
@@ -2029,12 +2061,13 @@ export interface PeerPullCreditRecord {
*/
status: PeerPullPaymentCreditStatus;
- kycInfo?: KycPendingInfo;
-
- kycUrl?: string;
+ kycPaytoHash?: string;
kycAccessToken?: string;
+ abortReason?: TalerErrorDetail;
+ failReason?: TalerErrorDetail;
+
withdrawalGroupId: string | undefined;
}
@@ -2096,6 +2129,9 @@ export interface PeerPushPaymentIncomingRecord {
*/
status: PeerPushCreditStatus;
+ abortReason?: TalerErrorDetail;
+ failReason?: TalerErrorDetail;
+
/**
* Associated withdrawal group.
*/
@@ -2109,9 +2145,7 @@ export interface PeerPushPaymentIncomingRecord {
*/
currency: string | undefined;
- kycInfo?: KycPendingInfo;
-
- kycUrl?: string;
+ kycPaytoHash?: string;
kycAccessToken?: string;
}
@@ -2174,6 +2208,9 @@ export interface PeerPullPaymentIncomingRecord {
abortRefreshGroupId?: string;
+ abortReason?: TalerErrorDetail;
+ failReason?: TalerErrorDetail;
+
coinSel?: PeerPullPaymentCoinSelection;
}
diff --git a/packages/taler-wallet-core/src/dbless.ts b/packages/taler-wallet-core/src/dbless.ts
index bc0b6e428..f0a43084d 100644
--- a/packages/taler-wallet-core/src/dbless.ts
+++ b/packages/taler-wallet-core/src/dbless.ts
@@ -212,7 +212,8 @@ export async function depositCoin(args: {
coin: CoinInfo;
amount: AmountString;
depositPayto?: string;
- merchantPub?: string;
+ merchantPub: string;
+ merchantPriv: string;
contractTermsHash?: string;
// 16 bytes, crockford encoded
wireSalt?: string;
@@ -243,6 +244,10 @@ export async function depositCoin(args: {
refundDeadline: refundDeadline,
wireInfoHash: hashWire(depositPayto, wireSalt),
});
+ const merchantContractSigResp = await cryptoApi.signContractTermsHash({
+ contractTermsHash,
+ merchantPriv: merchantPub,
+ });
const requestBody: ExchangeBatchDepositRequest = {
coins: [
{
@@ -253,6 +258,7 @@ export async function depositCoin(args: {
ub_sig: dp.ub_sig,
},
],
+ merchant_sig: merchantContractSigResp.sig,
merchant_payto_uri: depositPayto,
wire_salt: wireSalt,
h_contract_terms: contractTermsHash,
diff --git a/packages/taler-wallet-core/src/denomSelection.ts b/packages/taler-wallet-core/src/denomSelection.ts
index ecc1fa881..9e62857bf 100644
--- a/packages/taler-wallet-core/src/denomSelection.ts
+++ b/packages/taler-wallet-core/src/denomSelection.ts
@@ -123,9 +123,6 @@ export function selectWithdrawalDenominations(
selectedDenoms,
totalCoinValue: Amounts.stringify(totalCoinValue),
totalWithdrawCost: Amounts.stringify(totalWithdrawCost),
- earliestDepositExpiration: AbsoluteTime.toProtocolTimestamp(
- earliestDepositExpiration,
- ),
hasDenomWithAgeRestriction,
};
}
@@ -191,9 +188,6 @@ export function selectForcedWithdrawalDenominations(
selectedDenoms,
totalCoinValue: Amounts.stringify(totalCoinValue),
totalWithdrawCost: Amounts.stringify(totalWithdrawCost),
- earliestDepositExpiration: AbsoluteTime.toProtocolTimestamp(
- earliestDepositExpiration,
- ),
hasDenomWithAgeRestriction,
};
}
diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts
index e4b378f44..d35b24f32 100644
--- a/packages/taler-wallet-core/src/deposits.ts
+++ b/packages/taler-wallet-core/src/deposits.ts
@@ -27,25 +27,26 @@ import {
Amounts,
BatchDepositRequestCoin,
CancellationToken,
+ CheckDepositRequest,
+ CheckDepositResponse,
CoinRefreshRequest,
CreateDepositGroupRequest,
CreateDepositGroupResponse,
DepositGroupFees,
DepositTransactionTrackingState,
Duration,
+ Exchange,
ExchangeBatchDepositRequest,
- ExchangeHandle,
ExchangeRefundRequest,
HttpStatusCode,
+ KycAuthTransferInfo,
Logger,
MerchantContractTerms,
- NotificationType,
- PrepareDepositRequest,
- PrepareDepositResponse,
RefreshReason,
SelectedProspectiveCoin,
TalerError,
TalerErrorCode,
+ TalerErrorDetail,
TalerPreciseTimestamp,
TalerProtocolTimestamp,
TrackTransaction,
@@ -61,7 +62,9 @@ import {
canonicalJson,
checkDbInvariant,
checkLogicInvariant,
+ codecForAccountKycStatus,
codecForBatchDepositSuccess,
+ codecForLegitimizationNeededResponse,
codecForTackTransactionAccepted,
codecForTackTransactionWired,
encodeCrock,
@@ -72,7 +75,12 @@ import {
parsePaytoUri,
stringToBytes,
} from "@gnu-taler/taler-util";
-import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
+import {
+ readResponseJsonOrThrow,
+ readSuccessResponseJsonOrThrow,
+ readTalerErrorResponse,
+ throwUnexpectedRequestError,
+} from "@gnu-taler/taler-util/http";
import { selectPayCoins, selectPayCoinsInTx } from "./coinSelection.js";
import {
PendingTaskType,
@@ -90,20 +98,24 @@ import {
DepositInfoPerExchange,
DepositOperationStatus,
DepositTrackingInfo,
- KycPendingInfo,
RefreshOperationStatus,
WalletDbAllStoresReadOnlyTransaction,
WalletDbReadWriteTransaction,
+ timestampAbsoluteFromDb,
timestampPreciseFromDb,
timestampPreciseToDb,
timestampProtocolFromDb,
timestampProtocolToDb,
} from "./db.js";
import {
+ ReadyExchangeSummary,
+ fetchFreshExchange,
getExchangeWireDetailsInTx,
getExchangeWireFee,
getScopeForAllExchanges,
} from "./exchanges.js";
+import { EddsaKeyPairStrings } from "./index.js";
+import { checkDepositHardLimitExceeded, getDepositLimitInfo } from "./kyc.js";
import {
extractContractData,
generateDepositPermissions,
@@ -121,6 +133,7 @@ import {
parseTransactionIdentifier,
} from "./transactions.js";
import { WalletExecutionContext, getDenomInfo } from "./wallet.js";
+import { augmentPaytoUrisForKycTransfer } from "./withdraw.js";
/**
* Logger.
@@ -193,6 +206,42 @@ export class DepositTransactionContext implements TransactionContext {
dg.statusPerCoin.length;
}
+ let kycAuthTransferInfo: KycAuthTransferInfo | undefined = undefined;
+
+ switch (dg.operationStatus) {
+ case DepositOperationStatus.PendingDepositKycAuth:
+ case DepositOperationStatus.SuspendedDepositKycAuth: {
+ if (!dg.kycInfo) {
+ break;
+ }
+ const plainCreditPaytoUris: string[] = [];
+ const exchangeWire = await getExchangeWireDetailsInTx(
+ tx,
+ dg.kycInfo.exchangeBaseUrl,
+ );
+ if (exchangeWire) {
+ for (const acc of exchangeWire.wireInfo.accounts) {
+ if (acc.conversion_url) {
+ // Conversion accounts do not work for KYC auth!
+ continue;
+ }
+ plainCreditPaytoUris.push(acc.payto_uri);
+ }
+ }
+ kycAuthTransferInfo = {
+ debitPaytoUri: dg.wire.payto_uri,
+ accountPub: dg.merchantPub,
+ creditPaytoUris: augmentPaytoUrisForKycTransfer(
+ plainCreditPaytoUris,
+ dg.kycInfo?.paytoHash,
+ // FIXME: Query tiny amount from exchange.
+ `${dg.currency}:0.01`,
+ ),
+ };
+ break;
+ }
+ }
+
const txState = computeDepositTransactionStatus(dg);
return {
type: TransactionType.Deposit,
@@ -217,6 +266,17 @@ export class DepositTransactionContext implements TransactionContext {
depositGroupId: dg.depositGroupId,
trackingState,
deposited,
+ abortReason: dg.abortReason,
+ failReason: dg.failReason,
+ kycAuthTransferInfo,
+ kycPaytoHash: dg.kycInfo?.paytoHash,
+ kycAccessToken: dg.kycInfo?.accessToken,
+ kycUrl: dg.kycInfo
+ ? new URL(
+ `kyc-spa/${dg.kycInfo.accessToken}`,
+ dg.kycInfo.exchangeBaseUrl,
+ ).href
+ : undefined,
...(ort?.lastError ? { error: ort.lastError } : {}),
};
}
@@ -277,11 +337,25 @@ export class DepositTransactionContext implements TransactionContext {
const oldState = computeDepositTransactionStatus(dg);
let newOpStatus: DepositOperationStatus | undefined;
switch (dg.operationStatus) {
+ case DepositOperationStatus.AbortedDeposit:
+ case DepositOperationStatus.FailedDeposit:
+ case DepositOperationStatus.FailedTrack:
+ case DepositOperationStatus.Finished:
+ case DepositOperationStatus.SuspendedAborting:
+ case DepositOperationStatus.SuspendedAggregateKyc:
+ case DepositOperationStatus.SuspendedDeposit:
+ case DepositOperationStatus.SuspendedDepositKyc:
+ case DepositOperationStatus.SuspendedTrack:
+ case DepositOperationStatus.SuspendedDepositKycAuth:
+ break;
+ case DepositOperationStatus.PendingDepositKyc:
+ newOpStatus = DepositOperationStatus.SuspendedDepositKyc;
+ break;
case DepositOperationStatus.PendingDeposit:
newOpStatus = DepositOperationStatus.SuspendedDeposit;
break;
- case DepositOperationStatus.PendingKyc:
- newOpStatus = DepositOperationStatus.SuspendedKyc;
+ case DepositOperationStatus.PendingAggregateKyc:
+ newOpStatus = DepositOperationStatus.SuspendedAggregateKyc;
break;
case DepositOperationStatus.PendingTrack:
newOpStatus = DepositOperationStatus.SuspendedTrack;
@@ -289,6 +363,11 @@ export class DepositTransactionContext implements TransactionContext {
case DepositOperationStatus.Aborting:
newOpStatus = DepositOperationStatus.SuspendedAborting;
break;
+ case DepositOperationStatus.PendingDepositKycAuth:
+ newOpStatus = DepositOperationStatus.SuspendedDepositKycAuth;
+ break;
+ default:
+ assertUnreachable(dg.operationStatus);
}
if (!newOpStatus) {
return undefined;
@@ -306,7 +385,7 @@ export class DepositTransactionContext implements TransactionContext {
notifyTransition(wex, transactionId, transitionInfo);
}
- async abortTransaction(): Promise<void> {
+ async abortTransaction(reason?: TalerErrorDetail): Promise<void> {
const { wex, depositGroupId, transactionId, taskId: retryTag } = this;
const transitionInfo = await wex.db.runReadWriteTx(
{ storeNames: ["depositGroups", "transactionsMeta"] },
@@ -320,11 +399,14 @@ export class DepositTransactionContext implements TransactionContext {
}
const oldState = computeDepositTransactionStatus(dg);
switch (dg.operationStatus) {
- case DepositOperationStatus.Finished:
- return undefined;
+ case DepositOperationStatus.PendingDepositKyc:
+ case DepositOperationStatus.SuspendedDepositKyc:
+ case DepositOperationStatus.PendingDepositKycAuth:
+ case DepositOperationStatus.SuspendedDepositKycAuth:
case DepositOperationStatus.PendingDeposit:
case DepositOperationStatus.SuspendedDeposit: {
dg.operationStatus = DepositOperationStatus.Aborting;
+ dg.abortReason = reason;
await tx.depositGroups.put(dg);
await this.updateTransactionMeta(tx);
return {
@@ -332,6 +414,19 @@ export class DepositTransactionContext implements TransactionContext {
newTxState: computeDepositTransactionStatus(dg),
};
}
+ case DepositOperationStatus.PendingTrack:
+ case DepositOperationStatus.SuspendedTrack:
+ case DepositOperationStatus.AbortedDeposit:
+ case DepositOperationStatus.Aborting:
+ case DepositOperationStatus.FailedDeposit:
+ case DepositOperationStatus.FailedTrack:
+ case DepositOperationStatus.Finished:
+ case DepositOperationStatus.SuspendedAborting:
+ case DepositOperationStatus.PendingAggregateKyc:
+ case DepositOperationStatus.SuspendedAggregateKyc:
+ break;
+ default:
+ assertUnreachable(dg.operationStatus);
}
return undefined;
},
@@ -339,10 +434,6 @@ export class DepositTransactionContext implements TransactionContext {
wex.taskScheduler.stopShepherdTask(retryTag);
notifyTransition(wex, transactionId, transitionInfo);
wex.taskScheduler.startShepherdTask(retryTag);
- wex.ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: transactionId,
- });
}
async resumeTransaction(): Promise<void> {
@@ -360,18 +451,37 @@ export class DepositTransactionContext implements TransactionContext {
const oldState = computeDepositTransactionStatus(dg);
let newOpStatus: DepositOperationStatus | undefined;
switch (dg.operationStatus) {
+ case DepositOperationStatus.AbortedDeposit:
+ case DepositOperationStatus.Aborting:
+ case DepositOperationStatus.FailedDeposit:
+ case DepositOperationStatus.FailedTrack:
+ case DepositOperationStatus.Finished:
+ case DepositOperationStatus.PendingAggregateKyc:
+ case DepositOperationStatus.PendingDeposit:
+ case DepositOperationStatus.PendingDepositKyc:
+ case DepositOperationStatus.PendingTrack:
+ case DepositOperationStatus.PendingDepositKycAuth:
+ break;
+ case DepositOperationStatus.SuspendedDepositKyc:
+ newOpStatus = DepositOperationStatus.PendingDepositKyc;
+ break;
case DepositOperationStatus.SuspendedDeposit:
newOpStatus = DepositOperationStatus.PendingDeposit;
break;
case DepositOperationStatus.SuspendedAborting:
newOpStatus = DepositOperationStatus.Aborting;
break;
- case DepositOperationStatus.SuspendedKyc:
- newOpStatus = DepositOperationStatus.PendingKyc;
+ case DepositOperationStatus.SuspendedAggregateKyc:
+ newOpStatus = DepositOperationStatus.PendingAggregateKyc;
break;
case DepositOperationStatus.SuspendedTrack:
newOpStatus = DepositOperationStatus.PendingTrack;
break;
+ case DepositOperationStatus.SuspendedDepositKycAuth:
+ newOpStatus = DepositOperationStatus.PendingDepositKycAuth;
+ break;
+ default:
+ assertUnreachable(dg.operationStatus);
}
if (!newOpStatus) {
return undefined;
@@ -389,7 +499,7 @@ export class DepositTransactionContext implements TransactionContext {
wex.taskScheduler.startShepherdTask(retryTag);
}
- async failTransaction(): Promise<void> {
+ async failTransaction(reason?: TalerErrorDetail): Promise<void> {
const { wex, depositGroupId, transactionId, taskId } = this;
const transitionInfo = await wex.db.runReadWriteTx(
{ storeNames: ["depositGroups", "transactionsMeta"] },
@@ -402,27 +512,46 @@ export class DepositTransactionContext implements TransactionContext {
return undefined;
}
const oldState = computeDepositTransactionStatus(dg);
+ let newState: DepositOperationStatus;
switch (dg.operationStatus) {
+ case DepositOperationStatus.PendingAggregateKyc:
+ case DepositOperationStatus.SuspendedAggregateKyc:
case DepositOperationStatus.SuspendedAborting:
case DepositOperationStatus.Aborting: {
- dg.operationStatus = DepositOperationStatus.Failed;
- await tx.depositGroups.put(dg);
- await this.updateTransactionMeta(tx);
- return {
- oldTxState: oldState,
- newTxState: computeDepositTransactionStatus(dg),
- };
+ newState = DepositOperationStatus.FailedDeposit;
+ break;
}
+ case DepositOperationStatus.PendingTrack:
+ case DepositOperationStatus.SuspendedTrack: {
+ newState = DepositOperationStatus.FailedTrack;
+ break;
+ }
+ case DepositOperationStatus.AbortedDeposit:
+ case DepositOperationStatus.FailedDeposit:
+ case DepositOperationStatus.FailedTrack:
+ case DepositOperationStatus.Finished:
+ case DepositOperationStatus.PendingDeposit:
+ case DepositOperationStatus.PendingDepositKyc:
+ case DepositOperationStatus.PendingDepositKycAuth:
+ case DepositOperationStatus.SuspendedDeposit:
+ case DepositOperationStatus.SuspendedDepositKyc:
+ case DepositOperationStatus.SuspendedDepositKycAuth:
+ throw Error("failing not supported in current state");
+ default:
+ assertUnreachable(dg.operationStatus);
}
- return undefined;
+ dg.operationStatus = newState;
+ dg.failReason = reason;
+ await tx.depositGroups.put(dg);
+ await this.updateTransactionMeta(tx);
+ return {
+ oldTxState: oldState,
+ newTxState: computeDepositTransactionStatus(dg),
+ };
},
);
wex.taskScheduler.stopShepherdTask(taskId);
notifyTransition(wex, transactionId, transitionInfo);
- wex.ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: transactionId,
- });
}
}
@@ -443,7 +572,7 @@ export function computeDepositTransactionStatus(
major: TransactionMajorState.Pending,
minor: TransactionMinorState.Deposit,
};
- case DepositOperationStatus.PendingKyc:
+ case DepositOperationStatus.PendingAggregateKyc:
return {
major: TransactionMajorState.Pending,
minor: TransactionMinorState.KycRequired,
@@ -453,7 +582,7 @@ export function computeDepositTransactionStatus(
major: TransactionMajorState.Pending,
minor: TransactionMinorState.Track,
};
- case DepositOperationStatus.SuspendedKyc:
+ case DepositOperationStatus.SuspendedAggregateKyc:
return {
major: TransactionMajorState.Suspended,
minor: TransactionMinorState.KycRequired,
@@ -471,18 +600,48 @@ export function computeDepositTransactionStatus(
return {
major: TransactionMajorState.Aborting,
};
- case DepositOperationStatus.Aborted:
+ case DepositOperationStatus.AbortedDeposit:
return {
major: TransactionMajorState.Aborted,
};
- case DepositOperationStatus.Failed:
+ case DepositOperationStatus.FailedDeposit:
return {
major: TransactionMajorState.Failed,
+ minor: TransactionMinorState.Deposit,
+ };
+ case DepositOperationStatus.FailedTrack:
+ return {
+ major: TransactionMajorState.Failed,
+ minor: TransactionMinorState.Track,
};
case DepositOperationStatus.SuspendedAborting:
return {
major: TransactionMajorState.SuspendedAborting,
};
+ case DepositOperationStatus.PendingDepositKyc:
+ return {
+ major: TransactionMajorState.Pending,
+ // We lie to the UI by hiding the specific KYC state.
+ minor: TransactionMinorState.KycRequired,
+ };
+ case DepositOperationStatus.SuspendedDepositKyc:
+ return {
+ major: TransactionMajorState.Suspended,
+ // We lie to the UI by hiding the specific KYC state.
+ minor: TransactionMinorState.KycRequired,
+ };
+ case DepositOperationStatus.PendingDepositKycAuth:
+ return {
+ major: TransactionMajorState.Pending,
+ // We lie to the UI by hiding the specific KYC state.
+ minor: TransactionMinorState.KycAuthRequired,
+ };
+ case DepositOperationStatus.SuspendedDepositKycAuth:
+ return {
+ major: TransactionMajorState.Suspended,
+ // We lie to the UI by hiding the specific KYC state.
+ minor: TransactionMinorState.KycAuthRequired,
+ };
default:
assertUnreachable(dg.operationStatus);
}
@@ -512,13 +671,14 @@ export function computeDepositTransactionActions(
TransactionAction.Fail,
TransactionAction.Suspend,
];
- case DepositOperationStatus.Aborted:
+ case DepositOperationStatus.AbortedDeposit:
return [TransactionAction.Delete];
- case DepositOperationStatus.Failed:
+ case DepositOperationStatus.FailedDeposit:
+ case DepositOperationStatus.FailedTrack:
return [TransactionAction.Delete];
case DepositOperationStatus.SuspendedAborting:
return [TransactionAction.Resume, TransactionAction.Fail];
- case DepositOperationStatus.PendingKyc:
+ case DepositOperationStatus.PendingAggregateKyc:
return [
TransactionAction.Retry,
TransactionAction.Suspend,
@@ -528,11 +688,19 @@ export function computeDepositTransactionActions(
return [
TransactionAction.Retry,
TransactionAction.Suspend,
- TransactionAction.Abort,
+ TransactionAction.Fail,
];
- case DepositOperationStatus.SuspendedKyc:
+ case DepositOperationStatus.SuspendedAggregateKyc:
return [TransactionAction.Resume, TransactionAction.Fail];
case DepositOperationStatus.SuspendedTrack:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ case DepositOperationStatus.PendingDepositKyc:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case DepositOperationStatus.SuspendedDepositKyc:
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case DepositOperationStatus.PendingDepositKycAuth:
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case DepositOperationStatus.SuspendedDepositKycAuth:
return [TransactionAction.Resume, TransactionAction.Abort];
default:
assertUnreachable(dg.operationStatus);
@@ -714,14 +882,14 @@ async function waitForRefreshOnDepositGroup(
// Maybe it got manually deleted? Means that we should
// just go into aborted.
logger.warn("no aborting refresh group found for deposit group");
- newOpState = DepositOperationStatus.Aborted;
+ newOpState = DepositOperationStatus.AbortedDeposit;
} else {
if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) {
- newOpState = DepositOperationStatus.Aborted;
+ newOpState = DepositOperationStatus.AbortedDeposit;
} else if (
refreshGroup.operationStatus === RefreshOperationStatus.Failed
) {
- newOpState = DepositOperationStatus.Aborted;
+ newOpState = DepositOperationStatus.AbortedDeposit;
}
}
if (newOpState) {
@@ -741,10 +909,6 @@ async function waitForRefreshOnDepositGroup(
);
notifyTransition(wex, ctx.transactionId, transitionInfo);
- wex.ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: ctx.transactionId,
- });
return TaskRunResult.backoff();
}
@@ -762,15 +926,16 @@ async function processDepositGroupAborting(
return waitForRefreshOnDepositGroup(wex, depositGroup);
}
+/**
+ * Process the transaction in states where KYC is required.
+ * Used for both the deposit KYC and aggregate KYC.
+ */
async function processDepositGroupPendingKyc(
wex: WalletExecutionContext,
depositGroup: DepositGroupRecord,
): Promise<TaskRunResult> {
const { depositGroupId } = depositGroup;
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Deposit,
- depositGroupId,
- });
+ const ctx = new DepositTransactionContext(wex, depositGroupId);
const kycInfo = depositGroup.kycInfo;
@@ -778,8 +943,13 @@ async function processDepositGroupPendingKyc(
throw Error("invalid DB state, in pending(kyc), but no kycInfo present");
}
+ const sigResp = await wex.cryptoApi.signWalletKycAuth({
+ accountPriv: depositGroup.merchantPriv,
+ accountPub: depositGroup.merchantPub,
+ });
+
const url = new URL(
- `kyc-check/${kycInfo.requirementRow}`,
+ `kyc-check/${kycInfo.paytoHash}`,
kycInfo.exchangeBaseUrl,
);
@@ -792,16 +962,19 @@ async function processDepositGroupPendingKyc(
return await wex.http.fetch(url.href, {
method: "GET",
cancellationToken: wex.cancellationToken,
+ headers: {
+ ["Account-Owner-Signature"]: sigResp.sig,
+ },
});
},
);
- const ctx = new DepositTransactionContext(wex, depositGroupId);
+ logger.trace(
+ `request to ${kycStatusRes.requestUrl} returned status ${kycStatusRes.status}`,
+ );
if (
kycStatusRes.status === HttpStatusCode.Ok ||
- //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
- // remove after the exchange is fixed or clarified
kycStatusRes.status === HttpStatusCode.NoContent
) {
const transitionInfo = await wex.db.runReadWriteTx(
@@ -811,26 +984,176 @@ async function processDepositGroupPendingKyc(
if (!newDg) {
return;
}
- if (newDg.operationStatus !== DepositOperationStatus.PendingKyc) {
- return;
- }
const oldTxState = computeDepositTransactionStatus(newDg);
- newDg.operationStatus = DepositOperationStatus.PendingTrack;
- const newTxState = computeDepositTransactionStatus(newDg);
+ switch (newDg.operationStatus) {
+ case DepositOperationStatus.PendingAggregateKyc:
+ newDg.operationStatus = DepositOperationStatus.PendingTrack;
+ break;
+ case DepositOperationStatus.PendingDepositKyc:
+ newDg.operationStatus = DepositOperationStatus.PendingDeposit;
+ break;
+ default:
+ return;
+ }
await tx.depositGroups.put(newDg);
await ctx.updateTransactionMeta(tx);
+ const newTxState = computeDepositTransactionStatus(newDg);
return { oldTxState, newTxState };
},
);
- notifyTransition(wex, transactionId, transitionInfo);
+ notifyTransition(wex, ctx.transactionId, transitionInfo);
} else if (kycStatusRes.status === HttpStatusCode.Accepted) {
- // FIXME: Do we have to update the URL here?
+ const statusResp = await readResponseJsonOrThrow(
+ kycStatusRes,
+ codecForAccountKycStatus(),
+ );
+ logger.info(`kyc still pending (HTTP 202): ${j2s(statusResp)}`);
} else {
- throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
+ throwUnexpectedRequestError(
+ kycStatusRes,
+ await readTalerErrorResponse(kycStatusRes),
+ );
}
return TaskRunResult.backoff();
}
+async function processDepositGroupPendingKycAuth(
+ wex: WalletExecutionContext,
+ depositGroup: DepositGroupRecord,
+): Promise<TaskRunResult> {
+ const { depositGroupId } = depositGroup;
+ const ctx = new DepositTransactionContext(wex, depositGroupId);
+
+ const kycInfo = depositGroup.kycInfo;
+
+ if (!kycInfo) {
+ throw Error(
+ "invalid DB state, in pending(kyc-auth), but no kycInfo present",
+ );
+ }
+
+ const sigResp = await wex.cryptoApi.signWalletKycAuth({
+ accountPriv: depositGroup.merchantPriv,
+ accountPub: depositGroup.merchantPub,
+ });
+
+ const url = new URL(
+ `kyc-check/${kycInfo.paytoHash}`,
+ kycInfo.exchangeBaseUrl,
+ );
+
+ // lpt=1 => wait for the KYC auth transfer (access token available)
+ url.searchParams.set("lpt", "1");
+
+ const kycStatusRes = await wex.ws.runLongpollQueueing(
+ wex,
+ url.hostname,
+ async (timeoutMs) => {
+ url.searchParams.set("timeout_ms", `${timeoutMs}`);
+ logger.info(`kyc url ${url.href}`);
+ return await wex.http.fetch(url.href, {
+ method: "GET",
+ cancellationToken: wex.cancellationToken,
+ headers: {
+ ["Account-Owner-Signature"]: sigResp.sig,
+ },
+ });
+ },
+ );
+
+ logger.info(`merchant pub: ${depositGroup.merchantPub}`);
+
+ logger.info(
+ `kyc-check for auth longpoll result status: ${kycStatusRes.status}`,
+ );
+
+ switch (kycStatusRes.status) {
+ case HttpStatusCode.Ok:
+ return await transitionKycAuthSuccess(ctx);
+ case HttpStatusCode.NoContent:
+ return await transitionKycAuthSuccess(ctx);
+ case HttpStatusCode.Accepted:
+ return await transitionKycAuthSuccess(ctx);
+ case HttpStatusCode.Conflict:
+ // FIXME: Consider also checking error code
+ logger.info("kyc still pending");
+ return TaskRunResult.longpollReturnedPending();
+ default:
+ throwUnexpectedRequestError(
+ kycStatusRes,
+ await readTalerErrorResponse(kycStatusRes),
+ );
+ }
+}
+
+async function transitionKycAuthSuccess(
+ ctx: DepositTransactionContext,
+): Promise<TaskRunResult> {
+ const transitionInfo = await ctx.wex.db.runReadWriteTx(
+ { storeNames: ["depositGroups", "transactionsMeta"] },
+ async (tx) => {
+ const newDg = await tx.depositGroups.get(ctx.depositGroupId);
+ if (!newDg) {
+ return;
+ }
+ const oldTxState = computeDepositTransactionStatus(newDg);
+ switch (newDg.operationStatus) {
+ case DepositOperationStatus.PendingDepositKycAuth:
+ newDg.operationStatus = DepositOperationStatus.PendingDeposit;
+ break;
+ default:
+ return;
+ }
+ await tx.depositGroups.put(newDg);
+ await ctx.updateTransactionMeta(tx);
+ const newTxState = computeDepositTransactionStatus(newDg);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(ctx.wex, ctx.transactionId, transitionInfo);
+ if (transitionInfo) {
+ return TaskRunResult.progress();
+ } else {
+ return TaskRunResult.backoff();
+ }
+}
+
+/**
+ * Finds the reserve key pair of the most recent withdrawal
+ * with the given exchange.
+ * Returns undefined if no such withdrawal exists.
+ */
+async function getLastWithdrawalKeyPair(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+): Promise<EddsaKeyPairStrings | undefined> {
+ let candidateTimestamp: AbsoluteTime | undefined = undefined;
+ let candidateRes: EddsaKeyPairStrings | undefined = undefined;
+ await wex.db.runAllStoresReadOnlyTx({}, async (tx) => {
+ const withdrawalRecs =
+ await tx.withdrawalGroups.indexes.byExchangeBaseUrl.getAll(
+ exchangeBaseUrl,
+ );
+ for (const rec of withdrawalRecs) {
+ if (!rec.timestampFinish) {
+ continue;
+ }
+ const currTimestamp = timestampAbsoluteFromDb(rec.timestampFinish);
+ if (
+ candidateTimestamp == null ||
+ AbsoluteTime.cmp(currTimestamp, candidateTimestamp) > 0
+ ) {
+ candidateTimestamp = currTimestamp;
+ candidateRes = {
+ priv: rec.reservePriv,
+ pub: rec.reservePub,
+ };
+ }
+ }
+ });
+ return candidateRes;
+}
+
/**
* Tracking information from the exchange indicated that
* KYC is required. We need to check the KYC info
@@ -839,24 +1162,35 @@ async function processDepositGroupPendingKyc(
async function transitionToKycRequired(
wex: WalletExecutionContext,
depositGroup: DepositGroupRecord,
- kycInfo: KycPendingInfo,
+ kycPaytoHash: string,
exchangeUrl: string,
): Promise<TaskRunResult> {
const { depositGroupId } = depositGroup;
const ctx = new DepositTransactionContext(wex, depositGroupId);
- const url = new URL(`kyc-check/${kycInfo.requirementRow}`, exchangeUrl);
+ const sigResp = await wex.cryptoApi.signWalletKycAuth({
+ accountPriv: depositGroup.merchantPriv,
+ accountPub: depositGroup.merchantPub,
+ });
+
+ const url = new URL(`kyc-check/${kycPaytoHash}`, exchangeUrl);
logger.info(`kyc url ${url.href}`);
- const kycStatusReq = await wex.http.fetch(url.href, {
+ const kycStatusResp = await wex.http.fetch(url.href, {
method: "GET",
+ headers: {
+ ["Account-Owner-Signature"]: sigResp.sig,
+ },
});
- if (kycStatusReq.status === HttpStatusCode.Ok) {
+ logger.trace(`response status of initial kyc-check: ${kycStatusResp.status}`);
+ if (kycStatusResp.status === HttpStatusCode.Ok) {
logger.warn("kyc requested, but already fulfilled");
return TaskRunResult.backoff();
- } else if (kycStatusReq.status === HttpStatusCode.Accepted) {
- const kycStatus = await kycStatusReq.json();
- logger.info(`kyc status: ${j2s(kycStatus)}`);
+ } else if (kycStatusResp.status === HttpStatusCode.Accepted) {
+ const statusResp = await readResponseJsonOrThrow(
+ kycStatusResp,
+ codecForAccountKycStatus(),
+ );
const transitionInfo = await wex.db.runReadWriteTx(
{ storeNames: ["depositGroups", "transactionsMeta"] },
async (tx) => {
@@ -864,15 +1198,21 @@ async function transitionToKycRequired(
if (!dg) {
return undefined;
}
- if (dg.operationStatus !== DepositOperationStatus.PendingTrack) {
- return undefined;
- }
const oldTxState = computeDepositTransactionStatus(dg);
+ switch (dg.operationStatus) {
+ case DepositOperationStatus.PendingTrack:
+ dg.operationStatus = DepositOperationStatus.PendingAggregateKyc;
+ break;
+ case DepositOperationStatus.PendingDeposit:
+ dg.operationStatus = DepositOperationStatus.PendingDepositKyc;
+ break;
+ default:
+ return;
+ }
dg.kycInfo = {
exchangeBaseUrl: exchangeUrl,
- kycUrl: kycStatus.kyc_url,
- paytoHash: kycInfo.paytoHash,
- requirementRow: kycInfo.requirementRow,
+ paytoHash: kycPaytoHash,
+ accessToken: statusResp.access_token,
};
await tx.depositGroups.put(dg);
await ctx.updateTransactionMeta(tx);
@@ -881,12 +1221,57 @@ async function transitionToKycRequired(
},
);
notifyTransition(wex, ctx.transactionId, transitionInfo);
- return TaskRunResult.finished();
+ return TaskRunResult.progress();
} else {
- throw Error(`unexpected response from kyc-check (${kycStatusReq.status})`);
+ throwUnexpectedRequestError(
+ kycStatusResp,
+ await readTalerErrorResponse(kycStatusResp),
+ );
}
}
+async function transitionToKycAuthRequired(
+ wex: WalletExecutionContext,
+ depositGroup: DepositGroupRecord,
+ kycPaytoHash: string,
+ exchangeUrl: string,
+): Promise<TaskRunResult> {
+ const { depositGroupId } = depositGroup;
+
+ const ctx = new DepositTransactionContext(wex, depositGroupId);
+
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["depositGroups", "transactionsMeta"] },
+ async (tx) => {
+ const dg = await tx.depositGroups.get(depositGroupId);
+ if (!dg) {
+ return undefined;
+ }
+ const oldTxState = computeDepositTransactionStatus(dg);
+ switch (dg.operationStatus) {
+ case DepositOperationStatus.PendingTrack:
+ throw Error("not yet supported");
+ break;
+ case DepositOperationStatus.PendingDeposit:
+ dg.operationStatus = DepositOperationStatus.PendingDepositKycAuth;
+ break;
+ default:
+ return;
+ }
+ dg.kycInfo = {
+ exchangeBaseUrl: exchangeUrl,
+ paytoHash: kycPaytoHash,
+ };
+ await tx.depositGroups.put(dg);
+ await ctx.updateTransactionMeta(tx);
+ const newTxState = computeDepositTransactionStatus(dg);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, ctx.transactionId, transitionInfo);
+ return TaskRunResult.progress();
+}
+
async function processDepositGroupPendingTrack(
wex: WalletExecutionContext,
depositGroup: DepositGroupRecord,
@@ -903,6 +1288,7 @@ async function processDepositGroupPendingTrack(
"unable to refund deposit group without coin selection (selection missing)",
);
}
+ logger.trace(`tracking deposit group, status ${j2s(statusPerCoin)}`);
const { depositGroupId } = depositGroup;
const ctx = new DepositTransactionContext(wex, depositGroupId);
for (let i = 0; i < statusPerCoin.length; i++) {
@@ -933,20 +1319,16 @@ async function processDepositGroupPendingTrack(
exchangeBaseUrl,
);
+ logger.trace(`track response: ${j2s(track)}`);
if (track.type === "accepted") {
if (!track.kyc_ok && track.requirement_row !== undefined) {
const paytoHash = encodeCrock(
hashTruncate32(stringToBytes(depositGroup.wire.payto_uri + "\0")),
);
- const { requirement_row: requirementRow } = track;
- const kycInfo: KycPendingInfo = {
- paytoHash,
- requirementRow,
- };
return transitionToKycRequired(
wex,
depositGroup,
- kycInfo,
+ paytoHash,
exchangeBaseUrl,
);
} else {
@@ -1053,10 +1435,6 @@ async function processDepositGroupPendingTrack(
);
notifyTransition(wex, ctx.transactionId, transitionInfo);
if (allWired) {
- wex.ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: ctx.transactionId,
- });
return TaskRunResult.finished();
} else {
return TaskRunResult.longpollReturnedPending();
@@ -1133,6 +1511,7 @@ async function processDepositGroupPendingDeposit(
exchanges: contractData.allowedExchanges,
},
restrictWireMethod: contractData.wireMethod,
+ depositPaytoUri: dg.wire.payto_uri,
contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
prevPayCoins: [],
@@ -1203,8 +1582,13 @@ async function processDepositGroupPendingDeposit(
exchanges.add(dp.exchange_url);
}
+ const merchantSigResp = await wex.ws.cryptoApi.signContractTermsHash({
+ contractTermsHash: depositGroup.contractTermsHash,
+ merchantPriv: depositGroup.merchantPriv,
+ });
+
// We need to do one batch per exchange.
- for (const exchangeUrl of exchanges.values()) {
+ for (const exchangeBaseUrl of exchanges.values()) {
const coins: BatchDepositRequestCoin[] = [];
const batchIndexes: number[] = [];
@@ -1217,11 +1601,12 @@ async function processDepositGroupPendingDeposit(
wire_salt: depositGroup.wire.salt,
wire_transfer_deadline: contractTerms.wire_transfer_deadline,
refund_deadline: contractTerms.refund_deadline,
+ merchant_sig: merchantSigResp.sig,
};
for (let i = 0; i < depositPermissions.length; i++) {
const perm = depositPermissions[i];
- if (perm.exchange_url != exchangeUrl) {
+ if (perm.exchange_url != exchangeBaseUrl) {
continue;
}
coins.push({
@@ -1237,7 +1622,7 @@ async function processDepositGroupPendingDeposit(
// Check for cancellation before making network request.
cancellationToken?.throwIfCancelled();
- const url = new URL(`batch-deposit`, exchangeUrl);
+ const url = new URL(`batch-deposit`, exchangeBaseUrl);
logger.info(`depositing to ${url.href}`);
logger.trace(`deposit request: ${j2s(batchReq)}`);
const httpResp = await wex.http.fetch(url.href, {
@@ -1245,6 +1630,37 @@ async function processDepositGroupPendingDeposit(
body: batchReq,
cancellationToken: cancellationToken,
});
+
+ switch (httpResp.status) {
+ case HttpStatusCode.Accepted:
+ case HttpStatusCode.Ok:
+ break;
+ case HttpStatusCode.UnavailableForLegalReasons: {
+ const kycLegiNeededResp = await readResponseJsonOrThrow(
+ httpResp,
+ codecForLegitimizationNeededResponse(),
+ );
+ logger.info(
+ `kyc legitimization needed response: ${j2s(kycLegiNeededResp)}`,
+ );
+ if (kycLegiNeededResp.bad_kyc_auth) {
+ return transitionToKycAuthRequired(
+ wex,
+ depositGroup,
+ kycLegiNeededResp.h_payto,
+ exchangeBaseUrl,
+ );
+ } else {
+ return transitionToKycRequired(
+ wex,
+ depositGroup,
+ kycLegiNeededResp.h_payto,
+ exchangeBaseUrl,
+ );
+ }
+ }
+ }
+
await readSuccessResponseJsonOrThrow(
httpResp,
codecForBatchDepositSuccess(),
@@ -1318,12 +1734,15 @@ export async function processDepositGroup(
switch (depositGroup.operationStatus) {
case DepositOperationStatus.PendingTrack:
return processDepositGroupPendingTrack(wex, depositGroup);
- case DepositOperationStatus.PendingKyc:
+ case DepositOperationStatus.PendingAggregateKyc:
+ case DepositOperationStatus.PendingDepositKyc:
return processDepositGroupPendingKyc(wex, depositGroup);
case DepositOperationStatus.PendingDeposit:
return processDepositGroupPendingDeposit(wex, depositGroup);
case DepositOperationStatus.Aborting:
return processDepositGroupAborting(wex, depositGroup);
+ case DepositOperationStatus.PendingDepositKycAuth:
+ return processDepositGroupPendingKycAuth(wex, depositGroup);
}
return TaskRunResult.finished();
@@ -1357,6 +1776,8 @@ async function trackDeposit(
url.hostname,
async (timeoutMs) => {
url.searchParams.set("timeout_ms", `${timeoutMs}`);
+ // wait for the a 202 state where kyc_ok is false or a 200 OK response
+ url.searchParams.set("lpt", `1`);
return await wex.http.fetch(url.href, {
method: "GET",
cancellationToken: wex.cancellationToken,
@@ -1370,6 +1791,7 @@ async function trackDeposit(
httpResp,
codecForTackTransactionAccepted(),
);
+ logger.trace(`deposits response: ${j2s(accepted)}`);
return { type: "accepted", ...accepted };
}
case HttpStatusCode.Ok: {
@@ -1380,8 +1802,9 @@ async function trackDeposit(
return { type: "wired", ...wired };
}
default: {
- throw Error(
- `unexpected response from track-transaction (${httpResp.status})`,
+ throwUnexpectedRequestError(
+ httpResp,
+ await readTalerErrorResponse(httpResp),
);
}
}
@@ -1393,8 +1816,8 @@ async function trackDeposit(
*/
export async function checkDepositGroup(
wex: WalletExecutionContext,
- req: PrepareDepositRequest,
-): Promise<PrepareDepositResponse> {
+ req: CheckDepositRequest,
+): Promise<CheckDepositResponse> {
return await runWithClientCancellation(
wex,
"checkDepositGroup",
@@ -1409,8 +1832,8 @@ export async function checkDepositGroup(
*/
export async function internalCheckDepositGroup(
wex: WalletExecutionContext,
- req: PrepareDepositRequest,
-): Promise<PrepareDepositResponse> {
+ req: CheckDepositRequest,
+): Promise<CheckDepositResponse> {
const p = parsePaytoUri(req.depositPaytoUri);
if (!p) {
throw Error("invalid payto URI");
@@ -1418,7 +1841,7 @@ export async function internalCheckDepositGroup(
const amount = Amounts.parseOrThrow(req.amount);
const currency = Amounts.currencyOf(amount);
- const exchangeInfos: ExchangeHandle[] = [];
+ const exchangeInfos: Exchange[] = [];
await wex.db.runReadOnlyTx(
{ storeNames: ["exchangeDetails", "exchanges"] },
@@ -1431,6 +1854,7 @@ export async function internalCheckDepositGroup(
}
exchangeInfos.push({
master_pub: details.masterPublicKey,
+ priority: 1,
url: e.baseUrl,
});
}
@@ -1477,6 +1901,7 @@ export async function internalCheckDepositGroup(
exchanges: contractData.allowedExchanges,
},
restrictWireMethod: contractData.wireMethod,
+ depositPaytoUri: req.depositPaytoUri,
contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
prevPayCoins: [],
@@ -1510,6 +1935,17 @@ export async function internalCheckDepositGroup(
selCoins,
);
+ const usedExchangesSet = new Set<string>();
+ for (const c of selCoins) {
+ usedExchangesSet.add(c.exchangeBaseUrl);
+ }
+
+ const exchanges: ReadyExchangeSummary[] = [];
+
+ for (const exchangeBaseUrl of usedExchangesSet) {
+ exchanges.push(await fetchFreshExchange(wex, exchangeBaseUrl));
+ }
+
const fees = await getTotalFeesForDepositAmount(
wex,
p.targetType,
@@ -1521,6 +1957,7 @@ export async function internalCheckDepositGroup(
totalDepositCost: Amounts.stringify(totalDepositCost),
effectiveDepositAmount: Amounts.stringify(effectiveDepositAmount),
fees,
+ ...getDepositLimitInfo(exchanges, effectiveDepositAmount),
};
}
@@ -1536,8 +1973,8 @@ export async function createDepositGroup(
wex: WalletExecutionContext,
req: CreateDepositGroupRequest,
): Promise<CreateDepositGroupResponse> {
- const p = parsePaytoUri(req.depositPaytoUri);
- if (!p) {
+ const depositPayto = parsePaytoUri(req.depositPaytoUri);
+ if (!depositPayto) {
throw Error("invalid payto URI");
}
@@ -1568,15 +2005,88 @@ export async function createDepositGroup(
AbsoluteTime.addDuration(now, Duration.fromSpec({ minutes: 5 })),
);
const nowRounded = AbsoluteTime.toProtocolTimestamp(now);
+
+ const payCoinSel = await selectPayCoins(wex, {
+ restrictExchanges: {
+ auditors: [],
+ exchanges: exchangeInfos.map((x) => ({
+ exchangeBaseUrl: x.url,
+ exchangePub: x.master_pub,
+ })),
+ },
+ restrictWireMethod: depositPayto.targetType,
+ depositPaytoUri: req.depositPaytoUri,
+ contractTermsAmount: amount,
+ depositFeeLimit: amount,
+ prevPayCoins: [],
+ });
+
+ let coins: SelectedProspectiveCoin[] | undefined = undefined;
+
+ switch (payCoinSel.type) {
+ case "success":
+ coins = payCoinSel.coinSel.coins;
+ break;
+ case "failure":
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails,
+ },
+ );
+ case "prospective":
+ coins = payCoinSel.result.prospectiveCoins;
+ break;
+ default:
+ assertUnreachable(payCoinSel);
+ }
+
+ const usedExchangesSet = new Set<string>();
+ for (const c of coins) {
+ usedExchangesSet.add(c.exchangeBaseUrl);
+ }
+
+ const exchanges: ReadyExchangeSummary[] = [];
+
+ for (const exchangeBaseUrl of usedExchangesSet) {
+ exchanges.push(await fetchFreshExchange(wex, exchangeBaseUrl));
+ }
+
+ if (checkDepositHardLimitExceeded(exchanges, req.amount)) {
+ throw Error("deposit would exceed hard KYC limit");
+ }
+
+ // Heuristic for the merchant key pair: If there's an exchange where we made
+ // a withdrawal from, use that key pair, so the user doesn't have to do
+ // a KYC transfer to establish a kyc account key pair.
+ // FIXME: Extend the heuristic to use the last used merchant key pair?
+ let merchantPair: EddsaKeyPairStrings | undefined = undefined;
+ if (coins.length > 0) {
+ const res = await getLastWithdrawalKeyPair(wex, coins[0].exchangeBaseUrl);
+ if (res) {
+ logger.info(
+ `reusing reserve pub ${res.pub} from last withdrawal to ${coins[0].exchangeBaseUrl}`,
+ );
+ merchantPair = res;
+ }
+ }
+ if (!merchantPair) {
+ logger.info(`creating new merchant key pair for deposit`);
+ merchantPair = await wex.cryptoApi.createEddsaKeypair({});
+ }
+
const noncePair = await wex.cryptoApi.createEddsaKeypair({});
- const merchantPair = await wex.cryptoApi.createEddsaKeypair({});
const wireSalt = encodeCrock(getRandomBytes(16));
const wireHash = hashWire(req.depositPaytoUri, wireSalt);
const contractTerms: MerchantContractTerms = {
- exchanges: exchangeInfos,
+ exchanges: exchangeInfos.map((x) => ({
+ master_pub: x.master_pub,
+ priority: 1,
+ url: x.url,
+ })),
amount: req.amount,
max_fee: Amounts.stringify(amount),
- wire_method: p.targetType,
+ wire_method: depositPayto.targetType,
timestamp: nowRounded,
merchant_base_url: "",
summary: "",
@@ -1604,37 +2114,6 @@ export async function createDepositGroup(
"",
);
- const payCoinSel = await selectPayCoins(wex, {
- restrictExchanges: {
- auditors: [],
- exchanges: contractData.allowedExchanges,
- },
- restrictWireMethod: contractData.wireMethod,
- contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
- depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
- prevPayCoins: [],
- });
-
- let coins: SelectedProspectiveCoin[] | undefined = undefined;
-
- switch (payCoinSel.type) {
- case "success":
- coins = payCoinSel.coinSel.coins;
- break;
- case "failure":
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE,
- {
- insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails,
- },
- );
- case "prospective":
- coins = payCoinSel.result.prospectiveCoins;
- break;
- default:
- assertUnreachable(payCoinSel);
- }
-
const totalDepositCost = await getTotalPaymentCost(wex, currency, coins);
let depositGroupId: string;
@@ -1666,7 +2145,11 @@ export async function createDepositGroup(
}
const counterpartyEffectiveDepositAmount =
- await getCounterpartyEffectiveDepositAmount(wex, p.targetType, coins);
+ await getCounterpartyEffectiveDepositAmount(
+ wex,
+ depositPayto.targetType,
+ coins,
+ );
const depositGroup: DepositGroupRecord = {
contractTermsHash,
@@ -1749,20 +2232,13 @@ export async function createDepositGroup(
},
);
- wex.ws.notify({
- type: NotificationType.TransactionStateTransition,
- transactionId,
+ notifyTransition(wex, transactionId, {
oldTxState: {
major: TransactionMajorState.None,
},
newTxState,
});
- wex.ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: transactionId,
- });
-
wex.taskScheduler.startShepherdTask(ctx.taskId);
return {
diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts
index 7b8e57c8b..560e660a5 100644
--- a/packages/taler-wallet-core/src/exchanges.ts
+++ b/packages/taler-wallet-core/src/exchanges.ts
@@ -26,6 +26,7 @@
import {
AbsoluteTime,
AccountKycStatus,
+ AccountLimit,
AgeRestriction,
Amount,
AmountLike,
@@ -62,6 +63,7 @@ import {
HttpStatusCode,
LegitimizationNeededResponse,
LibtoolVersion,
+ ListExchangesRequest,
Logger,
NotificationType,
OperationErrorInfo,
@@ -91,6 +93,7 @@ import {
WireFeeMap,
WireFeesJson,
WireInfo,
+ ZeroLimitedOperation,
assertUnreachable,
checkDbInvariant,
checkLogicInvariant,
@@ -101,10 +104,12 @@ import {
encodeCrock,
getRandomBytes,
hashDenomPub,
+ hashPaytoUri,
j2s,
makeErrorDetail,
makeTalerErrorDetail,
parsePaytoUri,
+ stringifyReservePaytoUri,
} from "@gnu-taler/taler-util";
import {
HttpRequestLibrary,
@@ -143,6 +148,7 @@ import {
ReserveRecord,
ReserveRecordStatus,
WalletDbAllStoresReadOnlyTransaction,
+ WalletDbAllStoresReadWriteTransaction,
WalletDbHelpers,
WalletDbReadOnlyTransaction,
WalletDbReadWriteTransaction,
@@ -166,6 +172,7 @@ import { createRefreshGroup } from "./refresh.js";
import {
constructTransactionIdentifier,
notifyTransition,
+ rematerializeTransactions,
} from "./transactions.js";
import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "./versions.js";
import { InternalWalletState, WalletExecutionContext } from "./wallet.js";
@@ -281,6 +288,9 @@ export async function getScopeForAllCoins(
return rs.filter((d): d is ScopeInfo => d !== undefined);
}
+/**
+ * Get a list of scope infos applicable to a list of exchanges.
+ */
export async function getScopeForAllExchanges(
tx: WalletDbReadOnlyTransaction<
[
@@ -850,6 +860,8 @@ export interface ExchangeKeysDownloadResult {
wireFees: { [methodName: string]: WireFeesJson[] };
currencySpecification?: CurrencySpecification;
walletBalanceLimits: AmountString[] | undefined;
+ hardLimits: AccountLimit[] | undefined;
+ zeroLimits: ZeroLimitedOperation[] | undefined;
}
/**
@@ -1015,6 +1027,8 @@ async function downloadExchangeKeysInfo(
currencySpecification: exchangeKeysJsonUnchecked.currency_specification,
walletBalanceLimits:
exchangeKeysJsonUnchecked.wallet_balance_limit_without_kyc,
+ hardLimits: exchangeKeysJsonUnchecked.hard_limits,
+ zeroLimits: exchangeKeysJsonUnchecked.zero_limits,
};
}
@@ -1255,6 +1269,9 @@ export interface ReadyExchangeSummary {
protocolVersionRange: string;
tosAcceptedTimestamp: TalerPreciseTimestamp | undefined;
scopeInfo: ScopeInfo;
+ walletBalanceLimitWithoutKyc: AmountString[] | undefined;
+ zeroLimits: ZeroLimitedOperation[];
+ hardLimits: AccountLimit[];
}
/**
@@ -1419,6 +1436,9 @@ async function waitReadyExchange(
exchange.tosAcceptedTimestamp,
),
scopeInfo,
+ walletBalanceLimitWithoutKyc: exchangeDetails.walletBalanceLimits,
+ hardLimits: exchangeDetails.hardLimits ?? [],
+ zeroLimits: exchangeDetails.zeroLimits ?? [],
};
if (options.expectedMasterPub) {
@@ -1745,6 +1765,8 @@ export async function updateExchangeFromUrlHandler(
wireInfo,
ageMask,
walletBalanceLimits: keysInfo.walletBalanceLimits,
+ hardLimits: keysInfo.hardLimits,
+ zeroLimits: keysInfo.zeroLimits,
};
r.noFees = noFees;
r.peerPaymentsDisabled = peerPaymentsDisabled;
@@ -2501,6 +2523,7 @@ export async function downloadExchangeInfo(
*/
export async function listExchanges(
wex: WalletExecutionContext,
+ req: ListExchangesRequest,
): Promise<ExchangesListResponse> {
const exchanges: ExchangeListItem[] = [];
await wex.db.runReadOnlyTx(
@@ -2533,15 +2556,30 @@ export async function listExchanges(
);
checkDbInvariant(!!reserveRec, "reserve record not found");
}
- exchanges.push(
- await makeExchangeListItem(
+ if (req.filterByScope) {
+ const inScope = await checkExchangeInScopeTx(
+ wex,
tx,
- exchangeRec,
- exchangeDetails,
- reserveRec,
- opRetryRecord?.lastError,
- ),
+ exchangeRec.baseUrl,
+ req.filterByScope,
+ );
+ if (!inScope) {
+ continue;
+ }
+ }
+ const li = await makeExchangeListItem(
+ tx,
+ exchangeRec,
+ exchangeDetails,
+ reserveRec,
+ opRetryRecord?.lastError,
);
+ if (req.filterByExchangeEntryStatus) {
+ if (req.filterByExchangeEntryStatus !== li.exchangeEntryStatus) {
+ continue;
+ }
+ }
+ exchanges.push(li);
}
},
);
@@ -2776,27 +2814,20 @@ async function internalGetExchangeResources(
* but keeps some transactions (payments, p2p, refreshes) around.
*/
async function purgeExchange(
- tx: WalletDbReadWriteTransaction<
- [
- "exchanges",
- "exchangeDetails",
- "transactionsMeta",
- "coinAvailability",
- "coins",
- "denominations",
- "exchangeSignKeys",
- "withdrawalGroups",
- "planchets",
- ]
- >,
+ wex: WalletExecutionContext,
+ tx: WalletDbAllStoresReadWriteTransaction,
exchangeBaseUrl: string,
): Promise<void> {
const detRecs = await tx.exchangeDetails.indexes.byExchangeBaseUrl.getAll();
+ // Remove all exchange detail records for that exchange
for (const r of detRecs) {
if (r.rowId == null) {
// Should never happen, as rowId is the primary key.
continue;
}
+ if (r.exchangeBaseUrl !== exchangeBaseUrl) {
+ continue;
+ }
await tx.exchangeDetails.delete(r.rowId);
const signkeyRecs =
await tx.exchangeSignKeys.indexes.byExchangeDetailsRowId.getAll(r.rowId);
@@ -2851,6 +2882,8 @@ async function purgeExchange(
}
}
}
+
+ await rematerializeTransactions(wex, tx);
}
export async function deleteExchange(
@@ -2859,36 +2892,29 @@ export async function deleteExchange(
): Promise<void> {
let inUse: boolean = false;
const exchangeBaseUrl = req.exchangeBaseUrl;
- await wex.db.runReadWriteTx(
- {
- storeNames: [
- "exchanges",
- "exchangeDetails",
- "transactionsMeta",
- "coinAvailability",
- "coins",
- "denominations",
- "exchangeSignKeys",
- "withdrawalGroups",
- "planchets",
- ],
- },
- async (tx) => {
- const exchangeRec = await tx.exchanges.get(exchangeBaseUrl);
- if (!exchangeRec) {
- // Nothing to delete!
- logger.info("no exchange found to delete");
- return;
- }
- const res = await internalGetExchangeResources(wex, tx, exchangeBaseUrl);
- if (res.hasResources && !req.purge) {
- inUse = true;
- return;
- }
- await purgeExchange(tx, exchangeBaseUrl);
- wex.ws.exchangeCache.clear();
- },
- );
+ const notif = await wex.db.runAllStoresReadWriteTx({}, async (tx) => {
+ const exchangeRec = await tx.exchanges.get(exchangeBaseUrl);
+ if (!exchangeRec) {
+ // Nothing to delete!
+ logger.info("no exchange found to delete");
+ return;
+ }
+ const oldExchangeState = getExchangeState(exchangeRec);
+ const res = await internalGetExchangeResources(wex, tx, exchangeBaseUrl);
+ if (res.hasResources && !req.purge) {
+ inUse = true;
+ return;
+ }
+ await purgeExchange(wex, tx, exchangeBaseUrl);
+ wex.ws.exchangeCache.clear();
+
+ return {
+ type: NotificationType.ExchangeStateTransition,
+ oldExchangeState,
+ newExchangeState: undefined,
+ exchangeBaseUrl,
+ } satisfies WalletNotification;
+ });
if (inUse) {
throw TalerError.fromUncheckedDetail({
@@ -2896,6 +2922,9 @@ export async function deleteExchange(
hint: "Exchange in use.",
});
}
+ if (notif) {
+ wex.ws.notify(notif);
+ }
}
export async function getExchangeResources(
@@ -3388,8 +3417,7 @@ async function handleExchangeKycRespLegi(
accountPriv: reserve.reservePriv,
accountPub: reserve.reservePub,
});
- const requirementRow = kycBody.requirement_row;
- const reqUrl = new URL(`kyc-check/${requirementRow}`, exchangeBaseUrl);
+ const reqUrl = new URL(`kyc-check/${kycBody.h_payto}`, exchangeBaseUrl);
const resp = await wex.http.fetch(reqUrl.href, {
method: "GET",
headers: {
@@ -3484,13 +3512,19 @@ async function handleExchangeKycPendingLegitimization(
accountPriv: reserve.reservePriv,
accountPub: reserve.reservePub,
});
- const requirementRow = reserve.requirementRow;
- checkDbInvariant(!!requirementRow, "requirement row");
+
+ const reservePayto = stringifyReservePaytoUri(
+ exchange.baseUrl,
+ reserve.reservePub,
+ );
+
+ const paytoHash = encodeCrock(hashPaytoUri(reservePayto));
+
const resp = await wex.ws.runLongpollQueueing(
wex,
exchange.baseUrl,
async (timeoutMs) => {
- const reqUrl = new URL(`kyc-check/${requirementRow}`, exchange.baseUrl);
+ const reqUrl = new URL(`kyc-check/${paytoHash}`, exchange.baseUrl);
reqUrl.searchParams.set("timeout_ms", `${timeoutMs}`);
logger.info(`long-polling wallet KYC status at ${reqUrl.href}`);
return await wex.http.fetch(reqUrl.href, {
@@ -3572,3 +3606,153 @@ export async function processExchangeKyc(
);
}
}
+
+export async function checkExchangeInScopeTx(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<
+ [
+ "globalCurrencyExchanges",
+ "globalCurrencyAuditors",
+ "exchanges",
+ "exchangeDetails",
+ ]
+ >,
+ exchangeBaseUrl: string,
+ scope: ScopeInfo,
+): Promise<boolean> {
+ switch (scope.type) {
+ case ScopeType.Exchange: {
+ return scope.url === exchangeBaseUrl;
+ }
+ case ScopeType.Global: {
+ const exchangeDetails = await getExchangeRecordsInternal(
+ tx,
+ exchangeBaseUrl,
+ );
+ if (!exchangeDetails) {
+ return false;
+ }
+ const gr = await tx.globalCurrencyExchanges.get([
+ exchangeDetails.currency,
+ exchangeBaseUrl,
+ exchangeDetails.masterPublicKey,
+ ]);
+ return gr != null;
+ }
+ case ScopeType.Auditor:
+ throw Error("auditor scope not supported yet");
+ }
+}
+
+/**
+ * Find a preferred exchange based on when we withdrew last from this exchange.
+ */
+export async function getPreferredExchangeForCurrency(
+ wex: WalletExecutionContext,
+ currency: string,
+): Promise<string | undefined> {
+ // Find an exchange with the matching currency.
+ // Prefer exchanges with the most recent withdrawal.
+ const url = await wex.db.runReadOnlyTx(
+ { storeNames: ["exchanges"] },
+ async (tx) => {
+ const exchanges = await tx.exchanges.iter().toArray();
+ let candidate = undefined;
+ for (const e of exchanges) {
+ if (e.detailsPointer?.currency !== currency) {
+ continue;
+ }
+ if (!candidate) {
+ candidate = e;
+ continue;
+ }
+ if (candidate.lastWithdrawal && !e.lastWithdrawal) {
+ continue;
+ }
+ const exchangeLastWithdrawal = timestampOptionalPreciseFromDb(
+ e.lastWithdrawal,
+ );
+ const candidateLastWithdrawal = timestampOptionalPreciseFromDb(
+ candidate.lastWithdrawal,
+ );
+ if (exchangeLastWithdrawal && candidateLastWithdrawal) {
+ if (
+ AbsoluteTime.cmp(
+ AbsoluteTime.fromPreciseTimestamp(exchangeLastWithdrawal),
+ AbsoluteTime.fromPreciseTimestamp(candidateLastWithdrawal),
+ ) > 0
+ ) {
+ candidate = e;
+ }
+ }
+ }
+ if (candidate) {
+ return candidate.baseUrl;
+ }
+ return undefined;
+ },
+ );
+ return url;
+}
+
+/**
+ * Find a preferred exchange based on when we withdrew last from this exchange.
+ */
+export async function getPreferredExchangeForScope(
+ wex: WalletExecutionContext,
+ scope: ScopeInfo,
+): Promise<string | undefined> {
+ // Find an exchange with the matching currency.
+ // Prefer exchanges with the most recent withdrawal.
+ const url = await wex.db.runReadOnlyTx(
+ {
+ storeNames: [
+ "exchanges",
+ "globalCurrencyAuditors",
+ "globalCurrencyExchanges",
+ "exchangeDetails",
+ ],
+ },
+ async (tx) => {
+ const exchanges = await tx.exchanges.iter().toArray();
+ let candidate = undefined;
+ for (const e of exchanges) {
+ const inScope = await checkExchangeInScopeTx(wex, tx, e.baseUrl, scope);
+ if (!inScope) {
+ continue;
+ }
+ if (e.detailsPointer?.currency !== scope.currency) {
+ continue;
+ }
+ if (!candidate) {
+ candidate = e;
+ continue;
+ }
+ if (candidate.lastWithdrawal && !e.lastWithdrawal) {
+ continue;
+ }
+ const exchangeLastWithdrawal = timestampOptionalPreciseFromDb(
+ e.lastWithdrawal,
+ );
+ const candidateLastWithdrawal = timestampOptionalPreciseFromDb(
+ candidate.lastWithdrawal,
+ );
+ if (exchangeLastWithdrawal && candidateLastWithdrawal) {
+ if (
+ AbsoluteTime.cmp(
+ AbsoluteTime.fromPreciseTimestamp(exchangeLastWithdrawal),
+ AbsoluteTime.fromPreciseTimestamp(candidateLastWithdrawal),
+ ) > 0
+ ) {
+ candidate = e;
+ }
+ }
+ }
+ if (candidate) {
+ return candidate.baseUrl;
+ }
+ return undefined;
+ },
+ );
+ return url;
+}
diff --git a/packages/taler-wallet-core/src/host-common.ts b/packages/taler-wallet-core/src/host-common.ts
index 7651e5a12..933c36533 100644
--- a/packages/taler-wallet-core/src/host-common.ts
+++ b/packages/taler-wallet-core/src/host-common.ts
@@ -58,3 +58,31 @@ export function makeTempfileId(length: number): string {
}
return result;
}
+
+/**
+ * Get the underlying sqlite3 DB filename
+ * from the storage path specified by the wallet-core client.
+ */
+export function getSqlite3FilenameFromStoragePath(
+ p: string | undefined,
+): string {
+ // Allow specifying a directory as the storage path.
+ // In that case, we pick the filename and use the sqlite3
+ // backend.
+ //
+ // We still allow specifying a filename for backwards
+ // compatibility and control over the exact file.
+ //
+ // Specifying a directory allows us to automatically
+ // migrate to a new DB file in the future.
+ if (!p) {
+ return ":memory:";
+ }
+ if (p.endsWith("/")) {
+ // Current sqlite3 DB filename.
+ // Should rarely if ever change.
+ return `${p}talerwalletdb-v30.sqlite3`;
+ } else {
+ return p;
+ }
+}
diff --git a/packages/taler-wallet-core/src/host-impl.node.ts b/packages/taler-wallet-core/src/host-impl.node.ts
index a7f0a5738..eb4191f81 100644
--- a/packages/taler-wallet-core/src/host-impl.node.ts
+++ b/packages/taler-wallet-core/src/host-impl.node.ts
@@ -41,7 +41,11 @@ import { createPlatformHttpLib } from "@gnu-taler/taler-util/http";
import * as fs from "fs";
import { NodeThreadCryptoWorkerFactory } from "./crypto/workers/nodeThreadWorker.js";
import { SynchronousCryptoWorkerFactoryPlain } from "./crypto/workers/synchronousWorkerFactoryPlain.js";
-import { DefaultNodeWalletArgs, makeTempfileId } from "./host-common.js";
+import {
+ DefaultNodeWalletArgs,
+ getSqlite3FilenameFromStoragePath,
+ makeTempfileId,
+} from "./host-common.js";
import { Wallet } from "./wallet.js";
const logger = new Logger("host-impl.node.ts");
@@ -114,7 +118,9 @@ async function makeSqliteDb(
BridgeIDBFactory.enableTracing = false;
}
const imp = await createNodeSqlite3Impl();
- const dbFilename = args.persistentStoragePath ?? ":memory:";
+ const dbFilename = getSqlite3FilenameFromStoragePath(
+ args.persistentStoragePath,
+ );
logger.info(`using database ${dbFilename}`);
const myBackend = await createSqliteBackend(imp, {
filename: dbFilename,
diff --git a/packages/taler-wallet-core/src/host-impl.qtart.ts b/packages/taler-wallet-core/src/host-impl.qtart.ts
index 9c985d0c1..b4ea04be5 100644
--- a/packages/taler-wallet-core/src/host-impl.qtart.ts
+++ b/packages/taler-wallet-core/src/host-impl.qtart.ts
@@ -32,11 +32,12 @@ import type {
import {
AccessStats,
BridgeIDBFactory,
- MemoryBackend,
createSqliteBackend,
+ MemoryBackend,
shimIndexedDB,
} from "@gnu-taler/idb-bridge";
import {
+ j2s,
Logger,
SetTimeoutTimerAPI,
WalletRunConfig,
@@ -44,7 +45,11 @@ import {
import { createPlatformHttpLib } from "@gnu-taler/taler-util/http";
import { qjsOs, qjsStd } from "@gnu-taler/taler-util/qtart";
import { SynchronousCryptoWorkerFactoryPlain } from "./crypto/workers/synchronousWorkerFactoryPlain.js";
-import { DefaultNodeWalletArgs, makeTempfileId } from "./host-common.js";
+import {
+ DefaultNodeWalletArgs,
+ getSqlite3FilenameFromStoragePath,
+ makeTempfileId,
+} from "./host-common.js";
import { Wallet } from "./wallet.js";
const logger = new Logger("host-impl.qtart.ts");
@@ -100,9 +105,13 @@ async function makeSqliteDb(
args: DefaultNodeWalletArgs,
): Promise<MakeDbResult> {
BridgeIDBFactory.enableTracing = false;
+ const filename = getSqlite3FilenameFromStoragePath(
+ args.persistentStoragePath,
+ );
+ logger.info(`opening sqlite3 database ${j2s(filename)}`);
const imp = await createQtartSqlite3Impl();
const myBackend = await createSqliteBackend(imp, {
- filename: args.persistentStoragePath ?? ":memory:",
+ filename,
});
myBackend.trackStats = true;
myBackend.enableTracing = false;
diff --git a/packages/taler-wallet-core/src/instructedAmountConversion.test.ts b/packages/taler-wallet-core/src/instructedAmountConversion.test.ts
index 03e702568..17c2439c7 100644
--- a/packages/taler-wallet-core/src/instructedAmountConversion.test.ts
+++ b/packages/taler-wallet-core/src/instructedAmountConversion.test.ts
@@ -26,7 +26,6 @@ import {
CoinInfo,
convertDepositAmountForAvailableCoins,
convertWithdrawalAmountFromAvailableCoins,
- getMaxDepositAmountForAvailableCoins,
} from "./instructedAmountConversion.js";
function makeCurrencyHelper(currency: string) {
@@ -254,76 +253,6 @@ test("deposit with wire fee raw 2", (t) => {
*
*/
-test("deposit max 35", (t) => {
- const coinList: Coin[] = [
- [kudos`2`, 5],
- [kudos`5`, 5],
- ];
- const result = getMaxDepositAmountForAvailableCoins(
- {
- list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
- exchanges: {
- "2": {
- wireFee: kudos`0.00`,
- purseFee: kudos`0.00`,
- creditDeadline: AbsoluteTime.never(),
- debitDeadline: AbsoluteTime.never(),
- },
- },
- },
- "KUDOS",
- );
- t.is(Amounts.stringifyValue(result.raw), "34.9");
- t.is(Amounts.stringifyValue(result.effective), "35");
-});
-
-test("deposit max 35 with wirefee", (t) => {
- const coinList: Coin[] = [
- [kudos`2`, 5],
- [kudos`5`, 5],
- ];
- const result = getMaxDepositAmountForAvailableCoins(
- {
- list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
- exchanges: {
- "2": {
- wireFee: kudos`1`,
- purseFee: kudos`0.00`,
- creditDeadline: AbsoluteTime.never(),
- debitDeadline: AbsoluteTime.never(),
- },
- },
- },
- "KUDOS",
- );
- t.is(Amounts.stringifyValue(result.raw), "33.9");
- t.is(Amounts.stringifyValue(result.effective), "35");
-});
-
-test("deposit max repeated denom", (t) => {
- const coinList: Coin[] = [
- [kudos`2`, 1],
- [kudos`2`, 1],
- [kudos`5`, 1],
- ];
- const result = getMaxDepositAmountForAvailableCoins(
- {
- list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
- exchanges: {
- "2": {
- wireFee: kudos`0.00`,
- purseFee: kudos`0.00`,
- creditDeadline: AbsoluteTime.never(),
- debitDeadline: AbsoluteTime.never(),
- },
- },
- },
- "KUDOS",
- );
- t.is(Amounts.stringifyValue(result.raw), "8.97");
- t.is(Amounts.stringifyValue(result.effective), "9");
-});
-
/**
* Making a withdrawal with effective amount
*
@@ -663,42 +592,6 @@ test("demo: withdraw raw 25", (t) => {
//shows fee = 0.2
});
-test("demo: deposit max after withdraw raw 25", (t) => {
- const coinList: Coin[] = [
- [kudos`0.1`, 8],
- [kudos`1`, 0],
- [kudos`2`, 2],
- [kudos`5`, 0],
- [kudos`10`, 2],
- ];
- const result = getMaxDepositAmountForAvailableCoins(
- {
- list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
- exchanges: {
- one: {
- wireFee: kudos`0.01`,
- purseFee: kudos`0.00`,
- creditDeadline: AbsoluteTime.never(),
- debitDeadline: AbsoluteTime.never(),
- },
- },
- },
- "KUDOS",
- );
- t.is(Amounts.stringifyValue(result.effective), "24.8");
- t.is(Amounts.stringifyValue(result.raw), "24.67");
-
- // 8 x 0.1
- // 2 x 0.2
- // 2 x 10.0
- // total effective 24.8
- // deposit fee 12 x 0.01 = 0.12
- // wire fee 0.01
- // total raw: 24.8 - 0.13 = 24.67
-
- // current wallet impl fee 0.14
-});
-
test("demo: withdraw raw 13", (t) => {
const coinList: Coin[] = [
[kudos`0.1`, 0],
@@ -729,39 +622,3 @@ test("demo: withdraw raw 13", (t) => {
//current wallet impl: hides the left in reserve fee
//shows fee = 0.2
});
-
-test("demo: deposit max after withdraw raw 13", (t) => {
- const coinList: Coin[] = [
- [kudos`0.1`, 8],
- [kudos`1`, 0],
- [kudos`2`, 1],
- [kudos`5`, 0],
- [kudos`10`, 1],
- ];
- const result = getMaxDepositAmountForAvailableCoins(
- {
- list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
- exchanges: {
- one: {
- wireFee: kudos`0.01`,
- purseFee: kudos`0.00`,
- creditDeadline: AbsoluteTime.never(),
- debitDeadline: AbsoluteTime.never(),
- },
- },
- },
- "KUDOS",
- );
- t.is(Amounts.stringifyValue(result.effective), "12.8");
- t.is(Amounts.stringifyValue(result.raw), "12.69");
-
- // 8 x 0.1
- // 1 x 0.2
- // 1 x 10.0
- // total effective 12.8
- // deposit fee 10 x 0.01 = 0.10
- // wire fee 0.01
- // total raw: 12.8 - 0.11 = 12.69
-
- // current wallet impl fee 0.14
-});
diff --git a/packages/taler-wallet-core/src/instructedAmountConversion.ts b/packages/taler-wallet-core/src/instructedAmountConversion.ts
index 5b399a0a7..c0f835dfa 100644
--- a/packages/taler-wallet-core/src/instructedAmountConversion.ts
+++ b/packages/taler-wallet-core/src/instructedAmountConversion.ts
@@ -23,12 +23,9 @@ import {
Amounts,
ConvertAmountRequest,
Duration,
- GetAmountRequest,
- GetPlanForOperationRequest,
TransactionAmountMode,
TransactionType,
checkDbInvariant,
- parsePaytoUri,
strcmp,
} from "@gnu-taler/taler-util";
import { DenominationRecord, timestampProtocolFromDb } from "./db.js";
@@ -85,26 +82,6 @@ interface SelectedCoins {
refresh?: RefreshChoice;
}
-function getCoinsFilter(req: GetPlanForOperationRequest): CoinsFilter {
- switch (req.type) {
- case TransactionType.Withdrawal: {
- return {
- exchanges:
- req.exchangeUrl === undefined ? undefined : [req.exchangeUrl],
- };
- }
- case TransactionType.Deposit: {
- const payto = parsePaytoUri(req.account);
- if (!payto) {
- throw Error(`wrong payto ${req.account}`);
- }
- return {
- wireMethod: payto.targetType,
- };
- }
- }
-}
-
interface RefreshChoice {
/**
* Amount that need to be covered
@@ -141,13 +118,13 @@ interface AvailableCoins {
* This function is costly (by the database access) but with high chances
* of being cached
*/
-async function getAvailableDenoms(
+async function getAvailableCoins(
wex: WalletExecutionContext,
op: TransactionType,
currency: string,
filters: CoinsFilter = {},
): Promise<AvailableCoins> {
- const operationType = getOperationType(TransactionType.Deposit);
+ const operationType = getOperationType(op);
return await wex.db.runReadOnlyTx(
{
@@ -283,7 +260,10 @@ async function getAvailableDenoms(
coinAvail.exchangeBaseUrl,
coinAvail.denomPubHash,
]);
- checkDbInvariant(!!denom, `denomination of a coin is missing hash: ${coinAvail.denomPubHash}`);
+ checkDbInvariant(
+ !!denom,
+ `denomination of a coin is missing hash: ${coinAvail.denomPubHash}`,
+ );
if (denom.isRevoked || !denom.isOffered) {
continue;
}
@@ -354,7 +334,7 @@ export async function convertDepositAmount(
const amount = Amounts.parseOrThrow(req.amount);
// const filter = getCoinsFilter(req);
- const denoms = await getAvailableDenoms(
+ const denoms = await getAvailableCoins(
wex,
TransactionType.Deposit,
amount.currency,
@@ -374,6 +354,7 @@ export async function convertDepositAmount(
const LOG_REFRESH = false;
const LOG_DEPOSIT = false;
+
export function convertDepositAmountForAvailableCoins(
denoms: AvailableCoins,
amount: AmountJson,
@@ -420,9 +401,7 @@ export function convertDepositAmountForAvailableCoins(
}
const refreshDenoms = rankDenominationForRefresh(denoms.list);
- /**
- * FIXME: looking for refresh AFTER selecting greedy is not optimal
- */
+ // FIXME: looking for refresh AFTER selecting greedy is not optimal
const refreshCoin = searchBestRefreshCoin(
depositDenoms,
refreshDenoms,
@@ -437,7 +416,7 @@ export function convertDepositAmountForAvailableCoins(
refreshCoin.refreshEffective,
).amount;
const raw = Amounts.sub(effective, fee, refreshCoin.totalFee).amount;
- //found with change
+ // found with change
return {
effective,
raw,
@@ -449,70 +428,13 @@ export function convertDepositAmountForAvailableCoins(
return result;
}
-export async function getMaxDepositAmount(
- wex: WalletExecutionContext,
- req: GetAmountRequest,
-): Promise<AmountResponse> {
- // const filter = getCoinsFilter(req);
-
- const denoms = await getAvailableDenoms(
- wex,
- TransactionType.Deposit,
- req.currency,
- {},
- );
-
- const result = getMaxDepositAmountForAvailableCoins(denoms, req.currency);
- return {
- effectiveAmount: Amounts.stringify(result.effective),
- rawAmount: Amounts.stringify(result.raw),
- };
-}
-
-export function getMaxDepositAmountForAvailableCoins(
- denoms: AvailableCoins,
- currency: string,
-): AmountWithFee {
- const zero = Amounts.zeroOfCurrency(currency);
- if (!denoms.list.length) {
- // no coins in the database
- return { effective: zero, raw: zero };
- }
-
- const result = getTotalEffectiveAndRawForDeposit(
- denoms.list.map((info) => {
- return { info, size: info.totalAvailable ?? 0 };
- }),
- currency,
- );
-
- const wireFee = Object.values(denoms.exchanges)[0]?.wireFee ?? zero;
- result.raw = Amounts.sub(result.raw, wireFee).amount;
-
- return result;
-}
-
-export async function convertPeerPushAmount(
- wex: WalletExecutionContext,
- req: ConvertAmountRequest,
-): Promise<AmountResponse> {
- throw Error("to be implemented after 1.0");
-}
-
-export async function getMaxPeerPushAmount(
- wex: WalletExecutionContext,
- req: GetAmountRequest,
-): Promise<AmountResponse> {
- throw Error("to be implemented after 1.0");
-}
-
export async function convertWithdrawalAmount(
wex: WalletExecutionContext,
req: ConvertAmountRequest,
): Promise<AmountResponse> {
const amount = Amounts.parseOrThrow(req.amount);
- const denoms = await getAvailableDenoms(
+ const denoms = await getAvailableCoins(
wex,
TransactionType.Withdrawal,
amount.currency,
@@ -553,14 +475,6 @@ export function convertWithdrawalAmountFromAvailableCoins(
* *****************************************************
*/
-/**
- *
- * @param depositDenoms
- * @param refreshDenoms
- * @param amount
- * @param mode
- * @returns
- */
function searchBestRefreshCoin(
depositDenoms: SelectableElement[],
refreshDenoms: Record<string, SelectableElement[]>,
@@ -643,18 +557,13 @@ function searchBestRefreshCoin(
/**
* Returns a copy of the list sorted for the best denom to withdraw first
- *
- * @param denoms
- * @returns
*/
function rankDenominationForWithdrawals(
denoms: CoinInfo[],
mode: TransactionAmountMode,
): SelectableElement[] {
const copyList = [...denoms];
- /**
- * Rank coins
- */
+ /// Rank coins
copyList.sort((d1, d2) => {
// the best coin to use is
// 1.- the one that contrib more and pay less fee
@@ -681,8 +590,8 @@ function rankDenominationForWithdrawals(
return copyList.map((info) => {
switch (mode) {
case TransactionAmountMode.Effective: {
- //if the user instructed "effective" then we need to selected
- //greedy total coin value
+ // if the user instructed "effective" then we need to selected
+ // greedy total coin value
return {
info,
value: info.value,
@@ -690,8 +599,8 @@ function rankDenominationForWithdrawals(
};
}
case TransactionAmountMode.Raw: {
- //if the user instructed "raw" then we need to selected
- //greedy total coin raw amount (without fee)
+ // if the user instructed "raw" then we need to selected
+ // greedy total coin raw amount (without fee)
return {
info,
value: Amounts.add(info.value, info.denomWithdraw).amount,
@@ -713,17 +622,15 @@ function rankDenominationForDeposit(
mode: TransactionAmountMode,
): SelectableElement[] {
const copyList = [...denoms];
- /**
- * Rank coins
- */
+ // Rank coins
copyList.sort((d1, d2) => {
// the best coin to use is
// 1.- the one that contrib more and pay less fee
// 2.- it takes more time before expires
- //different exchanges may have different wireFee
- //ranking should take the relative contribution in the exchange
- //which is (value - denomFee / fixedFee)
+ // different exchanges may have different wireFee
+ // ranking should take the relative contribution in the exchange
+ // which is (value - denomFee / fixedFee)
const rate1 = Amounts.isZero(d1.denomDeposit)
? Number.MIN_SAFE_INTEGER
: Amounts.divmod(d1.value, d1.denomDeposit).quotient;
@@ -742,8 +649,8 @@ function rankDenominationForDeposit(
return copyList.map((info) => {
switch (mode) {
case TransactionAmountMode.Effective: {
- //if the user instructed "effective" then we need to selected
- //greedy total coin value
+ // if the user instructed "effective" then we need to selected
+ // greedy total coin value
return {
info,
value: info.value,
@@ -751,8 +658,8 @@ function rankDenominationForDeposit(
};
}
case TransactionAmountMode.Raw: {
- //if the user instructed "raw" then we need to selected
- //greedy total coin raw amount (without fee)
+ // if the user instructed "raw" then we need to selected
+ // greedy total coin raw amount (without fee)
return {
info,
value: Amounts.sub(info.value, info.denomDeposit).amount,
@@ -765,9 +672,6 @@ function rankDenominationForDeposit(
/**
* Returns a copy of the list sorted for the best denom to withdraw first
- *
- * @param denoms
- * @returns
*/
function rankDenominationForRefresh(
denoms: CoinInfo[],
@@ -817,7 +721,7 @@ function selectGreedyCoins(
break iterateDenoms;
}
- //use Amounts.divmod instead of iterate
+ // use Amounts.divmod instead of iterate
const div = Amounts.divmod(left, denom.value);
const size = Math.min(div.quotient, denom.total);
if (size > 0) {
@@ -829,7 +733,7 @@ function selectGreedyCoins(
denom.total = denom.total - size;
}
- //go next denom
+ // go next denom
denomIdx++;
}
@@ -839,7 +743,7 @@ function selectGreedyCoins(
type AmountWithFee = { raw: AmountJson; effective: AmountJson };
type AmountAndRefresh = AmountWithFee & { refresh?: RefreshChoice };
-export function getTotalEffectiveAndRawForDeposit(
+function getTotalEffectiveAndRawForDeposit(
list: { info: CoinInfo; size: number }[],
currency: string,
): AmountWithFee {
diff --git a/packages/taler-wallet-core/src/kyc.ts b/packages/taler-wallet-core/src/kyc.ts
new file mode 100644
index 000000000..b28816073
--- /dev/null
+++ b/packages/taler-wallet-core/src/kyc.ts
@@ -0,0 +1,252 @@
+/*
+ This file is part of GNU Taler
+ (C) 2024 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 {
+ AmountJson,
+ AmountLike,
+ Amounts,
+ AmountString,
+} from "@gnu-taler/taler-util";
+import { ReadyExchangeSummary } from "./exchanges.js";
+
+/**
+ * @fileoverview Helpers for KYC.
+ * @author Florian Dold <dold@taler.net>
+ */
+
+export interface SimpleLimitInfo {
+ kycHardLimit: AmountString | undefined;
+ kycSoftLimit: AmountString | undefined;
+}
+
+export interface MultiExchangeLimitInfo {
+ kycHardLimit: AmountString | undefined;
+ kycSoftLimit: AmountString | undefined;
+
+ /**
+ * Exchanges that would require soft KYC.
+ */
+ kycExchanges: string[];
+}
+
+/**
+ * Return the smallest given amount, where an undefined amount
+ * is interpreted the larger amount.
+ */
+function minDefAmount(
+ a: AmountLike | undefined,
+ b: AmountLike | undefined,
+): AmountJson {
+ if (a == null) {
+ if (b == null) {
+ throw Error();
+ }
+ return Amounts.jsonifyAmount(b);
+ }
+ if (b == null) {
+ if (a == null) {
+ throw Error();
+ }
+ return Amounts.jsonifyAmount(a);
+ }
+ return Amounts.min(a, b);
+}
+
+/**
+ * Add to an amount.
+ * Interprets the second argument as zero if not defined.
+ */
+function addDefAmount(a: AmountLike, b: AmountLike | undefined): AmountJson {
+ if (b == null) {
+ return Amounts.jsonifyAmount(a);
+ }
+ return Amounts.add(a, b).amount;
+}
+
+export function getDepositLimitInfo(
+ exchanges: ReadyExchangeSummary[],
+ instructedAmount: AmountLike,
+): MultiExchangeLimitInfo {
+ let kycHardLimit: AmountJson | undefined;
+ let kycSoftLimit: AmountJson | undefined;
+ let kycExchanges: string[] = [];
+
+ // FIXME: Summing up the limits doesn't really make a lot of sense,
+ // as the funds at each exchange are limited (by the coins in the wallet),
+ // and thus an exchange where we don't have coins but that has a high
+ // KYC limit can't meaningfully contribute to the whole limit.
+
+ for (const exchange of exchanges) {
+ const exchLim = getSingleExchangeDepositLimitInfo(
+ exchange,
+ instructedAmount,
+ );
+ if (exchLim.kycSoftLimit) {
+ kycExchanges.push(exchange.exchangeBaseUrl);
+ kycSoftLimit = addDefAmount(exchLim.kycSoftLimit, kycSoftLimit);
+ }
+ if (exchLim.kycHardLimit) {
+ kycHardLimit = addDefAmount(exchLim.kycHardLimit, kycHardLimit);
+ }
+ }
+
+ return {
+ kycHardLimit: kycHardLimit ? Amounts.stringify(kycHardLimit) : undefined,
+ kycSoftLimit: kycSoftLimit ? Amounts.stringify(kycSoftLimit) : undefined,
+ kycExchanges,
+ };
+}
+
+export function getSingleExchangeDepositLimitInfo(
+ exchange: ReadyExchangeSummary,
+ instructedAmount: AmountLike,
+): SimpleLimitInfo {
+ let kycHardLimit: AmountJson | undefined;
+ let kycSoftLimit: AmountJson | undefined;
+
+ for (let lim of exchange.hardLimits) {
+ switch (lim.operation_type) {
+ case "DEPOSIT":
+ case "AGGREGATE":
+ // FIXME: This should consider past deposits and KYC checks
+ kycHardLimit = minDefAmount(kycHardLimit, lim.threshold);
+ break;
+ }
+ }
+
+ for (let limAmount of exchange.walletBalanceLimitWithoutKyc ?? []) {
+ kycSoftLimit = minDefAmount(kycSoftLimit, limAmount);
+ }
+
+ for (let lim of exchange.zeroLimits) {
+ switch (lim.operation_type) {
+ case "DEPOSIT":
+ case "AGGREGATE":
+ kycSoftLimit = Amounts.zeroOfAmount(instructedAmount);
+ break;
+ }
+ }
+
+ return {
+ kycHardLimit: kycHardLimit ? Amounts.stringify(kycHardLimit) : undefined,
+ kycSoftLimit: kycSoftLimit ? Amounts.stringify(kycSoftLimit) : undefined,
+ };
+}
+
+export function getPeerCreditLimitInfo(
+ exchange: ReadyExchangeSummary,
+ instructedAmount: AmountLike,
+): SimpleLimitInfo {
+ let kycHardLimit: AmountJson | undefined;
+ let kycSoftLimit: AmountJson | undefined;
+
+ for (let lim of exchange.hardLimits) {
+ switch (lim.operation_type) {
+ case "BALANCE":
+ case "MERGE":
+ // FIXME: This should consider past merges and KYC checks
+ kycHardLimit = minDefAmount(kycHardLimit, lim.threshold);
+ break;
+ }
+ }
+
+ for (let limAmount of exchange.walletBalanceLimitWithoutKyc ?? []) {
+ kycSoftLimit = minDefAmount(kycSoftLimit, limAmount);
+ }
+
+ for (let lim of exchange.zeroLimits) {
+ switch (lim.operation_type) {
+ case "BALANCE":
+ case "MERGE":
+ kycSoftLimit = Amounts.zeroOfAmount(instructedAmount);
+ break;
+ }
+ }
+
+ return {
+ kycHardLimit: kycHardLimit ? Amounts.stringify(kycHardLimit) : undefined,
+ kycSoftLimit: kycSoftLimit ? Amounts.stringify(kycSoftLimit) : undefined,
+ };
+}
+
+export function checkWithdrawalHardLimitExceeded(
+ exchange: ReadyExchangeSummary,
+ instructedAmount: AmountLike,
+): boolean {
+ const limitInfo = getWithdrawalLimitInfo(exchange, instructedAmount);
+ return (
+ limitInfo.kycHardLimit != null &&
+ Amounts.cmp(limitInfo.kycHardLimit, instructedAmount) <= 0
+ );
+}
+
+export function checkPeerCreditHardLimitExceeded(
+ exchange: ReadyExchangeSummary,
+ instructedAmount: AmountLike,
+): boolean {
+ const limitInfo = getPeerCreditLimitInfo(exchange, instructedAmount);
+ return (
+ limitInfo.kycHardLimit != null &&
+ Amounts.cmp(limitInfo.kycHardLimit, instructedAmount) <= 0
+ );
+}
+
+export function checkDepositHardLimitExceeded(
+ exchanges: ReadyExchangeSummary[],
+ instructedAmount: AmountLike,
+): boolean {
+ const limitInfo = getDepositLimitInfo(exchanges, instructedAmount);
+ return (
+ limitInfo.kycHardLimit != null &&
+ Amounts.cmp(limitInfo.kycHardLimit, instructedAmount) <= 0
+ );
+}
+
+export function getWithdrawalLimitInfo(
+ exchange: ReadyExchangeSummary,
+ instructedAmount: AmountLike,
+): SimpleLimitInfo {
+ let kycHardLimit: AmountJson | undefined;
+ let kycSoftLimit: AmountJson | undefined;
+
+ for (let lim of exchange.hardLimits) {
+ switch (lim.operation_type) {
+ case "BALANCE":
+ case "WITHDRAW":
+ // FIXME: This should consider past withdrawals and KYC checks
+ kycHardLimit = minDefAmount(kycHardLimit, lim.threshold);
+ break;
+ }
+ }
+
+ for (let limAmount of exchange.walletBalanceLimitWithoutKyc ?? []) {
+ kycSoftLimit = minDefAmount(kycSoftLimit, limAmount);
+ }
+
+ for (let lim of exchange.zeroLimits) {
+ switch (lim.operation_type) {
+ case "BALANCE":
+ case "WITHDRAW":
+ kycSoftLimit = Amounts.zeroOfAmount(instructedAmount);
+ break;
+ }
+ }
+
+ return {
+ kycHardLimit: kycHardLimit ? Amounts.stringify(kycHardLimit) : undefined,
+ kycSoftLimit: kycSoftLimit ? Amounts.stringify(kycSoftLimit) : undefined,
+ };
+}
diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts
index e0b1fad98..f48a2f2fe 100644
--- a/packages/taler-wallet-core/src/pay-merchant.ts
+++ b/packages/taler-wallet-core/src/pay-merchant.ts
@@ -32,7 +32,6 @@ import {
Amounts,
AmountString,
assertUnreachable,
- AsyncFlag,
checkDbInvariant,
CheckPayTemplateReponse,
CheckPayTemplateRequest,
@@ -58,6 +57,7 @@ import {
Logger,
makeErrorDetail,
makePendingOperationFailedError,
+ makeTalerErrorDetail,
MerchantCoinRefundStatus,
MerchantContractTerms,
MerchantPayResponse,
@@ -113,6 +113,8 @@ import {
} from "./coinSelection.js";
import {
constructTaskIdentifier,
+ genericWaitForState,
+ genericWaitForStateVal,
PendingTaskType,
spendCoins,
TaskIdentifiers,
@@ -123,7 +125,7 @@ import {
TransactionContext,
TransitionResultType,
} from "./common.js";
-import { EddsaKeypair } from "./crypto/cryptoImplementation.js";
+import { EddsaKeyPairStrings } from "./crypto/cryptoImplementation.js";
import {
CoinRecord,
DbCoinSelection,
@@ -302,6 +304,7 @@ export class PayMerchantTransactionContext implements TransactionContext {
proposalId: purchaseRec.proposalId,
}),
proposalId: purchaseRec.proposalId,
+ abortReason: purchaseRec.abortReason,
info,
refundQueryActive:
purchaseRec.purchaseStatus === PurchaseStatus.PendingQueryingRefund,
@@ -422,7 +425,7 @@ export class PayMerchantTransactionContext implements TransactionContext {
notifyTransition(wex, transactionId, transitionInfo);
}
- async abortTransaction(): Promise<void> {
+ async abortTransaction(reason?: TalerErrorDetail): Promise<void> {
const { wex, proposalId, transactionId } = this;
const transitionInfo = await wex.db.runReadWriteTx(
{
@@ -450,6 +453,7 @@ export class PayMerchantTransactionContext implements TransactionContext {
return;
case PurchaseStatus.PendingPaying:
case PurchaseStatus.SuspendedPaying: {
+ purchase.abortReason = reason;
purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund;
if (purchase.payInfo && purchase.payInfo.payCoinSelection) {
const coinSel = purchase.payInfo.payCoinSelection;
@@ -526,7 +530,7 @@ export class PayMerchantTransactionContext implements TransactionContext {
wex.taskScheduler.startShepherdTask(this.taskId);
}
- async failTransaction(): Promise<void> {
+ async failTransaction(reason?: TalerErrorDetail): Promise<void> {
const { wex, proposalId, transactionId } = this;
const transitionInfo = await wex.db.runReadWriteTx(
{
@@ -554,6 +558,7 @@ export class PayMerchantTransactionContext implements TransactionContext {
}
if (newState) {
purchase.purchaseStatus = newState;
+ purchase.failReason = reason;
await tx.purchases.put(purchase);
}
await this.updateTransactionMeta(tx);
@@ -1076,22 +1081,29 @@ async function processDownloadProposal(
fulfillmentUrl &&
(fulfillmentUrl.startsWith("http://") ||
fulfillmentUrl.startsWith("https://"));
- let otherPurchase: PurchaseRecord | undefined;
+ let repurchase: PurchaseRecord | undefined = undefined;
+ const otherPurchases =
+ await tx.purchases.indexes.byFulfillmentUrl.getAll(fulfillmentUrl);
if (isResourceFulfillmentUrl) {
- otherPurchase =
- await tx.purchases.indexes.byFulfillmentUrl.get(fulfillmentUrl);
+ for (const otherPurchase of otherPurchases) {
+ if (
+ otherPurchase.purchaseStatus == PurchaseStatus.Done ||
+ otherPurchase.purchaseStatus == PurchaseStatus.PendingPaying ||
+ otherPurchase.purchaseStatus == PurchaseStatus.PendingPayingReplay
+ ) {
+ repurchase = otherPurchase;
+ break;
+ }
+ }
}
+
// FIXME: Adjust this to account for refunds, don't count as repurchase
// if original order is refunded.
- if (
- otherPurchase &&
- (otherPurchase.purchaseStatus == PurchaseStatus.Done ||
- otherPurchase.purchaseStatus == PurchaseStatus.PendingPaying ||
- otherPurchase.purchaseStatus == PurchaseStatus.PendingPayingReplay)
- ) {
+
+ if (repurchase) {
logger.warn("repurchase detected");
p.purchaseStatus = PurchaseStatus.DoneRepurchaseDetected;
- p.repurchaseProposalId = otherPurchase.proposalId;
+ p.repurchaseProposalId = repurchase.proposalId;
await tx.purchases.put(p);
} else {
p.purchaseStatus = p.shared
@@ -1198,7 +1210,7 @@ async function createOrReusePurchase(
return oldProposal.proposalId;
}
- let noncePair: EddsaKeypair;
+ let noncePair: EddsaKeyPairStrings;
let shared = false;
if (noncePriv) {
shared = true;
@@ -1213,6 +1225,10 @@ async function createOrReusePurchase(
const { priv, pub } = noncePair;
const proposalId = encodeCrock(getRandomBytes(32));
+ logger.info(
+ `created new proposal for ${orderId} at ${merchantBaseUrl} session ${sessionId}`,
+ );
+
const proposalRecord: PurchaseRecord = {
download: undefined,
noncePriv: priv,
@@ -1605,6 +1621,10 @@ async function checkPaymentByProposalId(
let coins: SelectedProspectiveCoin[] | undefined = undefined;
+ const allowedExchangeUrls = contractData.allowedExchanges.map(
+ (x) => x.exchangeBaseUrl,
+ );
+
switch (res.type) {
case "failure": {
logger.info("not allowing payment, insufficient coins");
@@ -1613,12 +1633,15 @@ async function checkPaymentByProposalId(
res.insufficientBalanceDetails,
)}`,
);
+ let scopes = await wex.db.runAllStoresReadOnlyTx({}, async (tx) => {
+ return getScopeForAllExchanges(tx, allowedExchangeUrls);
+ });
return {
status: PreparePayResultType.InsufficientBalance,
contractTerms: d.contractTermsRaw,
- proposalId: proposal.proposalId,
transactionId,
amountRaw: Amounts.stringify(d.contractData.amount),
+ scopes,
talerUri,
balanceDetails: res.insufficientBalanceDetails,
};
@@ -1637,21 +1660,34 @@ async function checkPaymentByProposalId(
logger.trace("costInfo", totalCost);
logger.trace("coinsForPayment", res);
+ const exchanges = new Set<string>(coins.map((x) => x.exchangeBaseUrl));
+
+ const scopes = await wex.db.runAllStoresReadOnlyTx({}, async (tx) => {
+ return await getScopeForAllExchanges(tx, [...exchanges]);
+ });
+
return {
status: PreparePayResultType.PaymentPossible,
contractTerms: d.contractTermsRaw,
transactionId,
- proposalId: proposal.proposalId,
amountEffective: Amounts.stringify(totalCost),
amountRaw: Amounts.stringify(instructedAmount),
+ scopes,
contractTermsHash: d.contractData.contractTermsHash,
talerUri,
};
}
+ const scopes = await wex.db.runAllStoresReadOnlyTx({}, async (tx) => {
+ let exchangeUrls = contractData.allowedExchanges.map(
+ (x) => x.exchangeBaseUrl,
+ );
+ return await getScopeForAllExchanges(tx, exchangeUrls);
+ });
+
if (
- purchase.purchaseStatus === PurchaseStatus.Done &&
- purchase.lastSessionId !== sessionId
+ purchase.purchaseStatus === PurchaseStatus.Done ||
+ purchase.purchaseStatus === PurchaseStatus.PendingPayingReplay
) {
logger.trace(
"automatically re-submitting payment with different session ID",
@@ -1690,8 +1726,8 @@ async function checkPaymentByProposalId(
amountEffective: purchase.payInfo
? Amounts.stringify(purchase.payInfo.totalPayCost)
: undefined,
+ scopes,
transactionId,
- proposalId,
talerUri,
};
} else if (!purchase.timestampFirstSuccessfulPay) {
@@ -1705,16 +1741,12 @@ async function checkPaymentByProposalId(
amountEffective: purchase.payInfo
? Amounts.stringify(purchase.payInfo.totalPayCost)
: undefined,
+ scopes,
transactionId,
- proposalId,
talerUri,
};
} else {
- const paid =
- purchase.purchaseStatus === PurchaseStatus.Done ||
- purchase.purchaseStatus === PurchaseStatus.PendingQueryingRefund ||
- purchase.purchaseStatus === PurchaseStatus.FinalizingQueryingAutoRefund ||
- purchase.purchaseStatus === PurchaseStatus.PendingQueryingAutoRefund;
+ const paid = isPurchasePaid(purchase);
const download = await expectProposalDownload(wex, purchase);
return {
status: PreparePayResultType.AlreadyConfirmed,
@@ -1726,13 +1758,22 @@ async function checkPaymentByProposalId(
? Amounts.stringify(purchase.payInfo.totalPayCost)
: undefined,
...(paid ? { nextUrl: download.contractData.orderId } : {}),
+ scopes,
transactionId,
- proposalId,
talerUri,
};
}
}
+function isPurchasePaid(purchase: PurchaseRecord): boolean {
+ return (
+ purchase.purchaseStatus === PurchaseStatus.Done ||
+ purchase.purchaseStatus === PurchaseStatus.PendingQueryingRefund ||
+ purchase.purchaseStatus === PurchaseStatus.FinalizingQueryingAutoRefund ||
+ purchase.purchaseStatus === PurchaseStatus.PendingQueryingAutoRefund
+ );
+}
+
export async function getContractTermsDetails(
wex: WalletExecutionContext,
proposalId: string,
@@ -1803,59 +1844,39 @@ async function waitProposalDownloaded(
wex.taskScheduler.startShepherdTask(ctx.taskId);
- // FIXME: We should use Symbol.dispose magic here for cleanup!
-
- const payNotifFlag = new AsyncFlag();
- // Raise exchangeNotifFlag whenever we get a notification
- // about our exchange.
- const cancelNotif = wex.ws.addNotificationListener((notif) => {
- if (
- notif.type === NotificationType.TransactionStateTransition &&
- notif.transactionId === ctx.transactionId
- ) {
- logger.info(`raising update notification: ${j2s(notif)}`);
- payNotifFlag.raise();
- }
- });
-
- try {
- await internalWaitProposalDownloaded(ctx, payNotifFlag);
- logger.info(`done waiting for ${ctx.transactionId} to be downloaded`);
- } finally {
- cancelNotif();
- }
-}
-
-async function internalWaitProposalDownloaded(
- ctx: PayMerchantTransactionContext,
- payNotifFlag: AsyncFlag,
-): Promise<void> {
- while (true) {
- const { purchase, retryInfo } = await ctx.wex.db.runReadOnlyTx(
- { storeNames: ["purchases", "operationRetries"] },
- async (tx) => {
- return {
- purchase: await tx.purchases.get(ctx.proposalId),
- retryInfo: await tx.operationRetries.get(ctx.taskId),
- };
- },
- );
- if (!purchase) {
- throw Error("purchase does not exist anymore");
- }
- if (purchase.download) {
- return;
- }
- if (retryInfo) {
- if (retryInfo.lastError) {
- throw TalerError.fromUncheckedDetail(retryInfo.lastError);
- } else {
- throw Error("transient error while waiting for proposal download");
+ await genericWaitForState(wex, {
+ filterNotification(notif) {
+ return (
+ notif.type === NotificationType.TransactionStateTransition &&
+ notif.transactionId === ctx.transactionId
+ );
+ },
+ async checkState() {
+ const { purchase, retryInfo } = await ctx.wex.db.runReadOnlyTx(
+ { storeNames: ["purchases", "operationRetries"] },
+ async (tx) => {
+ return {
+ purchase: await tx.purchases.get(ctx.proposalId),
+ retryInfo: await tx.operationRetries.get(ctx.taskId),
+ };
+ },
+ );
+ if (!purchase) {
+ throw Error("purchase does not exist anymore");
}
- }
- await payNotifFlag.wait();
- payNotifFlag.reset();
- }
+ if (purchase.download) {
+ return true;
+ }
+ if (retryInfo) {
+ if (retryInfo.lastError) {
+ throw TalerError.fromUncheckedDetail(retryInfo.lastError);
+ } else {
+ throw Error("transient error while waiting for proposal download");
+ }
+ }
+ return false;
+ },
+ });
}
async function downloadTemplate(
@@ -1896,7 +1917,13 @@ export async function checkPayForTemplate(
const cfg = await merchantApi.getConfig();
if (cfg.type === "fail") {
- throw TalerError.fromUncheckedDetail(cfg.detail);
+ if (cfg.detail) {
+ throw TalerError.fromUncheckedDetail(cfg.detail);
+ } else {
+ throw TalerError.fromException(
+ new Error("failed to get merchant remote config"),
+ );
+ }
}
// FIXME: Put body.currencies *and* body.currency in the set of
@@ -2033,108 +2060,80 @@ export async function generateDepositPermissions(
return depositPermissions;
}
-async function internalWaitPaymentResult(
- ctx: PayMerchantTransactionContext,
- purchaseNotifFlag: AsyncFlag,
+/**
+ * Wait until either:
+ * a) the payment succeeded (if provided under the {@param waitSessionId}), or
+ * b) the attempt to pay failed (merchant unavailable, etc.)
+ */
+async function waitPaymentResult(
+ wex: WalletExecutionContext,
+ proposalId: string,
waitSessionId?: string,
): Promise<ConfirmPayResult> {
- while (true) {
- const txRes = await ctx.wex.db.runReadOnlyTx(
- { storeNames: ["purchases", "operationRetries"] },
- async (tx) => {
- const purchase = await tx.purchases.get(ctx.proposalId);
- const retryRecord = await tx.operationRetries.get(ctx.taskId);
- return { purchase, retryRecord };
- },
- );
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
- if (!txRes.purchase) {
- throw Error("purchase gone");
- }
+ return await genericWaitForStateVal<ConfirmPayResult>(wex, {
+ filterNotification(notif) {
+ return (
+ notif.type === NotificationType.TransactionStateTransition &&
+ notif.transactionId === ctx.transactionId
+ );
+ },
+ async checkState() {
+ const txRes = await ctx.wex.db.runReadOnlyTx(
+ { storeNames: ["purchases", "operationRetries"] },
+ async (tx) => {
+ const purchase = await tx.purchases.get(ctx.proposalId);
+ const retryRecord = await tx.operationRetries.get(ctx.taskId);
+ return { purchase, retryRecord };
+ },
+ );
+
+ if (!txRes.purchase) {
+ throw Error("purchase gone");
+ }
- const purchase = txRes.purchase;
+ const purchase = txRes.purchase;
- logger.info(
- `purchase is in state ${PurchaseStatus[purchase.purchaseStatus]}`,
- );
+ logger.info(
+ `purchase is in state ${PurchaseStatus[purchase.purchaseStatus]}`,
+ );
- const d = await expectProposalDownload(ctx.wex, purchase);
+ const d = await expectProposalDownload(ctx.wex, purchase);
- if (txRes.purchase.timestampFirstSuccessfulPay) {
- if (
- waitSessionId == null ||
- txRes.purchase.lastSessionId === waitSessionId
- ) {
+ if (txRes.purchase.timestampFirstSuccessfulPay) {
+ if (
+ waitSessionId == null ||
+ txRes.purchase.lastSessionId === waitSessionId
+ ) {
+ return {
+ type: ConfirmPayResultType.Done,
+ contractTerms: d.contractTermsRaw,
+ transactionId: ctx.transactionId,
+ };
+ }
+ }
+
+ if (txRes.retryRecord) {
+ return {
+ type: ConfirmPayResultType.Pending,
+ lastError: txRes.retryRecord.lastError,
+ transactionId: ctx.transactionId,
+ };
+ }
+
+ if (txRes.purchase.purchaseStatus >= PurchaseStatus.Done) {
return {
type: ConfirmPayResultType.Done,
contractTerms: d.contractTermsRaw,
transactionId: ctx.transactionId,
};
}
- }
-
- if (txRes.retryRecord) {
- return {
- type: ConfirmPayResultType.Pending,
- lastError: txRes.retryRecord.lastError,
- transactionId: ctx.transactionId,
- };
- }
-
- if (txRes.purchase.purchaseStatus >= PurchaseStatus.Done) {
- return {
- type: ConfirmPayResultType.Done,
- contractTerms: d.contractTermsRaw,
- transactionId: ctx.transactionId,
- };
- }
-
- await purchaseNotifFlag.wait();
- purchaseNotifFlag.reset();
- }
-}
-
-/**
- * Wait until either:
- * a) the payment succeeded (if provided under the {@param waitSessionId}), or
- * b) the attempt to pay failed (merchant unavailable, etc.)
- */
-async function waitPaymentResult(
- wex: WalletExecutionContext,
- proposalId: string,
- waitSessionId?: string,
-): Promise<ConfirmPayResult> {
- // FIXME: We don't support cancelletion yet!
- const ctx = new PayMerchantTransactionContext(wex, proposalId);
- wex.taskScheduler.startShepherdTask(ctx.taskId);
- // FIXME: Clean up using the new JS "using" / Symbol.dispose syntax.
- const purchaseNotifFlag = new AsyncFlag();
- // Raise purchaseNotifFlag whenever we get a notification
- // about our purchase.
- const cancelNotif = wex.ws.addNotificationListener((notif) => {
- if (
- notif.type === NotificationType.TransactionStateTransition &&
- notif.transactionId === ctx.transactionId
- ) {
- purchaseNotifFlag.raise();
- }
+ return undefined;
+ },
});
-
- try {
- logger.info(`waiting for first payment success on ${ctx.transactionId}`);
- const res = await internalWaitPaymentResult(
- ctx,
- purchaseNotifFlag,
- waitSessionId,
- );
- logger.info(
- `done waiting for first payment success on ${ctx.transactionId}, result ${res.type}`,
- );
- return res;
- } finally {
- cancelNotif();
- }
}
/**
@@ -2327,10 +2326,6 @@ export async function confirmPay(
);
notifyTransition(wex, transactionId, transitionInfo);
- wex.ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: transactionId,
- });
// In case we're sharing the payment and we're long-polling
wex.taskScheduler.stopShepherdTask(ctx.taskId);
@@ -2657,6 +2652,16 @@ async function processPurchasePay(
}
}
+ if (resp.status === HttpStatusCode.UnavailableForLegalReasons) {
+ logger.warn(`pay transaction aborted, merchant has KYC problems`);
+ await ctx.abortTransaction(
+ makeTalerErrorDetail(TalerErrorCode.WALLET_PAY_MERCHANT_KYC_MISSING, {
+ exchangeResponse: await resp.json(),
+ }),
+ );
+ return TaskRunResult.progress();
+ }
+
if (resp.status >= 400 && resp.status <= 499) {
logger.trace("got generic 4xx from merchant");
const err = await readTalerErrorResponse(resp);
diff --git a/packages/taler-wallet-core/src/pay-peer-common.ts b/packages/taler-wallet-core/src/pay-peer-common.ts
index 636dd4156..b9b637c57 100644
--- a/packages/taler-wallet-core/src/pay-peer-common.ts
+++ b/packages/taler-wallet-core/src/pay-peer-common.ts
@@ -71,6 +71,7 @@ export async function queryCoinInfosForSelection(
denomSig: coin.denomSig,
ageCommitmentProof: coin.ageCommitmentProof,
contribution: csel.contributions[i],
+ feeDeposit: denom.feeDeposit,
});
}
},
diff --git a/packages/taler-wallet-core/src/pay-peer-pull-credit.ts b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts
index 45ca93a40..a92d8e764 100644
--- a/packages/taler-wallet-core/src/pay-peer-pull-credit.ts
+++ b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts
@@ -18,7 +18,6 @@
* Imports.
*/
import {
- AbsoluteTime,
Amounts,
CheckPeerPullCreditRequest,
CheckPeerPullCreditResponse,
@@ -31,7 +30,10 @@ import {
Logger,
NotificationType,
PeerContractTerms,
+ ScopeInfo,
+ ScopeType,
TalerErrorCode,
+ TalerErrorDetail,
TalerPreciseTimestamp,
TalerProtocolTimestamp,
TalerUriAction,
@@ -43,11 +45,11 @@ import {
TransactionState,
TransactionType,
WalletAccountMergeFlags,
- WalletKycUuid,
assertUnreachable,
checkDbInvariant,
+ codecForAccountKycStatus,
codecForAny,
- codecForWalletKycUuid,
+ codecForLegitimizationNeededResponse,
encodeCrock,
getRandomBytes,
j2s,
@@ -55,7 +57,10 @@ import {
stringifyTalerUri,
talerPaytoFromExchangeReserve,
} from "@gnu-taler/taler-util";
-import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
+import {
+ readResponseJsonOrThrow,
+ readSuccessResponseJsonOrThrow,
+} from "@gnu-taler/taler-util/http";
import {
PendingTaskType,
TaskIdStr,
@@ -71,7 +76,6 @@ import {
runWithClientCancellation,
} from "./common.js";
import {
- KycPendingInfo,
OperationRetryRecord,
PeerPullCreditRecord,
PeerPullPaymentCreditStatus,
@@ -81,7 +85,6 @@ import {
WithdrawalGroupRecord,
WithdrawalGroupStatus,
WithdrawalRecordType,
- timestampOptionalPreciseFromDb,
timestampPreciseFromDb,
timestampPreciseToDb,
} from "./db.js";
@@ -89,9 +92,12 @@ import {
BalanceThresholdCheckResult,
checkIncomingAmountLegalUnderKycBalanceThreshold,
fetchFreshExchange,
+ getPreferredExchangeForCurrency,
+ getPreferredExchangeForScope,
getScopeForAllExchanges,
handleStartExchangeWalletKyc,
} from "./exchanges.js";
+import { checkPeerCreditHardLimitExceeded } from "./kyc.js";
import {
codecForExchangePurseStatus,
getMergeReserveInfo,
@@ -262,6 +268,14 @@ export class PeerPullCreditTransactionContext implements TransactionContext {
TaskIdentifiers.forPeerPullPaymentInitiation(pullCredit);
let pullCreditOrt = await tx.operationRetries.get(pullCreditOpId);
+ let kycUrl: string | undefined = undefined;
+ if (pullCredit.kycPaytoHash) {
+ kycUrl = new URL(
+ `kyc-spa/${pullCredit.kycPaytoHash}`,
+ pullCredit.exchangeBaseUrl,
+ ).href;
+ }
+
if (wsr) {
if (wsr.wgInfo.withdrawalType !== WithdrawalRecordType.PeerPullCredit) {
throw Error(`Unexpected withdrawalType: ${wsr.wgInfo.withdrawalType}`);
@@ -308,7 +322,10 @@ export class PeerPullCreditTransactionContext implements TransactionContext {
tag: TransactionType.PeerPullCredit,
pursePub: pullCredit.pursePub,
}),
- kycUrl: pullCredit.kycUrl,
+ abortReason: pullCredit.abortReason,
+ failReason: pullCredit.failReason,
+ // FIXME: Is this the KYC URL of the withdrawal group?!
+ kycUrl: kycUrl,
...(wsrOrt?.lastError
? {
error: silentWithdrawalErrorForInvoice
@@ -343,7 +360,11 @@ export class PeerPullCreditTransactionContext implements TransactionContext {
tag: TransactionType.PeerPullCredit,
pursePub: pullCredit.pursePub,
}),
- kycUrl: pullCredit.kycUrl,
+ kycUrl,
+ kycAccessToken: pullCredit.kycAccessToken,
+ kycPaytoHash: pullCredit.kycPaytoHash,
+ abortReason: pullCredit.abortReason,
+ failReason: pullCredit.failReason,
...(pullCreditOrt?.lastError ? { error: pullCreditOrt.lastError } : {}),
};
}
@@ -455,7 +476,7 @@ export class PeerPullCreditTransactionContext implements TransactionContext {
notifyTransition(wex, transactionId, transitionInfo);
}
- async failTransaction(): Promise<void> {
+ async failTransaction(reason?: TalerErrorDetail): Promise<void> {
const { wex, pursePub, taskId: retryTag, transactionId } = this;
const transitionInfo = await wex.db.runReadWriteTx(
{ storeNames: ["peerPullCredit", "transactionsMeta"] },
@@ -495,6 +516,7 @@ export class PeerPullCreditTransactionContext implements TransactionContext {
const oldTxState =
computePeerPullCreditTransactionState(pullCreditRec);
pullCreditRec.status = newStatus;
+ pullCreditRec.failReason = reason;
const newTxState =
computePeerPullCreditTransactionState(pullCreditRec);
await tx.peerPullCredit.put(pullCreditRec);
@@ -579,7 +601,7 @@ export class PeerPullCreditTransactionContext implements TransactionContext {
wex.taskScheduler.startShepherdTask(retryTag);
}
- async abortTransaction(): Promise<void> {
+ async abortTransaction(reason?: TalerErrorDetail): Promise<void> {
const { wex, pursePub, taskId: retryTag, transactionId } = this;
const transitionInfo = await wex.db.runReadWriteTx(
{ storeNames: ["peerPullCredit", "transactionsMeta"] },
@@ -598,10 +620,12 @@ export class PeerPullCreditTransactionContext implements TransactionContext {
case PeerPullPaymentCreditStatus.PendingCreatePurse:
case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
newStatus = PeerPullPaymentCreditStatus.AbortingDeletePurse;
+ pullCreditRec.abortReason = reason;
break;
case PeerPullPaymentCreditStatus.PendingWithdrawing:
throw Error("can't abort anymore");
case PeerPullPaymentCreditStatus.PendingReady:
+ pullCreditRec.abortReason = reason;
newStatus = PeerPullPaymentCreditStatus.AbortingDeletePurse;
break;
case PeerPullPaymentCreditStatus.Done:
@@ -758,7 +782,7 @@ async function longpollKycStatus(
wex: WalletExecutionContext,
pursePub: string,
exchangeUrl: string,
- kycInfo: KycPendingInfo,
+ kycPaytoHash: string,
): Promise<TaskRunResult> {
// FIXME: What if this changes? Should be part of the p2p record
const mergeReserveInfo = await getMergeReserveInfo(wex, {
@@ -771,7 +795,7 @@ async function longpollKycStatus(
});
const ctx = new PeerPullCreditTransactionContext(wex, pursePub);
- const url = new URL(`kyc-check/${kycInfo.requirementRow}`, exchangeUrl);
+ const url = new URL(`kyc-check/${kycPaytoHash}`, exchangeUrl);
const kycStatusRes = await wex.ws.runLongpollQueueing(
wex,
url.hostname,
@@ -1049,9 +1073,9 @@ async function handlePeerPullCreditCreatePurse(
if (httpResp.status === HttpStatusCode.UnavailableForLegalReasons) {
const respJson = await httpResp.json();
- const kycPending = codecForWalletKycUuid().decode(respJson);
+ const kycPending = codecForLegitimizationNeededResponse().decode(respJson);
logger.info(`kyc uuid response: ${j2s(kycPending)}`);
- return processPeerPullCreditKycRequired(wex, pullIni, kycPending);
+ return processPeerPullCreditKycRequired(wex, pullIni, kycPending.h_payto);
}
const resp = await readSuccessResponseJsonOrThrow(httpResp, codecForAny());
@@ -1111,14 +1135,14 @@ export async function processPeerPullCredit(
case PeerPullPaymentCreditStatus.PendingReady:
return queryPurseForPeerPullCredit(wex, pullIni);
case PeerPullPaymentCreditStatus.PendingMergeKycRequired: {
- if (!pullIni.kycInfo) {
- throw Error("invalid state, kycInfo required");
+ if (!pullIni.kycPaytoHash) {
+ throw Error("invalid state, kycPaytoHash required");
}
return await longpollKycStatus(
wex,
pursePub,
pullIni.exchangeBaseUrl,
- pullIni.kycInfo,
+ pullIni.kycPaytoHash,
);
}
case PeerPullPaymentCreditStatus.PendingCreatePurse:
@@ -1217,13 +1241,7 @@ async function processPeerPullCreditBalanceKyc(
return TransitionResult.stay();
}
rec.status = PeerPullPaymentCreditStatus.PendingBalanceKycRequired;
- delete rec.kycInfo;
rec.kycAccessToken = ret.walletKycAccessToken;
- // FIXME: #9109 this should not be constructed here, it should be an opaque URL from exchange response
- rec.kycUrl = new URL(
- `kyc-spa/${ret.walletKycAccessToken}`,
- exchangeBaseUrl,
- ).href;
return TransitionResult.transition(rec);
});
return TaskRunResult.progress();
@@ -1235,7 +1253,7 @@ async function processPeerPullCreditBalanceKyc(
async function processPeerPullCreditKycRequired(
wex: WalletExecutionContext,
peerIni: PeerPullCreditRecord,
- kycPending: WalletKycUuid,
+ kycPayoHash: string,
): Promise<TaskRunResult> {
const ctx = new PeerPullCreditTransactionContext(wex, peerIni.pursePub);
const { pursePub } = peerIni;
@@ -1250,10 +1268,7 @@ async function processPeerPullCreditKycRequired(
accountPub: mergeReserveInfo.reservePub,
});
- const url = new URL(
- `kyc-check/${kycPending.requirement_row}`,
- peerIni.exchangeBaseUrl,
- );
+ const url = new URL(`kyc-check/${kycPayoHash}`, peerIni.exchangeBaseUrl);
logger.info(`kyc url ${url.href}`);
const kycStatusRes = await wex.http.fetch(url.href, {
@@ -1271,7 +1286,10 @@ async function processPeerPullCreditKycRequired(
logger.warn("kyc requested, but already fulfilled");
return TaskRunResult.backoff();
} else if (kycStatusRes.status === HttpStatusCode.Accepted) {
- const kycStatus = await kycStatusRes.json();
+ const kycStatus = await readResponseJsonOrThrow(
+ kycStatusRes,
+ codecForAccountKycStatus(),
+ );
logger.info(`kyc status: ${j2s(kycStatus)}`);
const { transitionInfo, result } = await wex.db.runReadWriteTx(
{ storeNames: ["peerPullCredit", "transactionsMeta"] },
@@ -1284,11 +1302,11 @@ async function processPeerPullCreditKycRequired(
};
}
const oldTxState = computePeerPullCreditTransactionState(peerInc);
- peerInc.kycInfo = {
- paytoHash: kycPending.h_payto,
- requirementRow: kycPending.requirement_row,
- };
- peerInc.kycUrl = kycStatus.kyc_url;
+ peerInc.kycPaytoHash = kycPayoHash;
+ logger.info(
+ `setting peer-pull-credit kyc payto hash to ${kycPayoHash}`,
+ );
+ peerInc.kycAccessToken = kycStatus.access_token;
peerInc.status = PeerPullPaymentCreditStatus.PendingMergeKycRequired;
const newTxState = computePeerPullCreditTransactionState(peerInc);
await tx.peerPullCredit.put(peerInc);
@@ -1300,7 +1318,7 @@ async function processPeerPullCreditKycRequired(
},
);
notifyTransition(wex, ctx.transactionId, transitionInfo);
- return TaskRunResult.backoff();
+ return result;
} else {
throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
}
@@ -1330,14 +1348,34 @@ export async function internalCheckPeerPullCredit(
): Promise<CheckPeerPullCreditResponse> {
// FIXME: We don't support exchanges with purse fees yet.
// Select an exchange where we have money in the specified currency
- // FIXME: How do we handle regional currency scopes here? Is it an additional input?
+
+ const instructedAmount = Amounts.parseOrThrow(req.amount);
+ const currency = instructedAmount.currency;
+
+ logger.trace(
+ `checking peer push debit for ${Amounts.stringify(instructedAmount)}`,
+ );
+
+ let restrictScope: ScopeInfo;
+ if (req.restrictScope) {
+ restrictScope = req.restrictScope;
+ } else if (req.exchangeBaseUrl) {
+ restrictScope = {
+ type: ScopeType.Exchange,
+ currency,
+ url: req.exchangeBaseUrl,
+ };
+ } else {
+ throw Error("client must either specify exchangeBaseUrl or restrictScope");
+ }
logger.trace("checking peer-pull-credit fees");
- const currency = Amounts.currencyOf(req.amount);
- let exchangeUrl;
+ let exchangeUrl: string | undefined;
if (req.exchangeBaseUrl) {
exchangeUrl = req.exchangeBaseUrl;
+ } else if (req.restrictScope) {
+ exchangeUrl = await getPreferredExchangeForScope(wex, req.restrictScope);
} else {
exchangeUrl = await getPreferredExchangeForCurrency(wex, currency);
}
@@ -1376,57 +1414,6 @@ export async function internalCheckPeerPullCredit(
}
/**
- * Find a preferred exchange based on when we withdrew last from this exchange.
- */
-async function getPreferredExchangeForCurrency(
- wex: WalletExecutionContext,
- currency: string,
-): Promise<string | undefined> {
- // Find an exchange with the matching currency.
- // Prefer exchanges with the most recent withdrawal.
- const url = await wex.db.runReadOnlyTx(
- { storeNames: ["exchanges"] },
- async (tx) => {
- const exchanges = await tx.exchanges.iter().toArray();
- let candidate = undefined;
- for (const e of exchanges) {
- if (e.detailsPointer?.currency !== currency) {
- continue;
- }
- if (!candidate) {
- candidate = e;
- continue;
- }
- if (candidate.lastWithdrawal && !e.lastWithdrawal) {
- continue;
- }
- const exchangeLastWithdrawal = timestampOptionalPreciseFromDb(
- e.lastWithdrawal,
- );
- const candidateLastWithdrawal = timestampOptionalPreciseFromDb(
- candidate.lastWithdrawal,
- );
- if (exchangeLastWithdrawal && candidateLastWithdrawal) {
- if (
- AbsoluteTime.cmp(
- AbsoluteTime.fromPreciseTimestamp(exchangeLastWithdrawal),
- AbsoluteTime.fromPreciseTimestamp(candidateLastWithdrawal),
- ) > 0
- ) {
- candidate = e;
- }
- }
- }
- if (candidate) {
- return candidate.baseUrl;
- }
- return undefined;
- },
- );
- return url;
-}
-
-/**
* Initiate a peer pull payment.
*/
export async function initiatePeerPullPayment(
@@ -1450,6 +1437,12 @@ export async function initiatePeerPullPayment(
const exchange = await fetchFreshExchange(wex, exchangeBaseUrl);
requireExchangeTosAcceptedOrThrow(exchange);
+ if (
+ checkPeerCreditHardLimitExceeded(exchange, req.partialContractTerms.amount)
+ ) {
+ throw Error("peer credit would exceed hard KYC limit");
+ }
+
const mergeReserveInfo = await getMergeReserveInfo(wex, {
exchangeBaseUrl: exchangeBaseUrl,
});
@@ -1526,12 +1519,6 @@ export async function initiatePeerPullPayment(
notifyTransition(wex, ctx.transactionId, transitionInfo);
wex.taskScheduler.startShepherdTask(ctx.taskId);
- // The pending-incoming balance has changed.
- wex.ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: ctx.transactionId,
- });
-
return {
talerUri: stringifyTalerUri({
type: TalerUriAction.PayPull,
diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts
index 09ecbeb94..9c6c696dd 100644
--- a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts
+++ b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts
@@ -32,7 +32,6 @@ import {
ExchangePurseDeposits,
HttpStatusCode,
Logger,
- NotificationType,
ObservabilityEventType,
PeerContractTerms,
PreparePeerPullDebitRequest,
@@ -41,6 +40,7 @@ import {
SelectedProspectiveCoin,
TalerError,
TalerErrorCode,
+ TalerErrorDetail,
TalerPreciseTimestamp,
TalerProtocolViolationError,
Transaction,
@@ -61,6 +61,7 @@ import {
encodeCrock,
getRandomBytes,
j2s,
+ makeTalerErrorDetail,
parsePayPullUri,
} from "@gnu-taler/taler-util";
import {
@@ -89,6 +90,7 @@ import {
timestampPreciseFromDb,
timestampPreciseToDb,
} from "./db.js";
+import { getExchangeScopeInfo, getScopeForAllExchanges } from "./exchanges.js";
import {
codecForExchangePurseStatus,
getTotalPeerPaymentCost,
@@ -103,7 +105,6 @@ import {
parseTransactionIdentifier,
} from "./transactions.js";
import { WalletExecutionContext } from "./wallet.js";
-import { getScopeForAllExchanges } from "./exchanges.js";
const logger = new Logger("pay-peer-pull-debit.ts");
@@ -180,6 +181,8 @@ export class PeerPullDebitTransactionContext implements TransactionContext {
expiration: contractTerms.purse_expiration,
summary: contractTerms.summary,
},
+ abortReason: pi.abortReason,
+ failReason: pi.failReason,
timestamp: timestampPreciseFromDb(pi.timestampCreated),
transactionId: constructTransactionIdentifier({
tag: TransactionType.PeerPullDebit,
@@ -282,7 +285,7 @@ export class PeerPullDebitTransactionContext implements TransactionContext {
this.wex.taskScheduler.startShepherdTask(this.taskId);
}
- async failTransaction(): Promise<void> {
+ async failTransaction(reason?: TalerErrorDetail): Promise<void> {
const ctx = this;
await ctx.transition(async (pi) => {
switch (pi.status) {
@@ -292,6 +295,7 @@ export class PeerPullDebitTransactionContext implements TransactionContext {
case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
// FIXME: Should we also abort the corresponding refresh session?!
pi.status = PeerPullDebitRecordStatus.Failed;
+ pi.failReason = reason;
return TransitionResultType.Transition;
default:
return TransitionResultType.Stay;
@@ -300,7 +304,7 @@ export class PeerPullDebitTransactionContext implements TransactionContext {
this.wex.taskScheduler.stopShepherdTask(this.taskId);
}
- async abortTransaction(): Promise<void> {
+ async abortTransaction(reason?: TalerErrorDetail): Promise<void> {
const ctx = this;
await ctx.transitionExtra(
{
@@ -347,6 +351,7 @@ export class PeerPullDebitTransactionContext implements TransactionContext {
pi.status = PeerPullDebitRecordStatus.AbortingRefresh;
pi.abortRefreshGroupId = refresh.refreshGroupId;
+ pi.abortReason = reason;
return TransitionResultType.Transition;
},
);
@@ -657,7 +662,12 @@ async function processPeerPullDebitPendingDeposit(
continue;
}
case HttpStatusCode.Gone: {
- await ctx.abortTransaction();
+ await ctx.abortTransaction(
+ makeTalerErrorDetail(
+ TalerErrorCode.WALLET_PEER_PULL_DEBIT_PURSE_GONE,
+ {},
+ ),
+ );
return TaskRunResult.backoff();
}
case HttpStatusCode.Conflict: {
@@ -817,9 +827,7 @@ export async function confirmPeerPullDebit(
const totalAmount = await getTotalPeerPaymentCost(wex, coins);
- // FIXME: Missing notification here!
-
- await wex.db.runReadWriteTx(
+ const transitionInfo = await wex.db.runReadWriteTx(
{
storeNames: [
"coinAvailability",
@@ -841,6 +849,7 @@ export async function confirmPeerPullDebit(
if (pi.status !== PeerPullDebitRecordStatus.DialogProposed) {
return;
}
+ const oldTxState = computePeerPullDebitTransactionState(pi);
if (coinSelRes.type == "success") {
await spendCoins(wex, tx, {
transactionId,
@@ -859,13 +868,12 @@ export async function confirmPeerPullDebit(
pi.status = PeerPullDebitRecordStatus.PendingDeposit;
await ctx.updateTransactionMeta(tx);
await tx.peerPullDebit.put(pi);
+ const newTxState = computePeerPullDebitTransactionState(pi);
+ return { oldTxState, newTxState };
},
);
- wex.ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: transactionId,
- });
+ notifyTransition(wex, transactionId, transitionInfo);
wex.taskScheduler.startShepherdTask(ctx.taskId);
@@ -889,7 +897,16 @@ export async function preparePeerPullDebit(
}
const existing = await wex.db.runReadOnlyTx(
- { storeNames: ["peerPullDebit", "contractTerms"] },
+ {
+ storeNames: [
+ "peerPullDebit",
+ "contractTerms",
+ "exchangeDetails",
+ "exchanges",
+ "globalCurrencyAuditors",
+ "globalCurrencyExchanges",
+ ],
+ },
async (tx) => {
const peerPullDebitRecord =
await tx.peerPullDebit.indexes.byExchangeAndContractPriv.get([
@@ -905,7 +922,18 @@ export async function preparePeerPullDebit(
if (!contractTerms) {
return;
}
- return { peerPullDebitRecord, contractTerms };
+ const currency = Amounts.currencyOf(peerPullDebitRecord.amount);
+ const scopeInfo = await getExchangeScopeInfo(
+ tx,
+ peerPullDebitRecord.exchangeBaseUrl,
+ currency,
+ );
+ return {
+ peerPullDebitRecord,
+ contractTerms,
+ scopeInfo,
+ exchangeBaseUrl: peerPullDebitRecord.exchangeBaseUrl,
+ };
},
);
@@ -915,7 +943,8 @@ export async function preparePeerPullDebit(
amountRaw: existing.peerPullDebitRecord.amount,
amountEffective: existing.peerPullDebitRecord.totalCostEstimated,
contractTerms: existing.contractTerms.contractTermsRaw,
- peerPullDebitId: existing.peerPullDebitRecord.peerPullDebitId,
+ scopeInfo: existing.scopeInfo,
+ exchangeBaseUrl: existing.exchangeBaseUrl,
transactionId: constructTransactionIdentifier({
tag: TransactionType.PeerPullDebit,
peerPullDebitId: existing.peerPullDebitRecord.peerPullDebitId,
@@ -1000,6 +1029,7 @@ export async function preparePeerPullDebit(
}
const totalAmount = await getTotalPeerPaymentCost(wex, coins);
+ const currency = Amounts.currencyOf(totalAmount);
const ctx = new PeerPullDebitTransactionContext(wex, peerPullDebitId);
@@ -1025,16 +1055,18 @@ export async function preparePeerPullDebit(
},
);
+ const scopeInfo = await wex.db.runAllStoresReadOnlyTx({}, (tx) => {
+ return getExchangeScopeInfo(tx, exchangeBaseUrl, currency);
+ });
+
return {
amount: contractTerms.amount,
amountEffective: Amounts.stringify(totalAmount),
amountRaw: contractTerms.amount,
contractTerms: contractTerms,
- peerPullDebitId,
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.PeerPullDebit,
- peerPullDebitId: peerPullDebitId,
- }),
+ scopeInfo,
+ exchangeBaseUrl,
+ transactionId: ctx.transactionId,
};
}
diff --git a/packages/taler-wallet-core/src/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/pay-peer-push-credit.ts
index 7c131c45a..c84d79945 100644
--- a/packages/taler-wallet-core/src/pay-peer-push-credit.ts
+++ b/packages/taler-wallet-core/src/pay-peer-push-credit.ts
@@ -23,11 +23,13 @@ import {
ExchangePurseMergeRequest,
ExchangeWalletKycStatus,
HttpStatusCode,
+ LegitimizationNeededResponse,
Logger,
NotificationType,
PeerContractTerms,
PreparePeerPushCreditRequest,
PreparePeerPushCreditResponse,
+ TalerErrorDetail,
TalerPreciseTimestamp,
Transaction,
TransactionAction,
@@ -37,13 +39,13 @@ import {
TransactionState,
TransactionType,
WalletAccountMergeFlags,
- WalletKycUuid,
assertUnreachable,
checkDbInvariant,
+ codecForAccountKycStatus,
codecForAny,
codecForExchangeGetContractResponse,
+ codecForLegitimizationNeededResponse,
codecForPeerContractTerms,
- codecForWalletKycUuid,
decodeCrock,
eddsaGetPublic,
encodeCrock,
@@ -52,7 +54,10 @@ import {
parsePayPushUri,
talerPaytoFromExchangeReserve,
} from "@gnu-taler/taler-util";
-import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
+import {
+ readResponseJsonOrThrow,
+ readSuccessResponseJsonOrThrow,
+} from "@gnu-taler/taler-util/http";
import {
PendingTaskType,
TaskIdStr,
@@ -67,7 +72,6 @@ import {
requireExchangeTosAcceptedOrThrow,
} from "./common.js";
import {
- KycPendingInfo,
OperationRetryRecord,
PeerPushCreditStatus,
PeerPushPaymentIncomingRecord,
@@ -84,10 +88,15 @@ import {
BalanceThresholdCheckResult,
checkIncomingAmountLegalUnderKycBalanceThreshold,
fetchFreshExchange,
+ getExchangeScopeInfo,
getScopeForAllExchanges,
handleStartExchangeWalletKyc,
} from "./exchanges.js";
import {
+ checkPeerCreditHardLimitExceeded,
+ getPeerCreditLimitInfo,
+} from "./kyc.js";
+import {
codecForExchangePurseStatus,
getMergeReserveInfo,
} from "./pay-peer-common.js";
@@ -263,6 +272,11 @@ export class PeerPushCreditTransactionContext implements TransactionContext {
const peerContractTerms = ct.contractTermsRaw;
+ let kycUrl: string | undefined = undefined;
+ if (wg?.kycAccessToken && wg.exchangeBaseUrl) {
+ kycUrl = new URL(`kyc-spa/${wg.kycAccessToken}`, wg.exchangeBaseUrl).href;
+ }
+
if (wg) {
if (wg.wgInfo.withdrawalType !== WithdrawalRecordType.PeerPushCredit) {
throw Error("invalid withdrawal group type for push payment credit");
@@ -291,8 +305,10 @@ export class PeerPushCreditTransactionContext implements TransactionContext {
tag: TransactionType.PeerPushCredit,
peerPushCreditId: pushInc.peerPushCreditId,
}),
- kycUrl: wg.kycUrl,
- kycPaytoHash: wg.kycPending?.paytoHash,
+ abortReason: pushInc.abortReason,
+ failReason: pushInc.failReason,
+ kycUrl,
+ kycPaytoHash: wg.kycPaytoHash,
...(wgRetryRecord?.lastError ? { error: wgRetryRecord.lastError } : {}),
};
}
@@ -313,13 +329,15 @@ export class PeerPushCreditTransactionContext implements TransactionContext {
expiration: peerContractTerms.purse_expiration,
summary: peerContractTerms.summary,
},
- kycUrl: pushInc.kycUrl,
- kycPaytoHash: pushInc.kycInfo?.paytoHash,
+ kycUrl,
+ kycPaytoHash: pushInc.kycPaytoHash,
timestamp: timestampPreciseFromDb(pushInc.timestamp),
transactionId: constructTransactionIdentifier({
tag: TransactionType.PeerPushCredit,
peerPushCreditId: pushInc.peerPushCreditId,
}),
+ abortReason: pushInc.abortReason,
+ failReason: pushInc.failReason,
...(pushRetryRecord?.lastError
? { error: pushRetryRecord.lastError }
: {}),
@@ -521,7 +539,7 @@ export class PeerPushCreditTransactionContext implements TransactionContext {
wex.taskScheduler.startShepherdTask(retryTag);
}
- async failTransaction(): Promise<void> {
+ async failTransaction(reason?: TalerErrorDetail): Promise<void> {
const { wex, peerPushCreditId, taskId: retryTag, transactionId } = this;
const transitionInfo = await wex.db.runReadWriteTx(
{ storeNames: ["peerPushCredit", "transactionsMeta"] },
@@ -531,7 +549,7 @@ export class PeerPushCreditTransactionContext implements TransactionContext {
logger.warn(`peer push credit ${peerPushCreditId} not found`);
return;
}
- let newStatus: PeerPushCreditStatus | undefined = undefined;
+ let newStatus: PeerPushCreditStatus;
switch (pushCreditRec.status) {
case PeerPushCreditStatus.Done:
case PeerPushCreditStatus.Aborted:
@@ -554,20 +572,16 @@ export class PeerPushCreditTransactionContext implements TransactionContext {
default:
assertUnreachable(pushCreditRec.status);
}
- if (newStatus != null) {
- const oldTxState =
- computePeerPushCreditTransactionState(pushCreditRec);
- pushCreditRec.status = newStatus;
- const newTxState =
- computePeerPushCreditTransactionState(pushCreditRec);
- await tx.peerPushCredit.put(pushCreditRec);
- await this.updateTransactionMeta(tx);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
+ const oldTxState = computePeerPushCreditTransactionState(pushCreditRec);
+ pushCreditRec.status = newStatus;
+ pushCreditRec.failReason = reason;
+ const newTxState = computePeerPushCreditTransactionState(pushCreditRec);
+ await tx.peerPushCredit.put(pushCreditRec);
+ await this.updateTransactionMeta(tx);
+ return {
+ oldTxState,
+ newTxState,
+ };
},
);
wex.taskScheduler.stopShepherdTask(retryTag);
@@ -586,6 +600,10 @@ export async function preparePeerPushCredit(
throw Error("got invalid taler://pay-push URI");
}
+ // add exchange entry if it doesn't exist already!
+ const exchangeBaseUrl = uri.exchangeBaseUrl;
+ const exchange = await fetchFreshExchange(wex, exchangeBaseUrl);
+
const existing = await wex.db.runReadOnlyTx(
{ storeNames: ["contractTerms", "peerPushCredit"] },
async (tx) => {
@@ -613,22 +631,30 @@ export async function preparePeerPushCredit(
);
if (existing) {
+ const currency = Amounts.currencyOf(existing.existingContractTerms.amount);
+ const exchangeBaseUrl = existing.existingPushInc.exchangeBaseUrl;
+ const scopeInfo = await wex.db.runAllStoresReadOnlyTx(
+ {},
+ async (tx) => await getExchangeScopeInfo(tx, exchangeBaseUrl, currency),
+ );
return {
amount: existing.existingContractTerms.amount,
amountEffective: existing.existingPushInc.estimatedAmountEffective,
amountRaw: existing.existingContractTerms.amount,
contractTerms: existing.existingContractTerms,
- peerPushCreditId: existing.existingPushInc.peerPushCreditId,
transactionId: constructTransactionIdentifier({
tag: TransactionType.PeerPushCredit,
peerPushCreditId: existing.existingPushInc.peerPushCreditId,
}),
- exchangeBaseUrl: existing.existingPushInc.exchangeBaseUrl,
+ scopeInfo,
+ exchangeBaseUrl,
+ ...getPeerCreditLimitInfo(
+ exchange,
+ existing.existingContractTerms.amount,
+ ),
};
}
- const exchangeBaseUrl = uri.exchangeBaseUrl;
-
const contractPriv = uri.contractPriv;
const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv)));
@@ -649,12 +675,12 @@ export async function preparePeerPushCredit(
pursePub: pursePub,
});
+ const contractTerms = codecForPeerContractTerms().decode(dec.contractTerms);
+
const getPurseUrl = new URL(`purses/${pursePub}/deposit`, exchangeBaseUrl);
const purseHttpResp = await wex.http.fetch(getPurseUrl.href);
- const contractTerms = codecForPeerContractTerms().decode(dec.contractTerms);
-
const purseStatus = await readSuccessResponseJsonOrThrow(
purseHttpResp,
codecForExchangePurseStatus(),
@@ -685,7 +711,7 @@ export async function preparePeerPushCredit(
);
}
- const ctx = new PeerPushCreditTransactionContext(wex, withdrawalGroupId);
+ const ctx = new PeerPushCreditTransactionContext(wex, peerPushCreditId);
const transitionInfo = await wex.db.runReadWriteTx(
{ storeNames: ["contractTerms", "peerPushCredit", "transactionsMeta"] },
@@ -723,26 +749,24 @@ export async function preparePeerPushCredit(
},
);
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPushCredit,
- peerPushCreditId,
- });
+ const currency = Amounts.currencyOf(wi.withdrawalAmountRaw);
- notifyTransition(wex, transactionId, transitionInfo);
+ notifyTransition(wex, ctx.transactionId, transitionInfo);
- wex.ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: transactionId,
- });
+ const scopeInfo = await wex.db.runAllStoresReadOnlyTx(
+ {},
+ async (tx) => await getExchangeScopeInfo(tx, exchangeBaseUrl, currency),
+ );
return {
amount: purseStatus.balance,
amountEffective: wi.withdrawalAmountEffective,
amountRaw: purseStatus.balance,
contractTerms: dec.contractTerms,
- peerPushCreditId,
- transactionId,
+ transactionId: ctx.transactionId,
exchangeBaseUrl,
+ scopeInfo,
+ ...getPeerCreditLimitInfo(exchange, purseStatus.balance),
};
}
@@ -750,7 +774,7 @@ async function longpollKycStatus(
wex: WalletExecutionContext,
peerPushCreditId: string,
exchangeUrl: string,
- kycInfo: KycPendingInfo,
+ kycPaytoHash: string,
): Promise<TaskRunResult> {
const ctx = new PeerPushCreditTransactionContext(wex, peerPushCreditId);
@@ -764,7 +788,7 @@ async function longpollKycStatus(
accountPub: mergeReserveInfo.reservePub,
});
- const url = new URL(`kyc-check/${kycInfo.requirementRow}`, exchangeUrl);
+ const url = new URL(`kyc-check/${kycPaytoHash}`, exchangeUrl);
logger.info(`kyc url ${url.href}`);
const kycStatusRes = await wex.ws.runLongpollQueueing(
wex,
@@ -816,7 +840,7 @@ async function longpollKycStatus(
async function processPeerPushCreditKycRequired(
wex: WalletExecutionContext,
peerInc: PeerPushPaymentIncomingRecord,
- kycPending: WalletKycUuid,
+ kycPending: LegitimizationNeededResponse,
): Promise<TaskRunResult> {
const transactionId = constructTransactionIdentifier({
tag: TransactionType.PeerPushCredit,
@@ -839,7 +863,7 @@ async function processPeerPushCreditKycRequired(
});
const url = new URL(
- `kyc-check/${kycPending.requirement_row}`,
+ `kyc-check/${kycPending.h_payto}`,
peerInc.exchangeBaseUrl,
);
@@ -861,8 +885,11 @@ async function processPeerPushCreditKycRequired(
logger.warn("kyc requested, but already fulfilled");
return TaskRunResult.finished();
} else if (kycStatusRes.status === HttpStatusCode.Accepted) {
- const kycStatus = await kycStatusRes.json();
- logger.info(`kyc status: ${j2s(kycStatus)}`);
+ const statusResp = await readResponseJsonOrThrow(
+ kycStatusRes,
+ codecForAccountKycStatus(),
+ );
+ logger.info(`kyc status: ${j2s(statusResp)}`);
const { transitionInfo, result } = await wex.db.runReadWriteTx(
{ storeNames: ["peerPushCredit", "transactionsMeta"] },
async (tx) => {
@@ -874,11 +901,8 @@ async function processPeerPushCreditKycRequired(
};
}
const oldTxState = computePeerPushCreditTransactionState(peerInc);
- peerInc.kycInfo = {
- paytoHash: kycPending.h_payto,
- requirementRow: kycPending.requirement_row,
- };
- peerInc.kycUrl = kycStatus.kyc_url;
+ peerInc.kycPaytoHash = kycPending.h_payto;
+ peerInc.kycAccessToken = statusResp.access_token;
peerInc.status = PeerPushCreditStatus.PendingMergeKycRequired;
const newTxState = computePeerPushCreditTransactionState(peerInc);
await tx.peerPushCredit.put(peerInc);
@@ -978,10 +1002,14 @@ async function handlePendingMerge(
});
if (mergeHttpResp.status === HttpStatusCode.UnavailableForLegalReasons) {
- const respJson = await mergeHttpResp.json();
- const kycPending = codecForWalletKycUuid().decode(respJson);
- logger.info(`kyc uuid response: ${j2s(kycPending)}`);
- return processPeerPushCreditKycRequired(wex, peerInc, kycPending);
+ const kycLegiNeededResp = await readResponseJsonOrThrow(
+ mergeHttpResp,
+ codecForLegitimizationNeededResponse(),
+ );
+ logger.info(
+ `kyc legitimization needed response: ${j2s(kycLegiNeededResp)}`,
+ );
+ return processPeerPushCreditKycRequired(wex, peerInc, kycLegiNeededResp);
}
logger.trace(`merge request: ${j2s(mergeReq)}`);
@@ -1163,14 +1191,14 @@ export async function processPeerPushCredit(
switch (peerInc.status) {
case PeerPushCreditStatus.PendingMergeKycRequired: {
- if (!peerInc.kycInfo) {
- throw Error("invalid state, kycInfo required");
+ if (!peerInc.kycPaytoHash) {
+ throw Error("invalid state, kycPaytoHash required");
}
return await longpollKycStatus(
wex,
peerPushCreditId,
peerInc.exchangeBaseUrl,
- peerInc.kycInfo,
+ peerInc.kycPaytoHash,
);
}
case PeerPushCreditStatus.PendingMerge: {
@@ -1255,13 +1283,7 @@ async function processPeerPushCreditBalanceKyc(
return TransitionResult.stay();
}
rec.status = PeerPushCreditStatus.PendingBalanceKycRequired;
- delete rec.kycInfo;
rec.kycAccessToken = ret.walletKycAccessToken;
- // FIXME: #9109 this should not be constructed here, it should be an opaque URL from exchange response
- rec.kycUrl = new URL(
- `kyc-spa/${ret.walletKycAccessToken}`,
- exchangeBaseUrl,
- ).href;
return TransitionResult.transition(rec);
});
return TaskRunResult.progress();
@@ -1288,31 +1310,55 @@ export async function confirmPeerPushCredit(
logger.trace(`confirming peer-push-credit ${ctx.peerPushCreditId}`);
- const peerInc = await wex.db.runReadWriteTx(
+ const res = await wex.db.runReadWriteTx(
{ storeNames: ["contractTerms", "peerPushCredit", "transactionsMeta"] },
async (tx) => {
const rec = await tx.peerPushCredit.get(ctx.peerPushCreditId);
if (!rec) {
return;
}
- if (rec.status === PeerPushCreditStatus.DialogProposed) {
- rec.status = PeerPushCreditStatus.PendingMerge;
+ const ct = await tx.contractTerms.get(rec.contractTermsHash);
+ if (!ct) {
+ return undefined;
}
- await tx.peerPushCredit.put(rec);
- await ctx.updateTransactionMeta(tx);
- return rec;
+ return {
+ peerInc: rec,
+ contractTerms: ct.contractTermsRaw as PeerContractTerms,
+ };
},
);
- if (!peerInc) {
+ if (!res) {
throw Error(
`can't accept unknown incoming p2p push payment (${req.transactionId})`,
);
}
+ const peerInc = res.peerInc;
+
const exchange = await fetchFreshExchange(wex, peerInc.exchangeBaseUrl);
requireExchangeTosAcceptedOrThrow(exchange);
+ if (checkPeerCreditHardLimitExceeded(exchange, res.contractTerms.amount)) {
+ throw Error("peer credit would exceed hard KYC limit");
+ }
+
+ await wex.db.runReadWriteTx(
+ { storeNames: ["contractTerms", "peerPushCredit", "transactionsMeta"] },
+ async (tx) => {
+ const rec = await tx.peerPushCredit.get(ctx.peerPushCreditId);
+ if (!rec) {
+ return;
+ }
+ if (rec.status === PeerPushCreditStatus.DialogProposed) {
+ rec.status = PeerPushCreditStatus.PendingMerge;
+ }
+ await tx.peerPushCredit.put(rec);
+ await ctx.updateTransactionMeta(tx);
+ return rec;
+ },
+ );
+
wex.taskScheduler.startShepherdTask(ctx.taskId);
return {
@@ -1444,3 +1490,6 @@ export function computePeerPushCreditTransactionActions(
assertUnreachable(pushCreditRecord.status);
}
}
+function checkPeerHardLimitExceeded(exchanges: any, amount: any) {
+ throw new Error("Function not implemented.");
+}
diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.ts
index 4485976b9..31d320259 100644
--- a/packages/taler-wallet-core/src/pay-peer-push-debit.ts
+++ b/packages/taler-wallet-core/src/pay-peer-push-debit.ts
@@ -25,11 +25,13 @@ import {
InitiatePeerPushDebitRequest,
InitiatePeerPushDebitResponse,
Logger,
- NotificationType,
RefreshReason,
+ ScopeInfo,
+ ScopeType,
SelectedProspectiveCoin,
TalerError,
TalerErrorCode,
+ TalerErrorDetail,
TalerPreciseTimestamp,
TalerProtocolTimestamp,
TalerProtocolViolationError,
@@ -80,6 +82,7 @@ import {
timestampProtocolFromDb,
timestampProtocolToDb,
} from "./db.js";
+import { getScopeForAllExchanges } from "./exchanges.js";
import {
codecForExchangePurseStatus,
getTotalPeerPaymentCost,
@@ -93,7 +96,6 @@ import {
notifyTransition,
} from "./transactions.js";
import { WalletExecutionContext } from "./wallet.js";
-import { getScopeForAllExchanges } from "./exchanges.js";
const logger = new Logger("pay-peer-push-debit.ts");
@@ -185,6 +187,8 @@ export class PeerPushDebitTransactionContext implements TransactionContext {
tag: TransactionType.PeerPushDebit,
pursePub: pushDebitRec.pursePub,
}),
+ failReason: pushDebitRec.failReason,
+ abortReason: pushDebitRec.abortReason,
...(retryRec?.lastError ? { error: retryRec.lastError } : {}),
};
}
@@ -263,7 +267,7 @@ export class PeerPushDebitTransactionContext implements TransactionContext {
notifyTransition(wex, transactionId, transitionInfo);
}
- async abortTransaction(): Promise<void> {
+ async abortTransaction(reason?: TalerErrorDetail): Promise<void> {
const { wex, pursePub, transactionId, taskId: retryTag } = this;
const transitionInfo = await wex.db.runReadWriteTx(
{ storeNames: ["peerPushDebit", "transactionsMeta"] },
@@ -277,11 +281,13 @@ export class PeerPushDebitTransactionContext implements TransactionContext {
switch (pushDebitRec.status) {
case PeerPushDebitStatus.PendingReady:
case PeerPushDebitStatus.SuspendedReady:
+ pushDebitRec.abortReason = reason;
newStatus = PeerPushDebitStatus.AbortingDeletePurse;
break;
case PeerPushDebitStatus.SuspendedCreatePurse:
case PeerPushDebitStatus.PendingCreatePurse:
// Network request might already be in-flight!
+ pushDebitRec.abortReason = reason;
newStatus = PeerPushDebitStatus.AbortingDeletePurse;
break;
case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
@@ -377,7 +383,7 @@ export class PeerPushDebitTransactionContext implements TransactionContext {
notifyTransition(wex, transactionId, transitionInfo);
}
- async failTransaction(): Promise<void> {
+ async failTransaction(reason?: TalerErrorDetail): Promise<void> {
const { wex, pursePub, transactionId, taskId: retryTag } = this;
const transitionInfo = await wex.db.runReadWriteTx(
{ storeNames: ["peerPushDebit", "transactionsMeta"] },
@@ -387,7 +393,7 @@ export class PeerPushDebitTransactionContext implements TransactionContext {
logger.warn(`peer push debit ${pursePub} not found`);
return;
}
- let newStatus: PeerPushDebitStatus | undefined = undefined;
+ let newStatus: PeerPushDebitStatus;
switch (pushDebitRec.status) {
case PeerPushDebitStatus.AbortingRefreshDeleted:
case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
@@ -409,22 +415,20 @@ export class PeerPushDebitTransactionContext implements TransactionContext {
case PeerPushDebitStatus.Failed:
case PeerPushDebitStatus.Expired:
// Do nothing
- break;
+ return undefined;
default:
assertUnreachable(pushDebitRec.status);
}
- if (newStatus != null) {
- const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
- pushDebitRec.status = newStatus;
- const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
- await tx.peerPushDebit.put(pushDebitRec);
- await this.updateTransactionMeta(tx);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
+ const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
+ pushDebitRec.status = newStatus;
+ pushDebitRec.failReason = reason;
+ const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
+ await tx.peerPushDebit.put(pushDebitRec);
+ await this.updateTransactionMeta(tx);
+ return {
+ oldTxState,
+ newTxState,
+ };
},
);
wex.taskScheduler.stopShepherdTask(retryTag);
@@ -450,11 +454,24 @@ async function internalCheckPeerPushDebit(
req: CheckPeerPushDebitRequest,
): Promise<CheckPeerPushDebitResponse> {
const instructedAmount = Amounts.parseOrThrow(req.amount);
+ const currency = instructedAmount.currency;
logger.trace(
`checking peer push debit for ${Amounts.stringify(instructedAmount)}`,
);
+ let restrictScope: ScopeInfo | undefined = undefined;
+ if (req.restrictScope) {
+ restrictScope = req.restrictScope;
+ } else if (req.exchangeBaseUrl) {
+ restrictScope = {
+ type: ScopeType.Exchange,
+ currency,
+ url: req.exchangeBaseUrl,
+ };
+ }
const coinSelRes = await selectPeerCoins(wex, {
instructedAmount,
+ restrictScope,
+ feesCoveredByCounterparty: false,
});
let coins: SelectedProspectiveCoin[] | undefined = undefined;
switch (coinSelRes.type) {
@@ -527,8 +544,10 @@ async function handlePurseCreationConflict(
}
const coinSelRes = await selectPeerCoins(wex, {
- instructedAmount,
+ instructedAmount: Amounts.parseOrThrow(peerPushInitiation.amount),
+ restrictScope: peerPushInitiation.restrictScope,
repair,
+ feesCoveredByCounterparty: false,
});
switch (coinSelRes.type) {
@@ -599,6 +618,8 @@ async function processPeerPushDebitCreateReserve(
if (!peerPushInitiation.coinSel) {
const coinSelRes = await selectPeerCoins(wex, {
instructedAmount: Amounts.parseOrThrow(peerPushInitiation.amount),
+ restrictScope: peerPushInitiation.restrictScope,
+ feesCoveredByCounterparty: false,
});
switch (coinSelRes.type) {
@@ -667,11 +688,13 @@ async function processPeerPushDebitCreateReserve(
return TaskRunResult.backoff();
}
+ const purseAmount = peerPushInitiation.amount;
+
const purseSigResp = await wex.cryptoApi.signPurseCreation({
hContractTerms,
mergePub: peerPushInitiation.mergePub,
minAge: 0,
- purseAmount: peerPushInitiation.amount,
+ purseAmount,
purseExpiration: timestampProtocolFromDb(purseExpiration),
pursePriv: peerPushInitiation.pursePriv,
});
@@ -718,7 +741,8 @@ async function processPeerPushDebitCreateReserve(
);
const reqBody = {
- amount: peerPushInitiation.amount,
+ // Older wallets do not have amountPurse
+ amount: purseAmount,
merge_pub: peerPushInitiation.mergePub,
purse_sig: purseSigResp.sig,
h_contract_terms: hContractTerms,
@@ -743,6 +767,8 @@ async function processPeerPushDebitCreateReserve(
// Possibly on to the next batch.
continue;
case HttpStatusCode.Forbidden: {
+ const errResp = await readTalerErrorResponse(httpResp);
+ logger.error(`${j2s(errResp)}`);
// FIXME: Store this error!
await ctx.failTransaction();
return TaskRunResult.finished();
@@ -778,7 +804,7 @@ async function processPeerPushDebitCreateReserve(
switch (httpResp.status) {
case HttpStatusCode.Ok:
// Possibly on to the next batch.
- continue;
+ break;
case HttpStatusCode.Forbidden: {
// FIXME: Store this error!
await ctx.failTransaction();
@@ -801,6 +827,22 @@ async function processPeerPushDebitCreateReserve(
// All batches done!
+ const getPurseUrl = new URL(
+ `purses/${pursePub}/deposit`,
+ peerPushInitiation.exchangeBaseUrl,
+ );
+
+ const purseHttpResp = await wex.http.fetch(getPurseUrl.href);
+
+ const purseStatus = await readSuccessResponseJsonOrThrow(
+ purseHttpResp,
+ codecForExchangePurseStatus(),
+ );
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(`purse status: ${j2s(purseStatus)}`);
+ }
+
await transitionPeerPushDebitTransaction(wex, pursePub, {
stFrom: PeerPushDebitStatus.PendingCreatePurse,
stTo: PeerPushDebitStatus.PendingReady,
@@ -1192,13 +1234,11 @@ export async function initiatePeerPushDebit(
req.partialContractTerms.amount,
);
const purseExpiration = req.partialContractTerms.purse_expiration;
- const contractTerms = req.partialContractTerms;
+ const contractTerms = { ...req.partialContractTerms };
const pursePair = await wex.cryptoApi.createEddsaKeypair({});
const mergePair = await wex.cryptoApi.createEddsaKeypair({});
- const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms);
-
const contractKeyPair = await wex.cryptoApi.createEddsaKeypair({});
const pursePub = pursePair.pub;
@@ -1209,6 +1249,8 @@ export async function initiatePeerPushDebit(
const contractEncNonce = encodeCrock(getRandomBytes(24));
+ const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms);
+
const res = await wex.db.runReadWriteTx(
{
storeNames: [
@@ -1223,11 +1265,15 @@ export async function initiatePeerPushDebit(
"refreshGroups",
"refreshSessions",
"transactionsMeta",
+ "globalCurrencyExchanges",
+ "globalCurrencyAuditors",
],
},
async (tx) => {
const coinSelRes = await selectPeerCoinsInTx(wex, tx, {
instructedAmount,
+ restrictScope: req.restrictScope,
+ feesCoveredByCounterparty: false,
});
let coins: SelectedProspectiveCoin[] | undefined = undefined;
@@ -1250,11 +1296,26 @@ export async function initiatePeerPushDebit(
assertUnreachable(coinSelRes);
}
+ logger.trace(j2s(coinSelRes));
+
const sel = coinSelRes.result;
+ logger.trace(
+ `peer debit instructed amount: ${Amounts.stringify(instructedAmount)}`,
+ );
+ logger.trace(
+ `peer debit contract terms amount: ${Amounts.stringify(
+ contractTerms.amount,
+ )}`,
+ );
+ logger.trace(
+ `peer debit deposit fees: ${Amounts.stringify(sel.totalDepositFees)}`,
+ );
+
const totalAmount = await getTotalPeerPaymentCostInTx(wex, tx, coins);
const ppi: PeerPushDebitRecord = {
amount: Amounts.stringify(instructedAmount),
+ restrictScope: req.restrictScope,
contractPriv: contractKeyPair.priv,
contractPub: contractKeyPair.pub,
contractTermsHash: hContractTerms,
@@ -1307,10 +1368,6 @@ export async function initiatePeerPushDebit(
},
);
notifyTransition(wex, transactionId, res.transitionInfo);
- wex.ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: transactionId,
- });
wex.taskScheduler.startShepherdTask(ctx.taskId);
diff --git a/packages/taler-wallet-core/src/recoup.ts b/packages/taler-wallet-core/src/recoup.ts
index d39be32bd..e74e49118 100644
--- a/packages/taler-wallet-core/src/recoup.ts
+++ b/packages/taler-wallet-core/src/recoup.ts
@@ -67,7 +67,7 @@ import { constructTransactionIdentifier } from "./transactions.js";
import { WalletExecutionContext, getDenomInfo } from "./wallet.js";
import { internalCreateWithdrawalGroup } from "./withdraw.js";
-export const logger = new Logger("operations/recoup.ts");
+const logger = new Logger("operations/recoup.ts");
/**
* Store a recoup group record in the database after marking
diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts
index b8bd3c0b7..033a85adf 100644
--- a/packages/taler-wallet-core/src/refresh.ts
+++ b/packages/taler-wallet-core/src/refresh.ts
@@ -110,6 +110,7 @@ import {
WalletDbStoresArr,
} from "./db.js";
import { selectWithdrawalDenominations } from "./denomSelection.js";
+import { getScopeForAllExchanges } from "./exchanges.js";
import {
constructTransactionIdentifier,
isUnsuccessfulTransaction,
@@ -122,7 +123,6 @@ import {
WalletExecutionContext,
} from "./wallet.js";
import { getCandidateWithdrawalDenomsTx } from "./withdraw.js";
-import { getScopeForAllExchanges } from "./exchanges.js";
const logger = new Logger("refresh.ts");
@@ -187,7 +187,12 @@ export class RefreshTransactionContext implements TransactionContext {
return {
type: TransactionType.Refresh,
txState,
- scopes: await getScopeForAllExchanges(tx, !refreshGroupRecord.infoPerExchange? []: Object.keys(refreshGroupRecord.infoPerExchange)),
+ scopes: await getScopeForAllExchanges(
+ tx,
+ !refreshGroupRecord.infoPerExchange
+ ? []
+ : Object.keys(refreshGroupRecord.infoPerExchange),
+ ),
txActions: computeRefreshTransactionActions(refreshGroupRecord),
refreshReason: refreshGroupRecord.reason,
amountEffective: isUnsuccessfulTransaction(txState)
@@ -204,6 +209,7 @@ export class RefreshTransactionContext implements TransactionContext {
tag: TransactionType.Refresh,
refreshGroupId: refreshGroupRecord.refreshGroupId,
}),
+ failReason: refreshGroupRecord.failReason,
...(ort?.lastError ? { error: ort.lastError } : {}),
};
}
@@ -341,7 +347,7 @@ export class RefreshTransactionContext implements TransactionContext {
});
}
- async failTransaction(): Promise<void> {
+ async failTransaction(reason?: TalerErrorDetail): Promise<void> {
await this.transition({}, async (rec, tx) => {
if (!rec) {
return TransitionResult.stay();
@@ -353,6 +359,7 @@ export class RefreshTransactionContext implements TransactionContext {
case RefreshOperationStatus.Pending:
case RefreshOperationStatus.Suspended: {
rec.operationStatus = RefreshOperationStatus.Failed;
+ rec.failReason = reason;
return TransitionResult.transition(rec);
}
default:
@@ -406,6 +413,11 @@ export function getTotalRefreshCostInternal(
refreshedDenom: DenominationInfo,
amountLeft: AmountJson,
): AmountJson {
+ logger.trace(
+ `computing total refresh cost, denom value ${
+ refreshedDenom.value
+ }, amount left ${Amounts.stringify(amountLeft)}`,
+ );
const withdrawAmount = Amounts.sub(
amountLeft,
refreshedDenom.feeRefresh,
@@ -1748,6 +1760,12 @@ export async function createRefreshGroup(
const estimatedOutputPerCoin = outInfo.outputPerCoin;
+ if (logger.shouldLogTrace()) {
+ logger.trace(
+ `creating refresh group, inputs ${j2s(oldCoinPubs.map((x) => x.amount))}`,
+ );
+ }
+
await applyRefreshToOldCoins(wex, tx, oldCoinPubs, refreshGroupId);
const refreshGroup: RefreshGroupRecord = {
diff --git a/packages/taler-wallet-core/src/shepherd.ts b/packages/taler-wallet-core/src/shepherd.ts
index c52c55f50..03dd683b2 100644
--- a/packages/taler-wallet-core/src/shepherd.ts
+++ b/packages/taler-wallet-core/src/shepherd.ts
@@ -396,6 +396,14 @@ export class TaskSchedulerImpl implements TaskScheduler {
try {
res = await callOperationHandlerForTaskId(wex, taskId);
} catch (e) {
+ if (info.cts.token.isCancelled) {
+ logger.trace(`task ${taskId} cancelled, ignoring task error`);
+ return;
+ }
+ if (this.ws.stopped) {
+ logger.trace("wallet stopped, ignoring task error");
+ return;
+ }
const errorDetail = getErrorDetailFromException(e);
logger.trace(
`Shepherd error ${taskId} saving response ${j2s(errorDetail)}`,
diff --git a/packages/taler-wallet-core/src/testing.ts b/packages/taler-wallet-core/src/testing.ts
index 225773f8c..ce1d14013 100644
--- a/packages/taler-wallet-core/src/testing.ts
+++ b/packages/taler-wallet-core/src/testing.ts
@@ -78,7 +78,11 @@ import {
} from "./pay-peer-push-credit.js";
import { initiatePeerPushDebit } from "./pay-peer-push-debit.js";
import { getRefreshesForTransaction } from "./refresh.js";
-import { getTransactionById, getTransactions } from "./transactions.js";
+import {
+ getTransactionById,
+ getTransactions,
+ parseTransactionIdentifier,
+} from "./transactions.js";
import type { WalletExecutionContext } from "./wallet.js";
import { acceptBankIntegratedWithdrawal } from "./withdraw.js";
@@ -583,7 +587,7 @@ async function waitUntilTransactionPendingReady(
export async function waitTransactionState(
wex: WalletExecutionContext,
transactionId: string,
- txState: TransactionState,
+ txState: TransactionState | TransactionState[],
): Promise<void> {
logger.info(
`starting waiting for ${transactionId} to be in ${JSON.stringify(
@@ -595,9 +599,22 @@ export async function waitTransactionState(
const tx = await getTransactionById(wex, {
transactionId,
});
- return (
- tx.txState.major === txState.major && tx.txState.minor === txState.minor
- );
+ if (Array.isArray(txState)) {
+ for (const myState of txState) {
+ if (
+ tx.txState.major === myState.major &&
+ tx.txState.minor === myState.minor
+ ) {
+ return true;
+ }
+ }
+ return false;
+ } else {
+ return (
+ tx.txState.major === txState.major &&
+ tx.txState.minor === txState.minor
+ );
+ }
},
filterNotification(notif) {
return notif.type === NotificationType.TransactionStateTransition;
@@ -871,7 +888,9 @@ export async function testPay(
const purchase = await wex.db.runReadOnlyTx(
{ storeNames: ["purchases"] },
async (tx) => {
- return tx.purchases.get(result.proposalId);
+ const parsedTx = parseTransactionIdentifier(r.transactionId);
+ checkLogicInvariant(parsedTx?.tag === TransactionType.Payment);
+ return tx.purchases.get(parsedTx.proposalId);
},
);
checkLogicInvariant(!!purchase);
diff --git a/packages/taler-wallet-core/src/transactions.ts b/packages/taler-wallet-core/src/transactions.ts
index e314c0e46..b77393f6e 100644
--- a/packages/taler-wallet-core/src/transactions.ts
+++ b/packages/taler-wallet-core/src/transactions.ts
@@ -17,15 +17,22 @@
/**
* Imports.
*/
-import { GlobalIDB, IDBKeyRange } from "@gnu-taler/idb-bridge";
+import {
+ BridgeIDBKeyRange,
+ GlobalIDB,
+ IDBKeyRange,
+} from "@gnu-taler/idb-bridge";
import {
AbsoluteTime,
Amounts,
assertUnreachable,
+ GetTransactionsV2Request,
j2s,
Logger,
+ makeTalerErrorDetail,
NotificationType,
ScopeType,
+ TalerErrorCode,
Transaction,
TransactionByIdRequest,
TransactionIdStr,
@@ -42,8 +49,14 @@ import {
TransactionContext,
} from "./common.js";
import {
+ DbPreciseTimestamp,
+ OPERATION_STATUS_DONE_FIRST,
+ OPERATION_STATUS_DONE_LAST,
OPERATION_STATUS_NONFINAL_FIRST,
OPERATION_STATUS_NONFINAL_LAST,
+ timestampPreciseToDb,
+ TransactionMetaRecord,
+ WalletDbAllStoresReadOnlyTransaction,
WalletDbAllStoresReadWriteTransaction,
} from "./db.js";
import { DepositTransactionContext } from "./deposits.js";
@@ -63,7 +76,10 @@ import { WithdrawTransactionContext } from "./withdraw.js";
const logger = new Logger("taler-wallet-core:transactions.ts");
function shouldSkipCurrency(
- transactionsRequest: TransactionsRequest | undefined,
+ transactionsRequest:
+ | TransactionsRequest
+ | GetTransactionsV2Request
+ | undefined,
currency: string,
exchangesInTransaction: string[],
): boolean {
@@ -91,7 +107,6 @@ function shouldSkipCurrency(
assertUnreachable(transactionsRequest.scopeInfo);
}
}
- // FIXME: remove next release
if (transactionsRequest?.currency) {
return (
transactionsRequest.currency.toLowerCase() !== currency.toLowerCase()
@@ -163,6 +178,249 @@ export function isUnsuccessfulTransaction(state: TransactionState): boolean {
);
}
+function checkFilterIncludes(
+ req: GetTransactionsV2Request | undefined,
+ mtx: TransactionMetaRecord,
+): boolean {
+ if (shouldSkipCurrency(req, mtx.currency, mtx.exchanges)) {
+ return false;
+ }
+
+ const parsedTx = parseTransactionIdentifier(mtx.transactionId);
+ if (parsedTx?.tag === TransactionType.Refresh && !req?.includeRefreshes) {
+ return false;
+ }
+
+ let included: boolean;
+
+ const filter = req?.filterByState;
+ switch (filter) {
+ case "done":
+ included =
+ mtx.status >= OPERATION_STATUS_DONE_FIRST &&
+ mtx.status <= OPERATION_STATUS_DONE_LAST;
+ break;
+ case "final":
+ included = !(
+ mtx.status >= OPERATION_STATUS_NONFINAL_FIRST &&
+ mtx.status <= OPERATION_STATUS_NONFINAL_LAST
+ );
+ break;
+ case "nonfinal":
+ included =
+ mtx.status >= OPERATION_STATUS_NONFINAL_FIRST &&
+ mtx.status <= OPERATION_STATUS_NONFINAL_LAST;
+ break;
+ case undefined:
+ included = true;
+ break;
+ default:
+ assertUnreachable(filter);
+ }
+ return included;
+}
+
+async function addFiltered(
+ wex: WalletExecutionContext,
+ tx: WalletDbAllStoresReadOnlyTransaction,
+ req: GetTransactionsV2Request | undefined,
+ target: Transaction[],
+ source: TransactionMetaRecord[],
+): Promise<number> {
+ let numAdded: number = 0;
+ for (const mtx of source) {
+ if (req?.limit != null && target.length >= Math.abs(req.limit)) {
+ break;
+ }
+ if (checkFilterIncludes(req, mtx)) {
+ const ctx = await getContextForTransaction(wex, mtx.transactionId);
+ const txDetails = await ctx.lookupFullTransaction(tx);
+ // FIXME: This means that in some cases we can return fewer transactions
+ // than requested.
+ if (!txDetails) {
+ continue;
+ }
+ numAdded += 1;
+ target.push(txDetails);
+ }
+ }
+ return numAdded;
+}
+
+/**
+ * Sort transactions in-place according to the request.
+ */
+function sortTransactions(
+ req: GetTransactionsV2Request | undefined,
+ transactions: Transaction[],
+): void {
+ let sortSign: number;
+ if (req?.limit != null && req.limit < 0) {
+ sortSign = -1;
+ } else {
+ sortSign = 1;
+ }
+ const txCmp = (h1: Transaction, h2: Transaction) => {
+ // Order transactions by timestamp. Newest transactions come first.
+ const tsCmp = AbsoluteTime.cmp(
+ AbsoluteTime.fromPreciseTimestamp(h1.timestamp),
+ AbsoluteTime.fromPreciseTimestamp(h2.timestamp),
+ );
+ // If the timestamp is exactly the same, order by transaction type.
+ if (tsCmp === 0) {
+ return Math.sign(txOrder[h1.type] - txOrder[h2.type]);
+ }
+ return sortSign * tsCmp;
+ };
+ transactions.sort(txCmp);
+}
+
+async function findOffsetTransaction(
+ tx: WalletDbAllStoresReadOnlyTransaction,
+ req?: GetTransactionsV2Request,
+): Promise<TransactionMetaRecord | undefined> {
+ let forwards = req?.limit == null || req.limit >= 0;
+ let closestTimestamp: DbPreciseTimestamp | undefined = undefined;
+ if (req?.offsetTransactionId) {
+ const res = await tx.transactionsMeta.get(req.offsetTransactionId);
+ if (res) {
+ return res;
+ }
+ if (req.offsetTimestamp) {
+ closestTimestamp = timestampPreciseToDb(req.offsetTimestamp);
+ } else {
+ throw Error(
+ "offset transaction not found and no offset timestamp specified",
+ );
+ }
+ } else if (req?.offsetTimestamp) {
+ const dbStamp = timestampPreciseToDb(req.offsetTimestamp);
+ const res = await tx.transactionsMeta.indexes.byTimestamp.get(dbStamp);
+ if (res) {
+ return res;
+ }
+ closestTimestamp = timestampPreciseToDb(req.offsetTimestamp);
+ } else {
+ return undefined;
+ }
+
+ // We didn't find a precise offset transaction.
+ // This must mean that it was deleted.
+ // Depending on the direction, find the prev/next
+ // transaction and use it as an offset.
+
+ if (forwards) {
+ // We don't want to skip transactions in pagination,
+ // so get the transaction before the timestamp
+
+ // Slow query, but should not happen often!
+ const recs = await tx.transactionsMeta.indexes.byTimestamp.getAll(
+ BridgeIDBKeyRange.upperBound(closestTimestamp, false),
+ );
+ if (recs.length > 0) {
+ return recs[recs.length - 1];
+ }
+ return undefined;
+ } else {
+ // Likewise, get the transaction after the timestamp
+ const recs = await tx.transactionsMeta.indexes.byTimestamp.getAll(
+ BridgeIDBKeyRange.lowerBound(closestTimestamp, false),
+ 1,
+ );
+ return recs[0];
+ }
+}
+
+export async function getTransactionsV2(
+ wex: WalletExecutionContext,
+ transactionsRequest?: GetTransactionsV2Request,
+): Promise<TransactionsResponse> {
+ // The implementation of this function is optimized for
+ // the common fast path of requesting transactions
+ // in an ascending order.
+ // Other requests are more difficult to implement
+ // in a performant way due to IndexedDB limitations.
+
+ const resultTransactions: Transaction[] = [];
+
+ await wex.db.runAllStoresReadOnlyTx({}, async (tx) => {
+ let forwards =
+ transactionsRequest?.limit == null || transactionsRequest.limit >= 0;
+ let limit =
+ transactionsRequest?.limit != null
+ ? Math.abs(transactionsRequest.limit)
+ : undefined;
+ let offsetMtx = await findOffsetTransaction(tx, transactionsRequest);
+
+ if (limit == null && offsetMtx == null) {
+ // Fast path for returning *everything* that matches the filter.
+ // FIXME: We could use the DB for filtering here
+ const res = await tx.transactionsMeta.indexes.byStatus.getAll();
+ await addFiltered(wex, tx, transactionsRequest, resultTransactions, res);
+ } else if (!forwards) {
+ // Descending, backwards request.
+ // Slow implementation. Doing it properly would require using cursors,
+ // which are also slow in IndexedDB.
+ const res = await tx.transactionsMeta.indexes.byTimestamp.getAll();
+ res.reverse();
+ let start: number;
+ if (offsetMtx != null) {
+ const needleTxId = offsetMtx.transactionId;
+ start = res.findIndex((x) => x.transactionId === needleTxId);
+ if (start < 0) {
+ throw Error("offset transaction not found");
+ }
+ } else {
+ start = 0;
+ }
+ for (let i = start; i < res.length; i++) {
+ if (limit != null && resultTransactions.length >= limit) {
+ break;
+ }
+ await addFiltered(wex, tx, transactionsRequest, resultTransactions, [
+ res[i],
+ ]);
+ }
+ } else {
+ // Forward request
+ let query: BridgeIDBKeyRange | undefined = undefined;
+ if (offsetMtx != null) {
+ query = GlobalIDB.KeyRange.lowerBound(offsetMtx.timestamp, true);
+ }
+ while (true) {
+ const res = await tx.transactionsMeta.indexes.byTimestamp.getAll(
+ query,
+ limit,
+ );
+ if (res.length === 0) {
+ break;
+ }
+ await addFiltered(
+ wex,
+ tx,
+ transactionsRequest,
+ resultTransactions,
+ res,
+ );
+ if (limit != null && resultTransactions.length >= limit) {
+ break;
+ }
+ // Continue after last result
+ query = BridgeIDBKeyRange.lowerBound(
+ res[res.length - 1].timestamp,
+ true,
+ );
+ }
+ }
+ });
+
+ sortTransactions(transactionsRequest, resultTransactions);
+
+ return {
+ transactions: resultTransactions,
+ };
+}
+
/**
* Retrieve the full event history for this wallet.
*/
@@ -598,7 +856,12 @@ export async function failTransaction(
transactionId: string,
): Promise<void> {
const ctx = await getContextForTransaction(wex, transactionId);
- await ctx.failTransaction();
+ await ctx.failTransaction(
+ makeTalerErrorDetail(
+ TalerErrorCode.WALLET_TRANSACTION_ABANDONED_BY_USER,
+ {},
+ ),
+ );
}
/**
@@ -631,7 +894,9 @@ export async function abortTransaction(
transactionId: string,
): Promise<void> {
const ctx = await getContextForTransaction(wex, transactionId);
- await ctx.abortTransaction();
+ await ctx.abortTransaction(
+ makeTalerErrorDetail(TalerErrorCode.WALLET_TRANSACTION_ABORTED_BY_USER, {}),
+ );
}
export interface TransitionInfo {
@@ -662,5 +927,16 @@ export function notifyTransition(
transactionId,
experimentalUserData,
});
+
+ // As a heuristic, we emit balance-change notifications
+ // whenever the major state changes.
+ // This sometimes emits more notifications than we need,
+ // but makes it much more unlikely that we miss any.
+ if (transitionInfo.newTxState.major !== transitionInfo.oldTxState.major) {
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+ }
}
}
diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts
index e3112174b..79d918018 100644
--- a/packages/taler-wallet-core/src/wallet-api-types.ts
+++ b/packages/taler-wallet-core/src/wallet-api-types.ts
@@ -42,6 +42,8 @@ import {
BalancesResponse,
CanonicalizeBaseUrlRequest,
CanonicalizeBaseUrlResponse,
+ CheckDepositRequest,
+ CheckDepositResponse,
CheckPayTemplateReponse,
CheckPayTemplateRequest,
CheckPeerPullCreditRequest,
@@ -64,12 +66,10 @@ import {
EmptyObject,
ExchangeDetailedResponse,
ExchangesListResponse,
- ExchangesShortListResponse,
FailTransactionRequest,
ForceRefreshRequest,
ForgetKnownBankAccountsRequest,
GetActiveTasksResponse,
- GetAmountRequest,
GetBalanceDetailRequest,
GetBankingChoicesForPaytoRequest,
GetBankingChoicesForPaytoResponse,
@@ -84,10 +84,13 @@ import {
GetExchangeResourcesResponse,
GetExchangeTosRequest,
GetExchangeTosResult,
- GetPlanForOperationRequest,
- GetPlanForOperationResponse,
+ GetMaxDepositAmountRequest,
+ GetMaxDepositAmountResponse,
+ GetMaxPeerPushDebitAmountRequest,
+ GetMaxPeerPushDebitAmountResponse,
GetQrCodesForPaytoRequest,
GetQrCodesForPaytoResponse,
+ GetTransactionsV2Request,
GetWithdrawalDetailsForAmountRequest,
GetWithdrawalDetailsForUriRequest,
HintNetworkAvailabilityRequest,
@@ -103,14 +106,12 @@ import {
KnownBankAccounts,
ListAssociatedRefreshesRequest,
ListAssociatedRefreshesResponse,
- ListExchangesForScopedCurrencyRequest,
+ ListExchangesRequest,
ListGlobalCurrencyAuditorsResponse,
ListGlobalCurrencyExchangesResponse,
ListKnownBankAccountsRequest,
PrepareBankIntegratedWithdrawalRequest,
PrepareBankIntegratedWithdrawalResponse,
- PrepareDepositRequest,
- PrepareDepositResponse,
PreparePayRequest,
PreparePayResult,
PreparePayTemplateRequest,
@@ -186,6 +187,7 @@ export enum WalletApiOperation {
TestPay = "testPay",
AddExchange = "addExchange",
GetTransactions = "getTransactions",
+ GetTransactionsV2 = "getTransactionsV2",
GetTransactionById = "getTransactionById",
TestingGetSampleTransactions = "testingGetSampleTransactions",
ListExchanges = "listExchanges",
@@ -198,12 +200,9 @@ export enum WalletApiOperation {
AcceptManualWithdrawal = "acceptManualWithdrawal",
GetBalances = "getBalances",
GetBalanceDetail = "getBalanceDetail",
- GetPlanForOperation = "getPlanForOperation",
ConvertDepositAmount = "convertDepositAmount",
GetMaxDepositAmount = "getMaxDepositAmount",
- ConvertPeerPushAmount = "ConvertPeerPushAmount",
- GetMaxPeerPushAmount = "getMaxPeerPushAmount",
- ConvertWithdrawalAmount = "convertWithdrawalAmount",
+ GetMaxPeerPushDebitAmount = "getMaxPeerPushDebitAmount",
GetUserAttentionRequests = "getUserAttentionRequests",
GetUserAttentionUnreadCount = "getUserAttentionUnreadCount",
MarkAttentionRequestAsRead = "markAttentionRequestAsRead",
@@ -235,7 +234,7 @@ export enum WalletApiOperation {
ExportBackupRecovery = "exportBackupRecovery",
ImportBackupRecovery = "importBackupRecovery",
GetBackupInfo = "getBackupInfo",
- PrepareDeposit = "prepareDeposit",
+ CheckDeposit = "checkDeposit",
GetVersion = "getVersion",
GenerateDepositGroupTxId = "generateDepositGroupTxId",
CreateDepositGroup = "createDepositGroup",
@@ -260,7 +259,6 @@ export enum WalletApiOperation {
DeleteStoredBackup = "deleteStoredBackup",
RecoverStoredBackup = "recoverStoredBackup",
UpdateExchangeEntry = "updateExchangeEntry",
- ListExchangesForScopedCurrency = "listExchangesForScopedCurrency",
PrepareWithdrawExchange = "prepareWithdrawExchange",
GetExchangeResources = "getExchangeResources",
DeleteExchange = "deleteExchange",
@@ -289,6 +287,12 @@ export enum WalletApiOperation {
TestingResetAllRetries = "testingResetAllRetries",
StartExchangeWalletKyc = "startExchangeWalletKyc",
TestingWaitExchangeWalletKyc = "testingWaitWalletKyc",
+ HintApplicationResumed = "hintApplicationResumed",
+
+ /**
+ * @deprecated use checkDeposit instead
+ */
+ PrepareDeposit = "prepareDeposit",
}
// group: Initialization
@@ -311,6 +315,17 @@ export type ShutdownOp = {
};
/**
+ * Give wallet-core a kick and restart all pending tasks.
+ * Useful when the host application gets suspended and resumed,
+ * and active network requests might have stalled.
+ */
+export type HintApplicationResumedOp = {
+ op: WalletApiOperation.HintApplicationResumed;
+ request: EmptyObject;
+ response: EmptyObject;
+};
+
+/**
* Change the configuration of wallet-core.
*
* Currently an alias for the initWallet request.
@@ -349,36 +364,22 @@ export type GetBalancesDetailOp = {
response: PaymentBalanceDetails;
};
-export type GetPlanForOperationOp = {
- op: WalletApiOperation.GetPlanForOperation;
- request: GetPlanForOperationRequest;
- response: GetPlanForOperationResponse;
-};
-
export type ConvertDepositAmountOp = {
op: WalletApiOperation.ConvertDepositAmount;
request: ConvertAmountRequest;
response: AmountResponse;
};
+
export type GetMaxDepositAmountOp = {
op: WalletApiOperation.GetMaxDepositAmount;
- request: GetAmountRequest;
- response: AmountResponse;
+ request: GetMaxDepositAmountRequest;
+ response: GetMaxDepositAmountResponse;
};
-export type ConvertPeerPushAmountOp = {
- op: WalletApiOperation.ConvertPeerPushAmount;
- request: ConvertAmountRequest;
- response: AmountResponse;
-};
-export type GetMaxPeerPushAmountOp = {
- op: WalletApiOperation.GetMaxPeerPushAmount;
- request: GetAmountRequest;
- response: AmountResponse;
-};
-export type ConvertWithdrawalAmountOp = {
- op: WalletApiOperation.ConvertWithdrawalAmount;
- request: ConvertAmountRequest;
- response: AmountResponse;
+
+export type GetMaxPeerPushDebitAmountOp = {
+ op: WalletApiOperation.GetMaxPeerPushDebitAmount;
+ request: GetMaxPeerPushDebitAmountRequest;
+ response: GetMaxPeerPushDebitAmountResponse;
};
// group: Managing Transactions
@@ -392,6 +393,12 @@ export type GetTransactionsOp = {
response: TransactionsResponse;
};
+export type GetTransactionsV2Op = {
+ op: WalletApiOperation.GetTransactionsV2;
+ request: GetTransactionsV2Request;
+ response: TransactionsResponse;
+};
+
/**
* List refresh transactions associated with another transaction.
*/
@@ -646,7 +653,7 @@ export type RemoveGlobalCurrencyAuditorOp = {
*/
export type ListExchangesOp = {
op: WalletApiOperation.ListExchanges;
- request: EmptyObject;
+ request: ListExchangesRequest;
response: ExchangesListResponse;
};
@@ -663,16 +670,6 @@ export type TestingWaitExchangeWalletKycOp = {
};
/**
- * List exchanges that are available for withdrawing a particular
- * scoped currency.
- */
-export type ListExchangesForScopedCurrencyOp = {
- op: WalletApiOperation.ListExchangesForScopedCurrency;
- request: ListExchangesForScopedCurrencyRequest;
- response: ExchangesShortListResponse;
-};
-
-/**
* Prepare for withdrawing via a taler://withdraw-exchange URI.
*/
export type PrepareWithdrawExchangeOp = {
@@ -823,11 +820,19 @@ export type CreateDepositGroupOp = {
response: CreateDepositGroupResponse;
};
-// FIXME: Rename to checkDeposit, as it does not create a transaction, just computes fees!
+export type CheckDepositOp = {
+ op: WalletApiOperation.CheckDeposit;
+ request: CheckDepositRequest;
+ response: CheckDepositResponse;
+};
+
+/**
+ * @deprecated use CheckDepositOp instead
+ */
export type PrepareDepositOp = {
op: WalletApiOperation.PrepareDeposit;
- request: PrepareDepositRequest;
- response: PrepareDepositResponse;
+ request: CheckDepositRequest;
+ response: CheckDepositResponse;
};
// group: Backups
@@ -1297,12 +1302,10 @@ export type WalletOperations = {
[WalletApiOperation.GetBalances]: GetBalancesOp;
[WalletApiOperation.ConvertDepositAmount]: ConvertDepositAmountOp;
[WalletApiOperation.GetMaxDepositAmount]: GetMaxDepositAmountOp;
- [WalletApiOperation.ConvertPeerPushAmount]: ConvertPeerPushAmountOp;
- [WalletApiOperation.GetMaxPeerPushAmount]: GetMaxPeerPushAmountOp;
- [WalletApiOperation.ConvertWithdrawalAmount]: ConvertWithdrawalAmountOp;
- [WalletApiOperation.GetPlanForOperation]: GetPlanForOperationOp;
+ [WalletApiOperation.GetMaxPeerPushDebitAmount]: GetMaxPeerPushDebitAmountOp;
[WalletApiOperation.GetBalanceDetail]: GetBalancesDetailOp;
[WalletApiOperation.GetTransactions]: GetTransactionsOp;
+ [WalletApiOperation.GetTransactionsV2]: GetTransactionsV2Op;
[WalletApiOperation.TestingGetSampleTransactions]: TestingGetSampleTransactionsOp;
[WalletApiOperation.GetTransactionById]: GetTransactionByIdOp;
[WalletApiOperation.RetryPendingNow]: RetryPendingNowOp;
@@ -1322,7 +1325,6 @@ export type WalletOperations = {
[WalletApiOperation.AcceptBankIntegratedWithdrawal]: AcceptBankIntegratedWithdrawalOp;
[WalletApiOperation.AcceptManualWithdrawal]: AcceptManualWithdrawalOp;
[WalletApiOperation.ListExchanges]: ListExchangesOp;
- [WalletApiOperation.ListExchangesForScopedCurrency]: ListExchangesForScopedCurrencyOp;
[WalletApiOperation.AddExchange]: AddExchangeOp;
[WalletApiOperation.ListKnownBankAccounts]: ListKnownBankAccountsOp;
[WalletApiOperation.AddKnownBankAccounts]: AddKnownBankAccountsOp;
@@ -1333,6 +1335,7 @@ export type WalletOperations = {
[WalletApiOperation.GetExchangeDetailedInfo]: GetExchangeDetailedInfoOp;
[WalletApiOperation.GetExchangeEntryByUrl]: GetExchangeEntryByUrlOp;
[WalletApiOperation.PrepareDeposit]: PrepareDepositOp;
+ [WalletApiOperation.CheckDeposit]: CheckDepositOp;
[WalletApiOperation.GenerateDepositGroupTxId]: GenerateDepositGroupTxIdOp;
[WalletApiOperation.CreateDepositGroup]: CreateDepositGroupOp;
[WalletApiOperation.SetWalletDeviceId]: SetWalletDeviceIdOp;
@@ -1398,6 +1401,7 @@ export type WalletOperations = {
[WalletApiOperation.GetBankingChoicesForPayto]: GetBankingChoicesForPaytoOp;
[WalletApiOperation.StartExchangeWalletKyc]: StartExchangeWalletKycOp;
[WalletApiOperation.TestingWaitExchangeWalletKyc]: TestingWaitExchangeWalletKycOp;
+ [WalletApiOperation.HintApplicationResumed]: HintApplicationResumedOp;
};
export type WalletCoreRequestType<
diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts
index a377d0795..cdc066d85 100644
--- a/packages/taler-wallet-core/src/wallet.ts
+++ b/packages/taler-wallet-core/src/wallet.ts
@@ -53,7 +53,6 @@ import {
DenominationInfo,
Duration,
EmptyObject,
- ExchangesShortListResponse,
FailTransactionRequest,
ForgetKnownBankAccountsRequest,
GetActiveTasksResponse,
@@ -75,7 +74,6 @@ import {
IntegrationTestV2Args,
KnownBankAccounts,
KnownBankAccountsInfo,
- ListExchangesForScopedCurrencyRequest,
ListGlobalCurrencyAuditorsResponse,
ListGlobalCurrencyExchangesResponse,
ListKnownBankAccountsRequest,
@@ -117,6 +115,7 @@ import {
WalletCoreVersion,
WalletNotification,
WalletRunConfig,
+ WireTypeDetails,
WithdrawTestBalanceRequest,
canonicalizeBaseUrl,
checkDbInvariant,
@@ -132,6 +131,7 @@ import {
codecForAny,
codecForApplyDevExperiment,
codecForCanonicalizeBaseUrlRequest,
+ codecForCheckDepositRequest,
codecForCheckPayTemplateRequest,
codecForCheckPeerPullPaymentRequest,
codecForCheckPeerPushDebitRequest,
@@ -147,7 +147,6 @@ import {
codecForFailTransactionRequest,
codecForForceRefreshRequest,
codecForForgetKnownBankAccounts,
- codecForGetAmountRequest,
codecForGetBalanceDetailRequest,
codecForGetBankingChoicesForPaytoRequest,
codecForGetContractTermsDetails,
@@ -156,7 +155,10 @@ import {
codecForGetExchangeEntryByUrlRequest,
codecForGetExchangeResourcesRequest,
codecForGetExchangeTosRequest,
+ codecForGetMaxDepositAmountRequest,
+ codecForGetMaxPeerPushDebitAmountRequest,
codecForGetQrCodesForPaytoRequest,
+ codecForGetTransactionsV2Request,
codecForGetWithdrawalDetailsForAmountRequest,
codecForGetWithdrawalDetailsForUri,
codecForHintNetworkAvailabilityRequest,
@@ -166,10 +168,9 @@ import {
codecForInitiatePeerPushDebitRequest,
codecForIntegrationTestArgs,
codecForIntegrationTestV2Args,
- codecForListExchangesForScopedCurrencyRequest,
+ codecForListExchangesRequest,
codecForListKnownBankAccounts,
codecForPrepareBankIntegratedWithdrawalRequest,
- codecForPrepareDepositRequest,
codecForPreparePayRequest,
codecForPreparePayTemplateRequest,
codecForPreparePeerPullPaymentRequest,
@@ -232,6 +233,10 @@ import {
setWalletDeviceId,
} from "./backup/index.js";
import { getBalanceDetail, getBalances } from "./balance.js";
+import {
+ getMaxDepositAmount,
+ getMaxPeerPushDebitAmount,
+} from "./coinSelection.js";
import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js";
import {
CryptoDispatcher,
@@ -274,14 +279,9 @@ import {
handleTestingWaitExchangeWalletKyc,
listExchanges,
lookupExchangeByUri,
+ markExchangeUsed,
} from "./exchanges.js";
-import {
- convertDepositAmount,
- convertPeerPushAmount,
- convertWithdrawalAmount,
- getMaxDepositAmount,
- getMaxPeerPushAmount,
-} from "./instructedAmountConversion.js";
+import { convertDepositAmount } from "./instructedAmountConversion.js";
import {
ObservableDbAccess,
ObservableTaskScheduler,
@@ -338,11 +338,11 @@ import {
} from "./testing.js";
import {
abortTransaction,
- constructTransactionIdentifier,
deleteTransaction,
failTransaction,
getTransactionById,
getTransactions,
+ getTransactionsV2,
parseTransactionIdentifier,
rematerializeTransactions,
restartAll as restartAllRunningTasks,
@@ -910,6 +910,11 @@ async function handleAddExchange(
req: AddExchangeRequest,
): Promise<EmptyObject> {
await fetchFreshExchange(wex, req.exchangeBaseUrl, {});
+ // Exchange has been explicitly added upon user request.
+ // Thus, we mark it as "used".
+ await wex.db.runAllStoresReadWriteTx({}, async (tx) => {
+ await markExchangeUsed(wex, tx, req.exchangeBaseUrl);
+ });
return {};
}
@@ -949,26 +954,6 @@ async function handleTestingGetDenomStats(
return denomStats;
}
-async function handleListExchangesForScopedCurrency(
- wex: WalletExecutionContext,
- req: ListExchangesForScopedCurrencyRequest,
-): Promise<ExchangesShortListResponse> {
- const exchangesResp = await listExchanges(wex);
- const result: ExchangesShortListResponse = {
- exchanges: [],
- };
- // Right now we only filter on the currency, as wallet-core doesn't
- // fully support scoped currencies yet.
- for (const exch of exchangesResp.exchanges) {
- if (exch.currency === req.scope.currency) {
- result.exchanges.push({
- exchangeBaseUrl: exch.exchangeBaseUrl,
- });
- }
- }
- return result;
-}
-
async function handleAddKnownBankAccount(
wex: WalletExecutionContext,
req: AddKnownBankAccountsRequest,
@@ -1041,18 +1026,11 @@ async function handleGetContractTermsDetails(
wex: WalletExecutionContext,
req: GetContractTermsDetailsRequest,
): Promise<WalletContractData> {
- if (req.proposalId) {
- // FIXME: deprecated path
- return getContractTermsDetails(wex, req.proposalId);
- }
- if (req.transactionId) {
- const parsedTx = parseTransactionIdentifier(req.transactionId);
- if (parsedTx?.tag === TransactionType.Payment) {
- return getContractTermsDetails(wex, parsedTx.proposalId);
- }
+ const parsedTx = parseTransactionIdentifier(req.transactionId);
+ if (parsedTx?.tag !== TransactionType.Payment) {
throw Error("transactionId is not a payment transaction");
}
- throw Error("transactionId missing");
+ return getContractTermsDetails(wex, parsedTx.proposalId);
}
async function handleGetQrCodesForPayto(
@@ -1112,19 +1090,7 @@ async function handleConfirmPay(
wex: WalletExecutionContext,
req: ConfirmPayRequest,
): Promise<ConfirmPayResult> {
- let transactionId;
- if (req.proposalId) {
- // legacy client support
- transactionId = constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId: req.proposalId,
- });
- } else if (req.transactionId) {
- transactionId = req.transactionId;
- } else {
- throw Error("transactionId or (deprecated) proposalId required");
- }
- return await confirmPay(wex, transactionId, req.sessionId);
+ return await confirmPay(wex, req.transactionId, req.sessionId);
}
async function handleAbortTransaction(
@@ -1240,6 +1206,8 @@ async function handleGetDepositWireTypesForCurrency(
req: GetDepositWireTypesForCurrencyRequest,
): Promise<GetDepositWireTypesForCurrencyResponse> {
const wtSet: Set<string> = new Set();
+ const wireTypeDetails: WireTypeDetails[] = [];
+ const talerBankHostnames: string[] = [];
await wex.db.runReadOnlyTx(
{ storeNames: ["exchanges", "exchangeDetails"] },
async (tx) => {
@@ -1261,19 +1229,36 @@ async function handleGetDepositWireTypesForCurrency(
}
}
if (!usable) {
- break;
+ continue;
}
const parsedPayto = parsePaytoUri(acc.payto_uri);
if (!parsedPayto) {
continue;
}
- wtSet.add(parsedPayto.targetType);
+ if (
+ parsedPayto.isKnown &&
+ parsedPayto.targetType === "x-taler-bank"
+ ) {
+ if (!talerBankHostnames.includes(parsedPayto.host)) {
+ talerBankHostnames.push(parsedPayto.host);
+ }
+ }
+ if (!wtSet.has(parsedPayto.targetType)) {
+ wtSet.add(parsedPayto.targetType);
+ wireTypeDetails.push({
+ paymentTargetType: parsedPayto.targetType,
+ // Will possibly extended later by other exchanges
+ // with the same wire type.
+ talerBankHostnames,
+ });
+ }
}
}
},
);
return {
wireTypes: [...wtSet],
+ wireTypeDetails,
};
}
@@ -1530,6 +1515,15 @@ async function handleGetCurrencySpecification(
return defaultResp;
}
+export async function handleHintApplicationResumed(
+ wex: WalletExecutionContext,
+ req: EmptyObject,
+): Promise<EmptyObject> {
+ logger.info("handling hintApplicationResumed");
+ await restartAllRunningTasks(wex);
+ return {};
+}
+
async function handleGetVersion(
wex: WalletExecutionContext,
): Promise<WalletCoreVersion> {
@@ -1562,6 +1556,10 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = {
codec: codecForAny(),
handler: handleTestingWaitExchangeState,
},
+ [WalletApiOperation.HintApplicationResumed]: {
+ codec: codecForEmptyObject(),
+ handler: handleHintApplicationResumed,
+ },
[WalletApiOperation.AbortTransaction]: {
codec: codecForAbortTransaction(),
handler: handleAbortTransaction,
@@ -1619,6 +1617,10 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = {
codec: codecForTransactionsRequest(),
handler: getTransactions,
},
+ [WalletApiOperation.GetTransactionsV2]: {
+ codec: codecForGetTransactionsV2Request(),
+ handler: getTransactionsV2,
+ },
[WalletApiOperation.GetTransactionById]: {
codec: codecForTransactionByIdRequest(),
handler: getTransactionById,
@@ -1640,17 +1642,13 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = {
handler: handleTestingGetDenomStats,
},
[WalletApiOperation.ListExchanges]: {
- codec: codecForEmptyObject(),
+ codec: codecForListExchangesRequest(),
handler: listExchanges,
},
[WalletApiOperation.GetExchangeEntryByUrl]: {
codec: codecForGetExchangeEntryByUrlRequest(),
handler: lookupExchangeByUri,
},
- [WalletApiOperation.ListExchangesForScopedCurrency]: {
- codec: codecForListExchangesForScopedCurrencyRequest(),
- handler: handleListExchangesForScopedCurrency,
- },
[WalletApiOperation.GetExchangeDetailedInfo]: {
codec: codecForAddExchangeRequest(),
handler: (wex, req) => getExchangeDetailedInfo(wex, req.exchangeBaseUrl),
@@ -1864,27 +1862,23 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = {
handler: convertDepositAmount,
},
[WalletApiOperation.GetMaxDepositAmount]: {
- codec: codecForGetAmountRequest,
+ codec: codecForGetMaxDepositAmountRequest,
handler: getMaxDepositAmount,
},
- [WalletApiOperation.ConvertPeerPushAmount]: {
- codec: codecForConvertAmountRequest,
- handler: convertPeerPushAmount,
- },
- [WalletApiOperation.GetMaxPeerPushAmount]: {
- codec: codecForGetAmountRequest,
- handler: getMaxPeerPushAmount,
- },
- [WalletApiOperation.ConvertWithdrawalAmount]: {
- codec: codecForConvertAmountRequest,
- handler: convertWithdrawalAmount,
+ [WalletApiOperation.GetMaxPeerPushDebitAmount]: {
+ codec: codecForGetMaxPeerPushDebitAmountRequest(),
+ handler: getMaxPeerPushDebitAmount,
},
[WalletApiOperation.GetBackupInfo]: {
codec: codecForEmptyObject(),
handler: getBackupInfo,
},
[WalletApiOperation.PrepareDeposit]: {
- codec: codecForPrepareDepositRequest(),
+ codec: codecForCheckDepositRequest(),
+ handler: checkDepositGroup,
+ },
+ [WalletApiOperation.CheckDeposit]: {
+ codec: codecForCheckDepositRequest(),
handler: checkDepositGroup,
},
[WalletApiOperation.GenerateDepositGroupTxId]: {
@@ -2089,12 +2083,6 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = {
return {};
},
},
- [WalletApiOperation.GetPlanForOperation]: {
- codec: codecForAny(),
- handler: async (wex, req) => {
- throw Error("not implemented");
- },
- },
[WalletApiOperation.ExportBackup]: {
codec: codecForAny(),
handler: async (wex, req) => {
diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts
index a7d0d1ce1..080e915d0 100644
--- a/packages/taler-wallet-core/src/withdraw.ts
+++ b/packages/taler-wallet-core/src/withdraw.ts
@@ -33,7 +33,6 @@ import {
AmountLike,
AmountString,
Amounts,
- AsyncFlag,
BankWithdrawDetails,
CancellationToken,
CoinStatus,
@@ -85,6 +84,7 @@ import {
WithdrawalType,
addPaytoQueryParams,
assertUnreachable,
+ checkAccountRestriction,
checkDbInvariant,
checkLogicInvariant,
codecForAccountKycStatus,
@@ -93,14 +93,15 @@ import {
codecForCashinConversionResponse,
codecForConversionBankConfig,
codecForExchangeWithdrawBatchResponse,
+ codecForLegitimizationNeededResponse,
codecForReserveStatus,
- codecForWalletKycUuid,
codecForWithdrawOperationStatusResponse,
encodeCrock,
getErrorDetailFromException,
getRandomBytes,
j2s,
makeErrorDetail,
+ parsePaytoUri,
parseTalerUri,
parseWithdrawUri,
} from "@gnu-taler/taler-util";
@@ -129,13 +130,12 @@ import {
requireExchangeTosAcceptedOrThrow,
runWithClientCancellation,
} from "./common.js";
-import { EddsaKeypair } from "./crypto/cryptoImplementation.js";
+import { EddsaKeyPairStrings } from "./crypto/cryptoImplementation.js";
import {
CoinRecord,
CoinSourceType,
DenominationRecord,
DenominationVerificationStatus,
- KycPendingInfo,
OperationRetryRecord,
PlanchetRecord,
PlanchetStatus,
@@ -166,12 +166,17 @@ import {
fetchFreshExchange,
getExchangePaytoUri,
getExchangeWireDetailsInTx,
+ getPreferredExchangeForScope,
getScopeForAllExchanges,
handleStartExchangeWalletKyc,
listExchanges,
lookupExchangeByUri,
markExchangeUsed,
} from "./exchanges.js";
+import {
+ checkWithdrawalHardLimitExceeded,
+ getWithdrawalLimitInfo,
+} from "./kyc.js";
import { DbAccess } from "./query.js";
import {
TransitionInfo,
@@ -241,9 +246,11 @@ function buildTransactionForBankIntegratedWithdraw(
wg.status === WithdrawalGroupStatus.Done ||
wg.status === WithdrawalGroupStatus.PendingReady,
},
- kycUrl: wg.kycUrl,
+ kycUrl: kycDetails?.kycUrl,
kycAccessToken: wg.kycAccessToken,
- kycPaytoHash: wg.kycPending?.paytoHash,
+ kycPaytoHash: wg.kycPaytoHash,
+ abortReason: wg.abortReason,
+ failReason: wg.failReason,
timestamp: timestampPreciseFromDb(wg.timestampStart),
transactionId: constructTransactionIdentifier({
tag: TransactionType.Withdrawal,
@@ -302,13 +309,14 @@ function buildTransactionForManualWithdraw(
wg.status === WithdrawalGroupStatus.PendingReady,
reserveClosingDelay: exchangeDetails?.reserveClosingDelay ?? { d_us: 0 },
},
- kycUrl: wg.kycUrl,
+ kycUrl: kycDetails?.kycUrl,
exchangeBaseUrl: wg.exchangeBaseUrl,
timestamp: timestampPreciseFromDb(wg.timestampStart),
transactionId: constructTransactionIdentifier({
tag: TransactionType.Withdrawal,
withdrawalGroupId: wg.withdrawalGroupId,
}),
+ abortReason: wg.abortReason,
...(ort?.lastError ? { error: ort.lastError } : {}),
};
if (ort?.lastError) {
@@ -365,17 +373,21 @@ export class WithdrawTransactionContext implements TransactionContext {
let kycDetails: TxKycDetails | undefined = undefined;
- switch (withdrawalGroupRecord.status) {
- case WithdrawalGroupStatus.PendingKyc:
- case WithdrawalGroupStatus.SuspendedKyc: {
- kycDetails = {
- kycAccessToken: withdrawalGroupRecord.kycAccessToken,
- kycPaytoHash: withdrawalGroupRecord.kycPending?.paytoHash,
- kycUrl: withdrawalGroupRecord.kycUrl,
- };
- break;
+ if (exchangeBaseUrl) {
+ switch (withdrawalGroupRecord.status) {
+ case WithdrawalGroupStatus.PendingKyc:
+ case WithdrawalGroupStatus.SuspendedKyc: {
+ kycDetails = {
+ kycAccessToken: withdrawalGroupRecord.kycAccessToken,
+ kycPaytoHash: withdrawalGroupRecord.kycPaytoHash,
+ kycUrl: new URL(
+ `kyc-spa/${withdrawalGroupRecord.kycAccessToken}`,
+ exchangeBaseUrl,
+ ).href,
+ };
+ break;
+ }
}
- // For the balance KYC, the client should get the kycUrl etc. from the exchange entry!
}
const ort = await tx.operationRetries.get(this.taskId);
@@ -605,7 +617,7 @@ export class WithdrawTransactionContext implements TransactionContext {
);
}
- async abortTransaction(): Promise<void> {
+ async abortTransaction(reason?: TalerErrorDetail): Promise<void> {
const { withdrawalGroupId } = this;
await this.transition(
{
@@ -657,6 +669,7 @@ export class WithdrawTransactionContext implements TransactionContext {
default:
assertUnreachable(wg.status);
}
+ wg.abortReason = reason;
wg.status = newStatus;
return TransitionResult.transition(wg);
},
@@ -706,7 +719,7 @@ export class WithdrawTransactionContext implements TransactionContext {
);
}
- async failTransaction(): Promise<void> {
+ async failTransaction(reason?: TalerErrorDetail): Promise<void> {
const { withdrawalGroupId } = this;
await this.transition(
{
@@ -727,6 +740,7 @@ export class WithdrawTransactionContext implements TransactionContext {
return TransitionResult.stay();
}
wg.status = newStatus;
+ wg.failReason = reason;
return TransitionResult.transition(wg);
},
);
@@ -916,8 +930,8 @@ export function computeWithdrawalTransactionActions(
return [TransactionAction.Resume, TransactionAction.Abort];
case WithdrawalGroupStatus.PendingKyc:
return [
+ TransactionAction.Suspend,
TransactionAction.Retry,
- TransactionAction.Resume,
TransactionAction.Abort,
];
case WithdrawalGroupStatus.SuspendedKyc:
@@ -1027,13 +1041,8 @@ async function processWithdrawalGroupBalanceKyc(
return TransitionResult.stay();
}
wg.status = WithdrawalGroupStatus.PendingBalanceKyc;
- wg.kycPending = undefined;
wg.kycAccessToken = ret.walletKycAccessToken;
- // FIXME: #9109 this should not be constructed here, it should be an opaque URL from exchange response
- wg.kycUrl = new URL(
- `kyc-spa/${ret.walletKycAccessToken}`,
- exchangeBaseUrl,
- ).href;
+ delete wg.kycPaytoHash;
return TransitionResult.transition(wg);
});
return TaskRunResult.progress();
@@ -1149,7 +1158,13 @@ export async function getBankWithdrawalInfo(
);
if (resp.type === "fail") {
- throw TalerError.fromUncheckedDetail(resp.detail);
+ if (resp.detail) {
+ throw TalerError.fromUncheckedDetail(resp.detail);
+ } else {
+ throw TalerError.fromException(
+ new Error("failed to get bank remote config"),
+ );
+ }
}
const { body: status } = resp;
@@ -1343,13 +1358,6 @@ interface WithdrawalBatchResult {
batchResp: ExchangeWithdrawBatchResponse;
}
-// FIXME: Move to exchange API types
-enum ExchangeAmlStatus {
- Normal = 0,
- Pending = 1,
- Frozen = 2,
-}
-
/**
* Transition a transaction from pending(ready)
* into a pending(kyc|aml) state, in case KYC is required.
@@ -1363,20 +1371,17 @@ async function handleKycRequired(
): Promise<void> {
logger.info("withdrawal requires KYC");
const respJson = await resp.json();
- const uuidResp = codecForWalletKycUuid().decode(respJson);
+ const legiRequiredResp =
+ codecForLegitimizationNeededResponse().decode(respJson);
const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
- logger.info(`kyc uuid response: ${j2s(uuidResp)}`);
+ logger.info(`kyc uuid response: ${j2s(legiRequiredResp)}`);
const exchangeUrl = withdrawalGroup.exchangeBaseUrl;
- const kycInfo: KycPendingInfo = {
- paytoHash: uuidResp.h_payto,
- requirementRow: uuidResp.requirement_row,
- };
const sigResp = await wex.cryptoApi.signWalletKycAuth({
accountPriv: withdrawalGroup.reservePriv,
accountPub: withdrawalGroup.reservePub,
});
- const url = new URL(`kyc-check/${kycInfo.requirementRow}`, exchangeUrl);
+ const url = new URL(`kyc-check/${legiRequiredResp.h_payto}`, exchangeUrl);
logger.info(`kyc url ${url.href}`);
// We do not longpoll here, as this is the initial request to get information about the KYC.
const kycStatusRes = await wex.http.fetch(url.href, {
@@ -1430,16 +1435,7 @@ async function handleKycRequired(
if (wg2.status !== WithdrawalGroupStatus.PendingReady) {
return TransitionResult.stay();
}
- // FIXME: Why not just store the whole kycState?!
- wg2.kycPending = {
- paytoHash: uuidResp.h_payto,
- requirementRow: uuidResp.requirement_row,
- };
- // FIXME: #9109 this should not be constructed here, it should be an opaque URL from exchange response
- wg2.kycUrl = new URL(
- `kyc-spa/${kycStatus.access_token}`,
- exchangeUrl,
- ).href;
+ wg2.kycPaytoHash = legiRequiredResp.h_payto;
wg2.kycAccessToken = kycStatus.access_token;
wg2.status = WithdrawalGroupStatus.PendingKyc;
return TransitionResult.transition(wg2);
@@ -1917,8 +1913,8 @@ async function processQueryReserve(
) {
amountChanged = true;
}
- console.log(`amount change ${j2s(result.response)}`);
- console.log(
+ logger.trace(`amount change ${j2s(result.response)}`);
+ logger.trace(
`amount change ${j2s(withdrawalGroup.denomsSel.totalWithdrawCost)}`,
);
@@ -2015,12 +2011,12 @@ async function processWithdrawalGroupPendingKyc(
wex,
withdrawalGroup.withdrawalGroupId,
);
- const kycInfo = withdrawalGroup.kycPending;
- if (!kycInfo) {
+ const kycPaytoHash = withdrawalGroup.kycPaytoHash;
+ if (!kycPaytoHash) {
throw Error("no kyc info available in pending(kyc)");
}
const exchangeUrl = withdrawalGroup.exchangeBaseUrl;
- const url = new URL(`kyc-check/${kycInfo.requirementRow}`, exchangeUrl);
+ const url = new URL(`kyc-check/${kycPaytoHash}`, exchangeUrl);
const sigResp = await wex.cryptoApi.signWalletKycAuth({
accountPriv: withdrawalGroup.reservePriv,
accountPub: withdrawalGroup.reservePub,
@@ -2052,8 +2048,8 @@ async function processWithdrawalGroupPendingKyc(
}
switch (rec.status) {
case WithdrawalGroupStatus.PendingKyc: {
- delete rec.kycPending;
- delete rec.kycUrl;
+ delete rec.kycAccessToken;
+ delete rec.kycAccessToken;
rec.status = WithdrawalGroupStatus.PendingReady;
return TransitionResult.transition(rec);
}
@@ -2205,14 +2201,6 @@ async function redenominateWithdrawal(
.toString(),
hasDenomWithAgeRestriction:
prevHasDenomWithAgeRestriction || newSel.hasDenomWithAgeRestriction,
- earliestDepositExpiration: AbsoluteTime.toProtocolTimestamp(
- AbsoluteTime.min(
- prevEarliestDepositExpiration,
- AbsoluteTime.fromProtocolTimestamp(
- newSel.earliestDepositExpiration,
- ),
- ),
- ),
};
wg.denomsSel = mergedSel;
if (logger.shouldLogTrace()) {
@@ -2411,11 +2399,6 @@ async function processWithdrawalGroupPendingReady(
throw Error("withdrawal group does not exist anymore");
}
- wex.ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: ctx.transactionId,
- });
-
if (numPlanchetErrors > 0) {
return {
type: TaskRunResultType.Error,
@@ -2462,7 +2445,7 @@ export async function processWithdrawalGroup(
case WithdrawalGroupStatus.PendingWaitConfirmBank:
return await processReserveBankStatus(wex, withdrawalGroupId);
case WithdrawalGroupStatus.PendingKyc:
- return processWithdrawalGroupPendingKyc(wex, withdrawalGroup);
+ return await processWithdrawalGroupPendingKyc(wex, withdrawalGroup);
case WithdrawalGroupStatus.PendingReady:
// Continue with the actual withdrawal!
return await processWithdrawalGroupPendingReady(wex, withdrawalGroup);
@@ -2588,15 +2571,13 @@ export async function getExchangeWithdrawalInfo(
throw Error("exchange is in invalid state");
}
+ logger.info(`exchange ready summary: ${j2s(exchange)}`);
+
const ret: ExchangeWithdrawalDetails = {
- earliestDepositExpiration: selectedDenoms.earliestDepositExpiration,
exchangePaytoUris: paytoUris,
exchangeWireAccounts,
exchangeCreditAccountDetails: withdrawalAccountsList,
- exchangeVersion: exchange.protocolVersionRange || "unknown",
selectedDenoms,
- versionMatch,
- walletVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
termsOfServiceAccepted: tosAccepted,
withdrawalAmountEffective: Amounts.stringify(selectedDenoms.totalCoinValue),
withdrawalAmountRaw: Amounts.stringify(instructedAmount),
@@ -2606,15 +2587,11 @@ export async function getExchangeWithdrawalInfo(
? AGE_MASK_GROUPS
: undefined,
scopeInfo: exchange.scopeInfo,
+ ...getWithdrawalLimitInfo(exchange, instructedAmount),
};
return ret;
}
-export interface GetWithdrawalDetailsForUriOpts {
- restrictAge?: number;
- notifyChangeFromPendingTimeoutMs?: number;
-}
-
async function getWithdrawalDetailsForBankInfo(
wex: WalletExecutionContext,
info: BankWithdrawDetails,
@@ -2642,7 +2619,13 @@ async function getWithdrawalDetailsForBankInfo(
});
possibleExchanges = [ex];
} else {
- const listExchangesResp = await listExchanges(wex);
+ const listExchangesResp = await listExchanges(wex, {});
+
+ for (const exchange of listExchangesResp.exchanges) {
+ if (exchange.currency !== currency) {
+ continue;
+ }
+ }
possibleExchanges = listExchangesResp.exchanges.filter((x) => {
return (
@@ -2698,6 +2681,19 @@ export function augmentPaytoUrisForWithdrawal(
);
}
+export function augmentPaytoUrisForKycTransfer(
+ plainPaytoUris: string[],
+ reservePub: string,
+ tinyAmount: AmountLike,
+): string[] {
+ return plainPaytoUris.map((x) =>
+ addPaytoQueryParams(x, {
+ amount: Amounts.stringify(tinyAmount),
+ message: `Taler KYC ${reservePub}`,
+ }),
+ );
+}
+
/**
* Get payto URIs that can be used to fund a withdrawal operation.
*/
@@ -3072,7 +3068,7 @@ export async function internalPrepareCreateWithdrawalGroup(
exchangeBaseUrl: string | undefined;
forcedWithdrawalGroupId?: string;
forcedDenomSel?: ForcedDenomSel;
- reserveKeyPair?: EddsaKeypair;
+ reserveKeyPair?: EddsaKeyPairStrings;
restrictAge?: number;
wgInfo: WgInfo;
},
@@ -3136,7 +3132,6 @@ export async function internalPrepareCreateWithdrawalGroup(
status: args.reserveStatus,
withdrawalGroupId,
restrictAge: args.restrictAge,
- senderWire: undefined,
timestampFinish: undefined,
wgInfo: args.wgInfo,
};
@@ -3268,7 +3263,7 @@ export async function internalCreateWithdrawalGroup(
amount?: AmountJson;
forcedWithdrawalGroupId?: string;
forcedDenomSel?: ForcedDenomSel;
- reserveKeyPair?: EddsaKeypair;
+ reserveKeyPair?: EddsaKeyPairStrings;
restrictAge?: number;
wgInfo: WgInfo;
},
@@ -3367,6 +3362,7 @@ export async function prepareBankIntegratedWithdrawal(
timestampReserveInfoPosted: undefined,
wireTypes: withdrawInfo.wireTypes,
currency: withdrawInfo.currency,
+ senderWire: withdrawInfo.senderWire,
externalConfirmation,
},
},
@@ -3416,6 +3412,10 @@ export async function confirmWithdrawal(
const exchange = await fetchFreshExchange(wex, selectedExchange);
requireExchangeTosAcceptedOrThrow(exchange);
+ if (checkWithdrawalHardLimitExceeded(exchange, req.amount)) {
+ throw Error("withdrawal would exceed hard KYC limit");
+ }
+
const talerWithdrawUri = withdrawalGroup.wgInfo.bankInfo.talerWithdrawUri;
/**
@@ -3439,6 +3439,10 @@ export async function confirmWithdrawal(
bankCurrency = withdrawalGroup.wgInfo.bankInfo.currency;
}
+ if (exchange.currency !== bankCurrency) {
+ throw Error("currency mismatch between exchange and bank");
+ }
+
const exchangePaytoUri = await getExchangePaytoUri(
wex,
selectedExchange,
@@ -3454,6 +3458,35 @@ export async function confirmWithdrawal(
wex.cancellationToken,
);
+ const senderWire = withdrawalGroup.wgInfo.bankInfo.senderWire;
+
+ if (senderWire) {
+ logger.info(`sender wire is ${senderWire}`);
+ const parsedSenderWire = parsePaytoUri(senderWire);
+ if (!parsedSenderWire) {
+ throw Error("invalid payto URI");
+ }
+ let acceptable = false;
+ for (const acc of withdrawalAccountList) {
+ const parsedExchangeWire = parsePaytoUri(acc.paytoUri);
+ if (!parsedExchangeWire) {
+ continue;
+ }
+ const checkRes = checkAccountRestriction(
+ senderWire,
+ acc.creditRestrictions ?? [],
+ );
+ if (!checkRes.ok) {
+ continue;
+ }
+ acceptable = true;
+ break;
+ }
+ if (!acceptable) {
+ throw Error("bank account not acceptable by the exchange");
+ }
+ }
+
const ctx = new WithdrawTransactionContext(
wex,
withdrawalGroup.withdrawalGroupId,
@@ -3508,11 +3541,6 @@ export async function confirmWithdrawal(
await wex.taskScheduler.resetTaskRetries(ctx.taskId);
- wex.ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: ctx.transactionId,
- });
-
const res = await wex.db.runReadWriteTx(
{
storeNames: ["exchanges"],
@@ -3776,7 +3804,7 @@ async function fetchAccount(
});
if (reservePub != null) {
paytoUri = addPaytoQueryParams(paytoUri, {
- message: `Taler-> ${reservePub}`,
+ message: `Taler ${reservePub}`,
});
}
const acctInfo: WithdrawalExchangeAccountDetails = {
@@ -3858,7 +3886,11 @@ export async function createManualWithdrawal(
);
}
- let reserveKeyPair: EddsaKeypair;
+ if (checkWithdrawalHardLimitExceeded(exchange, req.amount)) {
+ throw Error("withdrawal would exceed hard KYC limit");
+ }
+
+ let reserveKeyPair: EddsaKeyPairStrings;
if (req.forceReservePriv) {
const pubResp = await wex.cryptoApi.eddsaGetPublic({
priv: req.forceReservePriv,
@@ -3923,7 +3955,7 @@ export async function createManualWithdrawal(
}
/**
- * Wait until a refresh operation is final.
+ * Wait until a withdrawal operation is final.
*/
export async function waitWithdrawalFinal(
wex: WalletExecutionContext,
@@ -3932,68 +3964,41 @@ export async function waitWithdrawalFinal(
const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
wex.taskScheduler.startShepherdTask(ctx.taskId);
- // FIXME: Clean up using the new JS "using" / Symbol.dispose syntax.
- const withdrawalNotifFlag = new AsyncFlag();
- // Raise purchaseNotifFlag whenever we get a notification
- // about our refresh.
- const cancelNotif = wex.ws.addNotificationListener((notif) => {
- if (
- notif.type === NotificationType.TransactionStateTransition &&
- notif.transactionId === ctx.transactionId
- ) {
- withdrawalNotifFlag.raise();
- }
- });
- const unregisterOnCancelled = wex.cancellationToken.onCancelled(() => {
- cancelNotif();
- withdrawalNotifFlag.raise();
- });
-
- try {
- await internalWaitWithdrawalFinal(ctx, withdrawalNotifFlag);
- } catch (e) {
- unregisterOnCancelled();
- cancelNotif();
- }
-}
-
-async function internalWaitWithdrawalFinal(
- ctx: WithdrawTransactionContext,
- flag: AsyncFlag,
-): Promise<void> {
- while (true) {
- if (ctx.wex.cancellationToken.isCancelled) {
- throw Error("cancelled");
- }
-
- // Check if refresh is final
- const res = await ctx.wex.db.runReadOnlyTx(
- { storeNames: ["withdrawalGroups"] },
- async (tx) => {
- return {
- wg: await tx.withdrawalGroups.get(ctx.withdrawalGroupId),
- };
- },
- );
- const { wg } = res;
- if (!wg) {
- // Must've been deleted, we consider that final.
- return;
- }
- switch (wg.status) {
- case WithdrawalGroupStatus.AbortedBank:
- case WithdrawalGroupStatus.AbortedExchange:
- case WithdrawalGroupStatus.Done:
- case WithdrawalGroupStatus.FailedAbortingBank:
- case WithdrawalGroupStatus.FailedBankAborted:
- // Transaction is final
- return;
- }
+ await genericWaitForState(wex, {
+ filterNotification(notif) {
+ return (
+ notif.type === NotificationType.TransactionStateTransition &&
+ notif.transactionId === ctx.transactionId
+ );
+ },
+ async checkState() {
+ // Check if withdrawal is final
+ const res = await ctx.wex.db.runReadOnlyTx(
+ { storeNames: ["withdrawalGroups"] },
+ async (tx) => {
+ return {
+ wg: await tx.withdrawalGroups.get(ctx.withdrawalGroupId),
+ };
+ },
+ );
+ const { wg } = res;
+ if (!wg) {
+ // Must've been deleted, we consider that final.
+ return true;
+ }
+ switch (wg.status) {
+ case WithdrawalGroupStatus.AbortedBank:
+ case WithdrawalGroupStatus.AbortedExchange:
+ case WithdrawalGroupStatus.Done:
+ case WithdrawalGroupStatus.FailedAbortingBank:
+ case WithdrawalGroupStatus.FailedBankAborted:
+ // Transaction is final
+ return true;
+ }
- // Wait for the next transition
- await flag.wait();
- flag.reset();
- }
+ return false;
+ },
+ });
}
export async function getWithdrawalDetailsForAmount(
@@ -4008,13 +4013,25 @@ export async function getWithdrawalDetailsForAmount(
);
}
-async function internalGetWithdrawalDetailsForAmount(
+export async function internalGetWithdrawalDetailsForAmount(
wex: WalletExecutionContext,
req: GetWithdrawalDetailsForAmountRequest,
): Promise<WithdrawalDetailsForAmount> {
+ let exchangeBaseUrl: string | undefined;
+ if (req.exchangeBaseUrl) {
+ exchangeBaseUrl = req.exchangeBaseUrl;
+ } else if (req.restrictScope) {
+ exchangeBaseUrl = await getPreferredExchangeForScope(
+ wex,
+ req.restrictScope,
+ );
+ }
+ if (!exchangeBaseUrl) {
+ throw Error("could not find exchange for withdrawal");
+ }
const wi = await getExchangeWithdrawalInfo(
wex,
- req.exchangeBaseUrl,
+ exchangeBaseUrl,
Amounts.parseOrThrow(req.amount),
req.restrictAge,
);
@@ -4023,6 +4040,7 @@ async function internalGetWithdrawalDetailsForAmount(
numCoins += x.count;
}
const resp: WithdrawalDetailsForAmount = {
+ exchangeBaseUrl,
amountRaw: req.amount,
amountEffective: Amounts.stringify(wi.selectedDenoms.totalCoinValue),
paytoUris: wi.exchangePaytoUris,
@@ -4031,6 +4049,8 @@ async function internalGetWithdrawalDetailsForAmount(
withdrawalAccountsList: wi.exchangeCreditAccountDetails,
numCoins,
scopeInfo: wi.scopeInfo,
+ kycHardLimit: wi.kycHardLimit,
+ kycSoftLimit: wi.kycSoftLimit,
};
return resp;
}