@chainlink/cre-sdk 1.6.0-alpha.2 → 1.6.0-alpha.3

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.
Files changed (38) hide show
  1. package/README.md +7 -1
  2. package/bin/cre-compile.ts +34 -17
  3. package/dist/generated/capabilities/blockchain/aptos/v1alpha/client_pb.d.ts +1023 -0
  4. package/dist/generated/capabilities/blockchain/aptos/v1alpha/client_pb.js +290 -0
  5. package/dist/generated/capabilities/blockchain/solana/v1alpha/client_pb.d.ts +2904 -0
  6. package/dist/generated/capabilities/blockchain/solana/v1alpha/client_pb.js +506 -0
  7. package/dist/generated-sdk/capabilities/blockchain/aptos/v1alpha/client_sdk_gen.d.ts +52 -0
  8. package/dist/generated-sdk/capabilities/blockchain/aptos/v1alpha/client_sdk_gen.js +186 -0
  9. package/dist/generated-sdk/capabilities/blockchain/solana/v1alpha/client_sdk_gen.d.ts +92 -0
  10. package/dist/generated-sdk/capabilities/blockchain/solana/v1alpha/client_sdk_gen.js +343 -0
  11. package/dist/sdk/cre/index.d.ts +6 -0
  12. package/dist/sdk/cre/index.js +8 -0
  13. package/dist/sdk/report.js +0 -15
  14. package/dist/sdk/test/generated/capabilities/blockchain/aptos/v1alpha/aptos_mock_gen.d.ts +25 -0
  15. package/dist/sdk/test/generated/capabilities/blockchain/aptos/v1alpha/aptos_mock_gen.js +111 -0
  16. package/dist/sdk/test/generated/capabilities/blockchain/solana/v1alpha/solana_mock_gen.d.ts +33 -0
  17. package/dist/sdk/test/generated/capabilities/blockchain/solana/v1alpha/solana_mock_gen.js +178 -0
  18. package/dist/sdk/test/generated/index.d.ts +2 -0
  19. package/dist/sdk/test/generated/index.js +2 -0
  20. package/package.json +3 -3
  21. package/scripts/run-standard-tests.sh +3 -3
  22. package/scripts/run.ts +6 -1
  23. package/scripts/src/check-determinism.test.ts +64 -0
  24. package/scripts/src/check-determinism.ts +32 -0
  25. package/scripts/src/compile-cli-args.test.ts +32 -0
  26. package/scripts/src/compile-cli-args.ts +35 -0
  27. package/scripts/src/compile-to-js.test.ts +90 -0
  28. package/scripts/src/compile-to-js.ts +53 -7
  29. package/scripts/src/compile-to-wasm.ts +18 -32
  30. package/scripts/src/compile-workflow.ts +55 -27
  31. package/scripts/src/generate-chain-selectors.ts +9 -27
  32. package/scripts/src/generate-sdks.ts +12 -0
  33. package/scripts/src/typecheck-workflow.test.ts +77 -0
  34. package/scripts/src/typecheck-workflow.ts +96 -0
  35. package/scripts/src/validate-shared.ts +400 -0
  36. package/scripts/src/validate-workflow-determinism.test.ts +409 -0
  37. package/scripts/src/validate-workflow-determinism.ts +545 -0
  38. package/scripts/src/validate-workflow-runtime-compat.ts +25 -377
