/* This file is part of GNU Taler (C) 2022-2023 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 */ import { AbsoluteTime, Amounts, CheckPeerPullCreditRequest, CheckPeerPullCreditResponse, ContractTermsUtil, ExchangeReservePurseRequest, HttpStatusCode, InitiatePeerPullCreditRequest, InitiatePeerPullCreditResponse, Logger, NotificationType, PeerContractTerms, TalerErrorCode, TalerPreciseTimestamp, TalerProtocolTimestamp, TalerUriAction, TransactionAction, TransactionIdStr, TransactionMajorState, TransactionMinorState, TransactionState, TransactionType, WalletAccountMergeFlags, WalletKycUuid, assertUnreachable, checkDbInvariant, codecForAny, codecForWalletKycUuid, encodeCrock, getRandomBytes, j2s, makeErrorDetail, stringifyTalerUri, talerPaytoFromExchangeReserve, } from "@gnu-taler/taler-util"; import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; import { PendingTaskType, TaskIdStr, TaskRunResult, TaskRunResultType, TombstoneTag, TransactionContext, constructTaskIdentifier, } from "./common.js"; import { KycPendingInfo, KycUserType, PeerPullCreditRecord, PeerPullPaymentCreditStatus, WithdrawalGroupStatus, WithdrawalRecordType, timestampOptionalPreciseFromDb, timestampPreciseFromDb, timestampPreciseToDb, } from "./db.js"; import { fetchFreshExchange } from "./exchanges.js"; import { codecForExchangePurseStatus, getMergeReserveInfo, } from "./pay-peer-common.js"; import { constructTransactionIdentifier, notifyTransition, } from "./transactions.js"; import { WalletExecutionContext } from "./wallet.js"; import { getExchangeWithdrawalInfo, internalCreateWithdrawalGroup, waitWithdrawalFinal, } from "./withdraw.js"; const logger = new Logger("pay-peer-pull-credit.ts"); export class PeerPullCreditTransactionContext implements TransactionContext { readonly transactionId: TransactionIdStr; readonly taskId: TaskIdStr; constructor( public wex: WalletExecutionContext, public pursePub: string, ) { this.taskId = constructTaskIdentifier({ tag: PendingTaskType.PeerPullCredit, pursePub, }); this.transactionId = constructTransactionIdentifier({ tag: TransactionType.PeerPullCredit, pursePub, }); } async deleteTransaction(): Promise { const { wex: ws, pursePub } = this; await ws.db.runReadWriteTx( { storeNames: ["withdrawalGroups", "peerPullCredit", "tombstones"] }, async (tx) => { const pullIni = await tx.peerPullCredit.get(pursePub); if (!pullIni) { return; } if (pullIni.withdrawalGroupId) { const withdrawalGroupId = pullIni.withdrawalGroupId; const withdrawalGroupRecord = await tx.withdrawalGroups.get(withdrawalGroupId); if (withdrawalGroupRecord) { await tx.withdrawalGroups.delete(withdrawalGroupId); await tx.tombstones.put({ id: TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId, }); } } await tx.peerPullCredit.delete(pursePub); await tx.tombstones.put({ id: TombstoneTag.DeletePeerPullCredit + ":" + pursePub, }); }, ); return; } async suspendTransaction(): Promise { const { wex, pursePub, taskId: retryTag, transactionId } = this; const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["peerPullCredit"] }, async (tx) => { const pullCreditRec = await tx.peerPullCredit.get(pursePub); if (!pullCreditRec) { logger.warn(`peer pull credit ${pursePub} not found`); return; } let newStatus: PeerPullPaymentCreditStatus | undefined = undefined; switch (pullCreditRec.status) { case PeerPullPaymentCreditStatus.PendingCreatePurse: newStatus = PeerPullPaymentCreditStatus.SuspendedCreatePurse; break; case PeerPullPaymentCreditStatus.PendingMergeKycRequired: newStatus = PeerPullPaymentCreditStatus.SuspendedMergeKycRequired; break; case PeerPullPaymentCreditStatus.PendingWithdrawing: newStatus = PeerPullPaymentCreditStatus.SuspendedWithdrawing; break; case PeerPullPaymentCreditStatus.PendingReady: newStatus = PeerPullPaymentCreditStatus.SuspendedReady; break; case PeerPullPaymentCreditStatus.AbortingDeletePurse: newStatus = PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse; break; case PeerPullPaymentCreditStatus.Done: case PeerPullPaymentCreditStatus.SuspendedCreatePurse: case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired: case PeerPullPaymentCreditStatus.SuspendedReady: case PeerPullPaymentCreditStatus.SuspendedWithdrawing: case PeerPullPaymentCreditStatus.Aborted: case PeerPullPaymentCreditStatus.Failed: case PeerPullPaymentCreditStatus.Expired: case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse: break; default: assertUnreachable(pullCreditRec.status); } if (newStatus != null) { const oldTxState = computePeerPullCreditTransactionState(pullCreditRec); pullCreditRec.status = newStatus; const newTxState = computePeerPullCreditTransactionState(pullCreditRec); await tx.peerPullCredit.put(pullCreditRec); return { oldTxState, newTxState, }; } return undefined; }, ); wex.taskScheduler.stopShepherdTask(retryTag); notifyTransition(wex, transactionId, transitionInfo); } async failTransaction(): Promise { const { wex, pursePub, taskId: retryTag, transactionId } = this; const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["peerPullCredit"] }, async (tx) => { const pullCreditRec = await tx.peerPullCredit.get(pursePub); if (!pullCreditRec) { logger.warn(`peer pull credit ${pursePub} not found`); return; } let newStatus: PeerPullPaymentCreditStatus | undefined = undefined; switch (pullCreditRec.status) { case PeerPullPaymentCreditStatus.PendingCreatePurse: case PeerPullPaymentCreditStatus.PendingMergeKycRequired: case PeerPullPaymentCreditStatus.PendingWithdrawing: case PeerPullPaymentCreditStatus.PendingReady: case PeerPullPaymentCreditStatus.Done: case PeerPullPaymentCreditStatus.SuspendedCreatePurse: case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired: case PeerPullPaymentCreditStatus.SuspendedReady: case PeerPullPaymentCreditStatus.SuspendedWithdrawing: case PeerPullPaymentCreditStatus.Aborted: case PeerPullPaymentCreditStatus.Failed: case PeerPullPaymentCreditStatus.Expired: break; case PeerPullPaymentCreditStatus.AbortingDeletePurse: case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse: newStatus = PeerPullPaymentCreditStatus.Failed; break; default: assertUnreachable(pullCreditRec.status); } if (newStatus != null) { const oldTxState = computePeerPullCreditTransactionState(pullCreditRec); pullCreditRec.status = newStatus; const newTxState = computePeerPullCreditTransactionState(pullCreditRec); await tx.peerPullCredit.put(pullCreditRec); return { oldTxState, newTxState, }; } return undefined; }, ); notifyTransition(wex, transactionId, transitionInfo); wex.taskScheduler.stopShepherdTask(retryTag); } async resumeTransaction(): Promise { const { wex, pursePub, taskId: retryTag, transactionId } = this; const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["peerPullCredit"] }, async (tx) => { const pullCreditRec = await tx.peerPullCredit.get(pursePub); if (!pullCreditRec) { logger.warn(`peer pull credit ${pursePub} not found`); return; } let newStatus: PeerPullPaymentCreditStatus | undefined = undefined; switch (pullCreditRec.status) { case PeerPullPaymentCreditStatus.PendingCreatePurse: case PeerPullPaymentCreditStatus.PendingMergeKycRequired: case PeerPullPaymentCreditStatus.PendingWithdrawing: case PeerPullPaymentCreditStatus.PendingReady: case PeerPullPaymentCreditStatus.AbortingDeletePurse: case PeerPullPaymentCreditStatus.Done: case PeerPullPaymentCreditStatus.Failed: case PeerPullPaymentCreditStatus.Expired: case PeerPullPaymentCreditStatus.Aborted: break; case PeerPullPaymentCreditStatus.SuspendedCreatePurse: newStatus = PeerPullPaymentCreditStatus.PendingCreatePurse; break; case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired: newStatus = PeerPullPaymentCreditStatus.PendingMergeKycRequired; break; case PeerPullPaymentCreditStatus.SuspendedReady: newStatus = PeerPullPaymentCreditStatus.PendingReady; break; case PeerPullPaymentCreditStatus.SuspendedWithdrawing: newStatus = PeerPullPaymentCreditStatus.PendingWithdrawing; break; case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse: newStatus = PeerPullPaymentCreditStatus.AbortingDeletePurse; break; default: assertUnreachable(pullCreditRec.status); } if (newStatus != null) { const oldTxState = computePeerPullCreditTransactionState(pullCreditRec); pullCreditRec.status = newStatus; const newTxState = computePeerPullCreditTransactionState(pullCreditRec); await tx.peerPullCredit.put(pullCreditRec); return { oldTxState, newTxState, }; } return undefined; }, ); notifyTransition(wex, transactionId, transitionInfo); wex.taskScheduler.startShepherdTask(retryTag); } async abortTransaction(): Promise { const { wex, pursePub, taskId: retryTag, transactionId } = this; const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["peerPullCredit"] }, async (tx) => { const pullCreditRec = await tx.peerPullCredit.get(pursePub); if (!pullCreditRec) { logger.warn(`peer pull credit ${pursePub} not found`); return; } let newStatus: PeerPullPaymentCreditStatus | undefined = undefined; switch (pullCreditRec.status) { case PeerPullPaymentCreditStatus.PendingCreatePurse: case PeerPullPaymentCreditStatus.PendingMergeKycRequired: newStatus = PeerPullPaymentCreditStatus.AbortingDeletePurse; break; case PeerPullPaymentCreditStatus.PendingWithdrawing: throw Error("can't abort anymore"); case PeerPullPaymentCreditStatus.PendingReady: newStatus = PeerPullPaymentCreditStatus.AbortingDeletePurse; break; case PeerPullPaymentCreditStatus.Done: case PeerPullPaymentCreditStatus.SuspendedCreatePurse: case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired: case PeerPullPaymentCreditStatus.SuspendedReady: case PeerPullPaymentCreditStatus.SuspendedWithdrawing: case PeerPullPaymentCreditStatus.Aborted: case PeerPullPaymentCreditStatus.AbortingDeletePurse: case PeerPullPaymentCreditStatus.Failed: case PeerPullPaymentCreditStatus.Expired: case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse: break; default: assertUnreachable(pullCreditRec.status); } if (newStatus != null) { const oldTxState = computePeerPullCreditTransactionState(pullCreditRec); pullCreditRec.status = newStatus; const newTxState = computePeerPullCreditTransactionState(pullCreditRec); await tx.peerPullCredit.put(pullCreditRec); return { oldTxState, newTxState, }; } return undefined; }, ); wex.taskScheduler.stopShepherdTask(retryTag); notifyTransition(wex, transactionId, transitionInfo); wex.taskScheduler.startShepherdTask(retryTag); } } async function queryPurseForPeerPullCredit( wex: WalletExecutionContext, pullIni: PeerPullCreditRecord, ): Promise { const purseDepositUrl = new URL( `purses/${pullIni.pursePub}/deposit`, pullIni.exchangeBaseUrl, ); purseDepositUrl.searchParams.set("timeout_ms", "30000"); logger.info(`querying purse status via ${purseDepositUrl.href}`); const resp = await wex.http.fetch(purseDepositUrl.href, { timeout: { d_ms: 60000 }, cancellationToken: wex.cancellationToken, }); const transactionId = constructTransactionIdentifier({ tag: TransactionType.PeerPullCredit, pursePub: pullIni.pursePub, }); logger.info(`purse status code: HTTP ${resp.status}`); switch (resp.status) { case HttpStatusCode.Gone: { // Exchange says that purse doesn't exist anymore => expired! const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["peerPullCredit"] }, async (tx) => { const finPi = await tx.peerPullCredit.get(pullIni.pursePub); if (!finPi) { logger.warn("peerPullCredit not found anymore"); return; } const oldTxState = computePeerPullCreditTransactionState(finPi); if (finPi.status === PeerPullPaymentCreditStatus.PendingReady) { finPi.status = PeerPullPaymentCreditStatus.Expired; } await tx.peerPullCredit.put(finPi); const newTxState = computePeerPullCreditTransactionState(finPi); return { oldTxState, newTxState }; }, ); notifyTransition(wex, transactionId, transitionInfo); return TaskRunResult.backoff(); } case HttpStatusCode.NotFound: // FIXME: Maybe check error code? 404 could also mean something else. return TaskRunResult.longpollReturnedPending(); } const result = await readSuccessResponseJsonOrThrow( resp, codecForExchangePurseStatus(), ); logger.trace(`purse status: ${j2s(result)}`); const depositTimestamp = result.deposit_timestamp; if (!depositTimestamp || TalerProtocolTimestamp.isNever(depositTimestamp)) { logger.info("purse not ready yet (no deposit)"); return TaskRunResult.backoff(); } const reserve = await wex.db.runReadOnlyTx( { storeNames: ["reserves"] }, async (tx) => { return await tx.reserves.get(pullIni.mergeReserveRowId); }, ); if (!reserve) { throw Error("reserve for peer pull credit not found in wallet DB"); } await internalCreateWithdrawalGroup(wex, { amount: Amounts.parseOrThrow(pullIni.amount), wgInfo: { withdrawalType: WithdrawalRecordType.PeerPullCredit, contractPriv: pullIni.contractPriv, }, forcedWithdrawalGroupId: pullIni.withdrawalGroupId, exchangeBaseUrl: pullIni.exchangeBaseUrl, reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus, reserveKeyPair: { priv: reserve.reservePriv, pub: reserve.reservePub, }, }); const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["peerPullCredit"] }, async (tx) => { const finPi = await tx.peerPullCredit.get(pullIni.pursePub); if (!finPi) { logger.warn("peerPullCredit not found anymore"); return; } const oldTxState = computePeerPullCreditTransactionState(finPi); if (finPi.status === PeerPullPaymentCreditStatus.PendingReady) { finPi.status = PeerPullPaymentCreditStatus.PendingWithdrawing; } await tx.peerPullCredit.put(finPi); const newTxState = computePeerPullCreditTransactionState(finPi); return { oldTxState, newTxState }; }, ); notifyTransition(wex, transactionId, transitionInfo); return TaskRunResult.backoff(); } async function longpollKycStatus( wex: WalletExecutionContext, pursePub: string, exchangeUrl: string, kycInfo: KycPendingInfo, userType: KycUserType, ): Promise { const transactionId = constructTransactionIdentifier({ tag: TransactionType.PeerPullCredit, pursePub, }); const url = new URL( `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`, exchangeUrl, ); url.searchParams.set("timeout_ms", "10000"); logger.info(`kyc url ${url.href}`); const kycStatusRes = await wex.http.fetch(url.href, { method: "GET", cancellationToken: wex.cancellationToken, }); 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 ) { const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["peerPullCredit"] }, async (tx) => { const peerIni = await tx.peerPullCredit.get(pursePub); if (!peerIni) { return; } if ( peerIni.status !== PeerPullPaymentCreditStatus.PendingMergeKycRequired ) { return; } const oldTxState = computePeerPullCreditTransactionState(peerIni); peerIni.status = PeerPullPaymentCreditStatus.PendingCreatePurse; const newTxState = computePeerPullCreditTransactionState(peerIni); await tx.peerPullCredit.put(peerIni); return { oldTxState, newTxState }; }, ); notifyTransition(wex, transactionId, transitionInfo); return TaskRunResult.progress(); } else if (kycStatusRes.status === HttpStatusCode.Accepted) { return TaskRunResult.longpollReturnedPending(); } else { throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`); } } async function processPeerPullCreditAbortingDeletePurse( wex: WalletExecutionContext, peerPullIni: PeerPullCreditRecord, ): Promise { const { pursePub, pursePriv } = peerPullIni; const transactionId = constructTransactionIdentifier({ tag: TransactionType.PeerPullCredit, pursePub, }); const sigResp = await wex.cryptoApi.signDeletePurse({ pursePriv, }); const purseUrl = new URL(`purses/${pursePub}`, peerPullIni.exchangeBaseUrl); const resp = await wex.http.fetch(purseUrl.href, { method: "DELETE", headers: { "taler-purse-signature": sigResp.sig, }, cancellationToken: wex.cancellationToken, }); logger.info(`deleted purse with response status ${resp.status}`); const transitionInfo = await wex.db.runReadWriteTx( { storeNames: [ "peerPullCredit", "refreshGroups", "denominations", "coinAvailability", "coins", ], }, async (tx) => { const ppiRec = await tx.peerPullCredit.get(pursePub); if (!ppiRec) { return undefined; } if (ppiRec.status !== PeerPullPaymentCreditStatus.AbortingDeletePurse) { return undefined; } const oldTxState = computePeerPullCreditTransactionState(ppiRec); ppiRec.status = PeerPullPaymentCreditStatus.Aborted; await tx.peerPullCredit.put(ppiRec); const newTxState = computePeerPullCreditTransactionState(ppiRec); return { oldTxState, newTxState, }; }, ); notifyTransition(wex, transactionId, transitionInfo); return TaskRunResult.backoff(); } async function handlePeerPullCreditWithdrawing( wex: WalletExecutionContext, pullIni: PeerPullCreditRecord, ): Promise { if (!pullIni.withdrawalGroupId) { throw Error("invalid db state (withdrawing, but no withdrawal group ID"); } await waitWithdrawalFinal(wex, pullIni.withdrawalGroupId); const transactionId = constructTransactionIdentifier({ tag: TransactionType.PeerPullCredit, pursePub: pullIni.pursePub, }); const wgId = pullIni.withdrawalGroupId; let finished: boolean = false; const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["peerPullCredit", "withdrawalGroups"] }, async (tx) => { const ppi = await tx.peerPullCredit.get(pullIni.pursePub); if (!ppi) { finished = true; return; } if (ppi.status !== PeerPullPaymentCreditStatus.PendingWithdrawing) { finished = true; return; } const oldTxState = computePeerPullCreditTransactionState(ppi); const wg = await tx.withdrawalGroups.get(wgId); if (!wg) { // FIXME: Fail the operation instead? return undefined; } switch (wg.status) { case WithdrawalGroupStatus.Done: finished = true; ppi.status = PeerPullPaymentCreditStatus.Done; break; // FIXME: Also handle other final states! } await tx.peerPullCredit.put(ppi); const newTxState = computePeerPullCreditTransactionState(ppi); return { oldTxState, newTxState, }; }, ); notifyTransition(wex, transactionId, transitionInfo); if (finished) { return TaskRunResult.finished(); } else { // FIXME: Return indicator that we depend on the other operation! return TaskRunResult.backoff(); } } async function handlePeerPullCreditCreatePurse( wex: WalletExecutionContext, pullIni: PeerPullCreditRecord, ): Promise { const purseFee = Amounts.stringify(Amounts.zeroOfAmount(pullIni.amount)); const pursePub = pullIni.pursePub; const mergeReserve = await wex.db.runReadOnlyTx( { storeNames: ["reserves"] }, async (tx) => { return tx.reserves.get(pullIni.mergeReserveRowId); }, ); if (!mergeReserve) { throw Error("merge reserve for peer pull payment not found in database"); } const contractTermsRecord = await wex.db.runReadOnlyTx( { storeNames: ["contractTerms"] }, async (tx) => { return tx.contractTerms.get(pullIni.contractTermsHash); }, ); if (!contractTermsRecord) { throw Error("contract terms for peer pull payment not found in database"); } const contractTerms: PeerContractTerms = contractTermsRecord.contractTermsRaw; const reservePayto = talerPaytoFromExchangeReserve( pullIni.exchangeBaseUrl, mergeReserve.reservePub, ); const econtractResp = await wex.cryptoApi.encryptContractForDeposit({ contractPriv: pullIni.contractPriv, contractPub: pullIni.contractPub, contractTerms: contractTermsRecord.contractTermsRaw, pursePriv: pullIni.pursePriv, pursePub: pullIni.pursePub, nonce: pullIni.contractEncNonce, }); const mergeTimestamp = timestampPreciseFromDb(pullIni.mergeTimestamp); const purseExpiration = contractTerms.purse_expiration; const sigRes = await wex.cryptoApi.signReservePurseCreate({ contractTermsHash: pullIni.contractTermsHash, flags: WalletAccountMergeFlags.CreateWithPurseFee, mergePriv: pullIni.mergePriv, mergeTimestamp: TalerPreciseTimestamp.round(mergeTimestamp), purseAmount: pullIni.amount, purseExpiration: purseExpiration, purseFee: purseFee, pursePriv: pullIni.pursePriv, pursePub: pullIni.pursePub, reservePayto, reservePriv: mergeReserve.reservePriv, }); const reservePurseReqBody: ExchangeReservePurseRequest = { merge_sig: sigRes.mergeSig, merge_timestamp: TalerPreciseTimestamp.round(mergeTimestamp), h_contract_terms: pullIni.contractTermsHash, merge_pub: pullIni.mergePub, min_age: 0, purse_expiration: purseExpiration, purse_fee: purseFee, purse_pub: pullIni.pursePub, purse_sig: sigRes.purseSig, purse_value: pullIni.amount, reserve_sig: sigRes.accountSig, econtract: econtractResp.econtract, }; logger.info(`reserve purse request: ${j2s(reservePurseReqBody)}`); const reservePurseMergeUrl = new URL( `reserves/${mergeReserve.reservePub}/purse`, pullIni.exchangeBaseUrl, ); const httpResp = await wex.http.fetch(reservePurseMergeUrl.href, { method: "POST", body: reservePurseReqBody, cancellationToken: wex.cancellationToken, }); if (httpResp.status === HttpStatusCode.UnavailableForLegalReasons) { const respJson = await httpResp.json(); const kycPending = codecForWalletKycUuid().decode(respJson); logger.info(`kyc uuid response: ${j2s(kycPending)}`); return processPeerPullCreditKycRequired(wex, pullIni, kycPending); } const resp = await readSuccessResponseJsonOrThrow(httpResp, codecForAny()); logger.info(`reserve merge response: ${j2s(resp)}`); const transactionId = constructTransactionIdentifier({ tag: TransactionType.PeerPullCredit, pursePub: pullIni.pursePub, }); const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["peerPullCredit"] }, async (tx) => { const pi2 = await tx.peerPullCredit.get(pursePub); if (!pi2) { return; } const oldTxState = computePeerPullCreditTransactionState(pi2); pi2.status = PeerPullPaymentCreditStatus.PendingReady; await tx.peerPullCredit.put(pi2); const newTxState = computePeerPullCreditTransactionState(pi2); return { oldTxState, newTxState }; }, ); notifyTransition(wex, transactionId, transitionInfo); return TaskRunResult.backoff(); } export async function processPeerPullCredit( wex: WalletExecutionContext, pursePub: string, ): Promise { const pullIni = await wex.db.runReadOnlyTx( { storeNames: ["peerPullCredit"] }, async (tx) => { return tx.peerPullCredit.get(pursePub); }, ); if (!pullIni) { throw Error("peer pull payment initiation not found in database"); } const retryTag = constructTaskIdentifier({ tag: PendingTaskType.PeerPullCredit, pursePub, }); logger.trace(`processing ${retryTag}, status=${pullIni.status}`); switch (pullIni.status) { case PeerPullPaymentCreditStatus.Done: { return TaskRunResult.finished(); } case PeerPullPaymentCreditStatus.PendingReady: return queryPurseForPeerPullCredit(wex, pullIni); case PeerPullPaymentCreditStatus.PendingMergeKycRequired: { if (!pullIni.kycInfo) { throw Error("invalid state, kycInfo required"); } return await longpollKycStatus( wex, pursePub, pullIni.exchangeBaseUrl, pullIni.kycInfo, "individual", ); } case PeerPullPaymentCreditStatus.PendingCreatePurse: return handlePeerPullCreditCreatePurse(wex, pullIni); case PeerPullPaymentCreditStatus.AbortingDeletePurse: return await processPeerPullCreditAbortingDeletePurse(wex, pullIni); case PeerPullPaymentCreditStatus.PendingWithdrawing: return handlePeerPullCreditWithdrawing(wex, pullIni); case PeerPullPaymentCreditStatus.Aborted: case PeerPullPaymentCreditStatus.Failed: case PeerPullPaymentCreditStatus.Expired: case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse: case PeerPullPaymentCreditStatus.SuspendedCreatePurse: case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired: case PeerPullPaymentCreditStatus.SuspendedReady: case PeerPullPaymentCreditStatus.SuspendedWithdrawing: break; default: assertUnreachable(pullIni.status); } return TaskRunResult.finished(); } async function processPeerPullCreditKycRequired( wex: WalletExecutionContext, peerIni: PeerPullCreditRecord, kycPending: WalletKycUuid, ): Promise { const transactionId = constructTransactionIdentifier({ tag: TransactionType.PeerPullCredit, pursePub: peerIni.pursePub, }); const { pursePub } = peerIni; const userType = "individual"; const url = new URL( `kyc-check/${kycPending.requirement_row}/${kycPending.h_payto}/${userType}`, peerIni.exchangeBaseUrl, ); logger.info(`kyc url ${url.href}`); const kycStatusRes = await wex.http.fetch(url.href, { method: "GET", cancellationToken: wex.cancellationToken, }); 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 TaskRunResult.backoff(); } else if (kycStatusRes.status === HttpStatusCode.Accepted) { const kycStatus = await kycStatusRes.json(); logger.info(`kyc status: ${j2s(kycStatus)}`); const { transitionInfo, result } = await wex.db.runReadWriteTx( { storeNames: ["peerPullCredit"] }, async (tx) => { const peerInc = await tx.peerPullCredit.get(pursePub); if (!peerInc) { return { transitionInfo: undefined, result: TaskRunResult.finished(), }; } const oldTxState = computePeerPullCreditTransactionState(peerInc); peerInc.kycInfo = { paytoHash: kycPending.h_payto, requirementRow: kycPending.requirement_row, }; peerInc.kycUrl = kycStatus.kyc_url; peerInc.status = PeerPullPaymentCreditStatus.PendingMergeKycRequired; const newTxState = computePeerPullCreditTransactionState(peerInc); await tx.peerPullCredit.put(peerInc); // We'll remove this eventually! New clients should rely on the // kycUrl field of the transaction, not the error code. const res: TaskRunResult = { type: TaskRunResultType.Error, errorDetail: makeErrorDetail( TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED, { kycUrl: kycStatus.kyc_url, }, ), }; return { transitionInfo: { oldTxState, newTxState }, result: res, }; }, ); notifyTransition(wex, transactionId, transitionInfo); return TaskRunResult.backoff(); } else { throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`); } } /** * Check fees and available exchanges for a peer push payment initiation. */ export async function checkPeerPullPaymentInitiation( wex: WalletExecutionContext, req: CheckPeerPullCreditRequest, ): Promise { // FIXME: We don't support exchanges with purse fees yet. // Select an exchange where we have money in the specified currency // FIXME: How do we handle regional currency scopes here? Is it an additional input? logger.trace("checking peer-pull-credit fees"); const currency = Amounts.currencyOf(req.amount); let exchangeUrl; if (req.exchangeBaseUrl) { exchangeUrl = req.exchangeBaseUrl; } else { exchangeUrl = await getPreferredExchangeForCurrency(wex, currency); } if (!exchangeUrl) { throw Error("no exchange found for initiating a peer pull payment"); } logger.trace(`found ${exchangeUrl} as preferred exchange`); const wi = await getExchangeWithdrawalInfo( wex, exchangeUrl, Amounts.parseOrThrow(req.amount), undefined, ); logger.trace(`got withdrawal info`); let numCoins = 0; for (let i = 0; i < wi.selectedDenoms.selectedDenoms.length; i++) { numCoins += wi.selectedDenoms.selectedDenoms[i].count; } return { exchangeBaseUrl: exchangeUrl, amountEffective: wi.withdrawalAmountEffective, amountRaw: req.amount, numCoins, }; } /** * Find a preferred exchange based on when we withdrew last from this exchange. */ async function getPreferredExchangeForCurrency( wex: WalletExecutionContext, currency: string, ): Promise { // Find an exchange with the matching currency. // Prefer exchanges with the most recent withdrawal. const url = await wex.db.runReadOnlyTx( { storeNames: ["exchanges"] }, async (tx) => { const exchanges = await tx.exchanges.iter().toArray(); let candidate = undefined; for (const e of exchanges) { if (e.detailsPointer?.currency !== currency) { continue; } if (!candidate) { candidate = e; continue; } if (candidate.lastWithdrawal && !e.lastWithdrawal) { continue; } const exchangeLastWithdrawal = timestampOptionalPreciseFromDb( e.lastWithdrawal, ); const candidateLastWithdrawal = timestampOptionalPreciseFromDb( candidate.lastWithdrawal, ); if (exchangeLastWithdrawal && candidateLastWithdrawal) { if ( AbsoluteTime.cmp( AbsoluteTime.fromPreciseTimestamp(exchangeLastWithdrawal), AbsoluteTime.fromPreciseTimestamp(candidateLastWithdrawal), ) > 0 ) { candidate = e; } } } if (candidate) { return candidate.baseUrl; } return undefined; }, ); return url; } /** * Initiate a peer pull payment. */ export async function initiatePeerPullPayment( wex: WalletExecutionContext, req: InitiatePeerPullCreditRequest, ): Promise { const currency = Amounts.currencyOf(req.partialContractTerms.amount); let maybeExchangeBaseUrl: string | undefined; if (req.exchangeBaseUrl) { maybeExchangeBaseUrl = req.exchangeBaseUrl; } else { maybeExchangeBaseUrl = await getPreferredExchangeForCurrency(wex, currency); } if (!maybeExchangeBaseUrl) { throw Error("no exchange found for initiating a peer pull payment"); } const exchangeBaseUrl = maybeExchangeBaseUrl; await fetchFreshExchange(wex, exchangeBaseUrl); const mergeReserveInfo = await getMergeReserveInfo(wex, { exchangeBaseUrl: exchangeBaseUrl, }); const pursePair = await wex.cryptoApi.createEddsaKeypair({}); const mergePair = await wex.cryptoApi.createEddsaKeypair({}); const contractTerms = req.partialContractTerms; const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms); const contractKeyPair = await wex.cryptoApi.createEddsaKeypair({}); const withdrawalGroupId = encodeCrock(getRandomBytes(32)); const mergeReserveRowId = mergeReserveInfo.rowId; checkDbInvariant(!!mergeReserveRowId); const contractEncNonce = encodeCrock(getRandomBytes(24)); const wi = await getExchangeWithdrawalInfo( wex, exchangeBaseUrl, Amounts.parseOrThrow(req.partialContractTerms.amount), undefined, ); const mergeTimestamp = TalerPreciseTimestamp.now(); const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["peerPullCredit", "contractTerms"] }, async (tx) => { const ppi: PeerPullCreditRecord = { amount: req.partialContractTerms.amount, contractTermsHash: hContractTerms, exchangeBaseUrl: exchangeBaseUrl, pursePriv: pursePair.priv, pursePub: pursePair.pub, mergePriv: mergePair.priv, mergePub: mergePair.pub, status: PeerPullPaymentCreditStatus.PendingCreatePurse, mergeTimestamp: timestampPreciseToDb(mergeTimestamp), contractEncNonce, mergeReserveRowId: mergeReserveRowId, contractPriv: contractKeyPair.priv, contractPub: contractKeyPair.pub, withdrawalGroupId, estimatedAmountEffective: wi.withdrawalAmountEffective, }; await tx.peerPullCredit.put(ppi); const oldTxState: TransactionState = { major: TransactionMajorState.None, }; const newTxState = computePeerPullCreditTransactionState(ppi); await tx.contractTerms.put({ contractTermsRaw: contractTerms, h: hContractTerms, }); return { oldTxState, newTxState }; }, ); const ctx = new PeerPullCreditTransactionContext(wex, pursePair.pub); notifyTransition(wex, ctx.transactionId, transitionInfo); wex.taskScheduler.startShepherdTask(ctx.taskId); // The pending-incoming balance has changed. wex.ws.notify({ type: NotificationType.BalanceChange, hintTransactionId: ctx.transactionId, }); return { talerUri: stringifyTalerUri({ type: TalerUriAction.PayPull, exchangeBaseUrl: exchangeBaseUrl, contractPriv: contractKeyPair.priv, }), transactionId: ctx.transactionId, }; } export function computePeerPullCreditTransactionState( pullCreditRecord: PeerPullCreditRecord, ): TransactionState { switch (pullCreditRecord.status) { case PeerPullPaymentCreditStatus.PendingCreatePurse: return { major: TransactionMajorState.Pending, minor: TransactionMinorState.CreatePurse, }; case PeerPullPaymentCreditStatus.PendingMergeKycRequired: return { major: TransactionMajorState.Pending, minor: TransactionMinorState.MergeKycRequired, }; case PeerPullPaymentCreditStatus.PendingReady: return { major: TransactionMajorState.Pending, minor: TransactionMinorState.Ready, }; case PeerPullPaymentCreditStatus.Done: return { major: TransactionMajorState.Done, }; case PeerPullPaymentCreditStatus.PendingWithdrawing: return { major: TransactionMajorState.Pending, minor: TransactionMinorState.Withdraw, }; case PeerPullPaymentCreditStatus.SuspendedCreatePurse: return { major: TransactionMajorState.Suspended, minor: TransactionMinorState.CreatePurse, }; case PeerPullPaymentCreditStatus.SuspendedReady: return { major: TransactionMajorState.Suspended, minor: TransactionMinorState.Ready, }; case PeerPullPaymentCreditStatus.SuspendedWithdrawing: return { major: TransactionMajorState.Pending, minor: TransactionMinorState.Withdraw, }; case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired: return { major: TransactionMajorState.Suspended, minor: TransactionMinorState.MergeKycRequired, }; case PeerPullPaymentCreditStatus.Aborted: return { major: TransactionMajorState.Aborted, }; case PeerPullPaymentCreditStatus.AbortingDeletePurse: return { major: TransactionMajorState.Aborting, minor: TransactionMinorState.DeletePurse, }; case PeerPullPaymentCreditStatus.Failed: return { major: TransactionMajorState.Failed, }; case PeerPullPaymentCreditStatus.Expired: return { major: TransactionMajorState.Expired, }; case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse: return { major: TransactionMajorState.Aborting, minor: TransactionMinorState.DeletePurse, }; } } export function computePeerPullCreditTransactionActions( pullCreditRecord: PeerPullCreditRecord, ): TransactionAction[] { switch (pullCreditRecord.status) { case PeerPullPaymentCreditStatus.PendingCreatePurse: return [TransactionAction.Abort, TransactionAction.Suspend]; case PeerPullPaymentCreditStatus.PendingMergeKycRequired: return [TransactionAction.Abort, TransactionAction.Suspend]; case PeerPullPaymentCreditStatus.PendingReady: return [TransactionAction.Abort, TransactionAction.Suspend]; case PeerPullPaymentCreditStatus.Done: return [TransactionAction.Delete]; case PeerPullPaymentCreditStatus.PendingWithdrawing: return [TransactionAction.Abort, TransactionAction.Suspend]; case PeerPullPaymentCreditStatus.SuspendedCreatePurse: return [TransactionAction.Resume, TransactionAction.Abort]; case PeerPullPaymentCreditStatus.SuspendedReady: return [TransactionAction.Abort, TransactionAction.Resume]; case PeerPullPaymentCreditStatus.SuspendedWithdrawing: return [TransactionAction.Resume, TransactionAction.Fail]; case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired: return [TransactionAction.Resume, TransactionAction.Fail]; case PeerPullPaymentCreditStatus.Aborted: return [TransactionAction.Delete]; case PeerPullPaymentCreditStatus.AbortingDeletePurse: return [TransactionAction.Suspend, TransactionAction.Fail]; case PeerPullPaymentCreditStatus.Failed: return [TransactionAction.Delete]; case PeerPullPaymentCreditStatus.Expired: return [TransactionAction.Delete]; case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse: return [TransactionAction.Resume, TransactionAction.Fail]; } }