diff --git a/src/main/java/co/rsk/federate/btcreleaseclient/BtcReleaseClient.java b/src/main/java/co/rsk/federate/btcreleaseclient/BtcReleaseClient.java index 89ed4a689..2c283def6 100644 --- a/src/main/java/co/rsk/federate/btcreleaseclient/BtcReleaseClient.java +++ b/src/main/java/co/rsk/federate/btcreleaseclient/BtcReleaseClient.java @@ -12,6 +12,8 @@ import co.rsk.crypto.Keccak256; import co.rsk.federate.FederatorSupport; import co.rsk.federate.adapter.ThinConverter; +import co.rsk.federate.btcreleaseclient.cache.PegoutSignedCache; +import co.rsk.federate.btcreleaseclient.cache.PegoutSignedCacheImpl; import co.rsk.federate.config.FedNodeSystemProperties; import co.rsk.federate.signing.ECDSASigner; import co.rsk.federate.signing.FederationCantSignException; @@ -35,6 +37,7 @@ import co.rsk.peg.federation.Federation; import co.rsk.peg.federation.ErpFederation; import co.rsk.peg.StateForFederator; +import java.time.Clock; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -84,11 +87,11 @@ public class BtcReleaseClient { private final Ethereum ethereum; private final FederatorSupport federatorSupport; - private final FedNodeSystemProperties systemProperties; private final Set observedFederations; private final NodeBlockProcessor nodeBlockProcessor; private final BridgeConstants bridgeConstants; private final boolean isPegoutEnabled; + private final PegoutSignedCache pegoutSignedCache; private ECDSASigner signer; private BtcReleaseEthereumListener blockListener; @@ -108,12 +111,13 @@ public BtcReleaseClient( ) { this.ethereum = ethereum; this.federatorSupport = federatorSupport; - this.systemProperties = systemProperties; this.observedFederations = new HashSet<>(); this.blockListener = new BtcReleaseEthereumListener(); - this.bridgeConstants = this.systemProperties.getNetworkConstants().getBridgeConstants(); - this.isPegoutEnabled = this.systemProperties.isPegoutEnabled(); + this.bridgeConstants = systemProperties.getNetworkConstants().getBridgeConstants(); + this.isPegoutEnabled = systemProperties.isPegoutEnabled(); this.nodeBlockProcessor = nodeBlockProcessor; + this.pegoutSignedCache = new PegoutSignedCacheImpl( + systemProperties.getPegoutSignedCacheTtl(), Clock.systemUTC()); } public void setup( @@ -143,7 +147,7 @@ public void setup( } peerGroup.start(); - blockListener = new BtcReleaseEthereumListener(); + this.blockListener = new BtcReleaseEthereumListener(); this.signerMessageBuilderFactory = signerMessageBuilderFactory; this.releaseCreationInformationGetter = pegoutCreationInformationGetter; this.releaseRequirementsEnforcer = releaseRequirementsEnforcer; @@ -283,10 +287,15 @@ protected Optional tryGetReleaseInformation( BtcTransaction pegoutBtcTx ) { try { + // Discard pegout tx if processed in a previous round of execution + logger.trace( + "[tryGetReleaseInformation] Checking if pegoutCreationTxHash {} has already been signed", + pegoutCreationRskTxHash); + validateTxIsNotCached(pegoutCreationRskTxHash); + // Discard pegout btc tx this fed already signed or cannot be signed by the observed federations logger.trace("[tryGetReleaseInformation] Validating if pegoutBtcTxHash {} can be signed by observed federations and " + "that it is not already signed by current fed", pegoutBtcTx.getHash()); - validateTxCanBeSigned(pegoutBtcTx); // IMPORTANT: As per the current behaviour of the bridge, no pegout should have inputs to be signed @@ -326,6 +335,15 @@ protected Optional tryGetReleaseInformation( return Optional.empty(); } + void validateTxIsNotCached(Keccak256 pegoutCreationRskTxHash) throws FederatorAlreadySignedException { + if (pegoutSignedCache.hasAlreadyBeenSigned(pegoutCreationRskTxHash)) { + String message = String.format( + "Rsk pegout creation tx hash %s was found in the pegouts signed cache", + pegoutCreationRskTxHash); + throw new FederatorAlreadySignedException(message); + } + } + protected void validateTxCanBeSigned(BtcTransaction pegoutBtcTx) throws FederatorAlreadySignedException, FederationCantSignException { try { BtcECKey federatorPublicKey = signer.getPublicKey(BTC_KEY_ID.getKeyId()).toBtcKey(); @@ -400,6 +418,11 @@ protected void signRelease(int signerVersion, ReleaseCreationInformation pegoutC logger.info("[signRelease] Signed pegout created in rsk transaction {}", pegoutCreationInformation.getPegoutConfirmationRskTxHash()); federatorSupport.addSignature(signatures, pegoutCreationInformation.getPegoutConfirmationRskTxHash().getBytes()); + + logger.trace("[signRelease] Put pegoutCreationRskTxHash {} in the pegouts signed cache", + pegoutCreationInformation.getPegoutCreationRskTxHash()); + pegoutSignedCache.putIfAbsent( + pegoutCreationInformation.getPegoutCreationRskTxHash()); } catch (SignerException e) { String message = String.format("Error signing pegout created in rsk transaction %s", pegoutCreationInformation.getPegoutCreationRskTxHash()); logger.error(message, e); diff --git a/src/main/java/co/rsk/federate/btcreleaseclient/cache/PegoutSignedCache.java b/src/main/java/co/rsk/federate/btcreleaseclient/cache/PegoutSignedCache.java new file mode 100644 index 000000000..3416cbb3e --- /dev/null +++ b/src/main/java/co/rsk/federate/btcreleaseclient/cache/PegoutSignedCache.java @@ -0,0 +1,26 @@ +package co.rsk.federate.btcreleaseclient.cache; + +import co.rsk.crypto.Keccak256; + +public interface PegoutSignedCache { + + /** + * Checks if the specified RSK transaction hash for pegout creation has already + * been signed. + * + * @param pegoutCreationRskTxHash The Keccak256 hash of the RSK transaction for + * pegout creation. + * @return {@code true} if the hash of the transaction has already been signed, + * {@code false} otherwise. + */ + boolean hasAlreadyBeenSigned(Keccak256 pegoutCreationRskTxHash); + + /** + * Stores the specified RSK transaction hash for pegout creation along with its + * timestamp in the cache if absent. + * + * @param pegoutCreationRskTxHash The Keccak256 hash of the RSK transaction for + * pegout creation to be stored. + */ + void putIfAbsent(Keccak256 pegoutCreationRskTxHash); +} diff --git a/src/main/java/co/rsk/federate/btcreleaseclient/cache/PegoutSignedCacheImpl.java b/src/main/java/co/rsk/federate/btcreleaseclient/cache/PegoutSignedCacheImpl.java new file mode 100644 index 000000000..2cdb92954 --- /dev/null +++ b/src/main/java/co/rsk/federate/btcreleaseclient/cache/PegoutSignedCacheImpl.java @@ -0,0 +1,86 @@ +package co.rsk.federate.btcreleaseclient.cache; + +import co.rsk.crypto.Keccak256; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class PegoutSignedCacheImpl implements PegoutSignedCache { + + private static final Logger logger = LoggerFactory.getLogger(PegoutSignedCacheImpl.class); + private static final Integer CLEANUP_INTERVAL_IN_HOURS = 1; + + private final Map cache = new ConcurrentHashMap<>(); + private final ScheduledExecutorService cleanupScheduler = Executors.newSingleThreadScheduledExecutor(); + private final Duration ttl; + private final Clock clock; + + public PegoutSignedCacheImpl(Duration ttl, Clock clock) { + validateTtl(ttl); + + this.ttl = ttl; + this.clock = clock; + + // Start a background thread for periodic cleanup + cleanupScheduler.scheduleAtFixedRate( + this::performCleanup, + CLEANUP_INTERVAL_IN_HOURS, // initial delay + CLEANUP_INTERVAL_IN_HOURS, // period + TimeUnit.HOURS + ); + } + + @Override + public boolean hasAlreadyBeenSigned(Keccak256 pegoutCreationRskTxHash) { + return Optional.ofNullable(pegoutCreationRskTxHash) + .map(cache::get) + .map(this::isValidTimestamp) + .orElse(false); + } + + @Override + public void putIfAbsent(Keccak256 pegoutCreationRskTxHash) { + if (pegoutCreationRskTxHash == null) { + throw new IllegalArgumentException( + "The pegoutCreationRskTxHash argument must not be null"); + } + + Optional.of(pegoutCreationRskTxHash) + .ifPresent(rskTxHash -> cache.putIfAbsent(rskTxHash, clock.instant())); + } + + void performCleanup() { + logger.trace( + "[performCleanup] Pegouts signed cache before cleanup: {}", cache.keySet()); + cache.entrySet().removeIf( + entry -> !isValidTimestamp(entry.getValue())); + logger.trace( + "[performCleanup] Pegouts signed cache after cleanup: {}", cache.keySet()); + } + + private boolean isValidTimestamp(Instant timestampInCache) { + return Optional.ofNullable(timestampInCache) + .map(timestamp -> clock.instant().toEpochMilli() - timestamp.toEpochMilli()) + .map(timeCachedInMillis -> timeCachedInMillis <= ttl.toMillis()) + .orElse(false); + } + + private static void validateTtl(Duration ttl) { + if (ttl == null || ttl.isNegative() || ttl.isZero()) { + Long ttlInMinutes = ttl != null ? ttl.toMinutes() : null; + String message = String.format( + "Invalid pegouts signed cache TTL value in minutes supplied: %d", ttlInMinutes); + logger.error("[validateTtl] {}", message); + + throw new IllegalArgumentException(message); + } + } +} diff --git a/src/main/java/co/rsk/federate/config/FedNodeSystemProperties.java b/src/main/java/co/rsk/federate/config/FedNodeSystemProperties.java index 1cb13202c..52e4f3804 100644 --- a/src/main/java/co/rsk/federate/config/FedNodeSystemProperties.java +++ b/src/main/java/co/rsk/federate/config/FedNodeSystemProperties.java @@ -3,6 +3,7 @@ import co.rsk.config.ConfigLoader; import co.rsk.config.RskSystemProperties; import com.typesafe.config.Config; +import java.time.Duration; import java.util.ArrayList; import java.util.List; @@ -83,4 +84,17 @@ public int getBtcReleaseClientInitializationMaxDepth() { configFromFiles.getInt("federator.pegoutStorageInitializationDepth") : 6_000; } + + /** + * Retrieves the time to live (TTL) duration for the pegout signed cache. + * The TTL duration specifies the validity period for cache entries. + * If the TTL value is not configured, a default value of 30 minutes is used. + * + * @return The time to live (TTL) duration for the pegout signed cache, + * or a default value of 30 minutes if not configured. + */ + public Duration getPegoutSignedCacheTtl() { + return Duration.ofMinutes( + getInt("federator.pegoutSignedCacheTtlInMinutes", 30)); + } } diff --git a/src/main/resources/config/fed-sample.conf b/src/main/resources/config/fed-sample.conf index b62bf6215..b296f89dd 100644 --- a/src/main/resources/config/fed-sample.conf +++ b/src/main/resources/config/fed-sample.conf @@ -46,4 +46,7 @@ federator { # Gas price to use for federate node transactions gasPrice = 1000 + + # Pegout signed cache ttl value to avoid signing the same pegout btc transaction + pegoutSignedCacheTtlInMinutes = 30 } diff --git a/src/test/java/co/rsk/federate/FedNodeSystemPropertiesTest.java b/src/test/java/co/rsk/federate/FedNodeSystemPropertiesTest.java index d22e4c196..4d03710ac 100644 --- a/src/test/java/co/rsk/federate/FedNodeSystemPropertiesTest.java +++ b/src/test/java/co/rsk/federate/FedNodeSystemPropertiesTest.java @@ -1,10 +1,12 @@ package co.rsk.federate; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import java.time.Duration; import co.rsk.config.ConfigLoader; import co.rsk.federate.config.FedNodeSystemProperties; import com.typesafe.config.Config; @@ -143,4 +145,36 @@ void updateBridgeTimer_enabled_mainnet() { assertTrue(fedNodeSystemProperties.isUpdateBridgeTimerEnabled()); } + + @Test + void getPegoutSignedCacheTtl_whenConfigurationPathExists_shouldUseCustomTtl() { + int customTtlInMinutes = 10; + when(configLoader.getConfig()).thenReturn(config); + when(config.hasPath( + "federator.pegoutSignedCacheTtlInMinutes")).thenReturn(true); + when(config.getInt( + "federator.pegoutSignedCacheTtlInMinutes")).thenReturn(customTtlInMinutes); + when(config.root()).thenReturn(configObject); + + FedNodeSystemProperties fedNodeSystemProperties = new FedNodeSystemProperties(configLoader); + + assertEquals( + Duration.ofMinutes(customTtlInMinutes), + fedNodeSystemProperties.getPegoutSignedCacheTtl()); + } + + @Test + void getPegoutSignedCacheTtl_whenConfigurationPathDoesNotExist_shouldUseDefaultTtl() { + int defaultTtlInMinutes = 30; + when(configLoader.getConfig()).thenReturn(config); + when(config.hasPath( + "federator.pegoutSignedCacheTtlInMinutes")).thenReturn(false); + when(config.root()).thenReturn(configObject); + + FedNodeSystemProperties fedNodeSystemProperties = new FedNodeSystemProperties(configLoader); + + assertEquals( + Duration.ofMinutes(defaultTtlInMinutes), + fedNodeSystemProperties.getPegoutSignedCacheTtl()); + } } diff --git a/src/test/java/co/rsk/federate/btcreleaseclient/BtcReleaseClientTest.java b/src/test/java/co/rsk/federate/btcreleaseclient/BtcReleaseClientTest.java index a7c041797..3395a4b58 100644 --- a/src/test/java/co/rsk/federate/btcreleaseclient/BtcReleaseClientTest.java +++ b/src/test/java/co/rsk/federate/btcreleaseclient/BtcReleaseClientTest.java @@ -2,6 +2,7 @@ import static co.rsk.federate.signing.PowPegNodeKeyId.BTC_KEY_ID; import static co.rsk.federate.signing.utils.TestUtils.createHash; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -35,6 +36,8 @@ import co.rsk.peg.constants.BridgeConstants; import co.rsk.crypto.Keccak256; import co.rsk.federate.FederatorSupport; +import co.rsk.federate.btcreleaseclient.cache.PegoutSignedCache; +import co.rsk.federate.btcreleaseclient.cache.PegoutSignedCacheImpl; import co.rsk.federate.config.FedNodeSystemProperties; import co.rsk.federate.mock.SimpleEthereumImpl; import co.rsk.federate.signing.ECDSASigner; @@ -59,8 +62,12 @@ import co.rsk.peg.federation.*; import co.rsk.peg.StateForFederator; +import java.lang.reflect.Field; import java.math.BigInteger; +import java.time.Clock; +import java.time.Duration; import java.time.Instant; +import java.time.ZoneId; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -89,6 +96,9 @@ import org.spongycastle.util.encoders.Hex; class BtcReleaseClientTest { + + private final static Duration PEGOUT_SIGNED_CACHE_TTL = Duration.ofMinutes(30); + private NetworkParameters params; private BridgeConstants bridgeConstants; @@ -109,6 +119,8 @@ void if_start_not_called_rsk_blockchain_not_listened() { Ethereum ethereum = mock(Ethereum.class); FedNodeSystemProperties fedNodeSystemProperties = mock(FedNodeSystemProperties.class); Mockito.doReturn(Constants.regtest()).when(fedNodeSystemProperties).getNetworkConstants(); + when(fedNodeSystemProperties.getPegoutSignedCacheTtl()) + .thenReturn(PEGOUT_SIGNED_CACHE_TTL); new BtcReleaseClient( ethereum, @@ -125,6 +137,8 @@ void when_start_called_rsk_blockchain_is_listened() { Ethereum ethereum = mock(Ethereum.class); FedNodeSystemProperties fedNodeSystemProperties = mock(FedNodeSystemProperties.class); Mockito.doReturn(Constants.regtest()).when(fedNodeSystemProperties).getNetworkConstants(); + when(fedNodeSystemProperties.getPegoutSignedCacheTtl()) + .thenReturn(PEGOUT_SIGNED_CACHE_TTL); BtcReleaseClient btcReleaseClient = new BtcReleaseClient( ethereum, @@ -147,6 +161,8 @@ void if_stop_called_with_just_one_federation_rsk_blockchain_is_still_listened() Ethereum ethereum = mock(Ethereum.class); FedNodeSystemProperties fedNodeSystemProperties = mock(FedNodeSystemProperties.class); Mockito.doReturn(Constants.regtest()).when(fedNodeSystemProperties).getNetworkConstants(); + when(fedNodeSystemProperties.getPegoutSignedCacheTtl()) + .thenReturn(PEGOUT_SIGNED_CACHE_TTL); BtcReleaseClient btcReleaseClient = new BtcReleaseClient( ethereum, @@ -172,6 +188,8 @@ void if_stop_called_with_federations_rsk_blockchain_is_not_listened() { Ethereum ethereum = mock(Ethereum.class); FedNodeSystemProperties fedNodeSystemProperties = mock(FedNodeSystemProperties.class); Mockito.doReturn(Constants.regtest()).when(fedNodeSystemProperties).getNetworkConstants(); + when(fedNodeSystemProperties.getPegoutSignedCacheTtl()) + .thenReturn(PEGOUT_SIGNED_CACHE_TTL); BtcReleaseClient btcReleaseClient = new BtcReleaseClient( ethereum, @@ -218,6 +236,8 @@ void processReleases_ok() throws Exception { FedNodeSystemProperties fedNodeSystemProperties = mock(FedNodeSystemProperties.class); when(fedNodeSystemProperties.getNetworkConstants()).thenReturn(Constants.regtest()); + when(fedNodeSystemProperties.getPegoutSignedCacheTtl()) + .thenReturn(PEGOUT_SIGNED_CACHE_TTL); SignerMessageBuilder messageBuilder = new SignerMessageBuilderV1(releaseTx); SignerMessageBuilderFactory signerMessageBuilderFactory = mock(SignerMessageBuilderFactory.class); @@ -316,6 +336,8 @@ void having_two_pegouts_signs_only_one() throws Exception { FedNodeSystemProperties fedNodeSystemProperties = mock(FedNodeSystemProperties.class); doReturn(Constants.regtest()).when(fedNodeSystemProperties).getNetworkConstants(); doReturn(true).when(fedNodeSystemProperties).isPegoutEnabled(); + when(fedNodeSystemProperties.getPegoutSignedCacheTtl()) + .thenReturn(PEGOUT_SIGNED_CACHE_TTL); SignerMessageBuilderFactory signerMessageBuilderFactory = new SignerMessageBuilderFactory( mock(ReceiptStore.class) @@ -380,6 +402,218 @@ void having_two_pegouts_signs_only_one() throws Exception { ); } + @Test + void onBestBlock_whenPegoutTxIsCached_shouldNotSignSamePegoutTxAgain() throws Exception { + Federation federation = TestUtils.createFederation(params, 9); + BtcTransaction pegout = TestUtils.createBtcTransaction(params, federation); + Keccak256 pegoutCreationRskTxHash = createHash(0); + SortedMap rskTxsWaitingForSignatures = new TreeMap<>(); + rskTxsWaitingForSignatures.put(pegoutCreationRskTxHash, pegout); + StateForFederator stateForFederator = new StateForFederator(rskTxsWaitingForSignatures); + + Ethereum ethereum = mock(Ethereum.class); + AtomicReference ethereumListener = new AtomicReference<>(); + doAnswer((InvocationOnMock invocation) -> { + ethereumListener.set((EthereumListener) invocation.getArguments()[0]); + return null; + }).when(ethereum).addListener(any(EthereumListener.class)); + + FederatorSupport federatorSupport = mock(FederatorSupport.class); + doReturn(stateForFederator).when(federatorSupport).getStateForFederator(); + + ECKey ecKey = new ECKey(); + BtcECKey fedKey = new BtcECKey(); + ECPublicKey signerPublicKey = new ECPublicKey(fedKey.getPubKey()); + + ECDSASigner signer = mock(ECDSASigner.class); + doReturn(signerPublicKey).when(signer).getPublicKey(BTC_KEY_ID.getKeyId()); + doReturn(1).when(signer).getVersionForKeyId(ArgumentMatchers.any(KeyId.class)); + doReturn(ecKey.doSign(new byte[]{})).when(signer).sign(any(KeyId.class), any(SignerMessage.class)); + + FedNodeSystemProperties fedNodeSystemProperties = mock(FedNodeSystemProperties.class); + doReturn(Constants.regtest()).when(fedNodeSystemProperties).getNetworkConstants(); + doReturn(true).when(fedNodeSystemProperties).isPegoutEnabled(); + when(fedNodeSystemProperties.getPegoutSignedCacheTtl()) + .thenReturn(PEGOUT_SIGNED_CACHE_TTL); + + SignerMessageBuilderFactory signerMessageBuilderFactory = new SignerMessageBuilderFactory( + mock(ReceiptStore.class) + ); + + BlockStore blockStore = mock(BlockStore.class); + ReceiptStore receiptStore = mock(ReceiptStore.class); + + Keccak256 blockHash = createHash(2); + Block block = mock(Block.class); + TransactionReceipt txReceipt = mock(TransactionReceipt.class); + TransactionInfo txInfo = mock(TransactionInfo.class); + when(block.getHash()).thenReturn(blockHash); + when(blockStore.getBlockByHash(blockHash.getBytes())).thenReturn(block); + when(txInfo.getReceipt()).thenReturn(txReceipt); + when(txInfo.getBlockHash()).thenReturn(blockHash.getBytes()); + when(receiptStore.getInMainChain(pegoutCreationRskTxHash.getBytes(), blockStore)).thenReturn(Optional.of(txInfo)); + + ReleaseCreationInformationGetter releaseCreationInformationGetter = + new ReleaseCreationInformationGetter( + receiptStore, blockStore + ); + + BtcReleaseClientStorageSynchronizer storageSynchronizer = + mock(BtcReleaseClientStorageSynchronizer.class); + when(storageSynchronizer.isSynced()).thenReturn(true); + + BtcReleaseClient btcReleaseClient = new BtcReleaseClient( + ethereum, + federatorSupport, + fedNodeSystemProperties, + mock(NodeBlockProcessor.class) + ); + + btcReleaseClient.setup( + signer, + mock(ActivationConfig.class), + signerMessageBuilderFactory, + releaseCreationInformationGetter, + mock(ReleaseRequirementsEnforcer.class), + mock(BtcReleaseClientStorageAccessor.class), + storageSynchronizer + ); + + btcReleaseClient.start(federation); + + // At this point there is nothing in the pegouts signed cache, + // so it should not throw an exception + assertDoesNotThrow( + () -> btcReleaseClient.validateTxIsNotCached(pegoutCreationRskTxHash)); + + // Start first round of execution + ethereumListener.get().onBestBlock(null, Collections.emptyList()); + + // After the first round of execution, we should throw an exception + // since we have signed the pegout and sent it to the bridge + assertThrows(FederatorAlreadySignedException.class, + () -> btcReleaseClient.validateTxIsNotCached(pegoutCreationRskTxHash)); + + // Execute second round of execution + ethereumListener.get().onBestBlock(null, Collections.emptyList()); + + // Verify we only send the add_signature tx to the bridge once + // throughout both rounds of execution + verify(federatorSupport, times(1)).addSignature( + anyList(), + any(byte[].class) + ); + } + + @Test + void onBestBlock_whenPegoutTxIsCachedWithInvalidTimestamp_shouldSignSamePegoutTxAgain() throws Exception { + Federation federation = TestUtils.createFederation(params, 9); + BtcTransaction pegout = TestUtils.createBtcTransaction(params, federation); + Keccak256 pegoutCreationRskTxHash = createHash(0); + SortedMap rskTxsWaitingForSignatures = new TreeMap<>(); + rskTxsWaitingForSignatures.put(pegoutCreationRskTxHash, pegout); + StateForFederator stateForFederator = new StateForFederator(rskTxsWaitingForSignatures); + + Ethereum ethereum = mock(Ethereum.class); + AtomicReference ethereumListener = new AtomicReference<>(); + doAnswer((InvocationOnMock invocation) -> { + ethereumListener.set((EthereumListener) invocation.getArguments()[0]); + return null; + }).when(ethereum).addListener(any(EthereumListener.class)); + + FederatorSupport federatorSupport = mock(FederatorSupport.class); + doReturn(stateForFederator).when(federatorSupport).getStateForFederator(); + + ECKey ecKey = new ECKey(); + BtcECKey fedKey = new BtcECKey(); + ECPublicKey signerPublicKey = new ECPublicKey(fedKey.getPubKey()); + + ECDSASigner signer = mock(ECDSASigner.class); + doReturn(signerPublicKey).when(signer).getPublicKey(BTC_KEY_ID.getKeyId()); + doReturn(1).when(signer).getVersionForKeyId(ArgumentMatchers.any(KeyId.class)); + doReturn(ecKey.doSign(new byte[]{})).when(signer).sign(any(KeyId.class), any(SignerMessage.class)); + + FedNodeSystemProperties fedNodeSystemProperties = mock(FedNodeSystemProperties.class); + doReturn(Constants.regtest()).when(fedNodeSystemProperties).getNetworkConstants(); + doReturn(true).when(fedNodeSystemProperties).isPegoutEnabled(); + when(fedNodeSystemProperties.getPegoutSignedCacheTtl()) + .thenReturn(PEGOUT_SIGNED_CACHE_TTL); + + SignerMessageBuilderFactory signerMessageBuilderFactory = new SignerMessageBuilderFactory( + mock(ReceiptStore.class) + ); + + BlockStore blockStore = mock(BlockStore.class); + ReceiptStore receiptStore = mock(ReceiptStore.class); + + Keccak256 blockHash = createHash(2); + Block block = mock(Block.class); + TransactionReceipt txReceipt = mock(TransactionReceipt.class); + TransactionInfo txInfo = mock(TransactionInfo.class); + when(block.getHash()).thenReturn(blockHash); + when(blockStore.getBlockByHash(blockHash.getBytes())).thenReturn(block); + when(txInfo.getReceipt()).thenReturn(txReceipt); + when(txInfo.getBlockHash()).thenReturn(blockHash.getBytes()); + when(receiptStore.getInMainChain(pegoutCreationRskTxHash.getBytes(), blockStore)).thenReturn(Optional.of(txInfo)); + + ReleaseCreationInformationGetter releaseCreationInformationGetter = + new ReleaseCreationInformationGetter( + receiptStore, blockStore + ); + + BtcReleaseClientStorageSynchronizer storageSynchronizer = + mock(BtcReleaseClientStorageSynchronizer.class); + when(storageSynchronizer.isSynced()).thenReturn(true); + + BtcReleaseClient btcReleaseClient = new BtcReleaseClient( + ethereum, + federatorSupport, + fedNodeSystemProperties, + mock(NodeBlockProcessor.class) + ); + + Clock baseClock = Clock.fixed(Instant.ofEpochMilli(0), ZoneId.systemDefault()); + PegoutSignedCache pegoutSignedCache = new PegoutSignedCacheImpl(PEGOUT_SIGNED_CACHE_TTL, baseClock); + Field field = btcReleaseClient.getClass().getDeclaredField("pegoutSignedCache"); + field.setAccessible(true); + field.set(btcReleaseClient, pegoutSignedCache); + + btcReleaseClient.setup( + signer, + mock(ActivationConfig.class), + signerMessageBuilderFactory, + releaseCreationInformationGetter, + mock(ReleaseRequirementsEnforcer.class), + mock(BtcReleaseClientStorageAccessor.class), + storageSynchronizer + ); + + btcReleaseClient.start(federation); + + // Start first round of execution + ethereumListener.get().onBestBlock(null, Collections.emptyList()); + + // Ensure the pegout tx becomes invalid by advancing the clock 1 hour + field = pegoutSignedCache.getClass().getDeclaredField("clock"); + field.setAccessible(true); + field.set(pegoutSignedCache, Clock.offset(baseClock, Duration.ofHours(1))); + + // At this point the pegout tx is invalid in the pegouts signed cache, + // so it should not throw an exception + assertDoesNotThrow( + () -> btcReleaseClient.validateTxIsNotCached(pegoutCreationRskTxHash)); + + // Execute second round of execution + ethereumListener.get().onBestBlock(null, Collections.emptyList()); + + // Verify we send the add_signature tx to the bridge twice + // throughout both rounds of execution + verify(federatorSupport, times(2)).addSignature( + anyList(), + any(byte[].class) + ); + } + @Test void onBestBlock_return_when_node_is_syncing() throws BtcReleaseClientException { // Arrange @@ -398,6 +632,8 @@ void onBestBlock_return_when_node_is_syncing() throws BtcReleaseClientException FedNodeSystemProperties fedNodeSystemProperties = mock(FedNodeSystemProperties.class); doReturn(Constants.regtest()).when(fedNodeSystemProperties).getNetworkConstants(); doReturn(true).when(fedNodeSystemProperties).isPegoutEnabled(); + when(fedNodeSystemProperties.getPegoutSignedCacheTtl()) + .thenReturn(PEGOUT_SIGNED_CACHE_TTL); NodeBlockProcessor nodeBlockProcessor = mock(NodeBlockProcessor.class); when(nodeBlockProcessor.hasBetterBlockToSync()).thenReturn(true); @@ -444,6 +680,8 @@ void onBestBlock_return_when_pegout_is_disabled() throws BtcReleaseClientExcepti FedNodeSystemProperties fedNodeSystemProperties = mock(FedNodeSystemProperties.class); doReturn(Constants.regtest()).when(fedNodeSystemProperties).getNetworkConstants(); doReturn(false).when(fedNodeSystemProperties).isPegoutEnabled(); + when(fedNodeSystemProperties.getPegoutSignedCacheTtl()) + .thenReturn(PEGOUT_SIGNED_CACHE_TTL); NodeBlockProcessor nodeBlockProcessor = mock(NodeBlockProcessor.class); when(nodeBlockProcessor.hasBetterBlockToSync()).thenReturn(false); @@ -490,6 +728,8 @@ void onBlock_return_when_node_is_syncing() { FedNodeSystemProperties fedNodeSystemProperties = mock(FedNodeSystemProperties.class); doReturn(Constants.regtest()).when(fedNodeSystemProperties).getNetworkConstants(); doReturn(true).when(fedNodeSystemProperties).isPegoutEnabled(); + when(fedNodeSystemProperties.getPegoutSignedCacheTtl()) + .thenReturn(PEGOUT_SIGNED_CACHE_TTL); NodeBlockProcessor nodeBlockProcessor = mock(NodeBlockProcessor.class); when(nodeBlockProcessor.hasBetterBlockToSync()).thenReturn(true); @@ -532,6 +772,8 @@ void onBlock_return_when_pegout_is_disabled() { FedNodeSystemProperties fedNodeSystemProperties = mock(FedNodeSystemProperties.class); doReturn(Constants.regtest()).when(fedNodeSystemProperties).getNetworkConstants(); doReturn(false).when(fedNodeSystemProperties).isPegoutEnabled(); + when(fedNodeSystemProperties.getPegoutSignedCacheTtl()) + .thenReturn(PEGOUT_SIGNED_CACHE_TTL); NodeBlockProcessor nodeBlockProcessor = mock(NodeBlockProcessor.class); when(nodeBlockProcessor.hasBetterBlockToSync()).thenReturn(false); @@ -640,6 +882,8 @@ void validateTxCanBeSigned_federatorAlreadySigned() throws Exception { FedNodeSystemProperties fedNodeSystemProperties = mock(FedNodeSystemProperties.class); when(fedNodeSystemProperties.getNetworkConstants()).thenReturn(Constants.regtest()); + when(fedNodeSystemProperties.getPegoutSignedCacheTtl()) + .thenReturn(PEGOUT_SIGNED_CACHE_TTL); ECPublicKey signerPublicKey = new ECPublicKey(federator1PrivKey.getPubKey()); ECDSASigner signer = mock(ECDSASigner.class); @@ -678,6 +922,8 @@ void validateTxCanBeSigned_federationCantSign() throws Exception { FedNodeSystemProperties fedNodeSystemProperties = mock(FedNodeSystemProperties.class); when(fedNodeSystemProperties.getNetworkConstants()).thenReturn(Constants.regtest()); + when(fedNodeSystemProperties.getPegoutSignedCacheTtl()) + .thenReturn(PEGOUT_SIGNED_CACHE_TTL); BtcECKey fed1Key = federation.getBtcPublicKeys().get(0); ECPublicKey signerPublicKey = new ECPublicKey(fed1Key.getPubKey()); @@ -739,6 +985,8 @@ void removeSignaturesFromTransaction() { FedNodeSystemProperties fedNodeSystemProperties = mock(FedNodeSystemProperties.class); when(fedNodeSystemProperties.getNetworkConstants()).thenReturn(Constants.regtest()); + when(fedNodeSystemProperties.getPegoutSignedCacheTtl()) + .thenReturn(PEGOUT_SIGNED_CACHE_TTL); BtcReleaseClient client = new BtcReleaseClient( mock(Ethereum.class), @@ -815,6 +1063,8 @@ private void test_validateTxCanBeSigned( ) throws Exception { FedNodeSystemProperties fedNodeSystemProperties = mock(FedNodeSystemProperties.class); when(fedNodeSystemProperties.getNetworkConstants()).thenReturn(Constants.regtest()); + when(fedNodeSystemProperties.getPegoutSignedCacheTtl()) + .thenReturn(PEGOUT_SIGNED_CACHE_TTL); ECDSASigner signer = mock(ECDSASigner.class); doReturn(signerPublicKey).when(signer).getPublicKey(any(KeyId.class)); @@ -846,6 +1096,8 @@ private void test_extractStandardRedeemScript( { FedNodeSystemProperties fedNodeSystemProperties = mock(FedNodeSystemProperties.class); when(fedNodeSystemProperties.getNetworkConstants()).thenReturn(Constants.regtest()); + when(fedNodeSystemProperties.getPegoutSignedCacheTtl()) + .thenReturn(PEGOUT_SIGNED_CACHE_TTL); BtcReleaseClient client = new BtcReleaseClient( mock(Ethereum.class), @@ -917,6 +1169,8 @@ private void testUsageOfStorageWhenSigning(boolean shouldHaveDataInFile) FedNodeSystemProperties fedNodeSystemProperties = mock(FedNodeSystemProperties.class); when(fedNodeSystemProperties.getNetworkConstants()).thenReturn(Constants.regtest()); when(fedNodeSystemProperties.isPegoutEnabled()).thenReturn(true); + when(fedNodeSystemProperties.getPegoutSignedCacheTtl()) + .thenReturn(PEGOUT_SIGNED_CACHE_TTL); NodeBlockProcessor nodeBlockProcessor = mock(NodeBlockProcessor.class); when(nodeBlockProcessor.hasBetterBlockToSync()).thenReturn(false); @@ -1022,6 +1276,8 @@ private BtcReleaseClient createBtcClient() { FedNodeSystemProperties fedNodeSystemProperties = mock(FedNodeSystemProperties.class); when(fedNodeSystemProperties.getNetworkConstants()).thenReturn(Constants.regtest()); when(fedNodeSystemProperties.isPegoutEnabled()).thenReturn(true); // Enabled by default + when(fedNodeSystemProperties.getPegoutSignedCacheTtl()) + .thenReturn(PEGOUT_SIGNED_CACHE_TTL); return new BtcReleaseClient( mock(Ethereum.class), diff --git a/src/test/java/co/rsk/federate/btcreleaseclient/cache/PegoutSignedCacheImplTest.java b/src/test/java/co/rsk/federate/btcreleaseclient/cache/PegoutSignedCacheImplTest.java new file mode 100644 index 000000000..76b2a9b3a --- /dev/null +++ b/src/test/java/co/rsk/federate/btcreleaseclient/cache/PegoutSignedCacheImplTest.java @@ -0,0 +1,151 @@ +package co.rsk.federate.btcreleaseclient.cache; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import co.rsk.crypto.Keccak256; +import co.rsk.federate.signing.utils.TestUtils; +import java.lang.reflect.Field; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +class PegoutSignedCacheImplTest { + + private static final Duration DEFAULT_TTL = Duration.ofMinutes(30); + private static final Keccak256 PEGOUT_CREATION_RSK_HASH = TestUtils.createHash(1); + + private final Map cache = new ConcurrentHashMap<>(); + private final Clock clock = Clock.fixed(Instant.ofEpochMilli(0), ZoneId.systemDefault()); + private final PegoutSignedCache pegoutSignedCache = new PegoutSignedCacheImpl(DEFAULT_TTL, clock); + + @BeforeEach + void setUp() throws Exception { + Field field = pegoutSignedCache.getClass().getDeclaredField("cache"); + field.setAccessible(true); + field.set(pegoutSignedCache, cache); + } + + @ParameterizedTest + @NullSource + @ValueSource(longs = { -10, 0 }) + void constructor_shouldThrowIllegalArgumentException_whenTtlIsInvalid(Long ttl) { + Duration invalidTtl = ttl != null ? Duration.ofMinutes(ttl) : null; + String expectedErrorMessage = String.format( + "Invalid pegouts signed cache TTL value in minutes supplied: %s", ttl); + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> new PegoutSignedCacheImpl(invalidTtl, clock)); + assertEquals(expectedErrorMessage, exception.getMessage()); + } + + @Test + void hasAlreadyBeenSigned_shouldReturnFalse_whenPegoutCreationRskTxHashIsNull() { + Keccak256 pegoutCreationRskTxHash = null; + + boolean result = pegoutSignedCache.hasAlreadyBeenSigned(pegoutCreationRskTxHash); + + assertFalse(result); + } + + @Test + void hasAlreadyBeenSigned_shouldReturnFalse_whenPegoutCreationRskTxHashIsNotCached() { + boolean result = pegoutSignedCache.hasAlreadyBeenSigned(PEGOUT_CREATION_RSK_HASH); + + assertFalse(result); + } + + @Test + void hasAlreadyBeenSigned_shouldReturnFalse_whenCacheContainsInvalidTimestamp() { + Instant currentTimestamp = clock.instant(); + Instant invalidTimestamp = currentTimestamp.minus(60, ChronoUnit.MINUTES); + cache.put(PEGOUT_CREATION_RSK_HASH, invalidTimestamp); + + boolean result = pegoutSignedCache.hasAlreadyBeenSigned(PEGOUT_CREATION_RSK_HASH); + + assertFalse(result); + } + + @Test + void hasAlreadyBeenSigned_shouldReturnTrue_whenCacheContainsValidTimestamp() { + Instant currentTimestamp = clock.instant(); + Instant validTimestamp = currentTimestamp.minus(10, ChronoUnit.MINUTES); + cache.put(PEGOUT_CREATION_RSK_HASH, validTimestamp); + + boolean result = pegoutSignedCache.hasAlreadyBeenSigned(PEGOUT_CREATION_RSK_HASH); + + assertTrue(result); + } + + @Test + void putIfAbsent_shouldThrowIllegalArgumentException_whenPegoutCreationRskTxHashIsNull() { + Keccak256 pegoutCreationRskTxHash = null; + + assertThrows(IllegalArgumentException.class, + () -> pegoutSignedCache.putIfAbsent(pegoutCreationRskTxHash)); + assertEquals(0, cache.size()); + } + + @Test + void putIfAbsent_shouldPutInCache_whenPegoutCreationRskTxHashIsNotNull() { + pegoutSignedCache.putIfAbsent(PEGOUT_CREATION_RSK_HASH); + + assertEquals(1, cache.size()); + assertTrue(cache.containsKey(PEGOUT_CREATION_RSK_HASH)); + } + + @Test + void putIfAbsent_shouldPutInCacheBoth_whenPegoutCreationRskTxHashAreNotSame() { + // first insert + pegoutSignedCache.putIfAbsent(PEGOUT_CREATION_RSK_HASH); + // second insert + Keccak256 otherPegoutCreationRskTxHash = TestUtils.createHash(2); + pegoutSignedCache.putIfAbsent(otherPegoutCreationRskTxHash); + + assertEquals(2, cache.size()); + } + + @Test + void putIfAbsent_shouldPutInCacheOnce_whenPegoutCreationRskTxHashIsTheSame() { + // first insert + pegoutSignedCache.putIfAbsent(PEGOUT_CREATION_RSK_HASH); + // second insert + pegoutSignedCache.putIfAbsent(PEGOUT_CREATION_RSK_HASH); + + assertEquals(1, cache.size()); + } + + @Test + void performCleanup_shouldRemoveOnlyInvalidPegouts_whenPerformCleanupIsTriggered() throws Exception { + // setup cache + PegoutSignedCacheImpl pegoutSignedCacheImpl = new PegoutSignedCacheImpl(DEFAULT_TTL, clock); + Field field = pegoutSignedCacheImpl.getClass().getDeclaredField("cache"); + field.setAccessible(true); + field.set(pegoutSignedCacheImpl, cache); + + // put a valid and invalid timestamp in the cache + Instant currentTimestamp = clock.instant(); + Instant validTimestamp = currentTimestamp.minus(10, ChronoUnit.MINUTES); + Instant notValidTimestamp = currentTimestamp.minus(60, ChronoUnit.MINUTES); + Keccak256 otherPegoutCreationRskHash = TestUtils.createHash(2); + cache.put(PEGOUT_CREATION_RSK_HASH, validTimestamp); + cache.put(otherPegoutCreationRskHash, notValidTimestamp); + + // trigger cleanup + pegoutSignedCacheImpl.performCleanup(); + + assertEquals(1, cache.size()); + assertTrue(cache.containsKey(PEGOUT_CREATION_RSK_HASH)); + } +}