commit 7dc29f9bc4cb3a8a1943ede93e5b0834f67d9788
parent 2643f33dbdfb6bb75d42ddce7c0c65c7adfa0842
Author: Florian Dold <florian@dold.me>
Date: Wed, 21 Aug 2024 15:24:40 +0200
wallet-core: balance limits for withdrawal
Diffstat:
11 files changed, 697 insertions(+), 32 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-kyc-balance-withdrawal.ts b/packages/taler-harness/src/integrationtests/test-kyc-balance-withdrawal.ts
@@ -0,0 +1,266 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ encodeCrock,
+ ExchangeWalletKycStatus,
+ hashPaytoUri,
+ j2s,
+ TalerCorebankApiClient,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionMinorState,
+} from "@gnu-taler/taler-util";
+import {
+ createSyncCryptoApi,
+ EddsaKeypair,
+ WalletApiOperation,
+} from "@gnu-taler/taler-wallet-core";
+import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ BankService,
+ DbInfo,
+ ExchangeService,
+ generateRandomPayto,
+ GlobalTestState,
+ HarnessExchangeBankAccount,
+ setupDb,
+ WalletClient,
+ WalletService,
+} from "../harness/harness.js";
+import {
+ EnvOptions,
+ postAmlDecisionNoRules,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+interface KycTestEnv {
+ commonDb: DbInfo;
+ bankClient: TalerCorebankApiClient;
+ exchange: ExchangeService;
+ exchangeBankAccount: HarnessExchangeBankAccount;
+ walletClient: WalletClient;
+ walletService: WalletService;
+ amlKeypair: EddsaKeypair;
+}
+
+async function createKycTestkudosEnvironment(
+ t: GlobalTestState,
+ coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS")),
+ opts: EnvOptions = {},
+): Promise<KycTestEnv> {
+ const db = await setupDb(t);
+
+ const bank = await BankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: db.connStr,
+ httpPort: 8082,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ let receiverName = "Exchange";
+ let exchangeBankUsername = "exchange";
+ let exchangeBankPassword = "mypw";
+ let exchangePaytoUri = generateRandomPayto(exchangeBankUsername);
+
+ await exchange.addBankAccount("1", {
+ accountName: exchangeBankUsername,
+ accountPassword: exchangeBankPassword,
+ wireGatewayApiBaseUrl: new URL(
+ "accounts/exchange/taler-wire-gateway/",
+ bank.baseUrl,
+ ).href,
+ accountPaytoUri: exchangePaytoUri,
+ });
+
+ bank.setSuggestedExchange(exchange, exchangePaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, {
+ auth: {
+ username: "admin",
+ password: "adminpw",
+ },
+ });
+
+ await bankClient.registerAccountExtended({
+ name: receiverName,
+ password: exchangeBankPassword,
+ username: exchangeBankUsername,
+ is_taler_exchange: true,
+ payto_uri: exchangePaytoUri,
+ });
+
+ exchange.addCoinConfigList(coinConfig);
+
+ await exchange.modifyConfig(async (config) => {
+ config.setString("exchange", "enable_kyc", "yes");
+
+ config.setString("KYC-RULE-R1", "operation_type", "balance");
+ config.setString("KYC-RULE-R1", "enabled", "yes");
+ config.setString("KYC-RULE-R1", "exposed", "yes");
+ config.setString("KYC-RULE-R1", "is_and_combinator", "yes");
+ config.setString("KYC-RULE-R1", "threshold", "TESTKUDOS:10");
+ config.setString("KYC-RULE-R1", "timeframe", "forever");
+ config.setString("KYC-RULE-R1", "next_measures", "M1");
+
+ config.setString("KYC-MEASURE-M1", "check_name", "C1");
+ config.setString("KYC-MEASURE-M1", "context", "{}");
+ config.setString("KYC-MEASURE-M1", "program", "P1");
+
+ config.setString("AML-PROGRAM-P1", "command", "/bin/true");
+ config.setString("AML-PROGRAM-P1", "enabled", "true");
+ config.setString("AML-PROGRAM-P1", "description", "this does nothing");
+ config.setString("AML-PROGRAM-P1", "fallback", "M1");
+
+ config.setString("KYC-CHECK-C1", "type", "INFO");
+ config.setString("KYC-CHECK-C1", "description", "my check!");
+ config.setString("KYC-CHECK-C1", "fallback", "M1");
+ });
+
+ await exchange.start();
+
+ const cryptoApi = createSyncCryptoApi();
+ const amlKeypair = await cryptoApi.createEddsaKeypair({});
+
+ await exchange.enableAmlAccount(amlKeypair.pub, "Alice");
+
+ const walletService = new WalletService(t, {
+ name: "wallet",
+ useInMemoryDb: true,
+ });
+ await walletService.start();
+ await walletService.pingUntilAvailable();
+
+ const walletClient = new WalletClient({
+ name: "wallet",
+ unixPath: walletService.socketPath,
+ onNotification(n) {
+ console.log("got notification", n);
+ },
+ });
+ await walletClient.connect();
+ await walletClient.client.call(WalletApiOperation.InitWallet, {
+ config: {
+ testing: {
+ skipDefaults: true,
+ },
+ },
+ });
+
+ console.log("setup done!");
+
+ return {
+ commonDb: db,
+ exchange,
+ amlKeypair,
+ walletClient,
+ walletService,
+ bankClient,
+ exchangeBankAccount: {
+ accountName: "",
+ accountPassword: "",
+ accountPaytoUri: "",
+ wireGatewayApiBaseUrl: "",
+ },
+ };
+}
+
+export async function runKycBalanceWithdrawalTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bankClient, exchange, amlKeypair } =
+ await createKycTestkudosEnvironment(t);
+
+ // Withdraw digital cash into the wallet.
+
+ const wres = await withdrawViaBankV3(t, {
+ amount: "TESTKUDOS:20",
+ bankClient,
+ exchange,
+ walletClient,
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: wres.transactionId as TransactionIdStr,
+ txState: {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.BalanceKycRequired,
+ },
+ });
+
+ {
+ const exchangeEntry = await walletClient.call(
+ WalletApiOperation.GetExchangeEntryByUrl,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ },
+ );
+ console.log(j2s(exchangeEntry));
+ }
+
+ await walletClient.call(WalletApiOperation.TestingWaitExchangeState, {
+ exchangeBaseUrl: exchange.baseUrl,
+ walletKycStatus: ExchangeWalletKycStatus.Legi,
+ });
+
+ {
+ const exchangeEntry = await walletClient.call(
+ WalletApiOperation.GetExchangeEntryByUrl,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ },
+ );
+ console.log(j2s(exchangeEntry));
+ t.assertDeepEqual(
+ exchangeEntry.walletKycStatus,
+ ExchangeWalletKycStatus.Legi,
+ );
+
+ const kycReservePub = exchangeEntry.walletKycReservePub;
+ t.assertTrue(!!kycReservePub);
+
+ // FIXME: Create/user helper function for this!
+ const hPayto = hashPaytoUri(
+ `payto://taler-reserve-http/localhost:${exchange.port}/${kycReservePub}`,
+ );
+
+ await postAmlDecisionNoRules(t, {
+ amlPriv: amlKeypair.priv,
+ amlPub: amlKeypair.pub,
+ exchangeBaseUrl: exchange.baseUrl,
+ paytoHash: encodeCrock(hPayto),
+ });
+ }
+
+ // Now after KYC is done for the balance, the withdrawal should finish
+ await wres.withdrawalFinishedCond;
+}
+
+runKycBalanceWithdrawalTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts
@@ -47,6 +47,7 @@ import { runExchangePurseTest } from "./test-exchange-purse.js";
import { runExchangeTimetravelTest } from "./test-exchange-timetravel.js";
import { runFeeRegressionTest } from "./test-fee-regression.js";
import { runForcedSelectionTest } from "./test-forced-selection.js";
+import { runKycBalanceWithdrawalTest } from "./test-kyc-balance-withdrawal.js";
import { runKycDepositAggregateTest } from "./test-kyc-deposit-aggregate.js";
import { runKycExchangeWalletTest } from "./test-kyc-exchange-wallet.js";
import { runKycFormWithdrawalTest } from "./test-kyc-form-withdrawal.js";
@@ -256,6 +257,7 @@ const allTests: TestMainFunction[] = [
runKycPeerPullTest,
runKycDepositAggregateTest,
runKycFormWithdrawalTest,
+ runKycBalanceWithdrawalTest,
];
export interface TestRunSpec {
diff --git a/packages/taler-util/src/types-taler-exchange.ts b/packages/taler-util/src/types-taler-exchange.ts
@@ -448,6 +448,12 @@ export interface ExchangeKeysJson {
wire_fees: { [methodName: string]: WireFeesJson[] };
denominations: DenomGroup[];
+
+ // Threshold amounts beyond which wallet should
+ // trigger the KYC process of the issuing exchange.
+ // Optional option, if not given there is no limit.
+ // Currency must match currency.
+ wallet_balance_limit_without_kyc?: AmountString[];
}
export interface ExchangeMeltRequest {
@@ -877,6 +883,10 @@ export const codecForExchangeKeysJson = (): Codec<ExchangeKeysJson> =>
.property("accounts", codecForList(codecForExchangeWireAccount()))
.property("wire_fees", codecForMap(codecForList(codecForWireFeesJson())))
.property("denominations", codecForList(codecForNgDenominations))
+ .property(
+ "wallet_balance_limit_without_kyc",
+ codecOptional(codecForList(codecForAmountString())),
+ )
.build("ExchangeKeysJson");
export const codecForWireFeesJson = (): Codec<WireFeesJson> =>
@@ -1692,8 +1702,7 @@ export interface KycProcessClientInformation {
declare const opaque_kycReq: unique symbol;
export type KycRequirementInformationId = string & { [opaque_kycReq]: true };
declare const opaque_formId: unique symbol;
-export type KycBuiltInFromId = string & { [opaque_formId]: true }
-
+export type KycBuiltInFromId = string & { [opaque_formId]: true };
export interface KycRequirementInformation {
// Which form should be used? Common values include "INFO"
@@ -2435,17 +2444,25 @@ export const codecForKycCheckPublicInformation =
export const codecForKycRequirementInformationId =
(): Codec<KycRequirementInformationId> =>
codecForString() as Codec<KycRequirementInformationId>;
-export const codecForKycFormId =
- (): Codec<KycBuiltInFromId> =>
- codecForString() as Codec<KycBuiltInFromId>;
-
+export const codecForKycFormId = (): Codec<KycBuiltInFromId> =>
+ codecForString() as Codec<KycBuiltInFromId>;
export const codecForKycRequirementInformation =
(): Codec<KycRequirementInformation> =>
buildCodecForObject<KycRequirementInformation>()
- .property("form", codecForEither(codecForConstString("LINK"), codecForConstString("INFO"), codecForKycFormId()))
+ .property(
+ "form",
+ codecForEither(
+ codecForConstString("LINK"),
+ codecForConstString("INFO"),
+ codecForKycFormId(),
+ ),
+ )
.property("description", codecForString())
- .property("description_i18n", codecOptional(codecForInternationalizedString()))
+ .property(
+ "description_i18n",
+ codecOptional(codecForInternationalizedString()),
+ )
.property("id", codecOptional(codecForKycRequirementInformationId()))
.build("TalerExchangeApi.KycRequirementInformation");
@@ -2454,7 +2471,10 @@ export const codecForKycProcessClientInformation =
buildCodecForObject<KycProcessClientInformation>()
.property(
"requirements",
- codecOptionalDefault(codecForList(codecForKycRequirementInformation()), []),
+ codecOptionalDefault(
+ codecForList(codecForKycRequirementInformation()),
+ [],
+ ),
)
.property("is_and_combinator", codecOptional(codecForBoolean()))
.property(
diff --git a/packages/taler-util/src/types-taler-wallet-transactions.ts b/packages/taler-util/src/types-taler-wallet-transactions.ts
@@ -128,6 +128,7 @@ export enum TransactionMinorState {
Deposit = "deposit",
KycRequired = "kyc",
MergeKycRequired = "merge-kyc",
+ BalanceKycRequired = "balance-kyc",
Track = "track",
SubmitPayment = "submit-payment",
RebindSession = "rebind-session",
diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts
@@ -1480,6 +1480,8 @@ export interface ExchangeListItem {
walletKycStatus?: ExchangeWalletKycStatus;
walletKycReservePub?: string;
+ walletKycAccessToken?: string;
+ walletKycUrl?: string;
/**
* P2P payments are disabled with this exchange
@@ -3222,6 +3224,11 @@ export interface WalletContractData {
minimumAge?: number;
}
+export interface TestingWaitExchangeStateRequest {
+ exchangeBaseUrl: string;
+ walletKycStatus?: ExchangeWalletKycStatus;
+}
+
export interface TestingWaitTransactionRequest {
transactionId: TransactionIdStr;
txState: TransactionState;
diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts
@@ -360,6 +360,8 @@ export async function getBalancesInsideTransaction(
case WithdrawalGroupStatus.SuspendedQueryingStatus:
// Pending, but no special flag.
break;
+ case WithdrawalGroupStatus.PendingBalanceKyc:
+ case WithdrawalGroupStatus.SuspendedBalanceKyc:
case WithdrawalGroupStatus.SuspendedKyc:
case WithdrawalGroupStatus.PendingKyc: {
checkDbInvariant(
diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts
@@ -304,6 +304,18 @@ export enum WithdrawalGroupStatus {
SuspendedReady = 0x0110_0004,
/**
+ * Exchange wants KYC info from the user.
+ */
+ PendingKyc = 0x0100_0005,
+ SuspendedKyc = 0x0110_005,
+
+ /**
+ * Exchange wants KYC info from the user.
+ */
+ PendingBalanceKyc = 0x0100_0006,
+ SuspendedBalanceKyc = 0x0110_006,
+
+ /**
* Proposed to the user, has can choose to accept/refuse.
*/
DialogProposed = 0x0101_0000,
@@ -316,12 +328,6 @@ export enum WithdrawalGroupStatus {
SuspendedAbortingBank = 0x0113_0001,
/**
- * Exchange wants KYC info from the user.
- */
- PendingKyc = 0x0100_0005,
- SuspendedKyc = 0x0110_005,
-
- /**
* The corresponding withdraw record has been created.
* No further processing is done, unless explicitly requested
* by the user.
@@ -607,6 +613,8 @@ export interface ExchangeDetailsRecord {
* Age restrictions supported by the exchange (bitmask).
*/
ageMask?: number;
+
+ walletBalanceLimits?: AmountString[];
}
export interface ExchangeDetailsPointer {
@@ -2178,6 +2186,11 @@ export interface ReserveRecord {
*/
thresholdGranted?: AmountString;
+ /**
+ * Threshold that will trigger the next KYC.
+ */
+ thresholdNext?: AmountString;
+
kycAccessToken?: string;
amlReview?: boolean;
diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts
@@ -25,9 +25,11 @@
*/
import {
AbsoluteTime,
+ AccountKycStatus,
AgeRestriction,
Amount,
AmountLike,
+ AmountString,
Amounts,
CancellationToken,
CoinRefreshRequest,
@@ -74,6 +76,7 @@ import {
TalerPreciseTimestamp,
TalerProtocolDuration,
TalerProtocolTimestamp,
+ TestingWaitExchangeStateRequest,
TestingWaitWalletKycRequest,
Transaction,
TransactionAction,
@@ -451,6 +454,10 @@ async function makeExchangeListItem(
exchangeEntryStatus: getExchangeEntryStatusFromRecord(r),
walletKycStatus,
walletKycReservePub: reserveRec?.reservePub,
+ walletKycUrl: reserveRec?.kycAccessToken
+ ? new URL(`kyc-spa/${reserveRec.kycAccessToken}`, r.baseUrl).href
+ : undefined,
+ walletKycAccessToken: reserveRec?.kycAccessToken,
tosStatus: getExchangeTosStatusFromRecord(r),
ageRestrictionOptions: exchangeDetails?.ageMask
? AgeRestriction.getAgeGroupsFromMask(exchangeDetails.ageMask)
@@ -838,6 +845,7 @@ export interface ExchangeKeysDownloadResult {
accounts: ExchangeWireAccount[];
wireFees: { [methodName: string]: WireFeesJson[] };
currencySpecification?: CurrencySpecification;
+ walletBalanceLimits: AmountString[] | undefined;
}
/**
@@ -1001,6 +1009,8 @@ async function downloadExchangeKeysInfo(
accounts: exchangeKeysJsonUnchecked.accounts,
wireFees: exchangeKeysJsonUnchecked.wire_fees,
currencySpecification: exchangeKeysJsonUnchecked.currency_specification,
+ walletBalanceLimits:
+ exchangeKeysJsonUnchecked.wallet_balance_limit_without_kyc,
};
}
@@ -1730,6 +1740,7 @@ export async function updateExchangeFromUrlHandler(
exchangeBaseUrl: r.baseUrl,
wireInfo,
ageMask,
+ walletBalanceLimits: keysInfo.walletBalanceLimits,
};
r.noFees = noFees;
r.peerPaymentsDisabled = peerPaymentsDisabled;
@@ -2952,6 +2963,156 @@ export async function getExchangeWireFee(
return fee;
}
+export type BalanceThresholdCheckResult =
+ | {
+ result: "ok";
+ }
+ | {
+ result: "violation";
+ nextThreshold: AmountString;
+ };
+
+export async function checkIncomingAmountLegalUnderKycBalanceThreshold(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+ amountIncoming: AmountLike,
+): Promise<BalanceThresholdCheckResult> {
+ logger.info(`checking ${exchangeBaseUrl} +${amountIncoming} for KYC`);
+ return await wex.db.runReadOnlyTx(
+ {
+ storeNames: [
+ "exchanges",
+ "exchangeDetails",
+ "reserves",
+ "coinAvailability",
+ ],
+ },
+ async (tx): Promise<BalanceThresholdCheckResult> => {
+ const exchangeRec = await tx.exchanges.get(exchangeBaseUrl);
+ if (!exchangeRec) {
+ throw Error("exchange not found");
+ }
+ const det = await getExchangeRecordsInternal(tx, exchangeBaseUrl);
+ if (!det) {
+ throw Error("exchange not found");
+ }
+ const coinAvRecs =
+ await tx.coinAvailability.indexes.byExchangeBaseUrl.getAll(
+ exchangeBaseUrl,
+ );
+ let balAmount = Amounts.zeroOfCurrency(det.currency);
+ for (const av of coinAvRecs) {
+ const n = av.freshCoinCount + (av.pendingRefreshOutputCount ?? 0);
+ balAmount = Amounts.add(
+ balAmount,
+ Amounts.mult(av.value, n).amount,
+ ).amount;
+ }
+ const balExpected = Amounts.add(balAmount, amountIncoming).amount;
+
+ // Check if we already have KYC for a sufficient threshold.
+
+ const reserveId = exchangeRec.currentMergeReserveRowId;
+ if (reserveId) {
+ const reserveRec = await tx.reserves.get(reserveId);
+ checkDbInvariant(!!reserveRec, "reserve");
+ // FIXME: also consider KYC expiration!
+ if (reserveRec.thresholdNext) {
+ if (Amounts.cmp(reserveRec.thresholdNext, balExpected) >= 0) {
+ return {
+ result: "ok",
+ };
+ }
+ } else if (reserveRec.status === ReserveRecordStatus.Done) {
+ // We don't know what the next threshold is, but we've passed *some* KYC
+ // check. We don't have enough information, so we allow the balance increase.
+ return {
+ result: "ok",
+ };
+ }
+ }
+
+ // No luck, check the next limit we should request, if any.
+
+ const limits = det.walletBalanceLimits;
+ if (!limits) {
+ logger.info("no balance limits defined");
+ return {
+ result: "ok",
+ };
+ }
+ limits.sort((a, b) => Amounts.cmp(a, b));
+ logger.info(`applicable limits: ${j2s(limits)}`);
+ let limViolated: AmountString | undefined = undefined;
+ let limNext: AmountString | undefined = undefined;
+ for (let i = 0; i < limits.length; i++) {
+ if (Amounts.cmp(limits[i], balExpected) <= 0) {
+ limViolated = limits[i];
+ limNext = limits[i + 1];
+ if (limNext == null || Amounts.cmp(limNext, balExpected) > 0) {
+ break;
+ }
+ }
+ }
+ if (!limViolated) {
+ logger.info("balance limit okay");
+ return {
+ result: "ok",
+ };
+ } else {
+ logger.info(
+ `balance limit ${limViolated} would be violated, next is ${limNext}`,
+ );
+ return {
+ result: "violation",
+ nextThreshold: limNext ?? limViolated,
+ };
+ }
+ },
+ );
+}
+
+/**
+ * Wait until it is allowed for the user to add the given amount
+ * to the wallet balance, either because the balance is low enough
+ * or KYC was completed.
+ */
+export async function waitIncomingAmountLegalUnderKycBalanceThreshold(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+ amountExpected: AmountLike,
+): Promise<void> {
+ await genericWaitForState(wex, {
+ async checkState(): Promise<boolean> {
+ const checkRes = await checkIncomingAmountLegalUnderKycBalanceThreshold(
+ wex,
+ exchangeBaseUrl,
+ amountExpected,
+ );
+ logger.info(
+ `balance check result for ${exchangeBaseUrl} +${amountExpected}: ${j2s(
+ checkRes,
+ )}`,
+ );
+ if (checkRes.result === "ok") {
+ return true;
+ }
+ await handleStartExchangeWalletKyc(wex, {
+ amount: checkRes.nextThreshold,
+ exchangeBaseUrl,
+ });
+ return false;
+ },
+ filterNotification(notif) {
+ return (
+ (notif.type === NotificationType.ExchangeStateTransition &&
+ notif.exchangeBaseUrl === exchangeBaseUrl) ||
+ notif.type === NotificationType.BalanceChange
+ );
+ },
+ });
+}
+
/**
* Wait until kyc has passed for the wallet.
*
@@ -3016,6 +3177,32 @@ export async function waitExchangeWalletKyc(
});
}
+export async function handleTestingWaitExchangeState(
+ wex: WalletExecutionContext,
+ req: TestingWaitExchangeStateRequest,
+): Promise<EmptyObject> {
+ await genericWaitForState(wex, {
+ async checkState(): Promise<boolean> {
+ const exchangeEntry = await lookupExchangeByUri(wex, {
+ exchangeBaseUrl: req.exchangeBaseUrl,
+ });
+ if (req.walletKycStatus) {
+ if (req.walletKycStatus !== exchangeEntry.walletKycStatus) {
+ return false;
+ }
+ }
+ return true;
+ },
+ filterNotification(notif) {
+ return (
+ notif.type === NotificationType.ExchangeStateTransition &&
+ notif.exchangeBaseUrl === req.exchangeBaseUrl
+ );
+ },
+ });
+ return {};
+}
+
export async function handleTestingWaitExchangeWalletKyc(
wex: WalletExecutionContext,
req: TestingWaitWalletKycRequest,
@@ -3080,7 +3267,7 @@ export async function handleStartExchangeWalletKyc(
} else {
// FIXME: Check expiration once exchange tells us!
logger.info(
- `KYC already granted for ${req.exchangeBaseUrl} over ${reserveRec.thresholdGranted}`,
+ `KYC already granted for ${req.exchangeBaseUrl} over ${req.amount}, granted ${reserveRec.thresholdGranted}`,
);
return undefined;
}
@@ -3127,11 +3314,15 @@ async function handleExchangeKycPendingWallet(
case HttpStatusCode.Ok: {
// KYC somehow already passed
// FIXME: Store next threshold and timestamp!
- return handleExchangeKycSuccess(wex, exchange.baseUrl);
+ const accountKycStatus = await readSuccessResponseJsonOrThrow(
+ res,
+ codecForAccountKycStatus(),
+ );
+ return handleExchangeKycSuccess(wex, exchange.baseUrl, accountKycStatus);
}
case HttpStatusCode.NoContent: {
// KYC disabled at exchange.
- return handleExchangeKycSuccess(wex, exchange.baseUrl);
+ return handleExchangeKycSuccess(wex, exchange.baseUrl, undefined);
}
case HttpStatusCode.Forbidden: {
// Did not work!
@@ -3155,7 +3346,9 @@ async function handleExchangeKycPendingWallet(
async function handleExchangeKycSuccess(
wex: WalletExecutionContext,
exchangeBaseUrl: string,
+ accountKycStatus: AccountKycStatus | undefined,
): Promise<TaskRunResult> {
+ logger.info(`kyc check for ${exchangeBaseUrl} satisfied`);
const dbRes = await wex.db.runReadWriteTx(
{ storeNames: ["exchanges", "reserves"] },
async (tx) => {
@@ -3181,7 +3374,20 @@ async function handleExchangeKycSuccess(
reserve.thresholdGranted = reserve.thresholdRequested;
delete reserve.thresholdRequested;
delete reserve.requirementRow;
+
+ // Try to figure out the next balance limit
+ let nextLimit: AmountString | undefined = undefined;
+ if (accountKycStatus?.limits) {
+ for (const lim of accountKycStatus.limits) {
+ if (lim.operation_type.toLowerCase() === "balance") {
+ nextLimit = lim.threshold;
+ }
+ }
+ }
+ reserve.thresholdNext = nextLimit;
+
await tx.reserves.put(reserve);
+ logger.info(`newly granted threshold: ${reserve.thresholdGranted}`);
return {
notification: {
type: NotificationType.ExchangeStateTransition,
@@ -3226,7 +3432,11 @@ async function handleExchangeKycRespLegi(
switch (resp.status) {
case HttpStatusCode.Ok: {
// FIXME: Store information about next limit!
- return handleExchangeKycSuccess(wex, exchangeBaseUrl);
+ const accountKycStatus = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForAccountKycStatus(),
+ );
+ return handleExchangeKycSuccess(wex, exchangeBaseUrl, accountKycStatus);
}
case HttpStatusCode.Accepted: {
// Store the result in the DB!
@@ -3234,7 +3444,7 @@ async function handleExchangeKycRespLegi(
}
case HttpStatusCode.NoContent: {
// KYC not configured, so already satisfied
- return handleExchangeKycSuccess(wex, exchangeBaseUrl);
+ return handleExchangeKycSuccess(wex, exchangeBaseUrl, undefined);
}
default: {
const err = await readTalerErrorResponse(resp);
@@ -3327,14 +3537,18 @@ async function handleExchangeKycPendingLegitimization(
switch (resp.status) {
case HttpStatusCode.Ok: {
// FIXME: Store information about next limit!
- return handleExchangeKycSuccess(wex, exchange.baseUrl);
+ const accountKycStatus = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForAccountKycStatus(),
+ );
+ return handleExchangeKycSuccess(wex, exchange.baseUrl, accountKycStatus);
}
case HttpStatusCode.Accepted:
// FIXME: Do we ever need to update the access token?
return TaskRunResult.longpollReturnedPending();
case HttpStatusCode.NoContent: {
// KYC not configured, so already satisfied
- return handleExchangeKycSuccess(wex, exchange.baseUrl);
+ return handleExchangeKycSuccess(wex, exchange.baseUrl, undefined);
}
default: {
const err = await readTalerErrorResponse(resp);
diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts
@@ -140,6 +140,7 @@ import {
TestingGetDenomStatsResponse,
TestingGetReserveHistoryRequest,
TestingSetTimetravelRequest,
+ TestingWaitExchangeStateRequest,
TestingWaitTransactionRequest,
TestingWaitWalletKycRequest,
Transaction,
@@ -279,6 +280,7 @@ export enum WalletApiOperation {
TestingWaitTransactionsFinal = "testingWaitTransactionsFinal",
TestingWaitRefreshesFinal = "testingWaitRefreshesFinal",
TestingWaitTransactionState = "testingWaitTransactionState",
+ TestingWaitExchangeState = "testingWaitExchangeState",
TestingWaitTasksDone = "testingWaitTasksDone",
TestingSetTimetravel = "testingSetTimetravel",
TestingGetDenomStats = "testingGetDenomStats",
@@ -1217,6 +1219,15 @@ export type TestingWaitTransactionStateOp = {
response: EmptyObject;
};
+/**
+ * Wait until an exchange entry is in a particular state.
+ */
+export type TestingWaitExchangeStateOp = {
+ op: WalletApiOperation.TestingWaitTransactionState;
+ request: TestingWaitExchangeStateRequest;
+ response: EmptyObject;
+};
+
export type TestingPingOp = {
op: WalletApiOperation.TestingPing;
request: EmptyObject;
@@ -1355,6 +1366,7 @@ export type WalletOperations = {
[WalletApiOperation.TestingWaitRefreshesFinal]: TestingWaitRefreshesFinalOp;
[WalletApiOperation.TestingSetTimetravel]: TestingSetTimetravelOp;
[WalletApiOperation.TestingWaitTransactionState]: TestingWaitTransactionStateOp;
+ [WalletApiOperation.TestingWaitExchangeState]: TestingWaitExchangeStateOp;
[WalletApiOperation.TestingWaitTasksDone]: TestingWaitTasksDoneOp;
[WalletApiOperation.GetCurrencySpecification]: GetCurrencySpecificationOp;
[WalletApiOperation.CreateStoredBackup]: CreateStoredBackupsOp;
diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts
@@ -270,6 +270,7 @@ import {
getExchangeTos,
getExchangeWireDetailsInTx,
handleStartExchangeWalletKyc,
+ handleTestingWaitExchangeState,
handleTestingWaitExchangeWalletKyc,
listExchanges,
lookupExchangeByUri,
@@ -1557,6 +1558,10 @@ interface HandlerWithValidator<Tag extends WalletApiOperation> {
}
const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = {
+ [WalletApiOperation.TestingWaitExchangeState]: {
+ codec: codecForAny(),
+ handler: handleTestingWaitExchangeState,
+ },
[WalletApiOperation.AbortTransaction]: {
codec: codecForAbortTransaction(),
handler: handleAbortTransaction,
diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts
@@ -159,13 +159,16 @@ import { isWithdrawableDenom } from "./denominations.js";
import {
ExchangeWireDetails,
ReadyExchangeSummary,
+ checkIncomingAmountLegalUnderKycBalanceThreshold,
fetchFreshExchange,
getExchangePaytoUri,
getExchangeWireDetailsInTx,
getScopeForAllExchanges,
+ handleStartExchangeWalletKyc,
listExchanges,
lookupExchangeByUri,
markExchangeUsed,
+ waitIncomingAmountLegalUnderKycBalanceThreshold,
} from "./exchanges.js";
import { DbAccess } from "./query.js";
import {
@@ -183,10 +186,17 @@ import { WalletExecutionContext, getDenomInfo } from "./wallet.js";
*/
const logger = new Logger("withdraw.ts");
+interface TxKycDetails {
+ kycAccessToken?: string;
+ kycUrl?: string;
+ kycPaytoHash?: string;
+}
+
function buildTransactionForBankIntegratedWithdraw(
wg: WithdrawalGroupRecord,
scopes: ScopeInfo[],
- ort?: OperationRetryRecord,
+ ort: OperationRetryRecord | undefined,
+ kycDetails: TxKycDetails | undefined,
): TransactionWithdrawal {
if (wg.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
throw Error("");
@@ -203,7 +213,7 @@ function buildTransactionForBankIntegratedWithdraw(
const txState = computeWithdrawalTransactionStatus(wg);
const zero = Amounts.stringify(Amounts.zeroOfCurrency(currency));
- return {
+ let txDetails: TransactionWithdrawal = {
type: TransactionType.Withdrawal,
txState,
scopes,
@@ -237,15 +247,22 @@ function buildTransactionForBankIntegratedWithdraw(
tag: TransactionType.Withdrawal,
withdrawalGroupId: wg.withdrawalGroupId,
}),
- ...(ort?.lastError ? { error: ort.lastError } : {}),
};
+ if (ort?.lastError) {
+ txDetails.error = ort.lastError;
+ }
+ if (kycDetails) {
+ txDetails = { ...txDetails, ...kycDetails };
+ }
+ return txDetails;
}
function buildTransactionForManualWithdraw(
wg: WithdrawalGroupRecord,
exchangeDetails: ExchangeWireDetails | undefined,
scopes: ScopeInfo[],
- ort?: OperationRetryRecord,
+ ort: OperationRetryRecord | undefined,
+ kycDetails: TxKycDetails | undefined,
): TransactionWithdrawal {
if (wg.wgInfo.withdrawalType !== WithdrawalRecordType.BankManual)
throw Error("");
@@ -264,7 +281,7 @@ function buildTransactionForManualWithdraw(
const txState = computeWithdrawalTransactionStatus(wg);
- return {
+ let txDetails: TransactionWithdrawal = {
type: TransactionType.Withdrawal,
txState,
scopes,
@@ -292,6 +309,13 @@ function buildTransactionForManualWithdraw(
}),
...(ort?.lastError ? { error: ort.lastError } : {}),
};
+ if (ort?.lastError) {
+ txDetails.error = ort.lastError;
+ }
+ if (kycDetails) {
+ txDetails = { ...txDetails, ...kycDetails };
+ }
+ return txDetails;
}
export class WithdrawTransactionContext implements TransactionContext {
@@ -327,18 +351,31 @@ export class WithdrawTransactionContext implements TransactionContext {
if (!withdrawalGroupRecord) {
return undefined;
}
+ const exchangeBaseUrl = withdrawalGroupRecord.exchangeBaseUrl;
const exchangeDetails =
- withdrawalGroupRecord.exchangeBaseUrl === undefined
+ exchangeBaseUrl == null
? undefined
- : await getExchangeWireDetailsInTx(
- tx,
- withdrawalGroupRecord.exchangeBaseUrl,
- );
+ : await getExchangeWireDetailsInTx(tx, exchangeBaseUrl);
const scopes = await getScopeForAllExchanges(
tx,
!exchangeDetails ? [] : [exchangeDetails.exchangeBaseUrl],
);
+ 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;
+ }
+ // For the balance KYC, the client should get the kycUrl etc. from the exchange entry!
+ }
+
const ort = await tx.operationRetries.get(this.taskId);
if (
withdrawalGroupRecord.wgInfo.withdrawalType ===
@@ -348,6 +385,7 @@ export class WithdrawTransactionContext implements TransactionContext {
withdrawalGroupRecord,
scopes,
ort,
+ kycDetails,
);
}
if (!exchangeDetails) {
@@ -360,6 +398,7 @@ export class WithdrawTransactionContext implements TransactionContext {
exchangeDetails,
scopes,
ort,
+ kycDetails,
);
}
@@ -588,6 +627,8 @@ export class WithdrawTransactionContext implements TransactionContext {
case WithdrawalGroupStatus.SuspendedReady:
case WithdrawalGroupStatus.PendingKyc:
case WithdrawalGroupStatus.PendingQueryingStatus:
+ case WithdrawalGroupStatus.PendingBalanceKyc:
+ case WithdrawalGroupStatus.SuspendedBalanceKyc:
newStatus = WithdrawalGroupStatus.AbortedExchange;
break;
case WithdrawalGroupStatus.PendingReady:
@@ -794,6 +835,16 @@ export function computeWithdrawalTransactionStatus(
major: TransactionMajorState.Aborted,
minor: TransactionMinorState.CompletedByOtherWallet,
};
+ case WithdrawalGroupStatus.PendingBalanceKyc:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.BalanceKycRequired,
+ };
+ case WithdrawalGroupStatus.SuspendedBalanceKyc:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.BalanceKycRequired,
+ };
}
}
@@ -865,9 +916,51 @@ export function computeWithdrawalTransactionActions(
return [TransactionAction.Delete];
case WithdrawalGroupStatus.DialogProposed:
return [TransactionAction.Abort];
+ case WithdrawalGroupStatus.PendingBalanceKyc:
+ return [
+ TransactionAction.Suspend,
+ TransactionAction.Retry,
+ TransactionAction.Abort,
+ ];
+ case WithdrawalGroupStatus.SuspendedBalanceKyc:
+ return [TransactionAction.Resume, TransactionAction.Abort];
}
}
+async function processWithdrawalGroupBalanceKyc(
+ ctx: WithdrawTransactionContext,
+ withdrawalGroup: WithdrawalGroupRecord,
+): Promise<TaskRunResult> {
+ const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl;
+ const amount = withdrawalGroup.effectiveWithdrawalAmount;
+ if (!exchangeBaseUrl) {
+ throw Error(
+ "invalid state (expected withdrawal group to have exchange base URL)",
+ );
+ }
+ if (!amount) {
+ throw Error(
+ "invalid state (expected withdrawal group to have effective withdrawal amount)",
+ );
+ }
+ await waitIncomingAmountLegalUnderKycBalanceThreshold(
+ ctx.wex,
+ exchangeBaseUrl,
+ amount,
+ );
+ await ctx.transition({}, async (wg) => {
+ if (!wg) {
+ return TransitionResult.stay();
+ }
+ if (wg.status !== WithdrawalGroupStatus.PendingBalanceKyc) {
+ return TransitionResult.stay();
+ }
+ wg.status = WithdrawalGroupStatus.PendingReady;
+ return TransitionResult.transition(wg);
+ });
+ return TaskRunResult.progress();
+}
+
async function processWithdrawalGroupDialogProposed(
ctx: WithdrawTransactionContext,
withdrawalGroup: WithdrawalGroupRecord,
@@ -2080,6 +2173,33 @@ async function processWithdrawalGroupPendingReady(
return TaskRunResult.finished();
}
+ checkDbInvariant(
+ withdrawalGroup.effectiveWithdrawalAmount != null,
+ "expected withdrawal group to have effective amount",
+ );
+
+ const kycCheckRes = await checkIncomingAmountLegalUnderKycBalanceThreshold(
+ wex,
+ withdrawalGroup.exchangeBaseUrl,
+ withdrawalGroup.effectiveWithdrawalAmount,
+ );
+
+ if (kycCheckRes.result === "violation") {
+ // Do this before we transition so that the exchange is already in the right state.
+ await handleStartExchangeWalletKyc(wex, {
+ amount: kycCheckRes.nextThreshold,
+ exchangeBaseUrl,
+ });
+ await ctx.transition({}, async (wg) => {
+ if (!wg) {
+ return TransitionResult.stay();
+ }
+ wg.status = WithdrawalGroupStatus.PendingBalanceKyc;
+ return TransitionResult.transition(wg);
+ });
+ return TaskRunResult.progress();
+ }
+
const numTotalCoins = withdrawalGroup.denomsSel.selectedDenoms
.map((x) => x.count)
.reduce((a, b) => a + b);
@@ -2268,6 +2388,8 @@ export async function processWithdrawalGroup(
return await processWithdrawalGroupAbortingBank(wex, withdrawalGroup);
case WithdrawalGroupStatus.DialogProposed:
return await processWithdrawalGroupDialogProposed(ctx, withdrawalGroup);
+ case WithdrawalGroupStatus.PendingBalanceKyc:
+ return await processWithdrawalGroupBalanceKyc(ctx, withdrawalGroup);
case WithdrawalGroupStatus.AbortedBank:
case WithdrawalGroupStatus.AbortedExchange:
case WithdrawalGroupStatus.FailedAbortingBank:
@@ -2277,6 +2399,7 @@ export async function processWithdrawalGroup(
case WithdrawalGroupStatus.SuspendedReady:
case WithdrawalGroupStatus.SuspendedRegisteringBank:
case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
+ case WithdrawalGroupStatus.SuspendedBalanceKyc:
case WithdrawalGroupStatus.Done:
case WithdrawalGroupStatus.FailedBankAborted:
case WithdrawalGroupStatus.AbortedUserRefused: