UNPKG

opnet-transform-web

Version:

OP_NET AssemblyScript transformer

858 lines (753 loc) 33.1 kB
import { Transform } from 'assemblyscript/transform'; import { ClassDeclaration, FieldDeclaration, MethodDeclaration, NodeKind, Parser, Program, Statement, } from 'assemblyscript/dist/assemblyscript.js'; import { fs } from 'assemblyscript/util/node.js'; // @ts-ignore import { SimpleParser } from '@btc-vision/visitor-as'; import { BlockStatement, CallExpression, ExpressionStatement, IdentifierExpression, } from 'types:assemblyscript/src/ast'; import { ElementKind, FunctionPrototype } from 'types:assemblyscript/src/program'; import * as prettier from 'prettier'; import { ABIDataTypes, AbiTypeToStr } from 'opnet'; import { StrToAbiType } from './StrToAbiType.js'; import { Logger } from '@btc-vision/logger'; import { unquote } from './utils/index.js'; import { ABICoder } from '@btc-vision/transaction'; import { jsonrepair } from 'jsonrepair'; import { ClassABI, DeclaredEvent, MethodCollection, ParamDefinition } from './interfaces/Abi.js'; // ------------------------------------------------------------------ // Transformer // ------------------------------------------------------------------ const logger = new Logger(); logger.setLogPrefix('OPNetTransformer'); logger.info('Compiling smart contract...'); const abiCoder = new ABICoder(); export { logger, SimpleParser }; export default class OPNetTransformer extends Transform { // -------------------------------------------------- // Per-class method info // -------------------------------------------------- protected methodsByClass: Map<string, MethodCollection[]> = new Map(); protected classDeclarations: Map<string, ClassDeclaration> = new Map(); // -------------------------------------------------- // Global event declarations (key = eventName) // -------------------------------------------------- private allEvents: Map<string, DeclaredEvent> = new Map(); // -------------------------------------------------- // Track usage: className -> set of eventNames used // -------------------------------------------------- private eventsUsedInClass: Map<string, Set<string>> = new Map(); private program: Program | undefined; // Scratch state for the visitor private currentClassName: string | null = null; private collectingEvent: boolean = false; // are we in an event class? private currentEventName: string | null = null; private isEventClass: boolean = false; // ------------------------------------------------------------ // Lifecycle Hooks // ------------------------------------------------------------ public async afterParse(parser: Parser): Promise<void> { // 1) Parse AST for (const source of parser.sources) { if (source.isLibrary || source.internalPath.startsWith('~lib/')) { continue; } for (const stmt of source.statements) { this.visitStatement(stmt); } } // 2) Build ABI per class const abiMap = this.buildAbiPerClass(); // 3) Create an output folder named "abis" fs.mkdirSync('abis', { recursive: true }); // 4) Write one JSON + .d.ts per class for (const [className, abiObj] of abiMap.entries()) { if (abiObj.functions.length === 0) continue; // JSON const filePath = `abis/${className}.abi.json`; fs.writeFileSync(filePath, JSON.stringify(abiObj, null, 4)); logger.success(`ABI generated to ${filePath}`); // DTS const dtsPath = `abis/${className}.d.ts`; const dtsContents = this.buildDtsForClass(className, abiObj); const formattedDts = await prettier.format(dtsContents, { parser: 'typescript', printWidth: 100, trailingComma: 'all', tabWidth: 4, semi: true, singleQuote: true, quoteProps: 'as-needed', bracketSpacing: true, bracketSameLine: true, arrowParens: 'always', singleAttributePerLine: true, }); fs.writeFileSync(dtsPath, formattedDts); logger.success(`Type definitions generated to ${dtsPath}`); } // 5) Inject/overwrite `execute` in each relevant class for (const [className, methods] of this.methodsByClass.entries()) { if (!methods.length) continue; logger.info(`Injecting 'execute' in class ${className}...`); const classDecl = this.classDeclarations.get(className); if (!classDecl) { logger.warn(`ClassDeclaration not found for ${className}`); continue; } const methodText = this.buildExecuteMethod(className, methods); const newMember = SimpleParser.parseClassMember(methodText, classDecl); // Overwrite if it exists const existingIndex = classDecl.members.findIndex((member) => { return ( member.kind === NodeKind.MethodDeclaration && (member as MethodDeclaration).name.text === 'execute' ); }); if (existingIndex !== -1) { logger.info(`Overwriting existing 'execute' in class ${className}`); classDecl.members[existingIndex] = newMember; } else { classDecl.members.push(newMember); } } // 6) Check for "unused" events and log warnings this.checkUnusedEvents(); } afterInitialize(program: Program): void { super.afterInitialize?.(program); this.program = program; // We fill in "internalName" for each method for (const [className, methods] of this.methodsByClass.entries()) { for (const methodInfo of methods) { const resolvedName = this.getInternalNameForMethodDeclaration( methodInfo.declaration, ); if (resolvedName) { methodInfo.internalName = resolvedName; } else { throw new Error( `Method ${className}.${methodInfo.methodName} not found in the program.`, ); } } } } // ------------------------------------------------------------ // Build final ABI per class // ------------------------------------------------------------ protected buildAbiPerClass(): Map<string, ClassABI> { const result = new Map<string, ClassABI>(); // For each known class, gather the methods for (const [className, methods] of this.methodsByClass.entries()) { // 1) Build "functions" const functions = methods.map((m) => { // inputs const inputs = m.paramDefs.map((p, idx) => { if (typeof p === 'string') { return { name: `param${idx + 1}`, type: this.mapToAbiDataType(p), }; } else { return { name: p.name, type: this.mapToAbiDataType(p.type), }; } }); // outputs let outputs: { name: string; type: ABIDataTypes }[] = []; if (m.returnDefs.length > 0) { outputs = m.returnDefs.map((p, idx) => { if (typeof p === 'string') { return { name: `returnVal${idx + 1}`, type: this.mapToAbiDataType(p), }; } else { return { name: p.name, type: this.mapToAbiDataType(p.type), }; } }); } return { name: m.methodName, type: 'Function' as const, inputs, outputs, }; }); // 2) Gather which events this class used const usedEventNames = this.eventsUsedInClass.get(className) || new Set(); // 3) Convert them to ABI const events = Array.from(usedEventNames) .map((evName) => { const declared = this.allEvents.get(evName); if (!declared) { // if user referenced an event that doesn't exist, we warn in checkUnusedEvents, // but let's skip adding a null event. return null; } return { name: declared.eventName, values: declared.params.map((p) => ({ name: p.name, type: p.type, })), type: 'Event' as const, }; }) .filter((x) => x !== null) as ClassABI['events']; // remove null placeholders // 4) Build final result.set(className, { functions, events }); } return result; } // ------------------------------------------------------------ // Generate .d.ts for each class // ------------------------------------------------------------ protected buildDtsForClass(className: string, abiObj: ClassABI): string { const interfaceName = `I${className}`; // 1) Event type definitions const eventTypeDefs: string[] = []; for (const evt of abiObj.events) { const fields = evt.values .map((v) => { const tsType = this.mapAbiTypeToTypescript(v.type); return ` readonly ${v.name}: ${tsType};`; }) .join('\n'); const eventName = `${evt.name}Event`; eventTypeDefs.push(`export type ${eventName} = {\n${fields}\n};`); } // 2) Specialized call-result types const methodsInClass = this.methodsByClass.get(className) || []; const callResultTypes: string[] = []; for (const fn of abiObj.functions) { const originalMethod = methodsInClass.find((m) => m.methodName === fn.name); const hasOutputs = fn.outputs.length > 0; const hasEmittedEvents = originalMethod && originalMethod.emittedEvents.length > 0; const typeName = this.toPascalCase(fn.name); // build the "outputs" object-literal let outputLines: string[] = []; if (hasOutputs) { for (const o of fn.outputs) { const tsType = this.mapAbiTypeToTypescript(o.type); outputLines.push(` ${o.name}: ${tsType};`); } } const outputObj = outputLines.length ? `{\n${outputLines.join('\n')}\n}` : '{}'; // build the union of events let eventUnion = 'never'; if (hasEmittedEvents && originalMethod) { // e.g. "FooEvent | BarEvent" eventUnion = originalMethod.emittedEvents .map((eName) => `${eName}Event`) .join(' | '); } // e.g. OPNetEvent<FooEvent | BarEvent>[] const eventsParam = `OPNetEvent<${eventUnion}>[]`; const docBlock = ` /** * @description Represents the result of the ${fn.name} function call. */ `.trim(); callResultTypes.push(` ${docBlock} export type ${typeName} = CallResult< ${outputObj}, ${eventsParam} >; `); } // 3) The interface const interfaceLines: string[] = [ `export interface ${interfaceName} extends IOP_NETContract {`, ]; for (const fn of abiObj.functions) { // param list const paramList = fn.inputs .map((p) => { const tsType = this.mapAbiTypeToTypescript(p.type); return `${p.name}: ${tsType}`; }) .join(', '); const callResultName = this.toPascalCase(fn.name); interfaceLines.push(` ${fn.name}(${paramList}): Promise<${callResultName}>;`); } interfaceLines.push('}'); // combine return [ `import { Address, AddressMap } from '@btc-vision/transaction';`, `import { CallResult, OPNetEvent, IOP_NETContract } from 'opnet';`, '', '// ------------------------------------------------------------------', '// Event Definitions', '// ------------------------------------------------------------------', ...eventTypeDefs, '', '// ------------------------------------------------------------------', '// Call Results', '// ------------------------------------------------------------------', ...callResultTypes, '', '// ------------------------------------------------------------------', `// ${interfaceName}`, '// ------------------------------------------------------------------', ...interfaceLines, '', ].join('\n'); } // ------------------------------------------------------------ // Build the `execute` method stubs // ------------------------------------------------------------ protected buildExecuteMethod(_className: string, methods: MethodCollection[]): string { const bodyLines: string[] = []; for (const m of methods) { // Build the method signature from paramDefs const realNames = m.paramDefs.map((param) => { if (typeof param === 'string') { return param; } const type = param.type; if (type.startsWith('ABIDataTypes.')) { const enumType = type.replace('ABIDataTypes.', ''); const enumValue = ABIDataTypes[enumType as keyof typeof ABIDataTypes]; if (!enumValue) { throw new Error(`Invalid abi type (from string): ${enumType}`); } const selectorValue = AbiTypeToStr[enumValue]; if (!selectorValue) { throw new Error(`Invalid abi type (to string): ${enumValue}`); } return selectorValue; } return type; }); const sig = realNames.length ? `${m.methodName}(${realNames.join(',')})` : m.methodName; m.signature = sig; // 4-byte selector const selectorHex = abiCoder.encodeSelector(sig); const selectorNum = `0x${selectorHex}`; logger.debugBright( `Found function ${sig} -> ${selectorNum} (${m.declaration.name.text})`, ); bodyLines.push( `if (selector == ${selectorNum}) return this.${m.declaration.name.text}(calldata);`, ); } bodyLines.push('return super.execute(selector, calldata);'); return ` // auto-injected by transform public override execute(selector: u32, calldata: Calldata): BytesWriter { ${bodyLines.join('\n ')} }`; } protected checkUnusedEvents(): void { /** * For each declared event: * - see if it's used in ANY class (based on eventsUsedInClass) * - if not used, warn */ const usedEvents = new Set<string>(); for (const [, usedSet] of this.eventsUsedInClass.entries()) { for (const evName of usedSet) { usedEvents.add(evName); // If it doesn't exist in allEvents, warn if (!this.allEvents.has(evName)) { logger.warn( `Method references event '${evName}' which is not declared anywhere.`, ); } } } // check for unused for (const evName of this.allEvents.keys()) { if (!usedEvents.has(evName)) { logger.warn( `Event '${evName}' was declared but never used (no @emit referencing it).`, ); } } } // ------------------------------------------------------------ // AST Visitor // ------------------------------------------------------------ protected visitStatement(stmt: Statement): void { switch (stmt.kind) { case NodeKind.ClassDeclaration: this.visitClassDeclaration(stmt as ClassDeclaration); break; case NodeKind.MethodDeclaration: this.visitMethodDeclaration(stmt as MethodDeclaration); break; case NodeKind.FieldDeclaration: this.visitFieldDeclaration(stmt as FieldDeclaration); break; default: // no-op break; } } private visitClassDeclaration(node: ClassDeclaration): void { this.currentClassName = node.name.text; this.classDeclarations.set(node.name.text, node); // Set up methods array if not present if (!this.methodsByClass.has(node.name.text)) { this.methodsByClass.set(node.name.text, []); } // Also set up usage tracking if (!this.eventsUsedInClass.has(node.name.text)) { this.eventsUsedInClass.set(node.name.text, new Set()); } // Check if it's an event class this.isEventClass = false; let possibleEventName: string | null = null; // 1) Check "extends NetEvent" if (node.extendsType && node.extendsType.name.identifier.text === 'NetEvent') { this.isEventClass = true; possibleEventName = node.name.text; logger.log(`Found event class: ${node.name.text}`); } // 2) Check @event decorator if (node.decorators) { for (const dec of node.decorators) { if (dec.name.kind === NodeKind.Identifier) { const decName = (dec.name as IdentifierExpression).text; if (decName === 'event') { this.isEventClass = true; if (dec.args && dec.args.length > 0) { possibleEventName = unquote(dec.args[0].range.toString()); } } } } } if (this.isEventClass) { this.collectingEvent = true; this.currentEventName = possibleEventName || node.name.text; // Add initial blank event to allEvents map if (!this.allEvents.has(this.currentEventName)) { this.allEvents.set(this.currentEventName, { eventName: this.currentEventName, params: [], }); } } // Visit members for (const member of node.members) { this.visitStatement(member); } // Reset this.collectingEvent = false; this.currentEventName = null; this.isEventClass = false; this.currentClassName = null; } private visitMethodDeclaration(node: MethodDeclaration): void { if (!this.currentClassName) return; // If it's the constructor of an event class, parse it if (this.isEventClass && node.name.text === 'constructor' && this.currentEventName) { this.parseEventConstructor(node, this.currentEventName); // don't `return` here because it might also have decorators } if (!node.decorators) return; let methodInfo: MethodCollection | null = null; // Check each decorator for (const dec of node.decorators) { if (dec.name.kind !== NodeKind.Identifier) continue; const decName = (dec.name as IdentifierExpression).text; if (decName === 'method') { // Gather raw strings from @method(...) arguments const rawArgs: string[] = []; if (dec.args && dec.args.length > 0) { for (const arg of dec.args) { rawArgs.push(unquote(arg.range.toString())); } } const { methodName, paramDefs } = this.parseDecoratorArgs(rawArgs, node.name.text); if (!methodInfo) { methodInfo = { methodName, paramDefs, returnDefs: [], declaration: node, emittedEvents: [], }; } else { methodInfo.methodName = methodName; methodInfo.paramDefs = paramDefs; } } else if (decName === 'returns') { // Parse return definitions const rawArgs: string[] = []; if (dec.args && dec.args.length > 0) { for (const arg of dec.args) { rawArgs.push(unquote(arg.range.toString())); } } const returnDefs = this.parseParamDefs(rawArgs); if (!methodInfo) { methodInfo = { methodName: node.name.text, paramDefs: [], returnDefs, declaration: node, emittedEvents: [], }; } else { methodInfo.returnDefs = returnDefs; } } else if (decName === 'emit') { // e.g. @emit("DepositEvent", "SomeOtherEvent") const rawArgs: string[] = []; if (dec.args && dec.args.length > 0) { for (const arg of dec.args) { rawArgs.push(unquote(arg.range.toString())); } } if (!methodInfo) { methodInfo = { methodName: node.name.text, paramDefs: [], returnDefs: [], declaration: node, emittedEvents: [], }; } // Record usage const usageSet = this.eventsUsedInClass.get(this.currentClassName) as Set<string>; for (const evName of rawArgs) { usageSet.add(evName); } methodInfo.emittedEvents.push(...rawArgs); } } if (methodInfo) { const arr = this.methodsByClass.get(this.currentClassName); if (!arr) return; const existing = arr.find((m) => m.declaration === node); if (existing) { // merge existing.methodName = methodInfo.methodName; existing.paramDefs = methodInfo.paramDefs; existing.returnDefs = methodInfo.returnDefs; existing.emittedEvents = [ ...new Set([...existing.emittedEvents, ...methodInfo.emittedEvents]), ]; } else { arr.push(methodInfo); } } } private visitFieldDeclaration(node: FieldDeclaration): void { // If we are collecting an event (NetEvent or @event class), treat the fields as event params if (!this.collectingEvent || !this.currentEventName) return; if (!node.type) return; const fieldName = node.name.text; const typeStr = node.type.range.toString(); const ev = this.allEvents.get(this.currentEventName); if (!ev) { // Should not happen if we set it earlier, but guard anyway return; } ev.params.push({ name: fieldName, type: this.mapToAbiDataType(typeStr), }); } private parseEventConstructor(node: MethodDeclaration, eventName: string): void { // Look for super("Foo", ...) let finalName = eventName; const methodBody = node.body; if (methodBody && methodBody.kind === NodeKind.Block) { const block = methodBody as BlockStatement; for (const stmt of block.statements) { if (stmt.kind === NodeKind.Expression) { const exprStmt = stmt as ExpressionStatement; if (exprStmt.expression.kind === NodeKind.Call) { const callExpr = exprStmt.expression as CallExpression; if (callExpr.expression.kind === NodeKind.Super) { // super("Deposit", ...) if (callExpr.args && callExpr.args.length > 0) { const possibleName = unquote(callExpr.args[0].range.toString()); if (possibleName) { finalName = possibleName; } } } } } } } // If user changed the name in super(...), update the map key if (finalName !== eventName) { // move or unify the event in allEvents const old = this.allEvents.get(eventName); if (old) { this.allEvents.delete(eventName); const existing = this.allEvents.get(finalName); if (existing) { // merge the params if needed existing.params.push(...old.params); } else { this.allEvents.set(finalName, { eventName: finalName, params: old.params, }); } } } // Collect constructor parameters const ev = this.allEvents.get(finalName); if (!ev) return; // sanity check if (node.signature.parameters) { for (const param of node.signature.parameters) { const pName = param.name.text; const pTypeStr = param.type ? param.type.range.toString() : 'unknown'; ev.params.push({ name: pName, type: this.mapToAbiDataType(pTypeStr), }); } } } // ------------------------------------------------------------ // Helpers // ------------------------------------------------------------ private getInternalNameForMethodDeclaration(methodDecl: MethodDeclaration): string | null { if (!this.program) return null; const element = this.program.getElementByDeclaration(methodDecl); if (!element) return null; if (element.kind === ElementKind.FunctionPrototype) { return (element as FunctionPrototype).internalName; } else if (element.kind === ElementKind.Function) { return element.internalName; } return null; } private parseDecoratorArgs( rawArgs: string[], defaultMethodName: string, ): { methodName: string; paramDefs: ParamDefinition[] } { const parsedItems = rawArgs.map((arg) => this.parseParamDefinition(arg)); if (parsedItems.length === 0) { // no arguments => no override name, no parameters return { methodName: defaultMethodName, paramDefs: [], }; } const firstItem = parsedItems[0]; // If recognized as a param => methodName is the default, // else the first item is actually the methodName if (this.isParamDefinition(firstItem)) { return { methodName: defaultMethodName, paramDefs: parsedItems, }; } else { // eslint-disable-next-line @typescript-eslint/no-base-to-string const methodName = String(firstItem); const paramDefs = parsedItems.slice(1); return { methodName, paramDefs }; } } private parseParamDefs(rawArgs: string[]): ParamDefinition[] { return rawArgs.map((arg) => this.parseParamDefinition(arg)); } private parseParamDefinition(raw: string): ParamDefinition { const trimmed = raw.trim(); if (trimmed.startsWith('{') && trimmed.endsWith('}')) { try { const parsed = JSON.parse(jsonrepair(trimmed)); if (typeof parsed.name === 'string' && typeof parsed.type === 'string') { return { name: parsed.name, type: parsed.type }; } } catch (e) { // ignore parse errors and fallback to string } } return trimmed; } private isParamDefinition(param: ParamDefinition): boolean { if (typeof param === 'string') { return param in StrToAbiType || param.startsWith('ABIDataTypes.'); } else { if (param.type.startsWith('ABIDataTypes.')) return true; return param.type in StrToAbiType; } } /** * Convert a user-supplied type string into our internal ABIDataTypes enum. */ private mapToAbiDataType(str: string): ABIDataTypes { if (str.startsWith('ABIDataTypes.')) { const enumName = str.replace('ABIDataTypes.', ''); return enumName as ABIDataTypes; } // else check our known mapping return StrToAbiType[str] || StrToAbiType['unknown']; } private mapAbiTypeToTypescript(abiType: ABIDataTypes): string { switch (abiType) { case ABIDataTypes.ADDRESS: return 'Address'; case ABIDataTypes.STRING: return 'string'; case ABIDataTypes.BOOL: return 'boolean'; case ABIDataTypes.BYTES: return 'Uint8Array'; case ABIDataTypes.UINT8: case ABIDataTypes.UINT16: case ABIDataTypes.UINT32: return 'number'; case ABIDataTypes.UINT64: case ABIDataTypes.INT128: case ABIDataTypes.UINT128: case ABIDataTypes.UINT256: return 'bigint'; case ABIDataTypes.ADDRESS_UINT256_TUPLE: return 'AddressMap<bigint>'; case ABIDataTypes.ARRAY_OF_ADDRESSES: return 'Address[]'; case ABIDataTypes.ARRAY_OF_STRING: return 'string[]'; case ABIDataTypes.ARRAY_OF_BYTES: return 'Uint8Array[]'; case ABIDataTypes.ARRAY_OF_UINT8: case ABIDataTypes.ARRAY_OF_UINT16: case ABIDataTypes.ARRAY_OF_UINT32: return 'number[]'; case ABIDataTypes.ARRAY_OF_UINT64: case ABIDataTypes.ARRAY_OF_UINT128: case ABIDataTypes.ARRAY_OF_UINT256: return 'bigint[]'; case ABIDataTypes.BYTES32: return 'Uint8Array'; default: logger.warn(`Unknown or unhandled type definition for ${abiType}`); return 'unknown'; } } private toPascalCase(str: string): string { return str .replace(/(^\w|_\w)/g, (match) => match.replace('_', '').toUpperCase()) .replace(/[^A-Za-z0-9]/g, ''); } }