diff --git a/rskj-core/src/main/java/co/rsk/RskContext.java b/rskj-core/src/main/java/co/rsk/RskContext.java index 8caf3b324f4..2a1fdaa848f 100644 --- a/rskj-core/src/main/java/co/rsk/RskContext.java +++ b/rskj-core/src/main/java/co/rsk/RskContext.java @@ -105,7 +105,7 @@ import org.ethereum.facade.Ethereum; import org.ethereum.facade.EthereumImpl; import org.ethereum.listener.CompositeEthereumListener; -import org.ethereum.listener.GasCalculator; +import org.ethereum.listener.GasPriceCalculator; import org.ethereum.listener.GasPriceTracker; import org.ethereum.net.EthereumChannelInitializerFactory; import org.ethereum.net.NodeManager; @@ -556,7 +556,7 @@ public synchronized Ethereum getRsk() { public GasPriceTracker getGasPriceTracker() { checkIfNotClosed(); if (this.gasPriceTracker == null) { - GasCalculator.GasCalculatorType calculatorType = getRskSystemProperties().getGasCalculatorType(); + GasPriceCalculator.GasCalculatorType calculatorType = getRskSystemProperties().getGasCalculatorType(); this.gasPriceTracker = GasPriceTracker.create(getBlockStore(), calculatorType); } return this.gasPriceTracker; diff --git a/rskj-core/src/main/java/co/rsk/config/RskSystemProperties.java b/rskj-core/src/main/java/co/rsk/config/RskSystemProperties.java index 05cac5fa073..7c37a790cd4 100644 --- a/rskj-core/src/main/java/co/rsk/config/RskSystemProperties.java +++ b/rskj-core/src/main/java/co/rsk/config/RskSystemProperties.java @@ -29,7 +29,7 @@ import org.ethereum.core.Account; import org.ethereum.crypto.ECKey; import org.ethereum.crypto.HashUtil; -import org.ethereum.listener.GasCalculator; +import org.ethereum.listener.GasPriceCalculator; import javax.annotation.Nullable; import java.nio.charset.StandardCharsets; @@ -485,12 +485,12 @@ public double getTopBest() { return value; } - public GasCalculator.GasCalculatorType getGasCalculatorType() { + public GasPriceCalculator.GasCalculatorType getGasCalculatorType() { String value = configFromFiles.getString(MINER_GAS_PRICE_CALCULATOR_TYPE); if (value == null || value.isEmpty()) { - return GasCalculator.GasCalculatorType.LEGACY; + return GasPriceCalculator.GasCalculatorType.PLAIN_PERCENTILE; } - GasCalculator.GasCalculatorType gasCalculatorType = GasCalculator.GasCalculatorType.fromString(value); + GasPriceCalculator.GasCalculatorType gasCalculatorType = GasPriceCalculator.GasCalculatorType.fromString(value); if(gasCalculatorType == null) { throw new RskConfigurationException("Invalid gasPriceCalculatorType: " + value); } diff --git a/rskj-core/src/main/java/org/ethereum/listener/GasCalculator.java b/rskj-core/src/main/java/org/ethereum/listener/GasPriceCalculator.java similarity index 81% rename from rskj-core/src/main/java/org/ethereum/listener/GasCalculator.java rename to rskj-core/src/main/java/org/ethereum/listener/GasPriceCalculator.java index c69502ba42e..a8be9510a34 100644 --- a/rskj-core/src/main/java/org/ethereum/listener/GasCalculator.java +++ b/rskj-core/src/main/java/org/ethereum/listener/GasPriceCalculator.java @@ -24,20 +24,20 @@ import java.util.List; import java.util.Optional; -public interface GasCalculator { +public interface GasPriceCalculator { public enum GasCalculatorType { - LEGACY, - WEIGHTED; + PLAIN_PERCENTILE, + WEIGHTED_PERCENTILE; public static GasCalculatorType fromString(String type) { if (type == null) { return null; } switch (type.toLowerCase()) { - case "weighted": - return WEIGHTED; - case "legacy": - return LEGACY; + case "weighted_percentile": + return WEIGHTED_PERCENTILE; + case "plain_percentile": + return PLAIN_PERCENTILE; default: return null; } @@ -46,4 +46,6 @@ public static GasCalculatorType fromString(String type) { Optional<Coin> getGasPrice(); void onBlock(Block block, List<TransactionReceipt> receipts); + + GasCalculatorType getType(); } diff --git a/rskj-core/src/main/java/org/ethereum/listener/GasPriceTracker.java b/rskj-core/src/main/java/org/ethereum/listener/GasPriceTracker.java index e0053c7935a..e4929c2124f 100644 --- a/rskj-core/src/main/java/org/ethereum/listener/GasPriceTracker.java +++ b/rskj-core/src/main/java/org/ethereum/listener/GasPriceTracker.java @@ -61,21 +61,21 @@ public class GasPriceTracker extends EthereumListenerAdapter { private Coin defaultPrice = Coin.valueOf(20_000_000_000L); private int blockIdx = 0; - private final GasCalculator gasCalculator; + private final GasPriceCalculator gasPriceCalculator; - private GasPriceTracker(BlockStore blockStore, GasCalculator gasCalculator) { + private GasPriceTracker(BlockStore blockStore, GasPriceCalculator gasPriceCalculator) { this.blockStore = blockStore; - this.gasCalculator = gasCalculator; + this.gasPriceCalculator = gasPriceCalculator; } - public static GasPriceTracker create(BlockStore blockStore, GasCalculator.GasCalculatorType gasCalculatorType) { - GasCalculator gasCal; + public static GasPriceTracker create(BlockStore blockStore, GasPriceCalculator.GasCalculatorType gasCalculatorType) { + GasPriceCalculator gasCal; switch (gasCalculatorType) { - case WEIGHTED: - gasCal = new GasWeightedCalc(); + case WEIGHTED_PERCENTILE: + gasCal = new WeightedPercentileGasPriceCalculator(); break; - case LEGACY: - gasCal = new LegacyGasCalculator(); + case PLAIN_PERCENTILE: + gasCal = new PercentileGasPriceCalculator(); break; default: throw new IllegalArgumentException("Unknown gas calculator type: " + gasCalculatorType); @@ -87,14 +87,12 @@ public static GasPriceTracker create(BlockStore blockStore, GasCalculator.GasCal } /** - * @deprecated Use {@link #create(BlockStore, GasCalculator.GasCalculatorType)} instead. + * @deprecated Use {@link #create(BlockStore, GasPriceCalculator.GasCalculatorType)} instead. */ @Deprecated public static GasPriceTracker create(BlockStore blockStore) { //Will be using the legacy gas calculator as default option - GasPriceTracker gasPriceTracker = new GasPriceTracker(blockStore, new LegacyGasCalculator()); - gasPriceTracker.initializeWindowsFromDB(); - return gasPriceTracker; + return GasPriceTracker.create(blockStore, GasPriceCalculator.GasCalculatorType.PLAIN_PERCENTILE); } @Override @@ -110,17 +108,17 @@ public synchronized void onBlock(Block block, List<TransactionReceipt> receipts) trackBlockCompleteness(block); - gasCalculator.onBlock(block, receipts); + gasPriceCalculator.onBlock(block, receipts); logger.trace("End onBlock"); } public synchronized Coin getGasPrice() { - Optional<Coin> gasPriceResult = gasCalculator.getGasPrice(); + Optional<Coin> gasPriceResult = gasPriceCalculator.getGasPrice(); if(!gasPriceResult.isPresent()) { return defaultPrice; } - logger.debug("Gas provided by GasWindowCalc: {}", gasCalculator.getGasPrice()); + logger.debug("Gas provided by GasWindowCalc: {}", gasPriceCalculator.getGasPrice()); Coin bestBlockPrice = bestBlockPriceRef.get(); if (bestBlockPrice == null) { @@ -191,4 +189,8 @@ private void trackBlockCompleteness(Block block) { blockWindow[blockIdx++] = completeness; } + public GasPriceCalculator.GasCalculatorType getGasCalculatorType() { + return gasPriceCalculator.getType(); + } + } diff --git a/rskj-core/src/main/java/org/ethereum/listener/LegacyGasCalculator.java b/rskj-core/src/main/java/org/ethereum/listener/PercentileGasPriceCalculator.java similarity index 92% rename from rskj-core/src/main/java/org/ethereum/listener/LegacyGasCalculator.java rename to rskj-core/src/main/java/org/ethereum/listener/PercentileGasPriceCalculator.java index 766accedb1d..58818f88a51 100644 --- a/rskj-core/src/main/java/org/ethereum/listener/LegacyGasCalculator.java +++ b/rskj-core/src/main/java/org/ethereum/listener/PercentileGasPriceCalculator.java @@ -28,7 +28,7 @@ import java.util.List; import java.util.Optional; -public class LegacyGasCalculator implements GasCalculator { +public class PercentileGasPriceCalculator implements GasPriceCalculator { private static final int TX_WINDOW_SIZE = 512; private final Coin[] txWindow = new Coin[TX_WINDOW_SIZE]; @@ -53,6 +53,11 @@ public void onBlock(Block block, List<TransactionReceipt> receipts) { onBlock(block.getTransactionsList()); } + @Override + public GasCalculatorType getType() { + return GasCalculatorType.PLAIN_PERCENTILE; + } + private void onBlock(List<Transaction> transactionList) { for (Transaction tx : transactionList) { if (!(tx instanceof RemascTransaction)) { diff --git a/rskj-core/src/main/java/org/ethereum/listener/WeightedPercentileCalc.java b/rskj-core/src/main/java/org/ethereum/listener/WeightedPercentileCalc.java new file mode 100644 index 00000000000..775da8ed65e --- /dev/null +++ b/rskj-core/src/main/java/org/ethereum/listener/WeightedPercentileCalc.java @@ -0,0 +1,50 @@ +/* + * This file is part of RskJ + * Copyright (C) 2024 RSK Labs Ltd. + * (derived from ethereumJ library, Copyright (c) 2016 <ether.camp>) + * + * 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 org.ethereum.listener; + +import co.rsk.core.Coin; + +import java.util.Collections; +import java.util.List; + +public class WeightedPercentileCalc { + + public Coin calculateWeightedPercentile(float percentile, List<WeightedPercentileGasPriceCalculator.GasEntry> gasEntries) { + if (gasEntries == null || gasEntries.isEmpty()) { + return null; + } + + Collections.sort(gasEntries); + + double totalWeight = gasEntries.stream().mapToLong(WeightedPercentileGasPriceCalculator.GasEntry::getGasUsed).sum(); + + double targetWeight = percentile / 100 * totalWeight; + + + double cumulativeWeight = 0; + for (WeightedPercentileGasPriceCalculator.GasEntry pair : gasEntries) { + cumulativeWeight += pair.getGasUsed(); + if (cumulativeWeight >= targetWeight) { + return pair.getGasPrice(); + } + } + + return null; + } +} diff --git a/rskj-core/src/main/java/org/ethereum/listener/GasWeightedCalc.java b/rskj-core/src/main/java/org/ethereum/listener/WeightedPercentileGasPriceCalculator.java similarity index 51% rename from rskj-core/src/main/java/org/ethereum/listener/GasWeightedCalc.java rename to rskj-core/src/main/java/org/ethereum/listener/WeightedPercentileGasPriceCalculator.java index 85a23ff027b..8c2af3ea886 100644 --- a/rskj-core/src/main/java/org/ethereum/listener/GasWeightedCalc.java +++ b/rskj-core/src/main/java/org/ethereum/listener/WeightedPercentileGasPriceCalculator.java @@ -1,7 +1,6 @@ /* * This file is part of RskJ * Copyright (C) 2024 RSK Labs Ltd. - * (derived from ethereumJ library, Copyright (c) 2016 <ether.camp>) * * 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 @@ -16,7 +15,6 @@ * 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 org.ethereum.listener; import co.rsk.core.Coin; @@ -27,73 +25,78 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.math.BigDecimal; import java.util.*; -public class GasWeightedCalc implements GasCalculator { - private static final Logger logger = LoggerFactory.getLogger("gaspricetracker"); +public class WeightedPercentileGasPriceCalculator implements GasPriceCalculator { + + private static final Logger logger = LoggerFactory.getLogger("wpGasPriceTracker"); private static final int WINDOW_SIZE = 512; - private final Deque<GasEntry> gasWindow = new ArrayDeque<>(WINDOW_SIZE); - private final Map<Coin, Long> windowMap = new HashMap<>(); + public static final int REFERENCE_PERCENTILE = 25; + + private final ArrayDeque<GasEntry> gasWindow; + private final WeightedPercentileCalc auxCalculator; + private int txCount = 0; private Coin cachedGasPrice = null; + public WeightedPercentileGasPriceCalculator() { + this(new WeightedPercentileCalc()); + } + + public WeightedPercentileGasPriceCalculator(WeightedPercentileCalc weightedPercentileCalc) { + auxCalculator = weightedPercentileCalc; + gasWindow = new ArrayDeque<>(WINDOW_SIZE); + } + + @Override + public Optional<Coin> getGasPrice() { + if (cachedGasPrice == null) { + cachedGasPrice = calculateGasPrice(); + } + return cachedGasPrice == null ? Optional.empty() : Optional.of(cachedGasPrice); + } + + @Override public synchronized void onBlock(Block block, List<TransactionReceipt> receipts) { - for(TransactionReceipt receipt : receipts) { + for (TransactionReceipt receipt : receipts) { if (!(receipt.getTransaction() instanceof RemascTransaction)) { addTx(receipt.getTransaction(), new Coin(receipt.getGasUsed()).asBigInteger().longValue()); } } } + @Override + public GasCalculatorType getType() { + return GasCalculatorType.WEIGHTED_PERCENTILE; + } + private void addTx(Transaction tx, long gasUsed) { + if (gasUsed == 0) { + return; + } + txCount++; Coin gasPrice = tx.getGasPrice(); if (gasWindow.size() == WINDOW_SIZE) { - GasEntry entry = gasWindow.removeFirst(); - long value = windowMap.get(entry.gasPrice) - entry.gasUsed; - if (value > 0) { - windowMap.put(entry.gasPrice, value); - } else { - windowMap.remove(entry.gasPrice); - } - } + gasWindow.removeFirst(); + } gasWindow.add(new GasEntry(gasPrice, gasUsed)); - windowMap.merge(gasPrice, gasUsed, Long::sum); - if (txCount >= WINDOW_SIZE) { + if (txCount > WINDOW_SIZE) { txCount = 0; // Reset the count cachedGasPrice = calculateGasPrice(); - logger.info("Updated gas price -> {}",cachedGasPrice); + logger.debug("Updated gas price -> {}", cachedGasPrice); } } - private synchronized Coin calculateGasPrice() { - double weightedSum = 0; - double totalGasUsed = 0; - for(Map.Entry<Coin,Long> entry : windowMap.entrySet()) { - weightedSum += entry.getKey().asBigInteger().doubleValue() * entry.getValue(); - totalGasUsed += entry.getValue(); - } - - if (totalGasUsed > 0) { - double result = weightedSum / totalGasUsed; - return new Coin(BigDecimal.valueOf(result).toBigInteger()); - } - return null; - } - - public synchronized Optional<Coin> getGasPrice() { - if(cachedGasPrice == null) { - cachedGasPrice = calculateGasPrice(); - } - return cachedGasPrice == null ? Optional.empty() : Optional.of(cachedGasPrice); + private Coin calculateGasPrice() { + return auxCalculator.calculateWeightedPercentile(REFERENCE_PERCENTILE, new ArrayList<>(gasWindow)); } - static class GasEntry { + static class GasEntry implements Comparable<GasEntry> { protected Coin gasPrice; protected long gasUsed; @@ -101,5 +104,43 @@ static class GasEntry { this.gasPrice = gasPrice; this.gasUsed = gasUsed; } + + + public Coin getGasPrice() { + return gasPrice; + } + + public long getGasUsed() { + return gasUsed; + } + + @Override + public int compareTo + (GasEntry o) { + return this.gasPrice.compareTo(o.gasPrice); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof GasEntry)) { + return false; + } + GasEntry gasEntry = (GasEntry) o; + return gasUsed == gasEntry.gasUsed && + Objects.equals(gasPrice, gasEntry.gasPrice); + } + + @Override + public int hashCode() { + return Objects.hash(gasPrice, gasUsed); + } + + @Override + public String toString() { + return "(" + gasPrice + ", " + gasUsed + ")"; + } } } diff --git a/rskj-core/src/main/resources/reference.conf b/rskj-core/src/main/resources/reference.conf index c0d6c4ddae3..a84e13aa5f9 100644 --- a/rskj-core/src/main/resources/reference.conf +++ b/rskj-core/src/main/resources/reference.conf @@ -165,7 +165,7 @@ peer { miner { # The default gas price minGasPrice = 0 - gasPriceCalculatorType = LEGACY + gasPriceCalculatorType = PLAIN_PERCENTILE server { enabled = false isFixedClock = false diff --git a/rskj-core/src/test/java/org/ethereum/listener/GasPriceTrackerTest.java b/rskj-core/src/test/java/org/ethereum/listener/GasPriceTrackerTest.java index 35e9134cd6d..09f4fe85c09 100644 --- a/rskj-core/src/test/java/org/ethereum/listener/GasPriceTrackerTest.java +++ b/rskj-core/src/test/java/org/ethereum/listener/GasPriceTrackerTest.java @@ -194,6 +194,20 @@ void isFeeMarketWorking_trueWhenAboveAverage() { assertTrue(gasPriceTracker.isFeeMarketWorking()); } + @Test + void gasTrackerIsCreatedWithTheCorrectType(){ + GasPriceTracker gasPriceTracker = GasPriceTracker.create(blockStore); + assertEquals(GasPriceCalculator.GasCalculatorType.PLAIN_PERCENTILE, gasPriceTracker.getGasCalculatorType(), "Plain pecentile is the default one"); + + assertEquals(GasPriceCalculator.GasCalculatorType.PLAIN_PERCENTILE, + GasPriceTracker.create(blockStore, GasPriceCalculator.GasCalculatorType.PLAIN_PERCENTILE).getGasCalculatorType(), + "Plain percentile type is expected when passed as parameter"); + + assertEquals(GasPriceCalculator.GasCalculatorType.WEIGHTED_PERCENTILE, + GasPriceTracker.create(blockStore, GasPriceCalculator.GasCalculatorType.WEIGHTED_PERCENTILE).getGasCalculatorType(), + "Weighted percentile type is expected when passed as parameter"); + } + private static Block makeBlock(Coin mgp, int txCount, Function<Integer, Transaction> txMaker) { Block block = mock(Block.class); diff --git a/rskj-core/src/test/java/org/ethereum/listener/GasWeightedCalcTest.java b/rskj-core/src/test/java/org/ethereum/listener/GasWeightedCalcTest.java deleted file mode 100644 index 7c2b44b7d04..00000000000 --- a/rskj-core/src/test/java/org/ethereum/listener/GasWeightedCalcTest.java +++ /dev/null @@ -1,143 +0,0 @@ -package org.ethereum.listener; - -import co.rsk.core.Coin; -import org.ethereum.core.Block; -import org.ethereum.core.Transaction; -import org.ethereum.core.TransactionReceipt; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.math.BigInteger; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.when; - -class GasWeightedCalcTest { - - private GasWeightedCalc gasWeightedCalc; - private static final int WINDOW_SIZE = 512; - - @BeforeEach - void setUp() { - gasWeightedCalc = new GasWeightedCalc(); - } - - @Test - void testCalculateGasPriceWithNoTransactions() { - // Test when no transactions are added - Block mockBlock = Mockito.mock(Block.class); - - gasWeightedCalc.onBlock(mockBlock, new ArrayList<>()); - - Optional<Coin> gasPrice = gasWeightedCalc.getGasPrice(); - assertFalse(gasPrice.isPresent(), "Gas price should not be present when no transactions are added"); - } - - @Test - void testCalculateGasPriceWithSingleTransaction() { - // Test when a single transaction is added - Block mockBlock = Mockito.mock(Block.class); - - TransactionReceipt mockReceipt = Mockito.mock(TransactionReceipt.class); - Transaction mockTransaction = Mockito.mock(Transaction.class); - when(mockTransaction.getGasPrice()).thenReturn(new Coin(BigInteger.valueOf(100))); - when(mockReceipt.getTransaction()).thenReturn(mockTransaction); - when(mockReceipt.getGasUsed()).thenReturn(BigInteger.valueOf(500).toByteArray()); - - gasWeightedCalc.onBlock(mockBlock, Collections.singletonList(mockReceipt)); - - Optional<Coin> gasPrice = gasWeightedCalc.getGasPrice(); - assertTrue(gasPrice.isPresent(), "Gas price should be present when a transaction is added"); - assertEquals(new Coin(BigInteger.valueOf(100)), gasPrice.get(), "Gas price should be the same as the single transaction's gas price"); - } - - @Test - void testCalculateGasPriceWithMultipleTransactionsSameGasUsage() { - // Test when multiple transactions are added - Block mockBlock = Mockito.mock(Block.class); - - TransactionReceipt mockReceipt1 = Mockito.mock(TransactionReceipt.class); - Transaction mockTransaction1 = Mockito.mock(Transaction.class); - when(mockTransaction1.getGasPrice()).thenReturn(new Coin(BigInteger.valueOf(100))); - when(mockReceipt1.getTransaction()).thenReturn(mockTransaction1); - when(mockReceipt1.getGasUsed()).thenReturn(BigInteger.valueOf(100).toByteArray()); - - TransactionReceipt mockReceipt2 = Mockito.mock(TransactionReceipt.class); - Transaction mockTransaction2 = Mockito.mock(Transaction.class); - when(mockTransaction2.getGasPrice()).thenReturn(new Coin(BigInteger.valueOf(300))); - when(mockReceipt2.getTransaction()).thenReturn(mockTransaction2); - when(mockReceipt2.getGasUsed()).thenReturn(BigInteger.valueOf(100).toByteArray()); - - gasWeightedCalc.onBlock(mockBlock, Arrays.asList(mockReceipt1, mockReceipt2)); - - Optional<Coin> gasPrice = gasWeightedCalc.getGasPrice(); - assertEquals(new Coin(BigInteger.valueOf(200)), gasPrice.get(), "Gas price should be the weighted average of multiple transactions"); - } - - @Test - void testCalculateGasPriceWithMultipleTransactionsDiffGasUsage() { - // Test when multiple transactions are added - Block mockBlock = Mockito.mock(Block.class); - - TransactionReceipt mockReceipt1 = Mockito.mock(TransactionReceipt.class); - Transaction mockTransaction1 = Mockito.mock(Transaction.class); - when(mockTransaction1.getGasPrice()).thenReturn(new Coin(BigInteger.valueOf(100))); - when(mockReceipt1.getTransaction()).thenReturn(mockTransaction1); - when(mockReceipt1.getGasUsed()).thenReturn(BigInteger.valueOf(100).toByteArray()); - - TransactionReceipt mockReceipt2 = Mockito.mock(TransactionReceipt.class); - Transaction mockTransaction2 = Mockito.mock(Transaction.class); - when(mockTransaction2.getGasPrice()).thenReturn(new Coin(BigInteger.valueOf(300))); - when(mockReceipt2.getTransaction()).thenReturn(mockTransaction2); - when(mockReceipt2.getGasUsed()).thenReturn(BigInteger.valueOf(200).toByteArray()); - - gasWeightedCalc.onBlock(mockBlock, Arrays.asList(mockReceipt1, mockReceipt2)); - - Optional<Coin> gasPrice = gasWeightedCalc.getGasPrice(); - assertEquals(new Coin(BigInteger.valueOf(233)), gasPrice.get(), "Gas price should be the weighted average of multiple transactions"); - } - - @Test - void priceIsCalculatedEachWindowSize() { - // Test when the window size is exceeded - Block mockBlock = Mockito.mock(Block.class); - - for (int i = 0; i < WINDOW_SIZE*2 + 1; i++) { - TransactionReceipt mockReceipt = Mockito.mock(TransactionReceipt.class); - Transaction mockTransaction = Mockito.mock(Transaction.class); - when(mockTransaction.getGasPrice()).thenReturn(new Coin(BigInteger.valueOf(100+(i*5)))); - when(mockReceipt.getTransaction()).thenReturn(mockTransaction); - when(mockReceipt.getGasUsed()).thenReturn(BigInteger.valueOf(200+(i*10)).toByteArray()); - gasWeightedCalc.onBlock(mockBlock, Arrays.asList(mockReceipt)); - if(i == (WINDOW_SIZE * 2) - 2) { - Optional<Coin> gasPrice = gasWeightedCalc.getGasPrice(); - assertEquals(new Coin(BigInteger.valueOf(1773)), gasPrice.get(), "Gas price won't change until next window size is reached"); - } - } - - Optional<Coin> gasPrice = gasWeightedCalc.getGasPrice(); - assertEquals(new Coin(BigInteger.valueOf(4076)), gasPrice.get(), "Gas price is updated after windows size is reached"); - } - - @Test - void testCalculateGasPriceWithZeroTotalGasUsed() { - // Test when the total gas used is zero - Block mockBlock = Mockito.mock(Block.class); - - TransactionReceipt mockReceipt = Mockito.mock(TransactionReceipt.class); - Transaction mockTransaction = Mockito.mock(Transaction.class); - when(mockTransaction.getGasPrice()).thenReturn(new Coin(BigInteger.valueOf(100))); - when(mockReceipt.getTransaction()).thenReturn(mockTransaction); - when(mockReceipt.getGasUsed()).thenReturn(BigInteger.ZERO.toByteArray()); - - gasWeightedCalc.onBlock(mockBlock, Collections.singletonList(mockReceipt)); - - Optional<Coin> gasPrice = gasWeightedCalc.getGasPrice(); - assertFalse(gasPrice.isPresent(), "Gas price should not be present when total gas used is zero"); - } -} \ No newline at end of file diff --git a/rskj-core/src/test/java/org/ethereum/listener/WeightedPercentileCalcTest.java b/rskj-core/src/test/java/org/ethereum/listener/WeightedPercentileCalcTest.java new file mode 100644 index 00000000000..8aea36eacad --- /dev/null +++ b/rskj-core/src/test/java/org/ethereum/listener/WeightedPercentileCalcTest.java @@ -0,0 +1,76 @@ +/* + * This file is part of RskJ + * Copyright (C) 2024 RSK Labs Ltd. + * (derived from ethereumJ library, Copyright (c) 2016 <ether.camp>) + * + * 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 org.ethereum.listener; + +import co.rsk.core.Coin; +import org.junit.jupiter.api.Test; + +import java.math.BigInteger; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class WeightedPercentileCalcTest { + + @Test + void testCalculateWeightedPercentile() { + WeightedPercentileCalc weightedPercentileCalc = new WeightedPercentileCalc(); + + // Sample gas entries with smaller numbers + WeightedPercentileGasPriceCalculator.GasEntry entry1 = new WeightedPercentileGasPriceCalculator.GasEntry(new Coin(BigInteger.valueOf(100)), 1); + WeightedPercentileGasPriceCalculator.GasEntry entry2 = new WeightedPercentileGasPriceCalculator.GasEntry(new Coin(BigInteger.valueOf(200)), 3); + WeightedPercentileGasPriceCalculator.GasEntry entry3 = new WeightedPercentileGasPriceCalculator.GasEntry(new Coin(BigInteger.valueOf(300)), 1); + WeightedPercentileGasPriceCalculator.GasEntry entry4 = new WeightedPercentileGasPriceCalculator.GasEntry(new Coin(BigInteger.valueOf(500)), 10); + WeightedPercentileGasPriceCalculator.GasEntry entry5 = new WeightedPercentileGasPriceCalculator.GasEntry(new Coin(BigInteger.valueOf(400)), 1); + WeightedPercentileGasPriceCalculator.GasEntry entry6 = new WeightedPercentileGasPriceCalculator.GasEntry(new Coin(BigInteger.valueOf(700)), 2); + WeightedPercentileGasPriceCalculator.GasEntry entry7 = new WeightedPercentileGasPriceCalculator.GasEntry(new Coin(BigInteger.valueOf(600)), 4); + WeightedPercentileGasPriceCalculator.GasEntry entry8 = new WeightedPercentileGasPriceCalculator.GasEntry(new Coin(BigInteger.valueOf(800)), 1); + + + List<WeightedPercentileGasPriceCalculator.GasEntry> gasEntries = Arrays.asList(entry1, entry2, entry3, entry4, entry5, entry6, entry7,entry8); + + + Coin result0 = weightedPercentileCalc.calculateWeightedPercentile(0, gasEntries); + assertEquals(new Coin(BigInteger.valueOf(800)), result0, "0th percentile should be 100"); + + Coin result10 = weightedPercentileCalc.calculateWeightedPercentile(1, gasEntries); + assertEquals(new Coin(BigInteger.valueOf(100)), result10, "1th percentile should be 100"); + + Coin result20 = weightedPercentileCalc.calculateWeightedPercentile(20.2f, gasEntries); + assertEquals(new Coin(BigInteger.valueOf(300)), result20, "20th percentile should be 300"); + + Coin result40 = weightedPercentileCalc.calculateWeightedPercentile(40, gasEntries); + assertEquals(new Coin(BigInteger.valueOf(500)), result40, "40th percentile should be 500"); + + Coin result50 = weightedPercentileCalc.calculateWeightedPercentile(50, gasEntries); + assertEquals(new Coin(BigInteger.valueOf(500)), result50, "50th percentile should be 500"); + + Coin result75 = weightedPercentileCalc.calculateWeightedPercentile(75, gasEntries); + assertEquals(new Coin(BigInteger.valueOf(600)), result75, "75th percentile should be 600"); + + Coin result90 = weightedPercentileCalc.calculateWeightedPercentile(90, gasEntries); + assertEquals(new Coin(BigInteger.valueOf(700)), result90, "90th percentile should be 600"); + + Coin result100 = weightedPercentileCalc.calculateWeightedPercentile(100, gasEntries); + assertEquals(new Coin(BigInteger.valueOf(800)), result100, "100th percentile should be 800"); + + + } +} \ No newline at end of file diff --git a/rskj-core/src/test/java/org/ethereum/listener/WeightedPercentileGasPriceCalculatorTest.java b/rskj-core/src/test/java/org/ethereum/listener/WeightedPercentileGasPriceCalculatorTest.java new file mode 100644 index 00000000000..597f5191202 --- /dev/null +++ b/rskj-core/src/test/java/org/ethereum/listener/WeightedPercentileGasPriceCalculatorTest.java @@ -0,0 +1,204 @@ +/* + * This file is part of RskJ + * Copyright (C) 2024 RSK Labs Ltd. + * (derived from ethereumJ library, Copyright (c) 2016 <ether.camp>) + * + * 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 org.ethereum.listener; + +import co.rsk.core.Coin; +import org.ethereum.core.Block; +import org.ethereum.core.Transaction; +import org.ethereum.core.TransactionReceipt; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +import java.math.BigInteger; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class WeightedPercentileGasPriceCalculatorTest { + + private static final int WINDOW_SIZE = 512; + private WeightedPercentileGasPriceCalculator weightedPercentileGasPriceCalculator; + + + @BeforeEach + void setup() { + weightedPercentileGasPriceCalculator = new WeightedPercentileGasPriceCalculator(); + } + + @Test + void testCalculateWeightedPercentileWithNoTransactions() { + // Test when no transactions are added + assertNotNull(weightedPercentileGasPriceCalculator); + assertFalse(weightedPercentileGasPriceCalculator.getGasPrice().isPresent(), "Gas price should not be present when no transactions are added"); + } + + @Test + void testCalculateGasPriceWithZeroTotalGasUsed() { + // Test when the total gas used is zero + Block mockBlock = Mockito.mock(Block.class); + + TransactionReceipt mockReceipt = Mockito.mock(TransactionReceipt.class); + Transaction mockTransaction = Mockito.mock(Transaction.class); + when(mockTransaction.getGasPrice()).thenReturn(new Coin(BigInteger.valueOf(100))); + when(mockReceipt.getTransaction()).thenReturn(mockTransaction); + when(mockReceipt.getGasUsed()).thenReturn(BigInteger.ZERO.toByteArray()); + + weightedPercentileGasPriceCalculator.onBlock(mockBlock, Collections.singletonList(mockReceipt)); + + Optional<Coin> gasPrice = weightedPercentileGasPriceCalculator.getGasPrice(); + assertFalse(gasPrice.isPresent(), "Gas price should not be present when total gas used is zero"); + } + + @Test + void testCalculateGasPriceWithSingleTransaction() { + // Test when a single transaction is added + Block mockBlock = Mockito.mock(Block.class); + + TransactionReceipt mockReceipt = Mockito.mock(TransactionReceipt.class); + Transaction mockTransaction = Mockito.mock(Transaction.class); + when(mockTransaction.getGasPrice()).thenReturn(new Coin(BigInteger.valueOf(100))); + when(mockReceipt.getTransaction()).thenReturn(mockTransaction); + when(mockReceipt.getGasUsed()).thenReturn(BigInteger.valueOf(500).toByteArray()); + + weightedPercentileGasPriceCalculator.onBlock(mockBlock, Collections.singletonList(mockReceipt)); + + Optional<Coin> gasPrice = weightedPercentileGasPriceCalculator.getGasPrice(); + assertTrue(gasPrice.isPresent(), "Gas price should be present when a transaction is added"); + assertEquals(new Coin(BigInteger.valueOf(100)), gasPrice.get(), "Gas price should be the same as the single transaction's gas price"); + } + + @Test + void testCalculateGasPriceWithMultipleTransactionsSameGasUsage() { + // Test when multiple transactions are added + Block mockBlock = Mockito.mock(Block.class); + + TransactionReceipt mockReceipt1 = Mockito.mock(TransactionReceipt.class); + Transaction mockTransaction1 = Mockito.mock(Transaction.class); + when(mockTransaction1.getGasPrice()).thenReturn(new Coin(BigInteger.valueOf(100))); + when(mockReceipt1.getTransaction()).thenReturn(mockTransaction1); + when(mockReceipt1.getGasUsed()).thenReturn(BigInteger.valueOf(100).toByteArray()); + + TransactionReceipt mockReceipt2 = Mockito.mock(TransactionReceipt.class); + Transaction mockTransaction2 = Mockito.mock(Transaction.class); + when(mockTransaction2.getGasPrice()).thenReturn(new Coin(BigInteger.valueOf(300))); + when(mockReceipt2.getTransaction()).thenReturn(mockTransaction2); + when(mockReceipt2.getGasUsed()).thenReturn(BigInteger.valueOf(300).toByteArray()); + + TransactionReceipt mockReceipt3 = Mockito.mock(TransactionReceipt.class); + Transaction mockTransaction3 = Mockito.mock(Transaction.class); + when(mockTransaction3.getGasPrice()).thenReturn(new Coin(BigInteger.valueOf(200))); + when(mockReceipt3.getTransaction()).thenReturn(mockTransaction3); + when(mockReceipt3.getGasUsed()).thenReturn(BigInteger.valueOf(200).toByteArray()); + weightedPercentileGasPriceCalculator.onBlock(mockBlock, Arrays.asList(mockReceipt1, mockReceipt2, mockReceipt3)); + + Optional<Coin> gasPrice = weightedPercentileGasPriceCalculator.getGasPrice(); + assertTrue(gasPrice.isPresent(), "Gas price should be present when multiple transactions are added"); + assertEquals(new Coin(BigInteger.valueOf(200)), gasPrice.get(), "Expecting 200 as weighted percentile for the provided set."); + } + + @Test + void testCalculateGasPriceWithPlainSet() { + Block mockBlock = Mockito.mock(Block.class); + List<TransactionReceipt> receipts = createMockReceipts(100, 1); + weightedPercentileGasPriceCalculator.onBlock(mockBlock, receipts); + Optional<Coin> gasPrice = weightedPercentileGasPriceCalculator.getGasPrice(); + assertTrue(gasPrice.isPresent(), "Gas price should be present when multiple transactions are added"); + assertEquals(new Coin(BigInteger.valueOf(25)), gasPrice.get(), "Gas price should be the weighted average of multiple transactions"); + } + + @Test + void cacheValueIsNotUpdatedUntilWindowSizeIsReached() { + WeightedPercentileCalc percentileCalc = new WeightedPercentileCalc(); + WeightedPercentileCalc spy = spy(percentileCalc); + WeightedPercentileGasPriceCalculator gasPriceCalculator = new WeightedPercentileGasPriceCalculator(spy); + + Block mockBlock = Mockito.mock(Block.class); + gasPriceCalculator.onBlock(mockBlock, createMockReceipts(10, 1)); + + Optional<Coin> result1 = gasPriceCalculator.getGasPrice(); + assertTrue(result1.isPresent(), "Gas price should be present when multiple transactions are added"); + + gasPriceCalculator.onBlock(mockBlock, createMockReceipts(WINDOW_SIZE - 20, 2)); + Optional<Coin> result2 = gasPriceCalculator.getGasPrice(); + assertTrue(result2.isPresent(), "Gas price should be present when multiple transactions are added"); + + assertEquals(result1.get(), result2.get(), "Gas price is not updated if window threshold is not reached"); + verify(spy, times(1)).calculateWeightedPercentile(anyFloat(), anyList()); + + gasPriceCalculator.onBlock(mockBlock, createMockReceipts(30, 1)); + Optional<Coin> result3 = gasPriceCalculator.getGasPrice(); + assertTrue(result3.isPresent(), "Gas price should be present when multiple transactions are added"); + + assertNotEquals(result1.get(), result3.get(), "Gas price is updated if window threshold is reached"); + verify(spy, times(2)).calculateWeightedPercentile(anyFloat(), anyList()); + } + + @Test + void olderTxAreRemovedWhenWindowLimitIsReach() { + Block mockBlock = Mockito.mock(Block.class); + WeightedPercentileCalc mockPC = Mockito.mock(WeightedPercentileCalc.class); + when(mockPC.calculateWeightedPercentile(anyFloat(), anyList())).thenReturn(new Coin(BigInteger.valueOf(1))); + + ArgumentCaptor<List<WeightedPercentileGasPriceCalculator.GasEntry>> captor = ArgumentCaptor.forClass(List.class); + WeightedPercentileGasPriceCalculator gpc = new WeightedPercentileGasPriceCalculator(mockPC); + + //Transactions are added until window size limit + gpc.onBlock(mockBlock, createMockReceipts(WINDOW_SIZE, 1)); + gpc.getGasPrice(); + + //New transactions are added to reach the window limit and re-calculate gas + TransactionReceipt mockReceipt = Mockito.mock(TransactionReceipt.class); + Transaction mockTransaction = Mockito.mock(Transaction.class); + when(mockTransaction.getGasPrice()).thenReturn(new Coin(BigInteger.valueOf(850))); + when(mockReceipt.getTransaction()).thenReturn(mockTransaction); + when(mockReceipt.getGasUsed()).thenReturn(BigInteger.valueOf(1).toByteArray()); + gpc.onBlock(mockBlock, Collections.singletonList(mockReceipt)); + gpc.getGasPrice(); + + verify(mockPC, times(2)).calculateWeightedPercentile(anyFloat(), captor.capture()); + List<List<WeightedPercentileGasPriceCalculator.GasEntry>> gasPriceList = captor.getAllValues(); + + List<WeightedPercentileGasPriceCalculator.GasEntry> firstList = gasPriceList.get(0); + Coin firstValueFirstList = firstList.get(0).getGasPrice(); + + assertEquals(new Coin(BigInteger.valueOf(1)), firstValueFirstList, "Gas price should be the same as the first transaction's gas price"); + + List<WeightedPercentileGasPriceCalculator.GasEntry> secondList = gasPriceList.get(1); + //The second time the getGasPrice is called the first transaction should be removed and the new one added at the bottom + assertEquals(new Coin(BigInteger.valueOf(850)), secondList.get(secondList.size() - 1).getGasPrice(), "Gas price should be the same as the first transaction's gas price"); + assertEquals(firstList.subList(1, firstList.size() - 1), secondList.subList(0, secondList.size() - 2), "The first list should be the same as the second list without the first and last element"); + } + + private List<TransactionReceipt> createMockReceipts(int numOfReceipts, int gasUsed) { + List<TransactionReceipt> receipts = new ArrayList<>(); + for (int i = 0; i < numOfReceipts; i++) { + TransactionReceipt mockReceipt = Mockito.mock(TransactionReceipt.class); + Transaction mockTransaction = Mockito.mock(Transaction.class); + when(mockTransaction.getGasPrice()).thenReturn(new Coin(BigInteger.valueOf(1 + i))); + when(mockReceipt.getTransaction()).thenReturn(mockTransaction); + when(mockReceipt.getGasUsed()).thenReturn(BigInteger.valueOf(gasUsed).toByteArray()); + receipts.add(mockReceipt); + } + return receipts; + } + +} \ No newline at end of file