From e951075d2ef52fa8e9e7489c62031777c3a7e66b Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Mon, 19 Feb 2024 18:05:48 +0100 Subject: wallet-core: flatten directory structure --- .../taler-wallet-core/src/pay-peer-pull-credit.ts | 1204 ++++++++++++++++++++ 1 file changed, 1204 insertions(+) create mode 100644 packages/taler-wallet-core/src/pay-peer-pull-credit.ts (limited to 'packages/taler-wallet-core/src/pay-peer-pull-credit.ts') diff --git a/packages/taler-wallet-core/src/pay-peer-pull-credit.ts b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts new file mode 100644 index 000000000..124496d02 --- /dev/null +++ b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts @@ -0,0 +1,1204 @@ +/* + 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, + CancellationToken, + CheckPeerPullCreditRequest, + CheckPeerPullCreditResponse, + ContractTermsUtil, + ExchangeReservePurseRequest, + HttpStatusCode, + InitiatePeerPullCreditRequest, + InitiatePeerPullCreditResponse, + Logger, + NotificationType, + PeerContractTerms, + TalerErrorCode, + TalerPreciseTimestamp, + TalerProtocolTimestamp, + TalerUriAction, + TransactionAction, + TransactionIdStr, + TransactionMajorState, + TransactionMinorState, + TransactionState, + TransactionType, + WalletAccountMergeFlags, + WalletKycUuid, + codecForAny, + codecForWalletKycUuid, + encodeCrock, + getRandomBytes, + j2s, + makeErrorDetail, + stringifyTalerUri, + talerPaytoFromExchangeReserve, +} from "@gnu-taler/taler-util"; +import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; +import { + KycPendingInfo, + KycUserType, + PeerPullCreditRecord, + PeerPullPaymentCreditStatus, + WithdrawalGroupStatus, + WithdrawalRecordType, + fetchFreshExchange, + timestampOptionalPreciseFromDb, + timestampPreciseFromDb, + timestampPreciseToDb, +} from "./index.js"; +import { InternalWalletState } from "./internal-wallet-state.js"; +import { PendingTaskType, TaskId } from "./pending-types.js"; +import { assertUnreachable } from "./util/assertUnreachable.js"; +import { checkDbInvariant } from "./util/invariants.js"; +import { + TaskRunResult, + TaskRunResultType, + TombstoneTag, + TransactionContext, + constructTaskIdentifier, +} from "./common.js"; +import { + codecForExchangePurseStatus, + getMergeReserveInfo, +} from "./pay-peer-common.js"; +import { + constructTransactionIdentifier, + notifyTransition, +} from "./transactions.js"; +import { + getExchangeWithdrawalInfo, + internalCreateWithdrawalGroup, +} from "./withdraw.js"; + +const logger = new Logger("pay-peer-pull-credit.ts"); + +export class PeerPullCreditTransactionContext implements TransactionContext { + readonly transactionId: TransactionIdStr; + readonly retryTag: TaskId; + + constructor( + public ws: InternalWalletState, + public pursePub: string, + ) { + this.retryTag = constructTaskIdentifier({ + tag: PendingTaskType.PeerPullCredit, + pursePub, + }); + this.transactionId = constructTransactionIdentifier({ + tag: TransactionType.PeerPullCredit, + pursePub, + }); + } + + async deleteTransaction(): Promise { + const { ws, pursePub } = this; + await ws.db.runReadWriteTx( + ["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 { ws, pursePub, retryTag, transactionId } = this; + const transitionInfo = await ws.db.runReadWriteTx( + ["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; + }, + ); + ws.taskScheduler.stopShepherdTask(retryTag); + notifyTransition(ws, transactionId, transitionInfo); + } + + async failTransaction(): Promise { + const { ws, pursePub, retryTag, transactionId } = this; + const transitionInfo = await ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + ws.taskScheduler.stopShepherdTask(retryTag); + } + + async resumeTransaction(): Promise { + const { ws, pursePub, retryTag, transactionId } = this; + const transitionInfo = await ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + ws.taskScheduler.startShepherdTask(retryTag); + } + + async abortTransaction(): Promise { + const { ws, pursePub, retryTag, transactionId } = this; + const transitionInfo = await ws.db.runReadWriteTx( + ["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; + }, + ); + ws.taskScheduler.stopShepherdTask(retryTag); + notifyTransition(ws, transactionId, transitionInfo); + ws.taskScheduler.startShepherdTask(retryTag); + } +} + +async function queryPurseForPeerPullCredit( + ws: InternalWalletState, + pullIni: PeerPullCreditRecord, + cancellationToken: CancellationToken, +): 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 ws.http.fetch(purseDepositUrl.href, { + timeout: { d_ms: 60000 }, + 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 ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + return TaskRunResult.backoff(); + } + case HttpStatusCode.NotFound: + return TaskRunResult.backoff(); + } + + 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 ws.db.runReadOnlyTx(["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(ws, { + 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 ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + return TaskRunResult.backoff(); +} + +async function longpollKycStatus( + ws: InternalWalletState, + pursePub: string, + exchangeUrl: string, + kycInfo: KycPendingInfo, + userType: KycUserType, + cancellationToken: CancellationToken, +): Promise { + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.PeerPullCredit, + pursePub, + }); + const retryTag = constructTaskIdentifier({ + tag: PendingTaskType.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 ws.http.fetch(url.href, { + method: "GET", + 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 ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + } else if (kycStatusRes.status === HttpStatusCode.Accepted) { + // FIXME: Do we have to update the URL here? + } else { + throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`); + } + return TaskRunResult.backoff(); +} + +async function processPeerPullCreditAbortingDeletePurse( + ws: InternalWalletState, + peerPullIni: PeerPullCreditRecord, +): Promise { + const { pursePub, pursePriv } = peerPullIni; + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.PeerPullCredit, + pursePub, + }); + + const sigResp = await ws.cryptoApi.signDeletePurse({ + pursePriv, + }); + const purseUrl = new URL(`purses/${pursePub}`, peerPullIni.exchangeBaseUrl); + const resp = await ws.http.fetch(purseUrl.href, { + method: "DELETE", + headers: { + "taler-purse-signature": sigResp.sig, + }, + }); + logger.info(`deleted purse with response status ${resp.status}`); + + const transitionInfo = await ws.db.runReadWriteTx( + [ + "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(ws, transactionId, transitionInfo); + + return TaskRunResult.backoff(); +} + +async function handlePeerPullCreditWithdrawing( + ws: InternalWalletState, + pullIni: PeerPullCreditRecord, +): Promise { + if (!pullIni.withdrawalGroupId) { + throw Error("invalid db state (withdrawing, but no withdrawal group ID"); + } + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.PeerPullCredit, + pursePub: pullIni.pursePub, + }); + const wgId = pullIni.withdrawalGroupId; + let finished: boolean = false; + const transitionInfo = await ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + if (finished) { + return TaskRunResult.finished(); + } else { + // FIXME: Return indicator that we depend on the other operation! + return TaskRunResult.backoff(); + } +} + +async function handlePeerPullCreditCreatePurse( + ws: InternalWalletState, + pullIni: PeerPullCreditRecord, +): Promise { + const purseFee = Amounts.stringify(Amounts.zeroOfAmount(pullIni.amount)); + const pursePub = pullIni.pursePub; + const mergeReserve = await ws.db.runReadOnlyTx(["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 ws.db.runReadOnlyTx( + ["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 ws.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 ws.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 ws.http.fetch(reservePurseMergeUrl.href, { + method: "POST", + body: reservePurseReqBody, + }); + + if (httpResp.status === HttpStatusCode.UnavailableForLegalReasons) { + const respJson = await httpResp.json(); + const kycPending = codecForWalletKycUuid().decode(respJson); + logger.info(`kyc uuid response: ${j2s(kycPending)}`); + return processPeerPullCreditKycRequired(ws, 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 ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + return TaskRunResult.backoff(); +} + +export async function processPeerPullCredit( + ws: InternalWalletState, + pursePub: string, + cancellationToken: CancellationToken, +): Promise { + const pullIni = await ws.db.runReadOnlyTx(["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(ws, pullIni, cancellationToken); + case PeerPullPaymentCreditStatus.PendingMergeKycRequired: { + if (!pullIni.kycInfo) { + throw Error("invalid state, kycInfo required"); + } + return await longpollKycStatus( + ws, + pursePub, + pullIni.exchangeBaseUrl, + pullIni.kycInfo, + "individual", + cancellationToken, + ); + } + case PeerPullPaymentCreditStatus.PendingCreatePurse: + return handlePeerPullCreditCreatePurse(ws, pullIni); + case PeerPullPaymentCreditStatus.AbortingDeletePurse: + return await processPeerPullCreditAbortingDeletePurse(ws, pullIni); + case PeerPullPaymentCreditStatus.PendingWithdrawing: + return handlePeerPullCreditWithdrawing(ws, 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( + ws: InternalWalletState, + 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 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 TaskRunResult.backoff(); + } else if (kycStatusRes.status === HttpStatusCode.Accepted) { + const kycStatus = await kycStatusRes.json(); + logger.info(`kyc status: ${j2s(kycStatus)}`); + const { transitionInfo, result } = await ws.db.runReadWriteTx( + ["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(ws, 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( + ws: InternalWalletState, + 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(ws, 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( + ws, + 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( + ws: InternalWalletState, + currency: string, +): Promise { + // Find an exchange with the matching currency. + // Prefer exchanges with the most recent withdrawal. + const url = await ws.db.runReadOnlyTx(["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( + ws: InternalWalletState, + req: InitiatePeerPullCreditRequest, +): Promise { + const currency = Amounts.currencyOf(req.partialContractTerms.amount); + let maybeExchangeBaseUrl: string | undefined; + if (req.exchangeBaseUrl) { + maybeExchangeBaseUrl = req.exchangeBaseUrl; + } else { + maybeExchangeBaseUrl = await getPreferredExchangeForCurrency(ws, currency); + } + + if (!maybeExchangeBaseUrl) { + throw Error("no exchange found for initiating a peer pull payment"); + } + + const exchangeBaseUrl = maybeExchangeBaseUrl; + + await fetchFreshExchange(ws, exchangeBaseUrl); + + const mergeReserveInfo = await getMergeReserveInfo(ws, { + exchangeBaseUrl: exchangeBaseUrl, + }); + + const pursePair = await ws.cryptoApi.createEddsaKeypair({}); + const mergePair = await ws.cryptoApi.createEddsaKeypair({}); + + const contractTerms = req.partialContractTerms; + + const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms); + + const contractKeyPair = await ws.cryptoApi.createEddsaKeypair({}); + + const withdrawalGroupId = encodeCrock(getRandomBytes(32)); + + const mergeReserveRowId = mergeReserveInfo.rowId; + checkDbInvariant(!!mergeReserveRowId); + + const contractEncNonce = encodeCrock(getRandomBytes(24)); + + const wi = await getExchangeWithdrawalInfo( + ws, + exchangeBaseUrl, + Amounts.parseOrThrow(req.partialContractTerms.amount), + undefined, + ); + + const mergeTimestamp = TalerPreciseTimestamp.now(); + + const transitionInfo = await ws.db.runReadWriteTx( + ["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(ws, pursePair.pub); + + // The pending-incoming balance has changed. + ws.notify({ + type: NotificationType.BalanceChange, + hintTransactionId: ctx.transactionId, + }); + + notifyTransition(ws, ctx.transactionId, transitionInfo); + ws.taskScheduler.startShepherdTask(ctx.retryTag); + + 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]; + } +} -- cgit v1.2.3