wallet-core: make changes to available amount atomic

W.r.t. transactions
This commit is contained in:
Florian Dold 2023-06-26 19:27:34 +02:00
parent 2779086a32
commit a844136489
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
7 changed files with 187 additions and 52 deletions

View File

@ -119,7 +119,7 @@ export const CURRENT_DB_CONFIG_KEY = "currentMainDbName";
* backwards-compatible way or object stores and indices * backwards-compatible way or object stores and indices
* are added. * are added.
*/ */
export const WALLET_DB_MINOR_VERSION = 8; export const WALLET_DB_MINOR_VERSION = 9;
/** /**
* Ranges for operation status fields. * Ranges for operation status fields.
@ -723,6 +723,14 @@ export interface CoinRecord {
*/ */
coinSource: CoinSource; coinSource: CoinSource;
/**
* Source transaction ID of the coin.
*
* Used to make the coin visible after the transaction
* has entered a final state.
*/
sourceTransactionId?: string;
/** /**
* Public key of the coin. * Public key of the coin.
*/ */
@ -768,6 +776,14 @@ export interface CoinRecord {
*/ */
status: CoinStatus; status: CoinStatus;
/**
* Non-zero for visible.
*
* A coin is visible when it is fresh and the
* source transaction is in a final state.
*/
visible?: number;
/** /**
* Information about what the coin has been allocated for. * Information about what the coin has been allocated for.
* *
@ -894,7 +910,7 @@ export enum RefreshCoinStatus {
* The refresh for this coin has been frozen, because of a permanent error. * The refresh for this coin has been frozen, because of a permanent error.
* More info in lastErrorPerCoin. * More info in lastErrorPerCoin.
*/ */
Frozen = OperationStatusRange.DORMANT_START + 1, Failed = OperationStatusRange.DORMANT_START + 1,
} }
export enum OperationStatus { export enum OperationStatus {
@ -1748,7 +1764,6 @@ export interface DepositKycInfo {
exchangeBaseUrl: string; exchangeBaseUrl: string;
} }
/** /**
* Record for a deposits that the wallet observed * Record for a deposits that the wallet observed
* as a result of double spending, but which is not * as a result of double spending, but which is not
@ -2132,6 +2147,15 @@ export interface CoinAvailabilityRecord {
* Number of fresh coins of this denomination that are available. * Number of fresh coins of this denomination that are available.
*/ */
freshCoinCount: number; freshCoinCount: number;
/**
* Number of fresh coins that are available
* and visible, i.e. the source transaction is in
* a final state.
*
* (Optional for backwards compatibility, defaults to 0.)
*/
visibleCoinCount?: number;
} }
export interface ContractTermsRecord { export interface ContractTermsRecord {
@ -2318,6 +2342,13 @@ export const WalletStoresV1 = {
["exchangeBaseUrl", "denomPubHash", "maxAge", "status"], ["exchangeBaseUrl", "denomPubHash", "maxAge", "status"],
), ),
byCoinEvHash: describeIndex("byCoinEvHash", "coinEvHash"), byCoinEvHash: describeIndex("byCoinEvHash", "coinEvHash"),
bySourceTransactionId: describeIndex(
"bySourceTransactionId",
"sourceTransactionId",
{
versionAdded: 9,
},
),
}, },
), ),
reserves: describeStore( reserves: describeStore(

View File

@ -20,14 +20,17 @@
* There are multiple definition of the wallet's balance. * There are multiple definition of the wallet's balance.
* We use the following terminology: * We use the following terminology:
* *
* - "available": Balance that the wallet believes will certainly be available * - "available": Balance that is available
* for spending, modulo any failures of the exchange or double spending issues. * for spending from transactions in their final state and
* This includes available coins *not* allocated to any * expected to be available from pending refreshes.
* spending/refresh/... operation. Pending withdrawals are *not* counted *
* towards this balance, because they are not certain to succeed. * - "pending-incoming": Expected (positive!) delta
* Pending refreshes *are* counted towards this balance. * to the available balance that we expect to have
* This balance type is nice to show to the user, because it does not * after pending operations reach the "done" state.
* temporarily decrease after payment when we are waiting for refreshes *
* - "pending-outgoing": Amount that is currently allocated
* to be spent, but the spend operation could still be aborted
* and part of the pending-outgoing amount could be recovered.
* *
* - "material": Balance that the wallet believes it could spend *right now*, * - "material": Balance that the wallet believes it could spend *right now*,
* without waiting for any operations to complete. * without waiting for any operations to complete.
@ -61,11 +64,13 @@ import {
AllowedExchangeInfo, AllowedExchangeInfo,
RefreshGroupRecord, RefreshGroupRecord,
WalletStoresV1, WalletStoresV1,
WithdrawalGroupStatus,
} from "../db.js"; } from "../db.js";
import { InternalWalletState } from "../internal-wallet-state.js"; import { InternalWalletState } from "../internal-wallet-state.js";
import { checkLogicInvariant } from "../util/invariants.js"; import { checkLogicInvariant } from "../util/invariants.js";
import { GetReadOnlyAccess } from "../util/query.js"; import { GetReadOnlyAccess } from "../util/query.js";
import { getExchangeDetails } from "./exchanges.js"; import { getExchangeDetails } from "./exchanges.js";
import { assertUnreachable } from "../util/assertUnreachable.js";
/** /**
* Logger. * Logger.
@ -133,7 +138,8 @@ export async function getBalancesInsideTransaction(
await tx.coinAvailability.iter().forEach((ca) => { await tx.coinAvailability.iter().forEach((ca) => {
const b = initBalance(ca.currency); const b = initBalance(ca.currency);
for (let i = 0; i < ca.freshCoinCount; i++) { const count = ca.visibleCoinCount ?? 0;
for (let i = 0; i < count; i++) {
b.available = Amounts.add(b.available, { b.available = Amounts.add(b.available, {
currency: ca.currency, currency: ca.currency,
fraction: ca.amountFrac, fraction: ca.amountFrac,
@ -150,14 +156,40 @@ export async function getBalancesInsideTransaction(
).amount; ).amount;
}); });
await tx.withdrawalGroups.iter().forEach((wds) => { // FIXME: Use indexing to filter out final transactions.
if (wds.timestampFinish) { await tx.withdrawalGroups.iter().forEach((wgRecord) => {
return; switch (wgRecord.status) {
case WithdrawalGroupStatus.AbortedBank:
case WithdrawalGroupStatus.AbortedExchange:
case WithdrawalGroupStatus.FailedAbortingBank:
case WithdrawalGroupStatus.FailedBankAborted:
case WithdrawalGroupStatus.Finished:
// Does not count as pendingIncoming
return;
case WithdrawalGroupStatus.PendingReady:
case WithdrawalGroupStatus.AbortingBank:
case WithdrawalGroupStatus.PendingAml:
case WithdrawalGroupStatus.PendingKyc:
case WithdrawalGroupStatus.PendingQueryingStatus:
case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
case WithdrawalGroupStatus.SuspendedReady:
case WithdrawalGroupStatus.SuspendedRegisteringBank:
case WithdrawalGroupStatus.SuspendedKyc:
case WithdrawalGroupStatus.SuspendedAbortingBank:
case WithdrawalGroupStatus.SuspendedAml:
case WithdrawalGroupStatus.PendingRegisteringBank:
case WithdrawalGroupStatus.PendingWaitConfirmBank:
case WithdrawalGroupStatus.SuspendedQueryingStatus:
break;
default:
assertUnreachable(wgRecord.status);
} }
const b = initBalance(Amounts.currencyOf(wds.denomsSel.totalWithdrawCost)); const b = initBalance(
Amounts.currencyOf(wgRecord.denomsSel.totalWithdrawCost),
);
b.pendingIncoming = Amounts.add( b.pendingIncoming = Amounts.add(
b.pendingIncoming, b.pendingIncoming,
wds.denomsSel.totalCoinValue, wgRecord.denomsSel.totalCoinValue,
).amount; ).amount;
}); });

View File

@ -81,6 +81,38 @@ export interface CoinsSpendInfo {
allocationId: TransactionIdStr; allocationId: TransactionIdStr;
} }
export async function makeCoinsVisible(
ws: InternalWalletState,
tx: GetReadWriteAccess<{
coins: typeof WalletStoresV1.coins;
coinAvailability: typeof WalletStoresV1.coinAvailability;
}>,
transactionId: string,
): Promise<void> {
const coins = await tx.coins.indexes.bySourceTransactionId.getAll(
transactionId,
);
for (const coinRecord of coins) {
if (!coinRecord.visible) {
coinRecord.visible = 1;
await tx.coins.put(coinRecord);
const ageRestriction = coinRecord.maxAge;
const car = await tx.coinAvailability.get([
coinRecord.exchangeBaseUrl,
coinRecord.denomPubHash,
ageRestriction,
]);
if (!car) {
logger.error("missing coin availability record");
continue;
}
const visCount = car.visibleCoinCount ?? 0;
car.visibleCoinCount = visCount + 1;
await tx.coinAvailability.put(car);
}
}
}
export async function makeCoinAvailable( export async function makeCoinAvailable(
ws: InternalWalletState, ws: InternalWalletState,
tx: GetReadWriteAccess<{ tx: GetReadWriteAccess<{
@ -195,6 +227,13 @@ export async function spendCoins(
); );
} }
coinAvailability.freshCoinCount--; coinAvailability.freshCoinCount--;
if (coin.visible) {
if (!coinAvailability.visibleCoinCount) {
logger.error("coin availability inconsistent");
} else {
coinAvailability.visibleCoinCount--;
}
}
await tx.coins.put(coin); await tx.coins.put(coin);
await tx.coinAvailability.put(coinAvailability); await tx.coinAvailability.put(coinAvailability);
} }

View File

@ -46,16 +46,20 @@ import {
NotificationType, NotificationType,
RefreshGroupId, RefreshGroupId,
RefreshReason, RefreshReason,
TalerError,
TalerErrorCode, TalerErrorCode,
TalerErrorDetail, TalerErrorDetail,
TalerPreciseTimestamp, TalerPreciseTimestamp,
TalerProtocolTimestamp,
TransactionAction, TransactionAction,
TransactionMajorState, TransactionMajorState,
TransactionState, TransactionState,
TransactionType, TransactionType,
URL, URL,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import {
readSuccessResponseJsonOrThrow,
readUnexpectedResponseDetails,
} from "@gnu-taler/taler-util/http";
import { TalerCryptoInterface } from "../crypto/cryptoImplementation.js"; import { TalerCryptoInterface } from "../crypto/cryptoImplementation.js";
import { import {
DerivedRefreshSession, DerivedRefreshSession,
@ -72,25 +76,23 @@ import {
RefreshReasonDetails, RefreshReasonDetails,
WalletStoresV1, WalletStoresV1,
} from "../db.js"; } from "../db.js";
import { TalerError } from "@gnu-taler/taler-util"; import { isWithdrawableDenom, PendingTaskType } from "../index.js";
import { import {
EXCHANGE_COINS_LOCK, EXCHANGE_COINS_LOCK,
InternalWalletState, InternalWalletState,
} from "../internal-wallet-state.js"; } from "../internal-wallet-state.js";
import { assertUnreachable } from "../util/assertUnreachable.js"; import { assertUnreachable } from "../util/assertUnreachable.js";
import { import { selectWithdrawalDenominations } from "../util/coinSelection.js";
readSuccessResponseJsonOrThrow,
readUnexpectedResponseDetails,
} from "@gnu-taler/taler-util/http";
import { checkDbInvariant } from "../util/invariants.js"; import { checkDbInvariant } from "../util/invariants.js";
import { GetReadOnlyAccess, GetReadWriteAccess } from "../util/query.js"; import { GetReadOnlyAccess, GetReadWriteAccess } from "../util/query.js";
import { constructTaskIdentifier, makeCoinAvailable, OperationAttemptResult, OperationAttemptResultType } from "./common.js";
import { updateExchangeFromUrl } from "./exchanges.js";
import { selectWithdrawalDenominations } from "../util/coinSelection.js";
import { import {
isWithdrawableDenom, constructTaskIdentifier,
PendingTaskType, makeCoinAvailable,
} from "../index.js"; makeCoinsVisible,
OperationAttemptResult,
OperationAttemptResultType,
} from "./common.js";
import { updateExchangeFromUrl } from "./exchanges.js";
import { import {
constructTransactionIdentifier, constructTransactionIdentifier,
notifyTransition, notifyTransition,
@ -144,24 +146,26 @@ export function getTotalRefreshCost(
return totalCost; return totalCost;
} }
function updateGroupStatus(rg: RefreshGroupRecord): void { function updateGroupStatus(rg: RefreshGroupRecord): { final: boolean } {
const allDone = fnutil.all( const allFinal = fnutil.all(
rg.statusPerCoin, rg.statusPerCoin,
(x) => x === RefreshCoinStatus.Finished || x === RefreshCoinStatus.Frozen, (x) => x === RefreshCoinStatus.Finished || x === RefreshCoinStatus.Failed,
); );
const anyFrozen = fnutil.any( const anyFailed = fnutil.any(
rg.statusPerCoin, rg.statusPerCoin,
(x) => x === RefreshCoinStatus.Frozen, (x) => x === RefreshCoinStatus.Failed,
); );
if (allDone) { if (allFinal) {
if (anyFrozen) { if (anyFailed) {
rg.timestampFinished = TalerPreciseTimestamp.now(); rg.timestampFinished = TalerPreciseTimestamp.now();
rg.operationStatus = RefreshOperationStatus.Failed; rg.operationStatus = RefreshOperationStatus.Failed;
} else { } else {
rg.timestampFinished = TalerPreciseTimestamp.now(); rg.timestampFinished = TalerPreciseTimestamp.now();
rg.operationStatus = RefreshOperationStatus.Finished; rg.operationStatus = RefreshOperationStatus.Finished;
} }
return { final: true };
} }
return { final: false };
} }
/** /**
@ -248,22 +252,30 @@ async function refreshCreateSession(
ws.config.testing.denomselAllowLate, ws.config.testing.denomselAllowLate,
); );
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Refresh,
refreshGroupId,
});
if (newCoinDenoms.selectedDenoms.length === 0) { if (newCoinDenoms.selectedDenoms.length === 0) {
logger.trace( logger.trace(
`not refreshing, available amount ${amountToPretty( `not refreshing, available amount ${amountToPretty(
availableAmount, availableAmount,
)} too small`, )} too small`,
); );
// FIXME: State transition notification missing.
await ws.db await ws.db
.mktx((x) => [x.coins, x.refreshGroups]) .mktx((x) => [x.coins, x.coinAvailability, x.refreshGroups])
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {
const rg = await tx.refreshGroups.get(refreshGroupId); const rg = await tx.refreshGroups.get(refreshGroupId);
if (!rg) { if (!rg) {
return; return;
} }
rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished; rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished;
updateGroupStatus(rg); const updateRes = updateGroupStatus(rg);
if (updateRes.final) {
await makeCoinsVisible(ws, tx, transactionId);
}
await tx.refreshGroups.put(rg); await tx.refreshGroups.put(rg);
}); });
return; return;
@ -418,10 +430,15 @@ async function refreshMelt(
}); });
}); });
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Refresh,
refreshGroupId,
});
if (resp.status === HttpStatusCode.NotFound) { if (resp.status === HttpStatusCode.NotFound) {
const errDetails = await readUnexpectedResponseDetails(resp); const errDetails = await readUnexpectedResponseDetails(resp);
await ws.db await ws.db
.mktx((x) => [x.refreshGroups]) .mktx((x) => [x.refreshGroups, x.coins, x.coinAvailability])
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {
const rg = await tx.refreshGroups.get(refreshGroupId); const rg = await tx.refreshGroups.get(refreshGroupId);
if (!rg) { if (!rg) {
@ -433,9 +450,12 @@ async function refreshMelt(
if (rg.statusPerCoin[coinIndex] !== RefreshCoinStatus.Pending) { if (rg.statusPerCoin[coinIndex] !== RefreshCoinStatus.Pending) {
return; return;
} }
rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Frozen; rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Failed;
rg.lastErrorPerCoin[coinIndex] = errDetails; rg.lastErrorPerCoin[coinIndex] = errDetails;
updateGroupStatus(rg); const updateRes = updateGroupStatus(rg);
if (updateRes.final) {
await makeCoinsVisible(ws, tx, transactionId);
}
await tx.refreshGroups.put(rg); await tx.refreshGroups.put(rg);
}); });
return; return;
@ -672,6 +692,11 @@ async function refreshReveal(
const coins: CoinRecord[] = []; const coins: CoinRecord[] = [];
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Refresh,
refreshGroupId,
});
for (let i = 0; i < refreshSession.newDenoms.length; i++) { for (let i = 0; i < refreshSession.newDenoms.length; i++) {
const ncd = newCoinDenoms[i]; const ncd = newCoinDenoms[i];
for (let j = 0; j < refreshSession.newDenoms[i].count; j++) { for (let j = 0; j < refreshSession.newDenoms[i].count; j++) {
@ -701,6 +726,7 @@ async function refreshReveal(
refreshGroupId, refreshGroupId,
oldCoinPub: refreshGroup.oldCoinPubs[coinIndex], oldCoinPub: refreshGroup.oldCoinPubs[coinIndex],
}, },
sourceTransactionId: transactionId,
coinEvHash: pc.coinEvHash, coinEvHash: pc.coinEvHash,
maxAge: pc.maxAge, maxAge: pc.maxAge,
ageCommitmentProof: pc.ageCommitmentProof, ageCommitmentProof: pc.ageCommitmentProof,

View File

@ -57,7 +57,7 @@ import {
readSuccessResponseJsonOrThrow, readSuccessResponseJsonOrThrow,
} from "@gnu-taler/taler-util/http"; } from "@gnu-taler/taler-util/http";
import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js"; import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
import { constructTaskIdentifier, makeCoinAvailable, OperationAttemptResult, OperationAttemptResultType } from "./common.js"; import { constructTaskIdentifier, makeCoinAvailable, makeCoinsVisible, OperationAttemptResult, OperationAttemptResultType } from "./common.js";
import { updateExchangeFromUrl } from "./exchanges.js"; import { updateExchangeFromUrl } from "./exchanges.js";
import { import {
getCandidateWithdrawalDenoms, getCandidateWithdrawalDenoms,
@ -387,6 +387,7 @@ export async function processTip(
coinIndex: i, coinIndex: i,
walletTipId: walletTipId, walletTipId: walletTipId,
}, },
sourceTransactionId: transactionId,
denomPubHash: denom.denomPubHash, denomPubHash: denom.denomPubHash,
denomSig: { cipher: DenomKeyType.Rsa, rsa_signature: denomSigRsa.sig }, denomSig: { cipher: DenomKeyType.Rsa, rsa_signature: denomSigRsa.sig },
exchangeBaseUrl: tipRecord.exchangeBaseUrl, exchangeBaseUrl: tipRecord.exchangeBaseUrl,
@ -416,6 +417,7 @@ export async function processTip(
for (const cr of newCoinRecords) { for (const cr of newCoinRecords) {
await makeCoinAvailable(ws, tx, cr); await makeCoinAvailable(ws, tx, cr);
} }
await makeCoinsVisible(ws, tx, transactionId);
return { oldTxState, newTxState }; return { oldTxState, newTxState };
}); });
notifyTransition(ws, transactionId, transitionInfo); notifyTransition(ws, transactionId, transitionInfo);

View File

@ -97,6 +97,7 @@ import {
TaskIdentifiers, TaskIdentifiers,
constructTaskIdentifier, constructTaskIdentifier,
makeCoinAvailable, makeCoinAvailable,
makeCoinsVisible,
makeExchangeListItem, makeExchangeListItem,
runLongpollAsync, runLongpollAsync,
} from "../operations/common.js"; } from "../operations/common.js";
@ -1029,6 +1030,11 @@ async function processPlanchetVerifyAndStoreCoin(
return; return;
} }
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Withdrawal,
withdrawalGroupId: wgContext.wgRecord.withdrawalGroupId,
});
const { planchet, denomInfo } = d; const { planchet, denomInfo } = d;
const planchetDenomPub = denomInfo.denomPub; const planchetDenomPub = denomInfo.denomPub;
@ -1099,6 +1105,7 @@ async function processPlanchetVerifyAndStoreCoin(
reservePub: withdrawalGroup.reservePub, reservePub: withdrawalGroup.reservePub,
withdrawalGroupId: withdrawalGroup.withdrawalGroupId, withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
}, },
sourceTransactionId: transactionId,
maxAge: withdrawalGroup.restrictAge ?? AgeRestriction.AGE_UNRESTRICTED, maxAge: withdrawalGroup.restrictAge ?? AgeRestriction.AGE_UNRESTRICTED,
ageCommitmentProof: planchet.ageCommitmentProof, ageCommitmentProof: planchet.ageCommitmentProof,
spendAllocation: undefined, spendAllocation: undefined,
@ -1111,7 +1118,7 @@ async function processPlanchetVerifyAndStoreCoin(
// Check if this is the first time that the whole // Check if this is the first time that the whole
// withdrawal succeeded. If so, mark the withdrawal // withdrawal succeeded. If so, mark the withdrawal
// group as finished. // group as finished.
const firstSuccess = await ws.db const success = await ws.db
.mktx((x) => [ .mktx((x) => [
x.coins, x.coins,
x.denominations, x.denominations,
@ -1130,7 +1137,9 @@ async function processPlanchetVerifyAndStoreCoin(
return true; return true;
}); });
ws.notify({ type: NotificationType.BalanceChange }); if (success) {
ws.notify({ type: NotificationType.BalanceChange });
}
} }
/** /**
@ -1495,10 +1504,7 @@ async function processWithdrawalGroupPendingReady(
}; };
}); });
notifyTransition(ws, transactionId, transitionInfo); notifyTransition(ws, transactionId, transitionInfo);
return { return OperationAttemptResult.finishedEmpty();
type: OperationAttemptResultType.Finished,
result: undefined,
};
} }
const numTotalCoins = withdrawalGroup.denomsSel.selectedDenoms const numTotalCoins = withdrawalGroup.denomsSel.selectedDenoms
@ -1563,7 +1569,7 @@ async function processWithdrawalGroupPendingReady(
const maxReportedErrors = 5; const maxReportedErrors = 5;
const res = await ws.db const res = await ws.db
.mktx((x) => [x.coins, x.withdrawalGroups, x.planchets]) .mktx((x) => [x.coins, x.coinAvailability, x.withdrawalGroups, x.planchets])
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {
const wg = await tx.withdrawalGroups.get(withdrawalGroupId); const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
if (!wg) { if (!wg) {
@ -1588,6 +1594,7 @@ async function processWithdrawalGroupPendingReady(
if (wg.timestampFinish === undefined && numFinished === numTotalCoins) { if (wg.timestampFinish === undefined && numFinished === numTotalCoins) {
wg.timestampFinish = TalerPreciseTimestamp.now(); wg.timestampFinish = TalerPreciseTimestamp.now();
wg.status = WithdrawalGroupStatus.Finished; wg.status = WithdrawalGroupStatus.Finished;
await makeCoinsVisible(ws, tx, transactionId);
} }
const newTxState = computeWithdrawalTransactionStatus(wg); const newTxState = computeWithdrawalTransactionStatus(wg);

View File

@ -31,7 +31,6 @@ import {
AmountJson, AmountJson,
AmountResponse, AmountResponse,
Amounts, Amounts,
AmountString,
CoinStatus, CoinStatus,
ConvertAmountRequest, ConvertAmountRequest,
DenominationInfo, DenominationInfo,
@ -42,7 +41,6 @@ import {
ForcedDenomSel, ForcedDenomSel,
GetAmountRequest, GetAmountRequest,
GetPlanForOperationRequest, GetPlanForOperationRequest,
GetPlanForOperationResponse,
j2s, j2s,
Logger, Logger,
parsePaytoUri, parsePaytoUri,