diff options
Diffstat (limited to 'packages')
11 files changed, 510 insertions, 321 deletions
diff --git a/packages/taler-harness/src/integrationtests/test-peer-to-peer-pull.ts b/packages/taler-harness/src/integrationtests/test-peer-to-peer-pull.ts index 7ed716bc1..a71175407 100644 --- a/packages/taler-harness/src/integrationtests/test-peer-to-peer-pull.ts +++ b/packages/taler-harness/src/integrationtests/test-peer-to-peer-pull.ts @@ -25,10 +25,17 @@ import { NotificationType, TransactionMajorState, TransactionMinorState, + TransactionType, WalletNotification, } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; -import { GlobalTestState } from "../harness/harness.js"; +import { + BankServiceHandle, + ExchangeService, + GlobalTestState, + WalletCli, + WalletClient, +} from "../harness/harness.js"; import { createSimpleTestkudosEnvironmentV2, createWalletDaemonWithClient, @@ -65,6 +72,23 @@ export async function runPeerToPeerPullTest(t: GlobalTestState) { const wallet1 = w1.walletClient; const wallet2 = w2.walletClient; + await checkNormalPeerPull(t, bank, exchange, wallet1, wallet2); + + console.log(`w1 notifications: ${j2s(allW1Notifications)}`); + + // Check that we don't have an excessive number of notifications. + t.assertTrue(allW1Notifications.length <= 60); + + await checkAbortedPeerPull(t, bank, exchange, wallet1, wallet2); +} + +async function checkNormalPeerPull( + t: GlobalTestState, + bank: BankServiceHandle, + exchange: ExchangeService, + wallet1: WalletClient, + wallet2: WalletClient, +): Promise<void> { const withdrawRes = await withdrawViaBankV2(t, { walletClient: wallet2, bank, @@ -94,7 +118,8 @@ export async function runPeerToPeerPullTest(t: GlobalTestState) { ); const peerPullCreditReadyCond = wallet1.waitForNotificationCond( - (x) => x.type === NotificationType.TransactionStateTransition && + (x) => + x.type === NotificationType.TransactionStateTransition && x.transactionId === resp.transactionId && x.newTxState.major === TransactionMajorState.Pending && x.newTxState.minor === TransactionMinorState.Ready, @@ -102,23 +127,32 @@ export async function runPeerToPeerPullTest(t: GlobalTestState) { await peerPullCreditReadyCond; + const creditTx = await wallet1.call(WalletApiOperation.GetTransactionById, { + transactionId: resp.transactionId, + }); + + t.assertDeepEqual(creditTx.type, TransactionType.PeerPullCredit); + t.assertTrue(!!creditTx.talerUri); + const checkResp = await wallet2.client.call( WalletApiOperation.PreparePeerPullDebit, { - talerUri: resp.talerUri, + talerUri: creditTx.talerUri, }, ); console.log(`checkResp: ${j2s(checkResp)}`); const peerPullCreditDoneCond = wallet1.waitForNotificationCond( - (x) => x.type === NotificationType.TransactionStateTransition && + (x) => + x.type === NotificationType.TransactionStateTransition && x.transactionId === resp.transactionId && x.newTxState.major === TransactionMajorState.Done, ); const peerPullDebitDoneCond = wallet2.waitForNotificationCond( - (x) => x.type === NotificationType.TransactionStateTransition && + (x) => + x.type === NotificationType.TransactionStateTransition && x.transactionId === checkResp.transactionId && x.newTxState.major === TransactionMajorState.Done, ); @@ -142,11 +176,106 @@ export async function runPeerToPeerPullTest(t: GlobalTestState) { console.log(`txn1: ${j2s(txn1)}`); console.log(`txn2: ${j2s(txn2)}`); +} - console.log(`w1 notifications: ${j2s(allW1Notifications)}`); +async function checkAbortedPeerPull( + t: GlobalTestState, + bank: BankServiceHandle, + exchange: ExchangeService, + wallet1: WalletClient, + wallet2: WalletClient, +): Promise<void> { + const withdrawRes = await withdrawViaBankV2(t, { + walletClient: wallet2, + bank, + exchange, + amount: "TESTKUDOS:20", + }); - // Check that we don't have an excessive number of notifications. - t.assertTrue(allW1Notifications.length <= 60); + await withdrawRes.withdrawalFinishedCond; + + const purseExpiration = AbsoluteTime.toProtocolTimestamp( + AbsoluteTime.addDuration( + AbsoluteTime.now(), + Duration.fromSpec({ days: 2 }), + ), + ); + + const resp = await wallet1.client.call( + WalletApiOperation.InitiatePeerPullCredit, + { + exchangeBaseUrl: exchange.baseUrl, + partialContractTerms: { + summary: "Hello World", + amount: "TESTKUDOS:5" as AmountString, + purse_expiration: purseExpiration, + }, + }, + ); + + const peerPullCreditReadyCond = wallet1.waitForNotificationCond( + (x) => + x.type === NotificationType.TransactionStateTransition && + x.transactionId === resp.transactionId && + x.newTxState.major === TransactionMajorState.Pending && + x.newTxState.minor === TransactionMinorState.Ready, + ); + + await peerPullCreditReadyCond; + + const creditTx = await wallet1.call(WalletApiOperation.GetTransactionById, { + transactionId: resp.transactionId, + }); + + t.assertDeepEqual(creditTx.type, TransactionType.PeerPullCredit); + t.assertTrue(!!creditTx.talerUri); + + const checkResp = await wallet2.client.call( + WalletApiOperation.PreparePeerPullDebit, + { + talerUri: creditTx.talerUri, + }, + ); + + console.log(`checkResp: ${j2s(checkResp)}`); + + const peerPullCreditAbortedCond = wallet1.waitForNotificationCond( + (x) => + x.type === NotificationType.TransactionStateTransition && + x.transactionId === resp.transactionId && + x.newTxState.major === TransactionMajorState.Aborted, + ); + + const peerPullDebitAbortedCond = wallet2.waitForNotificationCond( + (x) => + x.type === NotificationType.TransactionStateTransition && + x.transactionId === checkResp.transactionId && + x.newTxState.major === TransactionMajorState.Aborted, + ); + + await wallet1.call(WalletApiOperation.AbortTransaction, { + transactionId: resp.transactionId, + }); + + await wallet2.client.call(WalletApiOperation.ConfirmPeerPullDebit, { + peerPullDebitId: checkResp.peerPullDebitId, + }); + + await peerPullCreditAbortedCond; + await peerPullDebitAbortedCond; + + const txn1 = await wallet1.client.call( + WalletApiOperation.GetTransactions, + {}, + ); + + const txn2 = await wallet2.client.call( + WalletApiOperation.GetTransactions, + {}, + ); + + console.log(`txn1: ${j2s(txn1)}`); + console.log(`txn2: ${j2s(txn2)}`); } runPeerToPeerPullTest.suites = ["wallet"]; diff --git a/packages/taler-util/src/transactions-types.ts b/packages/taler-util/src/transactions-types.ts index 740478fb0..17b56d13b 100644 --- a/packages/taler-util/src/transactions-types.ts +++ b/packages/taler-util/src/transactions-types.ts @@ -369,8 +369,10 @@ export interface TransactionPeerPullCredit extends TransactionCommon { /** * URI to send to the other party. + * + * Only available in the right state. */ - talerUri: string; + talerUri: string | undefined; } /** diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index 76bb2e393..549bc7517 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -63,7 +63,9 @@ import { DbRetryInfo, TaskIdentifiers } from "./operations/common.js"; import { DbAccess, DbReadOnlyTransaction, + DbReadOnlyTransactionArr, DbReadWriteTransaction, + DbReadWriteTransactionArr, GetReadWriteAccess, IndexDescriptor, StoreDescriptor, @@ -2639,6 +2641,8 @@ export const WalletStoresV1 = { ), }; +type WalletStoreNames = StoreNames<typeof WalletStoresV1>; + export type WalletDbReadOnlyTransaction< Stores extends StoreNames<typeof WalletStoresV1> & string, > = DbReadOnlyTransaction<typeof WalletStoresV1, Stores>; diff --git a/packages/taler-wallet-core/src/operations/common.ts b/packages/taler-wallet-core/src/operations/common.ts index d8fb82be1..1103b7255 100644 --- a/packages/taler-wallet-core/src/operations/common.ts +++ b/packages/taler-wallet-core/src/operations/common.ts @@ -1075,3 +1075,22 @@ export namespace TaskIdentifiers { return `${PendingTaskType.PeerPushCredit}:${ppi.peerPushCreditId}` as TaskId; } } + +/** + * Result of a transaction transition. + */ +export enum TransitionResult { + Transition = 1, + Stay = 2, +} + +/** + * Transaction context. + * + * FIXME: Should eventually be implemented by all transactions. + */ +export interface TransactionContext { + abortTransaction(): Promise<void>; + resumeTransaction(): Promise<void>; + failTransaction(): Promise<void>; +} diff --git a/packages/taler-wallet-core/src/operations/pay-peer-common.ts b/packages/taler-wallet-core/src/operations/pay-peer-common.ts index 1a5dc6e89..88eedb530 100644 --- a/packages/taler-wallet-core/src/operations/pay-peer-common.ts +++ b/packages/taler-wallet-core/src/operations/pay-peer-common.ts @@ -44,11 +44,7 @@ import { getCandidateWithdrawalDenomsTx } from "./withdraw.js"; const logger = new Logger("operations/peer-to-peer.ts"); /** - * Get information about the coin selected for signatures - * - * @param ws - * @param csel - * @returns + * Get information about the coin selected for signatures. */ export async function queryCoinInfosForSelection( ws: InternalWalletState, 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 index 72e9e2e4a..9bbe2c875 100644 --- a/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts +++ b/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2022-2023 Taler Systems S.A. + (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 @@ -14,6 +14,15 @@ 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, @@ -53,19 +62,25 @@ import { readTalerErrorResponse, } from "@gnu-taler/taler-util/http"; import { + DbReadWriteTransactionArr, InternalWalletState, PeerPullDebitRecordStatus, PeerPullPaymentIncomingRecord, PendingTaskType, RefreshOperationStatus, + StoreNames, + 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"; @@ -80,19 +95,181 @@ import { parseTransactionIdentifier, stopLongpolling, } from "./transactions.js"; -import { PeerCoinRepair, selectPeerCoins } from "../util/coinSelection.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; + transactionId: string; + taskId: string; + 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 resumeTransaction(): Promise<void> { + const ctx = this; + stopLongpolling(ctx.ws, ctx.taskId); + 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; + } + }); + } + + async failTransaction(): Promise<void> { + const ctx = this; + stopLongpolling(ctx.ws, ctx.taskId); + 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; + } + }); + } + + 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, + ); + + 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: DbReadWriteTransactionArr< + 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; + } + }, + ); + notifyTransition(ws, this.transactionId, transitionInfo); + } +} + async function handlePurseCreationConflict( - ws: InternalWalletState, + ctx: PeerPullDebitTransactionContext, peerPullInc: PeerPullPaymentIncomingRecord, resp: HttpResponse, ): Promise<TaskRunResult> { - const pursePub = peerPullInc.pursePub; + const ws = ctx.ws; const errResp = await readTalerErrorResponse(resp); if (errResp.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS) { - await failPeerPullDebitTransaction(ws, pursePub); + await ctx.failTransaction(); return TaskRunResult.finished(); } @@ -139,29 +316,27 @@ async function handlePurseCreationConflict( coinSelRes.result.coins, ); - await ws.db - .mktx((x) => [x.peerPullDebit]) - .runReadWrite(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 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; } - await tx.peerPullDebit.put(myPpi); - }); + default: + return; + } + await tx.peerPullDebit.put(myPpi); + }); return TaskRunResult.finished(); } @@ -169,7 +344,6 @@ async function processPeerPullDebitPendingDeposit( ws: InternalWalletState, peerPullInc: PeerPullPaymentIncomingRecord, ): Promise<TaskRunResult> { - const peerPullDebitId = peerPullInc.peerPullDebitId; const pursePub = peerPullInc.pursePub; const coinSel = peerPullInc.coinSel; @@ -198,15 +372,16 @@ async function processPeerPullDebitPendingDeposit( logger.trace(`purse deposit payload: ${j2s(depositPayload)}`); } - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.PeerPullDebit, - peerPullDebitId, - }); - 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( @@ -215,77 +390,21 @@ async function processPeerPullDebitPendingDeposit( ); logger.trace(`purse deposit response: ${j2s(resp)}`); - const transitionInfo = await ws.db - .mktx((x) => [x.peerPullDebit]) - .runReadWrite(async (tx) => { - const pi = await tx.peerPullDebit.get(peerPullDebitId); - if (!pi) { - throw Error("peer pull payment not found anymore"); - } - if (pi.status !== PeerPullDebitRecordStatus.PendingDeposit) { - return; - } - const oldTxState = computePeerPullDebitTransactionState(pi); - pi.status = PeerPullDebitRecordStatus.Done; - const newTxState = computePeerPullDebitTransactionState(pi); - await tx.peerPullDebit.put(pi); - return { oldTxState, newTxState }; - }); - notifyTransition(ws, transactionId, transitionInfo); - break; + 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: { - const transitionInfo = await ws.db - .mktx((x) => [ - x.peerPullDebit, - x.refreshGroups, - x.denominations, - x.coinAvailability, - x.coins, - ]) - .runReadWrite(async (tx) => { - const pi = await tx.peerPullDebit.get(peerPullDebitId); - if (!pi) { - throw Error("peer pull payment not found anymore"); - } - if (pi.status !== PeerPullDebitRecordStatus.PendingDeposit) { - return; - } - const oldTxState = computePeerPullDebitTransactionState(pi); - - 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( - ws, - tx, - currency, - coinPubs, - RefreshReason.AbortPeerPullDebit, - ); - - pi.status = PeerPullDebitRecordStatus.AbortingRefresh; - pi.abortRefreshGroupId = refresh.refreshGroupId; - const newTxState = computePeerPullDebitTransactionState(pi); - await tx.peerPullDebit.put(pi); - return { oldTxState, newTxState }; - }); - notifyTransition(ws, transactionId, transitionInfo); - break; + await ctx.abortTransaction(); + return TaskRunResult.finished(); } case HttpStatusCode.Conflict: { - return handlePurseCreationConflict(ws, peerPullInc, httpResp); + return handlePurseCreationConflict(ctx, peerPullInc, httpResp); } default: { const errResp = await readTalerErrorResponse(httpResp); @@ -295,7 +414,6 @@ async function processPeerPullDebitPendingDeposit( }; } } - return TaskRunResult.finished(); } async function processPeerPullDebitAbortingRefresh( @@ -624,6 +742,9 @@ export async function preparePeerPullDebit( }; } +/** + * FIXME: This belongs in the transaction context! + */ export async function suspendPeerPullDebitTransaction( ws: InternalWalletState, peerPullDebitId: string, @@ -683,182 +804,6 @@ export async function suspendPeerPullDebitTransaction( notifyTransition(ws, transactionId, transitionInfo); } -export async function abortPeerPullDebitTransaction( - ws: InternalWalletState, - peerPullDebitId: string, -) { - const taskId = constructTaskIdentifier({ - tag: PendingTaskType.PeerPullDebit, - peerPullDebitId, - }); - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.PeerPullDebit, - peerPullDebitId, - }); - stopLongpolling(ws, taskId); - const transitionInfo = await ws.db - .mktx((x) => [x.peerPullDebit]) - .runReadWrite(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: - newStatus = PeerPullDebitRecordStatus.Aborted; - break; - case PeerPullDebitRecordStatus.Done: - 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.peerPullDebit.put(pullDebitRec); - return { - oldTxState, - newTxState, - }; - } - return undefined; - }); - notifyTransition(ws, transactionId, transitionInfo); -} - -export async function failPeerPullDebitTransaction( - ws: InternalWalletState, - peerPullDebitId: string, -) { - const taskId = constructTaskIdentifier({ - tag: PendingTaskType.PeerPullDebit, - peerPullDebitId, - }); - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.PeerPullDebit, - peerPullDebitId, - }); - stopLongpolling(ws, taskId); - const transitionInfo = await ws.db - .mktx((x) => [x.peerPullDebit]) - .runReadWrite(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: - newStatus = PeerPullDebitRecordStatus.Aborted; - break; - case PeerPullDebitRecordStatus.Done: - 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.peerPullDebit.put(pullDebitRec); - return { - oldTxState, - newTxState, - }; - } - return undefined; - }); - notifyTransition(ws, transactionId, transitionInfo); -} - -export async function resumePeerPullDebitTransaction( - ws: InternalWalletState, - peerPullDebitId: string, -) { - const taskId = constructTaskIdentifier({ - tag: PendingTaskType.PeerPullDebit, - peerPullDebitId, - }); - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.PeerPullDebit, - peerPullDebitId, - }); - stopLongpolling(ws, taskId); - const transitionInfo = await ws.db - .mktx((x) => [x.peerPullDebit]) - .runReadWrite(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: - case PeerPullDebitRecordStatus.Done: - 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.peerPullDebit.put(pullDebitRec); - return { - oldTxState, - newTxState, - }; - } - return undefined; - }); - ws.workAvailable.trigger(); - notifyTransition(ws, transactionId, transitionInfo); -} - export function computePeerPullDebitTransactionState( pullDebitRecord: PeerPullPaymentIncomingRecord, ): TransactionState { diff --git a/packages/taler-wallet-core/src/operations/refresh.ts b/packages/taler-wallet-core/src/operations/refresh.ts index 17ac54cfb..a8bcb28d1 100644 --- a/packages/taler-wallet-core/src/operations/refresh.ts +++ b/packages/taler-wallet-core/src/operations/refresh.ts @@ -84,6 +84,7 @@ import { RefreshSessionRecord, timestampPreciseToDb, timestampProtocolFromDb, + WalletDbReadWriteTransaction, } from "../index.js"; import { EXCHANGE_COINS_LOCK, @@ -92,7 +93,11 @@ import { import { assertUnreachable } from "../util/assertUnreachable.js"; import { selectWithdrawalDenominations } from "../util/coinSelection.js"; import { checkDbInvariant } from "../util/invariants.js"; -import { GetReadOnlyAccess, GetReadWriteAccess } from "../util/query.js"; +import { + DbReadWriteTransaction, + GetReadOnlyAccess, + GetReadWriteAccess, +} from "../util/query.js"; import { constructTaskIdentifier, makeCoinAvailable, @@ -1097,12 +1102,9 @@ async function applyRefresh( */ export async function createRefreshGroup( ws: InternalWalletState, - tx: GetReadWriteAccess<{ - denominations: typeof WalletStoresV1.denominations; - coins: typeof WalletStoresV1.coins; - refreshGroups: typeof WalletStoresV1.refreshGroups; - coinAvailability: typeof WalletStoresV1.coinAvailability; - }>, + tx: WalletDbReadWriteTransaction< + "denominations" | "coins" | "refreshGroups" | "coinAvailability" + >, currency: string, oldCoinPubs: CoinRefreshRequest[], reason: RefreshReason, diff --git a/packages/taler-wallet-core/src/operations/transactions.ts b/packages/taler-wallet-core/src/operations/transactions.ts index 3a219b39b..142eff7c1 100644 --- a/packages/taler-wallet-core/src/operations/transactions.ts +++ b/packages/taler-wallet-core/src/operations/transactions.ts @@ -114,11 +114,9 @@ import { suspendPeerPullCreditTransaction, } from "./pay-peer-pull-credit.js"; import { - abortPeerPullDebitTransaction, computePeerPullDebitTransactionActions, computePeerPullDebitTransactionState, - failPeerPullDebitTransaction, - resumePeerPullDebitTransaction, + PeerPullDebitTransactionContext, suspendPeerPullDebitTransaction, } from "./pay-peer-pull-debit.js"; import { @@ -1647,9 +1645,11 @@ export async function failTransaction( case TransactionType.PeerPullCredit: await failPeerPullCreditTransaction(ws, tx.pursePub); return; - case TransactionType.PeerPullDebit: - await failPeerPullDebitTransaction(ws, tx.peerPullDebitId); + case TransactionType.PeerPullDebit: { + const ctx = new PeerPullDebitTransactionContext(ws, tx.peerPullDebitId); + await ctx.failTransaction(); return; + } case TransactionType.PeerPushCredit: await failPeerPushCreditTransaction(ws, tx.peerPushCreditId); return; @@ -1692,9 +1692,11 @@ export async function resumeTransaction( case TransactionType.PeerPushDebit: await resumePeerPushDebitTransaction(ws, tx.pursePub); break; - case TransactionType.PeerPullDebit: - await resumePeerPullDebitTransaction(ws, tx.peerPullDebitId); - break; + case TransactionType.PeerPullDebit: { + const ctx = new PeerPullDebitTransactionContext(ws, tx.peerPullDebitId); + await ctx.resumeTransaction(); + return; + } case TransactionType.PeerPushCredit: await resumePeerPushCreditTransaction(ws, tx.peerPushCreditId); break; @@ -1936,9 +1938,11 @@ export async function abortTransaction( case TransactionType.PeerPullCredit: await abortPeerPullCreditTransaction(ws, txId.pursePub); break; - case TransactionType.PeerPullDebit: - await abortPeerPullDebitTransaction(ws, txId.peerPullDebitId); - break; + case TransactionType.PeerPullDebit: { + const ctx = new PeerPullDebitTransactionContext(ws, txId.peerPullDebitId); + await ctx.abortTransaction(); + return; + } case TransactionType.PeerPushCredit: await abortPeerPushCreditTransaction(ws, txId.peerPushCreditId); break; diff --git a/packages/taler-wallet-core/src/util/coinSelection.ts b/packages/taler-wallet-core/src/util/coinSelection.ts index 6070f4c78..9b29cee26 100644 --- a/packages/taler-wallet-core/src/util/coinSelection.ts +++ b/packages/taler-wallet-core/src/util/coinSelection.ts @@ -233,9 +233,9 @@ function tallyFees( export type SelectPayCoinsResult = | { - type: "failure"; - insufficientBalanceDetails: PayMerchantInsufficientBalanceDetails; - } + type: "failure"; + insufficientBalanceDetails: PayMerchantInsufficientBalanceDetails; + } | { type: "success"; coinSel: PayCoinSelection }; /** @@ -889,9 +889,9 @@ export interface PeerCoinSelectionDetails { export type SelectPeerCoinsResult = | { type: "success"; result: PeerCoinSelectionDetails } | { - type: "failure"; - insufficientBalanceDetails: PayPeerInsufficientBalanceDetails; - }; + type: "failure"; + insufficientBalanceDetails: PayPeerInsufficientBalanceDetails; + }; export interface PeerCoinRepair { exchangeBaseUrl: string; diff --git a/packages/taler-wallet-core/src/util/query.ts b/packages/taler-wallet-core/src/util/query.ts index 309c17a43..5d563f620 100644 --- a/packages/taler-wallet-core/src/util/query.ts +++ b/packages/taler-wallet-core/src/util/query.ts @@ -454,8 +454,8 @@ type DerefKeyPath<T, P> = P extends `${infer PX extends keyof T & KeyPathComponents}` ? T[PX] : P extends `${infer P0 extends keyof T & KeyPathComponents}.${infer Rest}` - ? DerefKeyPath<T[P0], Rest> - : unknown; + ? DerefKeyPath<T[P0], Rest> + : unknown; /** * Return a path if it is a valid dot-separate path to an object. @@ -465,8 +465,8 @@ type ValidateKeyPath<T, P> = P extends `${infer PX extends keyof T & KeyPathComponents}` ? PX : P extends `${infer P0 extends keyof T & KeyPathComponents}.${infer Rest}` - ? `${P0}.${ValidateKeyPath<T[P0], Rest>}` - : never; + ? `${P0}.${ValidateKeyPath<T[P0], Rest>}` + : never; // function foo<T, P>( // x: T, @@ -545,11 +545,60 @@ type ReadWriteTransactionFunction<BoundStores, T> = ( rawTx: IDBTransaction, ) => Promise<T>; +export type DbReadWriteTransactionArr< + StoreMap, + StoresArr extends Array<StoreNames<StoreMap>>, +> = StoreMap extends { + [P in string]: StoreWithIndexes<infer _SN1, infer _SD1, infer _IM1>; +} + ? { + [X in StoresArr[number] & + keyof StoreMap]: StoreMap[X] extends StoreWithIndexes< + infer _StoreName, + infer RecordType, + infer IndexMap + > + ? StoreReadWriteAccessor<RecordType, IndexMap> + : unknown; + } + : never; + +export type DbReadOnlyTransactionArr< + StoreMap, + StoresArr extends Array<StoreNames<StoreMap>>, +> = StoreMap extends { + [P in string]: StoreWithIndexes<infer _SN1, infer _SD1, infer _IM1>; +} + ? { + [X in StoresArr[number] & + keyof StoreMap]: StoreMap[X] extends StoreWithIndexes< + infer _StoreName, + infer RecordType, + infer IndexMap + > + ? StoreReadOnlyAccessor<RecordType, IndexMap> + : unknown; + } + : never; + export interface TransactionContext<BoundStores> { runReadWrite<T>(f: ReadWriteTransactionFunction<BoundStores, T>): Promise<T>; runReadOnly<T>(f: ReadOnlyTransactionFunction<BoundStores, T>): Promise<T>; } +/** + * Convert the type of an array to a union of the contents. + * + * Example: + * Input ["foo", "bar"] + * Output "foo" | "bar" + */ +export type UnionFromArray<Arr> = Arr extends { + [X in keyof Arr]: Arr[X] & string; +} + ? Arr[keyof Arr & number] + : unknown; + function runTx<Arg, Res>( tx: IDBTransaction, arg: Arg, @@ -743,7 +792,10 @@ type StoreNamesOf<X> = X extends { [x: number]: infer F } * A store map is the metadata that describes the store. */ export class DbAccess<StoreMap> { - constructor(private db: IDBDatabase, private stores: StoreMap) {} + constructor( + private db: IDBDatabase, + private stores: StoreMap, + ) {} idbHandle(): IDBDatabase { return this.db; @@ -803,6 +855,42 @@ export class DbAccess<StoreMap> { }; } + runReadWriteTx<T, StoreNameArray extends Array<StoreNames<StoreMap>>>( + storeNames: StoreNameArray, + txf: ( + tx: DbReadWriteTransactionArr<StoreMap, StoreNameArray>, + ) => Promise<T>, + ): Promise<T> { + const accessibleStores: { [x: string]: StoreWithIndexes<any, any, any> } = + {}; + const strStoreNames: string[] = []; + for (const sn of storeNames) { + const swi = (this.stores as any)[sn] as StoreWithIndexes<any, any, any>; + strStoreNames.push(swi.storeName); + accessibleStores[swi.storeName] = swi; + } + const tx = this.db.transaction(strStoreNames, "readwrite"); + const writeContext = makeWriteContext(tx, accessibleStores); + return runTx(tx, writeContext, txf); + } + + runReadOnlyTx<T, StoreNameArray extends Array<StoreNames<StoreMap>>>( + storeNames: StoreNameArray, + txf: (tx: DbReadOnlyTransactionArr<StoreMap, StoreNameArray>) => Promise<T>, + ): Promise<T> { + const accessibleStores: { [x: string]: StoreWithIndexes<any, any, any> } = + {}; + const strStoreNames: string[] = []; + for (const sn of storeNames) { + const swi = (this.stores as any)[sn] as StoreWithIndexes<any, any, any>; + strStoreNames.push(swi.storeName); + accessibleStores[swi.storeName] = swi; + } + const tx = this.db.transaction(strStoreNames, "readwrite"); + const readContext = makeReadContext(tx, accessibleStores); + return runTx(tx, readContext, txf); + } + /** * Run a transaction with selected object stores. * diff --git a/packages/taler-wallet-core/tsconfig.json b/packages/taler-wallet-core/tsconfig.json index 663a4dd98..7369e9783 100644 --- a/packages/taler-wallet-core/tsconfig.json +++ b/packages/taler-wallet-core/tsconfig.json @@ -15,7 +15,7 @@ "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "strict": true, - "strictPropertyInitialization": false, + "strictPropertyInitialization": true, "outDir": "lib", "noImplicitAny": true, "noImplicitThis": true, |