wallet-core: simplify coin record

we only track the allocation now, not the remaining amount
This commit is contained in:
Florian Dold 2022-10-15 11:52:07 +02:00
parent 4d70391f3d
commit e075134ffc
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
20 changed files with 238 additions and 211 deletions

View File

@ -475,6 +475,7 @@ export interface BackupRecoupGroup {
timestamp_finish?: TalerProtocolTimestamp; timestamp_finish?: TalerProtocolTimestamp;
finish_clock?: TalerProtocolTimestamp; finish_clock?: TalerProtocolTimestamp;
// FIXME: Use some enum here!
finish_is_failure?: boolean; finish_is_failure?: boolean;
/** /**
@ -483,7 +484,6 @@ export interface BackupRecoupGroup {
coins: { coins: {
coin_pub: string; coin_pub: string;
recoup_finished: boolean; recoup_finished: boolean;
old_amount: BackupAmountString;
}[]; }[];
} }
@ -582,9 +582,14 @@ export interface BackupCoin {
denom_sig: UnblindedSignature; denom_sig: UnblindedSignature;
/** /**
* Amount that's left on the coin. * Information about where and how the coin was spent.
*/ */
current_amount: BackupAmountString; spend_allocation:
| {
id: string;
amount: BackupAmountString;
}
| undefined;
/** /**
* Blinding key used when withdrawing the coin. * Blinding key used when withdrawing the coin.

View File

@ -968,60 +968,6 @@ export class WithdrawBatchResponse {
ev_sigs: WithdrawResponse[]; ev_sigs: WithdrawResponse[];
} }
/**
* Easy to process format for the public data of coins
* managed by the wallet.
*/
export interface CoinDumpJson {
coins: Array<{
/**
* The coin's denomination's public key.
*/
denom_pub: DenominationPubKey;
/**
* Hash of denom_pub.
*/
denom_pub_hash: string;
/**
* Value of the denomination (without any fees).
*/
denom_value: string;
/**
* Public key of the coin.
*/
coin_pub: string;
/**
* Base URL of the exchange for the coin.
*/
exchange_base_url: string;
/**
* Remaining value on the coin, to the knowledge of
* the wallet.
*/
remaining_value: string;
/**
* Public key of the parent coin.
* Only present if this coin was obtained via refreshing.
*/
refresh_parent_coin_pub: string | undefined;
/**
* Public key of the reserve for this coin.
* Only present if this coin was obtained via refreshing.
*/
withdrawal_reserve_pub: string | undefined;
/**
* Is the coin suspended?
* Suspended coins are not considered for payments.
*/
coin_suspended: boolean;
/**
* Information about the age restriction
*/
ageCommitmentProof: AgeCommitmentProof | undefined;
}>;
}
export interface MerchantPayResponse { export interface MerchantPayResponse {
sig: string; sig: string;
} }

View File

@ -63,7 +63,10 @@ import {
ExchangeAuditor, ExchangeAuditor,
UnblindedSignature, UnblindedSignature,
} from "./taler-types.js"; } from "./taler-types.js";
import { OrderShortInfo, codecForOrderShortInfo } from "./transactions-types.js"; import {
OrderShortInfo,
codecForOrderShortInfo,
} from "./transactions-types.js";
import { BackupRecovery } from "./backup-types.js"; import { BackupRecovery } from "./backup-types.js";
import { PaytoUri } from "./payto.js"; import { PaytoUri } from "./payto.js";
import { TalerErrorCode } from "./taler-error-codes.js"; import { TalerErrorCode } from "./taler-error-codes.js";
@ -141,6 +144,77 @@ export function mkAmount(
return { value, fraction, currency }; return { value, fraction, currency };
} }
/**
* Status of a coin.
*/
export enum CoinStatus {
/**
* Withdrawn and never shown to anybody.
*/
Fresh = "fresh",
/**
* Fresh, but currently marked as "suspended", thus won't be used
* for spending. Used for testing.
*/
FreshSuspended = "fresh-suspended",
/**
* A coin that has been spent and refreshed.
*/
Dormant = "dormant",
}
/**
* Easy to process format for the public data of coins
* managed by the wallet.
*/
export interface CoinDumpJson {
coins: Array<{
/**
* The coin's denomination's public key.
*/
denom_pub: DenominationPubKey;
/**
* Hash of denom_pub.
*/
denom_pub_hash: string;
/**
* Value of the denomination (without any fees).
*/
denom_value: string;
/**
* Public key of the coin.
*/
coin_pub: string;
/**
* Base URL of the exchange for the coin.
*/
exchange_base_url: string;
/**
* Public key of the parent coin.
* Only present if this coin was obtained via refreshing.
*/
refresh_parent_coin_pub: string | undefined;
/**
* Public key of the reserve for this coin.
* Only present if this coin was obtained via refreshing.
*/
withdrawal_reserve_pub: string | undefined;
coin_status: CoinStatus;
spend_allocation:
| {
id: string;
amount: string;
}
| undefined;
/**
* Information about the age restriction
*/
ageCommitmentProof: AgeCommitmentProof | undefined;
}>;
}
export enum ConfirmPayResultType { export enum ConfirmPayResultType {
Done = "done", Done = "done",
Pending = "pending", Pending = "pending",
@ -568,10 +642,11 @@ export enum RefreshReason {
} }
/** /**
* Wrapper for coin public keys. * Request to refresh a single coin.
*/ */
export interface CoinPublicKey { export interface CoinRefreshRequest {
readonly coinPub: string; readonly coinPub: string;
readonly amount: AmountJson;
} }
/** /**

View File

@ -1105,9 +1105,7 @@ advancedCli
console.log(`coin ${coin.coin_pub}`); console.log(`coin ${coin.coin_pub}`);
console.log(` exchange ${coin.exchange_base_url}`); console.log(` exchange ${coin.exchange_base_url}`);
console.log(` denomPubHash ${coin.denom_pub_hash}`); console.log(` denomPubHash ${coin.denom_pub_hash}`);
console.log( console.log(` status ${coin.coin_status}`);
` remaining amount ${Amounts.stringify(coin.remaining_value)}`,
);
} }
}); });
}); });

View File

@ -193,7 +193,7 @@ export async function runRefundIncrementalTest(t: GlobalTestState) {
.map((x) => x.amountEffective), .map((x) => x.amountEffective),
).amount; ).amount;
t.assertAmountEquals("TESTKUDOS:8.33", effective); t.assertAmountEquals("TESTKUDOS:8.59", effective);
} }
await t.shutdown(); await t.shutdown();

View File

@ -22,7 +22,7 @@
/** /**
* Imports. * Imports.
*/ */
import { Amounts } from "@gnu-taler/taler-util"; import { Amounts, CoinStatus } from "@gnu-taler/taler-util";
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js"; import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js";
import { import {
@ -32,7 +32,7 @@ import {
MerchantService, MerchantService,
setupDb, setupDb,
WalletCli, WalletCli,
getPayto getPayto,
} from "../harness/harness.js"; } from "../harness/harness.js";
import { SimpleTestEnvironment } from "../harness/helpers.js"; import { SimpleTestEnvironment } from "../harness/helpers.js";
@ -184,7 +184,10 @@ export async function runWallettestingTest(t: GlobalTestState) {
let susp: string | undefined; let susp: string | undefined;
{ {
for (const c of coinDump.coins) { for (const c of coinDump.coins) {
if (0 === Amounts.cmp(c.remaining_value, "TESTKUDOS:8")) { if (
c.coin_status === CoinStatus.Fresh &&
0 === Amounts.cmp(c.denom_value, "TESTKUDOS:8")
) {
susp = c.coin_pub; susp = c.coin_pub;
} }
} }

View File

@ -49,6 +49,8 @@ import {
ExchangeGlobalFees, ExchangeGlobalFees,
DenomSelectionState, DenomSelectionState,
TransactionIdStr, TransactionIdStr,
CoinRefreshRequest,
CoinStatus,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { RetryInfo, RetryTags } from "./util/retries.js"; import { RetryInfo, RetryTags } from "./util/retries.js";
import { Event, IDBDatabase } from "@gnu-taler/idb-bridge"; import { Event, IDBDatabase } from "@gnu-taler/idb-bridge";
@ -603,27 +605,6 @@ export interface PlanchetRecord {
ageCommitmentProof?: AgeCommitmentProof; ageCommitmentProof?: AgeCommitmentProof;
} }
/**
* Status of a coin.
*/
export enum CoinStatus {
/**
* Withdrawn and never shown to anybody.
*/
Fresh = "fresh",
/**
* Fresh, but currently marked as "suspended", thus won't be used
* for spending. Used for testing.
*/
FreshSuspended = "fresh-suspended",
/**
* A coin that has been spent and refreshed.
*/
Dormant = "dormant",
}
export enum CoinSourceType { export enum CoinSourceType {
Withdraw = "withdraw", Withdraw = "withdraw",
Refresh = "refresh", Refresh = "refresh",
@ -692,14 +673,6 @@ export interface CoinRecord {
*/ */
denomSig: UnblindedSignature; denomSig: UnblindedSignature;
/**
* Amount that's left on the coin.
*
* FIXME: This is pretty redundant with "allocation" and "status".
* Do we really need this?
*/
currentAmount: AmountJson;
/** /**
* Base URL that identifies the exchange from which we got the * Base URL that identifies the exchange from which we got the
* coin. * coin.
@ -732,7 +705,7 @@ export interface CoinRecord {
* - Diagnostics * - Diagnostics
* - Idempotency of applying a coin selection (e.g. after re-selection) * - Idempotency of applying a coin selection (e.g. after re-selection)
*/ */
allocation: CoinAllocation | undefined; spendAllocation: CoinAllocation | undefined;
/** /**
* Maximum age of purchases that can be made with this coin. * Maximum age of purchases that can be made with this coin.
@ -1461,18 +1434,11 @@ export interface RecoupGroupRecord {
*/ */
recoupFinishedPerCoin: boolean[]; recoupFinishedPerCoin: boolean[];
/**
* We store old amount (i.e. before recoup) of recouped coins here,
* as the balance of a recouped coin is set to zero when the
* recoup group is created.
*/
oldAmountPerCoin: AmountJson[];
/** /**
* Public keys of coins that should be scheduled for refreshing * Public keys of coins that should be scheduled for refreshing
* after all individual recoups are done. * after all individual recoups are done.
*/ */
scheduleRefreshCoins: string[]; scheduleRefreshCoins: CoinRefreshRequest[];
} }
export enum BackupProviderStateTag { export enum BackupProviderStateTag {
@ -1875,7 +1841,6 @@ export const WalletStoresV1 = {
"exchangeTos", "exchangeTos",
describeContents<ExchangeTosRecord>({ describeContents<ExchangeTosRecord>({
keyPath: ["exchangeBaseUrl", "etag"], keyPath: ["exchangeBaseUrl", "etag"],
autoIncrement: true,
}), }),
{}, {},
), ),

View File

@ -38,7 +38,7 @@ import {
CancellationToken, CancellationToken,
DenominationInfo, DenominationInfo,
RefreshGroupId, RefreshGroupId,
CoinPublicKey, CoinRefreshRequest,
RefreshReason, RefreshReason,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { CryptoDispatcher } from "./crypto/workers/cryptoDispatcher.js"; import { CryptoDispatcher } from "./crypto/workers/cryptoDispatcher.js";
@ -86,7 +86,7 @@ export interface RefreshOperations {
refreshGroups: typeof WalletStoresV1.refreshGroups; refreshGroups: typeof WalletStoresV1.refreshGroups;
coinAvailability: typeof WalletStoresV1.coinAvailability; coinAvailability: typeof WalletStoresV1.coinAvailability;
}>, }>,
oldCoinPubs: CoinPublicKey[], oldCoinPubs: CoinRefreshRequest[],
reason: RefreshReason, reason: RefreshReason,
): Promise<RefreshGroupId>; ): Promise<RefreshGroupId>;
} }

View File

@ -54,6 +54,7 @@ import {
BACKUP_VERSION_MINOR, BACKUP_VERSION_MINOR,
canonicalizeBaseUrl, canonicalizeBaseUrl,
canonicalJson, canonicalJson,
CoinStatus,
encodeCrock, encodeCrock,
getRandomBytes, getRandomBytes,
hash, hash,
@ -63,7 +64,6 @@ import {
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { import {
CoinSourceType, CoinSourceType,
CoinStatus,
ConfigRecordKey, ConfigRecordKey,
DenominationRecord, DenominationRecord,
PurchaseStatus, PurchaseStatus,
@ -206,7 +206,6 @@ export async function exportBackup(
coins: recoupGroup.coinPubs.map((x, i) => ({ coins: recoupGroup.coinPubs.map((x, i) => ({
coin_pub: x, coin_pub: x,
recoup_finished: recoupGroup.recoupFinishedPerCoin[i], recoup_finished: recoupGroup.recoupFinishedPerCoin[i],
old_amount: Amounts.stringify(recoupGroup.oldAmountPerCoin[i]),
})), })),
}); });
}); });
@ -259,8 +258,13 @@ export async function exportBackup(
blinding_key: coin.blindingKey, blinding_key: coin.blindingKey,
coin_priv: coin.coinPriv, coin_priv: coin.coinPriv,
coin_source: bcs, coin_source: bcs,
current_amount: Amounts.stringify(coin.currentAmount),
fresh: coin.status === CoinStatus.Fresh, fresh: coin.status === CoinStatus.Fresh,
spend_allocation: coin.spendAllocation
? {
amount: coin.spendAllocation.amount,
id: coin.spendAllocation.id,
}
: undefined,
denom_sig: coin.denomSig, denom_sig: coin.denomSig,
}); });
}); });

