commit 296afa3d9ccc7f8102dab1a3ae9b48c8ca84537e
parent d49344dae8b92bd458418ac20d93f61616897dc2
Author: Florian Dold <florian@dold.me>
Date: Mon, 19 Aug 2024 13:32:34 +0200
wallet-core: make withdrawal KYC work with the new exchange API
Diffstat:
5 files changed, 188 insertions(+), 155 deletions(-)
diff --git a/packages/taler-harness/src/harness/helpers.ts b/packages/taler-harness/src/harness/helpers.ts
@@ -24,16 +24,23 @@
* Imports
*/
import {
+ AmlDecisionRequest,
+ AmlDecisionRequestWithoutSignature,
AmountString,
ConfirmPayResultType,
+ decodeCrock,
Duration,
+ encodeCrock,
+ HttpStatusCode,
Logger,
MerchantApiClient,
NotificationType,
PartialWalletRunConfig,
PreparePayResultType,
+ signAmlDecision,
TalerCorebankApiClient,
TalerMerchantApi,
+ TalerProtocolTimestamp,
TransactionMajorState,
WalletNotification,
} from "@gnu-taler/taler-util";
@@ -49,19 +56,20 @@ import {
ExchangeService,
ExchangeServiceInterface,
FakebankService,
+ generateRandomPayto,
GlobalTestState,
HarnessExchangeBankAccount,
+ harnessHttpLib,
LibeufinBankService,
MerchantService,
MerchantServiceInterface,
+ setupDb,
+ setupSharedDb,
+ useLibeufinBank,
WalletCli,
WalletClient,
WalletService,
WithAuthorization,
- generateRandomPayto,
- setupDb,
- setupSharedDb,
- useLibeufinBank,
} from "./harness.js";
import * as fs from "fs";
@@ -558,7 +566,8 @@ export async function createSimpleTestkudosEnvironmentV3(
),
});
- const { walletClient, walletService } = await createWalletDaemonWithClient(t,
+ const { walletClient, walletService } = await createWalletDaemonWithClient(
+ t,
{
name: "wallet",
persistent: true,
@@ -623,7 +632,9 @@ export async function createWalletDaemonWithClient(
const defaultRunConfig = {
testing: {
skipDefaults: true,
- emitObservabilityEvents: !!process.env["TALER_TEST_OBSERVABILITY"] || !!args.emitObservabilityEvents,
+ emitObservabilityEvents:
+ !!process.env["TALER_TEST_OBSERVABILITY"] ||
+ !!args.emitObservabilityEvents,
},
} satisfies PartialWalletRunConfig;
await walletClient.client.call(WalletApiOperation.InitWallet, {
@@ -961,3 +972,52 @@ export async function makeTestPaymentV2(
t.assertTrue(orderStatus.order_status === "paid");
}
+
+/**
+ * Post an AML decision that no rules shall apply for the given account.
+ */
+export async function postAmlDecisionNoRules(
+ t: GlobalTestState,
+ req: {
+ exchangeBaseUrl: string;
+ paytoHash: string;
+ amlPriv: string;
+ amlPub: string;
+ },
+) {
+ const { exchangeBaseUrl, paytoHash, amlPriv, amlPub } = req;
+
+ const sigData: AmlDecisionRequestWithoutSignature = {
+ decision_time: TalerProtocolTimestamp.now(),
+ h_payto: paytoHash,
+ justification: "Bla",
+ keep_investigating: false,
+ new_rules: {
+ custom_measures: {},
+ expiration_time: TalerProtocolTimestamp.never(),
+ rules: [],
+ successor_measure: undefined,
+ },
+ properties: {
+ foo: "42",
+ },
+ };
+
+ const sig = signAmlDecision(decodeCrock(amlPriv), sigData);
+
+ const reqBody: AmlDecisionRequest = {
+ ...sigData,
+ officer_sig: encodeCrock(sig),
+ };
+
+ const reqUrl = new URL(`aml/${amlPub}/decision`, exchangeBaseUrl);
+
+ const resp = await harnessHttpLib.fetch(reqUrl.href, {
+ method: "POST",
+ body: reqBody,
+ });
+
+ console.log(`aml decision status: ${resp.status}`);
+
+ t.assertDeepEqual(resp.status, HttpStatusCode.NoContent);
+}
diff --git a/packages/taler-harness/src/integrationtests/test-kyc-exchange-wallet.ts b/packages/taler-harness/src/integrationtests/test-kyc-exchange-wallet.ts
@@ -18,17 +18,11 @@
* Imports.
*/
import {
- AmlDecisionRequest,
- AmlDecisionRequestWithoutSignature,
- decodeCrock,
encodeCrock,
ExchangeWalletKycStatus,
hashPaytoUri,
- HttpStatusCode,
j2s,
- signAmlDecision,
TalerCorebankApiClient,
- TalerProtocolTimestamp,
} from "@gnu-taler/taler-util";
import {
createSyncCryptoApi,
@@ -43,12 +37,11 @@ import {
generateRandomPayto,
GlobalTestState,
HarnessExchangeBankAccount,
- harnessHttpLib,
setupDb,
WalletClient,
WalletService,
} from "../harness/harness.js";
-import { EnvOptions } from "../harness/helpers.js";
+import { EnvOptions, postAmlDecisionNoRules } from "../harness/helpers.js";
interface KycTestEnv {
commonDb: DbInfo;
@@ -238,41 +231,12 @@ export async function runKycExchangeWalletTest(t: GlobalTestState) {
console.log(`hPayto: ${hPayto}`);
- {
- const sigData: AmlDecisionRequestWithoutSignature = {
- decision_time: TalerProtocolTimestamp.now(),
- h_payto: encodeCrock(hPayto),
- justification: "Bla",
- keep_investigating: false,
- new_rules: {
- custom_measures: {},
- expiration_time: TalerProtocolTimestamp.never(),
- rules: [],
- successor_measure: undefined,
- },
- properties: {
- foo: "42",
- },
- };
-
- const sig = signAmlDecision(decodeCrock(amlKeypair.priv), sigData);
-
- const reqBody: AmlDecisionRequest = {
- ...sigData,
- officer_sig: encodeCrock(sig),
- };
-
- const reqUrl = new URL(`aml/${amlKeypair.pub}/decision`, exchange.baseUrl);
-
- const resp = await harnessHttpLib.fetch(reqUrl.href, {
- method: "POST",
- body: reqBody,
- });
-
- console.log(`aml decision status: ${resp.status}`);
-
- t.assertDeepEqual(resp.status, HttpStatusCode.NoContent);
- }
+ await postAmlDecisionNoRules(t, {
+ amlPriv: amlKeypair.priv,
+ amlPub: amlKeypair.pub,
+ exchangeBaseUrl: exchange.baseUrl,
+ paytoHash: encodeCrock(hPayto),
+ });
await walletClient.call(WalletApiOperation.TestingWaitExchangeWalletKyc, {
amount: "TESTKUDOS:20",
diff --git a/packages/taler-harness/src/integrationtests/test-kyc-threshold-withdrawal.ts b/packages/taler-harness/src/integrationtests/test-kyc-threshold-withdrawal.ts
@@ -17,26 +17,49 @@
/**
* Imports.
*/
-import { Duration, TalerCorebankApiClient } from "@gnu-taler/taler-util";
-import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import {
+ encodeCrock,
+ hashPaytoUri,
+ NotificationType,
+ TalerCorebankApiClient,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionType,
+} 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,
- MerchantService,
+ HarnessExchangeBankAccount,
+ setupDb,
WalletClient,
WalletService,
- generateRandomPayto,
- setupDb,
} from "../harness/harness.js";
-import { EnvOptions, SimpleTestEnvironmentNg3 } from "../harness/helpers.js";
+import { EnvOptions, postAmlDecisionNoRules } 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<SimpleTestEnvironmentNg3> {
+): Promise<KycTestEnv> {
const db = await setupDb(t);
const bank = await BankService.create(t, {
@@ -53,13 +76,6 @@ async function createKycTestkudosEnvironment(
database: db.connStr,
});
- const merchant = await MerchantService.create(t, {
- name: "testmerchant-1",
- currency: "TESTKUDOS",
- httpPort: 8083,
- database: db.connStr,
- });
-
let receiverName = "Exchange";
let exchangeBankUsername = "exchange";
let exchangeBankPassword = "mypw";
@@ -119,12 +135,14 @@ async function createKycTestkudosEnvironment(
}
await exchange.modifyConfig(async (config) => {
- config.setString("KYC-RULE-R1", "operation_type", "balance");
+ config.setString("exchange", "enable_kyc", "yes");
+
+ config.setString("KYC-RULE-R1", "operation_type", "withdraw");
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:5");
- config.setString("KYC-RULE-R1", "timeframe", "forever");
+ config.setString("KYC-RULE-R1", "timeframe", "1d");
config.setString("KYC-RULE-R1", "next_measures", "M1");
config.setString("KYC-MEASURE-M1", "check_name", "C1");
@@ -142,30 +160,11 @@ async function createKycTestkudosEnvironment(
});
await exchange.start();
- await exchange.pingUntilAvailable();
- merchant.addExchange(exchange);
+ const cryptoApi = createSyncCryptoApi();
+ const amlKeypair = await cryptoApi.createEddsaKeypair({});
- await merchant.start();
- await merchant.pingUntilAvailable();
-
- await merchant.addInstanceWithWireAccount({
- id: "default",
- name: "Default Instance",
- paytoUris: [generateRandomPayto("merchant-default")],
- defaultWireTransferDelay: Duration.toTalerProtocolDuration(
- Duration.fromSpec({ minutes: 1 }),
- ),
- });
-
- await merchant.addInstanceWithWireAccount({
- id: "minst1",
- name: "minst1",
- paytoUris: [generateRandomPayto("minst1")],
- defaultWireTransferDelay: Duration.toTalerProtocolDuration(
- Duration.fromSpec({ minutes: 1 }),
- ),
- });
+ await exchange.enableAmlAccount(amlKeypair.pub, "Alice");
const walletService = new WalletService(t, {
name: "wallet",
@@ -195,7 +194,7 @@ async function createKycTestkudosEnvironment(
return {
commonDb: db,
exchange,
- merchant,
+ amlKeypair,
walletClient,
walletService,
bankClient,
@@ -211,7 +210,7 @@ async function createKycTestkudosEnvironment(
export async function runKycThresholdWithdrawalTest(t: GlobalTestState) {
// Set up test environment
- const { walletClient, bankClient, exchange, merchant } =
+ const { walletClient, bankClient, exchange, amlKeypair } =
await createKycTestkudosEnvironment(t);
// Withdraw digital cash into the wallet.
@@ -252,19 +251,50 @@ export async function runKycThresholdWithdrawalTest(t: GlobalTestState) {
withdrawalOperationId: wop.withdrawal_id,
});
- // const kycNotificationCond = walletClient.waitForNotificationCond((x) => {
- // if (
- // x.type === NotificationType.TransactionStateTransition &&
- // x.transactionId === withdrawalTxId &&
- // x.newTxState.major === TransactionMajorState.Pending &&
- // x.newTxState.minor === TransactionMinorState.KycRequired
- // ) {
- // return x;
- // }
- // return false;
- // });
-
- // await kycNotificationCond;
+ const kycNotificationCond = walletClient.waitForNotificationCond((x) => {
+ if (
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === withdrawalTxId &&
+ x.newTxState.major === TransactionMajorState.Pending &&
+ x.newTxState.minor === TransactionMinorState.KycRequired
+ ) {
+ return x;
+ }
+ return false;
+ });
+
+ await kycNotificationCond;
+
+
+ const txDet = await walletClient.call(WalletApiOperation.GetTransactionById, {
+ transactionId: withdrawalTxId,
+ });
+
+ t.assertDeepEqual(txDet.type, TransactionType.Withdrawal);
+
+ const kycPaytoHash = txDet.kycPaytoHash;
+ t.assertTrue(!!kycPaytoHash);
+
+
+ await postAmlDecisionNoRules(t, {
+ amlPriv: amlKeypair.priv,
+ amlPub: amlKeypair.pub,
+ exchangeBaseUrl: exchange.baseUrl,
+ paytoHash: kycPaytoHash,
+ });
+
+ const doneNotificationCond = walletClient.waitForNotificationCond((x) => {
+ if (
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === withdrawalTxId &&
+ x.newTxState.major === TransactionMajorState.Done
+ ) {
+ return x;
+ }
+ return false;
+ });
+
+ await doneNotificationCond;
}
runKycThresholdWithdrawalTest.suites = ["wallet"];
diff --git a/packages/taler-util/src/types-taler-wallet-transactions.ts b/packages/taler-util/src/types-taler-wallet-transactions.ts
@@ -221,6 +221,8 @@ export interface TransactionCommon {
* have the location where the user need to go to complete KYC information.
*/
kycUrl?: string;
+
+ kycPaytoHash?: string;
}
export type Transaction =
diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts
@@ -85,6 +85,7 @@ import {
assertUnreachable,
checkDbInvariant,
checkLogicInvariant,
+ codecForAccountKycStatus,
codecForBankWithdrawalOperationPostResponse,
codecForBankWithdrawalOperationStatus,
codecForCashinConversionResponse,
@@ -228,6 +229,7 @@ function buildTransactionForBankIntegratedWithdraw(
wg.status === WithdrawalGroupStatus.PendingReady,
},
kycUrl: wg.kycUrl,
+ kycPaytoHash: wg.kycPending?.paytoHash,
timestamp: timestampPreciseFromDb(wg.timestampStart),
transactionId: constructTransactionIdentifier({
tag: TransactionType.Withdrawal,
@@ -1190,23 +1192,25 @@ async function handleKycRequired(
const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
logger.info(`kyc uuid response: ${j2s(uuidResp)}`);
const exchangeUrl = withdrawalGroup.exchangeBaseUrl;
- const userType = "individual";
const kycInfo: KycPendingInfo = {
paytoHash: uuidResp.h_payto,
requirementRow: uuidResp.requirement_row,
};
- const url = new URL(
- `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
- exchangeUrl,
- );
+ const sigResp = await wex.cryptoApi.signWalletKycAuth({
+ accountPriv: withdrawalGroup.reservePriv,
+ accountPub: withdrawalGroup.reservePub,
+ });
+ const url = new URL(`kyc-check/${kycInfo.requirementRow}`, 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, {
method: "GET",
cancellationToken: wex.cancellationToken,
+ headers: {
+ ["Account-Owner-Signature"]: sigResp.sig,
+ },
});
let kycUrl: string;
- let amlStatus: ExchangeAmlStatus | undefined;
if (
kycStatusRes.status === HttpStatusCode.Ok ||
// FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
@@ -1216,17 +1220,17 @@ async function handleKycRequired(
logger.warn("kyc requested, but already fulfilled");
return;
} else if (kycStatusRes.status === HttpStatusCode.Accepted) {
- const kycStatus = await kycStatusRes.json();
+ const kycStatus = await readSuccessResponseJsonOrThrow(
+ kycStatusRes,
+ codecForAccountKycStatus(),
+ );
logger.info(`kyc status: ${j2s(kycStatus)}`);
- kycUrl = kycStatus.kyc_url;
- } else if (
- kycStatusRes.status === HttpStatusCode.UnavailableForLegalReasons
- ) {
- const kycStatus = await kycStatusRes.json();
- logger.info(`aml status: ${j2s(kycStatus)}`);
- amlStatus = kycStatus.aml_status;
+ kycUrl = new URL(`kyc-spa/${kycStatus.access_token}`, exchangeUrl).href;
} else {
- throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
+ throwUnexpectedRequestError(
+ kycStatusRes,
+ await readTalerErrorResponse(kycStatusRes),
+ );
}
await ctx.transition(
@@ -1256,14 +1260,7 @@ async function handleKycRequired(
requirementRow: uuidResp.requirement_row,
};
wg2.kycUrl = kycUrl;
- wg2.status =
- amlStatus === ExchangeAmlStatus.Normal || amlStatus === undefined
- ? WithdrawalGroupStatus.PendingKyc
- : amlStatus === ExchangeAmlStatus.Pending
- ? WithdrawalGroupStatus.PendingKyc
- : amlStatus === ExchangeAmlStatus.Frozen
- ? WithdrawalGroupStatus.SuspendedKyc
- : assertUnreachable(amlStatus);
+ wg2.status = WithdrawalGroupStatus.PendingKyc;
return TransitionResult.transition(wg2);
},
);
@@ -1837,17 +1834,16 @@ async function processWithdrawalGroupPendingKyc(
wex,
withdrawalGroup.withdrawalGroupId,
);
- const userType = "individual";
const kycInfo = withdrawalGroup.kycPending;
if (!kycInfo) {
throw Error("no kyc info available in pending(kyc)");
}
const exchangeUrl = withdrawalGroup.exchangeBaseUrl;
- const url = new URL(
- `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
- exchangeUrl,
- );
-
+ const url = new URL(`kyc-check/${kycInfo.requirementRow}`, exchangeUrl);
+ const sigResp = await wex.cryptoApi.signWalletKycAuth({
+ accountPriv: withdrawalGroup.reservePriv,
+ accountPub: withdrawalGroup.reservePub,
+ });
const kycStatusRes = await wex.ws.runLongpollQueueing(
wex,
url.hostname,
@@ -1857,6 +1853,9 @@ async function processWithdrawalGroupPendingKyc(
return await wex.http.fetch(url.href, {
method: "GET",
cancellationToken: wex.cancellationToken,
+ headers: {
+ ["Account-Owner-Signature"]: sigResp.sig,
+ },
});
},
);
@@ -1864,8 +1863,6 @@ async function processWithdrawalGroupPendingKyc(
logger.info(`kyc long-polling response status: HTTP ${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
) {
await ctx.transition({}, async (rec) => {
@@ -1884,28 +1881,8 @@ async function processWithdrawalGroupPendingKyc(
}
});
} else if (kycStatusRes.status === HttpStatusCode.Accepted) {
- const kycStatus = await kycStatusRes.json();
- logger.info(`kyc status: ${j2s(kycStatus)}`);
- const kycUrl = kycStatus.kyc_url;
- if (typeof kycUrl === "string") {
- await ctx.transition({}, async (rec) => {
- if (!rec) {
- return TransitionResult.stay();
- }
- switch (rec.status) {
- case WithdrawalGroupStatus.PendingReady: {
- rec.kycUrl = kycUrl;
- return TransitionResult.transition(rec);
- }
- }
- return TransitionResult.stay();
- });
- }
- } else if (
- kycStatusRes.status === HttpStatusCode.UnavailableForLegalReasons
- ) {
- const kycStatus = await kycStatusRes.json();
- logger.info(`aml status: ${j2s(kycStatus)}`);
+ logger.info("kyc not done yet, long-poll remains pending");
+ return TaskRunResult.longpollReturnedPending();
} else {
throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
}