diff options
author | Florian Dold <florian@dold.me> | 2023-06-05 11:45:16 +0200 |
---|---|---|
committer | Florian Dold <florian@dold.me> | 2023-06-05 11:45:16 +0200 |
commit | fda5a0ed87a6473a6b34bd1ac07d5f1d45dfbc19 (patch) | |
tree | c8b7b09ca441d2a01e340dd3f569e075d3ef278e /packages | |
parent | f3d4ff4e3a44141ad387ef68a9083b01bf1c818a (diff) | |
download | wallet-core-0.9.3-dev.14.tar.xz |
wallet-core: restructure p2p implv0.9.3-dev.14
Diffstat (limited to 'packages')
9 files changed, 3511 insertions, 3270 deletions
diff --git a/packages/taler-wallet-core/src/operations/pay-peer-common.ts b/packages/taler-wallet-core/src/operations/pay-peer-common.ts new file mode 100644 index 000000000..4b1dd31a5 --- /dev/null +++ b/packages/taler-wallet-core/src/operations/pay-peer-common.ts @@ -0,0 +1,463 @@ +/* + 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 <http://www.gnu.org/licenses/> + */ + +/** + * Imports. + */ +import { + AgeCommitmentProof, + AmountJson, + AmountString, + Amounts, + Codec, + CoinPublicKeyString, + CoinStatus, + Logger, + PayPeerInsufficientBalanceDetails, + TalerProtocolTimestamp, + UnblindedSignature, + buildCodecForObject, + codecForAmountString, + codecForTimestamp, + codecOptional, + strcmp, +} from "@gnu-taler/taler-util"; +import { SpendCoinDetails } from "../crypto/cryptoImplementation.js"; +import { + DenominationRecord, + PeerPushPaymentCoinSelection, + ReserveRecord, +} from "../db.js"; +import { InternalWalletState } from "../internal-wallet-state.js"; +import { checkDbInvariant } from "../util/invariants.js"; +import { getPeerPaymentBalanceDetailsInTx } from "./balance.js"; +import { getTotalRefreshCost } from "./refresh.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<SpendCoinDetails[]> { + 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 interface PeerCoinSelectionRequest { + instructedAmount: AmountJson; + + /** + * Instruct the coin selection to repair this coin + * selection instead of selecting completely new coins. + */ + repair?: { + exchangeBaseUrl: string; + coinPubs: CoinPublicKeyString[]; + contribs: AmountJson[]; + }; +} + +export async function selectPeerCoins( + ws: InternalWalletState, + req: PeerCoinSelectionRequest, +): Promise<SelectPeerCoinsResult> { + const instructedAmount = req.instructedAmount; + 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; + } + // FIXME: Can't we do this faster by using coinAvailability? + 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); + + if (req.repair) { + for (let i = 0; i < req.repair.coinPubs.length; i++) { + const contrib = req.repair.contribs[i]; + const coin = await tx.coins.get(req.repair.coinPubs[i]); + if (!coin) { + throw Error("repair not possible, coin not found"); + } + const denom = await ws.getDenomInfo( + ws, + tx, + coin.exchangeBaseUrl, + coin.denomPubHash, + ); + checkDbInvariant(!!denom); + resCoins.push({ + coinPriv: coin.coinPriv, + coinPub: coin.coinPub, + contribution: Amounts.stringify(contrib), + denomPubHash: coin.denomPubHash, + denomSig: coin.denomSig, + ageCommitmentProof: coin.ageCommitmentProof, + }); + const depositFee = Amounts.parseOrThrow(denom.feeDeposit); + lastDepositFee = depositFee; + amountAcc = Amounts.add( + amountAcc, + Amounts.sub(contrib, depositFee).amount, + ).amount; + depositFeesAcc = Amounts.add(depositFeesAcc, depositFee).amount; + } + } + + 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<AmountJson> { + 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; + }); +} + +interface ExchangePurseStatus { + balance: AmountString; + deposit_timestamp?: TalerProtocolTimestamp; + merge_timestamp?: TalerProtocolTimestamp; +} + +export const codecForExchangePurseStatus = (): Codec<ExchangePurseStatus> => + buildCodecForObject<ExchangePurseStatus>() + .property("balance", codecForAmountString()) + .property("deposit_timestamp", codecOptional(codecForTimestamp)) + .property("merge_timestamp", codecOptional(codecForTimestamp)) + .build("ExchangePurseStatus"); + +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}`; +} + +export async function getMergeReserveInfo( + ws: InternalWalletState, + req: { + exchangeBaseUrl: string; + }, +): Promise<ReserveRecord> { + // 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; +} diff --git a/packages/taler-wallet-core/src/operations/pay-peer-pull-credit.ts b/packages/taler-wallet-core/src/operations/pay-peer-pull-credit.ts new file mode 100644 index 000000000..b9c9728a1 --- /dev/null +++ b/packages/taler-wallet-core/src/operations/pay-peer-pull-credit.ts @@ -0,0 +1,910 @@ +/* + 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 <http://www.gnu.org/licenses/> + */ + +import { + AbsoluteTime, + Amounts, + CancellationToken, + CheckPeerPullCreditRequest, + CheckPeerPullCreditResponse, + ContractTermsUtil, + ExchangeReservePurseRequest, + HttpStatusCode, + InitiatePeerPullCreditRequest, + InitiatePeerPullCreditResponse, + Logger, + TalerPreciseTimestamp, + TransactionAction, + TransactionMajorState, + TransactionMinorState, + TransactionState, + TransactionType, + WalletAccountMergeFlags, + codecForAny, + codecForWalletKycUuid, + constructPayPullUri, + encodeCrock, + getRandomBytes, + j2s, +} from "@gnu-taler/taler-util"; +import { + readSuccessResponseJsonOrErrorCode, + readSuccessResponseJsonOrThrow, + throwUnexpectedRequestError, +} from "@gnu-taler/taler-util/http"; +import { + PeerPullPaymentInitiationRecord, + PeerPullPaymentInitiationStatus, + WithdrawalGroupStatus, + WithdrawalRecordType, + updateExchangeFromUrl, +} from "../index.js"; +import { InternalWalletState } from "../internal-wallet-state.js"; +import { PendingTaskType } from "../pending-types.js"; +import { assertUnreachable } from "../util/assertUnreachable.js"; +import { checkDbInvariant } from "../util/invariants.js"; +import { + OperationAttemptResult, + OperationAttemptResultType, + constructTaskIdentifier, +} from "../util/retries.js"; +import { + LongpollResult, + resetOperationTimeout, + runLongpollAsync, + runOperationWithErrorReporting, +} from "./common.js"; +import { + codecForExchangePurseStatus, + getMergeReserveInfo, + talerPaytoFromExchangeReserve, +} from "./pay-peer-common.js"; +import { + constructTransactionIdentifier, + notifyTransition, + stopLongpolling, +} from "./transactions.js"; +import { + checkWithdrawalKycStatus, + getExchangeWithdrawalInfo, + internalCreateWithdrawalGroup, + processWithdrawalGroup, +} from "./withdraw.js"; + +const logger = new Logger("pay-peer-pull-credit.ts"); + +export async function queryPurseForPeerPullCredit( + ws: InternalWalletState, + pullIni: PeerPullPaymentInitiationRecord, + cancellationToken: CancellationToken, +): Promise<LongpollResult> { + 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<OperationAttemptResult> { + 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); + }); + + return { + type: OperationAttemptResultType.Finished, + result: undefined, + }; +} + +/** + * Check fees and available exchanges for a peer push payment initiation. + */ +export async function checkPeerPullPaymentInitiation( + ws: InternalWalletState, + req: CheckPeerPullCreditRequest, +): Promise<CheckPeerPullCreditResponse> { + // 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, + }; +} + +/** + * Find a preferred exchange based on when we withdrew last from this exchange. + */ +async function getPreferredExchangeForCurrency( + ws: InternalWalletState, + currency: string, +): Promise<string | undefined> { + // 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; +} + +/** + * Initiate a peer pull payment. + */ +export async function initiatePeerPullPayment( + ws: InternalWalletState, + req: InitiatePeerPullCreditRequest, +): Promise<InitiatePeerPullCreditResponse> { + 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 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.AbortingDeletePurse: + newStatus = + PeerPullPaymentInitiationStatus.SuspendedAbortingDeletePurse; + break; + case PeerPullPaymentInitiationStatus.DonePurseDeposited: + case PeerPullPaymentInitiationStatus.SuspendedCreatePurse: + case PeerPullPaymentInitiationStatus.SuspendedMergeKycRequired: + case PeerPullPaymentInitiationStatus.SuspendedReady: + case PeerPullPaymentInitiationStatus.SuspendedWithdrawing: + case PeerPullPaymentInitiationStatus.Aborted: + case PeerPullPaymentInitiationStatus.Failed: + case PeerPullPaymentInitiationStatus.SuspendedAbortingDeletePurse: + 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 abortPeerPullCreditTransaction( + 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: + newStatus = PeerPullPaymentInitiationStatus.AbortingDeletePurse; + break; + case PeerPullPaymentInitiationStatus.PendingWithdrawing: + throw Error("can't abort anymore"); + case PeerPullPaymentInitiationStatus.PendingReady: + newStatus = PeerPullPaymentInitiationStatus.AbortingDeletePurse; + break; + case PeerPullPaymentInitiationStatus.DonePurseDeposited: + case PeerPullPaymentInitiationStatus.SuspendedCreatePurse: + case PeerPullPaymentInitiationStatus.SuspendedMergeKycRequired: + case PeerPullPaymentInitiationStatus.SuspendedReady: + case PeerPullPaymentInitiationStatus.SuspendedWithdrawing: + case PeerPullPaymentInitiationStatus.Aborted: + case PeerPullPaymentInitiationStatus.AbortingDeletePurse: + case PeerPullPaymentInitiationStatus.Failed: + case PeerPullPaymentInitiationStatus.SuspendedAbortingDeletePurse: + 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 failPeerPullCreditTransaction( + 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: + case PeerPullPaymentInitiationStatus.SuspendedMergeKycRequired: + case PeerPullPaymentInitiationStatus.SuspendedReady: + case PeerPullPaymentInitiationStatus.SuspendedWithdrawing: + case PeerPullPaymentInitiationStatus.Aborted: + case PeerPullPaymentInitiationStatus.Failed: + break; + case PeerPullPaymentInitiationStatus.AbortingDeletePurse: + case PeerPullPaymentInitiationStatus.SuspendedAbortingDeletePurse: + newStatus = PeerPullPaymentInitiationStatus.Failed; + 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.AbortingDeletePurse: + case PeerPullPaymentInitiationStatus.DonePurseDeposited: + case PeerPullPaymentInitiationStatus.Failed: + case PeerPullPaymentInitiationStatus.Aborted: + break; + 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; + case PeerPullPaymentInitiationStatus.SuspendedAbortingDeletePurse: + newStatus = PeerPullPaymentInitiationStatus.AbortingDeletePurse; + 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 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, + }; + case PeerPullPaymentInitiationStatus.Aborted: + return { + major: TransactionMajorState.Aborted, + }; + case PeerPullPaymentInitiationStatus.AbortingDeletePurse: + return { + major: TransactionMajorState.Aborting, + minor: TransactionMinorState.DeletePurse, + }; + case PeerPullPaymentInitiationStatus.Failed: + return { + major: TransactionMajorState.Failed, + }; + case PeerPullPaymentInitiationStatus.SuspendedAbortingDeletePurse: + return { + major: TransactionMajorState.Aborting, + minor: TransactionMinorState.DeletePurse, + }; + } +} + +export function computePeerPullCreditTransactionActions( + pullCreditRecord: PeerPullPaymentInitiationRecord, +): TransactionAction[] { + switch (pullCreditRecord.status) { + case PeerPullPaymentInitiationStatus.PendingCreatePurse: + return [TransactionAction.Abort, TransactionAction.Suspend]; + case PeerPullPaymentInitiationStatus.PendingMergeKycRequired: + return [TransactionAction.Abort, TransactionAction.Suspend]; + case PeerPullPaymentInitiationStatus.PendingReady: + return [TransactionAction.Abort, TransactionAction.Suspend]; + case PeerPullPaymentInitiationStatus.DonePurseDeposited: + return [TransactionAction.Delete]; + case PeerPullPaymentInitiationStatus.PendingWithdrawing: + return [TransactionAction.Abort, TransactionAction.Suspend]; + case PeerPullPaymentInitiationStatus.SuspendedCreatePurse: + return [TransactionAction.Resume, TransactionAction.Abort]; + case PeerPullPaymentInitiationStatus.SuspendedReady: + return [TransactionAction.Abort, TransactionAction.Resume]; + case PeerPullPaymentInitiationStatus.SuspendedWithdrawing: + return [TransactionAction.Resume, TransactionAction.Fail]; + case PeerPullPaymentInitiationStatus.SuspendedMergeKycRequired: + return [TransactionAction.Resume, TransactionAction.Fail]; + case PeerPullPaymentInitiationStatus.Aborted: + return [TransactionAction.Delete]; + case PeerPullPaymentInitiationStatus.AbortingDeletePurse: + return [TransactionAction.Suspend, TransactionAction.Fail]; + case PeerPullPaymentInitiationStatus.Failed: + return [TransactionAction.Delete]; + case PeerPullPaymentInitiationStatus.SuspendedAbortingDeletePurse: + return [TransactionAction.Resume, TransactionAction.Fail]; + } +} diff --git a/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts new file mode 100644 index 000000000..fdec42bbd --- /dev/null +++ b/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts @@ -0,0 +1,604 @@ +/* + 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 <http://www.gnu.org/licenses/> + */ + +import { + ConfirmPeerPullDebitRequest, + AcceptPeerPullPaymentResponse, + Amounts, + j2s, + TalerError, + TalerErrorCode, + TransactionType, + RefreshReason, + Logger, + PeerContractTerms, + PreparePeerPullDebitRequest, + PreparePeerPullDebitResponse, + TalerPreciseTimestamp, + codecForExchangeGetContractResponse, + codecForPeerContractTerms, + decodeCrock, + eddsaGetPublic, + encodeCrock, + getRandomBytes, + parsePayPullUri, + TransactionAction, + TransactionMajorState, + TransactionMinorState, + TransactionState, +} from "@gnu-taler/taler-util"; +import { + InternalWalletState, + PeerPullDebitRecordStatus, + PeerPullPaymentIncomingRecord, + PendingTaskType, +} from "../index.js"; +import { TaskIdentifiers, constructTaskIdentifier } from "../util/retries.js"; +import { spendCoins, runOperationWithErrorReporting } from "./common.js"; +import { + codecForExchangePurseStatus, + getTotalPeerPaymentCost, + selectPeerCoins, +} from "./pay-peer-common.js"; +import { processPeerPullDebit } from "./pay-peer-push-credit.js"; +import { + constructTransactionIdentifier, + notifyTransition, + stopLongpolling, +} from "./transactions.js"; +import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; +import { assertUnreachable } from "../util/assertUnreachable.js"; + +const logger = new Logger("pay-peer-pull-debit.ts"); + +export async function confirmPeerPullDebit( + ws: InternalWalletState, + req: ConfirmPeerPullDebitRequest, +): Promise<AcceptPeerPullPaymentResponse> { + 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<PreparePeerPullDebitResponse> { + 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 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; + case PeerPullDebitRecordStatus.Aborted: + break; + case PeerPullDebitRecordStatus.AbortingRefresh: + newStatus = PeerPullDebitRecordStatus.SuspendedAbortingRefresh; + break; + case PeerPullDebitRecordStatus.Failed: + break; + case PeerPullDebitRecordStatus.SuspendedAbortingRefresh: + 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 abortPeerPullDebitTransaction( + 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: + newStatus = PeerPullDebitRecordStatus.Aborted; + break; + case PeerPullDebitRecordStatus.DonePaid: + break; + case PeerPullDebitRecordStatus.PendingDeposit: + newStatus = PeerPullDebitRecordStatus.AbortingRefresh; + break; + case PeerPullDebitRecordStatus.SuspendedDeposit: + break; + case PeerPullDebitRecordStatus.Aborted: + break; + case PeerPullDebitRecordStatus.AbortingRefresh: + break; + case PeerPullDebitRecordStatus.Failed: + break; + case PeerPullDebitRecordStatus.SuspendedAbortingRefresh: + 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 failPeerPullDebitTransaction( + 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: + newStatus = PeerPullDebitRecordStatus.Aborted; + break; + case PeerPullDebitRecordStatus.DonePaid: + break; + case PeerPullDebitRecordStatus.PendingDeposit: + break; + case PeerPullDebitRecordStatus.SuspendedDeposit: + break; + case PeerPullDebitRecordStatus.Aborted: + break; + case PeerPullDebitRecordStatus.Failed: + break; + case PeerPullDebitRecordStatus.SuspendedAbortingRefresh: + case PeerPullDebitRecordStatus.AbortingRefresh: + // FIXME: abort underlying refresh! + newStatus = PeerPullDebitRecordStatus.Failed; + 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; + case PeerPullDebitRecordStatus.Aborted: + break; + case PeerPullDebitRecordStatus.AbortingRefresh: + break; + case PeerPullDebitRecordStatus.Failed: + break; + case PeerPullDebitRecordStatus.SuspendedAbortingRefresh: + newStatus = PeerPullDebitRecordStatus.AbortingRefresh; + 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 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, + }; + case PeerPullDebitRecordStatus.Aborted: + return { + major: TransactionMajorState.Aborted, + }; + case PeerPullDebitRecordStatus.AbortingRefresh: + return { + major: TransactionMajorState.Aborting, + minor: TransactionMinorState.Refresh, + }; + case PeerPullDebitRecordStatus.Failed: + return { + major: TransactionMajorState.Failed, + }; + case PeerPullDebitRecordStatus.SuspendedAbortingRefresh: + return { + major: TransactionMajorState.SuspendedAborting, + minor: TransactionMinorState.Refresh, + }; + } +} + +export function computePeerPullDebitTransactionActions( + pullDebitRecord: PeerPullPaymentIncomingRecord, +): TransactionAction[] { + switch (pullDebitRecord.status) { + case PeerPullDebitRecordStatus.DialogProposed: + return []; + case PeerPullDebitRecordStatus.PendingDeposit: + return [TransactionAction.Abort, TransactionAction.Suspend]; + case PeerPullDebitRecordStatus.DonePaid: + return [TransactionAction.Delete]; + case PeerPullDebitRecordStatus.SuspendedDeposit: + return [TransactionAction.Resume, TransactionAction.Abort]; + case PeerPullDebitRecordStatus.Aborted: + return [TransactionAction.Delete]; + case PeerPullDebitRecordStatus.AbortingRefresh: + return [TransactionAction.Fail, TransactionAction.Suspend]; + case PeerPullDebitRecordStatus.Failed: + return [TransactionAction.Delete]; + case PeerPullDebitRecordStatus.SuspendedAbortingRefresh: + return [TransactionAction.Resume, TransactionAction.Fail]; + } +} diff --git a/packages/taler-wallet-core/src/operations/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/operations/pay-peer-push-credit.ts new file mode 100644 index 000000000..69e0f3c27 --- /dev/null +++ b/packages/taler-wallet-core/src/operations/pay-peer-push-credit.ts @@ -0,0 +1,770 @@ +/* + 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 <http://www.gnu.org/licenses/> + */ + +import { + PreparePeerPushCredit, + PreparePeerPushCreditResponse, + parsePayPushUri, + codecForPeerContractTerms, + TransactionType, + encodeCrock, + eddsaGetPublic, + decodeCrock, + codecForExchangeGetContractResponse, + getRandomBytes, + ContractTermsUtil, + Amounts, + TalerPreciseTimestamp, + AcceptPeerPushPaymentResponse, + ConfirmPeerPushCreditRequest, + ExchangePurseMergeRequest, + HttpStatusCode, + PeerContractTerms, + TalerProtocolTimestamp, + WalletAccountMergeFlags, + codecForAny, + codecForWalletKycUuid, + j2s, + Logger, + ExchangePurseDeposits, + TransactionAction, + TransactionMajorState, + TransactionMinorState, + TransactionState, +} from "@gnu-taler/taler-util"; +import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; +import { + InternalWalletState, + PeerPullDebitRecordStatus, + PeerPushPaymentIncomingRecord, + PeerPushPaymentIncomingStatus, + PendingTaskType, + WithdrawalGroupStatus, + WithdrawalRecordType, +} from "../index.js"; +import { updateExchangeFromUrl } from "./exchanges.js"; +import { + codecForExchangePurseStatus, + getMergeReserveInfo, + queryCoinInfosForSelection, + talerPaytoFromExchangeReserve, +} from "./pay-peer-common.js"; +import { constructTransactionIdentifier, notifyTransition, stopLongpolling } from "./transactions.js"; +import { + checkWithdrawalKycStatus, + getExchangeWithdrawalInfo, + internalCreateWithdrawalGroup, +} from "./withdraw.js"; +import { checkDbInvariant } from "../util/invariants.js"; +import { + OperationAttemptResult, + OperationAttemptResultType, + constructTaskIdentifier, +} from "../util/retries.js"; +import { assertUnreachable } from "../util/assertUnreachable.js"; + +const logger = new Logger("pay-peer-push-credit.ts"); + +export async function preparePeerPushCredit( + ws: InternalWalletState, + req: PreparePeerPushCredit, +): Promise<PreparePeerPushCreditResponse> { + 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 async function processPeerPushCredit( + ws: InternalWalletState, + peerPushPaymentIncomingId: string, +): Promise<OperationAttemptResult> { + 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<AcceptPeerPushPaymentResponse> { + 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<OperationAttemptResult> { + 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 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; + case PeerPushPaymentIncomingStatus.Aborted: + break; + case PeerPushPaymentIncomingStatus.Failed: + 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 abortPeerPushCreditTransaction( + 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: + newStatus = PeerPushPaymentIncomingStatus.Aborted; + break; + case PeerPushPaymentIncomingStatus.Done: + break; + case PeerPushPaymentIncomingStatus.SuspendedMerge: + case PeerPushPaymentIncomingStatus.SuspendedMergeKycRequired: + case PeerPushPaymentIncomingStatus.SuspendedWithdrawing: + newStatus = PeerPushPaymentIncomingStatus.Aborted; + break; + case PeerPushPaymentIncomingStatus.PendingMergeKycRequired: + newStatus = PeerPushPaymentIncomingStatus.Aborted; + break; + case PeerPushPaymentIncomingStatus.PendingMerge: + newStatus = PeerPushPaymentIncomingStatus.Aborted; + break; + case PeerPushPaymentIncomingStatus.PendingWithdrawing: + newStatus = PeerPushPaymentIncomingStatus.Aborted; + break; + case PeerPushPaymentIncomingStatus.Aborted: + break; + case PeerPushPaymentIncomingStatus.Failed: + 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 failPeerPushCreditTransaction( + ws: InternalWalletState, + peerPushPaymentIncomingId: string, +) { + // We don't have any "aborting" states! + throw Error("can't run cancel-aborting on peer-push-credit transaction"); +} + +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; + case PeerPushPaymentIncomingStatus.Aborted: + break; + case PeerPushPaymentIncomingStatus.Failed: + 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 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, + }; + case PeerPushPaymentIncomingStatus.Aborted: + return { + major: TransactionMajorState.Aborted, + }; + case PeerPushPaymentIncomingStatus.Failed: + return { + major: TransactionMajorState.Failed, + }; + default: + assertUnreachable(pushCreditRecord.status); + } +} + +export function computePeerPushCreditTransactionActions( + pushCreditRecord: PeerPushPaymentIncomingRecord, +): TransactionAction[] { + switch (pushCreditRecord.status) { + case PeerPushPaymentIncomingStatus.DialogProposed: + return []; + case PeerPushPaymentIncomingStatus.PendingMerge: + return [TransactionAction.Abort, TransactionAction.Suspend]; + case PeerPushPaymentIncomingStatus.Done: + return [TransactionAction.Delete]; + case PeerPushPaymentIncomingStatus.PendingMergeKycRequired: + return [TransactionAction.Abort, TransactionAction.Suspend]; + case PeerPushPaymentIncomingStatus.PendingWithdrawing: + return [TransactionAction.Suspend, TransactionAction.Fail]; + case PeerPushPaymentIncomingStatus.SuspendedMerge: + return [TransactionAction.Resume, TransactionAction.Abort]; + case PeerPushPaymentIncomingStatus.SuspendedMergeKycRequired: + return [TransactionAction.Resume, TransactionAction.Abort]; + case PeerPushPaymentIncomingStatus.SuspendedWithdrawing: + return [TransactionAction.Resume, TransactionAction.Fail]; + case PeerPushPaymentIncomingStatus.Aborted: + return [TransactionAction.Delete]; + case PeerPushPaymentIncomingStatus.Failed: + return [TransactionAction.Delete]; + default: + assertUnreachable(pushCreditRecord.status); + } +}
\ No newline at end of file diff --git a/packages/taler-wallet-core/src/operations/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/operations/pay-peer-push-debit.ts new file mode 100644 index 000000000..dead6313d --- /dev/null +++ b/packages/taler-wallet-core/src/operations/pay-peer-push-debit.ts @@ -0,0 +1,742 @@ +/* + 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 <http://www.gnu.org/licenses/> + */ + +import { + Amounts, + CheckPeerPushDebitRequest, + CheckPeerPushDebitResponse, + ContractTermsUtil, + HttpStatusCode, + InitiatePeerPushDebitRequest, + InitiatePeerPushDebitResponse, + Logger, + RefreshReason, + TalerError, + TalerErrorCode, + TalerPreciseTimestamp, + TransactionAction, + TransactionMajorState, + TransactionMinorState, + TransactionState, + TransactionType, + constructPayPushUri, + j2s, +} from "@gnu-taler/taler-util"; +import { InternalWalletState } from "../internal-wallet-state.js"; +import { + selectPeerCoins, + getTotalPeerPaymentCost, + codecForExchangePurseStatus, + queryCoinInfosForSelection, +} from "./pay-peer-common.js"; +import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; +import { + PeerPushPaymentInitiationRecord, + PeerPushPaymentInitiationStatus, +} from "../index.js"; +import { PendingTaskType } from "../pending-types.js"; +import { + OperationAttemptResult, + OperationAttemptResultType, + constructTaskIdentifier, +} from "../util/retries.js"; +import { + runLongpollAsync, + spendCoins, + runOperationWithErrorReporting, +} from "./common.js"; +import { + constructTransactionIdentifier, + notifyTransition, + stopLongpolling, +} from "./transactions.js"; +import { assertUnreachable } from "../util/assertUnreachable.js"; + +const logger = new Logger("pay-peer-push-debit.ts"); + +export async function checkPeerPushDebit( + ws: InternalWalletState, + req: CheckPeerPushDebitRequest, +): Promise<CheckPeerPushDebitResponse> { + 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<OperationAttemptResult> { + 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<void> { + 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<OperationAttemptResult> { + 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 peer-push-debit for long-polling in background", + ); + return { + type: OperationAttemptResultType.Longpoll, + }; +} + +export async function processPeerPushDebit( + ws: InternalWalletState, + pursePub: string, +): Promise<OperationAttemptResult> { + 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<InitiatePeerPushDebitResponse> { + 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, + }), + }; +} + +export function computePeerPushDebitTransactionActions( + ppiRecord: PeerPushPaymentInitiationRecord, +): TransactionAction[] { + switch (ppiRecord.status) { + case PeerPushPaymentInitiationStatus.PendingCreatePurse: + return [TransactionAction.Abort, TransactionAction.Suspend]; + case PeerPushPaymentInitiationStatus.PendingReady: + return [TransactionAction.Abort, TransactionAction.Suspend]; + case PeerPushPaymentInitiationStatus.Aborted: + return [TransactionAction.Delete]; + case PeerPushPaymentInitiationStatus.AbortingDeletePurse: + return [TransactionAction.Suspend, TransactionAction.Fail]; + case PeerPushPaymentInitiationStatus.AbortingRefresh: + return [TransactionAction.Suspend, TransactionAction.Fail]; + case PeerPushPaymentInitiationStatus.SuspendedAbortingDeletePurse: + return [TransactionAction.Resume, TransactionAction.Fail]; + case PeerPushPaymentInitiationStatus.SuspendedAbortingRefresh: + return [TransactionAction.Resume, TransactionAction.Fail]; + case PeerPushPaymentInitiationStatus.SuspendedCreatePurse: + return [TransactionAction.Resume, TransactionAction.Abort]; + case PeerPushPaymentInitiationStatus.SuspendedReady: + return [TransactionAction.Suspend, TransactionAction.Abort]; + case PeerPushPaymentInitiationStatus.Done: + return [TransactionAction.Delete]; + case PeerPushPaymentInitiationStatus.Failed: + return [TransactionAction.Delete]; + } +} + +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; + case PeerPushPaymentInitiationStatus.Failed: + 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 failPeerPushDebitTransaction( + 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: + case PeerPushPaymentInitiationStatus.Failed: + // 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: + case PeerPushPaymentInitiationStatus.Failed: + // 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 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: + case PeerPushPaymentInitiationStatus.Failed: + // 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 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, + }; + case PeerPushPaymentInitiationStatus.Failed: + return { + major: TransactionMajorState.Failed, + }; + } +}
\ No newline at end of file diff --git a/packages/taler-wallet-core/src/operations/pay-peer.ts b/packages/taler-wallet-core/src/operations/pay-peer.ts deleted file mode 100644 index 28fef6afc..000000000 --- a/packages/taler-wallet-core/src/operations/pay-peer.ts +++ /dev/null @@ -1,3226 +0,0 @@ -/* - 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 <http://www.gnu.org/licenses/> - */ - -/** - * 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, - TransactionAction, -} 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<SpendCoinDetails[]> { - 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<SelectPeerCoinsResult> { - 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<AmountJson> { - 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<CheckPeerPushDebitResponse> { - 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<OperationAttemptResult> { - 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<void> { - 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<OperationAttemptResult> { - 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<OperationAttemptResult> { - 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<InitiatePeerPushDebitResponse> { - 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<ExchangePurseStatus> => - buildCodecForObject<ExchangePurseStatus>() - .property("balance", codecForAmountString()) - .property("deposit_timestamp", codecOptional(codecForTimestamp)) - .property("merge_timestamp", codecOptional(codecForTimestamp)) - .build("ExchangePurseStatus"); - -export async function preparePeerPushCredit( - ws: InternalWalletState, - req: PreparePeerPushCredit, -): Promise<PreparePeerPushCreditResponse> { - 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<ReserveRecord> { - // 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<OperationAttemptResult> { - 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<AcceptPeerPushPaymentResponse> { - 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<OperationAttemptResult> { - 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<AcceptPeerPullPaymentResponse> { - 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<PreparePeerPullDebitResponse> { - 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<LongpollResult> { - 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<OperationAttemptResult> { - 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); - }); - - 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<string | undefined> { - // 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<CheckPeerPullCreditResponse> { - // 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<InitiatePeerPullCreditResponse> { - 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, - }; - case PeerPushPaymentInitiationStatus.Failed: - return { - major: TransactionMajorState.Failed, - }; - } -} - -export function computePeerPushDebitTransactionActions( - ppiRecord: PeerPushPaymentInitiationRecord, -): TransactionAction[] { - switch (ppiRecord.status) { - case PeerPushPaymentInitiationStatus.PendingCreatePurse: - return [TransactionAction.Abort, TransactionAction.Suspend]; - case PeerPushPaymentInitiationStatus.PendingReady: - return [TransactionAction.Abort, TransactionAction.Suspend]; - case PeerPushPaymentInitiationStatus.Aborted: - return [TransactionAction.Delete]; - case PeerPushPaymentInitiationStatus.AbortingDeletePurse: - return [TransactionAction.Suspend, TransactionAction.Fail]; - case PeerPushPaymentInitiationStatus.AbortingRefresh: - return [TransactionAction.Suspend, TransactionAction.Fail]; - case PeerPushPaymentInitiationStatus.SuspendedAbortingDeletePurse: - return [TransactionAction.Resume, TransactionAction.Fail]; - case PeerPushPaymentInitiationStatus.SuspendedAbortingRefresh: - return [TransactionAction.Resume, TransactionAction.Fail]; - case PeerPushPaymentInitiationStatus.SuspendedCreatePurse: - return [TransactionAction.Resume, TransactionAction.Abort]; - case PeerPushPaymentInitiationStatus.SuspendedReady: - return [TransactionAction.Suspend, TransactionAction.Abort]; - case PeerPushPaymentInitiationStatus.Done: - return [TransactionAction.Delete]; - case PeerPushPaymentInitiationStatus.Failed: - return [TransactionAction.Delete]; - } -} - -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; - case PeerPushPaymentInitiationStatus.Failed: - 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 failPeerPushDebitTransaction( - 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: - case PeerPushPaymentInitiationStatus.Failed: - // 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: - case PeerPushPaymentInitiationStatus.Failed: - // 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; - case PeerPullDebitRecordStatus.Aborted: - break; - case PeerPullDebitRecordStatus.AbortingRefresh: - newStatus = PeerPullDebitRecordStatus.SuspendedAbortingRefresh; - break; - case PeerPullDebitRecordStatus.Failed: - break; - case PeerPullDebitRecordStatus.SuspendedAbortingRefresh: - 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 abortPeerPullDebitTransaction( - 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: - newStatus = PeerPullDebitRecordStatus.Aborted; - break; - case PeerPullDebitRecordStatus.DonePaid: - break; - case PeerPullDebitRecordStatus.PendingDeposit: - newStatus = PeerPullDebitRecordStatus.AbortingRefresh; - break; - case PeerPullDebitRecordStatus.SuspendedDeposit: - break; - case PeerPullDebitRecordStatus.Aborted: - break; - case PeerPullDebitRecordStatus.AbortingRefresh: - break; - case PeerPullDebitRecordStatus.Failed: - break; - case PeerPullDebitRecordStatus.SuspendedAbortingRefresh: - 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 failPeerPullDebitTransaction( - 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: - newStatus = PeerPullDebitRecordStatus.Aborted; - break; - case PeerPullDebitRecordStatus.DonePaid: - break; - case PeerPullDebitRecordStatus.PendingDeposit: - break; - case PeerPullDebitRecordStatus.SuspendedDeposit: - break; - case PeerPullDebitRecordStatus.Aborted: - break; - case PeerPullDebitRecordStatus.Failed: - break; - case PeerPullDebitRecordStatus.SuspendedAbortingRefresh: - case PeerPullDebitRecordStatus.AbortingRefresh: - // FIXME: abort underlying refresh! - newStatus = PeerPullDebitRecordStatus.Failed; - 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; - case PeerPullDebitRecordStatus.Aborted: - break; - case PeerPullDebitRecordStatus.AbortingRefresh: - break; - case PeerPullDebitRecordStatus.Failed: - break; - case PeerPullDebitRecordStatus.SuspendedAbortingRefresh: - newStatus = PeerPullDebitRecordStatus.AbortingRefresh; - 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; - case PeerPushPaymentIncomingStatus.Aborted: - break; - case PeerPushPaymentIncomingStatus.Failed: - 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 abortPeerPushCreditTransaction( - 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: - newStatus = PeerPushPaymentIncomingStatus.Aborted; - break; - case PeerPushPaymentIncomingStatus.Done: - break; - case PeerPushPaymentIncomingStatus.SuspendedMerge: - case PeerPushPaymentIncomingStatus.SuspendedMergeKycRequired: - case PeerPushPaymentIncomingStatus.SuspendedWithdrawing: - newStatus = PeerPushPaymentIncomingStatus.Aborted; - break; - case PeerPushPaymentIncomingStatus.PendingMergeKycRequired: - newStatus = PeerPushPaymentIncomingStatus.Aborted; - break; - case PeerPushPaymentIncomingStatus.PendingMerge: - newStatus = PeerPushPaymentIncomingStatus.Aborted; - break; - case PeerPushPaymentIncomingStatus.PendingWithdrawing: - newStatus = PeerPushPaymentIncomingStatus.Aborted; - break; - case PeerPushPaymentIncomingStatus.Aborted: - break; - case PeerPushPaymentIncomingStatus.Failed: - 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 failPeerPushCreditTransaction( - ws: InternalWalletState, - peerPushPaymentIncomingId: string, -) { - // We don't have any "aborting" states! - throw Error("can't run cancel-aborting on peer-push-credit transaction"); -} - -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; - case PeerPushPaymentIncomingStatus.Aborted: - break; - case PeerPushPaymentIncomingStatus.Failed: - 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.AbortingDeletePurse: - newStatus = - PeerPullPaymentInitiationStatus.SuspendedAbortingDeletePurse; - break; - case PeerPullPaymentInitiationStatus.DonePurseDeposited: - case PeerPullPaymentInitiationStatus.SuspendedCreatePurse: - case PeerPullPaymentInitiationStatus.SuspendedMergeKycRequired: - case PeerPullPaymentInitiationStatus.SuspendedReady: - case PeerPullPaymentInitiationStatus.SuspendedWithdrawing: - case PeerPullPaymentInitiationStatus.Aborted: - case PeerPullPaymentInitiationStatus.Failed: - case PeerPullPaymentInitiationStatus.SuspendedAbortingDeletePurse: - 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 abortPeerPullCreditTransaction( - 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: - newStatus = PeerPullPaymentInitiationStatus.AbortingDeletePurse; - break; - case PeerPullPaymentInitiationStatus.PendingWithdrawing: - throw Error("can't abort anymore"); - case PeerPullPaymentInitiationStatus.PendingReady: - newStatus = PeerPullPaymentInitiationStatus.AbortingDeletePurse; - break; - case PeerPullPaymentInitiationStatus.DonePurseDeposited: - case PeerPullPaymentInitiationStatus.SuspendedCreatePurse: - case PeerPullPaymentInitiationStatus.SuspendedMergeKycRequired: - case PeerPullPaymentInitiationStatus.SuspendedReady: - case PeerPullPaymentInitiationStatus.SuspendedWithdrawing: - case PeerPullPaymentInitiationStatus.Aborted: - case PeerPullPaymentInitiationStatus.AbortingDeletePurse: - case PeerPullPaymentInitiationStatus.Failed: - case PeerPullPaymentInitiationStatus.SuspendedAbortingDeletePurse: - 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 failPeerPullCreditTransaction( - 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: - case PeerPullPaymentInitiationStatus.SuspendedMergeKycRequired: - case PeerPullPaymentInitiationStatus.SuspendedReady: - case PeerPullPaymentInitiationStatus.SuspendedWithdrawing: - case PeerPullPaymentInitiationStatus.Aborted: - case PeerPullPaymentInitiationStatus.Failed: - break; - case PeerPullPaymentInitiationStatus.AbortingDeletePurse: - case PeerPullPaymentInitiationStatus.SuspendedAbortingDeletePurse: - newStatus = PeerPullPaymentInitiationStatus.Failed; - 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.AbortingDeletePurse: - case PeerPullPaymentInitiationStatus.DonePurseDeposited: - case PeerPullPaymentInitiationStatus.Failed: - case PeerPullPaymentInitiationStatus.Aborted: - break; - 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; - case PeerPullPaymentInitiationStatus.SuspendedAbortingDeletePurse: - newStatus = PeerPullPaymentInitiationStatus.AbortingDeletePurse; - 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: - case PeerPushPaymentInitiationStatus.Failed: - // 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, - }; - case PeerPushPaymentIncomingStatus.Aborted: - return { - major: TransactionMajorState.Aborted, - }; - case PeerPushPaymentIncomingStatus.Failed: - return { - major: TransactionMajorState.Failed, - }; - default: - assertUnreachable(pushCreditRecord.status); - } -} - -export function computePeerPushCreditTransactionActions( - pushCreditRecord: PeerPushPaymentIncomingRecord, -): TransactionAction[] { - switch (pushCreditRecord.status) { - case PeerPushPaymentIncomingStatus.DialogProposed: - return []; - case PeerPushPaymentIncomingStatus.PendingMerge: - return [TransactionAction.Abort, TransactionAction.Suspend]; - case PeerPushPaymentIncomingStatus.Done: - return [TransactionAction.Delete]; - case PeerPushPaymentIncomingStatus.PendingMergeKycRequired: - return [TransactionAction.Abort, TransactionAction.Suspend]; - case PeerPushPaymentIncomingStatus.PendingWithdrawing: - return [TransactionAction.Suspend, TransactionAction.Fail]; - case PeerPushPaymentIncomingStatus.SuspendedMerge: - return [TransactionAction.Resume, TransactionAction.Abort]; - case PeerPushPaymentIncomingStatus.SuspendedMergeKycRequired: - return [TransactionAction.Resume, TransactionAction.Abort]; - case PeerPushPaymentIncomingStatus.SuspendedWithdrawing: - return [TransactionAction.Resume, TransactionAction.Fail]; - case PeerPushPaymentIncomingStatus.Aborted: - return [TransactionAction.Delete]; - case PeerPushPaymentIncomingStatus.Failed: - return [TransactionAction.Delete]; - default: - assertUnreachable(pushCreditRecord.status); - } -} - -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, - }; - case PeerPullPaymentInitiationStatus.Aborted: - return { - major: TransactionMajorState.Aborted, - }; - case PeerPullPaymentInitiationStatus.AbortingDeletePurse: - return { - major: TransactionMajorState.Aborting, - minor: TransactionMinorState.DeletePurse, - }; - case PeerPullPaymentInitiationStatus.Failed: - return { - major: TransactionMajorState.Failed, - }; - case PeerPullPaymentInitiationStatus.SuspendedAbortingDeletePurse: - return { - major: TransactionMajorState.Aborting, - minor: TransactionMinorState.DeletePurse, - }; - } -} - -export function computePeerPullCreditTransactionActions( - pullCreditRecord: PeerPullPaymentInitiationRecord, -): TransactionAction[] { - switch (pullCreditRecord.status) { - case PeerPullPaymentInitiationStatus.PendingCreatePurse: - return [TransactionAction.Abort, TransactionAction.Suspend]; - case PeerPullPaymentInitiationStatus.PendingMergeKycRequired: - return [TransactionAction.Abort, TransactionAction.Suspend]; - case PeerPullPaymentInitiationStatus.PendingReady: - return [TransactionAction.Abort, TransactionAction.Suspend]; - case PeerPullPaymentInitiationStatus.DonePurseDeposited: - return [TransactionAction.Delete]; - case PeerPullPaymentInitiationStatus.PendingWithdrawing: - return [TransactionAction.Abort, TransactionAction.Suspend]; - case PeerPullPaymentInitiationStatus.SuspendedCreatePurse: - return [TransactionAction.Resume, TransactionAction.Abort]; - case PeerPullPaymentInitiationStatus.SuspendedReady: - return [TransactionAction.Abort, TransactionAction.Resume]; - case PeerPullPaymentInitiationStatus.SuspendedWithdrawing: - return [TransactionAction.Resume, TransactionAction.Fail]; - case PeerPullPaymentInitiationStatus.SuspendedMergeKycRequired: - return [TransactionAction.Resume, TransactionAction.Fail]; - case PeerPullPaymentInitiationStatus.Aborted: - return [TransactionAction.Delete]; - case PeerPullPaymentInitiationStatus.AbortingDeletePurse: - return [TransactionAction.Suspend, TransactionAction.Fail]; - case PeerPullPaymentInitiationStatus.Failed: - return [TransactionAction.Delete]; - case PeerPullPaymentInitiationStatus.SuspendedAbortingDeletePurse: - return [TransactionAction.Resume, TransactionAction.Fail]; - } -} - -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, - }; - case PeerPullDebitRecordStatus.Aborted: - return { - major: TransactionMajorState.Aborted, - }; - case PeerPullDebitRecordStatus.AbortingRefresh: - return { - major: TransactionMajorState.Aborting, - minor: TransactionMinorState.Refresh, - }; - case PeerPullDebitRecordStatus.Failed: - return { - major: TransactionMajorState.Failed, - }; - case PeerPullDebitRecordStatus.SuspendedAbortingRefresh: - return { - major: TransactionMajorState.SuspendedAborting, - minor: TransactionMinorState.Refresh, - }; - } -} - -export function computePeerPullDebitTransactionActions( - pullDebitRecord: PeerPullPaymentIncomingRecord, -): TransactionAction[] { - switch (pullDebitRecord.status) { - case PeerPullDebitRecordStatus.DialogProposed: - return []; - case PeerPullDebitRecordStatus.PendingDeposit: - return [TransactionAction.Abort, TransactionAction.Suspend]; - case PeerPullDebitRecordStatus.DonePaid: - return [TransactionAction.Delete]; - case PeerPullDebitRecordStatus.SuspendedDeposit: - return [TransactionAction.Resume, TransactionAction.Abort]; - case PeerPullDebitRecordStatus.Aborted: - return [TransactionAction.Delete]; - case PeerPullDebitRecordStatus.AbortingRefresh: - return [TransactionAction.Fail, TransactionAction.Suspend]; - case PeerPullDebitRecordStatus.Failed: - return [TransactionAction.Delete]; - case PeerPullDebitRecordStatus.SuspendedAbortingRefresh: - return [TransactionAction.Resume, TransactionAction.Fail]; - } -} diff --git a/packages/taler-wallet-core/src/operations/testing.ts b/packages/taler-wallet-core/src/operations/testing.ts index ef5aa907d..238a5dc66 100644 --- a/packages/taler-wallet-core/src/operations/testing.ts +++ b/packages/taler-wallet-core/src/operations/testing.ts @@ -50,14 +50,10 @@ import { getBalances } from "./balance.js"; import { checkLogicInvariant } from "../util/invariants.js"; import { acceptWithdrawalFromUri } from "./withdraw.js"; import { updateExchangeFromUrl } from "./exchanges.js"; -import { - confirmPeerPullDebit, - confirmPeerPushCredit, - initiatePeerPullPayment, - initiatePeerPushDebit, - preparePeerPullDebit, - preparePeerPushCredit, -} from "./pay-peer.js"; +import { initiatePeerPullPayment } from "./pay-peer-pull-credit.js"; +import { preparePeerPullDebit, confirmPeerPullDebit } from "./pay-peer-pull-debit.js"; +import { preparePeerPushCredit, confirmPeerPushCredit } from "./pay-peer-push-credit.js"; +import { initiatePeerPushDebit } from "./pay-peer-push-debit.js"; const logger = new Logger("operations/testing.ts"); diff --git a/packages/taler-wallet-core/src/operations/transactions.ts b/packages/taler-wallet-core/src/operations/transactions.ts index a0da95799..1bd024d28 100644 --- a/packages/taler-wallet-core/src/operations/transactions.ts +++ b/packages/taler-wallet-core/src/operations/transactions.ts @@ -93,32 +93,6 @@ import { computePayMerchantTransactionActions, } from "./pay-merchant.js"; import { - abortPeerPullCreditTransaction, - abortPeerPullDebitTransaction, - abortPeerPushCreditTransaction, - abortPeerPushDebitTransaction, - failPeerPullCreditTransaction, - failPeerPullDebitTransaction, - failPeerPushCreditTransaction, - failPeerPushDebitTransaction, - computePeerPullCreditTransactionState, - computePeerPullDebitTransactionState, - computePeerPushCreditTransactionState, - computePeerPushDebitTransactionState, - resumePeerPullCreditTransaction, - resumePeerPullDebitTransaction, - resumePeerPushCreditTransaction, - resumePeerPushDebitTransaction, - suspendPeerPullCreditTransaction, - suspendPeerPullDebitTransaction, - suspendPeerPushCreditTransaction, - suspendPeerPushDebitTransaction, - computePeerPushDebitTransactionActions, - computePeerPullDebitTransactionActions, - computePeerPullCreditTransactionActions, - computePeerPushCreditTransactionActions, -} from "./pay-peer.js"; -import { abortRefreshGroup, failRefreshGroup, computeRefreshTransactionState, @@ -143,6 +117,10 @@ import { suspendWithdrawalTransaction, computeWithdrawalTransactionActions, } from "./withdraw.js"; +import { computePeerPullCreditTransactionState, computePeerPullCreditTransactionActions, suspendPeerPullCreditTransaction, failPeerPullCreditTransaction, resumePeerPullCreditTransaction, abortPeerPullCreditTransaction } from "./pay-peer-pull-credit.js"; +import { computePeerPullDebitTransactionState, computePeerPullDebitTransactionActions, suspendPeerPullDebitTransaction, failPeerPullDebitTransaction, resumePeerPullDebitTransaction, abortPeerPullDebitTransaction } from "./pay-peer-pull-debit.js"; +import { computePeerPushCreditTransactionState, computePeerPushCreditTransactionActions, suspendPeerPushCreditTransaction, failPeerPushCreditTransaction, resumePeerPushCreditTransaction, abortPeerPushCreditTransaction } from "./pay-peer-push-credit.js"; +import { computePeerPushDebitTransactionState, computePeerPushDebitTransactionActions, suspendPeerPushDebitTransaction, failPeerPushDebitTransaction, resumePeerPushDebitTransaction, abortPeerPushDebitTransaction } from "./pay-peer-push-debit.js"; const logger = new Logger("taler-wallet-core:transactions.ts"); diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index df48c0e19..d0c34588b 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -63,8 +63,6 @@ import { codecForAddKnownBankAccounts, codecForAny, codecForApplyDevExperiment, - codecForApplyRefundFromPurchaseIdRequest, - codecForApplyRefundRequest, codecForCancelAbortingTransactionRequest, codecForCheckPeerPullPaymentRequest, codecForCheckPeerPushDebitRequest, @@ -196,22 +194,29 @@ import { getContractTermsDetails, preparePayForUri, processPurchase, + startQueryRefund, startRefundQueryForUri, } from "./operations/pay-merchant.js"; import { checkPeerPullPaymentInitiation, - checkPeerPushDebit, - confirmPeerPullDebit, - confirmPeerPushCredit, initiatePeerPullPayment, - initiatePeerPushDebit, + processPeerPullCredit, +} from "./operations/pay-peer-pull-credit.js"; +import { + confirmPeerPullDebit, preparePeerPullDebit, +} from "./operations/pay-peer-pull-debit.js"; +import { + confirmPeerPushCredit, preparePeerPushCredit, - processPeerPullCredit, processPeerPullDebit, processPeerPushCredit, +} from "./operations/pay-peer-push-credit.js"; +import { + checkPeerPushDebit, + initiatePeerPushDebit, processPeerPushDebit, -} from "./operations/pay-peer.js"; +} from "./operations/pay-peer-push-debit.js"; import { getPendingOperations } from "./operations/pending.js"; import { createRecoupGroup, @@ -232,8 +237,8 @@ import { import { acceptTip, prepareTip, processTip } from "./operations/tip.js"; import { abortTransaction, - failTransaction, deleteTransaction, + failTransaction, getTransactionById, getTransactions, parseTransactionIdentifier, @@ -280,7 +285,6 @@ import { WalletCoreApiClient, WalletCoreResponseType, } from "./wallet-api-types.js"; -import { startQueryRefund } from "./operations/pay-merchant.js"; const logger = new Logger("wallet.ts"); |