commit 2015d76af1721e81cc00fd6640ec07b94fc65cb6
parent c6dd41b63c9e5c88d5e916bacb258c37b1efc11b
Author: Florian Dold <florian@dold.me>
Date: Sun, 4 May 2025 00:26:35 +0200
wallet-core: improve handling of balance thresholds, extend test
Diffstat:
5 files changed, 160 insertions(+), 50 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
@@ -23,6 +23,10 @@ import {
ExchangeWalletKycStatus,
hashFullPaytoUri,
j2s,
+ LimitOperationType,
+ TalerProtocolDuration,
+ TalerProtocolTimestamp,
+ TalerWireGatewayHttpClient,
TransactionIdStr,
TransactionMajorState,
TransactionMinorState,
@@ -31,7 +35,7 @@ import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
import {
configureCommonKyc,
createKycTestkudosEnvironment,
- postAmlDecisionNoRules,
+ postAmlDecision,
withdrawViaBankV3,
} from "../harness/environments.js";
import { GlobalTestState } from "../harness/harness.js";
@@ -67,10 +71,15 @@ function adjustExchangeConfig(config: Configuration): void {
export async function runKycBalanceWithdrawalTest(t: GlobalTestState) {
// Set up test environment
- const { walletClient, bankClient, exchange, amlKeypair } =
- await createKycTestkudosEnvironment(t, {
- adjustExchangeConfig,
- });
+ const {
+ walletClient,
+ bankClient,
+ exchange,
+ amlKeypair,
+ exchangeBankAccount,
+ } = await createKycTestkudosEnvironment(t, {
+ adjustExchangeConfig,
+ });
// Withdraw digital cash into the wallet.
@@ -130,16 +139,78 @@ export async function runKycBalanceWithdrawalTest(t: GlobalTestState) {
`payto://taler-reserve-http/localhost:${exchange.port}/${kycReservePub}`,
);
- await postAmlDecisionNoRules(t, {
+ await postAmlDecision(t, {
amlPriv: amlKeypair.priv,
amlPub: amlKeypair.pub,
exchangeBaseUrl: exchange.baseUrl,
paytoHash: encodeCrock(hPayto),
+ newRules: {
+ expiration_time: TalerProtocolTimestamp.never(),
+ rules: [
+ {
+ measures: ["verboten"],
+ display_priority: 1,
+ operation_type: LimitOperationType.balance,
+ threshold: "TESTKUDOS:30",
+ timeframe: TalerProtocolDuration.forever(),
+ exposed: true,
+ },
+ {
+ measures: ["verboten"],
+ display_priority: 1,
+ operation_type: LimitOperationType.deposit,
+ threshold: "TESTKUDOS:5",
+ timeframe: TalerProtocolDuration.fromSpec({ days: 1 }),
+ exposed: true,
+ },
+ ],
+ custom_measures: {},
+ },
});
}
// Now after KYC is done for the balance, the withdrawal should finish
await wres.withdrawalFinishedCond;
+
+ // Now test the *next* threshold, but this time using manual withdrawal
+
+ t.logStep("testing manual withdrawal");
+
+ const mwResp = await walletClient.call(
+ WalletApiOperation.AcceptManualWithdrawal,
+ {
+ // Specify larger amount than what will be in the reserve.
+ amount: "TESTKUDOS:100",
+ exchangeBaseUrl: exchange.baseUrl,
+ },
+ );
+
+ const user = await bankClient.createRandomBankUser();
+
+ // Next, do a manual withdrawal.
+ const wireGatewayApiClient = new TalerWireGatewayHttpClient(
+ exchangeBankAccount.wireGatewayApiBaseUrl,
+ );
+
+ const reservePub = mwResp.reservePub;
+
+ await wireGatewayApiClient.addIncoming({
+ auth: exchangeBankAccount.wireGatewayAuth,
+ body: {
+ amount: "TESTKUDOS:5",
+ debit_account: user.accountPaytoUri,
+ reserve_pub: reservePub,
+ },
+ });
+
+ // Wallet should not require KYC, since the actual amount withdrawn
+ // does not pass the threshold.
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: mwResp.transactionId as TransactionIdStr,
+ txState: {
+ major: TransactionMajorState.Done,
+ },
+ });
}
runKycBalanceWithdrawalTest.suites = ["wallet"];
diff --git a/packages/taler-util/src/time.ts b/packages/taler-util/src/time.ts
@@ -101,6 +101,12 @@ export namespace TalerProtocolDuration {
export function fromSpec(d: DurationUnitSpec) {
return Duration.toTalerProtocolDuration(Duration.fromSpec(d));
}
+
+ export function forever(): TalerProtocolDuration {
+ return {
+ d_us: "forever",
+ };
+ }
}
export namespace TalerProtocolTimestamp {
diff --git a/packages/taler-util/src/types-taler-exchange.ts b/packages/taler-util/src/types-taler-exchange.ts
@@ -1850,6 +1850,9 @@ export interface WalletKycRequest {
reserve_pub: EddsaPublicKeyString;
}
+/**
+ * Doc name: api-exchange/WalletKycCheckResponse
+ */
export interface WalletKycCheckResponse {
// Next balance limit above which a KYC check
// may be required. Optional, not given if no
diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts
@@ -61,6 +61,7 @@ import {
HttpStatusCode,
LegitimizationNeededResponse,
LibtoolVersion,
+ LimitOperationType,
ListExchangesRequest,
Logger,
NotificationType,
@@ -96,6 +97,7 @@ import {
checkDbInvariant,
checkLogicInvariant,
codecForAccountKycStatus,
+ codecForAmlWalletKycCheckResponse,
codecForExchangeKeysResponse,
codecForLegitimizationNeededResponse,
durationMul,
@@ -3288,7 +3290,12 @@ export async function checkIncomingAmountLegalUnderKycBalanceThreshold(
checkDbInvariant(!!reserveRec, "reserve");
// FIXME: also consider KYC expiration!
if (reserveRec.thresholdNext) {
- if (Amounts.cmp(reserveRec.thresholdNext, balExpected) >= 0) {
+ logger.trace(
+ `Next threshold ${Amounts.stringify(
+ reserveRec.thresholdNext,
+ )} not yet breached`,
+ );
+ if (Amounts.cmp(reserveRec.thresholdNext, balExpected) <= 0) {
return {
result: "ok",
};
@@ -3296,6 +3303,9 @@ export async function checkIncomingAmountLegalUnderKycBalanceThreshold(
} 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.
+ logger.warn(
+ `No next balance threshold, assuming balance KYC is okay`,
+ );
return {
result: "ok",
};
@@ -3549,11 +3559,15 @@ async function handleExchangeKycPendingWallet(
case HttpStatusCode.Ok: {
// KYC somehow already passed
// FIXME: Store next threshold and timestamp!
- const accountKycStatus = await readSuccessResponseJsonOrThrow(
+ const walletKycResp = await readSuccessResponseJsonOrThrow(
res,
- codecForAccountKycStatus(),
+ codecForAmlWalletKycCheckResponse(),
+ );
+ return handleExchangeKycSuccess(
+ wex,
+ exchange.baseUrl,
+ walletKycResp.next_threshold,
);
- return handleExchangeKycSuccess(wex, exchange.baseUrl, accountKycStatus);
}
case HttpStatusCode.NoContent: {
// KYC disabled at exchange.
@@ -3581,7 +3595,7 @@ async function handleExchangeKycPendingWallet(
async function handleExchangeKycSuccess(
wex: WalletExecutionContext,
exchangeBaseUrl: string,
- accountKycStatus: AccountKycStatus | undefined,
+ nextThreshold: AmountLike | undefined,
): Promise<TaskRunResult> {
logger.info(`kyc check for ${exchangeBaseUrl} satisfied`);
const dbRes = await wex.db.runReadWriteTx(
@@ -3610,16 +3624,9 @@ async function handleExchangeKycSuccess(
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;
- }
- }
+ if (nextThreshold) {
+ reserve.thresholdNext = Amounts.stringify(nextThreshold);
}
- reserve.thresholdNext = nextLimit;
await tx.reserves.put(reserve);
logger.info(`newly granted threshold: ${reserve.thresholdGranted}`);
@@ -3639,6 +3646,21 @@ async function handleExchangeKycSuccess(
return TaskRunResult.progress();
}
+function findNextBalanceLimit(
+ accountKycStatus: AccountKycStatus,
+ currentGranted: AmountLike | undefined,
+): AmountString | undefined {
+ for (const lim of accountKycStatus.limits ?? []) {
+ if (
+ lim.operation_type === LimitOperationType.balance &&
+ (currentGranted == null || Amounts.cmp(lim.threshold, currentGranted) > 0)
+ ) {
+ return lim.threshold;
+ }
+ }
+ return undefined;
+}
+
/**
* The exchange has just told us that we need some legitimization
* from the user. Request more details and store the result in the database.
@@ -3670,7 +3692,12 @@ async function handleExchangeKycRespLegi(
resp,
codecForAccountKycStatus(),
);
- return handleExchangeKycSuccess(wex, exchangeBaseUrl, accountKycStatus);
+ logger.trace(`balance KYC account status: ${j2s(accountKycStatus)}`);
+ const nextLimit = findNextBalanceLimit(
+ accountKycStatus,
+ reserve.thresholdGranted,
+ );
+ return handleExchangeKycSuccess(wex, exchangeBaseUrl, nextLimit);
}
case HttpStatusCode.Accepted: {
// Store the result in the DB!
@@ -3776,7 +3803,12 @@ async function handleExchangeKycPendingLegitimization(
resp,
codecForAccountKycStatus(),
);
- return handleExchangeKycSuccess(wex, exchange.baseUrl, accountKycStatus);
+ logger.trace(`balance KYC account status: ${j2s(accountKycStatus)}`);
+ const nextLimit = findNextBalanceLimit(
+ accountKycStatus,
+ reserve.thresholdGranted,
+ );
+ return handleExchangeKycSuccess(wex, exchange.baseUrl, nextLimit);
}
case HttpStatusCode.Accepted:
// FIXME: Do we ever need to update the access token?
diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts
@@ -1038,7 +1038,7 @@ async function processWithdrawalGroupBalanceKyc(
}
if (
withdrawalGroup.status ===
- WithdrawalGroupStatus.PendingBalanceKycInit &&
+ WithdrawalGroupStatus.PendingBalanceKycInit &&
checkRes.walletKycStatus === ExchangeWalletKycStatus.Legi
) {
return checkRes;
@@ -2061,7 +2061,8 @@ export async function updateWithdrawalDenomsForExchange(
denom.verificationStatus === DenominationVerificationStatus.Unverified
) {
logger.trace(
- `Validating denomination (${current + 1}/${denominations.length
+ `Validating denomination (${current + 1}/${
+ denominations.length
}) signature of ${denom.denomPubHash}`,
);
let valid = false;
@@ -2563,6 +2564,8 @@ async function processWithdrawalGroupPendingReady(
withdrawalGroup.effectiveWithdrawalAmount,
);
+ logger.info(`balance-kyc check result: ${j2s(kycCheckRes)}`);
+
if (kycCheckRes.result === "violation") {
// Do this before we transition so that the exchange is already in the right state.
await handleStartExchangeWalletKyc(wex, {
@@ -2621,7 +2624,6 @@ async function processWithdrawalGroupPendingReady(
for (let i = 0; i < numTotalCoins; i += maxBatchSize) {
let resp: WithdrawalBatchResult;
if (exchangeVer.current >= 26) {
- logger.warn("new exchange version, but still using old batch request");
resp = await processPlanchetExchangeBatchRequest(wex, wgContext, {
batchSize: maxBatchSize,
coinStartIndex: i,
@@ -2752,7 +2754,7 @@ export async function processWithdrawalGroup(
logger.trace("processing withdrawal group", withdrawalGroupId);
const withdrawalGroup = await wex.db.runReadOnlyTx(
{ storeNames: ["withdrawalGroups"] },
- (tx) => tx.withdrawalGroups.get(withdrawalGroupId)
+ (tx) => tx.withdrawalGroups.get(withdrawalGroupId),
);
if (!withdrawalGroup) {
@@ -2873,7 +2875,7 @@ export async function getExchangeWithdrawalInfo(
) {
logger.warn(
`wallet's support for exchange protocol version ${WALLET_EXCHANGE_PROTOCOL_VERSION} might be outdated ` +
- `(exchange has ${exchange.protocolVersionRange}), checking for updates`,
+ `(exchange has ${exchange.protocolVersionRange}), checking for updates`,
);
}
}
@@ -3059,9 +3061,8 @@ async function getWithdrawalGroupRecordTx(
withdrawalGroupId: string;
},
): Promise<WithdrawalGroupRecord | undefined> {
- return await db.runReadOnlyTx(
- { storeNames: ["withdrawalGroups"] },
- (tx) => tx.withdrawalGroups.get(req.withdrawalGroupId)
+ return await db.runReadOnlyTx({ storeNames: ["withdrawalGroups"] }, (tx) =>
+ tx.withdrawalGroups.get(req.withdrawalGroupId),
);
}
@@ -3100,7 +3101,7 @@ async function registerReserveWithBank(
): Promise<TaskRunResult> {
const withdrawalGroup = await wex.db.runReadOnlyTx(
{ storeNames: ["withdrawalGroups"] },
- (tx) => tx.withdrawalGroups.get(withdrawalGroupId)
+ (tx) => tx.withdrawalGroups.get(withdrawalGroupId),
);
const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
switch (withdrawalGroup?.status) {
@@ -3426,11 +3427,7 @@ async function getInitialDenomsSelection(
if (forcedDenoms) {
logger.warn("using forced denom selection");
- return selectForcedWithdrawalDenominations(
- amount,
- denoms,
- forcedDenoms,
- );
+ return selectForcedWithdrawalDenominations(amount, denoms, forcedDenoms);
} else {
return selectWithdrawalDenominations(amount, denoms);
}
@@ -3528,9 +3525,9 @@ export async function internalPrepareCreateWithdrawalGroup(
!amount || !exchangeBaseUrl
? undefined
: {
- amount,
- canonExchange: exchangeBaseUrl,
- },
+ amount,
+ canonExchange: exchangeBaseUrl,
+ },
};
}
@@ -3688,7 +3685,8 @@ export async function prepareBankIntegratedWithdrawal(
): Promise<PrepareBankIntegratedWithdrawalResponse> {
const existingWithdrawalGroup = await wex.db.runReadOnlyTx(
{ storeNames: ["withdrawalGroups"] },
- (tx) => tx.withdrawalGroups.indexes.byTalerWithdrawUri.get(req.talerWithdrawUri)
+ (tx) =>
+ tx.withdrawalGroups.indexes.byTalerWithdrawUri.get(req.talerWithdrawUri),
);
const parsedUri = parseTalerUri(req.talerWithdrawUri);
@@ -3807,7 +3805,7 @@ export async function confirmWithdrawal(
}
const withdrawalGroup = await wex.db.runReadOnlyTx(
{ storeNames: ["withdrawalGroups"] },
- (tx) => tx.withdrawalGroups.get(parsedTx.withdrawalGroupId)
+ (tx) => tx.withdrawalGroups.get(parsedTx.withdrawalGroupId),
);
if (!withdrawalGroup) {
@@ -3916,7 +3914,7 @@ export async function confirmWithdrawal(
const bankAccountId = await wex.db.runReadWriteTx(
{ storeNames: ["bankAccountsV2"] },
- (tx) => storeKnownBankAccount(tx, instructedCurrency, senderWire)
+ (tx) => storeKnownBankAccount(tx, instructedCurrency, senderWire),
);
if (bankAccountId) {
@@ -4002,14 +4000,13 @@ export async function confirmWithdrawal(
await wex.taskScheduler.resetTaskRetries(ctx.taskId);
// FIXME: Merge with transaction above!
- const res = await wex.db.runReadWriteTx(
- { storeNames: ["exchanges"] },
- (tx) => internalPerformExchangeWasUsed(
+ const res = await wex.db.runReadWriteTx({ storeNames: ["exchanges"] }, (tx) =>
+ internalPerformExchangeWasUsed(
wex,
tx,
exchange.exchangeBaseUrl,
withdrawalGroup,
- )
+ ),
);
if (res.exchangeNotif) {
wex.ws.notify(res.exchangeNotif);
@@ -4106,7 +4103,8 @@ export async function acceptBankIntegratedWithdrawal(
const newWithdrawralGroup = await wex.db.runReadOnlyTx(
{ storeNames: ["withdrawalGroups"] },
- (tx) => tx.withdrawalGroups.indexes.byTalerWithdrawUri.get(req.talerWithdrawUri)
+ (tx) =>
+ tx.withdrawalGroups.indexes.byTalerWithdrawUri.get(req.talerWithdrawUri),
);
checkDbInvariant(
@@ -4173,7 +4171,7 @@ async function fetchAccount(
// fetch currency specification from DB
const resp = await wex.db.runReadOnlyTx(
{ storeNames: ["currencyInfo"] },
- (tx) => WalletDbHelpers.getCurrencyInfo(tx, scopeInfo)
+ (tx) => WalletDbHelpers.getCurrencyInfo(tx, scopeInfo),
);
if (resp) {
@@ -4307,7 +4305,7 @@ export async function createManualWithdrawal(
const exchangePaytoUris = await wex.db.runReadOnlyTx(
{ storeNames: ["withdrawalGroups", "exchanges", "exchangeDetails"] },
- (tx) => getFundingPaytoUris(tx, withdrawalGroup.withdrawalGroupId)
+ (tx) => getFundingPaytoUris(tx, withdrawalGroup.withdrawalGroupId),
);
wex.ws.notify({
@@ -4346,7 +4344,7 @@ export async function waitWithdrawalFinal(
// Check if withdrawal is final
const wg = await ctx.wex.db.runReadOnlyTx(
{ storeNames: ["withdrawalGroups"] },
- (tx) => tx.withdrawalGroups.get(ctx.withdrawalGroupId)
+ (tx) => tx.withdrawalGroups.get(ctx.withdrawalGroupId),
);
if (!wg) {
// Must've been deleted, we consider that final.