Skip to content

Commit 4321c89

Browse files
committed
feat: added relay subsidy balance tracker script and workflow
1 parent 3b38631 commit 4321c89

2 files changed

Lines changed: 253 additions & 0 deletions

File tree

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
// Fetch Relay balances and generate a Slack Incoming Webhook payload (Block Kit).
2+
const DEFAULT_RELAY_APP_FEES_ADDRESS =
3+
'0x8711E94aFc2463c9C2E75B84CA3d319c0131FA18';
4+
const DEFAULT_ALERT_USD_THRESHOLD = 5000;
5+
6+
const HEADER_EMOJI = ':musd:';
7+
const OK_EMOJI = '🟢';
8+
const LOW_EMOJI = ':alert:';
9+
const TOP_UP_EMOJI = ':rotating_light:';
10+
11+
const buildRelayBalancesUrl = (address) =>
12+
`https://api.relay.link/app-fees/${address}/balances`;
13+
14+
const parseAmountUsd = (value) => {
15+
if (typeof value === 'number') {
16+
return value;
17+
}
18+
19+
if (typeof value !== 'string') {
20+
return NaN;
21+
}
22+
23+
const normalized = value.trim();
24+
if (!normalized) {
25+
return NaN;
26+
}
27+
28+
return Number(normalized);
29+
};
30+
31+
const formatUsd = (value) => {
32+
if (!Number.isFinite(value)) {
33+
return 'N/A';
34+
}
35+
36+
const formatted = new Intl.NumberFormat('en-US', {
37+
minimumFractionDigits: 2,
38+
maximumFractionDigits: 2,
39+
}).format(value);
40+
41+
return `$${formatted}`;
42+
};
43+
44+
const fetchRelayBalances = async ({ url, timeoutMs }) => {
45+
const controller = new AbortController();
46+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
47+
48+
try {
49+
const response = await fetch(url, {
50+
method: 'GET',
51+
headers: { accept: 'application/json' },
52+
signal: controller.signal,
53+
});
54+
55+
if (!response.ok) {
56+
throw new Error(`Unexpected HTTP status ${response.status}`);
57+
}
58+
59+
return await response.json();
60+
} finally {
61+
clearTimeout(timeoutId);
62+
}
63+
};
64+
65+
const validateRelayResponseShape = (data) => {
66+
if (!data || typeof data !== 'object') {
67+
throw new Error('Relay response is not an object');
68+
}
69+
70+
if (!Array.isArray(data.balances)) {
71+
throw new Error('Relay response missing "balances" array');
72+
}
73+
74+
return data.balances;
75+
};
76+
77+
const computeTotalUsd = (balances) =>
78+
balances
79+
.map((b) => parseAmountUsd(b?.amountUsd))
80+
.filter((v) => Number.isFinite(v))
81+
.reduce((sum, v) => sum + v, 0);
82+
83+
const formatAsOfDate = (date) =>
84+
new Intl.DateTimeFormat('en-US', {
85+
year: 'numeric',
86+
month: 'short',
87+
day: '2-digit',
88+
hour: '2-digit',
89+
minute: '2-digit',
90+
timeZoneName: 'short',
91+
timeZone: 'UTC',
92+
}).format(date);
93+
94+
const buildSlackPayload = ({ totalUsd, alertUsdThreshold, asOfDate }) => {
95+
const isLow = totalUsd < alertUsdThreshold;
96+
const statusEmoji = isLow ? LOW_EMOJI : OK_EMOJI;
97+
const statusText = isLow ? '*LOW*' : '*OK*';
98+
99+
/** @type {any[]} */
100+
const blocks = [
101+
{
102+
type: 'section',
103+
text: {
104+
type: 'mrkdwn',
105+
text: `${HEADER_EMOJI} *Relay Subsidy Balance*`,
106+
},
107+
},
108+
{ type: 'divider' },
109+
];
110+
111+
if (isLow) {
112+
blocks.push({
113+
type: 'section',
114+
text: {
115+
type: 'mrkdwn',
116+
text: `${TOP_UP_EMOJI} *Top-up needed* ${TOP_UP_EMOJI}\nBalance is below ${formatUsd(
117+
alertUsdThreshold,
118+
)}.`,
119+
},
120+
});
121+
blocks.push({ type: 'divider' });
122+
}
123+
124+
blocks.push({
125+
type: 'section',
126+
fields: [
127+
{
128+
type: 'mrkdwn',
129+
text: `*Balance*\n*${formatUsd(totalUsd)}*`,
130+
},
131+
{
132+
type: 'mrkdwn',
133+
text: `*Status*\n${statusEmoji} ${statusText}`,
134+
},
135+
],
136+
});
137+
138+
// Keep the threshold visible but visually de-emphasized.
139+
blocks.push({
140+
type: 'context',
141+
elements: [
142+
{
143+
type: 'mrkdwn',
144+
text: `${formatAsOfDate(asOfDate)} • Alert threshold: ${formatUsd(
145+
alertUsdThreshold,
146+
)}`,
147+
},
148+
],
149+
});
150+
151+
return {
152+
text: `Relay subsidy balance (total: ${formatUsd(totalUsd)})`,
153+
blocks,
154+
};
155+
};
156+
157+
const main = async () => {
158+
const relayAddressRaw = process.env.RELAY_APP_FEES_ADDRESS;
159+
const relayAddress =
160+
typeof relayAddressRaw === 'string' && relayAddressRaw.trim()
161+
? relayAddressRaw.trim()
162+
: DEFAULT_RELAY_APP_FEES_ADDRESS;
163+
const url = buildRelayBalancesUrl(relayAddress);
164+
165+
const alertUsdThresholdRaw = process.env.RELAY_ALERT_USD_THRESHOLD;
166+
const configuredAlertUsdThreshold = Number(
167+
typeof alertUsdThresholdRaw === 'string' && alertUsdThresholdRaw.trim()
168+
? alertUsdThresholdRaw.trim()
169+
: DEFAULT_ALERT_USD_THRESHOLD,
170+
);
171+
const normalizedAlertUsdThreshold = Number.isFinite(configuredAlertUsdThreshold)
172+
? configuredAlertUsdThreshold
173+
: DEFAULT_ALERT_USD_THRESHOLD;
174+
175+
const timeoutMsRaw = process.env.RELAY_BALANCES_TIMEOUT_MS;
176+
const timeoutMs = Number(
177+
typeof timeoutMsRaw === 'string' && timeoutMsRaw.trim()
178+
? timeoutMsRaw.trim()
179+
: 15000,
180+
);
181+
const normalizedTimeoutMs = Number.isFinite(timeoutMs) ? timeoutMs : 15000;
182+
183+
const relayData = await fetchRelayBalances({ url, timeoutMs: normalizedTimeoutMs });
184+
185+
const balances = validateRelayResponseShape(relayData);
186+
const totalUsd = computeTotalUsd(balances);
187+
188+
const payload = buildSlackPayload({
189+
totalUsd,
190+
alertUsdThreshold: normalizedAlertUsdThreshold,
191+
asOfDate: new Date(),
192+
});
193+
194+
// Print a single-line JSON payload suitable for GitHub Actions step outputs.
195+
process.stdout.write(JSON.stringify(payload));
196+
};
197+
198+
main().catch((error) => {
199+
const message = error instanceof Error ? error.message : String(error);
200+
// eslint-disable-next-line no-console
201+
console.error(`relay-balances-slack: ${message}`);
202+
process.exitCode = 1;
203+
});
204+
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
name: Relay Balances Slack Report
2+
3+
on:
4+
schedule:
5+
# Every 12 hours (00:00 and 12:00 UTC)
6+
- cron: '0 */12 * * *'
7+
workflow_dispatch:
8+
9+
# Setup required:
10+
# - Create a Slack Incoming Webhook pointing at your target channel
11+
# - Add it as a repo secret: Settings -> Secrets and variables -> Actions -> New repository secret
12+
# - Name: SLACK_RELAY_SUBSIDY_BALANCE_TRACKER_WEBHOOK_URL
13+
# - Value: (full Slack Incoming Webhook URL)
14+
15+
permissions:
16+
contents: read
17+
18+
jobs:
19+
relay-balances-slack-report:
20+
name: Relay balances by chainId
21+
runs-on: ubuntu-latest
22+
steps:
23+
- name: Checkout repository
24+
uses: actions/checkout@v4
25+
26+
- name: Setup Node.js
27+
uses: actions/setup-node@v4
28+
with:
29+
node-version-file: .nvmrc
30+
31+
- name: Generate Slack payload
32+
id: payload
33+
env:
34+
RELAY_ALERT_USD_THRESHOLD: "${{ vars.RELAY_ALERT_USD_THRESHOLD }}"
35+
RELAY_APP_FEES_ADDRESS: "${{ vars.RELAY_APP_FEES_ADDRESS }}"
36+
run: |
37+
PAYLOAD="$(node .github/scripts/post-relay-subsidy-balance.mjs)"
38+
{
39+
echo "payload<<EOF"
40+
echo "$PAYLOAD"
41+
echo "EOF"
42+
} >> "$GITHUB_OUTPUT"
43+
44+
- name: Send Slack notification
45+
uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a
46+
with:
47+
webhook: "${{ secrets.SLACK_RELAY_SUBSIDY_BALANCE_TRACKER_WEBHOOK_URL }}"
48+
webhook-type: incoming-webhook
49+
payload: ${{ steps.payload.outputs.payload }}

0 commit comments

Comments
 (0)