@@ -0,0 +1,545 @@
1
+ /**
2
+ * Workflow Determinism Warning Analyzer
3
+ *
4
+ * CRE workflows execute on a Decentralized Oracle Network (DON) where multiple
5
+ * nodes must reach consensus. Non-deterministic patterns — code that can produce
6
+ * different results on different nodes — may prevent consensus.
7
+ *
8
+ * This module performs **static analysis** on workflow source code to detect
9
+ * patterns that are likely non-deterministic and warns developers at build time.
10
+ * Unlike the runtime compatibility validator, this module produces **warnings**
11
+ * that do not block compilation.
12
+ *
13
+ * ## Detected patterns
14
+ *
15
+ * - `Promise.race()` / `Promise.any()` / `Promise.all()` — concurrent execution, timing-dependent
16
+ * - `Date.now()` / `new Date()` — system clock varies across nodes
17
+ * - `for...in` loops — iteration order is not guaranteed by the spec
18
+ * - `Object.keys()` / `Object.values()` / `Object.entries()` without `.sort()`
19
+ *
20
+ * ## What is NOT detected
21
+ *
22
+ * - `Math.random()` — the Javy plugin overrides it with a seeded ChaCha8 CSPRNG
23
+ * that is deterministic in NODE mode. Safe by design.
24
+ *
25
+ * @see https://docs.chain.link/cre/concepts/non-determinism-ts
26
+ */
27
+
28
+ import * as ts from 'typescript'
29
+ import {
30
+ collectLocalSourceFiles,
31
+ createValidationProgram,
32
+ createViolation,
33
+ formatViolations,
34
+ toAbsolutePath,
35
+ type Violation,
36
+ } from './validate-shared'
37
+
38
+ const DOCS_URL = 'https://docs.chain.link/cre/concepts/non-determinism-ts'
39
+
40
+ /**
41
+ * A global object reference can be accessed directly (`Date`) or via
42
+ * `globalThis.Date`.
43
+ */
44
+ type GlobalObjectReference = {
45
+ identifier: ts.Identifier
46
+ }
47
+
48
+ const bindingNameContains = (bindingName: ts.BindingName, name: string): boolean => {
49
+ if (ts.isIdentifier(bindingName)) {
50
+ return bindingName.text === name
51
+ }
52
+
53
+ if (ts.isObjectBindingPattern(bindingName)) {
54
+ return bindingName.elements.some((element) => bindingNameContains(element.name, name))
55
+ }
56
+
57
+ return bindingName.elements.some(
58
+ (element) => !ts.isOmittedExpression(element) && bindingNameContains(element.name, name),
59
+ )
60
+ }
61
+
62
+ const importClauseDeclaresName = (importClause: ts.ImportClause, name: string): boolean => {
63
+ if (importClause.name?.text === name) {
64
+ return true
65
+ }
66
+
67
+ const namedBindings = importClause.namedBindings
68
+ if (!namedBindings) {
69
+ return false
70
+ }
71
+
72
+ if (ts.isNamespaceImport(namedBindings)) {
73
+ return namedBindings.name.text === name
74
+ }
75
+
76
+ return namedBindings.elements.some((element) => element.name.text === name)
77
+ }
78
+
79
+ const variableDeclarationListDeclaresName = (
80
+ declarationList: ts.VariableDeclarationList,
81
+ name: string,
82
+ ): boolean =>
83
+ declarationList.declarations.some((declaration) => bindingNameContains(declaration.name, name))
84
+
85
+ const statementDeclaresRuntimeName = (statement: ts.Statement, name: string): boolean => {
86
+ if (ts.isVariableStatement(statement)) {
87
+ return variableDeclarationListDeclaresName(statement.declarationList, name)
88
+ }
89
+
90
+ if (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) {
91
+ return statement.name?.text === name
92
+ }
93
+
94
+ if (ts.isEnumDeclaration(statement) || ts.isModuleDeclaration(statement)) {
95
+ return statement.name.text === name
96
+ }
97
+
98
+ if (ts.isImportDeclaration(statement) && statement.importClause) {
99
+ return importClauseDeclaresName(statement.importClause, name)
100
+ }
101
+
102
+ if (ts.isImportEqualsDeclaration(statement)) {
103
+ return statement.name.text === name
104
+ }
105
+
106
+ if (
107
+ (ts.isForStatement(statement) ||
108
+ ts.isForInStatement(statement) ||
109
+ ts.isForOfStatement(statement)) &&
110
+ statement.initializer &&
111
+ ts.isVariableDeclarationList(statement.initializer)
112
+ ) {
113
+ return variableDeclarationListDeclaresName(statement.initializer, name)
114
+ }
115
+
116
+ if (ts.isSwitchStatement(statement)) {
117
+ return statement.caseBlock.clauses.some((clause) =>
118
+ clause.statements.some((clauseStatement) =>
119
+ statementDeclaresRuntimeName(clauseStatement, name),
120
+ ),
121
+ )
122
+ }
123
+
124
+ return false
125
+ }
126
+
127
+ /**
128
+ * Checks whether a name is shadowed by a declaration that is actually visible
129
+ * at the current use site. This avoids suppressing warnings due to unrelated
130
+ * declarations in nested scopes elsewhere in the file.
131
+ */
132
+ const hasLocalDeclarationInScope = (
133
+ name: string,
134
+ referenceNode: ts.Node,
135
+ currentSourceFile: ts.SourceFile,
136
+ ): boolean => {
137
+ let current: ts.Node | undefined = referenceNode
138
+
139
+ while (current) {
140
+ if (
141
+ (ts.isFunctionDeclaration(current) || ts.isFunctionExpression(current)) &&
142
+ current.name &&
143
+ ts.isIdentifier(current.name) &&
144
+ current.name.text === name
145
+ ) {
146
+ return true
147
+ }
148
+
149
+ if (
150
+ (ts.isClassDeclaration(current) || ts.isClassExpression(current)) &&
151
+ current.name &&
152
+ ts.isIdentifier(current.name) &&
153
+ current.name.text === name
154
+ ) {
155
+ return true
156
+ }
157
+
158
+ if (
159
+ ts.isFunctionLike(current) &&
160
+ current.parameters.some((param) => bindingNameContains(param.name, name))
161
+ ) {
162
+ return true
163
+ }
164
+
165
+ if (ts.isCatchClause(current) && current.variableDeclaration) {
166
+ if (bindingNameContains(current.variableDeclaration.name, name)) {
167
+ return true
168
+ }
169
+ }
170
+
171
+ if (
172
+ (ts.isForStatement(current) ||
173
+ ts.isForInStatement(current) ||
174
+ ts.isForOfStatement(current)) &&
175
+ current.initializer &&
176
+ ts.isVariableDeclarationList(current.initializer)
177
+ ) {
178
+ if (variableDeclarationListDeclaresName(current.initializer, name)) {
179
+ return true
180
+ }
181
+ }
182
+
183
+ if (ts.isSourceFile(current) || ts.isBlock(current) || ts.isModuleBlock(current)) {
184
+ const refPos = referenceNode.pos
185
+ if (
186
+ current.statements.some((statement) => {
187
+ if (!statementDeclaresRuntimeName(statement, name)) return false
188
+ // Function declarations and imports are hoisted / always visible in scope.
189
+ if (
190
+ ts.isFunctionDeclaration(statement) ||
191
+ ts.isImportDeclaration(statement) ||
192
+ ts.isImportEqualsDeclaration(statement)
193
+ ) {
194
+ return true
195
+ }
196
+ // Other declarations (const, let, var, class) are only a shadow once
197
+ // they have been fully declared — i.e. the statement ends before the usage.
198
+ return statement.end <= refPos
199
+ })
200
+ ) {
201
+ return true
202
+ }
203
+ }
204
+
205
+ if (ts.isSwitchStatement(current)) {
206
+ const refPos = referenceNode.pos
207
+ if (
208
+ current.caseBlock.clauses.some((clause) =>
209
+ clause.statements.some((statement) => {
210
+ if (!statementDeclaresRuntimeName(statement, name)) return false
211
+ if (
212
+ ts.isFunctionDeclaration(statement) ||
213
+ ts.isImportDeclaration(statement) ||
214
+ ts.isImportEqualsDeclaration(statement)
215
+ ) {
216
+ return true
217
+ }
218
+ return statement.end <= refPos
219
+ }),
220
+ )
221
+ ) {
222
+ return true
223
+ }
224
+ }
225
+
226
+ if (current === currentSourceFile) {
227
+ break
228
+ }
229
+
230
+ current = current.parent
231
+ }
232
+
233
+ return false
234
+ }
235
+
236
+ const hasLocalDeclarationViaChecker = (
237
+ identifier: ts.Identifier,
238
+ checker: ts.TypeChecker,
239
+ localSourceFiles: Set<string>,
240
+ ): boolean => {
241
+ const symbol = checker.getSymbolAtLocation(identifier)
242
+ return (
243
+ symbol?.declarations?.some((declaration) => {
244
+ if (!localSourceFiles.has(toAbsolutePath(declaration.getSourceFile().fileName))) {
245
+ return false
246
+ }
247
+ // Function declarations and import bindings are hoisted / available throughout
248
+ // their scope — always count as a shadow regardless of position.
249
+ if (
250
+ ts.isFunctionDeclaration(declaration) ||
251
+ ts.isImportClause(declaration) ||
252
+ ts.isImportSpecifier(declaration) ||
253
+ ts.isNamespaceImport(declaration) ||
254
+ ts.isImportEqualsDeclaration(declaration)
255
+ ) {
256
+ return true
257
+ }
258
+ // const / let / var / class are only visible once their declaration is complete.
259
+ // A usage that appears before the declaration (e.g. due to TDZ) still refers
260
+ // to the global, so do not suppress the warning.
261
+ return declaration.end <= identifier.pos
262
+ }) ?? false
263
+ )
264
+ }
265
+
266
+ const getGlobalObjectReference = (
267
+ expression: ts.LeftHandSideExpression,
268
+ objectName: string,
269
+ ): GlobalObjectReference | null => {
270
+ if (ts.isIdentifier(expression) && expression.text === objectName) {
271
+ return { identifier: expression }
272
+ }
273
+
274
+ if (
275
+ ts.isPropertyAccessExpression(expression) &&
276
+ ts.isIdentifier(expression.expression) &&
277
+ expression.expression.text === 'globalThis' &&
278
+ expression.name.text === objectName
279
+ ) {
280
+ return { identifier: expression.expression }
281
+ }
282
+
283
+ return null
284
+ }
285
+
286
+ /**
287
+ * Determines whether an expression resolves to a true global object (e.g.
288
+ * `Date`, `Promise`, `Object`) rather than a user-defined local with the
289
+ * same name.
290
+ *
291
+ * Uses a two-layer approach:
292
+ * 1. **Type-checker** (`hasLocalDeclarationViaChecker`) — the authoritative
293
+ * source when the checker can resolve the symbol. This handles most TS
294
+ * files with full type information.
295
+ * 2. **AST scope walk** (`hasLocalDeclarationInScope`) — a syntactic fallback
296
+ * for cases where the type-checker cannot resolve the symbol (e.g. loose
297
+ * JS files, files outside the compilation root, or declaration-less
298
+ * globals). This mirrors the scoping rules manually so we still suppress
299
+ * warnings for locally-shadowed names.
300
+ *
301
+ * The runtime compat validator only needs the type-checker layer because it
302
+ * checks simple identifier names (`fetch`, `setTimeout`). This validator
303
+ * additionally needs the AST fallback because it checks property-access
304
+ * patterns on globals (`Date.now()`, `Promise.race()`) where the root
305
+ * identifier may not have a resolvable symbol.
306
+ */
307
+ const resolvesToGlobalObject = (
308
+ expression: ts.LeftHandSideExpression,
309
+ objectName: string,
310
+ checker: ts.TypeChecker,
311
+ localSourceFiles: Set<string>,
312
+ currentSourceFile: ts.SourceFile,
313
+ ): boolean => {
314
+ const reference = getGlobalObjectReference(expression, objectName)
315
+ if (!reference) {
316
+ return false
317
+ }
318
+
319
+ if (hasLocalDeclarationViaChecker(reference.identifier, checker, localSourceFiles)) {
320
+ return false
321
+ }
322
+
323
+ const fallbackName = reference.identifier.text
324
+ return !hasLocalDeclarationInScope(fallbackName, reference.identifier, currentSourceFile)
325
+ }
326
+
327
+ /**
328
+ * Checks whether a `CallExpression` is a method call on a global object.
329
+ * For example, `Promise.race(...)` or `globalThis.Promise.race(...)`.
330
+ *
331
+ * Returns the method name if matched, otherwise `null`.
332
+ */
333
+ const getGlobalMethodCall = (
334
+ node: ts.CallExpression,
335
+ objectName: string,
336
+ methodNames: Set<string>,
337
+ checker: ts.TypeChecker,
338
+ localSourceFiles: Set<string>,
339
+ currentSourceFile: ts.SourceFile,
340
+ ): string | null => {
341
+ if (!ts.isPropertyAccessExpression(node.expression)) return null
342
+
343
+ const propAccess = node.expression
344
+ if (!methodNames.has(propAccess.name.text)) return null
345
+ if (
346
+ !resolvesToGlobalObject(
347
+ propAccess.expression,
348
+ objectName,
349
+ checker,
350
+ localSourceFiles,
351
+ currentSourceFile,
352
+ )
353
+ ) {
354
+ return null
355
+ }
356
+
357
+ return propAccess.name.text
358
+ }
359
+
360
+ /**
361
+ * Checks whether a call to `Object.keys/values/entries()` is followed anywhere
362
+ * in the method chain by `.sort()` or `.toSorted()`, which makes the iteration
363
+ * order deterministic.
364
+ *
365
+ * Handles both direct chaining (`Object.keys(obj).sort()`) and intermediate
366
+ * calls (`Object.keys(obj).filter(...).sort()`). A `.sort()` that appears after
367
+ * any number of intermediate method calls still produces a deterministically
368
+ * ordered result.
369
+ *
370
+ * Note: this check is syntactic — it does not verify that the array returned
371
+ * by `Object.keys/values/entries()` is the same one eventually sorted.
372
+ * Patterns such as assigning to a variable and sorting later are not detected.
373
+ *
374
+ * The chain walk is capped at {@link MAX_CHAIN_DEPTH} iterations to guard
375
+ * against degenerate or malformed ASTs.
376
+ */
377
+ const MAX_CHAIN_DEPTH = 50
378
+ const isFollowedBySort = (callNode: ts.CallExpression): boolean => {
379
+ let current: ts.Node = callNode
380
+
381
+ for (let depth = 0; depth < MAX_CHAIN_DEPTH; depth++) {
382
+ const parent = current.parent
383
+ if (!ts.isPropertyAccessExpression(parent)) return false
384
+ // The PropertyAccessExpression must be the callee of a CallExpression
385
+ if (!ts.isCallExpression(parent.parent)) return false
386
+
387
+ if (parent.name.text === 'sort' || parent.name.text === 'toSorted') return true
388
+
389
+ // Some other chained method call — keep walking up the chain
390
+ current = parent.parent
391
+ }
392
+
393
+ return false
394
+ }
395
+
396
+ /**
397
+ * Collects determinism warnings from all local source files in the program.
398
+ */
399
+ const collectDeterminismWarnings = (
400
+ program: ts.Program,
401
+ localSourceFiles: Set<string>,
402
+ warnings: Violation[],
403
+ ) => {
404
+ const checker = program.getTypeChecker()
405
+
406
+ const promiseMethods = new Set(['race', 'any', 'all'])
407
+ const dateMethods = new Set(['now'])
408
+ const objectIterationMethods = new Set(['keys', 'values', 'entries'])
409
+
410
+ for (const sourceFile of program.getSourceFiles()) {
411
+ const resolvedSourcePath = toAbsolutePath(sourceFile.fileName)
412
+ if (!localSourceFiles.has(resolvedSourcePath)) continue
413
+
414
+ const visit = (node: ts.Node) => {
415
+ // --- Promise.race() / Promise.any() ---
416
+ if (ts.isCallExpression(node)) {
417
+ const promiseMethod = getGlobalMethodCall(
418
+ node,
419
+ 'Promise',
420
+ promiseMethods,
421
+ checker,
422
+ localSourceFiles,
423
+ sourceFile,
424
+ )
425
+ if (promiseMethod) {
426
+ const promiseWarning =
427
+ promiseMethod === 'all'
428
+ ? `Promise.all() executes promises concurrently — side effects may occur in different orders across nodes.`
429
+ : `Promise.${promiseMethod}() is non-deterministic — the first ${promiseMethod === 'race' ? 'settled' : 'fulfilled'} promise wins, and timing varies across nodes.`
430
+ warnings.push(
431
+ createViolation(
432
+ resolvedSourcePath,
433
+ node.expression.getStart(sourceFile),
434
+ sourceFile,
435
+ promiseWarning,
436
+ ),
437
+ )
438
+ }
439
+
440
+ // --- Date.now() ---
441
+ const dateMethod = getGlobalMethodCall(
442
+ node,
443
+ 'Date',
444
+ dateMethods,
445
+ checker,
446
+ localSourceFiles,
447
+ sourceFile,
448
+ )
449
+ if (dateMethod) {
450
+ warnings.push(
451
+ createViolation(
452
+ resolvedSourcePath,
453
+ node.expression.getStart(sourceFile),
454
+ sourceFile,
455
+ 'Date.now() uses the system clock which varies across nodes.',
456
+ ),
457
+ )
458
+ }
459
+
460
+ // --- Object.keys/values/entries() without .sort() ---
461
+ const objectMethod = getGlobalMethodCall(
462
+ node,
463
+ 'Object',
464
+ objectIterationMethods,
465
+ checker,
466
+ localSourceFiles,
467
+ sourceFile,
468
+ )
469
+ if (objectMethod && !isFollowedBySort(node)) {
470
+ warnings.push(
471
+ createViolation(
472
+ resolvedSourcePath,
473
+ node.expression.getStart(sourceFile),
474
+ sourceFile,
475
+ `Object.${objectMethod}() returns items in an order that may vary across engines. Chain with .sort() for deterministic ordering.`,
476
+ ),
477
+ )
478
+ }
479
+ }
480
+
481
+ // --- new Date() with no arguments ---
482
+ if (
483
+ ts.isNewExpression(node) &&
484
+ resolvesToGlobalObject(node.expression, 'Date', checker, localSourceFiles, sourceFile) &&
485
+ (!node.arguments || node.arguments.length === 0)
486
+ ) {
487
+ warnings.push(
488
+ createViolation(
489
+ resolvedSourcePath,
490
+ node.getStart(sourceFile),
491
+ sourceFile,
492
+ 'new Date() without arguments uses the system clock which varies across nodes. Pass an explicit timestamp instead.',
493
+ ),
494
+ )
495
+ }
496
+
497
+ // --- for...in loops ---
498
+ if (ts.isForInStatement(node)) {
499
+ warnings.push(
500
+ createViolation(
501
+ resolvedSourcePath,
502
+ node.getStart(sourceFile),
503
+ sourceFile,
504
+ 'for...in loop iteration order is not guaranteed by the spec and may vary across engines. Use for...of with Object.keys().sort() instead.',
505
+ ),
506
+ )
507
+ }
508
+
509
+ ts.forEachChild(node, visit)
510
+ }
511
+
512
+ visit(sourceFile)
513
+ }
514
+ }
515
+
516
+ /**
517
+ * Analyzes a workflow entry file (and all local files it transitively imports)
518
+ * for non-deterministic patterns that may prevent DON consensus.
519
+ *
520
+ * Returns an array of warnings. Does **not** throw — compilation should
521
+ * continue regardless of warnings.
522
+ */
523
+ export const checkWorkflowDeterminism = (entryFilePath: string): Violation[] => {
524
+ const rootFile = toAbsolutePath(entryFilePath)
525
+ const localSourceFiles = collectLocalSourceFiles(rootFile)
526
+ const program = createValidationProgram(rootFile, localSourceFiles)
527
+ const warnings: Violation[] = []
528
+
529
+ collectDeterminismWarnings(program, localSourceFiles, warnings)
530
+
531
+ return warnings
532
+ }
533
+
534
+ /**
535
+ * Prints determinism warnings to stderr in a user-friendly format.
536
+ */
537
+ export const printDeterminismWarnings = (warnings: Violation[]) => {
538
+ console.warn(
539
+ `\n⚠️ Non-determinism warnings (compilation will continue):
540
+ These patterns may prevent nodes from reaching consensus on the DON.
541
+ See ${DOCS_URL}
542
+
543
+ ${formatViolations(warnings)}\n`,
544
+ )
545
+ }