From 41d62f21e80ed69c9d47d2f44e01046fd85c4a7b Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Thu, 29 Jan 2026 18:53:28 -0500 Subject: [PATCH 1/4] feat: added relay subsidy balance tracker script and workflow --- .../scripts/post-relay-subsidy-balance.mjs | 205 ++++++++++++++++++ .../workflows/post-relay-subsidy-balance.yml | 43 ++++ 2 files changed, 248 insertions(+) create mode 100644 .github/scripts/post-relay-subsidy-balance.mjs create mode 100644 .github/workflows/post-relay-subsidy-balance.yml diff --git a/.github/scripts/post-relay-subsidy-balance.mjs b/.github/scripts/post-relay-subsidy-balance.mjs new file mode 100644 index 00000000..a19a289e --- /dev/null +++ b/.github/scripts/post-relay-subsidy-balance.mjs @@ -0,0 +1,205 @@ +// Fetch Relay balances and generate a Slack Incoming Webhook payload (Block Kit). +const DEFAULT_RELAY_APP_FEES_ADDRESS = + '0x8711E94aFc2463c9C2E75B84CA3d319c0131FA18'; +const DEFAULT_ALERT_USD_THRESHOLD = 5000; +const DEFAULT_TOP_UP_MENTION = ''; + +const HEADER_EMOJI = ':musd:'; +const OK_EMOJI = '🟢'; +const LOW_EMOJI = ':alert:'; +const TOP_UP_EMOJI = ':rotating_light:'; + +const buildRelayBalancesUrl = (address) => + `https://api.relay.link/app-fees/${address}/balances`; + +const parseAmountUsd = (value) => { + if (typeof value === 'number') { + return value; + } + + if (typeof value !== 'string') { + return NaN; + } + + const normalized = value.trim(); + if (!normalized) { + return NaN; + } + + return Number(normalized); +}; + +const formatUsd = (value) => { + if (!Number.isFinite(value)) { + return 'N/A'; + } + + const formatted = new Intl.NumberFormat('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(value); + + return `$${formatted}`; +}; + +const fetchRelayBalances = async ({ url, timeoutMs }) => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url, { + method: 'GET', + headers: { accept: 'application/json' }, + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error(`Unexpected HTTP status ${response.status}`); + } + + return await response.json(); + } finally { + clearTimeout(timeoutId); + } +}; + +const validateRelayResponseShape = (data) => { + if (!data || typeof data !== 'object') { + throw new Error('Relay response is not an object'); + } + + if (!Array.isArray(data.balances)) { + throw new Error('Relay response missing "balances" array'); + } + + return data.balances; +}; + +const computeTotalUsd = (balances) => + balances + .map((b) => parseAmountUsd(b?.amountUsd)) + .filter((v) => Number.isFinite(v)) + .reduce((sum, v) => sum + v, 0); + +const formatAsOfDate = (date) => + new Intl.DateTimeFormat('en-US', { + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + timeZoneName: 'short', + timeZone: 'UTC', + }).format(date); + +const buildSlackPayload = ({ totalUsd, alertUsdThreshold, asOfDate }) => { + const isLow = totalUsd < alertUsdThreshold; + const statusEmoji = isLow ? LOW_EMOJI : OK_EMOJI; + const statusText = isLow ? '*LOW*' : '*OK*'; + + const blocks = [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `${HEADER_EMOJI} *Relay Subsidy Balance*`, + }, + }, + { type: 'divider' }, + ]; + + if (isLow) { + blocks.push({ + type: 'section', + text: { + type: 'mrkdwn', + text: `${DEFAULT_TOP_UP_MENTION} ${TOP_UP_EMOJI} *Top-up needed* ${TOP_UP_EMOJI}\nBalance is below ${formatUsd( + alertUsdThreshold, + )}.`, + }, + }); + blocks.push({ type: 'divider' }); + } + + blocks.push({ + type: 'section', + fields: [ + { + type: 'mrkdwn', + text: `*Balance*\n*${formatUsd(totalUsd)}*`, + }, + { + type: 'mrkdwn', + text: `*Status*\n${statusEmoji} ${statusText}`, + }, + ], + }); + + blocks.push({ + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: `${formatAsOfDate(asOfDate)} • Alert threshold: ${formatUsd( + alertUsdThreshold, + )}`, + }, + ], + }); + + return { + text: `Relay subsidy balance (total: ${formatUsd(totalUsd)})`, + blocks, + }; +}; + +const main = async () => { + const relayAddressRaw = process.env.RELAY_APP_FEES_ADDRESS; + const relayAddress = + typeof relayAddressRaw === 'string' && relayAddressRaw.trim() + ? relayAddressRaw.trim() + : DEFAULT_RELAY_APP_FEES_ADDRESS; + const url = buildRelayBalancesUrl(relayAddress); + + const alertUsdThresholdRaw = process.env.RELAY_ALERT_USD_THRESHOLD; + const configuredAlertUsdThreshold = Number( + typeof alertUsdThresholdRaw === 'string' && alertUsdThresholdRaw.trim() + ? alertUsdThresholdRaw.trim() + : DEFAULT_ALERT_USD_THRESHOLD, + ); + const normalizedAlertUsdThreshold = Number.isFinite( + configuredAlertUsdThreshold, + ) + ? configuredAlertUsdThreshold + : DEFAULT_ALERT_USD_THRESHOLD; + + const timeoutMsRaw = process.env.RELAY_BALANCES_TIMEOUT_MS; + const timeoutMs = Number( + typeof timeoutMsRaw === 'string' && timeoutMsRaw.trim() + ? timeoutMsRaw.trim() + : 15000, + ); + const normalizedTimeoutMs = Number.isFinite(timeoutMs) ? timeoutMs : 15000; + + const relayData = await fetchRelayBalances({ + url, + timeoutMs: normalizedTimeoutMs, + }); + + const balances = validateRelayResponseShape(relayData); + const totalUsd = computeTotalUsd(balances); + + const payload = buildSlackPayload({ + totalUsd, + alertUsdThreshold: normalizedAlertUsdThreshold, + asOfDate: new Date(), + }); + + process.stdout.write(JSON.stringify(payload)); +}; + +main().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.error(`relay-balances-slack: ${message}`); + process.exitCode = 1; +}); diff --git a/.github/workflows/post-relay-subsidy-balance.yml b/.github/workflows/post-relay-subsidy-balance.yml new file mode 100644 index 00000000..870b86bd --- /dev/null +++ b/.github/workflows/post-relay-subsidy-balance.yml @@ -0,0 +1,43 @@ +name: Relay Balances Slack Report + +on: + schedule: + # Every 12 hours (00:00 and 12:00 UTC) + - cron: '0 */12 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + relay-balances-slack-report: + name: Relay balances by chainId + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + + - name: Generate Slack payload + id: payload + env: + RELAY_ALERT_USD_THRESHOLD: '${{ vars.RELAY_ALERT_USD_THRESHOLD }}' + RELAY_APP_FEES_ADDRESS: '${{ vars.RELAY_APP_FEES_ADDRESS }}' + run: | + PAYLOAD="$(node .github/scripts/post-relay-subsidy-balance.mjs)" + { + echo "payload<> "$GITHUB_OUTPUT" + + - name: Send Slack notification + uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a + with: + webhook: '${{ secrets.SLACK_RELAY_SUBSIDY_BALANCE_TRACKER_WEBHOOK_URL }}' + webhook-type: incoming-webhook + payload: ${{ steps.payload.outputs.payload }} From 5a31affa99c9111daba7f06a3f51331fba50f928 Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Fri, 30 Jan 2026 10:36:54 -0500 Subject: [PATCH 2/4] feat: addressed bugbot comment --- .github/workflows/post-relay-subsidy-balance.yml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/post-relay-subsidy-balance.yml b/.github/workflows/post-relay-subsidy-balance.yml index 870b86bd..10beef77 100644 --- a/.github/workflows/post-relay-subsidy-balance.yml +++ b/.github/workflows/post-relay-subsidy-balance.yml @@ -28,10 +28,21 @@ jobs: RELAY_ALERT_USD_THRESHOLD: '${{ vars.RELAY_ALERT_USD_THRESHOLD }}' RELAY_APP_FEES_ADDRESS: '${{ vars.RELAY_APP_FEES_ADDRESS }}' run: | - PAYLOAD="$(node .github/scripts/post-relay-subsidy-balance.mjs)" + set -euo pipefail + + node .github/scripts/post-relay-subsidy-balance.mjs > payload.json + + # Fail early with a clear error if the payload isn't valid JSON. + node -e 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"))' payload.json + + PAYLOAD="$(< payload.json)" + if [[ -z "${PAYLOAD//[[:space:]]/}" ]]; then + echo "Generated Slack payload is empty." >&2 + exit 1 + fi { echo "payload<> "$GITHUB_OUTPUT" From 7b61643058d77bcafe74fef1c250fd6133145415 Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Fri, 30 Jan 2026 10:39:54 -0500 Subject: [PATCH 3/4] feat: updated changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3259b5c0..b9b9d1ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add workflow **post-relay-subsidy-balance** to post Relay subsidy balance reports to Slack (via `.github/workflows/post-relay-subsidy-balance.yml` and `.github/scripts/post-relay-subsidy-balance.mjs`) + ## [1.4.4] ### Changed From a720ed288039e8249f889f0efd2dc97f019fcc9d Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Fri, 30 Jan 2026 10:51:09 -0500 Subject: [PATCH 4/4] feat: updated changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9b9d1ef..3412ec1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add workflow **post-relay-subsidy-balance** to post Relay subsidy balance reports to Slack (via `.github/workflows/post-relay-subsidy-balance.yml` and `.github/scripts/post-relay-subsidy-balance.mjs`) +- Add workflow **post-relay-subsidy-balance** to post Relay subsidy balance reports to Slack ([#211](https://github.com/MetaMask/github-tools/pull/211)) ## [1.4.4]