diff options
-rw-r--r-- | packages/taler-wallet-core/src/db.ts | 2 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/operations/transactions.ts | 1 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/operations/withdraw.ts | 334 |
3 files changed, 210 insertions, 127 deletions
diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index 3bf28aa94..9d0efbc6a 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -1416,6 +1416,8 @@ export interface WithdrawalGroupRecord { kycPending?: KycPendingInfo; + kycUrl?: string; + /** * Secret seed used to derive planchets. * Stored since planchets are created lazily. diff --git a/packages/taler-wallet-core/src/operations/transactions.ts b/packages/taler-wallet-core/src/operations/transactions.ts index 82b7cea64..b4791e7c3 100644 --- a/packages/taler-wallet-core/src/operations/transactions.ts +++ b/packages/taler-wallet-core/src/operations/transactions.ts @@ -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({ diff --git a/packages/taler-wallet-core/src/operations/withdraw.ts b/packages/taler-wallet-core/src/operations/withdraw.ts index ed9522c0f..28f4eeebb 100644 --- a/packages/taler-wallet-core/src/operations/withdraw.ts +++ b/packages/taler-wallet-core/src/operations/withdraw.ts @@ -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); } } |