diff options
Diffstat (limited to 'packages/taler-wallet-core/src/pay-peer-pull-debit.ts')
-rw-r--r-- | packages/taler-wallet-core/src/pay-peer-pull-debit.ts | 1019 |
1 files changed, 1019 insertions, 0 deletions
diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts new file mode 100644 index 000000000..0355b58ad --- /dev/null +++ b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts @@ -0,0 +1,1019 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 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/> + */ + +/** + * @fileoverview + * Implementation of the peer-pull-debit transaction, i.e. + * paying for an invoice the wallet received from another wallet. + */ + +/** + * Imports. + */ +import { + AcceptPeerPullPaymentResponse, + Amounts, + CoinRefreshRequest, + ConfirmPeerPullDebitRequest, + ContractTermsUtil, + ExchangePurseDeposits, + HttpStatusCode, + Logger, + NotificationType, + ObservabilityEventType, + PeerContractTerms, + PreparePeerPullDebitRequest, + PreparePeerPullDebitResponse, + RefreshReason, + SelectedProspectiveCoin, + TalerError, + TalerErrorCode, + TalerPreciseTimestamp, + TalerProtocolViolationError, + TransactionAction, + TransactionIdStr, + TransactionMajorState, + TransactionMinorState, + TransactionState, + TransactionType, + assertUnreachable, + checkLogicInvariant, + codecForAny, + codecForExchangeGetContractResponse, + codecForPeerContractTerms, + decodeCrock, + eddsaGetPublic, + encodeCrock, + getRandomBytes, + j2s, + parsePayPullUri, +} from "@gnu-taler/taler-util"; +import { + HttpResponse, + readSuccessResponseJsonOrThrow, + readTalerErrorResponse, +} from "@gnu-taler/taler-util/http"; +import { PreviousPayCoins, selectPeerCoins } from "./coinSelection.js"; +import { + PendingTaskType, + TaskIdStr, + TaskRunResult, + TaskRunResultType, + TransactionContext, + TransitionResultType, + constructTaskIdentifier, + spendCoins, +} from "./common.js"; +import { + PeerPullDebitRecordStatus, + PeerPullPaymentIncomingRecord, + RefreshOperationStatus, + WalletStoresV1, + timestampPreciseToDb, +} from "./db.js"; +import { + codecForExchangePurseStatus, + getTotalPeerPaymentCost, + queryCoinInfosForSelection, +} from "./pay-peer-common.js"; +import { DbReadWriteTransaction, StoreNames } from "./query.js"; +import { createRefreshGroup } from "./refresh.js"; +import { + constructTransactionIdentifier, + notifyTransition, + parseTransactionIdentifier, +} from "./transactions.js"; +import { WalletExecutionContext } from "./wallet.js"; + +const logger = new Logger("pay-peer-pull-debit.ts"); + +/** + * Common context for a peer-pull-debit transaction. + */ +export class PeerPullDebitTransactionContext implements TransactionContext { + wex: WalletExecutionContext; + readonly transactionId: TransactionIdStr; + readonly taskId: TaskIdStr; + peerPullDebitId: string; + + constructor(wex: WalletExecutionContext, peerPullDebitId: string) { + this.wex = wex; + this.transactionId = constructTransactionIdentifier({ + tag: TransactionType.PeerPullDebit, + peerPullDebitId, + }); + this.taskId = constructTaskIdentifier({ + tag: PendingTaskType.PeerPullDebit, + peerPullDebitId, + }); + this.peerPullDebitId = peerPullDebitId; + } + + async deleteTransaction(): Promise<void> { + const transactionId = this.transactionId; + const ws = this.wex; + const peerPullDebitId = this.peerPullDebitId; + await ws.db.runReadWriteTx( + { storeNames: ["peerPullDebit", "tombstones"] }, + async (tx) => { + const debit = await tx.peerPullDebit.get(peerPullDebitId); + if (debit) { + await tx.peerPullDebit.delete(peerPullDebitId); + await tx.tombstones.put({ id: transactionId }); + } + }, + ); + } + + async suspendTransaction(): Promise<void> { + const taskId = this.taskId; + const transactionId = this.transactionId; + const wex = this.wex; + const peerPullDebitId = this.peerPullDebitId; + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["peerPullDebit"] }, + async (tx) => { + const pullDebitRec = await tx.peerPullDebit.get(peerPullDebitId); + if (!pullDebitRec) { + logger.warn(`peer pull debit ${peerPullDebitId} not found`); + return; + } + let newStatus: PeerPullDebitRecordStatus | undefined = undefined; + switch (pullDebitRec.status) { + case PeerPullDebitRecordStatus.DialogProposed: + break; + case PeerPullDebitRecordStatus.Done: + 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.peerPullDebit.put(pullDebitRec); + return { + oldTxState, + newTxState, + }; + } + return undefined; + }, + ); + notifyTransition(wex, transactionId, transitionInfo); + wex.taskScheduler.stopShepherdTask(taskId); + } + + async resumeTransaction(): Promise<void> { + const ctx = this; + await ctx.transition(async (pi) => { + switch (pi.status) { + case PeerPullDebitRecordStatus.SuspendedDeposit: + pi.status = PeerPullDebitRecordStatus.PendingDeposit; + return TransitionResultType.Transition; + case PeerPullDebitRecordStatus.SuspendedAbortingRefresh: + pi.status = PeerPullDebitRecordStatus.AbortingRefresh; + return TransitionResultType.Transition; + case PeerPullDebitRecordStatus.Aborted: + case PeerPullDebitRecordStatus.AbortingRefresh: + case PeerPullDebitRecordStatus.Failed: + case PeerPullDebitRecordStatus.DialogProposed: + case PeerPullDebitRecordStatus.Done: + case PeerPullDebitRecordStatus.PendingDeposit: + return TransitionResultType.Stay; + } + }); + this.wex.taskScheduler.startShepherdTask(this.taskId); + } + + async failTransaction(): Promise<void> { + const ctx = this; + await ctx.transition(async (pi) => { + switch (pi.status) { + case PeerPullDebitRecordStatus.SuspendedDeposit: + case PeerPullDebitRecordStatus.PendingDeposit: + case PeerPullDebitRecordStatus.AbortingRefresh: + case PeerPullDebitRecordStatus.SuspendedAbortingRefresh: + // FIXME: Should we also abort the corresponding refresh session?! + pi.status = PeerPullDebitRecordStatus.Failed; + return TransitionResultType.Transition; + default: + return TransitionResultType.Stay; + } + }); + this.wex.taskScheduler.stopShepherdTask(this.taskId); + } + + async abortTransaction(): Promise<void> { + const ctx = this; + await ctx.transitionExtra( + { + extraStores: [ + "coinAvailability", + "denominations", + "refreshGroups", + "refreshSessions", + "coins", + "coinAvailability", + ], + }, + async (pi, tx) => { + switch (pi.status) { + case PeerPullDebitRecordStatus.SuspendedDeposit: + case PeerPullDebitRecordStatus.PendingDeposit: + break; + default: + return TransitionResultType.Stay; + } + const currency = Amounts.currencyOf(pi.totalCostEstimated); + const coinPubs: CoinRefreshRequest[] = []; + + if (!pi.coinSel) { + throw Error("invalid db state"); + } + + for (let i = 0; i < pi.coinSel.coinPubs.length; i++) { + coinPubs.push({ + amount: pi.coinSel.contributions[i], + coinPub: pi.coinSel.coinPubs[i], + }); + } + + const refresh = await createRefreshGroup( + ctx.wex, + tx, + currency, + coinPubs, + RefreshReason.AbortPeerPullDebit, + this.transactionId, + ); + + pi.status = PeerPullDebitRecordStatus.AbortingRefresh; + pi.abortRefreshGroupId = refresh.refreshGroupId; + return TransitionResultType.Transition; + }, + ); + } + + async transition( + f: (rec: PeerPullPaymentIncomingRecord) => Promise<TransitionResultType>, + ): Promise<void> { + return this.transitionExtra( + { + extraStores: [], + }, + f, + ); + } + + async transitionExtra< + StoreNameArray extends Array<StoreNames<typeof WalletStoresV1>> = [], + >( + opts: { extraStores: StoreNameArray }, + f: ( + rec: PeerPullPaymentIncomingRecord, + tx: DbReadWriteTransaction< + typeof WalletStoresV1, + ["peerPullDebit", ...StoreNameArray] + >, + ) => Promise<TransitionResultType>, + ): Promise<void> { + const wex = this.wex; + const extraStores = opts.extraStores ?? []; + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["peerPullDebit", ...extraStores] }, + async (tx) => { + const pi = await tx.peerPullDebit.get(this.peerPullDebitId); + if (!pi) { + throw Error("peer pull payment not found anymore"); + } + const oldTxState = computePeerPullDebitTransactionState(pi); + const res = await f(pi, tx); + switch (res) { + case TransitionResultType.Transition: { + await tx.peerPullDebit.put(pi); + const newTxState = computePeerPullDebitTransactionState(pi); + return { + oldTxState, + newTxState, + }; + } + default: + return undefined; + } + }, + ); + wex.taskScheduler.stopShepherdTask(this.taskId); + notifyTransition(wex, this.transactionId, transitionInfo); + wex.taskScheduler.startShepherdTask(this.taskId); + } +} + +async function handlePurseCreationConflict( + ctx: PeerPullDebitTransactionContext, + peerPullInc: PeerPullPaymentIncomingRecord, + resp: HttpResponse, +): Promise<TaskRunResult> { + const ws = ctx.wex; + const errResp = await readTalerErrorResponse(resp); + if (errResp.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS) { + await ctx.failTransaction(); + return TaskRunResult.finished(); + } + + // FIXME: Properly parse! + const brokenCoinPub = (errResp as any).coin_pub; + logger.trace(`excluded broken coin pub=${brokenCoinPub}`); + + if (!brokenCoinPub) { + // FIXME: Details! + throw new TalerProtocolViolationError(); + } + + const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount); + + const sel = peerPullInc.coinSel; + if (!sel) { + throw Error("invalid state (coin selection expected)"); + } + + const repair: PreviousPayCoins = []; + + for (let i = 0; i < sel.coinPubs.length; i++) { + if (sel.coinPubs[i] != brokenCoinPub) { + repair.push({ + coinPub: sel.coinPubs[i], + contribution: Amounts.parseOrThrow(sel.contributions[i]), + }); + } + } + + const coinSelRes = await selectPeerCoins(ws, { + instructedAmount, + repair, + }); + + switch (coinSelRes.type) { + case "failure": + // FIXME: Details! + throw Error( + "insufficient balance to re-select coins to repair double spending", + ); + case "prospective": + throw Error( + "insufficient balance to re-select coins to repair double spending (blocked on refresh)", + ); + case "success": + break; + default: + assertUnreachable(coinSelRes); + } + + const totalAmount = await getTotalPeerPaymentCost( + ws, + coinSelRes.result.coins, + ); + + await ws.db.runReadWriteTx({ storeNames: ["peerPullDebit"] }, async (tx) => { + const myPpi = await tx.peerPullDebit.get(peerPullInc.peerPullDebitId); + if (!myPpi) { + return; + } + switch (myPpi.status) { + case PeerPullDebitRecordStatus.PendingDeposit: + case PeerPullDebitRecordStatus.SuspendedDeposit: { + const sel = coinSelRes.result; + myPpi.coinSel = { + coinPubs: sel.coins.map((x) => x.coinPub), + contributions: sel.coins.map((x) => x.contribution), + totalCost: Amounts.stringify(totalAmount), + }; + break; + } + default: + return; + } + await tx.peerPullDebit.put(myPpi); + }); + return TaskRunResult.backoff(); +} + +async function processPeerPullDebitPendingDeposit( + wex: WalletExecutionContext, + peerPullInc: PeerPullPaymentIncomingRecord, +): Promise<TaskRunResult> { + const ctx = new PeerPullDebitTransactionContext( + wex, + peerPullInc.peerPullDebitId, + ); + + const pursePub = peerPullInc.pursePub; + + const coinSel = peerPullInc.coinSel; + + if (!coinSel) { + const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount); + + const coinSelRes = await selectPeerCoins(wex, { + instructedAmount, + }); + if (logger.shouldLogTrace()) { + logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`); + } + + let coins: SelectedProspectiveCoin[] | undefined = undefined; + + switch (coinSelRes.type) { + case "failure": + throw TalerError.fromDetail( + TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, + { + insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, + }, + ); + case "prospective": + throw Error("insufficient balance (locked behind refresh)"); + case "success": + coins = coinSelRes.result.coins; + break; + default: + assertUnreachable(coinSelRes); + } + + const peerPullDebitId = peerPullInc.peerPullDebitId; + const totalAmount = await getTotalPeerPaymentCost(wex, coins); + + // FIXME: Missing notification here! + + const transitionDone = await wex.db.runReadWriteTx( + { + storeNames: [ + "exchanges", + "coins", + "denominations", + "refreshGroups", + "refreshSessions", + "peerPullDebit", + "coinAvailability", + ], + }, + async (tx) => { + const pi = await tx.peerPullDebit.get(peerPullDebitId); + if (!pi) { + return false; + } + if (pi.status !== PeerPullDebitRecordStatus.PendingDeposit) { + return false; + } + if (pi.coinSel) { + return false; + } + await spendCoins(wex, tx, { + // allocationId: `txn:peer-pull-debit:${req.peerPullDebitId}`, + allocationId: constructTransactionIdentifier({ + tag: TransactionType.PeerPullDebit, + peerPullDebitId, + }), + coinPubs: coinSelRes.result.coins.map((x) => x.coinPub), + contributions: coinSelRes.result.coins.map((x) => + Amounts.parseOrThrow(x.contribution), + ), + refreshReason: RefreshReason.PayPeerPull, + }); + pi.coinSel = { + coinPubs: coinSelRes.result.coins.map((x) => x.coinPub), + contributions: coinSelRes.result.coins.map((x) => x.contribution), + totalCost: Amounts.stringify(totalAmount), + }; + await tx.peerPullDebit.put(pi); + return true; + }, + ); + if (transitionDone) { + return TaskRunResult.progress(); + } else { + return TaskRunResult.backoff(); + } + } + + const purseDepositUrl = new URL( + `purses/${pursePub}/deposit`, + peerPullInc.exchangeBaseUrl, + ); + + // FIXME: We could skip batches that we've already submitted. + + const coins = await queryCoinInfosForSelection(wex, coinSel); + + const maxBatchSize = 100; + + for (let i = 0; i < coins.length; i += maxBatchSize) { + const batchSize = Math.min(maxBatchSize, coins.length - i); + + wex.oc.observe({ + type: ObservabilityEventType.Message, + contents: `Depositing batch at ${i}/${coins.length} of size ${batchSize}`, + }); + + const batchCoins = coins.slice(i, i + batchSize); + const depositSigsResp = await wex.cryptoApi.signPurseDeposits({ + exchangeBaseUrl: peerPullInc.exchangeBaseUrl, + pursePub: peerPullInc.pursePub, + coins: batchCoins, + }); + + const depositPayload: ExchangePurseDeposits = { + deposits: depositSigsResp.deposits, + }; + + if (logger.shouldLogTrace()) { + logger.trace(`purse deposit payload: ${j2s(depositPayload)}`); + } + + const httpResp = await wex.http.fetch(purseDepositUrl.href, { + method: "POST", + body: depositPayload, + cancellationToken: wex.cancellationToken, + }); + + switch (httpResp.status) { + case HttpStatusCode.Ok: { + const resp = await readSuccessResponseJsonOrThrow( + httpResp, + codecForAny(), + ); + logger.trace(`purse deposit response: ${j2s(resp)}`); + continue; + } + case HttpStatusCode.Gone: { + await ctx.abortTransaction(); + return TaskRunResult.backoff(); + } + case HttpStatusCode.Conflict: { + return handlePurseCreationConflict(ctx, peerPullInc, httpResp); + } + default: { + const errResp = await readTalerErrorResponse(httpResp); + return { + type: TaskRunResultType.Error, + errorDetail: errResp, + }; + } + } + } + + // All batches succeeded, we can transition! + + await ctx.transition(async (r) => { + if (r.status !== PeerPullDebitRecordStatus.PendingDeposit) { + return TransitionResultType.Stay; + } + r.status = PeerPullDebitRecordStatus.Done; + return TransitionResultType.Transition; + }); + return TaskRunResult.finished(); +} + +async function processPeerPullDebitAbortingRefresh( + wex: WalletExecutionContext, + peerPullInc: PeerPullPaymentIncomingRecord, +): Promise<TaskRunResult> { + const peerPullDebitId = peerPullInc.peerPullDebitId; + const abortRefreshGroupId = peerPullInc.abortRefreshGroupId; + checkLogicInvariant(!!abortRefreshGroupId); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.PeerPullDebit, + peerPullDebitId, + }); + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["peerPullDebit", "refreshGroups"] }, + async (tx) => { + const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId); + let newOpState: PeerPullDebitRecordStatus | undefined; + if (!refreshGroup) { + // Maybe it got manually deleted? Means that we should + // just go into failed. + logger.warn("no aborting refresh group found for deposit group"); + newOpState = PeerPullDebitRecordStatus.Failed; + } else { + if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) { + newOpState = PeerPullDebitRecordStatus.Aborted; + } else if ( + refreshGroup.operationStatus === RefreshOperationStatus.Failed + ) { + newOpState = PeerPullDebitRecordStatus.Failed; + } + } + if (newOpState) { + const newDg = await tx.peerPullDebit.get(peerPullDebitId); + if (!newDg) { + return; + } + const oldTxState = computePeerPullDebitTransactionState(newDg); + newDg.status = newOpState; + const newTxState = computePeerPullDebitTransactionState(newDg); + await tx.peerPullDebit.put(newDg); + return { oldTxState, newTxState }; + } + return undefined; + }, + ); + notifyTransition(wex, transactionId, transitionInfo); + // FIXME: Shouldn't this be finished in some cases?! + return TaskRunResult.backoff(); +} + +export async function processPeerPullDebit( + wex: WalletExecutionContext, + peerPullDebitId: string, +): Promise<TaskRunResult> { + const peerPullInc = await wex.db.runReadOnlyTx( + { storeNames: ["peerPullDebit"] }, + async (tx) => { + return tx.peerPullDebit.get(peerPullDebitId); + }, + ); + if (!peerPullInc) { + throw Error("peer pull debit not found"); + } + + switch (peerPullInc.status) { + case PeerPullDebitRecordStatus.PendingDeposit: + return await processPeerPullDebitPendingDeposit(wex, peerPullInc); + case PeerPullDebitRecordStatus.AbortingRefresh: + return await processPeerPullDebitAbortingRefresh(wex, peerPullInc); + } + return TaskRunResult.finished(); +} + +export async function confirmPeerPullDebit( + wex: WalletExecutionContext, + req: ConfirmPeerPullDebitRequest, +): Promise<AcceptPeerPullPaymentResponse> { + let peerPullDebitId: string; + const parsedTx = parseTransactionIdentifier(req.transactionId); + if (!parsedTx || parsedTx.tag !== TransactionType.PeerPullDebit) { + throw Error("invalid peer-pull-debit transaction identifier"); + } + peerPullDebitId = parsedTx.peerPullDebitId; + + const peerPullInc = await wex.db.runReadOnlyTx( + { storeNames: ["peerPullDebit"] }, + async (tx) => { + return tx.peerPullDebit.get(peerPullDebitId); + }, + ); + + if (!peerPullInc) { + throw Error( + `can't accept unknown incoming p2p pull payment (${req.transactionId})`, + ); + } + + const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount); + + const coinSelRes = await selectPeerCoins(wex, { + instructedAmount, + }); + if (logger.shouldLogTrace()) { + logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`); + } + + let coins: SelectedProspectiveCoin[] | undefined = undefined; + + switch (coinSelRes.type) { + case "failure": + throw TalerError.fromDetail( + TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, + { + insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, + }, + ); + case "prospective": + coins = coinSelRes.result.prospectiveCoins; + break; + case "success": + coins = coinSelRes.result.coins; + break; + default: + assertUnreachable(coinSelRes); + } + + const totalAmount = await getTotalPeerPaymentCost(wex, coins); + + // FIXME: Missing notification here! + + await wex.db.runReadWriteTx( + { + storeNames: [ + "exchanges", + "coins", + "denominations", + "refreshGroups", + "refreshSessions", + "peerPullDebit", + "coinAvailability", + ], + }, + async (tx) => { + const pi = await tx.peerPullDebit.get(peerPullDebitId); + if (!pi) { + throw Error(); + } + if (pi.status !== PeerPullDebitRecordStatus.DialogProposed) { + return; + } + if (coinSelRes.type == "success") { + await spendCoins(wex, tx, { + // allocationId: `txn:peer-pull-debit:${req.peerPullDebitId}`, + allocationId: constructTransactionIdentifier({ + tag: TransactionType.PeerPullDebit, + peerPullDebitId, + }), + coinPubs: coinSelRes.result.coins.map((x) => x.coinPub), + contributions: coinSelRes.result.coins.map((x) => + Amounts.parseOrThrow(x.contribution), + ), + refreshReason: RefreshReason.PayPeerPull, + }); + pi.coinSel = { + coinPubs: coinSelRes.result.coins.map((x) => x.coinPub), + contributions: coinSelRes.result.coins.map((x) => x.contribution), + totalCost: Amounts.stringify(totalAmount), + }; + } + pi.status = PeerPullDebitRecordStatus.PendingDeposit; + await tx.peerPullDebit.put(pi); + }, + ); + + const ctx = new PeerPullDebitTransactionContext(wex, peerPullDebitId); + + const transactionId = ctx.transactionId; + + wex.ws.notify({ + type: NotificationType.BalanceChange, + hintTransactionId: transactionId, + }); + + wex.taskScheduler.startShepherdTask(ctx.taskId); + + return { + transactionId, + }; +} + +/** + * Look up information about an incoming peer pull payment. + * Store the results in the wallet DB. + */ +export async function preparePeerPullDebit( + wex: WalletExecutionContext, + req: PreparePeerPullDebitRequest, +): Promise<PreparePeerPullDebitResponse> { + const uri = parsePayPullUri(req.talerUri); + + if (!uri) { + throw Error("got invalid taler://pay-pull URI"); + } + + const existing = await wex.db.runReadOnlyTx( + { storeNames: ["peerPullDebit", "contractTerms"] }, + async (tx) => { + const peerPullDebitRecord = + await tx.peerPullDebit.indexes.byExchangeAndContractPriv.get([ + uri.exchangeBaseUrl, + uri.contractPriv, + ]); + if (!peerPullDebitRecord) { + return; + } + const contractTerms = await tx.contractTerms.get( + peerPullDebitRecord.contractTermsHash, + ); + if (!contractTerms) { + return; + } + return { peerPullDebitRecord, contractTerms }; + }, + ); + + if (existing) { + return { + amount: existing.peerPullDebitRecord.amount, + amountRaw: existing.peerPullDebitRecord.amount, + amountEffective: existing.peerPullDebitRecord.totalCostEstimated, + contractTerms: existing.contractTerms.contractTermsRaw, + peerPullDebitId: existing.peerPullDebitRecord.peerPullDebitId, + transactionId: constructTransactionIdentifier({ + tag: TransactionType.PeerPullDebit, + peerPullDebitId: existing.peerPullDebitRecord.peerPullDebitId, + }), + }; + } + + const exchangeBaseUrl = uri.exchangeBaseUrl; + const contractPriv = uri.contractPriv; + const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv))); + + const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl); + + const contractHttpResp = await wex.http.fetch(getContractUrl.href); + + const contractResp = await readSuccessResponseJsonOrThrow( + contractHttpResp, + codecForExchangeGetContractResponse(), + ); + + const pursePub = contractResp.purse_pub; + + const dec = await wex.cryptoApi.decryptContractForDeposit({ + ciphertext: contractResp.econtract, + contractPriv: contractPriv, + pursePub: pursePub, + }); + + const getPurseUrl = new URL(`purses/${pursePub}/merge`, exchangeBaseUrl); + + const purseHttpResp = await wex.http.fetch(getPurseUrl.href); + + const purseStatus = await readSuccessResponseJsonOrThrow( + purseHttpResp, + codecForExchangePurseStatus(), + ); + + const peerPullDebitId = 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"); + } + + const contractTermsHash = ContractTermsUtil.hashContractTerms(contractTerms); + + // FIXME: Why don't we compute the totalCost here?! + + const instructedAmount = Amounts.parseOrThrow(contractTerms.amount); + + const coinSelRes = await selectPeerCoins(wex, { + instructedAmount, + }); + if (logger.shouldLogTrace()) { + logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`); + } + + let coins: SelectedProspectiveCoin[] | undefined = undefined; + + switch (coinSelRes.type) { + case "failure": + throw TalerError.fromDetail( + TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, + { + insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, + }, + ); + case "prospective": + coins = coinSelRes.result.prospectiveCoins; + break; + case "success": + coins = coinSelRes.result.coins; + break; + default: + assertUnreachable(coinSelRes); + } + + const totalAmount = await getTotalPeerPaymentCost(wex, coins); + + await wex.db.runReadWriteTx( + { storeNames: ["peerPullDebit", "contractTerms"] }, + async (tx) => { + await tx.contractTerms.put({ + h: contractTermsHash, + contractTermsRaw: contractTerms, + }), + await tx.peerPullDebit.add({ + peerPullDebitId, + contractPriv: contractPriv, + exchangeBaseUrl: exchangeBaseUrl, + pursePub: pursePub, + timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()), + contractTermsHash, + amount: contractTerms.amount, + status: PeerPullDebitRecordStatus.DialogProposed, + totalCostEstimated: Amounts.stringify(totalAmount), + }); + }, + ); + + return { + amount: contractTerms.amount, + amountEffective: Amounts.stringify(totalAmount), + amountRaw: contractTerms.amount, + contractTerms: contractTerms, + peerPullDebitId, + transactionId: constructTransactionIdentifier({ + tag: TransactionType.PeerPullDebit, + peerPullDebitId: peerPullDebitId, + }), + }; +} + +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.Done: + 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.Done: + 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]; + } +} |