2018-04-06 14:52:10 +00:00
|
|
|
# Nimbus
|
|
|
|
# Copyright (c) 2018 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.
|
|
|
|
|
2018-01-17 11:24:09 +00:00
|
|
|
import
|
2019-11-13 14:49:39 +00:00
|
|
|
os, macros, json, strformat, strutils, parseutils, os, tables,
|
2019-05-30 16:42:55 +00:00
|
|
|
stew/byteutils, stew/ranges/typedranges, net, eth/[common, keys, rlp, p2p], unittest2,
|
2019-11-13 14:49:39 +00:00
|
|
|
../nimbus/[vm_state, config, transaction, utils, errors],
|
2018-06-15 09:11:25 +00:00
|
|
|
../nimbus/db/[db_chain, state_db],
|
2019-11-13 14:49:39 +00:00
|
|
|
../nimbus/vm/interpreter/vm_forks,
|
2018-09-19 16:46:14 +00:00
|
|
|
../tests/test_generalstate_failing
|
2018-01-17 11:24:09 +00:00
|
|
|
|
2019-08-26 15:19:18 +00:00
|
|
|
func revmap(x: Table[Fork, string]): Table[string, Fork] =
|
|
|
|
result = initTable[string, Fork]()
|
|
|
|
for k, v in x:
|
|
|
|
result[v] = k
|
|
|
|
|
2018-11-28 19:02:21 +00:00
|
|
|
const
|
|
|
|
# from https://ethereum-tests.readthedocs.io/en/latest/test_types/state_tests.html
|
|
|
|
forkNames* = {
|
|
|
|
FkFrontier: "Frontier",
|
|
|
|
FkHomestead: "Homestead",
|
|
|
|
FkTangerine: "EIP150",
|
|
|
|
FkSpurious: "EIP158",
|
|
|
|
FkByzantium: "Byzantium",
|
2019-11-13 11:39:35 +00:00
|
|
|
FkConstantinople: "ConstantinopleFix",
|
|
|
|
FkIstanbul: "Istanbul"
|
2018-11-28 19:02:21 +00:00
|
|
|
}.toTable
|
|
|
|
|
2019-11-13 11:39:35 +00:00
|
|
|
supportedForks* = {
|
|
|
|
FkFrontier,
|
|
|
|
FkHomestead,
|
|
|
|
FkTangerine,
|
|
|
|
FkSpurious,
|
|
|
|
FkByzantium,
|
|
|
|
FkConstantinople,
|
|
|
|
FkIstanbul}
|
2018-11-28 19:02:21 +00:00
|
|
|
|
2019-08-26 15:19:18 +00:00
|
|
|
nameToFork* = revmap(forkNames)
|
|
|
|
|
2018-02-27 18:10:34 +00:00
|
|
|
type
|
|
|
|
Status* {.pure.} = enum OK, Fail, Skip
|
|
|
|
|
2018-09-07 19:44:17 +00:00
|
|
|
func slowTest*(folder: string, name: string): bool =
|
2018-11-28 19:02:21 +00:00
|
|
|
result =
|
|
|
|
(folder == "vmPerformance" and "loop" in name) or
|
|
|
|
folder == "stQuadraticComplexityTest" or
|
|
|
|
name in @["randomStatetest352.json", "randomStatetest1.json",
|
|
|
|
"randomStatetest32.json", "randomStatetest347.json",
|
|
|
|
"randomStatetest393.json", "randomStatetest626.json",
|
|
|
|
"CALLCODE_Bounds.json", "DELEGATECALL_Bounds3.json",
|
|
|
|
"CALLCODE_Bounds4.json", "CALL_Bounds.json",
|
|
|
|
"DELEGATECALL_Bounds2.json", "CALL_Bounds3.json",
|
|
|
|
"CALLCODE_Bounds2.json", "CALLCODE_Bounds3.json",
|
|
|
|
"DELEGATECALL_Bounds.json", "CALL_Bounds2a.json",
|
|
|
|
"CALL_Bounds2.json",
|
|
|
|
"CallToNameRegistratorMemOOGAndInsufficientBalance.json",
|
2019-05-08 11:41:25 +00:00
|
|
|
"CallToNameRegistratorTooMuchMemory0.json",
|
|
|
|
|
|
|
|
# all these tests below actually pass
|
|
|
|
# but they are very slow
|
|
|
|
|
|
|
|
# constantinople slow tests
|
|
|
|
"Create2Recursive.json",
|
|
|
|
|
|
|
|
# byzantium slow tests
|
|
|
|
"LoopCallsDepthThenRevert3.json",
|
|
|
|
"LoopCallsDepthThenRevert2.json",
|
|
|
|
"LoopCallsDepthThenRevert.json",
|
|
|
|
"static_Call50000.json",
|
|
|
|
"static_Call50000_ecrec.json",
|
|
|
|
"static_Call50000_identity.json",
|
|
|
|
"static_Call50000_identity2.json",
|
|
|
|
"static_Call50000_rip160.json",
|
|
|
|
"static_Call50000_sha256.json",
|
|
|
|
"LoopCallsThenRevert.json",
|
|
|
|
"LoopDelegateCallsDepthThenRevert.json",
|
|
|
|
"recursiveCreateReturnValue.json",
|
|
|
|
"static_Call1024PreCalls2.json",
|
|
|
|
"Callcode1024BalanceTooLow.json",
|
|
|
|
"static_Call1024BalanceTooLow.json",
|
|
|
|
"static_Call1024BalanceTooLow2.json",
|
|
|
|
"static_Call1024OOG.json",
|
|
|
|
"static_Call1024PreCalls3.json",
|
|
|
|
"static_Call1024PreCalls.json",
|
|
|
|
"static_Call1MB1024Calldepth.json",
|
|
|
|
|
|
|
|
# Homestead recursives
|
|
|
|
"ContractCreationSpam.json",
|
|
|
|
"Call1024OOG.json",
|
|
|
|
"Call1024PreCalls.json",
|
|
|
|
"CallRecursiveBombPreCall.json",
|
|
|
|
"Delegatecall1024.json",
|
|
|
|
"Delegatecall1024OOG.json",
|
|
|
|
"JUMPDEST_Attack.json",
|
|
|
|
"JUMPDEST_AttackwithJump.json",
|
|
|
|
"ABAcalls1.json",
|
|
|
|
"ABAcalls2.json",
|
|
|
|
"CallRecursiveBomb0.json",
|
|
|
|
"CallRecursiveBomb0_OOG_atMaxCallDepth.json",
|
|
|
|
"CallRecursiveBomb1.json",
|
|
|
|
"CallRecursiveBomb2.json",
|
|
|
|
"CallRecursiveBombLog.json",
|
|
|
|
"CallRecursiveBombLog2.json",
|
|
|
|
"Call1024BalanceTooLow.json",
|
|
|
|
|
|
|
|
# Frontier recursives
|
|
|
|
"Callcode1024OOG.json",
|
|
|
|
"callcallcodecall_ABCB_RECURSIVE.json",
|
|
|
|
"callcallcodecallcode_ABCB_RECURSIVE.json",
|
|
|
|
"callcodecallcall_ABCB_RECURSIVE.json",
|
|
|
|
"callcodecallcallcode_ABCB_RECURSIVE.json",
|
|
|
|
"callcodecallcodecall_ABCB_RECURSIVE.json",
|
|
|
|
"callcodecallcodecallcode_ABCB_RECURSIVE.json",
|
2019-09-03 15:06:43 +00:00
|
|
|
"callcallcallcode_ABCB_RECURSIVE.json",
|
|
|
|
|
|
|
|
# BlockChain slow tests
|
2019-11-18 13:49:24 +00:00
|
|
|
"SuicideIssue.json",
|
|
|
|
"CALLBlake2f_MaxRounds.json"
|
2019-09-03 15:06:43 +00:00
|
|
|
]
|
2018-09-07 19:44:17 +00:00
|
|
|
|
2018-09-19 16:46:14 +00:00
|
|
|
func failIn32Bits(folder, name: string): bool =
|
|
|
|
return name in @[
|
2019-05-02 02:21:40 +00:00
|
|
|
"sha3_bigSize.json", # from vm_test
|
2019-08-17 14:50:51 +00:00
|
|
|
|
|
|
|
# crash with OOM
|
|
|
|
"static_Return50000_2.json",
|
|
|
|
"randomStatetest185.json",
|
|
|
|
"randomStatetest159.json",
|
2019-08-17 15:26:06 +00:00
|
|
|
"randomStatetest48.json",
|
|
|
|
|
|
|
|
# OOM in AppVeyor, not on my machine
|
2019-09-03 03:45:48 +00:00
|
|
|
"randomStatetest36.json",
|
|
|
|
|
|
|
|
# from test_transaction_json
|
|
|
|
"RLPHeaderSizeOverflowInt32.json"
|
2018-11-12 19:55:38 +00:00
|
|
|
]
|
2018-09-19 16:46:14 +00:00
|
|
|
|
2018-10-03 22:21:00 +00:00
|
|
|
func allowedFailInCurrentBuild(folder, name: string): bool =
|
|
|
|
when sizeof(int) == 4:
|
|
|
|
if failIn32Bits(folder, name):
|
|
|
|
return true
|
|
|
|
return allowedFailingGeneralStateTest(folder, name)
|
|
|
|
|
2018-09-05 23:48:06 +00:00
|
|
|
func validTest*(folder: string, name: string): bool =
|
2018-11-28 19:02:21 +00:00
|
|
|
# we skip tests that are slow or expected to fail for now
|
|
|
|
result =
|
|
|
|
not slowTest(folder, name) and
|
|
|
|
not allowedFailInCurrentBuild(folder, name)
|
2018-02-27 18:10:34 +00:00
|
|
|
|
2019-03-15 15:34:05 +00:00
|
|
|
proc lacksSupportedForks*(fixtures: JsonNode): bool =
|
2019-05-30 16:42:55 +00:00
|
|
|
# XXX: Until Nimbus supports all forks, some of the GeneralStateTests won't work.
|
2019-03-15 15:34:05 +00:00
|
|
|
|
2018-09-12 16:33:53 +00:00
|
|
|
var fixture: JsonNode
|
|
|
|
for label, child in fixtures:
|
|
|
|
fixture = child
|
|
|
|
break
|
|
|
|
|
2018-11-28 19:02:21 +00:00
|
|
|
# not all fixtures make a distinction between forks, so default to accepting
|
|
|
|
# them all, until we find the ones that specify forks in their "post" section
|
|
|
|
result = false
|
2019-03-15 15:34:05 +00:00
|
|
|
if fixture.kind == JObject and fixture.hasKey("transaction") and fixture.hasKey("post"):
|
2018-11-28 19:02:21 +00:00
|
|
|
result = true
|
|
|
|
for fork in supportedForks:
|
2019-03-15 15:34:05 +00:00
|
|
|
if fixture["post"].hasKey(forkNames[fork]):
|
2018-11-28 19:02:21 +00:00
|
|
|
result = false
|
|
|
|
break
|
2018-09-12 16:33:53 +00:00
|
|
|
|
2019-05-30 16:42:55 +00:00
|
|
|
var status = initOrderedTable[string, OrderedTable[string, Status]]()
|
|
|
|
|
2018-02-27 18:10:34 +00:00
|
|
|
macro jsonTest*(s: static[string], handler: untyped): untyped =
|
2018-07-06 15:08:31 +00:00
|
|
|
let
|
|
|
|
testStatusIMPL = ident("testStatusIMPL")
|
2019-05-30 16:42:55 +00:00
|
|
|
testName = ident("testName")
|
2018-07-06 15:08:31 +00:00
|
|
|
# workaround for strformat in quote do: https://github.com/nim-lang/Nim/issues/8220
|
|
|
|
symbol = newIdentNode"symbol"
|
|
|
|
final = newIdentNode"final"
|
|
|
|
name = newIdentNode"name"
|
|
|
|
formatted = newStrLitNode"{symbol[final]} {name:<64}{$final}{'\n'}"
|
2018-11-28 19:02:21 +00:00
|
|
|
|
2018-02-13 17:18:08 +00:00
|
|
|
result = quote:
|
2019-05-30 16:42:55 +00:00
|
|
|
var filenames: seq[string] = @[]
|
2018-02-27 18:10:34 +00:00
|
|
|
for filename in walkDirRec("tests" / "fixtures" / `s`):
|
2018-11-28 19:02:21 +00:00
|
|
|
if not filename.endsWith(".json"):
|
|
|
|
continue
|
2018-02-27 18:10:34 +00:00
|
|
|
var (folder, name) = filename.splitPath()
|
|
|
|
let last = folder.splitPath().tail
|
|
|
|
if not status.hasKey(last):
|
|
|
|
status[last] = initOrderedTable[string, Status]()
|
|
|
|
status[last][name] = Status.Skip
|
2019-03-15 15:34:05 +00:00
|
|
|
if last.validTest(name):
|
2019-05-30 16:42:55 +00:00
|
|
|
filenames.add(filename)
|
|
|
|
for fname in filenames:
|
|
|
|
test fname:
|
|
|
|
{.gcsafe.}:
|
|
|
|
let
|
|
|
|
filename = `testName` # the first argument passed to the `test` template
|
|
|
|
(folder, name) = filename.splitPath()
|
|
|
|
last = folder.splitPath().tail
|
|
|
|
# we set this here because exceptions might be raised in the handler:
|
|
|
|
status[last][name] = Status.Fail
|
|
|
|
let fixtures = parseJSON(readFile(filename))
|
|
|
|
if fixtures.lacksSupportedForks:
|
|
|
|
status[last][name] = Status.Skip
|
|
|
|
skip()
|
|
|
|
else:
|
|
|
|
when not paralleliseTests:
|
|
|
|
echo filename
|
|
|
|
`handler`(fixtures, `testStatusIMPL`)
|
|
|
|
if `testStatusIMPL` == OK:
|
|
|
|
status[last][name] = Status.OK
|
|
|
|
|
|
|
|
suiteTeardown:
|
|
|
|
status.sort do (a: (string, OrderedTable[string, Status]),
|
|
|
|
b: (string, OrderedTable[string, Status])) -> int: cmp(a[0], b[0])
|
|
|
|
|
|
|
|
let `symbol`: array[Status, string] = ["+", "-", " "]
|
|
|
|
var raw = ""
|
|
|
|
var okCountTotal = 0
|
|
|
|
var failCountTotal = 0
|
|
|
|
var skipCountTotal = 0
|
|
|
|
raw.add(`s` & "\n")
|
|
|
|
raw.add("===\n")
|
|
|
|
for folder, statuses in status:
|
|
|
|
raw.add("## " & folder & "\n")
|
|
|
|
raw.add("```diff\n")
|
|
|
|
var sortedStatuses = statuses
|
|
|
|
sortedStatuses.sort do (a: (string, Status), b: (string, Status)) -> int:
|
|
|
|
cmp(a[0], b[0])
|
|
|
|
var okCount = 0
|
|
|
|
var failCount = 0
|
|
|
|
var skipCount = 0
|
|
|
|
for `name`, `final` in sortedStatuses:
|
|
|
|
raw.add(&`formatted`)
|
|
|
|
case `final`:
|
|
|
|
of Status.OK: okCount += 1
|
|
|
|
of Status.Fail: failCount += 1
|
|
|
|
of Status.Skip: skipCount += 1
|
|
|
|
raw.add("```\n")
|
|
|
|
let sum = okCount + failCount + skipCount
|
|
|
|
okCountTotal += okCount
|
|
|
|
failCountTotal += failCount
|
|
|
|
skipCountTotal += skipCount
|
|
|
|
raw.add("OK: " & $okCount & "/" & $sum & " Fail: " & $failCount & "/" & $sum & " Skip: " & $skipCount & "/" & $sum & "\n")
|
|
|
|
|
|
|
|
let sumTotal = okCountTotal + failCountTotal + skipCountTotal
|
|
|
|
raw.add("\n---TOTAL---\n")
|
|
|
|
raw.add("OK: $1/$4 Fail: $2/$4 Skip: $3/$4\n" % [$okCountTotal, $failCountTotal, $skipCountTotal, $sumTotal])
|
|
|
|
writeFile(`s` & ".md", raw)
|
|
|
|
status.clear()
|
2018-01-17 11:24:09 +00:00
|
|
|
|
2018-09-13 15:00:23 +00:00
|
|
|
func ethAddressFromHex*(s: string): EthAddress = hexToByteArray(s, result)
|
2018-05-30 16:11:15 +00:00
|
|
|
|
2018-09-15 20:39:30 +00:00
|
|
|
func safeHexToSeqByte*(hexStr: string): seq[byte] =
|
|
|
|
if hexStr == "":
|
|
|
|
@[]
|
|
|
|
else:
|
|
|
|
hexStr.hexToSeqByte
|
|
|
|
|
2019-08-17 14:48:45 +00:00
|
|
|
func getHexadecimalInt*(j: JsonNode): int64 =
|
|
|
|
# parseutils.parseHex works with int which will overflow in 32 bit
|
|
|
|
var data: StUInt[64]
|
|
|
|
data = fromHex(StUInt[64], j.getStr)
|
|
|
|
result = cast[int64](data)
|
|
|
|
|
2018-07-05 12:41:01 +00:00
|
|
|
proc setupStateDB*(wantedState: JsonNode, stateDB: var AccountStateDB) =
|
|
|
|
for ac, accountData in wantedState:
|
|
|
|
let account = ethAddressFromHex(ac)
|
2018-02-13 17:18:08 +00:00
|
|
|
for slot, value in accountData{"storage"}:
|
2018-09-04 12:30:15 +00:00
|
|
|
stateDB.setStorage(account, fromHex(UInt256, slot), fromHex(UInt256, value.getStr))
|
|
|
|
|
2019-08-17 14:48:45 +00:00
|
|
|
let nonce = accountData{"nonce"}.getHexadecimalInt.AccountNonce
|
2018-09-15 20:39:30 +00:00
|
|
|
let code = accountData{"code"}.getStr.safeHexToSeqByte.toRange
|
2018-07-17 08:57:14 +00:00
|
|
|
let balance = UInt256.fromHex accountData{"balance"}.getStr
|
2018-01-17 11:24:09 +00:00
|
|
|
|
2018-02-13 17:18:08 +00:00
|
|
|
stateDB.setNonce(account, nonce)
|
|
|
|
stateDB.setCode(account, code)
|
|
|
|
stateDB.setBalance(account, balance)
|
2018-02-14 16:38:01 +00:00
|
|
|
|
2018-12-31 03:28:12 +00:00
|
|
|
proc verifyStateDB*(wantedState: JsonNode, stateDB: ReadOnlyStateDB) =
|
2018-07-05 12:41:01 +00:00
|
|
|
for ac, accountData in wantedState:
|
|
|
|
let account = ethAddressFromHex(ac)
|
|
|
|
for slot, value in accountData{"storage"}:
|
|
|
|
let
|
2018-07-17 08:57:14 +00:00
|
|
|
slotId = UInt256.fromHex slot
|
2018-07-05 12:41:01 +00:00
|
|
|
wantedValue = UInt256.fromHex value.getStr
|
|
|
|
|
|
|
|
let (actualValue, found) = stateDB.getStorage(account, slotId)
|
2019-09-23 15:50:13 +00:00
|
|
|
if not found:
|
|
|
|
raise newException(ValidationError, "account not found: " & ac)
|
|
|
|
if actualValue != wantedValue:
|
2019-09-25 13:05:33 +00:00
|
|
|
raise newException(ValidationError, &"{ac} storageDiff: [{slot}] {actualValue.toHex} != {wantedValue.toHex}")
|
2018-07-05 12:41:01 +00:00
|
|
|
|
|
|
|
let
|
|
|
|
wantedCode = hexToSeqByte(accountData{"code"}.getStr).toRange
|
2018-07-17 08:57:14 +00:00
|
|
|
wantedBalance = UInt256.fromHex accountData{"balance"}.getStr
|
2019-09-23 15:50:13 +00:00
|
|
|
wantedNonce = accountData{"nonce"}.getHexadecimalInt.AccountNonce
|
2018-07-05 12:41:01 +00:00
|
|
|
|
|
|
|
actualCode = stateDB.getCode(account)
|
|
|
|
actualBalance = stateDB.getBalance(account)
|
|
|
|
actualNonce = stateDB.getNonce(account)
|
|
|
|
|
2019-09-23 15:50:13 +00:00
|
|
|
if wantedCode != actualCode:
|
2019-09-25 13:05:33 +00:00
|
|
|
raise newException(ValidationError, &"{ac} codeDiff {wantedCode} != {actualCode}")
|
2019-09-23 15:50:13 +00:00
|
|
|
if wantedBalance != actualBalance:
|
2019-09-25 13:05:33 +00:00
|
|
|
raise newException(ValidationError, &"{ac} balanceDiff {wantedBalance.toHex} != {actualBalance.toHex}")
|
2019-09-23 15:50:13 +00:00
|
|
|
if wantedNonce != actualNonce:
|
2019-09-25 13:05:33 +00:00
|
|
|
raise newException(ValidationError, &"{ac} nonceDiff {wantedNonce.toHex} != {actualNonce.toHex}")
|
2018-07-05 12:41:01 +00:00
|
|
|
|
2018-09-19 16:46:14 +00:00
|
|
|
proc getFixtureTransaction*(j: JsonNode, dataIndex, gasIndex, valueIndex: int): Transaction =
|
2019-08-17 14:48:45 +00:00
|
|
|
result.accountNonce = j["nonce"].getHexadecimalInt.AccountNonce
|
|
|
|
result.gasPrice = j["gasPrice"].getHexadecimalInt
|
|
|
|
result.gasLimit = j["gasLimit"][gasIndex].getHexadecimalInt
|
2018-09-11 13:15:59 +00:00
|
|
|
|
2018-09-29 15:36:42 +00:00
|
|
|
# TODO: there are a couple fixtures which appear to distinguish between
|
2018-10-01 19:12:14 +00:00
|
|
|
# empty and 0 transaction.to; check/verify whether correct conditions.
|
2018-09-11 13:15:59 +00:00
|
|
|
let rawTo = j["to"].getStr
|
2018-09-29 15:36:42 +00:00
|
|
|
if rawTo == "":
|
|
|
|
result.to = "0x".parseAddress
|
|
|
|
result.isContractCreation = true
|
|
|
|
else:
|
|
|
|
result.to = rawTo.parseAddress
|
|
|
|
result.isContractCreation = false
|
2018-09-19 16:46:14 +00:00
|
|
|
result.value = fromHex(UInt256, j["value"][valueIndex].getStr)
|
|
|
|
result.payload = j["data"][dataIndex].getStr.safeHexToSeqByte
|
2018-09-05 23:48:06 +00:00
|
|
|
|
|
|
|
var secretKey = j["secretKey"].getStr
|
|
|
|
removePrefix(secretKey, "0x")
|
|
|
|
let privateKey = initPrivateKey(secretKey)
|
2019-03-02 12:05:24 +00:00
|
|
|
let sig = signMessage(privateKey, result.rlpEncode)
|
|
|
|
let raw = sig.getRaw()
|
2018-09-05 23:48:06 +00:00
|
|
|
|
2019-03-02 12:05:24 +00:00
|
|
|
result.R = fromBytesBE(Uint256, raw[0..31])
|
|
|
|
result.S = fromBytesBE(Uint256, raw[32..63])
|
|
|
|
result.V = raw[64] + 27.byte
|
2019-03-18 04:27:32 +00:00
|
|
|
|
2019-03-18 01:55:02 +00:00
|
|
|
proc hashLogEntries*(logs: seq[Log]): string =
|
|
|
|
toLowerAscii("0x" & $keccakHash(rlp.encode(logs)))
|
2019-04-24 20:45:29 +00:00
|
|
|
|
|
|
|
proc setupEthNode*(capabilities: varargs[ProtocolInfo, `protocolInfo`]): EthereumNode =
|
|
|
|
var
|
|
|
|
conf = getConfiguration()
|
|
|
|
keypair: KeyPair
|
|
|
|
keypair.seckey = conf.net.nodekey
|
|
|
|
keypair.pubkey = conf.net.nodekey.getPublicKey()
|
|
|
|
|
|
|
|
var srvAddress: Address
|
|
|
|
srvAddress.ip = parseIpAddress("0.0.0.0")
|
|
|
|
srvAddress.tcpPort = Port(conf.net.bindPort)
|
|
|
|
srvAddress.udpPort = Port(conf.net.discPort)
|
|
|
|
result = newEthereumNode(keypair, srvAddress, conf.net.networkId,
|
|
|
|
nil, "nimbus 0.1.0", addAllCapabilities = false)
|
|
|
|
for capability in capabilities:
|
2019-05-30 16:42:55 +00:00
|
|
|
result.addCapability capability
|