diff --git a/ethereum/core/src/main/java/org/hyperledger/besu/ethereum/bonsai/storage/BonsaiWorldStateKeyValueStorage.java b/ethereum/core/src/main/java/org/hyperledger/besu/ethereum/bonsai/storage/BonsaiWorldStateKeyValueStorage.java index 4f6c13212f9..898f9515ac3 100644 --- a/ethereum/core/src/main/java/org/hyperledger/besu/ethereum/bonsai/storage/BonsaiWorldStateKeyValueStorage.java +++ b/ethereum/core/src/main/java/org/hyperledger/besu/ethereum/bonsai/storage/BonsaiWorldStateKeyValueStorage.java @@ -29,6 +29,7 @@ import org.hyperledger.besu.ethereum.trie.MerkleTrie; import org.hyperledger.besu.ethereum.worldstate.DataStorageFormat; import org.hyperledger.besu.ethereum.worldstate.FlatDbMode; +import org.hyperledger.besu.ethereum.worldstate.FlatWorldStateStorage; import org.hyperledger.besu.ethereum.worldstate.StateTrieAccountValue; import org.hyperledger.besu.ethereum.worldstate.WorldStateStorage; import org.hyperledger.besu.evm.account.AccountStorageEntry; @@ -53,7 +54,7 @@ import org.slf4j.LoggerFactory; @SuppressWarnings("unused") -public class BonsaiWorldStateKeyValueStorage implements WorldStateStorage, AutoCloseable { +public class BonsaiWorldStateKeyValueStorage implements WorldStateStorage, FlatWorldStateStorage, AutoCloseable { private static final Logger LOG = LoggerFactory.getLogger(BonsaiWorldStateKeyValueStorage.class); // 0x776f726c64526f6f74 @@ -131,6 +132,7 @@ public FlatDbMode deriveFlatDbStrategy() { return flatDbMode; } + @Override public FlatDbStrategy getFlatDbStrategy() { if (flatDbStrategy == null) { loadFlatDbStrategy(); diff --git a/ethereum/core/src/main/java/org/hyperledger/besu/ethereum/worldstate/FlatWorldStateStorage.java b/ethereum/core/src/main/java/org/hyperledger/besu/ethereum/worldstate/FlatWorldStateStorage.java new file mode 100644 index 00000000000..8464f032152 --- /dev/null +++ b/ethereum/core/src/main/java/org/hyperledger/besu/ethereum/worldstate/FlatWorldStateStorage.java @@ -0,0 +1,25 @@ +/* + * Copyright Hyperledger Besu Contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.ethereum.worldstate; + +import org.hyperledger.besu.ethereum.bonsai.storage.flat.FlatDbStrategy; + +public interface FlatWorldStateStorage { + + FlatDbMode getFlatDbMode(); + + FlatDbStrategy getFlatDbStrategy(); + +} diff --git a/ethereum/eth/src/main/java/org/hyperledger/besu/ethereum/eth/manager/snap/SnapServer.java b/ethereum/eth/src/main/java/org/hyperledger/besu/ethereum/eth/manager/snap/SnapServer.java index 794c780c069..8984fd095a4 100644 --- a/ethereum/eth/src/main/java/org/hyperledger/besu/ethereum/eth/manager/snap/SnapServer.java +++ b/ethereum/eth/src/main/java/org/hyperledger/besu/ethereum/eth/manager/snap/SnapServer.java @@ -14,8 +14,11 @@ */ package org.hyperledger.besu.ethereum.eth.manager.snap; +import kotlin.collections.ArrayDeque; +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.apache.tuweni.units.bigints.UInt256; import org.hyperledger.besu.datatypes.Hash; -import org.hyperledger.besu.ethereum.bonsai.storage.flat.FlatDbReaderStrategy; import org.hyperledger.besu.ethereum.eth.manager.EthMessages; import org.hyperledger.besu.ethereum.eth.messages.snap.AccountRangeMessage; import org.hyperledger.besu.ethereum.eth.messages.snap.ByteCodesMessage; @@ -27,33 +30,39 @@ import org.hyperledger.besu.ethereum.proof.WorldStateProofProvider; import org.hyperledger.besu.ethereum.worldstate.WorldStateArchive; import org.hyperledger.besu.ethereum.worldstate.WorldStateStorage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.util.HashMap; +import java.util.NavigableMap; +import java.util.Optional; import java.util.function.Function; -import kotlin.collections.ArrayDeque; -import org.apache.tuweni.bytes.Bytes; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** TODO: See https://github.com/ethereum/devp2p/blob/master/caps/snap.md */ +/** See https://github.com/ethereum/devp2p/blob/master/caps/snap.md */ @SuppressWarnings("unused") class SnapServer { private static final Logger LOGGER = LoggerFactory.getLogger(SnapServer.class); private static final int MAX_ENTRIES_PER_REQUEST = 100000; - private static final int MAX_RESPONSE_SIZE = 2 * 1024 * 1024; + private static final AccountRangeMessage EMPTY_ACCOUNT_RANGE = AccountRangeMessage.create( + new HashMap<>(), new ArrayDeque<>()); + private final EthMessages snapMessages; - private final FlatDbReaderStrategy flatDbStrategy; - private final Function worldStateStorageProvider; + private final Function> worldStateStorageProvider; SnapServer(final EthMessages snapMessages, final WorldStateArchive archive) { this.snapMessages = snapMessages; - // TODO get these from worldstate archive: - this.flatDbStrategy = null; + // TODO implement worldstate storage retrieval by root hash in WorldStateArchive: this.worldStateStorageProvider = __ -> null; } + SnapServer( + final EthMessages snapMessages, + final Function> worldStateStorageProvider) { + this.snapMessages = snapMessages; + this.worldStateStorageProvider = worldStateStorageProvider; + } + private void registerResponseConstructors() { snapMessages.registerResponseConstructor( SnapV1.GET_ACCOUNT_RANGE, messageData -> constructGetAccountRangeResponse(messageData)); @@ -65,42 +74,41 @@ private void registerResponseConstructors() { SnapV1.GET_TRIE_NODES, messageData -> constructGetTrieNodesResponse(messageData)); } - private MessageData constructGetAccountRangeResponse(final MessageData message) { - if (flatDbStrategy == null) { - // TODO, remove this empty fallback - return AccountRangeMessage.create(new HashMap<>(), new ArrayDeque<>()); - } - + MessageData constructGetAccountRangeResponse(final MessageData message) { final GetAccountRangeMessage getAccountRangeMessage = GetAccountRangeMessage.readFrom(message); final GetAccountRangeMessage.Range range = getAccountRangeMessage.range(true); final int maxResponseBytes = Math.min(range.responseBytes().intValue(), MAX_RESPONSE_SIZE); - LOGGER.info( - "Receive get account range message from {} to {}", - range.startKeyHash().toHexString(), - range.endKeyHash().toHexString()); - - final var storage = - worldStateStorageProvider.apply(getAccountRangeMessage.getRootHash().orElseThrow()); - - final var accounts = - storage.streamFlatAccounts( - range.startKeyHash(), range.endKeyHash(), MAX_ENTRIES_PER_REQUEST); - // TODO if accounts is empty we need to return the first hash after the requested endHash - - final var worldStateProof = new WorldStateProofProvider(storage); - final ArrayDeque proof = - new ArrayDeque<>( - worldStateProof.getAccountProofRelatedNodes( - range.worldStateRootHash(), Hash.wrap(range.startKeyHash()))); - if (!accounts.isEmpty()) { - proof.addAll( - worldStateProof.getAccountProofRelatedNodes( - range.worldStateRootHash(), Hash.wrap(accounts.lastKey()))); - } - - return AccountRangeMessage.create(accounts, proof); + LOGGER.info("Receive get account range message from {} to {}", + range.startKeyHash().toHexString(), range.endKeyHash().toHexString()); + + var worldStateHash = getAccountRangeMessage + .range(true).worldStateRootHash(); + + return worldStateStorageProvider.apply(worldStateHash) + .map(storage -> { + NavigableMap + accounts = storage.streamFlatAccounts(range.startKeyHash(), range.endKeyHash(), + MAX_ENTRIES_PER_REQUEST); + + if (accounts.isEmpty()) { + // fetch next account after range, if it exists + accounts = storage.streamFlatAccounts(range.endKeyHash(), UInt256.MAX_VALUE, 1L); + } + + final var worldStateProof = new WorldStateProofProvider(storage); + final ArrayDeque proof = new ArrayDeque<>( + worldStateProof.getAccountProofRelatedNodes(range.worldStateRootHash(), + Hash.wrap(range.startKeyHash()))); + if (!accounts.isEmpty()) { + proof.addAll(worldStateProof.getAccountProofRelatedNodes(range.worldStateRootHash(), + Hash.wrap(accounts.lastKey()))); + } + return AccountRangeMessage.create(accounts, proof); + + }) + .orElse(EMPTY_ACCOUNT_RANGE); } private MessageData constructGetStorageRangeResponse(final MessageData message) { diff --git a/ethereum/eth/src/main/java/org/hyperledger/besu/ethereum/eth/messages/snap/GetAccountRangeMessage.java b/ethereum/eth/src/main/java/org/hyperledger/besu/ethereum/eth/messages/snap/GetAccountRangeMessage.java index 66aae554d48..8f5fcaf9a73 100644 --- a/ethereum/eth/src/main/java/org/hyperledger/besu/ethereum/eth/messages/snap/GetAccountRangeMessage.java +++ b/ethereum/eth/src/main/java/org/hyperledger/besu/ethereum/eth/messages/snap/GetAccountRangeMessage.java @@ -22,6 +22,7 @@ import org.hyperledger.besu.ethereum.rlp.RLPInput; import java.math.BigInteger; +import java.util.Optional; import org.apache.tuweni.bytes.Bytes; import org.apache.tuweni.bytes.Bytes32; @@ -95,6 +96,8 @@ public Range range(final boolean withRequestId) { @Value.Immutable public interface Range { + Optional requestId(); + Hash worldStateRootHash(); Hash startKeyHash(); diff --git a/ethereum/eth/src/test/java/org/hyperledger/besu/ethereum/eth/manager/snap/SnapServerTest.java b/ethereum/eth/src/test/java/org/hyperledger/besu/ethereum/eth/manager/snap/SnapServerTest.java new file mode 100644 index 00000000000..adbfeb3898c --- /dev/null +++ b/ethereum/eth/src/test/java/org/hyperledger/besu/ethereum/eth/manager/snap/SnapServerTest.java @@ -0,0 +1,169 @@ +/* + * Copyright Hyperledger Besu Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.ethereum.eth.manager.snap; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.hyperledger.besu.datatypes.Hash; +import org.hyperledger.besu.datatypes.Wei; +import org.hyperledger.besu.ethereum.bonsai.storage.BonsaiWorldStateKeyValueStorage; +import org.hyperledger.besu.ethereum.core.InMemoryKeyValueStorageProvider; +import org.hyperledger.besu.ethereum.eth.manager.EthMessages; +import org.hyperledger.besu.ethereum.eth.messages.snap.AccountRangeMessage; +import org.hyperledger.besu.ethereum.eth.messages.snap.GetAccountRangeMessage; +import org.hyperledger.besu.ethereum.proof.WorldStateProofProvider; +import org.hyperledger.besu.ethereum.rlp.RLP; +import org.hyperledger.besu.ethereum.storage.keyvalue.KeyValueStorageProvider; +import org.hyperledger.besu.ethereum.trie.patricia.StoredMerklePatriciaTrie; +import org.hyperledger.besu.ethereum.worldstate.StateTrieAccountValue; +import org.hyperledger.besu.metrics.ObservableMetricsSystem; +import org.hyperledger.besu.metrics.noop.NoOpMetricsSystem; +import org.junit.jupiter.api.Test; + +import java.math.BigInteger; +import java.util.Optional; +import java.util.Random; +import java.util.function.Function; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +public class SnapServerTest { + static Random rand = new Random(); + + record SnapTestAccount(Hash addressHash, StateTrieAccountValue accountValue){ + Bytes asRLP() { + return RLP.encode(accountValue::writeTo); + } + } + + static final ObservableMetricsSystem noopMetrics = new NoOpMetricsSystem(); + + + final static SnapTestAccount acct1 = testAcct("10"); + final static SnapTestAccount acct2 = testAcct("20"); + final static SnapTestAccount acct3 = testAcct("30"); + final static SnapTestAccount acct4 = testAcct("40"); + + final KeyValueStorageProvider storageProvider = new InMemoryKeyValueStorageProvider(); + final BonsaiWorldStateKeyValueStorage inMemoryStorage = + new BonsaiWorldStateKeyValueStorage(storageProvider, noopMetrics); + + final StoredMerklePatriciaTrie storageTrie = + new StoredMerklePatriciaTrie<>( + inMemoryStorage::getAccountStateTrieNode, + Function.identity(), + Function.identity()); + final WorldStateProofProvider proofProvider = new WorldStateProofProvider(inMemoryStorage); + + final SnapServer snapServer = new SnapServer(new EthMessages(), __ -> Optional.of(inMemoryStorage)); + + @Test + public void assertEmptyRangeLeftProofOfExclusionAndNextAccount() { + // for a range request that returns empty, we should return just a proof of exclusion on the left + // and the next account after the limit hash + insertTestAccounts(acct1, acct4); + + var rangeData = getAndVerifyAcountRangeData( + requestAccountRange(acct2.addressHash, acct3.addressHash), 1); + + // expect to find only one value acct4, outside the requested range + var outOfRangeVal = rangeData.accounts().entrySet().stream().findFirst(); + assertThat(outOfRangeVal).isPresent(); + assertThat(outOfRangeVal.get().getKey()).isEqualTo(acct4.addressHash()); + + // assert proofs are valid for the requested range + assertThat(assertIsValidRangeProof(acct2.addressHash, rangeData)).isTrue(); + } + + @Test + public void assertLastEmptyRange() { + // When our final range request is empty, no next account is possible, + // and we should return just a proof of exclusion of the right + insertTestAccounts(acct1, acct2); + var rangeData = getAndVerifyAcountRangeData( + requestAccountRange(acct3.addressHash, acct4.addressHash), 0); + + // assert proofs are valid for the requested range + assertThat(assertIsValidRangeProof(acct3.addressHash, rangeData)).isTrue(); + } + + @Test + public void assertAccountFoundAtStartHashProof() { + // account found at startHash + insertTestAccounts(acct1, acct2, acct3, acct4); + var rangeData = getAndVerifyAcountRangeData( + requestAccountRange(acct1.addressHash, acct4.addressHash), 4); + + // assert proofs are valid for requested range + assertThat(assertIsValidRangeProof(acct1.addressHash, rangeData)).isTrue(); + } + + + + + static SnapTestAccount testAcct(final String hexAddr) { + return new SnapTestAccount( + Hash.wrap(Bytes32.rightPad(Bytes.fromHexString(hexAddr))), + new StateTrieAccountValue( + rand.nextInt(0,1), + Wei.of(rand.nextLong(0L, 1L)), + Hash.EMPTY_TRIE_HASH, Hash.EMPTY)); + } + + void insertTestAccounts(final SnapTestAccount ... accounts) { + final var updater = inMemoryStorage.updater(); + for(SnapTestAccount account : accounts) { + updater.putAccountInfoState(account.addressHash(), account.asRLP()); + storageTrie.put(account.addressHash(), account.asRLP()); + } + storageTrie.commit(updater::putAccountStateTrieNode); + updater.commit(); + + } + + boolean assertIsValidRangeProof(final Hash startHash, final AccountRangeMessage.AccountRangeData accountRange) { + Bytes32 lastKey = accountRange.accounts() + .keySet() + .stream() + .reduce((first, second) -> second) + .orElse(startHash); + + return proofProvider + .isValidRangeProof( + acct2.addressHash, + lastKey, + storageTrie.getRootHash(), + accountRange.proofs(), + accountRange.accounts()); + } + + AccountRangeMessage requestAccountRange(final Hash startHash, final Hash limitHash) { + return (AccountRangeMessage) snapServer.constructGetAccountRangeResponse( + GetAccountRangeMessage.create( + Hash.wrap(storageTrie.getRootHash()), + startHash, + limitHash) + .wrapMessageData(BigInteger.ONE)); + } + + AccountRangeMessage.AccountRangeData getAndVerifyAcountRangeData( + final AccountRangeMessage range, final int expectedSize) { + assertThat(range).isNotNull(); + var accountData = range.accountData(false); + assertThat(accountData).isNotNull(); + assertThat(accountData.accounts().size()).isEqualTo(expectedSize); + return accountData; + } +}