Skip to content

Commit 471f672

Browse files
committed
notifs
1 parent 68ca9fd commit 471f672

10 files changed

Lines changed: 605 additions & 88 deletions

File tree

collector/src/channels.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,7 @@ export class TelegramChannel {
336336
const icon = event.severity === 'critical' ? '🚨' : event.severity === 'warning' ? '⚠️' : '🛰️';
337337
const url = new URL(notificationPath(event), this.publicUrl).toString();
338338
const references = [
339-
`slashveto.me case: ${url}`,
339+
`Case: ${url}`,
340340
...dashtecReferenceLines(event),
341341
...etherscanReferenceLines(event),
342342
];

collector/src/notification-content.mjs

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export function formatNotificationBody(transition) {
55
const body = typeof transition?.body === 'string' ? transition.body.trim() : '';
66
const sequencer = transitionSequencer(transition);
77
return sequencer
8-
? `Sequencer: ${shortAddress(sequencer)}${body ? `\n${body}` : ''}`
8+
? `Sequencer: ${sequencer}${body ? `\n${body}` : ''}`
99
: body;
1010
}
1111

@@ -25,23 +25,14 @@ export function etherscanReferenceLines(transition) {
2525
: 'https://etherscan.io';
2626
const lines = [];
2727
if (HASH.test(String(data.transactionHash ?? ''))) {
28-
lines.push(`Etherscan transaction: ${origin}/tx/${data.transactionHash}`);
29-
}
30-
if (/^\d+$/.test(String(data.blockNumber ?? ''))) {
31-
lines.push(`Etherscan block: ${origin}/block/${data.blockNumber}`);
32-
}
33-
if (ADDRESS.test(String(data.payloadAddress ?? ''))) {
34-
lines.push(`Etherscan candidate payload: ${origin}/address/${data.payloadAddress}`);
28+
lines.push(`Transaction: ${origin}/tx/${data.transactionHash}`);
3529
}
3630
return lines;
3731
}
3832

3933
function transitionSequencer(transition) {
40-
const value = transition?.data?.sequencer ?? transition?.targets?.[0];
34+
const targets = Array.isArray(transition?.targets) ? transition.targets : [];
35+
const value = transition?.data?.sequencer ?? (targets.length === 1 ? targets[0] : null);
4136
const normalized = String(value ?? '').toLowerCase();
4237
return ADDRESS.test(normalized) ? normalized : null;
4338
}
44-
45-
function shortAddress(value) {
46-
return `${value.slice(0, 6)}${value.slice(-4)}`;
47-
}

collector/test/case-repository.test.mjs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,49 @@ test('CaseRepository projects transitions and queues only matching watched seque
7575
repository.close();
7676
});
7777

