diff options
author | Florian Dold <florian@dold.me> | 2024-02-19 18:05:48 +0100 |
---|---|---|
committer | Florian Dold <florian@dold.me> | 2024-02-19 18:05:48 +0100 |
commit | e951075d2ef52fa8e9e7489c62031777c3a7e66b (patch) | |
tree | 64208c09a9162f3a99adccf30edc36de1ef884ef /packages/taler-wallet-core/src/pay-peer-pull-debit.ts | |
parent | e975740ac4e9ba4bc531226784d640a018c00833 (diff) | |
download | wallet-core-e951075d2ef52fa8e9e7489c62031777c3a7e66b.tar.xz |
wallet-core: flatten directory structure
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 | 883 |
1 files changed, 883 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..f71fdcee7 --- /dev/null +++ b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts @@ -0,0 +1,883 @@ +/* + 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, + PeerContractTerms, + PreparePeerPullDebitRequest, + PreparePeerPullDebitResponse, + RefreshReason, + TalerError, + TalerErrorCode, + TalerPreciseTimestamp, + TalerProtocolViolationError, + TransactionAction, + TransactionIdStr, + TransactionMajorState, + TransactionMinorState, + TransactionState, + TransactionType, + 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 { + DbReadWriteTransaction, + InternalWalletState, + PeerPullDebitRecordStatus, + PeerPullPaymentIncomingRecord, + PendingTaskType, + RefreshOperationStatus, + StoreNames, + TaskId, + WalletStoresV1, + createRefreshGroup, + timestampPreciseToDb, +} from "./index.js"; +import { assertUnreachable } from "./util/assertUnreachable.js"; +import { PeerCoinRepair, selectPeerCoins } from "./util/coinSelection.js"; +import { checkLogicInvariant } from "./util/invariants.js"; +import { + TaskRunResult, + TaskRunResultType, + TransactionContext, + TransitionResult, + constructTaskIdentifier, + spendCoins, +} from "./common.js"; +import { + codecForExchangePurseStatus, + getTotalPeerPaymentCost, + queryCoinInfosForSelection, +} from "./pay-peer-common.js"; +import { + constructTransactionIdentifier, + notifyTransition, + parseTransactionIdentifier, +} from "./transactions.js"; + +const logger = new Logger("pay-peer-pull-debit.ts"); + +/** + * Common context for a peer-pull-debit transaction. + */ +export class PeerPullDebitTransactionContext implements TransactionContext { + ws: InternalWalletState; + readonly transactionId: TransactionIdStr; + readonly taskId: TaskId; + peerPullDebitId: string; + + constructor(ws: InternalWalletState, peerPullDebitId: string) { + this.ws = ws; + 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.ws; + const peerPullDebitId = this.peerPullDebitId; + await ws.db.runReadWriteTx(["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 ws = this.ws; + const peerPullDebitId = this.peerPullDebitId; + const transitionInfo = await ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + ws.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 TransitionResult.Transition; + case PeerPullDebitRecordStatus.SuspendedAbortingRefresh: + pi.status = PeerPullDebitRecordStatus.AbortingRefresh; + return TransitionResult.Transition; + case PeerPullDebitRecordStatus.Aborted: + case PeerPullDebitRecordStatus.AbortingRefresh: + case PeerPullDebitRecordStatus.Failed: + case PeerPullDebitRecordStatus.DialogProposed: + case PeerPullDebitRecordStatus.Done: + case PeerPullDebitRecordStatus.PendingDeposit: + return TransitionResult.Stay; + } + }); + this.ws.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 TransitionResult.Transition; + default: + return TransitionResult.Stay; + } + }); + this.ws.taskScheduler.stopShepherdTask(this.taskId); + } + + async abortTransaction(): Promise<void> { + const ctx = this; + await ctx.transitionExtra( + { + extraStores: [ + "coinAvailability", + "denominations", + "refreshGroups", + "coins", + "coinAvailability", + ], + }, + async (pi, tx) => { + switch (pi.status) { + case PeerPullDebitRecordStatus.SuspendedDeposit: + case PeerPullDebitRecordStatus.PendingDeposit: + break; + default: + return TransitionResult.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.ws, + tx, + currency, + coinPubs, + RefreshReason.AbortPeerPullDebit, + this.transactionId, + ); + + pi.status = PeerPullDebitRecordStatus.AbortingRefresh; + pi.abortRefreshGroupId = refresh.refreshGroupId; + return TransitionResult.Transition; + }, + ); + } + + async transition( + f: (rec: PeerPullPaymentIncomingRecord) => Promise<TransitionResult>, + ): 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<TransitionResult>, + ): Promise<void> { + const ws = this.ws; + const extraStores = opts.extraStores ?? []; + const transitionInfo = await ws.db.runReadWriteTx( + ["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 TransitionResult.Transition: { + await tx.peerPullDebit.put(pi); + const newTxState = computePeerPullDebitTransactionState(pi); + return { + oldTxState, + newTxState, + }; + } + default: + return undefined; + } + }, + ); + ws.taskScheduler.stopShepherdTask(this.taskId); + notifyTransition(ws, this.transactionId, transitionInfo); + ws.taskScheduler.startShepherdTask(this.taskId); + } +} + +async function handlePurseCreationConflict( + ctx: PeerPullDebitTransactionContext, + peerPullInc: PeerPullPaymentIncomingRecord, + resp: HttpResponse, +): Promise<TaskRunResult> { + const ws = ctx.ws; + 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: PeerCoinRepair = { + coinPubs: [], + contribs: [], + exchangeBaseUrl: peerPullInc.exchangeBaseUrl, + }; + + for (let i = 0; i < sel.coinPubs.length; i++) { + if (sel.coinPubs[i] != brokenCoinPub) { + repair.coinPubs.push(sel.coinPubs[i]); + repair.contribs.push(Amounts.parseOrThrow(sel.contributions[i])); + } + } + + const coinSelRes = await selectPeerCoins(ws, { instructedAmount, repair }); + + if (coinSelRes.type == "failure") { + // FIXME: Details! + throw Error( + "insufficient balance to re-select coins to repair double spending", + ); + } + + const totalAmount = await getTotalPeerPaymentCost( + ws, + coinSelRes.result.coins, + ); + + await ws.db.runReadWriteTx(["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( + ws: InternalWalletState, + peerPullInc: PeerPullPaymentIncomingRecord, +): Promise<TaskRunResult> { + 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.fetch(purseDepositUrl.href, { + method: "POST", + body: depositPayload, + }); + + const ctx = new PeerPullDebitTransactionContext( + ws, + peerPullInc.peerPullDebitId, + ); + + switch (httpResp.status) { + case HttpStatusCode.Ok: { + const resp = await readSuccessResponseJsonOrThrow( + httpResp, + codecForAny(), + ); + logger.trace(`purse deposit response: ${j2s(resp)}`); + + await ctx.transition(async (r) => { + if (r.status !== PeerPullDebitRecordStatus.PendingDeposit) { + return TransitionResult.Stay; + } + r.status = PeerPullDebitRecordStatus.Done; + return TransitionResult.Transition; + }); + return TaskRunResult.finished(); + } + 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, + }; + } + } +} + +async function processPeerPullDebitAbortingRefresh( + ws: InternalWalletState, + peerPullInc: PeerPullPaymentIncomingRecord, +): Promise<TaskRunResult> { + const peerPullDebitId = peerPullInc.peerPullDebitId; + const abortRefreshGroupId = peerPullInc.abortRefreshGroupId; + checkLogicInvariant(!!abortRefreshGroupId); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.PeerPullDebit, + peerPullDebitId, + }); + const transitionInfo = await ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + // FIXME: Shouldn't this be finished in some cases?! + return TaskRunResult.backoff(); +} + +export async function processPeerPullDebit( + ws: InternalWalletState, + peerPullDebitId: string, +): Promise<TaskRunResult> { + const peerPullInc = await ws.db.runReadOnlyTx( + ["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(ws, peerPullInc); + case PeerPullDebitRecordStatus.AbortingRefresh: + return await processPeerPullDebitAbortingRefresh(ws, peerPullInc); + } + return TaskRunResult.finished(); +} + +export async function confirmPeerPullDebit( + ws: InternalWalletState, + req: ConfirmPeerPullDebitRequest, +): Promise<AcceptPeerPullPaymentResponse> { + let peerPullDebitId: string; + + if (req.transactionId) { + const parsedTx = parseTransactionIdentifier(req.transactionId); + if (!parsedTx || parsedTx.tag !== TransactionType.PeerPullDebit) { + throw Error("invalid peer-pull-debit transaction identifier"); + } + peerPullDebitId = parsedTx.peerPullDebitId; + } else if (req.peerPullDebitId) { + peerPullDebitId = req.peerPullDebitId; + } else { + throw Error("invalid request, transactionId or peerPullDebitId required"); + } + + const peerPullInc = await ws.db.runReadOnlyTx( + ["peerPullDebit"], + async (tx) => { + return tx.peerPullDebit.get(peerPullDebitId); + }, + ); + + if (!peerPullInc) { + throw Error( + `can't accept unknown incoming p2p pull payment (${req.peerPullDebitId})`, + ); + } + + const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount); + + const coinSelRes = await selectPeerCoins(ws, { instructedAmount }); + if (logger.shouldLogTrace()) { + logger.trace(`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, + ); + + await ws.db.runReadWriteTx( + [ + "exchanges", + "coins", + "denominations", + "refreshGroups", + "peerPullDebit", + "coinAvailability", + ], + async (tx) => { + await spendCoins(ws, tx, { + // allocationId: `txn:peer-pull-debit:${req.peerPullDebitId}`, + allocationId: constructTransactionIdentifier({ + tag: TransactionType.PeerPullDebit, + peerPullDebitId, + }), + coinPubs: sel.coins.map((x) => x.coinPub), + contributions: sel.coins.map((x) => + Amounts.parseOrThrow(x.contribution), + ), + refreshReason: RefreshReason.PayPeerPull, + }); + + const pi = await tx.peerPullDebit.get(peerPullDebitId); + 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.peerPullDebit.put(pi); + }, + ); + + const ctx = new PeerPullDebitTransactionContext(ws, peerPullDebitId); + + const transactionId = ctx.transactionId; + + ws.notify({ + type: NotificationType.BalanceChange, + hintTransactionId: transactionId, + }); + + ws.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( + ws: InternalWalletState, + req: PreparePeerPullDebitRequest, +): Promise<PreparePeerPullDebitResponse> { + const uri = parsePayPullUri(req.talerUri); + + if (!uri) { + throw Error("got invalid taler://pay-pull URI"); + } + + const existing = await ws.db.runReadOnlyTx( + ["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 ws.http.fetch(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.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(ws, { instructedAmount }); + if (logger.shouldLogTrace()) { + logger.trace(`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.runReadWriteTx(["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]; + } +} |