diff --git a/script/rewards/.env.template b/script/rewards/.env.template index 66cd814..7aec8d2 100644 --- a/script/rewards/.env.template +++ b/script/rewards/.env.template @@ -11,3 +11,8 @@ TURNKEY_ORGANIZATION_ID="your-organization-id" TURNKEY_API_PUBLIC_KEY="your-api-public-key" TURNKEY_API_PRIVATE_KEY="your-api-private-key" TURNKEY_WALLET_ADDRESS="0x..." # The EOA wallet address controlled by Turnkey + +# Slack Notifications (optional) +# If not set, scripts run normally without sending notifications. +# Create a webhook at: https://api.slack.com/messaging/webhooks +SLACK_WEBHOOK_URL= diff --git a/script/rewards/ExecuteMints.ts b/script/rewards/ExecuteMints.ts index b7860da..259afce 100644 --- a/script/rewards/ExecuteMints.ts +++ b/script/rewards/ExecuteMints.ts @@ -3,6 +3,7 @@ import { ethers, JsonRpcProvider, formatEther as ethersFormatEther } from "ether import { TurnkeySigner } from "@turnkey/ethers"; import { TurnkeyClient } from "@turnkey/http"; import { ApiKeyStamper } from "@turnkey/api-key-stamper"; +import { notifySlack } from "./slackNotify"; // Load environment variables dotEnvConfig(); @@ -190,6 +191,12 @@ async function executeMintRequest( console.error(` Status: ❌ Failed to execute mint`); console.error(` Error: ${error.message}`); console.error(`\n⚠️ Mint execution failed. Skipping notify step.`); + await notifySlack( + `*executeMint failed for request #${request.id}*\n` + + `Amount: ${formatEther(request.amount)} ZK\n` + + `Error: ${error.message}`, + "error" + ); return false; } @@ -214,6 +221,13 @@ async function executeMintRequest( console.error(`\n⚠️ CRITICAL: Mint executed but notify failed!`); console.error(` Manual action required:`); console.error(` Call staker.notifyRewardAmount(${request.amount}) immediately!`); + await notifySlack( + `*CRITICAL: Mint executed but notify failed!*\n` + + `Request #${request.id} | Amount: ${formatEther(request.amount)} ZK\n` + + `Manual recovery required:\n` + + `\`npx ts-node --transpileOnly NotifyReward.ts -- --amount=${request.amount}\``, + "error" + ); return false; } } @@ -315,15 +329,32 @@ async function main() { console.log(` Failed: ${failCount}`); console.log(`${"=".repeat(70)}\n`); + const totalAmount = pendingRequests.reduce((sum, r) => sum + r.amount, 0n); if (failCount > 0) { + await notifySlack( + `*Execute Mints completed with failures*\n` + + `Total: ${pendingRequests.length} | Succeeded: ${successCount} | Failed: ${failCount}\n` + + `Total amount attempted: ${formatEther(totalAmount)} ZK`, + "warning" + ); process.exit(1); + } else { + await notifySlack( + `*Mints Executed Successfully*\n` + + `Processed: ${successCount} request(s)\n` + + `Total amount: ${formatEther(totalAmount)} ZK` + ); } } main() .then(() => process.exit(0)) - .catch((error) => { + .catch(async (error) => { console.error("\n💥 Script failed:"); console.error(error.message || error); + await notifySlack( + `*ExecuteMints script crashed*\nError: ${error.message || error}`, + "error" + ); process.exit(1); }); diff --git a/script/rewards/NotifyReward.ts b/script/rewards/NotifyReward.ts index d5b98a1..3ee5baf 100644 --- a/script/rewards/NotifyReward.ts +++ b/script/rewards/NotifyReward.ts @@ -36,6 +36,7 @@ import { ethers, JsonRpcProvider, formatEther as ethersFormatEther } from "ether import { TurnkeySigner } from "@turnkey/ethers"; import { TurnkeyClient } from "@turnkey/http"; import { ApiKeyStamper } from "@turnkey/api-key-stamper"; +import { notifySlack } from "./slackNotify"; // Load environment variables dotEnvConfig(); @@ -217,6 +218,14 @@ async function main() { console.log(` New Reward End Time: ${new Date(Number(newEndTime.toString()) * 1000).toISOString()}`); console.log(` New Scaled Rate: ${newScaledRate.toString()}`); console.log(`${"=".repeat(70)}\n`); + + await notifySlack( + `*Disaster Recovery: notifyRewardAmount succeeded*\n` + + `Amount: ${formatEther(amount)} ZK\n` + + `New reward end: ${new Date(Number(newEndTime.toString()) * 1000).toISOString()}\n` + + `New scaled rate: ${newScaledRate.toString()}\n` + + `Tx: \`${notifyTx.hash}\`` + ); } catch (error: any) { console.error(`\n❌ Failed to notify staker`); console.error(` Error: ${error.message}`); @@ -227,14 +236,27 @@ async function main() { console.error(` staker.grantRole(NOTIFIER_ROLE, ${TURNKEY_WALLET_ADDRESS})`); } + const roleTip = error.message.includes("not notifier") + ? `\nTip: Grant NOTIFIER_ROLE to \`${TURNKEY_WALLET_ADDRESS}\`` + : ""; + await notifySlack( + `*Disaster Recovery FAILED: notifyRewardAmount*\n` + + `Amount: ${formatEther(amount)} ZK\n` + + `Error: ${error.message}${roleTip}`, + "error" + ); process.exit(1); } } main() .then(() => process.exit(0)) - .catch((error) => { + .catch(async (error) => { console.error("\n💥 Script failed:"); console.error(error.message || error); + await notifySlack( + `*NotifyReward (disaster recovery) script crashed*\nError: ${error.message || error}`, + "error" + ); process.exit(1); }); diff --git a/script/rewards/RequestMint.ts b/script/rewards/RequestMint.ts index 38bf038..d7e2fdd 100644 --- a/script/rewards/RequestMint.ts +++ b/script/rewards/RequestMint.ts @@ -3,6 +3,7 @@ import { ethers, JsonRpcProvider, formatEther as ethersFormatEther } from "ether import { TurnkeySigner } from "@turnkey/ethers"; import { TurnkeyClient } from "@turnkey/http"; import { ApiKeyStamper } from "@turnkey/api-key-stamper"; +import { notifySlack } from "./slackNotify"; // Load environment variables dotEnvConfig(); @@ -309,9 +310,23 @@ async function main() { console.log(` New Rate: ${newRate.toFixed(4)}% APR`); console.log(` New Reward End Time: ${new Date(Number(newState.rewardEndTime) * 1000).toISOString()}`); console.log(`${"=".repeat(70)}\n`); + + await notifySlack( + `*Rate Lowered*\n` + + `Previous rate: ${currentRate.toFixed(4)}% APR\n` + + `New rate: ${newRate.toFixed(4)}% APR\n` + + `New reward end: ${new Date(Number(newState.rewardEndTime) * 1000).toISOString()}\n` + + `Tx: \`${notifyTx.hash}\`` + ); } catch (error: any) { console.error(`\n❌ Failed to call notifyRewardAmount`); console.error(` Error: ${error.message}`); + await notifySlack( + `*Failed to lower reward rate*\n` + + `Attempted: notifyRewardAmount(0) on ZKStaker\n` + + `Error: ${error.message}`, + "error" + ); process.exit(1); } @@ -390,21 +405,49 @@ async function main() { console.log(` 1. Wait until ${executeAfter.toISOString()}`); console.log(` 2. Run: npx ts-node --transpileOnly script/rewards/ExecuteMints.ts`); console.log(`${"=".repeat(70)}\n`); + + await notifySlack( + `*Mint Requested*\n` + + `Amount: ${formatEther(rewardsToAdd)} ZK\n` + + `Request ID: ${mintRequestId}\n` + + `Current rate: ${currentRate.toFixed(4)}% APR | Target: ${desiredRatePercentage.toFixed(4)}% APR\n` + + `Execute after: ${executeAfter.toISOString()}\n` + + `Tx: \`${mintRequestTx.hash}\`` + ); } catch (e) { console.log(`\n✅ Mint request created (unable to determine request ID)`); console.log(` Run ExecuteMints.ts after the delay period to execute pending mints\n`); + + await notifySlack( + `*Mint Requested*\n` + + `Amount: ${formatEther(rewardsToAdd)} ZK\n` + + `Current rate: ${currentRate.toFixed(4)}% APR | Target: ${desiredRatePercentage.toFixed(4)}% APR\n` + + `Tx: \`${mintRequestTx.hash}\`\n` + + `_(Request ID could not be determined)_` + ); } } catch (error: any) { console.error(`\n❌ Failed to request mint`); console.error(` Error: ${error.message}`); + await notifySlack( + `*Failed to request mint*\n` + + `Attempted: ${formatEther(rewardsToAdd)} ZK via DelayMod\n` + + `Target rate: ${desiredRatePercentage.toFixed(4)}% APR\n` + + `Error: ${error.message}`, + "error" + ); process.exit(1); } } main() .then(() => process.exit(0)) - .catch((error) => { + .catch(async (error) => { console.error("\n💥 Script failed:"); console.error(error.message || error); + await notifySlack( + `*RequestMint script crashed*\nError: ${error.message || error}`, + "error" + ); process.exit(1); }); diff --git a/script/rewards/slackNotify.ts b/script/rewards/slackNotify.ts new file mode 100644 index 0000000..b6657ce --- /dev/null +++ b/script/rewards/slackNotify.ts @@ -0,0 +1,37 @@ +const SLACK_WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL; + +const LEVEL_EMOJI = { + info: ":white_check_mark:", + warning: ":warning:", + error: ":rotating_light:", +} as const; + +/** + * Posts a message to Slack via incoming webhook. + * Silently no-ops if SLACK_WEBHOOK_URL is not set. + * Never throws — logs a warning to console on failure. + */ +export async function notifySlack( + message: string, + level: "info" | "warning" | "error" = "info" +): Promise { + if (!SLACK_WEBHOOK_URL) return; + + const prefix = LEVEL_EMOJI[level]; + const payload = { + text: `${prefix} *[zkStaker]* ${message}`, + }; + + try { + const response = await fetch(SLACK_WEBHOOK_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + console.warn(`[Slack] Warning: webhook returned ${response.status}`); + } + } catch (err: any) { + console.warn(`[Slack] Warning: failed to send notification: ${err.message}`); + } +} diff --git a/test/DeployZkStaker.test.ts b/test/DeployZkStaker.test.ts index 03d31b2..89e02e7 100644 --- a/test/DeployZkStaker.test.ts +++ b/test/DeployZkStaker.test.ts @@ -2,6 +2,7 @@ import { expect } from "chai"; import { ethers } from "hardhat"; import { Contract } from "ethers"; import { execSync, spawn } from "child_process"; +import * as net from "net"; import * as dotenv from "dotenv"; dotenv.config(); @@ -16,6 +17,7 @@ describe("DeployZkStaker", function () { let mintRewardNotifierAddress = ""; before(async function () { + this.timeout(120000); // Get the local Hardhat node is running try { console.log("Starting local Hardhat node..."); @@ -29,8 +31,22 @@ describe("DeployZkStaker", function () { process.exit(1); } - // Wait for a few seconds to ensure the local node is ready - await new Promise((resolve) => setTimeout(resolve, 3000)); + // Wait for the local node to be ready by polling port 8011 + const maxWaitMs = 30000; + const pollIntervalMs = 500; + const start = Date.now(); + while (Date.now() - start < maxWaitMs) { + const ready = await new Promise((resolve) => { + const socket = new net.Socket(); + socket.setTimeout(pollIntervalMs); + socket.once("connect", () => { socket.destroy(); resolve(true); }); + socket.once("error", () => { socket.destroy(); resolve(false); }); + socket.once("timeout", () => { socket.destroy(); resolve(false); }); + socket.connect(8011, "0.0.0.0"); + }); + if (ready) break; + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } console.log("About to run deploy script..."); // Run the deploy script @@ -72,7 +88,11 @@ describe("DeployZkStaker", function () { after(async function () { // Terminate the local Hardhat node process if (localNodeProcess) { - process.kill(-localNodeProcess.pid); + try { + process.kill(-localNodeProcess.pid); + } catch (e) { + // Process may have already exited + } } });