78+
test('L1 ballots queue only the first vote and quorum', () => {
79+
const repository = createRepository();
80+
repository.createWatch({
81+
id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
82+
managementTokenHash: 'hash',
83+
network: 'mainnet',
84+
addresses: [SEQUENCER],
85+
now: 1_700_000_000_000,
86+
});
87+
repository.upsertEndpoint({
88+
watchId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
89+
kind: 'telegram',
90+
destination: '1234',
91+
now: 1_700_000_000_000,
92+
});
93+
94+
const firstVote = repository.recordObservations([
95+
l1Vote('vote-1', '2023-11-14T22:15:20.000Z', 1),
96+
], { protocol: protocol() });
97+
assert.equal(firstVote.transitions, 1);
98+
assert.equal(firstVote.queued, 1);
99+
100+
const secondVote = repository.recordObservations([
101+
l1Vote('vote-2', '2023-11-14T22:16:20.000Z', 2),
102+
]);
103+
assert.equal(secondVote.transitions, 0);
104+
assert.equal(secondVote.queued, 0);
105+
106+
const quorum = repository.recordObservations([
107+
l1Vote('quorum', '2023-11-14T22:17:20.000Z', 2, '100'),
108+
]);
109+
assert.equal(quorum.transitions, 1);
110+
assert.equal(quorum.queued, 1);
111+
112+
const item = repository.getCase(repository.listCases({ network: 'mainnet' })[0].id);
113+
assert.equal(item.state.stage, 'candidate');
114+
assert.deepEqual(item.transitions.map((transition) => transition.toStage), [
115+
'l1_support',
116+
'candidate',
117+
]);
118+
repository.close();
119+
});
120+
78121
test('L1 snapshots preserve a repeated sequencer as two exact target-epoch cases', () => {
79122
const repository = createRepository();
80123
repository.recordSuccessfulL1Snapshot('mainnet', {
@@ -456,3 +499,34 @@ function observation(id, kind, data) {
456499
data,
457500
};
458501
}
502+
503+
function l1Vote(id, observedAt, support, amount = null) {
504+
return {
505+
id,
506+
network: 'mainnet',
507+
source: 'ethereum_l1',
508+
kind: 'l1_round',
509+
sequencer: SEQUENCER,
510+
lineageId: PROPOSER,
511+
targetEpoch: '8',
512+
round: '10',
513+
provenance: {
514+
observedAt,
515+
blockNumber: '100',
516+
blockHash: BLOCK_HASH,
517+
canonical: true,
518+
},
519+
data: {
520+
round: '10',
521+
status: amount ? 'quorum-reached' : 'below-quorum',
522+
support,
523+
quorum: 2,
524+
amount,
525+
payloadAddress: amount ? PAYLOAD : null,
526+
isExecuted: false,
527+
isVetoed: false,
528+
stable: false,
529+
escaped: false,
530+
},
531+
};
532+
}

collector/test/channels.test.mjs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const EVENT = {
2424
chainId: 1,
2525
blockNumber: '25587802',
2626
payloadAddress: '0x2222222222222222222222222222222222222222',
27+
transactionHash: `0x${'34'.repeat(32)}`,
2728
},
2829
};
2930

@@ -55,10 +56,12 @@ test('WebPushChannel builds a scoped high-urgency payload and returns the provid
5556
assert.equal(payload.title, EVENT.title);
5657
assert.equal(
5758
payload.body,
58-
'Sequencer: 0x1111…1111\nA confirmed payload targets 0x1111…1111.',
59+
'Sequencer: 0x1111111111111111111111111111111111111111\n' +
60+
'A confirmed payload targets 0x1111…1111.',
5961
);
6062
assert.equal(payload.data.caseId, EVENT.data.caseId);
6163
assert.equal(payload.data.url, notificationPath(EVENT));
64+
assert.doesNotMatch(payload.body, /https?:\/\//);
6265
assert.equal(
6366
payload.data.url,
6467
'?view=pingme&network=mainnet&case=case%3Amainnet%3Alineage%3A0x1111111111111111111111111111111111111111%3A24',
@@ -290,11 +293,12 @@ test('TelegramClient long polling and TelegramChannel preserve routing semantics
290293
assert.equal(message.chatId, '-100123');
291294
assert.deepEqual(message.options, { priority: 'alert' });
292295
assert.match(message.text, /^🚨 Sequencer targeted/);
293-
assert.match(message.text, /Sequencer: 0x11111111/);
294-
assert.match(message.text, /slashveto\.me case: https:\/\/slashveto\.example\/base\/\?view=pingme&network=mainnet&case=case%3Amainnet%3Alineage%3A0x1111111111111111111111111111111111111111%3A24/);
296+
assert.match(message.text, /Sequencer: 0x1111111111111111111111111111111111111111/);
297+
assert.match(message.text, /Case: https:\/\/slashveto\.example\/base\/\?view=pingme&network=mainnet&case=case%3Amainnet%3Alineage%3A0x1111111111111111111111111111111111111111%3A24/);
295298
assert.match(message.text, /Dashtec: https:\/\/dashtec\.xyz\/sequencers\/0x1111111111111111111111111111111111111111/);
296-
assert.match(message.text, /Etherscan block: https:\/\/etherscan\.io\/block\/25587802/);
297-
assert.match(message.text, /Etherscan candidate payload: https:\/\/etherscan\.io\/address\/0x2222222222222222222222222222222222222222$/);
299+
assert.match(message.text, /Transaction: https:\/\/etherscan\.io\/tx\/0x3434343434343434343434343434343434343434343434343434343434343434/);
300+
assert.doesNotMatch(message.text, /Etherscan block/);
301+
assert.doesNotMatch(message.text, /candidate payload/);
298302
});
299303

300304
test('TelegramChannel keeps queued alerts retryable until the bot identity is validated', async () => {

collector/test/notification-content.test.mjs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,16 @@ import {
99

1010
const SEQUENCER = '0x1111111111111111111111111111111111111111';
1111

12-
test('case transition content identifies one sequencer and preserves educational copy', () => {
12+
test('case transition content identifies the full sequencer without repeating the heading', () => {
1313
const transition = {
1414
network: 'mainnet',
15-
body: 'Candidate. Target epoch 24. L1 does not encode an offense reason.',
15+
body: 'Event: Quorum reached for a 2,000 AZTEC slash\nEpoch: 24',
1616
targets: [SEQUENCER],
1717
data: { sequencer: SEQUENCER },
1818
};
1919
assert.equal(
2020
formatNotificationBody(transition),
21-
'Sequencer: 0x1111…1111\n' + transition.body,
21+
`Sequencer: ${SEQUENCER}\n${transition.body}`,
2222
);
2323
assert.deepEqual(dashtecReferenceLines(transition), [
2424
`Dashtec: https://dashtec.xyz/sequencers/${SEQUENCER}`,
@@ -36,8 +36,6 @@ test('case transition references use only its exact L1 facts', () => {
3636
payloadAddress,
3737
},
3838
}), [
39-
`Etherscan transaction: https://sepolia.etherscan.io/tx/${transactionHash}`,
40-
'Etherscan block: https://sepolia.etherscan.io/block/42',
41-
`Etherscan candidate payload: https://sepolia.etherscan.io/address/${payloadAddress}`,
39+
`Transaction: https://sepolia.etherscan.io/tx/${transactionHash}`,
4240
]);
4341
});

docs/notifications.md

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ should be able to answer, without decoding contract data:
1212
An alert links to the exact case whose transition triggered it, not just the
1313
PINGME landing page.
1414

15+
The heading keeps the compact `sequencer · case status` form. The body states
16+
the event, full sequencer address, relevant slot, epoch or round, UTC
17+
observation time, and reason provenance without repeating explanatory prose.
18+
1519
## Claims allowed at each stage
1620

1721
| Stage | What the alert may claim |
@@ -35,29 +39,32 @@ action” and “predicted payload address.”
3539
L1 votes contain no offense type. Alerts may attach a reason only as separately
3640
labelled node evidence matched by address and exact target epoch or slot:
3741

38-
> Candidate 2,000 AZTEC slash becomes executable in 2d 4h. This node observed
39-
> inactivity in target epoch 123.
42+
> Event: Quorum reached for a 2,000 AZTEC slash
43+
>
44+
> Epoch: 123
45+
>
46+
> Reason: Inactivity (node evidence)
4047
4148
They must not say:
4249

4350
> L1 confirmed a 2,000 AZTEC inactivity slash.
4451
4552
If several local offenses match the same L1 action, list them rather than
46-
choosing one. If no evidence matches, say “reason unknown on L1.” An approximate
47-
timestamp match is insufficient.
53+
choosing one. If no evidence matches, say `Reason: Not encoded on L1`. An
54+
approximate timestamp match is insufficient.
4855

4956
## Required context
5057

5158
Every alert includes:
5259

5360
- watched sequencer address;
54-
- case stage and previous stage;
55-
- exact slot, epoch, target epoch, or slashing round as applicable;
56-
- source (`this Aztec node`, `Sentinel`, or `Ethereum L1`);
57-
- source observation time; current freshness is shown when the case opens;
61+
- current case status in the heading;
62+
- exact slot, epoch, or slashing round as applicable;
63+
- UTC observation time;
64+
- reason and its node-evidence provenance, or that no reason is encoded on L1;
5865
- actual AZTEC amount or candidate AZTEC amount, explicitly distinguished;
5966
- the next transition and countdown, when deterministic; and
60-
- the exact slashveto.me case URL.
67+
- for Telegram, the exact slashveto.me case URL.
6168

6269
Stage-specific evidence is:
6370

@@ -70,9 +77,9 @@ Stage-specific evidence is:
7077
| Execution / slash | transaction, round, deployed payload, requested action, canonical `Slashed` amount, and post-slash/ejection state when known |
7178
| Reorg / correction | orphaned block or transaction and the case state that replaced it |
7279

73-
Telegram may add Dashtec links for affected sequencers and Etherscan links for
74-
the exact network transaction, block, or predicted/deployed address. Web Push
75-
opens the same exact case.
80+
Telegram includes the exact case link, a Dashtec sequencer link, and an
81+
Etherscan transaction link when a transaction hash exists. Web Push contains no
82+
links in its copy and opens the exact case when selected.
7683

7784
## Alert transitions and noise control
7885

@@ -81,7 +88,8 @@ Alert on meaningful path changes, not every poll:
8188
- first missed duty in an epoch;
8289
- newly qualifying inactive epoch or changed streak progress;
8390
- offense registration or safe withdrawal;
84-
- first L1 mention and relevant vote/penalty threshold crossings;
91+
- first L1 vote and slash quorum; intermediate ballot counts update the case
92+
without sending alerts;
8593
- candidate addition, removal, amount change, or address change;
8694
- round close, execution start, veto, materially changed pause protection, or
8795
expiry;

shared/protocol/cases.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,121 @@ describe('case projection', () => {
231231
severity: 'warning',
232232
});
233233
});
234+
235+
it('notifies on the first L1 vote and quorum, not each ballot increment', () => {
236+
const offense = {
237+
...observation('offense', 'node_offense', {
238+
status: 'active',
239+
offenseTypeName: 'inactivity',
240+
amount: '2000000000000000000000',
241+
expectedRound: '12',
242+
}),
243+
provenance: {
244+
observedAt: '2026-07-29T11:00:00.000Z',
245+
canonical: true,
246+
},
247+
};
248+
const firstVote = projectCases([
249+
offense,
250+
observation('vote-1', 'l1_round', {
251+
round: '12',
252+
status: 'below-quorum',
253+
support: 1,
254+
quorum: 65,
255+
amount: null,
256+
}, 'ethereum_l1'),
257+
], protocol())[0];
258+
const secondVote = projectCases([
259+
offense,
260+
observation('vote-2', 'l1_round', {
261+
round: '12',
262+
status: 'below-quorum',
263+
support: 2,
264+
quorum: 65,
265+
amount: null,
266+
}, 'ethereum_l1'),
267+
], protocol())[0];
268+
const quorum = projectCases([
269+
offense,
270+
observation('quorum', 'l1_round', {
271+
round: '12',
272+
status: 'quorum-reached',
273+
support: 65,
274+
quorum: 65,
275+
amount: '2000000000000000000000',
276+
payloadAddress: '0x3333333333333333333333333333333333333333',
277+
}, 'ethereum_l1'),
278+
], protocol())[0];
279+
280+
expect(transitionFor(null, firstVote)).toMatchObject({
281+
title: '0x1111…1111 · L1 mention',
282+
body: [
283+
'Event: First L1 slash vote recorded',
284+
'Epoch: 10',
285+
'Round: 12',
286+
'Time: 2026-07-29 12:00:06 UTC',
287+
'Reason: Inactivity (node evidence)',
288+
].join('\n'),
289+
});
290+
expect(transitionFor(firstVote, secondVote)).toBeNull();
291+
expect(transitionFor(firstVote, quorum)).toMatchObject({
292+
title: '0x1111…1111 · Candidate',
293+
body: [
294+
'Event: Quorum reached for a 2,000 AZTEC slash',
295+
'Epoch: 10',
296+
'Round: 12',
297+
'Time: 2026-07-29 12:00:06 UTC',
298+
'Reason: Inactivity (node evidence)',
299+
'Next: Voting closes',
300+
].join('\n'),
301+
});
302+
});
303+
304+
it('includes the relevant slot, epoch, and observation time', () => {
305+
const duty = projectCases([{
306+
...observation('duty', 'duty_miss', {
307+
epoch: 10,
308+
slot: '9001',
309+
status: 'attestation-missed',
310+
}),
311+
slot: '9001',
312+
}], protocol())[0];
313+
314+
expect(transitionFor(null, duty)).toMatchObject({
315+
title: '0x1111…1111 · Duty miss',
316+
body: [
317+
'Event: Duty missed',
318+
'Epoch: 10',
319+
'Slot: 9001',
320+
'Time: 2026-07-29 12:00:04 UTC',
321+
'Reason: Inactivity (node evidence)',
322+
].join('\n'),
323+
});
324+
});
325+
326+
it('names other offense types without repeating them', () => {
327+
const offense = projectCases([{
328+
...observation('duplicate', 'node_offense', {
329+
status: 'active',
330+
offenseTypeName: 'duplicate_proposal',
331+
amount: '5000000000000000000000',
332+
expectedRound: '12',
333+
}),
334+
slot: '9001',
335+
}], protocol())[0];
336+
337+
expect(transitionFor(null, offense)).toMatchObject({
338+
title: '0x1111…1111 · Awaiting L1 round',
339+
body: [
340+
'Event: Offense recorded by this node',
341+
'Epoch: 10',
342+
'Slot: 9001',
343+
'Round: 12',
344+
'Time: 2026-07-29 12:00:09 UTC',
345+
'Reason: Duplicate Proposal (node evidence)',
346+
].join('\n'),
347+
});
348+
});
234349
});
235350

236351
function observation(

0 commit comments

Comments
 (0)