View File

@ -27,6 +27,7 @@ import {
BackupRefundState, BackupRefundState,
BackupWgType, BackupWgType,
codecForContractTerms, codecForContractTerms,
CoinStatus,
DenomKeyType, DenomKeyType,
DenomSelectionState, DenomSelectionState,
j2s, j2s,
@ -41,10 +42,8 @@ import {
CoinRecord, CoinRecord,
CoinSource, CoinSource,
CoinSourceType, CoinSourceType,
CoinStatus,
DenominationRecord, DenominationRecord,
DenominationVerificationStatus, DenominationVerificationStatus,
OperationStatus,
ProposalDownloadInfo, ProposalDownloadInfo,
PurchaseStatus, PurchaseStatus,
PurchasePayInfo, PurchasePayInfo,
@ -272,7 +271,6 @@ export async function importCoin(
blindingKey: backupCoin.blinding_key, blindingKey: backupCoin.blinding_key,
coinEvHash: compCoin.coinEvHash, coinEvHash: compCoin.coinEvHash,
coinPriv: backupCoin.coin_priv, coinPriv: backupCoin.coin_priv,
currentAmount: Amounts.parseOrThrow(backupCoin.current_amount),
denomSig: backupCoin.denom_sig, denomSig: backupCoin.denom_sig,
coinPub: compCoin.coinPub, coinPub: compCoin.coinPub,
exchangeBaseUrl, exchangeBaseUrl,
@ -284,7 +282,7 @@ export async function importCoin(
// FIXME! // FIXME!
ageCommitmentProof: undefined, ageCommitmentProof: undefined,
// FIXME! // FIXME!
allocation: undefined, spendAllocation: undefined,
}; };
if (coinRecord.status === CoinStatus.Fresh) { if (coinRecord.status === CoinStatus.Fresh) {
await makeCoinAvailable(ws, tx, coinRecord); await makeCoinAvailable(ws, tx, coinRecord);

View File

@ -23,7 +23,7 @@ import {
Amounts, Amounts,
Logger, Logger,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { CoinStatus, WalletStoresV1 } from "../db.js"; import { WalletStoresV1 } from "../db.js";
import { GetReadOnlyAccess } from "../util/query.js"; import { GetReadOnlyAccess } from "../util/query.js";
import { InternalWalletState } from "../internal-wallet-state.js"; import { InternalWalletState } from "../internal-wallet-state.js";
@ -42,6 +42,7 @@ export async function getBalancesInsideTransaction(
ws: InternalWalletState, ws: InternalWalletState,
tx: GetReadOnlyAccess<{ tx: GetReadOnlyAccess<{
coins: typeof WalletStoresV1.coins; coins: typeof WalletStoresV1.coins;
coinAvailability: typeof WalletStoresV1.coinAvailability;
refreshGroups: typeof WalletStoresV1.refreshGroups; refreshGroups: typeof WalletStoresV1.refreshGroups;
withdrawalGroups: typeof WalletStoresV1.withdrawalGroups; withdrawalGroups: typeof WalletStoresV1.withdrawalGroups;
}>, }>,
@ -64,12 +65,14 @@ export async function getBalancesInsideTransaction(
return balanceStore[currency]; return balanceStore[currency];
}; };
await tx.coins.iter().forEach((c) => { await tx.coinAvailability.iter().forEach((ca) => {
// Only count fresh coins, as dormant coins will const b = initBalance(ca.currency);
// already be in a refresh session. for (let i = 0; i < ca.freshCoinCount; i++) {
if (c.status === CoinStatus.Fresh) { b.available = Amounts.add(b.available, {
const b = initBalance(c.currentAmount.currency); currency: ca.currency,
b.available = Amounts.add(b.available, c.currentAmount).amount; fraction: ca.amountFrac,
value: ca.amountVal,
}).amount;
} }
}); });
@ -139,7 +142,13 @@ export async function getBalances(
logger.trace("starting to compute balance"); logger.trace("starting to compute balance");
const wbal = await ws.db const wbal = await ws.db
.mktx((x) => [x.coins, x.refreshGroups, x.purchases, x.withdrawalGroups]) .mktx((x) => [
x.coins,
x.coinAvailability,
x.refreshGroups,
x.purchases,
x.withdrawalGroups,
])
.runReadOnly(async (tx) => { .runReadOnly(async (tx) => {
return getBalancesInsideTransaction(ws, tx); return getBalancesInsideTransaction(ws, tx);
}); });

View File

@ -20,6 +20,8 @@
import { import {
AmountJson, AmountJson,
Amounts, Amounts,
CoinRefreshRequest,
CoinStatus,
j2s, j2s,
Logger, Logger,
RefreshReason, RefreshReason,
@ -29,7 +31,7 @@ import {
TransactionIdStr, TransactionIdStr,
TransactionType, TransactionType,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { WalletStoresV1, CoinStatus, CoinRecord } from "../db.js"; import { WalletStoresV1, CoinRecord } from "../db.js";
import { makeErrorDetail, TalerError } from "../errors.js"; import { makeErrorDetail, TalerError } from "../errors.js";
import { InternalWalletState } from "../internal-wallet-state.js"; import { InternalWalletState } from "../internal-wallet-state.js";
import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js"; import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
@ -103,11 +105,19 @@ export async function spendCoins(
}>, }>,
csi: CoinsSpendInfo, csi: CoinsSpendInfo,
): Promise<void> { ): Promise<void> {
let refreshCoinPubs: CoinRefreshRequest[] = [];
for (let i = 0; i < csi.coinPubs.length; i++) { for (let i = 0; i < csi.coinPubs.length; i++) {
const coin = await tx.coins.get(csi.coinPubs[i]); const coin = await tx.coins.get(csi.coinPubs[i]);
if (!coin) { if (!coin) {
throw Error("coin allocated for payment doesn't exist anymore"); throw Error("coin allocated for payment doesn't exist anymore");
} }
const denom = await ws.getDenomInfo(
ws,
tx,
coin.exchangeBaseUrl,
coin.denomPubHash,
);
checkDbInvariant(!!denom);
const coinAvailability = await tx.coinAvailability.get([ const coinAvailability = await tx.coinAvailability.get([
coin.exchangeBaseUrl, coin.exchangeBaseUrl,
coin.denomPubHash, coin.denomPubHash,
@ -116,7 +126,7 @@ export async function spendCoins(
checkDbInvariant(!!coinAvailability); checkDbInvariant(!!coinAvailability);
const contrib = csi.contributions[i]; const contrib = csi.contributions[i];
if (coin.status !== CoinStatus.Fresh) { if (coin.status !== CoinStatus.Fresh) {
const alloc = coin.allocation; const alloc = coin.spendAllocation;
if (!alloc) { if (!alloc) {
continue; continue;
} }
@ -131,15 +141,18 @@ export async function spendCoins(
continue; continue;
} }
coin.status = CoinStatus.Dormant; coin.status = CoinStatus.Dormant;
coin.allocation = { coin.spendAllocation = {
id: csi.allocationId, id: csi.allocationId,
amount: Amounts.stringify(contrib), amount: Amounts.stringify(contrib),
}; };
const remaining = Amounts.sub(coin.currentAmount, contrib); const remaining = Amounts.sub(denom.value, contrib);
if (remaining.saturated) { if (remaining.saturated) {
throw Error("not enough remaining balance on coin for payment"); throw Error("not enough remaining balance on coin for payment");
} }
coin.currentAmount = remaining.amount; refreshCoinPubs.push({
amount: remaining.amount,
coinPub: coin.coinPub,
});
checkDbInvariant(!!coinAvailability); checkDbInvariant(!!coinAvailability);
if (coinAvailability.freshCoinCount === 0) { if (coinAvailability.freshCoinCount === 0) {
throw Error( throw Error(
@ -150,9 +163,6 @@ export async function spendCoins(
await tx.coins.put(coin); await tx.coins.put(coin);
await tx.coinAvailability.put(coinAvailability); await tx.coinAvailability.put(coinAvailability);
} }
const refreshCoinPubs = csi.coinPubs.map((x) => ({
coinPub: x,
}));
await ws.refreshOps.createRefreshGroup( await ws.refreshOps.createRefreshGroup(
ws, ws,
tx, tx,

View File

@ -40,7 +40,8 @@ import {
codecForMerchantPayResponse, codecForMerchantPayResponse,
codecForProposal, codecForProposal,
CoinDepositPermission, CoinDepositPermission,
CoinPublicKey, CoinRefreshRequest,
CoinStatus,
ConfirmPayResult, ConfirmPayResult,
ConfirmPayResultType, ConfirmPayResultType,
ContractTerms, ContractTerms,
@ -78,7 +79,6 @@ import {
AllowedExchangeInfo, AllowedExchangeInfo,
BackupProviderStateTag, BackupProviderStateTag,
CoinRecord, CoinRecord,
CoinStatus,
DenominationRecord, DenominationRecord,
PurchaseRecord, PurchaseRecord,
PurchaseStatus, PurchaseStatus,
@ -2084,7 +2084,7 @@ async function applySuccessfulRefund(
denominations: typeof WalletStoresV1.denominations; denominations: typeof WalletStoresV1.denominations;
}>, }>,
p: PurchaseRecord, p: PurchaseRecord,
refreshCoinsMap: Record<string, { coinPub: string }>, refreshCoinsMap: Record<string, CoinRefreshRequest>,
r: MerchantCoinRefundSuccessStatus, r: MerchantCoinRefundSuccessStatus,
): Promise<void> { ): Promise<void> {
// FIXME: check signature before storing it as valid! // FIXME: check signature before storing it as valid!
@ -2102,31 +2102,23 @@ async function applySuccessfulRefund(
if (!denom) { if (!denom) {
throw Error("inconsistent database"); throw Error("inconsistent database");
} }
refreshCoinsMap[coin.coinPub] = { coinPub: coin.coinPub };
const refundAmount = Amounts.parseOrThrow(r.refund_amount); const refundAmount = Amounts.parseOrThrow(r.refund_amount);
const refundFee = denom.fees.feeRefund; const refundFee = denom.fees.feeRefund;
const amountLeft = Amounts.sub(refundAmount, refundFee).amount;
coin.status = CoinStatus.Dormant; coin.status = CoinStatus.Dormant;
coin.currentAmount = Amounts.add(coin.currentAmount, refundAmount).amount;
coin.currentAmount = Amounts.sub(coin.currentAmount, refundFee).amount;
logger.trace(`coin amount after is ${Amounts.stringify(coin.currentAmount)}`);
await tx.coins.put(coin); await tx.coins.put(coin);
const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
.iter(coin.exchangeBaseUrl) .iter(coin.exchangeBaseUrl)
.toArray(); .toArray();
const amountLeft = Amounts.sub(
Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount))
.amount,
denom.fees.feeRefund,
).amount;
const totalRefreshCostBound = getTotalRefreshCost( const totalRefreshCostBound = getTotalRefreshCost(
allDenoms, allDenoms,
DenominationRecord.toDenomInfo(denom), DenominationRecord.toDenomInfo(denom),
amountLeft, amountLeft,
); );
refreshCoinsMap[coin.coinPub] = { coinPub: coin.coinPub, amount: amountLeft };
p.refunds[refundKey] = { p.refunds[refundKey] = {
type: RefundState.Applied, type: RefundState.Applied,
obtainedTime: AbsoluteTime.toTimestamp(AbsoluteTime.now()), obtainedTime: AbsoluteTime.toTimestamp(AbsoluteTime.now()),
@ -2167,9 +2159,9 @@ async function storePendingRefund(
.iter(coin.exchangeBaseUrl) .iter(coin.exchangeBaseUrl)
.toArray(); .toArray();
// Refunded amount after fees.
const amountLeft = Amounts.sub( const amountLeft = Amounts.sub(
Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount)) Amounts.parseOrThrow(r.refund_amount),
.amount,
denom.fees.feeRefund, denom.fees.feeRefund,
).amount; ).amount;
@ -2197,7 +2189,7 @@ async function storeFailedRefund(
denominations: typeof WalletStoresV1.denominations; denominations: typeof WalletStoresV1.denominations;
}>, }>,
p: PurchaseRecord, p: PurchaseRecord,
refreshCoinsMap: Record<string, { coinPub: string }>, refreshCoinsMap: Record<string, CoinRefreshRequest>,
r: MerchantCoinRefundFailureStatus, r: MerchantCoinRefundFailureStatus,
): Promise<void> { ): Promise<void> {
const refundKey = getRefundKey(r); const refundKey = getRefundKey(r);
@ -2221,8 +2213,7 @@ async function storeFailedRefund(
.toArray(); .toArray();
const amountLeft = Amounts.sub( const amountLeft = Amounts.sub(
Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount)) Amounts.parseOrThrow(r.refund_amount),
.amount,
denom.fees.feeRefund, denom.fees.feeRefund,
).amount; ).amount;
@ -2246,6 +2237,7 @@ async function storeFailedRefund(
if (p.purchaseStatus === PurchaseStatus.AbortingWithRefund) { if (p.purchaseStatus === PurchaseStatus.AbortingWithRefund) {
// Refund failed because the merchant didn't even try to deposit // Refund failed because the merchant didn't even try to deposit
// the coin yet, so we try to refresh. // the coin yet, so we try to refresh.
// FIXME: Is this case tested?!
if (r.exchange_code === TalerErrorCode.EXCHANGE_REFUND_DEPOSIT_NOT_FOUND) { if (r.exchange_code === TalerErrorCode.EXCHANGE_REFUND_DEPOSIT_NOT_FOUND) {
const coin = await tx.coins.get(r.coin_pub); const coin = await tx.coins.get(r.coin_pub);
if (!coin) { if (!coin) {
@ -2271,14 +2263,11 @@ async function storeFailedRefund(
contrib = payCoinSelection.coinContributions[i]; contrib = payCoinSelection.coinContributions[i];
} }
} }
if (contrib) { // FIXME: Is this case tested?!
coin.currentAmount = Amounts.add(coin.currentAmount, contrib).amount; refreshCoinsMap[coin.coinPub] = {
coin.currentAmount = Amounts.sub( coinPub: coin.coinPub,
coin.currentAmount, amount: amountLeft,
denom.fees.feeRefund, };
).amount;
}
refreshCoinsMap[coin.coinPub] = { coinPub: coin.coinPub };
await tx.coins.put(coin); await tx.coins.put(coin);
} }
} }
@ -2308,7 +2297,7 @@ async function acceptRefunds(
return; return;
} }
const refreshCoinsMap: Record<string, CoinPublicKey> = {}; const refreshCoinsMap: Record<string, CoinRefreshRequest> = {};
for (const refundStatus of refunds) { for (const refundStatus of refunds) {
const refundKey = getRefundKey(refundStatus); const refundKey = getRefundKey(refundStatus);
@ -2350,6 +2339,7 @@ async function acceptRefunds(
} }
const refreshCoinsPubs = Object.values(refreshCoinsMap); const refreshCoinsPubs = Object.values(refreshCoinsMap);
logger.info(`refreshCoinMap ${j2s(refreshCoinsMap)}`);
if (refreshCoinsPubs.length > 0) { if (refreshCoinsPubs.length > 0) {
await createRefreshGroup( await createRefreshGroup(
ws, ws,

View File

@ -36,6 +36,7 @@ import {
codecForAmountString, codecForAmountString,
codecForAny, codecForAny,
codecForExchangeGetContractResponse, codecForExchangeGetContractResponse,
CoinStatus,
constructPayPullUri, constructPayPullUri,
constructPayPushUri, constructPayPushUri,
ContractTermsUtil, ContractTermsUtil,
@ -63,17 +64,16 @@ import {
WalletAccountMergeFlags, WalletAccountMergeFlags,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { import {
CoinStatus,
WithdrawalGroupStatus,
WalletStoresV1,
WithdrawalRecordType,
ReserveRecord, ReserveRecord,
WalletStoresV1,
WithdrawalGroupStatus,
WithdrawalRecordType,
} from "../db.js"; } from "../db.js";
import { InternalWalletState } from "../internal-wallet-state.js"; import { InternalWalletState } from "../internal-wallet-state.js";
import { makeTransactionId, spendCoins } from "../operations/common.js";
import { readSuccessResponseJsonOrThrow } from "../util/http.js"; import { readSuccessResponseJsonOrThrow } from "../util/http.js";
import { checkDbInvariant } from "../util/invariants.js"; import { checkDbInvariant } from "../util/invariants.js";
import { GetReadOnlyAccess } from "../util/query.js"; import { GetReadOnlyAccess } from "../util/query.js";
import { spendCoins, makeTransactionId } from "../operations/common.js";
import { updateExchangeFromUrl } from "./exchanges.js"; import { updateExchangeFromUrl } from "./exchanges.js";
import { internalCreateWithdrawalGroup } from "./withdraw.js"; import { internalCreateWithdrawalGroup } from "./withdraw.js";

View File

@ -28,6 +28,7 @@ import {
Amounts, Amounts,
codecForRecoupConfirmation, codecForRecoupConfirmation,
codecForReserveStatus, codecForReserveStatus,
CoinStatus,
encodeCrock, encodeCrock,
getRandomBytes, getRandomBytes,
j2s, j2s,
@ -40,7 +41,6 @@ import {
import { import {
CoinRecord, CoinRecord,
CoinSourceType, CoinSourceType,
CoinStatus,
RecoupGroupRecord, RecoupGroupRecord,
RefreshCoinSource, RefreshCoinSource,
WalletStoresV1, WalletStoresV1,
@ -50,6 +50,7 @@ import {
} from "../db.js"; } from "../db.js";
import { InternalWalletState } from "../internal-wallet-state.js"; import { InternalWalletState } from "../internal-wallet-state.js";
import { readSuccessResponseJsonOrThrow } from "../util/http.js"; import { readSuccessResponseJsonOrThrow } from "../util/http.js";
import { checkDbInvariant } from "../util/invariants.js";
import { GetReadWriteAccess } from "../util/query.js"; import { GetReadWriteAccess } from "../util/query.js";
import { import {
OperationAttemptResult, OperationAttemptResult,
@ -180,8 +181,6 @@ async function recoupWithdrawCoin(
return; return;
} }
updatedCoin.status = CoinStatus.Dormant; updatedCoin.status = CoinStatus.Dormant;
const currency = updatedCoin.currentAmount.currency;
updatedCoin.currentAmount = Amounts.getZero(currency);
await tx.coins.put(updatedCoin); await tx.coins.put(updatedCoin);
await putGroupAsFinished(ws, tx, recoupGroup, coinIdx); await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
}); });
@ -265,16 +264,25 @@ async function recoupRefreshCoin(
logger.warn("refresh old coin for recoup not found"); logger.warn("refresh old coin for recoup not found");
return; return;
} }
revokedCoin.status = CoinStatus.Dormant; const oldCoinDenom = await ws.getDenomInfo(
oldCoin.currentAmount = Amounts.add( ws,
oldCoin.currentAmount, tx,
recoupGroup.oldAmountPerCoin[coinIdx], oldCoin.exchangeBaseUrl,
).amount; oldCoin.denomPubHash,
logger.trace(
"recoup: setting old coin amount to",
Amounts.stringify(oldCoin.currentAmount),
); );
recoupGroup.scheduleRefreshCoins.push(oldCoin.coinPub); const revokedCoinDenom = await ws.getDenomInfo(
ws,
tx,
revokedCoin.exchangeBaseUrl,
revokedCoin.denomPubHash,
);
checkDbInvariant(!!oldCoinDenom);
checkDbInvariant(!!revokedCoinDenom);
revokedCoin.status = CoinStatus.Dormant;
recoupGroup.scheduleRefreshCoins.push({
coinPub: oldCoin.coinPub,
amount: Amounts.sub(oldCoinDenom.value, revokedCoinDenom.value).amount,
});
await tx.coins.put(revokedCoin); await tx.coins.put(revokedCoin);
await tx.coins.put(oldCoin); await tx.coins.put(oldCoin);
await putGroupAsFinished(ws, tx, recoupGroup, coinIdx); await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
@ -410,7 +418,7 @@ export async function processRecoupGroupHandler(
const refreshGroupId = await createRefreshGroup( const refreshGroupId = await createRefreshGroup(
ws, ws,
tx, tx,
rg2.scheduleRefreshCoins.map((x) => ({ coinPub: x })), rg2.scheduleRefreshCoins,
RefreshReason.Recoup, RefreshReason.Recoup,
); );
processRefreshGroup(ws, refreshGroupId.refreshGroupId).catch((e) => { processRefreshGroup(ws, refreshGroupId.refreshGroupId).catch((e) => {
@ -442,8 +450,6 @@ export async function createRecoupGroup(
timestampFinished: undefined, timestampFinished: undefined,
timestampStarted: TalerProtocolTimestamp.now(), timestampStarted: TalerProtocolTimestamp.now(),
recoupFinishedPerCoin: coinPubs.map(() => false), recoupFinishedPerCoin: coinPubs.map(() => false),
// Will be populated later
oldAmountPerCoin: [],
scheduleRefreshCoins: [], scheduleRefreshCoins: [],
}; };
@ -454,12 +460,6 @@ export async function createRecoupGroup(
await putGroupAsFinished(ws, tx, recoupGroup, coinIdx); await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
continue; continue;
} }
if (Amounts.isZero(coin.currentAmount)) {
await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
continue;
}
recoupGroup.oldAmountPerCoin[coinIdx] = coin.currentAmount;
coin.currentAmount = Amounts.getZero(coin.currentAmount.currency);
await tx.coins.put(coin); await tx.coins.put(coin);
} }

View File

@ -23,8 +23,9 @@ import {
amountToPretty, amountToPretty,
codecForExchangeMeltResponse, codecForExchangeMeltResponse,
codecForExchangeRevealResponse, codecForExchangeRevealResponse,
CoinPublicKey,
CoinPublicKeyString, CoinPublicKeyString,
CoinRefreshRequest,
CoinStatus,
DenominationInfo, DenominationInfo,
DenomKeyType, DenomKeyType,
Duration, Duration,
@ -55,9 +56,7 @@ import { CryptoApiStoppedError } from "../crypto/workers/cryptoDispatcher.js";
import { import {
CoinRecord, CoinRecord,
CoinSourceType, CoinSourceType,
CoinStatus,
DenominationRecord, DenominationRecord,
OperationStatus,
RefreshCoinStatus, RefreshCoinStatus,
RefreshGroupRecord, RefreshGroupRecord,
RefreshOperationStatus, RefreshOperationStatus,
@ -672,7 +671,6 @@ async function refreshReveal(
blindingKey: pc.blindingKey, blindingKey: pc.blindingKey,
coinPriv: pc.coinPriv, coinPriv: pc.coinPriv,
coinPub: pc.coinPub, coinPub: pc.coinPub,
currentAmount: ncd.value,
denomPubHash: ncd.denomPubHash, denomPubHash: ncd.denomPubHash,
denomSig, denomSig,
exchangeBaseUrl: oldCoin.exchangeBaseUrl, exchangeBaseUrl: oldCoin.exchangeBaseUrl,
@ -684,7 +682,7 @@ async function refreshReveal(
coinEvHash: pc.coinEvHash, coinEvHash: pc.coinEvHash,
maxAge: pc.maxAge, maxAge: pc.maxAge,
ageCommitmentProof: pc.ageCommitmentProof, ageCommitmentProof: pc.ageCommitmentProof,
allocation: undefined, spendAllocation: undefined,
}; };
coins.push(coin); coins.push(coin);
@ -845,7 +843,7 @@ export async function createRefreshGroup(
refreshGroups: typeof WalletStoresV1.refreshGroups; refreshGroups: typeof WalletStoresV1.refreshGroups;
coinAvailability: typeof WalletStoresV1.coinAvailability; coinAvailability: typeof WalletStoresV1.coinAvailability;
}>, }>,
oldCoinPubs: CoinPublicKey[], oldCoinPubs: CoinRefreshRequest[],
reason: RefreshReason, reason: RefreshReason,
): Promise<RefreshGroupId> { ): Promise<RefreshGroupId> {
const refreshGroupId = encodeCrock(getRandomBytes(32)); const refreshGroupId = encodeCrock(getRandomBytes(32));
@ -908,9 +906,8 @@ export async function createRefreshGroup(
default: default:
assertUnreachable(coin.status); assertUnreachable(coin.status);
} }
const refreshAmount = coin.currentAmount; const refreshAmount = ocp.amount;
inputPerCoin.push(refreshAmount); inputPerCoin.push(refreshAmount);
coin.currentAmount = Amounts.getZero(refreshAmount.currency);
await tx.coins.put(coin); await tx.coins.put(coin);
const denoms = await getDenoms(coin.exchangeBaseUrl); const denoms = await getDenoms(coin.exchangeBaseUrl);
const cost = getTotalRefreshCost(denoms, denom, refreshAmount); const cost = getTotalRefreshCost(denoms, denom, refreshAmount);
@ -1008,7 +1005,7 @@ export async function autoRefresh(
const coins = await tx.coins.indexes.byBaseUrl const coins = await tx.coins.indexes.byBaseUrl
.iter(exchangeBaseUrl) .iter(exchangeBaseUrl)
.toArray(); .toArray();
const refreshCoins: CoinPublicKey[] = []; const refreshCoins: CoinRefreshRequest[] = [];
for (const coin of coins) { for (const coin of coins) {
if (coin.status !== CoinStatus.Fresh) { if (coin.status !== CoinStatus.Fresh) {
continue; continue;
@ -1023,7 +1020,14 @@ export async function autoRefresh(
} }
const executeThreshold = getAutoRefreshExecuteThreshold(denom); const executeThreshold = getAutoRefreshExecuteThreshold(denom);
if (AbsoluteTime.isExpired(executeThreshold)) { if (AbsoluteTime.isExpired(executeThreshold)) {
refreshCoins.push(coin); refreshCoins.push({
coinPub: coin.coinPub,
amount: {
value: denom.amountVal,
fraction: denom.amountFrac,
currency: denom.currency,
},
});
} else { } else {
const checkThreshold = getAutoRefreshCheckThreshold(denom); const checkThreshold = getAutoRefreshCheckThreshold(denom);
minCheckThreshold = AbsoluteTime.min( minCheckThreshold = AbsoluteTime.min(

View File

@ -24,6 +24,7 @@ import {
BlindedDenominationSignature, BlindedDenominationSignature,
codecForMerchantTipResponseV2, codecForMerchantTipResponseV2,
codecForTipPickupGetResponse, codecForTipPickupGetResponse,
CoinStatus,
DenomKeyType, DenomKeyType,
encodeCrock, encodeCrock,
getRandomBytes, getRandomBytes,
@ -41,7 +42,6 @@ import { DerivedTipPlanchet } from "../crypto/cryptoTypes.js";
import { import {
CoinRecord, CoinRecord,
CoinSourceType, CoinSourceType,
CoinStatus,
DenominationRecord, DenominationRecord,
TipRecord, TipRecord,
} from "../db.js"; } from "../db.js";
@ -311,7 +311,6 @@ export async function processTip(
coinIndex: i, coinIndex: i,
walletTipId: walletTipId, walletTipId: walletTipId,
}, },
currentAmount: DenominationRecord.getValue(denom),
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,
@ -319,7 +318,7 @@ export async function processTip(
coinEvHash: planchet.coinEvHash, coinEvHash: planchet.coinEvHash,
maxAge: AgeRestriction.AGE_UNRESTRICTED, maxAge: AgeRestriction.AGE_UNRESTRICTED,
ageCommitmentProof: planchet.ageCommitmentProof, ageCommitmentProof: planchet.ageCommitmentProof,
allocation: undefined, spendAllocation: undefined,
}); });
} }

View File

@ -540,7 +540,6 @@ function buildTransactionForTip(
/** /**
* For a set of refund with the same executionTime. * For a set of refund with the same executionTime.
*
*/ */
interface MergedRefundInfo { interface MergedRefundInfo {
executionTime: TalerProtocolTimestamp; executionTime: TalerProtocolTimestamp;
@ -556,7 +555,7 @@ function mergeRefundByExecutionTime(
const refundByExecTime = rs.reduce((prev, refund) => { const refundByExecTime = rs.reduce((prev, refund) => {
const key = `${refund.executionTime.t_s}`; const key = `${refund.executionTime.t_s}`;
//refunds counts if applied // refunds count if applied
const effective = const effective =
refund.type === RefundState.Applied refund.type === RefundState.Applied
? Amounts.sub( ? Amounts.sub(
@ -582,7 +581,10 @@ function mergeRefundByExecutionTime(
v.amountAppliedEffective, v.amountAppliedEffective,
effective, effective,
).amount; ).amount;
v.amountAppliedRaw = Amounts.add(v.amountAppliedRaw).amount; v.amountAppliedRaw = Amounts.add(
v.amountAppliedRaw,
refund.refundAmount,
).amount;
v.firstTimestamp = TalerProtocolTimestamp.min( v.firstTimestamp = TalerProtocolTimestamp.min(
v.firstTimestamp, v.firstTimestamp,
refund.obtainedTime, refund.obtainedTime,

View File

@ -36,6 +36,7 @@ import {
codecForWithdrawBatchResponse, codecForWithdrawBatchResponse,
codecForWithdrawOperationStatusResponse, codecForWithdrawOperationStatusResponse,
codecForWithdrawResponse, codecForWithdrawResponse,
CoinStatus,
DenomKeyType, DenomKeyType,
DenomSelectionState, DenomSelectionState,
Duration, Duration,
@ -57,7 +58,6 @@ import {
TransactionType, TransactionType,
UnblindedSignature, UnblindedSignature,
URL, URL,
VersionMatchResult,
WithdrawBatchResponse, WithdrawBatchResponse,
WithdrawResponse, WithdrawResponse,
WithdrawUriInfoResponse, WithdrawUriInfoResponse,
@ -66,10 +66,8 @@ import { EddsaKeypair } from "../crypto/cryptoImplementation.js";
import { import {
CoinRecord, CoinRecord,
CoinSourceType, CoinSourceType,
CoinStatus,
DenominationRecord, DenominationRecord,
DenominationVerificationStatus, DenominationVerificationStatus,
ExchangeTosRecord,
PlanchetRecord, PlanchetRecord,
PlanchetStatus, PlanchetStatus,
WalletStoresV1, WalletStoresV1,
@ -736,7 +734,6 @@ async function processPlanchetVerifyAndStoreCoin(
blindingKey: planchet.blindingKey, blindingKey: planchet.blindingKey,
coinPriv: planchet.coinPriv, coinPriv: planchet.coinPriv,
coinPub: planchet.coinPub, coinPub: planchet.coinPub,
currentAmount: denomInfo.value,
denomPubHash: planchet.denomPubHash, denomPubHash: planchet.denomPubHash,
denomSig, denomSig,
coinEvHash: planchet.coinEvHash, coinEvHash: planchet.coinEvHash,
@ -750,7 +747,7 @@ async function processPlanchetVerifyAndStoreCoin(
}, },
maxAge: planchet.maxAge, maxAge: planchet.maxAge,
ageCommitmentProof: planchet.ageCommitmentProof, ageCommitmentProof: planchet.ageCommitmentProof,
allocation: undefined, spendAllocation: undefined,
}; };
const planchetCoinPub = planchet.coinPub; const planchetCoinPub = planchet.coinPub;

View File

@ -95,6 +95,8 @@ import {
WalletNotification, WalletNotification,
codecForSetDevModeRequest, codecForSetDevModeRequest,
ExchangeTosStatusDetails, ExchangeTosStatusDetails,
CoinRefreshRequest,
CoinStatus,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js"; import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js";
import { import {
@ -105,11 +107,9 @@ import { clearDatabase } from "./db-utils.js";
import { import {
AuditorTrustRecord, AuditorTrustRecord,
CoinSourceType, CoinSourceType,
CoinStatus,
ConfigRecordKey, ConfigRecordKey,
DenominationRecord, DenominationRecord,
ExchangeDetailsRecord, ExchangeDetailsRecord,
ExchangeTosRecord,
exportDb, exportDb,
importDb, importDb,
WalletStoresV1, WalletStoresV1,
@ -934,10 +934,15 @@ async function dumpCoins(ws: InternalWalletState): Promise<CoinDumpJson> {
}), }),
exchange_base_url: c.exchangeBaseUrl, exchange_base_url: c.exchangeBaseUrl,
refresh_parent_coin_pub: refreshParentCoinPub, refresh_parent_coin_pub: refreshParentCoinPub,
remaining_value: Amounts.stringify(c.currentAmount),
withdrawal_reserve_pub: withdrawalReservePub, withdrawal_reserve_pub: withdrawalReservePub,
coin_suspended: c.status === CoinStatus.FreshSuspended, coin_status: c.status,
ageCommitmentProof: c.ageCommitmentProof, ageCommitmentProof: c.ageCommitmentProof,
spend_allocation: c.spendAllocation
? {
amount: c.spendAllocation.amount,
id: c.spendAllocation.id,
}
: undefined,
}); });
} }
}); });
@ -1153,7 +1158,6 @@ async function dispatchRequestInternal(
} }
case "forceRefresh": { case "forceRefresh": {
const req = codecForForceRefreshRequest().decode(payload); const req = codecForForceRefreshRequest().decode(payload);
const coinPubs = req.coinPubList.map((x) => ({ coinPub: x }));
const refreshGroupId = await ws.db const refreshGroupId = await ws.db
.mktx((x) => [ .mktx((x) => [
x.refreshGroups, x.refreshGroups,
@ -1162,6 +1166,24 @@ async function dispatchRequestInternal(
x.coins, x.coins,
]) ])
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {
let coinPubs: CoinRefreshRequest[] = [];
for (const c of req.coinPubList) {
const coin = await tx.coins.get(c);
if (!coin) {
throw Error(`coin (pubkey ${c}) not found`);
}
const denom = await ws.getDenomInfo(
ws,
tx,
coin.exchangeBaseUrl,
coin.denomPubHash,
);
checkDbInvariant(!!denom);
coinPubs.push({
coinPub: c,
amount: denom?.value,
});
}
return await createRefreshGroup( return await createRefreshGroup(
ws, ws,
tx, tx,