diff --git a/extras/nginx_docker/Makefile b/extras/nginx_docker/Makefile index 890425a8f..4fa71531b 100644 --- a/extras/nginx_docker/Makefile +++ b/extras/nginx_docker/Makefile @@ -1,33 +1,132 @@ .PHONY: all -all: docker +all: help -tag = 769498303037.dkr.ecr.us-east-1.amazonaws.com/webtank:latest -no_rate_limit_tag = 769498303037.dkr.ecr.us-east-1.amazonaws.com/webtank:no-rate-limit-latest +DEFAULT_LATEST_TAG = latest +DEFAULT_NO_RATE_LIMIT_TAG = no-rate-limit-latest +# Build and Push Commands +# ======================= + +# GCP / Nano Testnet +NANO_TESTNET_REGISTRY = us-central1-docker.pkg.dev/nano-testnet/fullnodes/webtank + +NANO_TESTNET_BRAVO_TAG_LATEST = bravo-latest +NANO_TESTNET_BRAVO_TAG_NO_RATE_LIMIT = bravo-no-rate-limit-latest + +.PHONY: nano-testnet +nano-testnet: nano-testnet-default nano-testnet-no-rate-limit nano-testnet-bravo-default nano-testnet-bravo-no-rate-limit + @echo "All Nano Testnet images built and pushed successfully!" + +.PHONY: nano-testnet-default +nano-testnet-default: clean nginx.conf set_real_ip_from_cloudfront + @echo "Building and pushing latest image for Nano Testnet..." + docker buildx build --pull --push --platform linux/arm64/v8,linux/amd64 --tag $(NANO_TESTNET_REGISTRY):$(DEFAULT_LATEST_TAG) . + +.PHONY: nano-testnet-no-rate-limit +nano-testnet-no-rate-limit: clean nginx_no_rate_limit.conf set_real_ip_from_cloudfront + @echo "Building and pushing no-rate-limit image for Nano Testnet..." + mv nginx_no_rate_limit.conf nginx.conf + docker buildx build --pull --push --platform linux/arm64/v8,linux/amd64 --tag $(NANO_TESTNET_REGISTRY):$(DEFAULT_NO_RATE_LIMIT_TAG) . + +.PHONY: nano-testnet-bravo-default +nano-testnet-bravo-default: clean nginx_bravo.conf set_real_ip_from_cloudfront + @echo "Building and pushing bravo image for Nano Testnet..." + mv nginx_bravo.conf nginx.conf + docker buildx build --pull --push --platform linux/arm64/v8,linux/amd64 --tag $(NANO_TESTNET_REGISTRY):$(NANO_TESTNET_BRAVO_TAG_LATEST) . + +.PHONY: nano-testnet-bravo-no-rate-limit +nano-testnet-bravo-no-rate-limit: clean nginx_bravo.conf set_real_ip_from_cloudfront + @echo "Building and pushing no-rate-limit bravo image for Nano Testnet..." + mv nginx_bravo.conf nginx.conf + docker buildx build --pull --push --platform linux/arm64/v8,linux/amd64 --tag $(NANO_TESTNET_REGISTRY):$(NANO_TESTNET_BRAVO_TAG_NO_RATE_LIMIT) . + +# GCP / Standalone Fullnodes +STANDALONE_FULLNODES_REGISTRY = us-central1-docker.pkg.dev/standalone-fullnodes/fullnodes/webtank + +.PHONY: standalone-fullnodes +standalone-fullnodes: standalone-fullnodes-default standalone-fullnodes-no-rate-limit + @echo "All Standalone Fullnodes images built and pushed successfully!" + +.PHONY: standalone-fullnodes-default +standalone-fullnodes-default: clean nginx.conf set_real_ip_from_cloudfront + @echo "Building and pushing latest image for Standalone Fullnodes..." + docker buildx build --pull --push --platform linux/arm64/v8,linux/amd64 --tag $(STANDALONE_FULLNODES_REGISTRY):$(DEFAULT_LATEST_TAG) . + +.PHONY: standalone-fullnodes-no-rate-limit +standalone-fullnodes-no-rate-limit: clean nginx_no_rate_limit.conf set_real_ip_from_cloudfront + @echo "Building and pushing no-rate-limit image for Standalone Fullnodes..." + mv nginx_no_rate_limit.conf nginx.conf + docker buildx build --pull --push --platform linux/arm64/v8,linux/amd64 --tag $(STANDALONE_FULLNODES_REGISTRY):$(DEFAULT_NO_RATE_LIMIT_TAG) . + +# GCP / Ekvilibro +EKVILIBRO_REGISTRY = us-central1-docker.pkg.dev/ekvilibro/fullnodes/webtank + +.PHONY: ekvilibro +ekvilibro: ekvilibro-default ekvilibro-no-rate-limit + @echo "All Ekvilibro images built and pushed successfully!" + +.PHONY: ekvilibro-default +ekvilibro-default: clean nginx.conf set_real_ip_from_cloudfront + @echo "Building and pushing latest image for Ekvilibro..." + docker buildx build --pull --push --platform linux/arm64/v8,linux/amd64 --tag $(EKVILIBRO_REGISTRY):$(DEFAULT_LATEST_TAG) . + +.PHONY: ekvilibro-no-rate-limit +ekvilibro-no-rate-limit: clean nginx_no_rate_limit.conf set_real_ip_from_cloudfront + @echo "Building and pushing no-rate-limit image for Ekvilibro..." + mv nginx_no_rate_limit.conf nginx.conf + docker buildx build --pull --push --platform linux/arm64/v8,linux/amd64 --tag $(EKVILIBRO_REGISTRY):$(DEFAULT_NO_RATE_LIMIT_TAG) . + +# AWS / Main Account +AWS_MAIN_REGISTRY = 769498303037.dkr.ecr.us-east-1.amazonaws.com/webtank + +.PHONY: aws-main +aws-main: aws-main-default aws-main-no-rate-limit + @echo "All AWS Main images built and pushed successfully!" + +.PHONY: aws-main-default +aws-main-default: clean nginx.conf set_real_ip_from_cloudfront + @echo "Building and pushing latest image for AWS Main..." + docker buildx build --pull --push --platform linux/arm64/v8,linux/amd64 --tag $(AWS_MAIN_REGISTRY):$(DEFAULT_LATEST_TAG) . + +.PHONY: aws-main-no-rate-limit +aws-main-no-rate-limit: clean nginx_no_rate_limit.conf set_real_ip_from_cloudfront + @echo "Building and pushing no-rate-limit image for AWS Main..." + mv nginx_no_rate_limit.conf nginx.conf + docker buildx build --pull --push --platform linux/arm64/v8,linux/amd64 --tag $(AWS_MAIN_REGISTRY):$(DEFAULT_NO_RATE_LIMIT_TAG) . + +# Build All (convenience command) +.PHONY: build-all +build-all: nano-testnet standalone-fullnodes ekvilibro aws-main + @echo "All images built and pushed successfully!" + +# Legacy commands for backward compatibility .PHONY: docker -docker: docker-default docker-no-rate-limit +docker: aws-main -# Default Nginx Image .PHONY: docker-default -docker-default: nginx.conf set_real_ip_from_cloudfront - docker buildx build --pull --push --platform linux/arm64/v8,linux/amd64 --tag $(tag) . +docker-default: aws-main-default + +.PHONY: docker-no-rate-limit +docker-no-rate-limit: aws-main-no-rate-limit + +# Configuration Generation +# ======================== nginx.conf: export PYTHONPATH := ../.. nginx.conf: @python -c "import os; import hathor; print('Using hathor-core from:', os.path.dirname(hathor.__file__))" python -m hathor generate_nginx_config - > $@ -# Nginx Image used for private use cases, with rate limits disabled -.PHONY: docker-no-rate-limit -docker-no-rate-limit: nginx_no_rate_limit.conf set_real_ip_from_cloudfront - mv nginx_no_rate_limit.conf nginx.conf - docker buildx build --pull --push --platform linux/arm64/v8,linux/amd64 --tag $(no_rate_limit_tag) . - nginx_no_rate_limit.conf: export PYTHONPATH := ../.. nginx_no_rate_limit.conf: @python -c "import os; import hathor; print('Using hathor-core from:', os.path.dirname(hathor.__file__))" python -m hathor generate_nginx_config --disable-rate-limits - > $@ +nginx_bravo.conf: export PYTHONPATH := ../.. +nginx_bravo.conf: + @python -c "import os; import hathor; print('Using hathor-core from:', os.path.dirname(hathor.__file__))" + python -m hathor generate_nginx_config --override nano-testnet-bravo - > $@ + set_real_ip_from_cloudfront: curl https://ip-ranges.amazonaws.com/ip-ranges.json -s \ | jq '.prefixes|map(select(.service=="CLOUDFRONT"))[]|.ip_prefix' -r \ @@ -35,6 +134,46 @@ set_real_ip_from_cloudfront: | xargs -n 1 printf "set_real_ip_from %s;\n" \ > $@ +# Utility Commands +# =============== + .PHONY: clean clean: - rm -f nginx.conf set_real_ip_from_cloudfront + rm -f nginx.conf nginx_no_rate_limit.conf nginx_bravo.conf set_real_ip_from_cloudfront + +.PHONY: help +help: + @echo "Available commands:" + @echo "" + @echo "Project/Account Commands:" + @echo " nano-testnet - Build and push all images for GCP Project Nano Testnet" + @echo " nano-testnet-default - Build and push default image for GCP Project Nano Testnet" + @echo " nano-testnet-no-rate-limit - Build and push no-rate-limit image for GCP Project Nano Testnet" + @echo " nano-testnet-bravo-default - Build and push bravo image for GCP Project Nano Testnet" + @echo " nano-testnet-bravo-no-rate-limit - Build and push no-rate-limit bravo image for GCP Project Nano Testnet" + @echo " standalone-fullnodes - Build and push all images for GCP Project Standalone Fullnodes" + @echo " standalone-fullnodes-default - Build and push default image for GCP Project Standalone Fullnodes" + @echo " standalone-fullnodes-no-rate-limit - Build and push no-rate-limit image for GCP Project Standalone Fullnodes" + @echo " ekvilibro - Build and push all images for GCP Project Ekvilibro" + @echo " ekvilibro-default - Build and push default image for GCP Project Ekvilibro" + @echo " ekvilibro-no-rate-limit - Build and push no-rate-limit image for GCP Project Ekvilibro" + @echo " aws-main - Build and push all images for AWS Main Account" + @echo " aws-main-default - Build and push default image for AWS Main Account" + @echo " aws-main-no-rate-limit - Build and push no-rate-limit image for AWS Main Account" + @echo "" + @echo "Utility Commands:" + @echo " build-all - Build and push all active project images" + @echo " clean - Remove generated files" + @echo " help - Show this help message" + @echo "" + @echo "Legacy Commands (for backward compatibility):" + @echo " docker - Alias for aws-main" + @echo " docker-default - Alias for aws-main-default" + @echo " docker-no-rate-limit - Alias for aws-main-no-rate-limit" + @echo "" + @echo "Supported Projects/Accounts:" + @echo " - Nano Testnet: $(NANO_TESTNET_REGISTRY)" + @echo " - Standalone Fullnodes: $(STANDALONE_FULLNODES_REGISTRY)" + @echo " - Ekvilibro: $(EKVILIBRO_REGISTRY)" + @echo " - AWS Main Account: $(AWS_MAIN_REGISTRY)" + @echo "" diff --git a/hathor/cli/nginx_config.py b/hathor/cli/nginx_config.py index 9f8684f0a..a52733bf5 100644 --- a/hathor/cli/nginx_config.py +++ b/hathor/cli/nginx_config.py @@ -100,16 +100,20 @@ def _scale_rate_limit(raw_rate: str, rate_k: float) -> str: return f'{int(scaled_rate_amount)}{rate_units}' -def _get_visibility(source: dict[str, Any], fallback: Visibility) -> tuple[Visibility, bool]: +def _get_visibility(source: dict[str, Any], fallback: Visibility, override: str) -> tuple[Visibility, bool, bool]: + if 'x-visibility-override' in source and override in source['x-visibility-override']: + visibility = source['x-visibility-override'][override] + return Visibility(visibility), False, True if 'x-visibility' in source: - return Visibility(source['x-visibility']), False + return Visibility(source['x-visibility']), False, False else: - return fallback, True + return fallback, True, False def generate_nginx_config(openapi: dict[str, Any], *, out_file: TextIO, rate_k: float = 1.0, fallback_visibility: Visibility = Visibility.PRIVATE, - disable_rate_limits: bool = False) -> None: + disable_rate_limits: bool = False, + override: str = "") -> None: """ Entry point of the functionality provided by the cli """ from datetime import datetime @@ -122,9 +126,11 @@ def generate_nginx_config(openapi: dict[str, Any], *, out_file: TextIO, rate_k: locations: dict[str, dict[str, Any]] = {} limit_rate_zones: list[RateLimitZone] = [] for path, params in openapi['paths'].items(): - visibility, did_fallback = _get_visibility(params, fallback_visibility) + visibility, did_fallback, did_override = _get_visibility(params, fallback_visibility, override) if did_fallback: warn(f'Visibility not set for path `{path}`, falling back to {fallback_visibility}') + if did_override: + warn(f'Visibility overridden for path `{path}` to {visibility}') if visibility is Visibility.PRIVATE: continue @@ -138,7 +144,7 @@ def generate_nginx_config(openapi: dict[str, Any], *, out_file: TextIO, rate_k: if method not in params: continue method_params = params[method] - method_visibility, _ = _get_visibility(method_params, Visibility.PUBLIC) + method_visibility, _, _ = _get_visibility(method_params, Visibility.PUBLIC, override) if method_visibility is Visibility.PRIVATE: continue allowed_methods.add(method.upper()) @@ -150,6 +156,7 @@ def generate_nginx_config(openapi: dict[str, Any], *, out_file: TextIO, rate_k: rate_limits = params.get('x-rate-limit') if not rate_limits: + warn(f'Path `{path}` is public but has no rate limits, ignoring') continue path_key = path.lower().replace('/', '__').replace('.', '__').replace('{', '').replace('}', '') @@ -352,6 +359,8 @@ def main(): help='Set the visibility for paths without `x-visibility`, defaults to private') parser.add_argument('--disable-rate-limits', type=bool, default=False, help='Disable including rate-limits in the config, defaults to False') + parser.add_argument('--override', type=str, default='', + help='Override visibility for paths with `x-visibility-override` for the given value') parser.add_argument('out', type=argparse.FileType('w', encoding='UTF-8'), default=sys.stdout, nargs='?', help='Output file where nginx config will be written') args = parser.parse_args() @@ -359,4 +368,5 @@ def main(): openapi = get_openapi(args.input_openapi_json) generate_nginx_config(openapi, out_file=args.out, rate_k=args.rate_multiplier, fallback_visibility=args.fallback_visibility, - disable_rate_limits=args.disable_rate_limits) + disable_rate_limits=args.disable_rate_limits, + override=args.override) diff --git a/hathor/cli/run_node.py b/hathor/cli/run_node.py index 7f2618624..549d4b5be 100644 --- a/hathor/cli/run_node.py +++ b/hathor/cli/run_node.py @@ -79,8 +79,10 @@ def create_parser(cls) -> ArgumentParser: netargs = parser.add_mutually_exclusive_group() netargs.add_argument('--nano-testnet', action='store_true', help='Connect to Hathor nano-testnet') - netargs.add_argument('--testnet', action='store_true', help='Connect to Hathor testnet') + netargs.add_argument('--testnet', action='store_true', help='Connect to Hathor the default testnet' + ' (currently testnet-hotel)') netargs.add_argument('--testnet-hotel', action='store_true', help='Connect to Hathor testnet-hotel') + netargs.add_argument('--testnet-golf', action='store_true', help='Connect to Hathor testnet-golf') netargs.add_argument('--localnet', action='store_true', help='Create a localnet with default configuration.') parser.add_argument('--test-mode-tx-weight', action='store_true', @@ -498,8 +500,8 @@ def __init__(self, *, argv=None): from hathor.conf import ( LOCALNET_SETTINGS_FILEPATH, NANO_TESTNET_SETTINGS_FILEPATH, + TESTNET_GOLF_SETTINGS_FILEPATH, TESTNET_HOTEL_SETTINGS_FILEPATH, - TESTNET_SETTINGS_FILEPATH, ) from hathor.conf.get_settings import get_global_settings self.log = logger.new() @@ -516,9 +518,11 @@ def __init__(self, *, argv=None): if self._args.config_yaml: os.environ['HATHOR_CONFIG_YAML'] = self._args.config_yaml elif self._args.testnet: - os.environ['HATHOR_CONFIG_YAML'] = TESTNET_SETTINGS_FILEPATH + os.environ['HATHOR_CONFIG_YAML'] = TESTNET_HOTEL_SETTINGS_FILEPATH elif self._args.testnet_hotel: os.environ['HATHOR_CONFIG_YAML'] = TESTNET_HOTEL_SETTINGS_FILEPATH + elif self._args.testnet_golf: + os.environ['HATHOR_CONFIG_YAML'] = TESTNET_GOLF_SETTINGS_FILEPATH elif self._args.nano_testnet: os.environ['HATHOR_CONFIG_YAML'] = NANO_TESTNET_SETTINGS_FILEPATH elif self._args.localnet: diff --git a/hathor/cli/run_node_args.py b/hathor/cli/run_node_args.py index 96470f518..08f47aad2 100644 --- a/hathor/cli/run_node_args.py +++ b/hathor/cli/run_node_args.py @@ -31,6 +31,7 @@ class RunNodeArgs(BaseModel, extra=Extra.allow): unsafe_mode: Optional[str] testnet: bool testnet_hotel: bool + testnet_golf: bool test_mode_tx_weight: bool dns: Optional[str] peer: Optional[str] diff --git a/hathor/conf/__init__.py b/hathor/conf/__init__.py index 07dab18ab..21bb032b9 100644 --- a/hathor/conf/__init__.py +++ b/hathor/conf/__init__.py @@ -19,15 +19,15 @@ parent_dir = Path(__file__).parent MAINNET_SETTINGS_FILEPATH = str(parent_dir / 'mainnet.yml') -TESTNET_SETTINGS_FILEPATH = str(parent_dir / 'testnet.yml') -TESTNET_HOTEL_SETTINGS_FILEPATH = str(parent_dir / 'testnet_hotel.yml') +TESTNET_GOLF_SETTINGS_FILEPATH = str(parent_dir / 'testnet_golf.yml') +TESTNET_HOTEL_SETTINGS_FILEPATH = str(parent_dir / 'testnet.yml') NANO_TESTNET_SETTINGS_FILEPATH = str(parent_dir / 'nano_testnet.yml') LOCALNET_SETTINGS_FILEPATH = str(parent_dir / 'localnet.yml') UNITTESTS_SETTINGS_FILEPATH = str(parent_dir / 'unittests.yml') __all__ = [ 'MAINNET_SETTINGS_FILEPATH', - 'TESTNET_SETTINGS_FILEPATH', + 'TESTNET_GOLF_SETTINGS_FILEPATH', 'TESTNET_HOTEL_SETTINGS_FILEPATH', 'NANO_TESTNET_SETTINGS_FILEPATH', 'LOCALNET_SETTINGS_FILEPATH', diff --git a/hathor/conf/settings.py b/hathor/conf/settings.py index c42bfad26..6996b1851 100644 --- a/hathor/conf/settings.py +++ b/hathor/conf/settings.py @@ -415,6 +415,9 @@ def GENESIS_TX2_TIMESTAMP(self) -> int: # List of soft voided transaction. SOFT_VOIDED_TX_IDS: list[bytes] = [] + # List of transactions to skip verification. + SKIP_VERIFICATION: list[bytes] = [] + # Identifier used in metadata's voided_by to mark a tx as soft-voided. SOFT_VOIDED_ID: bytes = b'tx-non-grata' @@ -592,6 +595,12 @@ def _validate_tokens(genesis_tokens: int, values: dict[str, Any]) -> int: allow_reuse=True, each_item=True )(parse_hex_str), + _parse_skipped_verification_tx_id=pydantic.validator( + 'SKIP_VERIFICATION', + pre=True, + allow_reuse=True, + each_item=True + )(parse_hex_str), _parse_checkpoints=pydantic.validator( 'CHECKPOINTS', pre=True diff --git a/hathor/conf/testnet.yml b/hathor/conf/testnet.yml index 9f9104475..4a4269e90 100644 --- a/hathor/conf/testnet.yml +++ b/hathor/conf/testnet.yml @@ -1,8 +1,8 @@ P2PKH_VERSION_BYTE: x49 MULTISIG_VERSION_BYTE: x87 -NETWORK_NAME: testnet-golf +NETWORK_NAME: testnet-hotel BOOTSTRAP_DNS: - - golf.testnet.hathor.network + - hotel.testnet.hathor.network # Genesis stuff GENESIS_OUTPUT_SCRIPT: 76a914a584cf48b161e4a49223ed220df30037ab740e0088ac @@ -63,6 +63,9 @@ CHECKPOINTS: 4_200_000: 00000000010a8dae043c84fcb2cef6a2b42a28279b95af20ab5a098acf2a3565 4_300_000: 000000000019da781ef75fa5f59c5537d8ed18b64c589c3e036109cfb1d84f7d +SKIP_VERIFICATION: + - 00000000af8c95ca9aabf5fd90ac44bd4f16d182618c357b301370ad0430c4a3 + FEATURE_ACTIVATION: default_threshold: 15_120 # 15120 = 75% of evaluation_interval (20160) features: @@ -91,14 +94,31 @@ FEATURE_ACTIVATION: version: 0.63.0 signal_support_by_default: true - COUNT_CHECKDATASIG_OP: + NANO_CONTRACTS: bit: 0 - # N = 5_120_640 - # Expected to be reached around Wednesday, 2025-08-13 03:09:44 GMT - # Right now the best block is 5_102_018 at Wednesday, 2025-08-06 15:58:44 GMT - start_height: 5_120_640 - timeout_height: 5_241_600 # N + 6 * 20160 (6 weeks after the start) + # N = 5_040_000 + # Expected to be reached around Friday, 2025-07-18. + # Right now the best block is 5_033_266 on testnet-hotel (2025-07-16). + start_height: 5_040_000 # N + timeout_height: 5_080_320 # N + 2 * 20160 (2 weeks after the start) minimum_activation_height: 0 lock_in_on_timeout: false version: 0.64.0 signal_support_by_default: true + + COUNT_CHECKDATASIG_OP: + bit: 1 + # N = 5_100_480 + # Expected to be reached around Saturday, 2025-08-09 09:31:28 GMT + # Right now the best block is 5_092_661 at Wednesday, 2025-08-06 16:21:58 GMT + start_height: 5_100_480 + timeout_height: 5_221_440 # N + 6 * 20160 (6 weeks after the start) + minimum_activation_height: 0 + lock_in_on_timeout: false + version: 0.64.0 + signal_support_by_default: true + +ENABLE_NANO_CONTRACTS: feature_activation +NC_ON_CHAIN_BLUEPRINT_ALLOWED_ADDRESSES: + - WWFiNeWAFSmgtjm4ht2MydwS5GY3kMJsEK + - WQFDxic8xWWnMLL4aE5abY2XRKPNvGhtjY diff --git a/hathor/conf/testnet.py b/hathor/conf/testnet_golf.py similarity index 100% rename from hathor/conf/testnet.py rename to hathor/conf/testnet_golf.py diff --git a/hathor/conf/testnet_hotel.yml b/hathor/conf/testnet_golf.yml similarity index 84% rename from hathor/conf/testnet_hotel.yml rename to hathor/conf/testnet_golf.yml index 160db7d9d..9f9104475 100644 --- a/hathor/conf/testnet_hotel.yml +++ b/hathor/conf/testnet_golf.yml @@ -1,8 +1,8 @@ P2PKH_VERSION_BYTE: x49 MULTISIG_VERSION_BYTE: x87 -NETWORK_NAME: testnet-hotel +NETWORK_NAME: testnet-golf BOOTSTRAP_DNS: - - hotel.testnet.hathor.network + - golf.testnet.hathor.network # Genesis stuff GENESIS_OUTPUT_SCRIPT: 76a914a584cf48b161e4a49223ed220df30037ab740e0088ac @@ -91,31 +91,14 @@ FEATURE_ACTIVATION: version: 0.63.0 signal_support_by_default: true - NANO_CONTRACTS: - bit: 0 - # N = 5_040_000 - # Expected to be reached around Friday, 2025-07-18. - # Right now the best block is 5_033_266 on testnet-hotel (2025-07-16). - start_height: 5_040_000 # N - timeout_height: 5_080_320 # N + 2 * 20160 (2 weeks after the start) - minimum_activation_height: 0 - lock_in_on_timeout: false - version: 0.64.0 - signal_support_by_default: true - COUNT_CHECKDATASIG_OP: - bit: 1 - # N = 5_100_480 - # Expected to be reached around Saturday, 2025-08-09 09:31:28 GMT - # Right now the best block is 5_092_661 at Wednesday, 2025-08-06 16:21:58 GMT - start_height: 5_100_480 - timeout_height: 5_221_440 # N + 6 * 20160 (6 weeks after the start) + bit: 0 + # N = 5_120_640 + # Expected to be reached around Wednesday, 2025-08-13 03:09:44 GMT + # Right now the best block is 5_102_018 at Wednesday, 2025-08-06 15:58:44 GMT + start_height: 5_120_640 + timeout_height: 5_241_600 # N + 6 * 20160 (6 weeks after the start) minimum_activation_height: 0 lock_in_on_timeout: false version: 0.64.0 signal_support_by_default: true - -ENABLE_NANO_CONTRACTS: feature_activation -NC_ON_CHAIN_BLUEPRINT_ALLOWED_ADDRESSES: - - WWFiNeWAFSmgtjm4ht2MydwS5GY3kMJsEK - - WQFDxic8xWWnMLL4aE5abY2XRKPNvGhtjY diff --git a/hathor/nanocontracts/allowed_imports.py b/hathor/nanocontracts/allowed_imports.py new file mode 100644 index 000000000..7d82d49bc --- /dev/null +++ b/hathor/nanocontracts/allowed_imports.py @@ -0,0 +1,63 @@ +# Copyright 2025 Hathor Labs +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import collections +import math +import typing + +import hathor.nanocontracts as nc + +# this is what's allowed to be imported in blueprints, to be checked in the AST and in runtime +ALLOWED_IMPORTS: dict[str, dict[str, object]] = { + # globals + 'math': dict( + ceil=math.ceil, + floor=math.floor, + ), + 'typing': dict( + Optional=typing.Optional, + NamedTuple=typing.NamedTuple, + TypeAlias=typing.TypeAlias, + Union=typing.Union, + ), + 'collections': dict(OrderedDict=collections.OrderedDict), + # hathor + 'hathor.nanocontracts': dict(Blueprint=nc.Blueprint), + 'hathor.nanocontracts.blueprint': dict(Blueprint=nc.Blueprint), + 'hathor.nanocontracts.context': dict(Context=nc.Context), + 'hathor.nanocontracts.exception': dict(NCFail=nc.NCFail), + 'hathor.nanocontracts.types': dict( + NCAction=nc.types.NCAction, + NCActionType=nc.types.NCActionType, + SignedData=nc.types.SignedData, + public=nc.public, + view=nc.view, + fallback=nc.fallback, + Address=nc.types.Address, + Amount=nc.types.Amount, + Timestamp=nc.types.Timestamp, + TokenUid=nc.types.TokenUid, + TxOutputScript=nc.types.TxOutputScript, + BlueprintId=nc.types.BlueprintId, + ContractId=nc.types.ContractId, + VertexId=nc.types.VertexId, + NCDepositAction=nc.types.NCDepositAction, + NCWithdrawalAction=nc.types.NCWithdrawalAction, + NCGrantAuthorityAction=nc.types.NCGrantAuthorityAction, + NCAcquireAuthorityAction=nc.types.NCAcquireAuthorityAction, + NCArgs=nc.types.NCArgs, + NCRawArgs=nc.types.NCRawArgs, + NCParsedArgs=nc.types.NCParsedArgs, + ), +} diff --git a/hathor/nanocontracts/custom_builtins.py b/hathor/nanocontracts/custom_builtins.py index 37b50aba1..817ae17cb 100644 --- a/hathor/nanocontracts/custom_builtins.py +++ b/hathor/nanocontracts/custom_builtins.py @@ -27,13 +27,15 @@ Sequence, SupportsIndex, TypeVar, + cast, final, ) from typing_extensions import Self, TypeVarTuple +from hathor.nanocontracts.allowed_imports import ALLOWED_IMPORTS from hathor.nanocontracts.exception import NCDisabledBuiltinError -from hathor.nanocontracts.on_chain_blueprint import ALLOWED_IMPORTS, BLUEPRINT_CLASS_NAME +from hathor.nanocontracts.on_chain_blueprint import BLUEPRINT_CLASS_NAME T = TypeVar('T') Ts = TypeVarTuple('Ts') @@ -218,7 +220,7 @@ def __call__( ... -def _generate_restricted_import_function(allowed_imports: dict[str, set[str]]) -> ImportFunction: +def _generate_restricted_import_function(allowed_imports: dict[str, dict[str, object]]) -> ImportFunction: """Returns a function equivalent to builtins.__import__ but that will only import `allowed_imports`""" @_wraps(builtins.__import__) def __import__( @@ -235,11 +237,23 @@ def __import__( raise ImportError('Only `from ... import ...` imports are allowed') if name not in allowed_imports: raise ImportError(f'Import from "{name}" is not allowed.') + + # Create a fake module class that will only be returned by this import call + class FakeModule: + __slots__ = tuple(fromlist) + + fake_module = FakeModule() allowed_fromlist = allowed_imports[name] + for import_what in fromlist: if import_what not in allowed_fromlist: raise ImportError(f'Import from "{name}.{import_what}" is not allowed.') - return builtins.__import__(name=name, globals=globals, fromlist=fromlist, level=0) + + setattr(fake_module, import_what, allowed_fromlist[import_what]) + + # This cast is safe because the only requirement is that the object contains the imported attributes. + return cast(types.ModuleType, fake_module) + return __import__ diff --git a/hathor/nanocontracts/faux_immutable.py b/hathor/nanocontracts/faux_immutable.py new file mode 100644 index 000000000..95a638505 --- /dev/null +++ b/hathor/nanocontracts/faux_immutable.py @@ -0,0 +1,119 @@ +# Copyright 2025 Hathor Labs +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from typing import Callable, TypeVar + +from typing_extensions import ParamSpec + + +def _validate_faux_immutable_meta(name: str, bases: tuple[type, ...], attrs: dict[str, object]) -> None: + """Run validations during faux-immutable class creation.""" + required_attrs = frozenset({ + '__slots__', + }) + + for attr in required_attrs: + if attr not in attrs: + raise TypeError(f'faux-immutable class `{name}` must define `{attr}`') + + allowed_dunder = frozenset({ + '__module__', + '__qualname__', + '__doc__', + '__init__', + }) + + # Prohibit all other dunder attributes/methods. + for attr in attrs: + if '__' in attr and attr not in required_attrs | allowed_dunder: + raise TypeError(f'faux-immutable class `{name}` must not define `{attr}`') + + # Prohibit inheritance on faux-immutable classes, this may be less strict in the future, + # but we may only allow bases where `type(base) is _FauxImmutableMeta`. + if len(bases) != 1 or not bases[0] is FauxImmutable: + raise TypeError(f'faux-immutable class `{name}` must inherit from `FauxImmutable` only') + + +class _FauxImmutableMeta(type): + """ + A metaclass for faux-immutable classes. + This means the class objects themselves are immutable, that is, `__setattr__` always raises AttributeError. + Don't use this metaclass directly, inherit from `FauxImmutable` instead. + """ + __slots__ = () + + def __new__(cls, name, bases, attrs, **kwargs): + # validations are just a sanity check to make sure we only apply this metaclass to classes + # that will actually become immutable, for example, using this metaclass doesn't provide + # complete faux-immutability if the class doesn't define `__slots__`. + if not attrs.get('__skip_faux_immutability_validation__', False): + _validate_faux_immutable_meta(name, bases, attrs) + return super().__new__(cls, name, bases, attrs, **kwargs) + + def __setattr__(cls, name: str, value: object) -> None: + raise AttributeError(f'cannot set attribute `{name}` on faux-immutable class') + + +class FauxImmutable(metaclass=_FauxImmutableMeta): + """ + Utility superclass for creating faux-immutable classes. + Simply inherit from it to define a faux-immutable class. + """ + __slots__ = () + __skip_faux_immutability_validation__: bool = True # Skip validation to bypass the no dunder rule. + + def __setattr__(self, name: str, value: object) -> None: + raise AttributeError(f'cannot set attribute `{name}` on faux-immutable object') + + +T = TypeVar('T', bound=FauxImmutable) +P = ParamSpec('P') + + +def create_with_shell(cls: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> T: + """Mimic `cls.__call__` method behavior, but wrapping the created instance with an ad-hoc shell class.""" + # Keep the same name as the original class. + assert isinstance(cls, type) + name = cls.__name__ + + # The original class is the shell's only base. + bases = (cls,) + + # The shell doesn't have any slots and must skip validation to bypass the inheritance rule. + attrs = dict(__slots__=(), __skip_faux_immutability_validation__=True) + + # Create a dynamic class that is only used on this call. + shell_type: type[T] = type(name, bases, attrs) + + # Use it to instantiate the object, init it, and return it. This mimics the default `__call__` behavior. + obj: T = cls.__new__(shell_type) + shell_type.__init__(obj, *args, **kwargs) + return obj + + +def __set_faux_immutable__(obj: FauxImmutable, name: str, value: object) -> None: + """ + When setting attributes on the `__init__` method of a faux-immutable class, + use this utility function to bypass the protections. + Only use it when you know what you're doing. + """ + if name.startswith('__') and not name.endswith('__'): + # Account for Python's name mangling. + name = f'_{obj.__class__.__name__}{name}' + + # This shows that a faux-immutable class is never actually immutable. + # It's always possible to mutate it via `object.__setattr__`. + object.__setattr__(obj, name, value) diff --git a/hathor/nanocontracts/metered_exec.py b/hathor/nanocontracts/metered_exec.py index 0097c30dd..e2e82c19d 100644 --- a/hathor/nanocontracts/metered_exec.py +++ b/hathor/nanocontracts/metered_exec.py @@ -18,7 +18,6 @@ from structlog import get_logger -from hathor.nanocontracts.custom_builtins import EXEC_BUILTINS from hathor.nanocontracts.on_chain_blueprint import PYTHON_CODE_COMPAT_VERSION logger = get_logger() @@ -59,6 +58,7 @@ def get_memory_limit(self) -> int: def exec(self, source: str, /) -> dict[str, Any]: """ This is equivalent to `exec(source)` but with execution metering and memory limiting. """ + from hathor.nanocontracts.custom_builtins import EXEC_BUILTINS env: dict[str, object] = { '__builtins__': EXEC_BUILTINS, } @@ -80,6 +80,7 @@ def exec(self, source: str, /) -> dict[str, Any]: def call(self, func: Callable[_P, _T], /, *, args: _P.args) -> _T: """ This is equivalent to `func(*args, **kwargs)` but with execution metering and memory limiting. """ + from hathor.nanocontracts.custom_builtins import EXEC_BUILTINS env: dict[str, object] = { '__builtins__': EXEC_BUILTINS, '__func__': func, diff --git a/hathor/nanocontracts/on_chain_blueprint.py b/hathor/nanocontracts/on_chain_blueprint.py index 0eef02261..3b7c88a24 100644 --- a/hathor/nanocontracts/on_chain_blueprint.py +++ b/hathor/nanocontracts/on_chain_blueprint.py @@ -53,42 +53,6 @@ # max compression level, used as default MAX_COMPRESSION_LEVEL = 9 -# this is what's allowed to be imported, to be checked in the AST and in runtime -ALLOWED_IMPORTS: dict[str, set[str]] = { - # globals - 'math': {'ceil', 'floor'}, - 'typing': {'Optional', 'NamedTuple', 'TypeAlias', 'Union'}, - 'collections': {'OrderedDict'}, - # hathor - 'hathor.nanocontracts': {'Blueprint'}, - 'hathor.nanocontracts.blueprint': {'Blueprint'}, - 'hathor.nanocontracts.context': {'Context'}, - 'hathor.nanocontracts.exception': {'NCFail'}, - 'hathor.nanocontracts.types': { - 'NCAction', - 'NCActionType', - 'SignedData', - 'public', - 'view', - 'fallback', - 'Address', - 'Amount', - 'Timestamp', - 'TokenUid', - 'TxOutputScript', - 'BlueprintId', - 'ContractId', - 'VertexId', - 'NCDepositAction', - 'NCWithdrawalAction', - 'NCGrantAuthorityAction', - 'NCAcquireAuthorityAction', - 'NCArgs', - 'NCRawArgs', - 'NCParsedArgs', - }, -} - # these names aren't allowed in the code, to be checked in the AST only AST_NAME_BLACKLIST: set[str] = { '__builtins__', diff --git a/hathor/nanocontracts/resources/__init__.py b/hathor/nanocontracts/resources/__init__.py index 5bb0b1119..ebbadf878 100644 --- a/hathor/nanocontracts/resources/__init__.py +++ b/hathor/nanocontracts/resources/__init__.py @@ -17,6 +17,7 @@ from hathor.nanocontracts.resources.builtin import BlueprintBuiltinResource from hathor.nanocontracts.resources.history import NanoContractHistoryResource from hathor.nanocontracts.resources.nc_creation import NCCreationResource +from hathor.nanocontracts.resources.nc_exec_logs import NCExecLogsResource from hathor.nanocontracts.resources.on_chain import BlueprintOnChainResource from hathor.nanocontracts.resources.state import NanoContractStateResource @@ -28,4 +29,5 @@ 'NanoContractStateResource', 'NanoContractHistoryResource', 'NCCreationResource', + 'NCExecLogsResource', ] diff --git a/hathor/nanocontracts/resources/nc_exec_logs.py b/hathor/nanocontracts/resources/nc_exec_logs.py index 3a8dd0da3..e18e76f3e 100644 --- a/hathor/nanocontracts/resources/nc_exec_logs.py +++ b/hathor/nanocontracts/resources/nc_exec_logs.py @@ -117,6 +117,23 @@ class NCExecLogsResponse(QueryParams): NCExecLogsResource.openapi = { '/nano_contract/logs': { 'x-visibility': 'private', + 'x-visibility-override': {'nano-testnet-bravo': 'public'}, + 'x-rate-limit': { + 'global': [ + { + 'rate': '3r/s', + 'burst': 10, + 'delay': 3 + } + ], + 'per-ip': [ + { + 'rate': '1r/s', + 'burst': 4, + 'delay': 2 + } + ] + }, 'get': { 'operationId': 'nano_contracts_logs', 'summary': 'Get execution logs of a nano contract', diff --git a/hathor/nanocontracts/rng.py b/hathor/nanocontracts/rng.py index f0401cc82..eebbfa631 100644 --- a/hathor/nanocontracts/rng.py +++ b/hathor/nanocontracts/rng.py @@ -14,23 +14,18 @@ from __future__ import annotations -from typing import Any, Sequence, TypeVar +from typing import Sequence, TypeVar, final from cryptography.hazmat.primitives.ciphers import Cipher, CipherContext, algorithms from hathor.difficulty import Hash +from hathor.nanocontracts.faux_immutable import FauxImmutable, __set_faux_immutable__ T = TypeVar('T') -class NoMethodOverrideMeta(type): - __slots__ = () - - def __setattr__(cls, name: str, value: Any) -> None: - raise AttributeError(f'Cannot override method `{name}`') - - -class NanoRNG(metaclass=NoMethodOverrideMeta): +@final +class NanoRNG(FauxImmutable): """Implement a deterministic random number generator that will be used by the sorter. This implementation uses the ChaCha20 encryption as RNG. @@ -40,31 +35,15 @@ class NanoRNG(metaclass=NoMethodOverrideMeta): def __init__(self, seed: bytes) -> None: self.__seed: Hash - object.__setattr__(self, '_NanoRNG__seed', Hash(seed)) + self.__encryptor: CipherContext + __set_faux_immutable__(self, '__seed', Hash(seed)) key = self.__seed nonce = self.__seed[:16] algorithm = algorithms.ChaCha20(key, nonce) cipher = Cipher(algorithm, mode=None) - - self.__encryptor: CipherContext - object.__setattr__(self, '_NanoRNG__encryptor', cipher.encryptor()) - - @classmethod - def create_with_shell(cls, seed: bytes) -> NanoRNG: - """Create a NanoRNG instance wrapped in a lightweight shell subclass. - - This method dynamically creates a subclass of NanoRNG (a "shell" class) and instantiates it. The shell class is - useful to prevent sharing classes and objects among different contracts. - """ - class ShellNanoRNG(NanoRNG): - __slots__ = () - - return ShellNanoRNG(seed=seed) - - def __setattr__(self, name: str, value: Any) -> None: - raise AttributeError("Cannot assign methods to this object.") + __set_faux_immutable__(self, '__encryptor', cipher.encryptor()) @property def seed(self) -> Hash: diff --git a/hathor/nanocontracts/runner/runner.py b/hathor/nanocontracts/runner/runner.py index fe7e6831c..a142c93c0 100644 --- a/hathor/nanocontracts/runner/runner.py +++ b/hathor/nanocontracts/runner/runner.py @@ -38,6 +38,7 @@ NCUninitializedContractError, NCViewMethodError, ) +from hathor.nanocontracts.faux_immutable import create_with_shell from hathor.nanocontracts.metered_exec import MeteredExecutor from hathor.nanocontracts.method import Method, ReturnOnly from hathor.nanocontracts.rng import NanoRNG @@ -789,7 +790,7 @@ def syscall_get_rng(self) -> NanoRNG: raise ValueError('no seed was provided') contract_id = self.get_current_contract_id() if contract_id not in self._rng_per_contract: - self._rng_per_contract[contract_id] = NanoRNG.create_with_shell(seed=self._rng.randbytes(32)) + self._rng_per_contract[contract_id] = create_with_shell(NanoRNG, seed=self._rng.randbytes(32)) return self._rng_per_contract[contract_id] def _internal_create_contract(self, contract_id: ContractId, blueprint_id: BlueprintId) -> None: diff --git a/hathor/nanocontracts/vertex_data.py b/hathor/nanocontracts/vertex_data.py index 09065ec51..bce8f72c4 100644 --- a/hathor/nanocontracts/vertex_data.py +++ b/hathor/nanocontracts/vertex_data.py @@ -15,10 +15,12 @@ from __future__ import annotations from dataclasses import dataclass +from enum import StrEnum, unique from typing import TYPE_CHECKING from typing_extensions import Self +from hathor.transaction.scripts import P2PKH, MultiSig, parse_address_script from hathor.types import TokenUid, VertexId if TYPE_CHECKING: @@ -126,17 +128,41 @@ def create_from_txin(cls, txin: TxInput, txin_output: TxOutput | None) -> Self: ) +@unique +class ScriptType(StrEnum): + P2PKH = 'P2PKH' + MULTI_SIG = 'MultiSig' + + +@dataclass(slots=True, frozen=True, kw_only=True) +class ScriptInfo: + type: ScriptType + address: str + timelock: int | None + + @classmethod + def from_script(cls, script: P2PKH | MultiSig) -> Self: + return cls( + type=ScriptType(script.get_type()), + address=script.get_address(), + timelock=script.get_timelock(), + ) + + @dataclass(frozen=True, slots=True, kw_only=True) class TxOutputData: value: int - script: bytes + raw_script: bytes + parsed_script: ScriptInfo | None token_data: int @classmethod def create_from_txout(cls, txout: TxOutput) -> Self: + parsed = parse_address_script(txout.script) return cls( value=txout.value, - script=txout.script, + raw_script=txout.script, + parsed_script=ScriptInfo.from_script(parsed) if parsed is not None else None, token_data=txout.token_data, ) diff --git a/hathor/transaction/scripts/base_script.py b/hathor/transaction/scripts/base_script.py index d76510dbd..6b110b4d7 100644 --- a/hathor/transaction/scripts/base_script.py +++ b/hathor/transaction/scripts/base_script.py @@ -37,8 +37,8 @@ def get_script(self) -> bytes: raise NotImplementedError @abstractmethod - def get_address(self) -> Optional[str]: - """Get address for this script, not all valid recognizable scripts have addresses.""" + def get_address(self) -> str: + """Get address for this script.""" raise NotImplementedError @abstractmethod diff --git a/hathor/transaction/scripts/multi_sig.py b/hathor/transaction/scripts/multi_sig.py index 7fe4f10ed..1fabd943a 100644 --- a/hathor/transaction/scripts/multi_sig.py +++ b/hathor/transaction/scripts/multi_sig.py @@ -64,7 +64,7 @@ def get_type(self) -> str: def get_script(self) -> bytes: return MultiSig.create_output_script(decode_address(self.address), self.timelock) - def get_address(self) -> Optional[str]: + def get_address(self) -> str: return self.address def get_timelock(self) -> Optional[int]: diff --git a/hathor/transaction/scripts/p2pkh.py b/hathor/transaction/scripts/p2pkh.py index 52812680c..9358098df 100644 --- a/hathor/transaction/scripts/p2pkh.py +++ b/hathor/transaction/scripts/p2pkh.py @@ -60,7 +60,7 @@ def get_type(self) -> str: def get_script(self) -> bytes: return P2PKH.create_output_script(decode_address(self.address), self.timelock) - def get_address(self) -> Optional[str]: + def get_address(self) -> str: return self.address def get_timelock(self) -> Optional[int]: diff --git a/hathor/verification/on_chain_blueprint_verifier.py b/hathor/verification/on_chain_blueprint_verifier.py index bbc3e8b7b..7ef689b01 100644 --- a/hathor/verification/on_chain_blueprint_verifier.py +++ b/hathor/verification/on_chain_blueprint_verifier.py @@ -13,6 +13,7 @@ # limitations under the License. import ast +from typing import Callable from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives import hashes @@ -21,9 +22,9 @@ from hathor.conf.settings import HathorSettings from hathor.crypto.util import get_address_b58_from_public_key_bytes, get_public_key_from_bytes_compressed from hathor.nanocontracts import OnChainBlueprint +from hathor.nanocontracts.allowed_imports import ALLOWED_IMPORTS from hathor.nanocontracts.exception import NCInvalidPubKey, NCInvalidSignature, OCBInvalidScript, OCBPubKeyNotAllowed from hathor.nanocontracts.on_chain_blueprint import ( - ALLOWED_IMPORTS, AST_NAME_BLACKLIST, BLUEPRINT_CLASS_NAME, PYTHON_CODE_COMPAT_VERSION, @@ -55,14 +56,39 @@ def visit_Try(self, node: ast.Try) -> None: raise SyntaxError('Try/Except blocks are not allowed.') def visit_Name(self, node: ast.Name) -> None: + assert isinstance(node.id, str) if node.id in AST_NAME_BLACKLIST: raise SyntaxError(f'Usage or reference to {node.id} is not allowed.') + assert BLUEPRINT_CLASS_NAME == '__blueprint__', 'sanity check for the rule below' + if '__' in node.id and node.id != BLUEPRINT_CLASS_NAME: + raise SyntaxError('Using dunder names is not allowed.') self.generic_visit(node) def visit_Attribute(self, node: ast.Attribute) -> None: - if isinstance(node.value, ast.Name): - if '__' in node.attr: - raise SyntaxError('Access to internal attributes and methods is not allowed.') + assert isinstance(node.attr, str) + if '__' in node.attr: + raise SyntaxError('Access to internal attributes and methods is not allowed.') + self.generic_visit(node) + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + assert isinstance(node.name, str) + if '__' in node.name: + raise SyntaxError('magic methods are not allowed') + self.generic_visit(node) + + def visit_MatchClass(self, node: ast.MatchClass) -> None: + for name in node.kwd_attrs: + assert isinstance(name, str) + if '__' in name: + raise SyntaxError('cannot match on dunder name') + self.generic_visit(node) + + def visit_MatchMapping(self, node: ast.MatchMapping) -> None: + for name in node.keys: + assert isinstance(name, ast.Constant) + assert isinstance(name.value, str) + if '__' in name.value: + raise SyntaxError('cannot match on dunder name') self.generic_visit(node) def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: @@ -172,12 +198,23 @@ def _get_python_code_ast(self, tx: OnChainBlueprint) -> ast.Module: tx._ast_cache = parsed_tree return parsed_tree + def blueprint_code_rules(self) -> tuple[Callable[[OnChainBlueprint], None], ...]: + """Get all rules used in code verification.""" + return ( + self._verify_raw_text, + self._verify_python_script, + self._verify_script_restrictions, + self._verify_has_blueprint_attr, + self._verify_blueprint_type, + ) + def verify_code(self, tx: OnChainBlueprint) -> None: """Run all verification related to the blueprint code.""" - self._verify_python_script(tx) - self._verify_script_restrictions(tx) - self._verify_has_blueprint_attr(tx) - self._verify_blueprint_type(tx) + for rule in self.blueprint_code_rules(): + try: + rule(tx) + except SyntaxError as e: + raise OCBInvalidScript('forbidden syntax') from e def _verify_python_script(self, tx: OnChainBlueprint) -> None: """Verify that the script can be parsed at all.""" @@ -186,12 +223,15 @@ def _verify_python_script(self, tx: OnChainBlueprint) -> None: except SyntaxError as e: raise OCBInvalidScript('Could not correctly parse the script') from e + def _verify_raw_text(self, tx: OnChainBlueprint) -> None: + """Verify that the script does not use any forbidden text.""" + assert BLUEPRINT_CLASS_NAME == '__blueprint__', 'sanity check for the rule below' + if '__' in tx.code.text.replace(BLUEPRINT_CLASS_NAME, ''): + raise SyntaxError('script contains dunder text') + def _verify_script_restrictions(self, tx: OnChainBlueprint) -> None: """Verify that the script does not use any forbidden syntax.""" - try: - _RestrictionsVisitor().visit(self._get_python_code_ast(tx)) - except SyntaxError as e: - raise OCBInvalidScript('forbidden syntax') from e + _RestrictionsVisitor().visit(self._get_python_code_ast(tx)) def _verify_has_blueprint_attr(self, tx: OnChainBlueprint) -> None: """Verify that the script defines a __blueprint__ attribute.""" diff --git a/hathor/verification/verification_service.py b/hathor/verification/verification_service.py index 33f0a6c99..1207da71d 100644 --- a/hathor/verification/verification_service.py +++ b/hathor/verification/verification_service.py @@ -102,6 +102,9 @@ def verify_basic( """Basic verifications (the ones without access to dependencies: parents+inputs). Raises on error. Used by `self.validate_basic`. Should not modify the validation state.""" + if vertex.hash in self._settings.SKIP_VERIFICATION: + return + self.verifiers.vertex.verify_version_basic(vertex) # We assert with type() instead of isinstance() because each subclass has a specific branch. @@ -165,6 +168,9 @@ def verify(self, vertex: BaseTransaction, params: VerificationParams) -> None: """Run all verifications. Raises on error. Used by `self.validate_full`. Should not modify the validation state.""" + if vertex.hash in self._settings.SKIP_VERIFICATION: + return + self.verifiers.vertex.verify_headers(vertex) # We assert with type() instead of isinstance() because each subclass has a specific branch. @@ -268,6 +274,9 @@ def _verify_token_creation_tx(self, tx: TokenCreationTransaction, params: Verifi self.verifiers.token_creation_tx.verify_token_info(tx) def verify_without_storage(self, vertex: BaseTransaction, params: VerificationParams) -> None: + if vertex.hash in self._settings.SKIP_VERIFICATION: + return + # We assert with type() instead of isinstance() because each subclass has a specific branch. match vertex.version: case TxVersion.REGULAR_BLOCK: diff --git a/tests/nanocontracts/blueprints/unittest.py b/tests/nanocontracts/blueprints/unittest.py index c913f9a92..972b03276 100644 --- a/tests/nanocontracts/blueprints/unittest.py +++ b/tests/nanocontracts/blueprints/unittest.py @@ -89,20 +89,40 @@ def _register_blueprint_class( def register_blueprint_file(self, path: PathLike[str], blueprint_id: BlueprintId | None = None) -> BlueprintId: """Register a blueprint file with an optional id, allowing contracts to be created from it.""" with open(path, 'r') as f: - return self.register_blueprint_contents(f, blueprint_id) + return self._register_blueprint_contents(f, blueprint_id) - def register_blueprint_contents( + def _register_blueprint_contents( self, contents: TextIOWrapper, blueprint_id: BlueprintId | None = None, + *, + skip_verification: bool = False, + inject_in_class: dict[str, object] | None = None, ) -> BlueprintId: - """Register blueprint contents with an optional id, allowing contracts to be created from it.""" + """ + Register blueprint contents with an optional id, allowing contracts to be created from it. + + Args: + contents: the blueprint source code, usually a file or StringIO + blueprint_id: optional ID for the blueprint + skip_verification: skip verifying the blueprint with restrictions such as AST verification + inject_in_class: objects to inject in the blueprint class, accessible in contract runtime + + Returns: the blueprint_id + """ code = Code.from_python_code(contents.read(), self._settings) - verifier = OnChainBlueprintVerifier(settings=self._settings) ocb = OnChainBlueprint(hash=b'', code=code) - verifier.verify_code(ocb) - return self._register_blueprint_class(ocb.get_blueprint_class(), blueprint_id) + if not skip_verification: + verifier = OnChainBlueprintVerifier(settings=self._settings) + verifier.verify_code(ocb) + + blueprint_class = ocb.get_blueprint_class() + if inject_in_class is not None: + for key, value in inject_in_class.items(): + setattr(blueprint_class, key, value) + + return self._register_blueprint_class(blueprint_class, blueprint_id) def build_runner(self) -> TestRunner: """Create a Runner instance.""" diff --git a/tests/nanocontracts/on_chain_blueprints/test_script_restrictions.py b/tests/nanocontracts/on_chain_blueprints/test_script_restrictions.py index e277abe91..1c7e5daf7 100644 --- a/tests/nanocontracts/on_chain_blueprints/test_script_restrictions.py +++ b/tests/nanocontracts/on_chain_blueprints/test_script_restrictions.py @@ -1,4 +1,5 @@ import os +from textwrap import dedent from hathor.exception import InvalidNewTransaction from hathor.nanocontracts import OnChainBlueprint @@ -50,49 +51,60 @@ def _create_on_chain_blueprint(self, nc_code: str) -> OnChainBlueprint: self._ocb_mine(blueprint) return blueprint - def _test_forbid_syntax(self, code: str, err_msg: str) -> None: + def _test_forbid_syntax( + self, + code: str, + syntax_errors: tuple[str, ...], + ) -> None: blueprint = self._create_on_chain_blueprint(code) with self.assertRaises(InvalidNewTransaction) as cm: self.manager.vertex_handler.on_new_relayed_vertex(blueprint) assert isinstance(cm.exception.__cause__, OCBInvalidScript) assert isinstance(cm.exception.__cause__.__cause__, SyntaxError) assert cm.exception.args[0] == 'full validation failed: forbidden syntax' - assert cm.exception.__cause__.__cause__.args[0] == err_msg + # The first error is always the one that makes the tx fail + assert cm.exception.__cause__.__cause__.args[0] == syntax_errors[0] + + rules = self.manager.verification_service.verifiers.on_chain_blueprint.blueprint_code_rules() + errors = [] + for rule in rules: + try: + rule(blueprint) + except SyntaxError as e: + errors.append(e) + except Exception: + # this test function is not interested in non-syntax errors + pass + + assert len(errors) == len(syntax_errors) + for error, expected in zip(errors, syntax_errors, strict=True): + assert error.args[0] == expected def test_forbid_import(self) -> None: self._test_forbid_syntax( 'import os', - 'Import statements are not allowed.', + syntax_errors=('Import statements are not allowed.',), ) def test_forbid_import_from(self) -> None: self._test_forbid_syntax( 'from os import path', - 'Importing from "os" is not allowed.', + syntax_errors=('Importing from "os" is not allowed.',), ) # XXX: only math.ceil and math.floor are currently allowed, log should error self._test_forbid_syntax( 'from math import log', - 'Importing "log" from "math" is not allowed.', + syntax_errors=('Importing "log" from "math" is not allowed.',), ) def test_forbid_try_except(self) -> None: self._test_forbid_syntax( 'try:\n ...\nexcept:\n ...', - 'Try/Except blocks are not allowed.', + syntax_errors=('Try/Except blocks are not allowed.',), ) def test_forbid_names_blacklist(self) -> None: forbidden_cases = { - '__builtins__': [ - r'''x = __builtins__('dir')''', - r'''y = __builtins__.dir''', - ], - '__import__': [ - r'''sys = __import__('sys')''', - r'''os = __import__('os.path')''', - r'''path = __import__('os.path', fromlist=[None])''', - ], 'compile': [ r'''code = compile('print("foo")')''', ], @@ -135,30 +147,197 @@ def test_forbid_names_blacklist(self) -> None: } for attr, codes in forbidden_cases.items(): for code in codes: - self._test_forbid_syntax(code, f'Usage or reference to {attr} is not allowed.') + self._test_forbid_syntax(code, syntax_errors=(f'Usage or reference to {attr} is not allowed.',)) + + forbidden_cases_with_dunder = { + '__builtins__': [ + r'''x = __builtins__('dir')''', + r'''y = __builtins__.dir''', + ], + '__import__': [ + r'''sys = __import__('sys')''', + r'''os = __import__('os.path')''', + r'''path = __import__('os.path', fromlist=[None])''', + ], + } + for attr, codes in forbidden_cases_with_dunder.items(): + for code in codes: + self._test_forbid_syntax( + code, + syntax_errors=( + 'script contains dunder text', + f'Usage or reference to {attr} is not allowed.', + ) + ) def test_forbid_internal_attr(self) -> None: self._test_forbid_syntax( 'x = 1\nx.__class__', - 'Access to internal attributes and methods is not allowed.', + syntax_errors=( + 'script contains dunder text', + 'Access to internal attributes and methods is not allowed.', + ), ) self._test_forbid_syntax( 'x = 1\nx.__runner', - 'Access to internal attributes and methods is not allowed.', + syntax_errors=( + 'script contains dunder text', + 'Access to internal attributes and methods is not allowed.', + ), ) self._test_forbid_syntax( 'x = 1\nx._Context__runner', - 'Access to internal attributes and methods is not allowed.', + syntax_errors=( + 'script contains dunder text', + 'Access to internal attributes and methods is not allowed.', + ), ) self._test_forbid_syntax( 'x = log.__entries__', - 'Access to internal attributes and methods is not allowed.', + syntax_errors=( + 'script contains dunder text', + 'Access to internal attributes and methods is not allowed.', + ), + ) + self._test_forbid_syntax( + 'x().__setattr__', + syntax_errors=( + 'script contains dunder text', + 'Access to internal attributes and methods is not allowed.', + ), + ) + self._test_forbid_syntax( + 'super().__setattr__', + syntax_errors=( + 'script contains dunder text', + 'Access to internal attributes and methods is not allowed.', + ), + ) + self._test_forbid_syntax( + '(lambda: object).__setattr__', + syntax_errors=( + 'script contains dunder text', + 'Access to internal attributes and methods is not allowed.', + ), + ) + self._test_forbid_syntax( + '(lambda: object)().__setattr__', + syntax_errors=( + 'script contains dunder text', + 'Access to internal attributes and methods is not allowed.', + ), + ) + self._test_forbid_syntax( + '(object,)[0].__setattr__', + syntax_errors=( + 'script contains dunder text', + 'Access to internal attributes and methods is not allowed.', + ), + ) + + def test_forbid_dunder_names(self) -> None: + self._test_forbid_syntax( + '__x__ = 123', + syntax_errors=( + 'script contains dunder text', + 'Using dunder names is not allowed.', + ), + ) + self._test_forbid_syntax( + 'x = "__x__"', + syntax_errors=('script contains dunder text',), + ) + self._test_forbid_syntax( + '__', + syntax_errors=( + 'script contains dunder text', + 'Using dunder names is not allowed.', + ), + ) + self._test_forbid_syntax( + dedent(''' + class Foo: + __slots__ = () + '''), + syntax_errors=( + 'script contains dunder text', + 'Using dunder names is not allowed.', + ), + ) + self._test_forbid_syntax( + dedent(''' + class Foo: + __match_args__ = ('__dict__',) + '''), + syntax_errors=( + 'script contains dunder text', + 'Using dunder names is not allowed.', + ), + ) + + def test_forbid_magic_methods(self) -> None: + self._test_forbid_syntax( + dedent(''' + class Foo: + def __init__(self): + pass + '''), + syntax_errors=( + 'script contains dunder text', + 'magic methods are not allowed', + ), + ) + self._test_forbid_syntax( + dedent(''' + class Foo: + def __new__(self): + pass + '''), + syntax_errors=( + 'script contains dunder text', + 'magic methods are not allowed', + ), + ) + self._test_forbid_syntax( + dedent(''' + class Foo: + def __setattr__(self): + pass + '''), + syntax_errors=( + 'script contains dunder text', + 'magic methods are not allowed', + ), + ) + + def test_forbid_match_dunder(self) -> None: + self._test_forbid_syntax( + dedent(''' + match 123: + case int(__dict__=my_dict): + pass + '''), + syntax_errors=( + 'script contains dunder text', + 'cannot match on dunder name', + ) + ) + self._test_forbid_syntax( + dedent(''' + match 123: + case {'__dict__': 123}: + pass + '''), + syntax_errors=( + 'script contains dunder text', + 'cannot match on dunder name', + ), ) def test_forbid_async_fn(self) -> None: self._test_forbid_syntax( 'async def foo():\n ...', - 'Async functions are not allowed.', + syntax_errors=('Async functions are not allowed.',) ) def test_forbid_await_syntax(self) -> None: @@ -168,15 +347,24 @@ def test_forbid_await_syntax(self) -> None: # forms a valid syntax tree self._test_forbid_syntax( 'x = await foo()', - 'Await is not allowed.', + syntax_errors=( + 'Await is not allowed.', + "'await' outside function", + ), ) self._test_forbid_syntax( 'async for i in range(10):\n ...', - 'Async loops are not allowed.', + syntax_errors=( + 'Async loops are not allowed.', + "'async for' outside async function", + ), ) self._test_forbid_syntax( 'async with foo():\n ...', - 'Async contexts are not allowed.', + syntax_errors=( + 'Async contexts are not allowed.', + "'async with' outside async function", + ), ) def test_blueprint_type_not_a_class(self) -> None: diff --git a/tests/nanocontracts/test_context.py b/tests/nanocontracts/test_context.py index 2251c40ff..f839c05ca 100644 --- a/tests/nanocontracts/test_context.py +++ b/tests/nanocontracts/test_context.py @@ -6,6 +6,8 @@ from hathor.nanocontracts.vertex_data import NanoHeaderData, VertexData from hathor.transaction import Block, Transaction from hathor.transaction.base_transaction import TxVersion +from hathor.transaction.scripts import parse_address_script +from hathor.util import not_none from tests.dag_builder.builder import TestDAGBuilder from tests.nanocontracts.blueprints.unittest import BlueprintTestCase @@ -67,9 +69,6 @@ def test_vertex_data(self) -> None: assert GLOBAL_VERTEX_DATA is not None vertex_data = copy.deepcopy(GLOBAL_VERTEX_DATA) - # XXX: nonce varies, even for a weight of 1.0 - # XXX: inptus/outputs/parents ignored since the dag builder will pick whatever to fill it in - self.assertEqual(vertex_data.version, TxVersion.REGULAR_TRANSACTION) self.assertEqual(vertex_data.hash, nc2.hash) self.assertEqual(vertex_data.signal_bits, 0) @@ -78,6 +77,24 @@ def test_vertex_data(self) -> None: self.assertEqual(vertex_data.block.hash, b12.hash) self.assertEqual(vertex_data.block.timestamp, b12.timestamp) self.assertEqual(vertex_data.block.height, b12.get_height()) + self.assertEqual(vertex_data.nonce, nc2.nonce) + self.assertEqual(vertex_data.parents, tuple(nc2.parents)) + + for i, input_tx in enumerate(nc2.inputs): + assert vertex_data.inputs[i].tx_id == input_tx.tx_id + assert vertex_data.inputs[i].index == input_tx.index + assert vertex_data.inputs[i].data == input_tx.data + + for i, output in enumerate(nc2.outputs): + parsed = not_none(parse_address_script(output.script)) + assert vertex_data.outputs[i].value == output.value + assert vertex_data.outputs[i].raw_script == output.script + assert not_none(vertex_data.outputs[i].parsed_script).type == parsed.get_type() + assert not_none(vertex_data.outputs[i].parsed_script).address == parsed.get_address() + assert not_none(vertex_data.outputs[i].parsed_script).timelock == parsed.get_timelock() + assert vertex_data.outputs[i].token_data == output.token_data + + self.assertEqual(set(vertex_data.parents), set(nc2.parents)) nano_header_data, = vertex_data.headers assert isinstance(nano_header_data, NanoHeaderData) self.assertEqual(nano_header_data.nc_id, nc1.hash) diff --git a/tests/nanocontracts/test_custom_import.py b/tests/nanocontracts/test_custom_import.py index 6197dbf36..876c15f33 100644 --- a/tests/nanocontracts/test_custom_import.py +++ b/tests/nanocontracts/test_custom_import.py @@ -12,32 +12,33 @@ # See the License for the specific language governing permissions and # limitations under the License. +import builtins from io import StringIO from textwrap import dedent -from unittest.mock import ANY, Mock, call +from unittest.mock import ANY, Mock, call, patch from hathor.nanocontracts.custom_builtins import EXEC_BUILTINS from tests.nanocontracts.blueprints.unittest import BlueprintTestCase class TestCustomImport(BlueprintTestCase): - def test_custom_import(self) -> None: + def test_custom_import_is_used(self) -> None: """Guarantee our custom import function is being called, instead of the builtin one.""" contract_id = self.gen_random_contract_id() blueprint = ''' - from hathor.nanocontracts import Blueprint - from hathor.nanocontracts.context import Context - from hathor.nanocontracts.types import public - - class MyBlueprint(Blueprint): - @public - def initialize(self, ctx: Context) -> None: - from math import ceil, floor - from collections import OrderedDict - from hathor.nanocontracts.exception import NCFail - from hathor.nanocontracts.types import NCAction, NCActionType - - __blueprint__ = MyBlueprint + from hathor.nanocontracts import Blueprint + from hathor.nanocontracts.context import Context + from hathor.nanocontracts.types import public + + class MyBlueprint(Blueprint): + @public + def initialize(self, ctx: Context) -> None: + from math import ceil, floor + from collections import OrderedDict + from hathor.nanocontracts.exception import NCFail + from hathor.nanocontracts.types import NCAction, NCActionType + + __blueprint__ = MyBlueprint ''' # Wrap our custom builtin so we can spy its calls @@ -49,7 +50,7 @@ def initialize(self, ctx: Context) -> None: # During blueprint registration, the function is called for each import at the module level. # This happens twice, once during verification and once during the actual registration. - blueprint_id = self.register_blueprint_contents(StringIO(dedent(blueprint))) + blueprint_id = self._register_blueprint_contents(StringIO(dedent(blueprint))) module_level_calls = [ call('hathor.nanocontracts', ANY, ANY, ('Blueprint',), 0), call('hathor.nanocontracts.context', ANY, ANY, ('Context',), 0), @@ -69,3 +70,45 @@ def initialize(self, ctx: Context) -> None: ] assert wrapped_import_function.call_count == len(method_level_imports) wrapped_import_function.assert_has_calls(method_level_imports) + + def test_builtin_import_is_not_used(self) -> None: + """ + Guarantee the builtin import function is never called in the contract runtime. + + To implement this test we need to use source code instead of a class directly, otherwise + the imports wouldn't run during nano runtime, but before. Because of that, we also need to + use `inject_in_class` to provide the blueprint with objects it cannot normally import. + """ + contract_id = self.gen_random_contract_id() + blueprint = ''' + from hathor.nanocontracts import Blueprint + from hathor.nanocontracts.context import Context + from hathor.nanocontracts.types import public + + class MyBlueprint(Blueprint): + @public + def initialize(self, ctx: Context) -> None: + wrapped_builtin_import = self.Mock(wraps=self.builtins.__import__) + wrapped_builtin_import.assert_not_called() + + with self.patch.object(self.builtins, '__import__', wrapped_builtin_import): + from math import ceil, floor + from collections import OrderedDict + from hathor.nanocontracts.exception import NCFail + from hathor.nanocontracts.types import NCAction, NCActionType + + wrapped_builtin_import.assert_not_called() + + __blueprint__ = MyBlueprint + ''' + + blueprint_id = self._register_blueprint_contents( + contents=StringIO(dedent(blueprint)), + skip_verification=True, + inject_in_class=dict( + builtins=builtins, + Mock=Mock, + patch=patch, + ) + ) + self.runner.create_contract(contract_id, blueprint_id, self.create_context()) diff --git a/tests/nanocontracts/test_exposed_properties.py b/tests/nanocontracts/test_exposed_properties.py index 2240b0ea8..2c6718f65 100644 --- a/tests/nanocontracts/test_exposed_properties.py +++ b/tests/nanocontracts/test_exposed_properties.py @@ -4,8 +4,8 @@ from typing import Any from hathor.nanocontracts import Blueprint, Context, public +from hathor.nanocontracts.allowed_imports import ALLOWED_IMPORTS from hathor.nanocontracts.custom_builtins import EXEC_BUILTINS -from hathor.nanocontracts.on_chain_blueprint import ALLOWED_IMPORTS from tests.nanocontracts.blueprints.unittest import BlueprintTestCase MAX_DEPTH = 20 @@ -195,6 +195,13 @@ 'super.some_new_attribute', 'type.mro', 'type.some_new_attribute', + 'typing.NamedTuple.some_new_attribute', + 'typing.Optional._getitem', + 'typing.Optional._name', + 'typing.TypeAlias._getitem', + 'typing.TypeAlias._name', + 'typing.Union._getitem', + 'typing.Union._name', 'vars.some_new_attribute', ] @@ -315,9 +322,6 @@ def check(self, ctx: Context) -> list[str]: mutable_props.extend(search_writeable_properties(ctx, 'ctx')) custom_import = EXEC_BUILTINS['__import__'] for module_name, import_names in ALLOWED_IMPORTS.items(): - if module_name == 'typing': - # FIXME: typing module causes problems for some reason - continue module = custom_import(module_name, fromlist=list(import_names)) for import_name in import_names: obj = getattr(module, import_name) diff --git a/tests/nanocontracts/test_faux_immutability.py b/tests/nanocontracts/test_faux_immutability.py new file mode 100644 index 000000000..368aa014d --- /dev/null +++ b/tests/nanocontracts/test_faux_immutability.py @@ -0,0 +1,239 @@ +# Copyright 2025 Hathor Labs +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from hathor.nanocontracts.faux_immutable import FauxImmutable, create_with_shell + + +def test_missing_slots() -> None: + with pytest.raises(TypeError, match='faux-immutable class `Foo` must define `__slots__`'): + class Foo(FauxImmutable): + pass + + +def test_defines_dunder() -> None: + with pytest.raises(TypeError, match='faux-immutable class `Foo1` must not define `__setattr__`'): + class Foo1(FauxImmutable): + __slots__ = () + + def __setattr__(self, name: str, value: object) -> None: + pass + + with pytest.raises(TypeError, match='faux-immutable class `Foo2` must not define `__call__`'): + class Foo2(FauxImmutable): + __slots__ = () + + def __call__(self, name: str, value: object) -> None: + pass + + +def test_invalid_inheritance() -> None: + class Super: + pass + + with pytest.raises(TypeError, match='faux-immutable class `Foo` must inherit from `FauxImmutable` only'): + class Foo(FauxImmutable, Super): + __slots__ = () + + +def test_immutability_success() -> None: + class Foo(FauxImmutable): + __slots__ = ('attr',) + class_attr = 'foo' + + def method(self) -> None: + pass + + @classmethod + def class_method(cls) -> None: + pass + + foo = Foo() + + # + # Existing attribute on instance + # + + # protected by FauxImmutable.__setattr__ + with pytest.raises(AttributeError, match='cannot set attribute `attr` on faux-immutable object'): + foo.attr = 123 + + # protected by FauxImmutable.__setattr__ + with pytest.raises(AttributeError, match='cannot set attribute `attr` on faux-immutable object'): + setattr(foo, 'attr', 123) + + # it doesn't protect against this case + object.__setattr__(foo, 'attr', 123) + + # + # Existing class attribute on instance + # + + # protected by FauxImmutable.__setattr__ + with pytest.raises(AttributeError, match='cannot set attribute `class_attr` on faux-immutable object'): + foo.class_attr = 'bar' + + # protected by FauxImmutable.__setattr__ + with pytest.raises(AttributeError, match='cannot set attribute `class_attr` on faux-immutable object'): + setattr(foo, 'class_attr', 123) + + # protected by FauxImmutable.__slots__ + with pytest.raises(AttributeError, match="'Foo' object attribute 'class_attr' is read-only"): + object.__setattr__(foo, 'class_attr', 123) + + # + # Existing method on instance + # + + # protected by FauxImmutable.__setattr__ + with pytest.raises(AttributeError, match='cannot set attribute `method` on faux-immutable object'): + foo.method = lambda: None # type: ignore[method-assign] + + # protected by FauxImmutable.__setattr__ + with pytest.raises(AttributeError, match='cannot set attribute `method` on faux-immutable object'): + setattr(foo, 'method', lambda: None) + + # protected by Foo.__slots__ + with pytest.raises(AttributeError, match="'Foo' object attribute 'method' is read-only"): + object.__setattr__(foo, 'method', lambda: None) + + # + # Existing class method on instance + # + + # protected by FauxImmutable.__setattr__ + with pytest.raises(AttributeError, match='cannot set attribute `class_method` on faux-immutable object'): + foo.class_method = lambda: None # type: ignore[method-assign] + + # protected by FauxImmutable.__setattr__ + with pytest.raises(AttributeError, match='cannot set attribute `class_method` on faux-immutable object'): + setattr(foo, 'class_method', lambda: None) + + # protected by FauxImmutable.__slots__ + with pytest.raises(AttributeError, match="'Foo' object attribute 'class_method' is read-only"): + object.__setattr__(foo, 'class_method', lambda: None) + + # + # New attribute on instance + # + + # protected by FauxImmutable.__setattr__ + with pytest.raises(AttributeError, match='cannot set attribute `new_attr` on faux-immutable object'): + foo.new_attr = 123 + + # protected by FauxImmutable.__setattr__ + with pytest.raises(AttributeError, match='cannot set attribute `new_attr` on faux-immutable object'): + setattr(foo, 'new_attr', 123) + + # protected by Foo.__slots__ + with pytest.raises(AttributeError, match="'Foo' object has no attribute 'new_attr'"): + object.__setattr__(foo, 'new_attr', 123) + + # + # Existing attribute on class + # + + # protected by _FauxImmutableMeta.__setattr__ + with pytest.raises(AttributeError, match='cannot set attribute `attr` on faux-immutable class'): + Foo.attr = 'bar' + + # protected by _FauxImmutableMeta.__setattr__ + with pytest.raises(AttributeError, match='cannot set attribute `attr` on faux-immutable class'): + setattr(Foo, 'attr', 'bar') + + # protected by Python itself + with pytest.raises(TypeError, match="can't apply this __setattr__ to _FauxImmutableMeta object"): + object.__setattr__(Foo, 'attr', 'bar') + + # + # Existing class attribute on class + # + + # protected by _FauxImmutableMeta.__setattr__ + with pytest.raises(AttributeError, match='cannot set attribute `class_attr` on faux-immutable class'): + Foo.class_attr = 'bar' + + # protected by _FauxImmutableMeta.__setattr__ + with pytest.raises(AttributeError, match='cannot set attribute `class_attr` on faux-immutable class'): + setattr(Foo, 'class_attr', 'bar') + + # protected by Python itself + with pytest.raises(TypeError, match="can't apply this __setattr__ to _FauxImmutableMeta object"): + object.__setattr__(Foo, 'class_attr', 'bar') + + # + # Existing method on class + # + + # protected by _FauxImmutableMeta.__setattr__ + with pytest.raises(AttributeError, match='cannot set attribute `method` on faux-immutable class'): + Foo.method = lambda self: None # type: ignore[method-assign] + + # protected by _FauxImmutableMeta.__setattr__ + with pytest.raises(AttributeError, match='cannot set attribute `method` on faux-immutable class'): + setattr(Foo, 'method', lambda self: None) + + # protected by Python itself + with pytest.raises(TypeError, match="can't apply this __setattr__ to _FauxImmutableMeta object"): + object.__setattr__(Foo, 'method', lambda self: None) + + # + # Existing class method on class + # + + # protected by _FauxImmutableMeta.__setattr__ + with pytest.raises(AttributeError, match='cannot set attribute `class_method` on faux-immutable class'): + Foo.class_method = lambda: None # type: ignore[method-assign] + + # protected by _FauxImmutableMeta.__setattr__ + with pytest.raises(AttributeError, match='cannot set attribute `class_method` on faux-immutable class'): + setattr(Foo, 'class_method', lambda self: None) + + # protected by Python itself + with pytest.raises(TypeError, match="can't apply this __setattr__ to _FauxImmutableMeta object"): + object.__setattr__(Foo, 'class_method', lambda self: None) + + # + # New attribute on class + # + + # protected by _FauxImmutableMeta.__setattr__ + with pytest.raises(AttributeError, match='cannot set attribute `new_class_attr` on faux-immutable class'): + Foo.new_class_attr = 'bar' + + # protected by _FauxImmutableMeta.__setattr__ + with pytest.raises(AttributeError, match='cannot set attribute `new_class_attr` on faux-immutable class'): + setattr(Foo, 'new_class_attr', 'bar') + + # protected by Python itself + with pytest.raises(TypeError, match="can't apply this __setattr__ to _FauxImmutableMeta object"): + object.__setattr__(Foo, 'new_class_attr', 'bar') + + +def test_shell_class() -> None: + class Foo(FauxImmutable): + __slots__ = () + + foo1 = create_with_shell(Foo) + foo2 = create_with_shell(Foo) + + assert foo1.__class__ is not Foo + assert foo1.__class__ != Foo + + assert foo2.__class__ is not Foo + assert foo2.__class__ != Foo + + assert foo1.__class__ is not foo2.__class__ + assert foo1.__class__ != foo2.__class__ diff --git a/tests/nanocontracts/test_rng.py b/tests/nanocontracts/test_rng.py index 79dcd055c..33d04e31c 100644 --- a/tests/nanocontracts/test_rng.py +++ b/tests/nanocontracts/test_rng.py @@ -6,6 +6,7 @@ from hathor.nanocontracts import Blueprint, Context, public from hathor.nanocontracts.catalog import NCBlueprintCatalog from hathor.nanocontracts.exception import NCFail +from hathor.nanocontracts.faux_immutable import create_with_shell from hathor.nanocontracts.rng import NanoRNG from hathor.nanocontracts.types import ContractId from hathor.transaction import Transaction @@ -78,11 +79,11 @@ def test_rng_override(self) -> None: # # protected by overridden __setattr__ - with pytest.raises(AttributeError, match='Cannot assign methods to this object.'): + with pytest.raises(AttributeError, match='cannot set attribute `_NanoRNG__seed` on faux-immutable object'): rng._NanoRNG__seed = b'1' * 32 # protected by overridden __setattr__ - with pytest.raises(AttributeError, match='Cannot assign methods to this object.'): + with pytest.raises(AttributeError, match='cannot set attribute `_NanoRNG__seed` on faux-immutable object'): setattr(rng, '_NanoRNG__seed', b'1' * 32) # it doesn't protect against this case @@ -94,11 +95,11 @@ def test_rng_override(self) -> None: # # protected by overridden NanoRNG.__setattr__ - with pytest.raises(AttributeError, match='Cannot assign methods to this object.'): + with pytest.raises(AttributeError, match='cannot set attribute `new_attr` on faux-immutable object'): rng.new_attr = 123 # protected by overridden NanoRNG.__setattr__ - with pytest.raises(AttributeError, match='Cannot assign methods to this object.'): + with pytest.raises(AttributeError, match='cannot set attribute `new_attr` on faux-immutable object'): setattr(rng, 'new_attr', 123) # protected by __slots__ @@ -110,15 +111,15 @@ def test_rng_override(self) -> None: # # protected by overridden NanoRNG.__setattr__ - with pytest.raises(AttributeError, match='Cannot assign methods to this object.'): + with pytest.raises(AttributeError, match='cannot set attribute `random` on faux-immutable object'): rng.random = lambda self: 2 # type: ignore[method-assign, misc, assignment] # protected by overridden NanoRNG.__setattr__ - with pytest.raises(AttributeError, match='Cannot assign methods to this object.'): + with pytest.raises(AttributeError, match='cannot set attribute `random` on faux-immutable object'): setattr(rng, 'random', lambda self: 2) # protected by overridden NanoRNG.__setattr__ - with pytest.raises(AttributeError, match='Cannot assign methods to this object.'): + with pytest.raises(AttributeError, match='cannot set attribute `random` on faux-immutable object'): from types import MethodType rng.random = MethodType(lambda self: 2, rng) # type: ignore[method-assign] @@ -131,15 +132,15 @@ def test_rng_override(self) -> None: # # protected by overridden NoMethodOverrideMeta.__setattr__ - with pytest.raises(AttributeError, match='Cannot override method `random`'): + with pytest.raises(AttributeError, match='cannot set attribute `random` on faux-immutable class'): NanoRNG.random = lambda self: 2 # type: ignore[method-assign] # protected by overridden NoMethodOverrideMeta.__setattr__ - with pytest.raises(AttributeError, match='Cannot override method `random`'): + with pytest.raises(AttributeError, match='cannot set attribute `random` on faux-immutable class'): setattr(NanoRNG, 'random', lambda self: 2) # protected by Python itself - with pytest.raises(TypeError, match='can\'t apply this __setattr__ to NoMethodOverrideMeta object'): + with pytest.raises(TypeError, match='can\'t apply this __setattr__ to _FauxImmutableMeta object'): object.__setattr__(NanoRNG, 'random', lambda self: 2) # @@ -147,15 +148,15 @@ def test_rng_override(self) -> None: # # protected by overridden NoMethodOverrideMeta.__setattr__ - with pytest.raises(AttributeError, match='Cannot override method `random`'): + with pytest.raises(AttributeError, match='cannot set attribute `random` on faux-immutable class'): rng.__class__.random = lambda self: 2 # type: ignore[method-assign] # protected by overridden NoMethodOverrideMeta.__setattr__ - with pytest.raises(AttributeError, match='Cannot override method `random`'): + with pytest.raises(AttributeError, match='cannot set attribute `random` on faux-immutable class'): setattr(rng.__class__, 'random', lambda self: 2) # protected by Python itself - with pytest.raises(TypeError, match='can\'t apply this __setattr__ to NoMethodOverrideMeta object'): + with pytest.raises(TypeError, match='can\'t apply this __setattr__ to _FauxImmutableMeta object'): object.__setattr__(rng.__class__, 'random', lambda self: 2) # @@ -163,33 +164,33 @@ def test_rng_override(self) -> None: # # protected by overridden NoMethodOverrideMeta.__setattr__ - with pytest.raises(AttributeError, match='Cannot override method `new_attr`'): + with pytest.raises(AttributeError, match='cannot set attribute `new_attr` on faux-immutable class'): NanoRNG.new_attr = 123 # protected by overridden NoMethodOverrideMeta.__setattr__ - with pytest.raises(AttributeError, match='Cannot override method `new_attr`'): + with pytest.raises(AttributeError, match='cannot set attribute `new_attr` on faux-immutable class'): setattr(NanoRNG, 'new_attr', 123) # protected by Python itself - with pytest.raises(TypeError, match='can\'t apply this __setattr__ to NoMethodOverrideMeta object'): + with pytest.raises(TypeError, match='can\'t apply this __setattr__ to _FauxImmutableMeta object'): object.__setattr__(NanoRNG, 'new_attr', 123) assert rng.random() < 1 def test_rng_shell_class(self) -> None: seed = b'0' * 32 - rng1 = NanoRNG.create_with_shell(seed=seed) - rng2 = NanoRNG.create_with_shell(seed=seed) + rng1 = create_with_shell(NanoRNG, seed=seed) + rng2 = create_with_shell(NanoRNG, seed=seed) assert rng1.__class__ != rng2.__class__ - with pytest.raises(AttributeError, match='Cannot override method `random`'): + with pytest.raises(AttributeError, match='cannot set attribute `random` on faux-immutable class'): rng1.__class__.random = lambda self: 2 # type: ignore[method-assign] - with pytest.raises(AttributeError, match='Cannot override method `random`'): + with pytest.raises(AttributeError, match='cannot set attribute `random` on faux-immutable class'): setattr(rng1.__class__, 'random', lambda self: 2) - with pytest.raises(TypeError, match='can\'t apply this __setattr__ to NoMethodOverrideMeta object'): + with pytest.raises(TypeError, match='can\'t apply this __setattr__ to _FauxImmutableMeta object'): object.__setattr__(rng1.__class__, 'random', lambda self: 2) def assertGoodnessOfFitTest(self, observed: list[int], expected: list[int]) -> None: diff --git a/tests/others/test_hathor_settings.py b/tests/others/test_hathor_settings.py index a69107b69..e41578579 100644 --- a/tests/others/test_hathor_settings.py +++ b/tests/others/test_hathor_settings.py @@ -20,10 +20,10 @@ from pydantic import ValidationError from hathor.checkpoint import Checkpoint -from hathor.conf import MAINNET_SETTINGS_FILEPATH, TESTNET_SETTINGS_FILEPATH +from hathor.conf import MAINNET_SETTINGS_FILEPATH, TESTNET_GOLF_SETTINGS_FILEPATH from hathor.conf.mainnet import SETTINGS as MAINNET_SETTINGS from hathor.conf.settings import DECIMAL_PLACES, GENESIS_TOKEN_UNITS, GENESIS_TOKENS, HathorSettings -from hathor.conf.testnet import SETTINGS as TESTNET_SETTINGS +from hathor.conf.testnet_golf import SETTINGS as TESTNET_GOLF_SETTINGS @pytest.mark.parametrize('filepath', ['fixtures/valid_hathor_settings_fixture.yml']) @@ -238,5 +238,5 @@ def test_mainnet_settings_migration(): assert MAINNET_SETTINGS == HathorSettings.from_yaml(filepath=MAINNET_SETTINGS_FILEPATH) -def test_testnet_settings_migration(): - assert TESTNET_SETTINGS == HathorSettings.from_yaml(filepath=TESTNET_SETTINGS_FILEPATH) +def test_testnet_golf_settings_migration(): + assert TESTNET_GOLF_SETTINGS == HathorSettings.from_yaml(filepath=TESTNET_GOLF_SETTINGS_FILEPATH)