wallet-core: KYC mvp

Only hard withdrawal KYC is supporte so far, and no long-polling is done
yet.
This commit is contained in:
Florian Dold 2023-01-10 17:31:01 +01:00
parent 688518ec73
commit a82d8fab69
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
6 changed files with 329 additions and 31 deletions

View File

@ -1081,6 +1081,17 @@ export class ExchangeService implements ExchangeServiceInterface {
return this.exchangeConfig.httpPort;
}
/**
* Run a function that modifies the existing exchange configuration.
* The modified exchange configuration will then be written to the
* file system.
*/
async modifyConfig(f: (config: Configuration) => Promise<void>): Promise<void> {
const config = Configuration.load(this.configFilename);
await f(config);
config.write(this.configFilename);
}
async addBankAccount(
localName: string,
exchangeBankAccount: HarnessExchangeBankAccount,

View File

@ -0,0 +1,204 @@
/*
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 { Duration } from "@gnu-taler/taler-util";
import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js";
import {
BankService,
ExchangeService,
getPayto,
GlobalTestState,
MerchantService,
setupDb,
WalletCli,
} from "../harness/harness.js";
import {
withdrawViaBank,
makeTestPayment,
EnvOptions,
SimpleTestEnvironment,
} from "../harness/helpers.js";
export async function createKycTestkudosEnvironment(
t: GlobalTestState,
coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS")),
opts: EnvOptions = {},
): Promise<SimpleTestEnvironment> {
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,
});
const merchant = await MerchantService.create(t, {
name: "testmerchant-1",
currency: "TESTKUDOS",
httpPort: 8083,
database: db.connStr,
});
const exchangeBankAccount = await bank.createExchangeAccount(
"myexchange",
"x",
);
exchange.addBankAccount("1", exchangeBankAccount);
bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
await bank.start();
await bank.pingUntilAvailable();
const ageMaskSpec = opts.ageMaskSpec;
if (ageMaskSpec) {
exchange.enableAgeRestrictions(ageMaskSpec);
// Enable age restriction for all coins.
exchange.addCoinConfigList(
coinConfig.map((x) => ({
...x,
name: `${x.name}-age`,
ageRestricted: true,
})),
);
// For mixed age restrictions, we also offer coins without age restrictions
if (opts.mixedAgeRestriction) {
exchange.addCoinConfigList(
coinConfig.map((x) => ({ ...x, ageRestricted: false })),
);
}
} else {
exchange.addCoinConfigList(coinConfig);
}
await exchange.modifyConfig(async (config) => {
const myprov = "kyc-provider-myprov";
config.setString(myprov, "cost", "0");
config.setString(myprov, "logic", "oauth2");
config.setString(myprov, "provided_checks", "dummy1");
config.setString(myprov, "user_type", "individual");
config.setString(myprov, "kyc_oauth2_validity", "forever");
config.setString(
myprov,
"kyc_oauth2_auth_url",
"http://localhost:6666/oauth/v2/token",
);
config.setString(
myprov,
"kyc_oauth2_login_url",
"http://localhost:6666/oauth/v2/login",
);
config.setString(
myprov,
"kyc_oauth2_info_url",
"http://localhost:6666/oauth/v2/login",
);
config.setString(
myprov,
"kyc_oauth2_client_id",
"taler-exchange",
);
config.setString(
myprov,
"kyc_oauth2_client_secret",
"exchange-secret",
);
config.setString(
myprov,
"kyc_oauth2_post_url",
"https://taler.com",
);
config.setString("kyc-legitimization-withdraw1", "operation_type", "withdraw");
config.setString("kyc-legitimization-withdraw1", "required_checks", "dummy1");
config.setString("kyc-legitimization-withdraw1", "timeframe", "1d");
config.setString("kyc-legitimization-withdraw1", "threshold", "TESTKUDOS:5");
});
await exchange.start();
await exchange.pingUntilAvailable();
merchant.addExchange(exchange);
await merchant.start();
await merchant.pingUntilAvailable();
await merchant.addInstance({
id: "default",
name: "Default Instance",
paytoUris: [getPayto("merchant-default")],
defaultWireTransferDelay: Duration.toTalerProtocolDuration(
Duration.fromSpec({ minutes: 1 }),
),
});
await merchant.addInstance({
id: "minst1",
name: "minst1",
paytoUris: [getPayto("minst1")],
defaultWireTransferDelay: Duration.toTalerProtocolDuration(
Duration.fromSpec({ minutes: 1 }),
),
});
console.log("setup done!");
const wallet = new WalletCli(t);
return {
commonDb: db,
exchange,
merchant,
wallet,
bank,
exchangeBankAccount,
};
}
export async function runKycTest(t: GlobalTestState) {
// Set up test environment
const { wallet, bank, exchange, merchant } =
await createKycTestkudosEnvironment(t);
// Withdraw digital cash into the wallet.
await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
const order = {
summary: "Buy me!",
amount: "TESTKUDOS:5",
fulfillment_url: "taler://fulfillment-success/thx",
};
await makeTestPayment(t, { wallet, merchant, order });
await wallet.runUntilDone();
}
runKycTest.suites = ["wallet"];

View File

@ -96,6 +96,7 @@ import { runWalletBalanceTest } from "./test-wallet-balance.js";
import { runAgeRestrictionsMixedMerchantTest } from "./test-age-restrictions-mixed-merchant.js";
import { runWalletCryptoWorkerTest } from "./test-wallet-cryptoworker.js";
import { runWithdrawalHighTest } from "./test-withdrawal-high.js";
import { runKycTest } from "./test-kyc.js";
/**
* Test runner.
@ -113,75 +114,76 @@ interface TestMainFunction {
const allTests: TestMainFunction[] = [
runAgeRestrictionsMerchantTest,
runAgeRestrictionsPeerTest,
runAgeRestrictionsMixedMerchantTest,
runAgeRestrictionsPeerTest,
runBankApiTest,
runClaimLoopTest,
runClauseSchnorrTest,
runWalletCryptoWorkerTest,
runDepositTest,
runDenomUnofferedTest,
runDepositTest,
runExchangeManagementTest,
runExchangeTimetravelTest,
runFeeRegressionTest,
runForcedSelectionTest,
runLibeufinBasicTest,
runLibeufinKeyrotationTest,
runLibeufinTutorialTest,
runLibeufinRefundTest,
runLibeufinC5xTest,
runLibeufinNexusBalanceTest,
runLibeufinBadGatewayTest,
runLibeufinRefundMultipleUsersTest,
runLibeufinApiPermissionsTest,
runLibeufinApiFacadeTest,
runLibeufinApiFacadeBadRequestTest,
runKycTest,
runLibeufinAnastasisFacadeTest,
runLibeufinApiSchedulingTest,
runLibeufinApiUsersTest,
runLibeufinApiBankaccountTest,
runLibeufinApiBankconnectionTest,
runLibeufinApiSandboxTransactionsTest,
runLibeufinApiFacadeBadRequestTest,
runLibeufinApiFacadeTest,
runLibeufinApiPermissionsTest,
runLibeufinApiSandboxCamtTest,
runLibeufinApiSandboxTransactionsTest,
runLibeufinApiSchedulingTest,
runLibeufinApiUsersTest,
runLibeufinBadGatewayTest,
runLibeufinBasicTest,
runLibeufinC5xTest,
runLibeufinKeyrotationTest,
runLibeufinNexusBalanceTest,
runLibeufinRefundMultipleUsersTest,
runLibeufinRefundTest,
runLibeufinSandboxWireTransferCliTest,
runLibeufinTutorialTest,
runMerchantExchangeConfusionTest,
runMerchantInstancesTest,
runMerchantInstancesDeleteTest,
runMerchantInstancesTest,
runMerchantInstancesUrlsTest,
runMerchantLongpollingTest,
runMerchantSpecPublicOrdersTest,
runMerchantRefundApiTest,
runMerchantSpecPublicOrdersTest,
runPaymentClaimTest,
runPaymentDemoTest,
runPaymentFaultTest,
runPaymentForgettableTest,
runPaymentIdempotencyTest,
runPaymentMultipleTest,
runPaymentTest,
runPaymentDemoTest,
runPaymentTransientTest,
runPaymentZeroTest,
runPayPaidTest,
runPaywallFlowTest,
runPeerToPeerPushTest,
runPeerToPeerPullTest,
runPeerToPeerPushTest,
runRefundAutoTest,
runRefundGoneTest,
runRefundIncrementalTest,
runRefundTest,
runRevocationTest,
runTestWithdrawalManualTest,
runWithdrawalFakebankTest,
runTimetravelAutorefreshTest,
runTimetravelWithdrawTest,
runTippingTest,
runWalletBackupBasicTest,
runWalletBackupDoublespendTest,
runWalletBalanceTest,
runWithdrawalHighTest,
runWallettestingTest,
runWalletCryptoWorkerTest,
runWalletDblessTest,
runWallettestingTest,
runWithdrawalAbortBankTest,
runWithdrawalBankIntegratedTest,
runWithdrawalFakebankTest,
runWithdrawalHighTest,
];
export interface TestRunSpec {

View File

@ -2027,3 +2027,18 @@ export interface ExchangeDepositRequest {
h_age_commitment?: string;
}
export interface WalletKycUuid {
// UUID that the wallet should use when initiating
// the KYC check.
requirement_row: number;
// Hash of the payto:// account URI for the wallet.
h_payto: string;
}
export const codecForWalletKycUuid = (): Codec<WalletKycUuid> =>
buildCodecForObject<WalletKycUuid>()
.property("requirement_row", codecForNumber())
.property("h_payto", codecForString())
.build("WalletKycUuid");

View File

@ -1327,6 +1327,11 @@ export type WgInfo =
| WgInfoBankPeerPush
| WgInfoBankRecoup;
export interface WithdrawalKycPendingInfo {
paytoHash: string;
requirementRow: number;
}
/**
* Group of withdrawal operations that need to be executed.
* (Either for a normal withdrawal or from a tip.)
@ -1342,6 +1347,8 @@ export interface WithdrawalGroupRecord {
wgInfo: WgInfo;
kycPending?: WithdrawalKycPendingInfo;
/**
* Secret seed used to derive planchets.
* Stored since planchets are created lazily.

View File

@ -33,6 +33,7 @@ import {
codecForBankWithdrawalOperationPostResponse,
codecForReserveStatus,
codecForTalerConfigResponse,
codecForWalletKycUuid,
codecForWithdrawBatchResponse,
codecForWithdrawOperationStatusResponse,
codecForWithdrawResponse,
@ -75,6 +76,7 @@ import {
WgInfo,
WithdrawalGroupRecord,
WithdrawalGroupStatus,
WithdrawalKycPendingInfo,
WithdrawalRecordType,
} from "../db.js";
import {
@ -530,8 +532,11 @@ async function processPlanchetExchangeRequest(
const resp = await ws.http.postJson(reqUrl, reqBody);
if (resp.status === HttpStatusCode.UnavailableForLegalReasons) {
logger.info("withdrawal requires KYC");
const respJson = await resp.json();
const uuidResp = codecForWalletKycUuid().decode(respJson);
logger.info(`kyc uuid response: ${j2s(uuidResp)}`);
await ws.db
.mktx((x) => [x.planchets])
.mktx((x) => [x.planchets, x.withdrawalGroups])
.runReadWrite(async (tx) => {
let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
withdrawalGroup.withdrawalGroupId,
@ -541,7 +546,18 @@ async function processPlanchetExchangeRequest(
return;
}
planchet.planchetStatus = PlanchetStatus.KycRequired;
const wg2 = await tx.withdrawalGroups.get(
withdrawalGroup.withdrawalGroupId,
);
if (!wg2) {
return;
}
wg2.kycPending = {
paytoHash: uuidResp.h_payto,
requirementRow: uuidResp.requirement_row,
};
await tx.planchets.put(planchet);
await tx.withdrawalGroups.put(wg2);
});
return;
}
@ -1148,7 +1164,7 @@ export async function processWithdrawalGroup(
let finishedForFirstTime = false;
let errorsPerCoin: Record<number, TalerErrorDetail> = {};
await ws.db
let res = await ws.db
.mktx((x) => [x.coins, x.withdrawalGroups, x.planchets])
.runReadWrite(async (tx) => {
const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
@ -1177,13 +1193,56 @@ export async function processWithdrawalGroup(
}
await tx.withdrawalGroups.put(wg);
return {
kycInfo: wg.kycPending,
};
});
if (!res) {
throw Error("withdrawal group does not exist anymore");
}
const { kycInfo } = res;
if (numKycRequired > 0) {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED,
{},
`KYC check required for withdrawal (not yet implemented in wallet-core)`,
);
if (kycInfo) {
const url = new URL(
`kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/individual`,
withdrawalGroup.exchangeBaseUrl,
);
logger.info(`kyc url ${url.href}`);
const kycStatusReq = await ws.http.fetch(url.href, {
method: "GET",
});
logger.warn("kyc requested, but already fulfilled");
if (kycStatusReq.status === HttpStatusCode.Ok) {
return {
type: OperationAttemptResultType.Pending,
result: undefined,
};
} else if (kycStatusReq.status === HttpStatusCode.Accepted) {
const kycStatus = await kycStatusReq.json();
logger.info(`kyc status: ${j2s(kycStatus)}`);
throw TalerError.fromDetail(
TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED,
{
kycUrl: kycStatus.kyc_url,
},
`KYC check required for withdrawal`,
);
} else {
throw Error(
`unexpected response from kyc-check (${kycStatusReq.status})`,
);
}
} else {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED,
{},
`KYC check required for withdrawal (not yet implemented in wallet-core)`,
);
}
}
if (numFinished != numTotalCoins) {
throw TalerError.fromDetail(