diff --git a/aws_lambda/relayers_observer/.gitignore b/aws_lambda/relayers_observer/.gitignore new file mode 100644 index 000000000..37833f8be --- /dev/null +++ b/aws_lambda/relayers_observer/.gitignore @@ -0,0 +1,10 @@ +*.swp +package-lock.json +__pycache__ +.pytest_cache +.venv +*.egg-info + +# CDK asset staging directory +.cdk.staging +cdk.out diff --git a/aws_lambda/relayers_observer/Dockerfile b/aws_lambda/relayers_observer/Dockerfile new file mode 100644 index 000000000..b6ac53149 --- /dev/null +++ b/aws_lambda/relayers_observer/Dockerfile @@ -0,0 +1,31 @@ +# trunk-ignore-all(trivy/DS002) +# trunk-ignore-all(trivy/DS026) +# trunk-ignore-all(checkov/CKV_DOCKER_2) +# trunk-ignore-all(checkov/CKV_DOCKER_3) +# trunk-ignore-all(hadolint/DL3013) +# trunk-ignore-all(hadolint/DL3033) +FROM amazon/aws-lambda-python:3.10 + +RUN yum update -y && \ + yum install -y gcc gmp-devel && \ + yum clean all && \ + rm -rf /var/cache/yum + +WORKDIR /var/task + +RUN pip install --no-cache-dir uv && uv venv + +COPY pyproject.toml ./ + +RUN pip install --no-cache-dir -e '.[lambda-dependencies]' + +COPY build ./build +COPY deployments ./deployments +COPY relayers.json ./relayers.json +COPY .env ./ +COPY relayers_observer.py ./ +COPY constants.py ./kakarot_scripts/constants.py +COPY starknet.py ./kakarot_scripts/utils/starknet.py +COPY data ./kakarot_scripts/data + +CMD ["relayers_observer.lambda_handler"] diff --git a/aws_lambda/relayers_observer/app.py b/aws_lambda/relayers_observer/app.py new file mode 100644 index 000000000..ef2ed8f42 --- /dev/null +++ b/aws_lambda/relayers_observer/app.py @@ -0,0 +1,9 @@ +#!/usr/bin/env python3 + +import aws_cdk as cdk +from relayers_observer_lambda_stack import RelayerObserverLambdaStack + +app = cdk.App() +RelayerObserverLambdaStack(app, "RelayerObserverLambdaStack") + +app.synth() diff --git a/aws_lambda/relayers_observer/cdk.json b/aws_lambda/relayers_observer/cdk.json new file mode 100644 index 000000000..9d6f1e8cb --- /dev/null +++ b/aws_lambda/relayers_observer/cdk.json @@ -0,0 +1,71 @@ +{ + "app": "python3 app.py", + "watch": { + "include": ["**"], + "exclude": [ + "README.md", + "cdk*.json", + "requirements*.txt", + "source.bat", + "**/__init__.py", + "**/__pycache__", + "tests" + ] + }, + "context": { + "@aws-cdk/aws-lambda:recognizeLayerVersion": true, + "@aws-cdk/core:checkSecretUsage": true, + "@aws-cdk/core:target-partitions": ["aws", "aws-cn"], + "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, + "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, + "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, + "@aws-cdk/aws-iam:minimizePolicies": true, + "@aws-cdk/core:validateSnapshotRemovalPolicy": true, + "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, + "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, + "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, + "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, + "@aws-cdk/core:enablePartitionLiterals": true, + "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, + "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, + "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true, + "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, + "@aws-cdk/aws-route53-patters:useCertificate": true, + "@aws-cdk/customresources:installLatestAwsSdkDefault": false, + "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true, + "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true, + "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true, + "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true, + "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true, + "@aws-cdk/aws-redshift:columnId": true, + "@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true, + "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true, + "@aws-cdk/aws-apigateway:requestValidatorUniqueId": true, + "@aws-cdk/aws-kms:aliasNameRef": true, + "@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true, + "@aws-cdk/core:includePrefixInUniqueNameGeneration": true, + "@aws-cdk/aws-efs:denyAnonymousAccess": true, + "@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true, + "@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": true, + "@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": true, + "@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters": true, + "@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier": true, + "@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": true, + "@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource": true, + "@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction": true, + "@aws-cdk/aws-codepipeline:crossAccountKeysDefaultValueToFalse": true, + "@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2": true, + "@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope": true, + "@aws-cdk/aws-eks:nodegroupNameAttribute": true, + "@aws-cdk/aws-ec2:ebsDefaultGp3Volume": true, + "@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm": true, + "@aws-cdk/custom-resources:logApiResponseDataPropertyTrueDefault": false, + "@aws-cdk/aws-s3:keepNotificationInImportedBucket": false, + "@aws-cdk/aws-ecs:reduceEc2FargateCloudWatchPermissions": true, + "@aws-cdk/aws-ec2:ec2SumTImeoutEnabled": true, + "@aws-cdk/aws-appsync:appSyncGraphQLAPIScopeLambdaPermission": true, + "@aws-cdk/aws-rds:setCorrectValueForDatabaseInstanceReadReplicaInstanceResourceId": true, + "@aws-cdk/core:cfnIncludeRejectComplexResourceUpdateCreatePolicyIntrinsics": true, + "@aws-cdk/aws-lambda-nodejs:sdkV3ExcludeSmithyPackages": true + } +} diff --git a/aws_lambda/relayers_observer/pyproject.toml b/aws_lambda/relayers_observer/pyproject.toml new file mode 100644 index 000000000..c40517e4e --- /dev/null +++ b/aws_lambda/relayers_observer/pyproject.toml @@ -0,0 +1,30 @@ +[project] +name = "relayers_observer" +version = "0.1.0" +description = "Relayers Observer Lambda function" +requires-python = ">=3.10,<3.11" + +[project.optional-dependencies] +lambda-dependencies = [ + "starknet-py==0.23.0", + "python-dotenv==0.21.0", + "web3==6", + "async_lru==2.0.4", + "cairo-lang==0.13.1", + "requests==2.32.3", + "eth_keys==0.5.1", + "boto3==1.35.36", +] + +cdk-dependencies = ["aws-cdk-lib==2.161.1", "constructs>=10.0.0,<11.0.0"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build] +include = [ + "relayers_observer.py", + "relayers_observer_lambda_stack.py", + "app.py", +] diff --git a/aws_lambda/relayers_observer/relayers.json b/aws_lambda/relayers_observer/relayers.json new file mode 100644 index 000000000..878784a14 --- /dev/null +++ b/aws_lambda/relayers_observer/relayers.json @@ -0,0 +1,92 @@ +[ + { + "address": 950135147883551002471284612127692562371997031991686973344811456814871551703 + }, + { + "address": 784553024261379269037061621041983249972520011546534893666375886810285652022 + }, + { + "address": 2016715077620147159803572307858325398159758228128803672826672562207191315116 + }, + { + "address": 1662920711526725350032789559696699552222118181660561466963176525053308263625 + }, + { + "address": 3153637514465957585881265164805986785841191541018593438889626498255327177411 + }, + { + "address": 2766997136495195637890235109180516317074792508593436805931502677369166475499 + }, + { + "address": 2093590491756438567971438593464470359400203878063146755934968664136245299457 + }, + { + "address": 1156309487454888498236027337307355210771977936939547064564822895690729329261 + }, + { + "address": 2375508979001195339092705976533981949661341069936882436634727685307875319193 + }, + { + "address": 2794561348319831960405086417566258806031170000859851643881352304467811247177 + }, + { + "address": 3196938585199069588552778059786084432204759834046564491402522960867285987487 + }, + { + "address": 3249849696581527057698578089564361163387585517848746353802934812359434293529 + }, + { + "address": 1707748983747411678822551525712719303335815970545789299210384474650319917052 + }, + { + "address": 871964783514378596548163446256746702153855963543252144050276816814875674558 + }, + { + "address": 447594387324528764199053850392655034838752348231600003608193346877723837852 + }, + { + "address": 249732155503971925348549527968576754852836046059112668466387462006740968212 + }, + { + "address": 3446662058793631682698091747697837875337565532441852226591023978462097914409 + }, + { + "address": 2431027466263453465882296491914298235363836672287741575367285923951389377303 + }, + { + "address": 2667345079579245645829382805366852599258219482721527050573445324850349248720 + }, + { + "address": 1912701013260142734206290515668606917117159045812693977111553625230013542785 + }, + { + "address": 2448460999699100678054857534135769814992359258605688440449958764415834140201 + }, + { + "address": 2506017580705275985007775372164438810134467385003111950692305383782396861109 + }, + { + "address": 2467262930860607918431477448588678974558152179771189301003734902904667082699 + }, + { + "address": 238052413982080525062062834169368364942253420244711858791548911875984508405 + }, + { + "address": 1377987273889472543754659361551378469821977441767344463704291075020954302330 + }, + { + "address": 2096011801106804190494834046291183630742307755777720881520190404439626060392 + }, + { + "address": 2526397583797409088576194203532356382647146866713022053321340510362096170426 + }, + { + "address": 408885905545087652195361774298355147012912093936939629684581725287151251217 + }, + { + "address": 2036100759087474495109507324801202976882523277765123480505872484555089939206 + }, + { + "address": 1646975286751587104385756489415815854141667265954694456915839184663717755926 + } +] diff --git a/aws_lambda/relayers_observer/relayers_observer.py b/aws_lambda/relayers_observer/relayers_observer.py new file mode 100644 index 000000000..a6be79e9b --- /dev/null +++ b/aws_lambda/relayers_observer/relayers_observer.py @@ -0,0 +1,93 @@ +import asyncio +import json +import logging + +import boto3 +import requests + +from kakarot_scripts.constants import SLACK_WEBHOOK_URL +from kakarot_scripts.utils.starknet import ( + fund_address, + get_balance, + get_eth_contract, + get_starknet_account, +) + +client = boto3.client("secretsmanager") +logger = logging.getLogger() +logger.setLevel("INFO") + + +def lambda_handler(event, context): + return asyncio.get_event_loop().run_until_complete(check_and_fund_relayers()) + + +async def check_and_fund_relayers(): + """ + Check the balance of relayer accounts and fund them if necessary. + + This function performs the following steps: + 1. Loads relayer information from a JSON file. + 2. Retrieves the funding account details from AWS Secrets Manager. + 3. Checks the balance of the main relayer account. + 4. Iterates through all relayers, checking their balances and funding if needed. + """ + # Constants for balance thresholds and funding amount + funding_account_lower_limit = 10 # ETH + relayers_lower_limit = 0.05 # ETH + amount_to_fund = 0.1 # ETH + + # Load relayers information from JSON file + with open("relayers.json", "r") as f: + relayers = json.load(f) + + # Retrieve secret from AWS Secrets Manager + response = client.get_secret_value(SecretId="relayers_fund_account") + secret_dict = json.loads(response["SecretString"]) + + address, private_key = next(iter(secret_dict.items())) + account = await get_starknet_account(address, private_key) + + # Get ETH contract and check main relayer account balance + eth_contract = await get_eth_contract(account) + balance = await get_balance(account.address, eth_contract) + + # Alert if main relayer account balance is lower than the funding_account_lower_limit + if balance / 1e18 < funding_account_lower_limit: + message = f"Fund the relayer account 0x{account.address:064x}. Current balance: {balance / 1e18} ETH" + send_message_to_slack(message) + + # Check and fund individual relayer accounts + for relayer in relayers: + relayer_balance = await get_balance(relayer["address"]) + if relayer_balance / 1e18 < relayers_lower_limit: + try: + await fund_address(address, amount_to_fund, account) + message = f"Funded address {address} with {amount_to_fund} ETH from {account.address}" + except Exception: + message = f"Failed to fund address {address}" + send_message_to_slack(message) + return { + "statusCode": 500, + "body": json.dumps({"Failed to fund address"}), + } + else: + logger.info( + f"Address {address} has enough balance: {relayer_balance / 1e18} ETH" + ) + + return { + "statusCode": 200, + } + + +def send_message_to_slack(message): + msg = { + "channel": "", + "username": "WEBHOOK_USERNAME", + "text": message, + } + resp = requests.post(SLACK_WEBHOOK_URL, json=msg) + logger.info( + {"message": "test", "status_code": resp.status_code, "response": resp.text} + ) diff --git a/aws_lambda/relayers_observer/relayers_observer_lambda_stack.py b/aws_lambda/relayers_observer/relayers_observer_lambda_stack.py new file mode 100644 index 000000000..e9a0b602b --- /dev/null +++ b/aws_lambda/relayers_observer/relayers_observer_lambda_stack.py @@ -0,0 +1,64 @@ +import os +import shutil + +from aws_cdk import Duration, Stack +from aws_cdk import aws_events as events +from aws_cdk import aws_events_targets as targets +from aws_cdk import aws_iam as iam +from aws_cdk import aws_lambda as _lambda +from constructs import Construct + + +class RelayerObserverLambdaStack(Stack): + def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: + super().__init__(scope, construct_id, **kwargs) + + self.build_lambda_func() + + def build_lambda_func(self): + dest_dir = "./" + shutil.copy( + "../../kakarot_scripts/constants.py", os.path.join(dest_dir, "constants.py") + ) + shutil.copy( + "../../kakarot_scripts/utils/starknet.py", + os.path.join(dest_dir, "starknet.py"), + ) + shutil.copy("../../.env", os.path.join(dest_dir, ".env")) + shutil.copytree( + "../../build", + os.path.join(dest_dir, "build"), + dirs_exist_ok=True, + ) + shutil.copytree( + "../../deployments", + os.path.join(dest_dir, "deployments"), + dirs_exist_ok=True, + ) + shutil.copytree( + "../../kakarot_scripts/data", + os.path.join(dest_dir, "data"), + dirs_exist_ok=True, + ) + self.prediction_lambda = _lambda.DockerImageFunction( + scope=self, + id="relayers_observer_lambda", + function_name="relayers_observer", + code=_lambda.DockerImageCode.from_image_asset(directory="."), + environment_encryption=None, + timeout=Duration.minutes(1), + ) + + self.prediction_lambda.add_to_role_policy( + iam.PolicyStatement( + actions=["secretsmanager:GetSecretValue"], + resources=[os.environ.get("RELAYERS_FUND_ACCOUNT_SECRET_ARN")], + ) + ) + + events.Rule( + self, + "ScheduleRule", + schedule=events.Schedule.cron(minute="0/20"), + targets=[targets.LambdaFunction(self.prediction_lambda)], + ) diff --git a/kakarot_scripts/constants.py b/kakarot_scripts/constants.py index 792c434c6..0cc177af2 100644 --- a/kakarot_scripts/constants.py +++ b/kakarot_scripts/constants.py @@ -314,6 +314,8 @@ class ChainId(IntEnum): logger.warning(f"⚠️ {prefix}_PRIVATE_KEY not set, defaulting to PRIVATE_KEY") NETWORK["private_key"] = os.getenv("PRIVATE_KEY") +SLACK_WEBHOOK_URL = os.environ.get("SLACK_WEBHOOK_URL") + class RelayerPool: def __init__(self, relayers: List[Dict[str, int]]):