Merge pull request #3520 from nucypher/v7.5.x

[EPIC] v7.5.x
main
Derek Pierre 2025-04-08 13:49:30 -04:00 committed by GitHub
commit 9a262f3ab2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
138 changed files with 15196 additions and 5187 deletions

View File

@ -1,5 +1,5 @@
[bumpversion]
current_version = 7.4.1
current_version = 7.5.0
commit = True
tag = False
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(-(?P<stage>[^.]*)\.(?P<devnum>\d+))?

View File

@ -11,6 +11,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.12"
- name: Checkout code
uses: actions/checkout@v4

View File

@ -15,6 +15,9 @@ jobs:
- name: Checkout repo
uses: actions/checkout@v4
- name: Install latest Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Set up Python
uses: actions/setup-python@v4
with:

View File

@ -6,7 +6,7 @@ repos:
- id: tests
name: Run Nucypher Unit Tests
entry: scripts/hooks/run_unit_tests.sh
entry: scripts/run_unit_tests.sh
language: system
types: [python]
stages: [push] # required additional setup: pre-commit install && pre-commit install -t pre-push

View File

@ -16,7 +16,9 @@ secrets management and dynamic access control.*
TACo is end-to-end encrypted data sharing and communication, without the requirement of
trusting a centralized authority, who might unilaterally deny service or even decrypt private user data. It is the only
access control layer available to Web3 developers that can offer a decentralized service, through a live,
well-collateralized and battle-tested network. See more here: [https://docs.threshold.network/applications/threshold-access-control](https://docs.threshold.network/applications/threshold-access-control)
well-collateralized and battle-tested network.
See more in the [TACo docs](https://docs.taco.build/).
# Getting Involved

View File

@ -13,6 +13,6 @@ We are happy to work together to use a more secure medium, such as Signal.
Email security@nucypher.com and we will coordinate a communication channel that we're both comfortable with.
A great place to begin your research is by working on our testnet.
Please see our [documentation](https://docs.threshold.network) to get started.
Please see our [documentation](https://docs.taco.build) to get started.
We ask that you please respect network machines and their owners.
If you find a vulnerability that you suspect has given you access to a machine against the owner's permission, stop what you're doing and immediately email security@nucypher.com.

View File

@ -1,185 +1,183 @@
abnf==2.2.0 ; python_version >= "3.9" and python_version < "4.0"
aiohappyeyeballs==2.3.2 ; python_version >= "3.9" and python_version < "4.0"
aiohttp==3.10.0 ; python_version >= "3.9" and python_version < "4"
aiosignal==1.3.1 ; python_version >= "3.9" and python_version < "4"
annotated-types==0.7.0 ; python_version >= "3.9" and python_version < "4.0"
ape-solidity==0.7.3 ; python_version >= "3.9" and python_version < "4"
appdirs==1.4.4 ; python_version >= "3.9" and python_version < "4"
asttokens==2.4.1 ; python_version >= "3.9" and python_version < "4"
async-timeout==4.0.3 ; python_version >= "3.9" and python_version < "3.11"
atomicwrites==1.4.1 ; python_version >= "3.9" and python_version < "4" and sys_platform == "win32"
attrs==23.2.0 ; python_version >= "3.9" and python_version < "4"
atxm==0.5.0 ; python_version >= "3.9" and python_version < "4"
autobahn==23.6.2 ; python_version >= "3.9" and python_version < "4"
automat==22.10.0 ; python_version >= "3.9" and python_version < "4"
base58==1.0.3 ; python_version >= "3.9" and python_version < "4"
bitarray==2.9.2 ; python_version >= "3.9" and python_version < "4"
blinker==1.8.2 ; python_version >= "3.9" and python_version < "4"
bytestring-splitter==2.4.1 ; python_version >= "3.9" and python_version < "4"
cached-property==1.5.2 ; python_version >= "3.9" and python_version < "4"
certifi==2024.7.4 ; python_version >= "3.9" and python_version < "4"
cffi==1.16.0 ; python_version >= "3.9" and python_version < "4"
cfgv==3.4.0 ; python_version >= "3.9" and python_version < "4"
charset-normalizer==3.3.2 ; python_version >= "3.9" and python_version < "4"
ckzg==1.0.2 ; python_version >= "3.9" and python_version < "4"
click==8.1.7 ; python_version >= "3.9" and python_version < "4"
colorama==0.4.6 ; python_version >= "3.9" and python_version < "4"
constant-sorrow==0.1.0a9 ; python_version >= "3.9" and python_version < "4"
constantly==23.10.4 ; python_version >= "3.9" and python_version < "4"
coverage==7.6.0 ; python_version >= "3.9" and python_version < "4"
coverage[toml]==7.6.0 ; python_version >= "3.9" and python_version < "4"
cryptography==43.0.0 ; python_version >= "3.9" and python_version < "4"
cytoolz==0.12.3 ; python_version >= "3.9" and python_version < "4" and implementation_name == "cpython"
dataclassy==0.11.1 ; python_version >= "3.9" and python_version < "4"
dateparser==1.2.0 ; python_version >= "3.9" and python_version < "4"
decorator==5.1.1 ; python_version >= "3.9" and python_version < "4"
deprecated==1.2.14 ; python_version >= "3.9" and python_version < "4"
distlib==0.3.8 ; python_version >= "3.9" and python_version < "4"
eip712==0.2.7 ; python_version >= "3.9" and python_version < "4"
eth-abi==5.1.0 ; python_version >= "3.9" and python_version < "4"
eth-account==0.11.2 ; python_version >= "3.9" and python_version < "4"
eth-ape==0.7.23 ; python_version >= "3.9" and python_version < "4"
eth-bloom==3.0.1 ; python_version >= "3.9" and python_version < "4"
eth-hash==0.7.0 ; python_version >= "3.9" and python_version < "4"
eth-hash[pycryptodome]==0.7.0 ; python_version >= "3.9" and python_version < "4"
eth-hash[pysha3]==0.7.0 ; python_version >= "3.9" and python_version < "4" and implementation_name == "cpython"
eth-keyfile==0.8.1 ; python_version >= "3.9" and python_version < "4"
eth-keys==0.5.1 ; python_version >= "3.9" and python_version < "4"
eth-pydantic-types==0.1.0 ; python_version >= "3.9" and python_version < "4"
eth-rlp==1.0.1 ; python_version >= "3.9" and python_version < "4"
eth-tester[py-evm]==0.11.0b2 ; python_version >= "3.9" and python_version < "4"
eth-typing==3.5.2 ; python_version >= "3.9" and python_version < "4"
eth-utils==2.3.1 ; python_version >= "3.9" and python_version < "4"
ethpm-types==0.6.14 ; python_version >= "3.9" and python_version < "4"
evm-trace==0.1.5 ; python_version >= "3.9" and python_version < "4"
evmchains==0.0.11 ; python_version >= "3.9" and python_version < "4"
exceptiongroup==1.2.2 ; python_version >= "3.9" and python_version < "3.11"
executing==2.0.1 ; python_version >= "3.9" and python_version < "4"
filelock==3.15.4 ; python_version >= "3.9" and python_version < "4"
flask==3.0.3 ; python_version >= "3.9" and python_version < "4"
frozenlist==1.4.1 ; python_version >= "3.9" and python_version < "4"
greenlet==3.0.3 ; python_version >= "3.9" and python_version < "4"
hendrix==5.0.0 ; python_version >= "3.9" and python_version < "4"
hexbytes==0.3.1 ; python_version >= "3.9" and python_version < "4"
humanize==4.10.0 ; python_version >= "3.9" and python_version < "4"
hyperlink==21.0.0 ; python_version >= "3.9" and python_version < "4"
identify==2.6.0 ; python_version >= "3.9" and python_version < "4"
idna==3.7 ; python_version >= "3.9" and python_version < "4"
ijson==3.3.0 ; python_version >= "3.9" and python_version < "4"
importlib-metadata==8.2.0 ; python_version >= "3.9" and python_version < "3.10"
incremental==24.7.2 ; python_version >= "3.9" and python_version < "4"
iniconfig==2.0.0 ; python_version >= "3.9" and python_version < "4"
ipython==8.18.1 ; python_version >= "3.9" and python_version < "4"
itsdangerous==2.2.0 ; python_version >= "3.9" and python_version < "4"
jedi==0.19.1 ; python_version >= "3.9" and python_version < "4"
jinja2==3.1.4 ; python_version >= "3.9" and python_version < "4"
jsonschema-specifications==2023.12.1 ; python_version >= "3.9" and python_version < "4"
jsonschema==4.23.0 ; python_version >= "3.9" and python_version < "4"
lazyasd==0.1.4 ; python_version >= "3.9" and python_version < "4"
lru-dict==1.2.0 ; python_version >= "3.9" and python_version < "4"
mako==1.3.5 ; python_version >= "3.9" and python_version < "4"
markdown-it-py==3.0.0 ; python_version >= "3.9" and python_version < "4"
markupsafe==2.1.5 ; python_version >= "3.9" and python_version < "4"
marshmallow==3.21.3 ; python_version >= "3.9" and python_version < "4"
matplotlib-inline==0.1.7 ; python_version >= "3.9" and python_version < "4"
maya==0.6.1 ; python_version >= "3.9" and python_version < "4"
mdurl==0.1.2 ; python_version >= "3.9" and python_version < "4"
mnemonic==0.21 ; python_version >= "3.9" and python_version < "4"
morphys==1.0 ; python_version >= "3.9" and python_version < "4"
msgpack-python==0.5.6 ; python_version >= "3.9" and python_version < "4"
msgspec==0.18.6 ; python_version >= "3.9" and python_version < "4"
multidict==6.0.5 ; python_version >= "3.9" and python_version < "4"
nodeenv==1.9.1 ; python_version >= "3.9" and python_version < "4"
nucypher-core==0.13.0 ; python_version >= "3.9" and python_version < "4"
numpy==1.26.4 ; python_version >= "3.9" and python_version < "4"
packaging==23.2 ; python_version >= "3.9" and python_version < "4"
pandas==1.5.3 ; python_version >= "3.9" and python_version < "4"
parsimonious==0.10.0 ; python_version >= "3.9" and python_version < "4"
parso==0.8.4 ; python_version >= "3.9" and python_version < "4"
pendulum==3.0.0 ; python_version >= "3.9" and python_version < "4"
pexpect==4.9.0 ; python_version >= "3.9" and python_version < "4" and sys_platform != "win32"
platformdirs==4.2.2 ; python_version >= "3.9" and python_version < "4"
pluggy==1.5.0 ; python_version >= "3.9" and python_version < "4"
pre-commit==2.21.0 ; python_version >= "3.9" and python_version < "4"
prometheus-client==0.20.0 ; python_version >= "3.9" and python_version < "4"
prompt-toolkit==3.0.47 ; python_version >= "3.9" and python_version < "4"
protobuf==5.27.2 ; python_version >= "3.9" and python_version < "4"
ptyprocess==0.7.0 ; python_version >= "3.9" and python_version < "4" and sys_platform != "win32"
pure-eval==0.2.3 ; python_version >= "3.9" and python_version < "4"
py-cid==0.3.0 ; python_version >= "3.9" and python_version < "4"
py-ecc==7.0.1 ; python_version >= "3.9" and python_version < "4"
py-evm==0.10.1b1 ; python_version >= "3.9" and python_version < "4"
py-geth==4.4.0 ; python_version >= "3.9" and python_version < "4"
py-multibase==1.0.3 ; python_version >= "3.9" and python_version < "4"
py-multicodec==0.2.1 ; python_version >= "3.9" and python_version < "4"
py-multihash==0.2.3 ; python_version >= "3.9" and python_version < "4"
py-solc-x==2.0.3 ; python_version >= "3.9" and python_version < "4"
py==1.11.0 ; python_version >= "3.9" and python_version < "4"
pyasn1-modules==0.4.0 ; python_version >= "3.9" and python_version < "4"
pyasn1==0.6.0 ; python_version >= "3.9" and python_version < "4"
pychalk==2.0.1 ; python_version >= "3.9" and python_version < "4"
pycparser==2.22 ; python_version >= "3.9" and python_version < "4"
pycryptodome==3.20.0 ; python_version >= "3.9" and python_version < "4"
pydantic-core==2.20.1 ; python_version >= "3.9" and python_version < "4.0"
pydantic-settings==2.4.0 ; python_version >= "3.9" and python_version < "4"
pydantic==2.8.2 ; python_version >= "3.9" and python_version < "4.0"
pygithub==1.59.1 ; python_version >= "3.9" and python_version < "4"
pygments==2.18.0 ; python_version >= "3.9" and python_version < "4"
pyjwt[crypto]==2.8.0 ; python_version >= "3.9" and python_version < "4"
pynacl==1.5.0 ; python_version >= "3.9" and python_version < "4"
pyopenssl==24.2.1 ; python_version >= "3.9" and python_version < "4"
pytest-cov==5.0.0 ; python_version >= "3.9" and python_version < "4"
pytest-mock==3.14.0 ; python_version >= "3.9" and python_version < "4"
pytest-timeout==2.2.0 ; python_version >= "3.9" and python_version < "4"
pytest-twisted==1.14.2 ; python_version >= "3.9" and python_version < "4"
pytest==6.2.5 ; python_version >= "3.9" and python_version < "4"
python-baseconv==1.2.2 ; python_version >= "3.9" and python_version < "4"
python-dateutil==2.9.0.post0 ; python_version >= "3.9" and python_version < "4"
python-dotenv==1.0.1 ; python_version >= "3.9" and python_version < "4"
pytz==2024.1 ; python_version >= "3.9" and python_version < "4"
pyunormalize==15.1.0 ; python_version >= "3.9" and python_version < "4"
pywin32==306 ; python_version >= "3.9" and python_version < "4" and platform_system == "Windows"
pyyaml==6.0.1 ; python_version >= "3.9" and python_version < "4"
referencing==0.35.1 ; python_version >= "3.9" and python_version < "4"
regex==2024.7.24 ; python_version >= "3.9" and python_version < "4"
requests==2.32.3 ; python_version >= "3.9" and python_version < "4"
rich==13.7.1 ; python_version >= "3.9" and python_version < "4"
rlp==4.0.1 ; python_version >= "3.9" and python_version < "4"
rpds-py==0.19.1 ; python_version >= "3.9" and python_version < "4"
abnf==2.3.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
aiohappyeyeballs==2.6.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
aiohttp==3.11.14 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
aiosignal==1.3.2 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
annotated-types==0.7.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
ape-solidity==0.8.5 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
appdirs==1.4.4 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
asttokens==3.0.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
async-timeout==5.0.1 ; python_version >= "3.9" and python_version < "3.11" and (implementation_name == "cpython" or implementation_name == "pypy")
attrs==25.3.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
atxm==0.5.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
autobahn==24.4.2 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
automat==24.8.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
base58==1.0.3 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
bitarray==3.2.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
blinker==1.9.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
bytestring-splitter==2.4.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
cached-property==2.0.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
certifi==2025.1.31 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
cffi==1.17.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
cfgv==3.4.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
charset-normalizer==3.4.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
ckzg==1.0.2 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
click==8.1.8 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
colorama==0.4.6 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
constant-sorrow==0.1.0a9 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
constantly==23.10.4 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
coverage==7.7.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
cryptography==43.0.3 ; python_version >= "3.9" and python_version < "3.11" and (implementation_name == "cpython" or implementation_name == "pypy")
cryptography==44.0.2 ; python_version >= "3.11" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
cytoolz==1.0.1 ; python_version >= "3.9" and python_version < "4" and implementation_name == "cpython"
dataclassy==0.11.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
dateparser==1.2.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
decorator==5.2.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
distlib==0.3.9 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
eip712==0.2.11 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
eth-abi==5.2.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
eth-account==0.11.3 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
eth-ape==0.8.12 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
eth-bloom==3.1.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
eth-hash==0.7.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
eth-keyfile==0.9.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
eth-keys==0.6.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
eth-pydantic-types==0.1.3 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
eth-rlp==1.0.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
eth-tester==0.11.0b2 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
eth-typing==3.5.2 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
eth-utils==2.3.2 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
ethpm-types==0.6.24 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
eval-type-backport==0.2.2 ; python_version == "3.9" and (implementation_name == "cpython" or implementation_name == "pypy")
evm-trace==0.2.4 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
evmchains==0.0.13 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
exceptiongroup==1.2.2 ; python_version >= "3.9" and python_version < "3.11" and (implementation_name == "cpython" or implementation_name == "pypy")
executing==2.2.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
filelock==3.18.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
flask==3.1.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
frozenlist==1.5.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
greenlet==3.1.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
hendrix==5.0.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
hexbytes==0.3.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
humanize==4.12.2 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
hyperlink==21.0.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
identify==2.6.9 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
idna==3.10 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
ijson==3.3.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
importlib-metadata==8.6.1 ; python_version == "3.9" and (implementation_name == "cpython" or implementation_name == "pypy")
incremental==24.7.2 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
iniconfig==2.1.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
ipython==8.18.1 ; python_version >= "3.9" and python_version < "3.11" and (implementation_name == "cpython" or implementation_name == "pypy")
ipython==8.34.0 ; python_version >= "3.11" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
itsdangerous==2.2.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
jedi==0.19.2 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
jinja2==3.1.6 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
jsonpath-ng==1.7.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
jsonschema-specifications==2024.10.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
jsonschema==4.23.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
lazyasd==0.1.4 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
lru-dict==1.2.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
mako==1.3.9 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
markdown-it-py==3.0.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
markupsafe==3.0.2 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
marshmallow==3.26.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
matplotlib-inline==0.1.7 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
maya==0.6.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
mdurl==0.1.2 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
mnemonic==0.21 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
morphys==1.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
msgpack-python==0.5.6 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
msgspec==0.19.0 ; python_version >= "3.9" and python_version < "3.13" and (implementation_name == "cpython" or implementation_name == "pypy")
multidict==6.2.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
nodeenv==1.9.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
nucypher-core==0.13.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
numpy==1.26.4 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
packaging==23.2 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
pandas==2.2.3 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
parsimonious==0.10.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
parso==0.8.4 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
pendulum==3.0.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
pexpect==4.9.0 ; python_version >= "3.9" and python_version < "4" and (sys_platform != "win32" and sys_platform != "emscripten" or python_version < "3.11") and sys_platform != "win32" and (implementation_name == "cpython" or implementation_name == "pypy")
platformdirs==4.3.7 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
pluggy==1.5.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
ply==3.11 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
pre-commit==2.21.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
prometheus-client==0.21.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
prompt-toolkit==3.0.50 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
propcache==0.3.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
protobuf==6.30.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
ptyprocess==0.7.0 ; python_version >= "3.9" and python_version < "4" and (sys_platform != "win32" and sys_platform != "emscripten" or python_version < "3.11") and sys_platform != "win32" and (implementation_name == "cpython" or implementation_name == "pypy")
pure-eval==0.2.3 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
py-cid==0.3.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
py-ecc==7.0.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
py-evm==0.10.1b1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
py-geth==5.3.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
py-multibase==1.0.3 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
py-multicodec==0.2.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
py-multihash==0.2.3 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
py-solc-x==2.0.3 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
pyasn1-modules==0.4.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
pyasn1==0.6.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
pychalk @ git+https://github.com/nucypher/pychalk.git@b1a5c7562f9788f9c8722216b32019eeca281b69 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
pycparser==2.22 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
pycryptodome==3.22.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
pydantic-core==2.27.2 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
pydantic-settings==2.8.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
pydantic==2.10.6 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
pygments==2.19.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
pyjwt==2.10.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
pynacl==1.5.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
pyopenssl==25.0.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
pytest-cov==6.0.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
pytest-mock==3.14.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
pytest-timeout==2.3.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
pytest-twisted==1.14.3 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
pytest==8.3.5 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
python-baseconv==1.2.2 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
python-dateutil==2.9.0.post0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
python-dotenv==1.0.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
python-statemachine==2.3.4 ; python_version >= "3.9" and python_version < "4"
pytz==2025.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
pyunormalize==16.0.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
pywin32==310 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy") and platform_system == "Windows"
pyyaml==6.0.2 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
referencing==0.36.2 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
regex==2024.11.6 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
requests==2.32.3 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
rich==13.9.4 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
rlp==4.1.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
rpds-py==0.23.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
safe-pysha3==1.0.4 ; python_version >= "3.9" and python_version < "4" and implementation_name == "cpython"
semantic-version==2.10.0 ; python_version >= "3.9" and python_version < "4"
service-identity==24.1.0 ; python_version >= "3.9" and python_version < "4"
setuptools==72.1.0 ; python_version >= "3.9" and python_version < "4"
siwe==4.2.0 ; python_version >= "3.9" and python_version < "4.0"
six==1.16.0 ; python_version >= "3.9" and python_version < "4"
snaptime==0.2.4 ; python_version >= "3.9" and python_version < "4"
sortedcontainers==2.4.0 ; python_version >= "3.9" and python_version < "4"
sqlalchemy==2.0.31 ; python_version >= "3.9" and python_version < "4"
stack-data==0.6.3 ; python_version >= "3.9" and python_version < "4"
tabulate==0.9.0 ; python_version >= "3.9" and python_version < "4"
time-machine==2.14.2 ; python_version >= "3.9" and python_version < "4"
toml==0.10.2 ; python_version >= "3.9" and python_version < "4"
tomli==2.0.1 ; python_version >= "3.9" and python_full_version <= "3.11.0a6"
toolz==0.12.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "pypy" or implementation_name == "cpython")
tqdm==4.66.4 ; python_version >= "3.9" and python_version < "4"
traitlets==5.14.3 ; python_version >= "3.9" and python_version < "4"
trie==3.0.1 ; python_version >= "3.9" and python_version < "4"
twisted-iocpsupport==1.0.4 ; python_version >= "3.9" and python_version < "4" and platform_system == "Windows"
twisted==24.3.0 ; python_version >= "3.9" and python_version < "4"
txaio==23.1.1 ; python_version >= "3.9" and python_version < "4"
typing-extensions==4.12.2 ; python_version >= "3.9" and python_version < "4"
tzdata==2024.1 ; python_version >= "3.9" and python_version < "4"
tzlocal==5.2 ; python_version >= "3.9" and python_version < "4"
urllib3==2.2.2 ; python_version >= "3.9" and python_version < "4"
varint==1.0.2 ; python_version >= "3.9" and python_version < "4"
virtualenv==20.26.3 ; python_version >= "3.9" and python_version < "4"
watchdog==3.0.0 ; python_version >= "3.9" and python_version < "4"
wcwidth==0.2.13 ; python_version >= "3.9" and python_version < "4"
web3==6.20.1 ; python_version >= "3.9" and python_version < "4"
web3[tester]==6.20.1 ; python_version >= "3.9" and python_version < "4"
websockets==12.0 ; python_version >= "3.9" and python_version < "4"
werkzeug==3.0.3 ; python_version >= "3.9" and python_version < "4"
wrapt==1.16.0 ; python_version >= "3.9" and python_version < "4"
yarl==1.9.4 ; python_version >= "3.9" and python_version < "4"
zipp==3.19.2 ; python_version >= "3.9" and python_version < "3.10"
zope-interface==6.4.post2 ; python_version >= "3.9" and python_version < "4"
semantic-version==2.10.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
service-identity==24.2.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
setuptools==78.0.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
siwe==4.2.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
six==1.17.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
snaptime @ git+https://github.com/nucypher/snaptime.git@7edf0f861198b3689ccf82602e3f7b0a24acf6d5 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
sortedcontainers==2.4.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
sqlalchemy==2.0.39 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
stack-data==0.6.3 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
tabulate==0.9.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
time-machine==2.16.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
toml==0.10.2 ; python_version >= "3.9" and python_version < "3.11" and (implementation_name == "cpython" or implementation_name == "pypy")
tomli==2.2.1 ; python_version >= "3.9" and python_version < "3.11" and (implementation_name == "cpython" or implementation_name == "pypy")
toolz==1.0.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
tqdm==4.67.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
traitlets==5.14.3 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
trie==3.1.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
twisted==24.11.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
txaio==23.1.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
types-requests==2.32.0.20250306 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
typing-extensions==4.12.2 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
tzdata==2025.2 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
tzlocal==5.3.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
urllib3==2.3.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
varint==1.0.2 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
virtualenv==20.29.3 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
watchdog==3.0.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
wcwidth==0.2.13 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
web3==6.20.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
websockets==15.0.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
werkzeug==3.1.3 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
yarl==1.18.3 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
zipp==3.21.0 ; python_version == "3.9" and (implementation_name == "cpython" or implementation_name == "pypy")
zope-interface==7.2 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")

View File

@ -39,7 +39,7 @@ coordinator_agent = CoordinatorAgent(
blockchain_endpoint=polygon_endpoint,
registry=registry,
)
ritual_id = 0 # got this from a side channel
ritual_id = 27 # got this from a side channel
ritual = coordinator_agent.get_ritual(ritual_id)
# known authorized encryptor for ritual 3

View File

@ -40,7 +40,7 @@ coordinator_agent = CoordinatorAgent(
blockchain_endpoint=polygon_endpoint,
registry=registry,
)
ritual_id = 0 # got this from a side channel
ritual_id = 27 # got this from a side channel
ritual = coordinator_agent.get_ritual(ritual_id)
# known authorized encryptor for ritual 3

View File

@ -0,0 +1 @@
Support for executing multiple conditions sequentially, where the outcome of one condition can be used as input for another.

View File

@ -0,0 +1 @@
Support for offchain JSON endpoint condition expression and evaluation

View File

View File

View File

@ -0,0 +1 @@
Expands recovery CLI to include audit and keystore identification features

View File

View File

View File

View File

View File

View File

View File

View File

@ -0,0 +1,2 @@
Condition that allows for if-then-else branching based on underlying conditions i.e. IF ``CONDITION A`` THEN ``CONDITION B`` ELSE ``CONDITION_C``.
The ELSE component can either be a Condition or a boolean value.

View File

@ -0,0 +1 @@
Enable support for Bearer authorization tokens (e.g., OAuth, JWT) within HTTP GET requests for ``JsonApiCondition``.

View File

@ -0,0 +1 @@
Enhance threshold decryption request efficiency by prioritizing nodes in the cohort with lower communication latency.

View File

View File

View File

View File

@ -0,0 +1 @@
Added plumbing to support EVM condition evaluation on "any" (major) EVM chain outside of Ethereum and Polygon - only enabled on ``lynx`` testnet for now.

View File

@ -0,0 +1 @@
Support for conditions based on verification of JWT tokens.

View File

@ -0,0 +1 @@
Support for conditions based on APIs provided by off-chain JSON RPC 2.0 endpoints.

View File

@ -0,0 +1 @@
Add support for EIP1271 signature verification for smart contract wallets.

View File

View File

View File

View File

@ -0,0 +1 @@
Allow BigInt values from ``taco-web`` typescript library to be provided as strings.

View File

View File

@ -0,0 +1 @@
Introduce necessary changes to adapt agents methods to breaking changes in Coordinator contract. Previous methods are now deprecated from the API.

View File

View File

@ -16,7 +16,7 @@ __url__ = "https://github.com/nucypher/nucypher"
__summary__ = "A threshold access control application to empower privacy in decentralized systems."
__version__ = "7.4.1"
__version__ = "7.5.0"
__author__ = "NuCypher"

View File

@ -4,7 +4,7 @@ import time
import traceback
from collections import defaultdict
from decimal import Decimal
from typing import DefaultDict, Dict, List, Optional, Union
from typing import Dict, List, Optional, Union
import maya
from atxm.exceptions import InsufficientFunds
@ -65,8 +65,10 @@ from nucypher.crypto.powers import (
TransactingPower,
)
from nucypher.datastore.dkg import DKGStorage
from nucypher.policy.conditions.evm import _CONDITION_CHAINS
from nucypher.policy.conditions.utils import evaluate_condition_lingo
from nucypher.policy.conditions.utils import (
ConditionProviderManager,
evaluate_condition_lingo,
)
from nucypher.policy.payment import ContractPayment
from nucypher.types import PhaseId
from nucypher.utilities.emitters import StdoutEmitter
@ -248,7 +250,7 @@ class Operator(BaseActor):
ThresholdRequestDecryptingPower
) # used for secure decryption request channel
self.condition_providers = self.connect_condition_providers(
self.condition_provider_manager = self.get_condition_provider_manager(
condition_blockchain_endpoints
)
@ -265,80 +267,79 @@ class Operator(BaseActor):
)
return receipt
@staticmethod
def _is_permitted_condition_chain(chain_id: int) -> bool:
return int(chain_id) in [int(cid) for cid in _CONDITION_CHAINS.keys()]
@staticmethod
def _make_condition_provider(uri: str) -> HTTPProvider:
provider = HTTPProvider(endpoint_uri=uri)
return provider
def connect_condition_providers(
self, endpoints: Dict[int, List[str]]
) -> DefaultDict[int, List[HTTPProvider]]:
providers = defaultdict(list) # use list to maintain order
def get_condition_provider_manager(
self, operator_configured_endpoints: Dict[int, List[str]]
) -> ConditionProviderManager:
# check that we have endpoints for all condition chains
if set(self.domain.condition_chain_ids) != set(endpoints):
# check that we have mandatory user configured endpoints
mandatory_configured_chains = {
self.domain.eth_chain.id,
self.domain.polygon_chain.id,
}
if mandatory_configured_chains != set(operator_configured_endpoints):
raise self.ActorError(
f"Missing blockchain endpoints for chains: "
f"{set(self.domain.condition_chain_ids) - set(endpoints)}"
f"Operator-configured condition endpoints for chains don't match mandatory chains: "
f"{set(operator_configured_endpoints)} vs expected {mandatory_configured_chains}"
)
providers = defaultdict(list) # use list to maintain order
# ensure that no endpoint uri for a specific chain is repeated
duplicated_endpoint_check = defaultdict(set)
# User-defined endpoints for chains
for chain_id, endpoints in endpoints.items():
if not self._is_permitted_condition_chain(chain_id):
raise NotImplementedError(
f"Chain ID {chain_id} is not supported for condition evaluation by this operator."
# Operator-configured endpoints for chains
for chain_id, chain_rpc_endpoints in operator_configured_endpoints.items():
if not chain_rpc_endpoints:
raise self.ActorError(
f"Operator-configured condition endpoint is missing for the required chain {chain_id}"
)
# connect to each endpoint and check that they are on the correct chain
for uri in endpoints:
for uri in chain_rpc_endpoints:
if uri in duplicated_endpoint_check[chain_id]:
self.log.warn(
f"Duplicated user-supplied blockchain uri, {uri}, for condition evaluation on chain {chain_id}; skipping"
f"Operator-configured condition endpoint {uri} is duplicated for condition evaluation on chain {chain_id}; skipping."
)
continue
provider = self._make_condition_provider(uri)
if int(Web3(provider).eth.chain_id) != int(chain_id):
raise self.ActorError(
f"Condition blockchain endpoint {uri} is not on chain {chain_id}"
f"Operator-configured RPC condition endpoint {uri} does not belong to chain {chain_id}"
)
healthy = rpc_endpoint_health_check(endpoint=uri)
if not healthy:
self.log.warn(
f"user-supplied condition RPC endpoint {uri} is unhealthy"
f"Operator-configured RPC condition endpoint {uri} is unhealthy"
)
providers[int(chain_id)].append(provider)
duplicated_endpoint_check[chain_id].add(uri)
# Ingest default/fallback RPC providers for each chain
for chain_id in self.domain.condition_chain_ids:
default_endpoints = get_healthy_default_rpc_endpoints(chain_id)
for uri in default_endpoints:
default_endpoints = get_healthy_default_rpc_endpoints(self.domain)
for chain_id, chain_rpc_endpoints in default_endpoints.items():
# randomize list so that the same fallback RPC endpoints aren't always used by all nodes
random.shuffle(chain_rpc_endpoints)
for uri in chain_rpc_endpoints:
if uri in duplicated_endpoint_check[chain_id]:
self.log.warn(
f"Duplicated fallback blockchain uri, {uri}, for condition evaluation on chain {chain_id}; skipping"
f"Fallback blockchain endpoint, {uri}, is duplicated for condition evaluation on chain {chain_id}; skipping"
)
continue
provider = self._make_condition_provider(uri)
providers[chain_id].append(provider)
duplicated_endpoint_check[chain_id].add(uri)
humanized_chain_ids = ", ".join(
_CONDITION_CHAINS[chain_id] for chain_id in providers
)
self.log.info(
f"Connected to {sum(len(v) for v in providers.values())} RPC endpoints for condition "
f"checking on chain IDs {humanized_chain_ids}"
f"checking on chain IDs {providers.keys()}"
)
return providers
return ConditionProviderManager(providers=providers)
def _resolve_ritual(self, ritual_id: int) -> Coordinator.Ritual:
if not self.coordinator_agent.is_ritual_active(ritual_id=ritual_id):
@ -749,7 +750,7 @@ class Operator(BaseActor):
return async_tx
def derive_decryption_share(
def produce_decryption_share(
self,
ritual_id: int,
ciphertext_header: CiphertextHeader,
@ -761,7 +762,7 @@ class Operator(BaseActor):
aggregated_transcript = AggregatedTranscript.from_bytes(
bytes(ritual.aggregated_transcript)
)
decryption_share = self.ritual_power.derive_decryption_share(
decryption_share = self.ritual_power.produce_decryption_share(
nodes=validators,
threshold=ritual.threshold,
shares=ritual.shares,
@ -809,10 +810,10 @@ class Operator(BaseActor):
f"Node not part of ritual {decryption_request.ritual_id}",
)
def _verify_ciphertext_authorization(
def _verify_encryption_authorization(
self, decryption_request: ThresholdDecryptionRequest
) -> None:
"""check that the ciphertext is authorized for this ritual"""
"""Check that the encryption is authorized for this ritual"""
ciphertext_header = decryption_request.ciphertext_header
authorization = decryption_request.acp.authorization
if not self.coordinator_agent.is_encryption_authorized(
@ -847,7 +848,7 @@ class Operator(BaseActor):
evaluate_condition_lingo(
condition_lingo=condition_lingo,
context=context,
providers=self.condition_providers,
providers=self.condition_provider_manager,
)
def _verify_decryption_request_authorization(
@ -855,10 +856,10 @@ class Operator(BaseActor):
) -> None:
"""check that the decryption request is authorized for this ritual"""
self._verify_active_ritual(decryption_request)
self._verify_ciphertext_authorization(decryption_request)
self._verify_encryption_authorization(decryption_request)
self._evaluate_conditions(decryption_request)
def _derive_decryption_share_for_request(
def _produce_decryption_share_for_request(
self,
decryption_request: ThresholdDecryptionRequest,
) -> Union[DecryptionShareSimple, DecryptionSharePrecomputed]:
@ -867,7 +868,7 @@ class Operator(BaseActor):
decryption_request=decryption_request
)
try:
decryption_share = self.derive_decryption_share(
decryption_share = self.produce_decryption_share(
ritual_id=decryption_request.ritual_id,
ciphertext_header=decryption_request.ciphertext_header,
aad=decryption_request.acp.aad(),

View File

@ -593,6 +593,8 @@ class CoordinatorAgent(EthereumContractAgent):
@contract_api(CONTRACT_CALL)
def __rituals(self, ritual_id: int) -> Coordinator.Ritual:
result = self.contract.functions.rituals(int(ritual_id)).call()
# legacy rituals do not have a fee model entry - so None is appropriate
fee_model = ChecksumAddress(result[12]) if len(result) >= 13 else None
ritual = Coordinator.Ritual(
id=ritual_id,
initiator=ChecksumAddress(result[0]),
@ -605,9 +607,10 @@ class CoordinatorAgent(EthereumContractAgent):
threshold=result[7],
aggregation_mismatch=result[8],
access_controller=ChecksumAddress(result[9]),
aggregated_transcript=bytes(result[11]),
public_key=Ferveo.G1Point(result[10][0], result[10][1]),
aggregated_transcript=bytes(result[11]),
participants=[], # solidity does not return sub-structs
fee_model=fee_model,
)
return ritual
@ -689,6 +692,7 @@ class CoordinatorAgent(EthereumContractAgent):
result = self.contract.functions.numberOfRituals().call()
return result
# TODO: Deprecate this method. Use IEncryptionAuthorizer contract directly - #3349
@contract_api(CONTRACT_CALL)
def is_encryption_authorized(
self, ritual_id: int, evidence: bytes, ciphertext_header: bytes
@ -697,14 +701,55 @@ class CoordinatorAgent(EthereumContractAgent):
This contract read is relayed through coordinator to the access controller
contract associated with a given ritual.
"""
result = self.contract.functions.isEncryptionAuthorized(
ritual_id, evidence, ciphertext_header
# TODO this makes the test pass more easily - find a better way
# for this access controller calling logic #3503
ritual = self.get_ritual(ritual_id=ritual_id)
abi = """[
{
"type": "function",
"name": "isAuthorized",
"stateMutability": "view",
"inputs": [
{
"name": "ritualId",
"type": "uint32",
"internalType": "uint32"
},
{
"name": "evidence",
"type": "bytes",
"internalType": "bytes"
},
{
"name": "ciphertextHeader",
"type": "bytes",
"internalType": "bytes"
}
],
"outputs": [
{
"name": "",
"type": "bool",
"internalType": "bool"
}
]
}
]"""
encryption_authorizer = self.blockchain.w3.eth.contract(
address=ritual.access_controller, abi=abi
)
is_authorized = encryption_authorizer.functions.isAuthorized(
ritual_id,
evidence,
ciphertext_header,
).call()
return result
return is_authorized
@contract_api(CONTRACT_CALL)
def is_provider_public_key_set(self, staking_provider: ChecksumAddress) -> bool:
result = self.contract.functions.isProviderPublicKeySet(staking_provider).call()
result = self.contract.functions.isProviderKeySet(staking_provider).call()
return result
@contract_api(TRANSACTION)
@ -722,6 +767,7 @@ class CoordinatorAgent(EthereumContractAgent):
@contract_api(TRANSACTION)
def initiate_ritual(
self,
fee_model: ChecksumAddress,
providers: List[ChecksumAddress],
authority: ChecksumAddress,
duration: int,
@ -729,7 +775,7 @@ class CoordinatorAgent(EthereumContractAgent):
transacting_power: TransactingPower,
) -> TxReceipt:
contract_function: ContractFunction = self.contract.functions.initiateRitual(
providers, authority, duration, access_controller
fee_model, providers, authority, duration, access_controller
)
receipt = self.blockchain.send_transaction(
contract_function=contract_function, transacting_power=transacting_power
@ -744,7 +790,8 @@ class CoordinatorAgent(EthereumContractAgent):
transacting_power: TransactingPower,
async_tx_hooks: BlockchainInterface.AsyncTxHooks,
) -> AsyncTx:
contract_function: ContractFunction = self.contract.functions.postTranscript(
# See sprints/#145
contract_function: ContractFunction = self.contract.functions.publishTranscript(
ritualId=ritual_id, transcript=bytes(transcript)
)
async_tx = self.blockchain.send_async_transaction(
@ -780,15 +827,6 @@ class CoordinatorAgent(EthereumContractAgent):
)
return async_tx
@contract_api(CONTRACT_CALL)
def get_ritual_initiation_cost(
self, providers: List[ChecksumAddress], duration: int
) -> Wei:
result = self.contract.functions.getRitualInitiationCost(
providers, duration
).call()
return Wei(result)
@contract_api(CONTRACT_CALL)
def get_ritual_id_from_public_key(self, public_key: DkgPublicKey) -> int:
g1_point = Ferveo.G1Point.from_dkg_public_key(public_key)

View File

@ -54,4 +54,6 @@ POA_CHAINS = {
80002, # "Polygon/Amoy"
}
CHAINLIST_URL = "https://raw.githubusercontent.com/nucypher/chainlist/main/rpc.json"
CHAINLIST_URL_TEMPLATE = (
"https://raw.githubusercontent.com/nucypher/chainlist/main/{domain}.json"
)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@
import functools
import inspect
from datetime import datetime
from datetime import datetime, timezone
from typing import Callable, Optional, TypeVar, Union
import eth_utils
@ -111,7 +111,7 @@ def save_receipt(actor_method) -> Callable: # TODO: rename to "save_result"?
@functools.wraps(actor_method)
def wrapped(self, *args, **kwargs) -> dict:
receipt_or_txhash = actor_method(self, *args, **kwargs)
self._saved_receipts.append((datetime.utcnow(), receipt_or_txhash))
self._saved_receipts.append((datetime.now(timezone.utc), receipt_or_txhash))
return receipt_or_txhash
return wrapped

View File

@ -1,7 +1,6 @@
from enum import Enum
from typing import Any, Dict, NamedTuple, Tuple
from cytoolz.functoolz import memoize
from functools import cache
from typing import Any, Dict, NamedTuple
class UnrecognizedTacoDomain(Exception):
@ -29,12 +28,10 @@ class TACoDomain:
name: str,
eth_chain: EthChain,
polygon_chain: PolygonChain,
condition_chains: Tuple[ChainInfo, ...],
):
self.name = name
self.eth_chain = eth_chain
self.polygon_chain = polygon_chain
self.condition_chains = condition_chains
def __repr__(self) -> str:
return f"<TACoDomain {self.name}>"
@ -43,9 +40,7 @@ class TACoDomain:
return self.name
def __hash__(self) -> int:
return hash(
(self.name, self.eth_chain, self.polygon_chain, self.condition_chains)
)
return hash((self.name, self.eth_chain, self.polygon_chain))
def __bytes__(self) -> bytes:
return self.name.encode()
@ -57,7 +52,6 @@ class TACoDomain:
self.name == other.name
and self.eth_chain == other.eth_chain
and self.polygon_chain == other.polygon_chain
and self.condition_chains == other.condition_chains
)
def __bool__(self) -> bool:
@ -67,36 +61,24 @@ class TACoDomain:
def is_testnet(self) -> bool:
return self.eth_chain != EthChain.MAINNET
@property
def condition_chain_ids(self) -> set:
return set(chain.id for chain in self.condition_chains)
MAINNET = TACoDomain(
name="mainnet",
eth_chain=EthChain.MAINNET,
polygon_chain=PolygonChain.MAINNET,
condition_chains=(EthChain.MAINNET, PolygonChain.MAINNET),
)
LYNX = TACoDomain(
name="lynx",
eth_chain=EthChain.SEPOLIA,
polygon_chain=PolygonChain.AMOY,
condition_chains=(
EthChain.MAINNET,
EthChain.SEPOLIA,
PolygonChain.AMOY,
PolygonChain.MAINNET,
),
)
TAPIR = TACoDomain(
name="tapir",
eth_chain=EthChain.SEPOLIA,
polygon_chain=PolygonChain.AMOY,
condition_chains=(EthChain.SEPOLIA, PolygonChain.AMOY),
)
@ -107,7 +89,7 @@ SUPPORTED_DOMAINS: Dict[str, TACoDomain] = {
}
@memoize
@cache
def get_domain(d: Any) -> TACoDomain:
if not isinstance(d, str):
raise TypeError(f"domain must be a string, not {type(d)}")

View File

@ -121,6 +121,7 @@ class Coordinator:
init_timestamp: int
end_timestamp: int
threshold: int
fee_model: ChecksumAddress = None
total_transcripts: int = 0
total_aggregations: int = 0
public_key: Ferveo.G1Point = None
@ -128,6 +129,7 @@ class Coordinator:
aggregated_transcript: bytes = bytes()
participants: List = field(default_factory=list)
@property
def providers(self):
return [p.provider for p in self.participants]

View File

@ -1,5 +1,6 @@
import time
from decimal import Decimal
from functools import cache
from typing import Dict, List, Union
import requests
@ -9,7 +10,8 @@ from web3 import Web3
from web3.contract.contract import ContractConstructor, ContractFunction
from web3.types import TxParams
from nucypher.blockchain.eth.constants import CHAINLIST_URL
from nucypher.blockchain.eth.constants import CHAINLIST_URL_TEMPLATE
from nucypher.blockchain.eth.domains import TACoDomain
from nucypher.utilities.logging import Logger
LOGGER = Logger("utility")
@ -133,17 +135,17 @@ def rpc_endpoint_health_check(endpoint: str, max_drift_seconds: int = 60) -> boo
return True # finally!
def get_default_rpc_endpoints() -> Dict[int, List[str]]:
@cache
def get_default_rpc_endpoints(domain: TACoDomain) -> Dict[int, List[str]]:
"""
Fetches the default RPC endpoints for various chains
For a given domain, fetches the default RPC endpoints for various chains
from the nucypher/chainlist repository.
"""
LOGGER.debug(
f"Fetching default RPC endpoints from remote chainlist {CHAINLIST_URL}"
)
url = CHAINLIST_URL_TEMPLATE.format(domain=domain.name)
LOGGER.debug(f"Fetching default RPC endpoints from remote chainlist {url}")
try:
response = requests.get(CHAINLIST_URL)
response = requests.get(url)
except RequestException:
LOGGER.warn("Failed to fetch default RPC endpoints: network error")
return {}
@ -159,23 +161,21 @@ def get_default_rpc_endpoints() -> Dict[int, List[str]]:
return {}
def get_healthy_default_rpc_endpoints(chain_id: int) -> List[str]:
"""Returns a list of healthy RPC endpoints for a given chain ID."""
def get_healthy_default_rpc_endpoints(domain: TACoDomain) -> Dict[int, List[str]]:
"""Returns a mapping of chain id to healthy RPC endpoints for a given domain."""
endpoints = get_default_rpc_endpoints(domain)
endpoints = get_default_rpc_endpoints()
chain_endpoints = endpoints.get(chain_id)
if not chain_endpoints:
LOGGER.error(f"No default RPC endpoints found for chain ID {chain_id}")
return list()
healthy = [
endpoint for endpoint in chain_endpoints if rpc_endpoint_health_check(endpoint)
]
LOGGER.info(f"Healthy default RPC endpoints for chain ID {chain_id}: {healthy}")
if not healthy:
LOGGER.warn(
f"No healthy default RPC endpoints available for chain ID {chain_id}"
)
if not domain.is_testnet:
# iterate over all chains and filter out unhealthy endpoints
healthy = {
chain_id: [
endpoint
for endpoint in endpoints[chain_id]
if rpc_endpoint_health_check(endpoint)
]
for chain_id in endpoints
}
else:
healthy = endpoints
return healthy

View File

@ -184,7 +184,7 @@ class DKGOmniscientDecryptionClient(ThresholdDecryptionClient):
self._learner._dkg_insight.validator_keypairs,
):
# get decryption fragments/shares
decryption_share = dkg.derive_decryption_share(
decryption_share = dkg.produce_decryption_share(
ritual_id=ritual_id,
me=validator,
shares=self._learner._dkg_insight.shares_num,

View File

@ -387,6 +387,8 @@ class Alice(Character, actors.PolicyAuthor):
class Bob(Character):
_TRACK_NODE_LATENCY_STATS = True
banner = BOB_BANNER
_default_dkg_variant = FerveoVariant.Simple
_default_crypto_powerups = [SigningPower, DecryptingPower]
@ -1272,7 +1274,9 @@ class Ursula(Teacher, Character, Operator):
decryption_request = self.decrypt_threshold_decryption_request(
encrypted_decryption_request
)
decryption_share = self._derive_decryption_share_for_request(decryption_request)
decryption_share = self._produce_decryption_share_for_request(
decryption_request
)
encrypted_response = self._encrypt_decryption_share(
decryption_share=decryption_share,
ritual_id=decryption_request.ritual_id,

View File

@ -11,7 +11,6 @@ from nucypher.cli.literature import (
GENERIC_PASSWORD_PROMPT,
PASSWORD_COLLECTION_NOTICE,
)
from nucypher.config.base import CharacterConfiguration
from nucypher.config.constants import NUCYPHER_ENVVAR_KEYSTORE_PASSWORD
from nucypher.crypto.keystore import _WORD_COUNT, Keystore
from nucypher.utilities.emitters import StdoutEmitter
@ -36,7 +35,7 @@ def get_client_password(checksum_address: str, envvar: str = None, confirm: bool
return client_password
def unlock_signer_account(config: CharacterConfiguration, json_ipc: bool) -> None:
def unlock_signer_account(config, json_ipc: bool) -> None:
# TODO: Remove this block after deprecating 'operator_address'
from nucypher.config.characters import UrsulaConfiguration
@ -67,7 +66,11 @@ def get_nucypher_password(emitter, confirm: bool = False, envvar=NUCYPHER_ENVVAR
return keystore_password
def unlock_nucypher_keystore(emitter: StdoutEmitter, password: str, character_configuration: CharacterConfiguration) -> bool:
def unlock_nucypher_keystore(
emitter: StdoutEmitter,
password: str,
character_configuration,
) -> bool:
"""Unlocks a nucypher keystore and attaches it to the supplied configuration if successful."""
emitter.message(DECRYPTING_CHARACTER_KEYSTORE.format(name=character_configuration.NAME.capitalize()), color='yellow')
@ -80,15 +83,27 @@ def unlock_nucypher_keystore(emitter: StdoutEmitter, password: str, character_co
return True
def recover_keystore(emitter) -> None:
def collect_mnemonic(emitter: StdoutEmitter) -> str:
"""Collect nucypher mnemonic seed words interactively."""
while True:
__words = click.prompt("Enter nucypher keystore seed words")
word_count = len(__words.split())
if word_count != _WORD_COUNT:
emitter.message(
f"Invalid mnemonic - Number of words must be {str(_WORD_COUNT)}, but only got {word_count}"
)
continue
break
return __words
def recover_keystore(emitter) -> Keystore:
emitter.message('This procedure will recover your nucypher keystore from mnemonic seed words. '
'You will need to provide the entire mnemonic (space seperated) in the correct '
'order and choose a new password.', color='cyan')
click.confirm('Do you want to continue', abort=True)
__words = click.prompt("Enter nucypher keystore seed words")
word_count = len(__words.split())
if word_count != _WORD_COUNT:
emitter.message(f'Invalid mnemonic - Number of words must be {str(_WORD_COUNT)}, but only got {word_count}')
__words = collect_mnemonic(emitter)
__password = get_nucypher_password(emitter=emitter, confirm=True)
keystore = Keystore.restore(words=__words, password=__password)
emitter.message(f'Recovered nucypher keystore {keystore.id} to \n {keystore.keystore_path}', color='green')
return keystore

View File

@ -187,3 +187,14 @@ def perform_startup_ip_check(emitter: StdoutEmitter, ursula: Ursula, force: bool
raise click.Abort()
else:
emitter.message('✓ External IP matches configuration', 'green')
def update_config_keystore_path(keystore_path: Path, config_file: Path = None) -> None:
"""Update the ursula.json configuration file to use the provided keystore path."""
keystore_path = str(keystore_path.resolve())
with open(config_file, "r+") as f:
ursula_config = json.load(f)
ursula_config["keystore_path"] = keystore_path
f.seek(0)
json.dump(ursula_config, f, indent=2)
f.truncate() # rest of the file truncated

View File

@ -3,6 +3,7 @@ from pathlib import Path
import click
from nucypher.cli.actions.auth import (
collect_mnemonic,
get_client_password,
get_nucypher_password,
recover_keystore,
@ -13,6 +14,7 @@ from nucypher.cli.actions.configure import (
get_or_update_configuration,
handle_missing_configuration_file,
perform_startup_ip_check,
update_config_keystore_path,
)
from nucypher.cli.actions.migrate import migrate
from nucypher.cli.actions.select import (
@ -49,14 +51,22 @@ from nucypher.cli.options import (
option_teacher_uri,
)
from nucypher.cli.painting.help import paint_new_installation_help
from nucypher.cli.types import EIP55_CHECKSUM_ADDRESS, NETWORK_PORT, OPERATOR_IP
from nucypher.cli.types import (
EIP55_CHECKSUM_ADDRESS,
EXISTING_READABLE_FILE,
NETWORK_PORT,
OPERATOR_IP,
)
from nucypher.cli.utils import make_cli_character, setup_emitter
from nucypher.config.characters import UrsulaConfiguration
from nucypher.config.constants import (
DEFAULT_CONFIG_FILEPATH,
NUCYPHER_ENVVAR_OPERATOR_ETH_PASSWORD,
TEMPORARY_DOMAIN_NAME,
)
from nucypher.crypto.keystore import Keystore
from nucypher.crypto.powers import RitualisticPower
from nucypher.utilities.emitters import StdoutEmitter
from nucypher.utilities.prometheus.metrics import PrometheusMetricsConfig
@ -154,7 +164,7 @@ class UrsulaConfigOptions:
# TODO: Exit codes (not only for this, but for other exceptions)
return click.get_current_context().exit(1)
def generate_config(self, emitter, config_root, force, key_material):
def generate_config(self, emitter, config_root, force, key_material, with_mnemonic):
if self.dev:
raise RuntimeError(
@ -183,6 +193,7 @@ class UrsulaConfigOptions:
return UrsulaConfiguration.generate(
password=get_nucypher_password(emitter=emitter, confirm=True),
key_material=bytes.fromhex(key_material) if key_material else None,
with_mnemonic=with_mnemonic,
config_root=config_root,
rest_host=self.rest_host,
rest_port=self.rest_port,
@ -313,7 +324,14 @@ def ursula():
@option_config_root
@group_general_config
@option_key_material
def init(general_config, config_options, force, config_root, key_material):
@click.option(
"--with-mnemonic",
help="Initialize with a mnemonic phrase instead of generating a new keypair from scratch",
is_flag=True,
)
def init(
general_config, config_options, force, config_root, key_material, with_mnemonic
):
"""Create a new Ursula node configuration."""
emitter = setup_emitter(general_config, config_options.operator_address)
_pre_launch_warnings(emitter, dev=None, force=force)
@ -339,16 +357,14 @@ def init(general_config, config_options, force, config_root, key_material):
)
return click.get_current_context().exit(1)
click.clear()
emitter.echo(
"Hello Operator, welcome on board :-) \n\n"
"NOTE: Initializing a new Ursula node configuration is a one-time operation\n"
"for the lifetime of your node. This is a two-step process:\n\n"
"1. Creating a password to encrypt your operator keys\n"
"2. Securing a taco node seed phase\n\n"
"Please follow the prompts.",
color="cyan",
)
if key_material and with_mnemonic:
raise click.BadOptionUsage(
"--key-material",
message=click.style(
"--key-material is incompatible with --with-mnemonic",
fg="red",
),
)
if not config_options.eth_endpoint:
raise click.BadOptionUsage(
@ -365,13 +381,39 @@ def init(general_config, config_options, force, config_root, key_material):
fg="red",
),
)
click.clear()
if with_mnemonic:
emitter.echo(
"Hello Operator, welcome :-) \n\n"
"You are about to initialize a new Ursula node configuration using an existing mnemonic phrase.\n"
"Have your mnemonic phrase ready and ensure you are in a secure environment.\n"
"Please follow the prompts.",
color="cyan",
)
else:
emitter.echo(
"Hello Operator, welcome on board :-) \n\n"
"NOTE: Initializing a new Ursula node configuration is a one-time operation\n"
"for the lifetime of your node. This is a two-step process:\n\n"
"1. Creating a password to encrypt your operator keys\n"
"2. Securing a taco node seed phase\n\n"
"Please follow the prompts.",
color="cyan",
)
if not config_options.domain:
config_options.domain = select_domain(
emitter,
message="Select TACo Domain",
)
ursula_config = config_options.generate_config(
emitter=emitter, config_root=config_root, force=force, key_material=key_material
emitter=emitter,
config_root=config_root,
force=force,
key_material=key_material,
with_mnemonic=with_mnemonic,
)
filepath = ursula_config.to_configuration_file()
paint_new_installation_help(
@ -380,13 +422,107 @@ def init(general_config, config_options, force, config_root, key_material):
@ursula.command()
@group_config_options
@group_general_config
def recover(general_config, config_options):
# TODO: Combine with work in PR #2682
# TODO: Integrate regeneration of configuration files
emitter = setup_emitter(general_config, config_options.operator_address)
recover_keystore(emitter=emitter)
@option_config_file
@click.option(
"--keystore-filepath",
help="Path to keystore .priv file",
type=EXISTING_READABLE_FILE,
required=False,
)
@click.option(
"--view-mnemonic",
help="View mnemonic seed words",
is_flag=True,
)
def audit(config_file, keystore_filepath, view_mnemonic):
"""Audit a mnemonic phrase against a local keystore or view mnemonic seed words."""
emitter = StdoutEmitter()
if keystore_filepath and config_file:
raise click.BadOptionUsage(
"--keystore-filepath",
message=click.style(
"--keystore-filepath is incompatible with --config-file",
fg="red",
),
)
if keystore_filepath:
keystore = Keystore(keystore_filepath)
else:
config_file = config_file or DEFAULT_CONFIG_FILEPATH
if not config_file.exists():
emitter.error(
f"Ursula configuration file not found - {config_file.resolve()}"
)
raise click.Abort()
ursula_config = UrsulaConfiguration.from_configuration_file(
filepath=config_file
)
keystore = ursula_config.keystore
password = get_nucypher_password(emitter=emitter, confirm=False)
try:
keystore.unlock(password=password)
except Keystore.AuthenticationFailed:
emitter.error("Password is incorrect.")
raise click.Abort()
emitter.message("Password is correct.", color="green")
if view_mnemonic:
mnemonic = keystore.get_mnemonic()
emitter.message(f"\n{mnemonic}", color="cyan")
return
try:
correct = keystore.audit(words=collect_mnemonic(emitter), password=password)
except Keystore.InvalidMnemonic:
correct = False
if not correct:
emitter.message("Mnemonic is incorrect.", color="red")
raise click.Abort()
emitter.message("Mnemonic is correct.", color="green")
@ursula.command()
@option_config_file
@click.option(
"--keystore-filepath",
help="Path to keystore .priv file Ursula should use",
type=EXISTING_READABLE_FILE,
required=False,
)
def recover(config_file, keystore_filepath):
emitter = StdoutEmitter()
config_file = config_file or DEFAULT_CONFIG_FILEPATH
if not config_file.exists():
emitter.error(f"Ursula configuration file not found - {config_file.resolve()}")
raise click.Abort()
if keystore_filepath:
# use available file
keystore = Keystore(keystore_filepath)
# ensure that the password for the keystore file is known
password = get_nucypher_password(emitter=emitter, confirm=False)
try:
keystore.unlock(password=password)
except Keystore.AuthenticationFailed:
emitter.error("Password is incorrect.")
raise click.Abort()
else:
# recovery keystore using user-provided mnemonic
keystore = recover_keystore(emitter=emitter)
update_config_keystore_path(
keystore_path=keystore.keystore_path, config_file=config_file
)
emitter.message(
f"Updated {config_file} to use keystore filepath: {keystore.keystore_path.resolve()}",
color="green",
)
@ursula.command()
@ -402,6 +538,50 @@ def destroy(general_config, config_options, config_file, force):
destroy_configuration(emitter, character_config=ursula_config, force=force)
@ursula.command()
@option_config_file
@click.option(
"--keystore-filepath",
help="Path to keystore .priv file",
type=EXISTING_READABLE_FILE,
)
@click.option(
"--from-mnemonic",
help="View TACo public keys from mnemonic seed words",
is_flag=True,
)
def public_keys(config_file, keystore_filepath, from_mnemonic):
"""Display the public keys of a keystore."""
emitter = StdoutEmitter()
if sum(1 for i in (keystore_filepath, config_file, from_mnemonic) if i) > 1:
raise click.BadOptionUsage(
"--keystore-filepath",
message=click.style(
"At most, one of --keystore-filepath, --config-file, or --from-mnemonic must be specified",
fg="red",
),
)
if from_mnemonic:
keystore = Keystore.from_mnemonic(collect_mnemonic(emitter))
else:
keystore_path_to_use = keystore_filepath
if not keystore_path_to_use:
config_file = config_file or DEFAULT_CONFIG_FILEPATH
ursula_config = UrsulaConfiguration.from_configuration_file(
filepath=config_file
)
keystore_path_to_use = ursula_config.keystore.keystore_path
keystore = Keystore(keystore_path_to_use)
keystore.unlock(get_nucypher_password(emitter=emitter, confirm=False))
ritualistic_power = keystore.derive_crypto_power(RitualisticPower)
ferveo_public_key = bytes(ritualistic_power.public_key()).hex()
emitter.message(f"\nFerveo Public Key: {ferveo_public_key}", color="cyan")
@ursula.command()
@group_character_options
@option_config_file
@ -508,7 +688,7 @@ def config(general_config, config_options, config_file, force, action):
emitter.error(
"--config-file <FILEPATH> is required to run a configuration file migration."
)
return click.Abort()
raise click.Abort()
config_file = select_config_file(
emitter=emitter,
checksum_address=config_options.operator_address,

View File

@ -48,9 +48,7 @@ Path to Logs: {USER_LOG_DIR}
- Never share secret keys with anyone!
- Backup your keystore! Character keys are required to interact with the protocol!
- Remember your password! Without the password, it's impossible to decrypt the key!
"""
- Remember your password! Without the password, it's impossible to decrypt the key!"""
)
if character_name == 'ursula':

View File

@ -24,6 +24,7 @@ from nucypher.blockchain.eth.registry import (
)
from nucypher.blockchain.eth.signers import Signer
from nucypher.characters.lawful import Ursula
from nucypher.cli.actions.auth import collect_mnemonic
from nucypher.config import constants
from nucypher.config.util import cast_paths_from
from nucypher.crypto.keystore import Keystore
@ -419,9 +420,7 @@ class CharacterConfiguration(BaseConfiguration):
self.crypto_power = crypto_power
if keystore_path and not keystore:
keystore = Keystore(keystore_path=keystore_path)
self.__keystore = self.__keystore = keystore or NO_KEYSTORE_ATTACHED.bool_value(
False
)
self.__keystore = keystore or NO_KEYSTORE_ATTACHED.bool_value(False)
self.keystore_dir = (
Path(keystore.keystore_path).parent
if keystore
@ -583,18 +582,29 @@ class CharacterConfiguration(BaseConfiguration):
Warning: This method allows mutation and may result in an inconsistent configuration.
"""
# config file should exist and we we override -> no need for modifier
# config file should exist and we override -> no need for modifier
return super().update(filepath=self.config_file_location, **kwargs)
@classmethod
def generate(
cls, password: str, key_material: Optional[bytes] = None, *args, **kwargs
cls,
password: str,
key_material: Optional[bytes] = None,
with_mnemonic: bool = False,
*args,
**kwargs,
):
"""
Generates local directories, private keys, and initial configuration for a new node.
"""
"""Generates local directories, private keys, and initial configuration for a new node."""
node_config = cls(dev_mode=False, *args, **kwargs)
node_config.initialize(key_material=key_material, password=password)
if key_material and with_mnemonic:
raise ValueError(
"Cannot provide key_material and with_mnemonic simultaneously"
)
node_config.initialize(
key_material=key_material,
with_mnemonic=with_mnemonic,
password=password,
)
node_config.keystore.unlock(password)
return node_config
@ -789,9 +799,19 @@ class CharacterConfiguration(BaseConfiguration):
power_ups.append(power_up)
return power_ups
def initialize(self, password: str, key_material: Optional[bytes] = None) -> Path:
def initialize(
self,
password: str,
key_material: Optional[bytes] = None,
with_mnemonic: bool = False,
) -> Path:
"""Initialize a new configuration and write installation files to disk."""
if key_material and with_mnemonic:
raise ValueError(
"Cannot provide key_material and with_mnemonic simultaneously"
)
# Development
if self.dev_mode:
self.__temp_dir = TemporaryDirectory(
@ -806,6 +826,7 @@ class CharacterConfiguration(BaseConfiguration):
key_material=key_material,
password=password,
interactive=self.MNEMONIC_KEYSTORE,
with_mnemonic=with_mnemonic,
)
self._cache_runtime_filepaths()
@ -824,13 +845,26 @@ class CharacterConfiguration(BaseConfiguration):
password: str,
key_material: Optional[bytes] = None,
interactive: bool = True,
with_mnemonic: bool = False,
) -> Keystore:
if key_material and with_mnemonic:
raise ValueError(
"Cannot provide key_material and with_mnemonic simultaneously"
)
if key_material:
self.__keystore = Keystore.import_secure(
key_material=key_material,
password=password,
keystore_dir=self.keystore_dir,
)
elif with_mnemonic:
self.__keystore = Keystore.restore(
password=password,
keystore_dir=self.keystore_dir,
words=collect_mnemonic(self.emitter),
)
else:
if interactive:
self.__keystore = Keystore.generate(

View File

@ -24,6 +24,7 @@ BASE_DIR = NUCYPHER_PACKAGE.parent.resolve()
# User Application Filepaths
APP_DIR = AppDirs(nucypher.__title__, nucypher.__author__)
DEFAULT_CONFIG_ROOT = Path(os.getenv('NUCYPHER_CONFIG_ROOT', default=APP_DIR.user_data_dir))
DEFAULT_CONFIG_FILEPATH = DEFAULT_CONFIG_ROOT / "ursula.json"
USER_LOG_DIR = Path(os.getenv('NUCYPHER_USER_LOG_DIR', default=APP_DIR.user_log_dir))
DEFAULT_LOG_FILENAME = "nucypher.log"
DEFAULT_JSON_LOG_FILENAME = "nucypher.json"

View File

@ -74,7 +74,7 @@ def verify_aggregate(
pvss_aggregated.verify(shares, transcripts)
def derive_decryption_share(
def produce_decryption_share(
nodes: List[Validator],
aggregated_transcript: AggregatedTranscript,
keypair: Keypair,

View File

@ -7,7 +7,7 @@ from json import JSONDecodeError
from os.path import abspath
from pathlib import Path
from secrets import token_bytes
from typing import Callable, ClassVar, Dict, List, Optional, Tuple, Union
from typing import Callable, Dict, List, Optional, Tuple, Type, Union
import click
from constant_sorrow.constants import KEYSTORE_LOCKED
@ -253,12 +253,34 @@ class Keystore:
class AuthenticationFailed(RuntimeError):
pass
def __init__(self, keystore_path: Path):
self.keystore_path = keystore_path
self.__created, self.__id = _parse_path(keystore_path)
class InvalidMnemonic(ValueError):
pass
def __init__(self, keystore_path: Path = None):
self.__secret = KEYSTORE_LOCKED
self.keystore_path = keystore_path
if self.keystore_path:
self.__created, self.__id = _parse_path(keystore_path)
@classmethod
def from_keystore_id(cls, filepath: Path) -> "Keystore":
instance = cls(keystore_path=filepath)
return instance
@classmethod
def from_mnemonic(cls, words: str) -> "Keystore":
__mnemonic = Mnemonic(_MNEMONIC_LANGUAGE)
__secret = bytes(__mnemonic.to_entropy(words))
keystore = cls()
keystore.__secret = __secret
return keystore
def __decrypt_keystore(self, path: Path, password: str) -> bool:
if not self.keystore_path:
raise Keystore.Invalid(
"Keystore path not set, initialize with a valid path."
)
payload = _read_keystore(path, deserializer=_deserialize_keystore)
__password_material = derive_key_material_from_password(password=password.encode(),
salt=payload['password_salt'])
@ -346,12 +368,32 @@ class Keystore:
@classmethod
def restore(cls, words: str, password: str, keystore_dir: Optional[Path] = None) -> 'Keystore':
"""Restore a keystore from seed words"""
__mnemonic = Mnemonic(_MNEMONIC_LANGUAGE)
__secret = bytes(__mnemonic.to_entropy(words))
mnemonic = Mnemonic(_MNEMONIC_LANGUAGE)
__secret = bytes(mnemonic.to_entropy(words))
path = Keystore.__save(secret=__secret, password=password, keystore_dir=keystore_dir)
keystore = cls(keystore_path=path)
return keystore
def audit(self, words: str, password: str) -> bool:
"""Check if a mnemonic phrase can derive the secret key for the local keystore"""
mnemonic = Mnemonic(_MNEMONIC_LANGUAGE)
try:
__expected_secret = bytes(mnemonic.to_entropy(words))
except ValueError as e:
raise self.InvalidMnemonic(str(e))
self.__decrypt_keystore(path=self.keystore_path, password=password)
return self.__secret == __expected_secret
def get_mnemonic(self) -> str:
"""Return the mnemonic phrase for the keystore"""
if self.__secret is KEYSTORE_LOCKED:
raise Keystore.Locked(
f"{self.id} is locked and must be unlocked before use."
)
mnemonic = Mnemonic(_MNEMONIC_LANGUAGE)
__words = mnemonic.to_mnemonic(self.__secret)
return __words
@classmethod
def generate(
cls, password: str,
@ -439,10 +481,9 @@ class Keystore:
def unlock(self, password: str) -> None:
self.__decrypt_keystore(path=self.keystore_path, password=password)
def derive_crypto_power(self,
power_class: ClassVar[CryptoPowerUp],
*power_args, **power_kwargs
) -> Union[KeyPairBasedPower, DerivedKeyBasedPower]:
def derive_crypto_power(
self, power_class: Type[CryptoPowerUp], *power_args, **power_kwargs
) -> Union[KeyPairBasedPower, DerivedKeyBasedPower]:
if not self.is_unlocked:
raise Keystore.Locked(f"{self.id} is locked and must be unlocked before use.")

View File

@ -265,7 +265,7 @@ class RitualisticPower(KeyPairBasedPower):
not_found_error = NoRitualisticPower
provides = ("derive_decryption_share", "generate_transcript")
def derive_decryption_share(
def produce_decryption_share(
self,
checksum_address: ChecksumAddress,
ritual_id: int,
@ -277,7 +277,7 @@ class RitualisticPower(KeyPairBasedPower):
aad: bytes,
variant: FerveoVariant,
) -> Union[DecryptionShareSimple, DecryptionSharePrecomputed]:
decryption_share = dkg.derive_decryption_share(
decryption_share = dkg.produce_decryption_share(
ritual_id=ritual_id,
me=Validator(address=checksum_address, public_key=self.keypair.pubkey),
shares=shares,

View File

@ -42,7 +42,7 @@ def generate_self_signed_certificate(
private_key = ec.generate_private_key(curve(), default_backend())
public_key = private_key.public_key()
now = datetime.datetime.utcnow()
now = datetime.datetime.now(datetime.timezone.utc)
fields = [x509.NameAttribute(NameOID.COMMON_NAME, host)]
subject = issuer = x509.Name(fields)

View File

@ -1,6 +1,5 @@
import math
from http import HTTPStatus
from random import shuffle
from typing import Dict, List, Tuple
from eth_typing import ChecksumAddress
@ -80,15 +79,20 @@ class ThresholdDecryptionClient(ThresholdAccessControlClient):
self.log.warn(message)
raise self.ThresholdDecryptionRequestFailed(message)
# TODO: Find a better request order, perhaps based on latency data obtained from discovery loop - #3395
requests = list(encrypted_requests)
shuffle(requests)
ursulas_to_contact = (
self._learner.node_latency_collector.order_addresses_by_latency(
list(encrypted_requests)
)
if self._learner.node_latency_collector
else list(encrypted_requests)
)
# Discussion about WorkerPool parameters:
# "https://github.com/nucypher/nucypher/pull/3393#discussion_r1456307991"
worker_pool = WorkerPool(
worker=worker,
value_factory=self.ThresholdDecryptionRequestFactory(
ursulas_to_contact=requests,
ursulas_to_contact=ursulas_to_contact,
batch_size=math.ceil(threshold * 1.25),
threshold=threshold,
),

View File

@ -1,3 +1,4 @@
import contextlib
import time
from collections import deque
from contextlib import suppress
@ -40,6 +41,7 @@ from nucypher.crypto.signing import InvalidSignature, SignatureStamp
from nucypher.network.exceptions import NodeSeemsToBeDown
from nucypher.network.middleware import RestMiddleware
from nucypher.network.protocols import InterfaceInfo, SuspiciousActivity
from nucypher.utilities.latency import NodeLatencyStatsCollector
from nucypher.utilities.logging import Logger
TEACHER_NODES = {
@ -218,6 +220,8 @@ class Learner:
_ROUNDS_WITHOUT_NODES_AFTER_WHICH_TO_SLOW_DOWN = 10
__DEFAULT_MIDDLEWARE_CLASS = RestMiddleware
_TRACK_NODE_LATENCY_STATS = False
_crashed = (
False # moved from Character - why was this in Character and not Learner before
)
@ -261,6 +265,10 @@ class Learner:
self.log = Logger("learning-loop") # type: Logger
self.domain = domain
self.node_latency_collector = (
NodeLatencyStatsCollector() if self._TRACK_NODE_LATENCY_STATS else None
)
self.learning_deferred = Deferred()
default_middleware = self.__DEFAULT_MIDDLEWARE_CLASS(
registry=self.registry, eth_endpoint=self.eth_endpoint
@ -827,11 +835,19 @@ class Learner:
return RELAX
try:
response = self.network_middleware.get_nodes_via_rest(
node=current_teacher,
announce_nodes=announce_nodes,
fleet_state_checksum=self.known_nodes.checksum,
optional_latency_context_manager = (
self.node_latency_collector.get_latency_tracker(
current_teacher.checksum_address
)
if self.node_latency_collector
else contextlib.nullcontext()
)
with optional_latency_context_manager:
response = self.network_middleware.get_nodes_via_rest(
node=current_teacher,
announce_nodes=announce_nodes,
fleet_state_checksum=self.known_nodes.checksum,
)
# These except clauses apply to the current_teacher itself, not the learned-about nodes.
except NodeSeemsToBeDown as e:
unresponsive_nodes.add(current_teacher)

View File

@ -154,8 +154,9 @@ def _make_rest_app(this_node, log: Logger) -> Flask:
"""
# TODO: When non-evm chains are supported, bump the version.
# this can return a list of chain names or other verifiable identifiers.
payload = {"version": 1.0, "evm": list(this_node.condition_providers)}
providers = this_node.condition_provider_manager.providers
sorted_chain_ids = sorted(list(providers))
payload = {"version": 1.0, "evm": sorted_chain_ids}
return Response(json.dumps(payload), mimetype="application/json")
@rest_app.route('/decrypt', methods=["POST"])
@ -260,7 +261,7 @@ def _make_rest_app(this_node, log: Logger) -> Flask:
try:
evaluate_condition_lingo(
condition_lingo=condition_lingo,
providers=this_node.condition_providers,
providers=this_node.condition_provider_manager,
context=context,
)
except ConditionEvalError as error:

View File

@ -1,16 +1,22 @@
from enum import Enum
from typing import List
from typing import List, Optional
import maya
from eth_account.account import Account
from eth_account.messages import HexBytes, encode_typed_data
from eth_typing import ChecksumAddress
from siwe import SiweMessage, VerificationError
from nucypher.policy.conditions.exceptions import NoConnectionToChain
from nucypher.policy.conditions.utils import ConditionProviderManager
from nucypher.utilities.logging import Logger
class EvmAuth:
class AuthScheme(Enum):
EIP712 = "EIP712"
EIP4361 = "EIP4361"
EIP1271 = "EIP1271"
@classmethod
def values(cls) -> List[str]:
@ -26,7 +32,13 @@ class EvmAuth:
"""The message is too old."""
@classmethod
def authenticate(cls, data, signature, expected_address):
def authenticate(
cls,
data,
signature: str,
expected_address: str,
providers: Optional[ConditionProviderManager] = None,
):
raise NotImplementedError
@classmethod
@ -35,13 +47,21 @@ class EvmAuth:
return EIP712Auth
elif scheme == cls.AuthScheme.EIP4361.value:
return EIP4361Auth
elif scheme == cls.AuthScheme.EIP1271.value:
return EIP1271Auth
raise ValueError(f"Invalid authentication scheme: {scheme}")
class EIP712Auth(EvmAuth):
@classmethod
def authenticate(cls, data, signature, expected_address):
def authenticate(
cls,
data,
signature: str,
expected_address: str,
providers: Optional[ConditionProviderManager] = None,
):
try:
# convert hex data for byte fields - bytes are expected by underlying library
# 1. salt
@ -72,7 +92,13 @@ class EIP4361Auth(EvmAuth):
FRESHNESS_IN_HOURS = 2
@classmethod
def authenticate(cls, data, signature, expected_address):
def authenticate(
cls,
data,
signature: str,
expected_address: str,
providers: Optional[ConditionProviderManager] = None,
):
try:
siwe_message = SiweMessage.from_message(message=data)
except Exception as e:
@ -106,3 +132,113 @@ class EIP4361Auth(EvmAuth):
raise cls.AuthenticationFailed(
f"Invalid EIP4361 signature; signature not valid for expected address, {expected_address}"
)
class EIP1271Auth(EvmAuth):
EIP1271_ABI = """[
{
"inputs":[
{
"internalType":"bytes32",
"name":"_hash",
"type":"bytes32"
},
{
"internalType":"bytes",
"name":"_signature",
"type":"bytes"
}
],
"name":"isValidSignature",
"outputs":[
{
"internalType":"bytes4",
"name":"",
"type":"bytes4"
}
],
"stateMutability":"view",
"type":"function"
}
]"""
MAGIC_VALUE_BYTES = bytes(HexBytes("0x1626ba7e"))
LOG = Logger("EIP1271Auth")
@classmethod
def _extract_typed_data(cls, data):
try:
data_hash = bytes(HexBytes(data["dataHash"]))
chain = data["chain"]
return data_hash, chain
except Exception as e:
# data could not be processed
raise cls.InvalidData(
f"Invalid EIP1271 authentication data: {str(e) or e.__class__.__name__}"
)
@classmethod
def _validate_auth_data(
cls, data_hash, signature_bytes, expected_address, chain, providers
):
web3_endpoints = providers.web3_endpoints(chain_id=chain)
last_error = None
for web3_instance in web3_endpoints:
try:
# Interact with the EIP1271 contract
eip1271_contract = web3_instance.eth.contract(
address=expected_address, abi=cls.EIP1271_ABI
)
result = eip1271_contract.functions.isValidSignature(
data_hash,
signature_bytes,
).call()
if result == cls.MAGIC_VALUE_BYTES:
return # Successful authentication
break
except Exception as e:
last_error = f"EIP1271 contract call failed ({expected_address}): {e}"
cls.LOG.warn(f"{last_error}; attempting next provider")
else:
# If all providers fail
if last_error:
raise cls.AuthenticationFailed(
f"EIP1271 verification failed; {last_error}"
)
raise cls.AuthenticationFailed(
f"EIP1271 verification failed; signature not valid for contract address, {expected_address}"
)
@classmethod
def authenticate(
cls,
data,
signature: str,
expected_address: ChecksumAddress,
providers: Optional[ConditionProviderManager] = None,
):
if not providers:
# should never happen
raise cls.AuthenticationFailed(
"EIP1271 verification failed; no endpoints provided"
)
# Extract and validate input data
data_hash, chain = cls._extract_typed_data(data)
# Validate the signature
signature_bytes = bytes(HexBytes(signature))
try:
cls._validate_auth_data(
data_hash, signature_bytes, expected_address, chain, providers
)
except NoConnectionToChain:
raise cls.AuthenticationFailed(
f"EIP1271 verification failed; No connection to chain ID {chain}"
)
except cls.AuthenticationFailed:
raise
except Exception as e:
# catch all
raise cls.AuthenticationFailed(f"EIP1271 verification failed; {e}")

View File

@ -1,14 +1,18 @@
import json
from abc import ABC, abstractmethod
from base64 import b64decode, b64encode
from typing import Any, Dict, Tuple
from typing import Any, List, Optional, Tuple
from marshmallow import Schema, ValidationError
from marshmallow import Schema, ValidationError, fields
from nucypher.policy.conditions.exceptions import (
InvalidCondition,
InvalidConditionLingo,
)
from nucypher.policy.conditions.utils import (
CamelCaseSchema,
extract_single_error_message_from_schema_errors,
)
class _Serializable:
@ -51,31 +55,102 @@ class _Serializable:
class AccessControlCondition(_Serializable, ABC):
CONDITION_TYPE = NotImplemented
class Schema(Schema):
name = NotImplemented
class Schema(CamelCaseSchema):
SKIP_VALUES = (None,)
name = fields.Str(required=False, allow_none=True)
condition_type = NotImplemented
def __init__(self, condition_type: str, name: Optional[str] = None):
super().__init__()
self.condition_type = condition_type
self.name = name
self._validate()
@abstractmethod
def verify(self, *args, **kwargs) -> Tuple[bool, Any]:
"""Returns the boolean result of the evaluation and the returned value in a two-tuple."""
return NotImplemented
raise NotImplementedError
@classmethod
def validate(cls, data: Dict) -> None:
errors = cls.Schema().validate(data=data)
def _validate(self, **kwargs):
errors = self.Schema().validate(data=self.to_dict())
if errors:
raise InvalidCondition(f"Invalid {cls.__name__}: {errors}")
error_message = extract_single_error_message_from_schema_errors(errors)
raise InvalidCondition(
f"Invalid {self.__class__.__name__}: {error_message}"
)
@classmethod
def from_dict(cls, data) -> "AccessControlCondition":
try:
return super().from_dict(data)
except ValidationError as e:
raise InvalidConditionLingo(f"Invalid condition grammar: {e}")
raise InvalidConditionLingo(f"Invalid condition grammar: {e}") from e
@classmethod
def from_json(cls, data) -> "AccessControlCondition":
try:
return super().from_json(data)
except ValidationError as e:
raise InvalidConditionLingo(f"Invalid condition grammar: {e}")
raise InvalidConditionLingo(f"Invalid condition grammar: {e}") from e
class MultiConditionAccessControl(AccessControlCondition):
MAX_NUM_CONDITIONS = 5
MAX_MULTI_CONDITION_NESTED_LEVEL = 2
@property
@abstractmethod
def conditions(self) -> List[AccessControlCondition]:
raise NotImplementedError
@classmethod
def _validate_multi_condition_nesting(
cls,
conditions: List[AccessControlCondition],
field_name: str,
current_level: int = 1,
):
if len(conditions) > cls.MAX_NUM_CONDITIONS:
raise ValidationError(
field_name=field_name,
message=f"Maximum of {cls.MAX_NUM_CONDITIONS} conditions are allowed",
)
for condition in conditions:
if not isinstance(condition, MultiConditionAccessControl):
continue
level = current_level + 1
if level > cls.MAX_MULTI_CONDITION_NESTED_LEVEL:
raise ValidationError(
field_name=field_name,
message=f"Only {cls.MAX_MULTI_CONDITION_NESTED_LEVEL} nested levels of multi-conditions are allowed",
)
cls._validate_multi_condition_nesting(
conditions=condition.conditions,
field_name=field_name,
current_level=level,
)
class ExecutionCall(_Serializable, ABC):
class InvalidExecutionCall(ValueError):
pass
class Schema(CamelCaseSchema):
pass
def __init__(self):
# validate call using marshmallow schema before creating
errors = self.Schema().validate(data=self.to_dict())
if errors:
error_message = extract_single_error_message_from_schema_errors(errors)
raise self.InvalidExecutionCall(f"{error_message}")
@abstractmethod
def execute(self, *args, **kwargs) -> Any:
raise NotImplementedError

View File

@ -1,6 +1,6 @@
import re
from functools import partial
from typing import Any, List, Union
from typing import Any, Dict, List, Optional, Union
from eth_typing import ChecksumAddress
from eth_utils import to_checksum_address
@ -11,6 +11,10 @@ from nucypher.policy.conditions.exceptions import (
InvalidContextVariableData,
RequiredContextVariable,
)
from nucypher.policy.conditions.utils import (
ConditionProviderManager,
check_and_convert_big_int_string_to_int,
)
USER_ADDRESS_CONTEXT = ":userAddress"
USER_ADDRESS_EIP4361_EXTERNAL_CONTEXT = ":userAddressExternalEIP4361"
@ -19,7 +23,7 @@ CONTEXT_PREFIX = ":"
CONTEXT_REGEX = re.compile(":[a-zA-Z_][a-zA-Z0-9_]*")
USER_ADDRESS_SCHEMES = {
USER_ADDRESS_CONTEXT: None, # allow any scheme (EIP4361, EIP712) for now; eventually EIP712 will be deprecated
USER_ADDRESS_CONTEXT: None, # allow any scheme (EIP4361, EIP1271, EIP712) for now; eventually EIP712 will be deprecated
USER_ADDRESS_EIP4361_EXTERNAL_CONTEXT: EvmAuth.AuthScheme.EIP4361.value,
}
@ -28,7 +32,11 @@ class UnexpectedScheme(Exception):
pass
def _resolve_user_address(user_address_context_variable, **context) -> ChecksumAddress:
def _resolve_user_address(
user_address_context_variable: str,
providers: Optional[ConditionProviderManager] = None,
**context,
) -> ChecksumAddress:
"""
Recovers a checksum address from a signed message.
@ -38,7 +46,7 @@ def _resolve_user_address(user_address_context_variable, **context) -> ChecksumA
{
"signature": "<signature>",
"address": "<address>",
"scheme": "EIP4361" | ...
"scheme": "EIP4361" | "EIP1271" | ...
"typedData": ...
}
}
@ -59,7 +67,10 @@ def _resolve_user_address(user_address_context_variable, **context) -> ChecksumA
auth = EvmAuth.from_scheme(scheme)
auth.authenticate(
data=typed_data, signature=signature, expected_address=expected_address
data=typed_data,
signature=signature,
expected_address=expected_address,
providers=providers,
)
except EvmAuth.InvalidData as e:
raise InvalidContextVariableData(
@ -90,18 +101,23 @@ _DIRECTIVES = {
def is_context_variable(variable) -> bool:
if isinstance(variable, str) and variable.startswith(CONTEXT_PREFIX):
if CONTEXT_REGEX.fullmatch(variable):
return True
else:
raise ValueError(f"Context variable name '{variable}' is not valid.")
return False
return isinstance(variable, str) and CONTEXT_REGEX.fullmatch(variable)
def get_context_value(context_variable: str, **context) -> Any:
def string_contains_context_variable(variable: str) -> bool:
matches = re.findall(CONTEXT_REGEX, variable)
return bool(matches)
def get_context_value(
context_variable: str,
providers: Optional[ConditionProviderManager] = None,
**context,
) -> Any:
try:
# DIRECTIVES are special context vars that will pre-processed by ursula
func = _DIRECTIVES[context_variable]
value = func(providers=providers, **context) # required inputs here
except KeyError:
# fallback for context variable without directive - assume key,value pair
# handles the case for user customized context variables
@ -110,24 +126,44 @@ def get_context_value(context_variable: str, **context) -> Any:
raise RequiredContextVariable(
f'No value provided for unrecognized context variable "{context_variable}"'
)
else:
value = func(**context) # required inputs here
elif isinstance(value, str):
# possible big int value
value = check_and_convert_big_int_string_to_int(value)
return value
def _resolve_context_variable(param: Union[Any, List[Any]], **context):
def resolve_any_context_variables(
param: Union[Any, List[Any], Dict[Any, Any]],
providers: Optional[ConditionProviderManager] = None,
**context,
):
if isinstance(param, list):
return [_resolve_context_variable(item, **context) for item in param]
elif is_context_variable(param):
return get_context_value(context_variable=param, **context)
return [
resolve_any_context_variables(item, providers, **context) for item in param
]
elif isinstance(param, dict):
return {
k: resolve_any_context_variables(v, providers, **context)
for k, v in param.items()
}
elif isinstance(param, str):
# either it is a context variable OR contains a context variable within it
# TODO separating the two cases for now out of concern of regex searching
# within strings (case 2)
if is_context_variable(param):
return get_context_value(
context_variable=param, providers=providers, **context
)
else:
matches = re.findall(CONTEXT_REGEX, param)
for context_var in matches:
# checking out of concern for faulty regex search within string
if context_var in context:
resolved_var = get_context_value(
context_variable=context_var, providers=providers, **context
)
param = param.replace(context_var, str(resolved_var))
return param
else:
return param
def resolve_any_context_variables(parameters: List[Any], return_value_test, **context):
processed_parameters = [
_resolve_context_variable(param, **context) for param in parameters
]
processed_return_value_test = return_value_test.with_resolved_context(**context)
return processed_parameters, processed_return_value_test

View File

@ -1,42 +1,52 @@
from typing import (
Any,
Dict,
Iterator,
List,
Optional,
Set,
Tuple,
)
from eth_typing import ChecksumAddress
from eth_utils import to_checksum_address
from marshmallow import ValidationError, fields, post_load, validate, validates_schema
from marshmallow import (
ValidationError,
fields,
post_load,
validate,
validates,
validates_schema,
)
from marshmallow.validate import OneOf
from web3 import HTTPProvider, Web3
from web3.contract.contract import ContractFunction
from web3.middleware import geth_poa_middleware
from web3.providers import BaseProvider
from typing_extensions import override
from web3 import Web3
from web3.types import ABIFunction
from nucypher.policy.conditions import STANDARD_ABI_CONTRACT_TYPES, STANDARD_ABIS
from nucypher.policy.conditions.base import AccessControlCondition
from nucypher.policy.conditions import STANDARD_ABI_CONTRACT_TYPES
from nucypher.policy.conditions.base import (
ExecutionCall,
)
from nucypher.policy.conditions.context import (
is_context_variable,
resolve_any_context_variables,
)
from nucypher.policy.conditions.exceptions import (
InvalidCondition,
NoConnectionToChain,
RequiredContextVariable,
RPCExecutionFailed,
)
from nucypher.policy.conditions.lingo import ConditionType, ReturnValueTest
from nucypher.policy.conditions.utils import CamelCaseSchema, camel_case_to_snake
from nucypher.policy.conditions.lingo import (
AnyField,
ConditionType,
ExecutionCallAccessControlCondition,
ReturnValueTest,
)
from nucypher.policy.conditions.utils import (
ConditionProviderManager,
camel_case_to_snake,
)
from nucypher.policy.conditions.validation import (
_align_comparator_value_with_abi,
_get_abi_types,
_validate_condition_abi,
_validate_multiple_output_types,
_validate_single_output_type,
align_comparator_value_with_abi,
get_unbound_contract_function,
validate_contract_function_expected_return_type,
validate_function_abi,
)
# TODO: Move this to a more appropriate location,
@ -44,87 +54,117 @@ from nucypher.policy.conditions.validation import (
# Permitted blockchains for condition evaluation
from nucypher.utilities import logging
_CONDITION_CHAINS = {
1: "ethereum/mainnet",
11155111: "ethereum/sepolia",
137: "polygon/mainnet",
80002: "polygon/amoy",
# TODO: Permit support for these chains
# 100: "gnosis/mainnet",
# 10200: "gnosis/chiado",
}
class RPCCall(ExecutionCall):
LOG = logging.Logger(__name__)
def _resolve_abi(
w3: Web3,
method: str,
standard_contract_type: Optional[str] = None,
function_abi: Optional[ABIFunction] = None,
) -> ABIFunction:
"""Resolves the contract an/or function ABI from a standard contract name"""
if not (function_abi or standard_contract_type):
raise InvalidCondition(
f"Ambiguous ABI - Supply either an ABI or a standard contract type ({STANDARD_ABI_CONTRACT_TYPES})."
)
if standard_contract_type:
try:
# Lookup the standard ABI given it's ERC standard name (standard contract type)
contract_abi = STANDARD_ABIS[standard_contract_type]
except KeyError:
raise InvalidCondition(
f"Invalid standard contract type {standard_contract_type}; Must be one of {STANDARD_ABI_CONTRACT_TYPES}"
)
try:
# Extract all function ABIs from the contract's ABI.
# Will raise a ValueError if there is not exactly one match.
function_abi = (
w3.eth.contract(abi=contract_abi).get_function_by_name(method).abi
)
except ValueError as e:
raise InvalidCondition(str(e))
return ABIFunction(function_abi)
def _validate_chain(chain: int) -> None:
if not isinstance(chain, int):
raise ValueError(
f'The "chain" field of a condition must be the '
f'integer chain ID (got "{chain}").'
)
if chain not in _CONDITION_CHAINS:
raise InvalidCondition(
f"chain ID {chain} is not a permitted "
f"blockchain for condition evaluation."
)
class RPCCondition(AccessControlCondition):
ETH_PREFIX = "eth_"
ALLOWED_METHODS = {
# RPC
"eth_getBalance": int,
} # TODO other allowed methods (tDEC #64)
LOG = logging.Logger(__name__)
class Schema(ExecutionCall.Schema):
chain = fields.Int(required=True, strict=True)
method = fields.Str(
required=True,
error_messages={
"required": "Undefined method name",
"null": "Undefined method name",
},
)
parameters = fields.List(AnyField, required=False, allow_none=True)
@validates("method")
def validate_method(self, value):
if value not in RPCCall.ALLOWED_METHODS:
raise ValidationError(
f"'{value}' is not a permitted RPC endpoint for condition evaluation."
)
@post_load
def make(self, data, **kwargs):
return RPCCall(**data)
def __init__(
self,
chain: int,
method: str,
parameters: Optional[List[Any]] = None,
):
self.chain = chain
self.method = method
self.parameters = parameters
super().__init__()
def _get_web3_py_function(self, w3: Web3, rpc_method: str):
web3_py_method = camel_case_to_snake(rpc_method)
rpc_function = getattr(
w3.eth, web3_py_method
) # bind contract function (only exposes the eth API)
return rpc_function
def execute(self, providers: ConditionProviderManager, **context) -> Any:
resolved_parameters = []
if self.parameters:
resolved_parameters = resolve_any_context_variables(
param=self.parameters, providers=providers, **context
)
endpoints = providers.web3_endpoints(self.chain)
latest_error = ""
for w3 in endpoints:
try:
result = self._execute(w3, resolved_parameters)
break
except RequiredContextVariable:
raise
except Exception as e:
latest_error = f"RPC call '{self.method}' failed: {e}"
self.LOG.warn(f"{latest_error}, attempting to try next endpoint.")
# Something went wrong. Try the next endpoint.
continue
else:
# Fuck.
raise RPCExecutionFailed(
f"RPC call '{self.method}' failed; latest error - {latest_error}"
)
return result
def _execute(self, w3: Web3, resolved_parameters: List[Any]) -> Any:
"""Execute onchain read and return result."""
rpc_endpoint_, rpc_method = self.method.split("_", 1)
rpc_function = self._get_web3_py_function(w3, rpc_method)
rpc_result = rpc_function(*resolved_parameters) # RPC read
return rpc_result
class RPCCondition(ExecutionCallAccessControlCondition):
EXECUTION_CALL_TYPE = RPCCall
CONDITION_TYPE = ConditionType.RPC.value
class Schema(CamelCaseSchema):
SKIP_VALUES = (None,)
name = fields.Str(required=False)
class Schema(ExecutionCallAccessControlCondition.Schema, RPCCall.Schema):
condition_type = fields.Str(
validate=validate.Equal(ConditionType.RPC.value), required=True
)
chain = fields.Int(
required=True, strict=True, validate=OneOf(_CONDITION_CHAINS)
)
method = fields.Str(required=True)
parameters = fields.List(fields.Field, attribute="parameters", required=False)
return_value_test = fields.Nested(
ReturnValueTest.ReturnValueTestSchema(), required=True
)
@validates_schema()
def validate_expected_return_type(self, data, **kwargs):
method = data.get("method")
return_value_test = data.get("return_value_test")
expected_return_type = RPCCall.ALLOWED_METHODS[method]
comparator_value = return_value_test.value
if is_context_variable(comparator_value):
return
if not isinstance(return_value_test.value, expected_return_type):
raise ValidationError(
field_name="return_value_test",
message=f"Return value comparison for '{method}' call output "
f"should be '{expected_return_type}' and not '{type(comparator_value)}'.",
)
@post_load
def make(self, data, **kwargs):
@ -139,113 +179,34 @@ class RPCCondition(AccessControlCondition):
chain: int,
method: str,
return_value_test: ReturnValueTest,
condition_type: str = CONDITION_TYPE,
condition_type: str = ConditionType.RPC.value,
name: Optional[str] = None,
parameters: Optional[List[Any]] = None,
*args,
**kwargs,
):
# Validate input
# TODO: Additional validation (function is valid for ABI, RVT validity, standard contract name validity, etc.)
_validate_chain(chain=chain)
super().__init__(
chain=chain,
method=method,
return_value_test=return_value_test,
condition_type=condition_type,
name=name,
parameters=parameters,
*args,
**kwargs,
)
# internal
if condition_type != self.CONDITION_TYPE:
raise InvalidCondition(
f"{self.__class__.__name__} must be instantiated with the {self.CONDITION_TYPE} type."
)
@property
def method(self):
return self.execution_call.method
self.condition_type = condition_type
self.name = name
self.chain = chain
self.provider: Optional[BaseProvider] = None # set in _configure_provider
self.method = self._validate_method(method=method)
@property
def chain(self):
return self.execution_call.chain
# test
# should not be set to None - we do list unpacking so cannot be None; use empty list
self.parameters = parameters or []
self.return_value_test = return_value_test # output
self._validate_expected_return_type()
def _validate_method(self, method):
if not method:
raise InvalidCondition("Undefined method name")
if method not in self.ALLOWED_METHODS:
raise InvalidCondition(
f"'{method}' is not a permitted RPC endpoint for condition evaluation."
)
return method
def _validate_expected_return_type(self):
expected_return_type = self.ALLOWED_METHODS[self.method]
comparator_value = self.return_value_test.value
if is_context_variable(comparator_value):
return
if not isinstance(self.return_value_test.value, expected_return_type):
raise InvalidCondition(
f"Return value comparison for '{self.method}' call output "
f"should be '{expected_return_type}' and not '{type(comparator_value)}'."
)
def _next_endpoint(
self, providers: Dict[int, Set[HTTPProvider]]
) -> Iterator[HTTPProvider]:
"""Yields the next web3 provider to try for a given chain ID"""
try:
rpc_providers = providers[self.chain]
# if there are no entries for the chain ID, there
# is no connection to that chain available.
except KeyError:
raise NoConnectionToChain(chain=self.chain)
if not rpc_providers:
raise NoConnectionToChain(chain=self.chain) # TODO: unreachable?
for provider in rpc_providers:
# Someday, we might make this whole function async, and then we can knock on
# each endpoint here to see if it's alive and only yield it if it is.
yield provider
def _configure_w3(self, provider: BaseProvider) -> Web3:
# Instantiate a local web3 instance
self.provider = provider
w3 = Web3(provider)
# inject web3 middleware to handle POA chain extra_data field.
w3.middleware_onion.inject(geth_poa_middleware, layer=0, name="poa")
return w3
def _check_chain_id(self) -> None:
"""
Validates that the actual web3 provider is *actually*
connected to the condition's chain ID by reading its RPC endpoint.
"""
provider_chain = self.w3.eth.chain_id
if provider_chain != self.chain:
raise InvalidCondition(
f"This condition can only be evaluated on chain ID {self.chain} but the provider's "
f"connection is to chain ID {provider_chain}"
)
def _configure_provider(self, provider: BaseProvider):
"""Binds the condition's contract function to a blockchain provider for evaluation"""
self.w3 = self._configure_w3(provider=provider)
self._check_chain_id()
return provider
def _get_web3_py_function(self, rpc_method: str):
web3_py_method = camel_case_to_snake(rpc_method)
rpc_function = getattr(
self.w3.eth, web3_py_method
) # bind contract function (only exposes the eth API)
return rpc_function
def _execute_call(self, parameters: List[Any]) -> Any:
"""Execute onchain read and return result."""
rpc_endpoint_, rpc_method = self.method.split("_", 1)
rpc_function = self._get_web3_py_function(rpc_method)
rpc_result = rpc_function(*parameters) # RPC read
return rpc_result
@property
def parameters(self):
return self.execution_call.parameters
def _align_comparator_value_with_abi(
self, return_value_test: ReturnValueTest
@ -253,123 +214,204 @@ class RPCCondition(AccessControlCondition):
return return_value_test
def verify(
self, providers: Dict[int, Set[HTTPProvider]], **context
self, providers: ConditionProviderManager, **context
) -> Tuple[bool, Any]:
"""
Verifies the onchain condition is met by performing a
read operation and evaluating the return value test.
"""
parameters, return_value_test = resolve_any_context_variables(
self.parameters, self.return_value_test, **context
resolved_return_value_test = self.return_value_test.with_resolved_context(
providers=providers, **context
)
return_value_test = self._align_comparator_value_with_abi(
resolved_return_value_test
)
return_value_test = self._align_comparator_value_with_abi(return_value_test)
endpoints = self._next_endpoint(providers=providers)
for provider in endpoints:
self._configure_provider(provider=provider)
try:
result = self._execute_call(parameters=parameters)
break
except Exception as e:
self.LOG.warn(
f"RPC call '{self.method}' failed: {e}, attempting to try next endpoint."
)
# Something went wrong. Try the next endpoint.
continue
else:
# Fuck.
raise RPCExecutionFailed(f"Contract call '{self.method}' failed.")
result = self.execution_call.execute(providers=providers, **context)
eval_result = return_value_test.eval(result) # test
return eval_result, result
class ContractCondition(RPCCondition):
CONDITION_TYPE = ConditionType.CONTRACT.value
class Schema(RPCCondition.Schema):
condition_type = fields.Str(
validate=validate.Equal(ConditionType.CONTRACT.value), required=True
)
standard_contract_type = fields.Str(required=False)
class ContractCall(RPCCall):
class Schema(RPCCall.Schema):
contract_address = fields.Str(required=True)
function_abi = fields.Dict(required=False)
standard_contract_type = fields.Str(
required=False,
validate=OneOf(
STANDARD_ABI_CONTRACT_TYPES,
error="Invalid standard contract type: {input}",
),
allow_none=True,
)
function_abi = fields.Dict(required=False, allow_none=True)
@post_load
def make(self, data, **kwargs):
return ContractCondition(**data)
return ContractCall(**data)
@validates("contract_address")
def validate_contract_address(self, value):
try:
to_checksum_address(value)
except ValueError:
raise ValidationError(f"Invalid checksum address: '{value}'")
@override
@validates("method")
def validate_method(self, value):
return
@validates("function_abi")
def validate_abi(self, value):
# needs to be done before schema validation
if value:
try:
validate_function_abi(value)
except ValueError as e:
raise ValidationError(
field_name="function_abi", message=str(e)
) from e
@validates_schema
def check_standard_contract_type_or_function_abi(self, data, **kwargs):
def validate_standard_contract_type_or_function_abi(self, data, **kwargs):
method = data.get("method")
standard_contract_type = data.get("standard_contract_type")
function_abi = data.get("function_abi")
# validate xor of standard contract type and function abi
if not (bool(standard_contract_type) ^ bool(function_abi)):
raise ValidationError(
field_name="standard_contract_type",
message=f"Provide a standard contract type or function ABI; got ({standard_contract_type}, {function_abi}).",
)
# validate function abi with method name (not available for field validation)
if function_abi:
try:
validate_function_abi(function_abi, method_name=method)
except ValueError as e:
raise ValidationError(
field_name="function_abi", message=str(e)
) from e
# validate contract
contract_address = to_checksum_address(data.get("contract_address"))
try:
_validate_condition_abi(
standard_contract_type, function_abi, method_name=data.get("method")
get_unbound_contract_function(
contract_address=contract_address,
method=method,
standard_contract_type=standard_contract_type,
function_abi=function_abi,
)
except ValueError as e:
raise ValidationError(str(e))
raise ValidationError(str(e)) from e
def __init__(
self,
method: str,
contract_address: ChecksumAddress,
condition_type: str = CONDITION_TYPE,
standard_contract_type: Optional[str] = None,
function_abi: Optional[ABIFunction] = None,
*args,
**kwargs,
):
if not method:
raise InvalidCondition("Undefined method name")
try:
_validate_condition_abi(
standard_contract_type, function_abi, method_name=method
)
except ValueError as e:
raise InvalidCondition(str(e))
self.method = method
self.w3 = Web3() # used to instantiate contract function without a provider
# preprocessing
contract_address = to_checksum_address(contract_address)
# spec
self.contract_address = contract_address
self.condition_type = condition_type
self.standard_contract_type = standard_contract_type
self.function_abi = function_abi
self.contract_function = self._get_unbound_contract_function()
super().__init__(method=method, *args, **kwargs)
# call to super must be at the end for proper validation
super().__init__(condition_type=condition_type, method=method, *args, **kwargs)
def _validate_method(self, method):
# overrides validate method used by rpc superclass
return method
def _validate_expected_return_type(self) -> None:
output_abi_types = _get_abi_types(self.contract_function.contract_abi[0])
comparator_value = self.return_value_test.value
comparator_index = self.return_value_test.index
index_string = (
f"@index={comparator_index}" if comparator_index is not None else ""
)
failure_message = (
f"Invalid return value comparison type '{type(comparator_value)}' for "
f"'{self.contract_function.fn_name}'{index_string} based on ABI types {output_abi_types}"
# contract function already validated - so should not raise an exception
self.contract_function = get_unbound_contract_function(
contract_address=self.contract_address,
method=self.method,
standard_contract_type=self.standard_contract_type,
function_abi=self.function_abi,
)
if len(output_abi_types) == 1:
_validate_single_output_type(
output_abi_types[0], comparator_value, comparator_index, failure_message
)
else:
_validate_multiple_output_types(
output_abi_types, comparator_value, comparator_index, failure_message
)
def _execute(self, w3: Web3, resolved_parameters: List[Any]) -> Any:
"""Execute onchain read and return result."""
self.contract_function.w3 = w3
bound_contract_function = self.contract_function(
*resolved_parameters
) # bind contract function
contract_result = bound_contract_function.call() # onchain read
return contract_result
class ContractCondition(RPCCondition):
EXECUTION_CALL_TYPE = ContractCall
CONDITION_TYPE = ConditionType.CONTRACT.value
class Schema(RPCCondition.Schema, ContractCall.Schema):
condition_type = fields.Str(
validate=validate.Equal(ConditionType.CONTRACT.value), required=True
)
@validates_schema()
def validate_expected_return_type(self, data, **kwargs):
# validate that contract function is correct
try:
contract_function = get_unbound_contract_function(
contract_address=data.get("contract_address"),
method=data.get("method"),
standard_contract_type=data.get("standard_contract_type"),
function_abi=data.get("function_abi"),
)
except ValueError as e:
raise ValidationError(str(e)) from e
# validate return type based on contract function
return_value_test = data.get("return_value_test")
try:
validate_contract_function_expected_return_type(
contract_function=contract_function,
return_value_test=return_value_test,
)
except ValueError as e:
raise ValidationError(
field_name="return_value_test",
message=str(e),
) from e
@post_load
def make(self, data, **kwargs):
return ContractCondition(**data)
def __init__(
self,
method: str,
contract_address: ChecksumAddress,
condition_type: str = ConditionType.CONTRACT.value,
standard_contract_type: Optional[str] = None,
function_abi: Optional[ABIFunction] = None,
*args,
**kwargs,
):
super().__init__(
method=method,
condition_type=condition_type,
contract_address=contract_address,
standard_contract_type=standard_contract_type,
function_abi=function_abi,
*args,
**kwargs,
)
@property
def function_abi(self):
return self.execution_call.function_abi
@property
def standard_contract_type(self):
return self.execution_call.standard_contract_type
@property
def contract_function(self):
return self.execution_call.contract_function
@property
def contract_address(self):
return self.execution_call.contract_address
def __repr__(self) -> str:
r = (
@ -379,41 +421,10 @@ class ContractCondition(RPCCondition):
)
return r
def _configure_provider(self, *args, **kwargs):
super()._configure_provider(*args, **kwargs)
self.contract_function.w3 = self.w3
def _get_unbound_contract_function(self) -> ContractFunction:
"""Gets an unbound contract function to evaluate for this condition"""
function_abi = _resolve_abi(
w3=self.w3,
standard_contract_type=self.standard_contract_type,
method=self.method,
function_abi=self.function_abi,
)
try:
contract = self.w3.eth.contract(
address=self.contract_address, abi=[function_abi]
)
contract_function = getattr(contract.functions, self.method)
return contract_function
except Exception as e:
raise InvalidCondition(
f"Unable to find contract function, '{self.method}', for condition: {e}"
)
def _execute_call(self, parameters: List[Any]) -> Any:
"""Execute onchain read and return result."""
bound_contract_function = self.contract_function(
*parameters
) # bind contract function
contract_result = bound_contract_function.call() # onchain read
return contract_result
def _align_comparator_value_with_abi(
self, return_value_test: ReturnValueTest
) -> ReturnValueTest:
return _align_comparator_value_with_abi(
return align_comparator_value_with_abi(
abi=self.contract_function.contract_abi[0],
return_value_test=return_value_test,
)

View File

@ -7,9 +7,22 @@ class InvalidConditionLingo(Exception):
class NoConnectionToChain(RuntimeError):
"""Raised when a node does not have an associated provider for a chain."""
def __init__(self, chain: int):
def __init__(self, chain: int, message: str = None):
self.chain = chain
message = f"No connection to chain ID {chain}"
message = message or f"No connection to chain ID {chain}"
super().__init__(message)
class InvalidConnectionToChain(RuntimeError):
"""Raised when a node does not have a valid provider for a chain."""
def __init__(self, expected_chain: int, actual_chain: int, message: str = None):
self.expected_chain = expected_chain
self.actual_chain = actual_chain
message = (
message
or f"Invalid blockchain connection; expected chain ID {expected_chain}, but detected {actual_chain}"
)
super().__init__(message)
@ -45,3 +58,11 @@ class ConditionEvaluationFailed(Exception):
class RPCExecutionFailed(ConditionEvaluationFailed):
"""Raised when an exception is raised from an RPC call."""
class JsonRequestException(ConditionEvaluationFailed):
"""Raised when an exception is raised from a JSON request."""
class JWTException(ConditionEvaluationFailed):
"""Raised when an exception is raised when validating a JWT token"""

View File

@ -0,0 +1,121 @@
from typing import Any, Optional
from marshmallow import ValidationError, fields, post_load, validate, validates
from marshmallow.fields import Url
from typing_extensions import override
from nucypher.policy.conditions.context import is_context_variable
from nucypher.policy.conditions.json.base import (
BaseJsonRequestCondition,
HTTPMethod,
JSONPathField,
JsonRequestCall,
)
from nucypher.policy.conditions.lingo import (
ConditionType,
ExecutionCallAccessControlCondition,
ReturnValueTest,
)
from nucypher.utilities.logging import Logger
class JsonApiCall(JsonRequestCall):
TIMEOUT = 5 # seconds
class Schema(JsonRequestCall.Schema):
endpoint = Url(required=True, relative=False, schemes=["https"])
parameters = fields.Dict(required=False, allow_none=True)
query = JSONPathField(required=False, allow_none=True)
authorization_token = fields.Str(required=False, allow_none=True)
@post_load
def make(self, data, **kwargs):
return JsonApiCall(**data)
@validates("authorization_token")
def validate_auth_token(self, value):
if value and not is_context_variable(value):
raise ValidationError(
f"Invalid value for authorization token; expected a context variable, but got '{value}'"
)
def __init__(
self,
endpoint: str,
parameters: Optional[dict] = None,
query: Optional[str] = None,
authorization_token: Optional[str] = None,
):
self.endpoint = endpoint
super().__init__(
http_method=HTTPMethod.GET,
parameters=parameters,
query=query,
authorization_token=authorization_token,
)
self.logger = Logger(__name__)
@override
def execute(self, **context) -> Any:
return super()._execute(endpoint=self.endpoint, **context)
class JsonApiCondition(BaseJsonRequestCondition):
"""
A JSON API condition is a condition that can be evaluated by performing a GET on a JSON
HTTPS endpoint. The response must return an HTTP 200 with valid JSON in the response body.
The response will be deserialized as JSON and parsed using jsonpath.
"""
EXECUTION_CALL_TYPE = JsonApiCall
CONDITION_TYPE = ConditionType.JSONAPI.value
class Schema(ExecutionCallAccessControlCondition.Schema, JsonApiCall.Schema):
condition_type = fields.Str(
validate=validate.Equal(ConditionType.JSONAPI.value), required=True
)
@post_load
def make(self, data, **kwargs):
return JsonApiCondition(**data)
def __init__(
self,
endpoint: str,
return_value_test: ReturnValueTest,
query: Optional[str] = None,
parameters: Optional[dict] = None,
authorization_token: Optional[str] = None,
condition_type: Optional[str] = ConditionType.JSONAPI.value,
name: Optional[str] = None,
):
super().__init__(
endpoint=endpoint,
return_value_test=return_value_test,
query=query,
parameters=parameters,
authorization_token=authorization_token,
condition_type=condition_type,
name=name,
)
@property
def endpoint(self):
return self.execution_call.endpoint
@property
def query(self):
return self.execution_call.query
@property
def parameters(self):
return self.execution_call.parameters
@property
def timeout(self):
return self.execution_call.timeout
@property
def authorization_token(self):
return self.execution_call.authorization_token

View File

@ -0,0 +1,159 @@
from abc import ABC
from enum import Enum
from http import HTTPStatus
from typing import Any, Optional, Tuple
import requests
from jsonpath_ng.exceptions import JsonPathLexerError, JsonPathParserError
from jsonpath_ng.ext import parse
from marshmallow.fields import Field
from nucypher.policy.conditions.base import ExecutionCall
from nucypher.policy.conditions.context import (
resolve_any_context_variables,
string_contains_context_variable,
)
from nucypher.policy.conditions.exceptions import (
ConditionEvaluationFailed,
JsonRequestException,
)
from nucypher.policy.conditions.json.utils import process_result_for_condition_eval
from nucypher.policy.conditions.lingo import ExecutionCallAccessControlCondition
from nucypher.utilities.logging import Logger
class HTTPMethod(Enum):
GET = "GET"
POST = "POST"
class JsonRequestCall(ExecutionCall, ABC):
TIMEOUT = 5 # seconds
def __init__(
self,
http_method: HTTPMethod,
parameters: Optional[dict] = None,
query: Optional[str] = None,
authorization_token: Optional[str] = None,
):
self.http_method = http_method
self.parameters = parameters or {}
self.query = query
self.authorization_token = authorization_token
self.timeout = self.TIMEOUT
self.logger = Logger(__name__)
super().__init__()
def _execute(self, endpoint: str, **context) -> Any:
data = self._fetch(endpoint, **context)
result = self._query_response(data, **context)
return result
def _fetch(self, endpoint: str, **context) -> Any:
resolved_endpoint = resolve_any_context_variables(endpoint, **context)
resolved_parameters = resolve_any_context_variables(self.parameters, **context)
headers = {"Content-Type": "application/json"}
if self.authorization_token:
resolved_authorization_token = resolve_any_context_variables(
self.authorization_token, **context
)
headers["Authorization"] = f"Bearer {resolved_authorization_token}"
try:
if self.http_method == HTTPMethod.GET:
response = requests.get(
resolved_endpoint,
params=resolved_parameters,
timeout=self.timeout,
headers=headers,
)
else:
# POST
response = requests.post(
resolved_endpoint,
json=resolved_parameters,
timeout=self.timeout,
headers=headers,
)
response.raise_for_status()
if response.status_code != HTTPStatus.OK:
raise JsonRequestException(
f"Failed to fetch from endpoint {resolved_endpoint}: {response.status_code}"
)
except requests.exceptions.RequestException as request_error:
raise JsonRequestException(
f"Failed to fetch from endpoint {resolved_endpoint}: {request_error}"
)
try:
data = response.json()
return data
except (requests.exceptions.RequestException, ValueError) as json_error:
raise JsonRequestException(
f"Failed to extract JSON response from {resolved_endpoint}: {json_error}"
)
def _query_response(self, response_json: Any, **context) -> Any:
if not self.query:
return response_json # primitive value
resolved_query = resolve_any_context_variables(self.query, **context)
try:
expression = parse(resolved_query)
matches = expression.find(response_json)
if not matches:
message = f"No matches found for the JSONPath query: {resolved_query}"
self.logger.info(message)
raise ConditionEvaluationFailed(message)
except (JsonPathLexerError, JsonPathParserError) as jsonpath_err:
self.logger.error(f"JSONPath error occurred: {jsonpath_err}")
raise ConditionEvaluationFailed(
f"JSONPath error: {jsonpath_err}"
) from jsonpath_err
if len(matches) > 1:
message = f"Ambiguous JSONPath query - multiple matches found for: {resolved_query}"
self.logger.info(message)
raise JsonRequestException(message)
result = matches[0].value
return result
class JSONPathField(Field):
default_error_messages = {
"invalidType": "Expression of type {value} is not valid for JSONPath",
"invalid": "'{value}' is not a valid JSONPath expression",
}
def _deserialize(self, value, attr, data, **kwargs):
if not isinstance(value, str):
raise self.make_error("invalidType", value=type(value))
try:
if not string_contains_context_variable(value):
parse(value)
except (JsonPathLexerError, JsonPathParserError) as e:
raise self.make_error("invalid", value=value) from e
return value
class BaseJsonRequestCondition(ExecutionCallAccessControlCondition, ABC):
def verify(self, **context) -> Tuple[bool, Any]:
"""
Verifies the JSON condition.
"""
result = self.execution_call.execute(**context)
result_for_eval = process_result_for_condition_eval(result)
resolved_return_value_test = self.return_value_test.with_resolved_context(
**context
)
eval_result = resolved_return_value_test.eval(result_for_eval) # test
return eval_result, result

View File

@ -0,0 +1,179 @@
from abc import ABC
from typing import Any, Optional
from uuid import uuid4
from marshmallow import ValidationError, fields, post_load, validate, validates
from marshmallow.fields import Url
from typing_extensions import override
from nucypher.policy.conditions.context import is_context_variable
from nucypher.policy.conditions.exceptions import (
JsonRequestException,
)
from nucypher.policy.conditions.json.base import (
BaseJsonRequestCondition,
HTTPMethod,
JSONPathField,
JsonRequestCall,
)
from nucypher.policy.conditions.lingo import (
AnyField,
ConditionType,
ExecutionCallAccessControlCondition,
ReturnValueTest,
)
class BaseJsonRPCCall(JsonRequestCall, ABC):
class Schema(JsonRequestCall.Schema):
method = fields.Str(required=True)
params = AnyField(required=False, allow_none=True)
query = JSONPathField(required=False, allow_none=True)
authorization_token = fields.Str(required=False, allow_none=True)
@validates("authorization_token")
def validate_auth_token(self, value):
if value and not is_context_variable(value):
raise ValidationError(
f"Invalid value for authorization token; expected a context variable, but got '{value}'"
)
def __init__(
self,
method: str,
params: Optional[Any] = None,
query: Optional[str] = None,
authorization_token: Optional[str] = None,
):
self.method = method
self.params = params or []
parameters = {
"jsonrpc": "2.0",
"method": self.method,
"params": self.params,
"id": str(uuid4()), # any random id will do
}
super().__init__(
http_method=HTTPMethod.POST,
parameters=parameters,
query=query,
authorization_token=authorization_token,
)
@override
def _execute(self, endpoint, **context):
data = self._fetch(endpoint, **context)
# response contains a value for either "result" or "error"
error = data.get("error")
if error:
raise JsonRequestException(
f"JSON RPC Request failed with error in response: code={error.get('code')}, msg={error.get('message')}"
)
# obtain result first then perform query
result = data.get("result")
if not result:
raise JsonRequestException(
f"Malformed JSON RPC response, no 'result' field - data={data}"
)
query_result = self._query_response(result, **context)
return query_result
class JsonEndpointRPCCall(BaseJsonRPCCall):
class Schema(BaseJsonRPCCall.Schema):
endpoint = Url(required=True, relative=False, schemes=["https"])
@post_load
def make(self, data, **kwargs):
return JsonEndpointRPCCall(**data)
def __init__(
self,
endpoint: str,
method: str,
params: Optional[Any] = None,
query: Optional[str] = None,
authorization_token: Optional[str] = None,
):
self.endpoint = endpoint
super().__init__(
method=method,
params=params,
query=query,
authorization_token=authorization_token,
)
@override
def execute(self, **context) -> Any:
return super()._execute(endpoint=self.endpoint, **context)
class JsonRpcCondition(BaseJsonRequestCondition):
"""
A JSON RPC condition is a condition that can be evaluated by performing a POST on a JSON
HTTPS endpoint. The response must return an HTTP 200 with valid JSON RPC 2.0 response.
The response will be deserialized as JSON and parsed using jsonpath.
"""
EXECUTION_CALL_TYPE = JsonEndpointRPCCall
CONDITION_TYPE = ConditionType.JSONRPC.value
class Schema(
ExecutionCallAccessControlCondition.Schema, JsonEndpointRPCCall.Schema
):
condition_type = fields.Str(
validate=validate.Equal(ConditionType.JSONRPC.value), required=True
)
@post_load
def make(self, data, **kwargs):
return JsonRpcCondition(**data)
def __init__(
self,
endpoint: str,
method: str,
return_value_test: ReturnValueTest,
params: Optional[Any] = None,
query: Optional[str] = None,
authorization_token: Optional[str] = None,
condition_type: Optional[str] = ConditionType.JSONRPC.value,
name: Optional[str] = None,
):
super().__init__(
endpoint=endpoint,
method=method,
return_value_test=return_value_test,
params=params,
query=query,
authorization_token=authorization_token,
condition_type=condition_type,
name=name,
)
@property
def endpoint(self):
return self.execution_call.endpoint
@property
def method(self):
return self.execution_call.method
@property
def params(self):
return self.execution_call.params
@property
def query(self):
return self.execution_call.query
@property
def authorization_token(self):
return self.execution_call.authorization_token
@property
def timeout(self):
return self.execution_call.timeout

View File

@ -0,0 +1,17 @@
from typing import Any
def process_result_for_condition_eval(result: Any):
# strings that are not already quoted will cause a problem for literal_eval
if not isinstance(result, str):
return result
# check if already quoted; if not, quote it
if not (
(result.startswith("'") and result.endswith("'"))
or (result.startswith('"') and result.endswith('"'))
):
quote_type_to_use = '"' if "'" in result else "'"
result = f"{quote_type_to_use}{result}{quote_type_to_use}"
return result

View File

@ -0,0 +1,154 @@
from typing import Any, Optional, Tuple
import jwt
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric import ec, rsa
from cryptography.hazmat.primitives.serialization import load_pem_public_key
from marshmallow import ValidationError, fields, post_load, validate, validates
from nucypher.policy.conditions.base import AccessControlCondition, ExecutionCall
from nucypher.policy.conditions.context import (
is_context_variable,
resolve_any_context_variables,
)
from nucypher.policy.conditions.exceptions import InvalidCondition, JWTException
from nucypher.policy.conditions.lingo import ConditionType
from nucypher.utilities.logging import Logger
class JWTVerificationCall(ExecutionCall):
_valid_jwt_algorithms = (
"ES256",
"RS256",
) # https://datatracker.ietf.org/doc/html/rfc7518#section-3.1
SECP_CURVE_FOR_ES256 = "secp256r1"
class Schema(ExecutionCall.Schema):
jwt_token = fields.Str(required=True)
# TODO: See #3572 for a discussion about deprecating this in favour of the expected issuer
public_key = fields.Str(
required=True
) # required? maybe a valid PK certificate passed by requester?
expected_issuer = fields.Str(required=False, allow_none=True)
# TODO: StringOrURI as per the spec.
@post_load
def make(self, data, **kwargs):
return JWTVerificationCall(**data)
@validates("jwt_token")
def validate_jwt_token(self, value):
if not is_context_variable(value):
raise ValidationError(
f"Invalid value for JWT token; expected a context variable, but got '{value}'"
)
@validates("public_key")
def validate_public_key(self, value):
try:
public_key = load_pem_public_key(
value.encode(), backend=default_backend()
)
if isinstance(public_key, rsa.RSAPublicKey):
return
elif isinstance(public_key, ec.EllipticCurvePublicKey):
curve = public_key.curve
if curve.name != JWTVerificationCall.SECP_CURVE_FOR_ES256:
raise ValidationError(
f"Invalid EC public key curve: {curve.name}"
)
except Exception as e:
raise ValidationError(f"Invalid public key format: {str(e)}")
def __init__(
self,
jwt_token: str,
public_key: str,
expected_issuer: Optional[str] = None,
):
self.jwt_token = jwt_token
self.public_key = public_key
self.expected_issuer = expected_issuer
self.logger = Logger(__name__)
super().__init__()
def execute(self, **context) -> Any:
jwt_token = resolve_any_context_variables(self.jwt_token, **context)
require = []
if self.expected_issuer:
require.append("iss")
try:
payload = jwt.decode(
jwt=jwt_token,
key=self.public_key,
algorithms=self._valid_jwt_algorithms,
options=dict(require=require),
issuer=self.expected_issuer,
)
except jwt.exceptions.InvalidAlgorithmError:
raise JWTException(f"valid algorithms: {self._valid_jwt_algorithms}")
except jwt.exceptions.InvalidTokenError as e:
raise JWTException(e)
return payload
class JWTCondition(AccessControlCondition):
"""
A JWT condition can be satisfied by presenting a valid JWT token, which not only is
required to be cryptographically verifiable, but also must fulfill certain additional
restrictions defined in the condition.
"""
CONDITION_TYPE = ConditionType.JWT.value
class Schema(AccessControlCondition.Schema, JWTVerificationCall.Schema):
condition_type = fields.Str(
validate=validate.Equal(ConditionType.JWT.value), required=True
)
@post_load
def make(self, data, **kwargs):
return JWTCondition(**data)
def __init__(
self,
jwt_token: str,
public_key: str,
condition_type: str = ConditionType.JWT.value,
name: Optional[str] = None,
expected_issuer: Optional[str] = None,
):
try:
self.execution_call = JWTVerificationCall(
jwt_token=jwt_token,
public_key=public_key,
expected_issuer=expected_issuer,
)
except ExecutionCall.InvalidExecutionCall as e:
raise InvalidCondition(str(e)) from e
super().__init__(condition_type=condition_type, name=name)
@property
def jwt_token(self):
return self.execution_call.jwt_token
@property
def public_key(self):
return self.execution_call.public_key
@property
def expected_issuer(self):
return self.execution_call.expected_issuer
def verify(self, **context) -> Tuple[bool, Any]:
payload = self.execution_call.execute(**context)
result = True
return result, payload

View File

@ -20,10 +20,15 @@ from marshmallow import (
from marshmallow.validate import OneOf, Range
from packaging.version import parse as parse_version
from nucypher.policy.conditions.base import AccessControlCondition, _Serializable
from nucypher.policy.conditions.base import (
AccessControlCondition,
ExecutionCall,
MultiConditionAccessControl,
_Serializable,
)
from nucypher.policy.conditions.context import (
_resolve_context_variable,
is_context_variable,
resolve_any_context_variables,
)
from nucypher.policy.conditions.exceptions import (
InvalidCondition,
@ -31,7 +36,53 @@ from nucypher.policy.conditions.exceptions import (
ReturnValueEvaluationError,
)
from nucypher.policy.conditions.types import ConditionDict, Lingo
from nucypher.policy.conditions.utils import CamelCaseSchema
from nucypher.policy.conditions.utils import (
CamelCaseSchema,
ConditionProviderManager,
check_and_convert_big_int_string_to_int,
)
class AnyField(fields.Field):
"""
Catch all field for all data types received in JSON.
However, `taco-web` will provide bigints as strings since typescript can't handle large
numbers as integers, so those need converting to integers.
"""
def _convert_any_big_ints_from_string(self, value):
if isinstance(value, list):
return [self._convert_any_big_ints_from_string(item) for item in value]
elif isinstance(value, dict):
return {
k: self._convert_any_big_ints_from_string(v) for k, v in value.items()
}
elif isinstance(value, str):
return check_and_convert_big_int_string_to_int(value)
return value
def _serialize(self, value, attr, obj, **kwargs):
return value
def _deserialize(self, value, attr, data, **kwargs):
return self._convert_any_big_ints_from_string(value)
class AnyLargeIntegerField(fields.Int):
"""
Integer field that also allows for big int values for large numbers
to be provided from `taco-web`. BigInts will be used for integer values > MAX_SAFE_INTEGER.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def _deserialize(self, value, attr, data, **kwargs):
if isinstance(value, str):
value = check_and_convert_big_int_string_to_int(value)
return super()._deserialize(value, attr, data, **kwargs)
class _ConditionField(fields.Dict):
@ -52,19 +103,8 @@ class _ConditionField(fields.Dict):
instance = condition_class.from_dict(condition_data)
return instance
#
# CONDITION = BASE_CONDITION | COMPOUND_CONDITION
#
# BASE_CONDITION = {
# // ..
# }
#
# COMPOUND_CONDITION = {
# "operator": OPERATOR,
# "operands": [CONDITION*]
# }
# CONDITION = TIME | CONTRACT | RPC | JSON_API | JSON_RPC | JWT | COMPOUND | SEQUENTIAL | IF_THEN_ELSE_CONDITION
class ConditionType(Enum):
"""
Defines the types of conditions that can be evaluated.
@ -73,14 +113,32 @@ class ConditionType(Enum):
TIME = "time"
CONTRACT = "contract"
RPC = "rpc"
JSONAPI = "json-api"
JSONRPC = "json-rpc"
JWT = "jwt"
COMPOUND = "compound"
SEQUENTIAL = "sequential"
IF_THEN_ELSE = "if-then-else"
@classmethod
def values(cls) -> List[str]:
return [condition.value for condition in cls]
class CompoundAccessControlCondition(AccessControlCondition):
class CompoundAccessControlCondition(MultiConditionAccessControl):
"""
A combination of two or more conditions connected by logical operators such as AND, OR, NOT.
CompoundCondition grammar:
OPERATOR = AND | OR | NOT
COMPOUND_CONDITION = {
"name": ... (Optional)
"conditionType": "compound",
"operator": OPERATOR,
"operands": [CONDITION*]
}
"""
AND_OPERATOR = "and"
OR_OPERATOR = "or"
NOT_OPERATOR = "not"
@ -92,28 +150,35 @@ class CompoundAccessControlCondition(AccessControlCondition):
def _validate_operator_and_operands(
cls,
operator: str,
operands: List,
exception_class: Union[Type[ValidationError], Type[InvalidCondition]],
operands: List[AccessControlCondition],
):
if operator not in cls.OPERATORS:
raise exception_class(f"{operator} is not a valid operator")
if operator == cls.NOT_OPERATOR:
if len(operands) != 1:
raise exception_class(
f"Only 1 operand permitted for '{operator}' compound condition"
)
elif len(operands) < 2:
raise exception_class(
f"Minimum of 2 operand needed for '{operator}' compound condition"
raise ValidationError(
field_name="operator", message=f"{operator} is not a valid operator"
)
class Schema(CamelCaseSchema):
SKIP_VALUES = (None,)
num_operands = len(operands)
if operator == cls.NOT_OPERATOR:
if num_operands != 1:
raise ValidationError(
field_name="operands",
message=f"Only 1 operand permitted for '{operator}' compound condition",
)
elif num_operands < 2:
raise ValidationError(
field_name="operands",
message=f"Minimum of 2 operand needed for '{operator}' compound condition",
)
elif num_operands > cls.MAX_NUM_CONDITIONS:
raise ValidationError(
field_name="operands",
message="Maximum of {cls.MAX_NUM_CONDITIONS} operands allowed for '{operator}' compound condition",
)
class Schema(AccessControlCondition.Schema):
condition_type = fields.Str(
validate=validate.Equal(ConditionType.COMPOUND.value), required=True
)
name = fields.Str(required=False)
operator = fields.Str(required=True)
operands = fields.List(_ConditionField, required=True)
@ -126,7 +191,10 @@ class CompoundAccessControlCondition(AccessControlCondition):
operator = data["operator"]
operands = data["operands"]
CompoundAccessControlCondition._validate_operator_and_operands(
operator, operands, ValidationError
operator, operands
)
CompoundAccessControlCondition._validate_multi_condition_nesting(
conditions=operands, field_name="operands"
)
@post_load
@ -146,18 +214,14 @@ class CompoundAccessControlCondition(AccessControlCondition):
"operands": [CONDITION*]
}
"""
if condition_type != self.CONDITION_TYPE:
raise InvalidCondition(
f"{self.__class__.__name__} must be instantiated with the {self.CONDITION_TYPE} type."
)
self._validate_operator_and_operands(operator, operands, InvalidCondition)
self.condition_type = condition_type
self.operator = operator
self.operands = operands
self.condition_type = condition_type
self.name = name
super().__init__(
condition_type=condition_type,
name=name,
)
self.id = md5(bytes(self)).hexdigest()[:6]
def __repr__(self):
@ -185,6 +249,10 @@ class CompoundAccessControlCondition(AccessControlCondition):
return overall_result, values
@property
def conditions(self):
return self.operands
class OrCompoundCondition(CompoundAccessControlCondition):
def __init__(self, operands: List[AccessControlCondition]):
@ -211,6 +279,275 @@ _COMPARATOR_FUNCTIONS = {
}
class ConditionVariable(_Serializable):
class Schema(CamelCaseSchema):
var_name = fields.Str(required=True) # TODO: should this be required?
condition = _ConditionField(required=True)
@post_load
def make(self, data, **kwargs):
return ConditionVariable(**data)
def __init__(self, var_name: str, condition: AccessControlCondition):
self.var_name = var_name
self.condition = condition
class SequentialAccessControlCondition(MultiConditionAccessControl):
"""
A series of conditions that are evaluated in a specific order, where the result of one
condition can be used in subsequent conditions.
SequentialCondition grammar:
CONDITION_VARIABLE = {
"varName": STR,
"condition": {
CONDITION
}
}
SEQUENTIAL_CONDITION = {
"name": ... (Optional)
"conditionType": "sequential",
"conditionVariables": [CONDITION_VARIABLE*]
}
"""
CONDITION_TYPE = ConditionType.SEQUENTIAL.value
@classmethod
def _validate_condition_variables(
cls,
condition_variables: List[ConditionVariable],
):
if not condition_variables or len(condition_variables) < 2:
raise ValidationError(
field_name="condition_variables",
message="At least two conditions must be specified",
)
if len(condition_variables) > cls.MAX_NUM_CONDITIONS:
raise ValidationError(
field_name="condition_variables",
message=f"Maximum of {cls.MAX_NUM_CONDITIONS} conditions are allowed",
)
# check for duplicate var names
var_names = set()
for condition_variable in condition_variables:
if condition_variable.var_name in var_names:
raise ValidationError(
field_name="condition_variables",
message=f"Duplicate variable names are not allowed - {condition_variable.var_name}",
)
var_names.add(condition_variable.var_name)
class Schema(AccessControlCondition.Schema):
condition_type = fields.Str(
validate=validate.Equal(ConditionType.SEQUENTIAL.value), required=True
)
condition_variables = fields.List(
fields.Nested(ConditionVariable.Schema(), required=True)
)
# maintain field declaration ordering
class Meta:
ordered = True
@validates("condition_variables")
def validate_condition_variables(self, value):
SequentialAccessControlCondition._validate_condition_variables(value)
conditions = [cv.condition for cv in value]
SequentialAccessControlCondition._validate_multi_condition_nesting(
conditions=conditions, field_name="condition_variables"
)
@post_load
def make(self, data, **kwargs):
return SequentialAccessControlCondition(**data)
def __init__(
self,
condition_variables: List[ConditionVariable],
condition_type: str = CONDITION_TYPE,
name: Optional[str] = None,
):
self.condition_variables = condition_variables
super().__init__(
condition_type=condition_type,
name=name,
)
def __repr__(self):
r = f"{self.__class__.__name__}(num_condition_variables={len(self.condition_variables)})"
return r
# TODO - think about not dereferencing context but using a dict;
# may allows more freedom for params
def verify(
self, providers: ConditionProviderManager, **context
) -> Tuple[bool, Any]:
values = []
latest_success = False
inner_context = dict(context) # don't modify passed in context - use a copy
# resolve variables
for condition_variable in self.condition_variables:
latest_success, result = condition_variable.condition.verify(
providers=providers, **inner_context
)
values.append(result)
if not latest_success:
# short circuit due to failed condition
break
inner_context[f":{condition_variable.var_name}"] = result
return latest_success, values
@property
def conditions(self):
return [
condition_variable.condition
for condition_variable in self.condition_variables
]
class _ElseConditionField(fields.Field):
"""
Serializes/Deserializes else conditions for IfThenElseCondition. This field represents either a
Condition or a boolean value.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def _serialize(self, value, attr, obj, **kwargs):
if isinstance(value, bool):
return value
return value.to_dict()
def _deserialize(self, value, attr, data, **kwargs):
if isinstance(value, bool):
return value
lingo_version = self.context.get("lingo_version")
condition_data = value
condition_class = ConditionLingo.resolve_condition_class(
condition=condition_data, version=lingo_version
)
instance = condition_class.from_dict(condition_data)
return instance
class IfThenElseCondition(MultiConditionAccessControl):
"""
A condition that represents simple if-then-else logic.
IF_THEN_ELSE_CONDITION = {
"conditionType": "if-then-else",
"ifCondition": CONDITION,
"thenCondition": CONDITION,
"elseCondition": CONDITION | true | false,
}
"""
CONDITION_TYPE = ConditionType.IF_THEN_ELSE.value
MAX_NUM_CONDITIONS = 3 # only ever max of 3 (if, then, else)
class Schema(AccessControlCondition.Schema):
condition_type = fields.Str(
validate=validate.Equal(ConditionType.IF_THEN_ELSE.value), required=True
)
if_condition = _ConditionField(required=True)
then_condition = _ConditionField(required=True)
else_condition = _ElseConditionField(required=True)
# maintain field declaration ordering
class Meta:
ordered = True
@staticmethod
def _validate_nested_conditions(field_name, condition):
IfThenElseCondition._validate_multi_condition_nesting(
conditions=[condition],
field_name=field_name,
)
@validates("if_condition")
def validate_if_condition(self, value):
self._validate_nested_conditions("if_condition", value)
@validates("then_condition")
def validate_then_condition(self, value):
self._validate_nested_conditions("then_condition", value)
@validates("else_condition")
def validate_else_condition(self, value):
if isinstance(value, AccessControlCondition):
self._validate_nested_conditions("else_condition", value)
@post_load
def make(self, data, **kwargs):
return IfThenElseCondition(**data)
def __init__(
self,
if_condition: AccessControlCondition,
then_condition: AccessControlCondition,
else_condition: Union[AccessControlCondition, bool],
condition_type: str = CONDITION_TYPE,
name: Optional[str] = None,
):
self.if_condition = if_condition
self.then_condition = then_condition
self.else_condition = else_condition
super().__init__(condition_type=condition_type, name=name)
def __repr__(self):
r = (
f"{self.__class__.__name__}("
f"if={self.if_condition.__class__.__name__}, "
f"then={self.then_condition.__class__.__name__}, "
f"else={self.else_condition.__class__.__name__}"
f")"
)
return r
@property
def conditions(self):
values = [self.if_condition, self.then_condition]
if isinstance(self.else_condition, AccessControlCondition):
values.append(self.else_condition)
return values
def verify(self, *args, **kwargs) -> Tuple[bool, Any]:
values = []
# if
if_result, if_value = self.if_condition.verify(*args, **kwargs)
values.append(if_value)
# then
if if_result:
then_result, then_value = self.then_condition.verify(*args, **kwargs)
values.append(then_value)
return then_result, values
# else
if isinstance(self.else_condition, AccessControlCondition):
# actual condition
else_result, else_value = self.else_condition.verify(*args, **kwargs)
else:
# boolean value
else_result, else_value = self.else_condition, self.else_condition
values.append(else_value)
return else_result, values
class ReturnValueTest:
class InvalidExpression(ValueError):
pass
@ -220,10 +557,12 @@ class ReturnValueTest:
class ReturnValueTestSchema(CamelCaseSchema):
SKIP_VALUES = (None,)
comparator = fields.Str(required=True, validate=OneOf(_COMPARATOR_FUNCTIONS))
value = fields.Raw(
value = AnyField(
allow_none=False, required=True
) # any valid type (excludes None)
index = fields.Int(strict=True, required=False, validate=Range(min=0))
index = fields.Int(
strict=True, required=False, validate=Range(min=0), allow_none=True
)
@post_load
def make(self, data, **kwargs):
@ -318,8 +657,10 @@ class ReturnValueTest:
result = _COMPARATOR_FUNCTIONS[self.comparator](left_operand, right_operand)
return result
def with_resolved_context(self, **context):
value = _resolve_context_variable(self.value, **context)
def with_resolved_context(
self, providers: Optional[ConditionProviderManager] = None, **context
):
value = resolve_any_context_variables(self.value, providers, **context)
return ReturnValueTest(self.comparator, value=value, index=self.index)
@ -356,16 +697,6 @@ class ConditionLingo(_Serializable):
"""
def __init__(self, condition: AccessControlCondition, version: str = VERSION):
"""
CONDITION = BASE_CONDITION | COMPOUND_CONDITION
BASE_CONDITION = {
// ..
}
COMPOUND_CONDITION = {
"operator": OPERATOR,
"operands": [CONDITION*]
}
"""
self.condition = condition
self.check_version_compatibility(version)
self.version = version
@ -411,11 +742,13 @@ class ConditionLingo(_Serializable):
cls, condition: ConditionDict, version: int = None
) -> Type[AccessControlCondition]:
"""
TODO: This feels like a jenky way to resolve data types from JSON blobs, but it works.
Inspects a given bloc of JSON and attempts to resolve it's intended datatype within the
Inspects a given block of JSON and attempts to resolve it's intended datatype within the
conditions expression framework.
"""
from nucypher.policy.conditions.evm import ContractCondition, RPCCondition
from nucypher.policy.conditions.json.api import JsonApiCondition
from nucypher.policy.conditions.json.rpc import JsonRpcCondition
from nucypher.policy.conditions.jwt import JWTCondition
from nucypher.policy.conditions.time import TimeCondition
# version logical adjustments can be made here as required
@ -426,12 +759,17 @@ class ConditionLingo(_Serializable):
ContractCondition,
RPCCondition,
CompoundAccessControlCondition,
JsonApiCondition,
JsonRpcCondition,
JWTCondition,
SequentialAccessControlCondition,
IfThenElseCondition,
):
if condition.CONDITION_TYPE == condition_type:
return condition
raise InvalidConditionLingo(
f"Cannot resolve condition lingo with condition type {condition_type}"
f"Cannot resolve condition lingo, {condition}, with condition type {condition_type}"
)
@classmethod
@ -440,3 +778,46 @@ class ConditionLingo(_Serializable):
raise InvalidConditionLingo(
f"Version provided, {version}, is incompatible with current version {cls.VERSION}"
)
class ExecutionCallAccessControlCondition(AccessControlCondition):
"""
Conditions that utilize underlying ExecutionCall objects.
"""
EXECUTION_CALL_TYPE = NotImplemented
class Schema(AccessControlCondition.Schema):
return_value_test = fields.Nested(
ReturnValueTest.ReturnValueTestSchema(), required=True
)
def __init__(
self,
condition_type: str,
return_value_test: ReturnValueTest,
name: Optional[str] = None,
*args,
**kwargs,
):
self.return_value_test = return_value_test
try:
self.execution_call = self.EXECUTION_CALL_TYPE(*args, **kwargs)
except ExecutionCall.InvalidExecutionCall as e:
raise InvalidCondition(str(e)) from e
super().__init__(condition_type=condition_type, name=name)
def verify(self, *args, **kwargs) -> Tuple[bool, Any]:
"""
Verifies the condition is met by performing execution call and
evaluating the return value test.
"""
result = self.execution_call.execute(*args, **kwargs)
resolved_return_value_test = self.return_value_test.with_resolved_context(
**kwargs
)
eval_result = resolved_return_value_test.eval(result) # test
return eval_result, result

View File

@ -1,33 +1,76 @@
from typing import Any, List, Optional
from marshmallow import fields, post_load, validate
from marshmallow.validate import Equal, OneOf
from marshmallow import (
ValidationError,
fields,
post_load,
validate,
validates,
validates_schema,
)
from typing_extensions import override
from web3 import Web3
from nucypher.policy.conditions.evm import _CONDITION_CHAINS, RPCCondition
from nucypher.policy.conditions.exceptions import InvalidCondition
from nucypher.policy.conditions.evm import RPCCall, RPCCondition
from nucypher.policy.conditions.lingo import ConditionType, ReturnValueTest
from nucypher.policy.conditions.utils import CamelCaseSchema
class TimeRPCCall(RPCCall):
METHOD = "blocktime"
class Schema(RPCCall.Schema):
method = fields.Str(dump_default="blocktime", required=True)
@override
@validates("method")
def validate_method(self, value):
if value != TimeRPCCall.METHOD:
raise ValidationError(f"method name must be {TimeRPCCall.METHOD}.")
@validates("parameters")
def validate_no_parameters(self, value):
if value:
raise ValidationError(
f"{TimeRPCCall.METHOD}' does not take any parameters"
)
@post_load
def make(self, data, **kwargs):
return TimeRPCCall(**data)
def __init__(
self,
chain: int,
method: str = METHOD,
parameters: Optional[List[Any]] = None,
):
super().__init__(chain=chain, method=method, parameters=parameters)
def _execute(self, w3: Web3, resolved_parameters: List[Any]) -> Any:
"""Execute onchain read and return result."""
# TODO may need to rethink as part of #3051 (multicall work).
latest_block = w3.eth.get_block("latest")
return latest_block.timestamp
class TimeCondition(RPCCondition):
METHOD = "blocktime"
EXECUTION_CALL_TYPE = TimeRPCCall
CONDITION_TYPE = ConditionType.TIME.value
class Schema(CamelCaseSchema):
SKIP_VALUES = (None,)
class Schema(RPCCondition.Schema, TimeRPCCall.Schema):
condition_type = fields.Str(
validate=validate.Equal(ConditionType.TIME.value), required=True
)
name = fields.Str(required=False)
chain = fields.Int(
required=True, strict=True, validate=OneOf(_CONDITION_CHAINS)
)
method = fields.Str(
dump_default="blocktime", required=True, validate=Equal("blocktime")
)
return_value_test = fields.Nested(
ReturnValueTest.ReturnValueTestSchema(), required=True
)
@validates_schema
def validate_expected_return_type(self, data, **kwargs):
return_value_test = data.get("return_value_test")
comparator_value = return_value_test.value
if not isinstance(comparator_value, int):
raise ValidationError(
field_name="return_value_test",
message=f"Invalid return value comparison type '{type(comparator_value)}'; must be an integer",
)
@post_load
def make(self, data, **kwargs):
@ -41,40 +84,19 @@ class TimeCondition(RPCCondition):
self,
return_value_test: ReturnValueTest,
chain: int,
method: str = METHOD,
condition_type: str = CONDITION_TYPE,
method: str = TimeRPCCall.METHOD,
condition_type: str = ConditionType.TIME.value,
name: Optional[str] = None,
):
if method != self.METHOD:
raise InvalidCondition(
f"{self.__class__.__name__} must be instantiated with the {self.METHOD} method."
)
# call to super must be at the end for proper validation
super().__init__(
return_value_test=return_value_test,
chain=chain,
method=method,
return_value_test=return_value_test,
name=name,
condition_type=condition_type,
name=name,
)
def _validate_method(self, method):
return method
def _validate_expected_return_type(self):
comparator_value = self.return_value_test.value
if not isinstance(comparator_value, int):
raise InvalidCondition(
f"Invalid return value comparison type '{type(comparator_value)}'; must be an integer"
)
@property
def timestamp(self):
return self.return_value_test.value
def _execute_call(self, parameters: List[Any]) -> Any:
"""Execute onchain read and return result."""
# TODO may need to rethink as part of #3051 (multicall work).
latest_block = self.w3.eth.get_block("latest")
return latest_block.timestamp

View File

@ -34,16 +34,20 @@ class ReturnValueTestDict(TypedDict):
key: NotRequired[Union[str, int]]
# Conditions
class _AccessControlCondition(TypedDict):
name: NotRequired[str]
class RPCConditionDict(_AccessControlCondition):
conditionType: str
class BaseExecConditionDict(_AccessControlCondition):
returnValueTest: ReturnValueTestDict
class RPCConditionDict(BaseExecConditionDict):
chain: int
method: str
parameters: NotRequired[List[Any]]
returnValueTest: ReturnValueTestDict
class TimeConditionDict(RPCConditionDict):
@ -56,17 +60,72 @@ class ContractConditionDict(RPCConditionDict):
functionAbi: NotRequired[ABIFunction]
class JsonApiConditionDict(BaseExecConditionDict):
endpoint: str
query: NotRequired[str]
parameters: NotRequired[Dict]
authorizationToken: NotRequired[str]
class JsonRpcConditionDict(BaseExecConditionDict):
endpoint: str
method: str
params: NotRequired[Any]
query: NotRequired[str]
authorizationToken: NotRequired[str]
class JWTConditionDict(_AccessControlCondition):
jwtToken: str
publicKey: str # TODO: See #3572 for a discussion about deprecating this in favour of the expected issuer
expectedIssuer: NotRequired[str]
#
# CompoundCondition represents:
# {
# "operator": ["and" | "or"]
# "operands": List[AccessControlCondition | CompoundCondition]
# "operator": ["and" | "or" | "not"]
# "operands": List[AccessControlCondition]
# }
#
class CompoundConditionDict(_AccessControlCondition):
operator: Literal["and", "or", "not"]
operands: List["ConditionDict"]
#
class CompoundConditionDict(TypedDict):
conditionType: str
operator: Literal["and", "or"]
operands: List["Lingo"]
# ConditionVariable represents:
# {
# varName: str
# condition: AccessControlCondition
# }
#
class ConditionVariableDict(TypedDict):
varName: str
condition: "ConditionDict"
#
# SequentialCondition represents:
# {
# "conditionVariables": List[ConditionVariable]
# }
#
class SequentialConditionDict(_AccessControlCondition):
conditionVariables = List[ConditionVariableDict]
#
# IfThenElseCondition represents:
# {
# "ifCondition": AccessControlCondition
# "thenCondition": AccessControlCondition
# "elseCondition": [AccessControlCondition | bool]
# }
class IfThenElseConditionDict(_AccessControlCondition):
ifCondition: "ConditionDict"
thenCondition: "ConditionDict"
elseCondition: Union["ConditionDict", bool]
#
@ -74,9 +133,22 @@ class CompoundConditionDict(TypedDict):
# - TimeCondition
# - RPCCondition
# - ContractCondition
# - CompoundConditionDict
# - CompoundCondition
# - JsonApiCondition
# - JsonRpcCondition
# - JWTCondition
# - SequentialCondition
# - IfThenElseCondition
ConditionDict = Union[
TimeConditionDict, RPCConditionDict, ContractConditionDict, CompoundConditionDict
TimeConditionDict,
RPCConditionDict,
ContractConditionDict,
CompoundConditionDict,
JsonApiConditionDict,
JsonRpcConditionDict,
JWTConditionDict,
SequentialConditionDict,
IfThenElseConditionDict,
]

View File

@ -1,8 +1,11 @@
import re
from http import HTTPStatus
from typing import Dict, Optional, Set, Tuple
from typing import Dict, Iterator, List, Optional, Tuple, Union
from marshmallow import Schema, post_dump
from marshmallow.exceptions import SCHEMA
from web3 import HTTPProvider, Web3
from web3.middleware import geth_poa_middleware
from web3.providers import BaseProvider
from nucypher.policy.conditions.exceptions import (
@ -10,6 +13,7 @@ from nucypher.policy.conditions.exceptions import (
ContextVariableVerificationFailed,
InvalidCondition,
InvalidConditionLingo,
InvalidConnectionToChain,
InvalidContextVariableData,
NoConnectionToChain,
RequiredContextVariable,
@ -21,6 +25,57 @@ from nucypher.utilities.logging import Logger
__LOGGER = Logger("condition-eval")
class ConditionProviderManager:
def __init__(self, providers: Dict[int, List[HTTPProvider]]):
self.providers = providers
self.logger = Logger(__name__)
def web3_endpoints(self, chain_id: int) -> Iterator[Web3]:
rpc_providers = self.providers.get(chain_id, None)
if not rpc_providers:
raise NoConnectionToChain(chain=chain_id)
iterator_returned_at_least_one = False
for provider in rpc_providers:
try:
w3 = self._configure_w3(provider=provider)
self._check_chain_id(chain_id, w3)
yield w3
iterator_returned_at_least_one = True
except InvalidConnectionToChain as e:
# don't expect to happen but must account
# for any misconfigurations of public endpoints
self.logger.warn(str(e))
# if we get here, it is because there were endpoints, but issue with configuring them
if not iterator_returned_at_least_one:
raise NoConnectionToChain(
chain=chain_id,
message=f"Problematic provider endpoints for chain ID {chain_id}",
)
@staticmethod
def _configure_w3(provider: BaseProvider) -> Web3:
# Instantiate a local web3 instance
w3 = Web3(provider)
# inject web3 middleware to handle POA chain extra_data field.
w3.middleware_onion.inject(geth_poa_middleware, layer=0, name="poa")
return w3
@staticmethod
def _check_chain_id(chain_id: int, w3: Web3) -> None:
"""
Validates that the actual web3 provider is *actually*
connected to the condition's chain ID by reading its RPC endpoint.
"""
provider_chain = w3.eth.chain_id
if provider_chain != chain_id:
raise InvalidConnectionToChain(
expected_chain=chain_id,
actual_chain=provider_chain,
)
class ConditionEvalError(Exception):
"""Exception when execution condition evaluation."""
def __init__(self, message: str, status_code: int):
@ -57,7 +112,7 @@ class CamelCaseSchema(Schema):
def evaluate_condition_lingo(
condition_lingo: Lingo,
providers: Optional[Dict[int, Set[BaseProvider]]] = None,
providers: Optional[ConditionProviderManager] = None,
context: Optional[ContextDict] = None,
log: Logger = __LOGGER,
):
@ -73,7 +128,7 @@ def evaluate_condition_lingo(
# Setup (don't use mutable defaults)
context = context or dict()
providers = providers or dict()
providers = providers or ConditionProviderManager(providers=dict())
error = None
# Evaluate
@ -138,3 +193,48 @@ def evaluate_condition_lingo(
if error:
log.info(error.message) # log error message
raise error
def extract_single_error_message_from_schema_errors(
errors: Dict[str, List[str]],
) -> str:
"""
Extract single error message from Schema().validate() errors result.
The result is only for a single error type, and only the first message string for that type.
If there are multiple error types, only one error type is used; the first field-specific (@validates)
error type encountered is prioritized over any schema-level-specific (@validates_schema) error.
"""
if not errors:
raise ValueError("Validation errors must be provided")
# extract error type - either field-specific (preferred) or schema-specific
error_key_to_use = None
for error_type in list(errors.keys()):
error_key_to_use = error_type
if error_key_to_use != SCHEMA:
# actual field
break
message = errors[error_key_to_use][0]
message_prefix = (
f"'{camel_case_to_snake(error_key_to_use)}' field - "
if error_key_to_use != SCHEMA
else ""
)
return f"{message_prefix}{message}"
def check_and_convert_big_int_string_to_int(value: str) -> Union[str, int]:
"""
Check if a string is a big int string and convert it to an integer, otherwise return the string.
"""
if re.fullmatch(r"^-?\d+n$", value):
try:
result = int(value[:-1])
return result
except ValueError:
# ignore
pass
return value

View File

@ -7,42 +7,24 @@ from typing import (
cast,
)
from eth_typing import ChecksumAddress
from web3 import Web3
from web3.auto import w3
from web3.contract.contract import ContractFunction
from web3.types import ABIFunction
from nucypher.policy.conditions import STANDARD_ABI_CONTRACT_TYPES, STANDARD_ABIS
from nucypher.policy.conditions.context import is_context_variable
from nucypher.policy.conditions.exceptions import (
InvalidCondition,
)
from nucypher.policy.conditions.lingo import ReturnValueTest
def _validate_single_output_type(
expected_type: str,
comparator_value: Any,
comparator_index: Optional[int],
failure_message: str,
) -> None:
if comparator_index is not None and _is_tuple_type(expected_type):
type_entries = _get_tuple_type_entries(expected_type)
expected_type = type_entries[comparator_index]
_validate_value_type(expected_type, comparator_value, failure_message)
#
# Schema logic
#
def _get_abi_types(abi: ABIFunction) -> List[str]:
return [_collapse_if_tuple(cast(Dict[str, Any], arg)) for arg in abi["outputs"]]
def _validate_value_type(
expected_type: str, comparator_value: Any, failure_message: str
) -> None:
if is_context_variable(comparator_value):
# context variable types cannot be known until execution time.
return
if not w3.is_encodable(expected_type, comparator_value):
raise InvalidCondition(failure_message)
def _collapse_if_tuple(abi: Dict[str, Any]) -> str:
abi_type = abi["type"]
if not abi_type.startswith("tuple"):
@ -66,6 +48,28 @@ def _get_tuple_type_entries(tuple_type: str) -> List[str]:
return result
def _validate_value_type(
expected_type: str, comparator_value: Any, failure_message: str
) -> None:
if is_context_variable(comparator_value):
# context variable types cannot be known until execution time.
return
if not w3.is_encodable(expected_type, comparator_value):
raise ValueError(failure_message)
def _validate_single_output_type(
expected_type: str,
comparator_value: Any,
comparator_index: Optional[int],
failure_message: str,
) -> None:
if comparator_index is not None and _is_tuple_type(expected_type):
type_entries = _get_tuple_type_entries(expected_type)
expected_type = type_entries[comparator_index]
_validate_value_type(expected_type, comparator_value, failure_message)
def _validate_multiple_output_types(
output_abi_types: List[str],
comparator_value: Any,
@ -82,15 +86,46 @@ def _validate_multiple_output_types(
return
if not isinstance(comparator_value, Sequence):
raise InvalidCondition(failure_message)
raise ValueError(failure_message)
if len(output_abi_types) != len(comparator_value):
raise InvalidCondition(failure_message)
raise ValueError(failure_message)
for output_abi_type, component_value in zip(output_abi_types, comparator_value):
_validate_value_type(output_abi_type, component_value, failure_message)
def _resolve_abi(
w3: Web3,
method: str,
standard_contract_type: Optional[str] = None,
function_abi: Optional[ABIFunction] = None,
) -> ABIFunction:
"""Resolves the contract an/or function ABI from a standard contract name"""
if not (function_abi or standard_contract_type):
raise ValueError(
f"Ambiguous ABI - Supply either an ABI or a standard contract type ({STANDARD_ABI_CONTRACT_TYPES})."
)
if standard_contract_type:
try:
# Lookup the standard ABI given it's ERC standard name (standard contract type)
contract_abi = STANDARD_ABIS[standard_contract_type]
except KeyError:
raise ValueError(
f"Invalid standard contract type {standard_contract_type}; Must be one of {STANDARD_ABI_CONTRACT_TYPES}"
)
# Extract all function ABIs from the contract's ABI.
# Will raise a ValueError if there is not exactly one match.
function_abi = (
w3.eth.contract(abi=contract_abi).get_function_by_name(method).abi
)
return ABIFunction(function_abi)
def _align_comparator_value_single_output(
expected_type: str, comparator_value: Any, comparator_index: Optional[int]
) -> Any:
@ -99,7 +134,7 @@ def _align_comparator_value_single_output(
expected_type = type_entries[comparator_index]
if not w3.is_encodable(expected_type, comparator_value):
raise InvalidCondition(
raise ValueError(
f"Mismatched comparator type ({comparator_value} as {expected_type})"
)
return comparator_value
@ -112,7 +147,7 @@ def _align_comparator_value_multiple_output(
expected_type = output_abi_types[comparator_index]
# ensure alignment
if not w3.is_encodable(expected_type, comparator_value):
raise InvalidCondition(
raise ValueError(
f"Mismatched comparator type ({comparator_value} as {expected_type})"
)
@ -122,15 +157,20 @@ def _align_comparator_value_multiple_output(
for output_abi_type, component_value in zip(output_abi_types, comparator_value):
# ensure alignment
if not w3.is_encodable(output_abi_type, component_value):
raise InvalidCondition(
raise ValueError(
f"Mismatched comparator type ({component_value} as {output_abi_type})"
)
values.append(component_value)
return values
def _align_comparator_value_with_abi(
abi, return_value_test: ReturnValueTest
#
# Public functions.
#
def align_comparator_value_with_abi(
abi: ABIFunction, return_value_test: ReturnValueTest
) -> ReturnValueTest:
output_abi_types = _get_abi_types(abi)
comparator = return_value_test.comparator
@ -155,13 +195,19 @@ def _align_comparator_value_with_abi(
)
def _validate_condition_function_abi(function_abi: Dict, method_name: str) -> None:
"""validates a dictionary as valid for use as a condition function ABI"""
def validate_function_abi(
function_abi: Dict, method_name: Optional[str] = None
) -> None:
"""
Validates a dictionary as valid for use as a condition function ABI.
Optionally validates the method_name
"""
abi = ABIFunction(function_abi)
if not abi.get("name"):
raise ValueError(f"Invalid ABI, no function name found {abi}")
if abi.get("name") != method_name:
if method_name and abi.get("name") != method_name:
raise ValueError(f"Mismatched ABI for contract function {method_name} - {abi}")
if abi.get("type") != "function":
raise ValueError(f"Invalid ABI type {abi}")
@ -171,14 +217,47 @@ def _validate_condition_function_abi(function_abi: Dict, method_name: str) -> No
raise ValueError(f"Invalid ABI stateMutability {abi}")
def _validate_condition_abi(
standard_contract_type: str,
function_abi: Dict,
method_name: str,
) -> None:
if not (bool(standard_contract_type) ^ bool(function_abi)):
def get_unbound_contract_function(
contract_address: ChecksumAddress,
method: str,
standard_contract_type: Optional[str] = None,
function_abi: Optional[ABIFunction] = None,
) -> ContractFunction:
"""Gets an unbound contract function to evaluate"""
w3 = Web3()
function_abi = _resolve_abi(
w3=w3,
standard_contract_type=standard_contract_type,
method=method,
function_abi=function_abi,
)
try:
contract = w3.eth.contract(address=contract_address, abi=[function_abi])
contract_function = getattr(contract.functions, method)
return contract_function
except Exception as e:
raise ValueError(
f"Provide 'standardContractType' or 'functionAbi'; got ({standard_contract_type}, {function_abi})."
f"Unable to find contract function, '{method}', for condition: {e}"
) from e
def validate_contract_function_expected_return_type(
contract_function: ContractFunction, return_value_test: ReturnValueTest
) -> None:
output_abi_types = _get_abi_types(contract_function.contract_abi[0])
comparator_value = return_value_test.value
comparator_index = return_value_test.index
index_string = f"@index={comparator_index}" if comparator_index is not None else ""
failure_message = (
f"Invalid return value comparison type '{type(comparator_value)}' for "
f"'{contract_function.fn_name}'{index_string} based on ABI types {output_abi_types}"
)
if len(output_abi_types) == 1:
_validate_single_output_type(
output_abi_types[0], comparator_value, comparator_index, failure_message
)
else:
_validate_multiple_output_types(
output_abi_types, comparator_value, comparator_index, failure_message
)
if function_abi:
_validate_condition_function_abi(function_abi, method_name=method_name)

View File

@ -188,7 +188,7 @@ class EventScanner:
# minor chain reorganisation?
return None
last_time = block_info["timestamp"]
return datetime.datetime.utcfromtimestamp(last_time)
return datetime.datetime.fromtimestamp(last_time, tz=datetime.timezone.utc)
def get_suggested_scan_start_block(self):
"""Get where we should start to scan for new token events.

View File

@ -0,0 +1,78 @@
import collections
import time
from collections import defaultdict
from threading import Lock
from typing import List
from eth_typing import ChecksumAddress
class NodeLatencyStatsCollector:
"""
Thread-safe utility that tracks latency statistics related to P2P connections with other nodes.
"""
MAX_MOVING_AVERAGE_WINDOW = 5
MAX_LATENCY = float(2**16) # just need a large number for sorting
class NodeLatencyContextManager:
def __init__(
self,
stats_collector: "NodeLatencyStatsCollector",
staker_address: ChecksumAddress,
):
self._stats_collector = stats_collector
self.staker_address = staker_address
def __enter__(self):
self.start_time = time.perf_counter()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type:
# exception occurred - reset stats since connectivity was compromised
self._stats_collector.reset_stats(self.staker_address)
else:
# no exception
end_time = time.perf_counter()
execution_time = end_time - self.start_time
self._stats_collector._update_stats(self.staker_address, execution_time)
def __init__(self, max_moving_average_window: int = MAX_MOVING_AVERAGE_WINDOW):
self._node_stats = defaultdict(
lambda: collections.deque([], maxlen=max_moving_average_window)
)
self._lock = Lock()
def _update_stats(self, staking_address: ChecksumAddress, latest_time_taken: float):
with self._lock:
self._node_stats[staking_address].append(latest_time_taken)
def reset_stats(self, staking_address: ChecksumAddress):
with self._lock:
self._node_stats[staking_address].clear()
def get_latency_tracker(
self, staker_address: ChecksumAddress
) -> NodeLatencyContextManager:
return self.NodeLatencyContextManager(
stats_collector=self, staker_address=staker_address
)
def get_average_latency_time(self, staking_address: ChecksumAddress) -> float:
with self._lock:
readings = list(self._node_stats[staking_address])
num_readings = len(readings)
# just need a large number > 0
return (
self.MAX_LATENCY if num_readings == 0 else sum(readings) / num_readings
)
def order_addresses_by_latency(
self, staking_addresses: List[ChecksumAddress]
) -> List[ChecksumAddress]:
result = sorted(
staking_addresses,
key=lambda staking_address: self.get_average_latency_time(staking_address),
)
return result

View File

@ -1,4 +1,5 @@
import pathlib
import re
import sys
from contextlib import contextmanager
from enum import Enum
@ -15,6 +16,7 @@ from twisted.logger import (
)
from twisted.logger import Logger as TwistedLogger
from twisted.python.logfile import LogFile
from twisted.web import http
import nucypher
from nucypher.config.constants import (
@ -211,11 +213,35 @@ def get_text_file_observer(name=DEFAULT_LOG_FILENAME, path=USER_LOG_DIR):
return observer
class Logger(TwistedLogger):
"""Drop-in replacement of Twisted's Logger, patching the emit() method to tolerate inputs with curly braces,
i.e., not compliant with PEP 3101.
def _redact_ip_address_when_logging_server_requests():
"""
Monkey-patch of twisted's HttpFactory log formatter so that logging of server requests
will exclude (redact) the IP address of the requester.
"""
original_formatter = http.combinedLogFormatter
ip_address_pattern = r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}"
See Issue #724 and, particularly, https://github.com/nucypher/nucypher/issues/724#issuecomment-600190455"""
def redact_ip_address_formatter(timestamp, request):
line = original_formatter(timestamp, request)
# redact any ip address
line = re.sub(ip_address_pattern, "<IP_REDACTED>", line)
return line
http.combinedLogFormatter = redact_ip_address_formatter
class Logger(TwistedLogger):
"""Drop-in replacement of Twisted's Logger:
1. patch the emit() method to tolerate inputs with curly braces,
i.e., not compliant with PEP 3101. See Issue #724 and, particularly,
https://github.com/nucypher/nucypher/issues/724#issuecomment-600190455
2. redact IP addresses for http requests
"""
_redact_ip_address_when_logging_server_requests()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@classmethod
def escape_format_string(cls, string):

5104
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,20 @@
[tool.poetry]
name = "nucypher"
version = "7.4.1"
version = "7.5.0"
authors = ["NuCypher"]
description = "A threshold access control application to empower privacy in decentralized systems."
[tool.poetry.dependencies]
python = ">=3.9,<4"
# Special dependencies required because their setup.cfg files are incorrect.
# Once these changes are incorporated in the origin projects, these lines can
# be removed.
# https://github.com/anthonyalmarza/chalk/pull/23
# https://github.com/zartstrom/snaptime/pull/6
pychalk = { git = "https://github.com/nucypher/pychalk.git", rev = "main" }
snaptime = { git = "https://github.com/nucypher/snaptime.git", rev = "main" }
nucypher-core = "==0.13.0"
cryptography = "*"
pynacl = ">=1.4.0"
@ -28,14 +37,16 @@ prometheus-client = '*'
siwe = "^4.2.0"
time-machine = "^2.13.0"
twisted = "^24.2.0rc1"
jsonpath-ng = "^1.6.1"
pyjwt = {extras = ["crypto"], version = "^2.10.1"}
[tool.poetry.dev-dependencies]
pytest = '<7'
[tool.poetry.group.dev.dependencies]
pytest = '*'
pytest-cov = '*'
pytest-mock = '*'
pytest-timeout = '*'
pytest-twisted = '*'
eth-ape = ">=0.7"
eth-ape = ">=0.8,<=0.8.12" # limit due to pytest not supporting newer version of eth-utils - also observed here: https://github.com/oceanprotocol/pdr-backend/issues/899
ape-solidity = '*'
coverage = '^7.3.2'
pre-commit = '^2.12.1'

View File

@ -1,103 +1,108 @@
abnf==2.2.0 ; python_version >= "3.9" and python_version < "4.0"
aiohappyeyeballs==2.3.2 ; python_version >= "3.9" and python_version < "4.0"
aiohttp==3.10.0 ; python_version >= "3.9" and python_version < "4"
aiosignal==1.3.1 ; python_version >= "3.9" and python_version < "4"
annotated-types==0.7.0 ; python_version >= "3.9" and python_version < "4.0"
appdirs==1.4.4 ; python_version >= "3.9" and python_version < "4"
async-timeout==4.0.3 ; python_version >= "3.9" and python_version < "3.11"
attrs==23.2.0 ; python_version >= "3.9" and python_version < "4"
atxm==0.5.0 ; python_version >= "3.9" and python_version < "4"
autobahn==23.6.2 ; python_version >= "3.9" and python_version < "4"
automat==22.10.0 ; python_version >= "3.9" and python_version < "4"
bitarray==2.9.2 ; python_version >= "3.9" and python_version < "4"
blinker==1.8.2 ; python_version >= "3.9" and python_version < "4"
bytestring-splitter==2.4.1 ; python_version >= "3.9" and python_version < "4"
certifi==2024.7.4 ; python_version >= "3.9" and python_version < "4"
cffi==1.16.0 ; python_version >= "3.9" and python_version < "4"
charset-normalizer==3.3.2 ; python_version >= "3.9" and python_version < "4"
ckzg==1.0.2 ; python_version >= "3.9" and python_version < "4"
click==8.1.7 ; python_version >= "3.9" and python_version < "4"
colorama==0.4.6 ; python_version >= "3.9" and python_version < "4"
constant-sorrow==0.1.0a9 ; python_version >= "3.9" and python_version < "4"
constantly==23.10.4 ; python_version >= "3.9" and python_version < "4"
cryptography==43.0.0 ; python_version >= "3.9" and python_version < "4"
cytoolz==0.12.3 ; python_version >= "3.9" and python_version < "4" and implementation_name == "cpython"
dateparser==1.2.0 ; python_version >= "3.9" and python_version < "4"
eth-abi==5.1.0 ; python_version >= "3.9" and python_version < "4"
eth-account==0.11.2 ; python_version >= "3.9" and python_version < "4"
eth-hash==0.7.0 ; python_version >= "3.9" and python_version < "4"
eth-hash[pycryptodome]==0.7.0 ; python_version >= "3.9" and python_version < "4"
eth-keyfile==0.8.1 ; python_version >= "3.9" and python_version < "4"
eth-keys==0.5.1 ; python_version >= "3.9" and python_version < "4"
eth-rlp==1.0.1 ; python_version >= "3.9" and python_version < "4"
eth-typing==3.5.2 ; python_version >= "3.9" and python_version < "4"
eth-utils==2.3.1 ; python_version >= "3.9" and python_version < "4"
flask==3.0.3 ; python_version >= "3.9" and python_version < "4"
frozenlist==1.4.1 ; python_version >= "3.9" and python_version < "4"
hendrix==5.0.0 ; python_version >= "3.9" and python_version < "4"
hexbytes==0.3.1 ; python_version >= "3.9" and python_version < "4"
humanize==4.10.0 ; python_version >= "3.9" and python_version < "4"
hyperlink==21.0.0 ; python_version >= "3.9" and python_version < "4"
idna==3.7 ; python_version >= "3.9" and python_version < "4"
importlib-metadata==8.2.0 ; python_version >= "3.9" and python_version < "3.10"
incremental==24.7.2 ; python_version >= "3.9" and python_version < "4"
itsdangerous==2.2.0 ; python_version >= "3.9" and python_version < "4"
jinja2==3.1.4 ; python_version >= "3.9" and python_version < "4"
jsonschema-specifications==2023.12.1 ; python_version >= "3.9" and python_version < "4"
jsonschema==4.23.0 ; python_version >= "3.9" and python_version < "4"
lru-dict==1.2.0 ; python_version >= "3.9" and python_version < "4"
mako==1.3.5 ; python_version >= "3.9" and python_version < "4"
markupsafe==2.1.5 ; python_version >= "3.9" and python_version < "4"
marshmallow==3.21.3 ; python_version >= "3.9" and python_version < "4"
maya==0.6.1 ; python_version >= "3.9" and python_version < "4"
mnemonic==0.21 ; python_version >= "3.9" and python_version < "4"
msgpack-python==0.5.6 ; python_version >= "3.9" and python_version < "4"
multidict==6.0.5 ; python_version >= "3.9" and python_version < "4"
nucypher-core==0.13.0 ; python_version >= "3.9" and python_version < "4"
packaging==23.2 ; python_version >= "3.9" and python_version < "4"
parsimonious==0.10.0 ; python_version >= "3.9" and python_version < "4"
pendulum==3.0.0 ; python_version >= "3.9" and python_version < "4"
prometheus-client==0.20.0 ; python_version >= "3.9" and python_version < "4"
protobuf==5.27.2 ; python_version >= "3.9" and python_version < "4"
pyasn1-modules==0.4.0 ; python_version >= "3.9" and python_version < "4"
pyasn1==0.6.0 ; python_version >= "3.9" and python_version < "4"
pychalk==2.0.1 ; python_version >= "3.9" and python_version < "4"
pycparser==2.22 ; python_version >= "3.9" and python_version < "4"
pycryptodome==3.20.0 ; python_version >= "3.9" and python_version < "4"
pydantic-core==2.20.1 ; python_version >= "3.9" and python_version < "4.0"
pydantic==2.8.2 ; python_version >= "3.9" and python_version < "4.0"
pynacl==1.5.0 ; python_version >= "3.9" and python_version < "4"
pyopenssl==24.2.1 ; python_version >= "3.9" and python_version < "4"
python-dateutil==2.9.0.post0 ; python_version >= "3.9" and python_version < "4"
abnf==2.3.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
aiohappyeyeballs==2.6.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
aiohttp==3.11.14 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
aiosignal==1.3.2 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
annotated-types==0.7.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
appdirs==1.4.4 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
async-timeout==5.0.1 ; python_version >= "3.9" and python_version < "3.11" and (implementation_name == "cpython" or implementation_name == "pypy")
attrs==25.3.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
atxm==0.5.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
autobahn==24.4.2 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
automat==24.8.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
bitarray==3.2.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
blinker==1.9.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
bytestring-splitter==2.4.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
cached-property==2.0.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
certifi==2025.1.31 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
cffi==1.17.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
charset-normalizer==3.4.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
ckzg==1.0.2 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
click==8.1.8 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
colorama==0.4.6 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
constant-sorrow==0.1.0a9 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
constantly==23.10.4 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
cryptography==43.0.3 ; python_version >= "3.9" and python_version < "3.11" and (implementation_name == "cpython" or implementation_name == "pypy")
cryptography==44.0.2 ; python_version >= "3.11" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
cytoolz==1.0.1 ; python_version >= "3.9" and python_version < "4" and implementation_name == "cpython"
dateparser==1.2.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
eth-abi==5.2.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
eth-account==0.11.3 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
eth-hash==0.7.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
eth-keyfile==0.9.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
eth-keys==0.6.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
eth-rlp==1.0.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
eth-typing==3.5.2 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
eth-utils==2.3.2 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
flask==3.1.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
frozenlist==1.5.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
hendrix==5.0.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
hexbytes==0.3.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
humanize==4.12.2 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
hyperlink==21.0.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
idna==3.10 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
importlib-metadata==8.6.1 ; python_version == "3.9" and (implementation_name == "cpython" or implementation_name == "pypy")
incremental==24.7.2 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
itsdangerous==2.2.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
jinja2==3.1.6 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
jsonpath-ng==1.7.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
jsonschema-specifications==2024.10.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
jsonschema==4.23.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
lru-dict==1.2.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
mako==1.3.9 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
markupsafe==3.0.2 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
marshmallow==3.26.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
maya==0.6.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
mnemonic==0.21 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
msgpack-python==0.5.6 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
multidict==6.2.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
nucypher-core==0.13.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
packaging==23.2 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
parsimonious==0.10.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
pendulum==3.0.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
ply==3.11 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
prometheus-client==0.21.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
propcache==0.3.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
protobuf==6.30.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
py-ecc==7.0.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
pyasn1-modules==0.4.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
pyasn1==0.6.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
pychalk @ git+https://github.com/nucypher/pychalk.git@b1a5c7562f9788f9c8722216b32019eeca281b69 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
pycparser==2.22 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
pycryptodome==3.22.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
pydantic-core==2.27.2 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
pydantic==2.10.6 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
pyjwt==2.10.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
pynacl==1.5.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
pyopenssl==25.0.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
python-dateutil==2.9.0.post0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
python-statemachine==2.3.4 ; python_version >= "3.9" and python_version < "4"
pytz==2024.1 ; python_version >= "3.9" and python_version < "4"
pyunormalize==15.1.0 ; python_version >= "3.9" and python_version < "4"
pywin32==306 ; python_version >= "3.9" and python_version < "4" and platform_system == "Windows"
referencing==0.35.1 ; python_version >= "3.9" and python_version < "4"
regex==2024.7.24 ; python_version >= "3.9" and python_version < "4"
requests==2.32.3 ; python_version >= "3.9" and python_version < "4"
rlp==4.0.1 ; python_version >= "3.9" and python_version < "4"
rpds-py==0.19.1 ; python_version >= "3.9" and python_version < "4"
service-identity==24.1.0 ; python_version >= "3.9" and python_version < "4"
setuptools==72.1.0 ; python_version >= "3.9" and python_version < "4"
siwe==4.2.0 ; python_version >= "3.9" and python_version < "4.0"
six==1.16.0 ; python_version >= "3.9" and python_version < "4"
snaptime==0.2.4 ; python_version >= "3.9" and python_version < "4"
tabulate==0.9.0 ; python_version >= "3.9" and python_version < "4"
time-machine==2.14.2 ; python_version >= "3.9" and python_version < "4"
tomli==2.0.1 ; python_version >= "3.9" and python_version < "3.11"
toolz==0.12.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "pypy" or implementation_name == "cpython")
twisted-iocpsupport==1.0.4 ; python_version >= "3.9" and python_version < "4" and platform_system == "Windows"
twisted==24.3.0 ; python_version >= "3.9" and python_version < "4"
txaio==23.1.1 ; python_version >= "3.9" and python_version < "4"
typing-extensions==4.12.2 ; python_version >= "3.9" and python_version < "4"
tzdata==2024.1 ; python_version >= "3.9" and python_version < "4"
tzlocal==5.2 ; python_version >= "3.9" and python_version < "4"
urllib3==2.2.2 ; python_version >= "3.9" and python_version < "4"
watchdog==3.0.0 ; python_version >= "3.9" and python_version < "4"
web3==6.20.1 ; python_version >= "3.9" and python_version < "4"
websockets==12.0 ; python_version >= "3.9" and python_version < "4"
werkzeug==3.0.3 ; python_version >= "3.9" and python_version < "4"
yarl==1.9.4 ; python_version >= "3.9" and python_version < "4"
zipp==3.19.2 ; python_version >= "3.9" and python_version < "3.10"
zope-interface==6.4.post2 ; python_version >= "3.9" and python_version < "4"
pytz==2025.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
pyunormalize==16.0.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
pywin32==310 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy") and platform_system == "Windows"
referencing==0.36.2 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
regex==2024.11.6 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
requests==2.32.3 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
rlp==4.1.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
rpds-py==0.23.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
service-identity==24.2.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
setuptools==78.0.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
siwe==4.2.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
six==1.17.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
snaptime @ git+https://github.com/nucypher/snaptime.git@7edf0f861198b3689ccf82602e3f7b0a24acf6d5 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
tabulate==0.9.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
time-machine==2.16.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
tomli==2.2.1 ; python_version >= "3.9" and python_version < "3.11" and (implementation_name == "cpython" or implementation_name == "pypy")
toolz==1.0.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
twisted==24.11.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
txaio==23.1.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
typing-extensions==4.12.2 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
tzdata==2025.2 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
tzlocal==5.3.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
urllib3==2.3.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
watchdog==3.0.0 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
web3==6.20.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
websockets==15.0.1 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
werkzeug==3.1.3 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
yarl==1.18.3 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")
zipp==3.21.0 ; python_version == "3.9" and (implementation_name == "cpython" or implementation_name == "pypy")
zope-interface==7.2 ; python_version >= "3.9" and python_version < "4" and (implementation_name == "cpython" or implementation_name == "pypy")

View File

@ -0,0 +1,112 @@
from typing import List, Optional
import click
import requests
from eth_typing import ChecksumAddress
from nucypher_core import NodeMetadata
from urllib3.exceptions import InsecureRequestWarning
from nucypher.blockchain.eth import domains
from nucypher.blockchain.eth.agents import (
ContractAgency,
CoordinatorAgent,
)
from nucypher.blockchain.eth.registry import ContractRegistry
from nucypher.utilities.emitters import StdoutEmitter
from nucypher.utilities.logging import GlobalLoggerSettings
GlobalLoggerSettings.start_console_logging()
emitter = StdoutEmitter(verbosity=2)
def get_node_urls(participants: Optional[List[ChecksumAddress]] = None):
participants_set = set(participants) if participants else None
node_urls = {}
try:
response = requests.get(
"https://mainnet.nucypher.network:9151/status?json=true",
verify=False,
timeout=5,
)
all_nodes = response.json().get("known_nodes", [])
for node in all_nodes:
staker_address = node["staker_address"]
if participants_set and staker_address not in participants_set:
continue
node_urls[node["staker_address"]] = f"https://{node['rest_url']}"
except Exception:
pass
return node_urls
def get_current_ferveo_key(node_url):
try:
response = requests.get(
f"{node_url}/public_information", verify=False, timeout=5
)
node_metadata = NodeMetadata.from_bytes(response.content)
return node_metadata.payload.ferveo_public_key
except Exception:
pass
return None
@click.command()
@click.option(
"--polygon-endpoint",
"polygon_endpoint",
help="Polygon network provider URI",
type=click.STRING,
required=True,
)
@click.option(
"--ritual-id", help="Ritual ID", type=click.INT, required=False, default=None
)
def differing_ferveo_keys(
polygon_endpoint,
ritual_id,
):
# Suppress https verification warnings
requests.packages.urllib3.disable_warnings(category=InsecureRequestWarning)
domain = domains.get_domain(domains.MAINNET.name)
registry = ContractRegistry.from_latest_publication(domain=domain)
emitter.echo(f"NOTICE: Connecting to {domain} domain", color="yellow")
coordinator_agent = ContractAgency.get_agent(
agent_class=CoordinatorAgent,
registry=registry,
blockchain_endpoint=polygon_endpoint,
) # type: CoordinatorAgent
participants = None # None means all nodes
if ritual_id:
ritual = coordinator_agent.get_ritual(ritual_id)
participants = ritual.providers # only nodes for ritual
node_urls = get_node_urls(participants)
ritual_id_for_public_key_check = ritual_id or coordinator_agent.number_of_rituals()
for provider, node_url in node_urls.items():
if not node_url:
print(f"Unable to determine public ip for {provider}")
continue
else:
current_ferveo_key = get_current_ferveo_key(node_url)
if not current_ferveo_key:
print(
f"Unable to obtain current public key from {node_url} for {provider}"
)
continue
reported_ferveo_key = coordinator_agent.get_provider_public_key(
provider, ritual_id_for_public_key_check
)
if bytes(current_ferveo_key) != bytes(reported_ferveo_key):
print(f"[MISMATCH] {provider} is using different key than reported")
if __name__ == "__main__":
differing_ferveo_keys()

53
scripts/dkg/verify_dkg.py Normal file
View File

@ -0,0 +1,53 @@
import random
from typing import List
from nucypher_core.ferveo import Transcript, Validator
from nucypher.blockchain.eth import domains
from nucypher.blockchain.eth.agents import ContractAgency, CoordinatorAgent
from nucypher.blockchain.eth.registry import ContractRegistry
from nucypher.crypto.ferveo.dkg import aggregate_transcripts
ritual_id = 20
domain = domains.MAINNET
endpoint = ""
registry = ContractRegistry.from_latest_publication(domain=domain)
coordinator_agent = ContractAgency.get_agent(
agent_class=CoordinatorAgent,
registry=registry,
blockchain_endpoint=endpoint,
)
def resolve_validators() -> List[Validator]:
result = list()
for staking_provider_address in ritual.providers:
public_key = coordinator_agent.get_provider_public_key(
provider=staking_provider_address, ritual_id=ritual.id
)
external_validator = Validator(
address=staking_provider_address, public_key=public_key
)
result.append(external_validator)
result = sorted(result, key=lambda x: x.address)
return result
ritual = coordinator_agent.get_ritual(
ritual_id=ritual_id,
transcripts=True,
)
validators = resolve_validators()
transcripts = [Transcript.from_bytes(bytes(t)) for t in ritual.transcripts]
messages = list(zip(validators, transcripts))
aggregate_transcripts(
transcripts=messages,
shares=ritual.shares,
threshold=ritual.threshold,
me=random.choice(validators), # this is hacky
ritual_id=ritual.id,
)

View File

@ -0,0 +1,49 @@
import asyncio
from collections import Counter
from contextlib import suppress
import aiohttp
import requests
TIMEOUT = 5
template = "https://{host}/status?json=true"
requests.packages.urllib3.disable_warnings()
async def fetch(session, url):
with suppress(Exception):
async with session.get(url, ssl=False, timeout=TIMEOUT) as response:
response = await response.json()
return response["version"]
return "unknown"
async def main():
url = template.format(host="mainnet.nucypher.network:9151")
async with aiohttp.ClientSession() as session:
async with session.get(url, ssl=False, timeout=TIMEOUT) as response:
status_data = await response.json()
nodes = status_data.get("known_nodes", [])
total_nodes = len(nodes)
print(f"Number of nodes: {total_nodes}")
tasks = set()
for node in nodes:
url = template.format(host=node["rest_url"])
tasks.add(fetch(session, url))
results = Counter()
for task in asyncio.as_completed(tasks):
if task:
result = await task
results[result] += 1
items = sorted(results.items(), key=lambda result: result[1], reverse=True)
for version, count in items:
print(f"Version {version}: {count} nodes ({count*100/total_nodes:.1f}%)")
asyncio.run(main())

View File

@ -16,6 +16,7 @@ from nucypher.blockchain.eth.models import Coordinator
from nucypher.blockchain.eth.registry import ContractRegistry
from nucypher.blockchain.eth.signers import InMemorySigner, Signer
from nucypher.characters.lawful import Bob, Enrico
from nucypher.cli.types import EIP55_CHECKSUM_ADDRESS
from nucypher.crypto.powers import TransactingPower
from nucypher.policy.conditions.lingo import ConditionLingo, ConditionType
from nucypher.utilities.emitters import StdoutEmitter
@ -48,7 +49,7 @@ def get_transacting_power(signer: Signer):
"--domain",
"domain",
help="TACo Domain",
type=click.Choice([str(domains.TAPIR), str(domains.LYNX)]),
type=click.Choice([str(domains.TAPIR), str(domains.LYNX), str(domains.MAINNET)]),
default=str(domains.LYNX),
)
@click.option(
@ -118,6 +119,13 @@ def get_transacting_power(signer: Signer):
type=click.Choice([GLOBAL_ALLOW_LIST, "OpenAccessAuthorizer"]),
required=False,
)
@click.option(
"--fee-model",
"-f",
help="Fee model contract address",
type=EIP55_CHECKSUM_ADDRESS,
required=False,
)
def nucypher_dkg(
domain,
eth_endpoint,
@ -129,6 +137,7 @@ def nucypher_dkg(
ritual_duration,
use_random_enrico,
access_controller,
fee_model,
):
if ritual_id < 0:
# if creating ritual(s)
@ -148,6 +157,14 @@ def nucypher_dkg(
fg="red",
),
)
if fee_model is None:
raise click.BadOptionUsage(
option_name="--fee-model",
message=click.style(
"--fee-model must be provided to create new ritual",
fg="red",
),
)
if num_rituals < 1:
raise click.BadOptionUsage(
option_name="--num-rituals",
@ -164,7 +181,14 @@ def nucypher_dkg(
fg="red",
),
)
if fee_model:
raise click.BadOptionUsage(
option_name="--fee-model",
message=click.style(
"--fee-model not needed since it is obtained from the Coordinator",
fg="red",
),
)
if num_rituals != 1:
raise click.BadOptionUsage(
option_name="--ritual-id, --num-rituals",
@ -248,6 +272,7 @@ def nucypher_dkg(
dkg_staking_providers.sort()
emitter.echo(f"Using staking providers for DKG: {dkg_staking_providers}")
receipt = coordinator_agent.initiate_ritual(
fee_model=fee_model,
providers=dkg_staking_providers,
authority=account_address,
duration=ritual_duration,

View File

@ -40,7 +40,7 @@ pip cache purge
set -e
echo "Building Development Requirements"
poetry lock
poetry lock --regenerate
poetry export -o dev-requirements.txt --without-hashes --with dev
echo "Building Standard Requirements"

View File

@ -2,6 +2,7 @@ import pytest
import pytest_twisted
from twisted.logger import globalLogPublisher
from nucypher.blockchain.eth.models import Coordinator
from nucypher.blockchain.eth.signers import InMemorySigner
from nucypher.crypto.keypairs import RitualisticKeypair
from nucypher.crypto.powers import RitualisticPower
@ -57,6 +58,7 @@ def test_dkg_failure_with_ferveo_key_mismatch(
interval,
testerchain,
initiator,
fee_model,
global_allow_list,
duration,
accounts,
@ -90,16 +92,15 @@ def test_dkg_failure_with_ferveo_key_mismatch(
cohort_staking_provider_addresses = list(u.checksum_address for u in cohort)
# Approve the ritual token for the coordinator agent to spend
amount = coordinator_agent.get_ritual_initiation_cost(
providers=cohort_staking_provider_addresses, duration=duration
)
amount = fee_model.getRitualCost(len(cohort_staking_provider_addresses), duration)
ritual_token.approve(
coordinator_agent.contract_address,
fee_model.address,
amount,
sender=accounts[initiator.transacting_power.account],
)
receipt = coordinator_agent.initiate_ritual(
fee_model=fee_model.address,
providers=cohort_staking_provider_addresses,
authority=initiator.transacting_power.account,
duration=duration,
@ -117,11 +118,22 @@ def test_dkg_failure_with_ferveo_key_mismatch(
globalLogPublisher.addObserver(log_trapper)
print("==================== AWAITING DKG FAILURE ====================")
while len(log_messages) == 0:
print(
"==================== AWAITING DKG FAILURE (FERVEO MISMATCH) ===================="
)
while (
coordinator_agent.get_ritual_status(ritual_id)
!= Coordinator.RitualStatus.DKG_TIMEOUT
):
# remains in awaiting transcripts because of the one bad ursula not submitting transcript
assert (
coordinator_agent.get_ritual_status(ritual_id)
== Coordinator.RitualStatus.DKG_AWAITING_TRANSCRIPTS
)
yield clock.advance(interval)
yield testerchain.time_travel(seconds=1)
yield testerchain.time_travel(
seconds=coordinator_agent.get_timeout() // 6
) # min. 6 rounds before timeout
assert (
render_ferveo_key_mismatch_warning(
@ -130,6 +142,17 @@ def test_dkg_failure_with_ferveo_key_mismatch(
in log_messages
)
for ursula in cohort:
participant = coordinator_agent.get_participant(
ritual_id, ursula.checksum_address, True
)
if ursula.checksum_address == bad_ursula.checksum_address:
assert (
not participant.transcript
), "transcript not submitted due to mismatched ferveo key"
else:
assert participant.transcript, "transcript submitted"
testerchain.tx_machine.stop()
assert not testerchain.tx_machine.running
globalLogPublisher.removeObserver(log_trapper)

View File

@ -1,6 +1,6 @@
import os
import random
from unittest.mock import patch
from unittest.mock import ANY, patch
import pytest
import pytest_twisted
@ -12,12 +12,15 @@ from nucypher.blockchain.eth.constants import NULL_ADDRESS
from nucypher.blockchain.eth.models import Coordinator
from nucypher.blockchain.eth.signers.software import InMemorySigner
from nucypher.characters.lawful import Enrico, Ursula
from nucypher.network.decryption import ThresholdDecryptionClient
from nucypher.policy.conditions.evm import ContractCondition, RPCCondition
from nucypher.policy.conditions.lingo import (
ConditionLingo,
ConditionVariable,
NotCompoundCondition,
OrCompoundCondition,
ReturnValueTest,
SequentialAccessControlCondition,
)
from nucypher.policy.conditions.time import TimeCondition
from tests.constants import TEST_ETH_PROVIDER_URI, TESTERCHAIN_CHAIN_ID
@ -93,7 +96,14 @@ def condition(test_registry):
)
not_not_condition = NotCompoundCondition(
operand=NotCompoundCondition(operand=and_condition)
operand=NotCompoundCondition(operand=rpc_condition)
)
sequential_condition = SequentialAccessControlCondition(
condition_variables=[
ConditionVariable("rpc", rpc_condition),
ConditionVariable("contract", contract_condition),
]
)
conditions = [
@ -103,6 +113,7 @@ def condition(test_registry):
or_condition,
and_condition,
not_not_condition,
sequential_condition,
]
condition_to_use = random.choice(conditions)
@ -137,6 +148,7 @@ def test_dkg_initiation(
accounts,
initiator,
cohort,
fee_model,
global_allow_list,
testerchain,
ritual_token,
@ -147,16 +159,15 @@ def test_dkg_initiation(
cohort_staking_provider_addresses = list(u.checksum_address for u in cohort)
# Approve the ritual token for the coordinator agent to spend
amount = coordinator_agent.get_ritual_initiation_cost(
providers=cohort_staking_provider_addresses, duration=duration
)
amount = fee_model.getRitualCost(len(cohort_staking_provider_addresses), duration)
ritual_token.approve(
coordinator_agent.contract_address,
fee_model.address,
amount,
sender=accounts[initiator.transacting_power.account],
)
receipt = coordinator_agent.initiate_ritual(
fee_model=fee_model.address,
providers=cohort_staking_provider_addresses,
authority=initiator.transacting_power.account,
duration=duration,
@ -243,8 +254,12 @@ def test_encrypt(
@pytest_twisted.inlineCallbacks
def test_unauthorized_decryption(bob, cohort, threshold_message_kit, ritual_id):
def test_unauthorized_decryption(
bob, cohort, threshold_message_kit, ritual_id, signer, global_allow_list
):
print("======== DKG DECRYPTION (UNAUTHORIZED) ========")
assert not global_allow_list.isAddressAuthorized(ritual_id, signer.accounts[0])
bob.start_learning_loop(now=True)
with pytest.raises(
Ursula.NotEnoughUrsulas,
@ -261,19 +276,88 @@ def test_unauthorized_decryption(bob, cohort, threshold_message_kit, ritual_id):
yield
def check_decrypt_without_any_cached_values(
@pytest_twisted.inlineCallbacks
def test_authorized_decryption(
mocker,
bob,
global_allow_list,
accounts,
coordinator_agent,
threshold_message_kit,
signer,
initiator,
ritual_id,
cohort,
plaintext,
):
print("==================== DKG DECRYPTION (AUTHORIZED) ====================")
# authorize Enrico to encrypt for ritual
global_allow_list.authorize(
ritual_id,
[signer.accounts[0]],
sender=accounts[initiator.transacting_power.account],
)
# fake some latency stats
latency_stats = {}
for ursula in cohort:
# reset all stats
bob.node_latency_collector.reset_stats(ursula.checksum_address)
# add a single data point for each ursula: some time between 0.1 and 4
mock_latency = random.uniform(0.1, 4)
bob.node_latency_collector._update_stats(ursula.checksum_address, mock_latency)
latency_stats[ursula.checksum_address] = mock_latency
expected_ursula_request_ordering = sorted(
list(latency_stats.keys()),
key=lambda ursula_checksum: latency_stats[ursula_checksum],
)
value_factory_spy = mocker.spy(
ThresholdDecryptionClient.ThresholdDecryptionRequestFactory, "__init__"
)
# ritual_id, ciphertext, conditions are obtained from the side channel
bob.start_learning_loop(now=True)
cleartext = yield bob.threshold_decrypt(
threshold_message_kit=threshold_message_kit,
)
assert bytes(cleartext) == plaintext.encode()
# check that proper ordering of ursulas used for worker pool factory for requests
value_factory_spy.assert_called_once_with(
ANY,
ursulas_to_contact=expected_ursula_request_ordering,
batch_size=ANY,
threshold=ANY,
)
# check prometheus metric for decryption requests
# since all running on the same machine - the value is not per-ursula but rather all
num_successes = REGISTRY.get_sample_value(
"threshold_decryption_num_successes_total"
)
ritual = coordinator_agent.get_ritual(ritual_id)
# at least a threshold of ursulas were successful (concurrency)
assert int(num_successes) >= ritual.threshold
print("===================== DECRYPTION SUCCESSFUL =====================")
yield
@pytest_twisted.inlineCallbacks
def test_decrypt_without_any_cached_values(
threshold_message_kit, ritual_id, cohort, bob, coordinator_agent, plaintext
):
print("==================== DKG DECRYPTION NO CACHE ====================")
original_validators = cohort[0].dkg_storage.get_validators(ritual_id)
for ursula in cohort:
ursula.dkg_storage.clear(ritual_id)
assert ursula.dkg_storage.get_validators(ritual_id) is None
assert ursula.dkg_storage.get_active_ritual(ritual_id) is None
# perform threshold decryption
bob.start_learning_loop(now=True)
cleartext = bob.threshold_decrypt(
cleartext = yield bob.threshold_decrypt(
threshold_message_kit=threshold_message_kit,
)
assert bytes(cleartext) == plaintext.encode()
@ -294,45 +378,7 @@ def check_decrypt_without_any_cached_values(
assert v.public_key == original_validators[v_index].public_key
assert num_used_ursulas >= ritual.threshold
print("===================== DECRYPTION SUCCESSFUL =====================")
@pytest_twisted.inlineCallbacks
def test_authorized_decryption(
bob,
global_allow_list,
accounts,
coordinator_agent,
threshold_message_kit,
signer,
initiator,
ritual_id,
plaintext,
):
print("==================== DKG DECRYPTION (AUTHORIZED) ====================")
# authorize Enrico to encrypt for ritual
global_allow_list.authorize(
ritual_id,
[signer.accounts[0]],
sender=accounts[initiator.transacting_power.account],
)
# ritual_id, ciphertext, conditions are obtained from the side channel
bob.start_learning_loop(now=True)
cleartext = yield bob.threshold_decrypt(
threshold_message_kit=threshold_message_kit,
)
assert bytes(cleartext) == plaintext.encode()
# check prometheus metric for decryption requests
# since all running on the same machine - the value is not per-ursula but rather all
num_successes = REGISTRY.get_sample_value(
"threshold_decryption_num_successes_total"
)
ritual = coordinator_agent.get_ritual(ritual_id)
# at least a threshold of ursulas were successful (concurrency)
assert int(num_successes) >= ritual.threshold
print("===================== DECRYPTION NO CACHE SUCCESSFUL =====================")
yield

View File

@ -14,6 +14,7 @@ def test_ursula_dkg_rounds_fault_tolerance(
ursulas,
testerchain,
ritual_token,
fee_model,
global_allow_list,
coordinator_agent,
accounts,
@ -30,6 +31,7 @@ def test_ursula_dkg_rounds_fault_tolerance(
initiator,
testerchain,
ritual_token,
fee_model,
global_allow_list,
coordinator_agent,
cohort_addresses,
@ -61,21 +63,21 @@ def initiate_dkg(
initiator,
testerchain,
ritual_token,
fee_model,
global_allow_list,
coordinator_agent,
cohort_addresses,
):
duration = 24 * 60 * 60
# Approve the ritual token for the coordinator agent to spend
amount = coordinator_agent.get_ritual_initiation_cost(
providers=cohort_addresses, duration=duration
)
amount = fee_model.getRitualCost(len(cohort_addresses), duration)
ritual_token.approve(
coordinator_agent.contract_address,
fee_model.address,
amount,
sender=accounts[initiator.transacting_power.account],
)
receipt = coordinator_agent.initiate_ritual(
fee_model=fee_model.address,
providers=cohort_addresses,
authority=initiator.transacting_power.account,
duration=duration,

View File

@ -1,5 +1,3 @@
import os
import pytest
import pytest_twisted
from eth_utils import keccak
@ -10,6 +8,7 @@ from twisted.internet.task import deferLater
from nucypher.blockchain.eth.agents import CoordinatorAgent
from nucypher.blockchain.eth.models import Coordinator
from nucypher.crypto.powers import TransactingPower
from tests.utils.dkg import generate_fake_ritual_transcript, threshold_from_shares
@pytest.fixture(scope='module')
@ -17,11 +16,6 @@ def agent(coordinator_agent) -> CoordinatorAgent:
return coordinator_agent
@pytest.fixture(scope='module')
def transcripts():
return [os.urandom(32), os.urandom(32)]
@pytest.mark.usefixtures("ursulas")
@pytest.fixture(scope="module")
def cohort(staking_providers):
@ -62,6 +56,7 @@ def test_initiate_ritual(
agent,
cohort,
get_random_checksum_address,
fee_model,
global_allow_list,
transacting_powers,
ritual_token,
@ -72,17 +67,18 @@ def test_initiate_ritual(
assert number_of_rituals == 0
duration = 60 * 60 * 24
amount = agent.get_ritual_initiation_cost(cohort, duration)
amount = fee_model.getRitualCost(len(cohort), duration)
# Approve the ritual token for the coordinator agent to spend
ritual_token.approve(
agent.contract_address,
fee_model.address,
amount,
sender=accounts[initiator.transacting_power.account],
)
authority = get_random_checksum_address()
receipt = agent.initiate_ritual(
fee_model=fee_model.address,
providers=cohort,
authority=authority,
duration=duration,
@ -114,15 +110,20 @@ def test_initiate_ritual(
@pytest_twisted.inlineCallbacks
def test_post_transcript(
agent, transcripts, transacting_powers, testerchain, clock, mock_async_hooks
agent, transacting_powers, testerchain, clock, mock_async_hooks
):
ritual_id = agent.number_of_rituals() - 1
dkg_size = len(transacting_powers)
threshold = threshold_from_shares(dkg_size)
txs = []
for i, transacting_power in enumerate(transacting_powers):
transcripts = []
for transacting_power in transacting_powers:
transcript = generate_fake_ritual_transcript(dkg_size, threshold)
transcripts.append(transcript)
async_tx = agent.post_transcript(
ritual_id=ritual_id,
transcript=transcripts[i],
transcript=transcript,
transacting_power=transacting_power,
async_tx_hooks=mock_async_hooks,
)
@ -169,7 +170,6 @@ def test_post_transcript(
@pytest_twisted.inlineCallbacks
def test_post_aggregation(
agent,
aggregated_transcript,
dkg_public_key,
transacting_powers,
cohort,
@ -183,7 +183,11 @@ def test_post_aggregation(
txs = []
participant_public_key = SessionStaticSecret.random().public_key()
for i, transacting_power in enumerate(transacting_powers):
dkg_size = len(transacting_powers)
threshold = threshold_from_shares(dkg_size)
aggregated_transcript = generate_fake_ritual_transcript(dkg_size, threshold)
for transacting_power in transacting_powers:
async_tx = agent.post_aggregation(
ritual_id=ritual_id,
aggregated_transcript=aggregated_transcript,

View File

@ -11,11 +11,6 @@ dependencies:
solidity:
version: 0.8.23
evm_version: paris
import_remapping:
- "@openzeppelin/contracts=openzeppelin/v5.0.0"
- "@openzeppelin-upgradeable/contracts=openzeppelin-upgradeable/v5.0.0"
- "@fx-portal/contracts=fx-portal/v1.0.5"
- "@threshold/contracts=threshold/v1.2.1"
- name: openzeppelin
github: OpenZeppelin/openzeppelin-contracts
@ -24,11 +19,10 @@ dependencies:
solidity:
version: 0.8.23
evm_version: paris
import_remapping:
- "@openzeppelin/contracts=openzeppelin/v5.0.0"
test:
provider:
chain_id: 131277322940537 # ensure ape doesn't change chain id to 1337
mnemonic: test test test test test test test test test test test junk
number_of_accounts: 30
balance: 1_000_000 ETH

View File

@ -2,26 +2,26 @@ import pytest
from nucypher.blockchain.eth.agents import (
ContractAgency,
NucypherTokenAgent,
SubscriptionManagerAgent,
)
from nucypher.policy.conditions.context import USER_ADDRESS_CONTEXT
from nucypher.policy.conditions.evm import ContractCondition
from nucypher.policy.conditions.lingo import (
AndCompoundCondition,
ConditionLingo,
OrCompoundCondition,
ReturnValueTest,
)
from nucypher.policy.conditions.utils import ConditionProviderManager
from tests.constants import TEST_ETH_PROVIDER_URI, TESTERCHAIN_CHAIN_ID
@pytest.fixture()
def condition_providers(testerchain):
providers = {testerchain.client.chain_id: {testerchain.provider}}
providers = ConditionProviderManager(
{testerchain.client.chain_id: {testerchain.provider}}
)
return providers
@pytest.fixture()
def compound_lingo(
erc721_evm_condition_balanceof,
@ -35,12 +35,8 @@ def compound_lingo(
operands=[
erc721_evm_condition_balanceof,
time_condition,
AndCompoundCondition(
operands=[
rpc_condition,
erc20_evm_condition_balanceof,
]
),
rpc_condition,
erc20_evm_condition_balanceof,
]
)
)
@ -48,14 +44,9 @@ def compound_lingo(
@pytest.fixture()
def erc20_evm_condition_balanceof(testerchain, test_registry):
token = ContractAgency.get_agent(
NucypherTokenAgent,
registry=test_registry,
blockchain_endpoint=TEST_ETH_PROVIDER_URI,
)
def erc20_evm_condition_balanceof(testerchain, test_registry, ritual_token):
condition = ContractCondition(
contract_address=token.contract.address,
contract_address=ritual_token.address,
method="balanceOf",
standard_contract_type="ERC20",
chain=TESTERCHAIN_CHAIN_ID,
@ -66,14 +57,12 @@ def erc20_evm_condition_balanceof(testerchain, test_registry):
@pytest.fixture
def erc721_contract(accounts, project):
account = accounts[0]
def erc721_contract(project, deployer_account):
# deploy contract
deployed_contract = project.ConditionNFT.deploy(sender=account)
deployed_contract = project.ConditionNFT.deploy(sender=deployer_account)
# mint nft with token id = 1
deployed_contract.mint(account.address, 1, sender=account)
deployed_contract.mint(deployer_account.address, 1, sender=deployer_account)
return deployed_contract
@ -152,15 +141,10 @@ def subscription_manager_is_active_policy_condition(testerchain, test_registry):
@pytest.fixture
def custom_context_variable_erc20_condition(
test_registry, testerchain, mock_condition_blockchains
test_registry, testerchain, mock_condition_blockchains, ritual_token
):
token = ContractAgency.get_agent(
NucypherTokenAgent,
registry=test_registry,
blockchain_endpoint=TEST_ETH_PROVIDER_URI,
)
condition = ContractCondition(
contract_address=token.contract.address,
contract_address=ritual_token.address,
method="balanceOf",
standard_contract_type="ERC20",
chain=TESTERCHAIN_CHAIN_ID,
@ -168,3 +152,11 @@ def custom_context_variable_erc20_condition(
parameters=[":addressToUse"],
)
return condition
@pytest.fixture
def eip1271_contract_wallet(project, deployer_account):
_eip1271_contract_wallet = deployer_account.deploy(
project.SmartContractWallet, deployer_account.address
)
return _eip1271_contract_wallet

View File

@ -3,6 +3,7 @@ import os
from unittest import mock
import pytest
from eth_account.messages import defunct_hash_message, encode_defunct
from hexbytes import HexBytes
from web3 import Web3
from web3.providers import BaseProvider
@ -13,6 +14,7 @@ from nucypher.blockchain.eth.agents import (
SubscriptionManagerAgent,
)
from nucypher.blockchain.eth.constants import NULL_ADDRESS
from nucypher.policy.conditions.auth.evm import EvmAuth
from nucypher.policy.conditions.context import (
USER_ADDRESS_CONTEXT,
get_context_value,
@ -22,17 +24,18 @@ from nucypher.policy.conditions.evm import (
RPCCondition,
)
from nucypher.policy.conditions.exceptions import (
InvalidCondition,
NoConnectionToChain,
RequiredContextVariable,
RPCExecutionFailed,
)
from nucypher.policy.conditions.json.rpc import JsonRpcCondition
from nucypher.policy.conditions.lingo import (
ConditionLingo,
ConditionType,
NotCompoundCondition,
ReturnValueTest,
)
from nucypher.policy.conditions.utils import ConditionProviderManager
from tests.constants import (
TEST_ETH_PROVIDER_URI,
TEST_POLYGON_PROVIDER_URI,
@ -42,6 +45,28 @@ from tests.utils.policy import make_message_kits
GET_CONTEXT_VALUE_IMPORT_PATH = "nucypher.policy.conditions.context.get_context_value"
getActiveStakingProviders_abi_2_params = {
"type": "function",
"name": "getActiveStakingProviders",
"stateMutability": "view",
"inputs": [
{"name": "_startIndex", "type": "uint256", "internalType": "uint256"},
{
"name": "_maxStakingProviders",
"type": "uint256",
"internalType": "uint256",
},
],
"outputs": [
{"name": "allAuthorizedTokens", "type": "uint96", "internalType": "uint96"},
{
"name": "activeStakingProviders",
"type": "bytes32[]",
"internalType": "bytes32[]",
},
],
}
def _dont_validate_user_address(context_variable: str, **context):
if context_variable == USER_ADDRESS_CONTEXT:
@ -67,11 +92,12 @@ def test_rpc_condition_evaluation_no_providers(
):
context = {USER_ADDRESS_CONTEXT: {"address": accounts.unassigned_accounts[0]}}
with pytest.raises(NoConnectionToChain):
_ = rpc_condition.verify(providers={}, **context)
_ = rpc_condition.verify(providers=ConditionProviderManager({}), **context)
with pytest.raises(NoConnectionToChain):
_ = rpc_condition.verify(
providers={testerchain.client.chain_id: set()}, **context
providers=ConditionProviderManager({testerchain.client.chain_id: list()}),
**context,
)
@ -84,10 +110,11 @@ def test_rpc_condition_evaluation_invalid_provider_for_chain(
):
context = {USER_ADDRESS_CONTEXT: {"address": accounts.unassigned_accounts[0]}}
new_chain = 23
rpc_condition.chain = new_chain
condition_providers = {new_chain: {testerchain.provider}}
rpc_condition.execution_call.chain = new_chain
condition_providers = ConditionProviderManager({new_chain: [testerchain.provider]})
with pytest.raises(
InvalidCondition, match=f"can only be evaluated on chain ID {new_chain}"
NoConnectionToChain,
match=f"Problematic provider endpoints for chain ID {new_chain}",
):
_ = rpc_condition.verify(providers=condition_providers, **context)
@ -118,13 +145,15 @@ def test_rpc_condition_evaluation_multiple_chain_providers(
):
context = {USER_ADDRESS_CONTEXT: {"address": accounts.unassigned_accounts[0]}}
condition_providers = {
"1": {"fake1a", "fake1b"},
"2": {"fake2"},
"3": {"fake3"},
"4": {"fake4"},
TESTERCHAIN_CHAIN_ID: {testerchain.provider},
}
condition_providers = ConditionProviderManager(
{
"1": ["fake1a", "fake1b"],
"2": ["fake2"],
"3": ["fake3"],
"4": ["fake4"],
TESTERCHAIN_CHAIN_ID: [testerchain.provider],
}
)
condition_result, call_result = rpc_condition.verify(
providers=condition_providers, **context
@ -144,22 +173,17 @@ def test_rpc_condition_evaluation_multiple_providers_no_valid_fallback(
):
context = {USER_ADDRESS_CONTEXT: {"address": accounts.unassigned_accounts[0]}}
def my_configure_w3(provider: BaseProvider):
return Web3(provider)
condition_providers = {
TESTERCHAIN_CHAIN_ID: {
mocker.Mock(spec=BaseProvider),
mocker.Mock(spec=BaseProvider),
mocker.Mock(spec=BaseProvider),
condition_providers = ConditionProviderManager(
{
TESTERCHAIN_CHAIN_ID: [
mocker.Mock(spec=BaseProvider),
mocker.Mock(spec=BaseProvider),
mocker.Mock(spec=BaseProvider),
]
}
}
mocker.patch.object(
rpc_condition, "_check_chain_id", return_value=None
) # skip chain check
mocker.patch.object(rpc_condition, "_configure_w3", my_configure_w3)
)
mocker.patch.object(condition_providers, "_check_chain_id", return_value=None)
with pytest.raises(RPCExecutionFailed):
_ = rpc_condition.verify(providers=condition_providers, **context)
@ -173,22 +197,18 @@ def test_rpc_condition_evaluation_multiple_providers_valid_fallback(
):
context = {USER_ADDRESS_CONTEXT: {"address": accounts.unassigned_accounts[0]}}
def my_configure_w3(provider: BaseProvider):
return Web3(provider)
condition_providers = {
TESTERCHAIN_CHAIN_ID: {
mocker.Mock(spec=BaseProvider),
mocker.Mock(spec=BaseProvider),
mocker.Mock(spec=BaseProvider),
testerchain.provider,
condition_providers = ConditionProviderManager(
{
TESTERCHAIN_CHAIN_ID: [
mocker.Mock(spec=BaseProvider),
mocker.Mock(spec=BaseProvider),
mocker.Mock(spec=BaseProvider),
testerchain.provider,
]
}
}
)
mocker.patch.object(
rpc_condition, "_check_chain_id", return_value=None
) # skip chain check
mocker.patch.object(rpc_condition, "_configure_w3", my_configure_w3)
mocker.patch.object(condition_providers, "_check_chain_id", return_value=None)
condition_result, call_result = rpc_condition.verify(
providers=condition_providers, **context
@ -211,10 +231,12 @@ def test_rpc_condition_evaluation_no_connection_to_chain(
context = {USER_ADDRESS_CONTEXT: {"address": accounts.unassigned_accounts[0]}}
# condition providers for other unrelated chains
providers = {
1: mock.Mock(), # mainnet
11155111: mock.Mock(), # Sepolia
}
providers = ConditionProviderManager(
{
1: [mock.Mock()], # mainnet
11155111: [mock.Mock()], # Sepolia
}
)
with pytest.raises(NoConnectionToChain):
rpc_condition.verify(providers=providers, **context)
@ -253,7 +275,10 @@ def test_rpc_condition_evaluation_with_context_var_in_return_value_test(
invalid_balance = balance + 1
context[":balanceContextVar"] = invalid_balance
condition_result, call_result = rpc_condition.verify(
providers={testerchain.client.chain_id: [testerchain.provider]}, **context
providers=ConditionProviderManager(
{testerchain.client.chain_id: [testerchain.provider]}
),
**context,
)
assert condition_result is False
assert call_result != invalid_balance
@ -770,30 +795,9 @@ def test_contract_condition_using_overloaded_function(
#
# valid overloaded function - 2 params
#
valid_abi_2_params = {
"type": "function",
"name": "getActiveStakingProviders",
"stateMutability": "view",
"inputs": [
{"name": "_startIndex", "type": "uint256", "internalType": "uint256"},
{
"name": "_maxStakingProviders",
"type": "uint256",
"internalType": "uint256",
},
],
"outputs": [
{"name": "allAuthorizedTokens", "type": "uint96", "internalType": "uint96"},
{
"name": "activeStakingProviders",
"type": "bytes32[]",
"internalType": "bytes32[]",
},
],
}
condition = ContractCondition(
contract_address=taco_child_application_agent.contract.address,
function_abi=ABIFunction(valid_abi_2_params),
function_abi=ABIFunction(getActiveStakingProviders_abi_2_params),
method="getActiveStakingProviders",
chain=TESTERCHAIN_CHAIN_ID,
return_value_test=ReturnValueTest("==", ":expectedStakingProviders"),
@ -897,3 +901,145 @@ def test_contract_condition_using_overloaded_function(
)
with pytest.raises(RPCExecutionFailed):
_ = condition.verify(providers=condition_providers, **context)
@pytest.mark.xfail(reason="This test uses a public rpc endpoint")
def test_json_rpc_condition_non_evm_prototyping_example():
condition = JsonRpcCondition(
endpoint="https://api.mainnet-beta.solana.com",
method="getBlockTime",
params=[308103883],
return_value_test=ReturnValueTest(">=", 1734461499),
)
success, _ = condition.verify()
assert success
condition = JsonRpcCondition(
endpoint="https://api.mainnet-beta.solana.com",
method="getBalance",
params=["83astBRguLMdt2h5U1Tpdq5tjFoJ6noeGwaY3mDLVcri"],
query="$.value",
return_value_test=ReturnValueTest(">=", 0),
)
success, _ = condition.verify()
assert success
condition = JsonRpcCondition(
endpoint="https://bitcoin.drpc.org",
method="getblock",
params=["00000000000000000001ed4d40e6b602d7f09b9d47d5e046d52339cc6673a486"],
query="$.time",
return_value_test=ReturnValueTest(">=", 1734461294),
)
success, _ = condition.verify()
assert success
def test_rpc_condition_using_eip1271(
deployer_account, eip1271_contract_wallet, condition_providers
):
# send some ETH to the smart contract wallet
eth_amount = Web3.to_wei(2.25, "ether")
encoded_deposit_function = eip1271_contract_wallet.deposit.encode_input().hex()
deployer_account.transfer(
account=eip1271_contract_wallet.address,
value=eth_amount,
data=encoded_deposit_function,
)
rpc_condition = RPCCondition(
method="eth_getBalance",
chain=TESTERCHAIN_CHAIN_ID,
parameters=[USER_ADDRESS_CONTEXT],
return_value_test=ReturnValueTest("==", eth_amount),
)
data = f"I'm the owner of the smart contract wallet address {eip1271_contract_wallet.address}"
signable_message = encode_defunct(text=data)
hash = defunct_hash_message(text=data)
message_signature = deployer_account.sign_message(signable_message)
hex_signature = HexBytes(message_signature.encode_rsv()).hex()
typedData = {"chain": TESTERCHAIN_CHAIN_ID, "dataHash": hash.hex()}
auth_message = {
"signature": f"{hex_signature}",
"address": f"{eip1271_contract_wallet.address}",
"scheme": EvmAuth.AuthScheme.EIP1271.value,
"typedData": typedData,
}
context = {
USER_ADDRESS_CONTEXT: auth_message,
}
condition_result, call_result = rpc_condition.verify(
providers=condition_providers, **context
)
assert condition_result is True
assert call_result == eth_amount
# withdraw some ETH and check condition again
withdraw_amount = Web3.to_wei(1, "ether")
eip1271_contract_wallet.withdraw(withdraw_amount, sender=deployer_account)
condition_result, call_result = rpc_condition.verify(
providers=condition_providers, **context
)
assert condition_result is False
assert call_result != eth_amount
assert call_result == (eth_amount - withdraw_amount)
@pytest.mark.usefixtures("staking_providers")
def test_big_int_string_handling(
accounts, taco_child_application_agent, bob, condition_providers
):
(
total_staked,
providers,
) = taco_child_application_agent._get_active_staking_providers_raw(0, 10, 0)
expected_result = [
total_staked,
[
HexBytes(provider_bytes).hex() for provider_bytes in providers
], # must be json serializable
]
context = {
":expectedStakingProviders": expected_result,
} # user-defined context vars
contract_condition = {
"conditionType": ConditionType.CONTRACT.value,
"contractAddress": taco_child_application_agent.contract.address,
"functionAbi": getActiveStakingProviders_abi_2_params,
"chain": TESTERCHAIN_CHAIN_ID,
"method": "getActiveStakingProviders",
"parameters": ["0n", "10n"], # use bigint notation
"returnValueTest": {
"comparator": "==",
"value": ":expectedStakingProviders",
},
}
rpc_condition = {
"conditionType": ConditionType.RPC.value,
"chain": TESTERCHAIN_CHAIN_ID,
"method": "eth_getBalance",
"parameters": [bob.checksum_address, "latest"],
"returnValueTest": {
"comparator": ">=",
"value": "10000000000000n",
}, # use bigint notation
}
compound_condition = {
"version": ConditionLingo.VERSION,
"condition": {
"conditionType": ConditionType.COMPOUND.value,
"operator": "and",
"operands": [contract_condition, rpc_condition],
},
}
compound_condition_json = json.dumps(compound_condition)
condition_result = ConditionLingo.from_json(compound_condition_json).eval(
providers=condition_providers, **context
)
assert condition_result, "condition executed and passes"

View File

@ -2,8 +2,15 @@ from collections import defaultdict
import pytest
from nucypher.policy.conditions.evm import RPCCondition
from nucypher.policy.conditions.lingo import ConditionLingo, ConditionType
from nucypher.policy.conditions.evm import RPCCall, RPCCondition
from nucypher.policy.conditions.lingo import (
CompoundAccessControlCondition,
ConditionLingo,
ConditionType,
ReturnValueTest,
)
from nucypher.policy.conditions.time import TimeCondition
from nucypher.policy.conditions.utils import ConditionProviderManager
from nucypher.utilities.logging import GlobalLoggerSettings
from tests.utils.policy import make_message_kits
@ -14,22 +21,28 @@ def make_multichain_evm_conditions(bob, chain_ids):
"""This is a helper function to make a set of conditions that are valid on multiple chains."""
operands = list()
for chain_id in chain_ids:
operand = [
{
"conditionType": ConditionType.TIME.value,
"returnValueTest": {"value": 0, "comparator": ">"},
"method": "blocktime",
"chain": chain_id,
},
{
"conditionType": ConditionType.RPC.value,
"chain": chain_id,
"method": "eth_getBalance",
"parameters": [bob.checksum_address, "latest"],
"returnValueTest": {"comparator": ">=", "value": 10000000000000},
},
]
operands.extend(operand)
compound_and_condition = CompoundAccessControlCondition(
operator="and",
operands=[
TimeCondition(
chain=chain_id,
return_value_test=ReturnValueTest(
comparator=">",
value=0,
),
),
RPCCondition(
chain=chain_id,
method="eth_getBalance",
parameters=[bob.checksum_address, "latest"],
return_value_test=ReturnValueTest(
comparator=">=",
value=10000000000000,
),
),
],
)
operands.append(compound_and_condition.to_dict())
_conditions = {
"version": ConditionLingo.VERSION,
@ -49,7 +62,7 @@ def conditions(bob, multichain_ids):
def test_single_retrieve_with_multichain_conditions(
enacted_policy, bob, multichain_ursulas, conditions, mock_rpc_condition
enacted_policy, bob, multichain_ursulas, conditions, monkeymodule, testerchain
):
bob.remember_node(multichain_ursulas[0])
bob.start_learning_loop()
@ -59,6 +72,11 @@ def test_single_retrieve_with_multichain_conditions(
encrypted_treasure_map=enacted_policy.treasure_map,
alice_verifying_key=enacted_policy.publisher_verifying_key,
)
monkeymodule.setattr(
ConditionProviderManager,
"web3_endpoints",
lambda *args, **kwargs: [testerchain.w3],
)
cleartexts = bob.retrieve_and_decrypt(
message_kits=message_kits,
@ -69,7 +87,7 @@ def test_single_retrieve_with_multichain_conditions(
def test_single_decryption_request_with_faulty_rpc_endpoint(
enacted_policy, bob, multichain_ursulas, conditions, mock_rpc_condition
monkeymodule, testerchain, enacted_policy, bob, multichain_ursulas, conditions
):
bob.remember_node(multichain_ursulas[0])
bob.start_learning_loop()
@ -80,30 +98,30 @@ def test_single_decryption_request_with_faulty_rpc_endpoint(
alice_verifying_key=enacted_policy.publisher_verifying_key,
)
calls = defaultdict(int)
original_execute_call = RPCCondition._execute_call
monkeymodule.setattr(
ConditionProviderManager,
"web3_endpoints",
lambda *args, **kwargs: [testerchain.w3, testerchain.w3],
) # a base, and fallback
def faulty_execute_call(*args, **kwargs):
rpc_calls = defaultdict(int)
original_execute_call = RPCCall._execute
def faulty_rpc_execute_call(*args, **kwargs):
"""Intercept the call to the RPC endpoint and raise an exception on the second call."""
nonlocal calls
rpc_call = args[0]
calls[rpc_call.chain] += 1
if (
calls[rpc_call.chain] == 2
and "tester://multichain.0" in rpc_call.provider.endpoint_uri
):
nonlocal rpc_calls
rpc_call_object = args[0]
rpc_calls[rpc_call_object.chain] += 1
if rpc_calls[rpc_call_object.chain] % 2 == 0:
# simulate a network error
raise ConnectionError("Something went wrong with the network")
elif calls[rpc_call.chain] == 3:
# check the provider is the fallback
this_uri = rpc_call.provider.endpoint_uri
assert "fallback" in this_uri
# make original call
return original_execute_call(*args, **kwargs)
RPCCondition._execute_call = faulty_execute_call
monkeymodule.setattr(RPCCall, "_execute", faulty_rpc_execute_call)
cleartexts = bob.retrieve_and_decrypt(
message_kits=message_kits,
**policy_info_kwargs,
)
assert cleartexts == messages
RPCCondition._execute_call = original_execute_call

View File

@ -1,6 +1,5 @@
import random
import maya
import pytest
from web3 import Web3
@ -14,14 +13,12 @@ from nucypher.blockchain.eth.agents import (
)
from nucypher.blockchain.eth.interfaces import BlockchainInterfaceFactory
from nucypher.blockchain.eth.registry import ContractRegistry, RegistrySourceManager
from nucypher.policy.conditions.evm import RPCCondition
from nucypher.utilities.logging import Logger
from tests.constants import (
BONUS_TOKENS_FOR_TESTS,
MIN_OPERATOR_SECONDS,
TEMPORARY_DOMAIN,
TEST_ETH_PROVIDER_URI,
TESTERCHAIN_CHAIN_ID,
)
from tests.utils.blockchain import ReservedTestAccountManager, TesterBlockchain
from tests.utils.registry import ApeRegistrySource
@ -32,6 +29,7 @@ from tests.utils.ursula import (
test_logger = Logger("acceptance-test-logger")
ONE_DAY = 24 * 60 * 60
# ERC-20
TOTAL_SUPPLY = Web3.to_wei(11_000_000_000, "ether")
@ -42,17 +40,12 @@ NU_TOTAL_SUPPLY = Web3.to_wei(
# TACo Application
MIN_AUTHORIZATION = Web3.to_wei(40_000, "ether")
REWARD_DURATION = 60 * 60 * 24 * 7 # one week in seconds
DEAUTHORIZATION_DURATION = 60 * 60 * 24 * 60 # 60 days in seconds
COMMITMENT_DURATION_1 = 182 * 60 * 24 * 60 # 182 days in seconds
COMMITMENT_DURATION_2 = 2 * COMMITMENT_DURATION_1 # 365 days in seconds
COMMITMENT_DEADLINE = 60 * 60 * 24 * 100 # 100 days after deployment
REWARD_DURATION = 7 * ONE_DAY # one week in seconds
DEAUTHORIZATION_DURATION = 60 * ONE_DAY # 60 days in seconds
PENALTY_DEFAULT = 1000 # 10% penalty
PENALTY_INCREMENT = 2500 # 25% penalty increment
PENALTY_DURATION = 60 * 60 * 24 # 1 day in seconds
PENALTY_DURATION = ONE_DAY # 1 day in seconds
# Coordinator
@ -138,14 +131,6 @@ def t_token(nucypher_dependency, deployer_account):
return _t_token
@pytest.fixture(scope="module")
def nu_token(nucypher_dependency, deployer_account):
_nu_token = deployer_account.deploy(
nucypher_dependency.NuCypherToken, NU_TOTAL_SUPPLY
)
return _nu_token
@pytest.fixture(scope="module")
def threshold_staking(nucypher_dependency, deployer_account):
_threshold_staking = deployer_account.deploy(
@ -170,8 +155,6 @@ def taco_application(
MIN_OPERATOR_SECONDS,
REWARD_DURATION,
DEAUTHORIZATION_DURATION,
[COMMITMENT_DURATION_1, COMMITMENT_DURATION_2],
maya.now().epoch + COMMITMENT_DEADLINE,
PENALTY_DEFAULT,
PENALTY_DURATION,
PENALTY_INCREMENT,
@ -237,8 +220,6 @@ def coordinator(
_coordinator = deployer_account.deploy(
nucypher_dependency.Coordinator,
taco_child_application.address,
ritual_token.address,
FEE_RATE,
)
encoded_initializer_function = _coordinator.initialize.encode_input(
@ -252,13 +233,33 @@ def coordinator(
)
proxy_contract = nucypher_dependency.Coordinator.at(proxy.address)
proxy_contract.makeInitiationPublic(sender=deployer_account)
taco_child_application.initialize(
proxy_contract.address, adjudicator.address, sender=deployer_account
)
return proxy_contract
@pytest.fixture(scope="module")
def fee_model(nucypher_dependency, deployer_account, coordinator, ritual_token):
contract = deployer_account.deploy(
nucypher_dependency.FlatRateFeeModel,
coordinator.address,
ritual_token.address,
FEE_RATE,
)
coordinator.grantRole(
coordinator.TREASURY_ROLE(), deployer_account.address, sender=deployer_account
)
coordinator.grantRole(
coordinator.FEE_MODEL_MANAGER_ROLE(),
deployer_account.address,
sender=deployer_account,
)
coordinator.approveFeeModel(contract.address, sender=deployer_account)
return contract
@pytest.fixture(scope="module")
def global_allow_list(nucypher_dependency, deployer_account, coordinator):
contract = deployer_account.deploy(
@ -285,11 +286,11 @@ def subscription_manager(nucypher_dependency, deployer_account):
def deployed_contracts(
ritual_token,
t_token,
nu_token,
threshold_staking,
taco_application,
taco_child_application,
coordinator,
fee_model,
global_allow_list,
subscription_manager,
):
@ -297,11 +298,11 @@ def deployed_contracts(
deployments = [
ritual_token,
t_token,
nu_token,
threshold_staking,
taco_application,
taco_child_application,
coordinator,
fee_model,
global_allow_list,
subscription_manager,
]
@ -412,19 +413,6 @@ def taco_child_application_agent(testerchain, test_registry):
# Conditions
#
@pytest.fixture(scope="module")
def mock_rpc_condition(module_mocker, testerchain, monkeymodule):
def configure_mock(condition, provider, *args, **kwargs):
condition.provider = provider
return testerchain.w3
monkeymodule.setattr(RPCCondition, "_configure_w3", configure_mock)
configure_spy = module_mocker.spy(RPCCondition, "_configure_w3")
chain_id_check_mock = module_mocker.patch.object(RPCCondition, "_check_chain_id")
return configure_spy, chain_id_check_mock
@pytest.fixture(scope="module")
def multichain_ids(module_mocker):
ids = mock_permitted_multichain_connections(mocker=module_mocker)
@ -432,7 +420,7 @@ def multichain_ids(module_mocker):
@pytest.fixture(scope="module")
def multichain_ursulas(ursulas, multichain_ids, mock_rpc_condition):
def multichain_ursulas(ursulas, multichain_ids):
setup_multichain_ursulas(ursulas=ursulas, chain_ids=multichain_ids)
return ursulas
@ -440,10 +428,6 @@ def multichain_ursulas(ursulas, multichain_ids, mock_rpc_condition):
@pytest.fixture(scope="module", autouse=True)
def mock_condition_blockchains(module_mocker):
"""adds testerchain's chain ID to permitted conditional chains"""
module_mocker.patch.dict(
"nucypher.policy.conditions.evm._CONDITION_CHAINS",
{TESTERCHAIN_CHAIN_ID: "eth-tester/pyevm"},
)
module_mocker.patch(
"nucypher.blockchain.eth.domains.get_domain", return_value=TEMPORARY_DOMAIN

Some files were not shown because too many files have changed in this diff Show More