2021-06-04 17:20:37 +00:00
|
|
|
# Nimbus
|
2024-02-15 02:57:05 +00:00
|
|
|
# Copyright (c) 2022-2024 Status Research & Development GmbH
|
2021-06-04 17:20:37 +00:00
|
|
|
# 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.
|
|
|
|
|
|
|
|
import
|
2023-01-24 14:52:02 +00:00
|
|
|
std/[algorithm, os, sequtils, strformat, strutils, times],
|
2022-07-21 18:16:28 +00:00
|
|
|
chronicles,
|
2022-12-02 04:39:12 +00:00
|
|
|
eth/keys,
|
2022-07-21 18:16:28 +00:00
|
|
|
stint,
|
|
|
|
unittest2,
|
2022-12-02 04:39:12 +00:00
|
|
|
../nimbus/core/[chain,
|
2021-08-19 12:00:30 +00:00
|
|
|
clique,
|
|
|
|
clique/clique_snapshot,
|
|
|
|
clique/clique_desc,
|
|
|
|
clique/clique_helpers
|
|
|
|
],
|
2022-12-02 04:39:12 +00:00
|
|
|
../nimbus/common/[common,context],
|
|
|
|
../nimbus/utils/[ec_recover, utils],
|
|
|
|
../nimbus/[config, constants],
|
2022-01-18 16:19:32 +00:00
|
|
|
./test_clique/pool,
|
2022-08-24 13:44:18 +00:00
|
|
|
./replay/undump_blocks
|
2021-06-04 17:20:37 +00:00
|
|
|
|
2021-07-30 14:06:51 +00:00
|
|
|
const
|
2022-01-18 16:19:32 +00:00
|
|
|
baseDir = [".", "tests", ".." / "tests", $DirSep] # path containg repo
|
|
|
|
repoDir = ["test_clique", "replay", "status"] # alternative repos
|
|
|
|
|
|
|
|
goerliCapture = "goerli68161.txt.gz"
|
2021-07-30 14:06:51 +00:00
|
|
|
groupReplayTransactions = 7
|
2021-07-06 13:14:45 +00:00
|
|
|
|
|
|
|
# ------------------------------------------------------------------------------
|
|
|
|
# Helpers
|
|
|
|
# ------------------------------------------------------------------------------
|
|
|
|
|
|
|
|
proc getBlockHeader(ap: TesterPool; number: BlockNumber): BlockHeader =
|
2023-08-04 11:10:09 +00:00
|
|
|
## Shortcut => db/core_db.getBlockHeader()
|
2021-07-06 13:14:45 +00:00
|
|
|
doAssert ap.db.getBlockHeader(number, result)
|
|
|
|
|
2021-07-30 14:06:51 +00:00
|
|
|
proc ppSecs(elapsed: Duration): string =
|
|
|
|
result = $elapsed.inSeconds
|
|
|
|
let ns = elapsed.inNanoseconds mod 1_000_000_000
|
|
|
|
if ns != 0:
|
|
|
|
# to rounded decimal seconds
|
|
|
|
let ds = (ns + 5_000_000i64) div 10_000_000i64
|
|
|
|
result &= &".{ds:02}"
|
|
|
|
result &= "s"
|
|
|
|
|
|
|
|
proc ppRow(elapsed: Duration): string =
|
|
|
|
let ms = elapsed.inMilliSeconds + 500
|
|
|
|
"x".repeat(ms div 1000)
|
|
|
|
|
2022-01-18 16:19:32 +00:00
|
|
|
proc findFilePath(file: string): string =
|
|
|
|
result = "?unknown?" / file
|
|
|
|
for dir in baseDir:
|
|
|
|
for repo in repoDir:
|
|
|
|
let path = dir / repo / file
|
|
|
|
if path.fileExists:
|
|
|
|
return path
|
|
|
|
|
2022-07-21 18:16:28 +00:00
|
|
|
proc setTraceLevel =
|
|
|
|
discard
|
|
|
|
when defined(chronicles_runtime_filtering) and loggingEnabled:
|
|
|
|
setLogLevel(LogLevel.TRACE)
|
|
|
|
|
|
|
|
proc setErrorLevel =
|
|
|
|
discard
|
|
|
|
when defined(chronicles_runtime_filtering) and loggingEnabled:
|
|
|
|
setLogLevel(LogLevel.ERROR)
|
|
|
|
|
2021-07-06 13:14:45 +00:00
|
|
|
# ------------------------------------------------------------------------------
|
|
|
|
# Test Runners
|
|
|
|
# ------------------------------------------------------------------------------
|
2021-06-16 15:12:51 +00:00
|
|
|
|
2021-06-04 17:20:37 +00:00
|
|
|
# clique/snapshot_test.go(99): func TestClique(t *testing.T) {
|
2021-07-21 13:31:52 +00:00
|
|
|
proc runCliqueSnapshot(noisy = true; postProcessOk = false;
|
2024-02-15 02:57:05 +00:00
|
|
|
testIds = {0'u16 .. 999'u16}; skipIds = {0'u16}-{0'u16}) =
|
2021-06-14 18:33:57 +00:00
|
|
|
## Clique PoA Snapshot
|
|
|
|
## ::
|
|
|
|
## Tests that Clique signer voting is evaluated correctly for various
|
|
|
|
## simple and complex scenarios, as well as that a few special corner
|
|
|
|
## cases fail correctly.
|
|
|
|
##
|
2021-07-21 13:31:52 +00:00
|
|
|
let postProcessInfo = if postProcessOk: ", Transaction Finaliser Applied"
|
|
|
|
else: ", Without Finaliser"
|
|
|
|
suite &"Clique PoA Snapshot{postProcessInfo}":
|
2022-07-21 18:16:28 +00:00
|
|
|
var pool = newVoterPool()
|
2021-06-04 17:20:37 +00:00
|
|
|
|
2022-07-21 18:16:28 +00:00
|
|
|
setErrorLevel()
|
|
|
|
if noisy:
|
|
|
|
pool.noisy = true
|
|
|
|
setTraceLevel()
|
2021-07-14 15:13:27 +00:00
|
|
|
|
2021-06-04 17:20:37 +00:00
|
|
|
# clique/snapshot_test.go(379): for i, tt := range tests {
|
2024-02-15 02:57:05 +00:00
|
|
|
for voterSample in voterSamples.filterIt(it.id.uint16 in testIds):
|
2023-01-26 12:37:19 +00:00
|
|
|
let tt = voterSample
|
2021-06-16 08:07:18 +00:00
|
|
|
test &"Snapshots {tt.id:2}: {tt.info.substr(0,50)}...":
|
2021-06-15 16:34:22 +00:00
|
|
|
pool.say "\n"
|
2021-06-14 18:33:57 +00:00
|
|
|
|
2021-07-21 13:31:52 +00:00
|
|
|
# Noisily skip this test
|
2024-02-15 02:57:05 +00:00
|
|
|
if tt.id.uint16 in skipIds:
|
2021-06-15 16:34:22 +00:00
|
|
|
skip()
|
2021-06-14 18:33:57 +00:00
|
|
|
|
|
|
|
else:
|
|
|
|
# Assemble a chain of headers from the cast votes
|
|
|
|
# see clique/snapshot_test.go(407): config := *params.TestChainConfig
|
2021-06-16 15:12:51 +00:00
|
|
|
pool
|
2021-07-30 14:06:51 +00:00
|
|
|
.resetVoterChain(tt.signers, tt.epoch, tt.runBack)
|
2021-06-16 15:12:51 +00:00
|
|
|
# see clique/snapshot_test.go(425): for j, block := range blocks {
|
|
|
|
.appendVoter(tt.votes)
|
2021-07-21 13:31:52 +00:00
|
|
|
.commitVoterChain(postProcessOk)
|
2021-06-14 18:33:57 +00:00
|
|
|
|
|
|
|
# see clique/snapshot_test.go(477): if err != nil {
|
2021-07-21 13:31:52 +00:00
|
|
|
if tt.failure != cliqueNoError[0]:
|
2021-06-14 18:33:57 +00:00
|
|
|
# Note that clique/snapshot_test.go does not verify _here_ against
|
|
|
|
# the scheduled test error -- rather this voting error is supposed
|
|
|
|
# to happen earlier (processed at clique/snapshot_test.go(467)) when
|
|
|
|
# assembling the block chain (sounds counter intuitive to the author
|
|
|
|
# of this source file as the scheduled errors are _clique_ related).
|
2021-07-21 13:31:52 +00:00
|
|
|
check pool.failed[1][0] == tt.failure
|
2021-06-14 18:33:57 +00:00
|
|
|
else:
|
|
|
|
let
|
|
|
|
expected = tt.results.mapIt("@" & it).sorted
|
2021-07-14 15:13:27 +00:00
|
|
|
snapResult = pool.pp(pool.cliqueSigners).sorted
|
2022-07-21 18:16:28 +00:00
|
|
|
pool.say "*** snap state=", pool.pp(pool.snapshot,16)
|
2021-06-14 18:33:57 +00:00
|
|
|
pool.say " result=[", snapResult.join(",") & "]"
|
|
|
|
pool.say " expected=[", expected.join(",") & "]"
|
|
|
|
|
|
|
|
# Verify the final list of signers against the expected ones
|
|
|
|
check snapResult == expected
|
2021-06-04 17:20:37 +00:00
|
|
|
|
2021-07-30 14:06:51 +00:00
|
|
|
proc runGoerliReplay(noisy = true; showElapsed = false,
|
2022-01-18 16:19:32 +00:00
|
|
|
captureFile = goerliCapture,
|
2021-07-30 14:06:51 +00:00
|
|
|
startAtBlock = 0u64; stopAfterBlock = 0u64) =
|
2021-07-06 13:14:45 +00:00
|
|
|
var
|
2021-07-14 15:13:27 +00:00
|
|
|
pool = newVoterPool()
|
2021-07-30 14:06:51 +00:00
|
|
|
cache: array[groupReplayTransactions,(seq[BlockHeader],seq[BlockBody])]
|
2021-07-06 13:14:45 +00:00
|
|
|
cInx = 0
|
|
|
|
stoppedOk = false
|
|
|
|
|
2022-01-18 16:19:32 +00:00
|
|
|
let
|
|
|
|
fileInfo = captureFile.splitFile.name.split(".")[0]
|
|
|
|
filePath = captureFile.findFilePath
|
|
|
|
|
2021-07-30 14:06:51 +00:00
|
|
|
pool.verifyFrom = startAtBlock
|
2021-07-14 15:13:27 +00:00
|
|
|
|
2022-07-21 18:16:28 +00:00
|
|
|
setErrorLevel()
|
|
|
|
if noisy:
|
|
|
|
pool.noisy = true
|
|
|
|
setTraceLevel()
|
|
|
|
|
2021-07-14 15:13:27 +00:00
|
|
|
let stopThreshold = if stopAfterBlock == 0u64: uint64.high.u256
|
|
|
|
else: stopAfterBlock.u256
|
|
|
|
|
2022-01-18 16:19:32 +00:00
|
|
|
suite &"Replay Goerli chain from {fileInfo} capture":
|
2021-07-06 13:14:45 +00:00
|
|
|
|
2023-04-21 21:11:04 +00:00
|
|
|
for w in filePath.undumpBlocks:
|
2021-07-06 13:14:45 +00:00
|
|
|
|
|
|
|
if w[0][0].blockNumber == 0.u256:
|
|
|
|
# Verify Genesis
|
|
|
|
doAssert w[0][0] == pool.getBlockHeader(0.u256)
|
|
|
|
|
|
|
|
else:
|
|
|
|
# Condense in cache
|
|
|
|
cache[cInx] = w
|
|
|
|
cInx.inc
|
|
|
|
|
|
|
|
# Handy for partial tests
|
2021-07-14 15:13:27 +00:00
|
|
|
if stopThreshold < cache[cInx-1][0][0].blockNumber:
|
2021-07-06 13:14:45 +00:00
|
|
|
stoppedOk = true
|
|
|
|
break
|
|
|
|
|
|
|
|
# Run from cache if complete set
|
|
|
|
if cache.len <= cInx:
|
|
|
|
cInx = 0
|
|
|
|
let
|
|
|
|
first = cache[0][0][0].blockNumber
|
|
|
|
last = cache[^1][0][^1].blockNumber
|
2021-07-30 14:06:51 +00:00
|
|
|
blkRange = &"#{first}..#{last}"
|
|
|
|
info = if first <= startAtBlock.u256 and startAtBlock.u256 <= last:
|
|
|
|
&", verification #{startAtBlock}.."
|
|
|
|
else:
|
|
|
|
""
|
|
|
|
test &"Goerli Blocks {blkRange} ({cache.len} transactions{info})":
|
|
|
|
let start = getTime()
|
2021-07-06 13:14:45 +00:00
|
|
|
for (headers,bodies) in cache:
|
2021-07-14 15:13:27 +00:00
|
|
|
let addedPersistBlocks = pool.chain.persistBlocks(headers,bodies)
|
2021-07-06 13:14:45 +00:00
|
|
|
check addedPersistBlocks == ValidationResult.Ok
|
|
|
|
if addedPersistBlocks != ValidationResult.Ok: return
|
2021-07-30 14:06:51 +00:00
|
|
|
if showElapsed and startAtBlock.u256 <= last:
|
|
|
|
let
|
|
|
|
elpd = getTime() - start
|
|
|
|
info = &"{elpd.ppSecs:>7} {pool.cliqueSignersLen} {elpd.ppRow}"
|
|
|
|
echo &"\n elapsed {blkRange:<17} {info}"
|
2021-07-06 13:14:45 +00:00
|
|
|
|
|
|
|
# Rest from cache
|
|
|
|
if 0 < cInx:
|
|
|
|
let
|
|
|
|
first = cache[0][0][0].blockNumber
|
|
|
|
last = cache[cInx-1][0][^1].blockNumber
|
2021-07-30 14:06:51 +00:00
|
|
|
blkRange = &"#{first}..#{last}"
|
|
|
|
info = if first <= startAtBlock.u256 and startAtBlock.u256 <= last:
|
|
|
|
&", Verification #{startAtBlock}.."
|
|
|
|
else:
|
|
|
|
""
|
|
|
|
test &"Goerli Blocks {blkRange} ({cache.len} transactions{info})":
|
|
|
|
let start = getTime()
|
2021-07-06 13:14:45 +00:00
|
|
|
for (headers,bodies) in cache:
|
2021-07-14 15:13:27 +00:00
|
|
|
let addedPersistBlocks = pool.chain.persistBlocks(headers,bodies)
|
2021-07-06 13:14:45 +00:00
|
|
|
check addedPersistBlocks == ValidationResult.Ok
|
|
|
|
if addedPersistBlocks != ValidationResult.Ok: return
|
2021-07-30 14:06:51 +00:00
|
|
|
if showElapsed and startAtBlock.u256 <= last:
|
|
|
|
let
|
|
|
|
elpsd = getTime() - start
|
|
|
|
info = &"{elpsd.ppSecs:>7} {pool.cliqueSignersLen} {elpsd.ppRow}"
|
|
|
|
echo &"\n elapsed {blkRange:<17} {info}"
|
2021-07-06 13:14:45 +00:00
|
|
|
|
|
|
|
if stoppedOk:
|
2021-07-14 15:13:27 +00:00
|
|
|
test &"Runner stopped after reaching #{stopThreshold}":
|
|
|
|
discard
|
|
|
|
|
|
|
|
|
2021-07-30 14:06:51 +00:00
|
|
|
proc runGoerliBaybySteps(noisy = true;
|
2022-01-18 16:19:32 +00:00
|
|
|
captureFile = goerliCapture,
|
2021-07-30 14:06:51 +00:00
|
|
|
stopAfterBlock = 0u64) =
|
2021-07-14 15:13:27 +00:00
|
|
|
var
|
|
|
|
pool = newVoterPool()
|
|
|
|
stoppedOk = false
|
|
|
|
|
2022-07-21 18:16:28 +00:00
|
|
|
setErrorLevel()
|
|
|
|
if noisy:
|
|
|
|
pool.noisy = true
|
|
|
|
setTraceLevel()
|
2021-07-14 15:13:27 +00:00
|
|
|
|
2022-01-18 16:19:32 +00:00
|
|
|
let
|
|
|
|
fileInfo = captureFile.splitFile.name.split(".")[0]
|
|
|
|
filePath = captureFile.findFilePath
|
|
|
|
stopThreshold = if stopAfterBlock == 0u64: 20.u256
|
|
|
|
else: stopAfterBlock.u256
|
2021-07-14 15:13:27 +00:00
|
|
|
|
2022-01-18 16:19:32 +00:00
|
|
|
suite &"Replay Goerli chain from {fileInfo} capture, single blockwise":
|
2021-07-14 15:13:27 +00:00
|
|
|
|
2023-04-21 21:11:04 +00:00
|
|
|
for w in filePath.undumpBlocks:
|
2021-07-21 13:31:52 +00:00
|
|
|
if stoppedOk:
|
|
|
|
break
|
2021-07-14 15:13:27 +00:00
|
|
|
if w[0][0].blockNumber == 0.u256:
|
|
|
|
# Verify Genesis
|
|
|
|
doAssert w[0][0] == pool.getBlockHeader(0.u256)
|
|
|
|
else:
|
|
|
|
for n in 0 ..< w[0].len:
|
|
|
|
let
|
|
|
|
header = w[0][n]
|
|
|
|
body = w[1][n]
|
2021-07-21 13:31:52 +00:00
|
|
|
var
|
2021-07-14 15:13:27 +00:00
|
|
|
parents = w[0][0 ..< n]
|
|
|
|
test &"Goerli Block #{header.blockNumber} + {parents.len} parents":
|
|
|
|
check pool.chain.clique.cliqueSnapshot(header,parents).isOk
|
|
|
|
let addedPersistBlocks = pool.chain.persistBlocks(@[header],@[body])
|
|
|
|
check addedPersistBlocks == ValidationResult.Ok
|
|
|
|
if addedPersistBlocks != ValidationResult.Ok: return
|
2021-07-21 13:31:52 +00:00
|
|
|
# Handy for partial tests
|
|
|
|
if stopThreshold <= header.blockNumber:
|
|
|
|
stoppedOk = true
|
|
|
|
break
|
2021-07-14 15:13:27 +00:00
|
|
|
|
|
|
|
if stoppedOk:
|
|
|
|
test &"Runner stopped after reaching #{stopThreshold}":
|
2021-07-06 13:14:45 +00:00
|
|
|
discard
|
|
|
|
|
2021-08-19 12:00:30 +00:00
|
|
|
proc cliqueMiscTests() =
|
2022-01-18 16:19:32 +00:00
|
|
|
let
|
|
|
|
prvKeyFile = "private.key".findFilePath
|
|
|
|
|
2021-08-19 12:00:30 +00:00
|
|
|
suite "clique misc":
|
|
|
|
test "signer func":
|
2021-09-07 12:30:12 +00:00
|
|
|
let
|
2022-01-18 16:19:32 +00:00
|
|
|
engineSigner = "658bdf435d810c91414ec09147daa6db62406379"
|
|
|
|
privateKey = prvKeyFile
|
2021-09-11 14:58:01 +00:00
|
|
|
conf = makeConfig(@["--engine-signer:" & engineSigner, "--import-key:" & privateKey])
|
|
|
|
ctx = newEthContext()
|
|
|
|
|
|
|
|
check ctx.am.importPrivateKey(string conf.importKey).isOk()
|
|
|
|
check ctx.am.getAccount(conf.engineSigner).isOk()
|
2021-08-19 12:00:30 +00:00
|
|
|
|
|
|
|
proc signFunc(signer: EthAddress, message: openArray[byte]): Result[RawSignature, cstring] {.gcsafe.} =
|
|
|
|
let
|
2021-09-11 14:58:01 +00:00
|
|
|
hashData = keccakHash(message)
|
|
|
|
acc = ctx.am.getAccount(conf.engineSigner).tryGet()
|
2021-08-19 12:00:30 +00:00
|
|
|
rawSign = sign(acc.privateKey, SkMessage(hashData.data)).toRaw
|
|
|
|
|
|
|
|
ok(rawSign)
|
|
|
|
|
|
|
|
let signerFn: CliqueSignerFn = signFunc
|
|
|
|
var header: BlockHeader
|
|
|
|
header.extraData.setLen(EXTRA_VANITY)
|
|
|
|
header.extraData.add 0.byte.repeat(EXTRA_SEAL)
|
|
|
|
|
2021-09-11 14:58:01 +00:00
|
|
|
let signature = signerFn(conf.engineSigner, header.encodeSealHeader).get()
|
2021-08-19 12:00:30 +00:00
|
|
|
let extraLen = header.extraData.len
|
|
|
|
if EXTRA_SEAL < extraLen:
|
|
|
|
header.extraData.setLen(extraLen - EXTRA_SEAL)
|
|
|
|
header.extraData.add signature
|
|
|
|
|
|
|
|
let resAddr = ecRecover(header)
|
|
|
|
check resAddr.isOk
|
2021-09-11 14:58:01 +00:00
|
|
|
check resAddr.value == conf.engineSigner
|
2021-08-19 12:00:30 +00:00
|
|
|
|
2021-07-06 13:14:45 +00:00
|
|
|
# ------------------------------------------------------------------------------
|
|
|
|
# Main function(s)
|
|
|
|
# ------------------------------------------------------------------------------
|
|
|
|
|
|
|
|
proc cliqueMain*(noisy = defined(debug)) =
|
2021-07-21 13:31:52 +00:00
|
|
|
noisy.runCliqueSnapshot(true)
|
2021-07-30 14:06:51 +00:00
|
|
|
noisy.runCliqueSnapshot(false)
|
2021-07-14 15:13:27 +00:00
|
|
|
noisy.runGoerliBaybySteps
|
2021-07-30 14:06:51 +00:00
|
|
|
noisy.runGoerliReplay(startAtBlock = 31100u64)
|
2021-08-19 12:00:30 +00:00
|
|
|
cliqueMiscTests()
|
2021-07-06 13:14:45 +00:00
|
|
|
|
2021-06-04 17:20:37 +00:00
|
|
|
when isMainModule:
|
2021-07-30 14:06:51 +00:00
|
|
|
let
|
|
|
|
skipIDs = {999}
|
|
|
|
# A new capture file can be generated using
|
|
|
|
# `test_clique/indiump.dumpGroupNl()`
|
|
|
|
# placed at the end of
|
|
|
|
# `p2p/chain/persist_blocks.persistBlocks()`.
|
2022-01-18 16:19:32 +00:00
|
|
|
captureFile = goerliCapture
|
2021-07-27 11:28:05 +00:00
|
|
|
#captureFile = "dump-stream.out.gz"
|
2021-07-30 14:06:51 +00:00
|
|
|
|
2022-01-18 16:19:32 +00:00
|
|
|
proc goerliReplay(noisy = true;
|
|
|
|
showElapsed = true;
|
|
|
|
captureFile = captureFile;
|
|
|
|
startAtBlock = 0u64;
|
|
|
|
stopAfterBlock = 0u64) =
|
2021-07-30 14:06:51 +00:00
|
|
|
runGoerliReplay(
|
2022-01-18 16:19:32 +00:00
|
|
|
noisy = noisy,
|
|
|
|
showElapsed = showElapsed,
|
|
|
|
captureFile = captureFile,
|
|
|
|
startAtBlock = startAtBlock,
|
|
|
|
stopAfterBlock = stopAfterBlock)
|
|
|
|
|
|
|
|
# local path is: nimbus-eth1/tests
|
|
|
|
let noisy = defined(debug)
|
2021-07-30 14:06:51 +00:00
|
|
|
|
2021-07-21 13:31:52 +00:00
|
|
|
noisy.runCliqueSnapshot(true)
|
|
|
|
noisy.runCliqueSnapshot(false)
|
2022-01-18 16:19:32 +00:00
|
|
|
noisy.runGoerliBaybySteps
|
2022-07-21 18:16:28 +00:00
|
|
|
false.runGoerliReplay(startAtBlock = 31100u64)
|
2022-01-18 16:19:32 +00:00
|
|
|
|
2021-07-30 14:06:51 +00:00
|
|
|
#noisy.goerliReplay(startAtBlock = 31100u64)
|
|
|
|
#noisy.goerliReplay(startAtBlock = 194881u64, stopAfterBlock = 198912u64)
|
2022-01-18 16:19:32 +00:00
|
|
|
|
2021-08-19 12:00:30 +00:00
|
|
|
cliqueMiscTests()
|
2021-06-04 17:20:37 +00:00
|
|
|
|
2021-07-06 13:14:45 +00:00
|
|
|
# ------------------------------------------------------------------------------
|
2021-06-04 17:20:37 +00:00
|
|
|
# End
|
2021-07-06 13:14:45 +00:00
|
|
|
# ------------------------------------------------------------------------------
|