taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

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:
Mpackages/taler-harness/src/harness/helpers.ts | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mpackages/taler-harness/src/integrationtests/test-kyc-exchange-wallet.ts | 50+++++++-------------------------------------------
Mpackages/taler-harness/src/integrationtests/test-kyc-threshold-withdrawal.ts | 136++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mpackages/taler-util/src/types-taler-wallet-transactions.ts | 2++
Mpackages/taler-wallet-core/src/withdraw.ts | 83+++++++++++++++++++++++++++++--------------------------------------------------
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})`); }