@effectstream/runtime 0.100.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +34 -0
- package/src/api/http-server.ts +942 -0
- package/src/api/pagination.ts +456 -0
- package/src/api/rpc-evm/eip1193.ts +920 -0
- package/src/api/rpc-evm/rpc-payloads.ts +297 -0
- package/src/api/rpc-evm/rpc-types.ts +87 -0
- package/src/api/rpc-evm/rpc-utils.ts +50 -0
- package/src/config-snapshot.ts +192 -0
- package/src/main.ts +317 -0
- package/src/mod.ts +3 -0
- package/src/process-blocks.ts +302 -0
- package/src/telemetry.ts +37 -0
- package/src/types.ts +76 -0
- package/src/version-migrations.ts +163 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import {
|
|
2
|
+
encodeFunctionData,
|
|
3
|
+
type RpcBlock,
|
|
4
|
+
type RpcLog,
|
|
5
|
+
type RpcTransaction,
|
|
6
|
+
type RpcTransactionReceipt,
|
|
7
|
+
stringToHex,
|
|
8
|
+
} from "viem";
|
|
9
|
+
import { add0x } from "./rpc-utils.ts";
|
|
10
|
+
|
|
11
|
+
export function toRpcTransaction(
|
|
12
|
+
data: {
|
|
13
|
+
blockHash: string;
|
|
14
|
+
blockNumber: number;
|
|
15
|
+
from: string;
|
|
16
|
+
inputData: string;
|
|
17
|
+
txHash: string;
|
|
18
|
+
txIndex: number;
|
|
19
|
+
},
|
|
20
|
+
): RpcTransaction {
|
|
21
|
+
const hexMsg = stringToHex(data.inputData);
|
|
22
|
+
/**
|
|
23
|
+
* Paima is not EVM so it doesn't have the same encoding of "inputData" in the EVM sense
|
|
24
|
+
* To convert between the two, we encode the Paima data with this fictional ABI
|
|
25
|
+
*/
|
|
26
|
+
const input = encodeFunctionData({
|
|
27
|
+
// TODO: this string should be exposed somewhere 3rd parties can access
|
|
28
|
+
// https://github.com/wevm/viem/issues/2591
|
|
29
|
+
abi: [
|
|
30
|
+
{
|
|
31
|
+
inputs: [{ internalType: "string", name: "input", type: "string" }],
|
|
32
|
+
name: "convertedPaimaData",
|
|
33
|
+
outputs: [],
|
|
34
|
+
stateMutability: "nonpayable",
|
|
35
|
+
type: "function",
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
args: [hexMsg],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
blockHash: add0x(data.blockHash),
|
|
43
|
+
blockNumber: `0x${data.blockNumber.toString(16)}`,
|
|
44
|
+
hash: add0x(data.txHash),
|
|
45
|
+
input,
|
|
46
|
+
transactionIndex: `0x${data.txIndex.toString(16)}`,
|
|
47
|
+
from: data.from as any, // we can't guarantee EVM address format here
|
|
48
|
+
value: "0x0", // TODO
|
|
49
|
+
nonce: "0x0", // TODO
|
|
50
|
+
...mockTxType,
|
|
51
|
+
...mockTxRecipient,
|
|
52
|
+
...mockTxGasPre,
|
|
53
|
+
...mockEvmEcdsaSignature,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function toRpcBlock(
|
|
58
|
+
data: {
|
|
59
|
+
blockHeight: number;
|
|
60
|
+
blockHash: string;
|
|
61
|
+
prevBlockHash: string;
|
|
62
|
+
msTimestamp: number;
|
|
63
|
+
txs: ({
|
|
64
|
+
blockHeight: number;
|
|
65
|
+
blockHash: string;
|
|
66
|
+
prevBlockHash: string;
|
|
67
|
+
msTimestamp: number;
|
|
68
|
+
txs: any[];
|
|
69
|
+
} | string)[];
|
|
70
|
+
},
|
|
71
|
+
txDetails: boolean,
|
|
72
|
+
): RpcBlock {
|
|
73
|
+
return {
|
|
74
|
+
number: `0x${data.blockHeight.toString(16)}`,
|
|
75
|
+
hash: add0x(data.blockHash),
|
|
76
|
+
parentHash: data.prevBlockHash == null ? "0x0" : add0x(data.prevBlockHash),
|
|
77
|
+
|
|
78
|
+
timestamp: `0x${Math.floor(data.msTimestamp / 1000).toString(16)}`,
|
|
79
|
+
size: "0x0", // TODO: do we really need this? I doubt it
|
|
80
|
+
|
|
81
|
+
transactions: txDetails
|
|
82
|
+
? (data
|
|
83
|
+
.txs as any[]).map((tx) => toRpcTransaction(tx))
|
|
84
|
+
: (data.txs as string[]).map((tx) => add0x(tx)),
|
|
85
|
+
...mockExtraData,
|
|
86
|
+
...mockRoots,
|
|
87
|
+
...mockUncles,
|
|
88
|
+
...mockMiner,
|
|
89
|
+
...mockBlockGas,
|
|
90
|
+
...mockLogBloom,
|
|
91
|
+
...mockSealFields,
|
|
92
|
+
...mockBlob,
|
|
93
|
+
...mockEip1559Gas,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function toRpcTransactionReceipt(
|
|
98
|
+
txData: {
|
|
99
|
+
result: {
|
|
100
|
+
txHash: string;
|
|
101
|
+
blockHash: string;
|
|
102
|
+
blockNumber: number;
|
|
103
|
+
txIndex: number;
|
|
104
|
+
from: string;
|
|
105
|
+
success: boolean;
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
): RpcTransactionReceipt {
|
|
109
|
+
return {
|
|
110
|
+
transactionHash: add0x(txData.result.txHash),
|
|
111
|
+
blockHash: add0x(txData.result.blockHash),
|
|
112
|
+
blockNumber: `0x${txData.result.blockNumber.toString(16)}`,
|
|
113
|
+
transactionIndex: `0x${txData.result.txIndex.toString(16)}`,
|
|
114
|
+
from: txData.result.from as any, // we can't guarantee EVM address format here
|
|
115
|
+
logs: [], // TODO
|
|
116
|
+
status: txData.result.success ? "0x1" : "0x0",
|
|
117
|
+
...mockTxGasPost,
|
|
118
|
+
...mockTxRecipient,
|
|
119
|
+
...mockTxType,
|
|
120
|
+
...mockLogBloom,
|
|
121
|
+
...mockContractAddress, // TODO: remove
|
|
122
|
+
// contractAddress: TODO // TODO
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function toRpcLog(
|
|
127
|
+
log: any,
|
|
128
|
+
): RpcLog {
|
|
129
|
+
return {
|
|
130
|
+
address: log.address as any, // can't guarantee address format matches
|
|
131
|
+
topics: [], // TODO: this should be all topics, not just the ones passed in
|
|
132
|
+
data: "0x", // TODO
|
|
133
|
+
blockNumber: `0x${log.blockNumber.toString(16)}`,
|
|
134
|
+
transactionIndex: add0x(log.txIndex.toString(16)),
|
|
135
|
+
transactionHash: add0x(log.transactionHash),
|
|
136
|
+
blockHash: add0x(log.blockHash),
|
|
137
|
+
logIndex: add0x(log.logIndex.toString(16)),
|
|
138
|
+
removed: false,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* We can't truly provide this for a few reasons:
|
|
144
|
+
* 1. Transactions aren't guaranteed to be on Ethereum (could be from a non-EVM chain)
|
|
145
|
+
* 2. Even on Ethereum, Paima txs aren't guaranteed to be from an EOA accounts
|
|
146
|
+
* i.e. it could be a game tick, it could be an internal tx (EVM internal txs are regular txs in Paima), etc.
|
|
147
|
+
*
|
|
148
|
+
* TODO: we could decide to add this for historical_game_inputs for EVM chains if we really want to
|
|
149
|
+
*/
|
|
150
|
+
const mockEvmEcdsaSignature = {
|
|
151
|
+
/** ECDSA signature r */
|
|
152
|
+
r: "0x0",
|
|
153
|
+
/** ECDSA signature s */
|
|
154
|
+
s: "0x0",
|
|
155
|
+
/**
|
|
156
|
+
* ECDSA recovery ID
|
|
157
|
+
* note: replaced by yParity if type != 0x0
|
|
158
|
+
*/
|
|
159
|
+
v: "0x0",
|
|
160
|
+
} as const;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Gas specified before tx lands onchain (as part of the tx input given by the user)
|
|
164
|
+
* Note: no concept of gas on Paima
|
|
165
|
+
*/
|
|
166
|
+
const mockTxGasPre = {
|
|
167
|
+
gas: "0x0",
|
|
168
|
+
gasPrice: "0x0",
|
|
169
|
+
// TODO: some other gas fields needed if we ever use type != 0x0
|
|
170
|
+
} as const;
|
|
171
|
+
|
|
172
|
+
const mockEip1559Gas = {
|
|
173
|
+
baseFeePerGas: "0x0",
|
|
174
|
+
maxFeePerGas: "0x0",
|
|
175
|
+
} as const;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Gas specified after tx lands onchain (after calculating how much gas is consumed in reality)
|
|
179
|
+
* Note: no concept of gas on Paima
|
|
180
|
+
*/
|
|
181
|
+
const mockTxGasPost = {
|
|
182
|
+
gasUsed: "0x0",
|
|
183
|
+
effectiveGasPrice: "0x0",
|
|
184
|
+
cumulativeGasUsed: "0x0",
|
|
185
|
+
} as const;
|
|
186
|
+
/**
|
|
187
|
+
* Gas consumed for a block
|
|
188
|
+
* Note: no concept of gas on Paima
|
|
189
|
+
*/
|
|
190
|
+
const mockBlockGas = {
|
|
191
|
+
gasLimit: "0x0",
|
|
192
|
+
gasUsed: "0x0",
|
|
193
|
+
} as const;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Mock info about blobs (doesn't exist in Paima)
|
|
197
|
+
*/
|
|
198
|
+
const mockBlob = {
|
|
199
|
+
blobGasUsed: "0x0",
|
|
200
|
+
excessBlobGas: "0x0",
|
|
201
|
+
} as const;
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* There is no concept of recipients in Paima since txs are to the state machine
|
|
205
|
+
* TODO: there are some cases where, after processing the STF, we could determine if this tx was *to* a specific address
|
|
206
|
+
* or conversely, make that the STF fails if the state transition doesn't match some asserted *to* address
|
|
207
|
+
* so we could support some limited form of this if needed
|
|
208
|
+
*/
|
|
209
|
+
const mockTxRecipient = {
|
|
210
|
+
to: "0x0",
|
|
211
|
+
} as const;
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* EVM has multiple tx types
|
|
215
|
+
* - 0x0 for legacy transactions
|
|
216
|
+
* - 0x1 for access list types
|
|
217
|
+
* - 0x2 for dynamic fees
|
|
218
|
+
*
|
|
219
|
+
* Not all chains use 0x2, so it's not clear which we should use for mock data in Paima
|
|
220
|
+
* Note: making this an ENV var doesn't make sense either since it's not something the node can know ahead of time
|
|
221
|
+
* since it depends on which tool is making the RPC query, not the node itself
|
|
222
|
+
* We pick 0x0 for best chance at compatibility
|
|
223
|
+
*
|
|
224
|
+
* Note: if we change this to something other than 0x0, we also have to
|
|
225
|
+
* 1. change `v` to `yParity` in the signature
|
|
226
|
+
* 2. change the gas fields
|
|
227
|
+
*/
|
|
228
|
+
const mockTxType = {
|
|
229
|
+
type: "0x0",
|
|
230
|
+
} as const;
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Theoretically we could implement this in Paima, but it takes time to calculate and basically 0 dApps and tools use this
|
|
234
|
+
* In fact, it's being set to empty-string with an EIP from 2024
|
|
235
|
+
* https://github.com/ethereum/EIPs/blob/master/EIPS/eip-7668.md
|
|
236
|
+
*/
|
|
237
|
+
const mockLogBloom = {
|
|
238
|
+
// TODO: unclear how this should be handled post-7668, but I assume `0x0` is the right approach
|
|
239
|
+
// https://github.com/wevm/viem/pull/2587
|
|
240
|
+
logsBloom: "0x0",
|
|
241
|
+
} as const;
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* This is non-null when the transaction created a new contract
|
|
245
|
+
* However, Paima doesn't support creating new contracts on the L2 side, so it's always null
|
|
246
|
+
* Note: you could argue that maybe we might want this in a few cases:
|
|
247
|
+
* 1. Dynamic primitives (so this would be the address in an underlying chain)
|
|
248
|
+
* 2. Dynamically creating new precompiles
|
|
249
|
+
*/
|
|
250
|
+
const mockContractAddress = {
|
|
251
|
+
contractAddress: null,
|
|
252
|
+
} as const;
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* No concept of miners in Paima (it's a based rollup) nor PoW
|
|
256
|
+
*/
|
|
257
|
+
const mockMiner = {
|
|
258
|
+
miner: "0x0",
|
|
259
|
+
mixHash: "0x0",
|
|
260
|
+
/** note: `nonce` here isn't a transaction nonce, but rather a nonce for PoW */
|
|
261
|
+
nonce: "0x0",
|
|
262
|
+
difficulty: "0x0",
|
|
263
|
+
totalDifficulty: "0x0",
|
|
264
|
+
} as const;
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* No concept of uncles in Paima as we only consider finalized blocks
|
|
268
|
+
*/
|
|
269
|
+
const mockUncles = {
|
|
270
|
+
sha3Uncles: "0x0" as const,
|
|
271
|
+
uncles: [] as `0x${string}`[],
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Paima, similar to other chains like Solana, does not Merklize state for performance reasons
|
|
276
|
+
* TODO: we could expose this as an ENV var if we really want/need to
|
|
277
|
+
*/
|
|
278
|
+
const mockRoots = {
|
|
279
|
+
transactionsRoot: "0x0",
|
|
280
|
+
stateRoot: "0x0",
|
|
281
|
+
receiptsRoot: "0x0",
|
|
282
|
+
} as const;
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* This has no purpose in Ethereum, but block creators can stuff whatever they want in here
|
|
286
|
+
* No similar concept in Paima, but we could introduce a similar concept in theory when submitting to the L2 contract
|
|
287
|
+
*/
|
|
288
|
+
const mockExtraData = {
|
|
289
|
+
extraData: "0x0",
|
|
290
|
+
} as const;
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* used for Pow, but not used anymore
|
|
294
|
+
*/
|
|
295
|
+
const mockSealFields = {
|
|
296
|
+
sealFields: ["0x0" as const],
|
|
297
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import {
|
|
2
|
+
InternalRpcError,
|
|
3
|
+
InvalidParamsRpcError,
|
|
4
|
+
MethodNotFoundRpcError,
|
|
5
|
+
MethodNotSupportedRpcError,
|
|
6
|
+
type PublicRpcSchema,
|
|
7
|
+
RpcRequestError,
|
|
8
|
+
} from "viem";
|
|
9
|
+
import { ENV } from "@effectstream/utils/node-env";
|
|
10
|
+
|
|
11
|
+
export type PaimaEvmRpcSchema = [
|
|
12
|
+
...PublicRpcSchema,
|
|
13
|
+
{
|
|
14
|
+
Method: "eth_syncing";
|
|
15
|
+
Parameters?: undefined;
|
|
16
|
+
/**
|
|
17
|
+
* different EVM clients return different values for this
|
|
18
|
+
* but the two seemingly common fields are
|
|
19
|
+
* - startingBlock
|
|
20
|
+
* - currentBlock
|
|
21
|
+
*/
|
|
22
|
+
ReturnType: boolean | {
|
|
23
|
+
startingBlock: `0x${string}`;
|
|
24
|
+
currentBlock: `0x${string}`;
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export type EvmRpcReturn<Method extends string> = Extract<
|
|
30
|
+
PaimaEvmRpcSchema[number],
|
|
31
|
+
{ Method: Method }
|
|
32
|
+
>["ReturnType"];
|
|
33
|
+
|
|
34
|
+
export function createRpcRequestError(
|
|
35
|
+
data: Record<string, unknown>,
|
|
36
|
+
code: number,
|
|
37
|
+
msg?: string,
|
|
38
|
+
): RpcRequestError {
|
|
39
|
+
return new RpcRequestError({
|
|
40
|
+
body: data,
|
|
41
|
+
url: `http://localhost:${ENV.EFFECTSTREAM_API_PORT}/rpc/evm`,
|
|
42
|
+
error: {
|
|
43
|
+
code: code,
|
|
44
|
+
message: msg ?? "No additional information provided",
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createInternalRpcError(
|
|
50
|
+
data: Record<string, unknown>,
|
|
51
|
+
msg?: string,
|
|
52
|
+
): InternalRpcError {
|
|
53
|
+
return new InternalRpcError(
|
|
54
|
+
createRpcRequestError(data, InternalRpcError.code, msg),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createMethodNotSupportedRpcError(
|
|
59
|
+
method: string,
|
|
60
|
+
msg?: string,
|
|
61
|
+
): MethodNotFoundRpcError {
|
|
62
|
+
return new MethodNotFoundRpcError(
|
|
63
|
+
createRpcRequestError({}, MethodNotFoundRpcError.code, msg),
|
|
64
|
+
{
|
|
65
|
+
method,
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function createMethodNotFoundRpcError(
|
|
71
|
+
method: string,
|
|
72
|
+
msg?: string,
|
|
73
|
+
): MethodNotSupportedRpcError {
|
|
74
|
+
return new MethodNotSupportedRpcError(
|
|
75
|
+
createRpcRequestError({}, MethodNotSupportedRpcError.code, msg),
|
|
76
|
+
{ method },
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function createInvalidParamsError(
|
|
81
|
+
data: Record<string, unknown>,
|
|
82
|
+
msg?: string,
|
|
83
|
+
): InvalidParamsRpcError {
|
|
84
|
+
return new InvalidParamsRpcError(
|
|
85
|
+
createRpcRequestError(data, InvalidParamsRpcError.code, msg),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export function isValidHexadecimal(str: string): boolean {
|
|
2
|
+
// Regular expression to match a valid hexadecimal string
|
|
3
|
+
const hexRegExp = /^[0-9a-fA-F]+$/;
|
|
4
|
+
|
|
5
|
+
// Test the string against the regular expression
|
|
6
|
+
return hexRegExp.test(str);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function isValidBlockHash(str: string): boolean {
|
|
10
|
+
// Regular expression to match a valid hexadecimal string
|
|
11
|
+
const hexRegExp = /^0x[0-9a-fA-F]{64}$/;
|
|
12
|
+
|
|
13
|
+
// Test the string against the regular expression
|
|
14
|
+
return hexRegExp.test(str);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isValidTxHash(str: string): boolean {
|
|
18
|
+
// block hashes & tx hashes have the same format
|
|
19
|
+
return isValidBlockHash(str);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function add0x(str: string): `0x${string}` {
|
|
23
|
+
if (str.startsWith("0x")) {
|
|
24
|
+
return str as `0x{string}`;
|
|
25
|
+
}
|
|
26
|
+
return `0x${str}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function strip0x(str: string): string {
|
|
30
|
+
if (str.startsWith("0x")) {
|
|
31
|
+
return str.substring(2);
|
|
32
|
+
}
|
|
33
|
+
return str;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const stringify: typeof JSON.stringify = (value, replacer, space) =>
|
|
37
|
+
JSON.stringify(
|
|
38
|
+
value,
|
|
39
|
+
(key, value_) => {
|
|
40
|
+
const value = typeof value_ === "bigint" ? value_.toString() : value_;
|
|
41
|
+
return typeof replacer === "function" ? replacer(key, value) : value;
|
|
42
|
+
},
|
|
43
|
+
space,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
export type Prettify<T> =
|
|
47
|
+
& {
|
|
48
|
+
[K in keyof T]: T[K];
|
|
49
|
+
}
|
|
50
|
+
& {};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { Client } from "pg";
|
|
2
|
+
import type { Operation } from "effection";
|
|
3
|
+
import { until } from "effection";
|
|
4
|
+
import {
|
|
5
|
+
getSyncProtocolConfigSnapshot,
|
|
6
|
+
upsertSyncProtocolConfigSnapshot,
|
|
7
|
+
} from "@effectstream/db";
|
|
8
|
+
import { ConfigNetworkType, ConfigSyncProtocolType } from "@effectstream/config";
|
|
9
|
+
import type { SyncProtocolWithNetwork } from "@effectstream/config";
|
|
10
|
+
import { log, ComponentNames, SeverityNumber } from "@effectstream/log";
|
|
11
|
+
import { getEnv } from "@effectstream/utils/runtime";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Fields that must not change between restarts for each protocol type.
|
|
15
|
+
* Changing these would cause the node to sync from a different point in time/block,
|
|
16
|
+
* leading to inconsistent state.
|
|
17
|
+
*
|
|
18
|
+
* Field names by protocol:
|
|
19
|
+
* NTP → network.startTime, network.blockTimeMS
|
|
20
|
+
* Cardano CARP → syncProtocol.startSlot
|
|
21
|
+
* Cardano UTXOrpc → syncProtocol.startChainPoint
|
|
22
|
+
* EVM, Mina, Avail, Midnight,
|
|
23
|
+
* Bitcoin, Celestia → syncProtocol.startBlockHeight
|
|
24
|
+
*/
|
|
25
|
+
function extractImmutableConfig(protocol: SyncProtocolWithNetwork): Record<string, unknown> {
|
|
26
|
+
if (protocol.networkType === ConfigNetworkType.NTP) {
|
|
27
|
+
const ntpNetwork = protocol.network as { startTime: number; blockTimeMS: number };
|
|
28
|
+
return {
|
|
29
|
+
startTime: ntpNetwork.startTime,
|
|
30
|
+
blockTimeMS: ntpNetwork.blockTimeMS,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (protocol.syncProtocolType === ConfigSyncProtocolType.CARDANO_CARP_PARALLEL) {
|
|
35
|
+
const sp = protocol.syncProtocol as { startSlot: number };
|
|
36
|
+
return { startSlot: sp.startSlot };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (protocol.syncProtocolType === ConfigSyncProtocolType.CARDANO_UTXORPC_PARALLEL) {
|
|
40
|
+
const sp = protocol.syncProtocol as { startChainPoint: unknown };
|
|
41
|
+
return { startChainPoint: sp.startChainPoint };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// EVM, Mina, Avail, Midnight, Bitcoin, Celestia
|
|
45
|
+
const knownBlockHeightProtocols: string[] = [
|
|
46
|
+
ConfigSyncProtocolType.EVM_RPC_PARALLEL,
|
|
47
|
+
ConfigSyncProtocolType.MINA_PARALLEL,
|
|
48
|
+
ConfigSyncProtocolType.AVAIL_PARALLEL,
|
|
49
|
+
ConfigSyncProtocolType.MIDNIGHT_PARALLEL,
|
|
50
|
+
ConfigSyncProtocolType.BITCOIN_RPC_PARALLEL,
|
|
51
|
+
ConfigSyncProtocolType.CELESTIA_PARALLEL,
|
|
52
|
+
ConfigSyncProtocolType.NEAR_RPC_PARALLEL,
|
|
53
|
+
];
|
|
54
|
+
if (!knownBlockHeightProtocols.includes(protocol.syncProtocolType)) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`[config-snapshot] Unhandled sync protocol type "${protocol.syncProtocolType}". ` +
|
|
57
|
+
`extractImmutableConfig must be updated to support this protocol.`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const sp = protocol.syncProtocol as { startBlockHeight: number };
|
|
62
|
+
return { startBlockHeight: sp.startBlockHeight };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Checks the saved config snapshot for each sync protocol against the current config.
|
|
67
|
+
*
|
|
68
|
+
* On the first run (no snapshot in DB), the current config is persisted as the canonical snapshot.
|
|
69
|
+
*
|
|
70
|
+
* On subsequent runs, if an immutable field has changed:
|
|
71
|
+
* - If the `USE_DB_STARTHEIGHT` environment variable is set, the DB value is used and a warning is logged.
|
|
72
|
+
* - Otherwise, an error is thrown to prevent the node from starting with an inconsistent config.
|
|
73
|
+
*
|
|
74
|
+
* The function mutates `syncInfo` in place when `USE_DB_STARTHEIGHT` overrides values.
|
|
75
|
+
*/
|
|
76
|
+
export function* validateAndSnapshotConfig(
|
|
77
|
+
syncInfo: SyncProtocolWithNetwork[],
|
|
78
|
+
dbConn: Client,
|
|
79
|
+
): Operation<void> {
|
|
80
|
+
console.log("validateAndSnapshotConfig");
|
|
81
|
+
const useDbStartHeight = getEnv("USE_DB_STARTHEIGHT") !== undefined;
|
|
82
|
+
|
|
83
|
+
for (const protocol of syncInfo) {
|
|
84
|
+
const protocolName: string = (protocol.syncProtocol as { name: string }).name;
|
|
85
|
+
const currentImmutable = extractImmutableConfig(protocol);
|
|
86
|
+
|
|
87
|
+
const rows = yield* until(
|
|
88
|
+
getSyncProtocolConfigSnapshot.run({ protocolName }, dbConn),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
if (rows.length === 0) {
|
|
92
|
+
// First run: persist the snapshot.
|
|
93
|
+
yield* until(
|
|
94
|
+
upsertSyncProtocolConfigSnapshot.run(
|
|
95
|
+
{
|
|
96
|
+
protocolName,
|
|
97
|
+
networkType: protocol.networkType,
|
|
98
|
+
immutableConfig: JSON.stringify(currentImmutable),
|
|
99
|
+
},
|
|
100
|
+
dbConn,
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
log.remote(
|
|
104
|
+
ComponentNames.EFFECTSTREAM_RUNTIME,
|
|
105
|
+
[],
|
|
106
|
+
SeverityNumber.INFO,
|
|
107
|
+
(l) => l(`[config-snapshot] Saved initial config snapshot for protocol "${protocolName}"`),
|
|
108
|
+
);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const saved = rows[0].immutable_config as Record<string, unknown>;
|
|
113
|
+
const mismatches: string[] = [];
|
|
114
|
+
console.log("saved", saved);
|
|
115
|
+
for (const [key, savedValue] of Object.entries(saved)) {
|
|
116
|
+
console.log("key", key, savedValue, currentImmutable[key]);
|
|
117
|
+
// if (currentImmutable[key] !== savedValue) {
|
|
118
|
+
// mismatches.push(` ${key}: saved=${JSON.stringify(savedValue)}, current=${JSON.stringify(currentImmutable[key])}`);
|
|
119
|
+
// }
|
|
120
|
+
if (typeof savedValue === "object" && savedValue !== null) {
|
|
121
|
+
for (const [k, v] of Object.entries(savedValue)) {
|
|
122
|
+
if ((currentImmutable[key] as any)[k] !== v) {
|
|
123
|
+
mismatches.push(` ${key}.${k}: saved=${JSON.stringify(v)},\n current=${JSON.stringify((currentImmutable[key] as any)[k])}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} else if (currentImmutable[key] !== savedValue) {
|
|
127
|
+
mismatches.push(` ${key}: saved=${JSON.stringify(savedValue)},\n current=${JSON.stringify(currentImmutable[key])}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (mismatches.length === 0) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const mismatchDetails = mismatches.join("\n");
|
|
136
|
+
|
|
137
|
+
if (!useDbStartHeight) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`[config-snapshot] CRITICAL: Immutable config fields have changed for protocol "${protocolName}".\n` +
|
|
140
|
+
`${mismatchDetails}\n\n` +
|
|
141
|
+
`If this is intentional (e.g. you want to resume from the original start height/time stored in the DB), ` +
|
|
142
|
+
`set the USE_DB_STARTHEIGHT environment variable and restart the service.\n` +
|
|
143
|
+
`WARNING: Only set USE_DB_STARTHEIGHT if you understand the implications — mismatched start values ` +
|
|
144
|
+
`can cause the node to skip blocks or produce inconsistent state.`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// USE_DB_STARTHEIGHT is set — apply the DB values and warn.
|
|
149
|
+
log.remote(
|
|
150
|
+
ComponentNames.EFFECTSTREAM_RUNTIME,
|
|
151
|
+
[],
|
|
152
|
+
SeverityNumber.WARN,
|
|
153
|
+
(l) =>
|
|
154
|
+
l(
|
|
155
|
+
`[config-snapshot] WARNING: Immutable config mismatch for protocol "${protocolName}". ` +
|
|
156
|
+
`USE_DB_STARTHEIGHT is set; overriding in-memory config with DB snapshot values.\n${mismatchDetails}`,
|
|
157
|
+
),
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
applySnapshotOverrides(protocol, saved);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Applies the saved immutable values from `snapshot` onto the in-memory protocol config.
|
|
166
|
+
* This mutates the protocol objects so the rest of the startup uses the corrected values.
|
|
167
|
+
*/
|
|
168
|
+
function applySnapshotOverrides(
|
|
169
|
+
protocol: SyncProtocolWithNetwork,
|
|
170
|
+
snapshot: Record<string, unknown>,
|
|
171
|
+
): void {
|
|
172
|
+
if (protocol.networkType === ConfigNetworkType.NTP) {
|
|
173
|
+
const ntpNetwork = protocol.network as Record<string, unknown>;
|
|
174
|
+
if ("startTime" in snapshot) ntpNetwork["startTime"] = snapshot["startTime"];
|
|
175
|
+
if ("blockTimeMS" in snapshot) ntpNetwork["blockTimeMS"] = snapshot["blockTimeMS"];
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const sp = protocol.syncProtocol as Record<string, unknown>;
|
|
180
|
+
|
|
181
|
+
if (protocol.syncProtocolType === ConfigSyncProtocolType.CARDANO_CARP_PARALLEL) {
|
|
182
|
+
if ("startSlot" in snapshot) sp["startSlot"] = snapshot["startSlot"];
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (protocol.syncProtocolType === ConfigSyncProtocolType.CARDANO_UTXORPC_PARALLEL) {
|
|
187
|
+
if ("startChainPoint" in snapshot) sp["startChainPoint"] = snapshot["startChainPoint"];
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if ("startBlockHeight" in snapshot) sp["startBlockHeight"] = snapshot["startBlockHeight"];
|
|
192
|
+
}
|