Skip to content

Commit 0b586c1

Browse files
committed
backend deets
1 parent 0630a1d commit 0b586c1

13 files changed

Lines changed: 731 additions & 109 deletions

collector/src/database.mjs

Lines changed: 151 additions & 34 deletions
Large diffs are not rendered by default.

collector/src/l1-abis.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export const rollupAbi = [
3333
fn('getLegacySlasher', [], [out('address', 'slasher'), out('uint256', 'authorizedUntil')]),
3434
fn('getCurrentSlot', [], [out('uint256')]),
3535
fn('getCurrentEpoch', [], [out('uint256')]),
36+
fn('getGenesisTime', [], [out('uint256')]),
3637
fn('getSlotAt', [input('uint256', '_timestamp')], [out('uint256')]),
3738
fn('getEpochDuration', [], [out('uint256')]),
3839
fn('getSlotDuration', [], [out('uint256')]),

collector/src/l1-scanner.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,7 @@ export class L1Scanner {
325325
legacySlasherValue,
326326
currentSlot,
327327
currentEpoch,
328+
l1GenesisTime,
328329
slotDuration,
329330
epochDuration,
330331
] = await Promise.all([
@@ -334,6 +335,7 @@ export class L1Scanner {
334335
read(client, rollupAddress, rollupAbi, 'getLegacySlasher', blockNumber),
335336
read(client, rollupAddress, rollupAbi, 'getCurrentSlot', blockNumber),
336337
read(client, rollupAddress, rollupAbi, 'getCurrentEpoch', blockNumber),
338+
read(client, rollupAddress, rollupAbi, 'getGenesisTime', blockNumber),
337339
read(client, rollupAddress, rollupAbi, 'getSlotDuration', blockNumber),
338340
read(client, rollupAddress, rollupAbi, 'getEpochDuration', blockNumber),
339341
]);
@@ -410,6 +412,7 @@ export class L1Scanner {
410412
registryAddress: this.registryAddress,
411413
rollupAddress,
412414
rollupVersion: rollupVersion.toString(),
415+
l1GenesisTime: l1GenesisTime.toString(),
413416
currentSlot: currentSlot.toString(),
414417
currentEpoch: currentEpoch.toString(),
415418
slotDuration: slotDuration.toString(),

collector/test/journal.test.mjs

Lines changed: 105 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,9 @@ test('pending offense transitions and matching delivery fanout are atomic and de
6565
certainty: 'pending',
6666
});
6767
assert.deepEqual(events.map((event) => event.title), [
68-
'Offense detected',
69-
'Offense changed',
70-
'Offense returned',
68+
'Inactivity offense detected',
69+
'Inactivity offense changed',
70+
'Inactivity offense returned',
7171
]);
7272
assert.equal(repository.getDeliveryCounts().pending, 3);
7373
const deliveries = [];
@@ -85,6 +85,57 @@ test('pending offense transitions and matching delivery fanout are atomic and de
8585
}
8686
});
8787

