nimbus-eth1/nimbus/sync/protocol/snap1.nim

343 lines
15 KiB
Nim

# Nimbus - Ethereum Snap Protocol (SNAP), version 1
#
# Copyright (c) 2021 Status Research & Development GmbH
# Licensed under either of
# * Apache License, version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or
# http://www.apache.org/licenses/LICENSE-2.0)
# * MIT license ([LICENSE-MIT](LICENSE-MIT) or
# http://opensource.org/licenses/MIT)
# at your option. This file may not be copied, modified, or distributed
# except according to those terms.
## This module implements `snap/1`, the `Ethereum Snapshot Protocol (SNAP)
## <https://github.com/ethereum/devp2p/blob/master/caps/snap.md>`_.
##
## Modified `GetStorageRanges` (0x02) message syntax
## -------------------------------------------------
## As implementes here, the request message is encoded as
##
## `[reqID, rootHash, accountHashes, origin, limit, responseBytes]`
##
## It requests the storage slots of multiple accounts' storage tries. Since
## certain contracts have huge state, the method can also request storage
## slots from a single account, starting at a specific storage key hash.
## The intended purpose of this message is to fetch a large number of
## subsequent storage slots from a remote node and reconstruct a state
## subtrie locally.
##
## * `reqID`: Request ID to match up responses with
## * `rootHash`: 32 byte root hash of the account trie to serve
## * `accountHashes`: Array of 32 byte account hashes of the storage tries to serve
## * `origin`: Storage slot hash fragment of the first to retrieve (see below)
## * `limit`: Storage slot hash fragment after which to stop serving (see below)
## * `responseBytes`: 64 bit number soft limit at which to stop returning data
##
## Discussion of *Geth* `GetStorageRanges` behaviour
## ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
## - Parameters `origin` and `limit` may each be empty blobs, which mean "all
## zeros" (0x00000...) or "no limit" (0xfffff...) respectively.
##
## (Blobs shorter than 32 bytes can also be given, and they are extended with
## zero bytes; longer than 32 bytes can be given and are truncated, but this
## is *Geth* being too accepting, and shouldn't be used.)
##
## - In the `slots` reply, the last account's storage list may be empty even if
## that account has non-empty storage.
##
## This happens when the bytes threshold is reached just after finishing
## storage for the previous account, or when `origin` is greater than the
## first account's last storage slot. When either of these happens, `proof`
## is non-empty. In the case of `origin` zero or empty, the non-empty proof
## only contains the left-side boundary proof, because it meets the condition
## for omitting the right-side proof described in the next point.
##
## - In the `proof` reply, the right-side boundary proof is only included if
## the last returned storage slot has non-zero path and `origin != 0`, or if
## the result stops due to reaching the bytes threshold.
##
## Because there's only one proof anyway if left-side and right-side are the
## same path, this works out to mean the right-side proof is omitted in cases
## where `origin == 0` and the result stops at a slot `>= limit` before
## reaching the bytes threshold.
##
## Although the specification doesn't say anything about `limit`, this is
## against the spirit of the specification rule, which says the right-side
## proof is always included if the last returned path differs from the
## starting hash.
##
## The omitted right-side proof can cause problems when using `limit`.
## In other words, when doing range queries, or merging results from
## pipelining where different `stateRoot` hashes are used as time progresses.
## Workarounds:
##
## * Fetch the proof using a second `GetStorageRanges` query with non-zero
## `origin` (perhaps equal to `limit`; use `origin = 1` if `limit == 0`).
##
## * Avoid the condition by using `origin >= 1` when using `limit`.
##
## * Use trie node traversal (`snap` `GetTrieNodes`) to obtain the omitted proof.
##
## - When multiple accounts are requested with `origin > 0`, only one account's
## storage is returned. There is no point requesting multiple accounts with
## `origin > 0`. (It might be useful if it treated `origin` as applying to
## only the first account, but it doesn't.)
##
## - When multiple accounts are requested with non-default `limit` and
## `origin == 0`, and the first account result stops at a slot `>= limit`
## before reaching the bytes threshold, storage for the other accounts in the
## request are returned as well. The other accounts are not limited by
## `limit`, only the bytes threshold. The right-side proof is omitted from
## `proof` when this happens, because this is the same condition as described
## earlier for omitting the right-side proof. (It might be useful if it
## treated `origin` as applying to only the first account and `limit` to only
## the last account, but it doesn't.)
##
##
## Performance benefits
## --------------------
## `snap` is used for much higher performance transfer of the entire Ethereum
## execution state (accounts, storage, bytecode) compared with hexary trie
## traversal using the now obsolete `eth/66` `GetNodeData`.
##
## It improves both network and local storage performance. The benefits are
## substantial, and summarised here:
##
## - `Ethereum Snapshot Protocol (SNAP) - Expected results
## <https://github.com/ethereum/devp2p/blob/master/caps/snap.md>`_
## - `Geth v1.10.0 - Snap sync
## <https://blog.ethereum.org/2021/03/03/geth-v1-10-0/#snap-sync>`_
##
## In the Snap sync model, local storage benefits require clients to adopt a
## different representation of Ethereum state than the trie storage that *Geth*
## (and most clients) traditionally used, and still do in archive mode,
##
## However, Nimbus's sync method obtains similar local storage benefits
## whichever network protocol is used. Nimbus uses `snap` protocol because it
## is a more efficient network protocol.
##
## Distributed hash table (DHT) building block
## -------------------------------------------
## Although `snap` was designed for bootstrapping clients with the entire
## Ethereum state, it is well suited to fetching only a subset of path ranges.
## This may be useful for bootstrapping distributed hash tables (DHTs).
##
## Path range metadata benefits
## ----------------------------
## Because data is handled in path ranges, this allows a compact metadata
## representation of what data is stored locally and what isn't, compared with
## the size of a representation of partially completed trie traversal with
## `eth` `GetNodeData`. Due to the smaller metadata, after aborting a partial
## sync and restarting, it is possible to resume quickly, without waiting for
## the very slow local database scan associated with older versions of *Geth*.
##
## However, Nimbus's sync method uses this principle as inspiration to
## obtain similar metadata benefits whichever network protocol is used.
import
std/options,
chronicles,
chronos,
eth/[common/eth_types, p2p, p2p/private/p2p_types],
nimcrypto/hash,
stew/byteutils,
../../constants,
./trace_config
logScope:
topics = "datax"
type
SnapAccount* = object
accHash*: Hash256
accBody* {.rlpCustomSerialization.}: Account
SnapAccountProof* = seq[Blob]
SnapStorage* = object
slotHash*: Hash256
slotData*: Blob
SnapStorageProof* = seq[Blob]
const
snapVersion* = 1
prettySnapProtoName* = "[snap/" & $snapVersion & "]"
# Pickeled tracer texts
trSnapRecvReceived* =
"<< " & prettySnapProtoName & " Received "
trSnapRecvProtocolViolation* =
"<< " & prettySnapProtoName & " Protocol violation, "
trSnapRecvError* =
"<< " & prettySnapProtoName & " Error "
trSnapRecvTimeoutWaiting* =
"<< " & prettySnapProtoName & " Timeout waiting "
trSnapSendSending* =
">> " & prettySnapProtoName & " Sending "
trSnapSendReplying* =
">> " & prettySnapProtoName & " Replying "
# The `snap` protocol represents `Account` differently from the regular RLP
# serialisation used in `eth` protocol as well as the canonical Merkle hash
# over all accounts. In `snap`, empty storage hash and empty code hash are
# each represented by an RLP zero-length string instead of the full hash. This
# avoids transmitting these hashes in about 90% of accounts. We need to
# recognise or set these hashes in `Account` when serialising RLP for `snap`.
proc snapRead*(rlp: var Rlp; T: type Account; strict: static[bool] = false): T
{.gcsafe, raises: [Defect, RlpError]} =
## RLP decoding for `Account`. The `snap` RLP representation of the account
## differs from standard `Account` RLP. Empty storage hash and empty code
## hash are each represented by an RLP zero-length string instead of the
## full hash.
##
## Normally, this read function will silently handle standard encodinig and
## `snap` enciding. Setting the argument strict as `false` the function will
## throw an exception if `snap` encoding is violated.
rlp.tryEnterList()
result.nonce = rlp.read(typeof(result.nonce))
result.balance = rlp.read(typeof(result.balance))
if rlp.blobLen != 0 or not rlp.isBlob:
result.storageRoot = rlp.read(typeof(result.storageRoot))
when strict:
if result.storageRoot == BLANK_ROOT_HASH:
raise newException(RlpTypeMismatch,
"BLANK_ROOT_HASH not encoded as empty string in Snap protocol")
else:
rlp.skipElem()
result.storageRoot = BLANK_ROOT_HASH
if rlp.blobLen != 0 or not rlp.isBlob:
result.codeHash = rlp.read(typeof(result.codeHash))
when strict:
if result.codeHash == EMPTY_SHA3:
raise newException(RlpTypeMismatch,
"EMPTY_SHA3 not encoded as empty string in Snap protocol")
else:
rlp.skipElem()
result.codeHash = EMPTY_SHA3
proc snapAppend*(writer: var RlpWriter; account: Account) =
## RLP encoding for `Account`. The snap RLP representation of the account
## differs from standard `Account` RLP. Empty storage hash and empty code
## hash are each represented by an RLP zero-length string instead of the
## full hash.
writer.startList(4)
writer.append(account.nonce)
writer.append(account.balance)
if account.storageRoot == BLANK_ROOT_HASH:
writer.append("")
else:
writer.append(account.storageRoot)
if account.codeHash == EMPTY_SHA3:
writer.append("")
else:
writer.append(account.codeHash)
proc read(rlp: var Rlp, t: var SnapAccount, T: type Account): T =
## RLP Mixin: decoding for `SnapAccount`.
result = rlp.snapRead(T)
proc append(rlpWriter: var RlpWriter, t: SnapAccount, account: Account) =
## RLP Mixin: encoding for `SnapAccount`.
rlpWriter.snapAppend(account)
p2pProtocol snap1(version = 1,
rlpxName = "snap",
useRequestIds = true):
requestResponse:
# User message 0x00: GetAccountRange.
# Note: `origin` and `limit` differs from the specification to match Geth.
proc getAccountRange(peer: Peer, rootHash: Hash256, origin: Hash256,
limit: Hash256, responseBytes: uint64) =
trace trSnapRecvReceived & "GetAccountRange (0x00)", peer,
accountRange=(origin,limit), stateRoot=($rootHash), responseBytes
trace trSnapSendReplying & "EMPTY AccountRange (0x01)", peer, sent=0
await response.send(@[], @[])
# User message 0x01: AccountRange.
proc accountRange(peer: Peer, accounts: seq[SnapAccount],
proof: SnapAccountProof)
requestResponse:
# User message 0x02: GetStorageRanges.
# Note: `origin` and `limit` differs from the specification to match Geth.
proc getStorageRanges(peer: Peer, rootHash: Hash256,
accounts: openArray[Hash256], origin: openArray[byte],
limit: openArray[byte], responseBytes: uint64) =
when trSnapTracePacketsOk:
var definiteFullRange = ((origin.len == 32 or origin.len == 0) and
(limit.len == 32 or limit.len == 0))
if definiteFullRange:
for i in 0 ..< origin.len:
if origin[i] != 0x00:
definiteFullRange = false
break
if definiteFullRange:
for i in 0 ..< limit.len:
if limit[i] != 0xff:
definiteFullRange = false
break
template describe(value: openArray[byte]): string =
if value.len == 0: "(empty)"
elif value.len == 32: value.toHex
else: "(non-standard-len=" & $value.len & ')' & value.toHex
if definiteFullRange:
# Fetching storage for multiple accounts.
trace trSnapRecvReceived & "GetStorageRanges/A (0x02)", peer,
accountPaths=accounts.len,
stateRoot=($rootHash), responseBytes
elif accounts.len == 1:
# Fetching partial storage for one account, aka. "large contract".
trace trSnapRecvReceived & "GetStorageRanges/S (0x02)", peer,
accountPaths=1,
storageRange=(describe(origin) & '-' & describe(limit)),
stateRoot=($rootHash), responseBytes
else:
# This branch is separated because these shouldn't occur. It's not
# really specified what happens when there are multiple accounts and
# non-default path range.
trace trSnapRecvReceived & "GetStorageRanges/AS?? (0x02)", peer,
accountPaths=accounts.len,
storageRange=(describe(origin) & '-' & describe(limit)),
stateRoot=($rootHash), responseBytes
trace trSnapSendReplying & "EMPTY StorageRanges (0x03)", peer, sent=0
await response.send(@[], @[])
# User message 0x03: StorageRanges.
# Note: See comments in this file for a list of Geth quirks to expect.
proc storageRanges(peer: Peer, slots: openArray[seq[SnapStorage]],
proof: SnapStorageProof)
# User message 0x04: GetByteCodes.
requestResponse:
proc getByteCodes(peer: Peer, nodeHashes: openArray[Hash256],
responseBytes: uint64) =
trace trSnapRecvReceived & "GetByteCodes (0x04)", peer,
hashes=nodeHashes.len, responseBytes
trace trSnapSendReplying & "EMPTY ByteCodes (0x05)", peer, sent=0
await response.send(@[])
# User message 0x05: ByteCodes.
proc byteCodes(peer: Peer, codes: openArray[Blob])
# User message 0x06: GetTrieNodes.
requestResponse:
proc getTrieNodes(peer: Peer, rootHash: Hash256,
paths: openArray[seq[Blob]], responseBytes: uint64) =
trace trSnapRecvReceived & "GetTrieNodes (0x06)", peer,
nodePaths=paths.len, stateRoot=($rootHash), responseBytes
trace trSnapSendReplying & "EMPTY TrieNodes (0x07)", peer, sent=0
await response.send(@[])
# User message 0x07: TrieNodes.
proc trieNodes(peer: Peer, nodes: openArray[Blob])