From e1959abbec9d90cf5ed2b1bec6a44d18487c9e7b Mon Sep 17 00:00:00 2001 From: root Date: Thu, 16 Jan 2025 15:06:03 +0000 Subject: [PATCH 01/13] sparta bot initial commit --- tooling/sparta/.env.example | 19 ++++ tooling/sparta/.gitignore | 3 + tooling/sparta/Dockerfile | 7 ++ tooling/sparta/package.json | 21 ++++ tooling/sparta/src/commands/addValidator.ts | 88 +++++++++++++++ tooling/sparta/src/commands/getChainInfo.ts | 31 ++++++ tooling/sparta/src/commands/index.ts | 7 ++ tooling/sparta/src/deploy-commands.ts | 26 +++++ tooling/sparta/src/env.ts | 40 +++++++ tooling/sparta/src/index.ts | 104 ++++++++++++++++++ .../sparta/src/services/chaininfo-service.ts | 55 +++++++++ .../sparta/src/services/validator-service.ts | 54 +++++++++ tooling/sparta/tsconfig.json | 15 +++ 13 files changed, 470 insertions(+) create mode 100644 tooling/sparta/.env.example create mode 100644 tooling/sparta/.gitignore create mode 100644 tooling/sparta/Dockerfile create mode 100644 tooling/sparta/package.json create mode 100644 tooling/sparta/src/commands/addValidator.ts create mode 100644 tooling/sparta/src/commands/getChainInfo.ts create mode 100644 tooling/sparta/src/commands/index.ts create mode 100644 tooling/sparta/src/deploy-commands.ts create mode 100644 tooling/sparta/src/env.ts create mode 100644 tooling/sparta/src/index.ts create mode 100644 tooling/sparta/src/services/chaininfo-service.ts create mode 100644 tooling/sparta/src/services/validator-service.ts create mode 100644 tooling/sparta/tsconfig.json diff --git a/tooling/sparta/.env.example b/tooling/sparta/.env.example new file mode 100644 index 0000000..ffd09bf --- /dev/null +++ b/tooling/sparta/.env.example @@ -0,0 +1,19 @@ +ENVIRONMENT= + +DEV_CHANNEL_ID= +DEV_CHANNEL_NAME= + +PRODUCTION_CHANNEL_ID= +PRODUCTION_CHANNEL_NAME= + +BOT_TOKEN= +BOT_CLIENT_ID= +GUILD_ID= + +ETHEREUM_HOST= +ETHEREUM_MNEMONIC= +ETHEREUM_PRIVATE_KEY= +ETHEREUM_ROLLUP_ADDRESS= +ETHEREUM_CHAIN_ID= +ETHEREUM_VALUE= +ETHEREUM_ADMIN_ADDRESS= diff --git a/tooling/sparta/.gitignore b/tooling/sparta/.gitignore new file mode 100644 index 0000000..fb21f24 --- /dev/null +++ b/tooling/sparta/.gitignore @@ -0,0 +1,3 @@ +.env +node_modules +bun.lockb diff --git a/tooling/sparta/Dockerfile b/tooling/sparta/Dockerfile new file mode 100644 index 0000000..07b0a39 --- /dev/null +++ b/tooling/sparta/Dockerfile @@ -0,0 +1,7 @@ +FROM oven/bun:latest + +COPY package.json ./ +COPY bun.lockb ./ +COPY src ./ + +RUN bun install diff --git a/tooling/sparta/package.json b/tooling/sparta/package.json new file mode 100644 index 0000000..923056f --- /dev/null +++ b/tooling/sparta/package.json @@ -0,0 +1,21 @@ +{ + "name": "sparta-bot", + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "tsc", + "start": "bun run src/index.ts", + "dev": "bun run --watch src/index.ts", + "deploy": "bun run src/deploy-commands.ts" + }, + "dependencies": { + "discord.js": "^14.14.1", + "dotenv": "^16.3.1" + }, + "devDependencies": { + "typescript": "^5.3.3", + "@types/node": "^20.10.5", + "ts-node": "^10.9.2", + "bun-types": "latest" + } +} diff --git a/tooling/sparta/src/commands/addValidator.ts b/tooling/sparta/src/commands/addValidator.ts new file mode 100644 index 0000000..21100ea --- /dev/null +++ b/tooling/sparta/src/commands/addValidator.ts @@ -0,0 +1,88 @@ +import { SlashCommandBuilder, ChatInputCommandInteraction } from "discord.js"; +import { ValidatorService } from "../services/validator-service"; +import { ChainInfoService } from "../services/chaininfo-service"; + +export default { + data: new SlashCommandBuilder() + .setName("validator") + .setDescription("Manage validator addresses") + .addSubcommand((subcommand) => + subcommand + .setName("add") + .setDescription("Add yourself to the validator set") + .addStringOption((option) => + option + .setName("address") + .setDescription("Your validator address") + ) + ) + .addSubcommand((subcommand) => + subcommand + .setName("check") + .setDescription("Check if you are a validator") + .addStringOption((option) => + option + .setName("address") + .setDescription("The validator address to check") + ) + ), + + execute: async (interaction: ChatInputCommandInteraction) => { + const address = interaction.options.getString("address"); + if (!address) { + return interaction.reply({ + content: "Address is required.", + ephemeral: true, + }); + } + + // Basic address validation + if (!address.match(/^0x[a-fA-F0-9]{40}$/)) { + return interaction.reply({ + content: "Please provide a valid Ethereum address.", + ephemeral: true, + }); + } + + await interaction.deferReply(); + + if (interaction.options.getSubcommand() === "add") { + try { + await ValidatorService.addValidator(address); + + await interaction.editReply({ + content: `Successfully added validator address: ${address}`, + }); + } catch (error) { + await interaction.editReply({ + content: `Failed to add validator address: ${ + error instanceof Error ? error.message : String(error) + }`, + }); + } + } else if (interaction.options.getSubcommand() === "check") { + try { + const info = await ChainInfoService.getInfo(); + const { validators, committee } = info; + + let reply = ""; + if (validators.includes(address)) { + reply += "You are a validator\n"; + } + if (committee.includes(address)) { + reply += "You are a committee member\n"; + } + + await interaction.editReply({ + content: reply, + }); + } catch (error) { + await interaction.editReply({ + content: `Failed to check validator address: ${ + error instanceof Error ? error.message : String(error) + }`, + }); + } + } + }, +}; diff --git a/tooling/sparta/src/commands/getChainInfo.ts b/tooling/sparta/src/commands/getChainInfo.ts new file mode 100644 index 0000000..5ca6ec3 --- /dev/null +++ b/tooling/sparta/src/commands/getChainInfo.ts @@ -0,0 +1,31 @@ +import { SlashCommandBuilder, ChatInputCommandInteraction } from "discord.js"; +import { ChainInfoService } from "../services/chaininfo-service"; + +export default { + data: new SlashCommandBuilder() + .setName("get-info") + .setDescription("Get chain info"), + + execute: async (interaction: ChatInputCommandInteraction) => { + await interaction.deferReply(); + + try { + const { + pendingBlockNum, + provenBlockNum, + currentEpoch, + currentSlot, + proposerNow, + } = await ChainInfoService.getInfo(); + + await interaction.editReply({ + content: `Pending block: ${pendingBlockNum}\nProven block: ${provenBlockNum}\nCurrent epoch: ${currentEpoch}\nCurrent slot: ${currentSlot}\nProposer now: ${proposerNow}`, + }); + } catch (error) { + console.error("Error in get-info command:", error); + await interaction.editReply({ + content: `Failed to get chain info`, + }); + } + }, +}; diff --git a/tooling/sparta/src/commands/index.ts b/tooling/sparta/src/commands/index.ts new file mode 100644 index 0000000..911efd3 --- /dev/null +++ b/tooling/sparta/src/commands/index.ts @@ -0,0 +1,7 @@ +import address from "./addValidator"; +import chainInfo from "./getChainInfo"; + +export default { + address, + chainInfo, +}; diff --git a/tooling/sparta/src/deploy-commands.ts b/tooling/sparta/src/deploy-commands.ts new file mode 100644 index 0000000..fa7313f --- /dev/null +++ b/tooling/sparta/src/deploy-commands.ts @@ -0,0 +1,26 @@ +import { REST, Routes } from "discord.js"; +import commands from "./commands/index.js"; +import { BOT_TOKEN, BOT_CLIENT_ID, GUILD_ID } from "./env.js"; + +export const deployCommands = async (): Promise => { + const rest = new REST({ version: "10" }).setToken(BOT_TOKEN as string); + + try { + console.log("Started refreshing application (/) commands."); + + const commandsData = Object.values(commands).map((command) => + command.data.toJSON() + ); + + await rest.put( + Routes.applicationGuildCommands(BOT_CLIENT_ID, GUILD_ID), + { + body: commandsData, + } + ); + + console.log("Successfully reloaded application (/) commands."); + } catch (error) { + console.error(error); + } +}; diff --git a/tooling/sparta/src/env.ts b/tooling/sparta/src/env.ts new file mode 100644 index 0000000..345ad2d --- /dev/null +++ b/tooling/sparta/src/env.ts @@ -0,0 +1,40 @@ +import dotenv from "dotenv"; +dotenv.config(); + +export const { + TOKEN, + CLIENT_ID, + GUILD_ID, + PRODUCTION_CHANNEL_NAME, + DEV_CHANNEL_NAME, + PRODUCTION_CHANNEL_ID, + DEV_CHANNEL_ID, + ETHEREUM_HOST, + ETHEREUM_ROLLUP_ADDRESS, + ETHEREUM_ADMIN_ADDRESS, + ETHEREUM_CHAIN_ID, + ETHEREUM_MNEMONIC, + ETHEREUM_PRIVATE_KEY, + ETHEREUM_VALUE, + BOT_TOKEN, + BOT_CLIENT_ID, + ENVIRONMENT, +} = process.env as { + TOKEN: string; + CLIENT_ID: string; + GUILD_ID: string; + PRODUCTION_CHANNEL_NAME: string; + DEV_CHANNEL_NAME: string; + ETHEREUM_HOST: string; + ETHEREUM_ROLLUP_ADDRESS: string; + ETHEREUM_ADMIN_ADDRESS: string; + ETHEREUM_CHAIN_ID: string; + ETHEREUM_MNEMONIC: string; + ETHEREUM_PRIVATE_KEY: string; + ETHEREUM_VALUE: string; + BOT_TOKEN: string; + PRODUCTION_CHANNEL_ID: string; + DEV_CHANNEL_ID: string; + BOT_CLIENT_ID: string; + ENVIRONMENT: string; +}; diff --git a/tooling/sparta/src/index.ts b/tooling/sparta/src/index.ts new file mode 100644 index 0000000..1de8237 --- /dev/null +++ b/tooling/sparta/src/index.ts @@ -0,0 +1,104 @@ +import { + Client, + GatewayIntentBits, + Collection, + Interaction, + MessageFlags, +} from "discord.js"; +import { deployCommands } from "./deploy-commands"; +import commands from "./commands/index.js"; +import { + BOT_TOKEN, + PRODUCTION_CHANNEL_ID, + DEV_CHANNEL_ID, + ENVIRONMENT, + PRODUCTION_CHANNEL_NAME, + DEV_CHANNEL_NAME, +} from "./env.js"; + +// Extend the Client class to include the commands property +interface ExtendedClient extends Client { + commands: Collection; +} + +const client = new Client({ + intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages], +}) as ExtendedClient; + +client.commands = new Collection(); + +for (const command of Object.values(commands)) { + client.commands.set(command.data.name, command); +} + +client.once("ready", () => { + console.log("Sparta bot is ready!"); + deployCommands(); +}); + +client.on("interactionCreate", async (interaction: Interaction) => { + if (!interaction.isChatInputCommand()) return; + + // Determine which channel to use based on environment + const targetChannelId = + ENVIRONMENT === "production" ? PRODUCTION_CHANNEL_ID : DEV_CHANNEL_ID; + + // Check if the command is in the correct channel + if (interaction.channelId !== targetChannelId) { + const channelName = + ENVIRONMENT === "production" + ? PRODUCTION_CHANNEL_NAME + : DEV_CHANNEL_NAME; + return interaction.reply({ + content: `This command can only be used in the ${channelName} channel.`, + flags: MessageFlags.Ephemeral, + }); + } + + const command = client.commands.get(interaction.commandName); + if (!command) return; + + console.log(JSON.stringify(command.execute, null, 2)); + + try { + console.log("Executing command:", command.data.name); + const response = await command.execute(interaction); + + if (typeof response === "string") { + const [ + pendingBlock, + provenBlock, + validators, + committee, + archive, + currentEpoch, + currentSlot, + proposerPrevious, + proposerNow, + ] = response + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + + console.log({ + pendingBlock, + provenBlock, + validators, + committee, + archive, + currentEpoch, + currentSlot, + proposerPrevious, + proposerNow, + }); + } + } catch (error) { + console.error(error); + await interaction.reply({ + content: "There was an error executing this command!", + flags: MessageFlags.Ephemeral, + }); + } +}); + +client.login(BOT_TOKEN); diff --git a/tooling/sparta/src/services/chaininfo-service.ts b/tooling/sparta/src/services/chaininfo-service.ts new file mode 100644 index 0000000..fe8bec9 --- /dev/null +++ b/tooling/sparta/src/services/chaininfo-service.ts @@ -0,0 +1,55 @@ +import { exec } from "child_process"; +import { promisify } from "util"; +import { + ETHEREUM_HOST, + ETHEREUM_ROLLUP_ADDRESS, + ETHEREUM_CHAIN_ID, +} from "../env.js"; + +type ChainInfo = { + pendingBlockNum: string; + provenBlockNum: string; + validators: string[]; + committee: string[]; + archive: string[]; + currentEpoch: string; + currentSlot: string; + proposerNow: string; +}; + +const execAsync = promisify(exec); + +export class ChainInfoService { + static async getInfo(): Promise { + try { + // Add validator to the set + const command = `docker run --rm aztecprotocol/aztec:unhinged-unicorn debug-rollup -u ${ETHEREUM_HOST} --rollup ${ETHEREUM_ROLLUP_ADDRESS} --l1-chain-id ${ETHEREUM_CHAIN_ID} `; + + console.log("Running command:", command); + const { stdout, stderr } = await execAsync(command); + + if (stderr) { + throw new Error(stderr); + } + + // looks like hell, but it just parses the output of the command + // into a key-value object + const info = stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .reduce((acc, s) => { + const [key, value] = s.split(": "); + const sanitizedKey = key + .toLowerCase() + .replace(/\s+(.)/g, (_, c) => c.toUpperCase()); + return { ...acc, [sanitizedKey]: value }; + }, {}); + + return info as ChainInfo; + } catch (error) { + console.error("Error getting chain info:", error); + throw error; + } + } +} diff --git a/tooling/sparta/src/services/validator-service.ts b/tooling/sparta/src/services/validator-service.ts new file mode 100644 index 0000000..9efad16 --- /dev/null +++ b/tooling/sparta/src/services/validator-service.ts @@ -0,0 +1,54 @@ +import { exec } from "child_process"; +import { promisify } from "util"; +import { + ETHEREUM_HOST, + ETHEREUM_ROLLUP_ADDRESS, + ETHEREUM_ADMIN_ADDRESS, + ETHEREUM_CHAIN_ID, + ETHEREUM_MNEMONIC, + ETHEREUM_PRIVATE_KEY, + ETHEREUM_VALUE, +} from "../env.js"; +import { ChainInfoService } from "./chaininfo-service.js"; + +const execAsync = promisify(exec); + +export class ValidatorService { + static async addValidator(address: string): Promise { + try { + // Send ETH to the validator address + await this.fundValidator(address); + + // Add validator to the set + const command = `docker run --rm aztecprotocol/aztec:unhinged-unicorn add-l1-validator -u ${ETHEREUM_HOST} --validator ${address} --rollup ${ETHEREUM_ROLLUP_ADDRESS} --withdrawer ${ETHEREUM_ADMIN_ADDRESS} --l1-chain-id ${ETHEREUM_CHAIN_ID} --mnemonic "${ETHEREUM_MNEMONIC}"`; + + const { stdout, stderr } = await execAsync(command); + + if (stderr) { + throw new Error(stderr); + } + + return stdout; + } catch (error) { + console.error("Error adding validator:", error); + throw error; + } + } + + static async fundValidator(address: string): Promise { + try { + const command = `cast send --value ${ETHEREUM_VALUE} --rpc-url ${ETHEREUM_HOST} --chain-id ${ETHEREUM_CHAIN_ID} --private-key ${ETHEREUM_PRIVATE_KEY} ${address}`; + + const { stdout, stderr } = await execAsync(command); + + if (stderr) { + throw new Error(stderr); + } + + return stdout; + } catch (error) { + console.error("Error funding validator:", error); + throw error; + } + } +} diff --git a/tooling/sparta/tsconfig.json b/tooling/sparta/tsconfig.json new file mode 100644 index 0000000..0c1b8fc --- /dev/null +++ b/tooling/sparta/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} From 68291bae47d5b9042de0214e503f2f11af38a2d5 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 16 Jan 2025 15:28:19 +0000 Subject: [PATCH 02/13] ready for real-world testing I guess --- tooling/sparta/.gitignore | 2 + tooling/sparta/Dockerfile | 8 ++- tooling/sparta/README.md | 36 ++++++++++ tooling/sparta/dist/commands/addValidator.js | 71 +++++++++++++++++++ tooling/sparta/dist/commands/getChainInfo.js | 22 ++++++ tooling/sparta/dist/commands/index.js | 6 ++ tooling/sparta/dist/deploy-commands.js | 17 +++++ tooling/sparta/dist/env.js | 3 + tooling/sparta/dist/index.js | 46 ++++++++++++ .../sparta/dist/services/chaininfo-service.js | 34 +++++++++ .../sparta/dist/services/validator-service.js | 37 ++++++++++ tooling/sparta/docker-compose.yml | 7 ++ tooling/sparta/package.json | 7 +- tooling/sparta/src/commands/addValidator.ts | 4 +- tooling/sparta/src/commands/getChainInfo.ts | 2 +- tooling/sparta/src/commands/index.ts | 8 +-- tooling/sparta/src/index.ts | 33 +-------- .../sparta/src/services/chaininfo-service.ts | 2 - .../sparta/src/services/validator-service.ts | 1 - tooling/sparta/tsconfig.json | 6 +- 20 files changed, 301 insertions(+), 51 deletions(-) create mode 100644 tooling/sparta/README.md create mode 100644 tooling/sparta/dist/commands/addValidator.js create mode 100644 tooling/sparta/dist/commands/getChainInfo.js create mode 100644 tooling/sparta/dist/commands/index.js create mode 100644 tooling/sparta/dist/deploy-commands.js create mode 100644 tooling/sparta/dist/env.js create mode 100644 tooling/sparta/dist/index.js create mode 100644 tooling/sparta/dist/services/chaininfo-service.js create mode 100644 tooling/sparta/dist/services/validator-service.js create mode 100644 tooling/sparta/docker-compose.yml diff --git a/tooling/sparta/.gitignore b/tooling/sparta/.gitignore index fb21f24..55041b4 100644 --- a/tooling/sparta/.gitignore +++ b/tooling/sparta/.gitignore @@ -1,3 +1,5 @@ .env node_modules bun.lockb +.vercel +.dist diff --git a/tooling/sparta/Dockerfile b/tooling/sparta/Dockerfile index 07b0a39..827ef12 100644 --- a/tooling/sparta/Dockerfile +++ b/tooling/sparta/Dockerfile @@ -1,7 +1,13 @@ FROM oven/bun:latest +RUN apt update && apt install -y curl +RUN curl -fsSL https://get.docker.com | bash + +WORKDIR /app COPY package.json ./ COPY bun.lockb ./ -COPY src ./ +COPY src ./src +COPY .env ./ RUN bun install +CMD ["bun", "run", "start"] diff --git a/tooling/sparta/README.md b/tooling/sparta/README.md new file mode 100644 index 0000000..f375562 --- /dev/null +++ b/tooling/sparta/README.md @@ -0,0 +1,36 @@ +# Sparta + +Welcome to Sparta, the Discord bot. It's like having a virtual assistant, but with less features and less judgment. + +Here's a quick rundown of what this codebase is all about: + +## What is Sparta? + +Sparta is a Discord bot that lives to serve (and occasionally sass) testnet participants. + +## Features (WIP) + +- **Chain Info**: Need to know the latest on your blockchain? Sparta's got you covered with the `/get-info` command. It's like having a blockchain oracle, but without the cryptic riddles. + +- **Add Validators**: Are you an S&P Participant and want to be added to the validator set? Just go `/validator add` and send your address, you can then query it with... + +- **Check Validators**: ... `/validator check`, which tells you if you're in the validator set (also tells you if you're in the committee) + +## Getting Started + +To get Sparta up and running, you'll need to: + +1. Clone the repo. +2. Install the dependencies with `bun install`. +3. Copy .env.example and set up with your environment stuff +4. Start the bot with `bun run start`. + +And just like that, you're ready to unleash Sparta on your Discord server! + +## Contributing + +Want to make Sparta even better? Feel free to fork the repo and submit a pull request. Just remember, with great power comes great responsibility (and maybe a few more memes). + +## License + +This project is licensed under the MIT License. Because sharing is caring. diff --git a/tooling/sparta/dist/commands/addValidator.js b/tooling/sparta/dist/commands/addValidator.js new file mode 100644 index 0000000..3925e8e --- /dev/null +++ b/tooling/sparta/dist/commands/addValidator.js @@ -0,0 +1,71 @@ +import { SlashCommandBuilder } from "discord.js"; +import { ValidatorService } from "../services/validator-service.js"; +import { ChainInfoService } from "../services/chaininfo-service.js"; +export default { + data: new SlashCommandBuilder() + .setName("validator") + .setDescription("Manage validator addresses") + .addSubcommand((subcommand) => subcommand + .setName("add") + .setDescription("Add yourself to the validator set") + .addStringOption((option) => option + .setName("address") + .setDescription("Your validator address"))) + .addSubcommand((subcommand) => subcommand + .setName("check") + .setDescription("Check if you are a validator") + .addStringOption((option) => option + .setName("address") + .setDescription("The validator address to check"))), + execute: async (interaction) => { + const address = interaction.options.getString("address"); + if (!address) { + return interaction.reply({ + content: "Address is required.", + ephemeral: true, + }); + } + // Basic address validation + if (!address.match(/^0x[a-fA-F0-9]{40}$/)) { + return interaction.reply({ + content: "Please provide a valid Ethereum address.", + ephemeral: true, + }); + } + await interaction.deferReply(); + if (interaction.options.getSubcommand() === "add") { + try { + await ValidatorService.addValidator(address); + await interaction.editReply({ + content: `Successfully added validator address: ${address}`, + }); + } + catch (error) { + await interaction.editReply({ + content: `Failed to add validator address: ${error instanceof Error ? error.message : String(error)}`, + }); + } + } + else if (interaction.options.getSubcommand() === "check") { + try { + const info = await ChainInfoService.getInfo(); + const { validators, committee } = info; + let reply = ""; + if (validators.includes(address)) { + reply += "You are a validator\n"; + } + if (committee.includes(address)) { + reply += "You are a committee member\n"; + } + await interaction.editReply({ + content: reply, + }); + } + catch (error) { + await interaction.editReply({ + content: `Failed to check validator address: ${error instanceof Error ? error.message : String(error)}`, + }); + } + } + }, +}; diff --git a/tooling/sparta/dist/commands/getChainInfo.js b/tooling/sparta/dist/commands/getChainInfo.js new file mode 100644 index 0000000..d956b06 --- /dev/null +++ b/tooling/sparta/dist/commands/getChainInfo.js @@ -0,0 +1,22 @@ +import { SlashCommandBuilder } from "discord.js"; +import { ChainInfoService } from "../services/chaininfo-service.js"; +export default { + data: new SlashCommandBuilder() + .setName("get-info") + .setDescription("Get chain info"), + execute: async (interaction) => { + await interaction.deferReply(); + try { + const { pendingBlockNum, provenBlockNum, currentEpoch, currentSlot, proposerNow, } = await ChainInfoService.getInfo(); + await interaction.editReply({ + content: `Pending block: ${pendingBlockNum}\nProven block: ${provenBlockNum}\nCurrent epoch: ${currentEpoch}\nCurrent slot: ${currentSlot}\nProposer now: ${proposerNow}`, + }); + } + catch (error) { + console.error("Error in get-info command:", error); + await interaction.editReply({ + content: `Failed to get chain info`, + }); + } + }, +}; diff --git a/tooling/sparta/dist/commands/index.js b/tooling/sparta/dist/commands/index.js new file mode 100644 index 0000000..c2bbcb6 --- /dev/null +++ b/tooling/sparta/dist/commands/index.js @@ -0,0 +1,6 @@ +import addValidator from "./addValidator.js"; +import getChainInfo from "./getChainInfo.js"; +export default { + addValidator, + getChainInfo, +}; diff --git a/tooling/sparta/dist/deploy-commands.js b/tooling/sparta/dist/deploy-commands.js new file mode 100644 index 0000000..2372684 --- /dev/null +++ b/tooling/sparta/dist/deploy-commands.js @@ -0,0 +1,17 @@ +import { REST, Routes } from "discord.js"; +import commands from "./commands/index.js"; +import { BOT_TOKEN, BOT_CLIENT_ID, GUILD_ID } from "./env.js"; +export const deployCommands = async () => { + const rest = new REST({ version: "10" }).setToken(BOT_TOKEN); + try { + console.log("Started refreshing application (/) commands."); + const commandsData = Object.values(commands).map((command) => command.data.toJSON()); + await rest.put(Routes.applicationGuildCommands(BOT_CLIENT_ID, GUILD_ID), { + body: commandsData, + }); + console.log("Successfully reloaded application (/) commands."); + } + catch (error) { + console.error(error); + } +}; diff --git a/tooling/sparta/dist/env.js b/tooling/sparta/dist/env.js new file mode 100644 index 0000000..0cdbfa1 --- /dev/null +++ b/tooling/sparta/dist/env.js @@ -0,0 +1,3 @@ +import dotenv from "dotenv"; +dotenv.config(); +export const { TOKEN, CLIENT_ID, GUILD_ID, PRODUCTION_CHANNEL_NAME, DEV_CHANNEL_NAME, PRODUCTION_CHANNEL_ID, DEV_CHANNEL_ID, ETHEREUM_HOST, ETHEREUM_ROLLUP_ADDRESS, ETHEREUM_ADMIN_ADDRESS, ETHEREUM_CHAIN_ID, ETHEREUM_MNEMONIC, ETHEREUM_PRIVATE_KEY, ETHEREUM_VALUE, BOT_TOKEN, BOT_CLIENT_ID, ENVIRONMENT, } = process.env; diff --git a/tooling/sparta/dist/index.js b/tooling/sparta/dist/index.js new file mode 100644 index 0000000..90c6603 --- /dev/null +++ b/tooling/sparta/dist/index.js @@ -0,0 +1,46 @@ +import { Client, GatewayIntentBits, Collection, MessageFlags, } from "discord.js"; +import { deployCommands } from "./deploy-commands.js"; +import commands from "./commands/index.js"; +import { BOT_TOKEN, PRODUCTION_CHANNEL_ID, DEV_CHANNEL_ID, ENVIRONMENT, PRODUCTION_CHANNEL_NAME, DEV_CHANNEL_NAME, } from "./env.js"; +const client = new Client({ + intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages], +}); +client.commands = new Collection(); +for (const command of Object.values(commands)) { + client.commands.set(command.data.name, command); +} +client.once("ready", () => { + console.log("Sparta bot is ready!"); + deployCommands(); +}); +client.on("interactionCreate", async (interaction) => { + if (!interaction.isChatInputCommand()) + return; + // Determine which channel to use based on environment + const targetChannelId = ENVIRONMENT === "production" ? PRODUCTION_CHANNEL_ID : DEV_CHANNEL_ID; + // Check if the command is in the correct channel + if (interaction.channelId !== targetChannelId) { + const channelName = ENVIRONMENT === "production" + ? PRODUCTION_CHANNEL_NAME + : DEV_CHANNEL_NAME; + return interaction.reply({ + content: `This command can only be used in the ${channelName} channel.`, + flags: MessageFlags.Ephemeral, + }); + } + const command = client.commands.get(interaction.commandName); + if (!command) + return; + try { + console.log("Executing command:", command.data.name); + const response = await command.execute(interaction); + } + catch (error) { + console.error(error); + await interaction.reply({ + content: "There was an error executing this command!", + flags: MessageFlags.Ephemeral, + }); + } +}); +client.login(BOT_TOKEN); diff --git a/tooling/sparta/dist/services/chaininfo-service.js b/tooling/sparta/dist/services/chaininfo-service.js new file mode 100644 index 0000000..82b5007 --- /dev/null +++ b/tooling/sparta/dist/services/chaininfo-service.js @@ -0,0 +1,34 @@ +import { exec } from "child_process"; +import { promisify } from "util"; +import { ETHEREUM_HOST, ETHEREUM_ROLLUP_ADDRESS, ETHEREUM_CHAIN_ID, } from "../env.js"; +const execAsync = promisify(exec); +export class ChainInfoService { + static async getInfo() { + try { + // Add validator to the set + const command = `docker run --rm aztecprotocol/aztec:unhinged-unicorn debug-rollup -u ${ETHEREUM_HOST} --rollup ${ETHEREUM_ROLLUP_ADDRESS} --l1-chain-id ${ETHEREUM_CHAIN_ID} `; + const { stdout, stderr } = await execAsync(command); + if (stderr) { + throw new Error(stderr); + } + // looks like hell, but it just parses the output of the command + // into a key-value object + const info = stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .reduce((acc, s) => { + const [key, value] = s.split(": "); + const sanitizedKey = key + .toLowerCase() + .replace(/\s+(.)/g, (_, c) => c.toUpperCase()); + return { ...acc, [sanitizedKey]: value }; + }, {}); + return info; + } + catch (error) { + console.error("Error getting chain info:", error); + throw error; + } + } +} diff --git a/tooling/sparta/dist/services/validator-service.js b/tooling/sparta/dist/services/validator-service.js new file mode 100644 index 0000000..ebf33c2 --- /dev/null +++ b/tooling/sparta/dist/services/validator-service.js @@ -0,0 +1,37 @@ +import { exec } from "child_process"; +import { promisify } from "util"; +import { ETHEREUM_HOST, ETHEREUM_ROLLUP_ADDRESS, ETHEREUM_ADMIN_ADDRESS, ETHEREUM_CHAIN_ID, ETHEREUM_MNEMONIC, ETHEREUM_PRIVATE_KEY, ETHEREUM_VALUE, } from "../env.js"; +const execAsync = promisify(exec); +export class ValidatorService { + static async addValidator(address) { + try { + // Send ETH to the validator address + await this.fundValidator(address); + // Add validator to the set + const command = `docker run --rm aztecprotocol/aztec:unhinged-unicorn add-l1-validator -u ${ETHEREUM_HOST} --validator ${address} --rollup ${ETHEREUM_ROLLUP_ADDRESS} --withdrawer ${ETHEREUM_ADMIN_ADDRESS} --l1-chain-id ${ETHEREUM_CHAIN_ID} --mnemonic "${ETHEREUM_MNEMONIC}"`; + const { stdout, stderr } = await execAsync(command); + if (stderr) { + throw new Error(stderr); + } + return stdout; + } + catch (error) { + console.error("Error adding validator:", error); + throw error; + } + } + static async fundValidator(address) { + try { + const command = `cast send --value ${ETHEREUM_VALUE} --rpc-url ${ETHEREUM_HOST} --chain-id ${ETHEREUM_CHAIN_ID} --private-key ${ETHEREUM_PRIVATE_KEY} ${address}`; + const { stdout, stderr } = await execAsync(command); + if (stderr) { + throw new Error(stderr); + } + return stdout; + } + catch (error) { + console.error("Error funding validator:", error); + throw error; + } + } +} diff --git a/tooling/sparta/docker-compose.yml b/tooling/sparta/docker-compose.yml new file mode 100644 index 0000000..a8a70ee --- /dev/null +++ b/tooling/sparta/docker-compose.yml @@ -0,0 +1,7 @@ +name: Sparta +services: + sparta: + volumes: + - /var/run/docker.sock:/var/run/docker.sock + build: + context: . diff --git a/tooling/sparta/package.json b/tooling/sparta/package.json index 923056f..5d83d6b 100644 --- a/tooling/sparta/package.json +++ b/tooling/sparta/package.json @@ -4,9 +4,7 @@ "type": "module", "scripts": { "build": "tsc", - "start": "bun run src/index.ts", - "dev": "bun run --watch src/index.ts", - "deploy": "bun run src/deploy-commands.ts" + "start": "bun run src/index.ts" }, "dependencies": { "discord.js": "^14.14.1", @@ -15,7 +13,6 @@ "devDependencies": { "typescript": "^5.3.3", "@types/node": "^20.10.5", - "ts-node": "^10.9.2", - "bun-types": "latest" + "ts-node": "^10.9.2" } } diff --git a/tooling/sparta/src/commands/addValidator.ts b/tooling/sparta/src/commands/addValidator.ts index 21100ea..0be51f5 100644 --- a/tooling/sparta/src/commands/addValidator.ts +++ b/tooling/sparta/src/commands/addValidator.ts @@ -1,6 +1,6 @@ import { SlashCommandBuilder, ChatInputCommandInteraction } from "discord.js"; -import { ValidatorService } from "../services/validator-service"; -import { ChainInfoService } from "../services/chaininfo-service"; +import { ValidatorService } from "../services/validator-service.js"; +import { ChainInfoService } from "../services/chaininfo-service.js"; export default { data: new SlashCommandBuilder() diff --git a/tooling/sparta/src/commands/getChainInfo.ts b/tooling/sparta/src/commands/getChainInfo.ts index 5ca6ec3..1d83a6b 100644 --- a/tooling/sparta/src/commands/getChainInfo.ts +++ b/tooling/sparta/src/commands/getChainInfo.ts @@ -1,5 +1,5 @@ import { SlashCommandBuilder, ChatInputCommandInteraction } from "discord.js"; -import { ChainInfoService } from "../services/chaininfo-service"; +import { ChainInfoService } from "../services/chaininfo-service.js"; export default { data: new SlashCommandBuilder() diff --git a/tooling/sparta/src/commands/index.ts b/tooling/sparta/src/commands/index.ts index 911efd3..bf543c4 100644 --- a/tooling/sparta/src/commands/index.ts +++ b/tooling/sparta/src/commands/index.ts @@ -1,7 +1,7 @@ -import address from "./addValidator"; -import chainInfo from "./getChainInfo"; +import addValidator from "./addValidator.js"; +import getChainInfo from "./getChainInfo.js"; export default { - address, - chainInfo, + addValidator, + getChainInfo, }; diff --git a/tooling/sparta/src/index.ts b/tooling/sparta/src/index.ts index 1de8237..8d3b171 100644 --- a/tooling/sparta/src/index.ts +++ b/tooling/sparta/src/index.ts @@ -5,7 +5,7 @@ import { Interaction, MessageFlags, } from "discord.js"; -import { deployCommands } from "./deploy-commands"; +import { deployCommands } from "./deploy-commands.js"; import commands from "./commands/index.js"; import { BOT_TOKEN, @@ -58,40 +58,9 @@ client.on("interactionCreate", async (interaction: Interaction) => { const command = client.commands.get(interaction.commandName); if (!command) return; - console.log(JSON.stringify(command.execute, null, 2)); - try { console.log("Executing command:", command.data.name); const response = await command.execute(interaction); - - if (typeof response === "string") { - const [ - pendingBlock, - provenBlock, - validators, - committee, - archive, - currentEpoch, - currentSlot, - proposerPrevious, - proposerNow, - ] = response - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); - - console.log({ - pendingBlock, - provenBlock, - validators, - committee, - archive, - currentEpoch, - currentSlot, - proposerPrevious, - proposerNow, - }); - } } catch (error) { console.error(error); await interaction.reply({ diff --git a/tooling/sparta/src/services/chaininfo-service.ts b/tooling/sparta/src/services/chaininfo-service.ts index fe8bec9..3b5c9c4 100644 --- a/tooling/sparta/src/services/chaininfo-service.ts +++ b/tooling/sparta/src/services/chaininfo-service.ts @@ -24,8 +24,6 @@ export class ChainInfoService { try { // Add validator to the set const command = `docker run --rm aztecprotocol/aztec:unhinged-unicorn debug-rollup -u ${ETHEREUM_HOST} --rollup ${ETHEREUM_ROLLUP_ADDRESS} --l1-chain-id ${ETHEREUM_CHAIN_ID} `; - - console.log("Running command:", command); const { stdout, stderr } = await execAsync(command); if (stderr) { diff --git a/tooling/sparta/src/services/validator-service.ts b/tooling/sparta/src/services/validator-service.ts index 9efad16..1c9db9b 100644 --- a/tooling/sparta/src/services/validator-service.ts +++ b/tooling/sparta/src/services/validator-service.ts @@ -9,7 +9,6 @@ import { ETHEREUM_PRIVATE_KEY, ETHEREUM_VALUE, } from "../env.js"; -import { ChainInfoService } from "./chaininfo-service.js"; const execAsync = promisify(exec); diff --git a/tooling/sparta/tsconfig.json b/tooling/sparta/tsconfig.json index 0c1b8fc..8c2b766 100644 --- a/tooling/sparta/tsconfig.json +++ b/tooling/sparta/tsconfig.json @@ -1,14 +1,14 @@ { "compilerOptions": { "target": "ES2020", - "module": "commonjs", + "module": "NodeNext", + "moduleResolution": "nodenext", "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true + "forceConsistentCasingInFileNames": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] From 97f8127da4ad2a8ab6cac586174bdc4548791291 Mon Sep 17 00:00:00 2001 From: signorecello Date: Thu, 16 Jan 2025 18:12:57 +0000 Subject: [PATCH 03/13] no no dont stop until i tell you so --- tooling/sparta/docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/tooling/sparta/docker-compose.yml b/tooling/sparta/docker-compose.yml index a8a70ee..a086b63 100644 --- a/tooling/sparta/docker-compose.yml +++ b/tooling/sparta/docker-compose.yml @@ -5,3 +5,4 @@ services: - /var/run/docker.sock:/var/run/docker.sock build: context: . + restart: unless-stopped From 79658ed95c145062828ee125193f611d1f52fc49 Mon Sep 17 00:00:00 2001 From: signorecello Date: Thu, 16 Jan 2025 18:32:26 +0000 Subject: [PATCH 04/13] hiding private queries on whether i'm a validator or not --- tooling/sparta/src/commands/addValidator.ts | 10 +++++++--- tooling/sparta/src/index.ts | 22 ++++++++------------- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/tooling/sparta/src/commands/addValidator.ts b/tooling/sparta/src/commands/addValidator.ts index 0be51f5..65110f0 100644 --- a/tooling/sparta/src/commands/addValidator.ts +++ b/tooling/sparta/src/commands/addValidator.ts @@ -1,4 +1,8 @@ -import { SlashCommandBuilder, ChatInputCommandInteraction } from "discord.js"; +import { + SlashCommandBuilder, + ChatInputCommandInteraction, + MessageFlags, +} from "discord.js"; import { ValidatorService } from "../services/validator-service.js"; import { ChainInfoService } from "../services/chaininfo-service.js"; @@ -40,11 +44,11 @@ export default { if (!address.match(/^0x[a-fA-F0-9]{40}$/)) { return interaction.reply({ content: "Please provide a valid Ethereum address.", - ephemeral: true, + flags: MessageFlags.Ephemeral, }); } - await interaction.deferReply(); + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); if (interaction.options.getSubcommand() === "add") { try { diff --git a/tooling/sparta/src/index.ts b/tooling/sparta/src/index.ts index 8d3b171..85492ec 100644 --- a/tooling/sparta/src/index.ts +++ b/tooling/sparta/src/index.ts @@ -39,20 +39,14 @@ client.once("ready", () => { client.on("interactionCreate", async (interaction: Interaction) => { if (!interaction.isChatInputCommand()) return; - // Determine which channel to use based on environment - const targetChannelId = - ENVIRONMENT === "production" ? PRODUCTION_CHANNEL_ID : DEV_CHANNEL_ID; - - // Check if the command is in the correct channel - if (interaction.channelId !== targetChannelId) { - const channelName = - ENVIRONMENT === "production" - ? PRODUCTION_CHANNEL_NAME - : DEV_CHANNEL_NAME; - return interaction.reply({ - content: `This command can only be used in the ${channelName} channel.`, - flags: MessageFlags.Ephemeral, - }); + if ( + ENVIRONMENT === "development" && + interaction.channelId === PRODUCTION_CHANNEL_ID + ) { + console.log( + "Can't use this command in production if ENVIRONMENT is set to development" + ); + return; } const command = client.commands.get(interaction.commandName); From 2b773150399064b14a506d71c4160aa28dfede57 Mon Sep 17 00:00:00 2001 From: signorecello Date: Thu, 16 Jan 2025 18:59:15 +0000 Subject: [PATCH 05/13] sparta wants a foundry --- tooling/sparta/Dockerfile | 8 +++++++- tooling/sparta/src/index.ts | 10 ++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/tooling/sparta/Dockerfile b/tooling/sparta/Dockerfile index 827ef12..93014a3 100644 --- a/tooling/sparta/Dockerfile +++ b/tooling/sparta/Dockerfile @@ -1,7 +1,13 @@ FROM oven/bun:latest -RUN apt update && apt install -y curl +ENV PATH="/root/.foundry/bin:${PATH}" + +RUN apt update && apt install -y curl apt-utils RUN curl -fsSL https://get.docker.com | bash +RUN curl -L https://foundry.paradigm.xyz | bash + +RUN foundryup +RUN cast --version WORKDIR /app COPY package.json ./ diff --git a/tooling/sparta/src/index.ts b/tooling/sparta/src/index.ts index 85492ec..bf7961b 100644 --- a/tooling/sparta/src/index.ts +++ b/tooling/sparta/src/index.ts @@ -49,6 +49,16 @@ client.on("interactionCreate", async (interaction: Interaction) => { return; } + if ( + ENVIRONMENT === "production" && + interaction.channelId !== DEV_CHANNEL_ID + ) { + console.log( + "Can't use this command in development if ENVIRONMENT is set to production" + ); + return; + } + const command = client.commands.get(interaction.commandName); if (!command) return; From 052f194db16181425e81184501713066391bc6fe Mon Sep 17 00:00:00 2001 From: signorecello Date: Thu, 16 Jan 2025 19:09:08 +0000 Subject: [PATCH 06/13] some flags to make local dev only answer to the test channel and vice versa --- tooling/sparta/.env.example | 6 +----- tooling/sparta/src/env.ts | 9 +++------ tooling/sparta/src/index.ts | 31 ++++++++++++------------------- 3 files changed, 16 insertions(+), 30 deletions(-) diff --git a/tooling/sparta/.env.example b/tooling/sparta/.env.example index ffd09bf..62ed7fc 100644 --- a/tooling/sparta/.env.example +++ b/tooling/sparta/.env.example @@ -1,10 +1,6 @@ ENVIRONMENT= - DEV_CHANNEL_ID= -DEV_CHANNEL_NAME= - -PRODUCTION_CHANNEL_ID= -PRODUCTION_CHANNEL_NAME= +PROD_CHANNEL_ID= BOT_TOKEN= BOT_CLIENT_ID= diff --git a/tooling/sparta/src/env.ts b/tooling/sparta/src/env.ts index 345ad2d..869d712 100644 --- a/tooling/sparta/src/env.ts +++ b/tooling/sparta/src/env.ts @@ -5,9 +5,7 @@ export const { TOKEN, CLIENT_ID, GUILD_ID, - PRODUCTION_CHANNEL_NAME, - DEV_CHANNEL_NAME, - PRODUCTION_CHANNEL_ID, + PROD_CHANNEL_ID, DEV_CHANNEL_ID, ETHEREUM_HOST, ETHEREUM_ROLLUP_ADDRESS, @@ -23,8 +21,8 @@ export const { TOKEN: string; CLIENT_ID: string; GUILD_ID: string; - PRODUCTION_CHANNEL_NAME: string; - DEV_CHANNEL_NAME: string; + PROD_CHANNEL_ID: string; + DEV_CHANNEL_ID: string; ETHEREUM_HOST: string; ETHEREUM_ROLLUP_ADDRESS: string; ETHEREUM_ADMIN_ADDRESS: string; @@ -34,7 +32,6 @@ export const { ETHEREUM_VALUE: string; BOT_TOKEN: string; PRODUCTION_CHANNEL_ID: string; - DEV_CHANNEL_ID: string; BOT_CLIENT_ID: string; ENVIRONMENT: string; }; diff --git a/tooling/sparta/src/index.ts b/tooling/sparta/src/index.ts index bf7961b..9d21d39 100644 --- a/tooling/sparta/src/index.ts +++ b/tooling/sparta/src/index.ts @@ -9,11 +9,9 @@ import { deployCommands } from "./deploy-commands.js"; import commands from "./commands/index.js"; import { BOT_TOKEN, - PRODUCTION_CHANNEL_ID, DEV_CHANNEL_ID, ENVIRONMENT, - PRODUCTION_CHANNEL_NAME, - DEV_CHANNEL_NAME, + PROD_CHANNEL_ID, } from "./env.js"; // Extend the Client class to include the commands property @@ -39,23 +37,18 @@ client.once("ready", () => { client.on("interactionCreate", async (interaction: Interaction) => { if (!interaction.isChatInputCommand()) return; - if ( - ENVIRONMENT === "development" && - interaction.channelId === PRODUCTION_CHANNEL_ID - ) { - console.log( - "Can't use this command in production if ENVIRONMENT is set to development" - ); + const { channelId } = interaction; + if (ENVIRONMENT === "production" && channelId !== PROD_CHANNEL_ID) { + await interaction.reply({ + content: "This command can only be used in the production channel", + flags: MessageFlags.Ephemeral, + }); return; - } - - if ( - ENVIRONMENT === "production" && - interaction.channelId !== DEV_CHANNEL_ID - ) { - console.log( - "Can't use this command in development if ENVIRONMENT is set to production" - ); + } else if (ENVIRONMENT === "development" && channelId !== DEV_CHANNEL_ID) { + await interaction.reply({ + content: "This command can only be used in the development channel", + flags: MessageFlags.Ephemeral, + }); return; } From a31fe9dfdfe6f148a8acd86a0a69dd6af1deb982 Mon Sep 17 00:00:00 2001 From: signorecello Date: Fri, 17 Jan 2025 11:19:23 +0000 Subject: [PATCH 07/13] making chain info replies ephemeral --- tooling/sparta/src/commands/addValidator.ts | 2 +- tooling/sparta/src/commands/getChainInfo.ts | 10 ++++++++-- tooling/sparta/src/index.ts | 16 +++++----------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/tooling/sparta/src/commands/addValidator.ts b/tooling/sparta/src/commands/addValidator.ts index 65110f0..8c1385a 100644 --- a/tooling/sparta/src/commands/addValidator.ts +++ b/tooling/sparta/src/commands/addValidator.ts @@ -36,7 +36,7 @@ export default { if (!address) { return interaction.reply({ content: "Address is required.", - ephemeral: true, + flags: MessageFlags.Ephemeral, }); } diff --git a/tooling/sparta/src/commands/getChainInfo.ts b/tooling/sparta/src/commands/getChainInfo.ts index 1d83a6b..8bc801b 100644 --- a/tooling/sparta/src/commands/getChainInfo.ts +++ b/tooling/sparta/src/commands/getChainInfo.ts @@ -1,4 +1,8 @@ -import { SlashCommandBuilder, ChatInputCommandInteraction } from "discord.js"; +import { + SlashCommandBuilder, + ChatInputCommandInteraction, + MessageFlags, +} from "discord.js"; import { ChainInfoService } from "../services/chaininfo-service.js"; export default { @@ -7,7 +11,9 @@ export default { .setDescription("Get chain info"), execute: async (interaction: ChatInputCommandInteraction) => { - await interaction.deferReply(); + await interaction.deferReply({ + flags: MessageFlags.Ephemeral, + }); try { const { diff --git a/tooling/sparta/src/index.ts b/tooling/sparta/src/index.ts index 9d21d39..11fe2d0 100644 --- a/tooling/sparta/src/index.ts +++ b/tooling/sparta/src/index.ts @@ -38,17 +38,11 @@ client.on("interactionCreate", async (interaction: Interaction) => { if (!interaction.isChatInputCommand()) return; const { channelId } = interaction; - if (ENVIRONMENT === "production" && channelId !== PROD_CHANNEL_ID) { - await interaction.reply({ - content: "This command can only be used in the production channel", - flags: MessageFlags.Ephemeral, - }); - return; - } else if (ENVIRONMENT === "development" && channelId !== DEV_CHANNEL_ID) { - await interaction.reply({ - content: "This command can only be used in the development channel", - flags: MessageFlags.Ephemeral, - }); + if ( + (ENVIRONMENT === "production" && channelId !== PROD_CHANNEL_ID) || + (ENVIRONMENT === "development" && channelId !== DEV_CHANNEL_ID) + ) { + console.log(`Ignoring interaction in channel ${channelId}`); return; } From b1b515d0db2e791a759b3dbfec851eeb45dbd526 Mon Sep 17 00:00:00 2001 From: signorecello Date: Fri, 17 Jan 2025 12:23:20 +0000 Subject: [PATCH 08/13] some AdMIN commands for AMIN --- tooling/sparta/package.json | 3 +- tooling/sparta/src/commands/getAdminInfo.ts | 59 +++++++++++++++++++ tooling/sparta/src/commands/index.ts | 6 +- .../sparta/src/services/chaininfo-service.ts | 19 +++--- tooling/sparta/src/utils/pagination.ts | 33 +++++++++++ 5 files changed, 108 insertions(+), 12 deletions(-) create mode 100644 tooling/sparta/src/commands/getAdminInfo.ts create mode 100644 tooling/sparta/src/utils/pagination.ts diff --git a/tooling/sparta/package.json b/tooling/sparta/package.json index 5d83d6b..edd7f08 100644 --- a/tooling/sparta/package.json +++ b/tooling/sparta/package.json @@ -4,7 +4,8 @@ "type": "module", "scripts": { "build": "tsc", - "start": "bun run src/index.ts" + "start": "bun run src/index.ts", + "dev": "bun run --watch src/index.ts" }, "dependencies": { "discord.js": "^14.14.1", diff --git a/tooling/sparta/src/commands/getAdminInfo.ts b/tooling/sparta/src/commands/getAdminInfo.ts new file mode 100644 index 0000000..68ef282 --- /dev/null +++ b/tooling/sparta/src/commands/getAdminInfo.ts @@ -0,0 +1,59 @@ +import { + SlashCommandBuilder, + ChatInputCommandInteraction, + MessageFlags, + PermissionFlagsBits, +} from "discord.js"; +import { ChainInfoService } from "../services/chaininfo-service.js"; +import { paginate } from "../utils/pagination.js"; + +export default { + data: new SlashCommandBuilder() + .setName("admin-info") + .setDescription("Get admin info about the chain ") + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .addSubcommand((subcommand) => + subcommand + .setName("validators") + .setDescription("Get the current list of validators") + ) + .addSubcommand((subcommand) => + subcommand + .setName("committee") + .setDescription("Get the current committee") + ), + + execute: async (interaction: ChatInputCommandInteraction) => { + await interaction.deferReply({ + flags: MessageFlags.Ephemeral, + }); + + try { + const addressesPerPage = 40; + + const { validators, committee } = await ChainInfoService.getInfo(); + if (interaction.options.getSubcommand() === "committee") { + await paginate( + committee as string[], + addressesPerPage, + interaction, + "Committee" + ); + } else if (interaction.options.getSubcommand() === "validators") { + await paginate( + validators as string[], + addressesPerPage, + interaction, + "Validators" + ); + + return; + } + } catch (error) { + console.error("Error in get-info command:", error); + await interaction.editReply({ + content: `Failed to get chain info`, + }); + } + }, +}; diff --git a/tooling/sparta/src/commands/index.ts b/tooling/sparta/src/commands/index.ts index bf543c4..186909d 100644 --- a/tooling/sparta/src/commands/index.ts +++ b/tooling/sparta/src/commands/index.ts @@ -1,7 +1,5 @@ import addValidator from "./addValidator.js"; import getChainInfo from "./getChainInfo.js"; +import getAdminInfo from "./getAdminInfo.js"; -export default { - addValidator, - getChainInfo, -}; +export default { addValidator, getChainInfo, getAdminInfo }; diff --git a/tooling/sparta/src/services/chaininfo-service.ts b/tooling/sparta/src/services/chaininfo-service.ts index 3b5c9c4..607a953 100644 --- a/tooling/sparta/src/services/chaininfo-service.ts +++ b/tooling/sparta/src/services/chaininfo-service.ts @@ -6,12 +6,12 @@ import { ETHEREUM_CHAIN_ID, } from "../env.js"; -type ChainInfo = { +type ChainInfo<> = { pendingBlockNum: string; provenBlockNum: string; - validators: string[]; - committee: string[]; - archive: string[]; + validators: string | string[]; + committee: string | string[]; + archive: string | string[]; currentEpoch: string; currentSlot: string; proposerNow: string; @@ -37,13 +37,18 @@ export class ChainInfoService { .map((line) => line.trim()) .filter(Boolean) .reduce((acc, s) => { - const [key, value] = s.split(": "); + let [key, value] = s.split(": "); const sanitizedKey = key .toLowerCase() .replace(/\s+(.)/g, (_, c) => c.toUpperCase()); return { ...acc, [sanitizedKey]: value }; - }, {}); - + }, {}) as ChainInfo; + if (typeof info.validators === "string") { + info.validators = info.validators.split(", ").map(String); + } + if (typeof info.committee === "string") { + info.committee = info.committee.split(", ").map(String); + } return info as ChainInfo; } catch (error) { console.error("Error getting chain info:", error); diff --git a/tooling/sparta/src/utils/pagination.ts b/tooling/sparta/src/utils/pagination.ts new file mode 100644 index 0000000..1875f2f --- /dev/null +++ b/tooling/sparta/src/utils/pagination.ts @@ -0,0 +1,33 @@ +import { MessageFlags } from "discord.js"; + +import { ChatInputCommandInteraction } from "discord.js"; + +export const paginate = async ( + array: string[], + perMessage: number, + interaction: ChatInputCommandInteraction, + message: string +) => { + const numMessages = Math.ceil(array.length / perMessage); + + for (let i = 0; i < numMessages; i++) { + const start = i * perMessage; + const end = Math.min(start + perMessage, array.length); + const validatorSlice = array.slice(start, end) as string[]; + + if (i === 0) { + await interaction.editReply({ + content: `${message} (${start + 1}-${end} of ${ + array.length + }):\n${validatorSlice.join("\n")}`, + }); + } else { + await interaction.followUp({ + content: `${message} (${start + 1}-${end} of ${ + array.length + }):\n${validatorSlice.join("\n")}`, + flags: MessageFlags.Ephemeral, + }); + } + } +}; From 0b59556f508f50cc13152221dd6f243c7551d4cb Mon Sep 17 00:00:00 2001 From: signorecello Date: Fri, 17 Jan 2025 12:41:01 +0000 Subject: [PATCH 09/13] excluding excluding --- tooling/sparta/src/commands/getAdminInfo.ts | 64 ++++++++++++++++++- .../sparta/src/services/chaininfo-service.ts | 2 +- tooling/sparta/src/utils/pagination.ts | 13 ++-- 3 files changed, 70 insertions(+), 9 deletions(-) diff --git a/tooling/sparta/src/commands/getAdminInfo.ts b/tooling/sparta/src/commands/getAdminInfo.ts index 68ef282..319d700 100644 --- a/tooling/sparta/src/commands/getAdminInfo.ts +++ b/tooling/sparta/src/commands/getAdminInfo.ts @@ -7,6 +7,57 @@ import { import { ChainInfoService } from "../services/chaininfo-service.js"; import { paginate } from "../utils/pagination.js"; +export const EXCLUDED_VALIDATORS = [ + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x90F79bf6EB2c4f870365E785982E1f101E93b906", + "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65", + "0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc", + "0x976EA74026E726554dB657fA54763abd0C3a0aa9", + "0x14dC79964da2C08b23698B3D3cc7Ca32193d9955", + "0x23618e81E3f5cdF7f54C3d65f7FBc0aBf5B21E8f", + "0xa0Ee7A142d267C1f36714E4a8F75612F20a79720", + "0xBcd4042DE499D14e55001CcbB24a551F3b954096", + "0x71bE63f3384f5fb98995898A86B02Fb2426c5788", + "0xFABB0ac9d68B0B445fB7357272Ff202C5651694a", + "0x1CBd3b2770909D4e10f157cABC84C7264073C9Ec", + "0xdF3e18d64BC6A983f673Ab319CCaE4f1a57C7097", + "0xcd3B766CCDd6AE721141F452C550Ca635964ce71", + "0x2546BcD3c84621e976D8185a91A922aE77ECEc30", + "0xbDA5747bFD65F08deb54cb465eB87D40e51B197E", + "0xdD2FD4581271e230360230F9337D5c0430Bf44C0", + "0x8626f6940E2eb28930eFb4CeF49B2d1F2C9C1199", + "0x09DB0a93B389bEF724429898f539AEB7ac2Dd55f", + "0x02484cb50AAC86Eae85610D6f4Bf026f30f6627D", + "0x08135Da0A343E492FA2d4282F2AE34c6c5CC1BbE", + "0x5E661B79FE2D3F6cE70F5AAC07d8Cd9abb2743F1", + "0x61097BA76cD906d2ba4FD106E757f7Eb455fc295", + "0xDf37F81dAAD2b0327A0A50003740e1C935C70913", + "0x553BC17A05702530097c3677091C5BB47a3a7931", + "0x87BdCE72c06C21cd96219BD8521bDF1F42C78b5e", + "0x40Fc963A729c542424cD800349a7E4Ecc4896624", + "0x9DCCe783B6464611f38631e6C851bf441907c710", + "0x1BcB8e569EedAb4668e55145Cfeaf190902d3CF2", + "0x8263Fce86B1b78F95Ab4dae11907d8AF88f841e7", + "0xcF2d5b3cBb4D7bF04e3F7bFa8e27081B52191f91", + "0x86c53Eb85D0B7548fea5C4B4F82b4205C8f6Ac18", + "0x1aac82773CB722166D7dA0d5b0FA35B0307dD99D", + "0x2f4f06d218E426344CFE1A83D53dAd806994D325", + "0x1003ff39d25F2Ab16dBCc18EcE05a9B6154f65F4", + "0x9eAF5590f2c84912A08de97FA28d0529361Deb9E", + "0x11e8F3eA3C6FcF12EcfF2722d75CEFC539c51a1C", + "0x7D86687F980A56b832e9378952B738b614A99dc6", + "0x9eF6c02FB2ECc446146E05F1fF687a788a8BF76d", + "0x08A2DE6F3528319123b25935C92888B16db8913E", + "0xe141C82D99D85098e03E1a1cC1CdE676556fDdE0", + "0x4b23D303D9e3719D6CDf8d172Ea030F80509ea15", + "0xC004e69C5C04A223463Ff32042dd36DabF63A25a", + "0x5eb15C0992734B5e77c888D713b4FC67b3D679A2", + "0x7Ebb637fd68c523613bE51aad27C35C4DB199B9c", + "0x3c3E2E178C69D4baD964568415a0f0c84fd6320A", +]; + export default { data: new SlashCommandBuilder() .setName("admin-info") @@ -32,16 +83,25 @@ export default { const addressesPerPage = 40; const { validators, committee } = await ChainInfoService.getInfo(); + const filteredValidators = (validators as string[]).filter( + (v) => !EXCLUDED_VALIDATORS.includes(v) + ); + const filteredCommittee = (committee as string[]).filter( + (v) => !EXCLUDED_VALIDATORS.includes(v) + ); + if (interaction.options.getSubcommand() === "committee") { await paginate( - committee as string[], + filteredCommittee, + committee.length, addressesPerPage, interaction, "Committee" ); } else if (interaction.options.getSubcommand() === "validators") { await paginate( - validators as string[], + filteredValidators, + validators.length, addressesPerPage, interaction, "Validators" diff --git a/tooling/sparta/src/services/chaininfo-service.ts b/tooling/sparta/src/services/chaininfo-service.ts index 607a953..dde1e90 100644 --- a/tooling/sparta/src/services/chaininfo-service.ts +++ b/tooling/sparta/src/services/chaininfo-service.ts @@ -6,7 +6,7 @@ import { ETHEREUM_CHAIN_ID, } from "../env.js"; -type ChainInfo<> = { +type ChainInfo = { pendingBlockNum: string; provenBlockNum: string; validators: string | string[]; diff --git a/tooling/sparta/src/utils/pagination.ts b/tooling/sparta/src/utils/pagination.ts index 1875f2f..166030e 100644 --- a/tooling/sparta/src/utils/pagination.ts +++ b/tooling/sparta/src/utils/pagination.ts @@ -4,6 +4,7 @@ import { ChatInputCommandInteraction } from "discord.js"; export const paginate = async ( array: string[], + total: number, perMessage: number, interaction: ChatInputCommandInteraction, message: string @@ -17,15 +18,15 @@ export const paginate = async ( if (i === 0) { await interaction.editReply({ - content: `${message} (${start + 1}-${end} of ${ - array.length - }):\n${validatorSlice.join("\n")}`, + content: `${message} total: ${total}.\n${message} (excl. Aztec Labs) (${ + start + 1 + }-${end} of ${array.length}):\n${validatorSlice.join("\n")}`, }); } else { await interaction.followUp({ - content: `${message} (${start + 1}-${end} of ${ - array.length - }):\n${validatorSlice.join("\n")}`, + content: `${message} total: ${total}.\n${message} (excl. Aztec Labs) (${ + start + 1 + }-${end} of ${array.length}):\n${validatorSlice.join("\n")}`, flags: MessageFlags.Ephemeral, }); } From b3fd0197bc80702bec66d31b29aa0576b4f6ca1d Mon Sep 17 00:00:00 2001 From: signorecello Date: Mon, 20 Jan 2025 17:11:07 +0000 Subject: [PATCH 10/13] trying a different approach (serverless), tests pass! --- tooling/sparta-aws/Dockerfile | 25 +++ tooling/sparta-aws/README.md | 89 ++++++++ tooling/sparta-aws/src/.env.example | 37 ++++ tooling/sparta-aws/src/.gitignore | 175 +++++++++++++++ tooling/sparta-aws/src/README.md | 15 ++ tooling/sparta-aws/src/bun.lockb | Bin 0 -> 257107 bytes .../src/commands/__tests__/commands.test.ts | 203 ++++++++++++++++++ .../sparta-aws/src/commands/addValidator.ts | 45 ++++ .../src/commands/adminValidators.ts | 135 ++++++++++++ .../sparta-aws/src/commands/getChainInfo.ts | 37 ++++ tooling/sparta-aws/src/commands/index.ts | 10 + tooling/sparta-aws/src/index.ts | 117 ++++++++++ tooling/sparta-aws/src/local-dev.ts | 116 ++++++++++ tooling/sparta-aws/src/package.json | 44 ++++ .../src/scripts/register-commands.ts | 48 +++++ .../src/services/chaininfo-service.ts | 86 ++++++++ .../src/services/validator-service.ts | 151 +++++++++++++ tooling/sparta-aws/src/tsconfig.json | 34 +++ tooling/sparta-aws/src/types/discord.ts | 80 +++++++ .../sparta-aws/src/utils/discord-verify.ts | 25 +++ .../sparta-aws/src/utils/parameter-store.ts | 60 ++++++ tooling/sparta-aws/terraform/main.tf | 202 +++++++++++++++++ tooling/sparta-aws/terraform/outputs.tf | 24 +++ .../sparta-aws/terraform/task-definition.json | 62 ++++++ tooling/sparta-aws/terraform/variables.tf | 34 +++ tooling/sparta/src/admins/index.ts | 3 + .../getAdminInfo.ts => admins/validators.ts} | 51 ++++- tooling/sparta/src/commands/index.ts | 3 +- tooling/sparta/src/deploy-commands.ts | 10 +- tooling/sparta/src/index.ts | 5 +- .../sparta/src/services/validator-service.ts | 18 ++ 31 files changed, 1926 insertions(+), 18 deletions(-) create mode 100644 tooling/sparta-aws/Dockerfile create mode 100644 tooling/sparta-aws/README.md create mode 100644 tooling/sparta-aws/src/.env.example create mode 100644 tooling/sparta-aws/src/.gitignore create mode 100644 tooling/sparta-aws/src/README.md create mode 100755 tooling/sparta-aws/src/bun.lockb create mode 100644 tooling/sparta-aws/src/commands/__tests__/commands.test.ts create mode 100644 tooling/sparta-aws/src/commands/addValidator.ts create mode 100644 tooling/sparta-aws/src/commands/adminValidators.ts create mode 100644 tooling/sparta-aws/src/commands/getChainInfo.ts create mode 100644 tooling/sparta-aws/src/commands/index.ts create mode 100644 tooling/sparta-aws/src/index.ts create mode 100644 tooling/sparta-aws/src/local-dev.ts create mode 100644 tooling/sparta-aws/src/package.json create mode 100644 tooling/sparta-aws/src/scripts/register-commands.ts create mode 100644 tooling/sparta-aws/src/services/chaininfo-service.ts create mode 100644 tooling/sparta-aws/src/services/validator-service.ts create mode 100644 tooling/sparta-aws/src/tsconfig.json create mode 100644 tooling/sparta-aws/src/types/discord.ts create mode 100644 tooling/sparta-aws/src/utils/discord-verify.ts create mode 100644 tooling/sparta-aws/src/utils/parameter-store.ts create mode 100644 tooling/sparta-aws/terraform/main.tf create mode 100644 tooling/sparta-aws/terraform/outputs.tf create mode 100644 tooling/sparta-aws/terraform/task-definition.json create mode 100644 tooling/sparta-aws/terraform/variables.tf create mode 100644 tooling/sparta/src/admins/index.ts rename tooling/sparta/src/{commands/getAdminInfo.ts => admins/validators.ts} (75%) diff --git a/tooling/sparta-aws/Dockerfile b/tooling/sparta-aws/Dockerfile new file mode 100644 index 0000000..982fced --- /dev/null +++ b/tooling/sparta-aws/Dockerfile @@ -0,0 +1,25 @@ +# Use a slim base image +FROM node:18-slim + +# Install system dependencies +RUN apt-get update && \ + apt-get install -y curl && \ + rm -rf /var/lib/apt/lists/* + +# Set working directory +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci --only=production + +# Copy application files +COPY dist/ ./dist/ + +# Set environment variables +ENV NODE_ENV=production + +# Command will be provided by ECS task definition +CMD ["node", "dist/index.js"] diff --git a/tooling/sparta-aws/README.md b/tooling/sparta-aws/README.md new file mode 100644 index 0000000..96967b1 --- /dev/null +++ b/tooling/sparta-aws/README.md @@ -0,0 +1,89 @@ +# Sparta AWS Infrastructure + +This is the AWS infrastructure version of the Sparta Discord bot for managing validators. It uses serverless architecture with AWS Lambda, API Gateway, and other AWS services. + +## Architecture + +- AWS Lambda for the Discord bot logic +- API Gateway for Discord webhook endpoints +- DynamoDB for state management (if needed) +- CloudWatch for logging and monitoring +- ECR for Docker container storage +- Parameter Store for secrets +- CloudWatch Alarms for monitoring + +## Prerequisites + +1. AWS CLI installed and configured +2. Terraform installed +3. Node.js 18.x or later +4. Discord bot token and application ID +5. Required environment variables (see below) + +## Setup + +1. Create a `.env` file based on `.env.example` +2. Deploy the infrastructure: + ```bash + cd terraform + terraform init + terraform plan + terraform apply + ``` +3. Deploy the Lambda function: + ```bash + cd src + npm install + npm run build + npm run deploy + ``` + +## Environment Variables + +Required environment variables in AWS Parameter Store: + +- `/sparta/discord/bot_token` +- `/sparta/discord/client_id` +- `/sparta/discord/guild_id` +- `/sparta/discord/prod_channel_id` +- `/sparta/discord/dev_channel_id` +- `/sparta/ethereum/host` +- `/sparta/ethereum/rollup_address` +- `/sparta/ethereum/admin_address` +- `/sparta/ethereum/chain_id` +- `/sparta/ethereum/mnemonic` +- `/sparta/ethereum/private_key` +- `/sparta/ethereum/value` + +## Architecture Details + +### Lambda Function +The main bot logic runs in a Lambda function, triggered by API Gateway when Discord sends webhook events. + +### API Gateway +Handles incoming webhook requests from Discord and routes them to the appropriate Lambda function. + +### CloudWatch +All Lambda function logs are automatically sent to CloudWatch Logs. CloudWatch Alarms monitor for errors and latency. + +### Parameter Store +Stores all sensitive configuration values securely. + +### ECR +Stores the Docker container used for validator operations (implementation pending). + +## Monitoring + +CloudWatch dashboards and alarms are set up to monitor: +- Lambda function errors +- API Gateway latency +- Lambda function duration +- Lambda function throttles +- API Gateway 4xx/5xx errors + +## Security + +- All secrets are stored in AWS Parameter Store +- IAM roles follow least privilege principle +- API Gateway endpoints are secured with Discord signature verification +- Network access is restricted where possible diff --git a/tooling/sparta-aws/src/.env.example b/tooling/sparta-aws/src/.env.example new file mode 100644 index 0000000..4382f80 --- /dev/null +++ b/tooling/sparta-aws/src/.env.example @@ -0,0 +1,37 @@ +# Environment (development or production) +ENVIRONMENT=development + +# Discord Bot Configuration +# For development (sparta-dev), use these values locally +# For production (sparta), these values should be set in CI/CD pipeline +DISCORD_BOT_TOKEN= +DISCORD_CLIENT_ID= +DISCORD_PUBLIC_KEY= +DISCORD_GUILD_ID= +DISCORD_CHANNEL_ID=1329081299490570296 # Dev channel: bot-test +# Production channel reference: 1302946562745438238 + +# Ethereum Configuration +ETHEREUM_HOST=http://localhost:8545 +ETHEREUM_MNEMONIC= +ETHEREUM_PRIVATE_KEY= +ETHEREUM_ROLLUP_ADDRESS= +ETHEREUM_CHAIN_ID=1337 +ETHEREUM_VALUE=20ether +ETHEREUM_ADMIN_ADDRESS= + +# AWS Configuration +AWS_REGION=us-east-1 +AWS_ACCOUNT_ID= + +# For local development +# Note: Install ngrok globally with: npm install -g ngrok +# Then authenticate with: ngrok config add-authtoken YOUR_AUTH_TOKEN +ENDPOINT_URL=https://your-domain.ngrok-free.app/discord-webhook + +# ECS Configuration +ECS_CLUSTER_ARN=your_cluster_arn_here +ECS_TASK_DEFINITION=your_task_definition_here +ECS_CONTAINER_NAME=your_container_name_here +ECS_SUBNET_IDS=subnet-xxxxx,subnet-yyyyy +ECS_SECURITY_GROUP_IDS=sg-xxxxx,sg-yyyyy diff --git a/tooling/sparta-aws/src/.gitignore b/tooling/sparta-aws/src/.gitignore new file mode 100644 index 0000000..9b1ee42 --- /dev/null +++ b/tooling/sparta-aws/src/.gitignore @@ -0,0 +1,175 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/tooling/sparta-aws/src/README.md b/tooling/sparta-aws/src/README.md new file mode 100644 index 0000000..237a41c --- /dev/null +++ b/tooling/sparta-aws/src/README.md @@ -0,0 +1,15 @@ +# sparta-discord-bot + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run dist/index.js +``` + +This project was created using `bun init` in bun v1.1.43. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/tooling/sparta-aws/src/bun.lockb b/tooling/sparta-aws/src/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..8f8705e1cf010559175de9d87bb80188377213f6 GIT binary patch literal 257107 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

-xa?oFZdjfP%7aAvVh3=`#xZ-!KE0!e;M(K+e^m?g|b?L2~3T{9D#3mio z!Ll6qCz*cMTd{&J+W#5i$;^K-b4soaRS;y}e`Yo$0rB3$`zD z99-LEq)?n4)L1)|RU_{T>VF6#KS>UV$#5yJsOFVE(C%E&!hDbAZ7Ciu5@SP4M zAPg#{H5}pfzBCw8vtxR{pdUI_PU|Vd4sIZPRZAP>5!6 zRa;YUO+-XKY0a0r;RD=rpxdlt`^K){jVA|&%Gx%-)!VCq3I%lV3&T18x5BSg+km!k z(ZvQU%fgz?$1mYd%F-Pie;e^V)KW$6%~_r8#}NQ7c&>m1)Ki@xfo%F#4za8zq=rtP zcx2*@4RKi()_Z!Xu(Pm}RK?fj5rh&>udA14@3_kL%`4%xfv-PD&DpzXkE7_Ef&HjU zpgYY_x_{hV&xEZ@`CR@pFIK-F|6Y_~RMw4o*(#Nl6oW^+ZS~Bhme^e%(XnohNQfb> zmmSGgd%I2LfFH{sT@+C770~6NU!EL{3Hcfto0!0H?`^{2B_0z{ha}}4btK#;}GEA?|`2^t zSeEXX6@1EH2eKc^#$n`pN@wjj$H=Z7trAS_T(eAGLh#N#EY<3&rOuSpameoZpNs+Z zUISfYm56w2;*6%`Vf5IBi~|IMxxUfs(P4vcmb_)V?-!?>h4x%a4P*WB^aq~w$sc0X zcKP+`;5Xq|#x@3{>u&@uw(dG-4H3<5j3>XFPVIt^<-J1!g+i+Pz20pp8@Bir8D)6a@w?v~KR~L>Z=`|! zvpb;M+V;Z3SCET34;{Kz?}ie@P}20rmBaBgB)%bP!Kf+oGY9BtjXveO`>ig)$ zrHjACio!lr`7F90tljQm0QG|Bct}7h+ZK3#tDr&Oc~j#pqiOtr>upZ493(u-6%D+O|+w)EtKqlF%j?%AV(A)=2q|`<|}^ zGZyGR0^NR1Q2X=TJ^wfo%oCQ-*XaYglyu#yIqy-2q?o!XGn$^qLbOHO(5(cD*)SxV z_>&MP8yg#c_s4%S21wB-e&BZu@;p2N-IQv)SB>MZcBOD1rJB1ZKiPvWlTcGeej1FsPf_aD$Ta6s(BAb)oV zb6A2hT+J%VjQ7pwBznnRmvnJWM?>C-``V!8r#zWm4=HZRCd0=Sor&(uxFR-P1SPx> zT;>{7fD68(h6EHglY*}2G(tTQSj;o%!G$9FbCo#19lc^cOB6Gjpp#a}Mw{N~>ltWb zmIvwEm#fY12@~$JH!OVj_X;@C1ug#-`#<%9=O9QxB^$8FF&1_RAHr$I`2T$=U!pMy zu7<*yaqUTo`Rwil;h%{F;9aA$I~a6$gT}JhM+B_Z=@=5dp0%EC1>i-SmMa#<9~iisq?xJ= zUY{e5^s4=pK9_*f!wq@+U|za&R7lcF!Ory#_JpUmQMHSMNp~xr`W#QRNhkf6qN5L^wk=S zkHP>K2I$(8{MHQh`H@Dl?GC3Pcq~Xy1UeE;;u)J~rB_#%>4^?j!<9$#njUqLyPKx| zQ|X)ibARL`jrK#kj)+C`-8gXGfd#sa&aJ(LYSn{2!Z*JNUNaSQHE$fWbv4=$ey`+l z!7Z2KTPfed_I<-8VQRQ8#6HOs|Oy$ z6Xh3<#;@cwk10_QC@t#3@_o<`x{a;m-Qbo-1=t{o!tW$;#R4X}eP^Al8(+cY14aPKf_ zG9HA{s%}yK%V;#jNXD?4cuk;sF9M2biz9l0b4vN&@Uwu$?+eN@SOLxlZRePQsC+QuL!@U$Z8y|A@M*8#PB<>o}b=ct?N;sUyCbs|CU)g$WLo}d`hHw(<0BkZzDrLgB5aIQPJ>xFr+a`IB6ZG$R4v=!;^q&AEPi8Q}@|u zLhT&^CPfxjFxQJR(|L;-pk5@PtN2TgD|ZQ2z@4~`S(|j!TBBHUl2sG)y%7?bz1H4J zB*t`LhZh_iEG18V_9{w_3UMv4qsz0Dy}ObWc9pKD&O){I1=QrsuELb+B zPs2D&?0ajJtdCm8-E8)NdQpKc3^P3KG@-+MR$*sp(8}ghu~gep&>H$gG?oEc*~GfP z9+jBTpu~Y(s$t-3Tofju_ZNzxV);9oUcH>z=KOmPfQtrn-((_jWI_?DC<$9I>ZeZI zj>^NMa7-v6vQE8W4}JX=i*h64aDBe~JFcDpWBVrPSIHofE^>CXC)q&oI!aA4CBQ`o zy2W@>2`>Ja>9>fB`?|Ptx)lW{IYCHL@G~F8iA`>u7U>uMsz&)LePZyddu^*rX3&88 z;pb}>H{qSdRU1xynu)5d9KM&v-ZLa1lFouGotcin*VZ8_e{}wWDbEp5v_6ev*%$i{5V7$n}F) zmaK)~#8@dSJHaEkX9ig>z`vaY2}n{}Nhr6mwxCA@<2Gj5a%u?!M)z^c-IzM>*Lj== zY!(|$y3&)cU!~3 zNyiVY(`xs89Wehi6ALUpgHwjza<+5c9o!p$`F3-^`EqhKD3wZ-XgXS+I zVT3F6ck6_vyzNo#I_k(e(Y_hr5(3?>J;G4># z@tr8JGcT8~G$t{6ETiMlJVXRk)i_r77K$pF=xg&4@M69LxI{p=+z;V6%1io#Sb>Lb&R&+28Zsd_&1{~r}&)U zo*85uh=DE;gjq2aeN0YbI#}kui8L4RJ|;ZeNae=Bvr~4s$@o~J{$w35H5ud z9dlkrxqP3za&XPomY^&h96Wi-k-$3@C+iH>113pMbNIzNaezw-bpM`fRP3!1@M~W0 z`f1n3t^Ta0jvyDGSRJt#GPcfgCgr}_QARFfYcd1{620FiT^>zRrt-t$d z4BYdD)Jq0*VQ|_%O3_?t+t_{)jI>fxHFBE|avgI1%{Gdesly~Y(Jjk+^G%78hTh9j zmXz4|o=t)^1jgwRTV5QUE&!bl58#pmUGDQdoT%%UH)UT@#s20tu3^N^ka&u3yuRmj zkXTQ}b)>6f=J3xxvC#66sxTQc2z)|)z1%3Z<_btym!KBqVF9=lKsT&Mm0LDJUVv6- ziOeXrXuec9S46yi7M8aBryD&=*8%61$Qdo@dpnVJk`!hc6#<*1e<}Obu7d?9m*`t7 zM&Rd033Q7(Gp)2~ikc1ye7~Ym^SySfM?>dXAeIr@w!9Bc3U(2+^LdkJw72QBpQMk4 zC4sLD%$fN`J(x=sciw+@jC3ASv% zrwAf(MeU6CFk3q4&1TlL){W7iSa6KzP&7n1DHsCsmN|%JyqS(&9G#dbvZ=>Y+6+EA z0rzJ#KsVlfkridWIJfh&PsT`cm_m>)$?<+JJR6QodzLlnB4>9#CPS%6G<(psbsm|W zy6F4Bq%flHWDZ_)`LFQ7l)(3m7U&MP^iiaX8Y`+^w1UD&3QwGTE?m!ay{c*4xF+@_ z7;eSM#xx>ah2N6y9@~Gc9m;+&r6xl$xr4=cn&K0`YX$cE-T>XRZz~ySa1WQU1r<@cz>dnT#1a$OGqSf-q-{ysX9QL`3UinMFRDInzoWnS6Sj`ko_plD! zN6`V@u4r#%LA4tz{w06rkWM-NJCV+F*m}0@6kTyOcZ4U@Tt`L)oG(zmG`a0BF^O0@ zA>9(D%{PUL`wSWq&5z%K{j;|~7w*Wqb6H|pO<3lRxXaBI^mpc~+ z!Aj@A@%hGLq~mbTA6Uf!FZ>o_{8W4y(qt}DBO$!}>A=b8LopS= zWdOP^$8HN}f8!YlaBf@5=Af z94fYkY5w&BQ%rw>FT6Q5z-0uw?873Gf5gRjV->c=E_hKZtsJEEKT2S%fnFj=UAhaD=e;I75FNh&_BEJ*E~Dibo7*GFuIBeww4aZ6 ziZy0zk1+U1x7?uvEye%-bsaKPs=)?+cTm?U(4SNFZABC!7)$Z@y)P%XW{Iww2cEN- zfvzDb;)PhDIwF2qc`)1Q>f8^0|8Os8zeX8FPqGV-(V38TrzPh^J>#9)$NzrLX`xMC z`s`KTrZp&1@Ezrshq_I`IIsZS!NdQ<-gy8-v2^)xvQ>502*eQND;o$S7UP1vW2h36LLt~xwt{-W+Z?rVB!jP!kD_3*=o`dhc# zrpL|<)Cw545S6!XOQqpOPn{L#ea4FEI%F>vT~~kJuV8v|Uf_?;MvolK#+Wsz-r!tD ztBsCk+J>99-hSMCibtn0+s9~4eX3G%b#d=HU0*KU?9i*;mWA;zRw(G(PBC4pS31*j zbZba#Y+Nqfi!^s>5)}4%e#=qU+E!lKcUqh3i$700xo+~|tm5LCr6tdA)oQZmlJThN zqL;c7*D4v+gR~Xr)h3GRKAAS4#Bbg)-Fg<;HNzS;y|6cGTc&gG$?5?ax;8Q>J#G>c_8h_fp?t)7X%e%4-H&Gdxx^|%~Ubn8qG_dOexxMs_o16 zj{Z?^){WYindbgTf5@VpGiqjMXz9%K3TR|jyR*K=1-}DtbHA?nV4i%sk@~%JMxn;O zE3VnZDWq$rnC|BtANGu?^l;n6k|vLDKfl%ItLD7z#&#q>U@Mu>1>mf;r+N;a!M=GRi zp_uNWUKMS33|Td}p3&aVcH3sH)hJAx*s(aX&QRSdt$*Hp^h`X*K5Ll6!3(D))w1Fj z4a#&rw&CQ>w4|2>z1m!B)b=Xf8J)9a_ja?2wGv%t53RF0wjd&MxbG45BAfb;Qxg=&q4tXDE}u|3 z=hWN@v(9dAwaCZ+(8`{<{SUlc_RUIram3Fj+D$JvYI8LBQb~o;6M8oLcBn{y#-28l zb3;utH0IfzwAy|Au;OzHR*LDy-MV3_Ggoi?t@z^rmKBv>++J-dHUbo zOQNQwP3f2Je74?x_Z?qu?s!)H>-?U#tF{R_U^!}OxYRwbqssIHMsFS#ZgM>BX)r%{ z=)n_1^c4G@4vOiXYL{AUR8;xL@hZLITSe4=d9RV_n=g4{4x#Mhdx9>N|x+cBQ=c>kw$6pVat?spFbd0H5P~e6)8#UjVF4CLR zHRZ+(m(c|~owG~BHqP_&*l>KQLb^7J=_Y@0d|GS}p%Z@RUi|mKIPD{*Ci=nHPKCst|Fu)5`Hj?%nHL`Y~~E>8|m!@Pna)M-5u$->b4-!61cnJ1eGZw90>H z?Rk}F9Xi`Ms8(rYRA!CSgJ;cscql;Ht-$7X=+VB*jF+5F>{|cM)gRZ#cAK`l@05wp zM)g{|;K{XZ*^9RARotJnRZKT(izN5zlQcIq+c$l@H5~_Ri3xnOGQ8=`gWCPVJNWzW z@3e2rXP*+4PF8hyESh-i*60u05`(L@?4?y4)$P`WEKPdXK-S*6D5mS`Z0FYNe2=Ym zdC#l*sAP@XrXQGmK;xeH=HoAJ0mHqXUF_33XkBr)#{RE@Cm)b@7R_p0*lAaS{knU8 zHS;uvT%4$oZdb*0)q*si6?clbweRCp$5_o#I=$AsSa9WMihl2kPZqs8CBlyLhhhowePTKUZ&2 zd#2@R7xgOHAMBHVek~~|yydvB>Vw6vSN>R?qS5d9^}6B~+U}-#kKd{KI6UJHSz5J$OKkngI|i*EzpF&_VR!20tA^u;)j1RC(f!=)HDjB*4sFt+Z9=OP z)-S3q&uu^QcDu)guTO5Z=^kBoUPZ-uHT^cW?5|Q<^t3z8b=$qXqPwwAaX$@h?b!pg zXH_!%`MF-Dj%gP?8b;mBo@Q5XZN*KZ4{0@>OZ8v-t+30@TVyh@OQ-f8+jm+k*w;=m zUGMna4=-(>S<&#w^S4`Lj#k*;r{>&Or(T)6eXn${Z`!hy3$K1WJ6Jhw=^_m~udrw2!dq;84h{0L?3-3D9GdnUrvsX;FVd%Ey z53dcL33ZVfc8I-Ti%exbRl-o3*QK6J_0 zadD1QM}>496w?*m*K-gpU90b&^F7BtYTMWOK~68HRGuOY_5bwf{%g!I&o+7$usb{9 zc4+l$L1DQWAKzt}8+5RHXP6rLNz!h}Dur|%71JH+Sh()u?0akOwhA=2-aB`X*sq}H zq_NelUhVxJbw1%o;g_1vvUM{(d!#lczF@Ri%0g>Gn}f zH_2@Ao3(w_@BQ34f8)C!hqAhlHm}xb;l{phc@HxDKdzteaCz-*vuX{hIA3`^Wmw0f zZFM`3^B8*0rSsK27n_%w=Nwi@x36NlE+e(vtihwSCifyurA}>%yDZq4Z`8ht zQ;uPU@pCe@3~KEk+4G0>kh@y@`cD{gM3VThu1%YVyY}=ca(MgbvO>E36w`HTA+4}k4PaURuQG2LaSO;d_L2T#7B(@Ywv-N%nFy#853C;w2z7Tq=KAGwiUPv_0{4VCAepSN`1qdtcsYMY)mw4A)9 zN@iuL_JOb(pSmA^e1B3Ut*3i(%wHJX?=BxUg_|!Yld;W0yV-MyHuzu0B?TiES zMK|}fS`@2)v`W+mRo6EA3=)0exv_g!{TMR9#GSTS9vE>rqi zOl`I{eaWZIxlb19-fR~0WzZ_$P1>G&nfp&GP(D zzqSp}9~3y_y+Lk5WcBOL?HUGG{W*EpQTvYrX1>|6;LO1sTX#`Vdk;_hdM#e~r$&9q zEi#{?kgl6zy0t6Do9dP{FS`3O$D`8Gp5EGXYHrz;Dl)7xWOkL0?R1>FRULS}^+;!( zN)sksmwq%)%dIkBx|C%Fz?v1)pX@o+$?uzLKHJk9_>i2?`M=mz(b8kgft4Ozn z+Zt^jkdxNSbA{_x@95l@dPS?R8SNS0KDe6R%gy|Haw$3@#dbj*8Rs-C!7B_j!{l zn>^Q+ewwBlJO0ha^=q%4EWD_Y?oh>a1McqGcKyu2eKuMi1=7Iv`M$2tCJpaa>+!u! zYsP!dnYC5pV4HgvwC-D6I{4wawY$0g;{#C@Lzj=++gp_D>-o$`aevZ7G2O%t{mn-w zWL0=7sWw2}`of4~l^xEfZ~Y;%THbtUqMofqr)uu|JCBR=_54vS#3No)%fc-wf3uLxnydlpDkKkki2)^@v9CCTf}U=^RDluC6^1X zCA#{CUJP58wNUb*q|NiicPiN~8optR+S*eZ{rh)Nyx-!bnC`?(!?XntTWrj)-|+tM z#{Kp4uk_8S@u8@_S4<1@F}F|MX*&4AUdfP=D?IzU^q${+^!jQl{k~<77vH>O?%30Q z?WJ@DeZ3XaJ>gs9shQKM-5D>>kElGlUiRE4F=sRf-s$t?U1Z0n1*cq^dh0p2{+=_^ z6tek_ zk7By1n{SV6U-4wfoX;;)i&h>S7Wp+R?33!`*ec)K{M?mbP^rgq(YZnAqCDpu_Vt_| zy5qXqcoXNOC!^nZ>pmMBKeag${f~5g71Q-N-r`G>mXjYw2CurIq5i3xgUQz5eLc^c z`DLB1GjsT2-@)3NlOil#x75CJ{Qk5zCfX^(&9*MESb8c&HMqO=bi+;x>G~6m z$(iWb?5>G5&Yz5bnY`-Eg3rZwnq5CN&pB}Xvl{`v>er`_nl!W8gu7jrHryYS)kbyj z*N5#=jv1``;aj4my;>n%f5mi1HM+k4_U5Rp9aGh-nKT_xYuC>aKWpxGI%g2QC}67U zfP!bG66<))HUl@@@%rptZH47UnS#k8!&N?Gj#?c4Ja>Y@>7mEot3}6nzMC+~ed)~oJL}~I7zKW__dMn^ zFQlIRjeQE~iWSp6w>YMCW|y~`CKiv1iuP`L8@<@1SA5qlEnj?`TX$HB*wVdt`oc;5 zFAv*uW!AVmg-eUa%~~)bJ!sH?M3YATSNGpme9k;jG2NM08_${S-Ft$M){#q1+ZOfm zdj0+E#80m4=Bkfh>)r5TY25sJ(*{?5H@z_Xp69gtgSHLW)T_a`hkK?Dw|#&5!sJfd z74!{KOn2L<+{afWK3!su#~XI5a6GmDKId&$x>!vU2m8*@S#&FA_0jhE(y_K#F~Lm- znjBM^oh!ASl_s&MXuF_tT+~p*MGEN#E2f*#x!(KI-r}?F!{*kX?lU)Sz}F*VuFTJ0 z?GdzHby&p$Ti+4-FP2zNs8@ejm5t?}=7DSyWEx7$0Gyh!mZOiHEsj`B}wfq%*Z zJJdJKFThtMTG&V=QWJ@i{%RWk z+4Yo1h}0;-H!M`7-b5s-i99L^kz~jJERBDqob-#0l>|^8O`D2D>i+^8{44cWrv0z8 zfYLYqb+%QethB)2X#r}hf$@>P(Nb~rG#h@L`8(5Bru9G90yNK%Au9#>ON=z0`)Juq zB&zXW9QP?bvD7bC5*i@t+gl{6{$ET_rl<1w?`r|xhm1nR(wP6)^Pn&;0LS+Oc|V}A z2xpN<2R<0mr7OVsRl!g5}nS=C`io-^TqeY@( z|Not9HQLrs3KofKm!l6M`-MowQkYIOMj{ebD@PiL`Y-$A^@|9%L=KU||26v&|1#y3 z2`eq2w1CnAN(=n$7Eq4+BJQg_Moi(y#3URw|INnkzrE6x2`Vk{KfnUiH${u35uqc* zqA~N7`#%2zs_cKIirm_YHmdNaxG$BtjQ81{IBF}$l)vN~WK*&5pwrJ){MgLd*>^bi z9F#2VFC|*eiz(m8XmL!8L>!&6hTmi4^pl24!cps25ca?E-Zt4FS`rrR8z>U_Ny3%S zr3wDU`ROmuWt7wP{>#xw7hnH~=x`j@ZvFS$m-;UkP?>- zWIy_uZjs0$kUy%~NTdCYCE?N--_TIw&N#5Y}G^Q$^SUoYUU`A*>qCPjDe3(Lf=LN^%mA?#@D3 zb)2^a^fyQdtATTUK)MeW!qjnY4(QKC2&;*6Jqo~|E5h(kqyZj5KT^?MxK0!2I+Pgx zh6rI~yVn!}e?x^ZZJfUm!aRg9vS$Y&%u@){L6{BBsoY*d7}>ZG(4V&uRtM+90F`l= z5T=WB7eM9l5yI-?yeFXY_zGe5aDG_OnLLPWRv(-cZ0|3GH9%N*s!;p|2w@FzeqM+x z7Qz}KtUw4OPo(r3gHoK6ErNuwCOGdagSQx@c{pR2X!bs03;kp($e}{9@Gg=61iSr(Ueo`T<6~del zM!LrcVXbja`sv{qD}=Sd`5ojz`Hv973~_!>2#XWK$R9u8obrzs!i;cEx=Vd8#bC4aVXw>egosoZ0JEC?&K1A(;{D%C5 z{G9xn>W=)D>W1oq{E+;P+V>|U_62+dpTT?Z0pvpd3dbAZ7Ptwh?Oz60!4+@~6aZ@f zJHc)c3&Maehyf!&IEV($fcgb7@CH#J7(@bB;0}g>;UEGG1%bc=1OZPF0=z&R7z8B1 z2ZRDY;12@83mEJrC;`vGEpQv$0e8VYPz3IShu|p40Y?D!Qwu>FNC%6+VvqrrfTds= zSPoWzm0%TE4c36QU>#TwHh@f!1vY|BU^CbPwt{US8*B$Vz)r9W>;`+lUa$}B2M54G za0na*yHH2l!EUe<>;XGKHrNKJZ#@l8f#YB$SOYS^a?l*K2jt70fEgga?*OPhksp)a zQkyXc27vszBOt$T2dG_H0cs=EE-iow&;yph8c;iPuU=2EePT)K`p?q)!yaVsS2k;Sm z0-wQG@EsI`A}|}oft_GI7!D?ZeAN3za0y%nS3m(M1Xsaza2}ii8^L<87o-Dk;0pqP zKkxwqz#!lR7J!8y64-%oK;s{ccRyjfEwJwqa1_wksRG_1JOc7lIGzABejW#Nz+5mH zOajSZ8K{Cdl|ft7gAqtV*cdPtYy;0BdjWbwHURVi9YH_f1p0#hpckNV*$(ssG#=CV zdj*^Umq7!BHw2HsRZswKgJN(UQ~;lG-4E~yWa0A~kO^|Yc5o2nf;=z%l9t84m{u4e>0Y@{GuOsLLY(W=b13CalK=XpDsN);p7AOQsh}#u( z2den225!UNcfeh658MP7z-AB(qChl|f*24B_8|WeAO^&OeB?C`OaWE#c|4Aza2yTZ zfD-T$yalc(ha2z!gMc@v3jBc^un-pa+_RCZHu~ z478BHCisN&m*6${2Ht{qKozoTKn>KwXHB33Hi6Ax3&;m&K`KZ@esN$t=mcy)XJ8Au zfUck$=ni@SJJ1u@gI=IFZ~(`F74ozOG%xA~x`Rrnhsxj!Z2Juqf&1Vgcmzg+cZll+ zyn!e10KwocFouqGkxm_;3mSpyKnvlu!B#LEjKQ@hz#OFa7)%4x!B6lJJOeMl6EFcJ zgJHPd2lxU%5D4ZVt&=#ug#0b|1b;e-<7%)9=!0q)KWJ`$6ja2uGzY(i@N-}o7z|uM zPv8Jba9s@2EyZ3T~r6k*}uHn;=s zfg*4p&|J|T*Y*c=EyX3BKH^9+I>txhfB0LS%pdjT9+DUVu!;?Nv~@}TQ#1Dfwp{N_MWcZyH?ngWW~7Raune5kC3 zfaW-|vQe4Z0J@galgZlQ+!&YuGhhoWK@VU7%t3cR;q3v*DE!y3t_bS}=(^6J1F!*| zKu2H=tN?{~0dx&r+f_Kyb@Z9!T><4qpD90k&=ZiZ4!|Aw0AD~p;|d0YA)r5S1_QwW z-~=d);%+$nvJ^6tD5e=KvsVef$i< zc@zi(AwU8`!Eg`(!a*dM0j7g#U@DjbV!&iD0>px7Kw&fnk-tm=5=b&v?U0;;F>U=$bwWOk=8x_&gEYY382SQ4Q0DP5{pN^^qnnXZ{AoXhgF7CzH; z$$-i-6HwVwzy)v`90Di6ac~Uefde2H><0(IQLqne1DnBmuovtByFn({1vY^VAOoxh z>%ej_7i57|U<=p@wu5D0F<1oBz|eg~BmiK~C4nt|fhB z=Sy+EMB%e6Ty~@~ZUq#Fu3ZjfGAcWjmC8tEqwp1g^dg&3nJJ9QNxH2BR3@43bfnKD zqjFIiYk(|$I<5t>>t*4J*HPLem&r&cvODQ3D;L?CY)aQ{1hQ*?JyJYbIjCMJ4wXky zXPF%+E~QIp%Iukqb6GwVu4u0v!nLyNDIcnz<$%(X)j7$jo@IK_XGJ~9Rx*7K;GBF? zmOoufZCrLe`LfJs$u5d%QaD{l{zmcWGhHJqA6m?Nvh=84Nq?#< znQpT5W#y*NGCdXTMB%b@WO~T-|5bJk*IosMpa5I~CjnhYKKiRKQ~6H`a+1l?Rc!13 zF72Gken!w$W{0!*Oy|To;dov+QeSlu$l~PVT=9B}a|MtME(5aBul7;2jjXLuynk2w zP+7_<2Zdh)v}kZA*2Z+*dMl7GkXEBFR}0GfFJqyvr>Kt)gk_#=$g z%d~E$H3_ZPstU(yI93N5KpoTsnm`L|1{B{HBmvUd17rcxgVG%iF5>(&*ba_@T_77Q z1GT{xFaRWi<-i~4fF-~U%mB3jm5Ht=8Ra`2kX;4?%3BvuzLcjKj)MTDOX>LmLqO@d zg8sk>^aFK3U(g3Q0te6=^aA#vC$IxOKzGm$bOl{NXF&N{0jjTNpcA0{O+iOs30eWl zzcuIptU+^77f>91&;Zm2^*~dg2bzF}pfP9!=o$k+>9qtc0Oe2lQ5fmbUieJ;Q{J+C z+u)qipg0ylreiytw*|()2$%>*3ZrYx0mYGBPi1Hd8_VKRIOSstNSFWaBl!mTgslFZ zaXt{J0+PwXU4(NABOfN;mHE#QoRjZRS>3@<-~~K^Hy8$dfGqA*oRbey8e{`mI^@&j zuW^9tT@2cS01yg>0|_9T1%hBeZ8ZqTFdV7vl7CQ|RG)Nx2#5k_&K&Ch8Ol@`?j?{)m;YhlV1k?wTjIO2Q7#v4~nt*JO1jshB zG${Ufe4ZeLPr`8uNCuP!)qxvu1=JR2ffPWpnP4$k1k%AgK>AXibHHqn3g&`^!f`&1 z3qTsk0Hnt|Rwmd0s6SZ;)`BH~(xH4-1FD-< zUNt;X+roK9Mu7RUJocOich+e-)sV;*G53PZUb8Zl||;y6uuKE zmT@~iX9Ci52T&~Qetf2DPXU=tsJutLVZLiuR^9QVdA{ zyTAk70OS*757Oxhj;8_XMd?r(2LQ7FWpD|Q4pdh9Bb!h>(wTI<4rI2vhI11;GAqsWheVm9tA+L%>D71(xSK&PgX93WB2DiXXa0kfpC4INN=inK54@yB}@EW`VCEz8<0&l?^@J{$l*JuDblKeCH4oI&rAPjs3-#{b?00tFM zMnKnC0X>{I0k)tcpnG49fDvc_=zbX87o)VSaHRcW%BvlY^Kfj7V*-w?acl)zg25=4 zAHsBTt_jpYB~T5hf~uf0s0b1{R<_ zp#5#y6DPZx0t%UIGd>Rl0|2F65h#|2Y&HnE0ve}>;OGY0;oKd^=}2P$@F6+y1)hNWjBC7c z?gfScice6OpYSt2mKUn3cv~BrnizL5Gk`=5lG9aMAM9f??wx9dP~RA_GzNBsq$VVX zbJu&E%)W64aV(5Yt?;iCB-J75v|+-Uu3CwUAu;PqkMSXBBRAIF%og~EpHR&Y{R`N zBr!%T%;Ka*T(pHp)?Ge2BdVAAl*I*ab8XuG{Qma+FXhQ z0Ym{2G2-wMm}hSDoHKT0K?zCPLt-ux$HP8SsmRFjc+V)~h{lkhwyllLxb-V(kosZG z@_mgej_2}0iTITpJS)lS$n;$cdK6vY;!rl+9G>EIS^M;xS>y2cTpUxXZR&Bz6V;6^ zx);5-8z{svHDSdu<9m3D6YaRSr0q4MEW{xjARp>G$p%B#KR;o&;%pq34;4ogC4~|2 zryD;ZFluhp}H z3QWV*58Ztx$*C?k&K^o$yo1w#lLQHpe(8s-9;nUOEU%wvL1Nugywb0tt_Mlrq|l9? z5untX-fZXH(!1&73M#NCCrK70u0b7y=3JaCK~m*O)3g25YHZ`;a2f3uB#TG2t^6qWV-dwc(?X@@LZXg* zCiYhix0#SWl#_6&-LP}v#ZyrYQ%dYX;4;e za~=5~ddt}GNJ%7Wb>@)g>G7?M77OhZmHG#ND@QNJt{-;4Q=U=AF9Gu_*kb)j%e;R3 zio+_Xpl{)NfA)y+yA8;xc)w=(D7*KM_Kh?0iwH=Fx9}X3w7WS*bTeW6If+h`dh%0S zZ1)8Ww$Y=W!ot`Lj!+&SW%RI?Sht|#rM~yGy8h1EU6;SEprRp!u&u+kL3y-RMVCw^ zZKpDKq4ybIel$ZhSctSCp|Qko$hvV`i)UGr4Y-MehJ06$UgvDI(BA72wrer?Hh z%ij=(%A;QA)6my5?u`~^DCm}ZqZ_ADvw6R!%k#S2s>MmT8Z|1 zFhwyJ4MQ;XSLT9H-D=rS41S2{m_*aJ3Y~B7T4NhYU zjosL!rs{82ey-9eO>UhcNVFlTyKH27%KE`(atYf8rHDgwa$}oZ@snwTWHWEnLIqR- zS!Z$j>H1M&d#ZD(aZOkQ5~}UU$eMNT*qHwi@LL#L zTh)x&XALG4uM|>4oM2yRL|iz0sLS+fQyuT+Lqany)TsgDkZuc_pA^Sx56YFtVe7U4 zE=4|?y`{?BE2j3-xj5WZh+R^)b=bCM$6?!m9fw^WwgwFEEoW;yUW>2Ok?#sSjLnL4 zr~ZJSi(L>TkFy>P3eQg0q5hx)H_|eC7jp~I9yC&FjnS2d7eDEm`iW9&&&_vYu%`9H zxM08RcIHam{g9xxIZzZ%nJ&{xH z3{oNw`M*|_=*qFbhd3lz1>Zp*@qa`V)S4gTrO!< zq|S*EQ>LT+VBXD@v>qhXS6R$yX+F*2az+IeO@vSzXoDjRloY?~R>8Hq`mh^TZP;@# z5p@js z!!N`|?DB+01o+bOFz5b=&PzHtUE$(j-^I-I_xh1(z%D79g?jR|ZyEQ`-ruB( ze1!U8+kjot-|G~*ah3Xeox-m<=XnYZsJ8uw?z!lsI}xi;Xu!q!0Z9``Hkgn2c*UWy z79_OPYmd8oQgTP^P%X~vXDy>E{-L9k$Wbk zj8=lSz_7s|{f$vl#9@>ax|LZ{l$C#Wlg0(vURni}Hhj@V(GB^Yyjq0K@n-`U?Z=E5 z%U#+`djSdcF2T~xKfh>XNpyIBLwP%bd z7Jn|J_APncoNMQ!YFALXjS$NDoggW!*&$rJb{_{W4mX-bHRiAA@+s1AU)7h%kkGUf zT91WIe}u-E4*GtJrd`~L<;oQUy{WT{+Hx?2G9eiVgv-a2f;Oq9h zH}&zf&4%@l9$y}p{Q(z3e^xpwA4{aZ@m%fh*Pmiy@TqharN-}IV4H@MY#y_2_LkfR zZMoFA`iYH^aH4ig%}E>N6B{ex_Ny9{^mWK@-WaV!*d7(dhlQGjpgh~{ z#Fod?7qsNZA1dkZ8rXK`yV#A;n(X#*oLYVt&2DI?V@&z=$M5S#ZiWII%xcQ}sAlN| zP2Cm|SUYp|1HWDj3AOr<$H zw2|1?KUk#ybkg)g`3n$-CjXGI=Pn(P5A`8~{A(5(W_a%w@_}w;&g{6FJB~Qy*VV7T zoA17>dK4FjD=A}E1K(lHVIg76cVKG=1AYvCy*oKIUGBdr{A4 z&h@?Q-m-No-qvo;8KNJrzMPi(&hNc4*Hgf+B~st$Xx{`;tvjO+U-s`nb1`8PW4ULglHjyhuyiHCUmfV<90AnN}lHz29ALD|wt$NT{Sf(!4cWwGxu#l2wpY zhvf4Y_r=L>E*~MG8^xG)vR4wn&#Po{JdAh`+2j%C8$58KY}Sd?%B&yiDE^QSTZ6wh zwJyfI*6-fU+vYHJ8tn}VzQfjF9pX^^JV;Dhnb6KH`u`=)fOdG^1aWRBXg@!;vF;@y zPC2c^wgFqhE)S!fmQ%O$s8?#+TIv1j>PKYA+9^_d3<>qum&V5Z8u^_I!0 z12)e-Izv^+=O66-|6Uugy{bIcVaH*&V8&bwt%Tk0l&9}y%$|@BV}=3=yF8ZIyQJCe z38Q^QhsGUW{;%>kwgzk;WyfLLn(bBWeAshKwnf?fD7&X%mxnE3`wn}|VwaTdJLS=U zolghsX3-jB*_aLICN>-8C+}U@7G-O|mauJ5o^jhRB2G%Xtvkjg9h}i)n;NvnE(J}A z*&47V?3!cyD0@Cyp7OA5%~-Mgv8Q0KG1%o{dkEWZe{UT2=vx?_eb-i)S);v;&?5xh4PFg?0nc>#n#|2kHPQ*c5SnDW7O$C*tY-swO-zZ z-*XuKyuyOw#pkxkcbsz|q5WR(Yv+?1SG$TmV%c7Fkr3y5ta{E=Zw;)#c!{YI2HWt+jai{xrAMFjGYqH z9J{Y#tXRrf+wAeD2<4&msj*?BTG~HM@N~MYr0nvrwPv>xw(tDCcVCqESn#Ad?qk$I zoht9KDDScS6ZcryZ9AnK|Jvv9jA=h22#R; z@(&{|JD)PvGI(a< F$7yD1+RK!3+cl+dbmoe+)@~PH?zi&2cyJPD7yBQ7Sb}NrX zTOl>t>s)j0sr}i7X&sQ7aIfv}wHw=_E_VEG_={a?+A4j*M6yxFM8qDMf)R)A#strlQ~dKsIi(N2b4bJpFq|&A(SS<#=i$Qa-bQ?;XHhd1^n(XBPgaJ+r{> zN7=pQe}A9I)~!6NHWjQ(=!t>9XKhxV{`%i})yA%E#yw=rgc$db|F{Ze?>UrbUyN}t z=Z~{AwpX!jz@7#C_peWtPY0Ta7|)cz50op{Kef}pw>2{EQKA;u_xRa&aFtI7;#>cI zd+S2>bf8%37b^)35NR58?h#kDbT{6hz?(1h8gOJUey^r>O^2cW!B_bwZmei`KTyiQ zd$K2WyzZousY@DN${HNiM1Bz{g3hv@v*?R>yd`q^TfL z)v`6*qcZQTTw(zUy}xj>>VSpzPr{DMCA|en-7jBEolmzH%O#$ISy zg5>6eKzq{#H;%|969q{d?N;V0(;uvmOVR|1U!8B&G@9sqkV`fSlDVB;7=)xON|sA< z1j)${Nliwrc9|=eT!Do40+u!~*?0M=|4_N)DI~Ou<7}I6*szh-I=SShAW_$~d%AS< zh!b*2ZAU&%mc7I)x=YIpxx`SAg!-)fSvY%dH@T#nATiS#?OL?Y*-tKUgM{{oFRsvR zl-KHMf4M|Fkbe$;@ZSAw7|+w^5D1jmoaSK`wa?3F+3l(;D{{p6}<%CBJ@umF9n>wwSDm zII?e?(!?EU3uALVoAwmP$Uju_R(vkacvGvzq;&`GYpP70`8e)QGiMmRopVes`BgVw zLa!TlLL9O|Yp>NOJ9+vvhJ<#D%8 z`+m+N&;Aox6z>{SonFEfWVaO|Ry$98PNW?`VehHIAU>ZLpZsnQtEFlIiEz&~P!b+s z6dNh3SuCkpf9F6cmm0U5cNuXgHP?Y>oP0HRoRG)4BS?CUsZF7zlQ{|JZ;(jBBr&4*UIS))m!56|iLhrEX%xeKtZJU=Livyp_bu)A2Th{j_R%6=^ zYG){aWAyhpZJ;gv-9a4k;wDQf+_TW$@ffM0uC1Wka5uhJ&U~6AwZ7XfSDxA@NT{Sv z?T)UjcRE)Gaj4Cqwkx{xt+8jWzyGh3q`jUC}h z^D~G;Ht>sT*12nS4SGk7_HIp4mLa_NUUbjQcX+o9JAh-TG2)3zaXhLGe;S$h?mAq| zjzJv6ARlGdk2I8fty=VQNj06;;uLz~Q?RJN#7Ghz7=d43s{SqH#b)Y5uounlk?3vX zg@SI+!}1opKOTrTEV=SPw-th05Q5n%nj#&I(UMYUp*S1@C}F6O!j4BPUnu*nSzO0lu06V)e= zL4rXbY17ojH**F>-IB*S0|_c4$#|gEsbf8yPRZkZg@iPC*2?+F@`Tcv@;FVsd9Cxs z@0QPbR{BjIr#B>qh|}P5nxD=3dAx3%4Wy7zYIiN)z5cA#?3FxDIwb8N*&9}B&^>Lh z9iI=`;3OopZ+UQ9dgDz9U}i^?02Ji+@BNaEr|fxe8SNDAPNQa!I9!?# z?l1DG-`^_3wfeaVD$R!Rt)tBLgYS(n5t#~CX!NkZsnPi*jlrlnlGs8*J#4T0p*`Ci z+0EBBC+Q0bwUX}rUHbP+nR|$jV~L^3Ly*+}6!vmMAN^@^Ntx}3ODzO(sE2jj`Sac# z&wDO%4Msvj?dSQEMT-Wt%k2vZ%@1I=6i8}AA__c|_cE395Mlnd5|Sp63`tyl@O|w& zo8_q;frR|cVpK}n+fmunU!$a?HQS>0K77yjI&XAj#?`Yl7sI!QkgfYeLOoJM+69d< z$$b|>LSL5##vQSme;3oTd=_@Na$VC z;sJ>j`?;iJr$i=Ux1X~8`p=$NW}D;NPiTanZ>Uscd}xnxce7SB-@%AQli0HJX^&RV zYCo1Db}M1E1xpdDEtrX_1@bn3vVNLIVPTa>=qAjf%d9!Bm9Q-ug710t7vUS)ogHnT zKXj(PS6Jzlr=&BX8?9It*WUfC{-guLks5t>h9!O}4HBB~B;ZZ;n_Ui!5hP}oBK06X zj*o+Xb^WO>H#iNr^+Gd9Xk_}VB_966;vCJmXbd*B5H%0xTYb@wbDLXPsHbrfZVt&)(PcTl%o#}O6qiE%5BaDe;mz>O#Zew+;?-cT0JA5e<}_)clif&`*+TV8#Vu; z4a(&^H0%6}`TSFUP#zonCvn)THuegvJnNrme9=)1z8-v5Z7<)w`xY5cKgxegR2lm_ z!px%GPhT| z)L0YHI)k*vOckps_A0c@-k)1%9Ybm~gFHL@Zq|YJ^J#BgnD-7H!OsR~uE=)zKKcU9 zl?91kw68yXxp0oowJQ5^A2)%7Ms#TK1#xJ$=Hm0>=a*F94_q8>z7rM4&-pr+=4?By zY00lOxq0GlNT?Pnxtk3>S0$5nkI4@(@2wWkr`D!j?{A+xbZE{etYykm3z3p&`ppT^ z*plZ7HjZ^@RZX)RtWViH3+$TvwWr{8EBBrQdxwd=?@~E|U(2XBKWt-nv7J~b4_5E& zwM-qvp|#Ac487amk}OjZ2jA3Bwr0$p@GbuAJ1N(Z8m&u~Z29zVq0h}6E;Vj8_y`j6 zs{CTFtWQzGBdv?9ee*-j(e!OT zn7z^3Y$PP)Z&NDIX?3&H&6`|moWGU1yU)$}CL)d|;v_YiTesJ%&a?_8-H=+@-#19f zvF{=<)^kX$Y!9K^NoCH`FlJea*mesY$=BRswYa^f0*_sQ*1~Ek2rCIpuSJ1wDJAuX zIQ4{tcEe%OL8ExNfC7Kd-l^iZ}^Z3tu3idq$^HM%C1K_;o;=8+uYzT|sMWeD7psSwEX1MyU{n>a#hqu+_tbNKjX0(g_zFkxMN5Tt^Jt$B z*6BWpw_Bl;{krF~iYy=z+FTz2ksSv0L<5PQvxD^zF1p!^H`gq=$bLINQ*EBK3T*TU@XNUw?`_17R~iuIm35?@@9k zMLuQ5;qtjOnIDGOgJ? z1$!mI_BXcH?4FO^e%N=u8M6&|)!$p5f9n1=d(2|j_BYfvwS~ETtTty`cdifL5n4&L zX?&kp^X;-39!5cQ-%R+nKlXhvFLKtwY6g8(S#xDhgEHsj zcyy7qb4n7%Tp6`cW z`9L?x48A9ipB{6t){8GGNKNQ1*|p88A2Si77lW_x9xMCvMn%6}u1dpT*BZj%as!KdkSZ zGo03VLeX6G_xAoxQ~7*8tvUEH(af$t@)6bxZ0ocU;_R6{>TdVtakQEedKY$`vPVGn zT>0D(zWx0Eq>?!_px(bu-x{xW92$u`-!iS)PtUOTci7_sd(G4p8fZd;S_S&XtG6wt zd*Z^r$$=jW7RohP05QMa(`p{DEmn=cAI1O%Ix_yelj&8bc9&c%5=?z22Wx?l84Yw ziJJVOw%K-LKWoVD56;6nwEwm~qHzDkD0Mxu4&Q=rLqaY1O2NvFGdi{E!s|xk>92j2 zj!GN&C;c6n$InMsg^Y+z_~FrrOO5-HkPDE|>}kSQ@r5=W`qL9P)Kg$r>N+I!RLi#< ztIub|0|NvJ`VjUv^0RHtUOWF&VSMr#wBm7`kc04?MQ)-+O-lHSU=o-Ue}kWPxTGe*m0&`7BG?_EgGdI+P;-#f2j?=-H526Vr~t}r+K;o-VlpaH!>fj)#?3%|ZaB5w=q zyJOps5A`m=mc!PzYx@$vG=X_MH}fuY1y=6+X8Vy3jm{hUzH}d`duE?pH}=!%?6%FW zQ+7|zUKOzCZ^xl^Eoj|;)5W-#SL<0qYg)0GqVI)-W+dI zu!(4*d;37GlXtx7J0$t{nSP!3%Etxv2*~a&**y}wckz$l$Dxr2JJ>c`b8a=tgWp%9 z`6zqDVCxnj4#3Z3;g^UCBA<4z`S=Y|6WS?zlsT5pd%WkP@6Quw)VL$JDBI)D3vrrH zI5+Lb3bGxm-&4k#a412!A_Qb0w56vlZ7Z+ZfymwP3*?^x@ zuzO2(owDcG>?h6Hwau;twyoLilx>58MSPpf$eupKv_@quybCAn5|sID1h-}qM@Q2y zb;aL)TAX~#cs~COAT8cS0~YhYQqpPK%3EelKhrw`bZ-iN9R>-_(h_er(LC#KMEAGp z{%LzWyPv`LRUg6^jMq_HOHabkZXQx&TZi2i*zJ@(KX?KSNH_guZ@)x$v^_vt^Y1Tw zhJ?oY$qu%CR=rF94hiir;WZbw6+3#}W?b`1AlJ{LP?GD(>laCKUygwvo* z2^XhKtdWyO%C+QF_aV{U#dw zT~hYA{fGX|nY9;P9=kwYLx5)1z4@X6rV~`B~b^=ZS_fo~3lRg2q$aj!iJNrY%*G-CLEnj}FDqlRdN# z3khBTj+H=Cx8+3NE6dFWKtlJ#QJylN_vdCckC6|JbKgT%wnpmP-9Q|gbwUI7o6x0* zLu1w!NmjwxT8HW&4%rPFR9M0HVh?W*JRy16$eWXJ`7rjCp%<){M4|xqqM} zR7@{d9Pmr6^v&z0Y+hx-c!G$#S6#-lIKobS8BY)i9?yP)==W#L%#7oyfFj?J5!~^8 z|9LgL-g2b-&bY@zzdnk6f6hnERh##7=<6{J5_)C{jO8B<`6K`aM@MY`Rvr zXGom`$+Ry=!q9^d^?EzR+ zETvyfm5Soq+-oo@Cy!Q?WCPCDzrA%XJY_&@z+c}8LH~-WJbrPJTOh_w3@kS8A4}^6 z!FS4dVoi8QIv~P7CR!3Mp61fp-Y7q}1aSnvuCtc+>%1W*AJt~;nk8rr>y%NaG)IEg zO%R9Hb9E{YY2?&s0`BR-*DyoCw-gH!y%COEqjemqr!c{f+M;bUyccoIg*YzPiww1! z^hxC6z&;`o<7ozH&>3-PrMGnQ{vPYU*IbJ@G$)6I{p3||#G!s?aeA{u{pPNFhd6k* zg5nGoB;lVseCikC+yD|Fw(Z*+30>xg_{Y1&wxDM-vhx0jy25F7*vl@xYc3P~-b_IPu` zY`6PSv^!}Ie}k>dcve^NkZp)V{abduCVh_7GN>x#16!Yjq#h(i$v0!RIh|I8glvF( ziXdqKNoS|XghrE&+CakHwK5TXgM{8HlOAx%+FU_{dJ5{vky`zAyriRPpTYIV-KFPH zXe>c$){rzu9M}52FE)9zo$dz<5_WmS_?3EE>FphyG;)jP`cIlFBi8deFz8(Z%I=uWa7cB$JbHZ_%^&cQ$76 zejV1jv0vsqzy9?7qnz19fxgn1DD-_J?mJ#KaZ2$*YFI~*tt~e4esHcvzbiv`zG}@s z$AzI$Gy@Xy_$gIeFYUJ~dmR_Y)VKros~{myoE|W5+kqRW?ou4iIHH!D_>s0#liuEm za~%6{aky3zEsm8+0uw|U&3X>*GQTZ7nLsrM>#%!eM*YA#j2;%{VfV1?K9Sw?mFWju zFV=iBZ=E~4vNZgi*DObQ=owo04tqx?3UTOeUshB1m0u?PY$n(MT9@%mu+Zk%_gKpK zJ#nfAXi)Y&SL)z={Uf4jpu7HYR=~dH52?*rqlbkC4qN%2JZ)aryeoU$>p}t#rCyBD zvXIZOscEXrKq~c7!q{ku5q+IbOZBAVo`%yVBQ;@fkkRuYHLY#@e%1Bl7C&q~lB*$( zFb4lVqH}EyTL1n_ypVA7oi*A2r@6Bax-_{8`UH!LMhaogA7T=n#Dpp#&wS79OxRt< zSh#LcB?e0VkTRS5@!a>l^WLwy&%N`$FT_P-j6oCxqArL)8KI0|VkJ~1Bp`t$8Y=`V zL8<~-C@jP;BB&56Ruui6)1S{*_j4!w;mq#b?tc25KK*t2^y$;5O?^f`{qhez`Rtc` zS}9;UT)6N~zR3D>^7z}{djIF2|5sX5tGWK zsLvaI)O>l*zy9X$eC^SXyoxWvmH9F)%MI69F8t!3_~@(N{AWLPmoN02@Pm86h)(r` zU-%#2@#K4^MtZs?l4@nK-Tr&U+g|?MBf|Nlb*N9j?DCg^`695L{n)1;{ruNF^}|XV z52UAPYnStkEvLUe{Sr!t_ZzGII>`Htm1;UQbGmMK_MDD`9v*Vz!iC>?udyxqj!!=I zf_H!H4Sz#kO1`Dz;0xYo?*6a-*l+*jFMQ&acfyxf^W~Ma=sRBa%OC!QcYhUkQXEWs zm2q>Sp90}X<8xN&PrTuqU$*+c|5io_Lx)c7xCtN|=r*qcp{OQNm;RW_88wo}{iY%RT!4uT? z{TL@al-#eC>gVLka(h(5nSSSo9{JwS`_7rzZHTNaGi$z8@wWPz4}J0@fA_cKW{_P1 zzy1AYW?lWtfBdR9{}S{MOUk)bv9iF!g!o^B?%s%d1yQee8e{T2B;5RXPMY^5+;#a==FBG?b9qpv$Nd4{B-v<5lIfT17 z?PPy1bnIBH4~Xyj39FTQ@Z-h&N7;s1Q&NA~~xS3ad**n_3G zWxpKX-`0oJ)bY`SR&?39GE4sSPnucs?ce?6@&91$5E?E7zi>|MXBV^j@zem`QJ$P|}f@LhlZ^&c6@?og$G zYZpEVjP3!?o_WbDf8bZYS3QE66M;^TM)Ilq`HE)0H#m~R=<1Y?hKEszk%$N6l`R+|$@UDyOF&xYv{3)}}efg8$_O_RP^)0gl z6HV;({&M=;?QerkTJ(QD^_qY3+K+z4-wNeC%NL>G84b1oa z)?az<`?eq87Rl@BHTlzDPXCvF3jP!2Nb9`gJ&(Nar~b_=W`Tl!Y=8YZ({u0mi?92Z zFaGr(zCQ%!g+F8Z=bJut^wGCI@*DU$mG%4v(#B_BV!pinBhQ|H|GkgT83(96#Jgmh zf@e5GKrMB9`%<&V_yg<5{^fsp-S=?A?+7OJ8+{x#()N_&QPE96fRI>aB~_{2R;l?9~@aX?nK5C6?~|L^Di{Of*bOk76xukGG6|Dx$rpIi`^zWl+HZ}`^PlYi=^ zJN3uG{oT*L_y@Od`#Ucl&sUcv?GOFR+aLSQm)(E=uHvE)zAOLI&tLqjUpxEi&-_=a zE4#`!-@f^?FF5w!aOjp3A$K>zwz*;}GcCsJ-QC^! zrmQZ@^M{Mkay%W`gzoJp8$=bK$&)`ZLEr`Rm^ zM{|p`_FbEBt8{4kT_L8Lo?J%5%_(zHPO4^7RW0w@O5Ce1L*6b_z*P>lMalK(-F&^O zY}%!Jl@M(xJ>Km$)oU&TeT2q!dwyxPS>CHIt+z*)L#E3`6e&38VGpu9`o`&qR+Mep zZe5J`vst;-$`@l~(%E{u)bE@1;v9wUB2iE)FXgq;3K30EDpPI>m9)MRAUJ#knq^-L ztX68eUY2K*awDyBT8;}UUXGSkF@V;t>=f0NtPOQNLvV{8*VZwO1roR+KT-8|Z-A257IBs|llLzg=*Ybaq~hrqk_sJv}eX0G3miO<5f8mfGWmo>}9^ zw%`KvygDwY)o51Q(V{x(LS@>?nsLe~m+?=|(c*Ztnl1nyt&U2ITA2WSHQLQ5BfgxA zYfThfE4`z|7-S4XZdLA}Y_}Fv>)B+zKAD$A1*aPA_S;fRE2fYslXJa=1(=eV9Gi7D zmm!g8$9X*0*Fmm~X2cpe{J5oum^AGsyvsVw7rm z)^0r)JE{3>UTzb87H0!h9EB3;_H_FRW7y-{3s@Ia8gg*%em`(7sBpQ-J1sZT!SWO8 zny6pI83Bu8TD#m7^;}nA9S2s(+(3la?Y8T4B~?Q}<)`5capD*l{Z(d`FK4_z(o*=P zMQ))KPWRYS`if=OkCa5~aX+?{-26blC;-#A%iTzbkstD-K+5C~w4g-IGAUgUI9c@5aN~1421$F!FY;x+B#NVl<|xNYk{3(gaxMu@a$&PKX|l=C##x5YhOg5FLu|jA9+g6w?B6S#9Q1GZYE(f{ zvj~zDeQxHiYmEVxdV(}xo>kU}21hLpifj0V_O+CU6Y!9;I4_RN#im3RQ6b^OB-uG^ z*Cg!DNwH*%cI#y|+ply>T%c;u2GSjack}I+DMTKXD5fb z$^@_p=%SEg2DM56T8nF`ISf&SP0_CUgJ?cls{kZF+9NpzAbqIqOj$k*q!sO^j8%BA z>52Yf+UQAeHByxJ+#m(U3#~p3WS))1WKwt>4RuM37c((iLl#s$Uj|{N4vi6S8m5sr z2v=Yoo+#lX3Y!ygYD`3UA1GRBiYYf*7zb9n0E!UNnW1wZ4bkan%N>mQMsyHpwGyrF z^MK5Uq2ms7Gres2Vu18v7QFo8vn+SV>#3|jHbi;(1$#6R#gstV5Kz|$0KxgOQQIt#73ai!0s4B67xK2f{ z-msWiOwehHTomEar_5Me?p7={$MbD@87=B58mN;p$~9Rp7Ki{Sk)lL~4Qm2=*whH^ z%uj6tuy$c?+O(<=8Z1;x<^& z5h>>#pphR)5YsS-UXG4%qovy;_(SMiJ&-LhkKUJeAMgsmOw?=p%bNVOzBu@_z5ksW zD``lG>A$86Wmo60J*hiQa$;rufEidlV$Dt6=mZcb zJ=9^34Y7~v^tI$G;soV!woqxaP1aM}T%!k?)u}ZT!ko(qN7(OX#qHt&hqfFFr8FjP z35kymp{6UDks!x%l(Rc6%iU@;No}fq9fhmDHv)rAOYAj0cmw>yqp%HZ6;9JkgRDI$)(GJ#@YdZ zrmWmJb6_3>(tbH&<6@#SEAW>Uc1x#e#Ch*iRN5GJzyS!r)AFzB{Nl zwSdODta?rbDK9Rt2wp?68v?(r|fQov$pb@u1P8!-~G8k;6V3Z6vHyNEt zokwR>Ru%`w6;pkuJt}YeCd!r1z{*ddo@g_JGL}yuOf;U9hJhm8wqJ5aREe=-xx!+B zO-|ty8YBu2M9CwkzUr$pPDl{g$_urvH0sS?-C>O$jOL5ccwuytwm^9|2wfUmcF~vY z)}}K#SV$Dy$Vst{6dhSP{S2~y!FvUI4r7BNx*LI;RuRI4+5{m{E{KR?+S|E(m#;an zKCGl`b8)=uJE>TdtE1gDhV>%|B8iIV$3Y`1h_ z8`M8tdJ81_oCVr$kfsdWu{=yO4D$j z?-^JhrQE!J$(%s)FFb*zE+?W!3lXJ7F~IYMxmz-Tx*$YaL$E|8?=c3&f&(}g6ms(i zxbw0sAH8`)8pedQFE~0#Pa)mQ$wF!Z1}Fn#WMp7W0GZgrBqi6kYZh(u3(1|AULd%AQpj0WshFBTRhD>*`8zQBpQutH*Xc=?fMi$ zs*W#gmz^YoS!5X^TXl0YA1#FKKHxBFs}6pAhUI*UsTR6e%)VG0IfUVrJyU|%FGimi z>!YLYFWYjm-&Uy2;OIN6Ky%bY2(a(i79%GA&X>-76EKc~XY(T_qB_hArR(O&XI9Nf zxtea)h=ifOnjCP~8cC|q{4V>uyA{4vkI&gC)C=~>G@%U}J4Bfzg*9t)Us#xqO=H@u z^OXw9w8925F%md;S)*2PNAYIG?a1J2*zsvwV&vJ8PQ!*eMP*@1GdXejbzf^M?8ZXv zTUQ;)xLc!uz$S3Fak))(<@t8$OIvxUD=G+}By zV86#br}$W_NBf0nzv$CW@Oq9G0@v0ko6LYvw4nuS$+gxuqiQ$1UCVMh!k%Gk^$7>a z-(p~vDzC#cRBRV8mE{sVyn{Xgm$o(3>Ml(=rpr1SueUpyl$ai&5l;hvb>wO!G^uM4G0>e)N^`Qx+#Vs6&al0)5xlq3Q@+{|THVo@Y;?O;G-MY&yd46Ih6jrh1cE7ZU+t#qmp6T`Sd;xsL4 zC`VR;%~I`fSt(c4#Y3^^xR>T}ViUMpY3(M6w>Ej*nQMd&D2nSy)dF=!J5a5y+=DSj zy+b=RK)5yvDF?MS^Cn1G;Ht&BN-#h$unedzse-PZco5Eruc|N!#%5Az(gAH30Xr^R zN9j_-$QXb%h?^h_kwBU2?07^N`E&sGHV~z>6GLeOa7OK*!h5|DH0dCGvgred{D@{h zFHG7oW9E8W2A6hhir;qksdNqH0)0$bXzk(G2iCi`fzu|ar37ql3~0R?`=9|m)&=HT zse$dF7E-+R_02Z4!`}H(%gE_d#;Ps=j|s@p#&E&B)&Ii19Zk(`*+Er~2EUU`n@NMJ z=Xx{(mQ4$rQoEys+TL$k!cf_+y|V{(JMkc#UVQ*6A0CFmDOa?MBGpISLycssS<22^ zhq}I@Gis^9z)_9B{*;IpP|u zD(Y3YS{yiZx;28(-2LIu-i}6Qht`r}(iuL7;*9#VUV=&c9FRsT+dAgx!=dQA)@93B z`aanrj9l%8N>^r#N4+|?>$?p9WCr%Z$?r(dye1x$!(D|`)jEkou7jIPkGu24d>o8} zp6MbZE~c{qSDb&b^*3tPj`lwxr)^f9@M(Q9ztM1B6pn(mz0uiR$&bT zu}W5`Q)c3ErC?)eRrGldSao~qEPbH1QHzWeb`DXLgp`y&QFDy7p_&6-BV`!2s2Oc5 z2gX`Y=x+OJzuBO(t+-({w*BFUr&f8fMw3?-r`SQPPg5=JhEf~KHB}9j3@M&G93&aI zA=Rc8)EzYlRUJZb*A!!>gSzKSJR5vtnaNhfEMm7lDOdjVY|uPxfIvedB`$kV<%}B< zTOS{>Xa=>udL!xBa#@bq(@ys5??pkd?w&$KxfvaN4r-8&nh0D^3G(Upq7LTrL)U=f z%fSVB-!MY8kHk$OhI!6`PNG> ztLi4@vKRh*^q@2W>K<4J-UVTO!|n^_d>XT@eSxpl**;B(0at6DDV3^bsqS2BVWFA|MDv;IU2jZ&L+Y@Hn;h}KZmi4%7{6Ji_z5`b+ zeju*e=m1wOeju)R_<<^p9*8L#+#rf#2jX$P&*TJ86gv=)83{Tm!4t(E3Xe{Bk4Iw< zg-2(e$D^?W@wky=+9!$~h{uf@gC~kTBp&KQy&N852jU658#EzuAeOKwfF(o@#9~H^ ztRudwByu2@u#ABvL=MDan!@yw4v4i;vyDJ06Y8g`QX4RVsGe>8s?rtc}VAUd^LKil8Rh z(t1`~9i_~?e5xXvCX1SPzLZTeHNzU-JT!Fijc>fj(=427Ye=u5UeQzP6_>Te=T!`Y z1;&}Up|d|#Uh^A6)6j7gG2f~goP0VtORfz>F8rLp>p<7q7Xt|Ww|D3?$n?mi3*IP>&J z#Q`0~hSgrj&rVLla7S;{D!wKun(=jnS&0FYkZzB1TlYJXzz;6+7NUWm90(rV|~t6E_DL zt3gA*M_|c}aoQxE7J6r+ix`vXz#15!xGZT7ld8q#&SPsW4j$X~!g#HJhr88$h3y@) zODZ~EpQT7_<^`jLk`>_5ylH4O7=Seln_KH#M=+LE(Ra(pQ{=RBd!)wJqL9Z3vR57S z!7U*5SDA$BAy^*ftUl)p)ca0@h+%Tlokl$)IO_?8Jm8d@fpYx_M-k$W&IJc?nTTFM zqZrUuVXEYdoh|bzbz0+LoZYmz0cRRews=E&TLU*C^Mqgwwn0bmH5B+Fv`iT5uzRG1IxqyHAbWyt1Z*QPB3~3@PJXnKYr+Lu480=U4Y1lTa6NU*&>1n&CLs0z zofKdbfs{Tes*;f=H;+4WS^seii(SDDaV|M^XH&1@GH`{H>9t`9$Yebpae&d2i|gwT zyx3=Y$L9ycsWtkNGEy4Y;%+dajvfke^*VY%?J$;PmYtLDEB9KP` z%sf%1h07@%wC9NC>SHMqlzYIjH@oZ&0H?j zmOw_qy{byjI+m_qxpL*|t=qRJqtzT6A9dSWim$A#blV!AoGkwH{Zbk2mM`3jMAi~t zCGJ|5!8;u%2JeCCK2= zNsk?pT2-VJMLrKme0b3I&643|H4yl4RFS*)wsU;Yo|cP+e3vy*m%u2Q>YK$9Fx|5Tqbw%3FNrZm7>^l z9oRTgJDXl{t*#BD_hI9LWh0slG-3X{ml~zjIfn+owptP!x zAc~~OG6IQFtQh4^6%Ri1*-m1knIE91>4$>6E@V=q6-rbQ&)S|EN8&+h0xJF0-p!5^ zTTd&80_#N5sOUsHiF+(wI|t5&#CZ}PigM-Z>wQ4!FKofOLrav@De!{93%{)8d2_h7 zAPtIkqRIuiI@Hz+X#&%OcAVs(nM>@8rMGNISAMs35VZp;&ipxtYy)qCM1LV$8X>K+ zaMMCx)N@s>U(JUX2T*ITf19r72C=|X}2G)8w09%7o8|E&h|MJYkb#7d;E5htt<~;TubrL zDy@}6Ixz2`wR37D)FK___Nq`GzPc$zPK$z6=Fg!KVr^5a%=qKOSJSG+MB&vcA^P?? zRKj8jinqRULJmrEs+~h8zEK&*+Q3$E?JQC|%UB<-Jge?PMcGqY$RK4QT0PWyVN|89 zB1|%fACiAH)wHbwni;gHoPW5^X&n^TtwO>l&^!6-(C$EC-a<^;FpR3wuPNoh)lQ5~ z1`_1e&S!+C_`GHqis%px1W^oaLtfZJ=_}8?$cS{o0>_bgeL?MH!?l1r={VqIMNUi{ zxP+rhYqY|@J&qrehe-8|V5=t#qo#fQ0;2ddCZa6Sqh=&T=VY#O@!45~3MMo;v4=Zm zOB1Ce%CYD7QDzE?Vk#!*_(zn#16s`vM4`ufOLiZn2ZX7YeY&zsJ9c0hB*=#oRIr4I zYAbY8E2>JJjfuAMdBEYr6rUgEV^LGc3W349*leOOUsUu)lC zI|P*cC_v6!U^B-`%jktnNcmHan>Nq+A9V)PiAtFrEwtIK?Qm1dnyn`YbSl&mhs^_w zxf(YABCcsqp06itY}oMcQ%hi!3o@dZve4X+D9?ByPwUSwQk~@)EUAV8i6Cl1*zQ)O zZbql8)n-{>sB4Do9-dklzF(0OyIy_0x>%K`+*nFc-jN$hI#LJmqT`ql@YLcu-E(ab zR|^DjR0m*Ah)~Jnq}00@a^HlYPV^jH)Up&C#dH^{!@j}_jqSA<%gQT9i4on%DGgeO z4UOqOs2XDcO3fD`H1^T{YLT)Jr^(fW3)6;65B75ca`D zG49~0BOKK258bc7xHO)u%ga$a_zdCBPAw@IwWbV;`amGK?^9qep@>9FgiPQJ5g5K` zO41T&jf9{iX%ZK{=rqagp=(%8w=^-DRFzY5H0lLWbA8YSwS{sF{BE;Dp&{yC+9z*x z;A>*&6>VoTNtwFc+2HBVXmeBkwvTrq8~Ea=vXb1isSjgQsc2jmci2Mb+NL%yKv9cp z>nasUFk9vamqJqG&tKoMh|FD{z)cbE3JsvNv$+D^faud}fg-S&xU{wOPSwLxGuV_nMb|5}2Y~AcM&45v0+G>qd?lBF)-jiq6JEi?dIqLPZ(-KioNG6@9|YA z>Qa5<^9V66{~$JDsIUVbXd8-}!Zw=Tz{|I-W&_W`8)<9i3#fLgv-%A?ahq}p%;LXL zb8S`|xrw=6AiySQYvpyN#PD=lUMy%30iH&RF67GA#QxQOOps=C1VlmxM`t=a*>%XR zEz5LtU#T|1Ud5-E7q<^k6P_>GWuKV)$BE)HkdTqdp-XFS?Fp2EFhmqKs|v2w8Tfi5 z$Sx=^-Fkj56;de$-abfnG=ooX9|If3q8Xv7)AdAeOlpK%Oegk5v-LKb#kMguPt0g! zPn_tb&isy?CZ33}1j;s1(lq{dUSTO^bw&AO#_xVMLqVGH5E6Mq1z{AUv+Y|b398zS z=W1DN(P33oWUgBr9v6>LnDuFZVyd1IjP-;;{8fo!r^_j`Bgw>S1eF`rx$ETS<}$0j z+&-Pmx0Ah9rm4xIthb06m0`)cuXmO}DfZWu9b)S65j}|xGn1)C4467nwQ+JwU{p)r zI9lh37|kP(<8E-LBPJm@sEnTrT_vb(ZQ7*7VG1&xVU2GfGW=`e_j% z64bRbyK)Su7=X&EKu6TGx{EiBRCgs@Ujdg3RQR*jz%zUlI8xlR87R@D4M=87i9!-6pL^t zs)K3yaw zr;!4dd;^(kD({wdilgEkDsR;gw+jg3C_~on^>9UaZ*DZ5FyHqtP^=Hb(DH?zU%f5b zN%>K|ZBB=y>U>>JfeH~_SaY1X?nDy!dIDW*_Z`Yfau4p5`d$V98i6&}H}}xjQKbA7 z?9r{`^TZI5S!g{MxS+C!oM7V(2c?0W(V-q4=Gv!WBM5vWMQ2}b4(q)YV9os%5qGlX zI!B;Vq6@2pqmcB5tZusRX!!8PWLifkE!dGkn;K`%*Pag!{1HvMBIuzbXA-!#ImcuF4G#$?(_U(3#xZ z$z9px%avHn2*uDwx}VoN#W|z8ld)JI%u(*)Ck88l>h6AZG};KK+E2uLsJ!UHsjVP%p4dqsaW^O^$oVfBJ z$nJQ(N5Dj+#;I#!%nyR6YX;@h*^(QrjfQB{hoTxO!;sn*3(X!kVHjH4qOcvS@_oJR zw2YHjWu4+K_KC-pbtl@aV>E;k;xPiBlG7IkaspeUJ>r1@);Ma8QNRObSP- z_>~mN4mVVZt_Ya~mxo};ACoL@_Nm%Ox5dS?X$dv-ZCVEiu>TdKTKIq-HI`XmcB+Ho|xp z#SQf{c5+)0%@?&YaK4wQa5JmL4vu(1%5&%rhB0NUrsrV-rAf z&NH)8+&xO=OaOZtd(t#Qg|-rpqKQ8Bd8Bb%{!na}RG^OKuZTpe06F9yF(&=_wj13O zCd-MN5WxzDq(TMd%=O zoaJVdFG)ddsZ=CyeH875fQ@1*pX6i&T3$eav?0p07luh-!zEu6!zN@hC@h4s@Eqp} z`#E4kM0iM+`-EK~upt8M9V4^AE#wAjv8cGbGc81AqFjmzHaXFov1+cx`Aj)a9aA;4 zQd~1h*=%kHPHEOgY&B>;24bag#F9%XSJ>QO3UvoIL{tFCb&78-Kz&#tbMlPnoQ+=w z8-msW&d9ACpuC5bFX#diqt2@dAkv2+GS7KAau$uOuq+o;c9UyTEVRYHHDETRot|S- z?Z+~PJj@Y2WkGpSOlNeCQumq#pe_gp%I|o}3VBfrN%dP}CCT8ek@%EN@7q<(M7yrmeY+F--DS zKcAJ^pe;lq!VbgdRx+a-ZL(+Ug4xg!?V3zLZo^oj061n{m=LG`9nh=f3n@cR9 z^o-4O-#y1QUQf@BrIVFqm}F`htgn02G4cXN_@pMriSEsT(x#TsHd-NAv?zhzs$5Mi zMyxAva9||3eED&1ij1Rl;x(SWp^!WpDIak8k-dXh>jdJsYMEMQEZvd|6!JsK*NuKR zU-EtF$omxn_8n1q4x~2plTsa`6b-l2T3fn=1Z#|!rzXk_;wYw@j)4O-8gn3tVnUpZ z^w6}NIjt~3$rENXg3pE!1z?c_8*IFn9Weh21=77xATeDj6NURc7FzQwjvE8pLf9w3 n%0 Promise.resolve()), + removeValidator: mock(() => Promise.resolve()), +}; + +const mockChainInfoService = { + getInfo: mock(() => + Promise.resolve({ + validators: ["MOCK-0x123", "MOCK-0x456"], + committee: ["MOCK-0x123"], + archive: [], + pendingBlockNum: "MOCK-1", + provenBlockNum: "MOCK-1", + currentEpoch: "MOCK-1", + currentSlot: "MOCK-1", + proposerNow: "MOCK-0x123", + }) + ), +}; + +mock.module("../../services/validator-service.js", () => ({ + ValidatorService: mockValidatorService, +})); + +mock.module("../../services/chaininfo-service.js", () => ({ + ChainInfoService: mockChainInfoService, +})); + +describe("Discord Commands", () => { + describe("addValidator", () => { + test("should add a validator with valid address", async () => { + const interaction: DiscordInteraction = { + data: { + name: "add-validator", + options: [ + { + name: "address", + value: "0x123", + type: ApplicationCommandOptionType.STRING, + }, + ], + }, + }; + + const response = await addValidator.execute(interaction); + expect(response.data.content).toBe("MOCK - Added validator 0x123"); + }); + + test("should handle missing address", async () => { + const interaction: DiscordInteraction = { + data: { + name: "add-validator", + options: [], + }, + }; + + const response = await addValidator.execute(interaction); + expect(response.data.content).toBe( + "MOCK - Please provide an address to add" + ); + }); + }); + + describe("adminValidators", () => { + test("should get committee list", async () => { + const interaction: DiscordInteraction = { + data: { + name: "admin", + options: [ + { + name: "committee", + type: ApplicationCommandOptionType.SUB_COMMAND_GROUP, + options: [ + { + name: "get", + type: ApplicationCommandOptionType.SUB_COMMAND, + options: [], + }, + ], + }, + ], + }, + }; + + const response = await adminValidators.execute(interaction); + expect(response.data.content).toContain( + "MOCK - Committee total: 1" + ); + expect(response.data.content).toContain("MOCK-0x123"); + }); + + test("should get validators list", async () => { + const interaction: DiscordInteraction = { + data: { + name: "admin", + options: [ + { + name: "validators", + type: ApplicationCommandOptionType.SUB_COMMAND_GROUP, + options: [ + { + name: "get", + type: ApplicationCommandOptionType.SUB_COMMAND, + options: [], + }, + ], + }, + ], + }, + }; + + const response = await adminValidators.execute(interaction); + expect(response.data.content).toContain( + "MOCK - Validators total: 2" + ); + expect(response.data.content).toContain("MOCK-0x123"); + expect(response.data.content).toContain("MOCK-0x456"); + }); + + test("should remove validator", async () => { + const interaction: DiscordInteraction = { + data: { + name: "admin", + options: [ + { + name: "validators", + type: ApplicationCommandOptionType.SUB_COMMAND_GROUP, + options: [ + { + name: "remove", + type: ApplicationCommandOptionType.SUB_COMMAND, + options: [ + { + name: "address", + value: "0x123", + type: ApplicationCommandOptionType.STRING, + }, + ], + }, + ], + }, + ], + }, + }; + + const response = await adminValidators.execute(interaction); + expect(response.data.content).toBe( + "MOCK - Removed validator 0x123" + ); + }); + + test("should handle invalid command structure", async () => { + const interaction: DiscordInteraction = { + data: { + name: "admin", + options: [], + }, + }; + + const response = await adminValidators.execute(interaction); + expect(response.data.content).toBe( + "MOCK - Invalid command structure" + ); + }); + + test("should handle service errors", async () => { + const error = new Error("Service error"); + const errorMockValidatorService = { + addValidator: mock(() => Promise.reject(error)), + removeValidator: mock(() => Promise.reject(error)), + }; + + mock.module("../../services/validator-service.js", () => ({ + ValidatorService: errorMockValidatorService, + })); + + const interaction: DiscordInteraction = { + data: { + name: "add-validator", + options: [ + { + name: "address", + value: "0x123", + type: ApplicationCommandOptionType.STRING, + }, + ], + }, + }; + + const response = await addValidator.execute(interaction); + expect(response.data.content).toBe( + "MOCK - Failed to add validator: Service error" + ); + }); + }); +}); diff --git a/tooling/sparta-aws/src/commands/addValidator.ts b/tooling/sparta-aws/src/commands/addValidator.ts new file mode 100644 index 0000000..149b1af --- /dev/null +++ b/tooling/sparta-aws/src/commands/addValidator.ts @@ -0,0 +1,45 @@ +import { SlashCommandBuilder } from "@discordjs/builders"; +import { PermissionFlagsBits } from "discord.js"; +import { ValidatorService } from "../services/validator-service.js"; +import type { CommandModule, DiscordInteraction } from "../types/discord.js"; +import { + ApplicationCommandOptionType, + createMockResponse, +} from "../types/discord.js"; + +export const addValidator: CommandModule = { + data: new SlashCommandBuilder() + .setName("add-validator") + .setDescription("Add a validator") + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .addStringOption((option) => + option + .setName("address") + .setDescription("The validator to add") + .setRequired(true) + ), + + async execute(interaction: DiscordInteraction) { + try { + const address = interaction.data.options?.find( + (opt) => + opt.name === "address" && + opt.type === ApplicationCommandOptionType.STRING + )?.value; + + if (!address) { + return createMockResponse("Please provide an address to add"); + } + + await ValidatorService.addValidator(address); + return createMockResponse(`Added validator ${address}`); + } catch (error) { + console.error("Error in add-validator command:", error); + return createMockResponse( + `Failed to add validator: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + }, +}; diff --git a/tooling/sparta-aws/src/commands/adminValidators.ts b/tooling/sparta-aws/src/commands/adminValidators.ts new file mode 100644 index 0000000..08f6772 --- /dev/null +++ b/tooling/sparta-aws/src/commands/adminValidators.ts @@ -0,0 +1,135 @@ +import { SlashCommandBuilder } from "@discordjs/builders"; +import { PermissionFlagsBits } from "discord.js"; +import { ChainInfoService } from "../services/chaininfo-service.js"; +import { ValidatorService } from "../services/validator-service.js"; +import type { CommandModule, DiscordInteraction } from "../types/discord.js"; +import { + ApplicationCommandOptionType, + createMockResponse, +} from "../types/discord.js"; + +// List of excluded validator addresses +export const EXCLUDED_VALIDATORS = [ + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + // ... add the rest of the excluded validators here +]; + +export const adminValidators: CommandModule = { + data: new SlashCommandBuilder() + .setName("admin") + .setDescription("Admin commands") + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .addSubcommandGroup((group) => + group + .setName("validators") + .setDescription("Manage validators") + .addSubcommand((subcommand) => + subcommand.setName("get").setDescription("Get validators") + ) + .addSubcommand((subcommand) => + subcommand + .setName("remove") + .setDescription("Remove a validator") + .addStringOption((option) => + option + .setName("address") + .setDescription("The validator to remove") + .setRequired(true) + ) + ) + ) + .addSubcommandGroup((group) => + group + .setName("committee") + .setDescription("Manage the committee") + .addSubcommand((subcommand) => + subcommand + .setName("get") + .setDescription("Get the current committee") + ) + ), + + async execute(interaction: DiscordInteraction) { + try { + // Get subcommand group and subcommand from the nested options + const subcommandGroup = interaction.data.options?.find( + (opt) => + opt.type === ApplicationCommandOptionType.SUB_COMMAND_GROUP + ); + const subcommand = subcommandGroup?.options?.find( + (opt) => opt.type === ApplicationCommandOptionType.SUB_COMMAND + ); + const subcommandOptions = subcommand?.options || []; + + console.log("Executing admin command:", { + subcommandGroup: subcommandGroup?.name, + subcommand: subcommand?.name, + options: subcommandOptions, + }); + + const { validators, committee } = await ChainInfoService.getInfo(); + + // Ensure validators and committee are arrays + const validatorList = Array.isArray(validators) ? validators : []; + const committeeList = Array.isArray(committee) ? committee : []; + + const filteredValidators = validatorList.filter( + (v) => !EXCLUDED_VALIDATORS.includes(v) + ); + const filteredCommittee = committeeList.filter( + (v) => !EXCLUDED_VALIDATORS.includes(v) + ); + + if (!subcommandGroup?.name || !subcommand?.name) { + return createMockResponse("Invalid command structure"); + } + + if ( + subcommandGroup.name === "committee" && + subcommand.name === "get" + ) { + return createMockResponse( + `Committee total: ${ + committeeList.length + }.\nCommittee (excl. Aztec Labs):\n${filteredCommittee.join( + "\n" + )}` + ); + } else if (subcommandGroup.name === "validators") { + if (subcommand.name === "get") { + return createMockResponse( + `Validators total: ${ + validatorList.length + }.\nValidators (excl. Aztec Labs):\n${filteredValidators.join( + "\n" + )}` + ); + } else if (subcommand.name === "remove") { + const address = subcommandOptions.find( + (opt) => + opt.name === "address" && + opt.type === ApplicationCommandOptionType.STRING + )?.value; + if (!address) { + return createMockResponse( + "Please provide an address to remove" + ); + } + await ValidatorService.removeValidator(address); + return createMockResponse(`Removed validator ${address}`); + } + } + + return createMockResponse( + `Invalid command: ${subcommandGroup.name} ${subcommand.name}` + ); + } catch (error) { + console.error("Error in admin command:", error); + return createMockResponse( + `Failed to execute admin command: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + }, +}; diff --git a/tooling/sparta-aws/src/commands/getChainInfo.ts b/tooling/sparta-aws/src/commands/getChainInfo.ts new file mode 100644 index 0000000..89077b7 --- /dev/null +++ b/tooling/sparta-aws/src/commands/getChainInfo.ts @@ -0,0 +1,37 @@ +import { SlashCommandBuilder } from '@discordjs/builders'; +import { ChainInfoService } from '../services/chaininfo-service.js'; + +export const getChainInfo = { + data: new SlashCommandBuilder() + .setName('get-info') + .setDescription('Get chain info'), + + async execute(interaction: any) { + try { + const { + pendingBlockNum, + provenBlockNum, + currentEpoch, + currentSlot, + proposerNow, + } = await ChainInfoService.getInfo(); + + return { + type: 4, + data: { + content: `Pending block: ${pendingBlockNum}\nProven block: ${provenBlockNum}\nCurrent epoch: ${currentEpoch}\nCurrent slot: ${currentSlot}\nProposer now: ${proposerNow}`, + flags: 64 + } + }; + } catch (error) { + console.error('Error in get-info command:', error); + return { + type: 4, + data: { + content: 'Failed to get chain info', + flags: 64 + } + }; + } + }, +}; diff --git a/tooling/sparta-aws/src/commands/index.ts b/tooling/sparta-aws/src/commands/index.ts new file mode 100644 index 0000000..6ba52b6 --- /dev/null +++ b/tooling/sparta-aws/src/commands/index.ts @@ -0,0 +1,10 @@ +import { ExtendedClient } from "../types/discord.js"; +import { addValidator } from "./addValidator.js"; +import { getChainInfo } from "./getChainInfo.js"; +import { adminValidators } from "./adminValidators.js"; + +export async function loadCommands(client: ExtendedClient) { + client.commands.set("validator", addValidator); + client.commands.set("get-info", getChainInfo); + client.commands.set("admin", adminValidators); +} diff --git a/tooling/sparta-aws/src/index.ts b/tooling/sparta-aws/src/index.ts new file mode 100644 index 0000000..fc12556 --- /dev/null +++ b/tooling/sparta-aws/src/index.ts @@ -0,0 +1,117 @@ +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; +import { Client, GatewayIntentBits, Collection } from "discord.js"; +import { SSMClient } from "@aws-sdk/client-ssm"; +import { loadCommands } from "./commands/index.js"; +import { verifyDiscordRequest } from "./utils/discord-verify.js"; +import { getParameter } from "./utils/parameter-store.js"; +import type { ExtendedClient } from "./types/discord.js"; + +const ssm = new SSMClient({}); +let client: ExtendedClient; + +export const handler = async ( + event: APIGatewayProxyEvent +): Promise => { + try { + // Verify Discord request + const signature = event.headers["x-signature-ed25519"]; + const timestamp = event.headers["x-signature-timestamp"]; + + if (!signature || !timestamp) { + return { + statusCode: 401, + body: JSON.stringify({ error: "Invalid request signature" }), + }; + } + + const isValid = await verifyDiscordRequest( + event.body || "", + signature, + timestamp + ); + if (!isValid) { + return { + statusCode: 401, + body: JSON.stringify({ error: "Invalid request signature" }), + }; + } + + // Initialize Discord client if not already initialized + if (!client) { + // Use environment variable in development, Parameter Store in production + const token = + process.env["ENVIRONMENT"] === "development" + ? process.env["DISCORD_BOT_TOKEN"] + : await getParameter("/sparta/discord/bot_token"); + + if (!token) { + throw new Error("Discord bot token not found"); + } + + client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + ], + }) as ExtendedClient; + + client.commands = new Collection(); + await loadCommands(client); + await client.login(token); + } + + // Parse the interaction + const interaction = JSON.parse(event.body || "{}"); + + // Handle ping + if (interaction.type === 1) { + return { + statusCode: 200, + body: JSON.stringify({ type: 1 }), + }; + } + + // Handle command + if (interaction.type === 2) { + const command = client.commands.get(interaction.data.name); + if (!command) { + return { + statusCode: 404, + body: JSON.stringify({ error: "Command not found" }), + }; + } + + try { + const response = await command.execute(interaction); + return { + statusCode: 200, + body: JSON.stringify(response), + }; + } catch (error) { + console.error("Error executing command:", error); + return { + statusCode: 500, + body: JSON.stringify({ + type: 4, + data: { + content: + "There was an error executing this command!", + flags: 64, + }, + }), + }; + } + } + + return { + statusCode: 400, + body: JSON.stringify({ error: "Unknown interaction type" }), + }; + } catch (error) { + console.error("Error handling request:", error); + return { + statusCode: 500, + body: JSON.stringify({ error: "Internal server error" }), + }; + } +}; diff --git a/tooling/sparta-aws/src/local-dev.ts b/tooling/sparta-aws/src/local-dev.ts new file mode 100644 index 0000000..aec7b77 --- /dev/null +++ b/tooling/sparta-aws/src/local-dev.ts @@ -0,0 +1,116 @@ +import express, { Request, Response } from "express"; +import { handler } from "./index.js"; +import { verifyKey } from "discord-interactions"; +import dotenv from "dotenv"; +import { APIGatewayProxyEvent } from "aws-lambda"; + +dotenv.config(); + +const app = express(); +const port = process.env["PORT"] || 3000; + +// Logging middleware +app.use((req, res, next) => { + console.log(`${new Date().toISOString()} ${req.method} ${req.path}`); + console.log("Headers:", req.headers); + next(); +}); + +// Raw body buffer handling for all routes +app.use(express.raw({ type: "*/*" })); + +// Handle Discord interactions +app.post("/discord-webhook", async (req: Request, res: Response) => { + try { + const signature = req.headers["x-signature-ed25519"]; + const timestamp = req.headers["x-signature-timestamp"]; + const body = req.body; // This will be a raw buffer + + console.log("Received Discord interaction:"); + console.log("Signature:", signature); + console.log("Timestamp:", timestamp); + console.log("Body:", body.toString()); + + if (!signature || !timestamp) { + console.log("Missing signature or timestamp"); + return res.status(401).json({ error: "Invalid request signature" }); + } + + const isValid = verifyKey( + body, + signature as string, + timestamp as string, + process.env["DISCORD_PUBLIC_KEY"]! + ); + + console.log("Verification result:", isValid); + + if (!isValid) { + console.log("Invalid signature"); + return res.status(401).json({ error: "Invalid request signature" }); + } + + const event: APIGatewayProxyEvent = { + body: body.toString(), + headers: { + "x-signature-ed25519": String(signature || ""), + "x-signature-timestamp": String(timestamp || ""), + }, + multiValueHeaders: {}, + httpMethod: "POST", + isBase64Encoded: false, + path: "/discord-webhook", + pathParameters: null, + queryStringParameters: null, + multiValueQueryStringParameters: null, + stageVariables: null, + requestContext: { + accountId: "", + apiId: "", + authorizer: {}, + protocol: "HTTP/1.1", + httpMethod: "POST", + identity: { + accessKey: null, + accountId: null, + apiKey: null, + apiKeyId: null, + caller: null, + clientCert: null, + cognitoAuthenticationProvider: null, + cognitoAuthenticationType: null, + cognitoIdentityId: null, + cognitoIdentityPoolId: null, + principalOrgId: null, + sourceIp: String(req.ip || ""), + user: null, + userAgent: String(req.headers["user-agent"] || ""), + userArn: null, + }, + path: "/discord-webhook", + stage: "prod", + requestId: "", + requestTimeEpoch: Date.now(), + resourceId: "", + resourcePath: "/discord-webhook", + }, + resource: "/discord-webhook", + }; + + const result = await handler(event); + console.log("Handler response:", result); + res.status(result.statusCode).json(JSON.parse(result.body)); + } catch (error) { + console.error("Error handling request:", error); + res.status(500).json({ error: "Internal server error" }); + } +}); + +// Health check endpoint +app.get("/health", (_req: Request, res: Response) => { + res.json({ status: "ok" }); +}); + +app.listen(port, () => { + console.log(`Local development server running at http://localhost:${port}`); +}); diff --git a/tooling/sparta-aws/src/package.json b/tooling/sparta-aws/src/package.json new file mode 100644 index 0000000..27c872a --- /dev/null +++ b/tooling/sparta-aws/src/package.json @@ -0,0 +1,44 @@ +{ + "name": "sparta-discord-bot", + "version": "1.0.0", + "description": "AWS Lambda function for Sparta Discord bot", + "main": "dist/index.js", + "type": "module", + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "test": "jest", + "deploy": "npm run build && cd ../terraform && terraform apply", + "lint": "eslint . --ext .ts", + "format": "prettier --write \"src/**/*.ts\"", + "dev": "bun dev::register-commands && bun --watch local-dev.ts", + "dev::register-commands": "tsx scripts/register-commands.ts" + }, + "dependencies": { + "@aws-sdk/client-ssm": "^3.0.0", + "@aws-sdk/client-ecs": "^3.0.0", + "@discordjs/rest": "^2.0.0", + "discord.js": "^14.0.0", + "discord-interactions": "^3.4.0", + "aws-lambda": "^1.0.7", + "express": "^4.18.2", + "dotenv": "^16.0.3", + "node-fetch": "^3.3.0" + }, + "devDependencies": { + "@types/aws-lambda": "^8.10.92", + "@types/node": "^18.0.0", + "@types/express": "^4.17.17", + "@types/node-fetch": "^2.6.4", + "@typescript-eslint/eslint-plugin": "^5.0.0", + "@typescript-eslint/parser": "^5.0.0", + "eslint": "^8.0.0", + "prettier": "^2.8.0", + "typescript": "^5.0.0", + "jest": "^29.0.0", + "@types/jest": "^29.0.0", + "ts-jest": "^29.0.0", + "tsx": "^4.7.0", + "@types/bun": "latest" + } +} \ No newline at end of file diff --git a/tooling/sparta-aws/src/scripts/register-commands.ts b/tooling/sparta-aws/src/scripts/register-commands.ts new file mode 100644 index 0000000..c94779e --- /dev/null +++ b/tooling/sparta-aws/src/scripts/register-commands.ts @@ -0,0 +1,48 @@ +import { REST } from "@discordjs/rest"; +import { Routes } from "discord.js"; +import dotenv from "dotenv"; +import { addValidator } from "../commands/addValidator.js"; +import { getChainInfo } from "../commands/getChainInfo.js"; +import { adminValidators } from "../commands/adminValidators.js"; + +dotenv.config(); + +const commands = [ + addValidator.data.toJSON(), + getChainInfo.data.toJSON(), + adminValidators.data.toJSON(), +]; + +const isDev = process.env["ENVIRONMENT"] === "development"; +const { DISCORD_BOT_TOKEN, DISCORD_CLIENT_ID, DISCORD_GUILD_ID } = process.env; + +if (!DISCORD_BOT_TOKEN || !DISCORD_CLIENT_ID || !DISCORD_GUILD_ID) { + console.error("Missing required environment variables"); + process.exit(1); +} + +const rest = new REST({ version: "10" }).setToken(DISCORD_BOT_TOKEN as string); + +async function main() { + try { + console.log( + `Started refreshing application (/) commands for ${ + isDev ? "development" : "production" + } environment.` + ); + + await rest.put( + Routes.applicationGuildCommands( + DISCORD_CLIENT_ID as string, + DISCORD_GUILD_ID as string + ), + { body: commands } + ); + + console.log("Successfully reloaded application (/) commands."); + } catch (error) { + console.error("Error registering commands:", error); + } +} + +main(); diff --git a/tooling/sparta-aws/src/services/chaininfo-service.ts b/tooling/sparta-aws/src/services/chaininfo-service.ts new file mode 100644 index 0000000..ad2ef98 --- /dev/null +++ b/tooling/sparta-aws/src/services/chaininfo-service.ts @@ -0,0 +1,86 @@ +import { getParameter } from "../utils/parameter-store.js"; +import { ECSClient, RunTaskCommand } from "@aws-sdk/client-ecs"; + +type ChainInfo = { + pendingBlockNum: string; + provenBlockNum: string; + validators: string[]; + committee: string[]; + archive: string[]; + currentEpoch: string; + currentSlot: string; + proposerNow: string; +}; + +const ecs = new ECSClient({}); + +export class ChainInfoService { + static async getInfo(): Promise { + try { + // In development mode, return mock data + if (process.env["ENVIRONMENT"] === "development") { + const mockInfo: ChainInfo = { + pendingBlockNum: "MOCK-123456", + provenBlockNum: "MOCK-123455", + validators: [ + "MOCK-0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "MOCK-0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + ], + committee: [ + "MOCK-0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "MOCK-0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + ], + archive: [ + "MOCK-0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + ], + currentEpoch: "MOCK-1", + currentSlot: "MOCK-12345", + proposerNow: + "MOCK-0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + }; + return mockInfo; + } + + // Production code (commented out for now) + // const clusterArn = await getParameter("/sparta/ecs/cluster_arn"); + // const taskDefinition = await getParameter("/sparta/ecs/task_definition"); + // const containerName = await getParameter("/sparta/ecs/container_name"); + // const ethereumHost = await getParameter("/sparta/ethereum/host"); + // const rollupAddress = await getParameter("/sparta/ethereum/rollup_address"); + // const chainId = await getParameter("/sparta/ethereum/chain_id"); + + // const command = new RunTaskCommand({ + // cluster: clusterArn, + // taskDefinition: taskDefinition, + // launchType: 'FARGATE', + // networkConfiguration: { + // awsvpcConfiguration: { + // subnets: ['subnet-xxxxxx'], + // securityGroups: ['sg-xxxxxx'], + // assignPublicIp: 'ENABLED' + // } + // }, + // overrides: { + // containerOverrides: [ + // { + // name: containerName, + // command: [ + // 'debug-rollup', + // '-u', ethereumHost, + // '--rollup', rollupAddress, + // '--l1-chain-id', chainId + // ] + // } + // ] + // } + // }); + + // const response = await ecs.send(command); + // TODO: Parse response and return actual data + throw new Error("Production mode not implemented yet"); + } catch (error) { + console.error("Error getting chain info:", error); + throw error; + } + } +} diff --git a/tooling/sparta-aws/src/services/validator-service.ts b/tooling/sparta-aws/src/services/validator-service.ts new file mode 100644 index 0000000..0d995dd --- /dev/null +++ b/tooling/sparta-aws/src/services/validator-service.ts @@ -0,0 +1,151 @@ +import { getParameter } from "../utils/parameter-store.js"; +import { ECSClient, RunTaskCommand } from "@aws-sdk/client-ecs"; + +const ecs = new ECSClient({}); + +export class ValidatorService { + static async addValidator(address: string): Promise { + try { + // const clusterArn = await getParameter('/sparta/ecs/cluster_arn'); + // const taskDefinition = await getParameter('/sparta/ecs/task_definition'); + // const containerName = await getParameter('/sparta/ecs/container_name'); + // const ethereumHost = await getParameter('/sparta/ethereum/host'); + // const rollupAddress = await getParameter('/sparta/ethereum/rollup_address'); + // const adminAddress = await getParameter('/sparta/ethereum/admin_address'); + // const chainId = await getParameter('/sparta/ethereum/chain_id'); + // const mnemonic = await getParameter('/sparta/ethereum/mnemonic'); + + // // First, fund the validator + // await this.fundValidator(address); + + // // Then add the validator to the set + // const command = new RunTaskCommand({ + // cluster: clusterArn, + // taskDefinition: taskDefinition, + // launchType: 'FARGATE', + // networkConfiguration: { + // awsvpcConfiguration: { + // subnets: ['subnet-xxxxxx'], // Replace with actual subnet IDs + // securityGroups: ['sg-xxxxxx'], // Replace with actual security group IDs + // assignPublicIp: 'ENABLED' + // } + // }, + // overrides: { + // containerOverrides: [ + // { + // name: containerName, + // command: [ + // 'add-l1-validator', + // '-u', ethereumHost, + // '--validator', address, + // '--rollup', rollupAddress, + // '--withdrawer', adminAddress, + // '--l1-chain-id', chainId, + // '--mnemonic', mnemonic + // ] + // } + // ] + // } + // }); + + // const response = await ecs.send(command); + return "Validator added successfully"; + } catch (error) { + console.error("Error adding validator:", error); + throw error; + } + } + + static async removeValidator(address: string): Promise { + try { + // const clusterArn = await getParameter('/sparta/ecs/cluster_arn'); + // const taskDefinition = await getParameter('/sparta/ecs/task_definition'); + // const containerName = await getParameter('/sparta/ecs/container_name'); + // const ethereumHost = await getParameter('/sparta/ethereum/host'); + // const rollupAddress = await getParameter('/sparta/ethereum/rollup_address'); + // const chainId = await getParameter('/sparta/ethereum/chain_id'); + // const mnemonic = await getParameter('/sparta/ethereum/mnemonic'); + + // const command = new RunTaskCommand({ + // cluster: clusterArn, + // taskDefinition: taskDefinition, + // launchType: 'FARGATE', + // networkConfiguration: { + // awsvpcConfiguration: { + // subnets: ['subnet-xxxxxx'], // Replace with actual subnet IDs + // securityGroups: ['sg-xxxxxx'], // Replace with actual security group IDs + // assignPublicIp: 'ENABLED' + // } + // }, + // overrides: { + // containerOverrides: [ + // { + // name: containerName, + // command: [ + // 'remove-l1-validator', + // '-u', ethereumHost, + // '--validator', address, + // '--rollup', rollupAddress, + // '--l1-chain-id', chainId, + // '--mnemonic', mnemonic + // ] + // } + // ] + // } + // }); + + // const response = await ecs.send(command); + return "MOCK: Validator removed successfully"; + } catch (error) { + console.error("Error removing validator:", error); + throw error; + } + } + + static async fundValidator(address: string): Promise { + try { + // const clusterArn = await getParameter('/sparta/ecs/cluster_arn'); + // const taskDefinition = await getParameter('/sparta/ecs/task_definition'); + // const containerName = await getParameter('/sparta/ecs/container_name'); + // const ethereumHost = await getParameter('/sparta/ethereum/host'); + // const chainId = await getParameter('/sparta/ethereum/chain_id'); + // const privateKey = await getParameter('/sparta/ethereum/private_key'); + // const value = await getParameter('/sparta/ethereum/value'); + + // const command = new RunTaskCommand({ + // cluster: clusterArn, + // taskDefinition: taskDefinition, + // launchType: 'FARGATE', + // networkConfiguration: { + // awsvpcConfiguration: { + // subnets: ['subnet-xxxxxx'], // Replace with actual subnet IDs + // securityGroups: ['sg-xxxxxx'], // Replace with actual security group IDs + // assignPublicIp: 'ENABLED' + // } + // }, + // overrides: { + // containerOverrides: [ + // { + // name: containerName, + // command: [ + // 'cast', + // 'send', + // '--value', value, + // '--rpc-url', ethereumHost, + // '--chain-id', chainId, + // '--private-key', privateKey, + // address + // ] + // } + // ] + // } + // }); + + // const response = await ecs.send(command); + return "MOCK: Validator funded successfully"; + } catch (error) { + console.error("Error funding validator:", error); + throw error; + } + } +} diff --git a/tooling/sparta-aws/src/tsconfig.json b/tooling/sparta-aws/src/tsconfig.json new file mode 100644 index 0000000..6f44a00 --- /dev/null +++ b/tooling/sparta-aws/src/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": false, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags + "noUnusedLocals": true, + "noUnusedParameters": true, + "noPropertyAccessFromIndexSignature": true, + + // Additional settings + "esModuleInterop": true, + "resolveJsonModule": true, + "types": ["bun-types", "jest", "node"] + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules", "dist"] +} diff --git a/tooling/sparta-aws/src/types/discord.ts b/tooling/sparta-aws/src/types/discord.ts new file mode 100644 index 0000000..e12ae71 --- /dev/null +++ b/tooling/sparta-aws/src/types/discord.ts @@ -0,0 +1,80 @@ +import type { Client, Collection, PermissionsBitField } from "discord.js"; +import type { + SlashCommandBuilder, + SlashCommandSubcommandsOnlyBuilder, + SlashCommandOptionsOnlyBuilder, +} from "@discordjs/builders"; + +export interface ExtendedClient extends Client { + commands: Collection; +} + +export interface CommandModule { + data: + | SlashCommandBuilder + | SlashCommandSubcommandsOnlyBuilder + | SlashCommandOptionsOnlyBuilder; + execute: (interaction: DiscordInteraction) => Promise; +} + +export interface CommandOption { + name: string; + value?: string; + options?: CommandOption[]; + type: ApplicationCommandOptionType; +} + +// https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-option-type +export enum ApplicationCommandOptionType { + SUB_COMMAND = 1, + SUB_COMMAND_GROUP = 2, + STRING = 3, + INTEGER = 4, + BOOLEAN = 5, + USER = 6, + CHANNEL = 7, + ROLE = 8, + MENTIONABLE = 9, + NUMBER = 10, + ATTACHMENT = 11, +} + +export interface DiscordInteraction { + data: { + name: string; + options?: CommandOption[]; + }; +} + +export interface InteractionResponse { + type: InteractionResponseType; + data: { + content: string; + flags: MessageFlags; + }; +} + +// https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-response-object-interaction-callback-type +export enum InteractionResponseType { + PONG = 1, + CHANNEL_MESSAGE_WITH_SOURCE = 4, + DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE = 5, + DEFERRED_UPDATE_MESSAGE = 6, + UPDATE_MESSAGE = 7, + APPLICATION_COMMAND_AUTOCOMPLETE_RESULT = 8, + MODAL = 9, + PREMIUM_REQUIRED = 10, +} + +// https://discord.com/developers/docs/resources/channel#message-object-message-flags +export enum MessageFlags { + EPHEMERAL = 64, +} + +export const createMockResponse = (content: string): InteractionResponse => ({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `MOCK - ${content}`, + flags: MessageFlags.EPHEMERAL, + }, +}); diff --git a/tooling/sparta-aws/src/utils/discord-verify.ts b/tooling/sparta-aws/src/utils/discord-verify.ts new file mode 100644 index 0000000..be14361 --- /dev/null +++ b/tooling/sparta-aws/src/utils/discord-verify.ts @@ -0,0 +1,25 @@ +import { getParameter } from "./parameter-store.js"; +import { verifyKey } from "discord-interactions"; + +export async function verifyDiscordRequest( + body: string, + signature: string, + timestamp: string +): Promise { + try { + // Use environment variable in development, Parameter Store in production + const publicKey = + process.env["ENVIRONMENT"] === "development" + ? process.env["DISCORD_PUBLIC_KEY"] + : await getParameter("/sparta/discord/public_key"); + + if (!publicKey) { + throw new Error("Discord public key not found"); + } + + return verifyKey(body, signature, timestamp, publicKey); + } catch (error) { + console.error("Error verifying Discord request:", error); + return false; + } +} diff --git a/tooling/sparta-aws/src/utils/parameter-store.ts b/tooling/sparta-aws/src/utils/parameter-store.ts new file mode 100644 index 0000000..5642ca6 --- /dev/null +++ b/tooling/sparta-aws/src/utils/parameter-store.ts @@ -0,0 +1,60 @@ +import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm"; + +const ssm = new SSMClient({}); + +const ENV_MAP: Record = { + "/sparta/discord/bot_token": "DISCORD_BOT_TOKEN", + "/sparta/discord/public_key": "DISCORD_PUBLIC_KEY", + "/sparta/ethereum/host": "ETHEREUM_HOST", + "/sparta/ethereum/rollup_address": "ETHEREUM_ROLLUP_ADDRESS", + "/sparta/ethereum/admin_address": "ETHEREUM_ADMIN_ADDRESS", + "/sparta/ethereum/chain_id": "ETHEREUM_CHAIN_ID", + "/sparta/ethereum/mnemonic": "ETHEREUM_MNEMONIC", + "/sparta/ethereum/private_key": "ETHEREUM_PRIVATE_KEY", + "/sparta/ethereum/value": "ETHEREUM_VALUE", + "/sparta/ecs/cluster_arn": "ECS_CLUSTER_ARN", + "/sparta/ecs/task_definition": "ECS_TASK_DEFINITION", + "/sparta/ecs/container_name": "ECS_CONTAINER_NAME", +}; + +export async function getParameter(name: string): Promise { + // In development mode, use environment variables + if (process.env.ENVIRONMENT === "development") { + const envVar = ENV_MAP[name]; + if (!envVar) { + throw new Error( + `No environment variable mapping found for parameter: ${name}` + ); + } + const value = process.env[envVar]; + if (!value) { + throw new Error(`Environment variable not set: ${envVar}`); + } + return value; + } + + // In production mode, use AWS Parameter Store + const command = new GetParameterCommand({ + Name: name, + WithDecryption: true, + }); + + const response = await ssm.send(command); + if (!response.Parameter?.Value) { + throw new Error(`Parameter not found: ${name}`); + } + + return response.Parameter.Value; +} + +export async function getParameters( + names: string[] +): Promise> { + const results: Record = {}; + + for (const name of names) { + results[name] = await getParameter(name); + } + + return results; +} diff --git a/tooling/sparta-aws/terraform/main.tf b/tooling/sparta-aws/terraform/main.tf new file mode 100644 index 0000000..070c372 --- /dev/null +++ b/tooling/sparta-aws/terraform/main.tf @@ -0,0 +1,202 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } + backend "s3" { + bucket = "sparta-terraform-state" + key = "sparta/terraform.tfstate" + region = "us-east-1" + } +} + +provider "aws" { + region = var.aws_region +} + +# API Gateway +resource "aws_apigatewayv2_api" "discord_webhook" { + name = "sparta-discord-webhook" + protocol_type = "HTTP" +} + +resource "aws_apigatewayv2_stage" "discord_webhook" { + api_id = aws_apigatewayv2_api.discord_webhook.id + name = "$default" + auto_deploy = true + + access_log_settings { + destination_arn = aws_cloudwatch_log_group.api_gateway.arn + format = jsonencode({ + requestId = "$context.requestId" + ip = "$context.identity.sourceIp" + requestTime = "$context.requestTime" + httpMethod = "$context.httpMethod" + routeKey = "$context.routeKey" + status = "$context.status" + protocol = "$context.protocol" + responseTime = "$context.responseLatency" + }) + } +} + +# Lambda Function +resource "aws_lambda_function" "discord_bot" { + filename = "../dist/lambda.zip" + function_name = "sparta-discord-bot" + role = aws_iam_role.lambda_role.arn + handler = "index.handler" + runtime = "nodejs18.x" + timeout = 30 + memory_size = 256 + + environment { + variables = { + ENVIRONMENT = var.environment + } + } + + depends_on = [ + aws_cloudwatch_log_group.lambda, + ] +} + +# CloudWatch Log Groups +resource "aws_cloudwatch_log_group" "lambda" { + name = "/aws/lambda/sparta-discord-bot" + retention_in_days = 14 +} + +resource "aws_cloudwatch_log_group" "api_gateway" { + name = "/aws/apigateway/sparta-discord-webhook" + retention_in_days = 14 +} + +# IAM Role for Lambda +resource "aws_iam_role" "lambda_role" { + name = "sparta-lambda-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "lambda.amazonaws.com" + } + } + ] + }) +} + +# IAM Policy for Lambda +resource "aws_iam_role_policy" "lambda_policy" { + name = "sparta-lambda-policy" + role = aws_iam_role.lambda_role.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ] + Resource = "arn:aws:logs:*:*:*" + }, + { + Effect = "Allow" + Action = [ + "ssm:GetParameter", + "ssm:GetParameters" + ] + Resource = [ + "arn:aws:ssm:${var.aws_region}:${var.aws_account_id}:parameter/sparta/*" + ] + }, + { + Effect = "Allow" + Action = [ + "ecr:GetAuthorizationToken", + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage" + ] + Resource = "*" + } + ] + }) +} + +# ECR Repository +resource "aws_ecr_repository" "validator_ops" { + name = "sparta-validator-ops" + image_tag_mutability = "MUTABLE" + + image_scanning_configuration { + scan_on_push = true + } +} + +# CloudWatch Alarms +resource "aws_cloudwatch_metric_alarm" "lambda_errors" { + alarm_name = "sparta-lambda-errors" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = "1" + metric_name = "Errors" + namespace = "AWS/Lambda" + period = "300" + statistic = "Sum" + threshold = "0" + alarm_description = "This metric monitors lambda function errors" + alarm_actions = [] # Add SNS topic ARN here if needed + + dimensions = { + FunctionName = aws_lambda_function.discord_bot.function_name + } +} + +resource "aws_cloudwatch_metric_alarm" "api_latency" { + alarm_name = "sparta-api-latency" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = "1" + metric_name = "Latency" + namespace = "AWS/ApiGateway" + period = "300" + statistic = "Average" + threshold = "1000" + alarm_description = "This metric monitors API Gateway latency" + alarm_actions = [] # Add SNS topic ARN here if needed + + dimensions = { + ApiId = aws_apigatewayv2_api.discord_webhook.id + } +} + +# API Gateway Integration with Lambda +resource "aws_apigatewayv2_integration" "lambda" { + api_id = aws_apigatewayv2_api.discord_webhook.id + + integration_uri = aws_lambda_function.discord_bot.invoke_arn + integration_type = "AWS_PROXY" + integration_method = "POST" +} + +resource "aws_apigatewayv2_route" "lambda" { + api_id = aws_apigatewayv2_api.discord_webhook.id + route_key = "POST /discord-webhook" + target = "integrations/${aws_apigatewayv2_integration.lambda.id}" +} + +resource "aws_lambda_permission" "apigw" { + statement_id = "AllowAPIGatewayInvoke" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.discord_bot.function_name + principal = "apigateway.amazonaws.com" + source_arn = "${aws_apigatewayv2_api.discord_webhook.execution_arn}/*/*" +} diff --git a/tooling/sparta-aws/terraform/outputs.tf b/tooling/sparta-aws/terraform/outputs.tf new file mode 100644 index 0000000..191e321 --- /dev/null +++ b/tooling/sparta-aws/terraform/outputs.tf @@ -0,0 +1,24 @@ +output "lambda_function_name" { + description = "Name of the Lambda function" + value = aws_lambda_function.discord_bot.function_name +} + +output "api_gateway_url" { + description = "URL of the API Gateway endpoint" + value = "${aws_apigatewayv2_api.discord_webhook.api_endpoint}/discord-webhook" +} + +output "ecr_repository_url" { + description = "URL of the ECR repository" + value = aws_ecr_repository.validator_ops.repository_url +} + +output "cloudwatch_log_group_lambda" { + description = "Name of the CloudWatch log group for Lambda" + value = aws_cloudwatch_log_group.lambda.name +} + +output "cloudwatch_log_group_api" { + description = "Name of the CloudWatch log group for API Gateway" + value = aws_cloudwatch_log_group.api_gateway.name +} diff --git a/tooling/sparta-aws/terraform/task-definition.json b/tooling/sparta-aws/terraform/task-definition.json new file mode 100644 index 0000000..3c8461e --- /dev/null +++ b/tooling/sparta-aws/terraform/task-definition.json @@ -0,0 +1,62 @@ +{ + "family": "sparta-validator-ops", + "networkMode": "awsvpc", + "requiresCompatibilities": [ + "FARGATE" + ], + "cpu": "256", + "memory": "512", + "executionRoleArn": "arn:aws:iam::${aws_account_id}:role/ecsTaskExecutionRole", + "taskRoleArn": "arn:aws:iam::${aws_account_id}:role/sparta-ecs-task-role", + "containerDefinitions": [ + { + "name": "validator-ops", + "image": "${ecr_repository_url}:latest", + "essential": true, + "logConfiguration": { + "logDriver": "awslogs", + "options": { + "awslogs-group": "/ecs/sparta-validator-ops", + "awslogs-region": "${aws_region}", + "awslogs-stream-prefix": "ecs" + } + }, + "environment": [ + { + "name": "ENVIRONMENT", + "value": "${environment}" + } + ], + "secrets": [ + { + "name": "ETHEREUM_HOST", + "valueFrom": "arn:aws:ssm:${aws_region}:${aws_account_id}:parameter/sparta/ethereum/host" + }, + { + "name": "ETHEREUM_ROLLUP_ADDRESS", + "valueFrom": "arn:aws:ssm:${aws_region}:${aws_account_id}:parameter/sparta/ethereum/rollup_address" + }, + { + "name": "ETHEREUM_ADMIN_ADDRESS", + "valueFrom": "arn:aws:ssm:${aws_region}:${aws_account_id}:parameter/sparta/ethereum/admin_address" + }, + { + "name": "ETHEREUM_CHAIN_ID", + "valueFrom": "arn:aws:ssm:${aws_region}:${aws_account_id}:parameter/sparta/ethereum/chain_id" + }, + { + "name": "ETHEREUM_MNEMONIC", + "valueFrom": "arn:aws:ssm:${aws_region}:${aws_account_id}:parameter/sparta/ethereum/mnemonic" + }, + { + "name": "ETHEREUM_PRIVATE_KEY", + "valueFrom": "arn:aws:ssm:${aws_region}:${aws_account_id}:parameter/sparta/ethereum/private_key" + }, + { + "name": "ETHEREUM_VALUE", + "valueFrom": "arn:aws:ssm:${aws_region}:${aws_account_id}:parameter/sparta/ethereum/value" + } + ] + } + ] +} diff --git a/tooling/sparta-aws/terraform/variables.tf b/tooling/sparta-aws/terraform/variables.tf new file mode 100644 index 0000000..aff9aeb --- /dev/null +++ b/tooling/sparta-aws/terraform/variables.tf @@ -0,0 +1,34 @@ +variable "aws_region" { + description = "AWS region to deploy resources" + type = string + default = "us-east-1" +} + +variable "aws_account_id" { + description = "AWS account ID" + type = string +} + +variable "environment" { + description = "Environment (development/production)" + type = string + default = "development" +} + +variable "lambda_memory" { + description = "Lambda function memory size" + type = number + default = 256 +} + +variable "lambda_timeout" { + description = "Lambda function timeout" + type = number + default = 30 +} + +variable "log_retention_days" { + description = "CloudWatch log retention in days" + type = number + default = 14 +} diff --git a/tooling/sparta/src/admins/index.ts b/tooling/sparta/src/admins/index.ts new file mode 100644 index 0000000..b20f6c8 --- /dev/null +++ b/tooling/sparta/src/admins/index.ts @@ -0,0 +1,3 @@ +import validators from "./validators.js"; + +export default { validators }; diff --git a/tooling/sparta/src/commands/getAdminInfo.ts b/tooling/sparta/src/admins/validators.ts similarity index 75% rename from tooling/sparta/src/commands/getAdminInfo.ts rename to tooling/sparta/src/admins/validators.ts index 319d700..2642b24 100644 --- a/tooling/sparta/src/commands/getAdminInfo.ts +++ b/tooling/sparta/src/admins/validators.ts @@ -6,6 +6,7 @@ import { } from "discord.js"; import { ChainInfoService } from "../services/chaininfo-service.js"; import { paginate } from "../utils/pagination.js"; +import { ValidatorService } from "../services/validator-service.js"; export const EXCLUDED_VALIDATORS = [ "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", @@ -60,18 +61,37 @@ export const EXCLUDED_VALIDATORS = [ export default { data: new SlashCommandBuilder() - .setName("admin-info") - .setDescription("Get admin info about the chain ") + .setName("admin") + .setDescription("Admin commands") .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) - .addSubcommand((subcommand) => - subcommand + .addSubcommandGroup((group) => + group .setName("validators") - .setDescription("Get the current list of validators") + .setDescription("Manage validators") + .addSubcommand((subcommand) => + subcommand.setName("get").setDescription("Get validators") + ) + .addSubcommand((subcommand) => + subcommand + .setName("remove") + .setDescription("Remove a validator") + .addStringOption((option) => + option + .setName("address") + .setDescription("The validator to remove") + .setRequired(true) + ) + ) ) - .addSubcommand((subcommand) => - subcommand + .addSubcommandGroup((group) => + group .setName("committee") - .setDescription("Get the current committee") + .setDescription("Manage the committee") + .addSubcommand((subcommand) => + subcommand + .setName("get") + .setDescription("Get the current committee") + ) ), execute: async (interaction: ChatInputCommandInteraction) => { @@ -106,9 +126,20 @@ export default { interaction, "Validators" ); - - return; + } else if (interaction.options.getSubcommand() === "remove") { + const address = interaction.options.getString("address"); + if (!address) { + await interaction.editReply({ + content: "Please provide an address to remove", + }); + return; + } + await ValidatorService.removeValidator(address); + await interaction.editReply({ + content: `Removed validator ${address}`, + }); } + return; } catch (error) { console.error("Error in get-info command:", error); await interaction.editReply({ diff --git a/tooling/sparta/src/commands/index.ts b/tooling/sparta/src/commands/index.ts index 186909d..6bd3ac7 100644 --- a/tooling/sparta/src/commands/index.ts +++ b/tooling/sparta/src/commands/index.ts @@ -1,5 +1,4 @@ import addValidator from "./addValidator.js"; import getChainInfo from "./getChainInfo.js"; -import getAdminInfo from "./getAdminInfo.js"; -export default { addValidator, getChainInfo, getAdminInfo }; +export default { addValidator, getChainInfo }; diff --git a/tooling/sparta/src/deploy-commands.ts b/tooling/sparta/src/deploy-commands.ts index fa7313f..ac81270 100644 --- a/tooling/sparta/src/deploy-commands.ts +++ b/tooling/sparta/src/deploy-commands.ts @@ -1,5 +1,6 @@ import { REST, Routes } from "discord.js"; -import commands from "./commands/index.js"; +import usersCommands from "./commands/index.js"; +import adminsCommands from "./admins/index.js"; import { BOT_TOKEN, BOT_CLIENT_ID, GUILD_ID } from "./env.js"; export const deployCommands = async (): Promise => { @@ -8,9 +9,10 @@ export const deployCommands = async (): Promise => { try { console.log("Started refreshing application (/) commands."); - const commandsData = Object.values(commands).map((command) => - command.data.toJSON() - ); + const commandsData = Object.values({ + ...usersCommands, + ...adminsCommands, + }).map((command) => command.data.toJSON()); await rest.put( Routes.applicationGuildCommands(BOT_CLIENT_ID, GUILD_ID), diff --git a/tooling/sparta/src/index.ts b/tooling/sparta/src/index.ts index 11fe2d0..1b66d10 100644 --- a/tooling/sparta/src/index.ts +++ b/tooling/sparta/src/index.ts @@ -6,7 +6,8 @@ import { MessageFlags, } from "discord.js"; import { deployCommands } from "./deploy-commands.js"; -import commands from "./commands/index.js"; +import usersCommands from "./commands/index.js"; +import adminsCommands from "./admins/index.js"; import { BOT_TOKEN, DEV_CHANNEL_ID, @@ -25,7 +26,7 @@ const client = new Client({ client.commands = new Collection(); -for (const command of Object.values(commands)) { +for (const command of Object.values({ ...usersCommands, ...adminsCommands })) { client.commands.set(command.data.name, command); } diff --git a/tooling/sparta/src/services/validator-service.ts b/tooling/sparta/src/services/validator-service.ts index 1c9db9b..985a89c 100644 --- a/tooling/sparta/src/services/validator-service.ts +++ b/tooling/sparta/src/services/validator-service.ts @@ -34,6 +34,24 @@ export class ValidatorService { } } + static async removeValidator(address: string): Promise { + try { + // Add validator to the set + const command = `docker run --rm aztecprotocol/aztec:unhinged-unicorn remove-l1-validator -u ${ETHEREUM_HOST} --validator ${address} --rollup ${ETHEREUM_ROLLUP_ADDRESS} --l1-chain-id ${ETHEREUM_CHAIN_ID} --mnemonic "${ETHEREUM_MNEMONIC}"`; + + const { stdout, stderr } = await execAsync(command); + + if (stderr) { + throw new Error(stderr); + } + + return stdout; + } catch (error) { + console.error("Error removing validator:", error); + throw error; + } + } + static async fundValidator(address: string): Promise { try { const command = `cast send --value ${ETHEREUM_VALUE} --rpc-url ${ETHEREUM_HOST} --chain-id ${ETHEREUM_CHAIN_ID} --private-key ${ETHEREUM_PRIVATE_KEY} ${address}`; From 0d1284125c351b7f2acec477d54dfb59611ca52e Mon Sep 17 00:00:00 2001 From: signorecello Date: Fri, 24 Jan 2025 00:07:52 +0000 Subject: [PATCH 11/13] yey aws --- LICENSE.txt | 92 +++ terraform.tfstate | 9 + tooling/sparta-aws/Dockerfile | 25 - tooling/sparta-aws/README.md | 89 --- tooling/sparta-aws/src/.env.example | 37 -- tooling/sparta-aws/src/.gitignore | 175 ------ tooling/sparta-aws/src/README.md | 15 - tooling/sparta-aws/src/bun.lockb | Bin 257107 -> 0 bytes .../src/commands/__tests__/commands.test.ts | 203 ------- .../sparta-aws/src/commands/addValidator.ts | 45 -- .../src/commands/adminValidators.ts | 135 ----- .../sparta-aws/src/commands/getChainInfo.ts | 37 -- tooling/sparta-aws/src/commands/index.ts | 10 - tooling/sparta-aws/src/index.ts | 117 ---- tooling/sparta-aws/src/local-dev.ts | 116 ---- tooling/sparta-aws/src/package.json | 44 -- .../src/scripts/register-commands.ts | 48 -- .../src/services/chaininfo-service.ts | 86 --- .../src/services/validator-service.ts | 151 ----- tooling/sparta-aws/src/tsconfig.json | 34 -- tooling/sparta-aws/src/types/discord.ts | 80 --- .../sparta-aws/src/utils/discord-verify.ts | 25 - .../sparta-aws/src/utils/parameter-store.ts | 60 -- tooling/sparta-aws/terraform/main.tf | 202 ------- tooling/sparta-aws/terraform/outputs.tf | 24 - .../sparta-aws/terraform/task-definition.json | 62 -- tooling/sparta-aws/terraform/variables.tf | 34 -- tooling/sparta/.env.example | 15 - tooling/sparta/.gitignore | 41 +- tooling/sparta/Dockerfile | 19 - tooling/sparta/README.md | 143 ++++- tooling/sparta/dist/commands/addValidator.js | 71 --- tooling/sparta/dist/commands/getChainInfo.js | 22 - tooling/sparta/dist/commands/index.js | 6 - tooling/sparta/dist/deploy-commands.js | 17 - tooling/sparta/dist/env.js | 3 - tooling/sparta/dist/index.js | 46 -- .../sparta/dist/services/chaininfo-service.js | 34 -- .../sparta/dist/services/validator-service.js | 37 -- tooling/sparta/docker-compose.yml | 8 - tooling/sparta/package.json | 19 - tooling/sparta/src/.env.example | 12 + .../hooks/prebuild/01_install_dependencies.sh | 23 + tooling/sparta/src/env.ts | 75 ++- tooling/sparta/src/index.ts | 21 +- tooling/sparta/src/package.json | 24 + .../sparta/src/services/chaininfo-service.ts | 7 +- .../sparta/src/services/validator-service.ts | 15 +- tooling/sparta/{ => src}/tsconfig.json | 4 +- .../sparta/src/{ => utils}/deploy-commands.ts | 6 +- tooling/sparta/terraform/main.tf | 569 ++++++++++++++++++ tooling/sparta/terraform/outputs.tf | 138 +++++ .../sparta/terraform/terraform.tfvars.example | 56 ++ tooling/sparta/terraform/variables.tf | 88 +++ 54 files changed, 1250 insertions(+), 2224 deletions(-) create mode 100644 LICENSE.txt create mode 100644 terraform.tfstate delete mode 100644 tooling/sparta-aws/Dockerfile delete mode 100644 tooling/sparta-aws/README.md delete mode 100644 tooling/sparta-aws/src/.env.example delete mode 100644 tooling/sparta-aws/src/.gitignore delete mode 100644 tooling/sparta-aws/src/README.md delete mode 100755 tooling/sparta-aws/src/bun.lockb delete mode 100644 tooling/sparta-aws/src/commands/__tests__/commands.test.ts delete mode 100644 tooling/sparta-aws/src/commands/addValidator.ts delete mode 100644 tooling/sparta-aws/src/commands/adminValidators.ts delete mode 100644 tooling/sparta-aws/src/commands/getChainInfo.ts delete mode 100644 tooling/sparta-aws/src/commands/index.ts delete mode 100644 tooling/sparta-aws/src/index.ts delete mode 100644 tooling/sparta-aws/src/local-dev.ts delete mode 100644 tooling/sparta-aws/src/package.json delete mode 100644 tooling/sparta-aws/src/scripts/register-commands.ts delete mode 100644 tooling/sparta-aws/src/services/chaininfo-service.ts delete mode 100644 tooling/sparta-aws/src/services/validator-service.ts delete mode 100644 tooling/sparta-aws/src/tsconfig.json delete mode 100644 tooling/sparta-aws/src/types/discord.ts delete mode 100644 tooling/sparta-aws/src/utils/discord-verify.ts delete mode 100644 tooling/sparta-aws/src/utils/parameter-store.ts delete mode 100644 tooling/sparta-aws/terraform/main.tf delete mode 100644 tooling/sparta-aws/terraform/outputs.tf delete mode 100644 tooling/sparta-aws/terraform/task-definition.json delete mode 100644 tooling/sparta-aws/terraform/variables.tf delete mode 100644 tooling/sparta/.env.example delete mode 100644 tooling/sparta/Dockerfile delete mode 100644 tooling/sparta/dist/commands/addValidator.js delete mode 100644 tooling/sparta/dist/commands/getChainInfo.js delete mode 100644 tooling/sparta/dist/commands/index.js delete mode 100644 tooling/sparta/dist/deploy-commands.js delete mode 100644 tooling/sparta/dist/env.js delete mode 100644 tooling/sparta/dist/index.js delete mode 100644 tooling/sparta/dist/services/chaininfo-service.js delete mode 100644 tooling/sparta/dist/services/validator-service.js delete mode 100644 tooling/sparta/docker-compose.yml delete mode 100644 tooling/sparta/package.json create mode 100644 tooling/sparta/src/.env.example create mode 100755 tooling/sparta/src/.platform/hooks/prebuild/01_install_dependencies.sh create mode 100644 tooling/sparta/src/package.json rename tooling/sparta/{ => src}/tsconfig.json (85%) rename tooling/sparta/src/{ => utils}/deploy-commands.ts (78%) create mode 100644 tooling/sparta/terraform/main.tf create mode 100644 tooling/sparta/terraform/outputs.tf create mode 100644 tooling/sparta/terraform/terraform.tfvars.example create mode 100644 tooling/sparta/terraform/variables.tf diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..8142708 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,92 @@ +License text copyright (c) 2020 MariaDB Corporation Ab, All Rights Reserved. +"Business Source License" is a trademark of MariaDB Corporation Ab. + +Parameters + +Licensor: HashiCorp, Inc. +Licensed Work: Terraform Version 1.6.0 or later. The Licensed Work is (c) 2024 + HashiCorp, Inc. +Additional Use Grant: You may make production use of the Licensed Work, provided + Your use does not include offering the Licensed Work to third + parties on a hosted or embedded basis in order to compete with + HashiCorp's paid version(s) of the Licensed Work. For purposes + of this license: + + A "competitive offering" is a Product that is offered to third + parties on a paid basis, including through paid support + arrangements, that significantly overlaps with the capabilities + of HashiCorp's paid version(s) of the Licensed Work. If Your + Product is not a competitive offering when You first make it + generally available, it will not become a competitive offering + later due to HashiCorp releasing a new version of the Licensed + Work with additional capabilities. In addition, Products that + are not provided on a paid basis are not competitive. + + "Product" means software that is offered to end users to manage + in their own environments or offered as a service on a hosted + basis. + + "Embedded" means including the source code or executable code + from the Licensed Work in a competitive offering. "Embedded" + also means packaging the competitive offering in such a way + that the Licensed Work must be accessed or downloaded for the + competitive offering to operate. + + Hosting or using the Licensed Work(s) for internal purposes + within an organization is not considered a competitive + offering. HashiCorp considers your organization to include all + of your affiliates under common control. + + For binding interpretive guidance on using HashiCorp products + under the Business Source License, please visit our FAQ. + (https://www.hashicorp.com/license-faq) +Change Date: Four years from the date the Licensed Work is published. +Change License: MPL 2.0 + +For information about alternative licensing arrangements for the Licensed Work, +please contact licensing@hashicorp.com. + +Notice + +Business Source License 1.1 + +Terms + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make an Additional Use Grant, above, permitting limited production use. + +Effective on the Change Date, or the fourth anniversary of the first publicly +available distribution of a specific version of the Licensed Work under this +License, whichever comes first, the Licensor hereby grants you rights under +the terms of the Change License, and the rights granted in the paragraph +above terminate. + +If your use of the Licensed Work does not comply with the requirements +currently in effect as described in this License, you must purchase a +commercial license from the Licensor, its affiliated entities, or authorized +resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works +of the Licensed Work, are subject to this License. This License applies +separately for each version of the Licensed Work and the Change Date may vary +for each version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy +of the Licensed Work. If you receive the Licensed Work in original or +modified form from a third party, the terms and conditions set forth in this +License apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically +terminate your rights under this License for the current and all other +versions of the Licensed Work. + +This License does not grant you any right in any trademark or logo of +Licensor or its affiliates (provided that you may use a trademark or logo of +Licensor as expressly required by this License). + +TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +TITLE. diff --git a/terraform.tfstate b/terraform.tfstate new file mode 100644 index 0000000..fafc94b --- /dev/null +++ b/terraform.tfstate @@ -0,0 +1,9 @@ +{ + "version": 4, + "terraform_version": "1.10.4", + "serial": 1, + "lineage": "5b4500a2-d397-e510-9de0-aaeab4d564c0", + "outputs": {}, + "resources": [], + "check_results": null +} diff --git a/tooling/sparta-aws/Dockerfile b/tooling/sparta-aws/Dockerfile deleted file mode 100644 index 982fced..0000000 --- a/tooling/sparta-aws/Dockerfile +++ /dev/null @@ -1,25 +0,0 @@ -# Use a slim base image -FROM node:18-slim - -# Install system dependencies -RUN apt-get update && \ - apt-get install -y curl && \ - rm -rf /var/lib/apt/lists/* - -# Set working directory -WORKDIR /app - -# Copy package files -COPY package*.json ./ - -# Install dependencies -RUN npm ci --only=production - -# Copy application files -COPY dist/ ./dist/ - -# Set environment variables -ENV NODE_ENV=production - -# Command will be provided by ECS task definition -CMD ["node", "dist/index.js"] diff --git a/tooling/sparta-aws/README.md b/tooling/sparta-aws/README.md deleted file mode 100644 index 96967b1..0000000 --- a/tooling/sparta-aws/README.md +++ /dev/null @@ -1,89 +0,0 @@ -# Sparta AWS Infrastructure - -This is the AWS infrastructure version of the Sparta Discord bot for managing validators. It uses serverless architecture with AWS Lambda, API Gateway, and other AWS services. - -## Architecture - -- AWS Lambda for the Discord bot logic -- API Gateway for Discord webhook endpoints -- DynamoDB for state management (if needed) -- CloudWatch for logging and monitoring -- ECR for Docker container storage -- Parameter Store for secrets -- CloudWatch Alarms for monitoring - -## Prerequisites - -1. AWS CLI installed and configured -2. Terraform installed -3. Node.js 18.x or later -4. Discord bot token and application ID -5. Required environment variables (see below) - -## Setup - -1. Create a `.env` file based on `.env.example` -2. Deploy the infrastructure: - ```bash - cd terraform - terraform init - terraform plan - terraform apply - ``` -3. Deploy the Lambda function: - ```bash - cd src - npm install - npm run build - npm run deploy - ``` - -## Environment Variables - -Required environment variables in AWS Parameter Store: - -- `/sparta/discord/bot_token` -- `/sparta/discord/client_id` -- `/sparta/discord/guild_id` -- `/sparta/discord/prod_channel_id` -- `/sparta/discord/dev_channel_id` -- `/sparta/ethereum/host` -- `/sparta/ethereum/rollup_address` -- `/sparta/ethereum/admin_address` -- `/sparta/ethereum/chain_id` -- `/sparta/ethereum/mnemonic` -- `/sparta/ethereum/private_key` -- `/sparta/ethereum/value` - -## Architecture Details - -### Lambda Function -The main bot logic runs in a Lambda function, triggered by API Gateway when Discord sends webhook events. - -### API Gateway -Handles incoming webhook requests from Discord and routes them to the appropriate Lambda function. - -### CloudWatch -All Lambda function logs are automatically sent to CloudWatch Logs. CloudWatch Alarms monitor for errors and latency. - -### Parameter Store -Stores all sensitive configuration values securely. - -### ECR -Stores the Docker container used for validator operations (implementation pending). - -## Monitoring - -CloudWatch dashboards and alarms are set up to monitor: -- Lambda function errors -- API Gateway latency -- Lambda function duration -- Lambda function throttles -- API Gateway 4xx/5xx errors - -## Security - -- All secrets are stored in AWS Parameter Store -- IAM roles follow least privilege principle -- API Gateway endpoints are secured with Discord signature verification -- Network access is restricted where possible diff --git a/tooling/sparta-aws/src/.env.example b/tooling/sparta-aws/src/.env.example deleted file mode 100644 index 4382f80..0000000 --- a/tooling/sparta-aws/src/.env.example +++ /dev/null @@ -1,37 +0,0 @@ -# Environment (development or production) -ENVIRONMENT=development - -# Discord Bot Configuration -# For development (sparta-dev), use these values locally -# For production (sparta), these values should be set in CI/CD pipeline -DISCORD_BOT_TOKEN= -DISCORD_CLIENT_ID= -DISCORD_PUBLIC_KEY= -DISCORD_GUILD_ID= -DISCORD_CHANNEL_ID=1329081299490570296 # Dev channel: bot-test -# Production channel reference: 1302946562745438238 - -# Ethereum Configuration -ETHEREUM_HOST=http://localhost:8545 -ETHEREUM_MNEMONIC= -ETHEREUM_PRIVATE_KEY= -ETHEREUM_ROLLUP_ADDRESS= -ETHEREUM_CHAIN_ID=1337 -ETHEREUM_VALUE=20ether -ETHEREUM_ADMIN_ADDRESS= - -# AWS Configuration -AWS_REGION=us-east-1 -AWS_ACCOUNT_ID= - -# For local development -# Note: Install ngrok globally with: npm install -g ngrok -# Then authenticate with: ngrok config add-authtoken YOUR_AUTH_TOKEN -ENDPOINT_URL=https://your-domain.ngrok-free.app/discord-webhook - -# ECS Configuration -ECS_CLUSTER_ARN=your_cluster_arn_here -ECS_TASK_DEFINITION=your_task_definition_here -ECS_CONTAINER_NAME=your_container_name_here -ECS_SUBNET_IDS=subnet-xxxxx,subnet-yyyyy -ECS_SECURITY_GROUP_IDS=sg-xxxxx,sg-yyyyy diff --git a/tooling/sparta-aws/src/.gitignore b/tooling/sparta-aws/src/.gitignore deleted file mode 100644 index 9b1ee42..0000000 --- a/tooling/sparta-aws/src/.gitignore +++ /dev/null @@ -1,175 +0,0 @@ -# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore - -# Logs - -logs -_.log -npm-debug.log_ -yarn-debug.log* -yarn-error.log* -lerna-debug.log* -.pnpm-debug.log* - -# Caches - -.cache - -# Diagnostic reports (https://nodejs.org/api/report.html) - -report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json - -# Runtime data - -pids -_.pid -_.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover - -lib-cov - -# Coverage directory used by tools like istanbul - -coverage -*.lcov - -# nyc test coverage - -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) - -.grunt - -# Bower dependency directory (https://bower.io/) - -bower_components - -# node-waf configuration - -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) - -build/Release - -# Dependency directories - -node_modules/ -jspm_packages/ - -# Snowpack dependency directory (https://snowpack.dev/) - -web_modules/ - -# TypeScript cache - -*.tsbuildinfo - -# Optional npm cache directory - -.npm - -# Optional eslint cache - -.eslintcache - -# Optional stylelint cache - -.stylelintcache - -# Microbundle cache - -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - -# Optional REPL history - -.node_repl_history - -# Output of 'npm pack' - -*.tgz - -# Yarn Integrity file - -.yarn-integrity - -# dotenv environment variable files - -.env -.env.development.local -.env.test.local -.env.production.local -.env.local - -# parcel-bundler cache (https://parceljs.org/) - -.parcel-cache - -# Next.js build output - -.next -out - -# Nuxt.js build / generate output - -.nuxt -dist - -# Gatsby files - -# Comment in the public line in if your project uses Gatsby and not Next.js - -# https://nextjs.org/blog/next-9-1#public-directory-support - -# public - -# vuepress build output - -.vuepress/dist - -# vuepress v2.x temp and cache directory - -.temp - -# Docusaurus cache and generated files - -.docusaurus - -# Serverless directories - -.serverless/ - -# FuseBox cache - -.fusebox/ - -# DynamoDB Local files - -.dynamodb/ - -# TernJS port file - -.tern-port - -# Stores VSCode versions used for testing VSCode extensions - -.vscode-test - -# yarn v2 - -.yarn/cache -.yarn/unplugged -.yarn/build-state.yml -.yarn/install-state.gz -.pnp.* - -# IntelliJ based IDEs -.idea - -# Finder (MacOS) folder config -.DS_Store diff --git a/tooling/sparta-aws/src/README.md b/tooling/sparta-aws/src/README.md deleted file mode 100644 index 237a41c..0000000 --- a/tooling/sparta-aws/src/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# sparta-discord-bot - -To install dependencies: - -```bash -bun install -``` - -To run: - -```bash -bun run dist/index.js -``` - -This project was created using `bun init` in bun v1.1.43. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/tooling/sparta-aws/src/bun.lockb b/tooling/sparta-aws/src/bun.lockb deleted file mode 100755 index 8f8705e1cf010559175de9d87bb80188377213f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 257107 zcmeFad0bE1_s9Q^q(K@)rj#LtQVJ!~pi)suDr1`74K!&U44I3B3Z){FC@MrHLli=h zc_=b#P{`SR*SWqQI`8`Z^ZPySemK3}d!PMWYwh9ev(M?>y+a491o-)?IJ$eO zaJ>TM90R?!i@d@%Hs}@K+9074E{}a9l?$IQ-1qDE&uPbjl=&AWaZl!E*Zy4)BP>vMyBe7Syp98rD_rx)axfnyP@2a5a~P;t=5pgln=Xe|a6g?t`p z4^Tg@moo;DN#}h*(GC}y?(Xa7?@H&ryVLdk9sC3QxPIVrMtl!#D%H zeB6D2h^6xpv_DH)OCf0NzX4Dg>-#zcDLZ;Qhd74#bJ3-Lh!0oU-H#&)jmP#0f?|D) zL)qDpvxJW82b3cp4xNVm?gNVbXbFn_*a$zc|As=L7^s_rpLdWKhtmb}Sl$;D?YnwY z{UQc=?2ivn8S5JZkNNE|Na*JZ6iS0W1;ufF0p~HUg`hZ&Q=xsBzYc?p+(lYFx&Ch6 z&VC#RCnv6tzaJ+bg2j4H-rkGdp`QXBd_%BboxS~GUUN8K;WM7+_Tg}PgZ2YO`!ndg zHYk`5A4%&Y2nyT(9x7wp9^TFlIFQfiyuY8ar+YBh*Wb^>-4PuD2TcvX3Lmk4Yd;Q0 z7PJ-={r&Ez63U^8Fs{z-PVOAe9jJu;_k94>P9v~`{pdT8>Zc%jd|p5~#x+ZZs`n7` z*shOszMj?A~Up`S`kfI&d6(UE8KwBLs=OLy(`cpYvi)u&0OG zBJg!cjw<(a2nG)vPC$Tr+dL2OclW^jnZZ>5O&CJ87uE@7u7i`?MaW~^s-QStCPS%y zJpCmzGzaWqoQa^=U)A!IU3WiaU#=@Rcr%pae1S^?-E%zU zC_g>{ULj82&fG5W*RuNX3;8 zHgLR2yHb@Xds{%Ue9I_mJmx7=aj$@KoG&_~seC#p&Rb6?$9X$Lg~NfSgxf$K%XLAq zePh553}N_dRSpM+BK#UC41M@uIFIuq49;VF!_+9hw?XlIHk4x=W}ukAr_SNP)Ce~o zOWAj!Ik%Bi|Mi1B_U|>wW4&a^V_f6LQ|I~U^Yh_+4=6tmdFaA$3Gjz%2;~^}1WhX5 zpKud=V)6PoeW^ z9I9R-s4$fK`vt=%4#&lhnu$JBsebO<>%T8(KPOY2}zY*$ZOzZ+Be8d~py;`}M0^(d_=v_{hk z>);}|)p6Q>Kzk?7q}q8CR0Q||R#e_+v8%GPyD!JZ-OE`yz=!h~%JKXyT77WK;o#2& zb@z1l=lCf5w=LjLEU9{4*3`VT1;uezv!TY%6!JKq9z!1I-(<++{9bHJwa3ZL!DBJR z)pa&CAK`wb?Be0z`W4Qj|Eo}reE&JrxQ&6hg(1IoE_L1MgSM^ba31T)&ZoxzA|2Pc zh15Low5R6zJW%X63s8(B!+{zlujG-(K(|3+1WK)OEQY6xWq`P>%5}0>$}6Nkf@PWy57@OE_Y@cU;!vkA(3L;WyNZ1-eP zT*qCxUVb57PMk%4-d^K?FSVntpFSScxFtXymdtQJ(BYuQprb$sgDQZ2UqF>#1%)Lu zd?)A_&=AnUpmRXwK-EE!?*WS3YZxE6b%d9J;yRd4=Q*IVkgtLE^##2Pit+Y^@x^wN z`QQsTF@M-7#lv|lUk!@mamSx(R|(BI0*C#5A%L=T5)|jt+(2qPchK|c8|0C9^mTB; zjo6YPY98c)Vm}E5Q|J9W+O`**G_a53c?0GbsuyT~&^{qlKlco!+O-B0*O4$#wEqRp z<9K?6QT1Lz9^3Z-6vxwQDOGPfDBAr7iu3#pXxn(fxWX`T&Mu?u=7M5-+`U{p++E%L zgM1x)e885smlGH3b%W2?exnssy@|BS&!y}shEx7!K+z7l{?0%iXAoS@cs-0+P4WJoJ}{#=93OXISUvpyxnAJ; zfa?Tf<_Ga$Kah3h(i$orZZOvgE;&xfT56r}0LAgP2gUesFQL4c8)6N4jQ{33s^1jW zQ}u$}+@0Xkfqey70-Xl+us`)dv0dI?Tz}l+!~WXcN!i8A65_!Al8L1Jy`MwbQHOFI zk7*mId07aGcF1^YLcSN|JslRqKHc3{0rF@^8WhLhJ&KA~2=drJKfpe=V-MID23@&{ z(uuTkgJE%nBm#c19+^KLc$e90Cnj!U4pehuA9 z#p4T#{WTcMu^;T|dNV-9A+Jm8#Te?mI^;19qgcvsIc@(DkJ!B4#&wGdzHgE0`)O2&qONz8K5$d=lc5M4msE{iLzI3 zN!4qHJjS;ynYw;mLHj`7lGYkHkNt3Y7j^ys*hl|(zg2efaL?FH+4J%B_QdC)Nhy@w z#UAh!3Gsx$M&A##%O1+k2RM)Yc@X^LIKPHGs>)u<{xDE%FPIK+2d+mdK{EQ9I?xYxtHb>_fBkFSFZ zhXa>%AjWwplWOO3y8hThl%IG|#ZCyLx&1LXY9|7#Gwehw2vxCvRWuXop2@VR9snQ1das*IgOA znmq5=gMaM*S)drFF|AogsdlGQIjJbOf!-a;bL1I)Lsu zevrrW_OvbnMSIR%*rLIE1X-Hv1KL*Z?d0$4?#1Q$;S-|YKl{Aj_jg>sB>}LEa9}xa z8$YFdYG05AiudP_dDJ@SPun5S*BHEG2!|7Yl4{RpQ1t7`^>yW{aX6%%utc^s^gUt~%?F;#o)714B3yS0Rdq3tvJt-)MY1?)kPB}~2T?dNSQ6(sj zL)-jb?Cw7i>S2E?gJQYJIm&N5D9+jTYJ+W*SMeYqufj^EpC>@sxSy#}$BT?$?MIbG!|7)i=~#n0w7HP7Ed>@1q6GWYZ? z{$bH`>Jx$$l@kZpf7rTaUY{8@PaDS`HX6N6>B^lqhbwv+Ue2C;(s|Y-xn|KJ$9wCh zybB&Xe|dPG{Hg<`<4Vjq=meq641Yge(1*c;<_APuf~GboHyfEjpH$^WAnN`1thf^LUba>*z_v z+?owmHu7eJQ%ZEIdL7Lch`l|=Xyahf_j{WKWa9ho4olc{V3u0$_JaefZZ2FKczw&B z=@z$dd6uLu*Li&3@^$7*E3cyoQehwOPFRuYGyBC2i7i><7dG53>TdA5IX}*~+u~O> zx?8@+-rDzKj8#K7@h6qew=YLUgdLe5vm)$HW5ZgjKog1kv6&BC>pZ`1lpPzRpuWN6 zR?>0Fv764lEa_7^_tlHJ*X&w@PgLExUp-{@GtsH$@25sd_AA{g^k{8PX3?`i?c@pO zw|6?Gmh3oXyj3lze)hI&-an0YKg!rEUHCQCN9t1G`i*L@MSiFbt$uv%pnBk!+a`k~ zoaW}=FqdAl<##{vKa!r6Jv%J8qRd4{P)%@h-jSC!>GC-usaNAeb}zc=zEx%Jrs2o6 zCkS~@x>#qqv#>kA$+G@1#i8NjukR^2X22bHF=4yHmzBa5I^ud;3Wuy$9s2b{>VU@SCKd97uBs%f z_tV-kcHZnm^P2{H%)D*o)bsABB)dG$klM8y&s1y|dGK-D>=PnObnKb}zfIYF=v`jK zmIT36L2snR%8@eixTrH6Zu%UYhkHM@9#l69f;b&sF?=SNI-jMV7a zZ9!&Xzt0Y~^~P(g16pUw8howpzF*(4%e&ndObntFL(gsQ^?2FYTTT-sZD%HNTHk)V z64XCSqMYAjAZb_gx7mpiw$@+l{YuhuBtpU;_&OQMf3d7MJkQg?&2mTeh8`KBX4Q5Y zA3w$&F^Sx;_IYUd-VhaA-!3UnB_wU!DjtPM)?YZ(_ok?N(}>A>Ehn0l)1JF;@vQB> zA!El|-#2@2u6!K)Xhg7$M1oo0A>K)b&u$krcE9AF^uxA5z4qXK!$r#djITY^IVchm z)?{!!U(01#{*#KatM0$y&zsZKRZ8N6z>?T4f)3Xrw#>-ddOk`}PttzU9;KeEW;yq1 z`lK{=)x$)KcNfIYWkesA{kG7&V7cZLKEBVNhwxugKKb;_%8MyhLH8$Sk8Dt0p4vah zIrtr4g_Ts6zmokkXV>S4)6+6P92&j8$#|x~{@u$DmaiGM)N%W=E~yjm>6zuO3_l$; z<%sZvo0D?4$v>2r5|$k|Uulch$6gnnKUq_~qa<9rx>Wc0GdoM~_|*@G7`?eKmcJz? zy(K~L)}dQ3ld3npKYo92_B`ps&%Ad^4sYCBKhf-^xZ1{N+*wn8XO4NGoy+mP)9->~^Q_C_qV~O%>*ja!QQM}i$Av zEfd71BqdfHG{5t6x^Cf&(W7K~Nw%ua{oq%-J)2t_r>=KJ-6dx9&p(ZuZ^GrtDKW=} zrzZO17=$_HC`YzjFC6*ugzUa}VnF*0jyXC``My=&K_Arr(kFM)_CV$H+ z>9vg!k9+qtnd{_sPiv66kwZ=Bwjts}wWkDi|B!cMkGM>UVy%Um#4?+wCucf(*zf&$ zcWZ%Orjh#sUmb-cQE7wPU0H#bW=^gcHBd$(eb)fhlecP}=9YdAmtDHgIyQFVo1u#~ z?Tm2j^Fv5XR_gOeNkw(OjnDT~u68jRv;E<@^PI-1!)v?f_Wky4%*-p}xuxA_>@ceF zJTE9Qc~m}MTv-JFA-9DRTETk{E??9rD1TE`f9JwH0kJOQrc$Uw^tW-uN2Zy=LUbfU+Ho41_v68v3cZ@T8QmQGMlu;Y*r&+C1eEs=K)#-ia-#09F zaw=E&;2T@zc2ab9?2HPNkGo&aHjj={Zi>0EK0?4f zf6cSa2HRco4;gag4qvJ2TT^5v8fU6-#<(&;EVp^k!!6;v^2aXS6yLP;=;QiKZrZM! zyH8vk*sC?UR;;gg{B^PA5z}|fc)KTkZt2qbev!tlxoN|e{_rSnF`E~>Ft6{0s)~x% z7xfbz94@Pw3#rFAg`P`JS@y1EnNQNavRh3%a_;tDI=HE~$x9p2m|@T7gzh{2srTco zS2IGpnpr30**$nS+B~IyWdE<84F{(U*6DN@&k=73rd*{{lUwdlt)7J7V#j&FCVpEJN#k3nV`}BG> zT`|J=ocx9TRtf_a@z>8Zom~H7X1%_JwT0QH*fXQggkN*DNvY?SNbHj*||=Syga9ro_~J_)(`g^IS^Ehv?Ck8ka9jF~58`GO@DA zYDv}XxvOpR`Ht5&&ajQHk?^ouB&hA4TGwrn@W?syCCZfx;yeScNhd4`waXA2(kK@s z6|1nq!Mf4>>*6mb#!hb1U;kzL=C@;B40YSve7j&*Wv+|(uC=jOR^AjP^I>!E1r7x# zcMkPW*}ZpHO7hF0p3Mu_>6zNy*8X%@t;>f;cQvPYuQ81b51)NHTqWbulLpS zWPV+8@@R7Q*N;2wD~n{iz!Kfq=riYRW4ENo4T_gHE$u1YY&rUM9 zp~5{g)4jM$>#Z#+VuFscTaVSv5U~xcN|TH9p6{u9bWxCP^a4+J{z=a#lk4xQVC`^I z^DAYcODAjY{bt)$udB$9MQ^ftW*6@@nWZ3izd>NtoZY?e96P$d(As-O_lmCp&1j_qeuC1A6(c5!D%RIW1x{GZE!tPOqOm??l4+mac$|JEYsYI$rCn;4(A zth=Z9{_$Hs>34Z|;adjvo1c!g)yiDBNrG=j_5y>UB0G}moOYzQ2HX%#2q+ygeplUE z#p*dn<~}}rKe22pztFu2PnJ3PET7>Jk#zaRo0I*EtX8(PM@4K)K;77i z`k)_Vzk%M5usv`A=3 z!CbziN~15keU|jCc(rNULylO+lWLP2NBuRwAKW)D#A^kaKQjjn50>aU|6qm8M<-6I zP>7aGhF`3L-J)}n6F)7S9&`1;xswZl;{Uub*z`}#?#D)%H=ZJ3~hAJCb;& zzK2{tkyY|)eWv$#n|mg#JTSFYwD zF=^Ka)C7F_QKZp)m22s|I){6G5L(-dTbXD*Hd4RWw_?&@m=5h_V${WU086f@cxTcVz%+_W{Nt7LJwOG7~kd3A0jnz z`N1Na4>o#}ii7MeEe_uZYPi>L+@a|Wvn{2so-9thdt+U};SBk;m(tG4T|YDbWlMc% zRFB7vcYJE~d%k`jc6!3(VQJ<2+{P{3ucFkjEwoZryv#{CWa5UsRceEq&poPr6;U;M zTl{CQ?W?4O_YWO0F>Utn`JZCfnM|IR`Z1zKW~-@}Q%Y=*_C-NOE5S?0d&C&dl2~(p z>X6+{-+Z+upR!aKJ@oj_dHoFT7AL$o+c&$3TOL39uwvpM&)%EA>o1Tl=r!iW+=WL% z_ST1v@}K`4+N}O7s}K2|vd+xvBKH1u zR>)S{XG^|loBM>wtUW(za)jmH=7kqun#ZvZSFXFc zUK+75Jm{&5*o~Cx+gT@MGY)V%>%OMt zJ}MO;taDpS1&5yM+2&x;UJ5DmXLf7FI`IFq3sd9!ecH9IaLR#}p8JE=xV#!| z5im|T`RT(o-Jh#lKX^JyQg&LXT%c)=uas#~aKR^yZdxXp>VooSCe!C_(A=&ZH@H~x z^0CV+V)R`P$rtr}eQ@eA=l2hWmdmd(exuu`+Z-8lnbns|=BR!VjykbTP6?Zs)I_~=j7V|4b(FI_Fs|Koniq%JWoTSzoxqgwnEWb^T}9jozA9TR_ls>z+y-y7+P2bT(K6x92~*vg24tvM1dZH1?nZX2 zRPgcv!&06kT|3NGOI1FzVdBBnX}S`jiD!!s%quJz)B&n)_H>D2F zwTzT_nYrYOz=+e+R9Aisd*fOjAz43!ydN-$nCksWHRUcJyie;)>O#Xkz|bUkKi3uV zc#!h;Y?FEcz)u67p9bJvEUQHLB8Gob#>N1tI~YF9V8oAYV3P>H1U{Go&uf1Yz7Rf4 zXSAQK9m02qfHi0yem~7~|I0zgn*)z+?gj@L2h9I15&vEQOa>lVEaNr)L%=ipkJtYH z3_N^AA7b0q%B5V!%&lQKrNKYk>2R{?JbJXXZE!|y0qCBk0lYSQq~^Jj9&!a&F9N^Z)MfBE2iYXTPXnHL{qY)qB=9=4 zfB5|`&;5TBcx~V@W?XyVcg?I4iC+P3n7Y7Y9%Cov?HM8UT!AO+XGgBz-N57ZPsX0u zW$XV2@VNiN*zx+q-&3k@t*@Yw&D#~6mkW$@SsY!cx&(EdrD*X!>-@MQkLR*2{RmxP-x z@)$FT0YA5wNZskcll?FDAt`6euVV1n_qYdSlZc=5z~lHKgZBVl$FCLmu{4i;Pv!yI z`&%OZ)!?OvDn0%kX}=@zHfc|V?s6+Uk(v*MfJKR6IN=Jo<;NH0wm-e-Avazc_Zh zB*JUJh81~?A7kLP{mX#I>ks|o_``Hym5Bdiz{7Vb+txqyj|ZDX_(tG0fyZkP6We|u z{BZbku^RB0@5uAtY~XSKgnl~`e+ux}e)NwvNL=vouZh%?>CfTJW$>gQ{tClM!Uq9Q z_J25k@nDk(e-?PWelg#X_6rR7_w~c8e^cNM!9V)$$n~=scwGOm-{4lp^ZH)`yaw%` z^a+@0H<7yh1F7{N=N`75?K1@7HG$Ux|F8{diyy{k2^F9UA?JVI!L^dsi~mIyyUmYV{0HDi)BT5Kyz-+5Q~OW6{&`8nza8*n==le)l6YQ!t^+@fjvr%Zy9N>e z#zTHRzheBP96$dpk$Ta<I6S__^;nTpiQ(c&78Jjkcx`%*Q z0UjA#yLr8SDuKuA2YF%>Kev}i-F^zc+K;|TIa}Tmc)b5$eSC&yn~0wcz?1zW_C2rr zzjMIj{R3HYu!({A|3vfr0O9)^Qr?~oQcq3s*ZY_D80LAxy8%!3Z@35Lb^h%Iek!yd z$F35L^2%o^QTH!wJ0@QF$H3$G5uVrew>NB_jey7g$3*&t%KiB#spkhg_8;r#Q#1+cw5+fF#Z3#)}M2PKLb48zexZ6nfUh^!uNp1$AH1} z+J1lF-8#Wn0ndz|SN|hbsrS!h-Q<-I1Rn3-r2ly3yTRrS@1Gsz9e~ICUq|_4z}qo+ z(hvV0?f;u2<3C1?dVh}ovF)V%{}TT$A@!nx@67ev06eq(|Mv3d9PzKC{%`+(CjNbf z@N0o*`X}Z8uKj;=q+S#7HjMt~b^a`bn-_Eb|6fo4yM*{Z47@4$$9o4RUj2UoepV;= zY4Gxhxqk8LKM{Cl|B-f4t@-m$Qtv(R2A$}C3wZd(_ILF9O9j3&?f(EgK0o1gi+#sy z|BZs1uRSCFKlj+b&yfDx_#4lw|9aq=^OslN8XC`BfBzljpK~Ps!@xT;+Ry9yCjpyR zm_p%bkGw`zfN(Nz$bFLvpZQ@CyaL&|vJ z9{{fpJhE)pUZ(#60OR_NJRWQlsk<0>ynl9-F906>ceMYeOyO{BfycJrB0;fe1gW|c z__>|%{}Ffx+J8shUxdTv&$1Kz4d9vYPgpd)tqN&>UqkqB<1zg6%C7{zGwpu{yi+Iq z8^X7Up5_XE%DKR%WQNdGkhPp;qYz~Ve)lL)T|&fyh8INtku!9mK~vq9=D1s?a` zm`C5d`p*Skm-bKIfwkuw&lCUr#=pLQz=k7r2yY8KY{A2^TpA8u``-&59&!EYD1Qq0 z>7C#O;g?sP;b(P{Pw6E8rjxuTG#;PdJKF!NfQKp2Vg5=dc}cU*`JdZKes3rFx=!+H zaPf4e|3f>;p9dcI{~et_g0Ok(3~$~^KCYAeL*NZM(f&cOdFl-B*hzkWC;7Tg@}pt% zg!c~_m_T?>!F?o~MD9QM@)TY{xAA!YW}65f1H3Nqc;6*uY;2IakAR0qs5YJrN9qt> z!HV+Fw%xz;uE4`9yl{RvL2Qybe^XL7nZdKYccWdx*8-32NB_Je!YjbbALKjQelGC1 ze?tEqi9Z>5>_3c$?cNL9N#ef+yc*4uGCbd2B6YjL!#lh}Yn#6U0I`jM@Ed@iL;FXc zXoF26{Bz)83HxXMvK>dlPq+E?{sH~+8vh>P;T1}U+y54LJ>b#z05}Bk1B*oBSA~a9 zeE%guGlN;MScUM*fyeuAZ<^z^{TG49`9tO}vD;o4q@D!4yfy$H*FI9#9*gHmU2EXU z^}~iEbqK!+cyj%-Z9CKdec<&WeqxX9HB9OcgUOH2Z^+@n>-@C`p4@*ra{t%`FCXTC zf4qNngl`6ZD$RG~`qu^LQ##>aXFm1&bJBk}Hf$2<|2KBj{K2;OX9Lo82>%RTJ~~7E zglBsnApAyq>i5sc_k)A&Gce(6fye!4NByffQ0ouQyS{MnI{%7*hg--$^OwX0rv92p zJq5>q&wm_8Hi_^~z~l9g*DfjJg--+?=O1Bvv4I)B9s`f>&oFKrd)Q8}O2nUv6LtNN zcFSJ5Og{eA4&eiV*8>0O8$nhu)FXTj@VI~I+ClyU@MD1|*FLZQ72x8r2cFDd2!mB3 z@m~Pmh2}eQ{fy#L?_WE5{jCLFmk~dX1Do_u{J#X#y+(md8j8@%qn zwA`rm7w0|Nl^VSjmhohrmU?Yia)9e>&3st-zE1!}XhOBK`Lh zc=*@h`R~2t*ZjlvPn2Z<#D5j=@Ce`Lza#Tk-535}NerIX`Lhdndz$aa{ihZ9nVsNg z_)&lV-_hsaWZS+6)18+$49r3Ri@az2pUc+n?iQgOeX`P7w9`G1Hv4>;8CXxCB1F7*R zdF%r=iPXpcXY~w*e>N=EA^aKOasNrKTU-O!B*J%vn-}gsG2fB-rw#mA;7Pv`Z7&Am zKMZ)Xf5y4b>-@g}yaD(p`vzWl$>3l6@AhJ5o+t572VRZQerBD&eJ1>J;Bo(nag#Cp z8%NF&z65wZ;5&N#jtZgvego~X?K_Nt_+Jk^uHQKCk!Ndz@FPNhJwLK#nf~K|HwOP$ z))Br6c%1(@e>yV%`-D;ZSLAu=L(=}0z+?ZRZ!9C_@bRyS)Vl?|E~EXtUVpuoQasyr ziv&gd&j%j+kMtcWC-wfMq+S~EQ-LRQN90eyZ{>vNUq-DT;&;8lUgdl#1R+W*&q$N5YA6aAB4QcnC!FQ@K5Y`NcgYv45*{@Kn0;(rtHxc(FW zq@4KqlahMpfXDtrz9Z}3d*DrhCoHl1C%>ee)U#Yc^&c5GwtEo5Z>D*)%S#&wUk*I( zKV*Ps>l=L$UMrm9vHj>@2o5%h@GHWp|9(hE{a*nduOHlZpnp8rB;vp4N^1Qfc{VK8 zA@$9HpV*21iv*s$KWE!_O#f$rpA7yz}OOgJmCX?*9HG1PqaNVq~3AhF@D@TvW*>W6TTUEec(y|k#e^F z4L5ae{n<$KxPD;bb^I^UJmGkaziTwL|02G5<<)_o0{z#~^(PQ`e1C!0Pe;Zt2YB-S zkMseyrM*Pj`3!iyPQ*WSGc|v4{$TvXE?fT|z~l21*2nLVvh4%nCmr|+G%pG~+dd%t z6W}}3{#9E#cmJsb-WcM?-yO1TJ5>FrpVhZg&(DPAwf)P0$Nd+%{sqxEi$wab5ctkK z|9%F(GxzU_F~8nlVHP9f<>eYA?Y|B@^Zg@>u4}78c%j(N?H^_W-Bk{CD*J z_a1mN;4wbj1K=9YCXx6}c2MgV_8T6gygeJF-g@BCe@ElL4Ln&t*p5BMM*Pdi|9XGL zcI=tFJMcQ4h`#~&X`SFT6MpUA$r!ZP_ssa$0pFSUUjc6f@ne5>r2mw5{@Q-tv%yb|zu{p0+(~|6v)g{xgBshWI->KLye-6!{ofH@ zWB0H7XGiAW65#Rq1O0bo{l5f!XVyP~6zcsOjtBNV85j8Y*F@?WrTlyU!t4B71w48F zM2EbNUoPxZV*io2;NxEtsn=@{wf9ZuLnFnyP|JA z*d*fb1@OASldI~W6r z-vW62{TUfI5(kO%PfGX|z~lQ*j2ruq*Y&Rucmv>Z{Llum3m^ZQNIju_)PFw*eIw87 z^NS|%W57Rh_zsZRg^zztq@FwQ&Uy?kp z{*!<=0sk0#4>)*j|4ZO;|ArjKz$-sCgK9te=OvN$t^wW{{FCd3SO0f`$MqBYjo4%p z2dOKW`Rn)hyx1VTG4S~OM8=Jjw-*Dcw-k81e_;Q0gM)295juYd#zbEIDH9Eul$GF*dsZU0Q*$^NS&{j&4Jo^Y^D zB>rc>s{oI&OMslWUhK z>-HbzaCGVTvF}K`*z#POC(q8j^7+8y{hQ3+_WY9b#Lq|IasFZ7vu!)tApGcK)cupp z8&XdE{7FeY58!eAM*o=D#z6Q(z+?L{k7Lj4_}9=p`X)B_vry?zE1Qu`P5jn_Zh7)bmUz+?P4e#nw?`1sdE>TLp^ zxqk5K{~GYveqPot42<{}JoW4E7dvwO*maU`0e%MEe;x5}cDi%d&rzBu@4va8 zlK%S&{6vT!d7J}yA7YaTuYQKZ(WTpuW6$gO`vH&pCmesab06a){e&r(S`o;2&^dA>^ z*tfL({u1X8+h;)He;4rh{s?_bz`^z&K=@JTe*OQGdI8V&9!&U5;HQFr>_03c*AaaD zYa)CT@VNeAWsIL~9}<4tdFuX${&D_eAF@e=_W*t@_{VnR{$G*}40Q;f0zB?N&_Avn zyte-v@P@z>d%U(^r})?W$MNH}{jtDL2LIT1=zBOEY!Zq02JmG6hJAoz$R-h9yo6dm zk;Oxa4GeV%KMi=i|4Bg^u06b7KWl-<^#j)p{2g8_9Bh*`RKx@Ou0I?Y{{T2JHvB#U zrvIg~P!4N$TYYV;1c?{LangeW-?^E?fe97;TVMtfD$WJio3^FSDB88A?eL;_EzGC6 z|D`xyZg8M|ce)-bw$GE!qhf!0(|J^^??dNNF~0;3Y;Pc~VW4Pd1ss@AaeN~&0|^zm z4VZ!SzZCsO!GZNQ!GQ@C$9oGLcs>RWOsJTTqjfv2@t~Ogmty^$ZD;?k*iZZ6z&O(B z`u|I@zq8;#yE$|{UQ`Gec${cUyeM+#;lTJ{IcQV3tlAPP#&Z!4JbwueOsF_MS1|(# z73-B_1`<=T{CZm{rD&&u&ZFYEK7<47Rl$Mje<{w7r*H_s@fr@aQ$uSlC?-^ls{szI z_W=&9_Yn>p_b+t*D<~#ZJl_llaxHLRJuJjLKPbi}K&v2DhQvnEjtE_!sUx9$99_;- ztgi{>cxnRtK)(}d)uvSk6#j8^;Rn{ALgx)YvAz*qZUTz&o6+SKv|57VJey7HLQq^g zT|wa=#|?g<-Nm5T-#&ES4;21!{NV@s2?WLSVRU{ODB4*IihKlJzJb;#`g|NH{Nrqc zAIR^dbvIp}Lg&*!F`?qe{qO_lX*MX@KMIO*6lO z6#jAU;SX9d?rO-Ro#&uvrj{7k2UP;adMdQ4fxsMNv>GP;K z4nOHUD%Qu&MdhQl3wAanR4ngG=TQ;uMwbiF<)~P%JFS9rIV$cou>#JPHc`ILGnF9~A2q(C1OH z-6uhD9W18HQL(&)&ZA=cF4B2aJb#JSQd%$5=l_@D{JKw{N5%1cMC&uUoT+&JHC@hB zEUJSa=)WEm<7%MmF%_?~4|F+GvHmBz92M<+2F3EPbU9OTUFL^Q!+PC7vHyhVyf9`U zp`t%=I**F=C1{nT%Tdvf6rD%KdcA4wLzn+A#r_xo=P|@VbbVR6J}Qo%BAsU{+8;re zqav>aisj05IV$Ey)A|3UcwU7*kBT4F={zd7e*$Pf(3zm<$C^Hmit*2-%NKy6Jv;h5 zD)x&#okvC9i7w}YVtp6-{Qpw)??InuD&GJ6X+D5H&(uC}ej}74zm={RLu)Ku9~I-- z4vIxP>2g#oPp0#z_;C;X!1knq;AXu}A=Zpq=jYc~nG2=sZ)gUJtq)75iBV z6pQ-74?N$W)&Zaxrwk~zTMiWM4g0Eo}WqQQHMi5n9eg5+Y?HcqhcJ(==|Rl z?JuY6ucYgvV)-gsSJUOF=w~gh>*;b-tRF$=Q86D$=TUK<#(*LpN0*~wy=}B^r^`{X zdGP;qKb6*fw5HMLnTpp*CS8t-b`I0^ zv*>a*ivF_c`lx8<2yN#mt;cB11%-c{0{DURvj`OHpT|O4F<(OGQ8CUdbRHEyUZ?Y@ z=)Z#28+17;+PMXa*Gn}ho_Ya4aQqrD^P6J*w{$sEvHU%h<9K}pMSq{@dQ8Q7U!WYv z?>ntO>3XPmo(~)Io1$HQx*Qej34o%X?z9Th<-&Ac1eB!z-lP5f{Tj}*|MmV1`wz=; zez?Jb{p}71CR7|pPs~6<#c}b*3?x*n?}HggsF?rnJsNdg|MwmZXT^W-(f)gnM$Lo& z-lI|X@&DeVQS*WM-VdMm|9g){^~-$sY51JUe1C>#Fpu*F-=ASZ{qH^6zu%|*_Z|&?Uj*+j zaGw149ob>vnSU&`c=C92;;st zt@#>pp{Lk#z0IXnX%CFYG-o#^m@33)q^`NvHTiSd={w){-l_W#rl`lTOW$VNO!Egh z+9)YzCv5L!yg@GPmQwhPgsjJ5xBKo};t(IBdOW5d*HdG4+@gg0Ix1r4bmF$3n$1o0 zKK3&4`aSuzVRBm^P~VxM`hkAUO4)5yOGvrmQ?XEBs!(vtvm2W4G?sjk&dnL2W1v2o z+v`WzW}$6IyC3cQX=hpRq45)&FAPbo=@$Y7}w*$DIwQ?=Jsjq-^+p|GLz9Mg})+Vh1ED z+HTltc-D2m4~AX5r;x;7d9E(}qV7(`0FjByc8{*hPT%)1eS!4K`P+`EpBLXMJ}A2P z`_{ug3i%Z|sSDrr`PSeiA})Gnn91>$fVbSJYT3nEr}DC6ds=2# z9LDc3k@<`7%t_+UO1_zDer3sn{UeTKuS{K{H)L_ck!>b(CQQ3A^7g`mX}NB1E#gB` z-pBTw5>#xV@pN@<{%c3iZTa6<*)8&NY+kj1VHdwkLlXbWRByh~w_LA>wH}}AYEqWl z9k?v_M9{LM{2)5W^lL#9hh*W2O{6GFz^5bFtRL(;L1?XeBW0 z;&+Wm;@9PT*tVytNul?<=(nDGZ54!NJ_f|6UtM*uWy#{zyT>Qys8b1mis^vHSmQd2cECThCGbH|oI8%u#4X%Sn??jDW-#pHI}4KdZ8DtXV|E5` zR!lM~Isbma#7#w)b{KX`juRiHe*I!aV|?NH>Yv)K$8}Og8=j;`Up(wsVO2A2udvN? z6Q|E}k1FDKRBhm)8#HvS#I@>t6bUHa5v#IdRTX z{L?|tW!$XUms4S0TvJFWJ)6Vr_vvu|isWM}znxgmu*>{jHUFS|rAu|AGNMv;xfy!w zU*9Wz{FlAUWmLuOq70-Dh@Oly-{}x(6m@K=XO)l0;{$Sw&!7E#Rcb;v+ZFvLge>Zs zW6H40{JuN?o;iLoq9tPm^u#4YCCy9S{+3=6~mNPeRx7=s;?W323*18Wj z7H0ua<z@bsx4zCCebUeRwew6_ z<-U~l`{bj!btgoIj+wq~&+;9opPf&7b5OF|%3~s)8X{upJr`{rZ8FZQGP^ZL zbE02KN(Rk*Q~hfC@D~9LyZAdZlK5l4Uvyut<7ksNZ&H--$BGGogXF^MG&5ZfNPCr! zsl680uS{f^V(Faw*=v&e#;8|aG+os#_=@eQKKTLb69x)e!-z6Z4e|5p7n%3k} z%>%Jvmztc#lC1g*`@sMm!%P*_rB{SeV3h@p-@^jOpt$t){MMT@!U9r z?3ru$yId8-?^=?6!0(EX#6NymR@$zfp-*$Nv*r7)KbqLAG&ojh&mDmYatS|XrCUgk z3N?D0kuPJ_&qVWzWG>_q$R z@!wzTII5+to2h=bVnoUQ{PV>V@@i#wJZtDJkWgJeU86Ote#Makt0VaaS*zQ3aWwGn zdgK-(Ui=#ZKwbflZsYdhPOF>A*} zOv#;B*0ZMkxc1Ob=k|NO$mpteUg&6CnY3&llbwccjwTO3JvwL|V5@z%m(IP^_YAxE z-9M7}Jw}&o&ogmLxbD%l^|q<9<%JELHW&hhE}vj%-C`(zwYeI-2($Js}HZKKA(Qg z-1Oa0!S915CHIF2B~Oki-6ba2d{J$slgPaVYY&EunAta1WLcw<{|km){2K_8_%9i{ zHeE6PE)sdWF{V+d(p_+pPT%0MH!9!kix}_FXXMb5r;^e8=RJ#Fu_?x7ctT&X`pZdg zjXz7|#r7UB>X^WmP=;MOVic6$ZB?`MpjV5#&8;@;zr)^D+oqXmgRPlugOhb`|{eiK7}fKY}OCYe)GC_Y5k{rI+=bAvP(GiQDjz8VR+*KbuX>?tWI89Gf-{k9hOmT>BJ@shsw7PsBs zSXzqQ+?BU(WFS-}M@eZAn(+;won~tg)RDbHj0<=!qN$+u5gFU#eQP=(Y^L zxm{jRRo{Gk|IdMbs#?Am7MPkU4x26H1WE9<(E%&;kG zy5nEgY3VpxJ$vTzV^OzZvsCz`k1rkn=5gtVMMl#&j|Vo4VAvhbw41D1x2(uea-?a1 zPxjOBirEUUd`_4qmUI0VjOen#>g>o1{cNhHrv9A8k$K@&{(YKNcFD*{Gv(gtQ)*`B zkH6`|d~Q)-+TC~2>QrQ}JJqpLi;J}~WF}l*a--Y67{QZf5BoQ9PAz*M-sMnBy811y zy_#oX_dzSW{+u}d#I&C_>v}m2+cR^;xHXJ;6`6J$bH;R6ysfrRqvgout2y5^xo*NVCpNfUTY~~-B-zdKEZsuu)^l{zN!)8@Y z71UR;e5bVOxQA)-ejS}d47>Q$uU zylTV&H~R%zUEZezW}g_DQwxBk{NGc!Z)FOq|D6<*~x&0CsL zbZkSe*7opiOBr^Rn09~M`);9h>(aiw%aZ0EPkO(79GZP;ROR#3cbtO2YE|QwbGpKV z7VZ{JS-Q%q_(z$$=z>vBW{M|QsGpQJ)eE>dK$2lsnQ7PDz%;v3%0i<1yGsl6hiz_{ zeM~{=#@N#5_mh)T9T!&}+W)#*$oj^xqi?S3j(92cDL+FjPW@m)X(q5!j>r=a8rJgqG+?ysfTk_{aGLR$he&* zx23nfQ$3dA9eZZ6?~!FL2k~z}$o*J_Y1euDWRK>8^0611H|QVgrk!+HDm*-1TU-3N zwi(yK``Ps~(;p06X%l<1=b5K6SwmM3)t}Uq;}dXwz;>17!R6^YHZtt0GVSJk(k!rh z8F^;RX z>i7)hbgSUL__r7&-Z4zO-&c$rdOJG%>O$8w!-qZOj+wq_Q(mD`=3AF;$-TtQ6RLNu zjBAL>34OCTSEl!u!cW&Sk4xRJ3f)!ucA8zn=QpzWw;;r>8q+R!#j>zW%X4*7cKepk z>i=fD^xW504lhb7&TlL@5zuvIp|AQ>y;W`oJ@2g3`7v+g@;wJX+!$1ITcbzw3YB-B z`L4|8A$6wR*&ojidS77{d1=G>nva)8^7}=`Iyet}+ps`wwphF$sad$vs9UX$fR?#U(RsC1MXjS&Dz3C;?o-Dx?QT(B zH~5sZsC>zz`bh4!4}O_$4ev4){ft|sw(9$PO?hGyW##88xK{eo^Pd^x^BhjMj(z^_ zV}ygodF9$^&QrJUW5he2Y1i6o^Th$9V)etdGBsvT^^ckHO#0`xM*g1rMDE`mH|xid zS8h5lbQ>pB2JRhRvv1)?&WMQZ^|C_iUzmhy&)x3oiGMRi#!Z81_s7}>Z@ytK{I8cz zR~xZ>Pk$rnlIyNslk>aRq|Hh=-!f?B&8^J?)&_Z-T6>%+7MQFYBd)uDd&NxKZ_;Tm zKOSkM{-%ezA8Ru0-jtm6NpABQCv`dLIek(s7Py=YJP#WSDe!GlugdnzA34<4T)7eV_(HeA?!r4;_gLjhpV5pomiXX)dCdD0#tge! zOuKiA%7eND%4ZBtIQ?ez>ir{9`OZJdZ@Q?kaqI13`?onNB2ms)N8Mi@a@w|bV3uZ3 z{#tD*CHa$0BZsdWr#Qkijk!L+f79Tf#Lv~=x<32GjJcdhRb{i)4-|rZzI0u3>4`Ca zP3kVo)00Cl=O{=}~-qX@T9^!<&Q$zpg7jK3-5E@yNBWF~hYM3$EMS2Ii{|;I+`2!MRad;f|MTqU4R_PGSXIdl5&k$b#LGMB zA-|-<^QUQh3YzOjDLr|%X2Yo6&mY%VMwYwT>Tk3-d-}QA!3FafcHwWM|4IDZ)~WXo zc~a`Qcj{c7}~oPI<~-8FqDKXy_P|C-rOeGEK< znfD<*rrofR8_T=jcHL0k_jKOFZgLCFw`eHsx^6BUIA*c*vs*^1ckDctB9-#wiFoqS zai=8xzkRApws+sUFKLLjjp(7_+nM_keWqOpjpg-xY2&Tlo7O#Rh~Ck3cYL7x@ds17 z*Y9&zEOlI%@g_D&+AdJeb864L%R{CuThT?MW_tbk9E+T$z!}Oy9|kh|VG7f()R0TZ z#ec2})sGnb|4?<8VOce81E}ei?(R-0>5`J}?oR3MZUm$oB&0*SyGu$!T0o^GrMAA$ z=iamD&-!;B7uUVk^bCVh$T?8xZ7YLVXmVD%wzz=$B#EfO|-$3$Wp%&!#a zI_^o${T6G&_$*HqBzsbb=IHlHE*m#Y?rnqubg?ss%G7{0sw?E-4N8`y{^aBq-^{w` zW!D=XJ`tXcIHjcwaNI2oy3u2zB)hgej-8WwR#wv83tbJ$k#8qg30q5EX`}OMoSp)JQ1G+*%Ea5!O*%ohxe||=Dnn6Bq zxKpZn^+FD6?;%Szsr^Bi3nJO(+n~g*w6ltD)vm>lGQuH)g|_c?c8J2Kd(muwD+{`} zlFa316LmAi&%EN{8ArJR?xEp7iPfTv;13YhhEowmQZguQrI8wr#QEw}Ito5#uQCk$ zYH!fUrujl4I!y!zxNkuBkZ&|E$5$pdh$%2cK}^JaicN=`sPJ80D8Bd_e$?39+%>18 zJOn0O%isquQ~Z6WC-=L>`s43mG#3JJ#c@60{DU0m!pvoBw|t%ZhGx@hZXjv-S%kIy z^7Ys&@1jfH$My1oGh4`J*-^GOf2F7?=*A4FM$=LZh_G@gJmp%?D63z~;Jl|i=B zx?qcsONq}rnEc6 zIDTg&@hYWk!UD*H0_gs_eP~x}xNdPt$+UUd`R%Ew_vFoEkH|C)XI*3~^Fh{hwr!*7QImmys|32CThdPs z6k}{-lRbHJ#lkbTLaOUzV^VGrWeu(;RlM->BfAdbZ{D-u1Qv<7NflQ`g!}_qDUctJugJaP=-SnEuDdIL8jrpL$ zg_w*%>=OF*{I&g*>3MN#F1d7-8(tKe6ifH)N%E-4YHepO1VlBB^{Eg z{5zjf9>7%tUC(9pObgMY0ZPV*iLbhHcmvlq`(=^>?c+(!uOh;FuBFK?-%D6W^63{H zK%m8=MP|9OvdL30EjOMe{ogAZ(5XrQ(@%p2asqj644GJasi}GsCDPBk}ziq(P0NoxNXp9rz)B~(` z%}~l+H4&D%i6~_Jyv&8;Q!E{(U=M|wL1AX~VG@0f_@S9b!-?=_4T1wv+Gji~y|;-; zZ^8L>P0;mTcKt3p4I}Da5H=IzR|#)++;n(*IU{KSC&-Hr(=~NmSt^5=>!00)(O^I4 z#DF}Bz{W9)ji?RJuE;uk zWS^~39KTJ{+ z&YnNbKQ(F&y&hV}=Z&bLt7Y@U42V}3bgvp~4MJu;lOaRzkO;S7D?OQ;^hMe8>Nv5Q z)%>Vxpu6AuJ`~KR+@hVn-7qg8dym(3LFW`9KzCH(k$88PnHd7p z+Z>4Z-#L!IJdDeE<52VJkKx)i9&@t3#6VlPl=X}PVcV+3+>YfrAN>3fXC3OHfV`vT zoL!HaFvVSJ^xsdDOuzP;jYfj+u?+#&0F0NFx~7r0OJVv4p286`Q!!pfW?4gb7uIVO zT)#v0M;i#XjR$#FdiBoA?{zdq62@bddKxZo7#JPwhNBC(rG+8@*AR3^1o~>#xm}~h zeOue>34Rtn7z(IkiCm0J6!2HFP{PM~kKHEw!N_Akl1D`SlAlF>!OM0gd%08kf?{0g zq1F&=FN{FJ906^$gesCW_1Wp4p12T#GZ z3vp0zFV7@I1gn-@)@7^5{p2gC0>o)_LFD#E>fPbCKQHapq z?LrI0YYMtW10uo3YJ%nPT0?t^-w%9X<|rqIe#FrR<_Ocyk_n&+tgJeCIFP)zyB%cm zA%ekhVo6v3dHP!E(jE!jsq5W4z%>J1pR4aK$U515+B^KgGMmDvW+{K(t1iAEJG%01 ztD9_n(VC-FB90nVvZ1$EX-4+3l#7%R2|UGu(Qzas1JCm*fNKuAw90V;2Ze6pNEIs1 zDMe(OLD-tsoX0}~lOo$!9&VMq<@FT%%BhM|)NEa089Lsu`fO2t4apQY+dXnPmOp&J zc>)X2J=fgSIiGOoWZm5Lc~yz}@|}TFG@c-P-&Mc)N`p@pnWHP7Mb6sD;%Q}JG*ZT< z!pMt;MB&A$-L8V8>&e_#B0#*Bpo`*wIJ6vy)8Cm!jusYr+mQxe>UTGrT6X!em9O%= zuI~2cDk8?>Sj3rxFM;=RIBk?3Jz!+$-3zIDj+Y(PbCZB;1-d^v0$y*d^0QdsPljxJ zw7poOY?t*=G0t{MKR%9fmfVsO6`qEeL3>NgZ#@30*2wYZCad^-}*Kyfll&?s8}gL67)R z_}hEfDrPO>Pg7YeadawQeY(N=_wRYIe+7o1;u4bl7Dg}zIm%*75Wbv$znh;!yVNvT zr`It+dMS=AJE$JvMsL;)+K2dsalcJ!O#r8zQpOp^G?8`(k~BCDu>FVrdBP}q49T{j zBKB^@=P^XTmP2gt%*k-g)%H(84gbMNoj{?KJ`g{2D*D3}TEl6FyZy6Y19D?!-+aAq zZ=GH5H76aA2RqP}Z18^K3wN3qcjERgUQWru9DDHP)GXhFBBj4NJy7$Gl9;6F z(I{-GexX{;I$5sodwblJ5RSiL&#zJin-2FBL;P0}DJM>+eEy^0yyxHZb^i)Xkp%qF zw`IC!Cf@lEo0sV|zOB)LF8oT(`#$MA49TlLj$J&^S&6e4S&V&|_|`#t8{BVU_NK{Y zV8V;0$DLHKfp{JNp?{t*UUuPls0$@a-Jvl7qP_=uuU!-ZKWprWq0DR{%|pCCCT88c zCh0@UlyrFV|#0wiuk@ROX=N6AAl9Z797}{Md<6o1TF_ODgD=X`kwQs0g@!&k_DB zFlftYA#xL`2FL4h6SG42tmi?O2~ngzN$nK@S9XInJQS3-b{``1SC69)0(yaw|7%z|I%>HaT`}e)MfUZ)^j+aNsg5){| z3(^SwEsZsy3gke_k2;cJR0NgHSg6L6`BcI74pROuw<%48QJ1u07{8}&j)5p7qQhod z!yq6JuAuA3XHRtpi$%J{dcEO1B~pYrd?0-ijO15K@ry$s?fNon!PLzlJSL9_d!(Jx z*SYK0kGt<5W+_Cyl8L-lNX9w=*9~;f;?%Qyihp5gP7FkOs z>w5aj5~53KTy{$Z`yOp4W7+gj_#K~fQcKw|vD49dyA(ptH~0{a)fYZ zfOtJXH!1y@sr)r%QzTW%k5?L$QN!J*>o5-@=(n@+kXAG)^t^8)JyJTjM6EvZyb6My z$&z+;7et9sN)==6Z=1-d*1(C|ZMf~u1%1~{GW zqys3-15%o>MI<4KMP9ydHgK!hgc-f4%}mDg;S12K@X23S*w7mxAG}@sxM(ksFpv(o z-k_UMO2(SEU%WOt2LCWF+1yhn{AB|@ek+eEKXhjCr!7@e4C2Ew!VX!z-t}dI?j;$| zO0r$>wNw~ZP18=-B0)Cb`he~N`X^Ww6S?1R73=&yK7PKZsA!T*OmAS>1XSbJx19=j zwFRO_(9w)IPlP{zh?5dhc>|-X@qB)ZM%#gOuo(YZ=)n6QP5#82#O zhPWq?t(yyd8RO#nB1?}yIqN3_0M{RM50VuacW{6IZe=(Yi`(1fv?OkVVnY-j#^X54 zyjCJiaLQ|?j{b@-zS5hzty=LtGdrSj!#`N=pz4$Vwabjdj8OCBdLu=1kl` zhnK+QD_cpe@5>#A<}QJcHINYAQwU36n7qd&$t?1c(T5_=V0$>~oZ-Af>`NMR| zcNir{7S+aJAl?wr&AEcymu>xAYj`d_^_b4Hz4L8qA^-AV9G=|il9FT~_$q{I((45( zdG?wM5mZO?cfa^|PD5gKCXmFXzP+E8!2Oe=A?xCWmv2hYJ*H%NvBj0_grDU3YbSo3>e$_OevbT-`1^8m8?zHnO}`rF z$1HCRd?UE-6b`z_O?t61j)@OU* z;h#Gnxu%`ZHC(IJ9WFd1{JUFCpNXl?87scxlPar0ksiM(K}BKydlvd%yBP_(Mi1J* z?1-JKt8Gu3V^>1Ql-}GX=EiekH$0G?5-b=c#dhg$(a}!RJmLRH)EV(XWV-lli0Yv5 z&TN1`Rj7D+6>$G-H2Z%Gj8x=O$mYFYx08sbKp=*|gKP>L7fuezOIP}AI*A10XxWdP zt|6MRL;5iC2M7gO(OK`7>DFa31fFoKZ}D!t&;U33|5g8d7nl08ae0%F0^CiERxa^x zzE>3)D{RIf(^NYNd~t}BxV!&Od}^voHqNTdd6>Q(X8)AkG9hT80h6rc@Z{FJ_upNE z|E(9sfbP!CJ(cMft@Bl#AD@fe_ur2(eB1ZiVCzBPb2MZdB@hr1&?Ak*GErPPpElPC z`1QU*_?snvWYXSgDNH!3Mkd&giUr+w(rlgNVq+G=xjF4Lvc^!k1u)@Me&jfZPbtAd zfi@qITb+(Fe zmyjUWP`uz`_jwsW_WGHNMC;|kA{Mk-^||B*1V=2*C%DsdZLz|5)dH0P3Z$l&Yd=GD zf&9jUu6ip4g_-=J`0RATuYN1?*_9M6nix}oQLh=^aelWx^!ZHLpPV%h{&4H`g#7u& z(dY)u)4@H@OvZOFOYCMuSpYWybRjBqO;rvqHZV0BBazv%4|ZZgF1@-tpf9dZt6SgE zW()Wg%qR0f*RmGJzFDAU|CGWr<#lm3L)aP1XuYeOrwzD?plfYH-1@aD!LiM}ZQA&%e~ckTtu4>j`w8(+YE z2fA;M4XMA|OMmVp&axhu|M2xTjn6#FDQ>xN?ey~}D$USTp47N9ysN$xo0hn)NCSE| zD!U2~hJ`IxDLMUjcrA5+n*_QEXkABQQO=2?_%5=NrHPvG)${^Pig0uBsPYX6+Yekw z=sIqw>q)g&;sWGXO6KnD%V~nOCr4Ru5y~q$1)_d{n+&?8G+1Pe9?-;|Wp~71C=rvw zYG=_2!?%<(>!2)QFlDdaw_C*P?YY12iM%}P8!R&KJP0#7d}AzT+Iz}j3-tgAxGA9P z<7MWFY#GUr$k7{t;Ol~&AjwO592Yo62L)nhuQqa$1 zCg4s$SV_G*xttWrjLHZlxA6p1ply>EXQC(^tbbEM_wCV!!$pWco-xlfsTbg&x(GDW~5ZxUW$qskxfIf1l$& zUEn|JQK)$GHVX;GYI%mOg!IAO#io43LWfFYG(!4TQWg+J;e<6 zS6ZpazRB$}wUd_;p2(3RvS#)5Ta>{Qhfe&B$J;5R(~^+@4MJI23jWrQRcF6^p7o7> z3A>5<&2mbB{q-!+C8g}`)?y6r+-|4Jn(fSKNk&Mg>b?RMsUo(>O**(5Bb~fDWg7=^GW^1ReQZJAyi8h6 zm73E2n??yIo#SV5*X0!cUFoq@A_M^--aODP{-zQO(d>fw;n76nGf$YjS+$X2D%Woh z2Wpta0Qx>YtM*BXn9_TrZw&zVJn^+P`BBI*q6+Ge1+ zyb0oy$;=Pn4~#2M`c=6W9dteEzV-Tis{UNnBk3{et834giu1icOJkMR!0*?OiVd7w1% z35hA?*)+30xe!hZ;)XN#K+g!(IRNrd2)c9oUPS40lQE?IbH{z9FVjbPkbf{6@7>Zf zXa(OWptF-FwKOY){&4Yog%6wM_>lmy$|D9_O6XM1(ihDw90A;?Pz1WBik7Sx$9_?` zD-LO5smY<4tQ_xB9d9KwSbu7Tpspa5Su71=-@VJBgJXxio_R)3bn*|6coCmcsK?MP zjbI4QdlrK(()lKmS###7^~efU&H5p!WaKc%%2c6sT#I1Ek(OCg5JWHeuJ_3Z^5gz2 zmL?1=mmta3jOtRMYhpZhdiH3qxi@R?uRhjU=Dk2N?t)2ZHTQ)Qxv) zJ%D&iLHD#X-=22%o8Bo7P3TMoKkKXm&&cDA=q>Y1j* zcQ;;BSPzs>U-(br+?IJ+9(X(qTQYezm6|ZvsTH+65$36UP#&fB^<_DeCq4K2jpu+LpI-*vRd4p0hz~p`3r?&B4?iM0DpB&_mNYvP!>r(|zHZh7V zfv|+s_ZB&m%MM6WICjkBfLj5&Rx9mCG&_^FW@i+EkZeKL4RGz?tJE|I*qZ_0Fg zvu3^LxBDHjs$I(+*5O{SP_sA8Lync=mr%v)7ylaE2i!`~4Z2z2gGU?Qwi0_I*vE|JH*z>QhgHzJ8uz2C>*JrE_1=pj&$HZ@{er zU5@H>&P4}+Zrimb5md81m=+XIgU-?o6@vzNx=PQ zwV+$~r3Ix+B&9}#`M7s2FYdQP|BJbHt)>`lPthoJdF4&a4a^qnf$e_RofKmcJF|G+ zQ-i`p^`WB8kV*t8)GkpV-a61t|D8+e5n8EfGwoyP^ZayRPM_7UVC7=2Xy{VS=eA-P z4+o_v5%Oh@!s&VPh!f%FQ%h%ec~l3aPd;u~Km2d-d#eXsO8Bh{)tavh8cAGX*_-Gk zsciHTS*O|qMzQ0P6JuJ``d?*UYzUuXk6bo(%8BFBGSpI!CcO(5m*6wh*zipKdw2Z5 z{5F6to4T0R%L_jxRQn#)c8H6711zZf@<@` zB{QaHNcH(Mn8{BD_vl*lios^toxI)=%pPs5NH%s2&m<4zy{!a?OU$-u2MlzBs2VAIYe-%Ik>oJ@yrLVJ|j@31H|?Vqew zq^ezx@f64{Nb##MqqQ0AQE=8`>=B9*aXS>*F$Mu{3+TSGI6Ost{f^W|m^SCA*j016 zU(aqFmPetLjwSyzMS&`=es5)f%Ar`rear)>jW`Mh!qww79plkM-R^~c(RBmj~>vus_)Wx;!oB zw5a8LQp?h|kGkhGyGl@OOrMoT5Cd`p`Tui~u8D*Qy=P85ejr&NA60F5$VS7EdTM{t-($LkR-=$aGoxWJF=86#`6!Z*?TaDHT&q!+3vVX|{nUiJ^wk&}vHNPk?FQYLFJinJ znve`^5Yg6?nR$5yr##LE9zCBeoR|-0ijkb8Ls^dX>w967Qn~0inlj;~iVgH7nvxk0 zigCleT}e>@w+D2GWv0DH4(M$#>yB8gQhpuFi70)nw7+Dkl-<57Ncd)Cm69T-Ygq;1 zTPk0^Bbc%TvE2O*&TlJSuqecQDqm0zaCAuL{ zzuqb*2jcAqT}%(WAwDQB`gwW$rKj|g*Y~%IM3mRO9}7q;#5pISH+P1c|6H$NB6h%e zEyFmKJa>uJ-`8Cjl>f!EizlJT%LZ@jfPA-Q-4k9k6y<4 z`lo{PlS80O9%0A#WzEcPeDXr67E%B=SQ#O7kgbGK1;3M7sz{4GD>TL<;5vmj%b0PG z1)rrmIx>*h6HY0nY^9D7FWC}Y9~uVT+*!m1M@Q{nW0G0y%oSqG09$?~9&--2|@$)IGozmAhwz2c;r zHYwnag6=&-Z>Lz=3sN0wed96|&WJnZujT@9T4@G~(f!Hnn-q3vk(ro*d~bVKkTt_* z>bjD;q-70*H;`;eKf1_OQGx5nW1#!cL#f$OlIvmdTZUFKuzZMEArB9eqUGt&?y>pG zVbz*X15zl_pHBzV6i)8_$QH^FAVWwibH5CBV4;U~<8guQ&^YK~tD}#fYg$+GL3e5Bls6t{24t?jLJ7de&E`fc#E^uJoWme&5BZX^iv!7Aw<3JUn`m4>J39L{E0q@*P+D zPr3?;MhSH{BpgU&oVfQyupHlU6RO#z4rKba?N9ns_5gPZbji~-VLz>NpoT)rknrFK z!Xr(*;|hS_Ze#4ywY2g|aT>)dYveG*`0^PdQ%JB6%5zM-bM=d~u5pi|OHY3a3Rpi( zgD$?=jlcpdnj=OU zw)U&S`TcCw_w7W&kJ+2qlz3(I?j&aeJ}N9W>JU@{?kwnrX4}oh0RsoTE9Igx?hY=^IvCUtF5wI!=M#@f_$z zAuklId|J~%+4Kx?TRtP-e8%g3CWjb!Gwm0<3vK-)yjpvuc&XRc;006tNi|U#bGzgc zC*aP5Zc6~%s|QHSq5Dab-DvK8cIS)@ zvB~3$9f@kh3I){s`AbNOc@#zU)Az=R9*0=I-Q{wR1C$N6#V1Z1d#JUf!42TI`?&fU*Zl+tp4mSGPnAZrp)cZ%fXL zS4Tbjc1EwpW`TGYLH8$>yjexdpvzjL)%7iFM8q$q_*X30_=(h6G1eDH@=+EFMB6+4 zogZvkD0pe@WTuDb2Pl7_`Jv;SZQ`l-T37<^66nrOktX~|yP%kI=D4!l^&3kU>Gq1jhqc{3pG_6d4y9~O~M0N?w z_%_x=-D}M*XWNtmO9n}`ku1_7a<6_)#i3)Vxz1wuYn`GtbAAz{Q~WGD-aPFlwpbnW zt6YRpghmbtaKC_Vq-2^UDmr^BtQIAkcwOrzzedFnZm3Qorxs0V6xyzn-#sJ+%IjAj z&0Rb7c->KnE6Ik~$QLYfjJsw zg;fkP=jE6kU$6&a4iUmt@9W)1MKOi_K-xJmhv?Vk5w>wiJSFVX1`X#t{IE#1Q%}$Yl|Roop=($51?Y3r7%ci{x1*z{eP=1 z(9NrNog5U<3r}KRL{&?-&_>GoNn=PQaerPft6#CXoW%_(p6-zCZv~= zew~g_hl;+D4NZoBjEVfec>jKH`=Dz&*zD_G9pWdTTjOcqBlX8?b{+M{O9_t5ezE%f1-qR6&3?C6;j(l~BKfd9Kj;6w4?%y-1JK<%Ef6BLL&s~z zO-|6Fk3NN#5JaORVn3lPf3Xvd39Svo7vXcXi72e-*DP7>F~7kk{0+{7pDOt#evRoX zThM>wSO^62KjtCm+Qb*UG%Xb-Hc~r=@o){$Uz2j=oehOE+w~SC^1P_x6j51n8mO|< zBM41Cy7r>gk0>#Oz+c=WG)(eik#KENSHcJkPRE=#?xT@fqm;X0z3Hsmv zlluX>ZpD59u(qCZ3zUM6)Cq~t3(2yl`|zu$(3?;){#CTzIuBc!+Q$;K4`vqv;|kZ< zZdU7+RuaP@S;(a$ZG6VA|M$E7jrS+$K7U;cw6FK^#7A{u;Rp}xG4PqX`K?C1TO7=S zfv^C(U8I>p>wh+-M*gV$wNdMvCku|}Q}pf7lE>la#{8PQ|Mu}iApCuQk3jc*wIR*X zhsD$fRask{%^T=y?6&;hDq6!zrYD5)OJ#|_j`!3gBZTL%YF6Kfzkjafc-7TgoNvfd z8~;Xekx<~rfB8lD`~Dt-Zl0Pye64EBtdnX`K!W`H`L9GZr^vIjyrX)(o^66|YdB=X zJ6E;D%CMLieK5iN9y6YL=p4}mzkX}T?umP=`25fPuTnb!-45ZNPx8&=I$e^X4V@qT ze(}0mJiz#sTIL3dCn`(F??v!z$F}xfC6T@S)?sLB&&cTDkT}-r+_OsmhN-q}MCX4l z0z}Xs^AvPphhyh-eoH{qRUdyjH9S!F(1WbOhLI_l)M?jP!*LgxbjW=ej=D0Mn@P&h z%Pf#m7Sg-V~$s6X3Z zoLUinhYfR`R{0i0zD1vsT;$6(HAT>mDj{@QVX4Hk8Mn2FG0^k+zj*(?59gq(!Rdv$ z8``hRX+7PF85s; z-|Cu!rSrPM|Hb>aez*W#YX(~nm;v?6FhV3Kxj|^6fZ>kGoNM(FE*P3d0p&vp!%u~% z&({wV{_-!6Gp(-=b|me|Azam)zObT4_=tu6Hx7V62>N6G0$ms%Nt~ZFNw&*+G-$aE zrT4h_8ZjqG48zX%s<5S<)4~4XDXp-BAvkg)8Un5#BKXa?=}gzc!t+C}Dew{rRvZ2= zUeQ11CFs7!R$E01OBY8Hg;3je+dZAINm;x1Aq>c@I{4zT;GOAiJfe#a!x&y+M!=qT zawISz{S%dyTuv@>n&><34)=fiOCS*b=JyJ8F$}wlE{`yr5~1?^Laiiz_MaC#pD|^r zbfbKtJ3kjRcAH?$I{=ayk{#QR-gD#@` z-ZbKqyAxWYdvLE(Ot;VGwh2@(o>BZw25AbZ7Eai#G%0-Lwzhd;V-@9W1Wfs0)YN?V zqSx%h811)>mH+NP3;N$Z@;9LSU0XXv%T%gj8wTkkwX_)|b>N+aO4IH4^2+!D1ASK7 zv=1=Ps;6Z|q5;a88$E(OBC|a$tU-LoB>e|*Rn~#v{LU@t(u;7yTLxLSAW_VH*g9gM zMIdGAyTU~pMJo9G`ddYsixxi3_}18OoRK$QNZU^(f1Y|vvpblRp<(>0esxc8vT->hq7M)bQu{6pYx zr1upq>ynx&0rw=_hOQpbr^bg*ftz?G%!qh8%Fre2SL9C5^jd>P6#QC}Tnl@=%Ey(M zfcpTtAESimA6wmoZIfzccU#SQZNiJWNH+3P0UgeYo@ zl@Z{ke_O$SM)l&lD9&En3UD7mmjwNnLs2pE7OIZMIi%A0K~Mt04%^lK(;)-2EYb_b z4ZJa11ls$=dauUe)1g|2!RYTqO*yoYs>I}PzdcsF5diKJ=nALQK`w}xYRsT6}O?|25{{o5A(D=;msPS?r8y7zTlsb?uUi=;V)lvP`+AHF54DhA4? z4w`5CI@_#h=UI$6a!0K)^S}I&?@0I_hQOrr*jAoV&Gz58<$vc+L07s*$Lf(C8Z)B9 z2mjA0q(rtoN1_+ite+Y3W(LLjg7O0JY^re_@|mnqKjFJOc~FZF|H3gGy?ukhaFWsT z2I&Zh_wOAD{|e0WvtWisG94MsAoH0U1f+cOH78B*^4mO$gh`aHWt-pg}#3+$c7S#I*xX_?09X$i7-m50+j}{O}sBl8_d~Xmj>&i$W zQ}z}b6GP?;1+F3KskTOeO&>SPqqAlD;BnNlcnb3IF2~wwK@~rE?;;H7cDSR}MU`k5 z$E_QQ8?FwY)gxxCI~7nvJ-VB1g=84se;@gT5PMOu(7@Nq7y99&u4{F?>f*gu>s;jF zqDB8SI3D`D2jE|US%C~}xvtT5plM{o`&6PtWb}4pFhN|^@G*EL_;&4~K#X`hS!>6m z^0NI@cfE2g1+Jmj(9)(nBTK9i>z-468OQ@1=$0x3FDz!gH*zSTcKm_RH$~djNMR-t zntN_8YIIDS`D_8jo)q0btuvEZiz_|yh$qF*5+df`Ql7=0pJgzRYXP|Mpv#ST58+0a za;eIEWCvT{Ks~dnf%Y*$SQ-hg3J;Hq>7uXzW>exx0GftoIhkh^ham=k-*zVJPF0M* zET>B4sTFV$KsPJF>|0>Jjcc-~0x=Vtw*|Zt7chPK5nGE0}g6^&M?Z*4K-t1Qlgd@*iA5@-eAjC(5l`SP+ ziBxLBElZn?o$)IAEc}_xnJy@NiYD-w`~eHI61Y+1HZr)ca|WK1_&ayD!vgb+1wtc>%PzV%|o+qX#ugYSQh(v7kKdbAt zJZf@Tmv{%niwwF{F4-m~eC#oRRIY_pQT7W~l!xPRB6{*^!HI@ZGUlPG-D+m0ThuxLl6T60@( z$@gBiC2kb@C>q-HhfC{&Za& zGxeLm$5hTjry+UBc@)+c{J}j|IJ}s5@8>TvNW8VSrB|6U z?lf*zdZ9wBvmW7u=Iq0>u4nKbD$lKGp^f7<=rDZ#8*Xm%8muUDl%e^1Ot5`J2VF@F zDB;ojnA7vOSx6=muq2E&*egegPab^ga}3>_B3X1su#%hHmYMu7oDPPOw~|oP#~&5_ zwttat4of2z(l7w=Vt}sOQTIA>+Slqj?q+GqU4jx`@;JL#wfkVU`)ubVe?E_a;*rg* z)0digVu8~6auZ>NF?^N5&c{na{4j8a5X@jZ|2Ou31tz~a&%iV;$4&w|@#Xo;CF%=H zR-4S9@gY7556{}VLix0-BQZX|Scv{S9fj20>g5S}Qa_;lX%Y>wc5wfM=Fj8|IV3~at{>h3 zUW=T+&)!iO|H6NHdS^XKBUmP&C?+qFJJ2JTbX7E+F|Q++^iiYrJ`^+8C7(%LR07)?LWJ-|cUo z>R*BJeK=hl7c9;5c3Xb!#9S62qIKf;oo%Bc9XbkEVIU8G``G^q4E?JnUKV57-JPYo zPn9toGDbvOTwYryUa)P+E0HFGjbR@*8BlLCUzt1;jwSVnN3Wg8)hh8BwXGIIv(q~b zF90qv=n6i3gmSd)R=(FC7GEbS_8wamAf~8Ug_Yj=k*p|c_Y~SN8u^)ayhys*R>bM7 z@g6Svc&VAR(9k9w{a%s-O$u;HKzC>)a&>IGsayV9y8?AAY#^`1H{f?Bk2yx*E=JGX z;GByOv}1Zpe1mej$ecvt#d?S21HF8l^X_%tsxE2WpgQ3GU7P(^V02`zEA(~6=UYRI ze|($jBH+cv7tFn2lKaK=EhYm7S!m3smojRnylE3rt%3#KwHFDpeOO$5Ok1yDu-lQh zCk}AQKz9Tyjkfnq3p?Wx4;S6m{PfY?SuN>Fzn%vQwf)dL-ywYXvJY8pN2C1*Q6Pyf*LFNusx31EwNmJ=Z4h7N~>9-3#1-j#$i$V{7jZP&w{J7;YE|COo%rC}_hNsWI$jaMz0G>@7Mawv z?F*kiS$N`VKmpan)M8l`j5hrZ;8KCET{xUrNg`RkC7(Nf4rH@0;<^|s<(46@;^SBW zv~Z(^1eAFfPk$%jyGho%NMSixrB+X-57UJbR;y|EQsL*|Js8xW3ro7#$(~AlTCs-y zT6c^?u3!vHHz~J!V1%2g2=0D?rd;+m2OgI>3NrWhqg$43&&#ag8*(No`AP}w>F-{X zlR&(G*L40Bn8DNfjskBcD^5(em%atk&~=CJRV?LP`c5XK%{3EPx@@4Kn@CJQpI`Of z9lAl=M|JBdY1(^w%}c<%WxY9(W?PN|Z2Ft1ZXXCh^L%Q~Xk zN@<6u2~VBg=)REAb09H5xIldm1$STJ(w5Guhu`>hCwwIxJr`nD@xS@A|Hj>Ppxf*Y zu}#F3c_+hJ(*9a~0*7W)amB5-e5=%D%ZQP^a7d}TnTbX6Gpy5=;tXVJThwHv1w3_< z_{$fjQ^8O&u`NKn^q^ZKkt}#54C%4e0!wb^{;Ffprl3|5T_cza@z0;J$E-cENBvYx zG1Vps^-Szza4<+`Sh+d=06X(7Ag|JNd*g3T|K;ItU*cbZd7UXkLX;PQCu{vn-dt^Tx>G5==@LW?#Y5*=H z=t_Q8572`7I>gCH#VC#p)u9+Je5&piqBz%*VSg*l8_jQ!iDXSndYH9Kc?OA++AE;yL4Gr&j zC;zJpBc_7svh*0f2ztlLB};?x1u71EV-aH9tM37p2u47>%%I!3!f!4A(%w$D&!*ww z;Vj2^I*tKZhy1#~au?s2{1plmfqk-x=a*t4x7iSLd81C2ZeKL$>~*+L#j`;rKTJ&k z_wU}he+4GwaYeESQ_XW%5aC5bYp_&dnrL@LtG?TID4O2Gz_}}Oy~1@CdWggI^kJza ztH75Jw0<+>gCUjVfxBly#RA~`11sn{U}6SQQi(W-G&Lm)nZd;7p{rl&U=gsQt;n){ zLl_dT?o$>01>2<9d=)pr{aWL2fn1F{|1RQPm-t@ z0<&QHNxH*k|MihI%D0ioDbgN1YyJ7kPQ=BL&Yf&tZvF(5D*_{}*cpRDo~UV?SDL4< z$V;>VmmPGaN0B9Y499bM$L(jHi+0i`oaj9QxcI@B*a5+F%7Sk&}I$f&7{n1lcK3wWocrh`~csf>Bof{pr z+2*o2Iy#a&^P$f>!6_0??A-b*(Rf(Y&>8ig0X;O(ZId>3fcp}3**<1wLrO3e6i4dJ zAvArr`*^tQArliKG1Hct!g0E}X`@IuZ+FL)E307hecFfQgYM~$^&UQ^3IqKWbDva~ zCg5^{?iSi-q@HW~7Vm@(MRxsFMs5el!6o zERoJ>I*QSkZ$nM+{EA&q|MnUF{cc>K`!vAob{sCiy~}_g+a8a;+;tXcnovC9MKv)p z@lo+gmunW&a;t?Iz^nNkSv+gKY{jfxfQI3`=4wie? ze!%4cUF|KK2QfHRN@f~H-@b|GiDJ~>8vd%$>$+8rICq_1#GKk+Ui#tr5Q#*2eGrOc zXov`Yynf-$>GmX6B8~g15$u=ng6?1FoVcQcnHw4{QnfOmRU#uzS zaE?+VqR!LWx_7JE&z$%py0Ba!nH%C1qByW=5H^MK=Zt}PUxDtXOy(!(t(l#9ivNeI zyYPzY3;P954Bg$0NS7cXCEX$2U4nFXcXy|BOM@WY9RdQ9(k+d^{mor>-h1BfAMjbv z+H=m1XTwYsdDLS@@szv2LNQ!O?%q}zaVDIm7|+15uhs86uXai!q)gS=%Pq|#C#Or@ zxr$&HPi9s^Q-I3{bQvE#_GgbZjm6(82Yh3Z?c5>!Em=)rs7k47E0lhg}-mKY{S<{Qz`- z<9Ksd(VF~)Wr640L;AV4Ae4Cwf<98M)DlvY+&LFG!t7ChPr61FMqaKXtf=wsaF-fy z%#~~$#_YU|81#J#;0gd;1Jg9`)^B8iH(Pz{YLA@3TX7U#%ZBGsW&BquIoP@zENd%5 zs)JMMy&Cif?5#YySL#Fl)yqBH?>wCG{qd{E0514!4+$uoqPrvM{Evc7VEBc%-p7y9 zDw&GhTylq>9uKehn55s@6ERHeKjw;$JEi2slO%^G;D;wR^)T?QZ`^WX(MkdL58yKa zBp?a4V)Frg^96~CiK-_A=Fi2Ysq6mOUpGT<1BpOn&(^c}$9PotD6e_X*&N0A0qQF6@RIb|O(>JQp=s zGyN|=+|k)m8-lB~-d(n*)MI5NsOfznMGY@Qq}y8;YUhwb33HqII`?`Z$eAq)Wts$V zKLXuD)&7|~?zEJne#xl9T~$s>zeFpMLculxjIRx5fz`U^f2grE(#(6-C%LX^yfEM6 zyt?A)&&D*DtMk6m9!OmWxS~MUASxz`Bh!AZb=>?UVIzJC4n9&3-!jRffXiVxzm?dS zA!RM~oy~i63`Ax%x~t}xoiQd7R;{mbAJYf9$G_*M09-Mkd+nFu`asI`6qg%6c77YM zO6+WfNGt05G|rvEf!)bhJXL-qDQ8$V<7Yf5ETW1b-atRCJCHik=U_~t(dc^D0dU2E zuJzlEop&QNx-B9?SNDeW=o2?LC%lx#NWlxKx_d``IV?Wf29Zz`?(D)+f8J;bY~(nK z4ig#R31YBE&2E&8gXdhx^B@6q-6x1;iL7yX%fymdzMBgUu%k~D-k)zt6uEy}X$hqc zX1dEQq~AVVaPiidII#Fzt@$D(LGq3}u79cJ*W6N56u`$2^Y(MIlHcQG{Ho~QY&j2pCXA23)+wx2ED$-)gVYGv$DXSmC(%!0P-b)IOQzfR09OX++V61+eSS<` zi_Mv;t_Y9OQpyfUNBVm9O)doXoS`AW_nr`E5_Ni>l+Y*Y@alUJ(bRH1vmeZxT&zJp zK`$$Ycz`Plbp7n8-TwOq14-ggF(cf$J_OlsGOP4LbV|QzLBHEM=Zw^vhTJwNM{lV0 zgUd7(DEB*5Q!A<5$&sIPo6q&Rh>QTP9MJ7^*~eGuHpQj;eR28KnD6BN`dgFQcJIK8 z(Qd0w-Aa&*%pWMv$x@rp=ufT~9_3dx52u<4KX}SS4N`?Bx+Y5jt~}6feM@Ia1n-;H z5Hg@^pt{tqV(n_JS;}~;8-N;0Z6sd3`@H#w%DK?qWLPKY5Y=EF)2Bo;XEL0D;Glq# zG&32z21A|)1)v-Dj#7%%*nT{cj+@lgrc7ItKKkd-*DRm1Qw@|^+d1_ z9$n~5FkyW>Y~D@x>-43t%Q>`4O_rD@Z3LMVg;^UL#y0_M@+$hF0-mP1e%qUHr-Vpf zdhl0fS;`sz@BP^S`<+Z>pewlI@DhBDk!CZq{gP9rUS#m8Tp-CF37~M9AN`A zVXHZ1{;#vMe$8W&d3r49)@z%tO&%fS&pI}&ggKyI@ONB70zx5WXf?}M;yTqIo=WEk z@jOR=n2yPmhOb@@bo4yaB?+Z<@32Yydsa==d!`upHG@VJsay0&+gC)|K8N-Y6ajF- zYX>Btf8QoF>8j_nc3^%HMm%Oj{J_)pSx{Nnsx{|(^ld`RezDmskqoa>72tb|Cq#AN z`8@q$>=zE4q2z*Al+T=g7QhAfDj@+)bs89WWei?Zof$|_P~Tl~-rX#Jrx+V9+()5s zQW)^ZtBiyar@lJ0Y5SrA8zHV}P zlN#_n2A^pm0b%pRGwCv6IA34I7H>U+hI~pYNca#|Wn#yCTeX=eJ|{-^1i1}S^q$KE zQlC{TYxLE!@@rL0KJfWC+xL8`0)B52_)Gu^h*%d5&MH(yIW>9#PmX+J7ZKItyTMIB zP-F6$uxSg+az7LXQutE(nKCzB-KDD!k9ODA*n5iS-lwjAA>p|B4--2C#vLDV7HsA6!|ZGNbpwsE zoFdQX@{a*7_$&tr=%$dwICuU_@VlHlJ-4p8jHq!#s2@s!O%P^gBn%a_F`@c&&uYJ32u3 zQ_dRmn}bw|ocCu^&8i5-i=#2fJN1U5v&EK`ds?Q^Y0tV%S<#M?|0p5HSVB3 zd8u^ZpgMhBBiwuc1aQGUI!Hi*J{0iytaF&6!WgjmABD8ulbn{_xl%7`mA8`G{M!td z*b)dWVBu`U(x3Uv(ceDzBji<>14)d8uwFV8vGP3#;OYV0Hwvd7E|YP_@71V6r}hM} zeEQJzK;>jvmrsa4;FFp>M6?pg)z_^Sq5jRh;eSXM*LR8j!oCF+&|$5BS0% zXy=q49vq#Ooqfg}zXR$u0Jkq zar(v*Lqx}+DSGV`HKQi#Nx7>?kfG>`u~jGpuL-zc2lr$k0UaklMGK|xQ!y=0kOp0I zeEG(HD#H_S((V%e#WJeDYx^jyRYrJw`=5_jD|Hi>Q&f0H5ql zZ{7R6SaS4lT2_v2Ty{q42?O^%xw{wMnLknwD@SnG)HY2yl5vy#jDefG(e@*yTa1#=Ph)`Qou` zzQ0RqVZ-s=?;s^aj`lXrkAV!q_AcRbE_Np&i1RYuL~z;J=%0B#N%XxW0=S)r=`Av@7#duc*lkpZj*Fan|pqobE-(wh^yn$d5($}JHW94lap1K^xN%L8K zExD8U(`r^OuR+pvdjm9MSA4y!naM%zvgMosQ#0jk2_YRn8e@Oc3DWFP^t*<;I- zdC&Ra%hfEzt%&m~tfwwOl@w`Oh(-L|f%E6Nt09T^g8oHz%;|ZaF^G|N$TW)v=k=*o z(UmZ9Dfa@{C$I*(W&AZ|zLu=7OSmUX#K&7mKN!RKk~QM&W3{<&9C?w5KIsY~7jC=T zV`$c+@AKzt!M!k&3qTKv{AK6|{mbnFKeLd}?K9B*Fl=3KST$?zf6=eP@UT}qlNPpJ zJk-(Be{eW~{h*SR9BuMj-TEiLdE$V!L49&;=5#$XbR7e{IA6tXvFIRpuLp6#JvvB0 z<}O;>@9Z@|sbW3jYfWpPB?Fp$aMb0+|7zK$O$0BaSn1neXoTI&c%r{mfBlN2M^pc@ z5=F#_u3y~yqyMW`0Kl~cx(?830x@x9ij8Sj0#^cbr)o|H&S+x}?$!?82w1908^0(9SYc>TsTff3;5;$ZJr?~L|) zL7-;hGi}!gK~3f^^7UN{tY!D5?`i&Z+2;_M1_`P#hdBDxtc!IsrB%YgMgZRnJD?lE z6i>=wRPFjiVKYGU;#em5VUntMUi`2FxiB+YJ)^&4-;fS@SH55mE_D_w<`dVct+tA%%wWfqNtn z*AeLEsJTpZsI3HKNs-bcW{)4r^--8ejHpZh0FBp&-!7npr;O9WY8fl62(fA)H63cT zu0Fawy~8k)lq`^Yq(!~~xK2P9TH|6})Xn?n(Q)*gHJeDb)Q-36udB473a*n(_g~aB z40+vRDZGZX$I5z5gW_1N==44M^~s}(GNq@4?8(A`1+{1~w$?vCEdFEC^?109 z!2q}}KsR|k;<-zpDuxYzm;kZ!$gj7rmVfY^KD_}YLgPmKWaGWAe^zDAnaYf=A70M* zrM8s+JvG7~PMB!^@oDsiAX+P`>aE#B~+ShN^i)@)p2$4y)m{b0rig}_oW%(?&e9J)oLs%_1`np zApJ(4?MPFgHkBS3nMy5d3rjlpudCyq@ONLfIgb`IaxYdXt@;Vn!lDol zybtgKx<0S0zjE@c$dG?Flr2(Nk0=N;@V$^UUM4LTxooPItBxD{lHAG>vujf^Wgp8q zEOz(`mW-mf>G{s07^m9xWB}^*2D(M6cMa>Gd8OK2UFNKGXGl)pt1oCeuGMJO6V)R( z3-04y9VNgJG3BU`T$*znA@zrR~*h~Wwk*9 z@*Oxna`Ok)`8QCbY1$e2?i6Or*o+4>WbvXL1kUg*CWi0yn041i#5)fx9h{v?tZSmdERYH1KjbBeB($t_|W%!#a$+#n7p z$~;eRC1N#ozgh@?$8p@-3Ai5n0bT3P*YbTdnl=Qjd=&%YsDFQRkSF(=~ z;06F)t{92^4Et@DO3?{g=!3Xuoqrpo-{~|Jak+j*9q{cI*j;C?rn!@?D5geuW(2rj zFTPum>zZdtC^0T~ZDO4b}G<(nIT@{ynCwQ8k`^`g2!U z5xqC9=EMux1eAhul#Y^QB_SLwOWRBY+zObOTXUxs?9<9c9E?<8ft$ zgSU*HxM$(w1Y2=Fysxlyur(`$oR(Td1*Q*9BE*(xGVmFQx-zHFtz@|`v{TUcZ+Zc4 zFwp&w>3pa6iSLL&xZo_EY9meO^Sw0J%wSP-5=qnDfwbHarMvka!kTHh)&zH1yln8^ z+Qae{*VXywPuHoQA_lNVeAAZak(=@)fJAp>3?nfkf-KAUK$8X zwpO}^bC{;Yw5VDCIyEeAyRvv#M)c{}EW(ALi~k1rcaEVz_i)d4x06R_rl{Vv;)W$m zm5r=YRx&z3T~2W5b9u<&$(g;P>#}%bvn#tYdC);nwy5NLA-C5IA$*gFJ}Crt@Etc~ z{tW}VEz|#SMm_D@3FjU(SWeIrxF<61#GBNZ4_KXPTO4v={bTJ)sTu>xH19t9H6`ZFu92xd=ISi5-( zhu(7V@ZvM9rSla-Te9R|zhl<@chE(PNMh#BVY$ z5|OlLU4m5)r6)G57jO?{rB+A$bIuzfj~+I)A{b&ZsibxQaHE0lN+$Ch!Hrsj7L7JZ zxF}!D^BAd55P2rWh=X&(n^}A$e|QWF?eGcRG1&V%c_9_{@x;jn3Ym_doJYyz{N&B~ z05=Bc`iL;$GgB-OD?f!T$!4vgO}-8X&P{X^r`uRf!&#&1*bcz#u!K8B1hsjmIv*D% z(1tC&o$TI7J{=pQD4o|_1-P+5S0OknXYzssR^AiW_qyo1c^{%}E6%57_HtyJDY({4~v)43`V*x!i*x;5I&-J_F8bN)qGImd(; z{WLya1VKY1(eFkZ+LTUTOjFC#>xFijv#1Z{U8p5NV~DK{Y{BGE>N-KIAIMF$f%7`J z_Xi2+YPZ5XixfKLBh(d$a?W(ELy&^1s6|_EaCZbVU|%Bv=)!l{`^%{TDuvU$g1Q}~)s;xR zCJ8_3;b^TuNS4YfDi2y z0N{e}_#gqv#p6$=cAkIV;)>coLsd)(iG?ZeGGjXv(Vlf}jUdN2Mnq4k%tTijxFM;x z_g%)=l>HZAz^u}eG{-vgJ-kmB;3fmzBECEx+1w`<>L)qa2YOP3?(dfLPf0WSEC4-!zjegdEAZLaOf zxJ69aP-0}8$<2y0LE>h3O1Kq;VkAe%2-h}kPQ%sKqIivz<-o=%i?ljdLS| zlsmA`l?rqdd~8Qb*&*YBk8$(L}Fl4Y4h}~Gnk-rP%u_7(lD0) zQ&Y;m^e?llloc)R$z*kvN6G>fP%n6I2?^-@JQtrtZ`k+Y_ohx&Prp>K-2g!i0(+-9 zY+)uBVvZx(VN;dzJ)9f{(~PAH*EQ=ztedTX-q80kMRDUt<%n+|kA-*^=DRdt=u z)4SemFt(c3Qa@BP^m$4(dG@bu{aHh5dvPRU)MLYFsGW0jcU_Cbqz(r`^utK z`S#DJOf(l1`BZ4)ZB+0y*0PPXcf;EoB%V!yhDfgA09^tlUnRH?+SO}|6jl!L!2SdH&H)lohz>O! z?Vn@M{YN<#Qll^4a7m0VC}ngv`$^%Uo80OCcHQ$`xhC{wI_~Yi;vJ>@2OC_~8qv4? zrf4d?CE7R90ri5{4oE1_q1SbI9tCK3l_DzR0X|$X1PG3bg8wfs^E_W zGF_l?iU7TN5%^IffD7({LIO(h^l;Cg;*uOs$^J#VYD;kS_ra!jzO!X?EZMl#rmC6q zEBCJ`73}TX663Bs9cqceO!ch9yu`=yK}6>fP1P-c3tp2T0qrigAt^4AA+|0Dx!?7I zghOl)hEwNP353lQ%Y{gidXIDYe|zV~H|| zKo=GH^GuO0?bbUe4H{CYNghnlTItpxlMQXRk=W6~*jWg}4PdOt4dnf{U(jXyI z+uQ^mt~VvuvrUmTyg~rC5a{N63*aD;;RcD4I4IHMvwf2$AddNd0dnp-pp9DLpIK9J zNBY9N`-`GJC1ODH2RwbPKIXZ85jGD#!v5I5v7f+xY7x-=#7kUyn3mc|l|cATsfE@# zjX*_+pFldQb0Ey1hQt~6!CiQ3=E3yvaI{k3#=N5?JvFN$@>u;lKA$NkfuqM6px$Dj ztFcUb@?&;6@oL1p>1@mjU)kqH$24MlK4#dWu>g*LvM-vO_b(*aEnI#jeQF4kA^wB$Or|PTq)HH6DOl0(3*&)fCbY)s9_pC|+Yg)K8!l zi&Tal!q-_8^r^A}d#@q(;mAlT)_v^Z-0pz7dNk|wo$2`VEk=GVe5dEasAj%m{3?E7NbfwupCPk}Vp877_Hh4byJd3?5D- zR(w%y>2t@U*-dY7S>yPUub%0w1bWQ#71gX|4KiVKCT8gcn z$#_=dp ze}FV#Cg$R1MwxEZ0VJy{W_nY|x{pD^ z58k6g+&Z9ZMt(tFnn?>JP2AnatCjp3;2Uv6GNV6@x)FtP(Q=O*?(A?oj2e9#se;=?&;v)*Ba#kc^s0q9l^>)iX(D3lQM z{PX<5-BQORbVEFZRBqHzzhRxMGaaLjD^<;aB{($%ID@ z`+xmE&NraDGi!8ezN_kL{);@?wo_j0@(Q7HNjmgNwyKu}sk#AEl4p)fV0AhJDM*_p z4RrW7@-G^-V zMaR`}w2#J&w%-iT4T?(=VzW2)RfzvhVxI5bod;PjmQat%xDZxbUrbM7aZ5>m*h{9_ z?D9_sp5K~*F4~{}enFkTE+_Gp!zhO{qcs{uPIQkn$z(mb%szkAwS_(2^XI*ua*h*C z2zKLTP~TaIf}pA(H~I$}hty#|a`0ROska5_2Fi@bp^_1obRq|PHPagzld}s ze#9_sV-3$;3XVJ(>BF3Uw36>}4Z<3p`GZGbQyib8;seU?96VqL_IJQ@F(ja>fZ+wV zHU$nPckGjL?y<%7m#C2FbLxiz8YBy-Zp-zSdr-%PWlFf-yXQD5B@k)9 z%hH+oFMXKTkM%SwgN^(pH`MZtXC{&I4@LmD9q2N729bUhY|!#4R_<~pVjnCnH3&dW z5fJ@p!AahB#Ai$j|DNf*%J-P_c1r8P^<(CmOZ{pr!OwDpwULb_Y7OvO38}XO=+6H4 z(|3V4ZmQkGO@ACFx@`lF=%3hMyI3^ehU@}MsuF2c-L`*L%h8d6= ziT$G*^G z>#H>^?fA?6679|K$`W#k97Pp&-&oXo?$f3ZulV0)MBl`srD4$CW$6(NvoJv=otir~ zZ`!Ud8~Y%$OajIM{O&;ly4oR!n#gJSWpCqrlY-TIsYxxj=%R;wN5T+a%BVjIw+c62 zn9zhSa}IjRcax$dQw-@a6a0cN@q#REg-ONb3UK>?F6AF%bH>dFxkZ=}G3gIdBg#Q= zGX7!?P;{@1vA~&QdgEtl3_*bY+{f;ZxOKr_Tw#fg)0|0J6(0y{Y z?uC9U83f%*PBDi6N95fmZ7jk<~9{FCXmV&PfQ{cCdL%nO6JMR@}7*$3eOv2 z!BSaBj4$xRrESP*Bi);<+PJxT8JZT2vzy!l+(Dqrb?x-@utqr?oXXDJQH@Of_IE8^ zedzum-3pq_7e=#0Ms~@UauWaZdjA;9JTIltxt%;7MNzccaQ&8%phe9cfI9?qi`MaT zUAp`b%mTv61gtB&i9S#(Gk}Y1;bXLuL{Mj_HPG)`LeLbz=1U=1ssC$0F z$*KMFeW8bC9^eiGU1m;aK@||@wcmLByE_LitaVQP9}G~WVLI`$8r|RKth9SE(J|cyV+TAp3kKnWBRF2VN^7^VtZ{Re2<5N6)9sXIBUm|HM<0TNrWN z`FQ{PNH391bQ|7g!+eJXrgk@jPc30?r{}#N87$xTG)WArqT^cn@v84`8vu6{=x&fy z;lF&rMl9NJGr7pN-p>J12F>0axNj=s&x@`2Z(GU^>}lSYdxHu@4ZUf-H{(Jid#(!` zth=dxT_X7D1IGc}`-24JH_XdZhJgrQINA{C%cWP911D8>$i_Dj9Cu=x7F;?;9r*Kc>6tErz-{nD`hbf?&7$88Ij%~7#yo@hMvm1lr zTZW#Xx|SQ;W#agETeR)1`9IqKegO|zIYlwzm7wV@M2cz&A( zx>YsaFef~)HEuyCS@%L$blcqyeRn@deO-Ys;&~Pj zRgG$ES>fpv8}(odnSZ|_8&X_VI(pg}C`>vd&_(UnPPqBbZZnlES(GjXk1%?Q%M;+v z0bL?gq(2tw)fH282N@C2o-1N%Z#-};$rQ*rK4FqEVvZ)670|kjAx>lT#aDZA89;UE zw?fMXf7P2w*!XQkhXbAyAoYUZ5lBFXf7c$!sl$=u4>o>LKj;61MYvK;Z8k$FDrg(2 zEtlq2FB~t08Sbm(BgiYy;3SIaW!uAaIy}`ZOj|!nuMm#{xZoZrB%n0~OT-cCl7U_1 zaJZgn7kw9E`=4|7rp&RwEQHz(QmY*>)_dGXQn9&TyDz6*8u#&Dqj%)G_YZ2D2}Bk) zEWqy=q~1lK>&OYcL$l0wS=IlMSdJZg3OyTv&8VD_qRZ5$NPG)Ah=V>&^l{%@94X9p zl7QuK8#AT)YKxNevX<;^vq68f48Z*fbib%)^*s_5;4_p}M--wc>$#a`3qDA^k;)ZI z_I&VBA344nofnLC$V-Xmu>9(vM=}ss&|>yfLx+L?@9C$e2`|6}zk85?g1m*WyF4DU z1`1dWL-v=EV&!-pk7qfBYTyve*>GCXO3sk{{z`>A-^2Nv)Fh(`Ne957@uYPp>iuwV z+0rci0&tgsE|&|7&raI$VDOa|XUVsoy^t69bq>7@6O%`*LxxgrH^Z`bVTK5@I+pAt z{ocZG>e~9h30}VE89l=leQIDg2G6;W@de+#Kmz)xaqTiC_1(VKhOj1k0{y+gPTHD} zTe+dCZ@*KOqIut(T2K-yXMgmMCYE~SXlj8FVY?mc7E`>rgx)ko{5(g1`wQsWP)2pz zABIph@JnJz(+K>cyl~nO#*UoBhDsW=6+64@i1DDT*Y@yDRR&!v1R^6x{qZkcmZ-L! z(a#}?y!b8&aKUFINI>TdPN~O4Vt*G`m$xwa+*R%m4pW~37$fTvy#*?doY&7!|ILlr zxWeavR7ZPMyalQR&MWbELTZ&Q&|_LJci{o<8qgi4au_2%_?JlUUs&jE)wsMwP$XEL z7Hq5YW2s(TjOmeD;O(#XM4r4$hmTxj&?(AdqDWe6!*`rS1nnkw+El>v+d9xaot1k3 zIS-GDWhKEDxvjjqVt+liB7T4URwm*Q8NWaN74=7vgNUKC#E0^PqHmS+^cW$rtF$-v*)5Wks;pgQ29(2_TPNyy^2MsIKLf=>-7os0ZmrKTg=*vN;?6Z!4 zQfnxy*Mo?kA390@o)emJ>ffI1C6CYD^BF1nJ?}I+(tzs_c&s4-)oJ|oYj0|ajH4Oh zmiy3W4SjIX%69yr2@@mj*T>`5VW(;r4iff{Y>{NioY;@-@|s(O4m}{4k|X1y(W2Y@V{!-%wfCo4WK9=V^XUv9P-u2grI-ry`*^Wc(p<>1YrzMeY>Sw2< z$HLuuXG~4F_xPO2y7A5I3wmlJt z_#`R1f{;+k%Ao9rEh13Tap|tf&2A+pv$LjWsero-sn$6$2=gu{Ro?PQrdp~QC{lF zflp!7-nfjuEQT<_Q`#K2TK-O2-GUaB;+pzyeDZ{OUilZiE%=TQ@|=PH1^^Ne^xEy0 z*=voO!F{I?_T+NE1-OpIpbnXiw|4BdH;Er2xf6yy1hTF-K9$pn*=bI|BLDXz;V1A8 zISkq(g-;y#;57~69s*q+9Qzm1%{IypWgY`pLAO>Sw$-Tx&=t_7NCiw4)`I4KIy=-M`frIINR5OC)C=KhHcHVlT3 z)36^m#R73Dkbd(~iz_)km-xh-j63H-eHSPFOycN)hkC8&^EqHH<5z{Op$5P`2D+Y{ z?^0EhR45_`X9m3z2_0})EEgDE_g`v#e!Ak@aWsDGJNxp%_B~I>QCt9;Wv1W{W-7J< zn?*HK

-xa?oFZdjfP%7aAvVh3=`#xZ-!KE0!e;M(K+e^m?g|b?L2~3T{9D#3mio z!Ll6qCz*cMTd{&J+W#5i$;^K-b4soaRS;y}e`Yo$0rB3$`zD z99-LEq)?n4)L1)|RU_{T>VF6#KS>UV$#5yJsOFVE(C%E&!hDbAZ7Ciu5@SP4M zAPg#{H5}pfzBCw8vtxR{pdUI_PU|Vd4sIZPRZAP>5!6 zRa;YUO+-XKY0a0r;RD=rpxdlt`^K){jVA|&%Gx%-)!VCq3I%lV3&T18x5BSg+km!k z(ZvQU%fgz?$1mYd%F-Pie;e^V)KW$6%~_r8#}NQ7c&>m1)Ki@xfo%F#4za8zq=rtP zcx2*@4RKi()_Z!Xu(Pm}RK?fj5rh&>udA14@3_kL%`4%xfv-PD&DpzXkE7_Ef&HjU zpgYY_x_{hV&xEZ@`CR@pFIK-F|6Y_~RMw4o*(#Nl6oW^+ZS~Bhme^e%(XnohNQfb> zmmSGgd%I2LfFH{sT@+C770~6NU!EL{3Hcfto0!0H?`^{2B_0z{ha}}4btK#;}GEA?|`2^t zSeEXX6@1EH2eKc^#$n`pN@wjj$H=Z7trAS_T(eAGLh#N#EY<3&rOuSpameoZpNs+Z zUISfYm56w2;*6%`Vf5IBi~|IMxxUfs(P4vcmb_)V?-!?>h4x%a4P*WB^aq~w$sc0X zcKP+`;5Xq|#x@3{>u&@uw(dG-4H3<5j3>XFPVIt^<-J1!g+i+Pz20pp8@Bir8D)6a@w?v~KR~L>Z=`|! zvpb;M+V;Z3SCET34;{Kz?}ie@P}20rmBaBgB)%bP!Kf+oGY9BtjXveO`>ig)$ zrHjACio!lr`7F90tljQm0QG|Bct}7h+ZK3#tDr&Oc~j#pqiOtr>upZ493(u-6%D+O|+w)EtKqlF%j?%AV(A)=2q|`<|}^ zGZyGR0^NR1Q2X=TJ^wfo%oCQ-*XaYglyu#yIqy-2q?o!XGn$^qLbOHO(5(cD*)SxV z_>&MP8yg#c_s4%S21wB-e&BZu@;p2N-IQv)SB>MZcBOD1rJB1ZKiPvWlTcGeej1FsPf_aD$Ta6s(BAb)oV zb6A2hT+J%VjQ7pwBznnRmvnJWM?>C-``V!8r#zWm4=HZRCd0=Sor&(uxFR-P1SPx> zT;>{7fD68(h6EHglY*}2G(tTQSj;o%!G$9FbCo#19lc^cOB6Gjpp#a}Mw{N~>ltWb zmIvwEm#fY12@~$JH!OVj_X;@C1ug#-`#<%9=O9QxB^$8FF&1_RAHr$I`2T$=U!pMy zu7<*yaqUTo`Rwil;h%{F;9aA$I~a6$gT}JhM+B_Z=@=5dp0%EC1>i-SmMa#<9~iisq?xJ= zUY{e5^s4=pK9_*f!wq@+U|za&R7lcF!Ory#_JpUmQMHSMNp~xr`W#QRNhkf6qN5L^wk=S zkHP>K2I$(8{MHQh`H@Dl?GC3Pcq~Xy1UeE;;u)J~rB_#%>4^?j!<9$#njUqLyPKx| zQ|X)ibARL`jrK#kj)+C`-8gXGfd#sa&aJ(LYSn{2!Z*JNUNaSQHE$fWbv4=$ey`+l z!7Z2KTPfed_I<-8VQRQ8#6HOs|Oy$ z6Xh3<#;@cwk10_QC@t#3@_o<`x{a;m-Qbo-1=t{o!tW$;#R4X}eP^Al8(+cY14aPKf_ zG9HA{s%}yK%V;#jNXD?4cuk;sF9M2biz9l0b4vN&@Uwu$?+eN@SOLxlZRePQsC+QuL!@U$Z8y|A@M*8#PB<>o}b=ct?N;sUyCbs|CU)g$WLo}d`hHw(<0BkZzDrLgB5aIQPJ>xFr+a`IB6ZG$R4v=!;^q&AEPi8Q}@|u zLhT&^CPfxjFxQJR(|L;-pk5@PtN2TgD|ZQ2z@4~`S(|j!TBBHUl2sG)y%7?bz1H4J zB*t`LhZh_iEG18V_9{w_3UMv4qsz0Dy}ObWc9pKD&O){I1=QrsuELb+B zPs2D&?0ajJtdCm8-E8)NdQpKc3^P3KG@-+MR$*sp(8}ghu~gep&>H$gG?oEc*~GfP z9+jBTpu~Y(s$t-3Tofju_ZNzxV);9oUcH>z=KOmPfQtrn-((_jWI_?DC<$9I>ZeZI zj>^NMa7-v6vQE8W4}JX=i*h64aDBe~JFcDpWBVrPSIHofE^>CXC)q&oI!aA4CBQ`o zy2W@>2`>Ja>9>fB`?|Ptx)lW{IYCHL@G~F8iA`>u7U>uMsz&)LePZyddu^*rX3&88 z;pb}>H{qSdRU1xynu)5d9KM&v-ZLa1lFouGotcin*VZ8_e{}wWDbEp5v_6ev*%$i{5V7$n}F) zmaK)~#8@dSJHaEkX9ig>z`vaY2}n{}Nhr6mwxCA@<2Gj5a%u?!M)z^c-IzM>*Lj== zY!(|$y3&)cU!~3 zNyiVY(`xs89Wehi6ALUpgHwjza<+5c9o!p$`F3-^`EqhKD3wZ-XgXS+I zVT3F6ck6_vyzNo#I_k(e(Y_hr5(3?>J;G4># z@tr8JGcT8~G$t{6ETiMlJVXRk)i_r77K$pF=xg&4@M69LxI{p=+z;V6%1io#Sb>Lb&R&+28Zsd_&1{~r}&)U zo*85uh=DE;gjq2aeN0YbI#}kui8L4RJ|;ZeNae=Bvr~4s$@o~J{$w35H5ud z9dlkrxqP3za&XPomY^&h96Wi-k-$3@C+iH>113pMbNIzNaezw-bpM`fRP3!1@M~W0 z`f1n3t^Ta0jvyDGSRJt#GPcfgCgr}_QARFfYcd1{620FiT^>zRrt-t$d z4BYdD)Jq0*VQ|_%O3_?t+t_{)jI>fxHFBE|avgI1%{Gdesly~Y(Jjk+^G%78hTh9j zmXz4|o=t)^1jgwRTV5QUE&!bl58#pmUGDQdoT%%UH)UT@#s20tu3^N^ka&u3yuRmj zkXTQ}b)>6f=J3xxvC#66sxTQc2z)|)z1%3Z<_btym!KBqVF9=lKsT&Mm0LDJUVv6- ziOeXrXuec9S46yi7M8aBryD&=*8%61$Qdo@dpnVJk`!hc6#<*1e<}Obu7d?9m*`t7 zM&Rd033Q7(Gp)2~ikc1ye7~Ym^SySfM?>dXAeIr@w!9Bc3U(2+^LdkJw72QBpQMk4 zC4sLD%$fN`J(x=sciw+@jC3ASv% zrwAf(MeU6CFk3q4&1TlL){W7iSa6KzP&7n1DHsCsmN|%JyqS(&9G#dbvZ=>Y+6+EA z0rzJ#KsVlfkridWIJfh&PsT`cm_m>)$?<+JJR6QodzLlnB4>9#CPS%6G<(psbsm|W zy6F4Bq%flHWDZ_)`LFQ7l)(3m7U&MP^iiaX8Y`+^w1UD&3QwGTE?m!ay{c*4xF+@_ z7;eSM#xx>ah2N6y9@~Gc9m;+&r6xl$xr4=cn&K0`YX$cE-T>XRZz~ySa1WQU1r<@cz>dnT#1a$OGqSf-q-{ysX9QL`3UinMFRDInzoWnS6Sj`ko_plD! zN6`V@u4r#%LA4tz{w06rkWM-NJCV+F*m}0@6kTyOcZ4U@Tt`L)oG(zmG`a0BF^O0@ zA>9(D%{PUL`wSWq&5z%K{j;|~7w*Wqb6H|pO<3lRxXaBI^mpc~+ z!Aj@A@%hGLq~mbTA6Uf!FZ>o_{8W4y(qt}DBO$!}>A=b8LopS= zWdOP^$8HN}f8!YlaBf@5=Af z94fYkY5w&BQ%rw>FT6Q5z-0uw?873Gf5gRjV->c=E_hKZtsJEEKT2S%fnFj=UAhaD=e;I75FNh&_BEJ*E~Dibo7*GFuIBeww4aZ6 ziZy0zk1+U1x7?uvEye%-bsaKPs=)?+cTm?U(4SNFZABC!7)$Z@y)P%XW{Iww2cEN- zfvzDb;)PhDIwF2qc`)1Q>f8^0|8Os8zeX8FPqGV-(V38TrzPh^J>#9)$NzrLX`xMC z`s`KTrZp&1@Ezrshq_I`IIsZS!NdQ<-gy8-v2^)xvQ>502*eQND;o$S7UP1vW2h36LLt~xwt{-W+Z?rVB!jP!kD_3*=o`dhc# zrpL|<)Cw545S6!XOQqpOPn{L#ea4FEI%F>vT~~kJuV8v|Uf_?;MvolK#+Wsz-r!tD ztBsCk+J>99-hSMCibtn0+s9~4eX3G%b#d=HU0*KU?9i*;mWA;zRw(G(PBC4pS31*j zbZba#Y+Nqfi!^s>5)}4%e#=qU+E!lKcUqh3i$700xo+~|tm5LCr6tdA)oQZmlJThN zqL;c7*D4v+gR~Xr)h3GRKAAS4#Bbg)-Fg<;HNzS;y|6cGTc&gG$?5?ax;8Q>J#G>c_8h_fp?t)7X%e%4-H&Gdxx^|%~Ubn8qG_dOexxMs_o16 zj{Z?^){WYindbgTf5@VpGiqjMXz9%K3TR|jyR*K=1-}DtbHA?nV4i%sk@~%JMxn;O zE3VnZDWq$rnC|BtANGu?^l;n6k|vLDKfl%ItLD7z#&#q>U@Mu>1>mf;r+N;a!M=GRi zp_uNWUKMS33|Td}p3&aVcH3sH)hJAx*s(aX&QRSdt$*Hp^h`X*K5Ll6!3(D))w1Fj z4a#&rw&CQ>w4|2>z1m!B)b=Xf8J)9a_ja?2wGv%t53RF0wjd&MxbG45BAfb;Qxg=&q4tXDE}u|3 z=hWN@v(9dAwaCZ+(8`{<{SUlc_RUIram3Fj+D$JvYI8LBQb~o;6M8oLcBn{y#-28l zb3;utH0IfzwAy|Au;OzHR*LDy-MV3_Ggoi?t@z^rmKBv>++J-dHUbo zOQNQwP3f2Je74?x_Z?qu?s!)H>-?U#tF{R_U^!}OxYRwbqssIHMsFS#ZgM>BX)r%{ z=)n_1^c4G@4vOiXYL{AUR8;xL@hZLITSe4=d9RV_n=g4{4x#Mhdx9>N|x+cBQ=c>kw$6pVat?spFbd0H5P~e6)8#UjVF4CLR zHRZ+(m(c|~owG~BHqP_&*l>KQLb^7J=_Y@0d|GS}p%Z@RUi|mKIPD{*Ci=nHPKCst|Fu)5`Hj?%nHL`Y~~E>8|m!@Pna)M-5u$->b4-!61cnJ1eGZw90>H z?Rk}F9Xi`Ms8(rYRA!CSgJ;cscql;Ht-$7X=+VB*jF+5F>{|cM)gRZ#cAK`l@05wp zM)g{|;K{XZ*^9RARotJnRZKT(izN5zlQcIq+c$l@H5~_Ri3xnOGQ8=`gWCPVJNWzW z@3e2rXP*+4PF8hyESh-i*60u05`(L@?4?y4)$P`WEKPdXK-S*6D5mS`Z0FYNe2=Ym zdC#l*sAP@XrXQGmK;xeH=HoAJ0mHqXUF_33XkBr)#{RE@Cm)b@7R_p0*lAaS{knU8 zHS;uvT%4$oZdb*0)q*si6?clbweRCp$5_o#I=$AsSa9WMihl2kPZqs8CBlyLhhhowePTKUZ&2 zd#2@R7xgOHAMBHVek~~|yydvB>Vw6vSN>R?qS5d9^}6B~+U}-#kKd{KI6UJHSz5J$OKkngI|i*EzpF&_VR!20tA^u;)j1RC(f!=)HDjB*4sFt+Z9=OP z)-S3q&uu^QcDu)guTO5Z=^kBoUPZ-uHT^cW?5|Q<^t3z8b=$qXqPwwAaX$@h?b!pg zXH_!%`MF-Dj%gP?8b;mBo@Q5XZN*KZ4{0@>OZ8v-t+30@TVyh@OQ-f8+jm+k*w;=m zUGMna4=-(>S<&#w^S4`Lj#k*;r{>&Or(T)6eXn${Z`!hy3$K1WJ6Jhw=^_m~udrw2!dq;84h{0L?3-3D9GdnUrvsX;FVd%Ey z53dcL33ZVfc8I-Ti%exbRl-o3*QK6J_0 zadD1QM}>496w?*m*K-gpU90b&^F7BtYTMWOK~68HRGuOY_5bwf{%g!I&o+7$usb{9 zc4+l$L1DQWAKzt}8+5RHXP6rLNz!h}Dur|%71JH+Sh()u?0akOwhA=2-aB`X*sq}H zq_NelUhVxJbw1%o;g_1vvUM{(d!#lczF@Ri%0g>Gn}f zH_2@Ao3(w_@BQ34f8)C!hqAhlHm}xb;l{phc@HxDKdzteaCz-*vuX{hIA3`^Wmw0f zZFM`3^B8*0rSsK27n_%w=Nwi@x36NlE+e(vtihwSCifyurA}>%yDZq4Z`8ht zQ;uPU@pCe@3~KEk+4G0>kh@y@`cD{gM3VThu1%YVyY}=ca(MgbvO>E36w`HTA+4}k4PaURuQG2LaSO;d_L2T#7B(@Ywv-N%nFy#853C;w2z7Tq=KAGwiUPv_0{4VCAepSN`1qdtcsYMY)mw4A)9 zN@iuL_JOb(pSmA^e1B3Ut*3i(%wHJX?=BxUg_|!Yld;W0yV-MyHuzu0B?TiES zMK|}fS`@2)v`W+mRo6EA3=)0exv_g!{TMR9#GSTS9vE>rqi zOl`I{eaWZIxlb19-fR~0WzZ_$P1>G&nfp&GP(D zzqSp}9~3y_y+Lk5WcBOL?HUGG{W*EpQTvYrX1>|6;LO1sTX#`Vdk;_hdM#e~r$&9q zEi#{?kgl6zy0t6Do9dP{FS`3O$D`8Gp5EGXYHrz;Dl)7xWOkL0?R1>FRULS}^+;!( zN)sksmwq%)%dIkBx|C%Fz?v1)pX@o+$?uzLKHJk9_>i2?`M=mz(b8kgft4Ozn z+Zt^jkdxNSbA{_x@95l@dPS?R8SNS0KDe6R%gy|Haw$3@#dbj*8Rs-C!7B_j!{l zn>^Q+ewwBlJO0ha^=q%4EWD_Y?oh>a1McqGcKyu2eKuMi1=7Iv`M$2tCJpaa>+!u! zYsP!dnYC5pV4HgvwC-D6I{4wawY$0g;{#C@Lzj=++gp_D>-o$`aevZ7G2O%t{mn-w zWL0=7sWw2}`of4~l^xEfZ~Y;%THbtUqMofqr)uu|JCBR=_54vS#3No)%fc-wf3uLxnydlpDkKkki2)^@v9CCTf}U=^RDluC6^1X zCA#{CUJP58wNUb*q|NiicPiN~8optR+S*eZ{rh)Nyx-!bnC`?(!?XntTWrj)-|+tM z#{Kp4uk_8S@u8@_S4<1@F}F|MX*&4AUdfP=D?IzU^q${+^!jQl{k~<77vH>O?%30Q z?WJ@DeZ3XaJ>gs9shQKM-5D>>kElGlUiRE4F=sRf-s$t?U1Z0n1*cq^dh0p2{+=_^ z6tek_ zk7By1n{SV6U-4wfoX;;)i&h>S7Wp+R?33!`*ec)K{M?mbP^rgq(YZnAqCDpu_Vt_| zy5qXqcoXNOC!^nZ>pmMBKeag${f~5g71Q-N-r`G>mXjYw2CurIq5i3xgUQz5eLc^c z`DLB1GjsT2-@)3NlOil#x75CJ{Qk5zCfX^(&9*MESb8c&HMqO=bi+;x>G~6m z$(iWb?5>G5&Yz5bnY`-Eg3rZwnq5CN&pB}Xvl{`v>er`_nl!W8gu7jrHryYS)kbyj z*N5#=jv1``;aj4my;>n%f5mi1HM+k4_U5Rp9aGh-nKT_xYuC>aKWpxGI%g2QC}67U zfP!bG66<))HUl@@@%rptZH47UnS#k8!&N?Gj#?c4Ja>Y@>7mEot3}6nzMC+~ed)~oJL}~I7zKW__dMn^ zFQlIRjeQE~iWSp6w>YMCW|y~`CKiv1iuP`L8@<@1SA5qlEnj?`TX$HB*wVdt`oc;5 zFAv*uW!AVmg-eUa%~~)bJ!sH?M3YATSNGpme9k;jG2NM08_${S-Ft$M){#q1+ZOfm zdj0+E#80m4=Bkfh>)r5TY25sJ(*{?5H@z_Xp69gtgSHLW)T_a`hkK?Dw|#&5!sJfd z74!{KOn2L<+{afWK3!su#~XI5a6GmDKId&$x>!vU2m8*@S#&FA_0jhE(y_K#F~Lm- znjBM^oh!ASl_s&MXuF_tT+~p*MGEN#E2f*#x!(KI-r}?F!{*kX?lU)Sz}F*VuFTJ0 z?GdzHby&p$Ti+4-FP2zNs8@ejm5t?}=7DSyWEx7$0Gyh!mZOiHEsj`B}wfq%*Z zJJdJKFThtMTG&V=QWJ@i{%RWk z+4Yo1h}0;-H!M`7-b5s-i99L^kz~jJERBDqob-#0l>|^8O`D2D>i+^8{44cWrv0z8 zfYLYqb+%QethB)2X#r}hf$@>P(Nb~rG#h@L`8(5Bru9G90yNK%Au9#>ON=z0`)Juq zB&zXW9QP?bvD7bC5*i@t+gl{6{$ET_rl<1w?`r|xhm1nR(wP6)^Pn&;0LS+Oc|V}A z2xpN<2R<0mr7OVsRl!g5}nS=C`io-^TqeY@( z|Not9HQLrs3KofKm!l6M`-MowQkYIOMj{ebD@PiL`Y-$A^@|9%L=KU||26v&|1#y3 z2`eq2w1CnAN(=n$7Eq4+BJQg_Moi(y#3URw|INnkzrE6x2`Vk{KfnUiH${u35uqc* zqA~N7`#%2zs_cKIirm_YHmdNaxG$BtjQ81{IBF}$l)vN~WK*&5pwrJ){MgLd*>^bi z9F#2VFC|*eiz(m8XmL!8L>!&6hTmi4^pl24!cps25ca?E-Zt4FS`rrR8z>U_Ny3%S zr3wDU`ROmuWt7wP{>#xw7hnH~=x`j@ZvFS$m-;UkP?>- zWIy_uZjs0$kUy%~NTdCYCE?N--_TIw&N#5Y}G^Q$^SUoYUU`A*>qCPjDe3(Lf=LN^%mA?#@D3 zb)2^a^fyQdtATTUK)MeW!qjnY4(QKC2&;*6Jqo~|E5h(kqyZj5KT^?MxK0!2I+Pgx zh6rI~yVn!}e?x^ZZJfUm!aRg9vS$Y&%u@){L6{BBsoY*d7}>ZG(4V&uRtM+90F`l= z5T=WB7eM9l5yI-?yeFXY_zGe5aDG_OnLLPWRv(-cZ0|3GH9%N*s!;p|2w@FzeqM+x z7Qz}KtUw4OPo(r3gHoK6ErNuwCOGdagSQx@c{pR2X!bs03;kp($e}{9@Gg=61iSr(Ueo`T<6~del zM!LrcVXbja`sv{qD}=Sd`5ojz`Hv973~_!>2#XWK$R9u8obrzs!i;cEx=Vd8#bC4aVXw>egosoZ0JEC?&K1A(;{D%C5 z{G9xn>W=)D>W1oq{E+;P+V>|U_62+dpTT?Z0pvpd3dbAZ7Ptwh?Oz60!4+@~6aZ@f zJHc)c3&Maehyf!&IEV($fcgb7@CH#J7(@bB;0}g>;UEGG1%bc=1OZPF0=z&R7z8B1 z2ZRDY;12@83mEJrC;`vGEpQv$0e8VYPz3IShu|p40Y?D!Qwu>FNC%6+VvqrrfTds= zSPoWzm0%TE4c36QU>#TwHh@f!1vY|BU^CbPwt{US8*B$Vz)r9W>;`+lUa$}B2M54G za0na*yHH2l!EUe<>;XGKHrNKJZ#@l8f#YB$SOYS^a?l*K2jt70fEgga?*OPhksp)a zQkyXc27vszBOt$T2dG_H0cs=EE-iow&;yph8c;iPuU=2EePT)K`p?q)!yaVsS2k;Sm z0-wQG@EsI`A}|}oft_GI7!D?ZeAN3za0y%nS3m(M1Xsaza2}ii8^L<87o-Dk;0pqP zKkxwqz#!lR7J!8y64-%oK;s{ccRyjfEwJwqa1_wksRG_1JOc7lIGzABejW#Nz+5mH zOajSZ8K{Cdl|ft7gAqtV*cdPtYy;0BdjWbwHURVi9YH_f1p0#hpckNV*$(ssG#=CV zdj*^Umq7!BHw2HsRZswKgJN(UQ~;lG-4E~yWa0A~kO^|Yc5o2nf;=z%l9t84m{u4e>0Y@{GuOsLLY(W=b13CalK=XpDsN);p7AOQsh}#u( z2den225!UNcfeh658MP7z-AB(qChl|f*24B_8|WeAO^&OeB?C`OaWE#c|4Aza2yTZ zfD-T$yalc(ha2z!gMc@v3jBc^un-pa+_RCZHu~ z478BHCisN&m*6${2Ht{qKozoTKn>KwXHB33Hi6Ax3&;m&K`KZ@esN$t=mcy)XJ8Au zfUck$=ni@SJJ1u@gI=IFZ~(`F74ozOG%xA~x`Rrnhsxj!Z2Juqf&1Vgcmzg+cZll+ zyn!e10KwocFouqGkxm_;3mSpyKnvlu!B#LEjKQ@hz#OFa7)%4x!B6lJJOeMl6EFcJ zgJHPd2lxU%5D4ZVt&=#ug#0b|1b;e-<7%)9=!0q)KWJ`$6ja2uGzY(i@N-}o7z|uM zPv8Jba9s@2EyZ3T~r6k*}uHn;=s zfg*4p&|J|T*Y*c=EyX3BKH^9+I>txhfB0LS%pdjT9+DUVu!;?Nv~@}TQ#1Dfwp{N_MWcZyH?ngWW~7Raune5kC3 zfaW-|vQe4Z0J@galgZlQ+!&YuGhhoWK@VU7%t3cR;q3v*DE!y3t_bS}=(^6J1F!*| zKu2H=tN?{~0dx&r+f_Kyb@Z9!T><4qpD90k&=ZiZ4!|Aw0AD~p;|d0YA)r5S1_QwW z-~=d);%+$nvJ^6tD5e=KvsVef$i< zc@zi(AwU8`!Eg`(!a*dM0j7g#U@DjbV!&iD0>px7Kw&fnk-tm=5=b&v?U0;;F>U=$bwWOk=8x_&gEYY382SQ4Q0DP5{pN^^qnnXZ{AoXhgF7CzH; z$$-i-6HwVwzy)v`90Di6ac~Uefde2H><0(IQLqne1DnBmuovtByFn({1vY^VAOoxh z>%ej_7i57|U<=p@wu5D0F<1oBz|eg~BmiK~C4nt|fhB z=Sy+EMB%e6Ty~@~ZUq#Fu3ZjfGAcWjmC8tEqwp1g^dg&3nJJ9QNxH2BR3@43bfnKD zqjFIiYk(|$I<5t>>t*4J*HPLem&r&cvODQ3D;L?CY)aQ{1hQ*?JyJYbIjCMJ4wXky zXPF%+E~QIp%Iukqb6GwVu4u0v!nLyNDIcnz<$%(X)j7$jo@IK_XGJ~9Rx*7K;GBF? zmOoufZCrLe`LfJs$u5d%QaD{l{zmcWGhHJqA6m?Nvh=84Nq?#< znQpT5W#y*NGCdXTMB%b@WO~T-|5bJk*IosMpa5I~CjnhYKKiRKQ~6H`a+1l?Rc!13 zF72Gken!w$W{0!*Oy|To;dov+QeSlu$l~PVT=9B}a|MtME(5aBul7;2jjXLuynk2w zP+7_<2Zdh)v}kZA*2Z+*dMl7GkXEBFR}0GfFJqyvr>Kt)gk_#=$g z%d~E$H3_ZPstU(yI93N5KpoTsnm`L|1{B{HBmvUd17rcxgVG%iF5>(&*ba_@T_77Q z1GT{xFaRWi<-i~4fF-~U%mB3jm5Ht=8Ra`2kX;4?%3BvuzLcjKj)MTDOX>LmLqO@d zg8sk>^aFK3U(g3Q0te6=^aA#vC$IxOKzGm$bOl{NXF&N{0jjTNpcA0{O+iOs30eWl zzcuIptU+^77f>91&;Zm2^*~dg2bzF}pfP9!=o$k+>9qtc0Oe2lQ5fmbUieJ;Q{J+C z+u)qipg0ylreiytw*|()2$%>*3ZrYx0mYGBPi1Hd8_VKRIOSstNSFWaBl!mTgslFZ zaXt{J0+PwXU4(NABOfN;mHE#QoRjZRS>3@<-~~K^Hy8$dfGqA*oRbey8e{`mI^@&j zuW^9tT@2cS01yg>0|_9T1%hBeZ8ZqTFdV7vl7CQ|RG)Nx2#5k_&K&Ch8Ol@`?j?{)m;YhlV1k?wTjIO2Q7#v4~nt*JO1jshB zG${Ufe4ZeLPr`8uNCuP!)qxvu1=JR2ffPWpnP4$k1k%AgK>AXibHHqn3g&`^!f`&1 z3qTsk0Hnt|Rwmd0s6SZ;)`BH~(xH4-1FD-< zUNt;X+roK9Mu7RUJocOich+e-)sV;*G53PZUb8Zl||;y6uuKE zmT@~iX9Ci52T&~Qetf2DPXU=tsJutLVZLiuR^9QVdA{ zyTAk70OS*757Oxhj;8_XMd?r(2LQ7FWpD|Q4pdh9Bb!h>(wTI<4rI2vhI11;GAqsWheVm9tA+L%>D71(xSK&PgX93WB2DiXXa0kfpC4INN=inK54@yB}@EW`VCEz8<0&l?^@J{$l*JuDblKeCH4oI&rAPjs3-#{b?00tFM zMnKnC0X>{I0k)tcpnG49fDvc_=zbX87o)VSaHRcW%BvlY^Kfj7V*-w?acl)zg25=4 zAHsBTt_jpYB~T5hf~uf0s0b1{R<_ zp#5#y6DPZx0t%UIGd>Rl0|2F65h#|2Y&HnE0ve}>;OGY0;oKd^=}2P$@F6+y1)hNWjBC7c z?gfScice6OpYSt2mKUn3cv~BrnizL5Gk`=5lG9aMAM9f??wx9dP~RA_GzNBsq$VVX zbJu&E%)W64aV(5Yt?;iCB-J75v|+-Uu3CwUAu;PqkMSXBBRAIF%og~EpHR&Y{R`N zBr!%T%;Ka*T(pHp)?Ge2BdVAAl*I*ab8XuG{Qma+FXhQ z0Ym{2G2-wMm}hSDoHKT0K?zCPLt-ux$HP8SsmRFjc+V)~h{lkhwyllLxb-V(kosZG z@_mgej_2}0iTITpJS)lS$n;$cdK6vY;!rl+9G>EIS^M;xS>y2cTpUxXZR&Bz6V;6^ zx);5-8z{svHDSdu<9m3D6YaRSr0q4MEW{xjARp>G$p%B#KR;o&;%pq34;4ogC4~|2 zryD;ZFluhp}H z3QWV*58Ztx$*C?k&K^o$yo1w#lLQHpe(8s-9;nUOEU%wvL1Nugywb0tt_Mlrq|l9? z5untX-fZXH(!1&73M#NCCrK70u0b7y=3JaCK~m*O)3g25YHZ`;a2f3uB#TG2t^6qWV-dwc(?X@@LZXg* zCiYhix0#SWl#_6&-LP}v#ZyrYQ%dYX;4;e za~=5~ddt}GNJ%7Wb>@)g>G7?M77OhZmHG#ND@QNJt{-;4Q=U=AF9Gu_*kb)j%e;R3 zio+_Xpl{)NfA)y+yA8;xc)w=(D7*KM_Kh?0iwH=Fx9}X3w7WS*bTeW6If+h`dh%0S zZ1)8Ww$Y=W!ot`Lj!+&SW%RI?Sht|#rM~yGy8h1EU6;SEprRp!u&u+kL3y-RMVCw^ zZKpDKq4ybIel$ZhSctSCp|Qko$hvV`i)UGr4Y-MehJ06$UgvDI(BA72wrer?Hh z%ij=(%A;QA)6my5?u`~^DCm}ZqZ_ADvw6R!%k#S2s>MmT8Z|1 zFhwyJ4MQ;XSLT9H-D=rS41S2{m_*aJ3Y~B7T4NhYU zjosL!rs{82ey-9eO>UhcNVFlTyKH27%KE`(atYf8rHDgwa$}oZ@snwTWHWEnLIqR- zS!Z$j>H1M&d#ZD(aZOkQ5~}UU$eMNT*qHwi@LL#L zTh)x&XALG4uM|>4oM2yRL|iz0sLS+fQyuT+Lqany)TsgDkZuc_pA^Sx56YFtVe7U4 zE=4|?y`{?BE2j3-xj5WZh+R^)b=bCM$6?!m9fw^WwgwFEEoW;yUW>2Ok?#sSjLnL4 zr~ZJSi(L>TkFy>P3eQg0q5hx)H_|eC7jp~I9yC&FjnS2d7eDEm`iW9&&&_vYu%`9H zxM08RcIHam{g9xxIZzZ%nJ&{xH z3{oNw`M*|_=*qFbhd3lz1>Zp*@qa`V)S4gTrO!< zq|S*EQ>LT+VBXD@v>qhXS6R$yX+F*2az+IeO@vSzXoDjRloY?~R>8Hq`mh^TZP;@# z5p@js z!!N`|?DB+01o+bOFz5b=&PzHtUE$(j-^I-I_xh1(z%D79g?jR|ZyEQ`-ruB( ze1!U8+kjot-|G~*ah3Xeox-m<=XnYZsJ8uw?z!lsI}xi;Xu!q!0Z9``Hkgn2c*UWy z79_OPYmd8oQgTP^P%X~vXDy>E{-L9k$Wbk zj8=lSz_7s|{f$vl#9@>ax|LZ{l$C#Wlg0(vURni}Hhj@V(GB^Yyjq0K@n-`U?Z=E5 z%U#+`djSdcF2T~xKfh>XNpyIBLwP%bd z7Jn|J_APncoNMQ!YFALXjS$NDoggW!*&$rJb{_{W4mX-bHRiAA@+s1AU)7h%kkGUf zT91WIe}u-E4*GtJrd`~L<;oQUy{WT{+Hx?2G9eiVgv-a2f;Oq9h zH}&zf&4%@l9$y}p{Q(z3e^xpwA4{aZ@m%fh*Pmiy@TqharN-}IV4H@MY#y_2_LkfR zZMoFA`iYH^aH4ig%}E>N6B{ex_Ny9{^mWK@-WaV!*d7(dhlQGjpgh~{ z#Fod?7qsNZA1dkZ8rXK`yV#A;n(X#*oLYVt&2DI?V@&z=$M5S#ZiWII%xcQ}sAlN| zP2Cm|SUYp|1HWDj3AOr<$H zw2|1?KUk#ybkg)g`3n$-CjXGI=Pn(P5A`8~{A(5(W_a%w@_}w;&g{6FJB~Qy*VV7T zoA17>dK4FjD=A}E1K(lHVIg76cVKG=1AYvCy*oKIUGBdr{A4 z&h@?Q-m-No-qvo;8KNJrzMPi(&hNc4*Hgf+B~st$Xx{`;tvjO+U-s`nb1`8PW4ULglHjyhuyiHCUmfV<90AnN}lHz29ALD|wt$NT{Sf(!4cWwGxu#l2wpY zhvf4Y_r=L>E*~MG8^xG)vR4wn&#Po{JdAh`+2j%C8$58KY}Sd?%B&yiDE^QSTZ6wh zwJyfI*6-fU+vYHJ8tn}VzQfjF9pX^^JV;Dhnb6KH`u`=)fOdG^1aWRBXg@!;vF;@y zPC2c^wgFqhE)S!fmQ%O$s8?#+TIv1j>PKYA+9^_d3<>qum&V5Z8u^_I!0 z12)e-Izv^+=O66-|6Uugy{bIcVaH*&V8&bwt%Tk0l&9}y%$|@BV}=3=yF8ZIyQJCe z38Q^QhsGUW{;%>kwgzk;WyfLLn(bBWeAshKwnf?fD7&X%mxnE3`wn}|VwaTdJLS=U zolghsX3-jB*_aLICN>-8C+}U@7G-O|mauJ5o^jhRB2G%Xtvkjg9h}i)n;NvnE(J}A z*&47V?3!cyD0@Cyp7OA5%~-Mgv8Q0KG1%o{dkEWZe{UT2=vx?_eb-i)S);v;&?5xh4PFg?0nc>#n#|2kHPQ*c5SnDW7O$C*tY-swO-zZ z-*XuKyuyOw#pkxkcbsz|q5WR(Yv+?1SG$TmV%c7Fkr3y5ta{E=Zw;)#c!{YI2HWt+jai{xrAMFjGYqH z9J{Y#tXRrf+wAeD2<4&msj*?BTG~HM@N~MYr0nvrwPv>xw(tDCcVCqESn#Ad?qk$I zoht9KDDScS6ZcryZ9AnK|Jvv9jA=h22#R; z@(&{|JD)PvGI(a< F$7yD1+RK!3+cl+dbmoe+)@~PH?zi&2cyJPD7yBQ7Sb}NrX zTOl>t>s)j0sr}i7X&sQ7aIfv}wHw=_E_VEG_={a?+A4j*M6yxFM8qDMf)R)A#strlQ~dKsIi(N2b4bJpFq|&A(SS<#=i$Qa-bQ?;XHhd1^n(XBPgaJ+r{> zN7=pQe}A9I)~!6NHWjQ(=!t>9XKhxV{`%i})yA%E#yw=rgc$db|F{Ze?>UrbUyN}t z=Z~{AwpX!jz@7#C_peWtPY0Ta7|)cz50op{Kef}pw>2{EQKA;u_xRa&aFtI7;#>cI zd+S2>bf8%37b^)35NR58?h#kDbT{6hz?(1h8gOJUey^r>O^2cW!B_bwZmei`KTyiQ zd$K2WyzZousY@DN${HNiM1Bz{g3hv@v*?R>yd`q^TfL z)v`6*qcZQTTw(zUy}xj>>VSpzPr{DMCA|en-7jBEolmzH%O#$ISy zg5>6eKzq{#H;%|969q{d?N;V0(;uvmOVR|1U!8B&G@9sqkV`fSlDVB;7=)xON|sA< z1j)${Nliwrc9|=eT!Do40+u!~*?0M=|4_N)DI~Ou<7}I6*szh-I=SShAW_$~d%AS< zh!b*2ZAU&%mc7I)x=YIpxx`SAg!-)fSvY%dH@T#nATiS#?OL?Y*-tKUgM{{oFRsvR zl-KHMf4M|Fkbe$;@ZSAw7|+w^5D1jmoaSK`wa?3F+3l(;D{{p6}<%CBJ@umF9n>wwSDm zII?e?(!?EU3uALVoAwmP$Uju_R(vkacvGvzq;&`GYpP70`8e)QGiMmRopVes`BgVw zLa!TlLL9O|Yp>NOJ9+vvhJ<#D%8 z`+m+N&;Aox6z>{SonFEfWVaO|Ry$98PNW?`VehHIAU>ZLpZsnQtEFlIiEz&~P!b+s z6dNh3SuCkpf9F6cmm0U5cNuXgHP?Y>oP0HRoRG)4BS?CUsZF7zlQ{|JZ;(jBBr&4*UIS))m!56|iLhrEX%xeKtZJU=Livyp_bu)A2Th{j_R%6=^ zYG){aWAyhpZJ;gv-9a4k;wDQf+_TW$@ffM0uC1Wka5uhJ&U~6AwZ7XfSDxA@NT{Sv z?T)UjcRE)Gaj4Cqwkx{xt+8jWzyGh3q`jUC}h z^D~G;Ht>sT*12nS4SGk7_HIp4mLa_NUUbjQcX+o9JAh-TG2)3zaXhLGe;S$h?mAq| zjzJv6ARlGdk2I8fty=VQNj06;;uLz~Q?RJN#7Ghz7=d43s{SqH#b)Y5uounlk?3vX zg@SI+!}1opKOTrTEV=SPw-th05Q5n%nj#&I(UMYUp*S1@C}F6O!j4BPUnu*nSzO0lu06V)e= zL4rXbY17ojH**F>-IB*S0|_c4$#|gEsbf8yPRZkZg@iPC*2?+F@`Tcv@;FVsd9Cxs z@0QPbR{BjIr#B>qh|}P5nxD=3dAx3%4Wy7zYIiN)z5cA#?3FxDIwb8N*&9}B&^>Lh z9iI=`;3OopZ+UQ9dgDz9U}i^?02Ji+@BNaEr|fxe8SNDAPNQa!I9!?# z?l1DG-`^_3wfeaVD$R!Rt)tBLgYS(n5t#~CX!NkZsnPi*jlrlnlGs8*J#4T0p*`Ci z+0EBBC+Q0bwUX}rUHbP+nR|$jV~L^3Ly*+}6!vmMAN^@^Ntx}3ODzO(sE2jj`Sac# z&wDO%4Msvj?dSQEMT-Wt%k2vZ%@1I=6i8}AA__c|_cE395Mlnd5|Sp63`tyl@O|w& zo8_q;frR|cVpK}n+fmunU!$a?HQS>0K77yjI&XAj#?`Yl7sI!QkgfYeLOoJM+69d< z$$b|>LSL5##vQSme;3oTd=_@Na$VC z;sJ>j`?;iJr$i=Ux1X~8`p=$NW}D;NPiTanZ>Uscd}xnxce7SB-@%AQli0HJX^&RV zYCo1Db}M1E1xpdDEtrX_1@bn3vVNLIVPTa>=qAjf%d9!Bm9Q-ug710t7vUS)ogHnT zKXj(PS6Jzlr=&BX8?9It*WUfC{-guLks5t>h9!O}4HBB~B;ZZ;n_Ui!5hP}oBK06X zj*o+Xb^WO>H#iNr^+Gd9Xk_}VB_966;vCJmXbd*B5H%0xTYb@wbDLXPsHbrfZVt&)(PcTl%o#}O6qiE%5BaDe;mz>O#Zew+;?-cT0JA5e<}_)clif&`*+TV8#Vu; z4a(&^H0%6}`TSFUP#zonCvn)THuegvJnNrme9=)1z8-v5Z7<)w`xY5cKgxegR2lm_ z!px%GPhT| z)L0YHI)k*vOckps_A0c@-k)1%9Ybm~gFHL@Zq|YJ^J#BgnD-7H!OsR~uE=)zKKcU9 zl?91kw68yXxp0oowJQ5^A2)%7Ms#TK1#xJ$=Hm0>=a*F94_q8>z7rM4&-pr+=4?By zY00lOxq0GlNT?Pnxtk3>S0$5nkI4@(@2wWkr`D!j?{A+xbZE{etYykm3z3p&`ppT^ z*plZ7HjZ^@RZX)RtWViH3+$TvwWr{8EBBrQdxwd=?@~E|U(2XBKWt-nv7J~b4_5E& zwM-qvp|#Ac487amk}OjZ2jA3Bwr0$p@GbuAJ1N(Z8m&u~Z29zVq0h}6E;Vj8_y`j6 zs{CTFtWQzGBdv?9ee*-j(e!OT zn7z^3Y$PP)Z&NDIX?3&H&6`|moWGU1yU)$}CL)d|;v_YiTesJ%&a?_8-H=+@-#19f zvF{=<)^kX$Y!9K^NoCH`FlJea*mesY$=BRswYa^f0*_sQ*1~Ek2rCIpuSJ1wDJAuX zIQ4{tcEe%OL8ExNfC7Kd-l^iZ}^Z3tu3idq$^HM%C1K_;o;=8+uYzT|sMWeD7psSwEX1MyU{n>a#hqu+_tbNKjX0(g_zFkxMN5Tt^Jt$B z*6BWpw_Bl;{krF~iYy=z+FTz2ksSv0L<5PQvxD^zF1p!^H`gq=$bLINQ*EBK3T*TU@XNUw?`_17R~iuIm35?@@9k zMLuQ5;qtjOnIDGOgJ? z1$!mI_BXcH?4FO^e%N=u8M6&|)!$p5f9n1=d(2|j_BYfvwS~ETtTty`cdifL5n4&L zX?&kp^X;-39!5cQ-%R+nKlXhvFLKtwY6g8(S#xDhgEHsj zcyy7qb4n7%Tp6`cW z`9L?x48A9ipB{6t){8GGNKNQ1*|p88A2Si77lW_x9xMCvMn%6}u1dpT*BZj%as!KdkSZ zGo03VLeX6G_xAoxQ~7*8tvUEH(af$t@)6bxZ0ocU;_R6{>TdVtakQEedKY$`vPVGn zT>0D(zWx0Eq>?!_px(bu-x{xW92$u`-!iS)PtUOTci7_sd(G4p8fZd;S_S&XtG6wt zd*Z^r$$=jW7RohP05QMa(`p{DEmn=cAI1O%Ix_yelj&8bc9&c%5=?z22Wx?l84Yw ziJJVOw%K-LKWoVD56;6nwEwm~qHzDkD0Mxu4&Q=rLqaY1O2NvFGdi{E!s|xk>92j2 zj!GN&C;c6n$InMsg^Y+z_~FrrOO5-HkPDE|>}kSQ@r5=W`qL9P)Kg$r>N+I!RLi#< ztIub|0|NvJ`VjUv^0RHtUOWF&VSMr#wBm7`kc04?MQ)-+O-lHSU=o-Ue}kWPxTGe*m0&`7BG?_EgGdI+P;-#f2j?=-H526Vr~t}r+K;o-VlpaH!>fj)#?3%|ZaB5w=q zyJOps5A`m=mc!PzYx@$vG=X_MH}fuY1y=6+X8Vy3jm{hUzH}d`duE?pH}=!%?6%FW zQ+7|zUKOzCZ^xl^Eoj|;)5W-#SL<0qYg)0GqVI)-W+dI zu!(4*d;37GlXtx7J0$t{nSP!3%Etxv2*~a&**y}wckz$l$Dxr2JJ>c`b8a=tgWp%9 z`6zqDVCxnj4#3Z3;g^UCBA<4z`S=Y|6WS?zlsT5pd%WkP@6Quw)VL$JDBI)D3vrrH zI5+Lb3bGxm-&4k#a412!A_Qb0w56vlZ7Z+ZfymwP3*?^x@ zuzO2(owDcG>?h6Hwau;twyoLilx>58MSPpf$eupKv_@quybCAn5|sID1h-}qM@Q2y zb;aL)TAX~#cs~COAT8cS0~YhYQqpPK%3EelKhrw`bZ-iN9R>-_(h_er(LC#KMEAGp z{%LzWyPv`LRUg6^jMq_HOHabkZXQx&TZi2i*zJ@(KX?KSNH_guZ@)x$v^_vt^Y1Tw zhJ?oY$qu%CR=rF94hiir;WZbw6+3#}W?b`1AlJ{LP?GD(>laCKUygwvo* z2^XhKtdWyO%C+QF_aV{U#dw zT~hYA{fGX|nY9;P9=kwYLx5)1z4@X6rV~`B~b^=ZS_fo~3lRg2q$aj!iJNrY%*G-CLEnj}FDqlRdN# z3khBTj+H=Cx8+3NE6dFWKtlJ#QJylN_vdCckC6|JbKgT%wnpmP-9Q|gbwUI7o6x0* zLu1w!NmjwxT8HW&4%rPFR9M0HVh?W*JRy16$eWXJ`7rjCp%<){M4|xqqM} zR7@{d9Pmr6^v&z0Y+hx-c!G$#S6#-lIKobS8BY)i9?yP)==W#L%#7oyfFj?J5!~^8 z|9LgL-g2b-&bY@zzdnk6f6hnERh##7=<6{J5_)C{jO8B<`6K`aM@MY`Rvr zXGom`$+Ry=!q9^d^?EzR+ zETvyfm5Soq+-oo@Cy!Q?WCPCDzrA%XJY_&@z+c}8LH~-WJbrPJTOh_w3@kS8A4}^6 z!FS4dVoi8QIv~P7CR!3Mp61fp-Y7q}1aSnvuCtc+>%1W*AJt~;nk8rr>y%NaG)IEg zO%R9Hb9E{YY2?&s0`BR-*DyoCw-gH!y%COEqjemqr!c{f+M;bUyccoIg*YzPiww1! z^hxC6z&;`o<7ozH&>3-PrMGnQ{vPYU*IbJ@G$)6I{p3||#G!s?aeA{u{pPNFhd6k* zg5nGoB;lVseCikC+yD|Fw(Z*+30>xg_{Y1&wxDM-vhx0jy25F7*vl@xYc3P~-b_IPu` zY`6PSv^!}Ie}k>dcve^NkZp)V{abduCVh_7GN>x#16!Yjq#h(i$v0!RIh|I8glvF( ziXdqKNoS|XghrE&+CakHwK5TXgM{8HlOAx%+FU_{dJ5{vky`zAyriRPpTYIV-KFPH zXe>c$){rzu9M}52FE)9zo$dz<5_WmS_?3EE>FphyG;)jP`cIlFBi8deFz8(Z%I=uWa7cB$JbHZ_%^&cQ$76 zejV1jv0vsqzy9?7qnz19fxgn1DD-_J?mJ#KaZ2$*YFI~*tt~e4esHcvzbiv`zG}@s z$AzI$Gy@Xy_$gIeFYUJ~dmR_Y)VKros~{myoE|W5+kqRW?ou4iIHH!D_>s0#liuEm za~%6{aky3zEsm8+0uw|U&3X>*GQTZ7nLsrM>#%!eM*YA#j2;%{VfV1?K9Sw?mFWju zFV=iBZ=E~4vNZgi*DObQ=owo04tqx?3UTOeUshB1m0u?PY$n(MT9@%mu+Zk%_gKpK zJ#nfAXi)Y&SL)z={Uf4jpu7HYR=~dH52?*rqlbkC4qN%2JZ)aryeoU$>p}t#rCyBD zvXIZOscEXrKq~c7!q{ku5q+IbOZBAVo`%yVBQ;@fkkRuYHLY#@e%1Bl7C&q~lB*$( zFb4lVqH}EyTL1n_ypVA7oi*A2r@6Bax-_{8`UH!LMhaogA7T=n#Dpp#&wS79OxRt< zSh#LcB?e0VkTRS5@!a>l^WLwy&%N`$FT_P-j6oCxqArL)8KI0|VkJ~1Bp`t$8Y=`V zL8<~-C@jP;BB&56Ruui6)1S{*_j4!w;mq#b?tc25KK*t2^y$;5O?^f`{qhez`Rtc` zS}9;UT)6N~zR3D>^7z}{djIF2|5sX5tGWK zsLvaI)O>l*zy9X$eC^SXyoxWvmH9F)%MI69F8t!3_~@(N{AWLPmoN02@Pm86h)(r` zU-%#2@#K4^MtZs?l4@nK-Tr&U+g|?MBf|Nlb*N9j?DCg^`695L{n)1;{ruNF^}|XV z52UAPYnStkEvLUe{Sr!t_ZzGII>`Htm1;UQbGmMK_MDD`9v*Vz!iC>?udyxqj!!=I zf_H!H4Sz#kO1`Dz;0xYo?*6a-*l+*jFMQ&acfyxf^W~Ma=sRBa%OC!QcYhUkQXEWs zm2q>Sp90}X<8xN&PrTuqU$*+c|5io_Lx)c7xCtN|=r*qcp{OQNm;RW_88wo}{iY%RT!4uT? z{TL@al-#eC>gVLka(h(5nSSSo9{JwS`_7rzZHTNaGi$z8@wWPz4}J0@fA_cKW{_P1 zzy1AYW?lWtfBdR9{}S{MOUk)bv9iF!g!o^B?%s%d1yQee8e{T2B;5RXPMY^5+;#a==FBG?b9qpv$Nd4{B-v<5lIfT17 z?PPy1bnIBH4~Xyj39FTQ@Z-h&N7;s1Q&NA~~xS3ad**n_3G zWxpKX-`0oJ)bY`SR&?39GE4sSPnucs?ce?6@&91$5E?E7zi>|MXBV^j@zem`QJ$P|}f@LhlZ^&c6@?og$G zYZpEVjP3!?o_WbDf8bZYS3QE66M;^TM)Ilq`HE)0H#m~R=<1Y?hKEszk%$N6l`R+|$@UDyOF&xYv{3)}}efg8$_O_RP^)0gl z6HV;({&M=;?QerkTJ(QD^_qY3+K+z4-wNeC%NL>G84b1oa z)?az<`?eq87Rl@BHTlzDPXCvF3jP!2Nb9`gJ&(Nar~b_=W`Tl!Y=8YZ({u0mi?92Z zFaGr(zCQ%!g+F8Z=bJut^wGCI@*DU$mG%4v(#B_BV!pinBhQ|H|GkgT83(96#Jgmh zf@e5GKrMB9`%<&V_yg<5{^fsp-S=?A?+7OJ8+{x#()N_&QPE96fRI>aB~_{2R;l?9~@aX?nK5C6?~|L^Di{Of*bOk76xukGG6|Dx$rpIi`^zWl+HZ}`^PlYi=^ zJN3uG{oT*L_y@Od`#Ucl&sUcv?GOFR+aLSQm)(E=uHvE)zAOLI&tLqjUpxEi&-_=a zE4#`!-@f^?FF5w!aOjp3A$K>zwz*;}GcCsJ-QC^! zrmQZ@^M{Mkay%W`gzoJp8$=bK$&)`ZLEr`Rm^ zM{|p`_FbEBt8{4kT_L8Lo?J%5%_(zHPO4^7RW0w@O5Ce1L*6b_z*P>lMalK(-F&^O zY}%!Jl@M(xJ>Km$)oU&TeT2q!dwyxPS>CHIt+z*)L#E3`6e&38VGpu9`o`&qR+Mep zZe5J`vst;-$`@l~(%E{u)bE@1;v9wUB2iE)FXgq;3K30EDpPI>m9)MRAUJ#knq^-L ztX68eUY2K*awDyBT8;}UUXGSkF@V;t>=f0NtPOQNLvV{8*VZwO1roR+KT-8|Z-A257IBs|llLzg=*Ybaq~hrqk_sJv}eX0G3miO<5f8mfGWmo>}9^ zw%`KvygDwY)o51Q(V{x(LS@>?nsLe~m+?=|(c*Ztnl1nyt&U2ITA2WSHQLQ5BfgxA zYfThfE4`z|7-S4XZdLA}Y_}Fv>)B+zKAD$A1*aPA_S;fRE2fYslXJa=1(=eV9Gi7D zmm!g8$9X*0*Fmm~X2cpe{J5oum^AGsyvsVw7rm z)^0r)JE{3>UTzb87H0!h9EB3;_H_FRW7y-{3s@Ia8gg*%em`(7sBpQ-J1sZT!SWO8 zny6pI83Bu8TD#m7^;}nA9S2s(+(3la?Y8T4B~?Q}<)`5capD*l{Z(d`FK4_z(o*=P zMQ))KPWRYS`if=OkCa5~aX+?{-26blC;-#A%iTzbkstD-K+5C~w4g-IGAUgUI9c@5aN~1421$F!FY;x+B#NVl<|xNYk{3(gaxMu@a$&PKX|l=C##x5YhOg5FLu|jA9+g6w?B6S#9Q1GZYE(f{ zvj~zDeQxHiYmEVxdV(}xo>kU}21hLpifj0V_O+CU6Y!9;I4_RN#im3RQ6b^OB-uG^ z*Cg!DNwH*%cI#y|+ply>T%c;u2GSjack}I+DMTKXD5fb z$^@_p=%SEg2DM56T8nF`ISf&SP0_CUgJ?cls{kZF+9NpzAbqIqOj$k*q!sO^j8%BA z>52Yf+UQAeHByxJ+#m(U3#~p3WS))1WKwt>4RuM37c((iLl#s$Uj|{N4vi6S8m5sr z2v=Yoo+#lX3Y!ygYD`3UA1GRBiYYf*7zb9n0E!UNnW1wZ4bkan%N>mQMsyHpwGyrF z^MK5Uq2ms7Gres2Vu18v7QFo8vn+SV>#3|jHbi;(1$#6R#gstV5Kz|$0KxgOQQIt#73ai!0s4B67xK2f{ z-msWiOwehHTomEar_5Me?p7={$MbD@87=B58mN;p$~9Rp7Ki{Sk)lL~4Qm2=*whH^ z%uj6tuy$c?+O(<=8Z1;x<^& z5h>>#pphR)5YsS-UXG4%qovy;_(SMiJ&-LhkKUJeAMgsmOw?=p%bNVOzBu@_z5ksW zD``lG>A$86Wmo60J*hiQa$;rufEidlV$Dt6=mZcb zJ=9^34Y7~v^tI$G;soV!woqxaP1aM}T%!k?)u}ZT!ko(qN7(OX#qHt&hqfFFr8FjP z35kymp{6UDks!x%l(Rc6%iU@;No}fq9fhmDHv)rAOYAj0cmw>yqp%HZ6;9JkgRDI$)(GJ#@YdZ zrmWmJb6_3>(tbH&<6@#SEAW>Uc1x#e#Ch*iRN5GJzyS!r)AFzB{Nl zwSdODta?rbDK9Rt2wp?68v?(r|fQov$pb@u1P8!-~G8k;6V3Z6vHyNEt zokwR>Ru%`w6;pkuJt}YeCd!r1z{*ddo@g_JGL}yuOf;U9hJhm8wqJ5aREe=-xx!+B zO-|ty8YBu2M9CwkzUr$pPDl{g$_urvH0sS?-C>O$jOL5ccwuytwm^9|2wfUmcF~vY z)}}K#SV$Dy$Vst{6dhSP{S2~y!FvUI4r7BNx*LI;RuRI4+5{m{E{KR?+S|E(m#;an zKCGl`b8)=uJE>TdtE1gDhV>%|B8iIV$3Y`1h_ z8`M8tdJ81_oCVr$kfsdWu{=yO4D$j z?-^JhrQE!J$(%s)FFb*zE+?W!3lXJ7F~IYMxmz-Tx*$YaL$E|8?=c3&f&(}g6ms(i zxbw0sAH8`)8pedQFE~0#Pa)mQ$wF!Z1}Fn#WMp7W0GZgrBqi6kYZh(u3(1|AULd%AQpj0WshFBTRhD>*`8zQBpQutH*Xc=?fMi$ zs*W#gmz^YoS!5X^TXl0YA1#FKKHxBFs}6pAhUI*UsTR6e%)VG0IfUVrJyU|%FGimi z>!YLYFWYjm-&Uy2;OIN6Ky%bY2(a(i79%GA&X>-76EKc~XY(T_qB_hArR(O&XI9Nf zxtea)h=ifOnjCP~8cC|q{4V>uyA{4vkI&gC)C=~>G@%U}J4Bfzg*9t)Us#xqO=H@u z^OXw9w8925F%md;S)*2PNAYIG?a1J2*zsvwV&vJ8PQ!*eMP*@1GdXejbzf^M?8ZXv zTUQ;)xLc!uz$S3Fak))(<@t8$OIvxUD=G+}By zV86#br}$W_NBf0nzv$CW@Oq9G0@v0ko6LYvw4nuS$+gxuqiQ$1UCVMh!k%Gk^$7>a z-(p~vDzC#cRBRV8mE{sVyn{Xgm$o(3>Ml(=rpr1SueUpyl$ai&5l;hvb>wO!G^uM4G0>e)N^`Qx+#Vs6&al0)5xlq3Q@+{|THVo@Y;?O;G-MY&yd46Ih6jrh1cE7ZU+t#qmp6T`Sd;xsL4 zC`VR;%~I`fSt(c4#Y3^^xR>T}ViUMpY3(M6w>Ej*nQMd&D2nSy)dF=!J5a5y+=DSj zy+b=RK)5yvDF?MS^Cn1G;Ht&BN-#h$unedzse-PZco5Eruc|N!#%5Az(gAH30Xr^R zN9j_-$QXb%h?^h_kwBU2?07^N`E&sGHV~z>6GLeOa7OK*!h5|DH0dCGvgred{D@{h zFHG7oW9E8W2A6hhir;qksdNqH0)0$bXzk(G2iCi`fzu|ar37ql3~0R?`=9|m)&=HT zse$dF7E-+R_02Z4!`}H(%gE_d#;Ps=j|s@p#&E&B)&Ii19Zk(`*+Er~2EUU`n@NMJ z=Xx{(mQ4$rQoEys+TL$k!cf_+y|V{(JMkc#UVQ*6A0CFmDOa?MBGpISLycssS<22^ zhq}I@Gis^9z)_9B{*;IpP|u zD(Y3YS{yiZx;28(-2LIu-i}6Qht`r}(iuL7;*9#VUV=&c9FRsT+dAgx!=dQA)@93B z`aanrj9l%8N>^r#N4+|?>$?p9WCr%Z$?r(dye1x$!(D|`)jEkou7jIPkGu24d>o8} zp6MbZE~c{qSDb&b^*3tPj`lwxr)^f9@M(Q9ztM1B6pn(mz0uiR$&bT zu}W5`Q)c3ErC?)eRrGldSao~qEPbH1QHzWeb`DXLgp`y&QFDy7p_&6-BV`!2s2Oc5 z2gX`Y=x+OJzuBO(t+-({w*BFUr&f8fMw3?-r`SQPPg5=JhEf~KHB}9j3@M&G93&aI zA=Rc8)EzYlRUJZb*A!!>gSzKSJR5vtnaNhfEMm7lDOdjVY|uPxfIvedB`$kV<%}B< zTOS{>Xa=>udL!xBa#@bq(@ys5??pkd?w&$KxfvaN4r-8&nh0D^3G(Upq7LTrL)U=f z%fSVB-!MY8kHk$OhI!6`PNG> ztLi4@vKRh*^q@2W>K<4J-UVTO!|n^_d>XT@eSxpl**;B(0at6DDV3^bsqS2BVWFA|MDv;IU2jZ&L+Y@Hn;h}KZmi4%7{6Ji_z5`b+ zeju*e=m1wOeju)R_<<^p9*8L#+#rf#2jX$P&*TJ86gv=)83{Tm!4t(E3Xe{Bk4Iw< zg-2(e$D^?W@wky=+9!$~h{uf@gC~kTBp&KQy&N852jU658#EzuAeOKwfF(o@#9~H^ ztRudwByu2@u#ABvL=MDan!@yw4v4i;vyDJ06Y8g`QX4RVsGe>8s?rtc}VAUd^LKil8Rh z(t1`~9i_~?e5xXvCX1SPzLZTeHNzU-JT!Fijc>fj(=427Ye=u5UeQzP6_>Te=T!`Y z1;&}Up|d|#Uh^A6)6j7gG2f~goP0VtORfz>F8rLp>p<7q7Xt|Ww|D3?$n?mi3*IP>&J z#Q`0~hSgrj&rVLla7S;{D!wKun(=jnS&0FYkZzB1TlYJXzz;6+7NUWm90(rV|~t6E_DL zt3gA*M_|c}aoQxE7J6r+ix`vXz#15!xGZT7ld8q#&SPsW4j$X~!g#HJhr88$h3y@) zODZ~EpQT7_<^`jLk`>_5ylH4O7=Seln_KH#M=+LE(Ra(pQ{=RBd!)wJqL9Z3vR57S z!7U*5SDA$BAy^*ftUl)p)ca0@h+%Tlokl$)IO_?8Jm8d@fpYx_M-k$W&IJc?nTTFM zqZrUuVXEYdoh|bzbz0+LoZYmz0cRRews=E&TLU*C^Mqgwwn0bmH5B+Fv`iT5uzRG1IxqyHAbWyt1Z*QPB3~3@PJXnKYr+Lu480=U4Y1lTa6NU*&>1n&CLs0z zofKdbfs{Tes*;f=H;+4WS^seii(SDDaV|M^XH&1@GH`{H>9t`9$Yebpae&d2i|gwT zyx3=Y$L9ycsWtkNGEy4Y;%+dajvfke^*VY%?J$;PmYtLDEB9KP` z%sf%1h07@%wC9NC>SHMqlzYIjH@oZ&0H?j zmOw_qy{byjI+m_qxpL*|t=qRJqtzT6A9dSWim$A#blV!AoGkwH{Zbk2mM`3jMAi~t zCGJ|5!8;u%2JeCCK2= zNsk?pT2-VJMLrKme0b3I&643|H4yl4RFS*)wsU;Yo|cP+e3vy*m%u2Q>YK$9Fx|5Tqbw%3FNrZm7>^l z9oRTgJDXl{t*#BD_hI9LWh0slG-3X{ml~zjIfn+owptP!x zAc~~OG6IQFtQh4^6%Ri1*-m1knIE91>4$>6E@V=q6-rbQ&)S|EN8&+h0xJF0-p!5^ zTTd&80_#N5sOUsHiF+(wI|t5&#CZ}PigM-Z>wQ4!FKofOLrav@De!{93%{)8d2_h7 zAPtIkqRIuiI@Hz+X#&%OcAVs(nM>@8rMGNISAMs35VZp;&ipxtYy)qCM1LV$8X>K+ zaMMCx)N@s>U(JUX2T*ITf19r72C=|X}2G)8w09%7o8|E&h|MJYkb#7d;E5htt<~;TubrL zDy@}6Ixz2`wR37D)FK___Nq`GzPc$zPK$z6=Fg!KVr^5a%=qKOSJSG+MB&vcA^P?? zRKj8jinqRULJmrEs+~h8zEK&*+Q3$E?JQC|%UB<-Jge?PMcGqY$RK4QT0PWyVN|89 zB1|%fACiAH)wHbwni;gHoPW5^X&n^TtwO>l&^!6-(C$EC-a<^;FpR3wuPNoh)lQ5~ z1`_1e&S!+C_`GHqis%px1W^oaLtfZJ=_}8?$cS{o0>_bgeL?MH!?l1r={VqIMNUi{ zxP+rhYqY|@J&qrehe-8|V5=t#qo#fQ0;2ddCZa6Sqh=&T=VY#O@!45~3MMo;v4=Zm zOB1Ce%CYD7QDzE?Vk#!*_(zn#16s`vM4`ufOLiZn2ZX7YeY&zsJ9c0hB*=#oRIr4I zYAbY8E2>JJjfuAMdBEYr6rUgEV^LGc3W349*leOOUsUu)lC zI|P*cC_v6!U^B-`%jktnNcmHan>Nq+A9V)PiAtFrEwtIK?Qm1dnyn`YbSl&mhs^_w zxf(YABCcsqp06itY}oMcQ%hi!3o@dZve4X+D9?ByPwUSwQk~@)EUAV8i6Cl1*zQ)O zZbql8)n-{>sB4Do9-dklzF(0OyIy_0x>%K`+*nFc-jN$hI#LJmqT`ql@YLcu-E(ab zR|^DjR0m*Ah)~Jnq}00@a^HlYPV^jH)Up&C#dH^{!@j}_jqSA<%gQT9i4on%DGgeO z4UOqOs2XDcO3fD`H1^T{YLT)Jr^(fW3)6;65B75ca`D zG49~0BOKK258bc7xHO)u%ga$a_zdCBPAw@IwWbV;`amGK?^9qep@>9FgiPQJ5g5K` zO41T&jf9{iX%ZK{=rqagp=(%8w=^-DRFzY5H0lLWbA8YSwS{sF{BE;Dp&{yC+9z*x z;A>*&6>VoTNtwFc+2HBVXmeBkwvTrq8~Ea=vXb1isSjgQsc2jmci2Mb+NL%yKv9cp z>nasUFk9vamqJqG&tKoMh|FD{z)cbE3JsvNv$+D^faud}fg-S&xU{wOPSwLxGuV_nMb|5}2Y~AcM&45v0+G>qd?lBF)-jiq6JEi?dIqLPZ(-KioNG6@9|YA z>Qa5<^9V66{~$JDsIUVbXd8-}!Zw=Tz{|I-W&_W`8)<9i3#fLgv-%A?ahq}p%;LXL zb8S`|xrw=6AiySQYvpyN#PD=lUMy%30iH&RF67GA#QxQOOps=C1VlmxM`t=a*>%XR zEz5LtU#T|1Ud5-E7q<^k6P_>GWuKV)$BE)HkdTqdp-XFS?Fp2EFhmqKs|v2w8Tfi5 z$Sx=^-Fkj56;de$-abfnG=ooX9|If3q8Xv7)AdAeOlpK%Oegk5v-LKb#kMguPt0g! zPn_tb&isy?CZ33}1j;s1(lq{dUSTO^bw&AO#_xVMLqVGH5E6Mq1z{AUv+Y|b398zS z=W1DN(P33oWUgBr9v6>LnDuFZVyd1IjP-;;{8fo!r^_j`Bgw>S1eF`rx$ETS<}$0j z+&-Pmx0Ah9rm4xIthb06m0`)cuXmO}DfZWu9b)S65j}|xGn1)C4467nwQ+JwU{p)r zI9lh37|kP(<8E-LBPJm@sEnTrT_vb(ZQ7*7VG1&xVU2GfGW=`e_j% z64bRbyK)Su7=X&EKu6TGx{EiBRCgs@Ujdg3RQR*jz%zUlI8xlR87R@D4M=87i9!-6pL^t zs)K3yaw zr;!4dd;^(kD({wdilgEkDsR;gw+jg3C_~on^>9UaZ*DZ5FyHqtP^=Hb(DH?zU%f5b zN%>K|ZBB=y>U>>JfeH~_SaY1X?nDy!dIDW*_Z`Yfau4p5`d$V98i6&}H}}xjQKbA7 z?9r{`^TZI5S!g{MxS+C!oM7V(2c?0W(V-q4=Gv!WBM5vWMQ2}b4(q)YV9os%5qGlX zI!B;Vq6@2pqmcB5tZusRX!!8PWLifkE!dGkn;K`%*Pag!{1HvMBIuzbXA-!#ImcuF4G#$?(_U(3#xZ z$z9px%avHn2*uDwx}VoN#W|z8ld)JI%u(*)Ck88l>h6AZG};KK+E2uLsJ!UHsjVP%p4dqsaW^O^$oVfBJ z$nJQ(N5Dj+#;I#!%nyR6YX;@h*^(QrjfQB{hoTxO!;sn*3(X!kVHjH4qOcvS@_oJR zw2YHjWu4+K_KC-pbtl@aV>E;k;xPiBlG7IkaspeUJ>r1@);Ma8QNRObSP- z_>~mN4mVVZt_Ya~mxo};ACoL@_Nm%Ox5dS?X$dv-ZCVEiu>TdKTKIq-HI`XmcB+Ho|xp z#SQf{c5+)0%@?&YaK4wQa5JmL4vu(1%5&%rhB0NUrsrV-rAf z&NH)8+&xO=OaOZtd(t#Qg|-rpqKQ8Bd8Bb%{!na}RG^OKuZTpe06F9yF(&=_wj13O zCd-MN5WxzDq(TMd%=O zoaJVdFG)ddsZ=CyeH875fQ@1*pX6i&T3$eav?0p07luh-!zEu6!zN@hC@h4s@Eqp} z`#E4kM0iM+`-EK~upt8M9V4^AE#wAjv8cGbGc81AqFjmzHaXFov1+cx`Aj)a9aA;4 zQd~1h*=%kHPHEOgY&B>;24bag#F9%XSJ>QO3UvoIL{tFCb&78-Kz&#tbMlPnoQ+=w z8-msW&d9ACpuC5bFX#diqt2@dAkv2+GS7KAau$uOuq+o;c9UyTEVRYHHDETRot|S- z?Z+~PJj@Y2WkGpSOlNeCQumq#pe_gp%I|o}3VBfrN%dP}CCT8ek@%EN@7q<(M7yrmeY+F--DS zKcAJ^pe;lq!VbgdRx+a-ZL(+Ug4xg!?V3zLZo^oj061n{m=LG`9nh=f3n@cR9 z^o-4O-#y1QUQf@BrIVFqm}F`htgn02G4cXN_@pMriSEsT(x#TsHd-NAv?zhzs$5Mi zMyxAva9||3eED&1ij1Rl;x(SWp^!WpDIak8k-dXh>jdJsYMEMQEZvd|6!JsK*NuKR zU-EtF$omxn_8n1q4x~2plTsa`6b-l2T3fn=1Z#|!rzXk_;wYw@j)4O-8gn3tVnUpZ z^w6}NIjt~3$rENXg3pE!1z?c_8*IFn9Weh21=77xATeDj6NURc7FzQwjvE8pLf9w3 n%0 Promise.resolve()), - removeValidator: mock(() => Promise.resolve()), -}; - -const mockChainInfoService = { - getInfo: mock(() => - Promise.resolve({ - validators: ["MOCK-0x123", "MOCK-0x456"], - committee: ["MOCK-0x123"], - archive: [], - pendingBlockNum: "MOCK-1", - provenBlockNum: "MOCK-1", - currentEpoch: "MOCK-1", - currentSlot: "MOCK-1", - proposerNow: "MOCK-0x123", - }) - ), -}; - -mock.module("../../services/validator-service.js", () => ({ - ValidatorService: mockValidatorService, -})); - -mock.module("../../services/chaininfo-service.js", () => ({ - ChainInfoService: mockChainInfoService, -})); - -describe("Discord Commands", () => { - describe("addValidator", () => { - test("should add a validator with valid address", async () => { - const interaction: DiscordInteraction = { - data: { - name: "add-validator", - options: [ - { - name: "address", - value: "0x123", - type: ApplicationCommandOptionType.STRING, - }, - ], - }, - }; - - const response = await addValidator.execute(interaction); - expect(response.data.content).toBe("MOCK - Added validator 0x123"); - }); - - test("should handle missing address", async () => { - const interaction: DiscordInteraction = { - data: { - name: "add-validator", - options: [], - }, - }; - - const response = await addValidator.execute(interaction); - expect(response.data.content).toBe( - "MOCK - Please provide an address to add" - ); - }); - }); - - describe("adminValidators", () => { - test("should get committee list", async () => { - const interaction: DiscordInteraction = { - data: { - name: "admin", - options: [ - { - name: "committee", - type: ApplicationCommandOptionType.SUB_COMMAND_GROUP, - options: [ - { - name: "get", - type: ApplicationCommandOptionType.SUB_COMMAND, - options: [], - }, - ], - }, - ], - }, - }; - - const response = await adminValidators.execute(interaction); - expect(response.data.content).toContain( - "MOCK - Committee total: 1" - ); - expect(response.data.content).toContain("MOCK-0x123"); - }); - - test("should get validators list", async () => { - const interaction: DiscordInteraction = { - data: { - name: "admin", - options: [ - { - name: "validators", - type: ApplicationCommandOptionType.SUB_COMMAND_GROUP, - options: [ - { - name: "get", - type: ApplicationCommandOptionType.SUB_COMMAND, - options: [], - }, - ], - }, - ], - }, - }; - - const response = await adminValidators.execute(interaction); - expect(response.data.content).toContain( - "MOCK - Validators total: 2" - ); - expect(response.data.content).toContain("MOCK-0x123"); - expect(response.data.content).toContain("MOCK-0x456"); - }); - - test("should remove validator", async () => { - const interaction: DiscordInteraction = { - data: { - name: "admin", - options: [ - { - name: "validators", - type: ApplicationCommandOptionType.SUB_COMMAND_GROUP, - options: [ - { - name: "remove", - type: ApplicationCommandOptionType.SUB_COMMAND, - options: [ - { - name: "address", - value: "0x123", - type: ApplicationCommandOptionType.STRING, - }, - ], - }, - ], - }, - ], - }, - }; - - const response = await adminValidators.execute(interaction); - expect(response.data.content).toBe( - "MOCK - Removed validator 0x123" - ); - }); - - test("should handle invalid command structure", async () => { - const interaction: DiscordInteraction = { - data: { - name: "admin", - options: [], - }, - }; - - const response = await adminValidators.execute(interaction); - expect(response.data.content).toBe( - "MOCK - Invalid command structure" - ); - }); - - test("should handle service errors", async () => { - const error = new Error("Service error"); - const errorMockValidatorService = { - addValidator: mock(() => Promise.reject(error)), - removeValidator: mock(() => Promise.reject(error)), - }; - - mock.module("../../services/validator-service.js", () => ({ - ValidatorService: errorMockValidatorService, - })); - - const interaction: DiscordInteraction = { - data: { - name: "add-validator", - options: [ - { - name: "address", - value: "0x123", - type: ApplicationCommandOptionType.STRING, - }, - ], - }, - }; - - const response = await addValidator.execute(interaction); - expect(response.data.content).toBe( - "MOCK - Failed to add validator: Service error" - ); - }); - }); -}); diff --git a/tooling/sparta-aws/src/commands/addValidator.ts b/tooling/sparta-aws/src/commands/addValidator.ts deleted file mode 100644 index 149b1af..0000000 --- a/tooling/sparta-aws/src/commands/addValidator.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { SlashCommandBuilder } from "@discordjs/builders"; -import { PermissionFlagsBits } from "discord.js"; -import { ValidatorService } from "../services/validator-service.js"; -import type { CommandModule, DiscordInteraction } from "../types/discord.js"; -import { - ApplicationCommandOptionType, - createMockResponse, -} from "../types/discord.js"; - -export const addValidator: CommandModule = { - data: new SlashCommandBuilder() - .setName("add-validator") - .setDescription("Add a validator") - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) - .addStringOption((option) => - option - .setName("address") - .setDescription("The validator to add") - .setRequired(true) - ), - - async execute(interaction: DiscordInteraction) { - try { - const address = interaction.data.options?.find( - (opt) => - opt.name === "address" && - opt.type === ApplicationCommandOptionType.STRING - )?.value; - - if (!address) { - return createMockResponse("Please provide an address to add"); - } - - await ValidatorService.addValidator(address); - return createMockResponse(`Added validator ${address}`); - } catch (error) { - console.error("Error in add-validator command:", error); - return createMockResponse( - `Failed to add validator: ${ - error instanceof Error ? error.message : String(error) - }` - ); - } - }, -}; diff --git a/tooling/sparta-aws/src/commands/adminValidators.ts b/tooling/sparta-aws/src/commands/adminValidators.ts deleted file mode 100644 index 08f6772..0000000 --- a/tooling/sparta-aws/src/commands/adminValidators.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { SlashCommandBuilder } from "@discordjs/builders"; -import { PermissionFlagsBits } from "discord.js"; -import { ChainInfoService } from "../services/chaininfo-service.js"; -import { ValidatorService } from "../services/validator-service.js"; -import type { CommandModule, DiscordInteraction } from "../types/discord.js"; -import { - ApplicationCommandOptionType, - createMockResponse, -} from "../types/discord.js"; - -// List of excluded validator addresses -export const EXCLUDED_VALIDATORS = [ - "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", - // ... add the rest of the excluded validators here -]; - -export const adminValidators: CommandModule = { - data: new SlashCommandBuilder() - .setName("admin") - .setDescription("Admin commands") - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) - .addSubcommandGroup((group) => - group - .setName("validators") - .setDescription("Manage validators") - .addSubcommand((subcommand) => - subcommand.setName("get").setDescription("Get validators") - ) - .addSubcommand((subcommand) => - subcommand - .setName("remove") - .setDescription("Remove a validator") - .addStringOption((option) => - option - .setName("address") - .setDescription("The validator to remove") - .setRequired(true) - ) - ) - ) - .addSubcommandGroup((group) => - group - .setName("committee") - .setDescription("Manage the committee") - .addSubcommand((subcommand) => - subcommand - .setName("get") - .setDescription("Get the current committee") - ) - ), - - async execute(interaction: DiscordInteraction) { - try { - // Get subcommand group and subcommand from the nested options - const subcommandGroup = interaction.data.options?.find( - (opt) => - opt.type === ApplicationCommandOptionType.SUB_COMMAND_GROUP - ); - const subcommand = subcommandGroup?.options?.find( - (opt) => opt.type === ApplicationCommandOptionType.SUB_COMMAND - ); - const subcommandOptions = subcommand?.options || []; - - console.log("Executing admin command:", { - subcommandGroup: subcommandGroup?.name, - subcommand: subcommand?.name, - options: subcommandOptions, - }); - - const { validators, committee } = await ChainInfoService.getInfo(); - - // Ensure validators and committee are arrays - const validatorList = Array.isArray(validators) ? validators : []; - const committeeList = Array.isArray(committee) ? committee : []; - - const filteredValidators = validatorList.filter( - (v) => !EXCLUDED_VALIDATORS.includes(v) - ); - const filteredCommittee = committeeList.filter( - (v) => !EXCLUDED_VALIDATORS.includes(v) - ); - - if (!subcommandGroup?.name || !subcommand?.name) { - return createMockResponse("Invalid command structure"); - } - - if ( - subcommandGroup.name === "committee" && - subcommand.name === "get" - ) { - return createMockResponse( - `Committee total: ${ - committeeList.length - }.\nCommittee (excl. Aztec Labs):\n${filteredCommittee.join( - "\n" - )}` - ); - } else if (subcommandGroup.name === "validators") { - if (subcommand.name === "get") { - return createMockResponse( - `Validators total: ${ - validatorList.length - }.\nValidators (excl. Aztec Labs):\n${filteredValidators.join( - "\n" - )}` - ); - } else if (subcommand.name === "remove") { - const address = subcommandOptions.find( - (opt) => - opt.name === "address" && - opt.type === ApplicationCommandOptionType.STRING - )?.value; - if (!address) { - return createMockResponse( - "Please provide an address to remove" - ); - } - await ValidatorService.removeValidator(address); - return createMockResponse(`Removed validator ${address}`); - } - } - - return createMockResponse( - `Invalid command: ${subcommandGroup.name} ${subcommand.name}` - ); - } catch (error) { - console.error("Error in admin command:", error); - return createMockResponse( - `Failed to execute admin command: ${ - error instanceof Error ? error.message : String(error) - }` - ); - } - }, -}; diff --git a/tooling/sparta-aws/src/commands/getChainInfo.ts b/tooling/sparta-aws/src/commands/getChainInfo.ts deleted file mode 100644 index 89077b7..0000000 --- a/tooling/sparta-aws/src/commands/getChainInfo.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { SlashCommandBuilder } from '@discordjs/builders'; -import { ChainInfoService } from '../services/chaininfo-service.js'; - -export const getChainInfo = { - data: new SlashCommandBuilder() - .setName('get-info') - .setDescription('Get chain info'), - - async execute(interaction: any) { - try { - const { - pendingBlockNum, - provenBlockNum, - currentEpoch, - currentSlot, - proposerNow, - } = await ChainInfoService.getInfo(); - - return { - type: 4, - data: { - content: `Pending block: ${pendingBlockNum}\nProven block: ${provenBlockNum}\nCurrent epoch: ${currentEpoch}\nCurrent slot: ${currentSlot}\nProposer now: ${proposerNow}`, - flags: 64 - } - }; - } catch (error) { - console.error('Error in get-info command:', error); - return { - type: 4, - data: { - content: 'Failed to get chain info', - flags: 64 - } - }; - } - }, -}; diff --git a/tooling/sparta-aws/src/commands/index.ts b/tooling/sparta-aws/src/commands/index.ts deleted file mode 100644 index 6ba52b6..0000000 --- a/tooling/sparta-aws/src/commands/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ExtendedClient } from "../types/discord.js"; -import { addValidator } from "./addValidator.js"; -import { getChainInfo } from "./getChainInfo.js"; -import { adminValidators } from "./adminValidators.js"; - -export async function loadCommands(client: ExtendedClient) { - client.commands.set("validator", addValidator); - client.commands.set("get-info", getChainInfo); - client.commands.set("admin", adminValidators); -} diff --git a/tooling/sparta-aws/src/index.ts b/tooling/sparta-aws/src/index.ts deleted file mode 100644 index fc12556..0000000 --- a/tooling/sparta-aws/src/index.ts +++ /dev/null @@ -1,117 +0,0 @@ -import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; -import { Client, GatewayIntentBits, Collection } from "discord.js"; -import { SSMClient } from "@aws-sdk/client-ssm"; -import { loadCommands } from "./commands/index.js"; -import { verifyDiscordRequest } from "./utils/discord-verify.js"; -import { getParameter } from "./utils/parameter-store.js"; -import type { ExtendedClient } from "./types/discord.js"; - -const ssm = new SSMClient({}); -let client: ExtendedClient; - -export const handler = async ( - event: APIGatewayProxyEvent -): Promise => { - try { - // Verify Discord request - const signature = event.headers["x-signature-ed25519"]; - const timestamp = event.headers["x-signature-timestamp"]; - - if (!signature || !timestamp) { - return { - statusCode: 401, - body: JSON.stringify({ error: "Invalid request signature" }), - }; - } - - const isValid = await verifyDiscordRequest( - event.body || "", - signature, - timestamp - ); - if (!isValid) { - return { - statusCode: 401, - body: JSON.stringify({ error: "Invalid request signature" }), - }; - } - - // Initialize Discord client if not already initialized - if (!client) { - // Use environment variable in development, Parameter Store in production - const token = - process.env["ENVIRONMENT"] === "development" - ? process.env["DISCORD_BOT_TOKEN"] - : await getParameter("/sparta/discord/bot_token"); - - if (!token) { - throw new Error("Discord bot token not found"); - } - - client = new Client({ - intents: [ - GatewayIntentBits.Guilds, - GatewayIntentBits.GuildMessages, - ], - }) as ExtendedClient; - - client.commands = new Collection(); - await loadCommands(client); - await client.login(token); - } - - // Parse the interaction - const interaction = JSON.parse(event.body || "{}"); - - // Handle ping - if (interaction.type === 1) { - return { - statusCode: 200, - body: JSON.stringify({ type: 1 }), - }; - } - - // Handle command - if (interaction.type === 2) { - const command = client.commands.get(interaction.data.name); - if (!command) { - return { - statusCode: 404, - body: JSON.stringify({ error: "Command not found" }), - }; - } - - try { - const response = await command.execute(interaction); - return { - statusCode: 200, - body: JSON.stringify(response), - }; - } catch (error) { - console.error("Error executing command:", error); - return { - statusCode: 500, - body: JSON.stringify({ - type: 4, - data: { - content: - "There was an error executing this command!", - flags: 64, - }, - }), - }; - } - } - - return { - statusCode: 400, - body: JSON.stringify({ error: "Unknown interaction type" }), - }; - } catch (error) { - console.error("Error handling request:", error); - return { - statusCode: 500, - body: JSON.stringify({ error: "Internal server error" }), - }; - } -}; diff --git a/tooling/sparta-aws/src/local-dev.ts b/tooling/sparta-aws/src/local-dev.ts deleted file mode 100644 index aec7b77..0000000 --- a/tooling/sparta-aws/src/local-dev.ts +++ /dev/null @@ -1,116 +0,0 @@ -import express, { Request, Response } from "express"; -import { handler } from "./index.js"; -import { verifyKey } from "discord-interactions"; -import dotenv from "dotenv"; -import { APIGatewayProxyEvent } from "aws-lambda"; - -dotenv.config(); - -const app = express(); -const port = process.env["PORT"] || 3000; - -// Logging middleware -app.use((req, res, next) => { - console.log(`${new Date().toISOString()} ${req.method} ${req.path}`); - console.log("Headers:", req.headers); - next(); -}); - -// Raw body buffer handling for all routes -app.use(express.raw({ type: "*/*" })); - -// Handle Discord interactions -app.post("/discord-webhook", async (req: Request, res: Response) => { - try { - const signature = req.headers["x-signature-ed25519"]; - const timestamp = req.headers["x-signature-timestamp"]; - const body = req.body; // This will be a raw buffer - - console.log("Received Discord interaction:"); - console.log("Signature:", signature); - console.log("Timestamp:", timestamp); - console.log("Body:", body.toString()); - - if (!signature || !timestamp) { - console.log("Missing signature or timestamp"); - return res.status(401).json({ error: "Invalid request signature" }); - } - - const isValid = verifyKey( - body, - signature as string, - timestamp as string, - process.env["DISCORD_PUBLIC_KEY"]! - ); - - console.log("Verification result:", isValid); - - if (!isValid) { - console.log("Invalid signature"); - return res.status(401).json({ error: "Invalid request signature" }); - } - - const event: APIGatewayProxyEvent = { - body: body.toString(), - headers: { - "x-signature-ed25519": String(signature || ""), - "x-signature-timestamp": String(timestamp || ""), - }, - multiValueHeaders: {}, - httpMethod: "POST", - isBase64Encoded: false, - path: "/discord-webhook", - pathParameters: null, - queryStringParameters: null, - multiValueQueryStringParameters: null, - stageVariables: null, - requestContext: { - accountId: "", - apiId: "", - authorizer: {}, - protocol: "HTTP/1.1", - httpMethod: "POST", - identity: { - accessKey: null, - accountId: null, - apiKey: null, - apiKeyId: null, - caller: null, - clientCert: null, - cognitoAuthenticationProvider: null, - cognitoAuthenticationType: null, - cognitoIdentityId: null, - cognitoIdentityPoolId: null, - principalOrgId: null, - sourceIp: String(req.ip || ""), - user: null, - userAgent: String(req.headers["user-agent"] || ""), - userArn: null, - }, - path: "/discord-webhook", - stage: "prod", - requestId: "", - requestTimeEpoch: Date.now(), - resourceId: "", - resourcePath: "/discord-webhook", - }, - resource: "/discord-webhook", - }; - - const result = await handler(event); - console.log("Handler response:", result); - res.status(result.statusCode).json(JSON.parse(result.body)); - } catch (error) { - console.error("Error handling request:", error); - res.status(500).json({ error: "Internal server error" }); - } -}); - -// Health check endpoint -app.get("/health", (_req: Request, res: Response) => { - res.json({ status: "ok" }); -}); - -app.listen(port, () => { - console.log(`Local development server running at http://localhost:${port}`); -}); diff --git a/tooling/sparta-aws/src/package.json b/tooling/sparta-aws/src/package.json deleted file mode 100644 index 27c872a..0000000 --- a/tooling/sparta-aws/src/package.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "name": "sparta-discord-bot", - "version": "1.0.0", - "description": "AWS Lambda function for Sparta Discord bot", - "main": "dist/index.js", - "type": "module", - "scripts": { - "build": "tsc", - "watch": "tsc -w", - "test": "jest", - "deploy": "npm run build && cd ../terraform && terraform apply", - "lint": "eslint . --ext .ts", - "format": "prettier --write \"src/**/*.ts\"", - "dev": "bun dev::register-commands && bun --watch local-dev.ts", - "dev::register-commands": "tsx scripts/register-commands.ts" - }, - "dependencies": { - "@aws-sdk/client-ssm": "^3.0.0", - "@aws-sdk/client-ecs": "^3.0.0", - "@discordjs/rest": "^2.0.0", - "discord.js": "^14.0.0", - "discord-interactions": "^3.4.0", - "aws-lambda": "^1.0.7", - "express": "^4.18.2", - "dotenv": "^16.0.3", - "node-fetch": "^3.3.0" - }, - "devDependencies": { - "@types/aws-lambda": "^8.10.92", - "@types/node": "^18.0.0", - "@types/express": "^4.17.17", - "@types/node-fetch": "^2.6.4", - "@typescript-eslint/eslint-plugin": "^5.0.0", - "@typescript-eslint/parser": "^5.0.0", - "eslint": "^8.0.0", - "prettier": "^2.8.0", - "typescript": "^5.0.0", - "jest": "^29.0.0", - "@types/jest": "^29.0.0", - "ts-jest": "^29.0.0", - "tsx": "^4.7.0", - "@types/bun": "latest" - } -} \ No newline at end of file diff --git a/tooling/sparta-aws/src/scripts/register-commands.ts b/tooling/sparta-aws/src/scripts/register-commands.ts deleted file mode 100644 index c94779e..0000000 --- a/tooling/sparta-aws/src/scripts/register-commands.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { REST } from "@discordjs/rest"; -import { Routes } from "discord.js"; -import dotenv from "dotenv"; -import { addValidator } from "../commands/addValidator.js"; -import { getChainInfo } from "../commands/getChainInfo.js"; -import { adminValidators } from "../commands/adminValidators.js"; - -dotenv.config(); - -const commands = [ - addValidator.data.toJSON(), - getChainInfo.data.toJSON(), - adminValidators.data.toJSON(), -]; - -const isDev = process.env["ENVIRONMENT"] === "development"; -const { DISCORD_BOT_TOKEN, DISCORD_CLIENT_ID, DISCORD_GUILD_ID } = process.env; - -if (!DISCORD_BOT_TOKEN || !DISCORD_CLIENT_ID || !DISCORD_GUILD_ID) { - console.error("Missing required environment variables"); - process.exit(1); -} - -const rest = new REST({ version: "10" }).setToken(DISCORD_BOT_TOKEN as string); - -async function main() { - try { - console.log( - `Started refreshing application (/) commands for ${ - isDev ? "development" : "production" - } environment.` - ); - - await rest.put( - Routes.applicationGuildCommands( - DISCORD_CLIENT_ID as string, - DISCORD_GUILD_ID as string - ), - { body: commands } - ); - - console.log("Successfully reloaded application (/) commands."); - } catch (error) { - console.error("Error registering commands:", error); - } -} - -main(); diff --git a/tooling/sparta-aws/src/services/chaininfo-service.ts b/tooling/sparta-aws/src/services/chaininfo-service.ts deleted file mode 100644 index ad2ef98..0000000 --- a/tooling/sparta-aws/src/services/chaininfo-service.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { getParameter } from "../utils/parameter-store.js"; -import { ECSClient, RunTaskCommand } from "@aws-sdk/client-ecs"; - -type ChainInfo = { - pendingBlockNum: string; - provenBlockNum: string; - validators: string[]; - committee: string[]; - archive: string[]; - currentEpoch: string; - currentSlot: string; - proposerNow: string; -}; - -const ecs = new ECSClient({}); - -export class ChainInfoService { - static async getInfo(): Promise { - try { - // In development mode, return mock data - if (process.env["ENVIRONMENT"] === "development") { - const mockInfo: ChainInfo = { - pendingBlockNum: "MOCK-123456", - provenBlockNum: "MOCK-123455", - validators: [ - "MOCK-0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", - "MOCK-0x70997970C51812dc3A010C7d01b50e0d17dc79C8", - ], - committee: [ - "MOCK-0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", - "MOCK-0x70997970C51812dc3A010C7d01b50e0d17dc79C8", - ], - archive: [ - "MOCK-0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", - ], - currentEpoch: "MOCK-1", - currentSlot: "MOCK-12345", - proposerNow: - "MOCK-0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", - }; - return mockInfo; - } - - // Production code (commented out for now) - // const clusterArn = await getParameter("/sparta/ecs/cluster_arn"); - // const taskDefinition = await getParameter("/sparta/ecs/task_definition"); - // const containerName = await getParameter("/sparta/ecs/container_name"); - // const ethereumHost = await getParameter("/sparta/ethereum/host"); - // const rollupAddress = await getParameter("/sparta/ethereum/rollup_address"); - // const chainId = await getParameter("/sparta/ethereum/chain_id"); - - // const command = new RunTaskCommand({ - // cluster: clusterArn, - // taskDefinition: taskDefinition, - // launchType: 'FARGATE', - // networkConfiguration: { - // awsvpcConfiguration: { - // subnets: ['subnet-xxxxxx'], - // securityGroups: ['sg-xxxxxx'], - // assignPublicIp: 'ENABLED' - // } - // }, - // overrides: { - // containerOverrides: [ - // { - // name: containerName, - // command: [ - // 'debug-rollup', - // '-u', ethereumHost, - // '--rollup', rollupAddress, - // '--l1-chain-id', chainId - // ] - // } - // ] - // } - // }); - - // const response = await ecs.send(command); - // TODO: Parse response and return actual data - throw new Error("Production mode not implemented yet"); - } catch (error) { - console.error("Error getting chain info:", error); - throw error; - } - } -} diff --git a/tooling/sparta-aws/src/services/validator-service.ts b/tooling/sparta-aws/src/services/validator-service.ts deleted file mode 100644 index 0d995dd..0000000 --- a/tooling/sparta-aws/src/services/validator-service.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { getParameter } from "../utils/parameter-store.js"; -import { ECSClient, RunTaskCommand } from "@aws-sdk/client-ecs"; - -const ecs = new ECSClient({}); - -export class ValidatorService { - static async addValidator(address: string): Promise { - try { - // const clusterArn = await getParameter('/sparta/ecs/cluster_arn'); - // const taskDefinition = await getParameter('/sparta/ecs/task_definition'); - // const containerName = await getParameter('/sparta/ecs/container_name'); - // const ethereumHost = await getParameter('/sparta/ethereum/host'); - // const rollupAddress = await getParameter('/sparta/ethereum/rollup_address'); - // const adminAddress = await getParameter('/sparta/ethereum/admin_address'); - // const chainId = await getParameter('/sparta/ethereum/chain_id'); - // const mnemonic = await getParameter('/sparta/ethereum/mnemonic'); - - // // First, fund the validator - // await this.fundValidator(address); - - // // Then add the validator to the set - // const command = new RunTaskCommand({ - // cluster: clusterArn, - // taskDefinition: taskDefinition, - // launchType: 'FARGATE', - // networkConfiguration: { - // awsvpcConfiguration: { - // subnets: ['subnet-xxxxxx'], // Replace with actual subnet IDs - // securityGroups: ['sg-xxxxxx'], // Replace with actual security group IDs - // assignPublicIp: 'ENABLED' - // } - // }, - // overrides: { - // containerOverrides: [ - // { - // name: containerName, - // command: [ - // 'add-l1-validator', - // '-u', ethereumHost, - // '--validator', address, - // '--rollup', rollupAddress, - // '--withdrawer', adminAddress, - // '--l1-chain-id', chainId, - // '--mnemonic', mnemonic - // ] - // } - // ] - // } - // }); - - // const response = await ecs.send(command); - return "Validator added successfully"; - } catch (error) { - console.error("Error adding validator:", error); - throw error; - } - } - - static async removeValidator(address: string): Promise { - try { - // const clusterArn = await getParameter('/sparta/ecs/cluster_arn'); - // const taskDefinition = await getParameter('/sparta/ecs/task_definition'); - // const containerName = await getParameter('/sparta/ecs/container_name'); - // const ethereumHost = await getParameter('/sparta/ethereum/host'); - // const rollupAddress = await getParameter('/sparta/ethereum/rollup_address'); - // const chainId = await getParameter('/sparta/ethereum/chain_id'); - // const mnemonic = await getParameter('/sparta/ethereum/mnemonic'); - - // const command = new RunTaskCommand({ - // cluster: clusterArn, - // taskDefinition: taskDefinition, - // launchType: 'FARGATE', - // networkConfiguration: { - // awsvpcConfiguration: { - // subnets: ['subnet-xxxxxx'], // Replace with actual subnet IDs - // securityGroups: ['sg-xxxxxx'], // Replace with actual security group IDs - // assignPublicIp: 'ENABLED' - // } - // }, - // overrides: { - // containerOverrides: [ - // { - // name: containerName, - // command: [ - // 'remove-l1-validator', - // '-u', ethereumHost, - // '--validator', address, - // '--rollup', rollupAddress, - // '--l1-chain-id', chainId, - // '--mnemonic', mnemonic - // ] - // } - // ] - // } - // }); - - // const response = await ecs.send(command); - return "MOCK: Validator removed successfully"; - } catch (error) { - console.error("Error removing validator:", error); - throw error; - } - } - - static async fundValidator(address: string): Promise { - try { - // const clusterArn = await getParameter('/sparta/ecs/cluster_arn'); - // const taskDefinition = await getParameter('/sparta/ecs/task_definition'); - // const containerName = await getParameter('/sparta/ecs/container_name'); - // const ethereumHost = await getParameter('/sparta/ethereum/host'); - // const chainId = await getParameter('/sparta/ethereum/chain_id'); - // const privateKey = await getParameter('/sparta/ethereum/private_key'); - // const value = await getParameter('/sparta/ethereum/value'); - - // const command = new RunTaskCommand({ - // cluster: clusterArn, - // taskDefinition: taskDefinition, - // launchType: 'FARGATE', - // networkConfiguration: { - // awsvpcConfiguration: { - // subnets: ['subnet-xxxxxx'], // Replace with actual subnet IDs - // securityGroups: ['sg-xxxxxx'], // Replace with actual security group IDs - // assignPublicIp: 'ENABLED' - // } - // }, - // overrides: { - // containerOverrides: [ - // { - // name: containerName, - // command: [ - // 'cast', - // 'send', - // '--value', value, - // '--rpc-url', ethereumHost, - // '--chain-id', chainId, - // '--private-key', privateKey, - // address - // ] - // } - // ] - // } - // }); - - // const response = await ecs.send(command); - return "MOCK: Validator funded successfully"; - } catch (error) { - console.error("Error funding validator:", error); - throw error; - } - } -} diff --git a/tooling/sparta-aws/src/tsconfig.json b/tooling/sparta-aws/src/tsconfig.json deleted file mode 100644 index 6f44a00..0000000 --- a/tooling/sparta-aws/src/tsconfig.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "compilerOptions": { - // Enable latest features - "lib": ["ESNext", "DOM"], - "target": "ESNext", - "module": "ESNext", - "moduleDetection": "force", - "jsx": "react-jsx", - "allowJs": true, - - // Bundler mode - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": false, - "noEmit": true, - - // Best practices - "strict": true, - "skipLibCheck": true, - "noFallthroughCasesInSwitch": true, - - // Some stricter flags - "noUnusedLocals": true, - "noUnusedParameters": true, - "noPropertyAccessFromIndexSignature": true, - - // Additional settings - "esModuleInterop": true, - "resolveJsonModule": true, - "types": ["bun-types", "jest", "node"] - }, - "include": ["**/*.ts", "**/*.tsx"], - "exclude": ["node_modules", "dist"] -} diff --git a/tooling/sparta-aws/src/types/discord.ts b/tooling/sparta-aws/src/types/discord.ts deleted file mode 100644 index e12ae71..0000000 --- a/tooling/sparta-aws/src/types/discord.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { Client, Collection, PermissionsBitField } from "discord.js"; -import type { - SlashCommandBuilder, - SlashCommandSubcommandsOnlyBuilder, - SlashCommandOptionsOnlyBuilder, -} from "@discordjs/builders"; - -export interface ExtendedClient extends Client { - commands: Collection; -} - -export interface CommandModule { - data: - | SlashCommandBuilder - | SlashCommandSubcommandsOnlyBuilder - | SlashCommandOptionsOnlyBuilder; - execute: (interaction: DiscordInteraction) => Promise; -} - -export interface CommandOption { - name: string; - value?: string; - options?: CommandOption[]; - type: ApplicationCommandOptionType; -} - -// https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-option-type -export enum ApplicationCommandOptionType { - SUB_COMMAND = 1, - SUB_COMMAND_GROUP = 2, - STRING = 3, - INTEGER = 4, - BOOLEAN = 5, - USER = 6, - CHANNEL = 7, - ROLE = 8, - MENTIONABLE = 9, - NUMBER = 10, - ATTACHMENT = 11, -} - -export interface DiscordInteraction { - data: { - name: string; - options?: CommandOption[]; - }; -} - -export interface InteractionResponse { - type: InteractionResponseType; - data: { - content: string; - flags: MessageFlags; - }; -} - -// https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-response-object-interaction-callback-type -export enum InteractionResponseType { - PONG = 1, - CHANNEL_MESSAGE_WITH_SOURCE = 4, - DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE = 5, - DEFERRED_UPDATE_MESSAGE = 6, - UPDATE_MESSAGE = 7, - APPLICATION_COMMAND_AUTOCOMPLETE_RESULT = 8, - MODAL = 9, - PREMIUM_REQUIRED = 10, -} - -// https://discord.com/developers/docs/resources/channel#message-object-message-flags -export enum MessageFlags { - EPHEMERAL = 64, -} - -export const createMockResponse = (content: string): InteractionResponse => ({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: `MOCK - ${content}`, - flags: MessageFlags.EPHEMERAL, - }, -}); diff --git a/tooling/sparta-aws/src/utils/discord-verify.ts b/tooling/sparta-aws/src/utils/discord-verify.ts deleted file mode 100644 index be14361..0000000 --- a/tooling/sparta-aws/src/utils/discord-verify.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { getParameter } from "./parameter-store.js"; -import { verifyKey } from "discord-interactions"; - -export async function verifyDiscordRequest( - body: string, - signature: string, - timestamp: string -): Promise { - try { - // Use environment variable in development, Parameter Store in production - const publicKey = - process.env["ENVIRONMENT"] === "development" - ? process.env["DISCORD_PUBLIC_KEY"] - : await getParameter("/sparta/discord/public_key"); - - if (!publicKey) { - throw new Error("Discord public key not found"); - } - - return verifyKey(body, signature, timestamp, publicKey); - } catch (error) { - console.error("Error verifying Discord request:", error); - return false; - } -} diff --git a/tooling/sparta-aws/src/utils/parameter-store.ts b/tooling/sparta-aws/src/utils/parameter-store.ts deleted file mode 100644 index 5642ca6..0000000 --- a/tooling/sparta-aws/src/utils/parameter-store.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm"; - -const ssm = new SSMClient({}); - -const ENV_MAP: Record = { - "/sparta/discord/bot_token": "DISCORD_BOT_TOKEN", - "/sparta/discord/public_key": "DISCORD_PUBLIC_KEY", - "/sparta/ethereum/host": "ETHEREUM_HOST", - "/sparta/ethereum/rollup_address": "ETHEREUM_ROLLUP_ADDRESS", - "/sparta/ethereum/admin_address": "ETHEREUM_ADMIN_ADDRESS", - "/sparta/ethereum/chain_id": "ETHEREUM_CHAIN_ID", - "/sparta/ethereum/mnemonic": "ETHEREUM_MNEMONIC", - "/sparta/ethereum/private_key": "ETHEREUM_PRIVATE_KEY", - "/sparta/ethereum/value": "ETHEREUM_VALUE", - "/sparta/ecs/cluster_arn": "ECS_CLUSTER_ARN", - "/sparta/ecs/task_definition": "ECS_TASK_DEFINITION", - "/sparta/ecs/container_name": "ECS_CONTAINER_NAME", -}; - -export async function getParameter(name: string): Promise { - // In development mode, use environment variables - if (process.env.ENVIRONMENT === "development") { - const envVar = ENV_MAP[name]; - if (!envVar) { - throw new Error( - `No environment variable mapping found for parameter: ${name}` - ); - } - const value = process.env[envVar]; - if (!value) { - throw new Error(`Environment variable not set: ${envVar}`); - } - return value; - } - - // In production mode, use AWS Parameter Store - const command = new GetParameterCommand({ - Name: name, - WithDecryption: true, - }); - - const response = await ssm.send(command); - if (!response.Parameter?.Value) { - throw new Error(`Parameter not found: ${name}`); - } - - return response.Parameter.Value; -} - -export async function getParameters( - names: string[] -): Promise> { - const results: Record = {}; - - for (const name of names) { - results[name] = await getParameter(name); - } - - return results; -} diff --git a/tooling/sparta-aws/terraform/main.tf b/tooling/sparta-aws/terraform/main.tf deleted file mode 100644 index 070c372..0000000 --- a/tooling/sparta-aws/terraform/main.tf +++ /dev/null @@ -1,202 +0,0 @@ -terraform { - required_providers { - aws = { - source = "hashicorp/aws" - version = "~> 5.0" - } - } - backend "s3" { - bucket = "sparta-terraform-state" - key = "sparta/terraform.tfstate" - region = "us-east-1" - } -} - -provider "aws" { - region = var.aws_region -} - -# API Gateway -resource "aws_apigatewayv2_api" "discord_webhook" { - name = "sparta-discord-webhook" - protocol_type = "HTTP" -} - -resource "aws_apigatewayv2_stage" "discord_webhook" { - api_id = aws_apigatewayv2_api.discord_webhook.id - name = "$default" - auto_deploy = true - - access_log_settings { - destination_arn = aws_cloudwatch_log_group.api_gateway.arn - format = jsonencode({ - requestId = "$context.requestId" - ip = "$context.identity.sourceIp" - requestTime = "$context.requestTime" - httpMethod = "$context.httpMethod" - routeKey = "$context.routeKey" - status = "$context.status" - protocol = "$context.protocol" - responseTime = "$context.responseLatency" - }) - } -} - -# Lambda Function -resource "aws_lambda_function" "discord_bot" { - filename = "../dist/lambda.zip" - function_name = "sparta-discord-bot" - role = aws_iam_role.lambda_role.arn - handler = "index.handler" - runtime = "nodejs18.x" - timeout = 30 - memory_size = 256 - - environment { - variables = { - ENVIRONMENT = var.environment - } - } - - depends_on = [ - aws_cloudwatch_log_group.lambda, - ] -} - -# CloudWatch Log Groups -resource "aws_cloudwatch_log_group" "lambda" { - name = "/aws/lambda/sparta-discord-bot" - retention_in_days = 14 -} - -resource "aws_cloudwatch_log_group" "api_gateway" { - name = "/aws/apigateway/sparta-discord-webhook" - retention_in_days = 14 -} - -# IAM Role for Lambda -resource "aws_iam_role" "lambda_role" { - name = "sparta-lambda-role" - - assume_role_policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Action = "sts:AssumeRole" - Effect = "Allow" - Principal = { - Service = "lambda.amazonaws.com" - } - } - ] - }) -} - -# IAM Policy for Lambda -resource "aws_iam_role_policy" "lambda_policy" { - name = "sparta-lambda-policy" - role = aws_iam_role.lambda_role.id - - policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Effect = "Allow" - Action = [ - "logs:CreateLogGroup", - "logs:CreateLogStream", - "logs:PutLogEvents" - ] - Resource = "arn:aws:logs:*:*:*" - }, - { - Effect = "Allow" - Action = [ - "ssm:GetParameter", - "ssm:GetParameters" - ] - Resource = [ - "arn:aws:ssm:${var.aws_region}:${var.aws_account_id}:parameter/sparta/*" - ] - }, - { - Effect = "Allow" - Action = [ - "ecr:GetAuthorizationToken", - "ecr:BatchCheckLayerAvailability", - "ecr:GetDownloadUrlForLayer", - "ecr:BatchGetImage" - ] - Resource = "*" - } - ] - }) -} - -# ECR Repository -resource "aws_ecr_repository" "validator_ops" { - name = "sparta-validator-ops" - image_tag_mutability = "MUTABLE" - - image_scanning_configuration { - scan_on_push = true - } -} - -# CloudWatch Alarms -resource "aws_cloudwatch_metric_alarm" "lambda_errors" { - alarm_name = "sparta-lambda-errors" - comparison_operator = "GreaterThanThreshold" - evaluation_periods = "1" - metric_name = "Errors" - namespace = "AWS/Lambda" - period = "300" - statistic = "Sum" - threshold = "0" - alarm_description = "This metric monitors lambda function errors" - alarm_actions = [] # Add SNS topic ARN here if needed - - dimensions = { - FunctionName = aws_lambda_function.discord_bot.function_name - } -} - -resource "aws_cloudwatch_metric_alarm" "api_latency" { - alarm_name = "sparta-api-latency" - comparison_operator = "GreaterThanThreshold" - evaluation_periods = "1" - metric_name = "Latency" - namespace = "AWS/ApiGateway" - period = "300" - statistic = "Average" - threshold = "1000" - alarm_description = "This metric monitors API Gateway latency" - alarm_actions = [] # Add SNS topic ARN here if needed - - dimensions = { - ApiId = aws_apigatewayv2_api.discord_webhook.id - } -} - -# API Gateway Integration with Lambda -resource "aws_apigatewayv2_integration" "lambda" { - api_id = aws_apigatewayv2_api.discord_webhook.id - - integration_uri = aws_lambda_function.discord_bot.invoke_arn - integration_type = "AWS_PROXY" - integration_method = "POST" -} - -resource "aws_apigatewayv2_route" "lambda" { - api_id = aws_apigatewayv2_api.discord_webhook.id - route_key = "POST /discord-webhook" - target = "integrations/${aws_apigatewayv2_integration.lambda.id}" -} - -resource "aws_lambda_permission" "apigw" { - statement_id = "AllowAPIGatewayInvoke" - action = "lambda:InvokeFunction" - function_name = aws_lambda_function.discord_bot.function_name - principal = "apigateway.amazonaws.com" - source_arn = "${aws_apigatewayv2_api.discord_webhook.execution_arn}/*/*" -} diff --git a/tooling/sparta-aws/terraform/outputs.tf b/tooling/sparta-aws/terraform/outputs.tf deleted file mode 100644 index 191e321..0000000 --- a/tooling/sparta-aws/terraform/outputs.tf +++ /dev/null @@ -1,24 +0,0 @@ -output "lambda_function_name" { - description = "Name of the Lambda function" - value = aws_lambda_function.discord_bot.function_name -} - -output "api_gateway_url" { - description = "URL of the API Gateway endpoint" - value = "${aws_apigatewayv2_api.discord_webhook.api_endpoint}/discord-webhook" -} - -output "ecr_repository_url" { - description = "URL of the ECR repository" - value = aws_ecr_repository.validator_ops.repository_url -} - -output "cloudwatch_log_group_lambda" { - description = "Name of the CloudWatch log group for Lambda" - value = aws_cloudwatch_log_group.lambda.name -} - -output "cloudwatch_log_group_api" { - description = "Name of the CloudWatch log group for API Gateway" - value = aws_cloudwatch_log_group.api_gateway.name -} diff --git a/tooling/sparta-aws/terraform/task-definition.json b/tooling/sparta-aws/terraform/task-definition.json deleted file mode 100644 index 3c8461e..0000000 --- a/tooling/sparta-aws/terraform/task-definition.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "family": "sparta-validator-ops", - "networkMode": "awsvpc", - "requiresCompatibilities": [ - "FARGATE" - ], - "cpu": "256", - "memory": "512", - "executionRoleArn": "arn:aws:iam::${aws_account_id}:role/ecsTaskExecutionRole", - "taskRoleArn": "arn:aws:iam::${aws_account_id}:role/sparta-ecs-task-role", - "containerDefinitions": [ - { - "name": "validator-ops", - "image": "${ecr_repository_url}:latest", - "essential": true, - "logConfiguration": { - "logDriver": "awslogs", - "options": { - "awslogs-group": "/ecs/sparta-validator-ops", - "awslogs-region": "${aws_region}", - "awslogs-stream-prefix": "ecs" - } - }, - "environment": [ - { - "name": "ENVIRONMENT", - "value": "${environment}" - } - ], - "secrets": [ - { - "name": "ETHEREUM_HOST", - "valueFrom": "arn:aws:ssm:${aws_region}:${aws_account_id}:parameter/sparta/ethereum/host" - }, - { - "name": "ETHEREUM_ROLLUP_ADDRESS", - "valueFrom": "arn:aws:ssm:${aws_region}:${aws_account_id}:parameter/sparta/ethereum/rollup_address" - }, - { - "name": "ETHEREUM_ADMIN_ADDRESS", - "valueFrom": "arn:aws:ssm:${aws_region}:${aws_account_id}:parameter/sparta/ethereum/admin_address" - }, - { - "name": "ETHEREUM_CHAIN_ID", - "valueFrom": "arn:aws:ssm:${aws_region}:${aws_account_id}:parameter/sparta/ethereum/chain_id" - }, - { - "name": "ETHEREUM_MNEMONIC", - "valueFrom": "arn:aws:ssm:${aws_region}:${aws_account_id}:parameter/sparta/ethereum/mnemonic" - }, - { - "name": "ETHEREUM_PRIVATE_KEY", - "valueFrom": "arn:aws:ssm:${aws_region}:${aws_account_id}:parameter/sparta/ethereum/private_key" - }, - { - "name": "ETHEREUM_VALUE", - "valueFrom": "arn:aws:ssm:${aws_region}:${aws_account_id}:parameter/sparta/ethereum/value" - } - ] - } - ] -} diff --git a/tooling/sparta-aws/terraform/variables.tf b/tooling/sparta-aws/terraform/variables.tf deleted file mode 100644 index aff9aeb..0000000 --- a/tooling/sparta-aws/terraform/variables.tf +++ /dev/null @@ -1,34 +0,0 @@ -variable "aws_region" { - description = "AWS region to deploy resources" - type = string - default = "us-east-1" -} - -variable "aws_account_id" { - description = "AWS account ID" - type = string -} - -variable "environment" { - description = "Environment (development/production)" - type = string - default = "development" -} - -variable "lambda_memory" { - description = "Lambda function memory size" - type = number - default = 256 -} - -variable "lambda_timeout" { - description = "Lambda function timeout" - type = number - default = 30 -} - -variable "log_retention_days" { - description = "CloudWatch log retention in days" - type = number - default = 14 -} diff --git a/tooling/sparta/.env.example b/tooling/sparta/.env.example deleted file mode 100644 index 62ed7fc..0000000 --- a/tooling/sparta/.env.example +++ /dev/null @@ -1,15 +0,0 @@ -ENVIRONMENT= -DEV_CHANNEL_ID= -PROD_CHANNEL_ID= - -BOT_TOKEN= -BOT_CLIENT_ID= -GUILD_ID= - -ETHEREUM_HOST= -ETHEREUM_MNEMONIC= -ETHEREUM_PRIVATE_KEY= -ETHEREUM_ROLLUP_ADDRESS= -ETHEREUM_CHAIN_ID= -ETHEREUM_VALUE= -ETHEREUM_ADMIN_ADDRESS= diff --git a/tooling/sparta/.gitignore b/tooling/sparta/.gitignore index 55041b4..2e9690e 100644 --- a/tooling/sparta/.gitignore +++ b/tooling/sparta/.gitignore @@ -1,5 +1,40 @@ +# Node.js +node_modules/ +dist/ +*.log .env -node_modules +.env.* +!.env.example + +# Terraform +*.tfstate +*.tfstate.* +.terraform/ +terraform.tfvars +.terraform.lock.hcl +*.tfvars +!terraform.tfvars.example + +# Build artifacts +*.zip +deployment-*.zip + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Elastic Beanstalk Files +.elasticbeanstalk/* +!.elasticbeanstalk/*.cfg.yml +!.elasticbeanstalk/*.global.yml + +# Package managers bun.lockb -.vercel -.dist +yarn.lock +package-lock.json diff --git a/tooling/sparta/Dockerfile b/tooling/sparta/Dockerfile deleted file mode 100644 index 93014a3..0000000 --- a/tooling/sparta/Dockerfile +++ /dev/null @@ -1,19 +0,0 @@ -FROM oven/bun:latest - -ENV PATH="/root/.foundry/bin:${PATH}" - -RUN apt update && apt install -y curl apt-utils -RUN curl -fsSL https://get.docker.com | bash -RUN curl -L https://foundry.paradigm.xyz | bash - -RUN foundryup -RUN cast --version - -WORKDIR /app -COPY package.json ./ -COPY bun.lockb ./ -COPY src ./src -COPY .env ./ - -RUN bun install -CMD ["bun", "run", "start"] diff --git a/tooling/sparta/README.md b/tooling/sparta/README.md index f375562..31f6a1b 100644 --- a/tooling/sparta/README.md +++ b/tooling/sparta/README.md @@ -1,36 +1,141 @@ -# Sparta +# Sparta Discord Bot -Welcome to Sparta, the Discord bot. It's like having a virtual assistant, but with less features and less judgment. +A Discord bot for managing Aztec validators, built with Node.js and deployed on AWS Elastic Beanstalk. -Here's a quick rundown of what this codebase is all about: +## Prerequisites -## What is Sparta? +- Node.js v18 or higher +- AWS CLI configured with appropriate credentials +- Terraform v1.0 or higher +- Discord Bot Token and Application ID from [Discord Developer Portal](https://discord.com/developers/applications) -Sparta is a Discord bot that lives to serve (and occasionally sass) testnet participants. +## Security Notice -## Features (WIP) - -- **Chain Info**: Need to know the latest on your blockchain? Sparta's got you covered with the `/get-info` command. It's like having a blockchain oracle, but without the cryptic riddles. +⚠️ **Important**: This project uses sensitive credentials that should never be committed to version control: +- Discord bot tokens +- Ethereum private keys +- AWS credentials +- Environment variables -- **Add Validators**: Are you an S&P Participant and want to be added to the validator set? Just go `/validator add` and send your address, you can then query it with... +Always use: +- `.env` files for local development (never commit these) +- AWS Secrets Manager for production secrets +- `terraform.tfvars` for Terraform variables (never commit this) -- **Check Validators**: ... `/validator check`, which tells you if you're in the validator set (also tells you if you're in the committee) +## Local Development -## Getting Started +1. Clone the repository: +```bash +git clone +cd sparta +``` -To get Sparta up and running, you'll need to: +2. Install dependencies: +```bash +cd src +npm install +``` -1. Clone the repo. -2. Install the dependencies with `bun install`. -3. Copy .env.example and set up with your environment stuff -4. Start the bot with `bun run start`. +3. Create a `.env` file in the `src` directory using `.env.example` as a template: +```bash +cp .env.example .env +``` -And just like that, you're ready to unleash Sparta on your Discord server! +4. Fill in the required environment variables in `.env`: +``` +# Discord Bot Configuration +BOT_TOKEN=your_bot_token +BOT_CLIENT_ID=your_client_id +GUILD_ID=your_guild_id + +# Ethereum Configuration +ETHEREUM_HOST=http://localhost:8545 +ETHEREUM_ROLLUP_ADDRESS=your_rollup_address +ETHEREUM_ADMIN_ADDRESS=your_admin_address +ETHEREUM_CHAIN_ID=1337 +ETHEREUM_PRIVATE_KEY=your_private_key +ETHEREUM_VALUE=20ether +``` + +5. Start the bot in development mode: +```bash +npm run watch +``` + +## Deployment + +The bot is deployed using Terraform to AWS Elastic Beanstalk. Follow these steps: + +1. Navigate to the terraform directory: +```bash +cd terraform +``` + +2. Create `terraform.tfvars` using the example file: +```bash +cp terraform.tfvars.example terraform.tfvars +``` + +3. Fill in the required variables in `terraform.tfvars`: +```hcl +environment = "production" +aws_region = "us-west-2" +bot_token = "your_bot_token" +bot_client_id = "your_client_id" +guild_id = "your_guild_id" +ethereum_host = "your_ethereum_host" +# ... other variables +``` + +4. Initialize Terraform: +```bash +terraform init +``` + +5. Deploy: +```bash +terraform apply +``` + +## Architecture + +- **Discord.js**: Handles bot interactions and commands +- **AWS Elastic Beanstalk**: Hosts the bot in a scalable environment +- **AWS Secrets Manager**: Securely stores sensitive configuration +- **TypeScript**: Provides type safety and better development experience + +## Environment Variables + +### Development +- Uses `.env` file for local configuration +- Supports hot reloading through `npm run watch` + +### Production +- Uses AWS Secrets Manager for secure configuration +- Automatically loads secrets in production environment +- Supports staging and production environments + +## Commands + +- `/get-info`: Get chain information +- `/admin validators get`: List validators +- `/admin validators remove`: Remove a validator +- `/admin committee get`: Get committee information ## Contributing -Want to make Sparta even better? Feel free to fork the repo and submit a pull request. Just remember, with great power comes great responsibility (and maybe a few more memes). +1. Create a feature branch +2. Make your changes +3. Submit a pull request + +## Security + +- All sensitive information is stored in AWS Secrets Manager +- IAM roles are configured with least privilege +- Environment variables are never committed to version control +- SSH access is controlled via key pairs +- No sensitive information in logs or error messages ## License -This project is licensed under the MIT License. Because sharing is caring. +[Your License] diff --git a/tooling/sparta/dist/commands/addValidator.js b/tooling/sparta/dist/commands/addValidator.js deleted file mode 100644 index 3925e8e..0000000 --- a/tooling/sparta/dist/commands/addValidator.js +++ /dev/null @@ -1,71 +0,0 @@ -import { SlashCommandBuilder } from "discord.js"; -import { ValidatorService } from "../services/validator-service.js"; -import { ChainInfoService } from "../services/chaininfo-service.js"; -export default { - data: new SlashCommandBuilder() - .setName("validator") - .setDescription("Manage validator addresses") - .addSubcommand((subcommand) => subcommand - .setName("add") - .setDescription("Add yourself to the validator set") - .addStringOption((option) => option - .setName("address") - .setDescription("Your validator address"))) - .addSubcommand((subcommand) => subcommand - .setName("check") - .setDescription("Check if you are a validator") - .addStringOption((option) => option - .setName("address") - .setDescription("The validator address to check"))), - execute: async (interaction) => { - const address = interaction.options.getString("address"); - if (!address) { - return interaction.reply({ - content: "Address is required.", - ephemeral: true, - }); - } - // Basic address validation - if (!address.match(/^0x[a-fA-F0-9]{40}$/)) { - return interaction.reply({ - content: "Please provide a valid Ethereum address.", - ephemeral: true, - }); - } - await interaction.deferReply(); - if (interaction.options.getSubcommand() === "add") { - try { - await ValidatorService.addValidator(address); - await interaction.editReply({ - content: `Successfully added validator address: ${address}`, - }); - } - catch (error) { - await interaction.editReply({ - content: `Failed to add validator address: ${error instanceof Error ? error.message : String(error)}`, - }); - } - } - else if (interaction.options.getSubcommand() === "check") { - try { - const info = await ChainInfoService.getInfo(); - const { validators, committee } = info; - let reply = ""; - if (validators.includes(address)) { - reply += "You are a validator\n"; - } - if (committee.includes(address)) { - reply += "You are a committee member\n"; - } - await interaction.editReply({ - content: reply, - }); - } - catch (error) { - await interaction.editReply({ - content: `Failed to check validator address: ${error instanceof Error ? error.message : String(error)}`, - }); - } - } - }, -}; diff --git a/tooling/sparta/dist/commands/getChainInfo.js b/tooling/sparta/dist/commands/getChainInfo.js deleted file mode 100644 index d956b06..0000000 --- a/tooling/sparta/dist/commands/getChainInfo.js +++ /dev/null @@ -1,22 +0,0 @@ -import { SlashCommandBuilder } from "discord.js"; -import { ChainInfoService } from "../services/chaininfo-service.js"; -export default { - data: new SlashCommandBuilder() - .setName("get-info") - .setDescription("Get chain info"), - execute: async (interaction) => { - await interaction.deferReply(); - try { - const { pendingBlockNum, provenBlockNum, currentEpoch, currentSlot, proposerNow, } = await ChainInfoService.getInfo(); - await interaction.editReply({ - content: `Pending block: ${pendingBlockNum}\nProven block: ${provenBlockNum}\nCurrent epoch: ${currentEpoch}\nCurrent slot: ${currentSlot}\nProposer now: ${proposerNow}`, - }); - } - catch (error) { - console.error("Error in get-info command:", error); - await interaction.editReply({ - content: `Failed to get chain info`, - }); - } - }, -}; diff --git a/tooling/sparta/dist/commands/index.js b/tooling/sparta/dist/commands/index.js deleted file mode 100644 index c2bbcb6..0000000 --- a/tooling/sparta/dist/commands/index.js +++ /dev/null @@ -1,6 +0,0 @@ -import addValidator from "./addValidator.js"; -import getChainInfo from "./getChainInfo.js"; -export default { - addValidator, - getChainInfo, -}; diff --git a/tooling/sparta/dist/deploy-commands.js b/tooling/sparta/dist/deploy-commands.js deleted file mode 100644 index 2372684..0000000 --- a/tooling/sparta/dist/deploy-commands.js +++ /dev/null @@ -1,17 +0,0 @@ -import { REST, Routes } from "discord.js"; -import commands from "./commands/index.js"; -import { BOT_TOKEN, BOT_CLIENT_ID, GUILD_ID } from "./env.js"; -export const deployCommands = async () => { - const rest = new REST({ version: "10" }).setToken(BOT_TOKEN); - try { - console.log("Started refreshing application (/) commands."); - const commandsData = Object.values(commands).map((command) => command.data.toJSON()); - await rest.put(Routes.applicationGuildCommands(BOT_CLIENT_ID, GUILD_ID), { - body: commandsData, - }); - console.log("Successfully reloaded application (/) commands."); - } - catch (error) { - console.error(error); - } -}; diff --git a/tooling/sparta/dist/env.js b/tooling/sparta/dist/env.js deleted file mode 100644 index 0cdbfa1..0000000 --- a/tooling/sparta/dist/env.js +++ /dev/null @@ -1,3 +0,0 @@ -import dotenv from "dotenv"; -dotenv.config(); -export const { TOKEN, CLIENT_ID, GUILD_ID, PRODUCTION_CHANNEL_NAME, DEV_CHANNEL_NAME, PRODUCTION_CHANNEL_ID, DEV_CHANNEL_ID, ETHEREUM_HOST, ETHEREUM_ROLLUP_ADDRESS, ETHEREUM_ADMIN_ADDRESS, ETHEREUM_CHAIN_ID, ETHEREUM_MNEMONIC, ETHEREUM_PRIVATE_KEY, ETHEREUM_VALUE, BOT_TOKEN, BOT_CLIENT_ID, ENVIRONMENT, } = process.env; diff --git a/tooling/sparta/dist/index.js b/tooling/sparta/dist/index.js deleted file mode 100644 index 90c6603..0000000 --- a/tooling/sparta/dist/index.js +++ /dev/null @@ -1,46 +0,0 @@ -import { Client, GatewayIntentBits, Collection, MessageFlags, } from "discord.js"; -import { deployCommands } from "./deploy-commands.js"; -import commands from "./commands/index.js"; -import { BOT_TOKEN, PRODUCTION_CHANNEL_ID, DEV_CHANNEL_ID, ENVIRONMENT, PRODUCTION_CHANNEL_NAME, DEV_CHANNEL_NAME, } from "./env.js"; -const client = new Client({ - intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages], -}); -client.commands = new Collection(); -for (const command of Object.values(commands)) { - client.commands.set(command.data.name, command); -} -client.once("ready", () => { - console.log("Sparta bot is ready!"); - deployCommands(); -}); -client.on("interactionCreate", async (interaction) => { - if (!interaction.isChatInputCommand()) - return; - // Determine which channel to use based on environment - const targetChannelId = ENVIRONMENT === "production" ? PRODUCTION_CHANNEL_ID : DEV_CHANNEL_ID; - // Check if the command is in the correct channel - if (interaction.channelId !== targetChannelId) { - const channelName = ENVIRONMENT === "production" - ? PRODUCTION_CHANNEL_NAME - : DEV_CHANNEL_NAME; - return interaction.reply({ - content: `This command can only be used in the ${channelName} channel.`, - flags: MessageFlags.Ephemeral, - }); - } - const command = client.commands.get(interaction.commandName); - if (!command) - return; - try { - console.log("Executing command:", command.data.name); - const response = await command.execute(interaction); - } - catch (error) { - console.error(error); - await interaction.reply({ - content: "There was an error executing this command!", - flags: MessageFlags.Ephemeral, - }); - } -}); -client.login(BOT_TOKEN); diff --git a/tooling/sparta/dist/services/chaininfo-service.js b/tooling/sparta/dist/services/chaininfo-service.js deleted file mode 100644 index 82b5007..0000000 --- a/tooling/sparta/dist/services/chaininfo-service.js +++ /dev/null @@ -1,34 +0,0 @@ -import { exec } from "child_process"; -import { promisify } from "util"; -import { ETHEREUM_HOST, ETHEREUM_ROLLUP_ADDRESS, ETHEREUM_CHAIN_ID, } from "../env.js"; -const execAsync = promisify(exec); -export class ChainInfoService { - static async getInfo() { - try { - // Add validator to the set - const command = `docker run --rm aztecprotocol/aztec:unhinged-unicorn debug-rollup -u ${ETHEREUM_HOST} --rollup ${ETHEREUM_ROLLUP_ADDRESS} --l1-chain-id ${ETHEREUM_CHAIN_ID} `; - const { stdout, stderr } = await execAsync(command); - if (stderr) { - throw new Error(stderr); - } - // looks like hell, but it just parses the output of the command - // into a key-value object - const info = stdout - .split("\n") - .map((line) => line.trim()) - .filter(Boolean) - .reduce((acc, s) => { - const [key, value] = s.split(": "); - const sanitizedKey = key - .toLowerCase() - .replace(/\s+(.)/g, (_, c) => c.toUpperCase()); - return { ...acc, [sanitizedKey]: value }; - }, {}); - return info; - } - catch (error) { - console.error("Error getting chain info:", error); - throw error; - } - } -} diff --git a/tooling/sparta/dist/services/validator-service.js b/tooling/sparta/dist/services/validator-service.js deleted file mode 100644 index ebf33c2..0000000 --- a/tooling/sparta/dist/services/validator-service.js +++ /dev/null @@ -1,37 +0,0 @@ -import { exec } from "child_process"; -import { promisify } from "util"; -import { ETHEREUM_HOST, ETHEREUM_ROLLUP_ADDRESS, ETHEREUM_ADMIN_ADDRESS, ETHEREUM_CHAIN_ID, ETHEREUM_MNEMONIC, ETHEREUM_PRIVATE_KEY, ETHEREUM_VALUE, } from "../env.js"; -const execAsync = promisify(exec); -export class ValidatorService { - static async addValidator(address) { - try { - // Send ETH to the validator address - await this.fundValidator(address); - // Add validator to the set - const command = `docker run --rm aztecprotocol/aztec:unhinged-unicorn add-l1-validator -u ${ETHEREUM_HOST} --validator ${address} --rollup ${ETHEREUM_ROLLUP_ADDRESS} --withdrawer ${ETHEREUM_ADMIN_ADDRESS} --l1-chain-id ${ETHEREUM_CHAIN_ID} --mnemonic "${ETHEREUM_MNEMONIC}"`; - const { stdout, stderr } = await execAsync(command); - if (stderr) { - throw new Error(stderr); - } - return stdout; - } - catch (error) { - console.error("Error adding validator:", error); - throw error; - } - } - static async fundValidator(address) { - try { - const command = `cast send --value ${ETHEREUM_VALUE} --rpc-url ${ETHEREUM_HOST} --chain-id ${ETHEREUM_CHAIN_ID} --private-key ${ETHEREUM_PRIVATE_KEY} ${address}`; - const { stdout, stderr } = await execAsync(command); - if (stderr) { - throw new Error(stderr); - } - return stdout; - } - catch (error) { - console.error("Error funding validator:", error); - throw error; - } - } -} diff --git a/tooling/sparta/docker-compose.yml b/tooling/sparta/docker-compose.yml deleted file mode 100644 index a086b63..0000000 --- a/tooling/sparta/docker-compose.yml +++ /dev/null @@ -1,8 +0,0 @@ -name: Sparta -services: - sparta: - volumes: - - /var/run/docker.sock:/var/run/docker.sock - build: - context: . - restart: unless-stopped diff --git a/tooling/sparta/package.json b/tooling/sparta/package.json deleted file mode 100644 index edd7f08..0000000 --- a/tooling/sparta/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "sparta-bot", - "version": "1.0.0", - "type": "module", - "scripts": { - "build": "tsc", - "start": "bun run src/index.ts", - "dev": "bun run --watch src/index.ts" - }, - "dependencies": { - "discord.js": "^14.14.1", - "dotenv": "^16.3.1" - }, - "devDependencies": { - "typescript": "^5.3.3", - "@types/node": "^20.10.5", - "ts-node": "^10.9.2" - } -} diff --git a/tooling/sparta/src/.env.example b/tooling/sparta/src/.env.example new file mode 100644 index 0000000..ebfc976 --- /dev/null +++ b/tooling/sparta/src/.env.example @@ -0,0 +1,12 @@ +# Discord Bot Configuration +BOT_TOKEN=your_bot_token_here +BOT_CLIENT_ID=your_client_id_here +GUILD_ID=your_guild_id_here + +# Ethereum Configuration +ETHEREUM_HOST=http://localhost:8545 +ETHEREUM_PRIVATE_KEY=your_private_key_here +ETHEREUM_ROLLUP_ADDRESS=your_rollup_address_here +ETHEREUM_CHAIN_ID=1337 +ETHEREUM_VALUE=20ether +ETHEREUM_ADMIN_ADDRESS=your_admin_address_here diff --git a/tooling/sparta/src/.platform/hooks/prebuild/01_install_dependencies.sh b/tooling/sparta/src/.platform/hooks/prebuild/01_install_dependencies.sh new file mode 100755 index 0000000..04255ef --- /dev/null +++ b/tooling/sparta/src/.platform/hooks/prebuild/01_install_dependencies.sh @@ -0,0 +1,23 @@ +#!/bin/bash +echo "export ENVIRONMENT=${ENVIRONMENT}" >> /etc/profile.d/eb_env.sh +echo "export AWS_REGION=${AWS_REGION}" >> /etc/profile.d/eb_env.sh + +# Update system packages +sudo yum update -y +sudo yum install -y docker + +# Install and configure Docker +sudo systemctl enable docker +sudo systemctl start docker +sudo usermod -a -G docker webapp +sudo usermod -a -G docker ec2-user + +# Install Foundry +curl -L https://foundry.paradigm.xyz | bash +source /root/.bashrc +foundryup + +# Verify installations +echo "Verifying installations..." +docker --version || echo "Docker not installed" +source /etc/profile.d/foundry.sh && cast --version || echo "Foundry not installed" diff --git a/tooling/sparta/src/env.ts b/tooling/sparta/src/env.ts index 869d712..2271b3b 100644 --- a/tooling/sparta/src/env.ts +++ b/tooling/sparta/src/env.ts @@ -1,37 +1,92 @@ +import { SecretsManager } from "@aws-sdk/client-secrets-manager"; import dotenv from "dotenv"; -dotenv.config(); + +const loadSecrets = async () => { + try { + console.log("Loading secrets from AWS Secrets Manager"); + const secretsManager = new SecretsManager({ + region: process.env.AWS_REGION || "us-west-2", + }); + + const secretKeys = [ + "TOKEN", + "CLIENT_ID", + "GUILD_ID", + "ETHEREUM_HOST", + "ETHEREUM_ROLLUP_ADDRESS", + "ETHEREUM_ADMIN_ADDRESS", + "ETHEREUM_CHAIN_ID", + "ETHEREUM_PRIVATE_KEY", + "ETHEREUM_VALUE", + "BOT_TOKEN", + "BOT_CLIENT_ID", + ]; + + await Promise.all( + secretKeys.map(async (key) => { + try { + const secret = await secretsManager.getSecretValue({ + SecretId: `sparta-bot/${key}`, + }); + if (secret.SecretString) { + process.env[key] = secret.SecretString; + } + } catch (error) { + console.error(`Error loading secret ${key}:`, error); + } + }) + ); + + // Log loaded environment variables (excluding sensitive ones) + const safeKeys = [ + "GUILD_ID", + "ETHEREUM_HOST", + "ETHEREUM_ROLLUP_ADDRESS", + "ETHEREUM_ADMIN_ADDRESS", + "ETHEREUM_CHAIN_ID", + ]; + console.log("Loaded environment variables:"); + safeKeys.forEach((key) => { + console.log(`${key}: ${process.env[key]}`); + }); + } catch (error) { + console.error("Error initializing Secrets Manager:", error); + throw error; + } +}; + +if ( + process.env.ENVIRONMENT === "staging" || + process.env.ENVIRONMENT === "production" +) { + await loadSecrets(); +} else { + console.log("Loading environment from .env file"); + dotenv.config(); +} export const { TOKEN, CLIENT_ID, GUILD_ID, - PROD_CHANNEL_ID, - DEV_CHANNEL_ID, ETHEREUM_HOST, ETHEREUM_ROLLUP_ADDRESS, ETHEREUM_ADMIN_ADDRESS, ETHEREUM_CHAIN_ID, - ETHEREUM_MNEMONIC, ETHEREUM_PRIVATE_KEY, ETHEREUM_VALUE, BOT_TOKEN, BOT_CLIENT_ID, - ENVIRONMENT, } = process.env as { TOKEN: string; CLIENT_ID: string; GUILD_ID: string; - PROD_CHANNEL_ID: string; - DEV_CHANNEL_ID: string; ETHEREUM_HOST: string; ETHEREUM_ROLLUP_ADDRESS: string; ETHEREUM_ADMIN_ADDRESS: string; ETHEREUM_CHAIN_ID: string; - ETHEREUM_MNEMONIC: string; ETHEREUM_PRIVATE_KEY: string; ETHEREUM_VALUE: string; BOT_TOKEN: string; - PRODUCTION_CHANNEL_ID: string; BOT_CLIENT_ID: string; - ENVIRONMENT: string; }; diff --git a/tooling/sparta/src/index.ts b/tooling/sparta/src/index.ts index 1b66d10..8c35c9a 100644 --- a/tooling/sparta/src/index.ts +++ b/tooling/sparta/src/index.ts @@ -5,15 +5,9 @@ import { Interaction, MessageFlags, } from "discord.js"; -import { deployCommands } from "./deploy-commands.js"; +import { deployCommands } from "./utils/deploy-commands.js"; import usersCommands from "./commands/index.js"; import adminsCommands from "./admins/index.js"; -import { - BOT_TOKEN, - DEV_CHANNEL_ID, - ENVIRONMENT, - PROD_CHANNEL_ID, -} from "./env.js"; // Extend the Client class to include the commands property interface ExtendedClient extends Client { @@ -38,21 +32,12 @@ client.once("ready", () => { client.on("interactionCreate", async (interaction: Interaction) => { if (!interaction.isChatInputCommand()) return; - const { channelId } = interaction; - if ( - (ENVIRONMENT === "production" && channelId !== PROD_CHANNEL_ID) || - (ENVIRONMENT === "development" && channelId !== DEV_CHANNEL_ID) - ) { - console.log(`Ignoring interaction in channel ${channelId}`); - return; - } - const command = client.commands.get(interaction.commandName); if (!command) return; try { console.log("Executing command:", command.data.name); - const response = await command.execute(interaction); + await command.execute(interaction); } catch (error) { console.error(error); await interaction.reply({ @@ -62,4 +47,4 @@ client.on("interactionCreate", async (interaction: Interaction) => { } }); -client.login(BOT_TOKEN); +client.login(process.env.BOT_TOKEN); diff --git a/tooling/sparta/src/package.json b/tooling/sparta/src/package.json new file mode 100644 index 0000000..f09f7a4 --- /dev/null +++ b/tooling/sparta/src/package.json @@ -0,0 +1,24 @@ +{ + "name": "sparta-bot", + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "watch": "tsc -w", + "test": "jest", + "lint": "eslint . --ext .ts", + "format": "prettier --write \"src/**/*.ts\"" + }, + "dependencies": { + "@aws-sdk/client-secrets-manager": "^3.529.1", + "discord.js": "^14.14.1", + "dotenv": "^16.3.1" + }, + "devDependencies": { + "@types/node": "^20.10.5", + "ts-node": "^10.9.2", + "tsx": "^4.19.2", + "typescript": "^5.3.3" + } +} diff --git a/tooling/sparta/src/services/chaininfo-service.ts b/tooling/sparta/src/services/chaininfo-service.ts index dde1e90..71b27ae 100644 --- a/tooling/sparta/src/services/chaininfo-service.ts +++ b/tooling/sparta/src/services/chaininfo-service.ts @@ -1,10 +1,5 @@ import { exec } from "child_process"; import { promisify } from "util"; -import { - ETHEREUM_HOST, - ETHEREUM_ROLLUP_ADDRESS, - ETHEREUM_CHAIN_ID, -} from "../env.js"; type ChainInfo = { pendingBlockNum: string; @@ -23,7 +18,7 @@ export class ChainInfoService { static async getInfo(): Promise { try { // Add validator to the set - const command = `docker run --rm aztecprotocol/aztec:unhinged-unicorn debug-rollup -u ${ETHEREUM_HOST} --rollup ${ETHEREUM_ROLLUP_ADDRESS} --l1-chain-id ${ETHEREUM_CHAIN_ID} `; + const command = `docker run --rm aztecprotocol/aztec:unhinged-unicorn debug-rollup -u ${process.env.ETHEREUM_HOST} --rollup ${process.env.ETHEREUM_ROLLUP_ADDRESS} --l1-chain-id ${process.env.ETHEREUM_CHAIN_ID} `; const { stdout, stderr } = await execAsync(command); if (stderr) { diff --git a/tooling/sparta/src/services/validator-service.ts b/tooling/sparta/src/services/validator-service.ts index 985a89c..e0d32b0 100644 --- a/tooling/sparta/src/services/validator-service.ts +++ b/tooling/sparta/src/services/validator-service.ts @@ -1,14 +1,5 @@ import { exec } from "child_process"; import { promisify } from "util"; -import { - ETHEREUM_HOST, - ETHEREUM_ROLLUP_ADDRESS, - ETHEREUM_ADMIN_ADDRESS, - ETHEREUM_CHAIN_ID, - ETHEREUM_MNEMONIC, - ETHEREUM_PRIVATE_KEY, - ETHEREUM_VALUE, -} from "../env.js"; const execAsync = promisify(exec); @@ -19,7 +10,7 @@ export class ValidatorService { await this.fundValidator(address); // Add validator to the set - const command = `docker run --rm aztecprotocol/aztec:unhinged-unicorn add-l1-validator -u ${ETHEREUM_HOST} --validator ${address} --rollup ${ETHEREUM_ROLLUP_ADDRESS} --withdrawer ${ETHEREUM_ADMIN_ADDRESS} --l1-chain-id ${ETHEREUM_CHAIN_ID} --mnemonic "${ETHEREUM_MNEMONIC}"`; + const command = `docker run --rm aztecprotocol/aztec:unhinged-unicorn add-l1-validator -u ${process.env.ETHEREUM_HOST} --validator ${address} --rollup ${process.env.ETHEREUM_ROLLUP_ADDRESS} --withdrawer ${process.env.ETHEREUM_ADMIN_ADDRESS} --l1-chain-id ${process.env.ETHEREUM_CHAIN_ID} --mnemonic "${process.env.ETHEREUM_MNEMONIC}"`; const { stdout, stderr } = await execAsync(command); @@ -37,7 +28,7 @@ export class ValidatorService { static async removeValidator(address: string): Promise { try { // Add validator to the set - const command = `docker run --rm aztecprotocol/aztec:unhinged-unicorn remove-l1-validator -u ${ETHEREUM_HOST} --validator ${address} --rollup ${ETHEREUM_ROLLUP_ADDRESS} --l1-chain-id ${ETHEREUM_CHAIN_ID} --mnemonic "${ETHEREUM_MNEMONIC}"`; + const command = `docker run --rm aztecprotocol/aztec:unhinged-unicorn remove-l1-validator -u ${process.env.ETHEREUM_HOST} --validator ${address} --rollup ${process.env.ETHEREUM_ROLLUP_ADDRESS} --l1-chain-id ${process.env.ETHEREUM_CHAIN_ID} --mnemonic "${process.env.ETHEREUM_MNEMONIC}"`; const { stdout, stderr } = await execAsync(command); @@ -54,7 +45,7 @@ export class ValidatorService { static async fundValidator(address: string): Promise { try { - const command = `cast send --value ${ETHEREUM_VALUE} --rpc-url ${ETHEREUM_HOST} --chain-id ${ETHEREUM_CHAIN_ID} --private-key ${ETHEREUM_PRIVATE_KEY} ${address}`; + const command = `cast send --value ${process.env.ETHEREUM_VALUE} --rpc-url ${process.env.ETHEREUM_HOST} --chain-id ${process.env.ETHEREUM_CHAIN_ID} --private-key ${process.env.ETHEREUM_PRIVATE_KEY} ${address}`; const { stdout, stderr } = await execAsync(command); diff --git a/tooling/sparta/tsconfig.json b/tooling/sparta/src/tsconfig.json similarity index 85% rename from tooling/sparta/tsconfig.json rename to tooling/sparta/src/tsconfig.json index 8c2b766..8e9f773 100644 --- a/tooling/sparta/tsconfig.json +++ b/tooling/sparta/src/tsconfig.json @@ -4,12 +4,12 @@ "module": "NodeNext", "moduleResolution": "nodenext", "outDir": "./dist", - "rootDir": "./src", + "rootDir": "./", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true }, - "include": ["src/**/*"], + "include": ["**/*.ts"], "exclude": ["node_modules", "dist"] } diff --git a/tooling/sparta/src/deploy-commands.ts b/tooling/sparta/src/utils/deploy-commands.ts similarity index 78% rename from tooling/sparta/src/deploy-commands.ts rename to tooling/sparta/src/utils/deploy-commands.ts index ac81270..ce48799 100644 --- a/tooling/sparta/src/deploy-commands.ts +++ b/tooling/sparta/src/utils/deploy-commands.ts @@ -1,7 +1,7 @@ import { REST, Routes } from "discord.js"; -import usersCommands from "./commands/index.js"; -import adminsCommands from "./admins/index.js"; -import { BOT_TOKEN, BOT_CLIENT_ID, GUILD_ID } from "./env.js"; +import usersCommands from "../commands/index.js"; +import adminsCommands from "../admins/index.js"; +import { BOT_TOKEN, BOT_CLIENT_ID, GUILD_ID } from "../env.js"; export const deployCommands = async (): Promise => { const rest = new REST({ version: "10" }).setToken(BOT_TOKEN as string); diff --git a/tooling/sparta/terraform/main.tf b/tooling/sparta/terraform/main.tf new file mode 100644 index 0000000..d1506c6 --- /dev/null +++ b/tooling/sparta/terraform/main.tf @@ -0,0 +1,569 @@ +# ============================================================================= +# Sparta Discord Bot - Infrastructure as Code +# ============================================================================= +# This Terraform configuration sets up a production-ready infrastructure for the +# Sparta Discord bot on AWS Elastic Beanstalk. The infrastructure includes: +# - VPC with public subnet for internet access +# - Elastic Beanstalk environment running Docker +# - Auto-scaling configuration +# - Security groups and IAM roles +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Provider Configuration +# ----------------------------------------------------------------------------- +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" # Using AWS Provider version 5.x for latest features + } + } + + backend "s3" { + bucket = "sparta-terraform-state" + key = "sparta/terraform.tfstate" + region = "us-west-2" + encrypt = true + dynamodb_table = "sparta-terraform-locks" + } +} + +# Configure the AWS Provider with the specified region +provider "aws" { + region = var.aws_region +} + +# ----------------------------------------------------------------------------- +# Local Variables +# ----------------------------------------------------------------------------- +locals { + app_name = "sparta" # Application identifier used in resource naming + env = var.environment # Environment name (development/production) + timestamp = formatdate("YYYYMMDDhhmmss", timestamp()) +} + +# ----------------------------------------------------------------------------- +# Networking Configuration - VPC and Subnet +# ----------------------------------------------------------------------------- +# Virtual Private Cloud (VPC) - Isolated network for our application +resource "aws_vpc" "sparta_vpc" { + cidr_block = "10.0.0.0/16" # Provides 65,536 IP addresses + enable_dns_hostnames = true # Enables DNS hostnames for EC2 instances + enable_dns_support = true # Enables DNS resolution in the VPC + + tags = { + Name = "${local.app_name}-vpc-${local.env}" + } +} + +# Public Subnet - Where our Elastic Beanstalk instances will run +resource "aws_subnet" "public" { + vpc_id = aws_vpc.sparta_vpc.id + cidr_block = "10.0.1.0/24" # Provides 256 IP addresses + map_public_ip_on_launch = true # Automatically assign public IPs to instances + availability_zone = "${var.aws_region}a" # Use first AZ in the region + + tags = { + Name = "${local.app_name}-public-subnet-${local.env}" + } +} + +# ----------------------------------------------------------------------------- +# Internet Connectivity +# ----------------------------------------------------------------------------- +# Internet Gateway - Allows communication between VPC and internet +resource "aws_internet_gateway" "main" { + vpc_id = aws_vpc.sparta_vpc.id + + tags = { + Name = "${local.app_name}-igw-${local.env}" + } +} + +# Route Table - Defines routing rules for the public subnet +resource "aws_route_table" "public" { + vpc_id = aws_vpc.sparta_vpc.id + + route { + cidr_block = "0.0.0.0/0" # Route all external traffic + gateway_id = aws_internet_gateway.main.id # through the internet gateway + } + + tags = { + Name = "${local.app_name}-public-rt-${local.env}" + } +} + +# Associate the public subnet with the route table +resource "aws_route_table_association" "public" { + subnet_id = aws_subnet.public.id + route_table_id = aws_route_table.public.id +} + +# ----------------------------------------------------------------------------- +# Security Configuration +# ----------------------------------------------------------------------------- +# Security Group - Controls inbound/outbound traffic for EB instances +resource "aws_security_group" "eb_sg" { + name = "${local.app_name}-eb-sg-${local.env}" + description = "Security group for Sparta Discord bot" + vpc_id = aws_vpc.sparta_vpc.id + + # Allow inbound SSH traffic + ingress { + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] # Access controlled via SSH key pair + } + + # Allow all outbound traffic + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${local.app_name}-eb-sg-${local.env}" + } +} + +# ----------------------------------------------------------------------------- +# S3 Storage Configuration +# ----------------------------------------------------------------------------- +# S3 Bucket - Stores Elastic Beanstalk deployment artifacts +resource "aws_s3_bucket" "eb_bucket" { + bucket = "${local.app_name}-eb-bucket-${local.env}" +} + +# Create deployment package +resource "null_resource" "deployment_package" { + # Always rebuild + triggers = { + always_run = timestamp() + } + + provisioner "local-exec" { + command = < 0 ? "ssh -i /path/to/your/private/key ec2-user@${data.aws_instances.eb_instances.public_ips[0]}" : "No instances available yet" +} + +# ----------------------------------------------------------------------------- +# SSH Access Information +# ----------------------------------------------------------------------------- +output "ssh_connection_info" { + description = "Information about SSH access to EC2 instances" + value = <<-EOT + To SSH into your EC2 instance: + + Available instance IPs: ${jsonencode(data.aws_instances.eb_instances.public_ips)} + + Connect using: + ssh -i /path/to/your/private/key ec2-user@ + + Quick connect to first instance: + ${length(data.aws_instances.eb_instances.public_ips) > 0 ? "ssh -i /path/to/your/private/key ec2-user@${data.aws_instances.eb_instances.public_ips[0]}" : "No instances available yet"} + + Note: The Load Balancer URL (${aws_elastic_beanstalk_environment.sparta_bot_env.endpoint_url}) + cannot be used for SSH connections. You must use the EC2 instance's public IP. + + The IP may change if the instance is replaced. Always check the latest IPs in the outputs. + EOT +} + +output "application_endpoint" { + description = "Load Balancer URL for the application (NOT for SSH)" + value = aws_elastic_beanstalk_environment.sparta_bot_env.endpoint_url +} diff --git a/tooling/sparta/terraform/terraform.tfvars.example b/tooling/sparta/terraform/terraform.tfvars.example new file mode 100644 index 0000000..0c16aa8 --- /dev/null +++ b/tooling/sparta/terraform/terraform.tfvars.example @@ -0,0 +1,56 @@ +# ============================================================================= +# Example Terraform Variables +# ============================================================================= +# Copy this file to terraform.tfvars and fill in your values +# DO NOT commit terraform.tfvars to version control +# ============================================================================= + +# ----------------------------------------------------------------------------- +# AWS Configuration +# ----------------------------------------------------------------------------- +# Region where all AWS resources will be created +aws_region = "us-west-2" + +# Deployment environment - affects resource naming and may impact configuration +environment = "staging" # Must be either "staging" or "production" + +# ----------------------------------------------------------------------------- +# Discord Bot Configuration +# ----------------------------------------------------------------------------- +# Bot token from Discord Developer Portal (https://discord.com/developers/applications) +# This is a sensitive value - keep it secure +bot_token = "your_bot_token" # From Discord Developer Portal + +# Bot application ID from Discord Developer Portal +bot_client_id = "your_client_id" # From Discord Developer Portal + +# ID of the Discord server where the bot will operate +guild_id = "your_guild_id" # Your Discord server ID + +# ----------------------------------------------------------------------------- +# Ethereum Configuration +# ----------------------------------------------------------------------------- +# URL of the Ethereum node for blockchain interactions +ethereum_host = "http://your-ethereum-node:8545" + +# Private key for the Ethereum wallet (without 0x prefix) +# This is a sensitive value - keep it secure +ethereum_private_key = "0xYourPrivateKey" + +# Address of the rollup contract for L2 operations +ethereum_rollup_address = "0xYourRollupAddress" + +# Ethereum network identifier +ethereum_chain_id = "1337" # 1337 for local, 1 for mainnet + +# Default amount of ETH for transactions +ethereum_value = "20ether" # Default funding amount + +# Admin wallet address for privileged operations +ethereum_admin_address = "0xYourAdminAddress" + +# ----------------------------------------------------------------------------- +# SSH Configuration +# ----------------------------------------------------------------------------- +# Your public SSH key for accessing EC2 instances (content of your ~/.ssh/id_rsa.pub) +ssh_public_key = "ssh-rsa AAAA..." # Your public SSH key for EC2 access diff --git a/tooling/sparta/terraform/variables.tf b/tooling/sparta/terraform/variables.tf new file mode 100644 index 0000000..88bff76 --- /dev/null +++ b/tooling/sparta/terraform/variables.tf @@ -0,0 +1,88 @@ +# ============================================================================= +# Variables Configuration +# ============================================================================= +# This file defines all variables used in the Terraform configuration. +# Values for these variables should be provided in terraform.tfvars +# Sensitive values should never be committed to version control. +# ============================================================================= + +# ----------------------------------------------------------------------------- +# AWS Configuration +# ----------------------------------------------------------------------------- +variable "aws_region" { + description = "AWS region where resources will be created" + type = string + default = "us-west-2" +} + +variable "environment" { + description = "Deployment environment (staging/production)" + type = string + validation { + condition = contains(["staging", "production"], var.environment) + error_message = "Environment must be either 'staging' or 'production'" + } +} + +# ----------------------------------------------------------------------------- +# Discord Bot Configuration +# ----------------------------------------------------------------------------- +variable "bot_token" { + description = "Discord bot token from Discord Developer Portal" + type = string + sensitive = true +} + +variable "bot_client_id" { + description = "Discord application client ID from Developer Portal" + type = string + sensitive = true +} + +variable "guild_id" { + description = "Discord server (guild) ID where the bot will operate" + type = string +} + +# ----------------------------------------------------------------------------- +# Ethereum Configuration +# ----------------------------------------------------------------------------- +variable "ethereum_host" { + description = "Ethereum node URL for blockchain interactions" + type = string +} + +variable "ethereum_private_key" { + description = "Ethereum wallet private key for signing transactions" + type = string + sensitive = true +} + +variable "ethereum_rollup_address" { + description = "Ethereum rollup contract address for L2 interactions" + type = string +} + +variable "ethereum_chain_id" { + description = "Ethereum network chain ID" + type = string +} + +variable "ethereum_value" { + description = "Default ETH value for transactions" + type = string + default = "20ether" +} + +variable "ethereum_admin_address" { + description = "Ethereum admin wallet address for privileged operations" + type = string +} + +# ----------------------------------------------------------------------------- +# SSH Configuration +# ----------------------------------------------------------------------------- +variable "ssh_public_key" { + description = "Public SSH key for accessing EC2 instances" + type = string +} From cfa44885cd70bf3ba04243df8c8ef061e862de12 Mon Sep 17 00:00:00 2001 From: signorecello Date: Mon, 27 Jan 2025 19:20:04 +0000 Subject: [PATCH 12/13] fargate done! now replacing docker calls with viem WITHOUT a testnet... --- tooling/sparta/Dockerfile | 16 + tooling/sparta/src/commands/getChainInfo.ts | 1 + tooling/sparta/src/env.ts | 4 +- tooling/sparta/src/package.json | 7 +- .../sparta/src/services/chaininfo-service.ts | 67 +- tooling/sparta/src/tsconfig.json | 3 +- tooling/sparta/src/utils/ethereum.ts | 54 + tooling/sparta/src/utils/rollupAbi.json | 2702 +++++++++++++++++ tooling/sparta/terraform/main.tf | 658 +--- tooling/sparta/terraform/outputs.tf | 141 +- 10 files changed, 2997 insertions(+), 656 deletions(-) create mode 100644 tooling/sparta/Dockerfile create mode 100644 tooling/sparta/src/utils/ethereum.ts create mode 100644 tooling/sparta/src/utils/rollupAbi.json diff --git a/tooling/sparta/Dockerfile b/tooling/sparta/Dockerfile new file mode 100644 index 0000000..a255ac7 --- /dev/null +++ b/tooling/sparta/Dockerfile @@ -0,0 +1,16 @@ +FROM oven/bun:latest + +ENV PATH="/root/.foundry/bin:${PATH}" + +RUN apt update && apt install -y curl apt-utils +RUN curl -fsSL https://get.docker.com | bash +RUN curl -L https://foundry.paradigm.xyz | bash + +RUN foundryup +RUN cast --version + +WORKDIR /app +COPY src ./ + +RUN bun install +CMD ["bun", "run", "start"] diff --git a/tooling/sparta/src/commands/getChainInfo.ts b/tooling/sparta/src/commands/getChainInfo.ts index 8bc801b..6976b71 100644 --- a/tooling/sparta/src/commands/getChainInfo.ts +++ b/tooling/sparta/src/commands/getChainInfo.ts @@ -16,6 +16,7 @@ export default { }); try { + console.log("Getting chain info"); const { pendingBlockNum, provenBlockNum, diff --git a/tooling/sparta/src/env.ts b/tooling/sparta/src/env.ts index 2271b3b..a0586bc 100644 --- a/tooling/sparta/src/env.ts +++ b/tooling/sparta/src/env.ts @@ -4,9 +4,7 @@ import dotenv from "dotenv"; const loadSecrets = async () => { try { console.log("Loading secrets from AWS Secrets Manager"); - const secretsManager = new SecretsManager({ - region: process.env.AWS_REGION || "us-west-2", - }); + const secretsManager = new SecretsManager(); const secretKeys = [ "TOKEN", diff --git a/tooling/sparta/src/package.json b/tooling/sparta/src/package.json index f09f7a4..b18de09 100644 --- a/tooling/sparta/src/package.json +++ b/tooling/sparta/src/package.json @@ -3,8 +3,8 @@ "version": "1.0.0", "type": "module", "scripts": { - "build": "tsc", - "start": "node dist/index.js", + "build": "bun build index.ts --target bun --minify --outdir=dist", + "dev": "bun run --watch index.ts", "watch": "tsc -w", "test": "jest", "lint": "eslint . --ext .ts", @@ -13,7 +13,8 @@ "dependencies": { "@aws-sdk/client-secrets-manager": "^3.529.1", "discord.js": "^14.14.1", - "dotenv": "^16.3.1" + "dotenv": "^16.3.1", + "viem": "^2.22.15" }, "devDependencies": { "@types/node": "^20.10.5", diff --git a/tooling/sparta/src/services/chaininfo-service.ts b/tooling/sparta/src/services/chaininfo-service.ts index 71b27ae..312d0e7 100644 --- a/tooling/sparta/src/services/chaininfo-service.ts +++ b/tooling/sparta/src/services/chaininfo-service.ts @@ -1,5 +1,4 @@ -import { exec } from "child_process"; -import { promisify } from "util"; +import { Ethereum } from "../utils/ethereum.js"; type ChainInfo = { pendingBlockNum: string; @@ -12,39 +11,47 @@ type ChainInfo = { proposerNow: string; }; -const execAsync = promisify(exec); - export class ChainInfoService { static async getInfo(): Promise { try { - // Add validator to the set - const command = `docker run --rm aztecprotocol/aztec:unhinged-unicorn debug-rollup -u ${process.env.ETHEREUM_HOST} --rollup ${process.env.ETHEREUM_ROLLUP_ADDRESS} --l1-chain-id ${process.env.ETHEREUM_CHAIN_ID} `; - const { stdout, stderr } = await execAsync(command); + const ethereum = new Ethereum(); + const rollup = ethereum.getRollupContract(); + const [ + pendingNum, + provenNum, + validators, + committee, + archive, + epochNum, + slot, + nextBlockTS, + ] = await Promise.all([ + rollup.read.getPendingBlockNumber(), + rollup.read.getProvenBlockNumber(), + rollup.read.getAttesters(), + rollup.read.getCurrentEpochCommittee(), + rollup.read.archive(), + rollup.read.getCurrentEpoch(), + rollup.read.getCurrentSlot(), + rollup.read.getCurrentProposer(), + (async () => { + const block = await ethereum.getPublicClient().getBlock(); + return BigInt(block.timestamp + BigInt(12)); + })(), + ]); - if (stderr) { - throw new Error(stderr); - } + const proposer = await rollup.read.getProposerAt([nextBlockTS]); - // looks like hell, but it just parses the output of the command - // into a key-value object - const info = stdout - .split("\n") - .map((line) => line.trim()) - .filter(Boolean) - .reduce((acc, s) => { - let [key, value] = s.split(": "); - const sanitizedKey = key - .toLowerCase() - .replace(/\s+(.)/g, (_, c) => c.toUpperCase()); - return { ...acc, [sanitizedKey]: value }; - }, {}) as ChainInfo; - if (typeof info.validators === "string") { - info.validators = info.validators.split(", ").map(String); - } - if (typeof info.committee === "string") { - info.committee = info.committee.split(", ").map(String); - } - return info as ChainInfo; + return { + pendingBlockNum: pendingNum as string, + provenBlockNum: provenNum as string, + validators: validators as string[], + committee: committee as string[], + archive: archive as string[], + currentEpoch: epochNum as string, + currentSlot: slot as string, + proposerNow: proposer as string, + }; } catch (error) { console.error("Error getting chain info:", error); throw error; diff --git a/tooling/sparta/src/tsconfig.json b/tooling/sparta/src/tsconfig.json index 8e9f773..f1ae592 100644 --- a/tooling/sparta/src/tsconfig.json +++ b/tooling/sparta/src/tsconfig.json @@ -8,7 +8,8 @@ "strict": true, "esModuleInterop": true, "skipLibCheck": true, - "forceConsistentCasingInFileNames": true + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true }, "include": ["**/*.ts"], "exclude": ["node_modules", "dist"] diff --git a/tooling/sparta/src/utils/ethereum.ts b/tooling/sparta/src/utils/ethereum.ts new file mode 100644 index 0000000..d8fca79 --- /dev/null +++ b/tooling/sparta/src/utils/ethereum.ts @@ -0,0 +1,54 @@ +import { + AbiConstructorNotFoundError, + createPublicClient, + createWalletClient, + getContract, + http, + PublicClient, +} from "viem"; + +import RollupAbi from "./rollupAbi.json"; + +import { + generatePrivateKey, + mnemonicToAccount, + privateKeyToAccount, +} from "viem/accounts"; + +export class Ethereum { + public publicClient: PublicClient; + + constructor() { + this.publicClient = createPublicClient({ + chain: { + id: process.env.ETHEREUM_CHAIN_ID as unknown as number, + name: "Ethereum", + rpcUrls: { + default: { + http: [process.env.ETHEREUM_HOST as unknown as string], + }, + }, + nativeCurrency: { + decimals: 18, + name: "Ether", + symbol: "ETH", + }, + }, + transport: http(process.env.ETHEREUM_HOST as unknown as string), + }); + } + + getPublicClient = () => { + return this.publicClient; + }; + + getRollupContract = () => { + const rollup = getContract({ + address: process.env + .ETHEREUM_ROLLUP_ADDRESS as unknown as `0x${string}`, + abi: RollupAbi.abi, + client: this.publicClient, + }); + return rollup; + }; +} diff --git a/tooling/sparta/src/utils/rollupAbi.json b/tooling/sparta/src/utils/rollupAbi.json new file mode 100644 index 0000000..083f598 --- /dev/null +++ b/tooling/sparta/src/utils/rollupAbi.json @@ -0,0 +1,2702 @@ +{ + "abi": [ + { + "type": "constructor", + "inputs": [ + { + "name": "_fpcJuicePortal", + "type": "address", + "internalType": "contract IFeeJuicePortal" + }, + { + "name": "_rewardDistributor", + "type": "address", + "internalType": "contract IRewardDistributor" + }, + { + "name": "_stakingAsset", + "type": "address", + "internalType": "contract IERC20" + }, + { + "name": "_vkTreeRoot", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "_protocolContractTreeRoot", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "_ares", + "type": "address", + "internalType": "address" + }, + { + "name": "_config", + "type": "tuple", + "internalType": "struct Config", + "components": [ + { + "name": "aztecSlotDuration", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "aztecEpochDuration", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "targetCommitteeSize", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "aztecEpochProofClaimWindowInL2Slots", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "minimumStake", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "slashingQuorum", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "slashingRoundSize", + "type": "uint256", + "internalType": "uint256" + } + ] + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "ASSET", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IERC20" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "CLAIM_DURATION_IN_L2_SLOTS", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "CUAUHXICALLI", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "EPOCH_DURATION", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "EXIT_DELAY", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "Timestamp" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "FEE_JUICE_PORTAL", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IFeeJuicePortal" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "GENESIS_TIME", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "Timestamp" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "INBOX", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IInbox" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "IS_FOUNDRY_TEST", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "L1_BLOCK_AT_GENESIS", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "LAG", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "Slot" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "LIFETIME", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "Slot" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "MINIMUM_STAKE", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "OUTBOX", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IOutbox" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "PROOF_COMMITMENT_ESCROW", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IProofCommitmentEscrow" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "PROOF_COMMITMENT_MIN_BOND_AMOUNT_IN_TST", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "REWARD_DISTRIBUTOR", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IRewardDistributor" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "SLASHER", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract Slasher" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "SLOT_DURATION", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "STAKING_ASSET", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IERC20" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "TARGET_COMMITTEE_SIZE", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "VERSION", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "VM_ADDRESS", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "archive", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "archiveAt", + "inputs": [ + { + "name": "_blockNumber", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "canProposeAtTime", + "inputs": [ + { + "name": "_ts", + "type": "uint256", + "internalType": "Timestamp" + }, + { + "name": "_archive", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "Slot" + }, + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "canPrune", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "canPruneAtTime", + "inputs": [ + { + "name": "_ts", + "type": "uint256", + "internalType": "Timestamp" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "cheat__InitialiseValidatorSet", + "inputs": [ + { + "name": "_args", + "type": "tuple[]", + "internalType": "struct CheatDepositArgs[]", + "components": [ + { + "name": "attester", + "type": "address", + "internalType": "address" + }, + { + "name": "proposer", + "type": "address", + "internalType": "address" + }, + { + "name": "withdrawer", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ] + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "checkBlob", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "claimEpochProofRight", + "inputs": [ + { + "name": "_quote", + "type": "tuple", + "internalType": "struct SignedEpochProofQuote", + "components": [ + { + "name": "quote", + "type": "tuple", + "internalType": "struct EpochProofQuote", + "components": [ + { + "name": "epochToProve", + "type": "uint256", + "internalType": "Epoch" + }, + { + "name": "validUntilSlot", + "type": "uint256", + "internalType": "Slot" + }, + { + "name": "bondAmount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "prover", + "type": "address", + "internalType": "address" + }, + { + "name": "basisPointFee", + "type": "uint32", + "internalType": "uint32" + } + ] + }, + { + "name": "signature", + "type": "tuple", + "internalType": "struct Signature", + "components": [ + { + "name": "isEmpty", + "type": "bool", + "internalType": "bool" + }, + { + "name": "v", + "type": "uint8", + "internalType": "uint8" + }, + { + "name": "r", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "s", + "type": "bytes32", + "internalType": "bytes32" + } + ] + } + ] + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "deposit", + "inputs": [ + { + "name": "_attester", + "type": "address", + "internalType": "address" + }, + { + "name": "_proposer", + "type": "address", + "internalType": "address" + }, + { + "name": "_withdrawer", + "type": "address", + "internalType": "address" + }, + { + "name": "_amount", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "eip712Domain", + "inputs": [], + "outputs": [ + { + "name": "fields", + "type": "bytes1", + "internalType": "bytes1" + }, + { + "name": "name", + "type": "string", + "internalType": "string" + }, + { + "name": "version", + "type": "string", + "internalType": "string" + }, + { + "name": "chainId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "verifyingContract", + "type": "address", + "internalType": "address" + }, + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "extensions", + "type": "uint256[]", + "internalType": "uint256[]" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "finaliseWithdraw", + "inputs": [ + { + "name": "_attester", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "getActiveAttesterCount", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getAttesterAtIndex", + "inputs": [ + { + "name": "_index", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getAttesters", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address[]", + "internalType": "address[]" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getBlobPublicInputsHash", + "inputs": [ + { + "name": "_blockNumber", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getBlock", + "inputs": [ + { + "name": "_blockNumber", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "tuple", + "internalType": "struct BlockLog", + "components": [ + { + "name": "feeHeader", + "type": "tuple", + "internalType": "struct FeeHeader", + "components": [ + { + "name": "excessMana", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "feeAssetPriceNumerator", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "manaUsed", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "provingCostPerManaNumerator", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "congestionCost", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "archive", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "blockHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "slotNumber", + "type": "uint256", + "internalType": "Slot" + } + ] + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getClaimableEpoch", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "Epoch" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getCommitteeAt", + "inputs": [ + { + "name": "_ts", + "type": "uint256", + "internalType": "Timestamp" + } + ], + "outputs": [ + { + "name": "", + "type": "address[]", + "internalType": "address[]" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getCurrentEpoch", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "Epoch" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getCurrentEpochCommittee", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address[]", + "internalType": "address[]" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getCurrentProposer", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getCurrentSampleSeed", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getCurrentSlot", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "Slot" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getEpochAt", + "inputs": [ + { + "name": "_ts", + "type": "uint256", + "internalType": "Timestamp" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "Epoch" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getEpochAtSlot", + "inputs": [ + { + "name": "_slotNumber", + "type": "uint256", + "internalType": "Slot" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "Epoch" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getEpochCommittee", + "inputs": [ + { + "name": "_epoch", + "type": "uint256", + "internalType": "Epoch" + } + ], + "outputs": [ + { + "name": "", + "type": "address[]", + "internalType": "address[]" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getEpochForBlock", + "inputs": [ + { + "name": "_blockNumber", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "Epoch" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getEpochProofPublicInputs", + "inputs": [ + { + "name": "_epochSize", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "_args", + "type": "bytes32[7]", + "internalType": "bytes32[7]" + }, + { + "name": "_fees", + "type": "bytes32[]", + "internalType": "bytes32[]" + }, + { + "name": "_blobPublicInputs", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "_aggregationObject", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes32[]", + "internalType": "bytes32[]" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getEpochToProve", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "Epoch" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getExit", + "inputs": [ + { + "name": "_attester", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "tuple", + "internalType": "struct Exit", + "components": [ + { + "name": "exitableAt", + "type": "uint256", + "internalType": "Timestamp" + }, + { + "name": "recipient", + "type": "address", + "internalType": "address" + } + ] + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getFeeAssetPrice", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getInfo", + "inputs": [ + { + "name": "_attester", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "tuple", + "internalType": "struct ValidatorInfo", + "components": [ + { + "name": "stake", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "withdrawer", + "type": "address", + "internalType": "address" + }, + { + "name": "proposer", + "type": "address", + "internalType": "address" + }, + { + "name": "status", + "type": "uint8", + "internalType": "enum Status" + } + ] + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getL1FeesAt", + "inputs": [ + { + "name": "_timestamp", + "type": "uint256", + "internalType": "Timestamp" + } + ], + "outputs": [ + { + "name": "", + "type": "tuple", + "internalType": "struct L1FeeData", + "components": [ + { + "name": "baseFee", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "blobFee", + "type": "uint256", + "internalType": "uint256" + } + ] + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getManaBaseFeeAt", + "inputs": [ + { + "name": "_timestamp", + "type": "uint256", + "internalType": "Timestamp" + }, + { + "name": "_inFeeAsset", + "type": "bool", + "internalType": "bool" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getManaBaseFeeComponentsAt", + "inputs": [ + { + "name": "_timestamp", + "type": "uint256", + "internalType": "Timestamp" + }, + { + "name": "_inFeeAsset", + "type": "bool", + "internalType": "bool" + } + ], + "outputs": [ + { + "name": "", + "type": "tuple", + "internalType": "struct ManaBaseFeeComponents", + "components": [ + { + "name": "congestionCost", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "congestionMultiplier", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "dataCost", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "gasCost", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "provingCost", + "type": "uint256", + "internalType": "uint256" + } + ] + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getOperatorAtIndex", + "inputs": [ + { + "name": "_index", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "tuple", + "internalType": "struct OperatorInfo", + "components": [ + { + "name": "proposer", + "type": "address", + "internalType": "address" + }, + { + "name": "attester", + "type": "address", + "internalType": "address" + } + ] + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getPendingBlockNumber", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getProofClaim", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "tuple", + "internalType": "struct DataStructures.EpochProofClaim", + "components": [ + { + "name": "epochToProve", + "type": "uint256", + "internalType": "Epoch" + }, + { + "name": "basisPointFee", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "bondAmount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "bondProvider", + "type": "address", + "internalType": "address" + }, + { + "name": "proposerClaimant", + "type": "address", + "internalType": "address" + } + ] + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getProposerAt", + "inputs": [ + { + "name": "_ts", + "type": "uint256", + "internalType": "Timestamp" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getProposerAtIndex", + "inputs": [ + { + "name": "_index", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getProposerForAttester", + "inputs": [ + { + "name": "_attester", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getProvenBlockNumber", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getSampleSeedAt", + "inputs": [ + { + "name": "_ts", + "type": "uint256", + "internalType": "Timestamp" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getSlotAt", + "inputs": [ + { + "name": "_ts", + "type": "uint256", + "internalType": "Timestamp" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "Slot" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getTimestampForSlot", + "inputs": [ + { + "name": "_slotNumber", + "type": "uint256", + "internalType": "Slot" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "Timestamp" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getTips", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "tuple", + "internalType": "struct ChainTips", + "components": [ + { + "name": "pendingBlockNumber", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "provenBlockNumber", + "type": "uint256", + "internalType": "uint256" + } + ] + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "initiateWithdraw", + "inputs": [ + { + "name": "_attester", + "type": "address", + "internalType": "address" + }, + { + "name": "_recipient", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "owner", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "propose", + "inputs": [ + { + "name": "_args", + "type": "tuple", + "internalType": "struct ProposeArgs", + "components": [ + { + "name": "archive", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "blockHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "oracleInput", + "type": "tuple", + "internalType": "struct OracleInput", + "components": [ + { + "name": "provingCostModifier", + "type": "int256", + "internalType": "int256" + }, + { + "name": "feeAssetPriceModifier", + "type": "int256", + "internalType": "int256" + } + ] + }, + { + "name": "header", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "txHashes", + "type": "bytes32[]", + "internalType": "bytes32[]" + } + ] + }, + { + "name": "_signatures", + "type": "tuple[]", + "internalType": "struct Signature[]", + "components": [ + { + "name": "isEmpty", + "type": "bool", + "internalType": "bool" + }, + { + "name": "v", + "type": "uint8", + "internalType": "uint8" + }, + { + "name": "r", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "s", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "name": "", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "_blobInput", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "proposeAndClaim", + "inputs": [ + { + "name": "_args", + "type": "tuple", + "internalType": "struct ProposeArgs", + "components": [ + { + "name": "archive", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "blockHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "oracleInput", + "type": "tuple", + "internalType": "struct OracleInput", + "components": [ + { + "name": "provingCostModifier", + "type": "int256", + "internalType": "int256" + }, + { + "name": "feeAssetPriceModifier", + "type": "int256", + "internalType": "int256" + } + ] + }, + { + "name": "header", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "txHashes", + "type": "bytes32[]", + "internalType": "bytes32[]" + } + ] + }, + { + "name": "_signatures", + "type": "tuple[]", + "internalType": "struct Signature[]", + "components": [ + { + "name": "isEmpty", + "type": "bool", + "internalType": "bool" + }, + { + "name": "v", + "type": "uint8", + "internalType": "uint8" + }, + { + "name": "r", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "s", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "name": "_body", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "_blobInput", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "_quote", + "type": "tuple", + "internalType": "struct SignedEpochProofQuote", + "components": [ + { + "name": "quote", + "type": "tuple", + "internalType": "struct EpochProofQuote", + "components": [ + { + "name": "epochToProve", + "type": "uint256", + "internalType": "Epoch" + }, + { + "name": "validUntilSlot", + "type": "uint256", + "internalType": "Slot" + }, + { + "name": "bondAmount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "prover", + "type": "address", + "internalType": "address" + }, + { + "name": "basisPointFee", + "type": "uint32", + "internalType": "uint32" + } + ] + }, + { + "name": "signature", + "type": "tuple", + "internalType": "struct Signature", + "components": [ + { + "name": "isEmpty", + "type": "bool", + "internalType": "bool" + }, + { + "name": "v", + "type": "uint8", + "internalType": "uint8" + }, + { + "name": "r", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "s", + "type": "bytes32", + "internalType": "bytes32" + } + ] + } + ] + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "prune", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "quoteToDigest", + "inputs": [ + { + "name": "_quote", + "type": "tuple", + "internalType": "struct EpochProofQuote", + "components": [ + { + "name": "epochToProve", + "type": "uint256", + "internalType": "Epoch" + }, + { + "name": "validUntilSlot", + "type": "uint256", + "internalType": "Slot" + }, + { + "name": "bondAmount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "prover", + "type": "address", + "internalType": "address" + }, + { + "name": "basisPointFee", + "type": "uint32", + "internalType": "uint32" + } + ] + } + ], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "renounceOwnership", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setAssumeProvenThroughBlockNumber", + "inputs": [ + { + "name": "_blockNumber", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setEpochVerifier", + "inputs": [ + { + "name": "_verifier", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setProtocolContractTreeRoot", + "inputs": [ + { + "name": "_protocolContractTreeRoot", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setVkTreeRoot", + "inputs": [ + { + "name": "_vkTreeRoot", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setupEpoch", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "slash", + "inputs": [ + { + "name": "_attester", + "type": "address", + "internalType": "address" + }, + { + "name": "_amount", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "status", + "inputs": [ + { + "name": "_myHeaderBlockNumber", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "provenBlockNumber", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "provenArchive", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "pendingBlockNumber", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "pendingArchive", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "archiveOfMyBlock", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "provenEpochNumber", + "type": "uint256", + "internalType": "Epoch" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "submitEpochRootProof", + "inputs": [ + { + "name": "_args", + "type": "tuple", + "internalType": "struct SubmitEpochRootProofArgs", + "components": [ + { + "name": "epochSize", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "args", + "type": "bytes32[7]", + "internalType": "bytes32[7]" + }, + { + "name": "fees", + "type": "bytes32[]", + "internalType": "bytes32[]" + }, + { + "name": "blobPublicInputs", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "aggregationObject", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "proof", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "transferOwnership", + "inputs": [ + { + "name": "newOwner", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "updateL1GasFeeOracle", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "validateBlobs", + "inputs": [ + { + "name": "_blobsInput", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "validateEpochProofRightClaimAtTime", + "inputs": [ + { + "name": "_ts", + "type": "uint256", + "internalType": "Timestamp" + }, + { + "name": "_quote", + "type": "tuple", + "internalType": "struct SignedEpochProofQuote", + "components": [ + { + "name": "quote", + "type": "tuple", + "internalType": "struct EpochProofQuote", + "components": [ + { + "name": "epochToProve", + "type": "uint256", + "internalType": "Epoch" + }, + { + "name": "validUntilSlot", + "type": "uint256", + "internalType": "Slot" + }, + { + "name": "bondAmount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "prover", + "type": "address", + "internalType": "address" + }, + { + "name": "basisPointFee", + "type": "uint32", + "internalType": "uint32" + } + ] + }, + { + "name": "signature", + "type": "tuple", + "internalType": "struct Signature", + "components": [ + { + "name": "isEmpty", + "type": "bool", + "internalType": "bool" + }, + { + "name": "v", + "type": "uint8", + "internalType": "uint8" + }, + { + "name": "r", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "s", + "type": "bytes32", + "internalType": "bytes32" + } + ] + } + ] + } + ], + "outputs": [], + "stateMutability": "view" + }, + { + "type": "function", + "name": "validateHeader", + "inputs": [ + { + "name": "_header", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "_signatures", + "type": "tuple[]", + "internalType": "struct Signature[]", + "components": [ + { + "name": "isEmpty", + "type": "bool", + "internalType": "bool" + }, + { + "name": "v", + "type": "uint8", + "internalType": "uint8" + }, + { + "name": "r", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "s", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "name": "_digest", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "_currentTime", + "type": "uint256", + "internalType": "Timestamp" + }, + { + "name": "_blobsHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "_flags", + "type": "tuple", + "internalType": "struct DataStructures.ExecutionFlags", + "components": [ + { + "name": "ignoreDA", + "type": "bool", + "internalType": "bool" + }, + { + "name": "ignoreSignatures", + "type": "bool", + "internalType": "bool" + } + ] + } + ], + "outputs": [], + "stateMutability": "view" + }, + { + "type": "event", + "name": "Deposit", + "inputs": [ + { + "name": "attester", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "proposer", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "withdrawer", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "EIP712DomainChanged", + "inputs": [], + "anonymous": false + }, + { + "type": "event", + "name": "L2BlockProposed", + "inputs": [ + { + "name": "blockNumber", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + }, + { + "name": "archive", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "L2ProofVerified", + "inputs": [ + { + "name": "blockNumber", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + }, + { + "name": "proverId", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "OwnershipTransferred", + "inputs": [ + { + "name": "previousOwner", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "newOwner", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "ProofRightClaimed", + "inputs": [ + { + "name": "epoch", + "type": "uint256", + "indexed": true, + "internalType": "Epoch" + }, + { + "name": "bondProvider", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "proposer", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "bondAmount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "currentSlot", + "type": "uint256", + "indexed": false, + "internalType": "Slot" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "PrunedPending", + "inputs": [ + { + "name": "provenBlockNumber", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "pendingBlockNumber", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Slashed", + "inputs": [ + { + "name": "attester", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "WithdrawFinalised", + "inputs": [ + { + "name": "attester", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "recipient", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "WithdrawInitiated", + "inputs": [ + { + "name": "attester", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "recipient", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "HeaderLib__InvalidSlotNumber", + "inputs": [ + { + "name": "expected", + "type": "uint256", + "internalType": "Slot" + }, + { + "name": "actual", + "type": "uint256", + "internalType": "Slot" + } + ] + }, + { + "type": "error", + "name": "InvalidShortString", + "inputs": [] + }, + { + "type": "error", + "name": "Leonidas__InvalidDeposit", + "inputs": [ + { + "name": "attester", + "type": "address", + "internalType": "address" + }, + { + "name": "proposer", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "OwnableInvalidOwner", + "inputs": [ + { + "name": "owner", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "OwnableUnauthorizedAccount", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "Rollup__InvalidArchive", + "inputs": [ + { + "name": "expected", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "actual", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "Rollup__InvalidBlockNumber", + "inputs": [ + { + "name": "expected", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "actual", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "Rollup__InvalidEpoch", + "inputs": [ + { + "name": "expected", + "type": "uint256", + "internalType": "Epoch" + }, + { + "name": "actual", + "type": "uint256", + "internalType": "Epoch" + } + ] + }, + { + "type": "error", + "name": "Rollup__InvalidInHash", + "inputs": [ + { + "name": "expected", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "actual", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "Rollup__NoEpochToProve", + "inputs": [] + }, + { + "type": "error", + "name": "Rollup__NothingToPrune", + "inputs": [] + }, + { + "type": "error", + "name": "Rollup__ProofRightAlreadyClaimed", + "inputs": [] + }, + { + "type": "error", + "name": "Rollup__SlotAlreadyInChain", + "inputs": [ + { + "name": "lastSlot", + "type": "uint256", + "internalType": "Slot" + }, + { + "name": "proposedSlot", + "type": "uint256", + "internalType": "Slot" + } + ] + }, + { + "type": "error", + "name": "SafeCastOverflowedIntToUint", + "inputs": [ + { + "name": "value", + "type": "int256", + "internalType": "int256" + } + ] + }, + { + "type": "error", + "name": "Staking__AlreadyActive", + "inputs": [ + { + "name": "attester", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "Staking__AlreadyRegistered", + "inputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "Staking__CannotSlashExitedStake", + "inputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "Staking__FailedToRemove", + "inputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "Staking__InsufficientStake", + "inputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "Staking__NoOneToSlash", + "inputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "Staking__NotExiting", + "inputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "Staking__NotSlasher", + "inputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + }, + { + "name": "", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "Staking__NotWithdrawer", + "inputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + }, + { + "name": "", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "Staking__NothingToExit", + "inputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "Staking__WithdrawalNotUnlockedYet", + "inputs": [ + { + "name": "", + "type": "uint256", + "internalType": "Timestamp" + }, + { + "name": "", + "type": "uint256", + "internalType": "Timestamp" + } + ] + }, + { + "type": "error", + "name": "StringTooLong", + "inputs": [ + { + "name": "str", + "type": "string", + "internalType": "string" + } + ] + } + ] +} diff --git a/tooling/sparta/terraform/main.tf b/tooling/sparta/terraform/main.tf index d1506c6..3e1016a 100644 --- a/tooling/sparta/terraform/main.tf +++ b/tooling/sparta/terraform/main.tf @@ -1,17 +1,3 @@ -# ============================================================================= -# Sparta Discord Bot - Infrastructure as Code -# ============================================================================= -# This Terraform configuration sets up a production-ready infrastructure for the -# Sparta Discord bot on AWS Elastic Beanstalk. The infrastructure includes: -# - VPC with public subnet for internet access -# - Elastic Beanstalk environment running Docker -# - Auto-scaling configuration -# - Security groups and IAM roles -# ============================================================================= - -# ----------------------------------------------------------------------------- -# Provider Configuration -# ----------------------------------------------------------------------------- terraform { required_providers { aws = { @@ -21,549 +7,229 @@ terraform { } backend "s3" { - bucket = "sparta-terraform-state" - key = "sparta/terraform.tfstate" - region = "us-west-2" - encrypt = true - dynamodb_table = "sparta-terraform-locks" + bucket = "sparta-tf-state" + key = "sparta/terraform" + region = "eu-west-2" } } -# Configure the AWS Provider with the specified region provider "aws" { - region = var.aws_region -} - -# ----------------------------------------------------------------------------- -# Local Variables -# ----------------------------------------------------------------------------- -locals { - app_name = "sparta" # Application identifier used in resource naming - env = var.environment # Environment name (development/production) - timestamp = formatdate("YYYYMMDDhhmmss", timestamp()) -} - -# ----------------------------------------------------------------------------- -# Networking Configuration - VPC and Subnet -# ----------------------------------------------------------------------------- -# Virtual Private Cloud (VPC) - Isolated network for our application -resource "aws_vpc" "sparta_vpc" { - cidr_block = "10.0.0.0/16" # Provides 65,536 IP addresses - enable_dns_hostnames = true # Enables DNS hostnames for EC2 instances - enable_dns_support = true # Enables DNS resolution in the VPC - - tags = { - Name = "${local.app_name}-vpc-${local.env}" - } -} - -# Public Subnet - Where our Elastic Beanstalk instances will run -resource "aws_subnet" "public" { - vpc_id = aws_vpc.sparta_vpc.id - cidr_block = "10.0.1.0/24" # Provides 256 IP addresses - map_public_ip_on_launch = true # Automatically assign public IPs to instances - availability_zone = "${var.aws_region}a" # Use first AZ in the region - - tags = { - Name = "${local.app_name}-public-subnet-${local.env}" - } -} - -# ----------------------------------------------------------------------------- -# Internet Connectivity -# ----------------------------------------------------------------------------- -# Internet Gateway - Allows communication between VPC and internet -resource "aws_internet_gateway" "main" { - vpc_id = aws_vpc.sparta_vpc.id - - tags = { - Name = "${local.app_name}-igw-${local.env}" - } -} - -# Route Table - Defines routing rules for the public subnet -resource "aws_route_table" "public" { - vpc_id = aws_vpc.sparta_vpc.id - - route { - cidr_block = "0.0.0.0/0" # Route all external traffic - gateway_id = aws_internet_gateway.main.id # through the internet gateway - } - - tags = { - Name = "${local.app_name}-public-rt-${local.env}" - } -} - -# Associate the public subnet with the route table -resource "aws_route_table_association" "public" { - subnet_id = aws_subnet.public.id - route_table_id = aws_route_table.public.id -} - -# ----------------------------------------------------------------------------- -# Security Configuration -# ----------------------------------------------------------------------------- -# Security Group - Controls inbound/outbound traffic for EB instances -resource "aws_security_group" "eb_sg" { - name = "${local.app_name}-eb-sg-${local.env}" - description = "Security group for Sparta Discord bot" - vpc_id = aws_vpc.sparta_vpc.id - - # Allow inbound SSH traffic - ingress { - from_port = 22 - to_port = 22 - protocol = "tcp" - cidr_blocks = ["0.0.0.0/0"] # Access controlled via SSH key pair - } - - # Allow all outbound traffic - egress { - from_port = 0 - to_port = 0 - protocol = "-1" - cidr_blocks = ["0.0.0.0/0"] - } - - tags = { - Name = "${local.app_name}-eb-sg-${local.env}" - } + profile = "default" + region = "eu-west-2" } +resource "aws_iam_role" "ecs_task_execution_role" { + name = "ecs_task_execution_role" -# ----------------------------------------------------------------------------- -# S3 Storage Configuration -# ----------------------------------------------------------------------------- -# S3 Bucket - Stores Elastic Beanstalk deployment artifacts -resource "aws_s3_bucket" "eb_bucket" { - bucket = "${local.app_name}-eb-bucket-${local.env}" -} - -# Create deployment package -resource "null_resource" "deployment_package" { - # Always rebuild - triggers = { - always_run = timestamp() - } - - provisioner "local-exec" { - command = < 0 ? "ssh -i /path/to/your/private/key ec2-user@${data.aws_instances.eb_instances.public_ips[0]}" : "No instances available yet" -} - -# ----------------------------------------------------------------------------- -# SSH Access Information -# ----------------------------------------------------------------------------- -output "ssh_connection_info" { - description = "Information about SSH access to EC2 instances" - value = <<-EOT - To SSH into your EC2 instance: - - Available instance IPs: ${jsonencode(data.aws_instances.eb_instances.public_ips)} - - Connect using: - ssh -i /path/to/your/private/key ec2-user@ - - Quick connect to first instance: - ${length(data.aws_instances.eb_instances.public_ips) > 0 ? "ssh -i /path/to/your/private/key ec2-user@${data.aws_instances.eb_instances.public_ips[0]}" : "No instances available yet"} - - Note: The Load Balancer URL (${aws_elastic_beanstalk_environment.sparta_bot_env.endpoint_url}) - cannot be used for SSH connections. You must use the EC2 instance's public IP. - - The IP may change if the instance is replaced. Always check the latest IPs in the outputs. - EOT -} - -output "application_endpoint" { - description = "Load Balancer URL for the application (NOT for SSH)" - value = aws_elastic_beanstalk_environment.sparta_bot_env.endpoint_url +output "cloudwatch_log_group" { + description = "Name of the CloudWatch log group" + value = aws_cloudwatch_log_group.sparta_discord_bot_logs.name } From b5e8b12776e5de6492f33cbcfce362bf8678a20b Mon Sep 17 00:00:00 2001 From: signorecello Date: Tue, 28 Jan 2025 16:42:55 +0000 Subject: [PATCH 13/13] done with fargate --- tooling/sparta/.dockerignore | 3 + tooling/sparta/README.md | 76 ++- tooling/sparta/src/.env.example | 2 + .../hooks/prebuild/01_install_dependencies.sh | 23 - .../{validators.ts => editValidators.ts} | 36 +- tooling/sparta/src/admins/index.ts | 2 +- tooling/sparta/src/discord/index.ts | 80 ++++ tooling/sparta/src/env.ts | 90 ---- tooling/sparta/src/index.ts | 56 +-- tooling/sparta/src/package.json | 1 + .../sparta/src/services/chaininfo-service.ts | 5 +- .../sparta/src/services/validator-service.ts | 32 +- tooling/sparta/src/utils/deploy-commands.ts | 10 +- tooling/sparta/src/utils/ethereum.ts | 151 ++++-- tooling/sparta/src/utils/pagination.ts | 6 + tooling/sparta/src/utils/testERC20Abi.json | 451 ++++++++++++++++++ tooling/sparta/terraform/main.tf | 127 +++-- tooling/sparta/terraform/outputs.tf | 25 + .../sparta/terraform/terraform.tfvars.example | 2 + tooling/sparta/terraform/variables.tf | 7 +- 20 files changed, 896 insertions(+), 289 deletions(-) create mode 100644 tooling/sparta/.dockerignore delete mode 100755 tooling/sparta/src/.platform/hooks/prebuild/01_install_dependencies.sh rename tooling/sparta/src/admins/{validators.ts => editValidators.ts} (88%) create mode 100644 tooling/sparta/src/discord/index.ts delete mode 100644 tooling/sparta/src/env.ts create mode 100644 tooling/sparta/src/utils/testERC20Abi.json diff --git a/tooling/sparta/.dockerignore b/tooling/sparta/.dockerignore new file mode 100644 index 0000000..64d19ff --- /dev/null +++ b/tooling/sparta/.dockerignore @@ -0,0 +1,3 @@ +.env +.env* +dist diff --git a/tooling/sparta/README.md b/tooling/sparta/README.md index 31f6a1b..e7b9cab 100644 --- a/tooling/sparta/README.md +++ b/tooling/sparta/README.md @@ -2,12 +2,21 @@ A Discord bot for managing Aztec validators, built with Node.js and deployed on AWS Elastic Beanstalk. +## Overview + +Sparta is a Discord bot designed to manage and monitor Aztec validators. It provides commands for: +- Validator management (add, remove, list) +- Chain information retrieval +- Committee management +- Stake management + ## Prerequisites - Node.js v18 or higher - AWS CLI configured with appropriate credentials - Terraform v1.0 or higher - Discord Bot Token and Application ID from [Discord Developer Portal](https://discord.com/developers/applications) +- Ethereum node access (local or remote) ## Security Notice @@ -21,6 +30,22 @@ Always use: - `.env` files for local development (never commit these) - AWS Secrets Manager for production secrets - `terraform.tfvars` for Terraform variables (never commit this) +- Ensure `.gitignore` includes all sensitive files +- Use environment-specific configuration files + +## Project Structure + +``` +sparta/ +├── src/ # Source code +│ ├── commands/ # Discord bot commands +│ ├── discord/ # Discord bot setup +│ ├── services/ # Business logic services +│ ├── utils/ # Utility functions +│ └── admins/ # Admin-only commands +├── terraform/ # Infrastructure as Code +└── docker/ # Docker configuration +``` ## Local Development @@ -64,7 +89,7 @@ npm run watch ## Deployment -The bot is deployed using Terraform to AWS Elastic Beanstalk. Follow these steps: +The bot is deployed using Terraform to AWS Elastic Container Service (ECS). Follow these steps: 1. Navigate to the terraform directory: ```bash @@ -100,27 +125,57 @@ terraform apply ## Architecture - **Discord.js**: Handles bot interactions and commands -- **AWS Elastic Beanstalk**: Hosts the bot in a scalable environment +- **AWS ECS**: Runs the bot in containers for high availability - **AWS Secrets Manager**: Securely stores sensitive configuration - **TypeScript**: Provides type safety and better development experience +- **Terraform**: Manages infrastructure as code +- **Docker**: Containerizes the application ## Environment Variables ### Development - Uses `.env` file for local configuration - Supports hot reloading through `npm run watch` +- Environment-specific configurations (.env.local, .env.staging) ### Production - Uses AWS Secrets Manager for secure configuration - Automatically loads secrets in production environment - Supports staging and production environments -## Commands +## Available Commands +### User Commands - `/get-info`: Get chain information +- `/validator info`: Get validator information + +### Admin Commands - `/admin validators get`: List validators - `/admin validators remove`: Remove a validator - `/admin committee get`: Get committee information +- `/admin stake manage`: Manage validator stakes + +## Security Best Practices + +1. **Environment Variables** + - Never commit .env files + - Use different env files for different environments + - Rotate secrets regularly + +2. **AWS Security** + - Use IAM roles with least privilege + - Enable CloudWatch logging + - Use security groups to restrict access + +3. **Discord Security** + - Implement command permissions + - Use ephemeral messages for sensitive info + - Validate user inputs + +4. **Ethereum Security** + - Never expose private keys + - Use secure RPC endpoints + - Implement transaction signing safeguards ## Contributing @@ -128,14 +183,13 @@ terraform apply 2. Make your changes 3. Submit a pull request -## Security +## Monitoring and Logging -- All sensitive information is stored in AWS Secrets Manager -- IAM roles are configured with least privilege -- Environment variables are never committed to version control -- SSH access is controlled via key pairs -- No sensitive information in logs or error messages +- AWS CloudWatch for container logs +- Discord command execution logging +- Error tracking and reporting +- Performance monitoring -## License +## Support -[Your License] +For support, please open an issue in the repository or contact the maintainers. diff --git a/tooling/sparta/src/.env.example b/tooling/sparta/src/.env.example index ebfc976..06f4798 100644 --- a/tooling/sparta/src/.env.example +++ b/tooling/sparta/src/.env.example @@ -10,3 +10,5 @@ ETHEREUM_ROLLUP_ADDRESS=your_rollup_address_here ETHEREUM_CHAIN_ID=1337 ETHEREUM_VALUE=20ether ETHEREUM_ADMIN_ADDRESS=your_admin_address_here + +MINIMUM_STAKE=100000000000000000000 diff --git a/tooling/sparta/src/.platform/hooks/prebuild/01_install_dependencies.sh b/tooling/sparta/src/.platform/hooks/prebuild/01_install_dependencies.sh deleted file mode 100755 index 04255ef..0000000 --- a/tooling/sparta/src/.platform/hooks/prebuild/01_install_dependencies.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/bash -echo "export ENVIRONMENT=${ENVIRONMENT}" >> /etc/profile.d/eb_env.sh -echo "export AWS_REGION=${AWS_REGION}" >> /etc/profile.d/eb_env.sh - -# Update system packages -sudo yum update -y -sudo yum install -y docker - -# Install and configure Docker -sudo systemctl enable docker -sudo systemctl start docker -sudo usermod -a -G docker webapp -sudo usermod -a -G docker ec2-user - -# Install Foundry -curl -L https://foundry.paradigm.xyz | bash -source /root/.bashrc -foundryup - -# Verify installations -echo "Verifying installations..." -docker --version || echo "Docker not installed" -source /etc/profile.d/foundry.sh && cast --version || echo "Foundry not installed" diff --git a/tooling/sparta/src/admins/validators.ts b/tooling/sparta/src/admins/editValidators.ts similarity index 88% rename from tooling/sparta/src/admins/validators.ts rename to tooling/sparta/src/admins/editValidators.ts index 2642b24..462daa1 100644 --- a/tooling/sparta/src/admins/validators.ts +++ b/tooling/sparta/src/admins/editValidators.ts @@ -66,31 +66,28 @@ export default { .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) .addSubcommandGroup((group) => group - .setName("validators") - .setDescription("Manage validators") + .setName("get") + .setDescription("Get info about validators") .addSubcommand((subcommand) => - subcommand.setName("get").setDescription("Get validators") + subcommand + .setName("validators") + .setDescription("Get validators") ) .addSubcommand((subcommand) => subcommand - .setName("remove") - .setDescription("Remove a validator") - .addStringOption((option) => - option - .setName("address") - .setDescription("The validator to remove") - .setRequired(true) - ) + .setName("committee") + .setDescription("Get committee") ) ) - .addSubcommandGroup((group) => - group - .setName("committee") - .setDescription("Manage the committee") - .addSubcommand((subcommand) => - subcommand - .setName("get") - .setDescription("Get the current committee") + .addSubcommand((subcommand) => + subcommand + .setName("remove") + .setDescription("Remove a validator") + .addStringOption((option) => + option + .setName("address") + .setDescription("The validator to remove") + .setRequired(true) ) ), @@ -109,7 +106,6 @@ export default { const filteredCommittee = (committee as string[]).filter( (v) => !EXCLUDED_VALIDATORS.includes(v) ); - if (interaction.options.getSubcommand() === "committee") { await paginate( filteredCommittee, diff --git a/tooling/sparta/src/admins/index.ts b/tooling/sparta/src/admins/index.ts index b20f6c8..29113e7 100644 --- a/tooling/sparta/src/admins/index.ts +++ b/tooling/sparta/src/admins/index.ts @@ -1,3 +1,3 @@ -import validators from "./validators.js"; +import validators from "./editValidators.js"; export default { validators }; diff --git a/tooling/sparta/src/discord/index.ts b/tooling/sparta/src/discord/index.ts new file mode 100644 index 0000000..3ee2324 --- /dev/null +++ b/tooling/sparta/src/discord/index.ts @@ -0,0 +1,80 @@ +/** + * @fileoverview Discord bot service implementation + * @description Handles Discord bot initialization, command registration, and event handling + * @module sparta/discord + */ + +import { + Client, + GatewayIntentBits, + Collection, + Interaction, + MessageFlags, +} from "discord.js"; +import { deployCommands } from "../utils/deploy-commands.js"; +import usersCommands from "../commands/index.js"; +import adminsCommands from "../admins/index.js"; + +/** + * Extended Discord client interface with commands collection + * @interface ExtendedClient + * @extends {Client} + */ +interface ExtendedClient extends Client { + commands: Collection; +} + +// Initialize Discord client with required intents +const client = new Client({ + intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages], +}) as ExtendedClient; + +// Initialize commands collection +client.commands = new Collection(); + +// Register all commands from both users and admins +for (const command of Object.values({ ...usersCommands, ...adminsCommands })) { + client.commands.set(command.data.name, command); +} + +console.log("Starting discord service..."); + +/** + * Error event handler + */ +client.once("error", (error) => { + console.error("Error:", error); +}); + +/** + * Ready event handler - called when bot is initialized + */ +client.once("ready", async () => { + console.log("Sparta bot is ready!"); + console.log("Bot Client ID: ", process.env.BOT_CLIENT_ID); + deployCommands(); +}); + +/** + * Interaction event handler - processes all slash commands + * @param {Interaction} interaction - The interaction object from Discord + */ +client.on("interactionCreate", async (interaction: Interaction) => { + if (!interaction.isChatInputCommand()) return; + + const command = client.commands.get(interaction.commandName); + if (!command) return; + + try { + console.log("Executing command:", command.data.name); + await command.execute(interaction); + } catch (error) { + console.error(error); + await interaction.reply({ + content: "There was an error executing this command!", + flags: MessageFlags.Ephemeral, + }); + } +}); + +client.login(process.env.BOT_TOKEN); diff --git a/tooling/sparta/src/env.ts b/tooling/sparta/src/env.ts deleted file mode 100644 index a0586bc..0000000 --- a/tooling/sparta/src/env.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { SecretsManager } from "@aws-sdk/client-secrets-manager"; -import dotenv from "dotenv"; - -const loadSecrets = async () => { - try { - console.log("Loading secrets from AWS Secrets Manager"); - const secretsManager = new SecretsManager(); - - const secretKeys = [ - "TOKEN", - "CLIENT_ID", - "GUILD_ID", - "ETHEREUM_HOST", - "ETHEREUM_ROLLUP_ADDRESS", - "ETHEREUM_ADMIN_ADDRESS", - "ETHEREUM_CHAIN_ID", - "ETHEREUM_PRIVATE_KEY", - "ETHEREUM_VALUE", - "BOT_TOKEN", - "BOT_CLIENT_ID", - ]; - - await Promise.all( - secretKeys.map(async (key) => { - try { - const secret = await secretsManager.getSecretValue({ - SecretId: `sparta-bot/${key}`, - }); - if (secret.SecretString) { - process.env[key] = secret.SecretString; - } - } catch (error) { - console.error(`Error loading secret ${key}:`, error); - } - }) - ); - - // Log loaded environment variables (excluding sensitive ones) - const safeKeys = [ - "GUILD_ID", - "ETHEREUM_HOST", - "ETHEREUM_ROLLUP_ADDRESS", - "ETHEREUM_ADMIN_ADDRESS", - "ETHEREUM_CHAIN_ID", - ]; - console.log("Loaded environment variables:"); - safeKeys.forEach((key) => { - console.log(`${key}: ${process.env[key]}`); - }); - } catch (error) { - console.error("Error initializing Secrets Manager:", error); - throw error; - } -}; - -if ( - process.env.ENVIRONMENT === "staging" || - process.env.ENVIRONMENT === "production" -) { - await loadSecrets(); -} else { - console.log("Loading environment from .env file"); - dotenv.config(); -} - -export const { - TOKEN, - CLIENT_ID, - GUILD_ID, - ETHEREUM_HOST, - ETHEREUM_ROLLUP_ADDRESS, - ETHEREUM_ADMIN_ADDRESS, - ETHEREUM_CHAIN_ID, - ETHEREUM_PRIVATE_KEY, - ETHEREUM_VALUE, - BOT_TOKEN, - BOT_CLIENT_ID, -} = process.env as { - TOKEN: string; - CLIENT_ID: string; - GUILD_ID: string; - ETHEREUM_HOST: string; - ETHEREUM_ROLLUP_ADDRESS: string; - ETHEREUM_ADMIN_ADDRESS: string; - ETHEREUM_CHAIN_ID: string; - ETHEREUM_PRIVATE_KEY: string; - ETHEREUM_VALUE: string; - BOT_TOKEN: string; - BOT_CLIENT_ID: string; -}; diff --git a/tooling/sparta/src/index.ts b/tooling/sparta/src/index.ts index 8c35c9a..48f72ba 100644 --- a/tooling/sparta/src/index.ts +++ b/tooling/sparta/src/index.ts @@ -1,50 +1,12 @@ -import { - Client, - GatewayIntentBits, - Collection, - Interaction, - MessageFlags, -} from "discord.js"; -import { deployCommands } from "./utils/deploy-commands.js"; -import usersCommands from "./commands/index.js"; -import adminsCommands from "./admins/index.js"; +/** + * @fileoverview Main entry point for the Sparta Discord bot + * @description Initializes the Ethereum client and Discord bot services + * @module sparta/index + */ -// Extend the Client class to include the commands property -interface ExtendedClient extends Client { - commands: Collection; -} +import { Ethereum } from "./utils/ethereum.js"; -const client = new Client({ - intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages], -}) as ExtendedClient; +// Initialize Ethereum client as a singleton +export const ethereum = await Ethereum.new(); -client.commands = new Collection(); - -for (const command of Object.values({ ...usersCommands, ...adminsCommands })) { - client.commands.set(command.data.name, command); -} - -client.once("ready", () => { - console.log("Sparta bot is ready!"); - deployCommands(); -}); - -client.on("interactionCreate", async (interaction: Interaction) => { - if (!interaction.isChatInputCommand()) return; - - const command = client.commands.get(interaction.commandName); - if (!command) return; - - try { - console.log("Executing command:", command.data.name); - await command.execute(interaction); - } catch (error) { - console.error(error); - await interaction.reply({ - content: "There was an error executing this command!", - flags: MessageFlags.Ephemeral, - }); - } -}); - -client.login(process.env.BOT_TOKEN); +import "./discord/index.js"; diff --git a/tooling/sparta/src/package.json b/tooling/sparta/src/package.json index b18de09..2bf0fbb 100644 --- a/tooling/sparta/src/package.json +++ b/tooling/sparta/src/package.json @@ -5,6 +5,7 @@ "scripts": { "build": "bun build index.ts --target bun --minify --outdir=dist", "dev": "bun run --watch index.ts", + "start": "bun run index.ts", "watch": "tsc -w", "test": "jest", "lint": "eslint . --ext .ts", diff --git a/tooling/sparta/src/services/chaininfo-service.ts b/tooling/sparta/src/services/chaininfo-service.ts index 312d0e7..e53f7a7 100644 --- a/tooling/sparta/src/services/chaininfo-service.ts +++ b/tooling/sparta/src/services/chaininfo-service.ts @@ -1,4 +1,4 @@ -import { Ethereum } from "../utils/ethereum.js"; +import { ethereum } from "../index.js"; type ChainInfo = { pendingBlockNum: string; @@ -14,8 +14,7 @@ type ChainInfo = { export class ChainInfoService { static async getInfo(): Promise { try { - const ethereum = new Ethereum(); - const rollup = ethereum.getRollupContract(); + const rollup = ethereum.getRollup(); const [ pendingNum, provenNum, diff --git a/tooling/sparta/src/services/validator-service.ts b/tooling/sparta/src/services/validator-service.ts index e0d32b0..33e39f4 100644 --- a/tooling/sparta/src/services/validator-service.ts +++ b/tooling/sparta/src/services/validator-service.ts @@ -1,42 +1,24 @@ import { exec } from "child_process"; import { promisify } from "util"; +import { ethereum } from "../index.js"; +import { Transaction, TransactionReceipt } from "viem"; const execAsync = promisify(exec); export class ValidatorService { - static async addValidator(address: string): Promise { + static async addValidator(address: string): Promise { try { - // Send ETH to the validator address - await this.fundValidator(address); - - // Add validator to the set - const command = `docker run --rm aztecprotocol/aztec:unhinged-unicorn add-l1-validator -u ${process.env.ETHEREUM_HOST} --validator ${address} --rollup ${process.env.ETHEREUM_ROLLUP_ADDRESS} --withdrawer ${process.env.ETHEREUM_ADMIN_ADDRESS} --l1-chain-id ${process.env.ETHEREUM_CHAIN_ID} --mnemonic "${process.env.ETHEREUM_MNEMONIC}"`; - - const { stdout, stderr } = await execAsync(command); - - if (stderr) { - throw new Error(stderr); - } - - return stdout; + const receipts = await ethereum.addValidator(address); + return receipts; } catch (error) { console.error("Error adding validator:", error); throw error; } } - static async removeValidator(address: string): Promise { + static async removeValidator(address: string): Promise { try { - // Add validator to the set - const command = `docker run --rm aztecprotocol/aztec:unhinged-unicorn remove-l1-validator -u ${process.env.ETHEREUM_HOST} --validator ${address} --rollup ${process.env.ETHEREUM_ROLLUP_ADDRESS} --l1-chain-id ${process.env.ETHEREUM_CHAIN_ID} --mnemonic "${process.env.ETHEREUM_MNEMONIC}"`; - - const { stdout, stderr } = await execAsync(command); - - if (stderr) { - throw new Error(stderr); - } - - return stdout; + return await ethereum.removeValidator(address); } catch (error) { console.error("Error removing validator:", error); throw error; diff --git a/tooling/sparta/src/utils/deploy-commands.ts b/tooling/sparta/src/utils/deploy-commands.ts index ce48799..223310f 100644 --- a/tooling/sparta/src/utils/deploy-commands.ts +++ b/tooling/sparta/src/utils/deploy-commands.ts @@ -1,10 +1,11 @@ import { REST, Routes } from "discord.js"; import usersCommands from "../commands/index.js"; import adminsCommands from "../admins/index.js"; -import { BOT_TOKEN, BOT_CLIENT_ID, GUILD_ID } from "../env.js"; export const deployCommands = async (): Promise => { - const rest = new REST({ version: "10" }).setToken(BOT_TOKEN as string); + const rest = new REST({ version: "10" }).setToken( + process.env.BOT_TOKEN as string + ); try { console.log("Started refreshing application (/) commands."); @@ -15,7 +16,10 @@ export const deployCommands = async (): Promise => { }).map((command) => command.data.toJSON()); await rest.put( - Routes.applicationGuildCommands(BOT_CLIENT_ID, GUILD_ID), + Routes.applicationGuildCommands( + process.env.BOT_CLIENT_ID as string, + process.env.GUILD_ID as string + ), { body: commandsData, } diff --git a/tooling/sparta/src/utils/ethereum.ts b/tooling/sparta/src/utils/ethereum.ts index d8fca79..648546d 100644 --- a/tooling/sparta/src/utils/ethereum.ts +++ b/tooling/sparta/src/utils/ethereum.ts @@ -1,54 +1,133 @@ +/** + * @fileoverview Ethereum client and utilities + * @description Provides Ethereum client configuration and interaction methods + * @module sparta/utils/ethereum + */ + import { - AbiConstructorNotFoundError, createPublicClient, createWalletClient, getContract, http, - PublicClient, + TransactionReceipt, + WalletClient, } from "viem"; -import RollupAbi from "./rollupAbi.json"; +import RollupAbi from "./rollupAbi.json" assert { type: "json" }; +import TestERC20Abi from "./testERC20Abi.json" assert { type: "json" }; -import { - generatePrivateKey, - mnemonicToAccount, - privateKeyToAccount, -} from "viem/accounts"; +import { privateKeyToAccount } from "viem/accounts"; + +/** + * Ethereum chain configuration + * @const {Object} ethereumChain + */ +const ethereumChain = { + id: 31337, // Hardhat's default chain ID + name: "Local Hardhat", + network: "hardhat", + nativeCurrency: { + decimals: 18, + name: "Ethereum", + symbol: "ETH", + }, + rpcUrls: { + default: { + http: [process.env.ETHEREUM_HOST as string], + }, + public: { + http: [process.env.ETHEREUM_HOST as string], + }, + }, +} as const; export class Ethereum { - public publicClient: PublicClient; - - constructor() { - this.publicClient = createPublicClient({ - chain: { - id: process.env.ETHEREUM_CHAIN_ID as unknown as number, - name: "Ethereum", - rpcUrls: { - default: { - http: [process.env.ETHEREUM_HOST as unknown as string], - }, - }, - nativeCurrency: { - decimals: 18, - name: "Ether", - symbol: "ETH", - }, - }, - transport: http(process.env.ETHEREUM_HOST as unknown as string), + constructor( + private publicClient: ReturnType, + private walletClient: ReturnType, + private rollup: any, + private stakingAsset: any + ) {} + + static new = async () => { + const rpcUrl = process.env.ETHEREUM_HOST as string; + const privateKey = process.env.ETHEREUM_PRIVATE_KEY as `0x${string}`; + const rollupAddress = process.env + .ETHEREUM_ROLLUP_ADDRESS as `0x${string}`; + + const publicClient = createPublicClient({ + chain: ethereumChain, + transport: http(rpcUrl), }); - } - getPublicClient = () => { - return this.publicClient; - }; + const walletClient = createWalletClient({ + account: privateKeyToAccount(privateKey), + chain: ethereumChain, + transport: http(rpcUrl), + }); - getRollupContract = () => { const rollup = getContract({ - address: process.env - .ETHEREUM_ROLLUP_ADDRESS as unknown as `0x${string}`, + address: rollupAddress, abi: RollupAbi.abi, - client: this.publicClient, + client: walletClient, + }); + + const stakingAsset = getContract({ + address: (await rollup.read.STAKING_ASSET()) as `0x${string}`, + abi: TestERC20Abi.abi, + client: walletClient, }); - return rollup; + + return new Ethereum(publicClient, walletClient, rollup, stakingAsset); + }; + + getPublicClient = () => { + return this.publicClient; + }; + + getWalletClient = () => { + return this.walletClient; + }; + + getRollup = () => { + return this.rollup; + }; + + addValidator = async (address: string): Promise => { + const hashes = await Promise.all( + [ + await this.stakingAsset.write.mint([ + this.walletClient.account?.address.toString(), + process.env.MINIMUM_STAKE as unknown as string, + ]), + await this.stakingAsset.write.approve([ + this.rollup.address, + process.env.MINIMUM_STAKE as unknown as string, + ]), + await this.rollup.write.deposit([ + address, + address, + privateKeyToAccount( + process.env.ETHEREUM_PRIVATE_KEY as `0x${string}` + ).address, + process.env.MINIMUM_STAKE as unknown as string, + ]), + ].map((txHash) => + this.publicClient.waitForTransactionReceipt({ + hash: txHash, + }) + ) + ); + + return hashes; + }; + + removeValidator = async (address: string): Promise => { + const txHash = await this.rollup.write.initiateWithdraw([ + address, + address, + ]); + + return txHash; }; } diff --git a/tooling/sparta/src/utils/pagination.ts b/tooling/sparta/src/utils/pagination.ts index 166030e..d7ccd5b 100644 --- a/tooling/sparta/src/utils/pagination.ts +++ b/tooling/sparta/src/utils/pagination.ts @@ -10,6 +10,12 @@ export const paginate = async ( message: string ) => { const numMessages = Math.ceil(array.length / perMessage); + if (!numMessages) { + await interaction.followUp({ + content: "No validators present", + flags: MessageFlags.Ephemeral, + }); + } for (let i = 0; i < numMessages; i++) { const start = i * perMessage; diff --git a/tooling/sparta/src/utils/testERC20Abi.json b/tooling/sparta/src/utils/testERC20Abi.json new file mode 100644 index 0000000..4a43923 --- /dev/null +++ b/tooling/sparta/src/utils/testERC20Abi.json @@ -0,0 +1,451 @@ +{ + "abi": [ + { + "type": "constructor", + "inputs": [ + { + "name": "_name", + "type": "string", + "internalType": "string" + }, + { + "name": "_symbol", + "type": "string", + "internalType": "string" + }, + { + "name": "_owner", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "allowance", + "inputs": [ + { + "name": "owner", + "type": "address", + "internalType": "address" + }, + { + "name": "spender", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "approve", + "inputs": [ + { + "name": "spender", + "type": "address", + "internalType": "address" + }, + { + "name": "value", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "balanceOf", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "decimals", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint8", + "internalType": "uint8" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "freeForAll", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "mint", + "inputs": [ + { + "name": "_to", + "type": "address", + "internalType": "address" + }, + { + "name": "_amount", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "name", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "owner", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "renounceOwnership", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setFreeForAll", + "inputs": [ + { + "name": "_freeForAll", + "type": "bool", + "internalType": "bool" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "symbol", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "totalSupply", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "transfer", + "inputs": [ + { + "name": "to", + "type": "address", + "internalType": "address" + }, + { + "name": "value", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "transferFrom", + "inputs": [ + { + "name": "from", + "type": "address", + "internalType": "address" + }, + { + "name": "to", + "type": "address", + "internalType": "address" + }, + { + "name": "value", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "transferOwnership", + "inputs": [ + { + "name": "newOwner", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "event", + "name": "Approval", + "inputs": [ + { + "name": "owner", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "spender", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "value", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "OwnershipTransferred", + "inputs": [ + { + "name": "previousOwner", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "newOwner", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Transfer", + "inputs": [ + { + "name": "from", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "to", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "value", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "ERC20InsufficientAllowance", + "inputs": [ + { + "name": "spender", + "type": "address", + "internalType": "address" + }, + { + "name": "allowance", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "needed", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "ERC20InsufficientBalance", + "inputs": [ + { + "name": "sender", + "type": "address", + "internalType": "address" + }, + { + "name": "balance", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "needed", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "ERC20InvalidApprover", + "inputs": [ + { + "name": "approver", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "ERC20InvalidReceiver", + "inputs": [ + { + "name": "receiver", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "ERC20InvalidSender", + "inputs": [ + { + "name": "sender", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "ERC20InvalidSpender", + "inputs": [ + { + "name": "spender", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "OwnableInvalidOwner", + "inputs": [ + { + "name": "owner", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "OwnableUnauthorizedAccount", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + } + ] + } + ] +} diff --git a/tooling/sparta/terraform/main.tf b/tooling/sparta/terraform/main.tf index 3e1016a..e9bcaa1 100644 --- a/tooling/sparta/terraform/main.tf +++ b/tooling/sparta/terraform/main.tf @@ -7,18 +7,19 @@ terraform { } backend "s3" { - bucket = "sparta-tf-state" - key = "sparta/terraform" - region = "eu-west-2" + bucket = "sparta-tf-state" + key = "sparta/terraform" + region = "eu-west-2" } } provider "aws" { profile = "default" - region = "eu-west-2" + region = var.aws_region } + resource "aws_iam_role" "ecs_task_execution_role" { - name = "ecs_task_execution_role" + name = "ecs_task_execution_role-${var.environment}" assume_role_policy = jsonencode({ Version = "2012-10-17", @@ -38,9 +39,34 @@ resource "aws_iam_role_policy_attachment" "ecs_task_execution_policy" { policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" } +# Add CloudWatch logs permissions +resource "aws_iam_role_policy" "cloudwatch_logs_policy" { + name = "cloudwatch-logs-policy-${var.environment}" + role = aws_iam_role.ecs_task_execution_role.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents", + "logs:DescribeLogStreams" + ] + Resource = [ + "arn:aws:logs:${var.aws_region}:*:log-group:/fargate/service/${var.environment}/sparta-discord-bot:*", + "arn:aws:logs:${var.aws_region}:*:log-group:/fargate/service/${var.environment}/sparta-discord-bot:*:*" + ] + } + ] + }) +} + # Add ECR pull permissions resource "aws_iam_role_policy" "ecr_pull_policy" { - name = "ecr_pull_policy" + name = "ecr_pull_policy-${var.environment}" role = aws_iam_role.ecs_task_execution_role.id policy = jsonencode({ @@ -62,9 +88,9 @@ resource "aws_iam_role_policy" "ecr_pull_policy" { # Create ECR Repository resource "aws_ecr_repository" "sparta_bot" { - name = "sparta-bot" + name = "sparta-bot-${var.environment}" image_tag_mutability = "MUTABLE" - force_delete = true + force_delete = true image_scanning_configuration { scan_on_push = true @@ -79,7 +105,7 @@ resource "null_resource" "docker_build" { provisioner "local-exec" { command = <