forked from EvoMap/evolver
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathstakeBootstrap.test.js
More file actions
292 lines (258 loc) · 11.5 KB
/
Copy pathstakeBootstrap.test.js
File metadata and controls
292 lines (258 loc) · 11.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
// test/stakeBootstrap.test.js
// Unit tests for src/gep/validator/stakeBootstrap.js: retry state machine,
// failure classification, backoff behavior, and disk persistence across
// process boundaries (v1.69.11+). Mocks global.fetch and the a2aProtocol
// node-id/hub-url resolvers.
'use strict';
const { describe, it, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const Module = require('module');
function freshRequire(id) {
delete require.cache[require.resolve(id)];
return require(id);
}
function installA2aProtocolStub(nodeId, hubUrl) {
const target = require.resolve('../src/gep/a2aProtocol');
const sbPath = require.resolve('../src/gep/validator/stakeBootstrap');
delete require.cache[target];
delete require.cache[sbPath];
const origLoad = Module._load;
Module._load = function (request, parent, isMain) {
let resolved = null;
try { resolved = Module._resolveFilename(request, parent, isMain); } catch (_) {}
if (resolved === target) {
return {
buildHubHeaders: () => ({ 'content-type': 'application/json' }),
getHubUrl: () => hubUrl,
getNodeId: () => nodeId,
};
}
return origLoad.apply(this, arguments);
};
return () => {
Module._load = origLoad;
delete require.cache[target];
delete require.cache[sbPath];
};
}
describe('stakeBootstrap retry state machine', function () {
let restoreFetch;
let restoreA2a;
let tmpHome;
let prevHome;
beforeEach(() => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-stake-test-'));
prevHome = process.env.EVOLVER_HOME;
process.env.EVOLVER_HOME = tmpHome;
restoreA2a = installA2aProtocolStub('node-test-stake', 'https://hub.example.com');
});
afterEach(() => {
if (restoreA2a) restoreA2a();
if (restoreFetch) restoreFetch();
restoreFetch = null;
restoreA2a = null;
if (prevHome === undefined) delete process.env.EVOLVER_HOME;
else process.env.EVOLVER_HOME = prevHome;
try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch (_) {}
});
function stubFetch(responder) {
const original = global.fetch;
global.fetch = async (url, init) => responder(url, init);
return () => { global.fetch = original; };
}
it('success resets backoff and schedules next attempt ~24h away', async () => {
const sb = freshRequire('../src/gep/validator/stakeBootstrap');
sb._resetStateForTests();
restoreFetch = stubFetch(async () => ({
ok: true,
status: 200,
text: async () => JSON.stringify({ status: 'ok', stake: { stake_amount: 100, status: 'active', node_id: 'node-test-stake' } }),
}));
const result = await sb.ensureValidatorStake({});
assert.equal(result.ok, true);
const state = sb._getStateForTests();
assert.equal(state.transientFailures, 0);
assert.equal(state.fundsFailures, 0);
assert.ok(state.nextAttemptAt > Date.now() + (23 * 60 * 60 * 1000), 'next attempt should be ~24h from now');
});
it('network error increments transient failures and schedules first backoff (5min)', async () => {
const sb = freshRequire('../src/gep/validator/stakeBootstrap');
sb._resetStateForTests();
restoreFetch = stubFetch(async () => { throw new Error('network down'); });
const r = await sb.ensureValidatorStake({});
assert.equal(r.ok, false);
const state = sb._getStateForTests();
assert.equal(state.transientFailures, 1);
const delay = state.nextAttemptAt - Date.now();
assert.ok(delay >= 4 * 60 * 1000 && delay <= 6 * 60 * 1000, `first transient delay should be ~5min, got ${delay}`);
});
it('402 insufficient_credits classified as funds and uses funds backoff (~60min first)', async () => {
const sb = freshRequire('../src/gep/validator/stakeBootstrap');
sb._resetStateForTests();
restoreFetch = stubFetch(async () => ({
ok: false,
status: 402,
text: async () => JSON.stringify({ error: 'insufficient_credits: need 100, have 42' }),
}));
const r = await sb.ensureValidatorStake({});
assert.equal(r.ok, false);
assert.equal(r.kind, 'funds');
const state = sb._getStateForTests();
assert.equal(state.fundsFailures, 1);
assert.equal(state.transientFailures, 0);
const delay = state.nextAttemptAt - Date.now();
assert.ok(delay >= 59 * 60 * 1000 && delay <= 61 * 60 * 1000, `first funds delay should be ~60min, got ${delay}`);
});
it('400 stake_amount_must_be_at_least_100 classified as permanent (disabled_until_restart)', async () => {
const sb = freshRequire('../src/gep/validator/stakeBootstrap');
sb._resetStateForTests();
restoreFetch = stubFetch(async () => ({
ok: false,
status: 400,
text: async () => JSON.stringify({ error: 'stake_amount_must_be_at_least_100' }),
}));
const r1 = await sb.ensureValidatorStake({});
assert.equal(r1.ok, false);
assert.equal(r1.kind, 'permanent');
const r2 = await sb.ensureValidatorStake({});
assert.equal(r2.ok, false);
assert.equal(r2.skipped, 'disabled_until_restart');
});
it('backoff skip short-circuits until nextAttemptAt, then allows retry after force', async () => {
const sb = freshRequire('../src/gep/validator/stakeBootstrap');
sb._resetStateForTests();
let calls = 0;
restoreFetch = stubFetch(async () => {
calls += 1;
throw new Error('boom');
});
await sb.ensureValidatorStake({});
const skipped = await sb.ensureValidatorStake({});
assert.equal(skipped.skipped, 'backoff');
assert.equal(calls, 1);
const forced = await sb.ensureValidatorStake({ force: true });
assert.equal(forced.ok, false);
assert.equal(calls, 2);
const state = sb._getStateForTests();
assert.equal(state.transientFailures, 2);
const delay = state.nextAttemptAt - Date.now();
assert.ok(delay >= 14 * 60 * 1000 && delay <= 16 * 60 * 1000, `second transient delay should be ~15min, got ${delay}`);
});
it('exports DEFAULT_STAKE_AMOUNT = 100', function () {
const sb = freshRequire('../src/gep/validator/stakeBootstrap');
assert.equal(sb.DEFAULT_STAKE_AMOUNT, 100);
});
// --- Disk persistence (v1.69.11+) ---
it('success persists nextAttemptAt to disk under EVOLVER_HOME', async () => {
const sb = freshRequire('../src/gep/validator/stakeBootstrap');
sb._resetStateForTests();
restoreFetch = stubFetch(async () => ({
ok: true,
status: 200,
text: async () => JSON.stringify({ status: 'ok', stake: { stake_amount: 100, status: 'active', node_id: 'node-test-stake' } }),
}));
await sb.ensureValidatorStake({});
assert.ok(fs.existsSync(sb.STATE_FILE), 'state file should exist after success');
const raw = JSON.parse(fs.readFileSync(sb.STATE_FILE, 'utf8'));
assert.ok(raw.nextAttemptAt > Date.now() + 23 * 60 * 60 * 1000, 'persisted nextAttemptAt should be ~24h out');
assert.equal(raw.transientFailures, 0);
assert.equal(raw.fundsFailures, 0);
// disabledUntilRestart intentionally not persisted
assert.equal(raw.disabledUntilRestart, undefined);
});
it('persisted backoff survives a simulated process restart (module re-require)', async () => {
// First "process": record a transient failure and persist its backoff.
const sb1 = freshRequire('../src/gep/validator/stakeBootstrap');
sb1._resetStateForTests();
restoreFetch = stubFetch(async () => { throw new Error('network down'); });
await sb1.ensureValidatorStake({});
const persistedNext = sb1._getStateForTests().nextAttemptAt;
assert.ok(persistedNext > Date.now(), 'pre-restart state should have a future nextAttemptAt');
restoreFetch();
restoreFetch = null;
// Second "process": re-require the module. State file is still on disk.
let networkCalls = 0;
restoreFetch = stubFetch(async () => {
networkCalls += 1;
throw new Error('still down');
});
const sb2 = freshRequire('../src/gep/validator/stakeBootstrap');
// Skip should come from disk, NOT fetch the hub again.
const result = await sb2.ensureValidatorStake({});
assert.equal(result.skipped, 'backoff');
assert.equal(networkCalls, 0, 'must not hit hub while within persisted backoff window');
const state = sb2._getStateForTests();
assert.equal(state.transientFailures, 1, 'failure counter should be loaded from disk');
// nextAttemptAt round-trip should be within 1ms of original.
assert.ok(Math.abs(state.nextAttemptAt - persistedNext) < 2, `nextAttemptAt should round-trip, got ${state.nextAttemptAt} vs ${persistedNext}`);
});
it('disabledUntilRestart is NOT persisted -- restart lets retry happen once', async () => {
// First process: trigger permanent failure.
const sb1 = freshRequire('../src/gep/validator/stakeBootstrap');
sb1._resetStateForTests();
restoreFetch = stubFetch(async () => ({
ok: false,
status: 400,
text: async () => JSON.stringify({ error: 'stake_amount_must_be_at_least_100' }),
}));
const r1 = await sb1.ensureValidatorStake({});
assert.equal(r1.kind, 'permanent');
const r2 = await sb1.ensureValidatorStake({});
assert.equal(r2.skipped, 'disabled_until_restart');
// Simulate restart: re-require with successful fetch.
restoreFetch();
let hubHits = 0;
restoreFetch = stubFetch(async () => {
hubHits += 1;
return {
ok: true,
status: 200,
text: async () => JSON.stringify({ status: 'ok', stake: { stake_amount: 100, status: 'active' } }),
};
});
const sb2 = freshRequire('../src/gep/validator/stakeBootstrap');
const r3 = await sb2.ensureValidatorStake({});
assert.equal(r3.ok, true, 'after restart, permanent flag must clear');
assert.equal(hubHits, 1, 'restart should allow exactly one fresh attempt');
});
it('load clamps absurdly-far nextAttemptAt (clock-skew or corrupt file)', async () => {
const sb1 = freshRequire('../src/gep/validator/stakeBootstrap');
sb1._resetStateForTests();
// Write a poisoned state file: nextAttemptAt 10 years in the future.
fs.mkdirSync(path.dirname(sb1.STATE_FILE), { recursive: true });
fs.writeFileSync(sb1.STATE_FILE, JSON.stringify({
nextAttemptAt: Date.now() + 10 * 365 * 24 * 60 * 60 * 1000,
transientFailures: 99,
fundsFailures: 0,
lastSuccessAt: 0,
}));
restoreFetch = stubFetch(async () => ({
ok: true,
status: 200,
text: async () => JSON.stringify({ status: 'ok' }),
}));
const sb2 = freshRequire('../src/gep/validator/stakeBootstrap');
// Load; nextAttemptAt should be clamped to <= now + 24h.
sb2._loadStateFromDisk();
const loaded = sb2._getStateForTests();
assert.ok(loaded.nextAttemptAt <= Date.now() + 24 * 60 * 60 * 1000 + 1000,
`nextAttemptAt should be clamped to <= now+24h, got ${loaded.nextAttemptAt - Date.now()}ms out`);
});
it('gracefully tolerates corrupt state file (falls back to defaults)', async () => {
const sb1 = freshRequire('../src/gep/validator/stakeBootstrap');
sb1._resetStateForTests();
fs.mkdirSync(path.dirname(sb1.STATE_FILE), { recursive: true });
fs.writeFileSync(sb1.STATE_FILE, 'not json at all {{{');
restoreFetch = stubFetch(async () => ({
ok: true,
status: 200,
text: async () => JSON.stringify({ status: 'ok' }),
}));
const sb2 = freshRequire('../src/gep/validator/stakeBootstrap');
const r = await sb2.ensureValidatorStake({});
assert.equal(r.ok, true, 'corrupt file must not prevent stake attempt');
});
});