/* This file is part of GNU Taler (C) 2022 GNUnet e.V. 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 */ /** * Imports. */ import { AbsoluteTime, ConfirmPeerPullDebitRequest, AcceptPeerPullPaymentResponse, ConfirmPeerPushCreditRequest, AcceptPeerPushPaymentResponse, AgeCommitmentProof, AmountJson, Amounts, AmountString, buildCodecForObject, PreparePeerPullDebitRequest, PreparePeerPullDebitResponse, PreparePeerPushCredit, PreparePeerPushCreditResponse, Codec, codecForAmountString, codecForAny, codecForExchangeGetContractResponse, codecForPeerContractTerms, CoinStatus, constructPayPullUri, constructPayPushUri, ContractTermsUtil, decodeCrock, eddsaGetPublic, encodeCrock, ExchangePurseDeposits, ExchangePurseMergeRequest, ExchangeReservePurseRequest, getRandomBytes, InitiatePeerPullCreditRequest, InitiatePeerPullCreditResponse, InitiatePeerPushDebitRequest, InitiatePeerPushDebitResponse, j2s, Logger, parsePayPullUri, parsePayPushUri, PayPeerInsufficientBalanceDetails, PeerContractTerms, CheckPeerPullCreditRequest, CheckPeerPullCreditResponse, CheckPeerPushDebitRequest, CheckPeerPushDebitResponse, RefreshReason, strcmp, TalerErrorCode, TalerProtocolTimestamp, TransactionType, UnblindedSignature, WalletAccountMergeFlags, codecOptional, codecForTimestamp, CancellationToken, NotificationType, HttpStatusCode, codecForWalletKycUuid, TransactionState, TransactionMajorState, TransactionMinorState, TalerPreciseTimestamp, } from "@gnu-taler/taler-util"; import { SpendCoinDetails } from "../crypto/cryptoImplementation.js"; import { DenominationRecord, PeerPullPaymentIncomingRecord, PeerPullDebitRecordStatus, PeerPullPaymentInitiationRecord, PeerPullPaymentInitiationStatus, PeerPushPaymentCoinSelection, PeerPushPaymentIncomingRecord, PeerPushPaymentIncomingStatus, PeerPushPaymentInitiationRecord, PeerPushPaymentInitiationStatus, ReserveRecord, WithdrawalGroupStatus, WithdrawalRecordType, } from "../db.js"; import { TalerError } from "@gnu-taler/taler-util"; import { InternalWalletState } from "../internal-wallet-state.js"; import { LongpollResult, resetOperationTimeout, runLongpollAsync, runOperationWithErrorReporting, spendCoins, } from "../operations/common.js"; import { readSuccessResponseJsonOrErrorCode, readSuccessResponseJsonOrThrow, throwUnexpectedRequestError, } from "@gnu-taler/taler-util/http"; import { checkDbInvariant } from "../util/invariants.js"; import { constructTaskIdentifier, OperationAttemptResult, OperationAttemptResultType, TaskIdentifiers, } from "../util/retries.js"; import { getPeerPaymentBalanceDetailsInTx } from "./balance.js"; import { updateExchangeFromUrl } from "./exchanges.js"; import { getTotalRefreshCost } from "./refresh.js"; import { checkWithdrawalKycStatus, getExchangeWithdrawalInfo, internalCreateWithdrawalGroup, processWithdrawalGroup, } from "./withdraw.js"; import { PendingTaskType } from "../pending-types.js"; import { constructTransactionIdentifier, notifyTransition, stopLongpolling, } from "./transactions.js"; import { assertUnreachable } from "../util/assertUnreachable.js"; const logger = new Logger("operations/peer-to-peer.ts"); interface SelectedPeerCoin { coinPub: string; coinPriv: string; contribution: AmountString; denomPubHash: string; denomSig: UnblindedSignature; ageCommitmentProof: AgeCommitmentProof | undefined; } interface PeerCoinSelectionDetails { exchangeBaseUrl: string; /** * Info of Coins that were selected. */ coins: SelectedPeerCoin[]; /** * How much of the deposit fees is the customer paying? */ depositFees: AmountJson; } /** * Information about a selected coin for peer to peer payments. */ interface CoinInfo { /** * Public key of the coin. */ coinPub: string; coinPriv: string; /** * Deposit fee for the coin. */ feeDeposit: AmountJson; value: AmountJson; denomPubHash: string; denomSig: UnblindedSignature; maxAge: number; ageCommitmentProof?: AgeCommitmentProof; } export type SelectPeerCoinsResult = | { type: "success"; result: PeerCoinSelectionDetails } | { type: "failure"; insufficientBalanceDetails: PayPeerInsufficientBalanceDetails; }; export async function queryCoinInfosForSelection( ws: InternalWalletState, csel: PeerPushPaymentCoinSelection, ): Promise { let infos: SpendCoinDetails[] = []; await ws.db .mktx((x) => [x.coins, x.denominations]) .runReadOnly(async (tx) => { for (let i = 0; i < csel.coinPubs.length; i++) { const coin = await tx.coins.get(csel.coinPubs[i]); if (!coin) { throw Error("coin not found anymore"); } const denom = await ws.getDenomInfo( ws, tx, coin.exchangeBaseUrl, coin.denomPubHash, ); if (!denom) { throw Error("denom for coin not found anymore"); } infos.push({ coinPriv: coin.coinPriv, coinPub: coin.coinPub, denomPubHash: coin.denomPubHash, denomSig: coin.denomSig, ageCommitmentProof: coin.ageCommitmentProof, contribution: csel.contributions[i], }); } }); return infos; } export async function selectPeerCoins( ws: InternalWalletState, instructedAmount: AmountJson, ): Promise { if (Amounts.isZero(instructedAmount)) { // Other parts of the code assume that we have at least // one coin to spend. throw new Error("amount of zero not allowed"); } return await ws.db .mktx((x) => [ x.exchanges, x.contractTerms, x.coins, x.coinAvailability, x.denominations, x.refreshGroups, x.peerPushPaymentInitiations, ]) .runReadWrite(async (tx) => { const exchanges = await tx.exchanges.iter().toArray(); const exchangeFeeGap: { [url: string]: AmountJson } = {}; const currency = Amounts.currencyOf(instructedAmount); for (const exch of exchanges) { if (exch.detailsPointer?.currency !== currency) { continue; } const coins = ( await tx.coins.indexes.byBaseUrl.getAll(exch.baseUrl) ).filter((x) => x.status === CoinStatus.Fresh); const coinInfos: CoinInfo[] = []; for (const coin of coins) { const denom = await ws.getDenomInfo( ws, tx, coin.exchangeBaseUrl, coin.denomPubHash, ); if (!denom) { throw Error("denom not found"); } coinInfos.push({ coinPub: coin.coinPub, feeDeposit: Amounts.parseOrThrow(denom.feeDeposit), value: Amounts.parseOrThrow(denom.value), denomPubHash: denom.denomPubHash, coinPriv: coin.coinPriv, denomSig: coin.denomSig, maxAge: coin.maxAge, ageCommitmentProof: coin.ageCommitmentProof, }); } if (coinInfos.length === 0) { continue; } coinInfos.sort( (o1, o2) => -Amounts.cmp(o1.value, o2.value) || strcmp(o1.denomPubHash, o2.denomPubHash), ); let amountAcc = Amounts.zeroOfCurrency(currency); let depositFeesAcc = Amounts.zeroOfCurrency(currency); const resCoins: { coinPub: string; coinPriv: string; contribution: AmountString; denomPubHash: string; denomSig: UnblindedSignature; ageCommitmentProof: AgeCommitmentProof | undefined; }[] = []; let lastDepositFee = Amounts.zeroOfCurrency(currency); for (const coin of coinInfos) { if (Amounts.cmp(amountAcc, instructedAmount) >= 0) { break; } const gap = Amounts.add( coin.feeDeposit, Amounts.sub(instructedAmount, amountAcc).amount, ).amount; const contrib = Amounts.min(gap, coin.value); amountAcc = Amounts.add( amountAcc, Amounts.sub(contrib, coin.feeDeposit).amount, ).amount; depositFeesAcc = Amounts.add(depositFeesAcc, coin.feeDeposit).amount; resCoins.push({ coinPriv: coin.coinPriv, coinPub: coin.coinPub, contribution: Amounts.stringify(contrib), denomPubHash: coin.denomPubHash, denomSig: coin.denomSig, ageCommitmentProof: coin.ageCommitmentProof, }); lastDepositFee = coin.feeDeposit; } if (Amounts.cmp(amountAcc, instructedAmount) >= 0) { const res: PeerCoinSelectionDetails = { exchangeBaseUrl: exch.baseUrl, coins: resCoins, depositFees: depositFeesAcc, }; return { type: "success", result: res }; } const diff = Amounts.sub(instructedAmount, amountAcc).amount; exchangeFeeGap[exch.baseUrl] = Amounts.add(lastDepositFee, diff).amount; continue; } // We were unable to select coins. // Now we need to produce error details. const infoGeneral = await getPeerPaymentBalanceDetailsInTx(ws, tx, { currency, }); const perExchange: PayPeerInsufficientBalanceDetails["perExchange"] = {}; let maxFeeGapEstimate = Amounts.zeroOfCurrency(currency); for (const exch of exchanges) { if (exch.detailsPointer?.currency !== currency) { continue; } const infoExchange = await getPeerPaymentBalanceDetailsInTx(ws, tx, { currency, restrictExchangeTo: exch.baseUrl, }); let gap = exchangeFeeGap[exch.baseUrl] ?? Amounts.zeroOfCurrency(currency); if (Amounts.cmp(infoExchange.balanceMaterial, instructedAmount) < 0) { // Show fee gap only if we should've been able to pay with the material amount gap = Amounts.zeroOfCurrency(currency); } perExchange[exch.baseUrl] = { balanceAvailable: Amounts.stringify(infoExchange.balanceAvailable), balanceMaterial: Amounts.stringify(infoExchange.balanceMaterial), feeGapEstimate: Amounts.stringify(gap), }; maxFeeGapEstimate = Amounts.max(maxFeeGapEstimate, gap); } const errDetails: PayPeerInsufficientBalanceDetails = { amountRequested: Amounts.stringify(instructedAmount), balanceAvailable: Amounts.stringify(infoGeneral.balanceAvailable), balanceMaterial: Amounts.stringify(infoGeneral.balanceMaterial), feeGapEstimate: Amounts.stringify(maxFeeGapEstimate), perExchange, }; return { type: "failure", insufficientBalanceDetails: errDetails }; }); } export async function getTotalPeerPaymentCost( ws: InternalWalletState, pcs: SelectedPeerCoin[], ): Promise { return ws.db .mktx((x) => [x.coins, x.denominations]) .runReadOnly(async (tx) => { const costs: AmountJson[] = []; for (let i = 0; i < pcs.length; i++) { const coin = await tx.coins.get(pcs[i].coinPub); if (!coin) { throw Error("can't calculate payment cost, coin not found"); } const denom = await tx.denominations.get([ coin.exchangeBaseUrl, coin.denomPubHash, ]); if (!denom) { throw Error( "can't calculate payment cost, denomination for coin not found", ); } const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl .iter(coin.exchangeBaseUrl) .filter((x) => Amounts.isSameCurrency( DenominationRecord.getValue(x), pcs[i].contribution, ), ); const amountLeft = Amounts.sub( DenominationRecord.getValue(denom), pcs[i].contribution, ).amount; const refreshCost = getTotalRefreshCost( allDenoms, DenominationRecord.toDenomInfo(denom), amountLeft, ws.config.testing.denomselAllowLate, ); costs.push(Amounts.parseOrThrow(pcs[i].contribution)); costs.push(refreshCost); } const zero = Amounts.zeroOfAmount(pcs[0].contribution); return Amounts.sum([zero, ...costs]).amount; }); } export async function checkPeerPushDebit( ws: InternalWalletState, req: CheckPeerPushDebitRequest, ): Promise { const instructedAmount = Amounts.parseOrThrow(req.amount); const coinSelRes = await selectPeerCoins(ws, instructedAmount); if (coinSelRes.type === "failure") { throw TalerError.fromDetail( TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, { insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, }, ); } const totalAmount = await getTotalPeerPaymentCost( ws, coinSelRes.result.coins, ); return { amountEffective: Amounts.stringify(totalAmount), amountRaw: req.amount, }; } async function processPeerPushDebitCreateReserve( ws: InternalWalletState, peerPushInitiation: PeerPushPaymentInitiationRecord, ): Promise { const pursePub = peerPushInitiation.pursePub; const purseExpiration = peerPushInitiation.purseExpiration; const hContractTerms = peerPushInitiation.contractTermsHash; const purseSigResp = await ws.cryptoApi.signPurseCreation({ hContractTerms, mergePub: peerPushInitiation.mergePub, minAge: 0, purseAmount: peerPushInitiation.amount, purseExpiration, pursePriv: peerPushInitiation.pursePriv, }); const coins = await queryCoinInfosForSelection( ws, peerPushInitiation.coinSel, ); const depositSigsResp = await ws.cryptoApi.signPurseDeposits({ exchangeBaseUrl: peerPushInitiation.exchangeBaseUrl, pursePub: peerPushInitiation.pursePub, coins, }); const econtractResp = await ws.cryptoApi.encryptContractForMerge({ contractTerms: peerPushInitiation.contractTerms, mergePriv: peerPushInitiation.mergePriv, pursePriv: peerPushInitiation.pursePriv, pursePub: peerPushInitiation.pursePub, contractPriv: peerPushInitiation.contractPriv, contractPub: peerPushInitiation.contractPub, }); const createPurseUrl = new URL( `purses/${peerPushInitiation.pursePub}/create`, peerPushInitiation.exchangeBaseUrl, ); const httpResp = await ws.http.fetch(createPurseUrl.href, { method: "POST", body: { amount: peerPushInitiation.amount, merge_pub: peerPushInitiation.mergePub, purse_sig: purseSigResp.sig, h_contract_terms: hContractTerms, purse_expiration: purseExpiration, deposits: depositSigsResp.deposits, min_age: 0, econtract: econtractResp.econtract, }, }); const resp = await httpResp.json(); logger.info(`resp: ${j2s(resp)}`); if (httpResp.status !== HttpStatusCode.Ok) { throw Error("got error response from exchange"); } await ws.db .mktx((x) => [x.peerPushPaymentInitiations]) .runReadWrite(async (tx) => { const ppi = await tx.peerPushPaymentInitiations.get(pursePub); if (!ppi) { return; } ppi.status = PeerPushPaymentInitiationStatus.Done; await tx.peerPushPaymentInitiations.put(ppi); }); return { type: OperationAttemptResultType.Finished, result: undefined, }; } async function transitionPeerPushDebitFromReadyToDone( ws: InternalWalletState, pursePub: string, ): Promise { const transactionId = constructTransactionIdentifier({ tag: TransactionType.PeerPushDebit, pursePub, }); const transitionInfo = await ws.db .mktx((x) => [x.peerPushPaymentInitiations]) .runReadWrite(async (tx) => { const ppiRec = await tx.peerPushPaymentInitiations.get(pursePub); if (!ppiRec) { return undefined; } if (ppiRec.status !== PeerPushPaymentInitiationStatus.PendingReady) { return undefined; } const oldTxState = computePeerPushDebitTransactionState(ppiRec); ppiRec.status = PeerPushPaymentInitiationStatus.Done; const newTxState = computePeerPushDebitTransactionState(ppiRec); return { oldTxState, newTxState, }; }); notifyTransition(ws, transactionId, transitionInfo); } /** * Process the "pending(ready)" state of a peer-push-debit transaction. */ async function processPeerPushDebitReady( ws: InternalWalletState, peerPushInitiation: PeerPushPaymentInitiationRecord, ): Promise { const pursePub = peerPushInitiation.pursePub; const retryTag = constructTaskIdentifier({ tag: PendingTaskType.PeerPushDebit, pursePub, }); runLongpollAsync(ws, retryTag, async (ct) => { const mergeUrl = new URL(`purses/${pursePub}/merge`); mergeUrl.searchParams.set("timeout_ms", "30000"); const resp = await ws.http.fetch(mergeUrl.href, { // timeout: getReserveRequestTimeout(withdrawalGroup), cancellationToken: ct, }); if (resp.status === HttpStatusCode.Ok) { const purseStatus = await readSuccessResponseJsonOrThrow( resp, codecForExchangePurseStatus(), ); if (purseStatus.deposit_timestamp) { await transitionPeerPushDebitFromReadyToDone( ws, peerPushInitiation.pursePub, ); return { ready: true, }; } } else if (resp.status === HttpStatusCode.Gone) { // FIXME: transition the reserve into the expired state } return { ready: false, }; }); logger.trace( "returning early from withdrawal for long-polling in background", ); return { type: OperationAttemptResultType.Longpoll, }; } export async function processPeerPushDebit( ws: InternalWalletState, pursePub: string, ): Promise { const peerPushInitiation = await ws.db .mktx((x) => [x.peerPushPaymentInitiations]) .runReadOnly(async (tx) => { return tx.peerPushPaymentInitiations.get(pursePub); }); if (!peerPushInitiation) { throw Error("peer push payment not found"); } const retryTag = constructTaskIdentifier({ tag: PendingTaskType.PeerPushDebit, pursePub, }); // We're already running! if (ws.activeLongpoll[retryTag]) { logger.info("peer-push-debit task already in long-polling, returning!"); return { type: OperationAttemptResultType.Longpoll, }; } switch (peerPushInitiation.status) { case PeerPushPaymentInitiationStatus.PendingCreatePurse: return processPeerPushDebitCreateReserve(ws, peerPushInitiation); case PeerPushPaymentInitiationStatus.PendingReady: return processPeerPushDebitReady(ws, peerPushInitiation); } return { type: OperationAttemptResultType.Finished, result: undefined, }; } /** * Initiate sending a peer-to-peer push payment. */ export async function initiatePeerPushDebit( ws: InternalWalletState, req: InitiatePeerPushDebitRequest, ): Promise { const instructedAmount = Amounts.parseOrThrow( req.partialContractTerms.amount, ); const purseExpiration = req.partialContractTerms.purse_expiration; const contractTerms = req.partialContractTerms; const pursePair = await ws.cryptoApi.createEddsaKeypair({}); const mergePair = await ws.cryptoApi.createEddsaKeypair({}); const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms); const contractKeyPair = await ws.cryptoApi.createEddsaKeypair({}); const coinSelRes = await selectPeerCoins(ws, instructedAmount); if (coinSelRes.type !== "success") { throw TalerError.fromDetail( TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, { insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, }, ); } const sel = coinSelRes.result; logger.info(`selected p2p coins (push): ${j2s(coinSelRes)}`); const totalAmount = await getTotalPeerPaymentCost( ws, coinSelRes.result.coins, ); await ws.db .mktx((x) => [ x.exchanges, x.contractTerms, x.coins, x.coinAvailability, x.denominations, x.refreshGroups, x.peerPushPaymentInitiations, ]) .runReadWrite(async (tx) => { // FIXME: Instead of directly doing a spendCoin here, // we might want to mark the coins as used and spend them // after we've been able to create the purse. await spendCoins(ws, tx, { // allocationId: `txn:peer-push-debit:${pursePair.pub}`, allocationId: constructTransactionIdentifier({ tag: TransactionType.PeerPushDebit, pursePub: pursePair.pub, }), coinPubs: sel.coins.map((x) => x.coinPub), contributions: sel.coins.map((x) => Amounts.parseOrThrow(x.contribution), ), refreshReason: RefreshReason.PayPeerPush, }); await tx.peerPushPaymentInitiations.add({ amount: Amounts.stringify(instructedAmount), contractPriv: contractKeyPair.priv, contractPub: contractKeyPair.pub, contractTermsHash: hContractTerms, exchangeBaseUrl: sel.exchangeBaseUrl, mergePriv: mergePair.priv, mergePub: mergePair.pub, purseExpiration: purseExpiration, pursePriv: pursePair.priv, pursePub: pursePair.pub, timestampCreated: TalerPreciseTimestamp.now(), status: PeerPushPaymentInitiationStatus.PendingCreatePurse, contractTerms: contractTerms, coinSel: { coinPubs: sel.coins.map((x) => x.coinPub), contributions: sel.coins.map((x) => x.contribution), }, totalCost: Amounts.stringify(totalAmount), }); await tx.contractTerms.put({ h: hContractTerms, contractTermsRaw: contractTerms, }); }); const taskId = constructTaskIdentifier({ tag: PendingTaskType.PeerPushDebit, pursePub: pursePair.pub, }); await runOperationWithErrorReporting(ws, taskId, async () => { return await processPeerPushDebit(ws, pursePair.pub); }); return { contractPriv: contractKeyPair.priv, mergePriv: mergePair.priv, pursePub: pursePair.pub, exchangeBaseUrl: coinSelRes.result.exchangeBaseUrl, talerUri: constructPayPushUri({ exchangeBaseUrl: coinSelRes.result.exchangeBaseUrl, contractPriv: contractKeyPair.priv, }), transactionId: constructTransactionIdentifier({ tag: TransactionType.PeerPushDebit, pursePub: pursePair.pub, }), }; } interface ExchangePurseStatus { balance: AmountString; deposit_timestamp?: TalerProtocolTimestamp; merge_timestamp?: TalerProtocolTimestamp; } export const codecForExchangePurseStatus = (): Codec => buildCodecForObject() .property("balance", codecForAmountString()) .property("deposit_timestamp", codecOptional(codecForTimestamp)) .property("merge_timestamp", codecOptional(codecForTimestamp)) .build("ExchangePurseStatus"); export async function preparePeerPushCredit( ws: InternalWalletState, req: PreparePeerPushCredit, ): Promise { const uri = parsePayPushUri(req.talerUri); if (!uri) { throw Error("got invalid taler://pay-push URI"); } const existing = await ws.db .mktx((x) => [x.contractTerms, x.peerPushPaymentIncoming]) .runReadOnly(async (tx) => { const existingPushInc = await tx.peerPushPaymentIncoming.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, peerPushPaymentIncomingId: existing.existingPushInc.peerPushPaymentIncomingId, transactionId: constructTransactionIdentifier({ tag: TransactionType.PeerPushCredit, peerPushPaymentIncomingId: existing.existingPushInc.peerPushPaymentIncomingId, }), }; } const exchangeBaseUrl = uri.exchangeBaseUrl; await updateExchangeFromUrl(ws, exchangeBaseUrl); const contractPriv = uri.contractPriv; const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv))); const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl); const contractHttpResp = await ws.http.get(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.get(getPurseUrl.href); const purseStatus = await readSuccessResponseJsonOrThrow( purseHttpResp, codecForExchangePurseStatus(), ); const peerPushPaymentIncomingId = 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, ); await ws.db .mktx((x) => [x.contractTerms, x.peerPushPaymentIncoming]) .runReadWrite(async (tx) => { await tx.peerPushPaymentIncoming.add({ peerPushPaymentIncomingId, contractPriv: contractPriv, exchangeBaseUrl: exchangeBaseUrl, mergePriv: dec.mergePriv, pursePub: pursePub, timestamp: TalerPreciseTimestamp.now(), contractTermsHash, status: PeerPushPaymentIncomingStatus.DialogProposed, withdrawalGroupId, currency: Amounts.currencyOf(purseStatus.balance), estimatedAmountEffective: Amounts.stringify( wi.withdrawalAmountEffective, ), }); await tx.contractTerms.put({ h: contractTermsHash, contractTermsRaw: dec.contractTerms, }); }); return { amount: purseStatus.balance, amountEffective: wi.withdrawalAmountEffective, amountRaw: purseStatus.balance, contractTerms: dec.contractTerms, peerPushPaymentIncomingId, transactionId: constructTransactionIdentifier({ tag: TransactionType.PeerPushCredit, peerPushPaymentIncomingId, }), }; } export function talerPaytoFromExchangeReserve( exchangeBaseUrl: string, reservePub: string, ): string { const url = new URL(exchangeBaseUrl); let proto: string; if (url.protocol === "http:") { proto = "taler-reserve-http"; } else if (url.protocol === "https:") { proto = "taler-reserve"; } else { throw Error(`unsupported exchange base URL protocol (${url.protocol})`); } let path = url.pathname; if (!path.endsWith("/")) { path = path + "/"; } return `payto://${proto}/${url.host}${url.pathname}${reservePub}`; } async function getMergeReserveInfo( ws: InternalWalletState, req: { exchangeBaseUrl: string; }, ): Promise { // We have to eagerly create the key pair outside of the transaction, // due to the async crypto API. const newReservePair = await ws.cryptoApi.createEddsaKeypair({}); const mergeReserveRecord: ReserveRecord = await ws.db .mktx((x) => [x.exchanges, x.reserves, x.withdrawalGroups]) .runReadWrite(async (tx) => { const ex = await tx.exchanges.get(req.exchangeBaseUrl); checkDbInvariant(!!ex); if (ex.currentMergeReserveRowId != null) { const reserve = await tx.reserves.get(ex.currentMergeReserveRowId); checkDbInvariant(!!reserve); return reserve; } const reserve: ReserveRecord = { reservePriv: newReservePair.priv, reservePub: newReservePair.pub, }; const insertResp = await tx.reserves.put(reserve); checkDbInvariant(typeof insertResp.key === "number"); reserve.rowId = insertResp.key; ex.currentMergeReserveRowId = reserve.rowId; await tx.exchanges.put(ex); return reserve; }); return mergeReserveRecord; } export async function processPeerPushCredit( ws: InternalWalletState, peerPushPaymentIncomingId: string, ): Promise { let peerInc: PeerPushPaymentIncomingRecord | undefined; let contractTerms: PeerContractTerms | undefined; await ws.db .mktx((x) => [x.contractTerms, x.peerPushPaymentIncoming]) .runReadWrite(async (tx) => { peerInc = await tx.peerPushPaymentIncoming.get(peerPushPaymentIncomingId); if (!peerInc) { return; } const ctRec = await tx.contractTerms.get(peerInc.contractTermsHash); if (ctRec) { contractTerms = ctRec.contractTermsRaw; } await tx.peerPushPaymentIncoming.put(peerInc); }); if (!peerInc) { throw Error( `can't accept unknown incoming p2p push payment (${peerPushPaymentIncomingId})`, ); } checkDbInvariant(!!contractTerms); const amount = Amounts.parseOrThrow(contractTerms.amount); if ( peerInc.status === PeerPushPaymentIncomingStatus.PendingMergeKycRequired && peerInc.kycInfo ) { const txId = constructTransactionIdentifier({ tag: TransactionType.PeerPushCredit, peerPushPaymentIncomingId: peerInc.peerPushPaymentIncomingId, }); await checkWithdrawalKycStatus( ws, peerInc.exchangeBaseUrl, txId, peerInc.kycInfo, "individual", ); } 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.postJson(mergePurseUrl.href, mergeReq); if (mergeHttpResp.status === HttpStatusCode.UnavailableForLegalReasons) { const respJson = await mergeHttpResp.json(); const kycPending = codecForWalletKycUuid().decode(respJson); logger.info(`kyc uuid response: ${j2s(kycPending)}`); await ws.db .mktx((x) => [x.peerPushPaymentIncoming]) .runReadWrite(async (tx) => { const peerInc = await tx.peerPushPaymentIncoming.get( peerPushPaymentIncomingId, ); if (!peerInc) { return; } peerInc.kycInfo = { paytoHash: kycPending.h_payto, requirementRow: kycPending.requirement_row, }; peerInc.status = PeerPushPaymentIncomingStatus.PendingMergeKycRequired; await tx.peerPushPaymentIncoming.put(peerInc); }); return { type: OperationAttemptResultType.Pending, result: undefined, }; } logger.trace(`merge request: ${j2s(mergeReq)}`); const res = await readSuccessResponseJsonOrThrow( mergeHttpResp, codecForAny(), ); logger.trace(`merge response: ${j2s(res)}`); await internalCreateWithdrawalGroup(ws, { amount, wgInfo: { withdrawalType: WithdrawalRecordType.PeerPushCredit, contractTerms, }, forcedWithdrawalGroupId: peerInc.withdrawalGroupId, exchangeBaseUrl: peerInc.exchangeBaseUrl, reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus, reserveKeyPair: { priv: mergeReserveInfo.reservePriv, pub: mergeReserveInfo.reservePub, }, }); await ws.db .mktx((x) => [x.contractTerms, x.peerPushPaymentIncoming]) .runReadWrite(async (tx) => { const peerInc = await tx.peerPushPaymentIncoming.get( peerPushPaymentIncomingId, ); if (!peerInc) { return; } if ( peerInc.status === PeerPushPaymentIncomingStatus.PendingMerge || peerInc.status === PeerPushPaymentIncomingStatus.PendingMergeKycRequired ) { peerInc.status = PeerPushPaymentIncomingStatus.Done; } await tx.peerPushPaymentIncoming.put(peerInc); }); return { type: OperationAttemptResultType.Finished, result: undefined, }; } export async function confirmPeerPushCredit( ws: InternalWalletState, req: ConfirmPeerPushCreditRequest, ): Promise { let peerInc: PeerPushPaymentIncomingRecord | undefined; await ws.db .mktx((x) => [x.contractTerms, x.peerPushPaymentIncoming]) .runReadWrite(async (tx) => { peerInc = await tx.peerPushPaymentIncoming.get( req.peerPushPaymentIncomingId, ); if (!peerInc) { return; } if (peerInc.status === PeerPushPaymentIncomingStatus.DialogProposed) { peerInc.status = PeerPushPaymentIncomingStatus.PendingMerge; } await tx.peerPushPaymentIncoming.put(peerInc); }); if (!peerInc) { throw Error( `can't accept unknown incoming p2p push payment (${req.peerPushPaymentIncomingId})`, ); } ws.workAvailable.trigger(); const transactionId = constructTransactionIdentifier({ tag: TransactionType.PeerPushCredit, peerPushPaymentIncomingId: req.peerPushPaymentIncomingId, }); return { transactionId, }; } export async function processPeerPullDebit( ws: InternalWalletState, peerPullPaymentIncomingId: string, ): Promise { const peerPullInc = await ws.db .mktx((x) => [x.peerPullPaymentIncoming]) .runReadOnly(async (tx) => { return tx.peerPullPaymentIncoming.get(peerPullPaymentIncomingId); }); if (!peerPullInc) { throw Error("peer pull debit not found"); } if (peerPullInc.status === PeerPullDebitRecordStatus.PendingDeposit) { const pursePub = peerPullInc.pursePub; const coinSel = peerPullInc.coinSel; if (!coinSel) { throw Error("invalid state, no coins selected"); } const coins = await queryCoinInfosForSelection(ws, coinSel); const depositSigsResp = await ws.cryptoApi.signPurseDeposits({ exchangeBaseUrl: peerPullInc.exchangeBaseUrl, pursePub: peerPullInc.pursePub, coins, }); const purseDepositUrl = new URL( `purses/${pursePub}/deposit`, peerPullInc.exchangeBaseUrl, ); const depositPayload: ExchangePurseDeposits = { deposits: depositSigsResp.deposits, }; if (logger.shouldLogTrace()) { logger.trace(`purse deposit payload: ${j2s(depositPayload)}`); } const httpResp = await ws.http.postJson( purseDepositUrl.href, depositPayload, ); const resp = await readSuccessResponseJsonOrThrow(httpResp, codecForAny()); logger.trace(`purse deposit response: ${j2s(resp)}`); } await ws.db .mktx((x) => [x.peerPullPaymentIncoming]) .runReadWrite(async (tx) => { const pi = await tx.peerPullPaymentIncoming.get( peerPullPaymentIncomingId, ); if (!pi) { throw Error("peer pull payment not found anymore"); } if (pi.status === PeerPullDebitRecordStatus.PendingDeposit) { pi.status = PeerPullDebitRecordStatus.DonePaid; } await tx.peerPullPaymentIncoming.put(pi); }); return { type: OperationAttemptResultType.Finished, result: undefined, }; } export async function confirmPeerPullDebit( ws: InternalWalletState, req: ConfirmPeerPullDebitRequest, ): Promise { const peerPullInc = await ws.db .mktx((x) => [x.peerPullPaymentIncoming]) .runReadOnly(async (tx) => { return tx.peerPullPaymentIncoming.get(req.peerPullPaymentIncomingId); }); if (!peerPullInc) { throw Error( `can't accept unknown incoming p2p pull payment (${req.peerPullPaymentIncomingId})`, ); } const instructedAmount = Amounts.parseOrThrow( peerPullInc.contractTerms.amount, ); const coinSelRes = await selectPeerCoins(ws, instructedAmount); logger.info(`selected p2p coins (pull): ${j2s(coinSelRes)}`); if (coinSelRes.type !== "success") { throw TalerError.fromDetail( TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, { insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, }, ); } const sel = coinSelRes.result; const totalAmount = await getTotalPeerPaymentCost( ws, coinSelRes.result.coins, ); const ppi = await ws.db .mktx((x) => [ x.exchanges, x.coins, x.denominations, x.refreshGroups, x.peerPullPaymentIncoming, x.coinAvailability, ]) .runReadWrite(async (tx) => { await spendCoins(ws, tx, { // allocationId: `txn:peer-pull-debit:${req.peerPullPaymentIncomingId}`, allocationId: constructTransactionIdentifier({ tag: TransactionType.PeerPullDebit, peerPullPaymentIncomingId: req.peerPullPaymentIncomingId, }), coinPubs: sel.coins.map((x) => x.coinPub), contributions: sel.coins.map((x) => Amounts.parseOrThrow(x.contribution), ), refreshReason: RefreshReason.PayPeerPull, }); const pi = await tx.peerPullPaymentIncoming.get( req.peerPullPaymentIncomingId, ); if (!pi) { throw Error(); } if (pi.status === PeerPullDebitRecordStatus.DialogProposed) { pi.status = PeerPullDebitRecordStatus.PendingDeposit; pi.coinSel = { coinPubs: sel.coins.map((x) => x.coinPub), contributions: sel.coins.map((x) => x.contribution), totalCost: Amounts.stringify(totalAmount), }; } await tx.peerPullPaymentIncoming.put(pi); return pi; }); await runOperationWithErrorReporting( ws, TaskIdentifiers.forPeerPullPaymentDebit(ppi), async () => { return processPeerPullDebit(ws, ppi.peerPullPaymentIncomingId); }, ); const transactionId = constructTransactionIdentifier({ tag: TransactionType.PeerPullDebit, peerPullPaymentIncomingId: req.peerPullPaymentIncomingId, }); return { transactionId, }; } /** * Look up information about an incoming peer pull payment. * Store the results in the wallet DB. */ export async function preparePeerPullDebit( ws: InternalWalletState, req: PreparePeerPullDebitRequest, ): Promise { const uri = parsePayPullUri(req.talerUri); if (!uri) { throw Error("got invalid taler://pay-pull URI"); } const existingPullIncomingRecord = await ws.db .mktx((x) => [x.peerPullPaymentIncoming]) .runReadOnly(async (tx) => { return tx.peerPullPaymentIncoming.indexes.byExchangeAndContractPriv.get([ uri.exchangeBaseUrl, uri.contractPriv, ]); }); if (existingPullIncomingRecord) { return { amount: existingPullIncomingRecord.contractTerms.amount, amountRaw: existingPullIncomingRecord.contractTerms.amount, amountEffective: existingPullIncomingRecord.totalCostEstimated, contractTerms: existingPullIncomingRecord.contractTerms, peerPullPaymentIncomingId: existingPullIncomingRecord.peerPullPaymentIncomingId, transactionId: constructTransactionIdentifier({ tag: TransactionType.PeerPullDebit, peerPullPaymentIncomingId: existingPullIncomingRecord.peerPullPaymentIncomingId, }), }; } const exchangeBaseUrl = uri.exchangeBaseUrl; const contractPriv = uri.contractPriv; const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv))); const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl); const contractHttpResp = await ws.http.get(getContractUrl.href); const contractResp = await readSuccessResponseJsonOrThrow( contractHttpResp, codecForExchangeGetContractResponse(), ); const pursePub = contractResp.purse_pub; const dec = await ws.cryptoApi.decryptContractForDeposit({ ciphertext: contractResp.econtract, contractPriv: contractPriv, pursePub: pursePub, }); const getPurseUrl = new URL(`purses/${pursePub}/merge`, exchangeBaseUrl); const purseHttpResp = await ws.http.get(getPurseUrl.href); const purseStatus = await readSuccessResponseJsonOrThrow( purseHttpResp, codecForExchangePurseStatus(), ); const peerPullPaymentIncomingId = encodeCrock(getRandomBytes(32)); let contractTerms: PeerContractTerms; if (dec.contractTerms) { contractTerms = codecForPeerContractTerms().decode(dec.contractTerms); // FIXME: Check that the purseStatus balance matches contract terms amount } else { // FIXME: In this case, where do we get the purse expiration from?! // https://bugs.gnunet.org/view.php?id=7706 throw Error("pull payments without contract terms not supported yet"); } // FIXME: Why don't we compute the totalCost here?! const instructedAmount = Amounts.parseOrThrow(contractTerms.amount); const coinSelRes = await selectPeerCoins(ws, instructedAmount); logger.info(`selected p2p coins (pull): ${j2s(coinSelRes)}`); if (coinSelRes.type !== "success") { throw TalerError.fromDetail( TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, { insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, }, ); } const totalAmount = await getTotalPeerPaymentCost( ws, coinSelRes.result.coins, ); await ws.db .mktx((x) => [x.peerPullPaymentIncoming]) .runReadWrite(async (tx) => { await tx.peerPullPaymentIncoming.add({ peerPullPaymentIncomingId, contractPriv: contractPriv, exchangeBaseUrl: exchangeBaseUrl, pursePub: pursePub, timestampCreated: TalerPreciseTimestamp.now(), contractTerms, status: PeerPullDebitRecordStatus.DialogProposed, totalCostEstimated: Amounts.stringify(totalAmount), }); }); return { amount: contractTerms.amount, amountEffective: Amounts.stringify(totalAmount), amountRaw: contractTerms.amount, contractTerms: contractTerms, peerPullPaymentIncomingId, transactionId: constructTransactionIdentifier({ tag: TransactionType.PeerPullDebit, peerPullPaymentIncomingId: peerPullPaymentIncomingId, }), }; } export async function queryPurseForPeerPullCredit( ws: InternalWalletState, pullIni: PeerPullPaymentInitiationRecord, 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.get(purseDepositUrl.href, { timeout: { d_ms: 60000 }, cancellationToken, }); logger.info(`purse status code: HTTP ${resp.status}`); const result = await readSuccessResponseJsonOrErrorCode( resp, codecForExchangePurseStatus(), ); if (result.isError) { logger.info(`got purse status error, EC=${result.talerErrorResponse.code}`); if (resp.status === 404) { return { ready: false }; } else { throwUnexpectedRequestError(resp, result.talerErrorResponse); } } if (!result.response.deposit_timestamp) { logger.info("purse not ready yet (no deposit)"); return { ready: false }; } const reserve = await ws.db .mktx((x) => [x.reserves]) .runReadOnly(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, contractTerms: pullIni.contractTerms, contractPriv: pullIni.contractPriv, }, forcedWithdrawalGroupId: pullIni.withdrawalGroupId, exchangeBaseUrl: pullIni.exchangeBaseUrl, reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus, reserveKeyPair: { priv: reserve.reservePriv, pub: reserve.reservePub, }, }); await ws.db .mktx((x) => [x.peerPullPaymentInitiations]) .runReadWrite(async (tx) => { const finPi = await tx.peerPullPaymentInitiations.get(pullIni.pursePub); if (!finPi) { logger.warn("peerPullPaymentInitiation not found anymore"); return; } if (finPi.status === PeerPullPaymentInitiationStatus.PendingReady) { finPi.status = PeerPullPaymentInitiationStatus.DonePurseDeposited; } await tx.peerPullPaymentInitiations.put(finPi); }); return { ready: true, }; } export async function processPeerPullCredit( ws: InternalWalletState, pursePub: string, ): Promise { const pullIni = await ws.db .mktx((x) => [x.peerPullPaymentInitiations]) .runReadOnly(async (tx) => { return tx.peerPullPaymentInitiations.get(pursePub); }); if (!pullIni) { throw Error("peer pull payment initiation not found in database"); } const retryTag = constructTaskIdentifier({ tag: PendingTaskType.PeerPullCredit, pursePub, }); // We're already running! if (ws.activeLongpoll[retryTag]) { logger.info("peer-pull-credit already in long-polling, returning!"); return { type: OperationAttemptResultType.Longpoll, }; } logger.trace(`processing ${retryTag}, status=${pullIni.status}`); switch (pullIni.status) { case PeerPullPaymentInitiationStatus.DonePurseDeposited: { // We implement this case so that the "retry" action on a peer-pull-credit transaction // also retries the withdrawal task. logger.warn( "peer pull payment initiation is already finished, retrying withdrawal", ); const withdrawalGroupId = pullIni.withdrawalGroupId; if (withdrawalGroupId) { const taskId = constructTaskIdentifier({ tag: PendingTaskType.Withdraw, withdrawalGroupId, }); stopLongpolling(ws, taskId); await resetOperationTimeout(ws, taskId); await runOperationWithErrorReporting(ws, taskId, () => processWithdrawalGroup(ws, withdrawalGroupId), ); } return { type: OperationAttemptResultType.Finished, result: undefined, }; } case PeerPullPaymentInitiationStatus.PendingReady: runLongpollAsync(ws, retryTag, async (cancellationToken) => queryPurseForPeerPullCredit(ws, pullIni, cancellationToken), ); logger.trace( "returning early from processPeerPullCredit for long-polling in background", ); return { type: OperationAttemptResultType.Longpoll, }; case PeerPullPaymentInitiationStatus.PendingMergeKycRequired: { const transactionId = constructTransactionIdentifier({ tag: TransactionType.PeerPullCredit, pursePub: pullIni.pursePub, }); if (pullIni.kycInfo) { await checkWithdrawalKycStatus( ws, pullIni.exchangeBaseUrl, transactionId, pullIni.kycInfo, "individual", ); } break; } case PeerPullPaymentInitiationStatus.PendingCreatePurse: break; default: throw Error(`unknown PeerPullPaymentInitiationStatus ${pullIni.status}`); } const mergeReserve = await ws.db .mktx((x) => [x.reserves]) .runReadOnly(async (tx) => { return tx.reserves.get(pullIni.mergeReserveRowId); }); if (!mergeReserve) { throw Error("merge reserve for peer pull payment not found in database"); } const purseFee = Amounts.stringify(Amounts.zeroOfAmount(pullIni.amount)); const reservePayto = talerPaytoFromExchangeReserve( pullIni.exchangeBaseUrl, mergeReserve.reservePub, ); const econtractResp = await ws.cryptoApi.encryptContractForDeposit({ contractPriv: pullIni.contractPriv, contractPub: pullIni.contractPub, contractTerms: pullIni.contractTerms, pursePriv: pullIni.pursePriv, pursePub: pullIni.pursePub, }); const purseExpiration = pullIni.contractTerms.purse_expiration; const sigRes = await ws.cryptoApi.signReservePurseCreate({ contractTermsHash: pullIni.contractTermsHash, flags: WalletAccountMergeFlags.CreateWithPurseFee, mergePriv: pullIni.mergePriv, mergeTimestamp: TalerPreciseTimestamp.round(pullIni.mergeTimestamp), purseAmount: pullIni.contractTerms.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(pullIni.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.contractTerms.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.postJson( reservePurseMergeUrl.href, reservePurseReqBody, ); if (httpResp.status === HttpStatusCode.UnavailableForLegalReasons) { const respJson = await httpResp.json(); const kycPending = codecForWalletKycUuid().decode(respJson); logger.info(`kyc uuid response: ${j2s(kycPending)}`); await ws.db .mktx((x) => [x.peerPullPaymentInitiations]) .runReadWrite(async (tx) => { const peerIni = await tx.peerPullPaymentInitiations.get(pursePub); if (!peerIni) { return; } peerIni.kycInfo = { paytoHash: kycPending.h_payto, requirementRow: kycPending.requirement_row, }; peerIni.status = PeerPullPaymentInitiationStatus.PendingMergeKycRequired; await tx.peerPullPaymentInitiations.put(peerIni); }); return { type: OperationAttemptResultType.Pending, result: undefined, }; } const resp = await readSuccessResponseJsonOrThrow(httpResp, codecForAny()); logger.info(`reserve merge response: ${j2s(resp)}`); await ws.db .mktx((x) => [x.peerPullPaymentInitiations]) .runReadWrite(async (tx) => { const pi2 = await tx.peerPullPaymentInitiations.get(pursePub); if (!pi2) { return; } pi2.status = PeerPullPaymentInitiationStatus.PendingReady; await tx.peerPullPaymentInitiations.put(pi2); }); ws.notify({ type: NotificationType.PeerPullCreditReady, transactionId: constructTransactionIdentifier({ tag: TransactionType.PeerPullCredit, pursePub: pullIni.pursePub, }), }); return { type: OperationAttemptResultType.Finished, result: undefined, }; } /** * 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 .mktx((x) => [x.exchanges]) .runReadOnly(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; } if (candidate.lastWithdrawal && e.lastWithdrawal) { if ( AbsoluteTime.cmp( AbsoluteTime.fromPreciseTimestamp(e.lastWithdrawal), AbsoluteTime.fromPreciseTimestamp(candidate.lastWithdrawal), ) > 0 ) { candidate = e; } } } if (candidate) { return candidate.baseUrl; } return undefined; }); return url; } /** * 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`); return { exchangeBaseUrl: exchangeUrl, amountEffective: wi.withdrawalAmountEffective, amountRaw: req.amount, }; } /** * 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 updateExchangeFromUrl(ws, exchangeBaseUrl); const mergeReserveInfo = await getMergeReserveInfo(ws, { exchangeBaseUrl: exchangeBaseUrl, }); const mergeTimestamp = TalerPreciseTimestamp.now(); 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 wi = await getExchangeWithdrawalInfo( ws, exchangeBaseUrl, Amounts.parseOrThrow(req.partialContractTerms.amount), undefined, ); await ws.db .mktx((x) => [x.peerPullPaymentInitiations, x.contractTerms]) .runReadWrite(async (tx) => { await tx.peerPullPaymentInitiations.put({ amount: req.partialContractTerms.amount, contractTermsHash: hContractTerms, exchangeBaseUrl: exchangeBaseUrl, pursePriv: pursePair.priv, pursePub: pursePair.pub, mergePriv: mergePair.priv, mergePub: mergePair.pub, status: PeerPullPaymentInitiationStatus.PendingCreatePurse, contractTerms: contractTerms, mergeTimestamp, mergeReserveRowId: mergeReserveRowId, contractPriv: contractKeyPair.priv, contractPub: contractKeyPair.pub, withdrawalGroupId, estimatedAmountEffective: wi.withdrawalAmountEffective, }); await tx.contractTerms.put({ contractTermsRaw: contractTerms, h: hContractTerms, }); }); // FIXME: Should we somehow signal to the client // whether purse creation has failed, or does the client/ // check this asynchronously from the transaction status? const taskId = constructTaskIdentifier({ tag: PendingTaskType.PeerPullCredit, pursePub: pursePair.pub, }); await runOperationWithErrorReporting(ws, taskId, async () => { return processPeerPullCredit(ws, pursePair.pub); }); const transactionId = constructTransactionIdentifier({ tag: TransactionType.PeerPullCredit, pursePub: pursePair.pub, }); return { talerUri: constructPayPullUri({ exchangeBaseUrl: exchangeBaseUrl, contractPriv: contractKeyPair.priv, }), transactionId, }; } export function computePeerPushDebitTransactionState( ppiRecord: PeerPushPaymentInitiationRecord, ): TransactionState { switch (ppiRecord.status) { case PeerPushPaymentInitiationStatus.PendingCreatePurse: return { major: TransactionMajorState.Pending, minor: TransactionMinorState.CreatePurse, }; case PeerPushPaymentInitiationStatus.PendingReady: return { major: TransactionMajorState.Pending, minor: TransactionMinorState.Ready, }; case PeerPushPaymentInitiationStatus.Aborted: return { major: TransactionMajorState.Aborted, }; case PeerPushPaymentInitiationStatus.AbortingDeletePurse: return { major: TransactionMajorState.Aborting, minor: TransactionMinorState.DeletePurse, }; case PeerPushPaymentInitiationStatus.AbortingRefresh: return { major: TransactionMajorState.Aborting, minor: TransactionMinorState.Refresh, }; case PeerPushPaymentInitiationStatus.SuspendedAbortingDeletePurse: return { major: TransactionMajorState.SuspendedAborting, minor: TransactionMinorState.DeletePurse, }; case PeerPushPaymentInitiationStatus.SuspendedAbortingRefresh: return { major: TransactionMajorState.SuspendedAborting, minor: TransactionMinorState.Refresh, }; case PeerPushPaymentInitiationStatus.SuspendedCreatePurse: return { major: TransactionMajorState.Suspended, minor: TransactionMinorState.CreatePurse, }; case PeerPushPaymentInitiationStatus.SuspendedReady: return { major: TransactionMajorState.Suspended, minor: TransactionMinorState.Ready, }; case PeerPushPaymentInitiationStatus.Done: return { major: TransactionMajorState.Done, }; } } export async function abortPeerPushDebitTransaction( ws: InternalWalletState, pursePub: string, ) { const taskId = constructTaskIdentifier({ tag: PendingTaskType.PeerPushDebit, pursePub, }); const transactionId = constructTransactionIdentifier({ tag: TransactionType.PeerPushDebit, pursePub, }); stopLongpolling(ws, taskId); const transitionInfo = await ws.db .mktx((x) => [x.peerPushPaymentInitiations]) .runReadWrite(async (tx) => { const pushDebitRec = await tx.peerPushPaymentInitiations.get(pursePub); if (!pushDebitRec) { logger.warn(`peer push debit ${pursePub} not found`); return; } let newStatus: PeerPushPaymentInitiationStatus | undefined = undefined; switch (pushDebitRec.status) { case PeerPushPaymentInitiationStatus.PendingReady: case PeerPushPaymentInitiationStatus.SuspendedReady: newStatus = PeerPushPaymentInitiationStatus.AbortingDeletePurse; break; case PeerPushPaymentInitiationStatus.SuspendedCreatePurse: case PeerPushPaymentInitiationStatus.PendingCreatePurse: // Network request might already be in-flight! newStatus = PeerPushPaymentInitiationStatus.AbortingDeletePurse; break; case PeerPushPaymentInitiationStatus.SuspendedAbortingRefresh: case PeerPushPaymentInitiationStatus.SuspendedAbortingDeletePurse: case PeerPushPaymentInitiationStatus.AbortingRefresh: case PeerPushPaymentInitiationStatus.Done: case PeerPushPaymentInitiationStatus.AbortingDeletePurse: case PeerPushPaymentInitiationStatus.Aborted: // Do nothing break; default: assertUnreachable(pushDebitRec.status); } if (newStatus != null) { const oldTxState = computePeerPushDebitTransactionState(pushDebitRec); pushDebitRec.status = newStatus; const newTxState = computePeerPushDebitTransactionState(pushDebitRec); await tx.peerPushPaymentInitiations.put(pushDebitRec); return { oldTxState, newTxState, }; } return undefined; }); notifyTransition(ws, transactionId, transitionInfo); } export async function cancelAbortingPeerPushDebitTransaction( ws: InternalWalletState, pursePub: string, ) { const taskId = constructTaskIdentifier({ tag: PendingTaskType.PeerPushDebit, pursePub, }); const transactionId = constructTransactionIdentifier({ tag: TransactionType.PeerPushDebit, pursePub, }); stopLongpolling(ws, taskId); const transitionInfo = await ws.db .mktx((x) => [x.peerPushPaymentInitiations]) .runReadWrite(async (tx) => { const pushDebitRec = await tx.peerPushPaymentInitiations.get(pursePub); if (!pushDebitRec) { logger.warn(`peer push debit ${pursePub} not found`); return; } let newStatus: PeerPushPaymentInitiationStatus | undefined = undefined; switch (pushDebitRec.status) { case PeerPushPaymentInitiationStatus.AbortingRefresh: case PeerPushPaymentInitiationStatus.SuspendedAbortingRefresh: // FIXME: We also need to abort the refresh group! newStatus = PeerPushPaymentInitiationStatus.Aborted; break; case PeerPushPaymentInitiationStatus.AbortingDeletePurse: case PeerPushPaymentInitiationStatus.SuspendedAbortingDeletePurse: newStatus = PeerPushPaymentInitiationStatus.Aborted; break; case PeerPushPaymentInitiationStatus.PendingReady: case PeerPushPaymentInitiationStatus.SuspendedReady: case PeerPushPaymentInitiationStatus.SuspendedCreatePurse: case PeerPushPaymentInitiationStatus.PendingCreatePurse: case PeerPushPaymentInitiationStatus.Done: case PeerPushPaymentInitiationStatus.Aborted: // Do nothing break; default: assertUnreachable(pushDebitRec.status); } if (newStatus != null) { const oldTxState = computePeerPushDebitTransactionState(pushDebitRec); pushDebitRec.status = newStatus; const newTxState = computePeerPushDebitTransactionState(pushDebitRec); await tx.peerPushPaymentInitiations.put(pushDebitRec); return { oldTxState, newTxState, }; } return undefined; }); notifyTransition(ws, transactionId, transitionInfo); } export async function suspendPeerPushDebitTransaction( ws: InternalWalletState, pursePub: string, ) { const taskId = constructTaskIdentifier({ tag: PendingTaskType.PeerPushDebit, pursePub, }); const transactionId = constructTransactionIdentifier({ tag: TransactionType.PeerPushDebit, pursePub, }); stopLongpolling(ws, taskId); const transitionInfo = await ws.db .mktx((x) => [x.peerPushPaymentInitiations]) .runReadWrite(async (tx) => { const pushDebitRec = await tx.peerPushPaymentInitiations.get(pursePub); if (!pushDebitRec) { logger.warn(`peer push debit ${pursePub} not found`); return; } let newStatus: PeerPushPaymentInitiationStatus | undefined = undefined; switch (pushDebitRec.status) { case PeerPushPaymentInitiationStatus.PendingCreatePurse: newStatus = PeerPushPaymentInitiationStatus.SuspendedCreatePurse; break; case PeerPushPaymentInitiationStatus.AbortingRefresh: newStatus = PeerPushPaymentInitiationStatus.SuspendedAbortingRefresh; break; case PeerPushPaymentInitiationStatus.AbortingDeletePurse: newStatus = PeerPushPaymentInitiationStatus.SuspendedAbortingDeletePurse; break; case PeerPushPaymentInitiationStatus.PendingReady: newStatus = PeerPushPaymentInitiationStatus.SuspendedReady; break; case PeerPushPaymentInitiationStatus.SuspendedAbortingDeletePurse: case PeerPushPaymentInitiationStatus.SuspendedAbortingRefresh: case PeerPushPaymentInitiationStatus.SuspendedReady: case PeerPushPaymentInitiationStatus.SuspendedCreatePurse: case PeerPushPaymentInitiationStatus.Done: case PeerPushPaymentInitiationStatus.Aborted: // Do nothing break; default: assertUnreachable(pushDebitRec.status); } if (newStatus != null) { const oldTxState = computePeerPushDebitTransactionState(pushDebitRec); pushDebitRec.status = newStatus; const newTxState = computePeerPushDebitTransactionState(pushDebitRec); await tx.peerPushPaymentInitiations.put(pushDebitRec); return { oldTxState, newTxState, }; } return undefined; }); notifyTransition(ws, transactionId, transitionInfo); } export async function suspendPeerPullDebitTransaction( ws: InternalWalletState, peerPullPaymentIncomingId: string, ) { const taskId = constructTaskIdentifier({ tag: PendingTaskType.PeerPullDebit, peerPullPaymentIncomingId, }); const transactionId = constructTransactionIdentifier({ tag: TransactionType.PeerPullDebit, peerPullPaymentIncomingId, }); stopLongpolling(ws, taskId); const transitionInfo = await ws.db .mktx((x) => [x.peerPullPaymentIncoming]) .runReadWrite(async (tx) => { const pullDebitRec = await tx.peerPullPaymentIncoming.get( peerPullPaymentIncomingId, ); if (!pullDebitRec) { logger.warn(`peer pull debit ${peerPullPaymentIncomingId} not found`); return; } let newStatus: PeerPullDebitRecordStatus | undefined = undefined; switch (pullDebitRec.status) { case PeerPullDebitRecordStatus.DialogProposed: break; case PeerPullDebitRecordStatus.DonePaid: break; case PeerPullDebitRecordStatus.PendingDeposit: newStatus = PeerPullDebitRecordStatus.SuspendedDeposit; break; case PeerPullDebitRecordStatus.SuspendedDeposit: break; default: assertUnreachable(pullDebitRec.status); } if (newStatus != null) { const oldTxState = computePeerPullDebitTransactionState(pullDebitRec); pullDebitRec.status = newStatus; const newTxState = computePeerPullDebitTransactionState(pullDebitRec); await tx.peerPullPaymentIncoming.put(pullDebitRec); return { oldTxState, newTxState, }; } return undefined; }); notifyTransition(ws, transactionId, transitionInfo); } export async function resumePeerPullDebitTransaction( ws: InternalWalletState, peerPullPaymentIncomingId: string, ) { const taskId = constructTaskIdentifier({ tag: PendingTaskType.PeerPullDebit, peerPullPaymentIncomingId, }); const transactionId = constructTransactionIdentifier({ tag: TransactionType.PeerPullDebit, peerPullPaymentIncomingId, }); stopLongpolling(ws, taskId); const transitionInfo = await ws.db .mktx((x) => [x.peerPullPaymentIncoming]) .runReadWrite(async (tx) => { const pullDebitRec = await tx.peerPullPaymentIncoming.get( peerPullPaymentIncomingId, ); if (!pullDebitRec) { logger.warn(`peer pull debit ${peerPullPaymentIncomingId} not found`); return; } let newStatus: PeerPullDebitRecordStatus | undefined = undefined; switch (pullDebitRec.status) { case PeerPullDebitRecordStatus.DialogProposed: case PeerPullDebitRecordStatus.DonePaid: case PeerPullDebitRecordStatus.PendingDeposit: break; case PeerPullDebitRecordStatus.SuspendedDeposit: newStatus = PeerPullDebitRecordStatus.PendingDeposit; break; default: assertUnreachable(pullDebitRec.status); } if (newStatus != null) { const oldTxState = computePeerPullDebitTransactionState(pullDebitRec); pullDebitRec.status = newStatus; const newTxState = computePeerPullDebitTransactionState(pullDebitRec); await tx.peerPullPaymentIncoming.put(pullDebitRec); return { oldTxState, newTxState, }; } return undefined; }); ws.workAvailable.trigger(); notifyTransition(ws, transactionId, transitionInfo); } export async function suspendPeerPushCreditTransaction( ws: InternalWalletState, peerPushPaymentIncomingId: string, ) { const taskId = constructTaskIdentifier({ tag: PendingTaskType.PeerPushCredit, peerPushPaymentIncomingId, }); const transactionId = constructTransactionIdentifier({ tag: TransactionType.PeerPushCredit, peerPushPaymentIncomingId, }); stopLongpolling(ws, taskId); const transitionInfo = await ws.db .mktx((x) => [x.peerPushPaymentIncoming]) .runReadWrite(async (tx) => { const pushCreditRec = await tx.peerPushPaymentIncoming.get( peerPushPaymentIncomingId, ); if (!pushCreditRec) { logger.warn(`peer push credit ${peerPushPaymentIncomingId} not found`); return; } let newStatus: PeerPushPaymentIncomingStatus | undefined = undefined; switch (pushCreditRec.status) { case PeerPushPaymentIncomingStatus.DialogProposed: case PeerPushPaymentIncomingStatus.Done: case PeerPushPaymentIncomingStatus.SuspendedMerge: case PeerPushPaymentIncomingStatus.SuspendedMergeKycRequired: case PeerPushPaymentIncomingStatus.SuspendedWithdrawing: break; case PeerPushPaymentIncomingStatus.PendingMergeKycRequired: newStatus = PeerPushPaymentIncomingStatus.SuspendedMergeKycRequired; break; case PeerPushPaymentIncomingStatus.PendingMerge: newStatus = PeerPushPaymentIncomingStatus.SuspendedMerge; break; case PeerPushPaymentIncomingStatus.PendingWithdrawing: // FIXME: Suspend internal withdrawal transaction! newStatus = PeerPushPaymentIncomingStatus.SuspendedWithdrawing; break; default: assertUnreachable(pushCreditRec.status); } if (newStatus != null) { const oldTxState = computePeerPushCreditTransactionState(pushCreditRec); pushCreditRec.status = newStatus; const newTxState = computePeerPushCreditTransactionState(pushCreditRec); await tx.peerPushPaymentIncoming.put(pushCreditRec); return { oldTxState, newTxState, }; } return undefined; }); notifyTransition(ws, transactionId, transitionInfo); } export async function resumePeerPushCreditTransaction( ws: InternalWalletState, peerPushPaymentIncomingId: string, ) { const taskId = constructTaskIdentifier({ tag: PendingTaskType.PeerPushCredit, peerPushPaymentIncomingId, }); const transactionId = constructTransactionIdentifier({ tag: TransactionType.PeerPushCredit, peerPushPaymentIncomingId, }); stopLongpolling(ws, taskId); const transitionInfo = await ws.db .mktx((x) => [x.peerPushPaymentIncoming]) .runReadWrite(async (tx) => { const pushCreditRec = await tx.peerPushPaymentIncoming.get( peerPushPaymentIncomingId, ); if (!pushCreditRec) { logger.warn(`peer push credit ${peerPushPaymentIncomingId} not found`); return; } let newStatus: PeerPushPaymentIncomingStatus | undefined = undefined; switch (pushCreditRec.status) { case PeerPushPaymentIncomingStatus.DialogProposed: case PeerPushPaymentIncomingStatus.Done: case PeerPushPaymentIncomingStatus.PendingMergeKycRequired: case PeerPushPaymentIncomingStatus.PendingMerge: case PeerPushPaymentIncomingStatus.PendingWithdrawing: case PeerPushPaymentIncomingStatus.SuspendedMerge: newStatus = PeerPushPaymentIncomingStatus.PendingMerge; break; case PeerPushPaymentIncomingStatus.SuspendedMergeKycRequired: newStatus = PeerPushPaymentIncomingStatus.PendingMergeKycRequired; break; case PeerPushPaymentIncomingStatus.SuspendedWithdrawing: // FIXME: resume underlying "internal-withdrawal" transaction. newStatus = PeerPushPaymentIncomingStatus.PendingWithdrawing; break; default: assertUnreachable(pushCreditRec.status); } if (newStatus != null) { const oldTxState = computePeerPushCreditTransactionState(pushCreditRec); pushCreditRec.status = newStatus; const newTxState = computePeerPushCreditTransactionState(pushCreditRec); await tx.peerPushPaymentIncoming.put(pushCreditRec); return { oldTxState, newTxState, }; } return undefined; }); ws.workAvailable.trigger(); notifyTransition(ws, transactionId, transitionInfo); } export async function suspendPeerPullCreditTransaction( ws: InternalWalletState, pursePub: string, ) { const taskId = constructTaskIdentifier({ tag: PendingTaskType.PeerPullCredit, pursePub, }); const transactionId = constructTransactionIdentifier({ tag: TransactionType.PeerPullCredit, pursePub, }); stopLongpolling(ws, taskId); const transitionInfo = await ws.db .mktx((x) => [x.peerPullPaymentInitiations]) .runReadWrite(async (tx) => { const pullCreditRec = await tx.peerPullPaymentInitiations.get(pursePub); if (!pullCreditRec) { logger.warn(`peer pull credit ${pursePub} not found`); return; } let newStatus: PeerPullPaymentInitiationStatus | undefined = undefined; switch (pullCreditRec.status) { case PeerPullPaymentInitiationStatus.PendingCreatePurse: newStatus = PeerPullPaymentInitiationStatus.SuspendedCreatePurse; break; case PeerPullPaymentInitiationStatus.PendingMergeKycRequired: newStatus = PeerPullPaymentInitiationStatus.SuspendedMergeKycRequired; break; case PeerPullPaymentInitiationStatus.PendingWithdrawing: newStatus = PeerPullPaymentInitiationStatus.SuspendedWithdrawing; break; case PeerPullPaymentInitiationStatus.PendingReady: newStatus = PeerPullPaymentInitiationStatus.SuspendedReady; break; case PeerPullPaymentInitiationStatus.DonePurseDeposited: case PeerPullPaymentInitiationStatus.SuspendedCreatePurse: case PeerPullPaymentInitiationStatus.SuspendedMergeKycRequired: case PeerPullPaymentInitiationStatus.SuspendedReady: case PeerPullPaymentInitiationStatus.SuspendedWithdrawing: break; default: assertUnreachable(pullCreditRec.status); } if (newStatus != null) { const oldTxState = computePeerPullCreditTransactionState(pullCreditRec); pullCreditRec.status = newStatus; const newTxState = computePeerPullCreditTransactionState(pullCreditRec); await tx.peerPullPaymentInitiations.put(pullCreditRec); return { oldTxState, newTxState, }; } return undefined; }); notifyTransition(ws, transactionId, transitionInfo); } export async function resumePeerPullCreditTransaction( ws: InternalWalletState, pursePub: string, ) { const taskId = constructTaskIdentifier({ tag: PendingTaskType.PeerPullCredit, pursePub, }); const transactionId = constructTransactionIdentifier({ tag: TransactionType.PeerPullCredit, pursePub, }); stopLongpolling(ws, taskId); const transitionInfo = await ws.db .mktx((x) => [x.peerPullPaymentInitiations]) .runReadWrite(async (tx) => { const pullCreditRec = await tx.peerPullPaymentInitiations.get(pursePub); if (!pullCreditRec) { logger.warn(`peer pull credit ${pursePub} not found`); return; } let newStatus: PeerPullPaymentInitiationStatus | undefined = undefined; switch (pullCreditRec.status) { case PeerPullPaymentInitiationStatus.PendingCreatePurse: case PeerPullPaymentInitiationStatus.PendingMergeKycRequired: case PeerPullPaymentInitiationStatus.PendingWithdrawing: case PeerPullPaymentInitiationStatus.PendingReady: case PeerPullPaymentInitiationStatus.DonePurseDeposited: case PeerPullPaymentInitiationStatus.SuspendedCreatePurse: newStatus = PeerPullPaymentInitiationStatus.PendingCreatePurse; break; case PeerPullPaymentInitiationStatus.SuspendedMergeKycRequired: newStatus = PeerPullPaymentInitiationStatus.PendingMergeKycRequired; break; case PeerPullPaymentInitiationStatus.SuspendedReady: newStatus = PeerPullPaymentInitiationStatus.PendingReady; break; case PeerPullPaymentInitiationStatus.SuspendedWithdrawing: newStatus = PeerPullPaymentInitiationStatus.PendingWithdrawing; break; default: assertUnreachable(pullCreditRec.status); } if (newStatus != null) { const oldTxState = computePeerPullCreditTransactionState(pullCreditRec); pullCreditRec.status = newStatus; const newTxState = computePeerPullCreditTransactionState(pullCreditRec); await tx.peerPullPaymentInitiations.put(pullCreditRec); return { oldTxState, newTxState, }; } return undefined; }); ws.workAvailable.trigger(); notifyTransition(ws, transactionId, transitionInfo); } export async function resumePeerPushDebitTransaction( ws: InternalWalletState, pursePub: string, ) { const taskId = constructTaskIdentifier({ tag: PendingTaskType.PeerPushDebit, pursePub, }); const transactionId = constructTransactionIdentifier({ tag: TransactionType.PeerPushDebit, pursePub, }); stopLongpolling(ws, taskId); const transitionInfo = await ws.db .mktx((x) => [x.peerPushPaymentInitiations]) .runReadWrite(async (tx) => { const pushDebitRec = await tx.peerPushPaymentInitiations.get(pursePub); if (!pushDebitRec) { logger.warn(`peer push debit ${pursePub} not found`); return; } let newStatus: PeerPushPaymentInitiationStatus | undefined = undefined; switch (pushDebitRec.status) { case PeerPushPaymentInitiationStatus.SuspendedAbortingDeletePurse: newStatus = PeerPushPaymentInitiationStatus.AbortingDeletePurse; break; case PeerPushPaymentInitiationStatus.SuspendedAbortingRefresh: newStatus = PeerPushPaymentInitiationStatus.AbortingRefresh; break; case PeerPushPaymentInitiationStatus.SuspendedReady: newStatus = PeerPushPaymentInitiationStatus.PendingReady; break; case PeerPushPaymentInitiationStatus.SuspendedCreatePurse: newStatus = PeerPushPaymentInitiationStatus.PendingCreatePurse; break; case PeerPushPaymentInitiationStatus.PendingCreatePurse: case PeerPushPaymentInitiationStatus.AbortingRefresh: case PeerPushPaymentInitiationStatus.AbortingDeletePurse: case PeerPushPaymentInitiationStatus.PendingReady: case PeerPushPaymentInitiationStatus.Done: case PeerPushPaymentInitiationStatus.Aborted: // Do nothing break; default: assertUnreachable(pushDebitRec.status); } if (newStatus != null) { const oldTxState = computePeerPushDebitTransactionState(pushDebitRec); pushDebitRec.status = newStatus; const newTxState = computePeerPushDebitTransactionState(pushDebitRec); await tx.peerPushPaymentInitiations.put(pushDebitRec); return { oldTxState, newTxState, }; } return undefined; }); ws.workAvailable.trigger(); notifyTransition(ws, transactionId, transitionInfo); } export function computePeerPushCreditTransactionState( pushCreditRecord: PeerPushPaymentIncomingRecord, ): TransactionState { switch (pushCreditRecord.status) { case PeerPushPaymentIncomingStatus.DialogProposed: return { major: TransactionMajorState.Dialog, minor: TransactionMinorState.Proposed, }; case PeerPushPaymentIncomingStatus.PendingMerge: return { major: TransactionMajorState.Pending, minor: TransactionMinorState.Merge, }; case PeerPushPaymentIncomingStatus.Done: return { major: TransactionMajorState.Done, }; case PeerPushPaymentIncomingStatus.PendingMergeKycRequired: return { major: TransactionMajorState.Pending, minor: TransactionMinorState.KycRequired, }; case PeerPushPaymentIncomingStatus.PendingWithdrawing: return { major: TransactionMajorState.Pending, minor: TransactionMinorState.Withdraw, }; case PeerPushPaymentIncomingStatus.SuspendedMerge: return { major: TransactionMajorState.Suspended, minor: TransactionMinorState.Merge, }; case PeerPushPaymentIncomingStatus.SuspendedMergeKycRequired: return { major: TransactionMajorState.Suspended, minor: TransactionMinorState.MergeKycRequired, }; case PeerPushPaymentIncomingStatus.SuspendedWithdrawing: return { major: TransactionMajorState.Suspended, minor: TransactionMinorState.Withdraw, }; } } export function computePeerPullCreditTransactionState( pullCreditRecord: PeerPullPaymentInitiationRecord, ): TransactionState { switch (pullCreditRecord.status) { case PeerPullPaymentInitiationStatus.PendingCreatePurse: return { major: TransactionMajorState.Pending, minor: TransactionMinorState.CreatePurse, }; case PeerPullPaymentInitiationStatus.PendingMergeKycRequired: return { major: TransactionMajorState.Pending, minor: TransactionMinorState.MergeKycRequired, }; case PeerPullPaymentInitiationStatus.PendingReady: return { major: TransactionMajorState.Pending, minor: TransactionMinorState.Ready, }; case PeerPullPaymentInitiationStatus.DonePurseDeposited: return { major: TransactionMajorState.Done, }; case PeerPullPaymentInitiationStatus.PendingWithdrawing: return { major: TransactionMajorState.Pending, minor: TransactionMinorState.Withdraw, }; case PeerPullPaymentInitiationStatus.SuspendedCreatePurse: return { major: TransactionMajorState.Suspended, minor: TransactionMinorState.CreatePurse, }; case PeerPullPaymentInitiationStatus.SuspendedReady: return { major: TransactionMajorState.Suspended, minor: TransactionMinorState.Ready, }; case PeerPullPaymentInitiationStatus.SuspendedWithdrawing: return { major: TransactionMajorState.Pending, minor: TransactionMinorState.Withdraw, }; case PeerPullPaymentInitiationStatus.SuspendedMergeKycRequired: return { major: TransactionMajorState.Suspended, minor: TransactionMinorState.MergeKycRequired, }; } } export function computePeerPullDebitTransactionState( pullDebitRecord: PeerPullPaymentIncomingRecord, ): TransactionState { switch (pullDebitRecord.status) { case PeerPullDebitRecordStatus.DialogProposed: return { major: TransactionMajorState.Dialog, minor: TransactionMinorState.Proposed, }; case PeerPullDebitRecordStatus.PendingDeposit: return { major: TransactionMajorState.Pending, minor: TransactionMinorState.Deposit, }; case PeerPullDebitRecordStatus.DonePaid: return { major: TransactionMajorState.Done, }; case PeerPullDebitRecordStatus.SuspendedDeposit: return { major: TransactionMajorState.Suspended, minor: TransactionMinorState.Deposit, }; } }