wallet-core: fix withdrawal KYC transitions and use long-polling
This commit is contained in:
parent
30fb003ee3
commit
5eb339b836
@ -1416,6 +1416,8 @@ export interface WithdrawalGroupRecord {
|
||||
|
||||
kycPending?: KycPendingInfo;
|
||||
|
||||
kycUrl?: string;
|
||||
|
||||
/**
|
||||
* Secret seed used to derive planchets.
|
||||
* Stored since planchets are created lazily.
|
||||
|
@ -655,6 +655,7 @@ function buildTransactionForBankIntegratedWithdraw(
|
||||
wgRecord.status === WithdrawalGroupStatus.Finished ||
|
||||
wgRecord.status === WithdrawalGroupStatus.PendingReady,
|
||||
},
|
||||
kycUrl: wgRecord.kycUrl,
|
||||
exchangeBaseUrl: wgRecord.exchangeBaseUrl,
|
||||
timestamp: wgRecord.timestampStart,
|
||||
transactionId: constructTransactionIdentifier({
|
||||
|
@ -731,6 +731,96 @@ interface WithdrawalBatchResult {
|
||||
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.
|
||||
*
|
||||
@ -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> {
|
||||
const errDetail = getErrorDetailFromException(e);
|
||||
logger.trace("withdrawal request failed", e);
|
||||
@ -872,7 +925,7 @@ async function processPlanchetExchangeBatchRequest(
|
||||
try {
|
||||
const resp = await ws.http.postJson(reqUrl, batchReq);
|
||||
if (resp.status === HttpStatusCode.UnavailableForLegalReasons) {
|
||||
await handleKycRequired(resp, 0);
|
||||
await handleKycRequired(ws, withdrawalGroup, resp, 0, requestCoinIdxs);
|
||||
}
|
||||
const r = await readSuccessResponseJsonOrThrow(
|
||||
resp,
|
||||
@ -902,9 +955,15 @@ async function processPlanchetExchangeBatchRequest(
|
||||
`reserves/${withdrawalGroup.reservePub}/withdraw`,
|
||||
withdrawalGroup.exchangeBaseUrl,
|
||||
).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) {
|
||||
await handleKycRequired(resp, i);
|
||||
await handleKycRequired(
|
||||
ws,
|
||||
withdrawalGroup,
|
||||
resp,
|
||||
i,
|
||||
requestCoinIdxs,
|
||||
);
|
||||
// We still return blinded coins that we could actually withdraw.
|
||||
return {
|
||||
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(
|
||||
ws: InternalWalletState,
|
||||
withdrawalGroup: WithdrawalGroupRecord,
|
||||
@ -1419,8 +1568,6 @@ async function processWithdrawalGroupPendingReady(
|
||||
}
|
||||
|
||||
let numFinished = 0;
|
||||
let numKycRequired = 0;
|
||||
let finishedForFirstTime = false;
|
||||
const errorsPerCoin: Record<number, TalerErrorDetail> = {};
|
||||
let numPlanchetErrors = 0;
|
||||
const maxReportedErrors = 5;
|
||||
@ -1439,9 +1586,6 @@ async function processWithdrawalGroupPendingReady(
|
||||
if (x.planchetStatus === PlanchetStatus.WithdrawalDone) {
|
||||
numFinished++;
|
||||
}
|
||||
if (x.planchetStatus === PlanchetStatus.KycRequired) {
|
||||
numKycRequired++;
|
||||
}
|
||||
if (x.lastError) {
|
||||
numPlanchetErrors++;
|
||||
if (numPlanchetErrors < maxReportedErrors) {
|
||||
@ -1452,7 +1596,6 @@ async function processWithdrawalGroupPendingReady(
|
||||
const oldTxState = computeWithdrawalTransactionStatus(wg);
|
||||
logger.info(`now withdrawn ${numFinished} of ${numTotalCoins} coins`);
|
||||
if (wg.timestampFinish === undefined && numFinished === numTotalCoins) {
|
||||
finishedForFirstTime = true;
|
||||
wg.timestampFinish = TalerPreciseTimestamp.now();
|
||||
wg.status = WithdrawalGroupStatus.Finished;
|
||||
}
|
||||
@ -1475,46 +1618,6 @@ async function processWithdrawalGroupPendingReady(
|
||||
|
||||
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 {
|
||||
type: OperationAttemptResultType.Finished,
|
||||
result: undefined,
|
||||
@ -1588,53 +1691,30 @@ export async function processWithdrawalGroup(
|
||||
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:
|
||||
// Continue with the actual withdrawal!
|
||||
return await processWithdrawalGroupPendingReady(ws, withdrawalGroup);
|
||||
case WithdrawalGroupStatus.AbortingBank:
|
||||
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:
|
||||
throw new InvariantViolatedError(
|
||||
`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})`);
|
||||
assertUnreachable(withdrawalGroup.status);
|
||||
}
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user