2021-03-26 06:52:01 +00:00
|
|
|
# beacon_chain
|
2023-01-20 14:14:37 +00:00
|
|
|
# Copyright (c) 2018-2023 Status Research & Development GmbH
|
2021-03-26 06:52:01 +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.
|
|
|
|
|
2023-01-20 14:14:37 +00:00
|
|
|
{.push raises: [].}
|
2021-03-26 06:52:01 +00:00
|
|
|
|
2022-01-26 12:20:08 +00:00
|
|
|
import std/[sequtils, strutils]
|
2020-06-18 10:03:36 +00:00
|
|
|
import chronos, chronicles
|
2021-08-12 13:08:20 +00:00
|
|
|
import
|
2023-04-28 12:57:35 +00:00
|
|
|
../spec/datatypes/[phase0, deneb],
|
|
|
|
../spec/[forks, network],
|
2021-08-12 13:08:20 +00:00
|
|
|
../networking/eth2_network,
|
2022-01-26 12:20:08 +00:00
|
|
|
../consensus_object_pools/block_quarantine,
|
2023-04-28 12:57:35 +00:00
|
|
|
../consensus_object_pools/blob_quarantine,
|
2023-03-07 20:19:17 +00:00
|
|
|
"."/sync_protocol, "."/sync_manager,
|
|
|
|
../gossip_processing/block_processor
|
|
|
|
|
2023-01-31 23:25:08 +00:00
|
|
|
from ../beacon_clock import GetBeaconTimeFn
|
2022-06-10 14:16:37 +00:00
|
|
|
export block_quarantine, sync_manager
|
2020-06-18 10:03:36 +00:00
|
|
|
|
|
|
|
logScope:
|
|
|
|
topics = "requman"
|
|
|
|
|
|
|
|
const
|
2020-08-12 09:29:11 +00:00
|
|
|
SYNC_MAX_REQUESTED_BLOCKS* = 32 # Spec allows up to MAX_REQUEST_BLOCKS.
|
|
|
|
## Maximum number of blocks which will be requested in each
|
|
|
|
## `beaconBlocksByRoot` invocation.
|
2020-06-18 10:03:36 +00:00
|
|
|
PARALLEL_REQUESTS* = 2
|
|
|
|
## Number of peers we using to resolve our request.
|
2019-03-28 14:03:19 +00:00
|
|
|
|
2023-04-28 12:57:35 +00:00
|
|
|
BLOB_GOSSIP_WAIT_TIME_NS* = 2 * 1_000_000_000
|
|
|
|
## How long to wait for blobs to arrive over gossip before fetching.
|
|
|
|
|
2023-08-01 20:39:14 +00:00
|
|
|
POLL_INTERVAL = 1.seconds
|
2023-07-11 16:22:02 +00:00
|
|
|
|
2019-11-25 14:36:25 +00:00
|
|
|
type
|
2023-07-11 16:22:02 +00:00
|
|
|
BlockVerifierFn* =
|
2023-04-19 16:37:38 +00:00
|
|
|
proc(signedBlock: ForkedSignedBeaconBlock, maybeFinalized: bool):
|
2023-07-11 16:22:02 +00:00
|
|
|
Future[Result[void, VerifierError]] {.gcsafe, raises: [].}
|
|
|
|
InhibitFn* = proc: bool {.gcsafe, raises:[].}
|
|
|
|
|
2019-11-25 14:36:25 +00:00
|
|
|
RequestManager* = object
|
|
|
|
network*: Eth2Node
|
2023-01-31 23:25:08 +00:00
|
|
|
getBeaconTime: GetBeaconTimeFn
|
2023-07-11 16:22:02 +00:00
|
|
|
inhibit: InhibitFn
|
2023-04-28 12:57:35 +00:00
|
|
|
quarantine: ref Quarantine
|
|
|
|
blobQuarantine: ref BlobQuarantine
|
2023-07-11 16:22:02 +00:00
|
|
|
blockVerifier: BlockVerifierFn
|
2023-04-28 12:57:35 +00:00
|
|
|
blockLoopFuture: Future[void]
|
|
|
|
blobLoopFuture: Future[void]
|
2019-11-25 14:36:25 +00:00
|
|
|
|
2020-06-18 10:03:36 +00:00
|
|
|
func shortLog*(x: seq[Eth2Digest]): string =
|
|
|
|
"[" & x.mapIt(shortLog(it)).join(", ") & "]"
|
|
|
|
|
|
|
|
func shortLog*(x: seq[FetchRecord]): string =
|
|
|
|
"[" & x.mapIt(shortLog(it.root)).join(", ") & "]"
|
|
|
|
|
|
|
|
proc init*(T: type RequestManager, network: Eth2Node,
|
2023-03-11 00:28:19 +00:00
|
|
|
denebEpoch: Epoch,
|
2023-01-31 23:25:08 +00:00
|
|
|
getBeaconTime: GetBeaconTimeFn,
|
2023-07-11 16:22:02 +00:00
|
|
|
inhibit: InhibitFn,
|
2023-04-28 12:57:35 +00:00
|
|
|
quarantine: ref Quarantine,
|
|
|
|
blobQuarantine: ref BlobQuarantine,
|
2023-07-11 16:22:02 +00:00
|
|
|
blockVerifier: BlockVerifierFn): RequestManager =
|
2020-08-12 09:29:11 +00:00
|
|
|
RequestManager(
|
|
|
|
network: network,
|
2023-01-31 23:25:08 +00:00
|
|
|
getBeaconTime: getBeaconTime,
|
2023-07-11 16:22:02 +00:00
|
|
|
inhibit: inhibit,
|
2023-04-28 12:57:35 +00:00
|
|
|
quarantine: quarantine,
|
|
|
|
blobQuarantine: blobQuarantine,
|
2023-01-31 23:25:08 +00:00
|
|
|
blockVerifier: blockVerifier,
|
2020-06-18 10:03:36 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
proc checkResponse(roots: openArray[Eth2Digest],
|
2022-02-07 17:20:10 +00:00
|
|
|
blocks: openArray[ref ForkedSignedBeaconBlock]): bool =
|
2020-06-18 10:03:36 +00:00
|
|
|
## This procedure checks peer's response.
|
|
|
|
var checks = @roots
|
|
|
|
if len(blocks) > len(roots):
|
|
|
|
return false
|
|
|
|
for blk in blocks:
|
2022-02-07 17:20:10 +00:00
|
|
|
let res = checks.find(blk[].root)
|
2020-06-18 10:03:36 +00:00
|
|
|
if res == -1:
|
|
|
|
return false
|
|
|
|
else:
|
|
|
|
checks.del(res)
|
2023-04-28 12:57:35 +00:00
|
|
|
true
|
|
|
|
|
|
|
|
proc checkResponse(idList: seq[BlobIdentifier],
|
|
|
|
blobs: openArray[ref BlobSidecar]): bool =
|
|
|
|
if len(blobs) > len(idList):
|
|
|
|
return false
|
|
|
|
for blob in blobs:
|
|
|
|
var found = false
|
|
|
|
for id in idList:
|
|
|
|
if id.block_root == blob.block_root and
|
|
|
|
id.index == blob.index:
|
|
|
|
found = true
|
|
|
|
break
|
|
|
|
if not found:
|
|
|
|
return false
|
|
|
|
true
|
2020-06-18 10:03:36 +00:00
|
|
|
|
2023-07-11 16:22:02 +00:00
|
|
|
proc requestBlocksByRoot(rman: RequestManager, items: seq[Eth2Digest]) {.async.} =
|
2020-03-22 20:54:47 +00:00
|
|
|
var peer: Peer
|
|
|
|
try:
|
2020-06-18 10:03:36 +00:00
|
|
|
peer = await rman.network.peerPool.acquire()
|
|
|
|
debug "Requesting blocks by root", peer = peer, blocks = shortLog(items),
|
|
|
|
peer_score = peer.getScore()
|
|
|
|
|
2022-10-27 16:51:43 +00:00
|
|
|
let blocks = (await beaconBlocksByRoot_v2(peer, BlockRootsList items))
|
2021-07-15 19:01:07 +00:00
|
|
|
|
2020-05-12 22:37:07 +00:00
|
|
|
if blocks.isOk:
|
2020-06-18 10:03:36 +00:00
|
|
|
let ublocks = blocks.get()
|
2022-10-27 16:51:43 +00:00
|
|
|
if checkResponse(items, ublocks.asSeq()):
|
2022-01-26 12:20:08 +00:00
|
|
|
var
|
|
|
|
gotGoodBlock = false
|
|
|
|
gotUnviableBlock = false
|
|
|
|
|
|
|
|
for b in ublocks:
|
2023-04-19 16:37:38 +00:00
|
|
|
let ver = await rman.blockVerifier(b[], false)
|
2022-01-26 12:20:08 +00:00
|
|
|
if ver.isErr():
|
|
|
|
case ver.error()
|
2022-11-10 17:40:27 +00:00
|
|
|
of VerifierError.MissingParent:
|
2022-01-26 12:20:08 +00:00
|
|
|
# Ignoring because the order of the blocks that
|
|
|
|
# we requested may be different from the order in which we need
|
|
|
|
# these blocks to apply.
|
|
|
|
discard
|
2022-11-10 17:40:27 +00:00
|
|
|
of VerifierError.Duplicate:
|
2022-01-26 12:20:08 +00:00
|
|
|
# Ignoring because these errors could occur due to the
|
|
|
|
# concurrent/parallel requests we made.
|
|
|
|
discard
|
2022-11-10 17:40:27 +00:00
|
|
|
of VerifierError.UnviableFork:
|
2022-01-26 12:20:08 +00:00
|
|
|
# If they're working a different fork, we'll want to descore them
|
|
|
|
# but also process the other blocks (in case we can register the
|
|
|
|
# other blocks as unviable)
|
|
|
|
gotUnviableBlock = true
|
2022-11-10 17:40:27 +00:00
|
|
|
of VerifierError.Invalid:
|
2022-01-26 12:20:08 +00:00
|
|
|
# We stop processing blocks because peer is either sending us
|
|
|
|
# junk or working a different fork
|
2022-07-06 10:34:12 +00:00
|
|
|
notice "Received invalid block",
|
2022-01-26 12:20:08 +00:00
|
|
|
peer = peer, blocks = shortLog(items),
|
|
|
|
peer_score = peer.getScore()
|
2022-11-11 11:34:28 +00:00
|
|
|
peer.updateScore(PeerScoreBadValues)
|
2022-01-26 12:20:08 +00:00
|
|
|
|
|
|
|
return # Stop processing this junk...
|
|
|
|
else:
|
|
|
|
gotGoodBlock = true
|
|
|
|
|
|
|
|
if gotUnviableBlock:
|
|
|
|
notice "Received blocks from an unviable fork",
|
|
|
|
peer = peer, blocks = shortLog(items),
|
|
|
|
peer_score = peer.getScore()
|
|
|
|
peer.updateScore(PeerScoreUnviableFork)
|
|
|
|
elif gotGoodBlock:
|
2023-05-11 00:36:35 +00:00
|
|
|
debug "Request manager got good block",
|
2023-07-11 16:22:02 +00:00
|
|
|
peer = peer, blocks = shortLog(items), ublocks = len(ublocks)
|
2023-05-11 00:36:35 +00:00
|
|
|
|
2022-01-26 12:20:08 +00:00
|
|
|
# We reward peer only if it returns something.
|
2022-11-11 11:34:28 +00:00
|
|
|
peer.updateScore(PeerScoreGoodValues)
|
2022-01-26 12:20:08 +00:00
|
|
|
|
2020-06-18 10:03:36 +00:00
|
|
|
else:
|
2023-07-11 16:22:02 +00:00
|
|
|
debug "Mismatching response to blocks by root",
|
|
|
|
peer = peer, blocks = shortLog(items), ublocks = len(ublocks)
|
2020-06-18 10:03:36 +00:00
|
|
|
peer.updateScore(PeerScoreBadResponse)
|
|
|
|
else:
|
2023-07-11 16:22:02 +00:00
|
|
|
debug "Blocks by root request failed",
|
|
|
|
peer = peer, blocks = shortLog(items), err = blocks.error()
|
2022-11-11 11:34:28 +00:00
|
|
|
peer.updateScore(PeerScoreNoValues)
|
2020-06-18 10:03:36 +00:00
|
|
|
|
|
|
|
except CancelledError as exc:
|
|
|
|
raise exc
|
|
|
|
except CatchableError as exc:
|
2022-11-11 11:34:28 +00:00
|
|
|
peer.updateScore(PeerScoreNoValues)
|
2023-07-11 16:22:02 +00:00
|
|
|
debug "Error while fetching blocks by root", exc = exc.msg,
|
2020-06-18 10:03:36 +00:00
|
|
|
items = shortLog(items), peer = peer, peer_score = peer.getScore()
|
|
|
|
raise exc
|
2020-03-22 20:54:47 +00:00
|
|
|
finally:
|
|
|
|
if not(isNil(peer)):
|
2020-06-18 10:03:36 +00:00
|
|
|
rman.network.peerPool.release(peer)
|
|
|
|
|
2023-04-28 12:57:35 +00:00
|
|
|
proc fetchBlobsFromNetwork(self: RequestManager,
|
|
|
|
idList: seq[BlobIdentifier]) {.async.} =
|
|
|
|
var peer: Peer
|
|
|
|
|
|
|
|
try:
|
|
|
|
peer = await self.network.peerPool.acquire()
|
|
|
|
debug "Requesting blobs by root", peer = peer, blobs = shortLog(idList),
|
|
|
|
peer_score = peer.getScore()
|
|
|
|
|
|
|
|
let blobs = (await blobSidecarsByRoot(peer, BlobIdentifierList idList))
|
|
|
|
|
|
|
|
if blobs.isOk:
|
|
|
|
let ublobs = blobs.get()
|
|
|
|
if not checkResponse(idList, ublobs.asSeq()):
|
2023-07-11 16:22:02 +00:00
|
|
|
debug "Mismatched response to blobs by root",
|
|
|
|
peer = peer, blobs = shortLog(idList), ublobs = len(ublobs)
|
2023-04-28 12:57:35 +00:00
|
|
|
peer.updateScore(PeerScoreBadResponse)
|
|
|
|
return
|
|
|
|
|
|
|
|
for b in ublobs:
|
|
|
|
self.blobQuarantine[].put(b)
|
|
|
|
var curRoot: Eth2Digest
|
|
|
|
for b in ublobs:
|
|
|
|
if b.block_root != curRoot:
|
|
|
|
curRoot = b.block_root
|
|
|
|
if (let o = self.quarantine[].popBlobless(curRoot); o.isSome):
|
|
|
|
let b = o.unsafeGet()
|
|
|
|
discard await self.blockVerifier(ForkedSignedBeaconBlock.init(b), false)
|
|
|
|
# TODO:
|
|
|
|
# If appropriate, return a VerifierError.InvalidBlob from verification,
|
|
|
|
# check for it here, and penalize the peer accordingly.
|
2023-07-11 16:22:02 +00:00
|
|
|
else:
|
|
|
|
debug "Blobs by root request failed",
|
|
|
|
peer = peer, blobs = shortLog(idList), err = blobs.error()
|
|
|
|
peer.updateScore(PeerScoreNoValues)
|
2023-01-31 23:25:08 +00:00
|
|
|
|
2023-04-28 12:57:35 +00:00
|
|
|
except CancelledError as exc:
|
|
|
|
raise exc
|
|
|
|
except CatchableError as exc:
|
|
|
|
peer.updateScore(PeerScoreNoValues)
|
2023-07-11 16:22:02 +00:00
|
|
|
debug "Error while fetching blobs by root", exc = exc.msg,
|
2023-04-28 12:57:35 +00:00
|
|
|
idList = shortLog(idList), peer = peer, peer_score = peer.getScore()
|
|
|
|
raise exc
|
|
|
|
finally:
|
|
|
|
if not(isNil(peer)):
|
|
|
|
self.network.peerPool.release(peer)
|
|
|
|
|
|
|
|
proc requestManagerBlockLoop(rman: RequestManager) {.async.} =
|
2020-06-18 10:03:36 +00:00
|
|
|
while true:
|
2023-07-11 16:22:02 +00:00
|
|
|
# TODO This polling could be replaced with an AsyncEvent that is fired
|
|
|
|
# from the quarantine when there's work to do
|
2023-08-01 20:39:14 +00:00
|
|
|
await sleepAsync(POLL_INTERVAL)
|
2023-07-11 16:22:02 +00:00
|
|
|
|
|
|
|
if rman.inhibit():
|
|
|
|
continue
|
2020-06-18 10:03:36 +00:00
|
|
|
|
2023-07-11 16:22:02 +00:00
|
|
|
let blocks = mapIt(rman.quarantine[].checkMissing(
|
|
|
|
SYNC_MAX_REQUESTED_BLOCKS), it.root)
|
|
|
|
if blocks.len == 0:
|
|
|
|
continue
|
2020-06-18 10:03:36 +00:00
|
|
|
|
2023-07-11 16:22:02 +00:00
|
|
|
debug "Requesting detected missing blocks", blocks = shortLog(blocks)
|
|
|
|
try:
|
2021-12-16 14:57:16 +00:00
|
|
|
let start = SyncMoment.now(0)
|
2020-06-18 10:03:36 +00:00
|
|
|
|
2023-07-11 16:22:02 +00:00
|
|
|
var workers: array[PARALLEL_REQUESTS, Future[void]]
|
|
|
|
|
2020-06-18 10:03:36 +00:00
|
|
|
for i in 0 ..< PARALLEL_REQUESTS:
|
2023-07-11 16:22:02 +00:00
|
|
|
workers[i] = rman.requestBlocksByRoot(blocks)
|
2020-06-18 10:03:36 +00:00
|
|
|
|
|
|
|
await allFutures(workers)
|
|
|
|
|
2023-07-11 16:22:02 +00:00
|
|
|
let finish = SyncMoment.now(uint64(len(blocks)))
|
2020-06-18 10:03:36 +00:00
|
|
|
|
|
|
|
var succeed = 0
|
|
|
|
for worker in workers:
|
2023-07-11 16:22:02 +00:00
|
|
|
if worker.completed():
|
2020-06-18 10:03:36 +00:00
|
|
|
inc(succeed)
|
|
|
|
|
2023-07-11 16:22:02 +00:00
|
|
|
debug "Request manager block tick", blocks = shortLog(blocks),
|
2023-04-28 12:57:35 +00:00
|
|
|
succeed = succeed,
|
|
|
|
failed = (len(workers) - succeed),
|
|
|
|
sync_speed = speed(start, finish)
|
|
|
|
|
2023-08-01 20:39:14 +00:00
|
|
|
except CancelledError:
|
2023-07-11 16:22:02 +00:00
|
|
|
break
|
2023-04-28 12:57:35 +00:00
|
|
|
except CatchableError as exc:
|
2023-07-11 16:22:02 +00:00
|
|
|
warn "Unexpected error in request manager block loop", exc = exc.msg
|
2023-04-28 12:57:35 +00:00
|
|
|
|
|
|
|
|
2023-08-01 20:39:14 +00:00
|
|
|
proc getMissingBlobs(rman: RequestManager): seq[BlobIdentifier] =
|
|
|
|
let
|
|
|
|
wallTime = rman.getBeaconTime()
|
|
|
|
wallSlot = wallTime.slotOrZero()
|
|
|
|
delay = wallTime - wallSlot.start_beacon_time()
|
|
|
|
waitDur = TimeDiff(nanoseconds: BLOB_GOSSIP_WAIT_TIME_NS)
|
2023-04-28 12:57:35 +00:00
|
|
|
|
2023-08-01 20:39:14 +00:00
|
|
|
var fetches: seq[BlobIdentifier]
|
|
|
|
for blobless in rman.quarantine[].peekBlobless():
|
2023-04-28 12:57:35 +00:00
|
|
|
|
2023-08-01 20:39:14 +00:00
|
|
|
# give blobs a chance to arrive over gossip
|
|
|
|
if blobless.message.slot == wallSlot and delay < waitDur:
|
|
|
|
debug "Not handling missing blobs early in slot"
|
|
|
|
continue
|
2023-04-28 12:57:35 +00:00
|
|
|
|
2023-08-01 20:39:14 +00:00
|
|
|
if not rman.blobQuarantine[].hasBlobs(blobless):
|
|
|
|
let missing = rman.blobQuarantine[].blobFetchRecord(blobless)
|
|
|
|
if len(missing.indices) == 0:
|
|
|
|
warn "quarantine missing blobs, but missing indices is empty",
|
|
|
|
blk=blobless.root,
|
|
|
|
indices=rman.blobQuarantine[].blobIndices(blobless.root),
|
|
|
|
kzgs=len(blobless.message.body.blob_kzg_commitments)
|
|
|
|
for idx in missing.indices:
|
|
|
|
let id = BlobIdentifier(block_root: blobless.root, index: idx)
|
|
|
|
if id notin fetches:
|
|
|
|
fetches.add(id)
|
|
|
|
else:
|
|
|
|
# this is a programming error should it occur.
|
|
|
|
warn "missing blob handler found blobless block with all blobs",
|
|
|
|
blk=blobless.root,
|
|
|
|
indices=rman.blobQuarantine[].blobIndices(blobless.root),
|
|
|
|
kzgs=len(blobless.message.body.blob_kzg_commitments)
|
|
|
|
discard rman.blockVerifier(ForkedSignedBeaconBlock.init(blobless),
|
|
|
|
false)
|
|
|
|
rman.quarantine[].removeBlobless(blobless)
|
|
|
|
fetches
|
2023-04-28 12:57:35 +00:00
|
|
|
|
|
|
|
|
2023-08-01 20:39:14 +00:00
|
|
|
proc requestManagerBlobLoop(rman: RequestManager) {.async.} =
|
|
|
|
while true:
|
|
|
|
# TODO This polling could be replaced with an AsyncEvent that is fired
|
|
|
|
# from the quarantine when there's work to do
|
|
|
|
await sleepAsync(POLL_INTERVAL)
|
|
|
|
if rman.inhibit():
|
|
|
|
continue
|
2020-06-18 10:03:36 +00:00
|
|
|
|
2023-08-01 20:39:14 +00:00
|
|
|
let fetches = rman.getMissingBlobs()
|
|
|
|
if fetches.len > 0:
|
|
|
|
debug "Requesting detected missing blobs", blobs = shortLog(fetches)
|
|
|
|
try:
|
|
|
|
let start = SyncMoment.now(0)
|
|
|
|
var workers: array[PARALLEL_REQUESTS, Future[void]]
|
|
|
|
for i in 0 ..< PARALLEL_REQUESTS:
|
|
|
|
workers[i] = rman.fetchBlobsFromNetwork(fetches)
|
|
|
|
|
|
|
|
await allFutures(workers)
|
|
|
|
let finish = SyncMoment.now(uint64(len(fetches)))
|
|
|
|
|
|
|
|
var succeed = 0
|
|
|
|
for worker in workers:
|
|
|
|
if worker.finished() and not(worker.failed()):
|
|
|
|
inc(succeed)
|
|
|
|
|
|
|
|
debug "Request manager blob tick",
|
|
|
|
blobs_count = len(fetches),
|
|
|
|
succeed = succeed,
|
|
|
|
failed = (len(workers) - succeed),
|
|
|
|
sync_speed = speed(start, finish)
|
|
|
|
|
|
|
|
except CancelledError as exc:
|
|
|
|
break
|
|
|
|
except CatchableError as exc:
|
|
|
|
warn "Unexpected error in request manager blob loop", exc = exc.msg
|
2020-06-18 10:03:36 +00:00
|
|
|
|
|
|
|
proc start*(rman: var RequestManager) =
|
2023-04-28 12:57:35 +00:00
|
|
|
## Start Request Manager's loops.
|
|
|
|
rman.blockLoopFuture = rman.requestManagerBlockLoop()
|
|
|
|
rman.blobLoopFuture = rman.requestManagerBlobLoop()
|
2020-06-18 10:03:36 +00:00
|
|
|
|
|
|
|
proc stop*(rman: RequestManager) =
|
|
|
|
## Stop Request Manager's loop.
|
2023-04-28 12:57:35 +00:00
|
|
|
if not(isNil(rman.blockLoopFuture)):
|
2023-09-22 11:06:27 +00:00
|
|
|
rman.blockLoopFuture.cancelSoon()
|
2023-04-28 12:57:35 +00:00
|
|
|
if not(isNil(rman.blobLoopFuture)):
|
2023-09-22 11:06:27 +00:00
|
|
|
rman.blobLoopFuture.cancelSoon()
|
2020-02-26 13:31:24 +00:00
|
|
|
|