@cashscript/utils 0.13.0-next.7 → 0.13.0-next.9

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.
@@ -1,5 +1,6 @@
1
1
  export interface CompilerOptions {
2
2
  enforceFunctionParameterTypes?: boolean;
3
+ enforceLocktimeGuard?: boolean;
3
4
  }
4
5
  export interface AbiInput {
5
6
  name: string;
@@ -46,5 +47,6 @@ export interface Artifact {
46
47
  options?: CompilerOptions;
47
48
  };
48
49
  updatedAt: string;
50
+ fingerprint?: string;
49
51
  }
50
52
  export declare function formatArtifact(artifact: Artifact, format: 'json' | 'ts'): string;
package/dist/artifact.js CHANGED
@@ -1,37 +1,65 @@
1
1
  export function formatArtifact(artifact, format) {
2
2
  if (format === 'ts') {
3
- // We remove any undefined values to make the artifact serializable using stringifyAsTs
3
+ // We remove any undefined values to make the artifact serializable
4
4
  const normalisedArtifact = JSON.parse(JSON.stringify(artifact));
5
- return `export default ${stringifyAsTs(normalisedArtifact)} as const;\n`;
5
+ return `export default ${stringify(normalisedArtifact, 'ts')} as const;\n`;
6
6
  }
7
- return JSON.stringify(artifact, null, 2);
7
+ return stringify(artifact, 'json');
8
8
  }
9
9
  const indent = (level) => ' '.repeat(level);
