oops, missing file
This commit is contained in:
parent
857c0ab4cd
commit
59bd755f7d
@ -24,60 +24,54 @@
|
||||
/**
|
||||
* Imports.
|
||||
*/
|
||||
import { AmountJson } from "../util/amounts";
|
||||
import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto";
|
||||
import {
|
||||
CoinRecord,
|
||||
CoinStatus,
|
||||
DenominationRecord,
|
||||
initRetryInfo,
|
||||
ProposalRecord,
|
||||
ProposalStatus,
|
||||
PurchaseRecord,
|
||||
RefundReason,
|
||||
Stores,
|
||||
updateRetryInfoTimeout,
|
||||
} from "../types/dbTypes";
|
||||
import { NotificationType } from "../types/notifications";
|
||||
import {
|
||||
Auditor,
|
||||
ContractTerms,
|
||||
ExchangeHandle,
|
||||
MerchantRefundResponse,
|
||||
PayReq,
|
||||
Proposal,
|
||||
ContractTerms,
|
||||
MerchantRefundPermission,
|
||||
RefundRequest,
|
||||
} from "../types/talerTypes";
|
||||
import {
|
||||
Timestamp,
|
||||
CoinSelectionResult,
|
||||
CoinWithDenom,
|
||||
PayCoinInfo,
|
||||
getTimestampNow,
|
||||
PreparePayResult,
|
||||
ConfirmPayResult,
|
||||
getTimestampNow,
|
||||
OperationError,
|
||||
PayCoinInfo,
|
||||
PreparePayResult,
|
||||
RefreshReason,
|
||||
Timestamp,
|
||||
} from "../types/walletTypes";
|
||||
import {
|
||||
Stores,
|
||||
CoinStatus,
|
||||
DenominationRecord,
|
||||
ProposalRecord,
|
||||
PurchaseRecord,
|
||||
CoinRecord,
|
||||
ProposalStatus,
|
||||
initRetryInfo,
|
||||
updateRetryInfoTimeout,
|
||||
RefundReason,
|
||||
} from "../types/dbTypes";
|
||||
import * as Amounts from "../util/amounts";
|
||||
import { AmountJson } from "../util/amounts";
|
||||
import {
|
||||
amountToPretty,
|
||||
strcmp,
|
||||
canonicalJson,
|
||||
extractTalerStampOrThrow,
|
||||
extractTalerDuration,
|
||||
extractTalerStampOrThrow,
|
||||
strcmp,
|
||||
} from "../util/helpers";
|
||||
import { Logger } from "../util/logging";
|
||||
import { InternalWalletState } from "./state";
|
||||
import {
|
||||
parsePayUri,
|
||||
parseRefundUri,
|
||||
getOrderDownloadUrl,
|
||||
} from "../util/taleruri";
|
||||
import { getTotalRefreshCost, createRefreshGroup } from "./refresh";
|
||||
import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto";
|
||||
import { getOrderDownloadUrl, parsePayUri } from "../util/taleruri";
|
||||
import { guardOperationException } from "./errors";
|
||||
import { NotificationType } from "../types/notifications";
|
||||
import { createRefreshGroup, getTotalRefreshCost } from "./refresh";
|
||||
import { acceptRefundResponse } from "./refund";
|
||||
import { InternalWalletState } from "./state";
|
||||
|
||||
export interface SpeculativePayData {
|
||||
payCoinInfo: PayCoinInfo;
|
||||
|
@ -18,20 +18,18 @@
|
||||
* Imports.
|
||||
*/
|
||||
import {
|
||||
getTimestampNow,
|
||||
Timestamp,
|
||||
Duration,
|
||||
} from "../types/walletTypes";
|
||||
import { Database, TransactionHandle } from "../util/query";
|
||||
import { InternalWalletState } from "./state";
|
||||
import {
|
||||
Stores,
|
||||
ExchangeUpdateStatus,
|
||||
ReserveRecordStatus,
|
||||
CoinStatus,
|
||||
ProposalStatus,
|
||||
ReserveRecordStatus,
|
||||
Stores,
|
||||
} from "../types/dbTypes";
|
||||
import { PendingOperationsResponse, PendingOperationType } from "../types/pending";
|
||||
import {
|
||||
PendingOperationsResponse,
|
||||
PendingOperationType,
|
||||
} from "../types/pending";
|
||||
import { Duration, getTimestampNow, Timestamp } from "../types/walletTypes";
|
||||
import { TransactionHandle } from "../util/query";
|
||||
import { InternalWalletState } from "./state";
|
||||
|
||||
function updateRetryDelay(
|
||||
oldDelay: Duration,
|
||||
|
502
src/operations/refund.ts
Normal file
502
src/operations/refund.ts
Normal file
@ -0,0 +1,502 @@
|
||||
/*
|
||||
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 <http://www.gnu.org/licenses/>
|
||||
*/
|
||||
|
||||
/**
|
||||
* Implementation of the refund operation.
|
||||
*
|
||||
* @author Florian Dold
|
||||
*/
|
||||
|
||||
/**
|
||||
* Imports.
|
||||
*/
|
||||
import { InternalWalletState } from "./state";
|
||||
import {
|
||||
OperationError,
|
||||
getTimestampNow,
|
||||
RefreshReason,
|
||||
} from "../types/walletTypes";
|
||||
import {
|
||||
Stores,
|
||||
updateRetryInfoTimeout,
|
||||
initRetryInfo,
|
||||
CoinStatus,
|
||||
RefundReason,
|
||||
RefundEventRecord,
|
||||
} from "../types/dbTypes";
|
||||
import { NotificationType } from "../types/notifications";
|
||||
import { parseRefundUri } from "../util/taleruri";
|
||||
import { createRefreshGroup, getTotalRefreshCost } from "./refresh";
|
||||
import * as Amounts from "../util/amounts";
|
||||
import {
|
||||
MerchantRefundPermission,
|
||||
MerchantRefundResponse,
|
||||
RefundRequest,
|
||||
} from "../types/talerTypes";
|
||||
import { AmountJson } from "../util/amounts";
|
||||
import { guardOperationException, OperationFailedError } from "./errors";
|
||||
import { randomBytes } from "../crypto/primitives/nacl-fast";
|
||||
import { encodeCrock } from "../crypto/talerCrypto";
|
||||
import { HttpResponseStatus } from "../util/http";
|
||||
|
||||
async function incrementPurchaseQueryRefundRetry(
|
||||
ws: InternalWalletState,
|
||||
proposalId: string,
|
||||
err: OperationError | undefined,
|
||||
): Promise<void> {
|
||||
console.log("incrementing purchase refund query retry with error", err);
|
||||
await ws.db.runWithWriteTransaction([Stores.purchases], async tx => {
|
||||
const pr = await tx.get(Stores.purchases, proposalId);
|
||||
if (!pr) {
|
||||
return;
|
||||
}
|
||||
if (!pr.refundStatusRetryInfo) {
|
||||
return;
|
||||
}
|
||||
pr.refundStatusRetryInfo.retryCounter++;
|
||||
updateRetryInfoTimeout(pr.refundStatusRetryInfo);
|
||||
pr.lastRefundStatusError = err;
|
||||
await tx.put(Stores.purchases, pr);
|
||||
});
|
||||
ws.notify({ type: NotificationType.RefundStatusOperationError });
|
||||
}
|
||||
|
||||
async function incrementPurchaseApplyRefundRetry(
|
||||
ws: InternalWalletState,
|
||||
proposalId: string,
|
||||
err: OperationError | undefined,
|
||||
): Promise<void> {
|
||||
console.log("incrementing purchase refund apply retry with error", err);
|
||||
await ws.db.runWithWriteTransaction([Stores.purchases], async tx => {
|
||||
const pr = await tx.get(Stores.purchases, proposalId);
|
||||
if (!pr) {
|
||||
return;
|
||||
}
|
||||
if (!pr.refundApplyRetryInfo) {
|
||||
return;
|
||||
}
|
||||
pr.refundApplyRetryInfo.retryCounter++;
|
||||
updateRetryInfoTimeout(pr.refundStatusRetryInfo);
|
||||
pr.lastRefundApplyError = err;
|
||||
await tx.put(Stores.purchases, pr);
|
||||
});
|
||||
ws.notify({ type: NotificationType.RefundApplyOperationError });
|
||||
}
|
||||
|
||||
export async function getFullRefundFees(
|
||||
ws: InternalWalletState,
|
||||
refundPermissions: MerchantRefundPermission[],
|
||||
): Promise<AmountJson> {
|
||||
if (refundPermissions.length === 0) {
|
||||
throw Error("no refunds given");
|
||||
}
|
||||
const coin0 = await ws.db.get(Stores.coins, refundPermissions[0].coin_pub);
|
||||
if (!coin0) {
|
||||
throw Error("coin not found");
|
||||
}
|
||||
let feeAcc = Amounts.getZero(
|
||||
Amounts.parseOrThrow(refundPermissions[0].refund_amount).currency,
|
||||
);
|
||||
|
||||
const denoms = await ws.db
|
||||
.iterIndex(Stores.denominations.exchangeBaseUrlIndex, coin0.exchangeBaseUrl)
|
||||
.toArray();
|
||||
|
||||
for (const rp of refundPermissions) {
|
||||
const coin = await ws.db.get(Stores.coins, rp.coin_pub);
|
||||
if (!coin) {
|
||||
throw Error("coin not found");
|
||||
}
|
||||
const denom = await ws.db.get(Stores.denominations, [
|
||||
coin0.exchangeBaseUrl,
|
||||
coin.denomPub,
|
||||
]);
|
||||
if (!denom) {
|
||||
throw Error(`denom not found (${coin.denomPub})`);
|
||||
}
|
||||
// FIXME: this assumes that the refund already happened.
|
||||
// When it hasn't, the refresh cost is inaccurate. To fix this,
|
||||
// we need introduce a flag to tell if a coin was refunded or
|
||||
// refreshed normally (and what about incremental refunds?)
|
||||
const refundAmount = Amounts.parseOrThrow(rp.refund_amount);
|
||||
const refundFee = Amounts.parseOrThrow(rp.refund_fee);
|
||||
const refreshCost = getTotalRefreshCost(
|
||||
denoms,
|
||||
denom,
|
||||
Amounts.sub(refundAmount, refundFee).amount,
|
||||
);
|
||||
feeAcc = Amounts.add(feeAcc, refreshCost, refundFee).amount;
|
||||
}
|
||||
return feeAcc;
|
||||
}
|
||||
|
||||
export async function acceptRefundResponse(
|
||||
ws: InternalWalletState,
|
||||
proposalId: string,
|
||||
refundResponse: MerchantRefundResponse,
|
||||
reason: RefundReason,
|
||||
): Promise<void> {
|
||||
const refundPermissions = refundResponse.refund_permissions;
|
||||
|
||||
let numNewRefunds = 0;
|
||||
|
||||
const refundGroupId = encodeCrock(randomBytes(32));
|
||||
|
||||
await ws.db.runWithWriteTransaction([Stores.purchases], async tx => {
|
||||
const p = await tx.get(Stores.purchases, proposalId);
|
||||
if (!p) {
|
||||
console.error("purchase not found, not adding refunds");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!p.refundStatusRequested) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const perm of refundPermissions) {
|
||||
const isDone = p.refundState.refundsDone[perm.merchant_sig];
|
||||
const isPending = p.refundState.refundsPending[perm.merchant_sig];
|
||||
if (!isDone && !isPending) {
|
||||
p.refundState.refundsPending[perm.merchant_sig] = {
|
||||
perm,
|
||||
refundGroupId,
|
||||
};
|
||||
numNewRefunds++;
|
||||
}
|
||||
}
|
||||
|
||||
// Are we done with querying yet, or do we need to do another round
|
||||
// after a retry delay?
|
||||
let queryDone = true;
|
||||
|
||||
if (numNewRefunds === 0) {
|
||||
if (
|
||||
p.autoRefundDeadline &&
|
||||
p.autoRefundDeadline.t_ms > getTimestampNow().t_ms
|
||||
) {
|
||||
queryDone = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (queryDone) {
|
||||
p.lastRefundStatusTimestamp = getTimestampNow();
|
||||
p.lastRefundStatusError = undefined;
|
||||
p.refundStatusRetryInfo = initRetryInfo();
|
||||
p.refundStatusRequested = false;
|
||||
console.log("refund query done");
|
||||
} else {
|
||||
// No error, but we need to try again!
|
||||
p.lastRefundStatusTimestamp = getTimestampNow();
|
||||
p.refundStatusRetryInfo.retryCounter++;
|
||||
updateRetryInfoTimeout(p.refundStatusRetryInfo);
|
||||
p.lastRefundStatusError = undefined;
|
||||
console.log("refund query not done");
|
||||
}
|
||||
|
||||
if (numNewRefunds > 0) {
|
||||
const now = getTimestampNow();
|
||||
p.lastRefundApplyError = undefined;
|
||||
p.refundApplyRetryInfo = initRetryInfo();
|
||||
p.refundState.refundGroups.push({
|
||||
timestampQueried: now,
|
||||
reason,
|
||||
});
|
||||
|
||||
const refundEvent: RefundEventRecord = {
|
||||
proposalId,
|
||||
refundGroupId,
|
||||
timestamp: now,
|
||||
};
|
||||
await tx.put(Stores.refundEvents, refundEvent);
|
||||
}
|
||||
|
||||
await tx.put(Stores.purchases, p);
|
||||
});
|
||||
|
||||
ws.notify({
|
||||
type: NotificationType.RefundQueried,
|
||||
});
|
||||
if (numNewRefunds > 0) {
|
||||
await processPurchaseApplyRefund(ws, proposalId);
|
||||
}
|
||||
}
|
||||
|
||||
async function startRefundQuery(
|
||||
ws: InternalWalletState,
|
||||
proposalId: string,
|
||||
): Promise<void> {
|
||||
const success = await ws.db.runWithWriteTransaction(
|
||||
[Stores.purchases],
|
||||
async tx => {
|
||||
const p = await tx.get(Stores.purchases, proposalId);
|
||||
if (!p) {
|
||||
console.log("no purchase found for refund URL");
|
||||
return false;
|
||||
}
|
||||
p.refundStatusRequested = true;
|
||||
p.lastRefundStatusError = undefined;
|
||||
p.refundStatusRetryInfo = initRetryInfo();
|
||||
await tx.put(Stores.purchases, p);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
if (!success) {
|
||||
return;
|
||||
}
|
||||
|
||||
ws.notify({
|
||||
type: NotificationType.RefundStarted,
|
||||
});
|
||||
|
||||
await processPurchaseQueryRefund(ws, proposalId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a refund, return the contract hash for the contract
|
||||
* that was involved in the refund.
|
||||
*/
|
||||
export async function applyRefund(
|
||||
ws: InternalWalletState,
|
||||
talerRefundUri: string,
|
||||
): Promise<string> {
|
||||
const parseResult = parseRefundUri(talerRefundUri);
|
||||
|
||||
console.log("applying refund");
|
||||
|
||||
if (!parseResult) {
|
||||
throw Error("invalid refund URI");
|
||||
}
|
||||
|
||||
const purchase = await ws.db.getIndexed(Stores.purchases.orderIdIndex, [
|
||||
parseResult.merchantBaseUrl,
|
||||
parseResult.orderId,
|
||||
]);
|
||||
|
||||
if (!purchase) {
|
||||
throw Error("no purchase for the taler://refund/ URI was found");
|
||||
}
|
||||
|
||||
console.log("processing purchase for refund");
|
||||
await startRefundQuery(ws, purchase.proposalId);
|
||||
|
||||
return purchase.contractTermsHash;
|
||||
}
|
||||
|
||||
export async function processPurchaseQueryRefund(
|
||||
ws: InternalWalletState,
|
||||
proposalId: string,
|
||||
forceNow: boolean = false,
|
||||
): Promise<void> {
|
||||
const onOpErr = (e: OperationError) =>
|
||||
incrementPurchaseQueryRefundRetry(ws, proposalId, e);
|
||||
await guardOperationException(
|
||||
() => processPurchaseQueryRefundImpl(ws, proposalId, forceNow),
|
||||
onOpErr,
|
||||
);
|
||||
}
|
||||
|
||||
async function resetPurchaseQueryRefundRetry(
|
||||
ws: InternalWalletState,
|
||||
proposalId: string,
|
||||
) {
|
||||
await ws.db.mutate(Stores.purchases, proposalId, x => {
|
||||
if (x.refundStatusRetryInfo.active) {
|
||||
x.refundStatusRetryInfo = initRetryInfo();
|
||||
}
|
||||
return x;
|
||||
});
|
||||
}
|
||||
|
||||
async function processPurchaseQueryRefundImpl(
|
||||
ws: InternalWalletState,
|
||||
proposalId: string,
|
||||
forceNow: boolean,
|
||||
): Promise<void> {
|
||||
if (forceNow) {
|
||||
await resetPurchaseQueryRefundRetry(ws, proposalId);
|
||||
}
|
||||
const purchase = await ws.db.get(Stores.purchases, proposalId);
|
||||
if (!purchase) {
|
||||
return;
|
||||
}
|
||||
if (!purchase.refundStatusRequested) {
|
||||
return;
|
||||
}
|
||||
|
||||
const refundUrlObj = new URL(
|
||||
"refund",
|
||||
purchase.contractTerms.merchant_base_url,
|
||||
);
|
||||
refundUrlObj.searchParams.set("order_id", purchase.contractTerms.order_id);
|
||||
const refundUrl = refundUrlObj.href;
|
||||
let resp;
|
||||
try {
|
||||
resp = await ws.http.get(refundUrl);
|
||||
} catch (e) {
|
||||
console.error("error downloading refund permission", e);
|
||||
throw e;
|
||||
}
|
||||
if (resp.status !== 200) {
|
||||
throw Error(`unexpected status code (${resp.status}) for /refund`);
|
||||
}
|
||||
|
||||
const refundResponse = MerchantRefundResponse.checked(await resp.json());
|
||||
await acceptRefundResponse(
|
||||
ws,
|
||||
proposalId,
|
||||
refundResponse,
|
||||
RefundReason.NormalRefund,
|
||||
);
|
||||
}
|
||||
|
||||
export async function processPurchaseApplyRefund(
|
||||
ws: InternalWalletState,
|
||||
proposalId: string,
|
||||
forceNow: boolean = false,
|
||||
): Promise<void> {
|
||||
const onOpErr = (e: OperationError) =>
|
||||
incrementPurchaseApplyRefundRetry(ws, proposalId, e);
|
||||
await guardOperationException(
|
||||
() => processPurchaseApplyRefundImpl(ws, proposalId, forceNow),
|
||||
onOpErr,
|
||||
);
|
||||
}
|
||||
|
||||
async function resetPurchaseApplyRefundRetry(
|
||||
ws: InternalWalletState,
|
||||
proposalId: string,
|
||||
) {
|
||||
await ws.db.mutate(Stores.purchases, proposalId, x => {
|
||||
if (x.refundApplyRetryInfo.active) {
|
||||
x.refundApplyRetryInfo = initRetryInfo();
|
||||
}
|
||||
return x;
|
||||
});
|
||||
}
|
||||
|
||||
async function processPurchaseApplyRefundImpl(
|
||||
ws: InternalWalletState,
|
||||
proposalId: string,
|
||||
forceNow: boolean,
|
||||
): Promise<void> {
|
||||
if (forceNow) {
|
||||
await resetPurchaseApplyRefundRetry(ws, proposalId);
|
||||
}
|
||||
const purchase = await ws.db.get(Stores.purchases, proposalId);
|
||||
if (!purchase) {
|
||||
console.error("not submitting refunds, payment not found:");
|
||||
return;
|
||||
}
|
||||
const pendingKeys = Object.keys(purchase.refundState.refundsPending);
|
||||
if (pendingKeys.length === 0) {
|
||||
console.log("no pending refunds");
|
||||
return;
|
||||
}
|
||||
for (const pk of pendingKeys) {
|
||||
const info = purchase.refundState.refundsPending[pk];
|
||||
const perm = info.perm;
|
||||
const req: RefundRequest = {
|
||||
coin_pub: perm.coin_pub,
|
||||
h_contract_terms: purchase.contractTermsHash,
|
||||
merchant_pub: purchase.contractTerms.merchant_pub,
|
||||
merchant_sig: perm.merchant_sig,
|
||||
refund_amount: perm.refund_amount,
|
||||
refund_fee: perm.refund_fee,
|
||||
rtransaction_id: perm.rtransaction_id,
|
||||
};
|
||||
console.log("sending refund permission", perm);
|
||||
// FIXME: not correct once we support multiple exchanges per payment
|
||||
const exchangeUrl = purchase.payReq.coins[0].exchange_url;
|
||||
const reqUrl = new URL("refund", exchangeUrl);
|
||||
const resp = await ws.http.postJson(reqUrl.href, req);
|
||||
console.log("sent refund permission");
|
||||
let refundGone = false;
|
||||
switch (resp.status) {
|
||||
case HttpResponseStatus.Ok:
|
||||
break;
|
||||
case HttpResponseStatus.Gone:
|
||||
// We're too late, refund is expired.
|
||||
refundGone = true;
|
||||
break;
|
||||
default:
|
||||
let body: string | null = null;
|
||||
try {
|
||||
body = await resp.json();
|
||||
} catch {}
|
||||
const m = "refund request (at exchange) failed";
|
||||
throw new OperationFailedError(m, {
|
||||
message: m,
|
||||
type: "network",
|
||||
details: {
|
||||
body,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let allRefundsProcessed = false;
|
||||
|
||||
await ws.db.runWithWriteTransaction(
|
||||
[Stores.purchases, Stores.coins, Stores.refreshGroups],
|
||||
async tx => {
|
||||
const p = await tx.get(Stores.purchases, proposalId);
|
||||
if (!p) {
|
||||
return;
|
||||
}
|
||||
if (p.refundState.refundsPending[pk]) {
|
||||
if (refundGone) {
|
||||
p.refundState.refundsFailed[pk] = p.refundState.refundsPending[pk];
|
||||
} else {
|
||||
p.refundState.refundsDone[pk] = p.refundState.refundsPending[pk];
|
||||
}
|
||||
delete p.refundState.refundsPending[pk];
|
||||
}
|
||||
if (Object.keys(p.refundState.refundsPending).length === 0) {
|
||||
p.refundStatusRetryInfo = initRetryInfo();
|
||||
p.lastRefundStatusError = undefined;
|
||||
allRefundsProcessed = true;
|
||||
}
|
||||
await tx.put(Stores.purchases, p);
|
||||
const c = await tx.get(Stores.coins, perm.coin_pub);
|
||||
if (!c) {
|
||||
console.warn("coin not found, can't apply refund");
|
||||
return;
|
||||
}
|
||||
const refundAmount = Amounts.parseOrThrow(perm.refund_amount);
|
||||
const refundFee = Amounts.parseOrThrow(perm.refund_fee);
|
||||
c.status = CoinStatus.Dormant;
|
||||
c.currentAmount = Amounts.add(c.currentAmount, refundAmount).amount;
|
||||
c.currentAmount = Amounts.sub(c.currentAmount, refundFee).amount;
|
||||
await tx.put(Stores.coins, c);
|
||||
await createRefreshGroup(
|
||||
tx,
|
||||
[{ coinPub: perm.coin_pub }],
|
||||
RefreshReason.Refund,
|
||||
);
|
||||
},
|
||||
);
|
||||
if (allRefundsProcessed) {
|
||||
ws.notify({
|
||||
type: NotificationType.RefundFinished,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ws.notify({
|
||||
type: NotificationType.RefundsSubmitted,
|
||||
proposalId,
|
||||
});
|
||||
}
|
@ -47,7 +47,6 @@ import {
|
||||
|
||||
import {
|
||||
CoinRecord,
|
||||
CoinStatus,
|
||||
CurrencyRecord,
|
||||
DenominationRecord,
|
||||
ExchangeRecord,
|
||||
|
Loading…
Reference in New Issue
Block a user