-
Notifications
You must be signed in to change notification settings - Fork 266
/
TxQuotaChecker.java
244 lines (200 loc) · 12 KB
/
TxQuotaChecker.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
/*
* This file is part of RskJ
* Copyright (C) 2022 RSK Labs Ltd.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program 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 Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package co.rsk.net.handler.quota;
import co.rsk.core.RskAddress;
import co.rsk.core.bc.PendingState;
import co.rsk.db.RepositorySnapshot;
import co.rsk.util.MaxSizeHashMap;
import co.rsk.util.TimeProvider;
import org.ethereum.core.Block;
import org.ethereum.core.SignatureCache;
import org.ethereum.core.Transaction;
import org.ethereum.listener.GasPriceTracker;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.Nullable;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.util.Iterator;
import java.util.Map;
/**
* This class hosts a map with the available virtualGas for a set of addresses and validates or rejects a transaction accordingly.
* The underlying map has auto-clean capabilities so, when filled, the item with an older access date is removed to make room for a new one.
*/
public class TxQuotaChecker {
public static final int UNKNOWN_LAST_BLOCK_GAS_LIMIT = -1;
private static final int MAX_QUOTAS_SIZE = 400_000;
private static final int MAX_QUOTA_GAS_MULTIPLIER = 2000;
private static final double MAX_GAS_PER_SECOND_PERCENT = 0.9;
private static final Logger logger = LoggerFactory.getLogger(TxQuotaChecker.class);
private long lastBlockGasLimit;
private final MaxSizeHashMap<RskAddress, TxQuota> accountQuotas;
private final TimeProvider timeProvider;
private final SignatureCache signatureCache;
public TxQuotaChecker(TimeProvider timeProvider, SignatureCache signatureCache) {
this.accountQuotas = new MaxSizeHashMap<>(MAX_QUOTAS_SIZE, true);
this.timeProvider = timeProvider;
this.lastBlockGasLimit = UNKNOWN_LAST_BLOCK_GAS_LIMIT;
this.signatureCache = signatureCache;
}
/**
* Tries to accept a transaction under a certain context
*
* @param newTx The tx to accept
* @param replacedTx The tx being replaced by <code>newTx</code>, if any
* @param currentContext Some contextual information of the time <code>newTx</code> is being processed
* @return true if the <code>newTx</code> was accepted, false otherwise
*/
public synchronized boolean acceptTx(Transaction newTx, @Nullable Transaction replacedTx, CurrentContext currentContext) {
// keep track of lastBlockGasLimit on each processed transaction, so we can use it from cleanMaxQuotas were we lack this context
this.lastBlockGasLimit = currentContext.bestBlock.getGasLimitAsInteger().longValue();
// doing this before creating quota
boolean isFirstTxFromSender = isFirstTxFromSender(newTx, currentContext);
TxQuota senderQuota = updateSenderQuota(newTx, currentContext);
updateReceiverQuotaIfRequired(newTx, currentContext);
double consumedVirtualGas = calculateConsumedVirtualGas(newTx, replacedTx, currentContext);
// "isFirstTxFromSender" check is used to prevent the account first tx from taking hours to be propagated due to the minimum gas that is granted the first time account is added to node's quota map
// let's imagine a scenario with a tx [tx1] from sender [s1], nodes [n1, n2] and different time moments [t1, t2, t3]:
// t1: n1 receives tx1 => s1 is a new account for n1 (not in the map) and gets granted min virtual gas that is not enough for tx1 => n1 rejects tx1, and it is not propagated
// t2: n1 receives again tx1 => as for n1, s1 accumulated enough gas for tx1 (already in the map since t1) => n1 accepts and propagates tx1
// t3: n2 receives tx1 => n2 behaves in the exact same way as n1 did in t1 => n2 rejects tx1, and it is not propagated
// ...
// tn: nn ...
// by accepting the very first transaction regardless gas consumption we avoid this problem that won't occur with greater nonce
// this won't be a problem since this first tx has a cost, all available gas will be subtracted and account starts accumulating again for next tx
if (isFirstTxFromSender) {
senderQuota.forceVirtualGasSubtraction(consumedVirtualGas, newTx, currentContext.bestBlock.getNumber());
return true;
}
return senderQuota.acceptVirtualGasConsumption(consumedVirtualGas, newTx, currentContext.bestBlock.getNumber());
}
private TxQuota updateSenderQuota(Transaction newTx, CurrentContext currentContext) {
return updateQuota(newTx, true, currentContext);
}
private void updateReceiverQuotaIfRequired(Transaction newTx, CurrentContext currentContext) {
// updating receiver quota for it to start accumulating virtual gas as soon as we now of its existence
// with the following check we can face two scenarios if account is already in the map; we know that it is either:
// 1) an EOA => we want to now its existence the sooner, the better, so it starts accumulating virtual gas
// 2) a counterfactual contract (CF), not yet created (code not yet assigned) which received RBTCs =>
// in this case we don't care about quotas, a contract will never be a sender, but still we will be storing some contracts in the map
// we have decided to accept this caveat in our own benefit, since the map works as a cache that helps to avoid repository calls
// furthermore, quota map is cleaned up periodically and, later, when the CF exists on-chain, it won't be added ever again
boolean receiverIsEOAorCF = accountQuotas.get(newTx.getReceiveAddress()) != null || isEOA(newTx.getReceiveAddress(), currentContext.repository);
if (receiverIsEOAorCF || newAccountInRepository(newTx.getReceiveAddress(), currentContext.repository)) {
updateQuota(newTx, false, currentContext);
}
}
private boolean isFirstTxFromSender(Transaction newTx, CurrentContext currentContext) {
RskAddress senderAddress = newTx.getSender(signatureCache);
TxQuota quotaForSender = this.accountQuotas.get(senderAddress);
long accountNonce = currentContext.state.getNonce(senderAddress).longValue();
long txNonce = newTx.getNonceAsInteger().longValue();
// need to check that account it's not in the map to ensure it is not a resend or a gasPrice bump
return quotaForSender == null && accountNonce == 0 && txNonce == 0;
}
private boolean newAccountInRepository(RskAddress receiverAddress, RepositorySnapshot repository) {
return !repository.isExist(receiverAddress);
}
private boolean isEOA(RskAddress receiverAddress, RepositorySnapshot repository) {
return !repository.isContract(receiverAddress);
}
/**
* Cleans from the underlying map those entries that have <code>maxQuota</code> after being refreshed
* This method is intended to be called periodically with a rate similar to the time needed for an account to get <code>maxQuota</code>
*/
public synchronized void cleanMaxQuotas() {
if (lastBlockGasLimit == UNKNOWN_LAST_BLOCK_GAS_LIMIT) {
// no transactions yet processed
return;
}
long maxGasPerSecond = getMaxGasPerSecond(lastBlockGasLimit);
long maxQuota = getMaxQuota(maxGasPerSecond);
logger.debug("Clearing quota map, size before {}", this.accountQuotas.size());
Iterator<Map.Entry<RskAddress, TxQuota>> quotaIterator = accountQuotas.entrySet().iterator();
while (quotaIterator.hasNext()) {
Map.Entry<RskAddress, TxQuota> quotaEntry = quotaIterator.next();
RskAddress address = quotaEntry.getKey();
TxQuota quota = quotaEntry.getValue();
double accumulatedVirtualGas = quota.refresh(address, maxGasPerSecond, maxQuota);
boolean maxQuotaGranted = BigDecimal.valueOf(maxQuota).compareTo(BigDecimal.valueOf(accumulatedVirtualGas)) == 0;
if (maxQuotaGranted) {
logger.trace("Clearing {}, it has maxQuota", quota);
quotaIterator.remove();
}
}
logger.debug("Clearing quota map, size after {}", this.accountQuotas.size());
}
public TxQuota getTxQuota(RskAddress address) {
return this.accountQuotas.get(address);
}
private TxQuota updateQuota(Transaction newTx, boolean isTxSource, CurrentContext currentContext) {
BigInteger blockGasLimit = currentContext.bestBlock.getGasLimitAsInteger();
long maxGasPerSecond = getMaxGasPerSecond(blockGasLimit.longValue());
long maxQuota = getMaxQuota(maxGasPerSecond);
RskAddress address = isTxSource ? newTx.getSender(signatureCache) : newTx.getReceiveAddress();
TxQuota quotaForAddress = this.accountQuotas.get(address);
if (quotaForAddress == null) {
long accountNonce = currentContext.state.getNonce(address).longValue();
long initialQuota = calculateNewItemQuota(accountNonce, isTxSource, maxGasPerSecond, maxQuota);
quotaForAddress = TxQuota.createNew(address, newTx.getHash(), initialQuota, timeProvider);
this.accountQuotas.put(address, quotaForAddress);
} else {
quotaForAddress.refresh(address, maxGasPerSecond, maxQuota);
}
return quotaForAddress;
}
private long calculateNewItemQuota(long accountNonce, boolean isTxSource, long maxGasPerSecond, long maxQuota) {
boolean isNewAccount = accountNonce == 0;
boolean grantMaxQuota = isTxSource && !isNewAccount;
return grantMaxQuota ? maxQuota : maxGasPerSecond;
}
private long getMaxGasPerSecond(long blockGasLimit) {
return Math.round(blockGasLimit * MAX_GAS_PER_SECOND_PERCENT);
}
private long getMaxQuota(long maxGasPerSecond) {
return maxGasPerSecond * TxQuotaChecker.MAX_QUOTA_GAS_MULTIPLIER;
}
private double calculateConsumedVirtualGas(Transaction newTx, @Nullable Transaction replacedTx, CurrentContext currentContext) {
long accountNonce = currentContext.state.getNonce(newTx.getSender(signatureCache)).longValue();
long blockGasLimit = currentContext.bestBlock.getGasLimitAsInteger().longValue();
long blockMinGasPrice = currentContext.bestBlock.getMinimumGasPrice().asBigInteger().longValue();
TxVirtualGasCalculator calculator;
if (currentContext.gasPriceTracker.isFeeMarketWorking()) {
long avgGasPrice = currentContext.gasPriceTracker.getGasPrice().asBigInteger().longValue();
calculator = TxVirtualGasCalculator.createWithAllFactors(accountNonce, blockGasLimit, blockMinGasPrice, avgGasPrice);
} else {
calculator = TxVirtualGasCalculator.createSkippingGasPriceFactor(accountNonce, blockGasLimit, blockMinGasPrice);
}
return calculator.calculate(newTx, replacedTx);
}
/**
* Helper class holding contextual information of the time the tx is being processed (bestBlock, state, repository, etc)
*/
public static class CurrentContext {
private final Block bestBlock;
private final PendingState state;
private final RepositorySnapshot repository;
private final GasPriceTracker gasPriceTracker;
public CurrentContext(Block bestBlock, PendingState state, RepositorySnapshot repository, GasPriceTracker gasPriceTracker) {
this.bestBlock = bestBlock;
this.state = state;
this.repository = repository;
this.gasPriceTracker = gasPriceTracker;
}
}
}