wallet-core: fix withdrawal KYC transitions and use long-polling

This commit is contained in:
Florian Dold 2023-06-21 12:21:48 +02:00
parent 30fb003ee3
commit 5eb339b836
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
3 changed files with 210 additions and 127 deletions

View File

@ -1416,6 +1416,8 @@ export interface WithdrawalGroupRecord {
kycPending?: KycPendingInfo; kycPending?: KycPendingInfo;
kycUrl?: string;
/** /**
* Secret seed used to derive planchets. * Secret seed used to derive planchets.
* Stored since planchets are created lazily. * Stored since planchets are created lazily.

View File

@ -655,6 +655,7 @@ function buildTransactionForBankIntegratedWithdraw(
wgRecord.status === WithdrawalGroupStatus.Finished || wgRecord.status === WithdrawalGroupStatus.Finished ||
wgRecord.status === WithdrawalGroupStatus.PendingReady, wgRecord.status === WithdrawalGroupStatus.PendingReady,
}, },
kycUrl: wgRecord.kycUrl,
exchangeBaseUrl: wgRecord.exchangeBaseUrl, exchangeBaseUrl: wgRecord.exchangeBaseUrl,
timestamp: wgRecord.timestampStart, timestamp: wgRecord.timestampStart,
transactionId: constructTransactionIdentifier({ transactionId: constructTransactionIdentifier({

View File

@ -731,6 +731,96 @@ interface WithdrawalBatchResult {
batchResp: ExchangeWithdrawBatchResponse; batchResp: ExchangeWithdrawBatchResponse;
} }
async function handleKycRequired(
ws: InternalWalletState,
withdrawalGroup: WithdrawalGroupRecord,
resp: HttpResponse,
startIdx: number,
requestCoinIdxs: number[],
): Promise<void> {
logger.info("withdrawal requires KYC");
const respJson = await resp.json();
const uuidResp = codecForWalletKycUuid().decode(respJson);
const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Withdrawal,
withdrawalGroupId,
});
logger.info(`kyc uuid response: ${j2s(uuidResp)}`);
const exchangeUrl = withdrawalGroup.exchangeBaseUrl;
const userType = "individual";
const kycInfo: KycPendingInfo = {
paytoHash: uuidResp.h_payto,
requirementRow: uuidResp.requirement_row,
};
const url = new URL(
`kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
exchangeUrl,
);
logger.info(`kyc url ${url.href}`);
const kycStatusRes = await ws.http.fetch(url.href, {
method: "GET",
});
let kycUrl: string;
if (
kycStatusRes.status === HttpStatusCode.Ok ||
//FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
// remove after the exchange is fixed or clarified
kycStatusRes.status === HttpStatusCode.NoContent
) {
logger.warn("kyc requested, but already fulfilled");
return;
} else if (kycStatusRes.status === HttpStatusCode.Accepted) {
const kycStatus = await kycStatusRes.json();
logger.info(`kyc status: ${j2s(kycStatus)}`);
kycUrl = kycStatus.kyc_url;
} else {
throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
}
const transitionInfo = await ws.db
.mktx((x) => [x.planchets, x.withdrawalGroups])
.runReadWrite(async (tx) => {
for (let i = startIdx; i < requestCoinIdxs.length; i++) {
let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
withdrawalGroup.withdrawalGroupId,
requestCoinIdxs[i],
]);
if (!planchet) {
continue;
}
planchet.planchetStatus = PlanchetStatus.KycRequired;
await tx.planchets.put(planchet);
}
const wg2 = await tx.withdrawalGroups.get(
withdrawalGroup.withdrawalGroupId,
);
if (!wg2) {
return;
}
const oldTxState = computeWithdrawalTransactionStatus(wg2);
switch (wg2.status) {
case WithdrawalGroupStatus.PendingReady: {
wg2.kycPending = {
paytoHash: uuidResp.h_payto,
requirementRow: uuidResp.requirement_row,
};
wg2.kycUrl = kycUrl;
wg2.status = WithdrawalGroupStatus.PendingKyc;
await tx.withdrawalGroups.put(wg2);
const newTxState = computeWithdrawalTransactionStatus(wg2);
return {
oldTxState,
newTxState,
};
}
default:
return undefined;
}
});
notifyTransition(ws, transactionId, transitionInfo);
}
/** /**
* Send the withdrawal request for a generated planchet to the exchange. * Send the withdrawal request for a generated planchet to the exchange.
* *
@ -805,43 +895,6 @@ async function processPlanchetExchangeBatchRequest(
}; };
} }
async function handleKycRequired(
resp: HttpResponse,
startIdx: number,
): Promise<void> {
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, x.withdrawalGroups])
.runReadWrite(async (tx) => {
for (let i = startIdx; i < requestCoinIdxs.length; i++) {
let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
withdrawalGroup.withdrawalGroupId,
requestCoinIdxs[i],
]);
if (!planchet) {
continue;
}
planchet.planchetStatus = PlanchetStatus.KycRequired;
await tx.planchets.put(planchet);
}
const wg2 = await tx.withdrawalGroups.get(
withdrawalGroup.withdrawalGroupId,
);
if (!wg2) {
return;
}
wg2.kycPending = {
paytoHash: uuidResp.h_payto,
requirementRow: uuidResp.requirement_row,
};
await tx.withdrawalGroups.put(wg2);
});
return;
}
async function storeCoinError(e: any, coinIdx: number): Promise<void> { async function storeCoinError(e: any, coinIdx: number): Promise<void> {
const errDetail = getErrorDetailFromException(e); const errDetail = getErrorDetailFromException(e);
logger.trace("withdrawal request failed", e); logger.trace("withdrawal request failed", e);
@ -872,7 +925,7 @@ async function processPlanchetExchangeBatchRequest(
try { try {
const resp = await ws.http.postJson(reqUrl, batchReq); const resp = await ws.http.postJson(reqUrl, batchReq);
if (resp.status === HttpStatusCode.UnavailableForLegalReasons) { if (resp.status === HttpStatusCode.UnavailableForLegalReasons) {
await handleKycRequired(resp, 0); await handleKycRequired(ws, withdrawalGroup, resp, 0, requestCoinIdxs);
} }
const r = await readSuccessResponseJsonOrThrow( const r = await readSuccessResponseJsonOrThrow(
resp, resp,
@ -902,9 +955,15 @@ async function processPlanchetExchangeBatchRequest(
`reserves/${withdrawalGroup.reservePub}/withdraw`, `reserves/${withdrawalGroup.reservePub}/withdraw`,
withdrawalGroup.exchangeBaseUrl, withdrawalGroup.exchangeBaseUrl,
).href; ).href;
const resp = await ws.http.postJson(reqUrl, p); const resp = await ws.http.fetch(reqUrl, { method: "POST", body: p });
if (resp.status === HttpStatusCode.UnavailableForLegalReasons) { if (resp.status === HttpStatusCode.UnavailableForLegalReasons) {
await handleKycRequired(resp, i); await handleKycRequired(
ws,
withdrawalGroup,
resp,
i,
requestCoinIdxs,
);
// We still return blinded coins that we could actually withdraw. // We still return blinded coins that we could actually withdraw.
return { return {
coinIdxs: responseCoinIdxs, coinIdxs: responseCoinIdxs,
@ -1321,6 +1380,96 @@ async function processWithdrawalGroupAbortingBank(
}; };
} }
/**
* Store in the database that the KYC for a withdrawal is now
* satisfied.
*/
async function transitionKycSatisfied(
ws: InternalWalletState,
withdrawalGroup: WithdrawalGroupRecord,
): Promise<void> {
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Withdrawal,
withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
});
const transitionInfo = await ws.db
.mktx((x) => [x.withdrawalGroups])
.runReadWrite(async (tx) => {
const wg2 = await tx.withdrawalGroups.get(
withdrawalGroup.withdrawalGroupId,
);
if (!wg2) {
return;
}
const oldTxState = computeWithdrawalTransactionStatus(wg2);
switch (wg2.status) {
case WithdrawalGroupStatus.PendingKyc: {
delete wg2.kycPending;
delete wg2.kycUrl;
wg2.status = WithdrawalGroupStatus.PendingReady;
await tx.withdrawalGroups.put(wg2);
const newTxState = computeWithdrawalTransactionStatus(wg2);
return {
oldTxState,
newTxState,
};
}
default:
return undefined;
}
});
notifyTransition(ws, transactionId, transitionInfo);
}
async function processWithdrawalGroupPendingKyc(
ws: InternalWalletState,
withdrawalGroup: WithdrawalGroupRecord,
): Promise<OperationAttemptResult> {
const userType = "individual";
const kycInfo = withdrawalGroup.kycPending;
if (!kycInfo) {
throw Error("no kyc info available in pending(kyc)");
}
const exchangeUrl = withdrawalGroup.exchangeBaseUrl;
const url = new URL(
`kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
exchangeUrl,
);
url.searchParams.set("timeout_ms", "30000");
const retryTag = TaskIdentifiers.forWithdrawal(withdrawalGroup);
runLongpollAsync(ws, retryTag, async (cancellationToken) => {
logger.info(`long-polling for withdrawal KYC status via ${url.href}`);
const kycStatusRes = await ws.http.fetch(url.href, {
method: "GET",
cancellationToken,
});
logger.info(
`kyc long-polling response status: HTTP ${kycStatusRes.status}`,
);
if (
kycStatusRes.status === HttpStatusCode.Ok ||
//FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
// remove after the exchange is fixed or clarified
kycStatusRes.status === HttpStatusCode.NoContent
) {
await transitionKycSatisfied(ws, withdrawalGroup);
return { ready: true };
} else if (kycStatusRes.status === HttpStatusCode.Accepted) {
const kycStatus = await kycStatusRes.json();
logger.info(`kyc status: ${j2s(kycStatus)}`);
// FIXME: do we need to update the KYC url, or does it always stay constant?
return { ready: false };
} else {
throw Error(
`unexpected response from kyc-check (${kycStatusRes.status})`,
);
}
});
return OperationAttemptResult.longpoll();
}
async function processWithdrawalGroupPendingReady( async function processWithdrawalGroupPendingReady(
ws: InternalWalletState, ws: InternalWalletState,
withdrawalGroup: WithdrawalGroupRecord, withdrawalGroup: WithdrawalGroupRecord,
@ -1419,8 +1568,6 @@ async function processWithdrawalGroupPendingReady(
} }
let numFinished = 0; let numFinished = 0;
let numKycRequired = 0;
let finishedForFirstTime = false;
const errorsPerCoin: Record<number, TalerErrorDetail> = {}; const errorsPerCoin: Record<number, TalerErrorDetail> = {};
let numPlanchetErrors = 0; let numPlanchetErrors = 0;
const maxReportedErrors = 5; const maxReportedErrors = 5;
@ -1439,9 +1586,6 @@ async function processWithdrawalGroupPendingReady(
if (x.planchetStatus === PlanchetStatus.WithdrawalDone) { if (x.planchetStatus === PlanchetStatus.WithdrawalDone) {
numFinished++; numFinished++;
} }
if (x.planchetStatus === PlanchetStatus.KycRequired) {
numKycRequired++;
}
if (x.lastError) { if (x.lastError) {
numPlanchetErrors++; numPlanchetErrors++;
if (numPlanchetErrors < maxReportedErrors) { if (numPlanchetErrors < maxReportedErrors) {
@ -1452,7 +1596,6 @@ async function processWithdrawalGroupPendingReady(
const oldTxState = computeWithdrawalTransactionStatus(wg); const oldTxState = computeWithdrawalTransactionStatus(wg);
logger.info(`now withdrawn ${numFinished} of ${numTotalCoins} coins`); logger.info(`now withdrawn ${numFinished} of ${numTotalCoins} coins`);
if (wg.timestampFinish === undefined && numFinished === numTotalCoins) { if (wg.timestampFinish === undefined && numFinished === numTotalCoins) {
finishedForFirstTime = true;
wg.timestampFinish = TalerPreciseTimestamp.now(); wg.timestampFinish = TalerPreciseTimestamp.now();
wg.status = WithdrawalGroupStatus.Finished; wg.status = WithdrawalGroupStatus.Finished;
} }
@ -1475,46 +1618,6 @@ async function processWithdrawalGroupPendingReady(
notifyTransition(ws, transactionId, res.transitionInfo); notifyTransition(ws, transactionId, res.transitionInfo);
const { kycInfo } = res;
if (numKycRequired > 0) {
if (kycInfo) {
const txId = constructTransactionIdentifier({
tag: TransactionType.Withdrawal,
withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
});
await checkWithdrawalKycStatus(
ws,
withdrawalGroup.exchangeBaseUrl,
txId,
kycInfo,
"individual",
);
return {
type: OperationAttemptResultType.Pending,
result: undefined,
};
} else {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED,
{
//FIXME we can't rise KYC error here since we don't have the url
} as any,
`KYC check required for withdrawal (not yet implemented in wallet-core)`,
);
}
}
if (numFinished != numTotalCoins) {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_WITHDRAWAL_GROUP_INCOMPLETE,
{
numErrors: numPlanchetErrors,
errorsPerCoin,
},
`withdrawal did not finish (${numFinished} / ${numTotalCoins} coins withdrawn)`,
);
}
return { return {
type: OperationAttemptResultType.Finished, type: OperationAttemptResultType.Finished,
result: undefined, result: undefined,
@ -1588,53 +1691,30 @@ export async function processWithdrawalGroup(
result: undefined, result: undefined,
}; };
} }
case WithdrawalGroupStatus.PendingAml:
// FIXME: Handle this case, withdrawal doesn't support AML yet.
return OperationAttemptResult.pendingEmpty();
case WithdrawalGroupStatus.PendingKyc:
return processWithdrawalGroupPendingKyc(ws, withdrawalGroup);
case WithdrawalGroupStatus.PendingReady: case WithdrawalGroupStatus.PendingReady:
// Continue with the actual withdrawal! // Continue with the actual withdrawal!
return await processWithdrawalGroupPendingReady(ws, withdrawalGroup); return await processWithdrawalGroupPendingReady(ws, withdrawalGroup);
case WithdrawalGroupStatus.AbortingBank: case WithdrawalGroupStatus.AbortingBank:
return await processWithdrawalGroupAbortingBank(ws, withdrawalGroup); return await processWithdrawalGroupAbortingBank(ws, withdrawalGroup);
case WithdrawalGroupStatus.AbortedBank:
case WithdrawalGroupStatus.AbortedExchange:
case WithdrawalGroupStatus.FailedAbortingBank:
case WithdrawalGroupStatus.SuspendedAbortingBank:
case WithdrawalGroupStatus.SuspendedAml:
case WithdrawalGroupStatus.SuspendedKyc:
case WithdrawalGroupStatus.SuspendedQueryingStatus:
case WithdrawalGroupStatus.SuspendedReady:
case WithdrawalGroupStatus.SuspendedRegisteringBank:
case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
// Nothing to do.
return OperationAttemptResult.finishedEmpty();
default: default:
throw new InvariantViolatedError( assertUnreachable(withdrawalGroup.status);
`unknown withdrawal group status: ${withdrawalGroup.status}`,
);
}
}
export async function checkWithdrawalKycStatus(
ws: InternalWalletState,
exchangeUrl: string,
txId: string,
kycInfo: KycPendingInfo,
userType: KycUserType,
): Promise<void> {
const url = new URL(
`kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
exchangeUrl,
);
logger.info(`kyc url ${url.href}`);
const kycStatusRes = await ws.http.fetch(url.href, {
method: "GET",
});
if (
kycStatusRes.status === HttpStatusCode.Ok ||
//FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
// remove after the exchange is fixed or clarified
kycStatusRes.status === HttpStatusCode.NoContent
) {
logger.warn("kyc requested, but already fulfilled");
return;
} else if (kycStatusRes.status === HttpStatusCode.Accepted) {
const kycStatus = await kycStatusRes.json();
logger.info(`kyc status: ${j2s(kycStatus)}`);
throw TalerError.fromDetail(
TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED, //FIXME: another error code or rename for merge
{
kycUrl: kycStatus.kyc_url,
},
`KYC check required for transfer`,
);
} else {
throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
} }
} }