/*
This file is part of GNU Taler
(C) 2019 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
*/
/**
* Imports.
*/
import {
AbsoluteTime,
Amounts,
DepositTransactionTrackingState,
j2s,
Logger,
NotificationType,
OrderShortInfo,
PeerContractTerms,
RefundInfoShort,
RefundPaymentInfo,
stringifyPayPullUri,
stringifyPayPushUri,
TalerErrorCode,
Transaction,
TransactionByIdRequest,
TransactionIdStr,
TransactionMajorState,
TransactionRecordFilter,
TransactionsRequest,
TransactionsResponse,
TransactionState,
TransactionType,
WalletContractData,
WithdrawalType,
} from "@gnu-taler/taler-util";
import {
DepositElementStatus,
DepositGroupRecord,
ExchangeDetailsRecord,
OperationRetryRecord,
PeerPullCreditRecord,
PeerPullDebitRecordStatus,
PeerPullPaymentIncomingRecord,
PeerPushCreditStatus,
PeerPushDebitRecord,
PeerPushPaymentIncomingRecord,
PurchaseRecord,
PurchaseStatus,
RefreshGroupRecord,
RefreshOperationStatus,
RefundGroupRecord,
RewardRecord,
WithdrawalGroupRecord,
WithdrawalGroupStatus,
WithdrawalRecordType,
} from "../db.js";
import {
GetReadOnlyAccess,
PeerPushDebitStatus,
timestampPreciseFromDb,
timestampProtocolFromDb,
WalletStoresV1,
} from "../index.js";
import { InternalWalletState } from "../internal-wallet-state.js";
import { PendingTaskType } from "../pending-types.js";
import { assertUnreachable } from "../util/assertUnreachable.js";
import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
import {
constructTaskIdentifier,
resetPendingTaskTimeout,
TaskIdentifiers,
TombstoneTag,
} from "./common.js";
import {
abortDepositGroup,
computeDepositTransactionActions,
computeDepositTransactionStatus,
deleteDepositGroup,
failDepositTransaction,
resumeDepositGroup,
suspendDepositGroup,
} from "./deposits.js";
import { getExchangeDetails } from "./exchanges.js";
import {
abortPayMerchant,
computePayMerchantTransactionActions,
computePayMerchantTransactionState,
computeRefundTransactionState,
expectProposalDownload,
extractContractData,
failPaymentTransaction,
resumePayMerchant,
suspendPayMerchant,
} from "./pay-merchant.js";
import {
abortPeerPullCreditTransaction,
computePeerPullCreditTransactionActions,
computePeerPullCreditTransactionState,
failPeerPullCreditTransaction,
resumePeerPullCreditTransaction,
suspendPeerPullCreditTransaction,
} from "./pay-peer-pull-credit.js";
import {
abortPeerPullDebitTransaction,
computePeerPullDebitTransactionActions,
computePeerPullDebitTransactionState,
failPeerPullDebitTransaction,
resumePeerPullDebitTransaction,
suspendPeerPullDebitTransaction,
} from "./pay-peer-pull-debit.js";
import {
abortPeerPushCreditTransaction,
computePeerPushCreditTransactionActions,
computePeerPushCreditTransactionState,
failPeerPushCreditTransaction,
resumePeerPushCreditTransaction,
suspendPeerPushCreditTransaction,
} from "./pay-peer-push-credit.js";
import {
abortPeerPushDebitTransaction,
computePeerPushDebitTransactionActions,
computePeerPushDebitTransactionState,
failPeerPushDebitTransaction,
resumePeerPushDebitTransaction,
suspendPeerPushDebitTransaction,
} from "./pay-peer-push-debit.js";
import {
iterRecordsForDeposit,
iterRecordsForPeerPullDebit,
iterRecordsForPeerPullInitiation as iterRecordsForPeerPullCredit,
iterRecordsForPeerPushCredit,
iterRecordsForPeerPushInitiation as iterRecordsForPeerPushDebit,
iterRecordsForPurchase,
iterRecordsForRefresh,
iterRecordsForRefund,
iterRecordsForReward,
iterRecordsForWithdrawal,
} from "./pending.js";
import {
abortRefreshGroup,
computeRefreshTransactionActions,
computeRefreshTransactionState,
failRefreshGroup,
resumeRefreshGroup,
suspendRefreshGroup,
} from "./refresh.js";
import {
abortTipTransaction,
computeRewardTransactionStatus,
computeTipTransactionActions,
failTipTransaction,
resumeTipTransaction,
suspendRewardTransaction,
} from "./reward.js";
import {
abortWithdrawalTransaction,
augmentPaytoUrisForWithdrawal,
computeWithdrawalTransactionActions,
computeWithdrawalTransactionStatus,
failWithdrawalTransaction,
resumeWithdrawalTransaction,
suspendWithdrawalTransaction,
} from "./withdraw.js";
const logger = new Logger("taler-wallet-core:transactions.ts");
function shouldSkipCurrency(
transactionsRequest: TransactionsRequest | undefined,
currency: string,
): boolean {
if (!transactionsRequest?.currency) {
return false;
}
return transactionsRequest.currency.toLowerCase() !== currency.toLowerCase();
}
function shouldSkipSearch(
transactionsRequest: TransactionsRequest | undefined,
fields: string[],
): boolean {
if (!transactionsRequest?.search) {
return false;
}
const needle = transactionsRequest.search.trim();
for (const f of fields) {
if (f.indexOf(needle) >= 0) {
return false;
}
}
return true;
}
/**
* Fallback order of transactions that have the same timestamp.
*/
const txOrder: { [t in TransactionType]: number } = {
[TransactionType.Withdrawal]: 1,
[TransactionType.Reward]: 2,
[TransactionType.Payment]: 3,
[TransactionType.PeerPullCredit]: 4,
[TransactionType.PeerPullDebit]: 5,
[TransactionType.PeerPushCredit]: 6,
[TransactionType.PeerPushDebit]: 7,
[TransactionType.Refund]: 8,
[TransactionType.Deposit]: 9,
[TransactionType.Refresh]: 10,
[TransactionType.InternalWithdrawal]: 12,
};
export async function getTransactionById(
ws: InternalWalletState,
req: TransactionByIdRequest,
): Promise {
const parsedTx = parseTransactionIdentifier(req.transactionId);
if (!parsedTx) {
throw Error("invalid transaction ID");
}
switch (parsedTx.tag) {
case TransactionType.InternalWithdrawal:
case TransactionType.Withdrawal: {
const withdrawalGroupId = parsedTx.withdrawalGroupId;
return await ws.db
.mktx((x) => [
x.withdrawalGroups,
x.exchangeDetails,
x.exchanges,
x.operationRetries,
])
.runReadWrite(async (tx) => {
const withdrawalGroupRecord = await tx.withdrawalGroups.get(
withdrawalGroupId,
);
if (!withdrawalGroupRecord) throw Error("not found");
const opId = TaskIdentifiers.forWithdrawal(withdrawalGroupRecord);
const ort = await tx.operationRetries.get(opId);
if (
withdrawalGroupRecord.wgInfo.withdrawalType ===
WithdrawalRecordType.BankIntegrated
) {
return buildTransactionForBankIntegratedWithdraw(
withdrawalGroupRecord,
ort,
);
}
const exchangeDetails = await getExchangeDetails(
tx,
withdrawalGroupRecord.exchangeBaseUrl,
);
if (!exchangeDetails) throw Error("not exchange details");
return buildTransactionForManualWithdraw(
withdrawalGroupRecord,
exchangeDetails,
ort,
);
});
}
case TransactionType.Payment: {
const proposalId = parsedTx.proposalId;
return await ws.db
.mktx((x) => [
x.purchases,
x.tombstones,
x.operationRetries,
x.contractTerms,
])
.runReadWrite(async (tx) => {
const purchase = await tx.purchases.get(proposalId);
if (!purchase) throw Error("not found");
const download = await expectProposalDownload(ws, purchase, tx);
const contractData = download.contractData;
const payOpId = TaskIdentifiers.forPay(purchase);
const payRetryRecord = await tx.operationRetries.get(payOpId);
return buildTransactionForPurchase(
purchase,
contractData,
[], // FIXME: Add refunds from refund group records here.
payRetryRecord,
);
});
}
case TransactionType.Refresh: {
// FIXME: We should return info about the refresh here!
throw Error(`no tx for refresh`);
}
case TransactionType.Reward: {
const tipId = parsedTx.walletRewardId;
return await ws.db
.mktx((x) => [x.rewards, x.operationRetries])
.runReadWrite(async (tx) => {
const tipRecord = await tx.rewards.get(tipId);
if (!tipRecord) throw Error("not found");
const retries = await tx.operationRetries.get(
TaskIdentifiers.forTipPickup(tipRecord),
);
return buildTransactionForTip(tipRecord, retries);
});
}
case TransactionType.Deposit: {
const depositGroupId = parsedTx.depositGroupId;
return await ws.db
.mktx((x) => [x.depositGroups, x.operationRetries])
.runReadWrite(async (tx) => {
const depositRecord = await tx.depositGroups.get(depositGroupId);
if (!depositRecord) throw Error("not found");
const retries = await tx.operationRetries.get(
TaskIdentifiers.forDeposit(depositRecord),
);
return buildTransactionForDeposit(depositRecord, retries);
});
}
case TransactionType.Refund: {
return await ws.db
.mktx((x) => [x.refundGroups, x.contractTerms, x.purchases])
.runReadOnly(async (tx) => {
const refundRecord = await tx.refundGroups.get(
parsedTx.refundGroupId,
);
if (!refundRecord) {
throw Error("not found");
}
const contractData = await lookupMaybeContractData(
tx,
refundRecord?.proposalId,
);
return buildTransactionForRefund(refundRecord, contractData);
});
}
case TransactionType.PeerPullDebit: {
return await ws.db
.mktx((x) => [x.peerPullDebit, x.contractTerms])
.runReadWrite(async (tx) => {
const debit = await tx.peerPullDebit.get(parsedTx.peerPullDebitId);
if (!debit) throw Error("not found");
const contractTermsRec = await tx.contractTerms.get(
debit.contractTermsHash,
);
if (!contractTermsRec)
throw Error("contract terms for peer-pull-debit not found");
return buildTransactionForPullPaymentDebit(
debit,
contractTermsRec.contractTermsRaw,
);
});
}
case TransactionType.PeerPushDebit: {
return await ws.db
.mktx((x) => [x.peerPushDebit, x.contractTerms])
.runReadWrite(async (tx) => {
const debit = await tx.peerPushDebit.get(parsedTx.pursePub);
if (!debit) throw Error("not found");
const ct = await tx.contractTerms.get(debit.contractTermsHash);
checkDbInvariant(!!ct);
return buildTransactionForPushPaymentDebit(
debit,
ct.contractTermsRaw,
);
});
}
case TransactionType.PeerPushCredit: {
const peerPushCreditId = parsedTx.peerPushCreditId;
return await ws.db
.mktx((x) => [
x.peerPushCredit,
x.contractTerms,
x.withdrawalGroups,
x.operationRetries,
])
.runReadWrite(async (tx) => {
const pushInc = await tx.peerPushCredit.get(peerPushCreditId);
if (!pushInc) throw Error("not found");
const ct = await tx.contractTerms.get(pushInc.contractTermsHash);
checkDbInvariant(!!ct);
let wg: WithdrawalGroupRecord | undefined = undefined;
let wgOrt: OperationRetryRecord | undefined = undefined;
if (pushInc.withdrawalGroupId) {
wg = await tx.withdrawalGroups.get(pushInc.withdrawalGroupId);
if (wg) {
const withdrawalOpId = TaskIdentifiers.forWithdrawal(wg);
wgOrt = await tx.operationRetries.get(withdrawalOpId);
}
}
const pushIncOpId = TaskIdentifiers.forPeerPushCredit(pushInc);
let pushIncOrt = await tx.operationRetries.get(pushIncOpId);
return buildTransactionForPeerPushCredit(
pushInc,
pushIncOrt,
ct.contractTermsRaw,
wg,
wgOrt,
);
});
}
case TransactionType.PeerPullCredit: {
const pursePub = parsedTx.pursePub;
return await ws.db
.mktx((x) => [
x.peerPullCredit,
x.contractTerms,
x.withdrawalGroups,
x.operationRetries,
])
.runReadWrite(async (tx) => {
const pushInc = await tx.peerPullCredit.get(pursePub);
if (!pushInc) throw Error("not found");
const ct = await tx.contractTerms.get(pushInc.contractTermsHash);
checkDbInvariant(!!ct);
let wg: WithdrawalGroupRecord | undefined = undefined;
let wgOrt: OperationRetryRecord | undefined = undefined;
if (pushInc.withdrawalGroupId) {
wg = await tx.withdrawalGroups.get(pushInc.withdrawalGroupId);
if (wg) {
const withdrawalOpId = TaskIdentifiers.forWithdrawal(wg);
wgOrt = await tx.operationRetries.get(withdrawalOpId);
}
}
const pushIncOpId =
TaskIdentifiers.forPeerPullPaymentInitiation(pushInc);
let pushIncOrt = await tx.operationRetries.get(pushIncOpId);
return buildTransactionForPeerPullCredit(
pushInc,
pushIncOrt,
ct.contractTermsRaw,
wg,
wgOrt,
);
});
}
}
}
function buildTransactionForPushPaymentDebit(
pi: PeerPushDebitRecord,
contractTerms: PeerContractTerms,
ort?: OperationRetryRecord,
): Transaction {
let talerUri: string | undefined = undefined;
switch (pi.status) {
case PeerPushDebitStatus.PendingReady:
case PeerPushDebitStatus.SuspendedReady:
talerUri = stringifyPayPushUri({
exchangeBaseUrl: pi.exchangeBaseUrl,
contractPriv: pi.contractPriv,
});
}
return {
type: TransactionType.PeerPushDebit,
txState: computePeerPushDebitTransactionState(pi),
txActions: computePeerPushDebitTransactionActions(pi),
amountEffective: pi.totalCost,
amountRaw: pi.amount,
exchangeBaseUrl: pi.exchangeBaseUrl,
info: {
expiration: contractTerms.purse_expiration,
summary: contractTerms.summary,
},
timestamp: timestampPreciseFromDb(pi.timestampCreated),
talerUri,
transactionId: constructTransactionIdentifier({
tag: TransactionType.PeerPushDebit,
pursePub: pi.pursePub,
}),
...(ort?.lastError ? { error: ort.lastError } : {}),
};
}
function buildTransactionForPullPaymentDebit(
pi: PeerPullPaymentIncomingRecord,
contractTerms: PeerContractTerms,
ort?: OperationRetryRecord,
): Transaction {
return {
type: TransactionType.PeerPullDebit,
txState: computePeerPullDebitTransactionState(pi),
txActions: computePeerPullDebitTransactionActions(pi),
amountEffective: pi.coinSel?.totalCost
? pi.coinSel?.totalCost
: Amounts.stringify(pi.amount),
amountRaw: Amounts.stringify(pi.amount),
exchangeBaseUrl: pi.exchangeBaseUrl,
info: {
expiration: contractTerms.purse_expiration,
summary: contractTerms.summary,
},
timestamp: timestampPreciseFromDb(pi.timestampCreated),
transactionId: constructTransactionIdentifier({
tag: TransactionType.PeerPullDebit,
peerPullDebitId: pi.peerPullDebitId,
}),
...(ort?.lastError ? { error: ort.lastError } : {}),
};
}
function buildTransactionForPeerPullCredit(
pullCredit: PeerPullCreditRecord,
pullCreditOrt: OperationRetryRecord | undefined,
peerContractTerms: PeerContractTerms,
wsr: WithdrawalGroupRecord | undefined,
wsrOrt: OperationRetryRecord | undefined,
): Transaction {
if (wsr) {
if (wsr.wgInfo.withdrawalType !== WithdrawalRecordType.PeerPullCredit) {
throw Error(`Unexpected withdrawalType: ${wsr.wgInfo.withdrawalType}`);
}
/**
* FIXME: this should be handled in the withdrawal process.
* PeerPull withdrawal fails until reserve have funds but it is not
* an error from the user perspective.
*/
const silentWithdrawalErrorForInvoice =
wsrOrt?.lastError &&
wsrOrt.lastError.code ===
TalerErrorCode.WALLET_WITHDRAWAL_GROUP_INCOMPLETE &&
Object.values(wsrOrt.lastError.errorsPerCoin ?? {}).every((e) => {
return (
e.code === TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR &&
e.httpStatusCode === 409
);
});
return {
type: TransactionType.PeerPullCredit,
txState: computePeerPullCreditTransactionState(pullCredit),
txActions: computePeerPullCreditTransactionActions(pullCredit),
amountEffective: Amounts.stringify(wsr.denomsSel.totalCoinValue),
amountRaw: Amounts.stringify(wsr.instructedAmount),
exchangeBaseUrl: wsr.exchangeBaseUrl,
timestamp: timestampPreciseFromDb(pullCredit.mergeTimestamp),
info: {
expiration: peerContractTerms.purse_expiration,
summary: peerContractTerms.summary,
},
talerUri: stringifyPayPullUri({
exchangeBaseUrl: wsr.exchangeBaseUrl,
contractPriv: wsr.wgInfo.contractPriv,
}),
transactionId: constructTransactionIdentifier({
tag: TransactionType.PeerPullCredit,
pursePub: pullCredit.pursePub,
}),
kycUrl: pullCredit.kycUrl,
...(wsrOrt?.lastError
? {
error: silentWithdrawalErrorForInvoice
? undefined
: wsrOrt.lastError,
}
: {}),
};
}
return {
type: TransactionType.PeerPullCredit,
txState: computePeerPullCreditTransactionState(pullCredit),
txActions: computePeerPullCreditTransactionActions(pullCredit),
amountEffective: Amounts.stringify(pullCredit.estimatedAmountEffective),
amountRaw: Amounts.stringify(peerContractTerms.amount),
exchangeBaseUrl: pullCredit.exchangeBaseUrl,
timestamp: timestampPreciseFromDb(pullCredit.mergeTimestamp),
info: {
expiration: peerContractTerms.purse_expiration,
summary: peerContractTerms.summary,
},
talerUri: stringifyPayPullUri({
exchangeBaseUrl: pullCredit.exchangeBaseUrl,
contractPriv: pullCredit.contractPriv,
}),
transactionId: constructTransactionIdentifier({
tag: TransactionType.PeerPullCredit,
pursePub: pullCredit.pursePub,
}),
kycUrl: pullCredit.kycUrl,
...(pullCreditOrt?.lastError ? { error: pullCreditOrt.lastError } : {}),
};
}
function buildTransactionForPeerPushCredit(
pushInc: PeerPushPaymentIncomingRecord,
pushOrt: OperationRetryRecord | undefined,
peerContractTerms: PeerContractTerms,
wsr: WithdrawalGroupRecord | undefined,
wsrOrt: OperationRetryRecord | undefined,
): Transaction {
if (wsr) {
if (wsr.wgInfo.withdrawalType !== WithdrawalRecordType.PeerPushCredit) {
throw Error("invalid withdrawal group type for push payment credit");
}
return {
type: TransactionType.PeerPushCredit,
txState: computePeerPushCreditTransactionState(pushInc),
txActions: computePeerPushCreditTransactionActions(pushInc),
amountEffective: Amounts.stringify(wsr.denomsSel.totalCoinValue),
amountRaw: Amounts.stringify(wsr.instructedAmount),
exchangeBaseUrl: wsr.exchangeBaseUrl,
info: {
expiration: peerContractTerms.purse_expiration,
summary: peerContractTerms.summary,
},
timestamp: timestampPreciseFromDb(wsr.timestampStart),
transactionId: constructTransactionIdentifier({
tag: TransactionType.PeerPushCredit,
peerPushCreditId: pushInc.peerPushCreditId,
}),
kycUrl: pushInc.kycUrl,
...(wsrOrt?.lastError ? { error: wsrOrt.lastError } : {}),
};
}
return {
type: TransactionType.PeerPushCredit,
txState: computePeerPushCreditTransactionState(pushInc),
txActions: computePeerPushCreditTransactionActions(pushInc),
// FIXME: This is wrong, needs to consider fees!
amountEffective: Amounts.stringify(peerContractTerms.amount),
amountRaw: Amounts.stringify(peerContractTerms.amount),
exchangeBaseUrl: pushInc.exchangeBaseUrl,
info: {
expiration: peerContractTerms.purse_expiration,
summary: peerContractTerms.summary,
},
kycUrl: pushInc.kycUrl,
timestamp: timestampPreciseFromDb(pushInc.timestamp),
transactionId: constructTransactionIdentifier({
tag: TransactionType.PeerPushCredit,
peerPushCreditId: pushInc.peerPushCreditId,
}),
...(pushOrt?.lastError ? { error: pushOrt.lastError } : {}),
};
}
function buildTransactionForBankIntegratedWithdraw(
wgRecord: WithdrawalGroupRecord,
ort?: OperationRetryRecord,
): Transaction {
if (wgRecord.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated)
throw Error("");
return {
type: TransactionType.Withdrawal,
txState: computeWithdrawalTransactionStatus(wgRecord),
txActions: computeWithdrawalTransactionActions(wgRecord),
amountEffective: Amounts.stringify(wgRecord.denomsSel.totalCoinValue),
amountRaw: Amounts.stringify(wgRecord.instructedAmount),
withdrawalDetails: {
type: WithdrawalType.TalerBankIntegrationApi,
confirmed: wgRecord.wgInfo.bankInfo.timestampBankConfirmed ? true : false,
exchangeCreditAccountDetails: wgRecord.wgInfo.exchangeCreditAccounts,
reservePub: wgRecord.reservePub,
bankConfirmationUrl: wgRecord.wgInfo.bankInfo.confirmUrl,
reserveIsReady:
wgRecord.status === WithdrawalGroupStatus.Done ||
wgRecord.status === WithdrawalGroupStatus.PendingReady,
},
kycUrl: wgRecord.kycUrl,
exchangeBaseUrl: wgRecord.exchangeBaseUrl,
timestamp: timestampPreciseFromDb(wgRecord.timestampStart),
transactionId: constructTransactionIdentifier({
tag: TransactionType.Withdrawal,
withdrawalGroupId: wgRecord.withdrawalGroupId,
}),
...(ort?.lastError ? { error: ort.lastError } : {}),
};
}
function buildTransactionForManualWithdraw(
withdrawalGroup: WithdrawalGroupRecord,
exchangeDetails: ExchangeDetailsRecord,
ort?: OperationRetryRecord,
): Transaction {
if (withdrawalGroup.wgInfo.withdrawalType !== WithdrawalRecordType.BankManual)
throw Error("");
const plainPaytoUris =
exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ?? [];
const exchangePaytoUris = augmentPaytoUrisForWithdrawal(
plainPaytoUris,
withdrawalGroup.reservePub,
withdrawalGroup.instructedAmount,
);
return {
type: TransactionType.Withdrawal,
txState: computeWithdrawalTransactionStatus(withdrawalGroup),
txActions: computeWithdrawalTransactionActions(withdrawalGroup),
amountEffective: Amounts.stringify(
withdrawalGroup.denomsSel.totalCoinValue,
),
amountRaw: Amounts.stringify(withdrawalGroup.instructedAmount),
withdrawalDetails: {
type: WithdrawalType.ManualTransfer,
reservePub: withdrawalGroup.reservePub,
exchangePaytoUris,
exchangeCreditAccountDetails: withdrawalGroup.wgInfo.exchangeCreditAccounts,
reserveIsReady:
withdrawalGroup.status === WithdrawalGroupStatus.Done ||
withdrawalGroup.status === WithdrawalGroupStatus.PendingReady,
},
kycUrl: withdrawalGroup.kycUrl,
exchangeBaseUrl: withdrawalGroup.exchangeBaseUrl,
timestamp: timestampPreciseFromDb(withdrawalGroup.timestampStart),
transactionId: constructTransactionIdentifier({
tag: TransactionType.Withdrawal,
withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
}),
...(ort?.lastError ? { error: ort.lastError } : {}),
};
}
function buildTransactionForRefund(
refundRecord: RefundGroupRecord,
maybeContractData: WalletContractData | undefined,
): Transaction {
let paymentInfo: RefundPaymentInfo | undefined = undefined;
if (maybeContractData) {
paymentInfo = {
merchant: maybeContractData.merchant,
summary: maybeContractData.summary,
summary_i18n: maybeContractData.summaryI18n,
};
}
return {
type: TransactionType.Refund,
amountEffective: refundRecord.amountEffective,
amountRaw: refundRecord.amountRaw,
refundedTransactionId: constructTransactionIdentifier({
tag: TransactionType.Payment,
proposalId: refundRecord.proposalId,
}),
timestamp: timestampPreciseFromDb(refundRecord.timestampCreated),
transactionId: constructTransactionIdentifier({
tag: TransactionType.Refund,
refundGroupId: refundRecord.refundGroupId,
}),
txState: computeRefundTransactionState(refundRecord),
txActions: [],
paymentInfo,
};
}
function buildTransactionForRefresh(
refreshGroupRecord: RefreshGroupRecord,
ort?: OperationRetryRecord,
): Transaction {
const inputAmount = Amounts.sumOrZero(
refreshGroupRecord.currency,
refreshGroupRecord.inputPerCoin,
).amount;
const outputAmount = Amounts.sumOrZero(
refreshGroupRecord.currency,
refreshGroupRecord.expectedOutputPerCoin,
).amount;
return {
type: TransactionType.Refresh,
txState: computeRefreshTransactionState(refreshGroupRecord),
txActions: computeRefreshTransactionActions(refreshGroupRecord),
refreshReason: refreshGroupRecord.reason,
amountEffective: Amounts.stringify(
Amounts.zeroOfCurrency(refreshGroupRecord.currency),
),
amountRaw: Amounts.stringify(
Amounts.zeroOfCurrency(refreshGroupRecord.currency),
),
refreshInputAmount: Amounts.stringify(inputAmount),
refreshOutputAmount: Amounts.stringify(outputAmount),
originatingTransactionId:
refreshGroupRecord.reasonDetails?.originatingTransactionId,
timestamp: timestampPreciseFromDb(refreshGroupRecord.timestampCreated),
transactionId: constructTransactionIdentifier({
tag: TransactionType.Refresh,
refreshGroupId: refreshGroupRecord.refreshGroupId,
}),
...(ort?.lastError ? { error: ort.lastError } : {}),
};
}
function buildTransactionForDeposit(
dg: DepositGroupRecord,
ort?: OperationRetryRecord,
): Transaction {
let deposited = true;
for (const d of dg.statusPerCoin) {
if (d == DepositElementStatus.DepositPending) {
deposited = false;
}
}
const trackingState: DepositTransactionTrackingState[] = [];
for (const ts of Object.values(dg.trackingState ?? {})) {
trackingState.push({
amountRaw: ts.amountRaw,
timestampExecuted: timestampProtocolFromDb(ts.timestampExecuted),
wireFee: ts.wireFee,
wireTransferId: ts.wireTransferId,
});
}
return {
type: TransactionType.Deposit,
txState: computeDepositTransactionStatus(dg),
txActions: computeDepositTransactionActions(dg),
amountRaw: Amounts.stringify(dg.counterpartyEffectiveDepositAmount),
amountEffective: Amounts.stringify(dg.totalPayCost),
timestamp: timestampPreciseFromDb(dg.timestampCreated),
targetPaytoUri: dg.wire.payto_uri,
wireTransferDeadline: timestampProtocolFromDb(dg.wireTransferDeadline),
transactionId: constructTransactionIdentifier({
tag: TransactionType.Deposit,
depositGroupId: dg.depositGroupId,
}),
wireTransferProgress:
(100 *
dg.statusPerCoin.reduce(
(prev, cur) => prev + (cur === DepositElementStatus.Wired ? 1 : 0),
0,
)) /
dg.statusPerCoin.length,
depositGroupId: dg.depositGroupId,
trackingState,
deposited,
...(ort?.lastError ? { error: ort.lastError } : {}),
};
}
function buildTransactionForTip(
tipRecord: RewardRecord,
ort?: OperationRetryRecord,
): Transaction {
checkLogicInvariant(!!tipRecord.acceptedTimestamp);
return {
type: TransactionType.Reward,
txState: computeRewardTransactionStatus(tipRecord),
txActions: computeTipTransactionActions(tipRecord),
amountEffective: Amounts.stringify(tipRecord.rewardAmountEffective),
amountRaw: Amounts.stringify(tipRecord.rewardAmountRaw),
timestamp: timestampPreciseFromDb(tipRecord.acceptedTimestamp),
transactionId: constructTransactionIdentifier({
tag: TransactionType.Reward,
walletRewardId: tipRecord.walletRewardId,
}),
merchantBaseUrl: tipRecord.merchantBaseUrl,
...(ort?.lastError ? { error: ort.lastError } : {}),
};
}
async function lookupMaybeContractData(
tx: GetReadOnlyAccess<{
purchases: typeof WalletStoresV1.purchases;
contractTerms: typeof WalletStoresV1.contractTerms;
}>,
proposalId: string,
): Promise {
let contractData: WalletContractData | undefined = undefined;
const purchaseTx = await tx.purchases.get(proposalId);
if (purchaseTx && purchaseTx.download) {
const download = purchaseTx.download;
const contractTermsRecord = await tx.contractTerms.get(
download.contractTermsHash,
);
if (!contractTermsRecord) {
return;
}
contractData = extractContractData(
contractTermsRecord?.contractTermsRaw,
download.contractTermsHash,
download.contractTermsMerchantSig,
);
}
return contractData;
}
async function buildTransactionForPurchase(
purchaseRecord: PurchaseRecord,
contractData: WalletContractData,
refundsInfo: RefundGroupRecord[],
ort?: OperationRetryRecord,
): Promise {
const zero = Amounts.zeroOfAmount(contractData.amount);
const info: OrderShortInfo = {
merchant: contractData.merchant,
orderId: contractData.orderId,
summary: contractData.summary,
summary_i18n: contractData.summaryI18n,
contractTermsHash: contractData.contractTermsHash,
};
if (contractData.fulfillmentUrl !== "") {
info.fulfillmentUrl = contractData.fulfillmentUrl;
}
const refunds: RefundInfoShort[] = [];
const timestamp = purchaseRecord.timestampAccept;
checkDbInvariant(!!timestamp);
checkDbInvariant(!!purchaseRecord.payInfo);
return {
type: TransactionType.Payment,
txState: computePayMerchantTransactionState(purchaseRecord),
txActions: computePayMerchantTransactionActions(purchaseRecord),
amountRaw: Amounts.stringify(contractData.amount),
amountEffective: Amounts.stringify(purchaseRecord.payInfo.totalPayCost),
totalRefundRaw: Amounts.stringify(zero), // FIXME!
totalRefundEffective: Amounts.stringify(zero), // FIXME!
refundPending:
purchaseRecord.refundAmountAwaiting === undefined
? undefined
: Amounts.stringify(purchaseRecord.refundAmountAwaiting),
refunds,
posConfirmation: purchaseRecord.posConfirmation,
timestamp: timestampPreciseFromDb(timestamp),
transactionId: constructTransactionIdentifier({
tag: TransactionType.Payment,
proposalId: purchaseRecord.proposalId,
}),
proposalId: purchaseRecord.proposalId,
info,
refundQueryActive:
purchaseRecord.purchaseStatus === PurchaseStatus.PendingQueryingRefund,
...(ort?.lastError ? { error: ort.lastError } : {}),
};
}
/**
* Retrieve the full event history for this wallet.
*/
export async function getTransactions(
ws: InternalWalletState,
transactionsRequest?: TransactionsRequest,
): Promise {
const transactions: Transaction[] = [];
const filter: TransactionRecordFilter = {};
if (transactionsRequest?.filterByState) {
filter.onlyState = transactionsRequest.filterByState;
}
await ws.db
.mktx((x) => [
x.coins,
x.denominations,
x.depositGroups,
x.exchangeDetails,
x.exchanges,
x.operationRetries,
x.peerPullDebit,
x.peerPushDebit,
x.peerPushCredit,
x.peerPullCredit,
x.planchets,
x.purchases,
x.contractTerms,
x.recoupGroups,
x.rewards,
x.tombstones,
x.withdrawalGroups,
x.refreshGroups,
x.refundGroups,
])
.runReadOnly(async (tx) => {
await iterRecordsForPeerPushDebit(tx, filter, async (pi) => {
const amount = Amounts.parseOrThrow(pi.amount);
if (shouldSkipCurrency(transactionsRequest, amount.currency)) {
return;
}
if (shouldSkipSearch(transactionsRequest, [])) {
return;
}
const ct = await tx.contractTerms.get(pi.contractTermsHash);
checkDbInvariant(!!ct);
transactions.push(
buildTransactionForPushPaymentDebit(pi, ct.contractTermsRaw),
);
});
await iterRecordsForPeerPullDebit(tx, filter, async (pi) => {
const amount = Amounts.parseOrThrow(pi.amount);
if (shouldSkipCurrency(transactionsRequest, amount.currency)) {
return;
}
if (shouldSkipSearch(transactionsRequest, [])) {
return;
}
if (
pi.status !== PeerPullDebitRecordStatus.PendingDeposit &&
pi.status !== PeerPullDebitRecordStatus.Done
) {
// FIXME: Why?!
return;
}
const contractTermsRec = await tx.contractTerms.get(
pi.contractTermsHash,
);
if (!contractTermsRec) {
return;
}
transactions.push(
buildTransactionForPullPaymentDebit(
pi,
contractTermsRec.contractTermsRaw,
),
);
});
await iterRecordsForPeerPushCredit(tx, filter, async (pi) => {
if (!pi.currency) {
// Legacy transaction
return;
}
if (shouldSkipCurrency(transactionsRequest, pi.currency)) {
return;
}
if (shouldSkipSearch(transactionsRequest, [])) {
return;
}
if (pi.status === PeerPushCreditStatus.DialogProposed) {
// We don't report proposed push credit transactions, user needs
// to scan URI again and confirm to see it.
return;
}
const ct = await tx.contractTerms.get(pi.contractTermsHash);
let wg: WithdrawalGroupRecord | undefined = undefined;
let wgOrt: OperationRetryRecord | undefined = undefined;
if (pi.withdrawalGroupId) {
wg = await tx.withdrawalGroups.get(pi.withdrawalGroupId);
if (wg) {
const withdrawalOpId = TaskIdentifiers.forWithdrawal(wg);
wgOrt = await tx.operationRetries.get(withdrawalOpId);
}
}
const pushIncOpId = TaskIdentifiers.forPeerPushCredit(pi);
let pushIncOrt = await tx.operationRetries.get(pushIncOpId);
checkDbInvariant(!!ct);
transactions.push(
buildTransactionForPeerPushCredit(
pi,
pushIncOrt,
ct.contractTermsRaw,
wg,
wgOrt,
),
);
});
await iterRecordsForPeerPullCredit(tx, filter, async (pi) => {
const currency = Amounts.currencyOf(pi.amount);
if (shouldSkipCurrency(transactionsRequest, currency)) {
return;
}
if (shouldSkipSearch(transactionsRequest, [])) {
return;
}
const ct = await tx.contractTerms.get(pi.contractTermsHash);
let wg: WithdrawalGroupRecord | undefined = undefined;
let wgOrt: OperationRetryRecord | undefined = undefined;
if (pi.withdrawalGroupId) {
wg = await tx.withdrawalGroups.get(pi.withdrawalGroupId);
if (wg) {
const withdrawalOpId = TaskIdentifiers.forWithdrawal(wg);
wgOrt = await tx.operationRetries.get(withdrawalOpId);
}
}
const pushIncOpId = TaskIdentifiers.forPeerPullPaymentInitiation(pi);
let pushIncOrt = await tx.operationRetries.get(pushIncOpId);
checkDbInvariant(!!ct);
transactions.push(
buildTransactionForPeerPullCredit(
pi,
pushIncOrt,
ct.contractTermsRaw,
wg,
wgOrt,
),
);
});
await iterRecordsForRefund(tx, filter, async (refundGroup) => {
const currency = Amounts.currencyOf(refundGroup.amountRaw);
if (shouldSkipCurrency(transactionsRequest, currency)) {
return;
}
const contractData = await lookupMaybeContractData(
tx,
refundGroup.proposalId,
);
transactions.push(buildTransactionForRefund(refundGroup, contractData));
});
await iterRecordsForRefresh(tx, filter, async (rg) => {
if (shouldSkipCurrency(transactionsRequest, rg.currency)) {
return;
}
let required = false;
const opId = TaskIdentifiers.forRefresh(rg);
if (transactionsRequest?.includeRefreshes) {
required = true;
} else if (rg.operationStatus !== RefreshOperationStatus.Finished) {
const ort = await tx.operationRetries.get(opId);
if (ort) {
required = true;
}
}
if (required) {
const ort = await tx.operationRetries.get(opId);
transactions.push(buildTransactionForRefresh(rg, ort));
}
});
await iterRecordsForWithdrawal(tx, filter, async (wsr) => {
if (
shouldSkipCurrency(
transactionsRequest,
Amounts.currencyOf(wsr.rawWithdrawalAmount),
)
) {
return;
}
if (shouldSkipSearch(transactionsRequest, [])) {
return;
}
const opId = TaskIdentifiers.forWithdrawal(wsr);
const ort = await tx.operationRetries.get(opId);
switch (wsr.wgInfo.withdrawalType) {
case WithdrawalRecordType.PeerPullCredit:
// Will be reported by the corresponding p2p transaction.
// FIXME: If this is an orphan withdrawal, still report it as a withdrawal!
// FIXME: Still report if requested with verbose option?
return;
case WithdrawalRecordType.PeerPushCredit:
// Will be reported by the corresponding p2p transaction.
// FIXME: If this is an orphan withdrawal, still report it as a withdrawal!
// FIXME: Still report if requested with verbose option?
return;
case WithdrawalRecordType.BankIntegrated:
transactions.push(
buildTransactionForBankIntegratedWithdraw(wsr, ort),
);
return;
case WithdrawalRecordType.BankManual: {
const exchangeDetails = await getExchangeDetails(
tx,
wsr.exchangeBaseUrl,
);
if (!exchangeDetails) {
// FIXME: report somehow
return;
}
transactions.push(
buildTransactionForManualWithdraw(wsr, exchangeDetails, ort),
);
return;
}
case WithdrawalRecordType.Recoup:
// FIXME: Do we also report a transaction here?
return;
}
});
await iterRecordsForDeposit(tx, filter, async (dg) => {
const amount = Amounts.parseOrThrow(dg.amount);
if (shouldSkipCurrency(transactionsRequest, amount.currency)) {
return;
}
const opId = TaskIdentifiers.forDeposit(dg);
const retryRecord = await tx.operationRetries.get(opId);
transactions.push(buildTransactionForDeposit(dg, retryRecord));
});
await iterRecordsForPurchase(tx, filter, async (purchase) => {
const download = purchase.download;
if (!download) {
return;
}
if (!purchase.payInfo) {
return;
}
if (shouldSkipCurrency(transactionsRequest, download.currency)) {
return;
}
const contractTermsRecord = await tx.contractTerms.get(
download.contractTermsHash,
);
if (!contractTermsRecord) {
return;
}
if (
shouldSkipSearch(transactionsRequest, [
contractTermsRecord?.contractTermsRaw?.summary || "",
])
) {
return;
}
const contractData = extractContractData(
contractTermsRecord?.contractTermsRaw,
download.contractTermsHash,
download.contractTermsMerchantSig,
);
const payOpId = TaskIdentifiers.forPay(purchase);
const payRetryRecord = await tx.operationRetries.get(payOpId);
transactions.push(
await buildTransactionForPurchase(
purchase,
contractData,
[], // FIXME!
payRetryRecord,
),
);
});
await iterRecordsForReward(tx, filter, async (tipRecord) => {
if (
shouldSkipCurrency(
transactionsRequest,
Amounts.parseOrThrow(tipRecord.rewardAmountRaw).currency,
)
) {
return;
}
if (!tipRecord.acceptedTimestamp) {
return;
}
const opId = TaskIdentifiers.forTipPickup(tipRecord);
const retryRecord = await tx.operationRetries.get(opId);
transactions.push(buildTransactionForTip(tipRecord, retryRecord));
});
});
// One-off checks, because of a bug where the wallet previously
// did not migrate the DB correctly and caused these amounts
// to be missing sometimes.
for (let tx of transactions) {
if (!tx.amountEffective) {
logger.warn(`missing amountEffective in ${j2s(tx)}`);
}
if (!tx.amountRaw) {
logger.warn(`missing amountRaw in ${j2s(tx)}`);
}
if (!tx.timestamp) {
logger.warn(`missing timestamp in ${j2s(tx)}`);
}
}
const isPending = (x: Transaction) =>
x.txState.major === TransactionMajorState.Pending ||
x.txState.major === TransactionMajorState.Aborting ||
x.txState.major === TransactionMajorState.Dialog;
const txPending = transactions.filter((x) => isPending(x));
const txNotPending = transactions.filter((x) => !isPending(x));
let sortSign: number;
if (transactionsRequest?.sort == "descending") {
sortSign = -1;
} else {
sortSign = 1;
}
const txCmp = (h1: Transaction, h2: Transaction) => {
// Order transactions by timestamp. Newest transactions come first.
const tsCmp = AbsoluteTime.cmp(
AbsoluteTime.fromPreciseTimestamp(h1.timestamp),
AbsoluteTime.fromPreciseTimestamp(h2.timestamp),
);
// If the timestamp is exactly the same, order by transaction type.
if (tsCmp === 0) {
return Math.sign(txOrder[h1.type] - txOrder[h2.type]);
}
return sortSign * tsCmp;
};
txPending.sort(txCmp);
txNotPending.sort(txCmp);
return { transactions: [...txNotPending, ...txPending] };
}
export type ParsedTransactionIdentifier =
| { tag: TransactionType.Deposit; depositGroupId: string }
| { tag: TransactionType.Payment; proposalId: string }
| { tag: TransactionType.PeerPullDebit; peerPullDebitId: string }
| { tag: TransactionType.PeerPullCredit; pursePub: string }
| { tag: TransactionType.PeerPushCredit; peerPushCreditId: string }
| { tag: TransactionType.PeerPushDebit; pursePub: string }
| { tag: TransactionType.Refresh; refreshGroupId: string }
| { tag: TransactionType.Refund; refundGroupId: string }
| { tag: TransactionType.Reward; walletRewardId: string }
| { tag: TransactionType.Withdrawal; withdrawalGroupId: string }
| { tag: TransactionType.InternalWithdrawal; withdrawalGroupId: string };
export function constructTransactionIdentifier(
pTxId: ParsedTransactionIdentifier,
): TransactionIdStr {
switch (pTxId.tag) {
case TransactionType.Deposit:
return `txn:${pTxId.tag}:${pTxId.depositGroupId}` as TransactionIdStr;
case TransactionType.Payment:
return `txn:${pTxId.tag}:${pTxId.proposalId}` as TransactionIdStr;
case TransactionType.PeerPullCredit:
return `txn:${pTxId.tag}:${pTxId.pursePub}` as TransactionIdStr;
case TransactionType.PeerPullDebit:
return `txn:${pTxId.tag}:${pTxId.peerPullDebitId}` as TransactionIdStr;
case TransactionType.PeerPushCredit:
return `txn:${pTxId.tag}:${pTxId.peerPushCreditId}` as TransactionIdStr;
case TransactionType.PeerPushDebit:
return `txn:${pTxId.tag}:${pTxId.pursePub}` as TransactionIdStr;
case TransactionType.Refresh:
return `txn:${pTxId.tag}:${pTxId.refreshGroupId}` as TransactionIdStr;
case TransactionType.Refund:
return `txn:${pTxId.tag}:${pTxId.refundGroupId}` as TransactionIdStr;
case TransactionType.Reward:
return `txn:${pTxId.tag}:${pTxId.walletRewardId}` as TransactionIdStr;
case TransactionType.Withdrawal:
return `txn:${pTxId.tag}:${pTxId.withdrawalGroupId}` as TransactionIdStr;
case TransactionType.InternalWithdrawal:
return `txn:${pTxId.tag}:${pTxId.withdrawalGroupId}` as TransactionIdStr;
default:
assertUnreachable(pTxId);
}
}
/**
* Parse a transaction identifier string into a typed, structured representation.
*/
export function parseTransactionIdentifier(
transactionId: string,
): ParsedTransactionIdentifier | undefined {
const txnParts = transactionId.split(":");
if (txnParts.length < 3) {
throw Error("id should have al least 3 parts separated by ':'");
}
const [prefix, type, ...rest] = txnParts;
if (prefix != "txn") {
throw Error("invalid transaction identifier");
}
switch (type) {
case TransactionType.Deposit:
return { tag: TransactionType.Deposit, depositGroupId: rest[0] };
case TransactionType.Payment:
return { tag: TransactionType.Payment, proposalId: rest[0] };
case TransactionType.PeerPullCredit:
return { tag: TransactionType.PeerPullCredit, pursePub: rest[0] };
case TransactionType.PeerPullDebit:
return {
tag: TransactionType.PeerPullDebit,
peerPullDebitId: rest[0],
};
case TransactionType.PeerPushCredit:
return {
tag: TransactionType.PeerPushCredit,
peerPushCreditId: rest[0],
};
case TransactionType.PeerPushDebit:
return { tag: TransactionType.PeerPushDebit, pursePub: rest[0] };
case TransactionType.Refresh:
return { tag: TransactionType.Refresh, refreshGroupId: rest[0] };
case TransactionType.Refund:
return {
tag: TransactionType.Refund,
refundGroupId: rest[0],
};
case TransactionType.Reward:
return {
tag: TransactionType.Reward,
walletRewardId: rest[0],
};
case TransactionType.Withdrawal:
return {
tag: TransactionType.Withdrawal,
withdrawalGroupId: rest[0],
};
default:
return undefined;
}
}
export function stopLongpolling(ws: InternalWalletState, taskId: string) {
const longpoll = ws.activeLongpoll[taskId];
if (longpoll) {
logger.info(`cancelling long-polling for ${taskId}`);
longpoll.cancel();
delete ws.activeLongpoll[taskId];
}
}
/**
* Immediately retry the underlying operation
* of a transaction.
*/
export async function retryTransaction(
ws: InternalWalletState,
transactionId: string,
): Promise {
logger.info(`resetting retry timeout for ${transactionId}`);
const parsedTx = parseTransactionIdentifier(transactionId);
if (!parsedTx) {
throw Error("invalid transaction identifier");
}
// FIXME: We currently don't cancel active long-polling tasks here.
switch (parsedTx.tag) {
case TransactionType.PeerPullCredit: {
const taskId = constructTaskIdentifier({
tag: PendingTaskType.PeerPullCredit,
pursePub: parsedTx.pursePub,
});
await resetPendingTaskTimeout(ws, taskId);
stopLongpolling(ws, taskId);
break;
}
case TransactionType.Deposit: {
const taskId = constructTaskIdentifier({
tag: PendingTaskType.Deposit,
depositGroupId: parsedTx.depositGroupId,
});
await resetPendingTaskTimeout(ws, taskId);
stopLongpolling(ws, taskId);
break;
}
case TransactionType.InternalWithdrawal:
case TransactionType.Withdrawal: {
// FIXME: Abort current long-poller!
const taskId = constructTaskIdentifier({
tag: PendingTaskType.Withdraw,
withdrawalGroupId: parsedTx.withdrawalGroupId,
});
await resetPendingTaskTimeout(ws, taskId);
stopLongpolling(ws, taskId);
break;
}
case TransactionType.Payment: {
const taskId = constructTaskIdentifier({
tag: PendingTaskType.Purchase,
proposalId: parsedTx.proposalId,
});
await resetPendingTaskTimeout(ws, taskId);
stopLongpolling(ws, taskId);
break;
}
case TransactionType.Reward: {
const taskId = constructTaskIdentifier({
tag: PendingTaskType.RewardPickup,
walletRewardId: parsedTx.walletRewardId,
});
await resetPendingTaskTimeout(ws, taskId);
stopLongpolling(ws, taskId);
break;
}
case TransactionType.Refresh: {
const taskId = constructTaskIdentifier({
tag: PendingTaskType.Refresh,
refreshGroupId: parsedTx.refreshGroupId,
});
await resetPendingTaskTimeout(ws, taskId);
stopLongpolling(ws, taskId);
break;
}
case TransactionType.PeerPullDebit: {
const taskId = constructTaskIdentifier({
tag: PendingTaskType.PeerPullDebit,
peerPullDebitId: parsedTx.peerPullDebitId,
});
await resetPendingTaskTimeout(ws, taskId);
stopLongpolling(ws, taskId);
break;
}
case TransactionType.PeerPushCredit: {
const taskId = constructTaskIdentifier({
tag: PendingTaskType.PeerPushCredit,
peerPushCreditId: parsedTx.peerPushCreditId,
});
await resetPendingTaskTimeout(ws, taskId);
stopLongpolling(ws, taskId);
break;
}
case TransactionType.PeerPushDebit: {
const taskId = constructTaskIdentifier({
tag: PendingTaskType.PeerPushDebit,
pursePub: parsedTx.pursePub,
});
await resetPendingTaskTimeout(ws, taskId);
stopLongpolling(ws, taskId);
break;
}
case TransactionType.Refund:
// Nothing to do for a refund transaction.
break;
default:
assertUnreachable(parsedTx);
}
}
/**
* Suspends a pending transaction, stopping any associated network activities,
* but with a chance of trying again at a later time. This could be useful if
* a user needs to save battery power or bandwidth and an operation is expected
* to take longer (such as a backup, recovery or very large withdrawal operation).
*/
export async function suspendTransaction(
ws: InternalWalletState,
transactionId: string,
): Promise {
const tx = parseTransactionIdentifier(transactionId);
if (!tx) {
throw Error("invalid transaction ID");
}
switch (tx.tag) {
case TransactionType.Deposit:
await suspendDepositGroup(ws, tx.depositGroupId);
return;
case TransactionType.Refresh:
await suspendRefreshGroup(ws, tx.refreshGroupId);
return;
case TransactionType.InternalWithdrawal:
case TransactionType.Withdrawal:
await suspendWithdrawalTransaction(ws, tx.withdrawalGroupId);
return;
case TransactionType.Payment:
await suspendPayMerchant(ws, tx.proposalId);
return;
case TransactionType.PeerPullCredit:
await suspendPeerPullCreditTransaction(ws, tx.pursePub);
break;
case TransactionType.PeerPushDebit:
await suspendPeerPushDebitTransaction(ws, tx.pursePub);
break;
case TransactionType.PeerPullDebit:
await suspendPeerPullDebitTransaction(ws, tx.peerPullDebitId);
break;
case TransactionType.PeerPushCredit:
await suspendPeerPushCreditTransaction(ws, tx.peerPushCreditId);
break;
case TransactionType.Refund:
throw Error("refund transactions can't be suspended or resumed");
case TransactionType.Reward:
await suspendRewardTransaction(ws, tx.walletRewardId);
break;
default:
assertUnreachable(tx);
}
}
export async function failTransaction(
ws: InternalWalletState,
transactionId: string,
): Promise {
const tx = parseTransactionIdentifier(transactionId);
if (!tx) {
throw Error("invalid transaction ID");
}
switch (tx.tag) {
case TransactionType.Deposit:
await failDepositTransaction(ws, tx.depositGroupId);
return;
case TransactionType.InternalWithdrawal:
case TransactionType.Withdrawal:
await failWithdrawalTransaction(ws, tx.withdrawalGroupId);
return;
case TransactionType.Payment:
await failPaymentTransaction(ws, tx.proposalId);
return;
case TransactionType.Refund:
throw Error("can't do cancel-aborting on refund transaction");
case TransactionType.Reward:
await failTipTransaction(ws, tx.walletRewardId);
return;
case TransactionType.Refresh:
await failRefreshGroup(ws, tx.refreshGroupId);
return;
case TransactionType.PeerPullCredit:
await failPeerPullCreditTransaction(ws, tx.pursePub);
return;
case TransactionType.PeerPullDebit:
await failPeerPullDebitTransaction(ws, tx.peerPullDebitId);
return;
case TransactionType.PeerPushCredit:
await failPeerPushCreditTransaction(ws, tx.peerPushCreditId);
return;
case TransactionType.PeerPushDebit:
await failPeerPushDebitTransaction(ws, tx.pursePub);
return;
default:
assertUnreachable(tx);
}
}
/**
* Resume a suspended transaction.
*/
export async function resumeTransaction(
ws: InternalWalletState,
transactionId: string,
): Promise {
const tx = parseTransactionIdentifier(transactionId);
if (!tx) {
throw Error("invalid transaction ID");
}
switch (tx.tag) {
case TransactionType.Deposit:
await resumeDepositGroup(ws, tx.depositGroupId);
return;
case TransactionType.Refresh:
await resumeRefreshGroup(ws, tx.refreshGroupId);
return;
case TransactionType.InternalWithdrawal:
case TransactionType.Withdrawal:
await resumeWithdrawalTransaction(ws, tx.withdrawalGroupId);
return;
case TransactionType.Payment:
await resumePayMerchant(ws, tx.proposalId);
return;
case TransactionType.PeerPullCredit:
await resumePeerPullCreditTransaction(ws, tx.pursePub);
break;
case TransactionType.PeerPushDebit:
await resumePeerPushDebitTransaction(ws, tx.pursePub);
break;
case TransactionType.PeerPullDebit:
await resumePeerPullDebitTransaction(ws, tx.peerPullDebitId);
break;
case TransactionType.PeerPushCredit:
await resumePeerPushCreditTransaction(ws, tx.peerPushCreditId);
break;
case TransactionType.Refund:
throw Error("refund transactions can't be suspended or resumed");
case TransactionType.Reward:
await resumeTipTransaction(ws, tx.walletRewardId);
break;
}
}
/**
* Permanently delete a transaction based on the transaction ID.
*/
export async function deleteTransaction(
ws: InternalWalletState,
transactionId: string,
): Promise {
const parsedTx = parseTransactionIdentifier(transactionId);
if (!parsedTx) {
throw Error("invalid transaction ID");
}
switch (parsedTx.tag) {
case TransactionType.PeerPushCredit: {
const peerPushCreditId = parsedTx.peerPushCreditId;
await ws.db
.mktx((x) => [x.withdrawalGroups, x.peerPushCredit, x.tombstones])
.runReadWrite(async (tx) => {
const pushInc = await tx.peerPushCredit.get(peerPushCreditId);
if (!pushInc) {
return;
}
if (pushInc.withdrawalGroupId) {
const withdrawalGroupId = pushInc.withdrawalGroupId;
const withdrawalGroupRecord = await tx.withdrawalGroups.get(
withdrawalGroupId,
);
if (withdrawalGroupRecord) {
await tx.withdrawalGroups.delete(withdrawalGroupId);
await tx.tombstones.put({
id:
TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId,
});
}
}
await tx.peerPushCredit.delete(peerPushCreditId);
await tx.tombstones.put({
id: TombstoneTag.DeletePeerPushCredit + ":" + peerPushCreditId,
});
});
return;
}
case TransactionType.PeerPullCredit: {
const pursePub = parsedTx.pursePub;
await ws.db
.mktx((x) => [x.withdrawalGroups, x.peerPullCredit, x.tombstones])
.runReadWrite(async (tx) => {
const pullIni = await tx.peerPullCredit.get(pursePub);
if (!pullIni) {
return;
}
if (pullIni.withdrawalGroupId) {
const withdrawalGroupId = pullIni.withdrawalGroupId;
const withdrawalGroupRecord = await tx.withdrawalGroups.get(
withdrawalGroupId,
);
if (withdrawalGroupRecord) {
await tx.withdrawalGroups.delete(withdrawalGroupId);
await tx.tombstones.put({
id:
TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId,
});
}
}
await tx.peerPullCredit.delete(pursePub);
await tx.tombstones.put({
id: TombstoneTag.DeletePeerPullCredit + ":" + pursePub,
});
});
return;
}
case TransactionType.Withdrawal: {
const withdrawalGroupId = parsedTx.withdrawalGroupId;
await ws.db
.mktx((x) => [x.withdrawalGroups, x.tombstones])
.runReadWrite(async (tx) => {
const withdrawalGroupRecord = await tx.withdrawalGroups.get(
withdrawalGroupId,
);
if (withdrawalGroupRecord) {
await tx.withdrawalGroups.delete(withdrawalGroupId);
await tx.tombstones.put({
id: TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId,
});
return;
}
});
return;
}
case TransactionType.Payment: {
const proposalId = parsedTx.proposalId;
await ws.db
.mktx((x) => [x.purchases, x.tombstones])
.runReadWrite(async (tx) => {
let found = false;
const purchase = await tx.purchases.get(proposalId);
if (purchase) {
found = true;
await tx.purchases.delete(proposalId);
}
if (found) {
await tx.tombstones.put({
id: TombstoneTag.DeletePayment + ":" + proposalId,
});
}
});
return;
}
case TransactionType.Refresh: {
const refreshGroupId = parsedTx.refreshGroupId;
await ws.db
.mktx((x) => [x.refreshGroups, x.tombstones])
.runReadWrite(async (tx) => {
const rg = await tx.refreshGroups.get(refreshGroupId);
if (rg) {
await tx.refreshGroups.delete(refreshGroupId);
await tx.tombstones.put({
id: TombstoneTag.DeleteRefreshGroup + ":" + refreshGroupId,
});
}
});
return;
}
case TransactionType.Reward: {
const tipId = parsedTx.walletRewardId;
await ws.db
.mktx((x) => [x.rewards, x.tombstones])
.runReadWrite(async (tx) => {
const tipRecord = await tx.rewards.get(tipId);
if (tipRecord) {
await tx.rewards.delete(tipId);
await tx.tombstones.put({
id: TombstoneTag.DeleteReward + ":" + tipId,
});
}
});
return;
}
case TransactionType.Deposit: {
const depositGroupId = parsedTx.depositGroupId;
await deleteDepositGroup(ws, depositGroupId);
return;
}
case TransactionType.Refund: {
const refundGroupId = parsedTx.refundGroupId;
await ws.db
.mktx((x) => [x.refundGroups, x.tombstones])
.runReadWrite(async (tx) => {
const refundRecord = await tx.refundGroups.get(refundGroupId);
if (!refundRecord) {
return;
}
await tx.refundGroups.delete(refundGroupId);
await tx.tombstones.put({ id: transactionId });
// FIXME: Also tombstone the refund items, so that they won't reappear.
});
return;
}
case TransactionType.PeerPullDebit: {
const peerPullDebitId = parsedTx.peerPullDebitId;
await ws.db
.mktx((x) => [x.peerPullDebit, x.tombstones])
.runReadWrite(async (tx) => {
const debit = await tx.peerPullDebit.get(peerPullDebitId);
if (debit) {
await tx.peerPullDebit.delete(peerPullDebitId);
await tx.tombstones.put({ id: transactionId });
}
});
return;
}
case TransactionType.PeerPushDebit: {
const pursePub = parsedTx.pursePub;
await ws.db
.mktx((x) => [x.peerPushDebit, x.tombstones])
.runReadWrite(async (tx) => {
const debit = await tx.peerPushDebit.get(pursePub);
if (debit) {
await tx.peerPushDebit.delete(pursePub);
await tx.tombstones.put({ id: transactionId });
}
});
return;
}
}
}
export async function abortTransaction(
ws: InternalWalletState,
transactionId: string,
): Promise {
const txId = parseTransactionIdentifier(transactionId);
if (!txId) {
throw Error("invalid transaction identifier");
}
switch (txId.tag) {
case TransactionType.Payment: {
await abortPayMerchant(ws, txId.proposalId);
break;
}
case TransactionType.Withdrawal:
case TransactionType.InternalWithdrawal: {
await abortWithdrawalTransaction(ws, txId.withdrawalGroupId);
break;
}
case TransactionType.Deposit:
await abortDepositGroup(ws, txId.depositGroupId);
break;
case TransactionType.Reward:
await abortTipTransaction(ws, txId.walletRewardId);
break;
case TransactionType.Refund:
throw Error("can't abort refund transactions");
case TransactionType.Refresh:
await abortRefreshGroup(ws, txId.refreshGroupId);
break;
case TransactionType.PeerPullCredit:
await abortPeerPullCreditTransaction(ws, txId.pursePub);
break;
case TransactionType.PeerPullDebit:
await abortPeerPullDebitTransaction(ws, txId.peerPullDebitId);
break;
case TransactionType.PeerPushCredit:
await abortPeerPushCreditTransaction(ws, txId.peerPushCreditId);
break;
case TransactionType.PeerPushDebit:
await abortPeerPushDebitTransaction(ws, txId.pursePub);
break;
default: {
assertUnreachable(txId);
}
}
}
export interface TransitionInfo {
oldTxState: TransactionState;
newTxState: TransactionState;
}
/**
* Notify of a state transition if necessary.
*/
export function notifyTransition(
ws: InternalWalletState,
transactionId: string,
transitionInfo: TransitionInfo | undefined,
experimentalUserData: any = undefined,
): void {
if (
transitionInfo &&
!(
transitionInfo.oldTxState.major === transitionInfo.newTxState.major &&
transitionInfo.oldTxState.minor === transitionInfo.newTxState.minor
)
) {
ws.notify({
type: NotificationType.TransactionStateTransition,
oldTxState: transitionInfo.oldTxState,
newTxState: transitionInfo.newTxState,
transactionId,
experimentalUserData,
});
}
ws.workAvailable.trigger();
}