-withdrawal notifications

This commit is contained in:
Florian Dold 2023-05-02 10:59:50 +02:00
parent c4f5c83b8e
commit 16d30adf0d
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
6 changed files with 205 additions and 95 deletions

View File

@ -1680,21 +1680,15 @@ export const codecForResumeTransaction = (): Codec<ResumeTransactionRequest> =>
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<AbortTransactionRequest> =>
buildCodecForObject<AbortTransactionRequest>()
.property("transactionId", codecForString())
.property("forceImmediateAbort", codecOptional(codecForBoolean()))
.build("AbortTransactionRequest");
export interface DepositGroupFees {

View File

@ -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,
});
});
});

View File

@ -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<void> {
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,
});
}
}

View File

@ -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,
};
}

View File

@ -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;

View File

@ -1221,7 +1221,7 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
}
case WalletApiOperation.AbortTransaction: {
const req = codecForAbortTransaction().decode(payload);
await abortTransaction(ws, req.transactionId, req.forceImmediateAbort);
await abortTransaction(ws, req.transactionId);
return {};
}
case WalletApiOperation.SuspendTransaction: {