10
- function stringifyAsTs(obj, indentationLevel = 1) {
11
- // For strings we use JSON.stringify to handle escaping, but we want to use single quotes instead of double quotes
12
- // around string values inside objects, to match regular TS style
13
- if (typeof obj === 'string') {
14
- return `'${JSON.stringify(obj).replace(/'/g, "\\'").replace(/\\"/g, '"').slice(1, -1)}'`;
15
- }
16
- // Numbers and booleans are just converted to strings
17
- if (typeof obj === 'number' || typeof obj === 'boolean') {
10
+ // Objects with this many or more properties are always expanded onto multiple lines, to keep
11
+ // generated `.artifact.ts` files compliant with the `object-curly-newline` lint rule (minProperties: 4)
12
+ const MAX_INLINE_OBJECT_PROPERTIES = 3;
13
+ // Recursively serialises an artifact to JSON or TS. Small records that are elements of an array
14
+ // (e.g. `requires`, `constructorInputs`, `abi` inputs) are kept on a single line so list-like data
15
+ // stays compact, while standalone objects assigned to a property (e.g. `compiler.options`) keep
16
+ // their values expanded one per line.
17
+ function stringify(obj, format, indentationLevel = 1, isArrayElement = false) {
18
+ if (typeof obj === 'string')
19
+ return formatString(obj, format);
20
+ // Numbers, booleans and null are represented identically in JSON and TS
21
+ if (obj === null || typeof obj === 'number' || typeof obj === 'boolean') {
18
22
  return JSON.stringify(obj);
19
23
  }
20
- // Arrays are recursively formatted with indentation
21
- if (Array.isArray(obj)) {
22
- if (obj.length === 0)
23
- return '[]';
24
- const formattedItems = obj.map((item) => `${indent(indentationLevel)}${stringifyAsTs(item, indentationLevel + 1)}`);
25
- return `[\n${formattedItems.join(',\n')},\n${indent(indentationLevel - 1)}]`;
26
- }
27
- // Objects are recursively formatted with indentation
28
- if (typeof obj === 'object') {
29
- const entries = Object.entries(obj);
30
- if (entries.length === 0)
31
- return '{}';
32
- const formattedEntries = entries.map(([key, value]) => (`${indent(indentationLevel)}${key}: ${stringifyAsTs(value, indentationLevel + 1)}`));
33
- return `{\n${formattedEntries.join(',\n')},\n${indent(indentationLevel - 1)}}`;
34
- }
24
+ if (Array.isArray(obj))
25
+ return formatArray(obj, format, indentationLevel, isArrayElement);
26
+ if (typeof obj === 'object')
27
+ return formatObject(obj, format, indentationLevel, isArrayElement);
35
28
  throw new Error(`Unsupported type: ${typeof obj}`);
36
29
  }
30
+ function formatString(value, format) {
31
+ // JSON strings use standard double-quoted escaping
32
+ if (format === 'json')
33
+ return JSON.stringify(value);
34
+ // TS strings use single quotes instead of double quotes around string values, to match regular TS style
35
+ return `'${JSON.stringify(value).replace(/'/g, "\\'").replace(/\\"/g, '"').slice(1, -1)}'`;
36
+ }
37
+ function formatArray(array, format, indentationLevel, isArrayElement) {
38
+ if (array.length === 0)
39
+ return '[]';
40
+ const items = array.map((item) => stringify(item, format, indentationLevel + 1, true));
41
+ if (isArrayElement && canInline(array))
42
+ return `[${items.join(', ')}]`;
43
+ const lines = items.map((item) => `${indent(indentationLevel)}${item}`).join(',\n');
44
+ const trailingComma = format === 'ts' ? ',' : '';
45
+ return `[\n${lines}${trailingComma}\n${indent(indentationLevel - 1)}]`;
46
+ }
47
+ function formatObject(obj, format, indentationLevel, isArrayElement) {
48
+ const entries = Object.entries(obj).filter(([, value]) => value !== undefined);
49
+ if (entries.length === 0)
50
+ return '{}';
51
+ const formatKey = (key) => (format === 'json' ? JSON.stringify(key) : key);
52
+ const formatted = entries.map(([key, value]) => `${formatKey(key)}: ${stringify(value, format, indentationLevel + 1)}`);
53
+ if (isArrayElement && entries.length <= MAX_INLINE_OBJECT_PROPERTIES && canInline(obj)) {
54
+ return `{ ${formatted.join(', ')} }`;
55
+ }
56
+ const lines = formatted.map((entry) => `${indent(indentationLevel)}${entry}`).join(',\n');
57
+ const trailingComma = format === 'ts' ? ',' : '';
58
+ return `{\n${lines}${trailingComma}\n${indent(indentationLevel - 1)}}`;
59
+ }
60
+ // A container can be inlined when none of its values are themselves a non-empty object or array
61
+ function canInline(container) {
62
+ const values = Array.isArray(container) ? container : Object.values(container);
63
+ return values.every((value) => (value === undefined || value === null || typeof value !== 'object' || Object.keys(value).length === 0));
64
+ }
37
65
  //# sourceMappingURL=artifact.js.map
@@ -1,7 +1,7 @@
1
1
  import { range } from './data.js';
2
2
  import { scriptToBitAuthAsm } from './script.js';
3
3
  import { parseSourceTags, sourceMapToLocationData } from './source-map.js';
4
- import { PositionHint } from './types.js';
4
+ import { PositionHint, SourceTagKind } from './types.js';
5
5
  export function buildLineToOpcodesMap(bytecode, sourceMapOrLocationData) {
6
6
  const locationData = typeof sourceMapOrLocationData === 'string' ? sourceMapToLocationData(sourceMapOrLocationData) : sourceMapOrLocationData;
7
7
  return locationData.reduce((lineToOpcodeMap, singleLocation, index) => {
@@ -43,14 +43,38 @@ function getDisplayLine(singleLocation) {
43
43
  function escapeCommentChars(text) {
44
44
  return text.replaceAll('/*', '\\/*').replaceAll('*/', '*\\/');
45
45
  }
46
+ // Compiler-injected prologue checks: emitted before the function body but executing at its top.
47
+ const PROLOGUE_KINDS = [
48
+ SourceTagKind.LOCKTIME_GUARD,
49
+ SourceTagKind.PARAMETER_VALIDATION,
50
+ ];
46
51
  function buildInsertions(locationData, sourceLines, sourceTags) {
47
52
  const tags = (sourceTags ? parseSourceTags(sourceTags) : []);
48
53
  return tags.map((tag) => {
49
- const annotation = deriveTagLabel(tag, locationData, sourceLines);
50
- const insertAfterLine = getDisplayLine(locationData[Math.max(tag.startIndex - 1, 0)]);
54
+ const { insertAfterLine, indent } = deriveAnchor(tag, tags, locationData, sourceLines);
55
+ const annotation = `${indent}${tagDescription(tag, locationData, sourceLines)}`;
51
56
  return { insertAfterLine, annotation, startIndex: tag.startIndex, endIndex: tag.endIndex };
52
57
  });
53
58
  }
59
+ function deriveAnchor(tag, tags, locationData, sourceLines) {
60
+ if (PROLOGUE_KINDS.includes(tag.kind)) {
61
+ const prologueTags = tags.filter((t) => PROLOGUE_KINDS.includes(t.kind));
62
+ // The prologue is one contiguous opcode block at the function's start
63
+ const lastPrologueOpcode = Math.max(...prologueTags.map((t) => t.endIndex));
64
+ const firstBodyOpcode = lastPrologueOpcode + 1;
65
+ const firstBodyLine = getDisplayLine(locationData[firstBodyOpcode]);
66
+ return {
67
+ // `insertAfterLine` splices *after* a line, so `firstBodyLine - 1` lands all the prologue
68
+ // annotations directly above the first body statement, at its indentation.
69
+ insertAfterLine: firstBodyLine - 1,
70
+ indent: lineIndent(sourceLines, firstBodyLine),
71
+ };
72
+ }
73
+ return {
74
+ insertAfterLine: getDisplayLine(locationData[Math.max(tag.startIndex - 1, 0)]),
75
+ indent: deriveIndent(getDisplayLine(locationData[tag.startIndex]), sourceLines),
76
+ };
77
+ }
54
78
  function spliceSyntheticSourceLines(sourceLines, insertions) {
55
79
  return insertions.reduceRight((lines, ins) => [...lines.slice(0, ins.insertAfterLine), ins.annotation, ...lines.slice(ins.insertAfterLine)], sourceLines);
56
80
  }
@@ -80,19 +104,36 @@ const getUpdatedLineNumber = (currentLineNumber, insertion, opcodeIndex) => {
80
104
  return currentLineNumber + 1;
81
105
  return currentLineNumber;
82
106
  };
83
- /** Derive the annotation text (e.g. ">>> for-loop update (i = i + 1)") for a source tag. */
84
- function deriveTagLabel(tag, locationData, sourceLines) {
107
+ // e.g. ">>> for-loop update (i = i + 1)"
108
+ function tagDescription(tag, locationData, sourceLines) {
109
+ switch (tag.kind) {
110
+ case SourceTagKind.LOCKTIME_GUARD:
111
+ return '>>> tx.locktime guard (auto-injected)';
112
+ case SourceTagKind.PARAMETER_VALIDATION: {
113
+ const parameter = deriveSourceText(tag, locationData, sourceLines);
114
+ return `>>> parameter type check${parameter ? ` (${parameter})` : ''}`;
115
+ }
116
+ case SourceTagKind.FOR_UPDATE:
117
+ default:
118
+ return `>>> for-loop update (${deriveSourceText(tag, locationData, sourceLines)})`;
119
+ }
120
+ }
121
+ function deriveIndent(headerLine, sourceLines) {
122
+ const bodyLine = sourceLines.slice(headerLine).find((l) => l.trim().length > 0);
123
+ return bodyLine?.match(/^(\s*)/)?.[1] ?? '';
124
+ }
125
+ function lineIndent(sourceLines, line) {
126
+ return sourceLines[line - 1]?.match(/^(\s*)/)?.[1] ?? '';
127
+ }
128
+ function deriveSourceText(tag, locationData, sourceLines) {
85
129
  const headerLine = getDisplayLine(locationData[tag.startIndex]);
86
- // Find the column span of the update expression using single-line locations in the tag range
87
130
  const singleLineLocations = range(tag.startIndex, tag.endIndex)
88
131
  .map((idx) => locationData[idx].location)
89
- .filter((loc) => loc.start.line === loc.end.line);
132
+ .filter((loc) => loc.start.line === loc.end.line && loc.start.column !== loc.end.column);
133
+ if (singleLineLocations.length === 0)
134
+ return '';
90
135
  const startCol = Math.min(...singleLineLocations.map((loc) => loc.start.column));
91
136
  const endCol = Math.max(...singleLineLocations.map((loc) => loc.end.column));
92
- const expression = sourceLines[headerLine - 1].substring(startCol, endCol);
93
- // Indentation: use first non-empty line after header
94
- const bodyLine = sourceLines.slice(headerLine).find((l) => l.trim().length > 0);
95
- const indent = bodyLine?.match(/^(\s*)/)?.[1] ?? '';
96
- return `${indent}>>> for-loop update (${expression})`;
137
+ return sourceLines[headerLine - 1].substring(startCol, endCol).trim();
97
138
  }
98
139
  //# sourceMappingURL=bitauth-script.js.map
@@ -0,0 +1,5 @@
1
+ import { Op, Script } from './script.js';
2
+ export declare function computeBytecodePattern(bytecode: Uint8Array): Uint8Array;
3
+ export declare function computeBytecodeFingerprint(bytecode: Uint8Array): string;
4
+ export declare function computeBytecodeFingerprintWithConstructorArgs(bytecodeScript: Script, constructorArgsCount: number): string;
5
+ export declare function isPushOpcode(opcode: Op): boolean;
@@ -0,0 +1,50 @@
1
+ import { binToHex, decodeAuthenticationInstructions, encodeDataPush, flattenBinArray, isPushOperation, } from '@bitauth/libauth';
2
+ import { encodeInt } from './data.js';
3
+ import { sha256 } from './hash.js';
4
+ import { generateContractBytecodeScript, Op, scriptToBytecode } from './script.js';
5
+ // Transform locking bytecode into the normalized "pattern" form: every run of
6
+ // consecutive push instructions is replaced by a single push of the run length.
7
+ // See: https://gitlab.com/0353F40E/smart-contract-fingerprinting/
8
+ export function computeBytecodePattern(bytecode) {
9
+ const opcodes = decodeAuthenticationInstructions(bytecode).map((instruction) => instruction.opcode);
10
+ const chunks = groupByPushRun(opcodes).map((run) => (isPushOpcode(run[0]) ? encodePushCount(run.length) : Uint8Array.from(run)));
11
+ return flattenBinArray(chunks);
12
+ }
13
+ // Split opcodes into consecutive runs that are either all pushes or all non-pushes.
14
+ function groupByPushRun(opcodes) {
15
+ return opcodes.reduce((opcodeRuns, opcode) => {
16
+ const lastOpcodeRun = opcodeRuns.at(-1);
17
+ // If there are no runs yet, start a new run with the current opcode
18
+ if (!lastOpcodeRun)
19
+ return [[opcode]];
20
+ // If the last run is the same type of opcode as the current opcode, add the current opcode to the last run
21
+ // Else, start a new run with the current opcode
22
+ if (isPushOpcode(lastOpcodeRun[0]) === isPushOpcode(opcode)) {
23
+ lastOpcodeRun.push(opcode);
24
+ }
25
+ else {
26
+ opcodeRuns.push([opcode]);
27
+ }
28
+ return opcodeRuns;
29
+ }, []);
30
+ }
31
+ // Compute the SHA256 fingerprint of the normalized bytecode pattern, returned as hex.
32
+ export function computeBytecodeFingerprint(bytecode) {
33
+ return binToHex(sha256(computeBytecodePattern(bytecode)));
34
+ }
35
+ // Add placeholder constructor arguments to the bytecode script and compute the fingerprint.
36
+ export function computeBytecodeFingerprintWithConstructorArgs(bytecodeScript, constructorArgsCount) {
37
+ const placeholderConstructorArgs = new Array(constructorArgsCount).fill(0);
38
+ const bytecodeWithConstructorArgs = generateContractBytecodeScript(bytecodeScript, placeholderConstructorArgs);
39
+ return computeBytecodeFingerprint(scriptToBytecode(bytecodeWithConstructorArgs));
40
+ }
41
+ // Note: libauth's isPushOperation also matches OP_RESERVED (0x50) — the spec excludes it.
42
+ export function isPushOpcode(opcode) {
43
+ return isPushOperation(opcode) && opcode !== Op.OP_RESERVED;
44
+ }
45
+ // Encode a push count using the smallest possible push encoding.
46
+ // libauth's encodeDataPush minimally encodes VM Numbers in the -1..16 range as OP_N.
47
+ function encodePushCount(count) {
48
+ return encodeDataPush(encodeInt(BigInt(count)));
49
+ }
50
+ //# sourceMappingURL=fingerprint.js.map
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ export * from './artifact.js';
2
2
  export * from './bip68.js';
3
3
  export * from './bitauth-script.js';
4
4
  export * from './data.js';
5
+ export * from './fingerprint.js';
5
6
  export * from './hash.js';
6
7
  export * from './script.js';
7
8
  export * from './source-map.js';
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@ export * from './artifact.js';
2
2
  export * from './bip68.js';
3
3
  export * from './bitauth-script.js';
4
4
  export * from './data.js';
5
+ export * from './fingerprint.js';
5
6
  export * from './hash.js';
6
7
  export * from './script.js';
7
8
  export * from './source-map.js';
package/dist/script.js CHANGED
@@ -37,6 +37,8 @@ export function bytecodeToScript(bytecode) {
37
37
  export function asmToBytecode(asm) {
38
38
  // Remove any duplicate whitespace
39
39
  asm = asm.replace(/\s+/g, ' ').trim();
40
+ if (asm === '')
41
+ return new Uint8Array();
40
42
  // Convert the ASM tokens to AuthenticationInstructions
41
43
  const instructions = asm.split(' ').map((token) => {
42
44
  // Even though the OpcodesBch type allows for { [key: number]: string }, we know that the keys are always the opcodes
package/dist/types.d.ts CHANGED
@@ -52,7 +52,9 @@ export declare enum PositionHint {
52
52
  END = 1
53
53
  }
54
54
  export declare enum SourceTagKind {
55
- FOR_UPDATE = "fu"
55
+ FOR_UPDATE = "fu",
56
+ LOCKTIME_GUARD = "lg",
57
+ PARAMETER_VALIDATION = "pv"
56
58
  }
57
59
  export interface SourceTagEntry {
58
60
  startIndex: number;
package/dist/types.js CHANGED
@@ -191,10 +191,11 @@ export var PositionHint;
191
191
  PositionHint[PositionHint["START"] = 0] = "START";
192
192
  PositionHint[PositionHint["END"] = 1] = "END";
193
193
  })(PositionHint || (PositionHint = {}));
194
- // Semantic tags for opcodes that need special treatment in debugging output (e.g. synthetic labels).
195
- // Currently used for loop constructs where opcode order diverges from source line order.
194
+ // Semantic tags for compiler-injected opcodes that have no (or misleading) user source
196
195
  export var SourceTagKind;
197
196
  (function (SourceTagKind) {
198
197
  SourceTagKind["FOR_UPDATE"] = "fu";
198
+ SourceTagKind["LOCKTIME_GUARD"] = "lg";
199
+ SourceTagKind["PARAMETER_VALIDATION"] = "pv";
199
200
  })(SourceTagKind || (SourceTagKind = {}));
200
201
  //# sourceMappingURL=types.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cashscript/utils",
3
- "version": "0.13.0-next.7",
3
+ "version": "0.13.0-next.9",
4
4
  "description": "CashScript utilities and types",
5
5
  "keywords": [
6
6
  "bitcoin cash",
@@ -48,5 +48,5 @@
48
48
  "typescript": "^5.9.2",
49
49
  "vitest": "^4.0.15"
50
50
  },
51
- "gitHead": "4e8e91589e1218a7c04db1a4772f625efb0140e9"
51
+ "gitHead": "1bbda54a000f6744bdb67a6379c7bfb0b784dceb"
52
52
  }