From 16d30adf0d57f6d954230c437e56e8a8700ef2ae Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Tue, 2 May 2023 10:59:50 +0200 Subject: -withdrawal notifications --- packages/taler-util/src/wallet-types.ts | 12 +- packages/taler-wallet-cli/src/index.ts | 19 +- .../src/operations/transactions.ts | 48 ++++- .../taler-wallet-core/src/operations/withdraw.ts | 205 +++++++++++++-------- packages/taler-wallet-core/src/wallet-api-types.ts | 14 ++ packages/taler-wallet-core/src/wallet.ts | 2 +- 6 files changed, 205 insertions(+), 95 deletions(-) diff --git a/packages/taler-util/src/wallet-types.ts b/packages/taler-util/src/wallet-types.ts index ec53541a5..d2355be6f 100644 --- a/packages/taler-util/src/wallet-types.ts +++ b/packages/taler-util/src/wallet-types.ts @@ -1680,21 +1680,15 @@ export const codecForResumeTransaction = (): Codec => export interface AbortTransactionRequest { transactionId: string; +} - /** - * Move the payment immediately into an aborted state. - * The UI should warn the user that this might lead - * to money being lost. - * - * Defaults to false. - */ - forceImmediateAbort?: boolean; +export interface CancelAbortingTransactionRequest { + transactionId: string; } export const codecForAbortTransaction = (): Codec => buildCodecForObject() .property("transactionId", codecForString()) - .property("forceImmediateAbort", codecOptional(codecForBoolean())) .build("AbortTransactionRequest"); export interface DepositGroupFees { diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts index 7fd218941..e58ea4c8d 100644 --- a/packages/taler-wallet-cli/src/index.ts +++ b/packages/taler-wallet-cli/src/index.ts @@ -443,6 +443,21 @@ transactionsCli }); }); +transactionsCli + .subcommand("cancelAbortingTransaction", "suspend", { + help: "Cancel the attempt of properly aborting a transaction.", + }) + .requiredArgument("transactionId", clk.STRING, { + help: "Identifier of the transaction to cancel aborting.", + }) + .action(async (args) => { + await withWallet(args, async (wallet) => { + await wallet.client.call(WalletApiOperation.CancelAbortingTransaction, { + transactionId: args.cancelAbortingTransaction.transactionId, + }); + }); + }); + transactionsCli .subcommand("resumeTransaction", "resume", { help: "Resume a transaction.", @@ -484,14 +499,10 @@ transactionsCli .requiredArgument("transactionId", clk.STRING, { help: "Identifier of the transaction to delete", }) - .flag("force", ["--force"], { - help: "Force aborting the transaction. Might lose money.", - }) .action(async (args) => { await withWallet(args, async (wallet) => { await wallet.client.call(WalletApiOperation.AbortTransaction, { transactionId: args.abortTransaction.transactionId, - forceImmediateAbort: args.abortTransaction.force, }); }); }); diff --git a/packages/taler-wallet-core/src/operations/transactions.ts b/packages/taler-wallet-core/src/operations/transactions.ts index cacc179f2..02b0b56ba 100644 --- a/packages/taler-wallet-core/src/operations/transactions.ts +++ b/packages/taler-wallet-core/src/operations/transactions.ts @@ -26,6 +26,7 @@ import { ExtendedStatus, j2s, Logger, + NotificationType, OrderShortInfo, PaymentStatus, PeerContractTerms, @@ -38,6 +39,7 @@ import { TransactionMajorState, TransactionsRequest, TransactionsResponse, + TransactionState, TransactionType, WithdrawalType, } from "@gnu-taler/taler-util"; @@ -94,6 +96,7 @@ import { processPeerPullCredit } from "./pay-peer.js"; import { processRefreshGroup } from "./refresh.js"; import { computeTipTransactionStatus, processTip } from "./tip.js"; import { + abortWithdrawalTransaction, augmentPaytoUrisForWithdrawal, computeWithdrawalTransactionStatus, processWithdrawalGroup, @@ -1854,24 +1857,55 @@ export async function deleteTransaction( export async function abortTransaction( ws: InternalWalletState, transactionId: string, - forceImmediateAbort?: boolean, ): Promise { - const { type, args: rest } = parseId("txn", transactionId); + const txId = parseTransactionIdentifier(transactionId); + if (!txId) { + throw Error("invalid transaction identifier"); + } - switch (type) { + switch (txId.tag) { case TransactionType.Payment: { - const proposalId = rest[0]; - await abortPay(ws, proposalId, forceImmediateAbort); + await abortPay(ws, txId.proposalId); break; } - case TransactionType.PeerPushDebit: { + case TransactionType.Withdrawal: { + await abortWithdrawalTransaction(ws, txId.withdrawalGroupId); break; } default: { - const unknownTxType: any = type; + const unknownTxType: any = txId.tag; throw Error( `can't abort a '${unknownTxType}' transaction: not yet implemented`, ); } } } + +export interface TransitionInfo { + oldTxState: TransactionState; + newTxState: TransactionState; +} + +/** + * Notify of a state transition if necessary. + */ +export function notifyTransition( + ws: InternalWalletState, + transactionId: string, + ti: TransitionInfo | undefined, +): void { + if ( + ti && + !( + ti.oldTxState.major === ti.newTxState.major && + ti.oldTxState.minor === ti.newTxState.minor + ) + ) { + ws.notify({ + type: NotificationType.TransactionStateTransition, + oldTxState: ti.oldTxState, + newTxState: ti.newTxState, + transactionId, + }); + } +} diff --git a/packages/taler-wallet-core/src/operations/withdraw.ts b/packages/taler-wallet-core/src/operations/withdraw.ts index 3f3eb3784..d1816de03 100644 --- a/packages/taler-wallet-core/src/operations/withdraw.ts +++ b/packages/taler-wallet-core/src/operations/withdraw.ts @@ -132,6 +132,7 @@ import { import { PendingTaskType, isWithdrawableDenom } from "../index.js"; import { constructTransactionIdentifier, + notifyTransition, stopLongpolling, } from "./transactions.js"; @@ -149,7 +150,7 @@ export async function suspendWithdrawalTransaction( withdrawalGroupId, }); stopLongpolling(ws, taskId); - const stateUpdate = await ws.db + const transitionInfo = await ws.db .mktx((x) => [x.withdrawalGroups]) .runReadWrite(async (tx) => { const wg = await tx.withdrawalGroups.get(withdrawalGroupId); @@ -198,24 +199,18 @@ export async function suspendWithdrawalTransaction( return undefined; }); - if (stateUpdate) { - ws.notify({ - type: NotificationType.TransactionStateTransition, - transactionId: constructTransactionIdentifier({ - tag: TransactionType.Withdrawal, - withdrawalGroupId, - }), - oldTxState: stateUpdate.oldTxState, - newTxState: stateUpdate.newTxState, - }); - } + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + withdrawalGroupId, + }); + notifyTransition(ws, transactionId, transitionInfo); } export async function resumeWithdrawalTransaction( ws: InternalWalletState, withdrawalGroupId: string, ) { - const stateUpdate = await ws.db + const transitionInfo = await ws.db .mktx((x) => [x.withdrawalGroups]) .runReadWrite(async (tx) => { const wg = await tx.withdrawalGroups.get(withdrawalGroupId); @@ -264,17 +259,11 @@ export async function resumeWithdrawalTransaction( return undefined; }); - if (stateUpdate) { - ws.notify({ - type: NotificationType.TransactionStateTransition, - transactionId: constructTransactionIdentifier({ - tag: TransactionType.Withdrawal, - withdrawalGroupId, - }), - oldTxState: stateUpdate.oldTxState, - newTxState: stateUpdate.newTxState, - }); - } + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + withdrawalGroupId, + }); + notifyTransition(ws, transactionId, transitionInfo); } export async function abortWithdrawalTransaction( @@ -285,8 +274,12 @@ export async function abortWithdrawalTransaction( tag: PendingTaskType.Withdraw, withdrawalGroupId, }); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + withdrawalGroupId, + }); stopLongpolling(ws, taskId); - const stateUpdate = await ws.db + const transitionInfo = await ws.db .mktx((x) => [x.withdrawalGroups]) .runReadWrite(async (tx) => { const wg = await tx.withdrawalGroups.get(withdrawalGroupId); @@ -339,18 +332,7 @@ export async function abortWithdrawalTransaction( } return undefined; }); - - if (stateUpdate) { - ws.notify({ - type: NotificationType.TransactionStateTransition, - transactionId: constructTransactionIdentifier({ - tag: TransactionType.Withdrawal, - withdrawalGroupId, - }), - oldTxState: stateUpdate.oldTxState, - newTxState: stateUpdate.newTxState, - }); - } + notifyTransition(ws, transactionId, transitionInfo); } // Called "cancel" in the spec right now, @@ -363,6 +345,10 @@ export async function cancelAbortingWithdrawalTransaction( tag: PendingTaskType.Withdraw, withdrawalGroupId, }); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + withdrawalGroupId, + }); stopLongpolling(ws, taskId); const stateUpdate = await ws.db .mktx((x) => [x.withdrawalGroups]) @@ -392,21 +378,9 @@ export async function cancelAbortingWithdrawalTransaction( } return undefined; }); - - if (stateUpdate) { - ws.notify({ - type: NotificationType.TransactionStateTransition, - transactionId: constructTransactionIdentifier({ - tag: TransactionType.Withdrawal, - withdrawalGroupId, - }), - oldTxState: stateUpdate.oldTxState, - newTxState: stateUpdate.newTxState, - }); - } + notifyTransition(ws, transactionId, stateUpdate); } - export function computeWithdrawalTransactionStatus( wgRecord: WithdrawalGroupRecord, ): TransactionState { @@ -1140,6 +1114,10 @@ async function queryReserve( withdrawalGroupId: string, cancellationToken: CancellationToken, ): Promise<{ ready: boolean }> { + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + withdrawalGroupId, + }); const withdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, { withdrawalGroupId, }); @@ -1190,25 +1168,31 @@ async function queryReserve( logger.trace(`got reserve status ${j2s(result.response)}`); - await ws.db + const transitionResult = await ws.db .mktx((x) => [x.withdrawalGroups]) .runReadWrite(async (tx) => { const wg = await tx.withdrawalGroups.get(withdrawalGroupId); if (!wg) { logger.warn(`withdrawal group ${withdrawalGroupId} not found`); - return; + return undefined; } + const txStateOld = computeWithdrawalTransactionStatus(wg); wg.status = WithdrawalGroupStatus.Ready; + const txStateNew = computeWithdrawalTransactionStatus(wg); wg.reserveBalanceAmount = Amounts.stringify(result.response.balance); await tx.withdrawalGroups.put(wg); + return { + oldTxState: txStateOld, + newTxState: txStateNew, + }; }); + notifyTransition(ws, transactionId, transitionResult); + + // FIXME: This notification is deprecated with DD37 ws.notify({ type: NotificationType.WithdrawalGroupReserveReady, - transactionId: makeTransactionId( - TransactionType.Withdrawal, - withdrawalGroupId, - ), + transactionId, }); return { ready: true }; @@ -1252,6 +1236,10 @@ export async function processWithdrawalGroup( } const retryTag = TaskIdentifiers.forWithdrawal(withdrawalGroup); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + withdrawalGroupId, + }); // We're already running! if (ws.activeLongpoll[retryTag]) { @@ -1322,17 +1310,24 @@ export async function processWithdrawalGroup( if (withdrawalGroup.denomsSel.selectedDenoms.length === 0) { logger.warn("Finishing empty withdrawal group (no denoms)"); - await ws.db + const transitionInfo = await ws.db .mktx((x) => [x.withdrawalGroups]) .runReadWrite(async (tx) => { const wg = await tx.withdrawalGroups.get(withdrawalGroupId); if (!wg) { - return; + return undefined; } + const txStatusOld = computeWithdrawalTransactionStatus(wg); wg.status = WithdrawalGroupStatus.Finished; wg.timestampFinish = TalerProtocolTimestamp.now(); + const txStatusNew = computeWithdrawalTransactionStatus(wg); await tx.withdrawalGroups.put(wg); + return { + oldTxState: txStatusOld, + newTxState: txStatusNew, + }; }); + notifyTransition(ws, transactionId, transitionInfo); return { type: OperationAttemptResultType.Finished, result: undefined, @@ -1421,6 +1416,7 @@ export async function processWithdrawalGroup( errorsPerCoin[x.coinIdx] = x.lastError; } }); + const oldTxState = computeWithdrawalTransactionStatus(wg); logger.info(`now withdrawn ${numFinished} of ${numTotalCoins} coins`); if (wg.timestampFinish === undefined && numFinished === numTotalCoins) { finishedForFirstTime = true; @@ -1428,10 +1424,15 @@ export async function processWithdrawalGroup( wg.status = WithdrawalGroupStatus.Finished; } + const newTxState = computeWithdrawalTransactionStatus(wg); await tx.withdrawalGroups.put(wg); return { kycInfo: wg.kycPending, + transitionInfo: { + oldTxState, + newTxState, + }, }; }); @@ -1439,6 +1440,8 @@ export async function processWithdrawalGroup( throw Error("withdrawal group does not exist anymore"); } + notifyTransition(ws, transactionId, res.transitionInfo); + const { kycInfo } = res; if (numKycRequired > 0) { @@ -1478,6 +1481,7 @@ export async function processWithdrawalGroup( ); } + // FIXME: Deprecated with DD37 if (finishedForFirstTime) { ws.notify({ type: NotificationType.WithdrawGroupFinished, @@ -1838,6 +1842,10 @@ async function registerReserveWithBank( .runReadOnly(async (tx) => { return await tx.withdrawalGroups.get(withdrawalGroupId); }); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + withdrawalGroupId, + }); switch (withdrawalGroup?.status) { case WithdrawalGroupStatus.WaitConfirmBank: case WithdrawalGroupStatus.RegisteringBank: @@ -1860,19 +1868,21 @@ async function registerReserveWithBank( selected_exchange: bankInfo.exchangePaytoUri, }; logger.info(`registering reserve with bank: ${j2s(reqBody)}`); - const httpResp = await ws.http.postJson(bankStatusUrl, reqBody, { + const httpResp = await ws.http.fetch(bankStatusUrl, { + method: "POST", + body: reqBody, timeout: getReserveRequestTimeout(withdrawalGroup), }); await readSuccessResponseJsonOrThrow( httpResp, codecForBankWithdrawalOperationPostResponse(), ); - await ws.db + const transitionInfo = await ws.db .mktx((x) => [x.withdrawalGroups]) .runReadWrite(async (tx) => { const r = await tx.withdrawalGroups.get(withdrawalGroupId); if (!r) { - return; + return undefined; } switch (r.status) { case WithdrawalGroupStatus.RegisteringBank: @@ -1887,9 +1897,18 @@ async function registerReserveWithBank( r.wgInfo.bankInfo.timestampReserveInfoPosted = AbsoluteTime.toTimestamp( AbsoluteTime.now(), ); + const oldTxState = computeWithdrawalTransactionStatus(r); r.status = WithdrawalGroupStatus.WaitConfirmBank; + const newTxState = computeWithdrawalTransactionStatus(r); await tx.withdrawalGroups.put(r); + return { + oldTxState, + newTxState, + }; }); + + notifyTransition(ws, transactionId, transitionInfo); + // FIXME: This notification is deprecated with DD37 ws.notify({ type: NotificationType.ReserveRegisteredWithBank }); } @@ -1904,6 +1923,10 @@ async function processReserveBankStatus( const withdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, { withdrawalGroupId, }); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + withdrawalGroupId, + }); switch (withdrawalGroup?.status) { case WithdrawalGroupStatus.WaitConfirmBank: case WithdrawalGroupStatus.RegisteringBank: @@ -1938,7 +1961,7 @@ async function processReserveBankStatus( if (status.aborted) { logger.info("bank aborted the withdrawal"); - await ws.db + const transitionInfo = await ws.db .mktx((x) => [x.withdrawalGroups]) .runReadWrite(async (tx) => { const r = await tx.withdrawalGroups.get(withdrawalGroupId); @@ -1956,10 +1979,17 @@ async function processReserveBankStatus( throw Error("invariant failed"); } const now = AbsoluteTime.toTimestamp(AbsoluteTime.now()); + const oldTxState = computeWithdrawalTransactionStatus(r); r.wgInfo.bankInfo.timestampBankConfirmed = now; r.status = WithdrawalGroupStatus.BankAborted; + const newTxState = computeWithdrawalTransactionStatus(r); await tx.withdrawalGroups.put(r); + return { + oldTxState, + newTxState, + } }); + notifyTransition(ws, transactionId, transitionInfo); return { status: BankStatusResultCode.Aborted, }; @@ -1977,12 +2007,12 @@ async function processReserveBankStatus( return await processReserveBankStatus(ws, withdrawalGroupId); } - await ws.db + const transitionInfo = await ws.db .mktx((x) => [x.withdrawalGroups]) .runReadWrite(async (tx) => { const r = await tx.withdrawalGroups.get(withdrawalGroupId); if (!r) { - return; + return undefined; } // Re-check reserve status within transaction switch (r.status) { @@ -1990,16 +2020,18 @@ async function processReserveBankStatus( case WithdrawalGroupStatus.WaitConfirmBank: break; default: - return; + return undefined; } if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) { throw Error("invariant failed"); } + const oldTxState = computeWithdrawalTransactionStatus(r); if (status.transfer_done) { logger.info("withdrawal: transfer confirmed by bank."); const now = AbsoluteTime.toTimestamp(AbsoluteTime.now()); r.wgInfo.bankInfo.timestampBankConfirmed = now; r.status = WithdrawalGroupStatus.QueryingStatus; + // FIXME: Notification is deprecated with DD37. ws.notify({ type: NotificationType.WithdrawalGroupBankConfirmed, transactionId: makeTransactionId( @@ -2012,9 +2044,16 @@ async function processReserveBankStatus( r.wgInfo.bankInfo.confirmUrl = status.confirm_transfer_url; r.senderWire = status.sender_wire; } + const newTxState = computeWithdrawalTransactionStatus(r); await tx.withdrawalGroups.put(r); + return { + oldTxState, + newTxState, + } }); + notifyTransition(ws, transactionId, transitionInfo); + if (status.transfer_done) { return { status: BankStatusResultCode.Done, @@ -2071,6 +2110,11 @@ export async function internalCreateWithdrawalGroup( withdrawalGroupId = encodeCrock(getRandomBytes(32)); } + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + withdrawalGroupId, + }); + await updateWithdrawalDenoms(ws, canonExchange); const denoms = await getCandidateWithdrawalDenoms(ws, canonExchange); @@ -2122,7 +2166,7 @@ export async function internalCreateWithdrawalGroup( exchangeInfo.exchange, ); - await ws.db + const transitionInfo = await ws.db .mktx((x) => [ x.withdrawalGroups, x.reserves, @@ -2151,8 +2195,19 @@ export async function internalCreateWithdrawalGroup( uids: [encodeCrock(getRandomBytes(32))], }); } + + const oldTxState = { + major: TransactionMajorState.None, + } + const newTxState = computeWithdrawalTransactionStatus(withdrawalGroup); + return { + oldTxState, + newTxState, + } }); + notifyTransition(ws, transactionId, transitionInfo); + return withdrawalGroup; } @@ -2225,6 +2280,10 @@ export async function acceptWithdrawalFromUri( }); const withdrawalGroupId = withdrawalGroup.withdrawalGroupId; + const transactionId = constructTaskIdentifier({ + tag: PendingTaskType.Withdraw, + withdrawalGroupId, + }); // We do this here, as the reserve should be registered before we return, // so that we can redirect the user to the bank's status page. @@ -2249,10 +2308,7 @@ export async function acceptWithdrawalFromUri( return { reservePub: withdrawalGroup.reservePub, confirmTransferUrl: withdrawInfo.confirmTransferUrl, - transactionId: makeTransactionId( - TransactionType.Withdrawal, - withdrawalGroupId, - ), + transactionId, }; } @@ -2285,6 +2341,10 @@ export async function createManualWithdrawal( }); const withdrawalGroupId = withdrawalGroup.withdrawalGroupId; + const transactionId = constructTaskIdentifier({ + tag: PendingTaskType.Withdraw, + withdrawalGroupId, + }); const exchangePaytoUris = await ws.db .mktx((x) => [ @@ -2313,9 +2373,6 @@ export async function createManualWithdrawal( return { reservePub: withdrawalGroup.reservePub, exchangePaytoUris: exchangePaytoUris, - transactionId: makeTransactionId( - TransactionType.Withdrawal, - withdrawalGroupId, - ), + transactionId, }; } diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts index 9ddf82319..f394aa9ca 100644 --- a/packages/taler-wallet-core/src/wallet-api-types.ts +++ b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -40,6 +40,7 @@ import { ApplyRefundResponse, BackupRecovery, BalancesResponse, + CancelAbortingTransactionRequest, CheckPeerPullCreditRequest, CheckPeerPullCreditResponse, CheckPeerPushDebitRequest, @@ -156,6 +157,7 @@ export enum WalletApiOperation { GetExchangeDetailedInfo = "getExchangeDetailedInfo", RetryPendingNow = "retryPendingNow", AbortTransaction = "abortTransaction", + CancelAbortingTransaction = "cancelAbortingTransaction", SuspendTransaction = "suspendTransaction", ResumeTransaction = "resumeTransaction", ConfirmPay = "confirmPay", @@ -327,6 +329,17 @@ export type AbortTransactionOp = { response: EmptyObject; }; +/** + * Cancel aborting a transaction + * + * For payment transactions, it puts the payment into an "aborting" state. + */ +export type CancelAbortingTransactionOp = { + op: WalletApiOperation.CancelAbortingTransaction; + request: CancelAbortingTransactionRequest; + response: EmptyObject; +}; + /** * Suspend a transaction */ @@ -922,6 +935,7 @@ export type WalletOperations = { [WalletApiOperation.WithdrawTestkudos]: WithdrawTestkudosOp; [WalletApiOperation.ConfirmPay]: ConfirmPayOp; [WalletApiOperation.AbortTransaction]: AbortTransactionOp; + [WalletApiOperation.CancelAbortingTransaction]: CancelAbortingTransactionOp; [WalletApiOperation.SuspendTransaction]: SuspendTransactionOp; [WalletApiOperation.ResumeTransaction]: ResumeTransactionOp; [WalletApiOperation.GetBalances]: GetBalancesOp; diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index ab9f43004..733c239f9 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -1221,7 +1221,7 @@ async function dispatchRequestInternal( } case WalletApiOperation.AbortTransaction: { const req = codecForAbortTransaction().decode(payload); - await abortTransaction(ws, req.transactionId, req.forceImmediateAbort); + await abortTransaction(ws, req.transactionId); return {}; } case WalletApiOperation.SuspendTransaction: { -- cgit v1.2.3