mirror of
https://github.com/status-im/nimbus-eth1.git
synced 2025-01-24 11:11:59 +00:00
Move BlockHeaderWithProof content to content key selector 0 (#1379)
* Move BlockHeaderWithProof content to content key selector 0 - Remove as content type with content key selector 4 - Replace regular block header with BlockHeaderWithProof at content key selector 0 * Apply blockHeader content key also to bridge * Add tests for header with proof generation and verification
This commit is contained in:
parent
6fb48517ba
commit
12f66ae598
@ -5,7 +5,10 @@
|
|||||||
# * Apache v2 license (license terms in the root directory or at https://www.apache.org/licenses/LICENSE-2.0).
|
# * Apache v2 license (license terms in the root directory or at https://www.apache.org/licenses/LICENSE-2.0).
|
||||||
# at your option. This file may not be copied, modified, or distributed except according to those terms.
|
# at your option. This file may not be copied, modified, or distributed except according to those terms.
|
||||||
|
|
||||||
{.push raises: [Defect].}
|
when (NimMajor, NimMinor) < (1, 4):
|
||||||
|
{.push raises: [Defect].}
|
||||||
|
else:
|
||||||
|
{.push raises: [].}
|
||||||
|
|
||||||
import
|
import
|
||||||
json_serialization, json_serialization/std/tables,
|
json_serialization, json_serialization/std/tables,
|
||||||
@ -33,14 +36,16 @@ type
|
|||||||
|
|
||||||
BlockDataTable* = Table[string, BlockData]
|
BlockDataTable* = Table[string, BlockData]
|
||||||
|
|
||||||
|
proc toString(v: IoErrorCode): string =
|
||||||
|
try: ioErrorMsg(v)
|
||||||
|
except Exception as e: raiseAssert e.msg
|
||||||
|
|
||||||
proc readJsonType*(dataFile: string, T: type): Result[T, string] =
|
proc readJsonType*(dataFile: string, T: type): Result[T, string] =
|
||||||
let data = readAllFile(dataFile)
|
let data = ? readAllFile(dataFile).mapErr(toString)
|
||||||
if data.isErr(): # TODO: map errors
|
|
||||||
return err("Failed reading data-file")
|
|
||||||
|
|
||||||
let decoded =
|
let decoded =
|
||||||
try:
|
try:
|
||||||
Json.decode(data.get(), T)
|
Json.decode(data, T)
|
||||||
except SerializationError as e:
|
except SerializationError as e:
|
||||||
return err("Failed decoding json data-file: " & e.msg)
|
return err("Failed decoding json data-file: " & e.msg)
|
||||||
|
|
||||||
|
@ -181,8 +181,8 @@ proc historyPropagateHeadersWithProof*(
|
|||||||
let
|
let
|
||||||
content = headerWithProof.get()
|
content = headerWithProof.get()
|
||||||
contentKey = ContentKey(
|
contentKey = ContentKey(
|
||||||
contentType: blockHeaderWithProof,
|
contentType: blockHeader,
|
||||||
blockHeaderWithProofKey: BlockKey(blockHash: header.blockHash()))
|
blockHeaderKey: BlockKey(blockHash: header.blockHash()))
|
||||||
encKey = history_content.encode(contentKey)
|
encKey = history_content.encode(contentKey)
|
||||||
contentId = history_content.toContentId(encKey)
|
contentId = history_content.toContentId(encKey)
|
||||||
encodedContent = SSZ.encode(content)
|
encodedContent = SSZ.encode(content)
|
||||||
|
@ -32,7 +32,6 @@ type
|
|||||||
blockBody = 0x01
|
blockBody = 0x01
|
||||||
receipts = 0x02
|
receipts = 0x02
|
||||||
epochAccumulator = 0x03
|
epochAccumulator = 0x03
|
||||||
blockHeaderWithProof = 0x04
|
|
||||||
|
|
||||||
BlockKey* = object
|
BlockKey* = object
|
||||||
blockHash*: BlockHash
|
blockHash*: BlockHash
|
||||||
@ -50,8 +49,6 @@ type
|
|||||||
receiptsKey*: BlockKey
|
receiptsKey*: BlockKey
|
||||||
of epochAccumulator:
|
of epochAccumulator:
|
||||||
epochAccumulatorKey*: EpochAccumulatorKey
|
epochAccumulatorKey*: EpochAccumulatorKey
|
||||||
of blockHeaderWithProof:
|
|
||||||
blockHeaderWithProofKey*: BlockKey
|
|
||||||
|
|
||||||
func init*(
|
func init*(
|
||||||
T: type ContentKey, contentType: ContentType,
|
T: type ContentKey, contentType: ContentType,
|
||||||
@ -70,10 +67,6 @@ func init*(
|
|||||||
ContentKey(
|
ContentKey(
|
||||||
contentType: contentType,
|
contentType: contentType,
|
||||||
epochAccumulatorKey: EpochAccumulatorKey(epochHash: hash))
|
epochAccumulatorKey: EpochAccumulatorKey(epochHash: hash))
|
||||||
of blockHeaderWithProof:
|
|
||||||
ContentKey(
|
|
||||||
contentType: contentType,
|
|
||||||
blockHeaderWithProofKey: BlockKey(blockHash: hash))
|
|
||||||
|
|
||||||
func encode*(contentKey: ContentKey): ByteList =
|
func encode*(contentKey: ContentKey): ByteList =
|
||||||
ByteList.init(SSZ.encode(contentKey))
|
ByteList.init(SSZ.encode(contentKey))
|
||||||
@ -111,8 +104,6 @@ func `$`*(x: ContentKey): string =
|
|||||||
of epochAccumulator:
|
of epochAccumulator:
|
||||||
let key = x.epochAccumulatorKey
|
let key = x.epochAccumulatorKey
|
||||||
res.add("epochHash: " & $key.epochHash)
|
res.add("epochHash: " & $key.epochHash)
|
||||||
of blockHeaderWithProof:
|
|
||||||
res.add($x.blockHeaderWithProofKey)
|
|
||||||
|
|
||||||
res.add(")")
|
res.add(")")
|
||||||
|
|
||||||
|
@ -260,7 +260,7 @@ proc getVerifiedBlockHeader*(
|
|||||||
n: HistoryNetwork, hash: BlockHash):
|
n: HistoryNetwork, hash: BlockHash):
|
||||||
Future[Opt[BlockHeader]] {.async.} =
|
Future[Opt[BlockHeader]] {.async.} =
|
||||||
let
|
let
|
||||||
contentKey = ContentKey.init(blockHeaderWithProof, hash).encode()
|
contentKey = ContentKey.init(blockHeader, hash).encode()
|
||||||
contentId = contentKey.toContentId()
|
contentId = contentKey.toContentId()
|
||||||
|
|
||||||
logScope:
|
logScope:
|
||||||
@ -310,49 +310,6 @@ proc getVerifiedBlockHeader*(
|
|||||||
# Headers were requested `requestRetries` times and all failed on validation
|
# Headers were requested `requestRetries` times and all failed on validation
|
||||||
return Opt.none(BlockHeader)
|
return Opt.none(BlockHeader)
|
||||||
|
|
||||||
# TODO: To be deprecated or not? Should there be the case for requesting a
|
|
||||||
# block header without proofs?
|
|
||||||
proc getBlockHeader*(
|
|
||||||
n: HistoryNetwork, hash: BlockHash):
|
|
||||||
Future[Opt[BlockHeader]] {.async.} =
|
|
||||||
let
|
|
||||||
contentKey = ContentKey.init(blockHeader, hash).encode()
|
|
||||||
contentId = contentKey.toContentId()
|
|
||||||
|
|
||||||
logScope:
|
|
||||||
hash
|
|
||||||
contentKey
|
|
||||||
|
|
||||||
let headerFromDb = n.getContentFromDb(BlockHeader, contentId)
|
|
||||||
if headerFromDb.isSome():
|
|
||||||
info "Fetched block header from database"
|
|
||||||
return headerFromDb
|
|
||||||
|
|
||||||
for i in 0..<requestRetries:
|
|
||||||
let
|
|
||||||
headerContent = (await n.portalProtocol.contentLookup(
|
|
||||||
contentKey, contentId)).valueOr:
|
|
||||||
warn "Failed fetching block header from the network"
|
|
||||||
return Opt.none(BlockHeader)
|
|
||||||
|
|
||||||
header = validateBlockHeaderBytes(headerContent.content, hash).valueOr:
|
|
||||||
warn "Validation of block header failed", error
|
|
||||||
continue
|
|
||||||
|
|
||||||
info "Fetched valid block header from the network"
|
|
||||||
# Content is valid, it can be stored and propagated to interested peers
|
|
||||||
n.portalProtocol.storeContent(contentKey, contentId, headerContent.content)
|
|
||||||
n.portalProtocol.triggerPoke(
|
|
||||||
headerContent.nodesInterestedInContent,
|
|
||||||
contentKey,
|
|
||||||
headerContent.content
|
|
||||||
)
|
|
||||||
|
|
||||||
return Opt.some(header)
|
|
||||||
|
|
||||||
# Headers were requested `requestRetries` times and all failed on validation
|
|
||||||
return Opt.none(BlockHeader)
|
|
||||||
|
|
||||||
proc getBlockBody*(
|
proc getBlockBody*(
|
||||||
n: HistoryNetwork, hash: BlockHash, header: BlockHeader):
|
n: HistoryNetwork, hash: BlockHash, header: BlockHeader):
|
||||||
Future[Opt[BlockBody]] {.async.} =
|
Future[Opt[BlockBody]] {.async.} =
|
||||||
@ -528,19 +485,20 @@ proc validateContent(
|
|||||||
|
|
||||||
case key.contentType:
|
case key.contentType:
|
||||||
of blockHeader:
|
of blockHeader:
|
||||||
# Note: For now we still accept regular block header type to remain
|
let
|
||||||
# compatible with the current specs. However, a verification is done by
|
headerWithProof = decodeSsz(content, BlockHeaderWithProof).valueOr:
|
||||||
# basically requesting the header with proofs from somewhere else.
|
warn "Failed decoding header with proof", error
|
||||||
# This all doesn't make much sense aside from compatibility and should
|
return false
|
||||||
# eventually be removed.
|
header = validateBlockHeaderBytes(
|
||||||
let header = validateBlockHeaderBytes(
|
headerWithProof.header.asSeq(),
|
||||||
content, key.blockHeaderKey.blockHash).valueOr:
|
key.blockHeaderKey.blockHash).valueOr:
|
||||||
warn "Invalid block header offered", error
|
warn "Invalid block header offered", error
|
||||||
return false
|
return false
|
||||||
|
|
||||||
let res = await n.getVerifiedBlockHeader(key.blockHeaderKey.blockHash)
|
let res = n.verifyHeader(header, headerWithProof.proof)
|
||||||
if res.isNone():
|
if res.isErr():
|
||||||
warn "Block header failed canonical verification"
|
warn "Failed on check if header is part of canonical chain",
|
||||||
|
error = res.error
|
||||||
return false
|
return false
|
||||||
else:
|
else:
|
||||||
return true
|
return true
|
||||||
@ -594,25 +552,6 @@ proc validateContent(
|
|||||||
else:
|
else:
|
||||||
return true
|
return true
|
||||||
|
|
||||||
of blockHeaderWithProof:
|
|
||||||
let
|
|
||||||
headerWithProof = decodeSsz(content, BlockHeaderWithProof).valueOr:
|
|
||||||
warn "Failed decoding header with proof", error
|
|
||||||
return false
|
|
||||||
header = validateBlockHeaderBytes(
|
|
||||||
headerWithProof.header.asSeq(),
|
|
||||||
key.blockHeaderWithProofKey.blockHash).valueOr:
|
|
||||||
warn "Invalid block header offered", error
|
|
||||||
return false
|
|
||||||
|
|
||||||
let res = n.verifyHeader(header, headerWithProof.proof)
|
|
||||||
if res.isErr():
|
|
||||||
warn "Failed on check if header is part of canonical chain",
|
|
||||||
error = res.error
|
|
||||||
return false
|
|
||||||
else:
|
|
||||||
return true
|
|
||||||
|
|
||||||
proc new*(
|
proc new*(
|
||||||
T: type HistoryNetwork,
|
T: type HistoryNetwork,
|
||||||
baseProtocol: protocol.Protocol,
|
baseProtocol: protocol.Protocol,
|
||||||
|
@ -19,7 +19,8 @@ import
|
|||||||
history_data_json_store,
|
history_data_json_store,
|
||||||
history_data_ssz_e2s],
|
history_data_ssz_e2s],
|
||||||
../network/history/[history_content, accumulator],
|
../network/history/[history_content, accumulator],
|
||||||
../seed_db
|
../seed_db,
|
||||||
|
../tests/test_history_util
|
||||||
|
|
||||||
type
|
type
|
||||||
FutureCallback[A] = proc (): Future[A] {.gcsafe, raises: [Defect].}
|
FutureCallback[A] = proc (): Future[A] {.gcsafe, raises: [Defect].}
|
||||||
@ -42,24 +43,6 @@ type
|
|||||||
desc: "Port of the JSON-RPC service of the bootstrap (first) node"
|
desc: "Port of the JSON-RPC service of the bootstrap (first) node"
|
||||||
name: "base-rpc-port" .}: uint16
|
name: "base-rpc-port" .}: uint16
|
||||||
|
|
||||||
proc buildHeadersWithProof*(
|
|
||||||
blockHeaders: seq[BlockHeader],
|
|
||||||
epochAccumulator: EpochAccumulatorCached):
|
|
||||||
Result[seq[(seq[byte], seq[byte])], string] =
|
|
||||||
var blockHeadersWithProof: seq[(seq[byte], seq[byte])]
|
|
||||||
for header in blockHeaders:
|
|
||||||
if header.isPreMerge():
|
|
||||||
let
|
|
||||||
content = ? buildHeaderWithProof(header, epochAccumulator)
|
|
||||||
contentKey = ContentKey(
|
|
||||||
contentType: blockHeaderWithProof,
|
|
||||||
blockHeaderWithProofKey: BlockKey(blockHash: header.blockHash()))
|
|
||||||
|
|
||||||
blockHeadersWithProof.add(
|
|
||||||
(encode(contentKey).asSeq(), SSZ.encode(content)))
|
|
||||||
|
|
||||||
ok(blockHeadersWithProof)
|
|
||||||
|
|
||||||
proc connectToRpcServers(config: PortalTestnetConf):
|
proc connectToRpcServers(config: PortalTestnetConf):
|
||||||
Future[seq[RpcClient]] {.async.} =
|
Future[seq[RpcClient]] {.async.} =
|
||||||
var clients: seq[RpcClient]
|
var clients: seq[RpcClient]
|
||||||
|
@ -10,6 +10,7 @@
|
|||||||
import
|
import
|
||||||
./test_portal_wire_encoding,
|
./test_portal_wire_encoding,
|
||||||
./test_history_content,
|
./test_history_content,
|
||||||
|
./test_headers_with_proof,
|
||||||
./test_header_content,
|
./test_header_content,
|
||||||
./test_state_content,
|
./test_state_content,
|
||||||
./test_accumulator_root
|
./test_accumulator_root
|
||||||
|
@ -0,0 +1,97 @@
|
|||||||
|
# Nimbus
|
||||||
|
# Copyright (c) 2023 Status Research & Development GmbH
|
||||||
|
# Licensed and distributed under either of
|
||||||
|
# * MIT license (license terms in the root directory or at https://opensource.org/licenses/MIT).
|
||||||
|
# * Apache v2 license (license terms in the root directory or at https://www.apache.org/licenses/LICENSE-2.0).
|
||||||
|
# at your option. This file may not be copied, modified, or distributed except according to those terms.
|
||||||
|
|
||||||
|
{.used.}
|
||||||
|
|
||||||
|
when (NimMajor, NimMinor) < (1, 4):
|
||||||
|
{.push raises: [Defect].}
|
||||||
|
else:
|
||||||
|
{.push raises: [].}
|
||||||
|
|
||||||
|
import
|
||||||
|
unittest2, stew/byteutils,
|
||||||
|
eth/common/eth_types_rlp,
|
||||||
|
../../../network_metadata,
|
||||||
|
../../../eth_data/[history_data_json_store, history_data_ssz_e2s],
|
||||||
|
../../../network/history/[history_content, history_network, accumulator],
|
||||||
|
../../test_history_util
|
||||||
|
|
||||||
|
type
|
||||||
|
JsonHeaderContent* = object
|
||||||
|
content_key*: string
|
||||||
|
content_value*: string
|
||||||
|
|
||||||
|
JsonHeaderContentTable* = Table[string, JsonHeaderContent]
|
||||||
|
|
||||||
|
suite "Headers with Proof":
|
||||||
|
test "HeaderWithProof Decoding and Verifying":
|
||||||
|
const dataFile =
|
||||||
|
"./vendor/portal-spec-tests/tests/mainnet/history/headers_with_proof/1000001-1000010.json"
|
||||||
|
|
||||||
|
let accumulator =
|
||||||
|
try:
|
||||||
|
SSZ.decode(finishedAccumulator, FinishedAccumulator)
|
||||||
|
except SszError as err:
|
||||||
|
raiseAssert "Invalid baked-in accumulator: " & err.msg
|
||||||
|
|
||||||
|
let res = readJsonType(dataFile, JsonHeaderContentTable)
|
||||||
|
check res.isOk()
|
||||||
|
let headerContent = res.get()
|
||||||
|
|
||||||
|
for k, v in headerContent:
|
||||||
|
let
|
||||||
|
# TODO: strange assignment failure when using try/except ValueError
|
||||||
|
# for the hexToSeqByte() here.
|
||||||
|
contentKey = decodeSsz(
|
||||||
|
v.content_key.hexToSeqByte(), ContentKey)
|
||||||
|
contentValue = decodeSsz(
|
||||||
|
v.content_value.hexToSeqByte(), BlockHeaderWithProof)
|
||||||
|
|
||||||
|
check:
|
||||||
|
contentKey.isOk()
|
||||||
|
contentValue.isOk()
|
||||||
|
|
||||||
|
let blockHeaderWithProof = contentValue.get()
|
||||||
|
|
||||||
|
let res = decodeRlp(blockHeaderWithProof.header.asSeq(), BlockHeader)
|
||||||
|
check res.isOk()
|
||||||
|
let header = res.get()
|
||||||
|
|
||||||
|
check accumulator.verifyHeader(header, blockHeaderWithProof.proof).isOk()
|
||||||
|
|
||||||
|
test "HeaderWithProof Building and Encoding":
|
||||||
|
const
|
||||||
|
headerFile = "./vendor/portal-spec-tests/tests/mainnet/history/headers/1000001-1000010.e2s"
|
||||||
|
accumulatorFile = "./vendor/portal-spec-tests/tests/mainnet/history/accumulator/epoch-accumulator-00122.ssz"
|
||||||
|
headersWithProofFile = "./vendor/portal-spec-tests/tests/mainnet/history/headers_with_proof/1000001-1000010.json"
|
||||||
|
|
||||||
|
let
|
||||||
|
blockHeaders = readBlockHeaders(headerFile).valueOr:
|
||||||
|
raiseAssert "Invalid header file: " & headerFile
|
||||||
|
epochAccumulator = readEpochAccumulatorCached(accumulatorFile).valueOr:
|
||||||
|
raiseAssert "Invalid epoch accumulator file: " & accumulatorFile
|
||||||
|
blockHeadersWithProof =
|
||||||
|
buildHeadersWithProof(blockHeaders, epochAccumulator).valueOr:
|
||||||
|
raiseAssert "Could not build headers with proof"
|
||||||
|
|
||||||
|
let res = readJsonType(headersWithProofFile, JsonHeaderContentTable)
|
||||||
|
check res.isOk()
|
||||||
|
let headerContent = res.get()
|
||||||
|
|
||||||
|
# Go over all content keys and headers with generated proofs and compare
|
||||||
|
# them with the ones from the test vectors.
|
||||||
|
for i, (headerContentKey, headerWithProof) in blockHeadersWithProof:
|
||||||
|
let
|
||||||
|
blockNumber = blockHeaders[i].blockNumber
|
||||||
|
contentKey =
|
||||||
|
headerContent[blockNumber.toString()].content_key.hexToSeqByte()
|
||||||
|
contentValue =
|
||||||
|
headerContent[blockNumber.toString()].content_value.hexToSeqByte()
|
||||||
|
|
||||||
|
check:
|
||||||
|
contentKey == headerContentKey
|
||||||
|
contentValue == headerWithProof
|
@ -70,7 +70,7 @@ proc headersToContentInfo(
|
|||||||
headerHash = header.blockHash()
|
headerHash = header.blockHash()
|
||||||
blockKey = BlockKey(blockHash: headerHash)
|
blockKey = BlockKey(blockHash: headerHash)
|
||||||
contentKey = encode(ContentKey(
|
contentKey = encode(ContentKey(
|
||||||
contentType: blockHeaderWithProof, blockHeaderWithProofKey: blockKey))
|
contentType: blockHeader, blockHeaderKey: blockKey))
|
||||||
contentInfo = ContentInfo(
|
contentInfo = ContentInfo(
|
||||||
contentKey: contentKey, content: SSZ.encode(headerWithProof))
|
contentKey: contentKey, content: SSZ.encode(headerWithProof))
|
||||||
contentInfos.add(contentInfo)
|
contentInfos.add(contentInfo)
|
||||||
@ -119,7 +119,7 @@ procSuite "History Content Network":
|
|||||||
headerHash = header.blockHash()
|
headerHash = header.blockHash()
|
||||||
blockKey = BlockKey(blockHash: headerHash)
|
blockKey = BlockKey(blockHash: headerHash)
|
||||||
contentKey = ContentKey(
|
contentKey = ContentKey(
|
||||||
contentType: blockHeaderWithProof, blockHeaderWithProofKey: blockKey)
|
contentType: blockHeader, blockHeaderKey: blockKey)
|
||||||
encKey = encode(contentKey)
|
encKey = encode(contentKey)
|
||||||
contentId = toContentId(contentKey)
|
contentId = toContentId(contentKey)
|
||||||
historyNode2.portalProtocol().storeContent(
|
historyNode2.portalProtocol().storeContent(
|
||||||
|
36
fluffy/tests/test_history_util.nim
Normal file
36
fluffy/tests/test_history_util.nim
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# Nimbus
|
||||||
|
# Copyright (c) 2023 Status Research & Development GmbH
|
||||||
|
# Licensed and distributed under either of
|
||||||
|
# * MIT license (license terms in the root directory or at https://opensource.org/licenses/MIT).
|
||||||
|
# * Apache v2 license (license terms in the root directory or at https://www.apache.org/licenses/LICENSE-2.0).
|
||||||
|
# at your option. This file may not be copied, modified, or distributed except according to those terms.
|
||||||
|
|
||||||
|
when (NimMajor, NimMinor) < (1, 4):
|
||||||
|
{.push raises: [Defect].}
|
||||||
|
else:
|
||||||
|
{.push raises: [].}
|
||||||
|
|
||||||
|
import
|
||||||
|
stew/results,
|
||||||
|
eth/common/eth_types_rlp,
|
||||||
|
../network/history/[history_content, accumulator]
|
||||||
|
|
||||||
|
export results, accumulator, history_content
|
||||||
|
|
||||||
|
proc buildHeadersWithProof*(
|
||||||
|
blockHeaders: seq[BlockHeader],
|
||||||
|
epochAccumulator: EpochAccumulatorCached):
|
||||||
|
Result[seq[(seq[byte], seq[byte])], string] =
|
||||||
|
var blockHeadersWithProof: seq[(seq[byte], seq[byte])]
|
||||||
|
for header in blockHeaders:
|
||||||
|
if header.isPreMerge():
|
||||||
|
let
|
||||||
|
content = ? buildHeaderWithProof(header, epochAccumulator)
|
||||||
|
contentKey = ContentKey(
|
||||||
|
contentType: blockHeader,
|
||||||
|
blockHeaderKey: BlockKey(blockHash: header.blockHash()))
|
||||||
|
|
||||||
|
blockHeadersWithProof.add(
|
||||||
|
(encode(contentKey).asSeq(), SSZ.encode(content)))
|
||||||
|
|
||||||
|
ok(blockHeadersWithProof)
|
@ -231,7 +231,7 @@ proc run() {.raises: [Exception, Defect].} =
|
|||||||
blockhash = history_content.`$`hash
|
blockhash = history_content.`$`hash
|
||||||
|
|
||||||
block: # gossip header
|
block: # gossip header
|
||||||
let contentKey = ContentKey.init(blockHeaderWithProof, hash)
|
let contentKey = ContentKey.init(blockHeader, hash)
|
||||||
let encodedContentKey = contentKey.encode.asSeq()
|
let encodedContentKey = contentKey.encode.asSeq()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
2
vendor/portal-spec-tests
vendored
2
vendor/portal-spec-tests
vendored
@ -1 +1 @@
|
|||||||
Subproject commit 5a1d2e553d97c04339b6227624d4ebab4da88701
|
Subproject commit ee863fecdc6ec9cc81effe355e74459cd5dda28d
|
Loading…
x
Reference in New Issue
Block a user