wallet: allow forced denom selection for tests
This commit is contained in:
parent
fdd272af20
commit
bbd6ccf1c7
@ -212,6 +212,12 @@ export interface CreateReserveRequest {
|
|||||||
* URL to fetch the withdraw status from the bank.
|
* URL to fetch the withdraw status from the bank.
|
||||||
*/
|
*/
|
||||||
bankWithdrawStatusUrl?: string;
|
bankWithdrawStatusUrl?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forced denomination selection for the first withdrawal
|
||||||
|
* from this reserve, only used for testing.
|
||||||
|
*/
|
||||||
|
forcedDenomSel?: ForcedDenomSel;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const codecForCreateReserveRequest = (): Codec<CreateReserveRequest> =>
|
export const codecForCreateReserveRequest = (): Codec<CreateReserveRequest> =>
|
||||||
@ -727,6 +733,7 @@ export interface GetWithdrawalDetailsForAmountRequest {
|
|||||||
export interface AcceptBankIntegratedWithdrawalRequest {
|
export interface AcceptBankIntegratedWithdrawalRequest {
|
||||||
talerWithdrawUri: string;
|
talerWithdrawUri: string;
|
||||||
exchangeBaseUrl: string;
|
exchangeBaseUrl: string;
|
||||||
|
forcedDenomSel?: ForcedDenomSel;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const codecForAcceptBankIntegratedWithdrawalRequest =
|
export const codecForAcceptBankIntegratedWithdrawalRequest =
|
||||||
@ -734,6 +741,7 @@ export const codecForAcceptBankIntegratedWithdrawalRequest =
|
|||||||
buildCodecForObject<AcceptBankIntegratedWithdrawalRequest>()
|
buildCodecForObject<AcceptBankIntegratedWithdrawalRequest>()
|
||||||
.property("exchangeBaseUrl", codecForString())
|
.property("exchangeBaseUrl", codecForString())
|
||||||
.property("talerWithdrawUri", codecForString())
|
.property("talerWithdrawUri", codecForString())
|
||||||
|
.property("forcedDenomSel", codecForAny())
|
||||||
.build("AcceptBankIntegratedWithdrawalRequest");
|
.build("AcceptBankIntegratedWithdrawalRequest");
|
||||||
|
|
||||||
export const codecForGetWithdrawalDetailsForAmountRequest =
|
export const codecForGetWithdrawalDetailsForAmountRequest =
|
||||||
@ -1134,6 +1142,9 @@ export const codecForImportDbRequest = (): Codec<ImportDb> =>
|
|||||||
.property("dump", codecForAny())
|
.property("dump", codecForAny())
|
||||||
.build("ImportDbRequest");
|
.build("ImportDbRequest");
|
||||||
|
|
||||||
|
export interface ForcedDenomSel {
|
||||||
|
denoms: {
|
||||||
|
value: AmountString;
|
||||||
|
count: number;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
@ -106,11 +106,12 @@ export function getTotalRefreshCost(
|
|||||||
amountLeft,
|
amountLeft,
|
||||||
refreshedDenom.feeRefresh,
|
refreshedDenom.feeRefresh,
|
||||||
).amount;
|
).amount;
|
||||||
|
const denomMap = Object.fromEntries(denoms.map((x) => [x.denomPubHash, x]));
|
||||||
const withdrawDenoms = selectWithdrawalDenominations(withdrawAmount, denoms);
|
const withdrawDenoms = selectWithdrawalDenominations(withdrawAmount, denoms);
|
||||||
const resultingAmount = Amounts.add(
|
const resultingAmount = Amounts.add(
|
||||||
Amounts.getZero(withdrawAmount.currency),
|
Amounts.getZero(withdrawAmount.currency),
|
||||||
...withdrawDenoms.selectedDenoms.map(
|
...withdrawDenoms.selectedDenoms.map(
|
||||||
(d) => Amounts.mult(d.denom.value, d.count).amount,
|
(d) => Amounts.mult(denomMap[d.denomPubHash].value, d.count).amount,
|
||||||
),
|
),
|
||||||
).amount;
|
).amount;
|
||||||
const totalCost = Amounts.sub(amountLeft, resultingAmount).amount;
|
const totalCost = Amounts.sub(amountLeft, resultingAmount).amount;
|
||||||
@ -277,7 +278,7 @@ async function refreshCreateSession(
|
|||||||
sessionSecretSeed: sessionSecretSeed,
|
sessionSecretSeed: sessionSecretSeed,
|
||||||
newDenoms: newCoinDenoms.selectedDenoms.map((x) => ({
|
newDenoms: newCoinDenoms.selectedDenoms.map((x) => ({
|
||||||
count: x.count,
|
count: x.count,
|
||||||
denomPubHash: x.denom.denomPubHash,
|
denomPubHash: x.denomPubHash,
|
||||||
})),
|
})),
|
||||||
amountRefreshOutput: newCoinDenoms.totalCoinValue,
|
amountRefreshOutput: newCoinDenoms.totalCoinValue,
|
||||||
};
|
};
|
||||||
|
@ -37,6 +37,8 @@ import {
|
|||||||
TalerErrorDetail,
|
TalerErrorDetail,
|
||||||
AbsoluteTime,
|
AbsoluteTime,
|
||||||
URL,
|
URL,
|
||||||
|
AmountString,
|
||||||
|
ForcedDenomSel,
|
||||||
} from "@gnu-taler/taler-util";
|
} from "@gnu-taler/taler-util";
|
||||||
import { InternalWalletState } from "../internal-wallet-state.js";
|
import { InternalWalletState } from "../internal-wallet-state.js";
|
||||||
import {
|
import {
|
||||||
@ -68,7 +70,6 @@ import {
|
|||||||
updateExchangeFromUrl,
|
updateExchangeFromUrl,
|
||||||
} from "./exchanges.js";
|
} from "./exchanges.js";
|
||||||
import {
|
import {
|
||||||
denomSelectionInfoToState,
|
|
||||||
getBankWithdrawalInfo,
|
getBankWithdrawalInfo,
|
||||||
getCandidateWithdrawalDenoms,
|
getCandidateWithdrawalDenoms,
|
||||||
processWithdrawGroup,
|
processWithdrawGroup,
|
||||||
@ -180,8 +181,7 @@ export async function createReserve(
|
|||||||
|
|
||||||
await updateWithdrawalDenoms(ws, canonExchange);
|
await updateWithdrawalDenoms(ws, canonExchange);
|
||||||
const denoms = await getCandidateWithdrawalDenoms(ws, canonExchange);
|
const denoms = await getCandidateWithdrawalDenoms(ws, canonExchange);
|
||||||
const denomSelInfo = selectWithdrawalDenominations(req.amount, denoms);
|
const initialDenomSel = selectWithdrawalDenominations(req.amount, denoms);
|
||||||
const initialDenomSel = denomSelectionInfoToState(denomSelInfo);
|
|
||||||
|
|
||||||
const reserveRecord: ReserveRecord = {
|
const reserveRecord: ReserveRecord = {
|
||||||
instructedAmount: req.amount,
|
instructedAmount: req.amount,
|
||||||
@ -630,7 +630,7 @@ async function updateReserve(
|
|||||||
amountReservePlus,
|
amountReservePlus,
|
||||||
amountReserveMinus,
|
amountReserveMinus,
|
||||||
).amount;
|
).amount;
|
||||||
const denomSelInfo = selectWithdrawalDenominations(
|
const denomSel = selectWithdrawalDenominations(
|
||||||
remainingAmount,
|
remainingAmount,
|
||||||
denoms,
|
denoms,
|
||||||
);
|
);
|
||||||
@ -639,11 +639,11 @@ async function updateReserve(
|
|||||||
`Remaining unclaimed amount in reseve is ${Amounts.stringify(
|
`Remaining unclaimed amount in reseve is ${Amounts.stringify(
|
||||||
remainingAmount,
|
remainingAmount,
|
||||||
)} and can be withdrawn with ${
|
)} and can be withdrawn with ${
|
||||||
denomSelInfo.selectedDenoms.length
|
denomSel.selectedDenoms.length
|
||||||
} coins`,
|
} coins`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (denomSelInfo.selectedDenoms.length === 0) {
|
if (denomSel.selectedDenoms.length === 0) {
|
||||||
newReserve.reserveStatus = ReserveRecordStatus.Dormant;
|
newReserve.reserveStatus = ReserveRecordStatus.Dormant;
|
||||||
newReserve.operationStatus = OperationStatus.Finished;
|
newReserve.operationStatus = OperationStatus.Finished;
|
||||||
delete newReserve.lastError;
|
delete newReserve.lastError;
|
||||||
@ -669,7 +669,7 @@ async function updateReserve(
|
|||||||
timestampStart: AbsoluteTime.toTimestamp(AbsoluteTime.now()),
|
timestampStart: AbsoluteTime.toTimestamp(AbsoluteTime.now()),
|
||||||
retryInfo: resetRetryInfo(),
|
retryInfo: resetRetryInfo(),
|
||||||
lastError: undefined,
|
lastError: undefined,
|
||||||
denomsSel: denomSelectionInfoToState(denomSelInfo),
|
denomsSel: denomSel,
|
||||||
secretSeed: encodeCrock(getRandomBytes(64)),
|
secretSeed: encodeCrock(getRandomBytes(64)),
|
||||||
denomSelUid: encodeCrock(getRandomBytes(32)),
|
denomSelUid: encodeCrock(getRandomBytes(32)),
|
||||||
operationStatus: OperationStatus.Pending,
|
operationStatus: OperationStatus.Pending,
|
||||||
@ -755,6 +755,9 @@ export async function createTalerWithdrawReserve(
|
|||||||
ws: InternalWalletState,
|
ws: InternalWalletState,
|
||||||
talerWithdrawUri: string,
|
talerWithdrawUri: string,
|
||||||
selectedExchange: string,
|
selectedExchange: string,
|
||||||
|
options: {
|
||||||
|
forcedDenomSel?: ForcedDenomSel;
|
||||||
|
} = {},
|
||||||
): Promise<AcceptWithdrawalResponse> {
|
): Promise<AcceptWithdrawalResponse> {
|
||||||
await updateExchangeFromUrl(ws, selectedExchange);
|
await updateExchangeFromUrl(ws, selectedExchange);
|
||||||
const withdrawInfo = await getBankWithdrawalInfo(ws.http, talerWithdrawUri);
|
const withdrawInfo = await getBankWithdrawalInfo(ws.http, talerWithdrawUri);
|
||||||
|
@ -56,7 +56,6 @@ import {
|
|||||||
updateWithdrawalDenoms,
|
updateWithdrawalDenoms,
|
||||||
getCandidateWithdrawalDenoms,
|
getCandidateWithdrawalDenoms,
|
||||||
selectWithdrawalDenominations,
|
selectWithdrawalDenominations,
|
||||||
denomSelectionInfoToState,
|
|
||||||
} from "./withdraw.js";
|
} from "./withdraw.js";
|
||||||
import {
|
import {
|
||||||
getHttpResponseErrorDetails,
|
getHttpResponseErrorDetails,
|
||||||
@ -133,7 +132,7 @@ export async function prepareTip(
|
|||||||
tipAmountEffective: selectedDenoms.totalCoinValue,
|
tipAmountEffective: selectedDenoms.totalCoinValue,
|
||||||
retryInfo: resetRetryInfo(),
|
retryInfo: resetRetryInfo(),
|
||||||
lastError: undefined,
|
lastError: undefined,
|
||||||
denomsSel: denomSelectionInfoToState(selectedDenoms),
|
denomsSel: selectedDenoms,
|
||||||
pickedUpTimestamp: undefined,
|
pickedUpTimestamp: undefined,
|
||||||
secretSeed,
|
secretSeed,
|
||||||
denomSelUid,
|
denomSelUid,
|
||||||
|
@ -31,6 +31,7 @@ import {
|
|||||||
durationFromSpec,
|
durationFromSpec,
|
||||||
ExchangeListItem,
|
ExchangeListItem,
|
||||||
ExchangeWithdrawRequest,
|
ExchangeWithdrawRequest,
|
||||||
|
ForcedDenomSel,
|
||||||
LibtoolVersion,
|
LibtoolVersion,
|
||||||
Logger,
|
Logger,
|
||||||
NotificationType,
|
NotificationType,
|
||||||
@ -68,6 +69,7 @@ import {
|
|||||||
HttpRequestLibrary,
|
HttpRequestLibrary,
|
||||||
readSuccessResponseJsonOrThrow,
|
readSuccessResponseJsonOrThrow,
|
||||||
} from "../util/http.js";
|
} from "../util/http.js";
|
||||||
|
import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
|
||||||
import {
|
import {
|
||||||
resetRetryInfo,
|
resetRetryInfo,
|
||||||
RetryInfo,
|
RetryInfo,
|
||||||
@ -84,21 +86,6 @@ import { guardOperationException } from "./common.js";
|
|||||||
*/
|
*/
|
||||||
const logger = new Logger("operations/withdraw.ts");
|
const logger = new Logger("operations/withdraw.ts");
|
||||||
|
|
||||||
/**
|
|
||||||
* FIXME: Eliminate this in favor of DenomSelectionState.
|
|
||||||
*/
|
|
||||||
interface DenominationSelectionInfo {
|
|
||||||
totalCoinValue: AmountJson;
|
|
||||||
totalWithdrawCost: AmountJson;
|
|
||||||
selectedDenoms: {
|
|
||||||
/**
|
|
||||||
* How many times do we withdraw this denomination?
|
|
||||||
*/
|
|
||||||
count: number;
|
|
||||||
denom: DenominationRecord;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Information about what will happen when creating a reserve.
|
* Information about what will happen when creating a reserve.
|
||||||
*
|
*
|
||||||
@ -122,7 +109,7 @@ export interface ExchangeWithdrawDetails {
|
|||||||
/**
|
/**
|
||||||
* Selected denominations for withdraw.
|
* Selected denominations for withdraw.
|
||||||
*/
|
*/
|
||||||
selectedDenoms: DenominationSelectionInfo;
|
selectedDenoms: DenomSelectionState;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Does the wallet know about an auditor for
|
* Does the wallet know about an auditor for
|
||||||
@ -213,12 +200,12 @@ export function isWithdrawableDenom(d: DenominationRecord): boolean {
|
|||||||
export function selectWithdrawalDenominations(
|
export function selectWithdrawalDenominations(
|
||||||
amountAvailable: AmountJson,
|
amountAvailable: AmountJson,
|
||||||
denoms: DenominationRecord[],
|
denoms: DenominationRecord[],
|
||||||
): DenominationSelectionInfo {
|
): DenomSelectionState {
|
||||||
let remaining = Amounts.copy(amountAvailable);
|
let remaining = Amounts.copy(amountAvailable);
|
||||||
|
|
||||||
const selectedDenoms: {
|
const selectedDenoms: {
|
||||||
count: number;
|
count: number;
|
||||||
denom: DenominationRecord;
|
denomPubHash: string;
|
||||||
}[] = [];
|
}[] = [];
|
||||||
|
|
||||||
let totalCoinValue = Amounts.getZero(amountAvailable.currency);
|
let totalCoinValue = Amounts.getZero(amountAvailable.currency);
|
||||||
@ -248,7 +235,7 @@ export function selectWithdrawalDenominations(
|
|||||||
).amount;
|
).amount;
|
||||||
selectedDenoms.push({
|
selectedDenoms.push({
|
||||||
count,
|
count,
|
||||||
denom: d,
|
denomPubHash: d.denomPubHash,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -262,9 +249,7 @@ export function selectWithdrawalDenominations(
|
|||||||
`selected withdrawal denoms for ${Amounts.stringify(totalCoinValue)}`,
|
`selected withdrawal denoms for ${Amounts.stringify(totalCoinValue)}`,
|
||||||
);
|
);
|
||||||
for (const sd of selectedDenoms) {
|
for (const sd of selectedDenoms) {
|
||||||
logger.trace(
|
logger.trace(`denom_pub_hash=${sd.denomPubHash}, count=${sd.count}`);
|
||||||
`denom_pub_hash=${sd.denom.denomPubHash}, count=${sd.count}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
logger.trace("(end of withdrawal denom list)");
|
logger.trace("(end of withdrawal denom list)");
|
||||||
}
|
}
|
||||||
@ -276,6 +261,56 @@ export function selectWithdrawalDenominations(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function selectForcedWithdrawalDenominations(
|
||||||
|
amountAvailable: AmountJson,
|
||||||
|
denoms: DenominationRecord[],
|
||||||
|
forcedDenomSel: ForcedDenomSel,
|
||||||
|
): DenomSelectionState {
|
||||||
|
let remaining = Amounts.copy(amountAvailable);
|
||||||
|
|
||||||
|
const selectedDenoms: {
|
||||||
|
count: number;
|
||||||
|
denomPubHash: string;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
let totalCoinValue = Amounts.getZero(amountAvailable.currency);
|
||||||
|
let totalWithdrawCost = Amounts.getZero(amountAvailable.currency);
|
||||||
|
|
||||||
|
denoms = denoms.filter(isWithdrawableDenom);
|
||||||
|
denoms.sort((d1, d2) => Amounts.cmp(d2.value, d1.value));
|
||||||
|
|
||||||
|
for (const fds of forcedDenomSel.denoms) {
|
||||||
|
const count = fds.count;
|
||||||
|
const denom = denoms.find((x) => {
|
||||||
|
return Amounts.cmp(x.value, fds.value) == 0;
|
||||||
|
});
|
||||||
|
if (!denom) {
|
||||||
|
throw Error(
|
||||||
|
`unable to find denom for forced selection (value ${fds.value})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const cost = Amounts.add(denom.value, denom.feeWithdraw).amount;
|
||||||
|
totalCoinValue = Amounts.add(
|
||||||
|
totalCoinValue,
|
||||||
|
Amounts.mult(denom.value, count).amount,
|
||||||
|
).amount;
|
||||||
|
totalWithdrawCost = Amounts.add(
|
||||||
|
totalWithdrawCost,
|
||||||
|
Amounts.mult(cost, count).amount,
|
||||||
|
).amount;
|
||||||
|
selectedDenoms.push({
|
||||||
|
count,
|
||||||
|
denomPubHash: denom.denomPubHash,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedDenoms,
|
||||||
|
totalCoinValue,
|
||||||
|
totalWithdrawCost,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get information about a withdrawal from
|
* Get information about a withdrawal from
|
||||||
* a taler://withdraw URI by asking the bank.
|
* a taler://withdraw URI by asking the bank.
|
||||||
@ -695,21 +730,6 @@ async function processPlanchetVerifyAndStoreCoin(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function denomSelectionInfoToState(
|
|
||||||
dsi: DenominationSelectionInfo,
|
|
||||||
): DenomSelectionState {
|
|
||||||
return {
|
|
||||||
selectedDenoms: dsi.selectedDenoms.map((x) => {
|
|
||||||
return {
|
|
||||||
count: x.count,
|
|
||||||
denomPubHash: x.denom.denomPubHash,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
totalCoinValue: dsi.totalCoinValue,
|
|
||||||
totalWithdrawCost: dsi.totalWithdrawCost,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make sure that denominations that currently can be used for withdrawal
|
* Make sure that denominations that currently can be used for withdrawal
|
||||||
* are validated, and the result of validation is stored in the database.
|
* are validated, and the result of validation is stored in the database.
|
||||||
@ -1006,11 +1026,21 @@ export async function getExchangeWithdrawalInfo(
|
|||||||
exchange,
|
exchange,
|
||||||
);
|
);
|
||||||
|
|
||||||
let earliestDepositExpiration =
|
let earliestDepositExpiration: TalerProtocolTimestamp | undefined;
|
||||||
selectedDenoms.selectedDenoms[0].denom.stampExpireDeposit;
|
|
||||||
for (let i = 1; i < selectedDenoms.selectedDenoms.length; i++) {
|
for (let i = 1; i < selectedDenoms.selectedDenoms.length; i++) {
|
||||||
const expireDeposit =
|
const ds = selectedDenoms.selectedDenoms[i];
|
||||||
selectedDenoms.selectedDenoms[i].denom.stampExpireDeposit;
|
// FIXME: Do in one transaction!
|
||||||
|
const denom = await ws.db
|
||||||
|
.mktx((x) => ({ denominations: x.denominations }))
|
||||||
|
.runReadOnly(async (tx) => {
|
||||||
|
return ws.getDenomInfo(ws, tx, exchangeBaseUrl, ds.denomPubHash);
|
||||||
|
});
|
||||||
|
checkDbInvariant(!!denom);
|
||||||
|
const expireDeposit = denom.stampExpireDeposit;
|
||||||
|
if (!earliestDepositExpiration) {
|
||||||
|
earliestDepositExpiration = expireDeposit;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
AbsoluteTime.cmp(
|
AbsoluteTime.cmp(
|
||||||
AbsoluteTime.fromTimestamp(expireDeposit),
|
AbsoluteTime.fromTimestamp(expireDeposit),
|
||||||
@ -1021,6 +1051,8 @@ export async function getExchangeWithdrawalInfo(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
checkLogicInvariant(!!earliestDepositExpiration);
|
||||||
|
|
||||||
const possibleDenoms = await ws.db
|
const possibleDenoms = await ws.db
|
||||||
.mktx((x) => ({ denominations: x.denominations }))
|
.mktx((x) => ({ denominations: x.denominations }))
|
||||||
.runReadOnly(async (tx) => {
|
.runReadOnly(async (tx) => {
|
||||||
|
@ -598,18 +598,6 @@ async function getExchanges(
|
|||||||
return { exchanges };
|
return { exchanges };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function acceptWithdrawal(
|
|
||||||
ws: InternalWalletState,
|
|
||||||
talerWithdrawUri: string,
|
|
||||||
selectedExchange: string,
|
|
||||||
): Promise<AcceptWithdrawalResponse> {
|
|
||||||
try {
|
|
||||||
return createTalerWithdrawReserve(ws, talerWithdrawUri, selectedExchange);
|
|
||||||
} finally {
|
|
||||||
ws.latch.trigger();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inform the wallet that the status of a reserve has changed (e.g. due to a
|
* Inform the wallet that the status of a reserve has changed (e.g. due to a
|
||||||
* confirmation from the bank.).
|
* confirmation from the bank.).
|
||||||
@ -849,10 +837,13 @@ async function dispatchRequestInternal(
|
|||||||
case "acceptBankIntegratedWithdrawal": {
|
case "acceptBankIntegratedWithdrawal": {
|
||||||
const req =
|
const req =
|
||||||
codecForAcceptBankIntegratedWithdrawalRequest().decode(payload);
|
codecForAcceptBankIntegratedWithdrawalRequest().decode(payload);
|
||||||
return await acceptWithdrawal(
|
return await createTalerWithdrawReserve(
|
||||||
ws,
|
ws,
|
||||||
req.talerWithdrawUri,
|
req.talerWithdrawUri,
|
||||||
req.exchangeBaseUrl,
|
req.exchangeBaseUrl,
|
||||||
|
{
|
||||||
|
forcedDenomSel: req.forcedDenomSel,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "getExchangeTos": {
|
case "getExchangeTos": {
|
||||||
|
Loading…
Reference in New Issue
Block a user