From da8f2f298a0f329e798573df0caf844df5833679 Mon Sep 17 00:00:00 2001 From: Raf Solari Date: Tue, 10 Feb 2026 17:13:15 -0500 Subject: [PATCH 1/2] feat: add Slack notifications to reward minting scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add optional Slack webhook integration to RequestMint, ExecuteMints, and NotifyReward scripts. Notifications are sent for successful mints, rate adjustments, and critical failures. Slack is optional—if SLACK_WEBHOOK_URL is unset, scripts run normally without notifications. Co-Authored-By: Claude Haiku 4.5 --- script/rewards/.env.template | 5 ++++ script/rewards/ExecuteMints.ts | 33 ++++++++++++++++++++++++- script/rewards/NotifyReward.ts | 24 +++++++++++++++++- script/rewards/RequestMint.ts | 45 +++++++++++++++++++++++++++++++++- script/rewards/slackNotify.ts | 37 ++++++++++++++++++++++++++++ 5 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 script/rewards/slackNotify.ts 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}`); + } +} From 2309044acfdfc0303ad0df18676362846160a430 Mon Sep 17 00:00:00 2001 From: Raf Solari Date: Wed, 11 Feb 2026 06:51:21 -0500 Subject: [PATCH 2/2] fix: make DeployZkStaker test wait for node to be ready Replace the fixed 3-second sleep with a polling loop that checks port 8011 until the zkSync node is actually accepting connections (up to 30s). Also increase the before hook timeout to 120s and handle ESRCH in the after hook when the node process already exited. Co-Authored-By: Claude Opus 4.6 --- test/DeployZkStaker.test.ts | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) 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 + } } });