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-push-credit.ts | 1037 ++++++++++++++++++++ 1 file changed, 1037 insertions(+) create mode 100644 packages/taler-wallet-core/src/pay-peer-push-credit.ts (limited to 'packages/taler-wallet-core/src/pay-peer-push-credit.ts') diff --git a/packages/taler-wallet-core/src/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/pay-peer-push-credit.ts new file mode 100644 index 000000000..937ff7ff4 --- /dev/null +++ b/packages/taler-wallet-core/src/pay-peer-push-credit.ts @@ -0,0 +1,1037 @@ +/* + 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 { + AcceptPeerPushPaymentResponse, + Amounts, + CancellationToken, + ConfirmPeerPushCreditRequest, + ContractTermsUtil, + ExchangePurseMergeRequest, + HttpStatusCode, + Logger, + NotificationType, + PeerContractTerms, + PreparePeerPushCreditRequest, + PreparePeerPushCreditResponse, + TalerErrorCode, + TalerPreciseTimestamp, + TalerProtocolTimestamp, + TransactionAction, + TransactionMajorState, + TransactionMinorState, + TransactionState, + TransactionType, + WalletAccountMergeFlags, + WalletKycUuid, + codecForAny, + codecForExchangeGetContractResponse, + codecForPeerContractTerms, + codecForWalletKycUuid, + decodeCrock, + eddsaGetPublic, + encodeCrock, + getRandomBytes, + j2s, + makeErrorDetail, + parsePayPushUri, + talerPaytoFromExchangeReserve, +} from "@gnu-taler/taler-util"; +import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; +import { + InternalWalletState, + KycPendingInfo, + KycUserType, + PeerPushCreditStatus, + PeerPushPaymentIncomingRecord, + PendingTaskType, + TaskId, + WithdrawalGroupStatus, + WithdrawalRecordType, + timestampPreciseToDb, +} from "./index.js"; +import { assertUnreachable } from "./util/assertUnreachable.js"; +import { checkDbInvariant } from "./util/invariants.js"; +import { + TaskRunResult, + TaskRunResultType, + TombstoneTag, + TransactionContext, + constructTaskIdentifier, +} from "./common.js"; +import { fetchFreshExchange } from "./exchanges.js"; +import { + codecForExchangePurseStatus, + getMergeReserveInfo, +} from "./pay-peer-common.js"; +import { + TransitionInfo, + constructTransactionIdentifier, + notifyTransition, + parseTransactionIdentifier, +} from "./transactions.js"; +import { + PerformCreateWithdrawalGroupResult, + getExchangeWithdrawalInfo, + internalPerformCreateWithdrawalGroup, + internalPrepareCreateWithdrawalGroup, +} from "./withdraw.js"; + +const logger = new Logger("pay-peer-push-credit.ts"); + +export class PeerPushCreditTransactionContext implements TransactionContext { + readonly transactionId: string; + readonly retryTag: TaskId; + + constructor( + public ws: InternalWalletState, + public peerPushCreditId: string, + ) { + this.transactionId = constructTransactionIdentifier({ + tag: TransactionType.PeerPushCredit, + peerPushCreditId, + }); + this.retryTag = constructTaskIdentifier({ + tag: PendingTaskType.PeerPushCredit, + peerPushCreditId, + }); + } + + async deleteTransaction(): Promise { + const { ws, peerPushCreditId } = this; + await ws.db.runReadWriteTx( + ["withdrawalGroups", "peerPushCredit", "tombstones"], + async (tx) => { + const pushInc = await tx.peerPushCredit.get(peerPushCreditId); + if (!pushInc) { + return; + } + if (pushInc.withdrawalGroupId) { + const withdrawalGroupId = pushInc.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.peerPushCredit.delete(peerPushCreditId); + await tx.tombstones.put({ + id: TombstoneTag.DeletePeerPushCredit + ":" + peerPushCreditId, + }); + }, + ); + return; + } + + async suspendTransaction(): Promise { + const { ws, peerPushCreditId, retryTag, transactionId } = this; + const transitionInfo = await ws.db.runReadWriteTx( + ["peerPushCredit"], + async (tx) => { + const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId); + if (!pushCreditRec) { + logger.warn(`peer push credit ${peerPushCreditId} not found`); + return; + } + let newStatus: PeerPushCreditStatus | undefined = undefined; + switch (pushCreditRec.status) { + case PeerPushCreditStatus.DialogProposed: + case PeerPushCreditStatus.Done: + case PeerPushCreditStatus.SuspendedMerge: + case PeerPushCreditStatus.SuspendedMergeKycRequired: + case PeerPushCreditStatus.SuspendedWithdrawing: + break; + case PeerPushCreditStatus.PendingMergeKycRequired: + newStatus = PeerPushCreditStatus.SuspendedMergeKycRequired; + break; + case PeerPushCreditStatus.PendingMerge: + newStatus = PeerPushCreditStatus.SuspendedMerge; + break; + case PeerPushCreditStatus.PendingWithdrawing: + // FIXME: Suspend internal withdrawal transaction! + newStatus = PeerPushCreditStatus.SuspendedWithdrawing; + break; + case PeerPushCreditStatus.Aborted: + break; + case PeerPushCreditStatus.Failed: + break; + default: + assertUnreachable(pushCreditRec.status); + } + if (newStatus != null) { + const oldTxState = + computePeerPushCreditTransactionState(pushCreditRec); + pushCreditRec.status = newStatus; + const newTxState = + computePeerPushCreditTransactionState(pushCreditRec); + await tx.peerPushCredit.put(pushCreditRec); + return { + oldTxState, + newTxState, + }; + } + return undefined; + }, + ); + notifyTransition(ws, transactionId, transitionInfo); + ws.taskScheduler.stopShepherdTask(retryTag); + } + + async abortTransaction(): Promise { + const { ws, peerPushCreditId, retryTag, transactionId } = this; + const transitionInfo = await ws.db.runReadWriteTx( + ["peerPushCredit"], + async (tx) => { + const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId); + if (!pushCreditRec) { + logger.warn(`peer push credit ${peerPushCreditId} not found`); + return; + } + let newStatus: PeerPushCreditStatus | undefined = undefined; + switch (pushCreditRec.status) { + case PeerPushCreditStatus.DialogProposed: + newStatus = PeerPushCreditStatus.Aborted; + break; + case PeerPushCreditStatus.Done: + break; + case PeerPushCreditStatus.SuspendedMerge: + case PeerPushCreditStatus.SuspendedMergeKycRequired: + case PeerPushCreditStatus.SuspendedWithdrawing: + newStatus = PeerPushCreditStatus.Aborted; + break; + case PeerPushCreditStatus.PendingMergeKycRequired: + newStatus = PeerPushCreditStatus.Aborted; + break; + case PeerPushCreditStatus.PendingMerge: + newStatus = PeerPushCreditStatus.Aborted; + break; + case PeerPushCreditStatus.PendingWithdrawing: + newStatus = PeerPushCreditStatus.Aborted; + break; + case PeerPushCreditStatus.Aborted: + break; + case PeerPushCreditStatus.Failed: + break; + default: + assertUnreachable(pushCreditRec.status); + } + if (newStatus != null) { + const oldTxState = + computePeerPushCreditTransactionState(pushCreditRec); + pushCreditRec.status = newStatus; + const newTxState = + computePeerPushCreditTransactionState(pushCreditRec); + await tx.peerPushCredit.put(pushCreditRec); + return { + oldTxState, + newTxState, + }; + } + return undefined; + }, + ); + notifyTransition(ws, transactionId, transitionInfo); + ws.taskScheduler.startShepherdTask(retryTag); + } + + async resumeTransaction(): Promise { + const { ws, peerPushCreditId, retryTag, transactionId } = this; + const transitionInfo = await ws.db.runReadWriteTx( + ["peerPushCredit"], + async (tx) => { + const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId); + if (!pushCreditRec) { + logger.warn(`peer push credit ${peerPushCreditId} not found`); + return; + } + let newStatus: PeerPushCreditStatus | undefined = undefined; + switch (pushCreditRec.status) { + case PeerPushCreditStatus.DialogProposed: + case PeerPushCreditStatus.Done: + case PeerPushCreditStatus.PendingMergeKycRequired: + case PeerPushCreditStatus.PendingMerge: + case PeerPushCreditStatus.PendingWithdrawing: + case PeerPushCreditStatus.SuspendedMerge: + newStatus = PeerPushCreditStatus.PendingMerge; + break; + case PeerPushCreditStatus.SuspendedMergeKycRequired: + newStatus = PeerPushCreditStatus.PendingMergeKycRequired; + break; + case PeerPushCreditStatus.SuspendedWithdrawing: + // FIXME: resume underlying "internal-withdrawal" transaction. + newStatus = PeerPushCreditStatus.PendingWithdrawing; + break; + case PeerPushCreditStatus.Aborted: + break; + case PeerPushCreditStatus.Failed: + break; + default: + assertUnreachable(pushCreditRec.status); + } + if (newStatus != null) { + const oldTxState = + computePeerPushCreditTransactionState(pushCreditRec); + pushCreditRec.status = newStatus; + const newTxState = + computePeerPushCreditTransactionState(pushCreditRec); + await tx.peerPushCredit.put(pushCreditRec); + return { + oldTxState, + newTxState, + }; + } + return undefined; + }, + ); + notifyTransition(ws, transactionId, transitionInfo); + ws.taskScheduler.startShepherdTask(retryTag); + } + + async failTransaction(): Promise { + const { ws, peerPushCreditId, retryTag, transactionId } = this; + const transitionInfo = await ws.db.runReadWriteTx( + ["peerPushCredit"], + async (tx) => { + const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId); + if (!pushCreditRec) { + logger.warn(`peer push credit ${peerPushCreditId} not found`); + return; + } + let newStatus: PeerPushCreditStatus | undefined = undefined; + switch (pushCreditRec.status) { + case PeerPushCreditStatus.Done: + case PeerPushCreditStatus.Aborted: + case PeerPushCreditStatus.Failed: + // Already in a final state. + return; + case PeerPushCreditStatus.DialogProposed: + case PeerPushCreditStatus.PendingMergeKycRequired: + case PeerPushCreditStatus.PendingMerge: + case PeerPushCreditStatus.PendingWithdrawing: + case PeerPushCreditStatus.SuspendedMerge: + case PeerPushCreditStatus.SuspendedMergeKycRequired: + case PeerPushCreditStatus.SuspendedWithdrawing: + newStatus = PeerPushCreditStatus.Failed; + break; + default: + assertUnreachable(pushCreditRec.status); + } + if (newStatus != null) { + const oldTxState = + computePeerPushCreditTransactionState(pushCreditRec); + pushCreditRec.status = newStatus; + const newTxState = + computePeerPushCreditTransactionState(pushCreditRec); + await tx.peerPushCredit.put(pushCreditRec); + return { + oldTxState, + newTxState, + }; + } + return undefined; + }, + ); + ws.taskScheduler.stopShepherdTask(retryTag); + notifyTransition(ws, transactionId, transitionInfo); + ws.taskScheduler.startShepherdTask(retryTag); + } +} + +export async function preparePeerPushCredit( + ws: InternalWalletState, + req: PreparePeerPushCreditRequest, +): Promise { + const uri = parsePayPushUri(req.talerUri); + + if (!uri) { + throw Error("got invalid taler://pay-push URI"); + } + + const existing = await ws.db.runReadOnlyTx( + ["contractTerms", "peerPushCredit"], + async (tx) => { + const existingPushInc = + await tx.peerPushCredit.indexes.byExchangeAndContractPriv.get([ + uri.exchangeBaseUrl, + uri.contractPriv, + ]); + if (!existingPushInc) { + return; + } + const existingContractTermsRec = await tx.contractTerms.get( + existingPushInc.contractTermsHash, + ); + if (!existingContractTermsRec) { + throw Error( + "contract terms for peer push payment credit not found in database", + ); + } + const existingContractTerms = codecForPeerContractTerms().decode( + existingContractTermsRec.contractTermsRaw, + ); + return { existingPushInc, existingContractTerms }; + }, + ); + + if (existing) { + return { + amount: existing.existingContractTerms.amount, + amountEffective: existing.existingPushInc.estimatedAmountEffective, + amountRaw: existing.existingContractTerms.amount, + contractTerms: existing.existingContractTerms, + peerPushCreditId: existing.existingPushInc.peerPushCreditId, + transactionId: constructTransactionIdentifier({ + tag: TransactionType.PeerPushCredit, + peerPushCreditId: existing.existingPushInc.peerPushCreditId, + }), + exchangeBaseUrl: existing.existingPushInc.exchangeBaseUrl, + }; + } + + const exchangeBaseUrl = uri.exchangeBaseUrl; + + await fetchFreshExchange(ws, exchangeBaseUrl); + + const contractPriv = uri.contractPriv; + const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv))); + + const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl); + + const contractHttpResp = await ws.http.fetch(getContractUrl.href); + + const contractResp = await readSuccessResponseJsonOrThrow( + contractHttpResp, + codecForExchangeGetContractResponse(), + ); + + const pursePub = contractResp.purse_pub; + + const dec = await ws.cryptoApi.decryptContractForMerge({ + ciphertext: contractResp.econtract, + contractPriv: contractPriv, + pursePub: pursePub, + }); + + const getPurseUrl = new URL(`purses/${pursePub}/deposit`, exchangeBaseUrl); + + const purseHttpResp = await ws.http.fetch(getPurseUrl.href); + + const contractTerms = codecForPeerContractTerms().decode(dec.contractTerms); + + const purseStatus = await readSuccessResponseJsonOrThrow( + purseHttpResp, + codecForExchangePurseStatus(), + ); + + logger.info( + `peer push credit, purse balance ${purseStatus.balance}, contract amount ${contractTerms.amount}`, + ); + + const peerPushCreditId = encodeCrock(getRandomBytes(32)); + + const contractTermsHash = ContractTermsUtil.hashContractTerms( + dec.contractTerms, + ); + + const withdrawalGroupId = encodeCrock(getRandomBytes(32)); + + const wi = await getExchangeWithdrawalInfo( + ws, + exchangeBaseUrl, + Amounts.parseOrThrow(purseStatus.balance), + undefined, + ); + + const transitionInfo = await ws.db.runReadWriteTx( + ["contractTerms", "peerPushCredit"], + async (tx) => { + const rec: PeerPushPaymentIncomingRecord = { + peerPushCreditId, + contractPriv: contractPriv, + exchangeBaseUrl: exchangeBaseUrl, + mergePriv: dec.mergePriv, + pursePub: pursePub, + timestamp: timestampPreciseToDb(TalerPreciseTimestamp.now()), + contractTermsHash, + status: PeerPushCreditStatus.DialogProposed, + withdrawalGroupId, + currency: Amounts.currencyOf(purseStatus.balance), + estimatedAmountEffective: Amounts.stringify( + wi.withdrawalAmountEffective, + ), + }; + await tx.peerPushCredit.add(rec); + await tx.contractTerms.put({ + h: contractTermsHash, + contractTermsRaw: dec.contractTerms, + }); + + const newTxState = computePeerPushCreditTransactionState(rec); + + return { + oldTxState: { + major: TransactionMajorState.None, + }, + newTxState, + } satisfies TransitionInfo; + }, + ); + + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.PeerPushCredit, + peerPushCreditId, + }); + + notifyTransition(ws, transactionId, transitionInfo); + + ws.notify({ + type: NotificationType.BalanceChange, + hintTransactionId: transactionId, + }); + + return { + amount: purseStatus.balance, + amountEffective: wi.withdrawalAmountEffective, + amountRaw: purseStatus.balance, + contractTerms: dec.contractTerms, + peerPushCreditId, + transactionId, + exchangeBaseUrl, + }; +} + +async function longpollKycStatus( + ws: InternalWalletState, + peerPushCreditId: string, + exchangeUrl: string, + kycInfo: KycPendingInfo, + userType: KycUserType, + cancellationToken: CancellationToken, +): Promise { + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.PeerPushCredit, + peerPushCreditId, + }); + 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( + ["peerPushCredit"], + async (tx) => { + const peerInc = await tx.peerPushCredit.get(peerPushCreditId); + if (!peerInc) { + return; + } + if (peerInc.status !== PeerPushCreditStatus.PendingMergeKycRequired) { + return; + } + const oldTxState = computePeerPushCreditTransactionState(peerInc); + peerInc.status = PeerPushCreditStatus.PendingMerge; + const newTxState = computePeerPushCreditTransactionState(peerInc); + await tx.peerPushCredit.put(peerInc); + 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 processPeerPushCreditKycRequired( + ws: InternalWalletState, + peerInc: PeerPushPaymentIncomingRecord, + kycPending: WalletKycUuid, +): Promise { + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.PeerPushCredit, + peerPushCreditId: peerInc.peerPushCreditId, + }); + const { peerPushCreditId } = peerInc; + + const userType = "individual"; + const url = new URL( + `kyc-check/${kycPending.requirement_row}/${kycPending.h_payto}/${userType}`, + peerInc.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.finished(); + } else if (kycStatusRes.status === HttpStatusCode.Accepted) { + const kycStatus = await kycStatusRes.json(); + logger.info(`kyc status: ${j2s(kycStatus)}`); + const { transitionInfo, result } = await ws.db.runReadWriteTx( + ["peerPushCredit"], + async (tx) => { + const peerInc = await tx.peerPushCredit.get(peerPushCreditId); + if (!peerInc) { + return { + transitionInfo: undefined, + result: TaskRunResult.finished(), + }; + } + const oldTxState = computePeerPushCreditTransactionState(peerInc); + peerInc.kycInfo = { + paytoHash: kycPending.h_payto, + requirementRow: kycPending.requirement_row, + }; + peerInc.kycUrl = kycStatus.kyc_url; + peerInc.status = PeerPushCreditStatus.PendingMergeKycRequired; + const newTxState = computePeerPushCreditTransactionState(peerInc); + await tx.peerPushCredit.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 result; + } else { + throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`); + } +} + +async function handlePendingMerge( + ws: InternalWalletState, + peerInc: PeerPushPaymentIncomingRecord, + contractTerms: PeerContractTerms, +): Promise { + const { peerPushCreditId } = peerInc; + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.PeerPushCredit, + peerPushCreditId, + }); + + const amount = Amounts.parseOrThrow(contractTerms.amount); + + const mergeReserveInfo = await getMergeReserveInfo(ws, { + exchangeBaseUrl: peerInc.exchangeBaseUrl, + }); + + const mergeTimestamp = TalerProtocolTimestamp.now(); + + const reservePayto = talerPaytoFromExchangeReserve( + peerInc.exchangeBaseUrl, + mergeReserveInfo.reservePub, + ); + + const sigRes = await ws.cryptoApi.signPurseMerge({ + contractTermsHash: ContractTermsUtil.hashContractTerms(contractTerms), + flags: WalletAccountMergeFlags.MergeFullyPaidPurse, + mergePriv: peerInc.mergePriv, + mergeTimestamp: mergeTimestamp, + purseAmount: Amounts.stringify(amount), + purseExpiration: contractTerms.purse_expiration, + purseFee: Amounts.stringify(Amounts.zeroOfCurrency(amount.currency)), + pursePub: peerInc.pursePub, + reservePayto, + reservePriv: mergeReserveInfo.reservePriv, + }); + + const mergePurseUrl = new URL( + `purses/${peerInc.pursePub}/merge`, + peerInc.exchangeBaseUrl, + ); + + const mergeReq: ExchangePurseMergeRequest = { + payto_uri: reservePayto, + merge_timestamp: mergeTimestamp, + merge_sig: sigRes.mergeSig, + reserve_sig: sigRes.accountSig, + }; + + const mergeHttpResp = await ws.http.fetch(mergePurseUrl.href, { + method: "POST", + body: mergeReq, + }); + + if (mergeHttpResp.status === HttpStatusCode.UnavailableForLegalReasons) { + const respJson = await mergeHttpResp.json(); + const kycPending = codecForWalletKycUuid().decode(respJson); + logger.info(`kyc uuid response: ${j2s(kycPending)}`); + return processPeerPushCreditKycRequired(ws, peerInc, kycPending); + } + + logger.trace(`merge request: ${j2s(mergeReq)}`); + const res = await readSuccessResponseJsonOrThrow( + mergeHttpResp, + codecForAny(), + ); + logger.trace(`merge response: ${j2s(res)}`); + + const withdrawalGroupPrep = await internalPrepareCreateWithdrawalGroup(ws, { + amount, + wgInfo: { + withdrawalType: WithdrawalRecordType.PeerPushCredit, + }, + forcedWithdrawalGroupId: peerInc.withdrawalGroupId, + exchangeBaseUrl: peerInc.exchangeBaseUrl, + reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus, + reserveKeyPair: { + priv: mergeReserveInfo.reservePriv, + pub: mergeReserveInfo.reservePub, + }, + }); + + const txRes = await ws.db.runReadWriteTx( + [ + "contractTerms", + "peerPushCredit", + "withdrawalGroups", + "reserves", + "exchanges", + "exchangeDetails", + ], + async (tx) => { + const peerInc = await tx.peerPushCredit.get(peerPushCreditId); + if (!peerInc) { + return undefined; + } + const oldTxState = computePeerPushCreditTransactionState(peerInc); + let wgCreateRes: PerformCreateWithdrawalGroupResult | undefined = + undefined; + switch (peerInc.status) { + case PeerPushCreditStatus.PendingMerge: + case PeerPushCreditStatus.PendingMergeKycRequired: { + peerInc.status = PeerPushCreditStatus.PendingWithdrawing; + wgCreateRes = await internalPerformCreateWithdrawalGroup( + ws, + tx, + withdrawalGroupPrep, + ); + peerInc.withdrawalGroupId = + wgCreateRes.withdrawalGroup.withdrawalGroupId; + break; + } + } + await tx.peerPushCredit.put(peerInc); + const newTxState = computePeerPushCreditTransactionState(peerInc); + return { + peerPushCreditTransition: { oldTxState, newTxState }, + wgCreateRes, + }; + }, + ); + // Transaction was committed, now we can emit notifications. + if (txRes?.wgCreateRes?.exchangeNotif) { + ws.notify(txRes.wgCreateRes.exchangeNotif); + } + notifyTransition( + ws, + withdrawalGroupPrep.transactionId, + txRes?.wgCreateRes?.transitionInfo, + ); + notifyTransition(ws, transactionId, txRes?.peerPushCreditTransition); + + return TaskRunResult.backoff(); +} + +async function handlePendingWithdrawing( + ws: InternalWalletState, + peerInc: PeerPushPaymentIncomingRecord, +): Promise { + if (!peerInc.withdrawalGroupId) { + throw Error("invalid db state (withdrawing, but no withdrawal group ID"); + } + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.PeerPushCredit, + peerPushCreditId: peerInc.peerPushCreditId, + }); + const wgId = peerInc.withdrawalGroupId; + let finished: boolean = false; + const transitionInfo = await ws.db.runReadWriteTx( + ["peerPushCredit", "withdrawalGroups"], + async (tx) => { + const ppi = await tx.peerPushCredit.get(peerInc.peerPushCreditId); + if (!ppi) { + finished = true; + return; + } + if (ppi.status !== PeerPushCreditStatus.PendingWithdrawing) { + finished = true; + return; + } + const oldTxState = computePeerPushCreditTransactionState(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 = PeerPushCreditStatus.Done; + break; + // FIXME: Also handle other final states! + } + await tx.peerPushCredit.put(ppi); + const newTxState = computePeerPushCreditTransactionState(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(); + } +} + +export async function processPeerPushCredit( + ws: InternalWalletState, + peerPushCreditId: string, + cancellationToken: CancellationToken, +): Promise { + let peerInc: PeerPushPaymentIncomingRecord | undefined; + let contractTerms: PeerContractTerms | undefined; + await ws.db.runReadWriteTx( + ["contractTerms", "peerPushCredit"], + async (tx) => { + peerInc = await tx.peerPushCredit.get(peerPushCreditId); + if (!peerInc) { + return; + } + const ctRec = await tx.contractTerms.get(peerInc.contractTermsHash); + if (ctRec) { + contractTerms = ctRec.contractTermsRaw; + } + await tx.peerPushCredit.put(peerInc); + }, + ); + + if (!peerInc) { + throw Error( + `can't accept unknown incoming p2p push payment (${peerPushCreditId})`, + ); + } + + logger.info( + `processing peerPushCredit in state ${peerInc.status.toString(16)}`, + ); + + checkDbInvariant(!!contractTerms); + + switch (peerInc.status) { + case PeerPushCreditStatus.PendingMergeKycRequired: { + if (!peerInc.kycInfo) { + throw Error("invalid state, kycInfo required"); + } + return await longpollKycStatus( + ws, + peerPushCreditId, + peerInc.exchangeBaseUrl, + peerInc.kycInfo, + "individual", + cancellationToken, + ); + } + + case PeerPushCreditStatus.PendingMerge: + return handlePendingMerge(ws, peerInc, contractTerms); + + case PeerPushCreditStatus.PendingWithdrawing: + return handlePendingWithdrawing(ws, peerInc); + + default: + return TaskRunResult.finished(); + } +} + +export async function confirmPeerPushCredit( + ws: InternalWalletState, + req: ConfirmPeerPushCreditRequest, +): Promise { + let peerInc: PeerPushPaymentIncomingRecord | undefined; + let peerPushCreditId: string; + if (req.peerPushCreditId) { + peerPushCreditId = req.peerPushCreditId; + } else if (req.transactionId) { + const parsedTx = parseTransactionIdentifier(req.transactionId); + if (!parsedTx) { + throw Error("invalid transaction ID"); + } + if (parsedTx.tag !== TransactionType.PeerPushCredit) { + throw Error("invalid transaction ID type"); + } + peerPushCreditId = parsedTx.peerPushCreditId; + } else { + throw Error("no transaction ID (or deprecated peerPushCreditId) provided"); + } + + await ws.db.runReadWriteTx( + ["contractTerms", "peerPushCredit"], + async (tx) => { + peerInc = await tx.peerPushCredit.get(peerPushCreditId); + if (!peerInc) { + return; + } + if (peerInc.status === PeerPushCreditStatus.DialogProposed) { + peerInc.status = PeerPushCreditStatus.PendingMerge; + } + await tx.peerPushCredit.put(peerInc); + }, + ); + + if (!peerInc) { + throw Error( + `can't accept unknown incoming p2p push payment (${req.peerPushCreditId})`, + ); + } + + const ctx = new PeerPushCreditTransactionContext(ws, peerPushCreditId); + + ws.taskScheduler.startShepherdTask(ctx.retryTag); + + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.PeerPushCredit, + peerPushCreditId, + }); + + return { + transactionId, + }; +} + +export function computePeerPushCreditTransactionState( + pushCreditRecord: PeerPushPaymentIncomingRecord, +): TransactionState { + switch (pushCreditRecord.status) { + case PeerPushCreditStatus.DialogProposed: + return { + major: TransactionMajorState.Dialog, + minor: TransactionMinorState.Proposed, + }; + case PeerPushCreditStatus.PendingMerge: + return { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.Merge, + }; + case PeerPushCreditStatus.Done: + return { + major: TransactionMajorState.Done, + }; + case PeerPushCreditStatus.PendingMergeKycRequired: + return { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.KycRequired, + }; + case PeerPushCreditStatus.PendingWithdrawing: + return { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.Withdraw, + }; + case PeerPushCreditStatus.SuspendedMerge: + return { + major: TransactionMajorState.Suspended, + minor: TransactionMinorState.Merge, + }; + case PeerPushCreditStatus.SuspendedMergeKycRequired: + return { + major: TransactionMajorState.Suspended, + minor: TransactionMinorState.MergeKycRequired, + }; + case PeerPushCreditStatus.SuspendedWithdrawing: + return { + major: TransactionMajorState.Suspended, + minor: TransactionMinorState.Withdraw, + }; + case PeerPushCreditStatus.Aborted: + return { + major: TransactionMajorState.Aborted, + }; + case PeerPushCreditStatus.Failed: + return { + major: TransactionMajorState.Failed, + }; + default: + assertUnreachable(pushCreditRecord.status); + } +} + +export function computePeerPushCreditTransactionActions( + pushCreditRecord: PeerPushPaymentIncomingRecord, +): TransactionAction[] { + switch (pushCreditRecord.status) { + case PeerPushCreditStatus.DialogProposed: + return [TransactionAction.Delete]; + case PeerPushCreditStatus.PendingMerge: + return [TransactionAction.Abort, TransactionAction.Suspend]; + case PeerPushCreditStatus.Done: + return [TransactionAction.Delete]; + case PeerPushCreditStatus.PendingMergeKycRequired: + return [TransactionAction.Abort, TransactionAction.Suspend]; + case PeerPushCreditStatus.PendingWithdrawing: + return [TransactionAction.Suspend, TransactionAction.Fail]; + case PeerPushCreditStatus.SuspendedMerge: + return [TransactionAction.Resume, TransactionAction.Abort]; + case PeerPushCreditStatus.SuspendedMergeKycRequired: + return [TransactionAction.Resume, TransactionAction.Abort]; + case PeerPushCreditStatus.SuspendedWithdrawing: + return [TransactionAction.Resume, TransactionAction.Fail]; + case PeerPushCreditStatus.Aborted: + return [TransactionAction.Delete]; + case PeerPushCreditStatus.Failed: + return [TransactionAction.Delete]; + default: + assertUnreachable(pushCreditRecord.status); + } +} -- cgit v1.2.3