/* 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, ConfirmPeerPushCreditRequest, ContractTermsUtil, ExchangePurseMergeRequest, HttpStatusCode, Logger, NotificationType, PeerContractTerms, PreparePeerPushCreditRequest, PreparePeerPushCreditResponse, TalerErrorCode, TalerPreciseTimestamp, TalerProtocolTimestamp, TransactionAction, TransactionIdStr, TransactionMajorState, TransactionMinorState, TransactionState, TransactionType, WalletAccountMergeFlags, WalletKycUuid, assertUnreachable, checkDbInvariant, 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 { PendingTaskType, TaskIdStr, TaskRunResult, TaskRunResultType, TombstoneTag, TransactionContext, constructTaskIdentifier, } from "./common.js"; import { KycPendingInfo, KycUserType, PeerPushCreditStatus, PeerPushPaymentIncomingRecord, WithdrawalGroupStatus, WithdrawalRecordType, timestampPreciseToDb, } from "./db.js"; import { fetchFreshExchange } from "./exchanges.js"; import { codecForExchangePurseStatus, getMergeReserveInfo, } from "./pay-peer-common.js"; import { TransitionInfo, constructTransactionIdentifier, notifyTransition, parseTransactionIdentifier, } from "./transactions.js"; import { WalletExecutionContext } from "./wallet.js"; import { PerformCreateWithdrawalGroupResult, getExchangeWithdrawalInfo, internalPerformCreateWithdrawalGroup, internalPrepareCreateWithdrawalGroup, waitWithdrawalFinal, } from "./withdraw.js"; const logger = new Logger("pay-peer-push-credit.ts"); export class PeerPushCreditTransactionContext implements TransactionContext { readonly transactionId: TransactionIdStr; readonly taskId: TaskIdStr; constructor( public wex: WalletExecutionContext, public peerPushCreditId: string, ) { this.transactionId = constructTransactionIdentifier({ tag: TransactionType.PeerPushCredit, peerPushCreditId, }); this.taskId = constructTaskIdentifier({ tag: PendingTaskType.PeerPushCredit, peerPushCreditId, }); } async deleteTransaction(): Promise { const { wex, peerPushCreditId } = this; await wex.db.runReadWriteTx( { storeNames: ["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 { wex, peerPushCreditId, taskId: retryTag, transactionId } = this; const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["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(wex, transactionId, transitionInfo); wex.taskScheduler.stopShepherdTask(retryTag); } async abortTransaction(): Promise { const { wex, peerPushCreditId, taskId: retryTag, transactionId } = this; const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["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(wex, transactionId, transitionInfo); wex.taskScheduler.startShepherdTask(retryTag); } async resumeTransaction(): Promise { const { wex, peerPushCreditId, taskId: retryTag, transactionId } = this; const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["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(wex, transactionId, transitionInfo); wex.taskScheduler.startShepherdTask(retryTag); } async failTransaction(): Promise { const { wex, peerPushCreditId, taskId: retryTag, transactionId } = this; const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["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; }, ); wex.taskScheduler.stopShepherdTask(retryTag); notifyTransition(wex, transactionId, transitionInfo); wex.taskScheduler.startShepherdTask(retryTag); } } export async function preparePeerPushCredit( wex: WalletExecutionContext, req: PreparePeerPushCreditRequest, ): Promise { const uri = parsePayPushUri(req.talerUri); if (!uri) { throw Error("got invalid taler://pay-push URI"); } const existing = await wex.db.runReadOnlyTx( { storeNames: ["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(wex, exchangeBaseUrl); const contractPriv = uri.contractPriv; const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv))); const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl); const contractHttpResp = await wex.http.fetch(getContractUrl.href); const contractResp = await readSuccessResponseJsonOrThrow( contractHttpResp, codecForExchangeGetContractResponse(), ); const pursePub = contractResp.purse_pub; const dec = await wex.cryptoApi.decryptContractForMerge({ ciphertext: contractResp.econtract, contractPriv: contractPriv, pursePub: pursePub, }); const getPurseUrl = new URL(`purses/${pursePub}/deposit`, exchangeBaseUrl); const purseHttpResp = await wex.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( wex, exchangeBaseUrl, Amounts.parseOrThrow(purseStatus.balance), undefined, ); const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["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(wex, transactionId, transitionInfo); wex.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( wex: WalletExecutionContext, peerPushCreditId: string, exchangeUrl: string, kycInfo: KycPendingInfo, userType: KycUserType, ): 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", "30000"); 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: ["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(wex, transactionId, transitionInfo); return TaskRunResult.progress(); } else if (kycStatusRes.status === HttpStatusCode.Accepted) { // FIXME: Do we have to update the URL here? return TaskRunResult.longpollReturnedPending(); } else { throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`); } } async function processPeerPushCreditKycRequired( wex: WalletExecutionContext, 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 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.finished(); } 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: ["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(wex, transactionId, transitionInfo); return result; } else { throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`); } } async function handlePendingMerge( wex: WalletExecutionContext, 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(wex, { exchangeBaseUrl: peerInc.exchangeBaseUrl, }); const mergeTimestamp = TalerProtocolTimestamp.now(); const reservePayto = talerPaytoFromExchangeReserve( peerInc.exchangeBaseUrl, mergeReserveInfo.reservePub, ); const sigRes = await wex.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 wex.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(wex, peerInc, kycPending); } logger.trace(`merge request: ${j2s(mergeReq)}`); const res = await readSuccessResponseJsonOrThrow( mergeHttpResp, codecForAny(), ); logger.trace(`merge response: ${j2s(res)}`); const withdrawalGroupPrep = await internalPrepareCreateWithdrawalGroup(wex, { amount, wgInfo: { withdrawalType: WithdrawalRecordType.PeerPushCredit, }, forcedWithdrawalGroupId: peerInc.withdrawalGroupId, exchangeBaseUrl: peerInc.exchangeBaseUrl, reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus, reserveKeyPair: { priv: mergeReserveInfo.reservePriv, pub: mergeReserveInfo.reservePub, }, }); const txRes = await wex.db.runReadWriteTx( { storeNames: [ "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( wex, 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) { wex.ws.notify(txRes.wgCreateRes.exchangeNotif); } notifyTransition( wex, withdrawalGroupPrep.transactionId, txRes?.wgCreateRes?.transitionInfo, ); notifyTransition(wex, transactionId, txRes?.peerPushCreditTransition); return TaskRunResult.backoff(); } async function handlePendingWithdrawing( wex: WalletExecutionContext, peerInc: PeerPushPaymentIncomingRecord, ): Promise { if (!peerInc.withdrawalGroupId) { throw Error("invalid db state (withdrawing, but no withdrawal group ID"); } await waitWithdrawalFinal(wex, peerInc.withdrawalGroupId); const transactionId = constructTransactionIdentifier({ tag: TransactionType.PeerPushCredit, peerPushCreditId: peerInc.peerPushCreditId, }); const wgId = peerInc.withdrawalGroupId; let finished: boolean = false; const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["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(wex, 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( wex: WalletExecutionContext, peerPushCreditId: string, ): Promise { let peerInc: PeerPushPaymentIncomingRecord | undefined; let contractTerms: PeerContractTerms | undefined; await wex.db.runReadWriteTx( { storeNames: ["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( wex, peerPushCreditId, peerInc.exchangeBaseUrl, peerInc.kycInfo, "individual", ); } case PeerPushCreditStatus.PendingMerge: return handlePendingMerge(wex, peerInc, contractTerms); case PeerPushCreditStatus.PendingWithdrawing: return handlePendingWithdrawing(wex, peerInc); default: return TaskRunResult.finished(); } } export async function confirmPeerPushCredit( wex: WalletExecutionContext, req: ConfirmPeerPushCreditRequest, ): Promise { let peerInc: PeerPushPaymentIncomingRecord | undefined; let peerPushCreditId: string; 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; logger.trace(`confirming peer-push-credit ${peerPushCreditId}`); await wex.db.runReadWriteTx( { storeNames: ["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.transactionId})`, ); } const ctx = new PeerPushCreditTransactionContext(wex, peerPushCreditId); wex.taskScheduler.startShepherdTask(ctx.taskId); 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); } }