2022-05-31 10:45:37 +00:00
|
|
|
# beacon_chain
|
2024-01-06 14:26:56 +00:00
|
|
|
# Copyright (c) 2022-2024 Status Research & Development GmbH
|
2022-05-31 10:45:37 +00:00
|
|
|
# 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.
|
|
|
|
|
2024-05-15 14:01:52 +00:00
|
|
|
{.push raises: [].}
|
|
|
|
|
2022-05-31 10:45:37 +00:00
|
|
|
import
|
|
|
|
std/os,
|
2023-03-06 16:19:15 +00:00
|
|
|
chronicles, chronos, stew/io2,
|
2023-06-19 22:43:50 +00:00
|
|
|
eth/db/kvstore_sqlite3,
|
2023-05-15 05:05:12 +00:00
|
|
|
./el/el_manager,
|
2022-08-25 03:53:59 +00:00
|
|
|
./gossip_processing/optimistic_processor,
|
2023-09-08 05:53:27 +00:00
|
|
|
./networking/[topic_params, network_metadata_downloads],
|
2022-05-31 10:45:37 +00:00
|
|
|
./spec/beaconstate,
|
2023-03-09 00:34:17 +00:00
|
|
|
./spec/datatypes/[phase0, altair, bellatrix, capella, deneb],
|
2022-11-30 03:45:03 +00:00
|
|
|
"."/[filepath, light_client, light_client_db, nimbus_binary_common, version]
|
2022-05-31 10:45:37 +00:00
|
|
|
|
2022-07-14 04:07:40 +00:00
|
|
|
from ./gossip_processing/block_processor import newExecutionPayload
|
2022-08-25 03:53:59 +00:00
|
|
|
from ./gossip_processing/eth2_processor import toValidationResult
|
2022-05-31 10:45:37 +00:00
|
|
|
|
2022-11-19 10:58:04 +00:00
|
|
|
# this needs to be global, so it can be set in the Ctrl+C signal handler
|
|
|
|
var globalRunning = true
|
|
|
|
|
2022-05-31 10:45:37 +00:00
|
|
|
programMain:
|
2022-11-19 10:58:04 +00:00
|
|
|
## Ctrl+C handling
|
|
|
|
proc controlCHandler() {.noconv.} =
|
|
|
|
when defined(windows):
|
|
|
|
# workaround for https://github.com/nim-lang/Nim/issues/4057
|
|
|
|
try:
|
|
|
|
setupForeignThreadGc()
|
|
|
|
except Exception as exc: raiseAssert exc.msg # shouldn't happen
|
|
|
|
notice "Shutting down after having received SIGINT"
|
|
|
|
globalRunning = false
|
|
|
|
try:
|
|
|
|
setControlCHook(controlCHandler)
|
|
|
|
except Exception as exc: # TODO Exception
|
|
|
|
warn "Cannot set ctrl-c handler", msg = exc.msg
|
|
|
|
|
2022-05-31 10:45:37 +00:00
|
|
|
var config = makeBannerAndConfig(
|
|
|
|
"Nimbus light client " & fullVersionStr, LightClientConf)
|
|
|
|
setupLogging(config.logLevel, config.logStdout, config.logFile)
|
|
|
|
|
|
|
|
notice "Launching light client",
|
|
|
|
version = fullVersionStr, cmdParams = commandLineParams(), config
|
|
|
|
|
2022-11-30 03:45:03 +00:00
|
|
|
let dbDir = config.databaseDir
|
|
|
|
if (let res = secureCreatePath(dbDir); res.isErr):
|
|
|
|
fatal "Failed to create create database directory",
|
|
|
|
path = dbDir, err = ioErrorMsg(res.error)
|
|
|
|
quit 1
|
|
|
|
let backend = SqStoreRef.init(dbDir, "nlc").expect("Database OK")
|
|
|
|
defer: backend.close()
|
|
|
|
let db = backend.initLightClientDB(LightClientDBNames(
|
2023-01-16 15:53:45 +00:00
|
|
|
legacyAltairHeaders: "altair_lc_headers",
|
|
|
|
headers: "lc_headers",
|
2022-11-30 03:45:03 +00:00
|
|
|
altairSyncCommittees: "altair_sync_committees")).expect("Database OK")
|
|
|
|
defer: db.close()
|
|
|
|
|
2022-05-31 10:45:37 +00:00
|
|
|
let metadata = loadEth2Network(config.eth2Network)
|
|
|
|
for node in metadata.bootstrapNodes:
|
|
|
|
config.bootstrapNodes.add node
|
|
|
|
template cfg(): auto = metadata.cfg
|
|
|
|
|
|
|
|
let
|
2023-09-25 20:24:13 +00:00
|
|
|
genesisBytes = try: waitFor metadata.fetchGenesisBytes()
|
2023-09-08 05:53:27 +00:00
|
|
|
except CatchableError as err:
|
|
|
|
error "Failed to obtain genesis state",
|
|
|
|
source = metadata.genesis.sourceDesc,
|
|
|
|
err = err.msg
|
|
|
|
quit 1
|
2022-05-31 10:45:37 +00:00
|
|
|
genesisState =
|
|
|
|
try:
|
2023-09-08 05:53:27 +00:00
|
|
|
newClone(readSszForkedHashedBeaconState(cfg, genesisBytes))
|
2022-05-31 10:45:37 +00:00
|
|
|
except CatchableError as err:
|
|
|
|
raiseAssert "Invalid baked-in state: " & err.msg
|
|
|
|
|
2024-01-06 14:26:56 +00:00
|
|
|
genesisTime = getStateField(genesisState[], genesis_time)
|
|
|
|
beaconClock = BeaconClock.init(genesisTime).valueOr:
|
|
|
|
error "Invalid genesis time in state", genesisTime
|
|
|
|
quit 1
|
2022-05-31 10:45:37 +00:00
|
|
|
getBeaconTime = beaconClock.getBeaconTimeFn()
|
|
|
|
|
|
|
|
genesis_validators_root =
|
|
|
|
getStateField(genesisState[], genesis_validators_root)
|
|
|
|
forkDigests = newClone ForkDigests.init(cfg, genesis_validators_root)
|
|
|
|
|
|
|
|
genesisBlockRoot = get_initial_beacon_block(genesisState[]).root
|
|
|
|
|
2023-06-19 22:43:50 +00:00
|
|
|
rng = HmacDrbgContext.new()
|
2022-07-13 21:26:16 +00:00
|
|
|
netKeys = getRandomNetKeys(rng[])
|
2022-05-31 10:45:37 +00:00
|
|
|
network = createEth2Node(
|
|
|
|
rng, config, netKeys, cfg,
|
|
|
|
forkDigests, getBeaconTime, genesis_validators_root)
|
2023-03-05 01:40:21 +00:00
|
|
|
engineApiUrls = config.engineApiUrls
|
|
|
|
elManager =
|
|
|
|
if engineApiUrls.len > 0:
|
|
|
|
ELManager.new(
|
2022-12-07 10:24:51 +00:00
|
|
|
cfg,
|
2022-12-19 17:19:48 +00:00
|
|
|
metadata.depositContractBlock,
|
|
|
|
metadata.depositContractBlockHash,
|
2022-12-07 10:24:51 +00:00
|
|
|
db = nil,
|
2023-03-05 01:40:21 +00:00
|
|
|
engineApiUrls,
|
|
|
|
metadata.eth1Network)
|
2022-07-14 04:07:40 +00:00
|
|
|
else:
|
|
|
|
nil
|
|
|
|
|
2024-06-14 01:23:17 +00:00
|
|
|
optimisticHandler = proc(
|
|
|
|
signedBlock: ForkedSignedBeaconBlock
|
|
|
|
): Future[void] {.async: (raises: [CancelledError]).} =
|
2022-07-14 04:07:40 +00:00
|
|
|
withBlck(signedBlock):
|
2023-12-07 17:10:22 +00:00
|
|
|
when consensusFork >= ConsensusFork.Bellatrix:
|
2023-09-21 10:49:14 +00:00
|
|
|
if forkyBlck.message.is_execution_block:
|
|
|
|
template payload(): auto = forkyBlck.message.body.execution_payload
|
2023-03-05 01:40:21 +00:00
|
|
|
if elManager != nil and not payload.block_hash.isZero:
|
2023-09-21 10:49:14 +00:00
|
|
|
discard await elManager.newExecutionPayload(forkyBlck.message)
|
2022-07-14 04:07:40 +00:00
|
|
|
else: discard
|
2022-08-25 03:53:59 +00:00
|
|
|
optimisticProcessor = initOptimisticProcessor(
|
|
|
|
getBeaconTime, optimisticHandler)
|
2022-07-14 04:07:40 +00:00
|
|
|
|
2022-05-31 10:45:37 +00:00
|
|
|
lightClient = createLightClient(
|
2022-07-21 09:16:10 +00:00
|
|
|
network, rng, config, cfg, forkDigests, getBeaconTime,
|
|
|
|
genesis_validators_root, LightClientFinalizationMode.Optimistic)
|
2022-05-31 10:45:37 +00:00
|
|
|
|
2023-08-18 09:30:02 +00:00
|
|
|
# Run `exchangeTransitionConfiguration` loop
|
|
|
|
if elManager != nil:
|
|
|
|
elManager.start(syncChain = false)
|
|
|
|
|
2022-05-31 10:45:37 +00:00
|
|
|
info "Listening to incoming network requests"
|
2024-01-13 09:54:24 +00:00
|
|
|
network.registerProtocol(
|
|
|
|
PeerSync, PeerSync.NetworkState.init(
|
|
|
|
cfg, forkDigests, genesisBlockRoot, getBeaconTime))
|
|
|
|
|
2023-12-06 18:44:49 +00:00
|
|
|
withAll(ConsensusFork):
|
|
|
|
let forkDigest = forkDigests[].atConsensusFork(consensusFork)
|
|
|
|
network.addValidator(
|
|
|
|
getBeaconBlocksTopic(forkDigest), proc (
|
|
|
|
signedBlock: consensusFork.SignedBeaconBlock
|
|
|
|
): ValidationResult =
|
|
|
|
toValidationResult(
|
|
|
|
optimisticProcessor.processSignedBeaconBlock(signedBlock)))
|
2022-05-31 10:45:37 +00:00
|
|
|
lightClient.installMessageValidators()
|
|
|
|
waitFor network.startListening()
|
|
|
|
waitFor network.start()
|
|
|
|
|
2024-06-14 01:23:17 +00:00
|
|
|
func isSynced(optimisticSlot: Slot, wallSlot: Slot): bool =
|
|
|
|
# Check whether light client has synced sufficiently close to wall slot
|
|
|
|
const maxAge = 2 * SLOTS_PER_EPOCH
|
|
|
|
optimisticSlot >= max(wallSlot, maxAge.Slot) - maxAge
|
|
|
|
|
2022-08-25 03:53:59 +00:00
|
|
|
proc onFinalizedHeader(
|
2023-01-16 15:53:45 +00:00
|
|
|
lightClient: LightClient, finalizedHeader: ForkedLightClientHeader) =
|
|
|
|
withForkyHeader(finalizedHeader):
|
|
|
|
when lcDataFork > LightClientDataFork.None:
|
|
|
|
info "New LC finalized header",
|
|
|
|
finalized_header = shortLog(forkyHeader)
|
|
|
|
let
|
|
|
|
period = forkyHeader.beacon.slot.sync_committee_period
|
|
|
|
syncCommittee = lightClient.finalizedSyncCommittee.expect("Init OK")
|
|
|
|
db.putSyncCommittee(period, syncCommittee)
|
|
|
|
db.putLatestFinalizedHeader(finalizedHeader)
|
2022-11-30 03:45:03 +00:00
|
|
|
|
2024-06-14 01:23:17 +00:00
|
|
|
var optimisticFcuFut: Future[(PayloadExecutionStatus, Opt[BlockHash])]
|
|
|
|
.Raising([CancelledError])
|
2022-08-25 03:53:59 +00:00
|
|
|
proc onOptimisticHeader(
|
2023-01-16 15:53:45 +00:00
|
|
|
lightClient: LightClient, optimisticHeader: ForkedLightClientHeader) =
|
2024-06-14 01:23:17 +00:00
|
|
|
if optimisticFcuFut != nil:
|
|
|
|
return
|
2023-01-16 15:53:45 +00:00
|
|
|
withForkyHeader(optimisticHeader):
|
|
|
|
when lcDataFork > LightClientDataFork.None:
|
2024-06-14 01:23:17 +00:00
|
|
|
logScope: optimistic_header = shortLog(forkyHeader)
|
|
|
|
when lcDataFork >= LightClientDataFork.Capella:
|
|
|
|
let
|
|
|
|
bid = forkyHeader.beacon.toBlockId()
|
|
|
|
consensusFork = cfg.consensusForkAtEpoch(bid.slot.epoch)
|
|
|
|
blockHash = forkyHeader.execution.block_hash
|
|
|
|
|
|
|
|
info "New LC optimistic header"
|
|
|
|
if elManager == nil or blockHash.isZero or
|
|
|
|
not isSynced(bid.slot, getBeaconTime().slotOrZero()):
|
|
|
|
return
|
|
|
|
|
|
|
|
withConsensusFork(consensusFork):
|
|
|
|
when lcDataForkAtConsensusFork(consensusFork) == lcDataFork:
|
|
|
|
optimisticFcuFut = elManager.forkchoiceUpdated(
|
|
|
|
headBlockHash = blockHash,
|
|
|
|
safeBlockHash = blockHash, # stub value
|
|
|
|
finalizedBlockHash = ZERO_HASH,
|
|
|
|
payloadAttributes = Opt.none(consensusFork.PayloadAttributes))
|
|
|
|
optimisticFcuFut.addCallback do (future: pointer):
|
|
|
|
optimisticFcuFut = nil
|
|
|
|
else:
|
|
|
|
info "Ignoring new LC optimistic header until Capella"
|
2022-07-14 04:07:40 +00:00
|
|
|
|
2022-05-31 10:45:37 +00:00
|
|
|
lightClient.onFinalizedHeader = onFinalizedHeader
|
|
|
|
lightClient.onOptimisticHeader = onOptimisticHeader
|
|
|
|
lightClient.trustedBlockRoot = some config.trustedBlockRoot
|
2022-07-14 04:07:40 +00:00
|
|
|
|
2022-11-30 03:45:03 +00:00
|
|
|
let latestHeader = db.getLatestFinalizedHeader()
|
2023-01-16 15:53:45 +00:00
|
|
|
withForkyHeader(latestHeader):
|
|
|
|
when lcDataFork > LightClientDataFork.None:
|
|
|
|
let
|
|
|
|
period = forkyHeader.beacon.slot.sync_committee_period
|
|
|
|
syncCommittee = db.getSyncCommittee(period)
|
|
|
|
if syncCommittee.isErr:
|
|
|
|
error "LC store lacks sync committee", finalized_header = forkyHeader
|
|
|
|
else:
|
|
|
|
lightClient.resetToFinalizedHeader(latestHeader, syncCommittee.get)
|
2022-11-30 03:45:03 +00:00
|
|
|
|
2022-08-25 03:53:59 +00:00
|
|
|
# Full blocks gossip is required to portably drive an EL client:
|
2022-08-29 12:16:35 +00:00
|
|
|
# - EL clients may not sync when only driven with `forkChoiceUpdated`,
|
|
|
|
# e.g., Geth: "Forkchoice requested unknown head"
|
2022-08-25 03:53:59 +00:00
|
|
|
# - `newPayload` requires the full `ExecutionPayload` (most of block content)
|
2023-01-16 15:53:45 +00:00
|
|
|
# - `ExecutionPayload` block hash is not available in
|
2023-01-13 15:46:35 +00:00
|
|
|
# `altair.LightClientHeader`, so won't be exchanged via light client gossip
|
2022-08-25 03:53:59 +00:00
|
|
|
#
|
|
|
|
# Future `ethereum/consensus-specs` versions may remove need for full blocks.
|
|
|
|
# Therefore, this current mechanism is to be seen as temporary; it is not
|
|
|
|
# optimized for reducing code duplication, e.g., with `nimbus_beacon_node`.
|
|
|
|
|
2022-11-30 03:45:03 +00:00
|
|
|
func isSynced(wallSlot: Slot): bool =
|
2023-01-16 15:53:45 +00:00
|
|
|
let optimisticHeader = lightClient.optimisticHeader
|
|
|
|
withForkyHeader(optimisticHeader):
|
|
|
|
when lcDataFork > LightClientDataFork.None:
|
2024-06-14 01:23:17 +00:00
|
|
|
isSynced(forkyHeader.beacon.slot, wallSlot)
|
2023-01-16 15:53:45 +00:00
|
|
|
else:
|
|
|
|
false
|
2022-08-25 03:53:59 +00:00
|
|
|
|
2022-11-30 03:45:03 +00:00
|
|
|
func shouldSyncOptimistically(wallSlot: Slot): bool =
|
|
|
|
# Check whether an EL is connected
|
2023-03-05 01:40:21 +00:00
|
|
|
if elManager == nil:
|
2022-11-30 03:45:03 +00:00
|
|
|
return false
|
|
|
|
|
|
|
|
isSynced(wallSlot)
|
|
|
|
|
2022-08-25 03:53:59 +00:00
|
|
|
var blocksGossipState: GossipState = {}
|
|
|
|
proc updateBlocksGossipStatus(slot: Slot) =
|
|
|
|
let
|
|
|
|
isBehind = not shouldSyncOptimistically(slot)
|
|
|
|
|
|
|
|
targetGossipState = getTargetGossipState(
|
2022-12-04 07:42:03 +00:00
|
|
|
slot.epoch, cfg.ALTAIR_FORK_EPOCH, cfg.BELLATRIX_FORK_EPOCH,
|
2024-05-16 11:17:31 +00:00
|
|
|
cfg.CAPELLA_FORK_EPOCH, cfg.DENEB_FORK_EPOCH, FAR_FUTURE_EPOCH,
|
2024-05-15 13:30:49 +00:00
|
|
|
isBehind)
|
2022-08-25 03:53:59 +00:00
|
|
|
|
|
|
|
template currentGossipState(): auto = blocksGossipState
|
|
|
|
if currentGossipState == targetGossipState:
|
|
|
|
return
|
2022-07-14 04:07:40 +00:00
|
|
|
|
2022-08-25 03:53:59 +00:00
|
|
|
if currentGossipState.card == 0 and targetGossipState.card > 0:
|
|
|
|
debug "Enabling blocks topic subscriptions",
|
|
|
|
wallSlot = slot, targetGossipState
|
|
|
|
elif currentGossipState.card > 0 and targetGossipState.card == 0:
|
|
|
|
debug "Disabling blocks topic subscriptions",
|
|
|
|
wallSlot = slot
|
|
|
|
else:
|
|
|
|
# Individual forks added / removed
|
|
|
|
discard
|
|
|
|
|
|
|
|
let
|
|
|
|
newGossipForks = targetGossipState - currentGossipState
|
|
|
|
oldGossipForks = currentGossipState - targetGossipState
|
|
|
|
|
|
|
|
for gossipFork in oldGossipForks:
|
2023-03-11 14:39:29 +00:00
|
|
|
let forkDigest = forkDigests[].atConsensusFork(gossipFork)
|
2022-08-25 03:53:59 +00:00
|
|
|
network.unsubscribe(getBeaconBlocksTopic(forkDigest))
|
|
|
|
|
|
|
|
for gossipFork in newGossipForks:
|
2023-03-11 14:39:29 +00:00
|
|
|
let forkDigest = forkDigests[].atConsensusFork(gossipFork)
|
2022-08-25 03:53:59 +00:00
|
|
|
network.subscribe(
|
|
|
|
getBeaconBlocksTopic(forkDigest), blocksTopicParams,
|
|
|
|
enableTopicMetrics = true)
|
|
|
|
|
|
|
|
blocksGossipState = targetGossipState
|
|
|
|
|
2022-11-22 15:39:03 +00:00
|
|
|
proc onSlot(wallTime: BeaconTime, lastSlot: Slot) =
|
|
|
|
let
|
|
|
|
wallSlot = wallTime.slotOrZero()
|
|
|
|
expectedSlot = lastSlot + 1
|
|
|
|
delay = wallTime - expectedSlot.start_beacon_time()
|
|
|
|
|
|
|
|
finalizedHeader = lightClient.finalizedHeader
|
|
|
|
optimisticHeader = lightClient.optimisticHeader
|
|
|
|
|
2023-01-16 15:53:45 +00:00
|
|
|
finalizedBid = withForkyHeader(finalizedHeader):
|
|
|
|
when lcDataFork > LightClientDataFork.None:
|
|
|
|
forkyHeader.beacon.toBlockId()
|
2022-11-22 15:39:03 +00:00
|
|
|
else:
|
|
|
|
BlockId(root: genesisBlockRoot, slot: GENESIS_SLOT)
|
2023-01-16 15:53:45 +00:00
|
|
|
optimisticBid = withForkyHeader(optimisticHeader):
|
|
|
|
when lcDataFork > LightClientDataFork.None:
|
|
|
|
forkyHeader.beacon.toBlockId()
|
2022-11-22 15:39:03 +00:00
|
|
|
else:
|
|
|
|
BlockId(root: genesisBlockRoot, slot: GENESIS_SLOT)
|
|
|
|
|
|
|
|
syncStatus =
|
2023-01-16 15:53:45 +00:00
|
|
|
if optimisticHeader.kind == LightClientDataFork.None:
|
2022-11-22 15:39:03 +00:00
|
|
|
"bootstrapping(" & $config.trustedBlockRoot & ")"
|
2022-11-30 03:45:03 +00:00
|
|
|
elif not isSynced(wallSlot):
|
2022-11-22 15:39:03 +00:00
|
|
|
"syncing"
|
|
|
|
else:
|
|
|
|
"synced"
|
|
|
|
|
|
|
|
info "Slot start",
|
|
|
|
slot = shortLog(wallSlot),
|
|
|
|
epoch = shortLog(wallSlot.epoch),
|
|
|
|
sync = syncStatus,
|
|
|
|
peers = len(network.peerPool),
|
|
|
|
head = shortLog(optimisticBid),
|
|
|
|
finalized = shortLog(finalizedBid),
|
|
|
|
delay = shortLog(delay)
|
|
|
|
|
|
|
|
proc runOnSlotLoop() {.async.} =
|
|
|
|
var
|
|
|
|
curSlot = getBeaconTime().slotOrZero()
|
|
|
|
nextSlot = curSlot + 1
|
|
|
|
timeToNextSlot = nextSlot.start_beacon_time() - getBeaconTime()
|
|
|
|
while true:
|
|
|
|
await sleepAsync(timeToNextSlot)
|
|
|
|
|
|
|
|
let
|
|
|
|
wallTime = getBeaconTime()
|
|
|
|
wallSlot = wallTime.slotOrZero()
|
|
|
|
|
|
|
|
onSlot(wallTime, curSlot)
|
|
|
|
|
|
|
|
curSlot = wallSlot
|
|
|
|
nextSlot = wallSlot + 1
|
|
|
|
timeToNextSlot = nextSlot.start_beacon_time() - getBeaconTime()
|
|
|
|
|
2022-07-14 04:07:40 +00:00
|
|
|
proc onSecond(time: Moment) =
|
2022-09-16 22:48:53 +00:00
|
|
|
let wallSlot = getBeaconTime().slotOrZero()
|
2022-07-14 04:07:40 +00:00
|
|
|
if checkIfShouldStopAtEpoch(wallSlot, config.stopAtEpoch):
|
|
|
|
quit(0)
|
|
|
|
|
2022-08-25 03:53:59 +00:00
|
|
|
updateBlocksGossipStatus(wallSlot + 1)
|
2022-07-14 04:07:40 +00:00
|
|
|
lightClient.updateGossipStatus(wallSlot + 1)
|
|
|
|
|
|
|
|
proc runOnSecondLoop() {.async.} =
|
|
|
|
let sleepTime = chronos.seconds(1)
|
|
|
|
while true:
|
|
|
|
let start = chronos.now(chronos.Moment)
|
|
|
|
await chronos.sleepAsync(sleepTime)
|
|
|
|
let afterSleep = chronos.now(chronos.Moment)
|
|
|
|
let sleepTime = afterSleep - start
|
|
|
|
onSecond(start)
|
|
|
|
let finished = chronos.now(chronos.Moment)
|
|
|
|
let processingTime = finished - afterSleep
|
|
|
|
trace "onSecond task completed", sleepTime, processingTime
|
|
|
|
|
|
|
|
onSecond(Moment.now())
|
2022-05-31 10:45:37 +00:00
|
|
|
lightClient.start()
|
|
|
|
|
2022-11-22 15:39:03 +00:00
|
|
|
asyncSpawn runOnSlotLoop()
|
2022-07-14 04:07:40 +00:00
|
|
|
asyncSpawn runOnSecondLoop()
|
2022-11-19 10:58:04 +00:00
|
|
|
while globalRunning:
|
2022-05-31 10:45:37 +00:00
|
|
|
poll()
|
2022-11-19 10:58:04 +00:00
|
|
|
|
|
|
|
notice "Exiting light client"
|