88+
test('pending offenses connect their epoch or slot to offense and proposal rounds when L1 context is ready', () => {
89+
const repository = new OffenseRepository(':memory:');
90+
try {
91+
repository.recordSourceSuccess('l1', {
92+
epochDuration: '32',
93+
stacks: [{
94+
role: 'active',
95+
parameters: {
96+
roundSize: '128',
97+
slashOffsetInRounds: '2',
98+
},
99+
}],
100+
}, 10);
101+
const [epochOffense] = parseOffenseSnapshot([OFFENSE_A]);
102+
const [slotOffense] = parseOffenseSnapshot([OFFENSE_B]);
103+
104+
repository.recordSuccessfulPoll([epochOffense, slotOffense], {
105+
observedAt: 20,
106+
network: 'mainnet',
107+
});
108+
109+
const events = repository.listEvents({ network: 'mainnet', limit: 10 }).data;
110+
const epochEvent = events.find((event) => event.data.offenseId === epochOffense.id);
111+
const slotEvent = events.find((event) => event.data.offenseId === slotOffense.id);
112+
assert.deepEqual({
113+
epoch: epochEvent.data.epoch,
114+
slot: epochEvent.data.slot,
115+
offenseRound: epochEvent.data.offenseRound,
116+
proposalRound: epochEvent.data.proposalRound,
117+
}, {
118+
epoch: '42',
119+
slot: '1344',
120+
offenseRound: '10',
121+
proposalRound: '12',
122+
});
123+
assert.deepEqual({
124+
epoch: slotEvent.data.epoch,
125+
slot: slotEvent.data.slot,
126+
offenseRound: slotEvent.data.offenseRound,
127+
proposalRound: slotEvent.data.proposalRound,
128+
}, {
129+
epoch: '281',
130+
slot: '9001',
131+
offenseRound: '70',
132+
proposalRound: '72',
133+
});
134+
} finally {
135+
repository.close();
136+
}
137+
});
138+
88139
test('expired outbox leases are recovered on a later claim without restarting the worker', () => {
89140
const repository = new OffenseRepository(':memory:');
90141
try {
@@ -1165,6 +1216,48 @@ test('L1 snapshots alert on the first address-level vote, quorum, execution wind
11651216
}
11661217
});
11671218

1219+
test('L1 payload events count only tally actions and expose epoch, slot, and UTC execution timing', () => {
1220+
const repository = new OffenseRepository(':memory:');
1221+
try {
1222+
repository.recordSuccessfulL1Snapshot('mainnet', snapshot({
1223+
block: 100,
1224+
earlyTargets: [earlyTarget(SEQUENCER_A, 100), earlyTarget(SEQUENCER_B, 1)],
1225+
actions: [{ sequencer: SEQUENCER_A, amount: '1000' }],
1226+
payloadAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
1227+
status: 'quorum-reached',
1228+
targetEpochs: ['38', '39'],
1229+
executableSlot: '1100',
1230+
expirySlot: '1200',
1231+
}), { observedAt: 100 });
1232+
1233+
const event = repository.listEvents({ network: 'mainnet' }).data
1234+
.find((candidate) => candidate.type === 'onchain_targeted');
1235+
assert.ok(event);
1236+
assert.deepEqual(event.targets, [SEQUENCER_A]);
1237+
assert.match(event.body, /1 sequencer/i);
1238+
assert.doesNotMatch(event.body, /2 sequencers/i);
1239+
assert.deepEqual({
1240+
targetEpochs: event.data.targetEpochs,
1241+
currentEpoch: event.data.currentEpoch,
1242+
currentSlot: event.data.currentSlot,
1243+
executableSlot: event.data.executableSlot,
1244+
executableAt: event.data.executableAt,
1245+
expirySlot: event.data.expirySlot,
1246+
expiryAt: event.data.expiryAt,
1247+
}, {
1248+
targetEpochs: ['38', '39'],
1249+
currentEpoch: '10',
1250+
currentSlot: '1000',
1251+
executableSlot: '1100',
1252+
executableAt: '1970-01-01T03:41:40.000Z',
1253+
expirySlot: '1200',
1254+
expiryAt: '1970-01-01T04:01:40.000Z',
1255+
});
1256+
} finally {
1257+
repository.close();
1258+
}
1259+
});
1260+
11681261
test('L1 payload changes alert only sequencers whose own slash amount changed', () => {
11691262
const repository = new OffenseRepository(':memory:');
11701263
try {
@@ -1843,6 +1936,9 @@ function snapshot({
18431936
isProtected = false,
18441937
pauseStartedAtSlot = null,
18451938
pauseEndsAtSlot = null,
1939+
targetEpochs = [],
1940+
executableSlot = '900',
1941+
expirySlot = '1200',
18461942
}) {
18471943
return {
18481944
chainId: 1,
@@ -1852,6 +1948,9 @@ function snapshot({
18521948
registryAddress: '0x0000000000000000000000000000000000000001',
18531949
rollupAddress: '0x0000000000000000000000000000000000000002',
18541950
rollupVersion: '1',
1951+
l1GenesisTime: '100',
1952+
slotDuration: '12',
1953+
epochDuration: '32',
18551954
currentSlot: '1000',
18561955
currentEpoch: '10',
18571956
stackErrors: [],
@@ -1881,9 +1980,9 @@ function snapshot({
18811980
earlyTargets,
18821981
actions,
18831982
committees: [],
1884-
targetEpochs: [],
1885-
executableSlot: '900',
1886-
expirySlot: '1200',
1983+
targetEpochs,
1984+
executableSlot,
1985+
expirySlot,
18871986
}],
18881987
}],
18891988
};

collector/test/l1-scanner.test.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ test('scanWithClient publishes one coherent pinned-block snapshot', async () =>
168168
assert.equal(snapshot.blockNumber, '100');
169169
assert.equal(snapshot.blockHash, BLOCK_HASH);
170170
assert.equal(snapshot.rollupAddress, ROLLUP);
171+
assert.equal(snapshot.l1GenesisTime, '100');
171172
assert.equal(snapshot.degraded, false);
172173
assert.equal(snapshot.stacks.length, 1);
173174
assert.equal(snapshot.stacks[0].role, 'active');
@@ -446,6 +447,7 @@ function fakeL1Client({ replacementHash, brokenPendingStack = false, pausedExecu
446447
getLegacySlasher: [`0x${'00'.repeat(20)}`, 0n],
447448
getCurrentSlot: pausedExecutableRound ? 70n : 0n,
448449
getCurrentEpoch: 0n,
450+
getGenesisTime: 100n,
449451
getSlotDuration: 12n,
450452
getEpochDuration: 32n,
451453
}[functionName];

src/api/client.test.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ describe('Slashmon capability-scoped API reads', () => {
5353
vi.stubGlobal('fetch', fetchMock);
5454
const client = new SlashmonApiClient('https://api.slashmon.invalid');
5555

56+
await client.getEvents('mainnet');
5657
await client.getSubscriptionEvents('watch/1', 'bearer-secret', 'mainnet');
5758
const detail = await client.getSubscriptionEvent(
5859
'watch/1',
@@ -63,11 +64,14 @@ describe('Slashmon capability-scoped API reads', () => {
6364

6465
expect(detail.certainty).toBe('pending');
6566
expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual([
66-
'https://api.slashmon.invalid/api/v2/subscriptions/watch%2F1/events?limit=40',
67+
'https://api.slashmon.invalid/api/v2/events?network=mainnet&limit=67',
68+
'https://api.slashmon.invalid/api/v2/subscriptions/watch%2F1/events?limit=67',
6769
'https://api.slashmon.invalid/api/v2/subscriptions/watch%2F1/events/pending-event-1',
6870
]);
69-
for (const [, init] of fetchMock.mock.calls) {
70-
expect((init?.headers as Headers).get('authorization')).toBe('Bearer bearer-secret');
71+
for (const [index, [, init]] of fetchMock.mock.calls.entries()) {
72+
expect((init?.headers as Headers).get('authorization')).toBe(
73+
index === 0 ? null : 'Bearer bearer-secret',
74+
);
7175
expect(init?.cache).toBe('no-store');
7276
}
7377
});

src/api/client.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export class SlashmonApiClient {
4949
}
5050

5151
async getEvents(network: MonitorNetwork, signal?: AbortSignal, cursor?: string): Promise<EventPage> {
52-
const query = new URLSearchParams({ network, limit: '40' });
52+
const query = new URLSearchParams({ network, limit: '67' });
5353
if (cursor) {
5454
query.set('cursor', cursor);
5555
}
@@ -63,7 +63,7 @@ export class SlashmonApiClient {
6363
signal?: AbortSignal,
6464
cursor?: string,
6565
): Promise<EventPage> {
66-
const query = new URLSearchParams({ limit: '40' });
66+
const query = new URLSearchParams({ limit: '67' });
6767
if (cursor) {
6868
query.set('cursor', cursor);
6969
}

src/api/decode.test.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ describe('Slashmon API decoders', () => {
6969
epochOrSlot: '42',
7070
timeUnit: 'epoch',
7171
amount: '2000000000000000000000',
72+
epoch: '42',
73+
slot: '1344',
74+
offenseRound: '10',
75+
proposalRound: '12',
7276
},
7377
occurredAt: '2026-07-21T10:00:00.000Z',
7478
}],
@@ -82,6 +86,10 @@ describe('Slashmon API decoders', () => {
8286
epochOrSlot: '42',
8387
timeUnit: 'epoch',
8488
amount: '2000000000000000000000',
89+
epoch: '42',
90+
slot: '1344',
91+
offenseRound: '10',
92+
proposalRound: '12',
8593
});
8694
expect(page.nextCursor).toBe('cursor-2');
8795
});
@@ -99,7 +107,23 @@ describe('Slashmon API decoders', () => {
99107
targets: [address],
100108
title: 'Slashing is executable',
101109
body: 'A watched sequencer is in the payload.',
102-
data: {},
110+
data: {
111+
chainId: 1,
112+
role: 'active',
113+
round: '195',
114+
targetEpochs: ['772', '773'],
115+
currentSlot: '25000',
116+
currentEpoch: '781',
117+
executableSlot: '28672',
118+
executableAt: '2026-07-23T10:00:00.000Z',
119+
expirySlot: '29440',
120+
expiryAt: '2026-07-23T16:24:00.000Z',
121+
blockNumber: '25587802',
122+
blockHash: `0x${'12'.repeat(32)}`,
123+
transactionHash: `0x${'34'.repeat(32)}`,
124+
payloadAddress: '0x00000000000000000000000000000000000000bb',
125+
actions: [{ sequencer: address, amount: '2000000000000000000000' }],
126+
},
103127
occurredAt: '2026-07-21T10:00:00.000Z',
104128
},
105129
}, 'mainnet');
@@ -108,6 +132,24 @@ describe('Slashmon API decoders', () => {
108132
expect(event.certainty).toBe('confirmed');
109133
expect(event.targets).toEqual([address]);
110134
expect(event.offense).toBeNull();
135+
expect(event.l1).toEqual({
136+
chainId: 1,
137+
role: 'active',
138+
round: '195',
139+
targetEpochs: ['772', '773'],
140+
currentSlot: '25000',
141+
currentEpoch: '781',
142+
executableSlot: '28672',
143+
executableAt: '2026-07-23T10:00:00.000Z',
144+
expirySlot: '29440',
145+
expiryAt: '2026-07-23T16:24:00.000Z',
146+
blockNumber: '25587802',
147+
blockHash: `0x${'12'.repeat(32)}`,
148+
transactionHash: `0x${'34'.repeat(32)}`,
149+
payloadAddress: '0x00000000000000000000000000000000000000bb',
150+
amount: null,
151+
actions: [{ sequencer: address, amount: '2000000000000000000000' }],
152+
});
111153
});
112154

113155
it('requires the one-time management token on creation', () => {

0 commit comments

Comments
 (0)