From de618dc08212a7d9152c94e2d33df20a83aa04d3 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 05:06:21 +0000 Subject: [PATCH] feat(keeper): add telegram notifications on successful order execution - POSTs to Telegram Bot API sendMessage with order ID, action, and explorer tx link upon successful execute() transaction receipt. - Decodes action from ABI-encoded resultData. - Configurable via optional TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID env variables. - Silently skips when environment variables are unset. - Ensures order terms or trigger prices are never included in the notification message. - Adds comprehensive node:test coverage in keeper/test/lib.test.js. Co-authored-by: LSUDOKO <173903994+LSUDOKO@users.noreply.github.com> --- keeper/src/index.js | 17 +++++- keeper/src/lib.js | 110 ++++++++++++++++++++++++++++++++++ keeper/test/lib.test.js | 127 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 252 insertions(+), 2 deletions(-) diff --git a/keeper/src/index.js b/keeper/src/index.js index 70ba8d4..64c89b8 100644 --- a/keeper/src/index.js +++ b/keeper/src/index.js @@ -10,7 +10,13 @@ import { createPublicClient, createWalletClient, http, parseAbi, parseEventLogs, formatEther } from "viem"; import { privateKeyToAccount } from "viem/accounts"; -import { handleFetchResultResponse, determineRelayAction } from "./lib.js"; +import { + handleFetchResultResponse, + determineRelayAction, + shouldNotify, + decodeAction, + sendTelegramNotification +} from "./lib.js"; const RPC_URL = process.env.RPC_URL ?? "https://coston2-api.flare.network/ext/C/rpc"; const WRAITH_ADDRESS = required("WRAITH_ADDRESS"); @@ -129,6 +135,15 @@ async function relayResults() { }); await publicClient.waitForTransactionReceipt({ hash }); console.log(`order ${orderId} executed in ${hash}`); + + if (shouldNotify(process.env)) { + const action = decodeAction(relayAction.data); + try { + await sendTelegramNotification(process.env, orderId, action, hash); + } catch (notifyError) { + console.error(`Telegram notification error for order ${orderId}: ${notifyError.message}`); + } + } } catch (error) { console.error(`execute failed for order ${orderId}: ${error.shortMessage ?? error.message}`); } diff --git a/keeper/src/lib.js b/keeper/src/lib.js index deaa49b..d699b5d 100644 --- a/keeper/src/lib.js +++ b/keeper/src/lib.js @@ -38,3 +38,113 @@ export function determineRelayAction(result, defaultSubmissionTag = "submit") { signature: result.signature, }; } + +/** + * Decodes the action from the ABI-encoded result data. + * + * @param {string} data - Hex string starting with or without "0x" + * @returns {string} "swap", "redeem", or "unknown" + */ +export function decodeAction(data) { + if (!data || typeof data !== "string") return "unknown"; + const cleanData = data.startsWith("0x") ? data.slice(2) : data; + if (cleanData.length < 192) return "unknown"; + const actionHex = cleanData.slice(128, 192); + try { + const actionVal = Number(BigInt("0x" + actionHex)); + if (actionVal === 0) return "swap"; + if (actionVal === 1) return "redeem"; + } catch (e) { + // ignore + } + return "unknown"; +} + +/** + * Decodes the order ID from the ABI-encoded result data. + * + * @param {string} data - Hex string starting with or without "0x" + * @returns {string|null} The order ID as a string, or null if invalid + */ +export function decodeOrderId(data) { + if (!data || typeof data !== "string") return null; + const cleanData = data.startsWith("0x") ? data.slice(2) : data; + if (cleanData.length < 64) return null; + const orderIdHex = cleanData.slice(0, 64); + try { + return BigInt("0x" + orderIdHex).toString(); + } catch (e) { + return null; + } +} + +/** + * Determines whether Telegram notifications should be sent based on environment. + * + * @param {Record} env + * @returns {boolean} + */ +export function shouldNotify(env) { + return !!(env?.TELEGRAM_BOT_TOKEN && env?.TELEGRAM_CHAT_ID); +} + +/** + * Constructs the explorer transaction link. + * + * @param {string} txHash + * @returns {string} + */ +export function getExplorerTxLink(txHash) { + return `https://coston2.testnet.flarescan.com/tx/${txHash}`; +} + +/** + * Builds the pure text message for Telegram. + * + * @param {string|number} orderId + * @param {string} action - "swap", "redeem", or "unknown" + * @param {string} txHash + * @returns {string} + */ +export function buildTelegramMessage(orderId, action, txHash) { + const txLink = getExplorerTxLink(txHash); + return `Order executed!\nID: ${orderId}\nAction: ${action}\nTransaction: ${txLink}`; +} + +/** + * Sends a notification message to the configured Telegram Bot. + * + * @param {Record} env + * @param {string|number} orderId + * @param {string} action + * @param {string} txHash + * @returns {Promise} + */ +export async function sendTelegramNotification(env, orderId, action, txHash) { + if (!shouldNotify(env)) { + return false; + } + const message = buildTelegramMessage(orderId, action, txHash); + const url = `https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/sendMessage`; + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + chat_id: env.TELEGRAM_CHAT_ID, + text: message, + }), + }); + if (!response.ok) { + const text = await response.text(); + console.error(`Telegram Bot API error: ${response.status} ${text}`); + return false; + } + return true; + } catch (error) { + console.error(`Telegram network/fetch error: ${error.message}`); + return false; + } +} diff --git a/keeper/test/lib.test.js b/keeper/test/lib.test.js index c625cca..2ec8c56 100644 --- a/keeper/test/lib.test.js +++ b/keeper/test/lib.test.js @@ -1,6 +1,14 @@ import test from "node:test"; import assert from "node:assert"; -import { handleFetchResultResponse, determineRelayAction } from "../src/lib.js"; +import { + handleFetchResultResponse, + determineRelayAction, + shouldNotify, + decodeAction, + decodeOrderId, + buildTelegramMessage, + sendTelegramNotification +} from "../src/lib.js"; test("handleFetchResultResponse - proxy 404 -> null", async () => { const response = new Response(null, { status: 404 }); @@ -75,3 +83,120 @@ test("determineRelayAction - status 1 with no data or 0x -> skipped (null)", () assert.strictEqual(determineRelayAction({ status: 1, data: "0x" }), null); assert.strictEqual(determineRelayAction(null), null); }); + +test("shouldNotify - returns false when env vars are unset", () => { + assert.strictEqual(shouldNotify({}), false); + assert.strictEqual(shouldNotify({ TELEGRAM_BOT_TOKEN: "token" }), false); + assert.strictEqual(shouldNotify({ TELEGRAM_CHAT_ID: "chat" }), false); + assert.strictEqual(shouldNotify({ TELEGRAM_BOT_TOKEN: "", TELEGRAM_CHAT_ID: "" }), false); + assert.strictEqual(shouldNotify(null), false); +}); + +test("shouldNotify - returns true when env vars are set", () => { + assert.strictEqual(shouldNotify({ TELEGRAM_BOT_TOKEN: "token", TELEGRAM_CHAT_ID: "chat" }), true); +}); + +test("buildTelegramMessage - message contains order id and tx hash", () => { + const orderId = "42"; + const action = "swap"; + const txHash = "0xabc123"; + const message = buildTelegramMessage(orderId, action, txHash); + + assert.ok(message.includes("42"), "Message should contain order ID"); + assert.ok(message.includes("swap"), "Message should contain action"); + assert.ok(message.includes("0xabc123"), "Message should contain tx hash"); + assert.ok(message.includes("https://coston2.testnet.flarescan.com/tx/0xabc123"), "Message should contain correct explorer link"); +}); + +test("buildTelegramMessage - message never contains threshold-like fields", () => { + const orderId = "42"; + const action = "redeem"; + const txHash = "0xabc123"; + const message = buildTelegramMessage(orderId, action, txHash).toLowerCase(); + + const forbiddenWords = [ + "threshold", + "price", + "limit", + "trigger", + "minoutorlots", + "lots", + "underlyingaddress", + "tokenout", + "direction", + "feedid", + "expiry", + "terms" + ]; + for (const word of forbiddenWords) { + assert.ok(!message.includes(word), `Message should not leak the word "${word}"`); + } +}); + +test("decodeAction and decodeOrderId - parses hex results correctly", () => { + // orderId = 5, contract = 0x11..., action = 0 (swap) + const swapHex = "0x" + + "0000000000000000000000000000000000000000000000000000000000000005" + + "0000000000000000000000001111111111111111111111111111111111111111" + + "0000000000000000000000000000000000000000000000000000000000000000"; + + assert.strictEqual(decodeOrderId(swapHex), "5"); + assert.strictEqual(decodeAction(swapHex), "swap"); + + // orderId = 42, contract = 0x22..., action = 1 (redeem) + const redeemHex = "0x" + + "000000000000000000000000000000000000000000000000000000000000002a" + + "0000000000000000000000002222222222222222222222222222222222222222" + + "0000000000000000000000000000000000000000000000000000000000000001"; + + assert.strictEqual(decodeOrderId(redeemHex), "42"); + assert.strictEqual(decodeAction(redeemHex), "redeem"); + + // test invalid + assert.strictEqual(decodeAction("0x123"), "unknown"); + assert.strictEqual(decodeOrderId("0x123"), null); +}); + +test("sendTelegramNotification - posts to Telegram Bot API", async () => { + const originalFetch = globalThis.fetch; + let fetchCallArgs = null; + + globalThis.fetch = async (url, options) => { + fetchCallArgs = { url, options }; + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }; + + try { + const env = { TELEGRAM_BOT_TOKEN: "my-token", TELEGRAM_CHAT_ID: "my-chat" }; + const success = await sendTelegramNotification(env, "42", "swap", "0xabc123"); + + assert.strictEqual(success, true); + assert.ok(fetchCallArgs, "fetch should have been called"); + assert.strictEqual(fetchCallArgs.url, "https://api.telegram.org/botmy-token/sendMessage"); + assert.strictEqual(fetchCallArgs.options.method, "POST"); + const body = JSON.parse(fetchCallArgs.options.body); + assert.strictEqual(body.chat_id, "my-chat"); + assert.ok(body.text.includes("42")); + assert.ok(body.text.includes("swap")); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("sendTelegramNotification - skipped when env unset", async () => { + const originalFetch = globalThis.fetch; + let fetchCalled = false; + + globalThis.fetch = async () => { + fetchCalled = true; + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }; + + try { + const success = await sendTelegramNotification({}, "42", "swap", "0xabc123"); + assert.strictEqual(success, false); + assert.strictEqual(fetchCalled, false); + } finally { + globalThis.fetch = originalFetch; + } +});