commit 3c86bfb1435deba091771ca4e6135fbfd29b70ec parent 947aa424ca0bc214c3e175221636fe6193c939c2 Author: Florian Dold <florian@dold.me> Date: Thu, 8 Aug 2024 16:49:37 +0200 wallet-core: implement basic wallet KYC for balance thresholds Diffstat:
28 files changed, 1297 insertions(+), 290 deletions(-)
diff --git a/packages/taler-harness/src/harness/harness.ts b/packages/taler-harness/src/harness/harness.ts @@ -1618,6 +1618,26 @@ export class ExchangeService implements ExchangeServiceInterface { } } + async enableAmlAccount( + amlStaffPub: string, + legalName: string, + ): Promise<void> { + await runCommand( + this.globalState, + "exchange-offline", + "taler-exchange-offline", + [ + "-c", + this.configFilename, + "aml-enable", + amlStaffPub, + legalName, + "rw", + "upload", + ], + ); + } + async pingUntilAvailable(): Promise<void> { // We request /management/keys, since /keys can block // when we didn't do the key setup yet. diff --git a/packages/taler-harness/src/integrationtests/test-kyc-exchange-wallet.ts b/packages/taler-harness/src/integrationtests/test-kyc-exchange-wallet.ts @@ -0,0 +1,284 @@ +/* + 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 { + AmlDecisionRequest, + AmlDecisionRequestWithoutSignature, + decodeCrock, + encodeCrock, + ExchangeWalletKycStatus, + hashPaytoUri, + HttpStatusCode, + j2s, + signAmlDecision, + TalerCorebankApiClient, + TalerProtocolTimestamp, +} 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, + harnessHttpLib, + setupDb, + WalletClient, + WalletService, +} from "../harness/harness.js"; +import { EnvOptions } 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:5"); + 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, + walletClient, + walletService, + bankClient, + exchangeBankAccount: { + accountName: "", + accountPassword: "", + accountPaytoUri: "", + wireGatewayApiBaseUrl: "", + }, + amlKeypair, + }; +} + +export async function runKycExchangeWalletTest(t: GlobalTestState) { + // Set up test environment + + const { walletClient, exchange, amlKeypair } = + await createKycTestkudosEnvironment(t); + + await walletClient.call(WalletApiOperation.AddExchange, { + exchangeBaseUrl: exchange.baseUrl, + }); + + await walletClient.call(WalletApiOperation.StartExchangeWalletKyc, { + amount: "TESTKUDOS:20", + exchangeBaseUrl: exchange.baseUrl, + }); + + await walletClient.call(WalletApiOperation.TestingWaitExchangeWalletKyc, { + amount: "TESTKUDOS:20", + exchangeBaseUrl: exchange.baseUrl, + passed: false, + }); + + 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}`, + ); + + 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 walletClient.call(WalletApiOperation.TestingWaitExchangeWalletKyc, { + amount: "TESTKUDOS:20", + exchangeBaseUrl: exchange.baseUrl, + passed: true, + }); +} + +runKycExchangeWalletTest.suites = ["wallet"]; diff --git a/packages/taler-harness/src/integrationtests/test-kyc.ts b/packages/taler-harness/src/integrationtests/test-kyc.ts @@ -32,7 +32,7 @@ import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import * as http from "node:http"; import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js"; import { - BankService, + BankService, ExchangeService, GlobalTestState, MerchantService, @@ -81,7 +81,10 @@ async function createKycTestkudosEnvironment( await exchange.addBankAccount("1", { accountName: exchangeBankUsername, accountPassword: exchangeBankPassword, - wireGatewayApiBaseUrl: new URL("accounts/exchange/taler-wire-gateway/", bank.baseUrl).href, + wireGatewayApiBaseUrl: new URL( + "accounts/exchange/taler-wire-gateway/", + bank.baseUrl, + ).href, accountPaytoUri: exchangePaytoUri, }); @@ -236,10 +239,10 @@ async function createKycTestkudosEnvironment( walletService, bankClient, exchangeBankAccount: { - accountName: '', - accountPassword: '', - accountPaytoUri: '', - wireGatewayApiBaseUrl: '', + accountName: "", + accountPassword: "", + accountPaytoUri: "", + wireGatewayApiBaseUrl: "", }, }; } 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 { runKycExchangeWalletTest } from "./test-kyc-exchange-wallet.js"; import { runKycThresholdWithdrawalTest } from "./test-kyc-threshold-withdrawal.js"; import { runKycTest } from "./test-kyc.js"; import { runLibeufinBankTest } from "./test-libeufin-bank.js"; @@ -246,6 +247,7 @@ const allTests: TestMainFunction[] = [ runWithdrawalExternalTest, runWithdrawalIdempotentTest, runKycThresholdWithdrawalTest, + runKycExchangeWalletTest, ]; export interface TestRunSpec { diff --git a/packages/taler-util/src/http-client/exchange.ts b/packages/taler-util/src/http-client/exchange.ts @@ -298,7 +298,6 @@ export class TalerExchangeHttpClient { async uploadKycForm(requirement: KycRequirementInformationId, body: object) { const url = new URL(`kyc-upload/${requirement}`, this.baseUrl); - const resp = await this.httpLib.fetch(url.href, { method: "POST", body, @@ -627,8 +626,7 @@ export class TalerExchangeHttpClient { function buildKYCQuerySignature(key: SigningKey): string { const sigBlob = buildSigPS( - TalerSignaturePurpose.TALER_SIGNATURE_AML_QUERY, - // TalerSignaturePurpose.TALER_SIGNATURE_WALLET_ACCOUNT_SETUP, + TalerSignaturePurpose.AML_QUERY, ).build(); return encodeCrock(eddsaSign(sigBlob, key)); @@ -636,7 +634,7 @@ function buildKYCQuerySignature(key: SigningKey): string { function buildAMLQuerySignature(key: SigningKey): string { const sigBlob = buildSigPS( - TalerSignaturePurpose.TALER_SIGNATURE_AML_QUERY, + TalerSignaturePurpose.AML_QUERY, ).build(); return encodeCrock(eddsaSign(sigBlob, key)); @@ -648,7 +646,7 @@ function buildAMLDecisionSignature( ): AmlDecisionRequest { const zero = new Uint8Array(new ArrayBuffer(64)); - const sigBlob = buildSigPS(TalerSignaturePurpose.TALER_SIGNATURE_AML_DECISION) + const sigBlob = buildSigPS(TalerSignaturePurpose.AML_DECISION) //TODO: new need the null terminator, also in the exchange .put(hash(stringToBytes(decision.justification))) //check null .put(timestampRoundedToBuffer(decision.decision_time)) @@ -664,5 +662,3 @@ function buildAMLDecisionSignature( officer_sig, }; } - - diff --git a/packages/taler-util/src/http-common.ts b/packages/taler-util/src/http-common.ts @@ -274,10 +274,10 @@ export async function readSuccessResponseJsonOrErrorCode<T>( }; } -export async function readResponseJsonOrErrorCode<T>( +export async function readResponseJsonOrThrow<T>( httpResponse: HttpResponse, codec: Codec<T>, -): Promise<{ isError: boolean; response: T }> { +): Promise<T> { let respJson; try { respJson = await httpResponse.json(); @@ -310,13 +310,9 @@ export async function readResponseJsonOrErrorCode<T>( "Response invalid", ); } - return { - isError: !(httpResponse.status >= 200 && httpResponse.status < 300), - response: parsedResponse, - }; + return parsedResponse; } - type HttpErrorDetails = { requestUrl: string; requestMethod: string; diff --git a/packages/taler-util/src/index.ts b/packages/taler-util/src/index.ts @@ -2,59 +2,59 @@ import { TalerErrorCode } from "./taler-error-codes.js"; export { TalerErrorCode }; -export * from "./CancellationToken.js"; -export * from "./MerchantApiClient.js"; -export { RequestThrottler } from "./RequestThrottler.js"; -export * from "./ReserveStatus.js"; -export * from "./ReserveTransaction.js"; -export { TaskThrottler } from "./TaskThrottler.js"; -export * from "./amounts.js"; -export * from "./bank-api-client.js"; -export * from "./base64.js"; -export * from "./bitcoin.js"; -export * from "./codec.js"; -export * from "./contract-terms.js"; -export * from "./errors.js"; -export { fnutil } from "./fnutils.js"; -export * from "./helpers.js"; -export * from "./http-client/authentication.js"; -export * from "./http-client/bank-conversion.js"; -export * from "./http-client/bank-core.js"; -export * from "./http-client/bank-integration.js"; -export * from "./http-client/bank-revenue.js"; -export * from "./http-client/bank-wire.js"; -export * from "./http-client/challenger.js"; -export * from "./http-client/exchange.js"; -export * from "./http-client/merchant.js"; -export * from "./http-client/officer-account.js"; -export { CacheEvictor } from "./http-client/utils.js"; -export * from "./http-status-codes.js"; -export * from "./i18n.js"; -export * from "./iban.js"; -export * from "./invariants.js"; -export * from "./kdf.js"; -export * from "./libtool-version.js"; -export * from "./logging.js"; -export { - crypto_sign_keyPair_fromSeed, - randomBytes, - secretbox, - secretbox_open, - setPRNG, -} from "./nacl-fast.js"; -export * from "./notifications.js"; -export * from "./observability.js"; -export * from "./operation.js"; -export * from "./payto.js"; -export * from "./promises.js"; -export * from "./qr.js"; -export * from "./rfc3548.js"; -export * from "./taler-crypto.js"; -export * from "./taleruri.js"; -export * from "./time.js"; -export * from "./timer.js"; -export * from "./transaction-test-data.js"; -export * from "./url.js"; + export * from "./amounts.js"; + export * from "./bank-api-client.js"; + export * from "./base64.js"; + export * from "./bitcoin.js"; + export * from "./CancellationToken.js"; + export * from "./codec.js"; + export * from "./contract-terms.js"; + export * from "./errors.js"; + export { fnutil } from "./fnutils.js"; + export * from "./helpers.js"; + export * from "./http-client/authentication.js"; + export * from "./http-client/bank-conversion.js"; + export * from "./http-client/bank-core.js"; + export * from "./http-client/bank-integration.js"; + export * from "./http-client/bank-revenue.js"; + export * from "./http-client/bank-wire.js"; + export * from "./http-client/challenger.js"; + export * from "./http-client/exchange.js"; + export * from "./http-client/merchant.js"; + export * from "./http-client/officer-account.js"; + export { CacheEvictor } from "./http-client/utils.js"; + export * from "./http-status-codes.js"; + export * from "./i18n.js"; + export * from "./iban.js"; + export * from "./invariants.js"; + export * from "./kdf.js"; + export * from "./libtool-version.js"; + export * from "./logging.js"; + export * from "./MerchantApiClient.js"; + export { + crypto_sign_keyPair_fromSeed, + randomBytes, + secretbox, + secretbox_open, + setPRNG + } from "./nacl-fast.js"; + export * from "./notifications.js"; + export * from "./observability.js"; + export * from "./operation.js"; + export * from "./payto.js"; + export * from "./promises.js"; + export * from "./qr.js"; + export { RequestThrottler } from "./RequestThrottler.js"; + export * from "./ReserveStatus.js"; + export * from "./ReserveTransaction.js"; + export * from "./rfc3548.js"; + export * from "./taler-crypto.js"; + export * from "./taleruri.js"; + export { TaskThrottler } from "./TaskThrottler.js"; + export * from "./time.js"; + export * from "./timer.js"; + export * from "./transaction-test-data.js"; + export * from "./url.js"; export * from "./types-taler-bank-conversion.js"; export * from "./types-taler-bank-integration.js"; @@ -74,3 +74,5 @@ export * as TalerExchangeApi from "./types-taler-exchange.js"; export * as TalerMerchantApi from "./types-taler-merchant.js"; export * as TalerRevenueApi from "./types-taler-revenue.js"; export * as TalerWireGatewayApi from "./types-taler-wire-gateway.js"; + +export * from "./taler-signatures.js"; diff --git a/packages/taler-util/src/operation.ts b/packages/taler-util/src/operation.ts @@ -19,7 +19,7 @@ */ import { HttpResponse, - readResponseJsonOrErrorCode, + readResponseJsonOrThrow, readSuccessResponseJsonOrThrow, readTalerErrorResponse, } from "./http-common.js"; @@ -127,7 +127,7 @@ export async function opKnownAlternativeFailure<T extends HttpStatusCode, B>( s: T, codec: Codec<B>, ): Promise<OperationAlternative<T, B>> { - const body = (await readResponseJsonOrErrorCode(resp, codec)).response; + const body = await readResponseJsonOrThrow(resp, codec); return { type: "fail", case: s, body }; } diff --git a/packages/taler-util/src/payto.ts b/packages/taler-util/src/payto.ts @@ -25,7 +25,6 @@ import { } from "./codec.js"; import { AccessToken, - bytesToString, codecForAccessToken, codecOptional, hashTruncate32, @@ -214,9 +213,9 @@ export function stringifyPaytoUri(p: PaytoUri): PaytoString { return url.href as PaytoString; } -export function hashPaytoUri(p: PaytoUri): string { - const paytoUri = stringifyPaytoUri(p); - return bytesToString(hashTruncate32(stringToBytes(paytoUri + "\0"))); +export function hashPaytoUri(p: PaytoUri | string): Uint8Array { + const paytoUri = typeof p === "string" ? p : stringifyPaytoUri(p); + return hashTruncate32(stringToBytes(paytoUri + "\0")); } /** diff --git a/packages/taler-util/src/taler-crypto.ts b/packages/taler-util/src/taler-crypto.ts @@ -31,11 +31,12 @@ import { Logger } from "./logging.js"; import * as nacl from "./nacl-fast.js"; import { secretbox } from "./nacl-fast.js"; import { TalerProtocolDuration, TalerProtocolTimestamp } from "./time.js"; +import { CoinPublicKeyString, HashCodeString } from "./types-taler-common.js"; import { - CoinPublicKeyString, - HashCodeString, -} from "./types-taler-common.js"; -import { CoinEnvelope, DenomKeyType, DenominationPubKey } from "./types-taler-exchange.js"; + CoinEnvelope, + DenomKeyType, + DenominationPubKey, +} from "./types-taler-exchange.js"; export type Flavor<T, FlavorT extends string> = T & { _flavor?: `taler.${FlavorT}`; @@ -985,6 +986,7 @@ export enum TalerSignaturePurpose { MERCHANT_REFUND = 1102, WALLET_COIN_RECOUP = 1203, WALLET_COIN_LINK = 1204, + WALLET_ACCOUNT_SETUP = 1205, WALLET_COIN_RECOUP_REFRESH = 1206, WALLET_AGE_ATTESTATION = 1207, WALLET_PURSE_CREATE = 1210, @@ -996,9 +998,10 @@ export enum TalerSignaturePurpose { WALLET_COIN_HISTORY = 1209, EXCHANGE_CONFIRM_RECOUP = 1039, EXCHANGE_CONFIRM_RECOUP_REFRESH = 1041, - TALER_SIGNATURE_AML_DECISION = 1350, - TALER_SIGNATURE_AML_QUERY = 1351, - TALER_SIGNATURE_MASTER_AML_KEY = 1017, + AML_DECISION = 1350, + AML_QUERY = 1351, + MASTER_AML_KEY = 1017, + KYC_AUTH = 1360, ANASTASIS_POLICY_UPLOAD = 1400, ANASTASIS_POLICY_DOWNLOAD = 1401, SYNC_BACKUP_UPLOAD = 1450, diff --git a/packages/taler-util/src/taler-signatures.ts b/packages/taler-util/src/taler-signatures.ts @@ -0,0 +1,63 @@ +/* + This file is part of GNU Taler + (C) 2024 GNUnet e.V. + + 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 { canonicalJson } from "./index.js"; +import { + bufferForUint64, + buildSigPS, + decodeCrock, + eddsaSign, + hash, + stringToBytes, + TalerSignaturePurpose, + timestampRoundedToBuffer, +} from "./taler-crypto.js"; +import { AmlDecisionRequestWithoutSignature } from "./types-taler-exchange.js"; + +/** + * Implementation of Taler protocol signatures. + * + * In this file, we have implementations of signatures that are not used in the wallet, + * but in other places (tests, SPAs, ...). + */ + +/** + * Signature for the POST /aml/$OFFICER_PUB/decisions endpoint. + */ +export function signAmlDecision( + priv: Uint8Array, + decision: AmlDecisionRequestWithoutSignature, +): Uint8Array { + const builder = buildSigPS(TalerSignaturePurpose.AML_DECISION); + + const flags: number = decision.keep_investigating ? 1 : 0; + + builder.put(timestampRoundedToBuffer(decision.decision_time)); + builder.put(decodeCrock(decision.h_payto)); + builder.put(hash(stringToBytes(decision.justification))); + builder.put(hash(stringToBytes(canonicalJson(decision.properties) + "\0"))); + builder.put(hash(stringToBytes(canonicalJson(decision.new_rules) + "\0"))); + if (decision.new_measure != null) { + builder.put(hash(stringToBytes(decision.new_measure))); + } else { + builder.put(new Uint8Array(64)); + } + builder.put(bufferForUint64(flags)); + + const sigBlob = builder.build(); + + return eddsaSign(sigBlob, priv); +} diff --git a/packages/taler-util/src/types-taler-exchange.ts b/packages/taler-util/src/types-taler-exchange.ts @@ -42,7 +42,6 @@ import { Edx25519PublicKeyEnc } from "./taler-crypto.js"; import { TalerProtocolDuration, TalerProtocolTimestamp, - codecForAbsoluteTime, codecForDuration, codecForTimestamp, } from "./time.js"; @@ -51,9 +50,6 @@ import { AmlOfficerPublicKeyP, AmountString, Base32String, - codecForAccessToken, - codecForInternationalizedString, - codecForURLString, CoinPublicKeyString, Cs25519Point, CurrencySpecification, @@ -69,6 +65,9 @@ import { RsaPublicKeySring, Timestamp, WireSalt, + codecForAccessToken, + codecForInternationalizedString, + codecForURLString, } from "./types-taler-common.js"; export type DenominationPubKey = RsaDenominationPubKey | CsDenominationPubKey; @@ -1387,21 +1386,18 @@ export interface BatchDepositRequestCoin { } export interface AvailableMeasureSummary { - // Available original measures that can be // triggered directly by default rules. - roots: { [measure_name: string]: MeasureInformation; }; + roots: { [measure_name: string]: MeasureInformation }; // Available AML programs. - programs: { [prog_name: string]: AmlProgramRequirement; }; + programs: { [prog_name: string]: AmlProgramRequirement }; // Available KYC checks. - checks: { [check_name: string]: KycCheckInformation; }; - + checks: { [check_name: string]: KycCheckInformation }; } export interface MeasureInformation { - // Name of a KYC check. check_name: string; @@ -1410,11 +1406,9 @@ export interface MeasureInformation { // Context for the check. Optional. context?: Object; - } export interface AmlProgramRequirement { - // Description of what the AML program does. description: string; @@ -1428,11 +1422,9 @@ export interface AmlProgramRequirement { // are the minimum that the check must produce // (it may produce more). inputs: string[]; - } export interface KycCheckInformation { - // Description of the KYC check. Should be shown // to the AML staff but will also be shown to the // client when they initiate the check in the KYC SPA. @@ -1459,7 +1451,6 @@ export interface KycCheckInformation { fallback: string; } - export interface AmlDecisionDetails { // Array of AML decisions made for this account. Possibly // contains only the most recent decision if "history" was @@ -1469,6 +1460,7 @@ export interface AmlDecisionDetails { // Array of KYC attributes obtained for this account. kyc_attributes: KycDetail[]; } + export interface AmlDecisionDetail { // What was the justification given? justification: string; @@ -1485,6 +1477,7 @@ export interface AmlDecisionDetail { // Who made the decision? decider_pub: AmlOfficerPublicKeyP; } + export interface KycDetail { // Name of the configuration section that specifies the provider // which was used to collect the KYC details @@ -1502,6 +1495,10 @@ export interface KycDetail { expiration_time: Timestamp; } +export type AmlDecisionRequestWithoutSignature = Omit< + AmlDecisionRequest, + "officer_sig" +>; export interface ExchangeVersionResponse { // libtool-style representation of the Exchange protocol version, see @@ -1553,7 +1550,6 @@ export interface WireAccount { } export interface WalletKycRequest { - // Balance threshold (not necessarily exact balance) // to be crossed by the wallet that (may) trigger // additional KYC requirements. @@ -1570,7 +1566,6 @@ export interface WalletKycRequest { } export interface WalletKycCheckResponse { - // Next balance limit above which a KYC check // may be required. Optional, not given if no // threshold exists (assume infinity). @@ -1580,14 +1575,11 @@ export interface WalletKycCheckResponse { // expire and the wallet needs to check again // for updated thresholds. expiration_time: Timestamp; - } - // Implemented in this style since exchange // protocol **v20**. export interface LegitimizationNeededResponse { - // Numeric error code unique to the condition. // Should always be TALER_EC_EXCHANGE_GENERIC_KYC_REQUIRED. code: number; @@ -1619,11 +1611,9 @@ export interface LegitimizationNeededResponse { // should use the number to check for the account's AML/KYC status // using the /kyc-check/$REQUIREMENT_ROW endpoint. requirement_row: Integer; - } export interface AccountKycStatus { - // Current AML state for the target account. True if // operations are not happening due to staff processing // paperwork *or* due to legal requirements (so the @@ -1661,10 +1651,8 @@ export interface AccountKycStatus { // much or even prevent the user from generating a // request that would cause it to exceed hard limits). limits?: AccountLimit[]; - } export interface AccountLimit { - // Operation that is limited. // Must be one of "WITHDRAW", "DEPOSIT", "P2P-RECEIVE" // or "WALLET-BALANCE". @@ -1688,13 +1676,12 @@ export interface AccountLimit { } export interface KycProcessClientInformation { - // Array of requirements. requirements: KycRequirementInformation[]; // True if the client is expected to eventually satisfy all requirements. // Default (if missing) is false. - is_and_combinator?: boolean + is_and_combinator?: boolean; // List of available voluntary checks the client could pay for. // Since **vATTEST**. @@ -1702,10 +1689,9 @@ export interface KycProcessClientInformation { } declare const opaque_kycReq: unique symbol; -export type KycRequirementInformationId = string & { [opaque_kycReq]: true } +export type KycRequirementInformationId = string & { [opaque_kycReq]: true }; export interface KycRequirementInformation { - // Which form should be used? Common values include "INFO" // (to just show the descriptions but allow no action), // "LINK" (to enable the user to obtain a link via @@ -1730,7 +1716,6 @@ export interface KycRequirementInformation { // Since **vATTEST**. export interface KycCheckPublicInformation { - // English description of the check. description: string; @@ -1743,7 +1728,6 @@ export interface KycCheckPublicInformation { // something more??!? } - export interface EventCounter { // Number of events of the specified type in // the given range. @@ -1751,13 +1735,11 @@ export interface EventCounter { } export interface AmlDecisionsResponse { - // Array of AML decisions matching the query. records: AmlDecision[]; } export interface AmlDecision { - // Which payto-address is this record about. // Identifies a GNU Taler wallet or an affected bank account. h_payto: PaytoHash; @@ -1788,7 +1770,6 @@ export interface AmlDecision { // True if this is the active decision for the // account. is_active: boolean; - } // All fields in this object are optional. The actual @@ -1797,7 +1778,6 @@ export interface AmlDecision { // however, some common fields are standardized // and thus described here. export interface AccountProperties { - // True if this is a politically exposed account. // Rules for classifying accounts as politically // exposed are country-dependent. @@ -1824,10 +1804,13 @@ export interface AccountProperties { // Was the client's account reported to the authorities? was_reported?: boolean; + /** + * Additional free-form properties. + */ + [x: string]: any; } export interface LegitimizationRuleSet { - // When does this set of rules expire and // we automatically transition to the successor // measure? @@ -1844,12 +1827,10 @@ export interface LegitimizationRuleSet { // Custom measures that KYC rules and the // successor_measure may refer to. - custom_measures: { [measure_name: string]: MeasureInformation; }; - + custom_measures: { [measure_name: string]: MeasureInformation }; } export interface AmlDecisionRequest { - // Human-readable justification for the decision. justification: string; @@ -1884,12 +1865,9 @@ export interface AmlDecisionRequest { // When was the decision made? decision_time: Timestamp; - } - export interface KycRule { - // Type of operation to which the rule applies. operation_type: string; @@ -1932,18 +1910,13 @@ export interface KycRule { // measure what to do next. // Default (if missing) is false. is_and_combinator?: boolean; - } - export interface KycAttributes { - // Matching KYC attribute history of the account. details: KycAttributeCollectionEvent[]; - } export interface KycAttributeCollectionEvent { - // Row ID of the record. Used to filter by offset. rowid: Integer; @@ -1959,7 +1932,6 @@ export interface KycAttributeCollectionEvent { // Time when the KYC data was collected collection_time: Timestamp; - } export enum AmlState { @@ -2295,18 +2267,18 @@ export const codecForEventCounter = (): Codec<EventCounter> => .property("counter", codecForNumber()) .build("TalerExchangeApi.EventCounter"); - export const codecForAmlDecisionsResponse = (): Codec<AmlDecisionsResponse> => buildCodecForObject<AmlDecisionsResponse>() .property("records", codecForList(codecForAmlDecision())) .build("TalerExchangeApi.AmlDecisionsResponse"); -export const codecForAvailableMeasureSummary = (): Codec<AvailableMeasureSummary> => - buildCodecForObject<AvailableMeasureSummary>() - .property("checks", codecForMap(codecForKycCheckInformation())) - .property("programs", codecForMap(codecForAmlProgramRequirement())) - .property("roots", codecForMap(codecForMeasureInformation())) - .build("TalerExchangeApi.AvailableMeasureSummary"); +export const codecForAvailableMeasureSummary = + (): Codec<AvailableMeasureSummary> => + buildCodecForObject<AvailableMeasureSummary>() + .property("checks", codecForMap(codecForKycCheckInformation())) + .property("programs", codecForMap(codecForAmlProgramRequirement())) + .property("roots", codecForMap(codecForMeasureInformation())) + .build("TalerExchangeApi.AvailableMeasureSummary"); export const codecForAmlProgramRequirement = (): Codec<AmlProgramRequirement> => buildCodecForObject<AmlProgramRequirement>() @@ -2376,10 +2348,9 @@ export const codecForAccountProperties = (): Codec<AccountProperties> => .property("was_reported", codecOptional(codecForBoolean())) .build("TalerExchangeApi.AccountProperties"); - export const codecForLegitimizationRuleSet = (): Codec<LegitimizationRuleSet> => buildCodecForObject<LegitimizationRuleSet>() - .property("expiration_time", (codecForTimestamp)) + .property("expiration_time", codecForTimestamp) .property("successor_measure", codecOptional(codecForString())) .property("rules", codecForList(codecForKycRules())) .property("custom_measures", codecForMap(codecForMeasureInformation())) @@ -2396,35 +2367,36 @@ export const codecForKycRules = (): Codec<KycRule> => .property("is_and_combinator", codecOptional(codecForBoolean())) .build("TalerExchangeApi.KycRule"); - - export const codecForAmlKycAttributes = (): Codec<KycAttributes> => buildCodecForObject<KycAttributes>() .property("details", codecForList(codecForKycAttributeCollectionEvent())) .build("TalerExchangeApi.KycAttributes"); -export const codecForKycAttributeCollectionEvent = (): Codec<KycAttributeCollectionEvent> => - buildCodecForObject<KycAttributeCollectionEvent>() - .property("rowid", codecForNumber()) - .property("provider_name", codecOptional(codecForString())) - .property("collection_time", codecForTimestamp) - .property("attributes", codecOptional(codecForAny())) - .build("TalerExchangeApi.KycAttributeCollectionEvent"); - -export const codecForAmlWalletKycCheckResponse = (): Codec<WalletKycCheckResponse> => - buildCodecForObject<WalletKycCheckResponse>() - .property("next_threshold", codecOptional(codecForAmountString())) - .property("expiration_time", codecForTimestamp) - .build("TalerExchangeApi.WalletKycCheckResponse"); - -export const codecForLegitimizationNeededResponse = (): Codec<LegitimizationNeededResponse> => - buildCodecForObject<LegitimizationNeededResponse>() - .property("code", (codecForNumber())) - .property("hint", codecOptional(codecForString())) - .property("h_payto", (codecForString())) - .property("account_pub", codecOptional(codecForString())) - .property("requirement_row", (codecForNumber())) - .build("TalerExchangeApi.LegitimizationNeededResponse"); +export const codecForKycAttributeCollectionEvent = + (): Codec<KycAttributeCollectionEvent> => + buildCodecForObject<KycAttributeCollectionEvent>() + .property("rowid", codecForNumber()) + .property("provider_name", codecOptional(codecForString())) + .property("collection_time", codecForTimestamp) + .property("attributes", codecOptional(codecForAny())) + .build("TalerExchangeApi.KycAttributeCollectionEvent"); + +export const codecForAmlWalletKycCheckResponse = + (): Codec<WalletKycCheckResponse> => + buildCodecForObject<WalletKycCheckResponse>() + .property("next_threshold", codecOptional(codecForAmountString())) + .property("expiration_time", codecForTimestamp) + .build("TalerExchangeApi.WalletKycCheckResponse"); + +export const codecForLegitimizationNeededResponse = + (): Codec<LegitimizationNeededResponse> => + buildCodecForObject<LegitimizationNeededResponse>() + .property("code", codecForNumber()) + .property("hint", codecOptional(codecForString())) + .property("h_payto", codecForString()) + .property("account_pub", codecOptional(codecForString())) + .property("requirement_row", codecForNumber()) + .build("TalerExchangeApi.LegitimizationNeededResponse"); export const codecForAccountKycStatus = (): Codec<AccountKycStatus> => buildCodecForObject<AccountKycStatus>() @@ -2435,49 +2407,61 @@ export const codecForAccountKycStatus = (): Codec<AccountKycStatus> => export const codecForAccountLimit = (): Codec<AccountLimit> => buildCodecForObject<AccountLimit>() - .property("operation_type", codecForEither( - codecForConstString("WITHDRAW"), - codecForConstString("DEPOSIT"), - codecForConstString("P2P-RECEIVE"), - codecForConstString("WALLET-BALANCE")) + .property( + "operation_type", + codecForEither( + codecForConstString("WITHDRAW"), + codecForConstString("DEPOSIT"), + codecForConstString("P2P-RECEIVE"), + codecForConstString("WALLET-BALANCE"), + ), ) .property("timeframe", codecForDuration) .property("threshold", codecForAmountString()) .property("soft_limit", codecForBoolean()) .build("TalerExchangeApi.AccountLimit"); - -export const codecForKycCheckPublicInformation = (): Codec<KycCheckPublicInformation> => - buildCodecForObject<KycCheckPublicInformation>() - .property("description", codecForString()) - .property("description_i18n", codecForInternationalizedString()) - .build("TalerExchangeApi.KycCheckPublicInformation"); +export const codecForKycCheckPublicInformation = + (): Codec<KycCheckPublicInformation> => + buildCodecForObject<KycCheckPublicInformation>() + .property("description", codecForString()) + .property("description_i18n", codecForInternationalizedString()) + .build("TalerExchangeApi.KycCheckPublicInformation"); export const codecForKycRequirementInformationId = - (): Codec<KycRequirementInformationId> => codecForString() as Codec<KycRequirementInformationId>; - -export const codecForKycRequirementInformation = (): Codec<KycRequirementInformation> => - buildCodecForObject<KycRequirementInformation>() - .property("form", codecForString()) - .property("description", codecForString()) - .property("description_i18n", codecForInternationalizedString()) - .property("id", codecOptional(codecForKycRequirementInformationId())) - .build("TalerExchangeApi.KycRequirementInformation"); - -export const codecForKycProcessClientInformation = (): Codec<KycProcessClientInformation> => - buildCodecForObject<KycProcessClientInformation>() - .property("requirements", codecForList(codecForKycRequirementInformation())) - .property("is_and_combinator", codecOptional(codecForBoolean())) - .property("voluntary_checks", codecForMap(codecForKycCheckPublicInformation())) - .build("TalerExchangeApi.KycProcessClientInformation"); + (): Codec<KycRequirementInformationId> => + codecForString() as Codec<KycRequirementInformationId>; + +export const codecForKycRequirementInformation = + (): Codec<KycRequirementInformation> => + buildCodecForObject<KycRequirementInformation>() + .property("form", codecForString()) + .property("description", codecForString()) + .property("description_i18n", codecForInternationalizedString()) + .property("id", codecOptional(codecForKycRequirementInformationId())) + .build("TalerExchangeApi.KycRequirementInformation"); + +export const codecForKycProcessClientInformation = + (): Codec<KycProcessClientInformation> => + buildCodecForObject<KycProcessClientInformation>() + .property( + "requirements", + codecForList(codecForKycRequirementInformation()), + ) + .property("is_and_combinator", codecOptional(codecForBoolean())) + .property( + "voluntary_checks", + codecForMap(codecForKycCheckPublicInformation()), + ) + .build("TalerExchangeApi.KycProcessClientInformation"); interface KycProcessStartInformation { - // URL to open. redirect_url: string; } -export const codecForKycProcessStartInformation = (): Codec<KycProcessStartInformation> => - buildCodecForObject<KycProcessStartInformation>() - .property("redirect_url", codecForURLString()) - .build("TalerExchangeApi.KycProcessStartInformation"); +export const codecForKycProcessStartInformation = + (): Codec<KycProcessStartInformation> => + buildCodecForObject<KycProcessStartInformation>() + .property("redirect_url", codecForURLString()) + .build("TalerExchangeApi.KycProcessStartInformation"); diff --git a/packages/taler-util/src/types-taler-wallet-transactions.ts b/packages/taler-util/src/types-taler-wallet-transactions.ts @@ -127,7 +127,6 @@ export enum TransactionMinorState { Unknown = "unknown", Deposit = "deposit", KycRequired = "kyc", - AmlRequired = "aml", MergeKycRequired = "merge-kyc", Track = "track", SubmitPayment = "submit-payment", diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -1445,6 +1445,18 @@ export enum ExchangeUpdateStatus { OutdatedUpdate = "outdated-update", } +export enum ExchangeWalletKycStatus { + Done = "done", + /** + * Wallet needs to request KYC status. + */ + LegiInit = "legi-init", + /** + * User requires KYC or AML. + */ + Legi = "legi", +} + export interface OperationErrorInfo { error: TalerErrorDetail; } @@ -1466,6 +1478,9 @@ export interface ExchangeListItem { exchangeUpdateStatus: ExchangeUpdateStatus; ageRestrictionOptions: number[]; + walletKycStatus?: ExchangeWalletKycStatus; + walletKycReservePub?: string; + /** * P2P payments are disabled with this exchange * (e.g. because no global fees are configured). @@ -3583,3 +3598,34 @@ export type EmptyObject = Record<string, never>; export const codecForEmptyObject = (): Codec<EmptyObject> => buildCodecForObject<EmptyObject>().build("EmptyObject"); + +export interface TestingWaitWalletKycRequest { + exchangeBaseUrl: string; + amount: AmountString; + /** + * Do we wait for the KYC to be passed (true), + * or do we already return if legitimization is + * required (false). + */ + passed: boolean; +} + +export const codecForTestingWaitWalletKycRequest = + (): Codec<TestingWaitWalletKycRequest> => + buildCodecForObject<TestingWaitWalletKycRequest>() + .property("exchangeBaseUrl", codecForString()) + .property("amount", codecForAmountString()) + .property("passed", codecForBoolean()) + .build("TestingWaitWalletKycRequest"); + +export interface StartExchangeWalletKycRequest { + exchangeBaseUrl: string; + amount: AmountString; +} + +export const codecForStartExchangeWalletKycRequest = + (): Codec<StartExchangeWalletKycRequest> => + buildCodecForObject<StartExchangeWalletKycRequest>() + .property("exchangeBaseUrl", codecForString()) + .property("amount", codecForAmountString()) + .build("StartExchangeWalletKycRequest"); diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts @@ -94,7 +94,6 @@ interface WalletBalance { pendingIncoming: AmountJson; pendingOutgoing: AmountJson; flagIncomingKyc: boolean; - flagIncomingAml: boolean; flagIncomingConfirmation: boolean; flagOutgoingKyc: boolean; } @@ -170,7 +169,6 @@ class BalancesStore { available: zero, pendingIncoming: zero, pendingOutgoing: zero, - flagIncomingAml: false, flagIncomingConfirmation: false, flagIncomingKyc: false, flagOutgoingKyc: false, @@ -210,14 +208,6 @@ class BalancesStore { b.pendingOutgoing = Amounts.add(b.pendingOutgoing, amount).amount; } - async setFlagIncomingAml( - currency: string, - exchangeBaseUrl: string, - ): Promise<void> { - const b = await this.initBalance(currency, exchangeBaseUrl); - b.flagIncomingAml = true; - } - async setFlagIncomingKyc( currency: string, exchangeBaseUrl: string, @@ -254,9 +244,6 @@ class BalancesStore { .forEach((c) => { const v = balanceStore[c]; const flags: BalanceFlag[] = []; - if (v.flagIncomingAml) { - flags.push(BalanceFlag.IncomingAml); - } if (v.flagIncomingKyc) { flags.push(BalanceFlag.IncomingKyc); } @@ -387,20 +374,6 @@ export async function getBalancesInsideTransaction( await balanceStore.setFlagIncomingKyc(currency, wg.exchangeBaseUrl); break; } - case WithdrawalGroupStatus.PendingAml: - case WithdrawalGroupStatus.SuspendedAml: { - checkDbInvariant( - wg.denomsSel !== undefined, - "wg in aml state should have been initialized", - ); - checkDbInvariant( - wg.exchangeBaseUrl !== undefined, - "wg in kyc state should have been initialized", - ); - const currency = Amounts.currencyOf(wg.denomsSel.totalCoinValue); - await balanceStore.setFlagIncomingAml(currency, wg.exchangeBaseUrl); - break; - } case WithdrawalGroupStatus.PendingRegisteringBank: { if (wg.denomsSel && wg.exchangeBaseUrl) { const currency = Amounts.currencyOf(wg.denomsSel.totalCoinValue); diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts @@ -600,6 +600,7 @@ export function getAutoRefreshExecuteThreshold(d: { export enum PendingTaskType { ExchangeUpdate = "exchange-update", + ExchangeWalletKyc = "exchange-wallet-kyc", Purchase = "purchase", Refresh = "refresh", Recoup = "recoup", @@ -622,6 +623,7 @@ export type ParsedTaskIdentifier = withdrawalGroupId: string; } | { tag: PendingTaskType.ExchangeUpdate; exchangeBaseUrl: string } + | { tag: PendingTaskType.ExchangeWalletKyc; exchangeBaseUrl: string } | { tag: PendingTaskType.Backup; backupProviderBaseUrl: string } | { tag: PendingTaskType.Deposit; depositGroupId: string } | { tag: PendingTaskType.PeerPullDebit; peerPullDebitId: string } @@ -648,6 +650,8 @@ export function parseTaskIdentifier(x: string): ParsedTaskIdentifier { return { tag: type, depositGroupId: rest[0] }; case PendingTaskType.ExchangeUpdate: return { tag: type, exchangeBaseUrl: decodeURIComponent(rest[0]) }; + case PendingTaskType.ExchangeWalletKyc: + return { tag: type, exchangeBaseUrl: decodeURIComponent(rest[0]) }; case PendingTaskType.PeerPullCredit: return { tag: type, pursePub: rest[0] }; case PendingTaskType.PeerPullDebit: @@ -679,6 +683,8 @@ export function constructTaskIdentifier(p: ParsedTaskIdentifier): TaskIdStr { return `${p.tag}:${p.depositGroupId}` as TaskIdStr; case PendingTaskType.ExchangeUpdate: return `${p.tag}:${encodeURIComponent(p.exchangeBaseUrl)}` as TaskIdStr; + case PendingTaskType.ExchangeWalletKyc: + return `${p.tag}:${encodeURIComponent(p.exchangeBaseUrl)}` as TaskIdStr; case PendingTaskType.PeerPullDebit: return `${p.tag}:${p.peerPullDebitId}` as TaskIdStr; case PendingTaskType.PeerPushCredit: diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts @@ -114,6 +114,10 @@ import { SignReservePurseCreateRequest, SignReservePurseCreateResponse, SignTrackTransactionRequest, + SignWalletAccountSetupRequest, + SignWalletAccountSetupResponse, + SignWalletKycAuthRequest, + SignWalletKycAuthResponse, } from "./cryptoTypes.js"; const logger = new Logger("cryptoImplementation.ts"); @@ -240,6 +244,10 @@ export interface TalerCryptoInterface { signPurseMerge(req: SignPurseMergeRequest): Promise<SignPurseMergeResponse>; + signWalletAccountSetup( + req: SignWalletAccountSetupRequest, + ): Promise<SignWalletAccountSetupResponse>; + signReservePurseCreate( req: SignReservePurseCreateRequest, ): Promise<SignReservePurseCreateResponse>; @@ -253,6 +261,10 @@ export interface TalerCryptoInterface { signCoinHistoryRequest( req: SignCoinHistoryRequest, ): Promise<SignCoinHistoryResponse>; + + signWalletKycAuth( + req: SignWalletKycAuthRequest, + ): Promise<SignWalletKycAuthResponse>; } /** @@ -447,6 +459,16 @@ export const nullCrypto: TalerCryptoInterface = { ): Promise<SignReserveHistoryReqResponse> { throw new Error("Function not implemented."); }, + signWalletAccountSetup: function ( + req: SignWalletAccountSetupRequest, + ): Promise<SignWalletAccountSetupResponse> { + throw new Error("Function not implemented."); + }, + signWalletKycAuth: function ( + req: SignWalletKycAuthRequest, + ): Promise<SignWalletKycAuthResponse> { + throw new Error("Function not implemented."); + }, }; export type WithArg<X> = X extends (req: infer T) => infer R @@ -1765,6 +1787,34 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { sig: sigResp.sig, }; }, + async signWalletAccountSetup( + tci: TalerCryptoInterfaceR, + req: SignWalletAccountSetupRequest, + ): Promise<SignWalletAccountSetupResponse> { + const sigData = buildSigPS(TalerSignaturePurpose.WALLET_ACCOUNT_SETUP) + .put(amountToBuffer(req.threshold)) + .build(); + const sigResp = await tci.eddsaSign(tci, { + msg: encodeCrock(sigData), + priv: req.reservePriv, + }); + return { + sig: sigResp.sig, + }; + }, + async signWalletKycAuth( + tci: TalerCryptoInterfaceR, + req: SignWalletKycAuthRequest, + ): Promise<SignWalletKycAuthResponse> { + const sigData = buildSigPS(TalerSignaturePurpose.KYC_AUTH).build(); + const sigResp = await tci.eddsaSign(tci, { + msg: encodeCrock(sigData), + priv: req.accountPriv, + }); + return { + sig: sigResp.sig, + }; + }, }; export interface EddsaSignRequest { diff --git a/packages/taler-wallet-core/src/crypto/cryptoTypes.ts b/packages/taler-wallet-core/src/crypto/cryptoTypes.ts @@ -225,6 +225,25 @@ export interface DecryptContractForDepositResponse { contractTerms: any; } +export interface SignWalletAccountSetupRequest { + reservePub: string; + reservePriv: string; + threshold: AmountString; +} + +export interface SignWalletAccountSetupResponse { + sig: string; +} + +export interface SignWalletKycAuthRequest { + accountPub: string; + accountPriv: string; +} + +export interface SignWalletKycAuthResponse { + sig: string; +} + export interface SignPurseMergeRequest { mergeTimestamp: TalerProtocolTimestamp; diff --git a/packages/taler-wallet-core/src/crypto/index.ts b/packages/taler-wallet-core/src/crypto/index.ts @@ -0,0 +1,25 @@ +/* + 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 { CryptoDispatcher } from "./workers/crypto-dispatcher.js"; +import { SynchronousCryptoWorkerFactoryPlain } from "./workers/synchronousWorkerFactoryPlain.js"; + +export function createSyncCryptoApi() { + const cryptiDisp = new CryptoDispatcher( + new SynchronousCryptoWorkerFactoryPlain(), + ); + return cryptiDisp.cryptoApi; +} diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts @@ -322,12 +322,6 @@ export enum WithdrawalGroupStatus { SuspendedKyc = 0x0110_005, /** - * Exchange is doing AML checks. - */ - PendingAml = 0x0100_0006, - SuspendedAml = 0x0110_0006, - - /** * The corresponding withdraw record has been created. * No further processing is done, unless explicitly requested * by the user. @@ -2141,6 +2135,20 @@ export interface PeerPullPaymentIncomingRecord { coinSel?: PeerPullPaymentCoinSelection; } +export enum ReserveRecordStatus { + // Need to call the "/kyc-wallet" endpoint + PendingLegiInit = 0x0100_0001, + SuspendedLegiInit = 0x0110_0001, + // Need to wait for user to pass legitimization + PendingLegi = 0x0100_0002, + SuspendedLegi = 0x0110_0002, + + /** + * Done with KYC. + */ + Done = 0x0500_0000, +} + /** * Store for extra information about a reserve. * @@ -2153,6 +2161,24 @@ export interface ReserveRecord { rowId?: number; reservePub: string; reservePriv: string; + + status?: ReserveRecordStatus; + + requirementRow?: number; + + /** + * Balance threshold that we're currently requesting KYC for. + */ + thresholdRequested?: AmountString; + + /** + * Balance threshold that we already have passed KYC for. + */ + thresholdGranted?: AmountString; + + kycAccessToken?: string; + + amlReview?: boolean; } export interface OperationRetryRecord { diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts @@ -27,6 +27,7 @@ import { AbsoluteTime, AgeRestriction, Amount, + AmountLike, Amounts, CancellationToken, CoinRefreshRequest, @@ -40,6 +41,7 @@ import { DenominationPubKey, Duration, EddsaPublicKeyString, + EmptyObject, ExchangeAuditor, ExchangeDetailedResponse, ExchangeGlobalFees, @@ -47,6 +49,7 @@ import { ExchangeSignKeyJson, ExchangeTosStatus, ExchangeUpdateStatus, + ExchangeWalletKycStatus, ExchangeWireAccount, ExchangesListResponse, FeeDescription, @@ -55,6 +58,7 @@ import { GetExchangeTosResult, GlobalFees, HttpStatusCode, + LegitimizationNeededResponse, LibtoolVersion, Logger, NotificationType, @@ -63,12 +67,14 @@ import { RefreshReason, ScopeInfo, ScopeType, + StartExchangeWalletKycRequest, TalerError, TalerErrorCode, TalerErrorDetail, TalerPreciseTimestamp, TalerProtocolDuration, TalerProtocolTimestamp, + TestingWaitWalletKycRequest, Transaction, TransactionAction, TransactionIdStr, @@ -76,6 +82,7 @@ import { TransactionState, TransactionType, URL, + WalletKycRequest, WalletNotification, WireFee, WireFeeMap, @@ -84,7 +91,9 @@ import { assertUnreachable, checkDbInvariant, checkLogicInvariant, + codecForAccountKycStatus, codecForExchangeKeysJson, + codecForLegitimizationNeededResponse, durationMul, encodeCrock, getRandomBytes, @@ -97,6 +106,7 @@ import { import { HttpRequestLibrary, getExpiry, + readResponseJsonOrThrow, readSuccessResponseJsonOrThrow, readSuccessResponseTextOrThrow, readTalerErrorResponse, @@ -127,6 +137,8 @@ import { ExchangeEntryDbRecordStatus, ExchangeEntryDbUpdateStatus, ExchangeEntryRecord, + ReserveRecord, + ReserveRecordStatus, WalletDbAllStoresReadOnlyTransaction, WalletDbHelpers, WalletDbReadOnlyTransaction, @@ -395,6 +407,7 @@ async function makeExchangeListItem( >, r: ExchangeEntryRecord, exchangeDetails: ExchangeDetailsRecord | undefined, + reserveRec: ReserveRecord | undefined, lastError: TalerErrorDetail | undefined, ): Promise<ExchangeListItem> { const lastUpdateErrorInfo: OperationErrorInfo | undefined = lastError @@ -409,6 +422,25 @@ async function makeExchangeListItem( scopeInfo = await internalGetExchangeScopeInfo(tx, exchangeDetails); } + let walletKycStatus: ExchangeWalletKycStatus | undefined = undefined; + if (reserveRec) { + switch (reserveRec.status) { + case ReserveRecordStatus.Done: + walletKycStatus = ExchangeWalletKycStatus.Done; + break; + // FIXME: Do we handle the suspended state? + case ReserveRecordStatus.SuspendedLegiInit: + case ReserveRecordStatus.PendingLegiInit: + walletKycStatus = ExchangeWalletKycStatus.LegiInit; + break; + // FIXME: Do we handle the suspended state? + case ReserveRecordStatus.SuspendedLegi: + case ReserveRecordStatus.PendingLegi: + walletKycStatus = ExchangeWalletKycStatus.Legi; + break; + } + } + const listItem: ExchangeListItem = { exchangeBaseUrl: r.baseUrl, masterPub: exchangeDetails?.masterPublicKey, @@ -417,6 +449,8 @@ async function makeExchangeListItem( currency: exchangeDetails?.currency ?? r.presetCurrencyHint ?? "UNKNOWN", exchangeUpdateStatus: getExchangeUpdateStatusFromRecord(r), exchangeEntryStatus: getExchangeEntryStatusFromRecord(r), + walletKycStatus, + walletKycReservePub: reserveRec?.reservePub, tosStatus: getExchangeTosStatusFromRecord(r), ageRestrictionOptions: exchangeDetails?.ageMask ? AgeRestriction.getAgeGroupsFromMask(exchangeDetails.ageMask) @@ -477,6 +511,7 @@ export async function lookupExchangeByUri( { storeNames: [ "exchanges", + "reserves", "exchangeDetails", "operationRetries", "globalCurrencyAuditors", @@ -495,10 +530,18 @@ export async function lookupExchangeByUri( const opRetryRecord = await tx.operationRetries.get( TaskIdentifiers.forExchangeUpdate(exchangeRec), ); + let reserveRec: ReserveRecord | undefined = undefined; + if (exchangeRec.currentMergeReserveRowId != null) { + reserveRec = await tx.reserves.get( + exchangeRec.currentMergeReserveRowId, + ); + checkDbInvariant(!!reserveRec, "reserve record not found"); + } return await makeExchangeListItem( tx, exchangeRec, exchangeDetails, + reserveRec, opRetryRecord?.lastError, ); }, @@ -2449,6 +2492,7 @@ export async function listExchanges( { storeNames: [ "exchanges", + "reserves", "operationRetries", "exchangeDetails", "globalCurrencyAuditors", @@ -2457,18 +2501,29 @@ export async function listExchanges( }, async (tx) => { const exchangeRecords = await tx.exchanges.iter().toArray(); - for (const r of exchangeRecords) { + for (const exchangeRec of exchangeRecords) { const taskId = constructTaskIdentifier({ tag: PendingTaskType.ExchangeUpdate, - exchangeBaseUrl: r.baseUrl, + exchangeBaseUrl: exchangeRec.baseUrl, }); - const exchangeDetails = await getExchangeRecordsInternal(tx, r.baseUrl); + const exchangeDetails = await getExchangeRecordsInternal( + tx, + exchangeRec.baseUrl, + ); const opRetryRecord = await tx.operationRetries.get(taskId); + let reserveRec: ReserveRecord | undefined = undefined; + if (exchangeRec.currentMergeReserveRowId != null) { + reserveRec = await tx.reserves.get( + exchangeRec.currentMergeReserveRowId, + ); + checkDbInvariant(!!reserveRec, "reserve record not found"); + } exchanges.push( await makeExchangeListItem( tx, - r, + exchangeRec, exchangeDetails, + reserveRec, opRetryRecord?.lastError, ), ); @@ -2896,3 +2951,440 @@ export async function getExchangeWireFee( return fee; } + +/** + * Wait until kyc has passed for the wallet. + * + * If passed==false, already return when legitimization + * is requested. + */ +export async function waitExchangeWalletKyc( + wex: WalletExecutionContext, + exchangeBaseUrl: string, + amount: AmountLike, + passed: boolean, +): Promise<void> { + await genericWaitForState(wex, { + async checkState(): Promise<boolean> { + return await wex.db.runReadOnlyTx( + { + storeNames: ["exchanges", "reserves"], + }, + async (tx) => { + const exchange = await tx.exchanges.get(exchangeBaseUrl); + if (!exchange) { + throw new Error("exchange not found"); + } + const reserveId = exchange.currentMergeReserveRowId; + if (reserveId == null) { + logger.warn("KYC does not exist yet"); + return false; + } + const reserve = await tx.reserves.get(reserveId); + if (!reserve) { + throw Error("reserve not found"); + } + if (passed) { + if ( + reserve.thresholdGranted && + Amounts.cmp(reserve.thresholdGranted, amount) >= 0 + ) { + return true; + } + return false; + } else { + if ( + reserve.thresholdGranted && + Amounts.cmp(reserve.thresholdGranted, amount) >= 0 + ) { + return true; + } + if (reserve.status === ReserveRecordStatus.PendingLegi) { + return true; + } + return false; + } + }, + ); + }, + filterNotification(notif) { + return ( + notif.type === NotificationType.ExchangeStateTransition && + notif.exchangeBaseUrl === exchangeBaseUrl + ); + }, + }); +} + +export async function handleTestingWaitExchangeWalletKyc( + wex: WalletExecutionContext, + req: TestingWaitWalletKycRequest, +): Promise<EmptyObject> { + await waitExchangeWalletKyc(wex, req.exchangeBaseUrl, req.amount, req.passed); + return {}; +} + +export async function handleStartExchangeWalletKyc( + wex: WalletExecutionContext, + req: StartExchangeWalletKycRequest, +): Promise<EmptyObject> { + const newReservePair = await wex.cryptoApi.createEddsaKeypair({}); + const dbRes = await wex.db.runReadWriteTx( + { + storeNames: ["exchanges", "reserves"], + }, + async (tx) => { + const exchange = await tx.exchanges.get(req.exchangeBaseUrl); + if (!exchange) { + throw Error("exchange not found"); + } + const oldExchangeState = getExchangeState(exchange); + let mergeReserveRowId = exchange.currentMergeReserveRowId; + if (mergeReserveRowId == null) { + const putRes = await tx.reserves.put({ + reservePriv: newReservePair.priv, + reservePub: newReservePair.pub, + }); + checkDbInvariant(typeof putRes.key === "number", "primary key type"); + mergeReserveRowId = putRes.key; + exchange.currentMergeReserveRowId = mergeReserveRowId; + await tx.exchanges.put(exchange); + } + const reserveRec = await tx.reserves.get(mergeReserveRowId); + checkDbInvariant(reserveRec != null, "reserve record exists"); + if ( + reserveRec.thresholdGranted == null || + Amounts.cmp(reserveRec.thresholdGranted, req.amount) < 0 + ) { + if ( + reserveRec.thresholdRequested == null || + Amounts.cmp(reserveRec.thresholdRequested, req.amount) < 0 + ) { + reserveRec.thresholdRequested = req.amount; + reserveRec.status = ReserveRecordStatus.PendingLegiInit; + await tx.reserves.put(reserveRec); + return { + notification: { + type: NotificationType.ExchangeStateTransition, + exchangeBaseUrl: exchange.baseUrl, + oldExchangeState, + newExchangeState: getExchangeState(exchange), + } satisfies WalletNotification, + }; + } else { + logger.info( + `another KYC process is already active for ${req.exchangeBaseUrl} over ${reserveRec.thresholdRequested}`, + ); + return undefined; + } + } else { + // FIXME: Check expiration once exchange tells us! + logger.info( + `KYC already granted for ${req.exchangeBaseUrl} over ${reserveRec.thresholdGranted}`, + ); + return undefined; + } + }, + ); + if (dbRes && dbRes.notification) { + wex.ws.notify(dbRes.notification); + } + const taskId = constructTaskIdentifier({ + tag: PendingTaskType.ExchangeWalletKyc, + exchangeBaseUrl: req.exchangeBaseUrl, + }); + wex.taskScheduler.startShepherdTask(taskId); + return {}; +} + +async function handleExchangeKycPendingWallet( + wex: WalletExecutionContext, + exchange: ExchangeEntryRecord, + reserve: ReserveRecord, +): Promise<TaskRunResult> { + checkDbInvariant(!!reserve.thresholdRequested, "threshold"); + const threshold = reserve.thresholdRequested; + const sigResp = await wex.cryptoApi.signWalletAccountSetup({ + reservePriv: reserve.reservePriv, + reservePub: reserve.reservePub, + threshold, + }); + const requestUrl = new URL("kyc-wallet", exchange.baseUrl); + const body: WalletKycRequest = { + balance: reserve.thresholdRequested, + reserve_pub: reserve.reservePub, + reserve_sig: sigResp.sig, + }; + logger.info(`kyc-wallet request body: ${j2s(body)}`); + const res = await wex.http.fetch(requestUrl.href, { + method: "POST", + body, + }); + + logger.info(`kyc-wallet response status is ${res.status}`); + + switch (res.status) { + case HttpStatusCode.Ok: { + // KYC somehow already passed + // FIXME: Store next threshold and timestamp! + return handleExchangeKycSuccess(wex, exchange.baseUrl); + } + case HttpStatusCode.NoContent: { + // KYC disabled at exchange. + return handleExchangeKycSuccess(wex, exchange.baseUrl); + } + case HttpStatusCode.Forbidden: { + // Did not work! + const err = await readTalerErrorResponse(res); + throwUnexpectedRequestError(res, err); + } + case HttpStatusCode.UnavailableForLegalReasons: { + const kycBody = await readResponseJsonOrThrow( + res, + codecForLegitimizationNeededResponse(), + ); + return handleExchangeKycRespLegi(wex, exchange.baseUrl, reserve, kycBody); + } + default: { + const err = await readTalerErrorResponse(res); + throwUnexpectedRequestError(res, err); + } + } +} + +async function handleExchangeKycSuccess( + wex: WalletExecutionContext, + exchangeBaseUrl: string, +): Promise<TaskRunResult> { + const dbRes = await wex.db.runReadWriteTx( + { storeNames: ["exchanges", "reserves"] }, + async (tx) => { + const exchange = await tx.exchanges.get(exchangeBaseUrl); + if (!exchange) { + throw Error("exchange not found"); + } + const oldExchangeState = getExchangeState(exchange); + const reserveId = exchange.currentMergeReserveRowId; + if (reserveId == null) { + throw Error("expected exchange to have reserve ID"); + } + const reserve = await tx.reserves.get(reserveId); + checkDbInvariant(!!reserve, "merge reserve should exist"); + switch (reserve.status) { + case ReserveRecordStatus.PendingLegiInit: + case ReserveRecordStatus.PendingLegi: + break; + default: + throw Error("unexpected state (concurrent modification?)"); + } + reserve.status = ReserveRecordStatus.Done; + reserve.thresholdGranted = reserve.thresholdRequested; + delete reserve.thresholdRequested; + delete reserve.requirementRow; + await tx.reserves.put(reserve); + return { + notification: { + type: NotificationType.ExchangeStateTransition, + exchangeBaseUrl: exchange.baseUrl, + oldExchangeState, + newExchangeState: getExchangeState(exchange), + } satisfies WalletNotification, + }; + }, + ); + if (dbRes && dbRes.notification) { + wex.ws.notify(dbRes.notification); + } + return TaskRunResult.progress(); +} + +/** + * The exchange has just told us that we need some legitimization + * from the user. Request more details and store the result in the database. + */ +async function handleExchangeKycRespLegi( + wex: WalletExecutionContext, + exchangeBaseUrl: string, + reserve: ReserveRecord, + kycBody: LegitimizationNeededResponse, +): Promise<TaskRunResult> { + const sigResp = await wex.cryptoApi.signWalletKycAuth({ + accountPriv: reserve.reservePriv, + accountPub: reserve.reservePub, + }); + const requirementRow = kycBody.requirement_row; + const reqUrl = new URL(`kyc-check/${requirementRow}`, exchangeBaseUrl); + const resp = await wex.http.fetch(reqUrl.href, { + method: "GET", + headers: { + ["Account-Owner-Signature"]: sigResp.sig, + }, + }); + + logger.info(`kyc-check (long-poll) response status ${resp.status}`); + + switch (resp.status) { + case HttpStatusCode.Ok: { + // FIXME: Store information about next limit! + return handleExchangeKycSuccess(wex, exchangeBaseUrl); + } + case HttpStatusCode.Accepted: { + // Store the result in the DB! + break; + } + case HttpStatusCode.NoContent: { + // KYC not configured, so already satisfied + return handleExchangeKycSuccess(wex, exchangeBaseUrl); + } + default: { + const err = await readTalerErrorResponse(resp); + throwUnexpectedRequestError(resp, err); + } + } + + const accountKycStatusResp = await readResponseJsonOrThrow( + resp, + codecForAccountKycStatus(), + ); + + const dbRes = await wex.db.runReadWriteTx( + { storeNames: ["exchanges", "reserves"] }, + async (tx) => { + const exchange = await tx.exchanges.get(exchangeBaseUrl); + if (!exchange) { + throw Error("exchange not found"); + } + const oldExchangeState = getExchangeState(exchange); + const reserveId = exchange.currentMergeReserveRowId; + if (reserveId == null) { + throw Error("expected exchange to have reserve ID"); + } + const reserve = await tx.reserves.get(reserveId); + checkDbInvariant(!!reserve, "merge reserve should exist"); + switch (reserve.status) { + case ReserveRecordStatus.PendingLegiInit: + break; + default: + throw Error("unexpected state (concurrent modification?)"); + } + reserve.status = ReserveRecordStatus.PendingLegi; + reserve.requirementRow = kycBody.requirement_row; + reserve.amlReview = accountKycStatusResp.aml_review; + reserve.kycAccessToken = accountKycStatusResp.access_token; + + await tx.reserves.put(reserve); + return { + notification: { + type: NotificationType.ExchangeStateTransition, + exchangeBaseUrl: exchange.baseUrl, + oldExchangeState, + newExchangeState: getExchangeState(exchange), + } satisfies WalletNotification, + }; + }, + ); + if (dbRes && dbRes.notification) { + wex.ws.notify(dbRes.notification); + } + return TaskRunResult.progress(); +} + +/** + * Legitimization was requested from the user by the exchange. + * + * Long-poll for the legitimization to succeed. + */ +async function handleExchangeKycPendingLegitimization( + wex: WalletExecutionContext, + exchange: ExchangeEntryRecord, + reserve: ReserveRecord, +): Promise<TaskRunResult> { + // FIXME: Cache this signature + const sigResp = await wex.cryptoApi.signWalletKycAuth({ + accountPriv: reserve.reservePriv, + accountPub: reserve.reservePub, + }); + const requirementRow = reserve.requirementRow; + checkDbInvariant(!!requirementRow, "requirement row"); + const resp = await wex.ws.runLongpollQueueing( + wex, + exchange.baseUrl, + async (timeoutMs) => { + const reqUrl = new URL(`kyc-check/${requirementRow}`, 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, { + method: "GET", + headers: { + ["Account-Owner-Signature"]: sigResp.sig, + }, + }); + }, + ); + + logger.info(`kyc-check (long-poll) response status ${resp.status}`); + + switch (resp.status) { + case HttpStatusCode.Ok: { + // FIXME: Store information about next limit! + return handleExchangeKycSuccess(wex, exchange.baseUrl); + } + 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); + } + default: { + const err = await readTalerErrorResponse(resp); + throwUnexpectedRequestError(resp, err); + } + } +} + +export async function processExchangeKyc( + wex: WalletExecutionContext, + exchangeBaseUrl: string, +): Promise<TaskRunResult> { + const res = await wex.db.runReadOnlyTx( + { storeNames: ["exchanges", "reserves"] }, + async (tx) => { + const exchange = await tx.exchanges.get(exchangeBaseUrl); + if (!exchange) { + return undefined; + } + const reserveId = exchange.currentMergeReserveRowId; + let reserve: ReserveRecord | undefined = undefined; + if (reserveId != null) { + reserve = await tx.reserves.get(reserveId); + } + return { exchange, reserve }; + }, + ); + if (!res) { + logger.warn(`exchange ${exchangeBaseUrl} not found, not processing KYC`); + return TaskRunResult.finished(); + } + if (!res.reserve) { + return TaskRunResult.finished(); + } + switch (res.reserve.status) { + case undefined: + // No KYC requested + return TaskRunResult.finished(); + case ReserveRecordStatus.Done: + return TaskRunResult.finished(); + case ReserveRecordStatus.SuspendedLegiInit: + case ReserveRecordStatus.SuspendedLegi: + return TaskRunResult.finished(); + case ReserveRecordStatus.PendingLegiInit: + return handleExchangeKycPendingWallet(wex, res.exchange, res.reserve); + case ReserveRecordStatus.PendingLegi: + return handleExchangeKycPendingLegitimization( + wex, + res.exchange, + res.reserve, + ); + } +} diff --git a/packages/taler-wallet-core/src/index.ts b/packages/taler-wallet-core/src/index.ts @@ -20,6 +20,7 @@ export * from "./crypto/cryptoImplementation.js"; export * from "./crypto/cryptoTypes.js"; +export * from "./crypto/index.js"; export { CryptoDispatcher, CryptoWorkerFactory, diff --git a/packages/taler-wallet-core/src/shepherd.ts b/packages/taler-wallet-core/src/shepherd.ts @@ -53,6 +53,7 @@ import { OPERATION_STATUS_NONFINAL_FIRST, OPERATION_STATUS_NONFINAL_LAST, OperationRetryRecord, + ReserveRecordStatus, WalletDbAllStoresReadOnlyTransaction, WalletDbReadOnlyTransaction, timestampAbsoluteFromDb, @@ -64,6 +65,7 @@ import { } from "./deposits.js"; import { computeDenomLossTransactionStatus, + processExchangeKyc, updateExchangeFromUrlHandler, } from "./exchanges.js"; import { @@ -127,6 +129,7 @@ function taskGivesLiveness(taskId: string): boolean { switch (parsedTaskId.tag) { case PendingTaskType.Backup: case PendingTaskType.ExchangeUpdate: + case PendingTaskType.ExchangeWalletKyc: return false; case PendingTaskType.Deposit: case PendingTaskType.PeerPullCredit: @@ -393,10 +396,13 @@ export class TaskSchedulerImpl implements TaskScheduler { try { res = await callOperationHandlerForTaskId(wex, taskId); } catch (e) { - logger.trace(`Shepherd error ${taskId} saving response ${e}`); + const errorDetail = getErrorDetailFromException(e); + logger.trace( + `Shepherd error ${taskId} saving response ${j2s(errorDetail)}`, + ); res = { type: TaskRunResultType.Error, - errorDetail: getErrorDetailFromException(e), + errorDetail, }; } if (info.cts.token.isCancelled) { @@ -671,6 +677,8 @@ async function callOperationHandlerForTaskId( return await processPeerPullDebit(wex, pending.peerPullDebitId); case PendingTaskType.PeerPushCredit: return await processPeerPushCredit(wex, pending.peerPushCreditId); + case PendingTaskType.ExchangeWalletKyc: + return await processExchangeKyc(wex, pending.exchangeBaseUrl); case PendingTaskType.RewardPickup: throw Error("not supported anymore"); default: @@ -697,6 +705,7 @@ async function taskToRetryNotification( switch (parsedTaskId.tag) { case PendingTaskType.ExchangeUpdate: + case PendingTaskType.ExchangeWalletKyc: return makeExchangeRetryNotification(ws, tx, pendingTaskId, e); case PendingTaskType.PeerPullCredit: case PendingTaskType.PeerPullDebit: @@ -853,8 +862,12 @@ async function makeExchangeRetryNotification( ): Promise<WalletNotification | undefined> { logger.info("making exchange retry notification"); const parsedTaskId = parseTaskIdentifier(pendingTaskId); - if (parsedTaskId.tag !== PendingTaskType.ExchangeUpdate) { - throw Error("invalid task identifier"); + switch (parsedTaskId.tag) { + case PendingTaskType.ExchangeUpdate: + case PendingTaskType.ExchangeWalletKyc: + break; + default: + throw Error("invalid task identifier"); } const rec = await tx.exchanges.get(parsedTaskId.exchangeBaseUrl); @@ -958,6 +971,7 @@ export async function getActiveTaskIds( "peerPushDebit", "peerPullDebit", "peerPushCredit", + "reserves", ], }, async (tx) => { @@ -1091,7 +1105,7 @@ export async function getActiveTaskIds( } } - // exchange update + // exchange update and KYC { const exchanges = await tx.exchanges.getAll(); @@ -1101,6 +1115,22 @@ export async function getActiveTaskIds( exchangeBaseUrl: rec.baseUrl, }); res.taskIds.push(taskIdUpdate); + + const reserveId = rec.currentMergeReserveRowId; + if (reserveId == null) { + continue; + } + const reserveRec = await tx.reserves.get(reserveId); + if ( + reserveRec?.status != null && + reserveRec.status != ReserveRecordStatus.Done + ) { + const taskIdKyc = constructTaskIdentifier({ + tag: PendingTaskType.ExchangeWalletKyc, + exchangeBaseUrl: rec.baseUrl, + }); + res.taskIds.push(taskIdKyc); + } } } diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -130,6 +130,7 @@ import { SetWalletDeviceIdRequest, SharePaymentRequest, SharePaymentResult, + StartExchangeWalletKycRequest, StartRefundQueryForUriResponse, StartRefundQueryRequest, StoredBackupList, @@ -140,6 +141,7 @@ import { TestingGetReserveHistoryRequest, TestingSetTimetravelRequest, TestingWaitTransactionRequest, + TestingWaitWalletKycRequest, Transaction, TransactionByIdRequest, TransactionsRequest, @@ -283,6 +285,8 @@ export enum WalletApiOperation { TestingPing = "testingPing", TestingGetReserveHistory = "testingGetReserveHistory", TestingResetAllRetries = "testingResetAllRetries", + StartExchangeWalletKyc = "startExchangeWalletKyc", + TestingWaitExchangeWalletKyc = "testingWaitWalletKyc", } // group: Initialization @@ -644,6 +648,18 @@ export type ListExchangesOp = { response: ExchangesListResponse; }; +export type StartExchangeWalletKycOp = { + op: WalletApiOperation.StartExchangeWalletKyc; + request: StartExchangeWalletKycRequest; + response: EmptyObject; +}; + +export type TestingWaitExchangeWalletKycOp = { + op: WalletApiOperation.TestingWaitExchangeWalletKyc; + request: TestingWaitWalletKycRequest; + response: EmptyObject; +}; + /** * List exchanges that are available for withdrawing a particular * scoped currency. @@ -1368,6 +1384,8 @@ export type WalletOperations = { [WalletApiOperation.GetDepositWireTypesForCurrency]: GetDepositWireTypesForCurrencyOp; [WalletApiOperation.GetQrCodesForPayto]: GetQrCodesForPaytoOp; [WalletApiOperation.GetBankingChoicesForPayto]: GetBankingChoicesForPaytoOp; + [WalletApiOperation.StartExchangeWalletKyc]: StartExchangeWalletKycOp; + [WalletApiOperation.TestingWaitExchangeWalletKyc]: TestingWaitExchangeWalletKycOp; }; export type WalletCoreRequestType< diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts @@ -184,12 +184,14 @@ import { codecForSetCoinSuspendedRequest, codecForSetWalletDeviceIdRequest, codecForSharePaymentRequest, + codecForStartExchangeWalletKycRequest, codecForStartRefundQueryRequest, codecForSuspendTransaction, codecForTestPayArgs, codecForTestingGetDenomStatsRequest, codecForTestingGetReserveHistoryRequest, codecForTestingSetTimetravelRequest, + codecForTestingWaitWalletKycRequest, codecForTransactionByIdRequest, codecForTransactionsRequest, codecForUpdateExchangeEntryRequest, @@ -267,6 +269,8 @@ import { getExchangeResources, getExchangeTos, getExchangeWireDetailsInTx, + handleStartExchangeWalletKyc, + handleTestingWaitExchangeWalletKyc, listExchanges, lookupExchangeByUri, } from "./exchanges.js"; @@ -2102,6 +2106,14 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = { codec: codecForGetBankingChoicesForPaytoRequest(), handler: handleGetBankingChoicesForPayto, }, + [WalletApiOperation.StartExchangeWalletKyc]: { + codec: codecForStartExchangeWalletKycRequest(), + handler: handleStartExchangeWalletKyc, + }, + [WalletApiOperation.TestingWaitExchangeWalletKyc]: { + codec: codecForTestingWaitWalletKycRequest(), + handler: handleTestingWaitExchangeWalletKyc, + }, }; /** diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts @@ -548,9 +548,6 @@ export class WithdrawTransactionContext implements TransactionContext { case WithdrawalGroupStatus.PendingKyc: newStatus = WithdrawalGroupStatus.SuspendedKyc; break; - case WithdrawalGroupStatus.PendingAml: - newStatus = WithdrawalGroupStatus.SuspendedAml; - break; default: logger.warn( `Unsupported 'suspend' on withdrawal transaction in status ${wg.status}`, @@ -582,11 +579,9 @@ export class WithdrawTransactionContext implements TransactionContext { case WithdrawalGroupStatus.PendingRegisteringBank: newStatus = WithdrawalGroupStatus.AbortingBank; break; - case WithdrawalGroupStatus.SuspendedAml: case WithdrawalGroupStatus.SuspendedKyc: case WithdrawalGroupStatus.SuspendedQueryingStatus: case WithdrawalGroupStatus.SuspendedReady: - case WithdrawalGroupStatus.PendingAml: case WithdrawalGroupStatus.PendingKyc: case WithdrawalGroupStatus.PendingQueryingStatus: newStatus = WithdrawalGroupStatus.AbortedExchange; @@ -647,9 +642,6 @@ export class WithdrawTransactionContext implements TransactionContext { case WithdrawalGroupStatus.SuspendedRegisteringBank: newStatus = WithdrawalGroupStatus.PendingRegisteringBank; break; - case WithdrawalGroupStatus.SuspendedAml: - newStatus = WithdrawalGroupStatus.PendingAml; - break; case WithdrawalGroupStatus.SuspendedKyc: newStatus = WithdrawalGroupStatus.PendingKyc; break; @@ -758,21 +750,11 @@ export function computeWithdrawalTransactionStatus( major: TransactionMajorState.Suspended, minor: TransactionMinorState.WithdrawCoins, }; - case WithdrawalGroupStatus.PendingAml: - return { - major: TransactionMajorState.Pending, - minor: TransactionMinorState.AmlRequired, - }; case WithdrawalGroupStatus.PendingKyc: return { major: TransactionMajorState.Pending, minor: TransactionMinorState.KycRequired, }; - case WithdrawalGroupStatus.SuspendedAml: - return { - major: TransactionMajorState.Suspended, - minor: TransactionMinorState.AmlRequired, - }; case WithdrawalGroupStatus.SuspendedKyc: return { major: TransactionMajorState.Suspended, @@ -863,20 +845,12 @@ export function computeWithdrawalTransactionActions( return [TransactionAction.Resume, TransactionAction.Abort]; case WithdrawalGroupStatus.SuspendedReady: return [TransactionAction.Resume, TransactionAction.Abort]; - case WithdrawalGroupStatus.PendingAml: - return [ - TransactionAction.Retry, - TransactionAction.Resume, - TransactionAction.Abort, - ]; case WithdrawalGroupStatus.PendingKyc: return [ TransactionAction.Retry, TransactionAction.Resume, TransactionAction.Abort, ]; - case WithdrawalGroupStatus.SuspendedAml: - return [TransactionAction.Resume, TransactionAction.Abort]; case WithdrawalGroupStatus.SuspendedKyc: return [TransactionAction.Resume, TransactionAction.Abort]; case WithdrawalGroupStatus.FailedAbortingBank: @@ -1286,9 +1260,9 @@ async function handleKycRequired( amlStatus === ExchangeAmlStatus.Normal || amlStatus === undefined ? WithdrawalGroupStatus.PendingKyc : amlStatus === ExchangeAmlStatus.Pending - ? WithdrawalGroupStatus.PendingAml + ? WithdrawalGroupStatus.PendingKyc : amlStatus === ExchangeAmlStatus.Frozen - ? WithdrawalGroupStatus.SuspendedAml + ? WithdrawalGroupStatus.SuspendedKyc : assertUnreachable(amlStatus); return TransitionResult.transition(wg2); }, @@ -2302,9 +2276,6 @@ export async function processWithdrawalGroup( return processQueryReserve(wex, withdrawalGroupId); case WithdrawalGroupStatus.PendingWaitConfirmBank: return await processReserveBankStatus(wex, withdrawalGroupId); - case WithdrawalGroupStatus.PendingAml: - // FIXME: Handle this case, withdrawal doesn't support AML yet. - return TaskRunResult.backoff(); case WithdrawalGroupStatus.PendingKyc: return processWithdrawalGroupPendingKyc(wex, withdrawalGroup); case WithdrawalGroupStatus.PendingReady: @@ -2318,7 +2289,6 @@ export async function processWithdrawalGroup( case WithdrawalGroupStatus.AbortedExchange: case WithdrawalGroupStatus.FailedAbortingBank: case WithdrawalGroupStatus.SuspendedAbortingBank: - case WithdrawalGroupStatus.SuspendedAml: case WithdrawalGroupStatus.SuspendedKyc: case WithdrawalGroupStatus.SuspendedQueryingStatus: case WithdrawalGroupStatus.SuspendedReady: @@ -3514,7 +3484,6 @@ async function waitWithdrawalRegistered( {}, ); case WithdrawalGroupStatus.PendingKyc: - case WithdrawalGroupStatus.PendingAml: case WithdrawalGroupStatus.PendingQueryingStatus: case WithdrawalGroupStatus.PendingReady: case WithdrawalGroupStatus.Done: diff --git a/packages/taler-wallet-webextension/src/wallet/Transaction.tsx b/packages/taler-wallet-webextension/src/wallet/Transaction.tsx @@ -277,14 +277,6 @@ function TransactionTemplate({ ), }} /> - ) : transaction.txState.minor === - TransactionMinorState.AmlRequired ? ( - <WarningBox> - <i18n.Translate> - The transaction has been blocked since the account required an - AML check. - </i18n.Translate> - </WarningBox> ) : ( <WarningBox> <div style={{ justifyContent: "center", lineHeight: "25px" }}> @@ -461,8 +453,7 @@ export function TransactionView({ // ? transaction.withdrawalDetails.exchangeCreditAccountDetails ?? [] // : []; const blockedByKycOrAml = - transaction.txState.minor === TransactionMinorState.KycRequired || - transaction.txState.minor === TransactionMinorState.AmlRequired; + transaction.txState.minor === TransactionMinorState.KycRequired; return ( <TransactionTemplate transaction={transaction} @@ -537,8 +528,8 @@ export function TransactionView({ message: i18n.str`Withdrawal incomplete.`, description: ( <i18n.Translate> - If you have already sent money to the service provider account it will - wire it back at{" "} + If you have already sent money to the service provider account + it will wire it back at{" "} <Time timestamp={AbsoluteTime.addDuration( AbsoluteTime.fromPreciseTimestamp(transaction.timestamp), diff --git a/packages/taler-wallet-webextension/src/wxBackend.ts b/packages/taler-wallet-webextension/src/wxBackend.ts @@ -483,7 +483,7 @@ async function reinitWallet(): Promise<void> { return; } wallet.addNotificationListener((message) => { - logger.trace("Wallet -> Webex", message) + logger.trace("Wallet -> Webex", message); if (settings.showWalletActivity) { addNewWalletActivityNotification(activity, message); } @@ -522,7 +522,7 @@ export async function wxMain(): Promise<void> { logger.trace("listen all channels"); platform.listenToAllChannels(async (message, from) => { //wait until wallet is initialized - logger.trace("Webex -> Wallet", message) + logger.trace("Webex -> Wallet", message); await afterWalletIsInitialized; const result = await dispatch(message, from); return result; @@ -575,8 +575,6 @@ async function processWalletNotification(message: WalletNotification) { message.type === NotificationType.TransactionStateTransition && (message.newTxState.minor === TransactionMinorState.KycRequired || message.oldTxState.minor === TransactionMinorState.KycRequired || - message.newTxState.minor === TransactionMinorState.AmlRequired || - message.oldTxState.minor === TransactionMinorState.AmlRequired || message.newTxState.minor === TransactionMinorState.BankConfirmTransfer || message.oldTxState.minor === TransactionMinorState.BankConfirmTransfer) ) {