From 604ffca74f0b2b282c717fc0e8d5bddeb7823110 Mon Sep 17 00:00:00 2001 From: Anastasia Rodionova Date: Thu, 23 Oct 2025 12:40:11 +0200 Subject: [PATCH 1/2] Add async and fetch support to interpreters --- .changeset/warm-spiders-lick.md | 5 + apps/web/package.json | 2 +- apps/web/src/lib/interpreter.ts | 10 +- .../interpreters/aerodrom.ts | 9 + .../interpreters/{routers.ts => dexes.ts} | 12 +- .../interpreters/std.ts | 28 +-- .../interpreters/zeroEx.ts | 15 +- packages/transaction-interpreter/package.json | 2 +- .../src/EvalInterpreter.ts | 63 +++-- .../src/QuickjsConfig.ts | 1 + .../src/QuickjsInterpreter.ts | 31 ++- .../src/interpreter.ts | 9 +- .../transaction-interpreter/src/quickjs.ts | 223 ++++++++++++++++-- packages/transaction-interpreter/src/types.ts | 12 +- pnpm-lock.yaml | 74 +++++- 15 files changed, 400 insertions(+), 96 deletions(-) create mode 100644 .changeset/warm-spiders-lick.md create mode 100644 packages/transaction-interpreter/interpreters/aerodrom.ts rename packages/transaction-interpreter/interpreters/{routers.ts => dexes.ts} (76%) diff --git a/.changeset/warm-spiders-lick.md b/.changeset/warm-spiders-lick.md new file mode 100644 index 00000000..0dd48e7c --- /dev/null +++ b/.changeset/warm-spiders-lick.md @@ -0,0 +1,5 @@ +--- +'@3loop/transaction-interpreter': minor +--- + +Add fetch and async calls support to quickjs and eval interpreters with extra config parameter. Add extra optional 'context' field to all interpreted tx diff --git a/apps/web/package.json b/apps/web/package.json index f758558f..f21148e8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,7 +11,7 @@ "dependencies": { "@3loop/transaction-decoder": "workspace:*", "@3loop/transaction-interpreter": "workspace:*", - "@jitl/quickjs-singlefile-browser-release-sync": "^0.29.2", + "@jitl/quickjs-singlefile-browser-release-sync": "^0.31.0", "@monaco-editor/react": "^4.6.0", "@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-dialog": "^1.1.4", diff --git a/apps/web/src/lib/interpreter.ts b/apps/web/src/lib/interpreter.ts index 4a34b36b..f8870e6e 100644 --- a/apps/web/src/lib/interpreter.ts +++ b/apps/web/src/lib/interpreter.ts @@ -12,7 +12,10 @@ import variant from '@jitl/quickjs-singlefile-browser-release-sync' const config = Layer.succeed(QuickjsConfig, { variant: variant, - runtimeConfig: { timeout: 1000 }, + runtimeConfig: { + timeout: 1000, + useFetch: true, + }, }) const layer = Layer.provide(QuickjsInterpreterLive, config) @@ -26,10 +29,13 @@ export interface Interpretation { export async function applyInterpreter( decodedTx: DecodedTransaction, interpreter: Interpreter, + interpretAsUserAddress?: string, ): Promise { const runnable = Effect.gen(function* () { const interpreterService = yield* TransactionInterpreter - const interpretation = yield* interpreterService.interpretTx(decodedTx, interpreter) + const interpretation = yield* interpreterService.interpretTransaction(decodedTx, interpreter, { + interpretAsUserAddress, + }) return interpretation }).pipe(Effect.provide(layer)) diff --git a/packages/transaction-interpreter/interpreters/aerodrom.ts b/packages/transaction-interpreter/interpreters/aerodrom.ts new file mode 100644 index 00000000..abe5d02a --- /dev/null +++ b/packages/transaction-interpreter/interpreters/aerodrom.ts @@ -0,0 +1,9 @@ +import { genericSwapInterpreter } from './std.js' +import type { InterpretedTransaction } from '../src/types.js' +import type { DecodedTransaction } from '@3loop/transaction-decoder' + +export function transformEvent(event: DecodedTransaction): InterpretedTransaction { + return genericSwapInterpreter(event) +} + +export const contracts = [] diff --git a/packages/transaction-interpreter/interpreters/routers.ts b/packages/transaction-interpreter/interpreters/dexes.ts similarity index 76% rename from packages/transaction-interpreter/interpreters/routers.ts rename to packages/transaction-interpreter/interpreters/dexes.ts index b7b279b4..03361e03 100644 --- a/packages/transaction-interpreter/interpreters/routers.ts +++ b/packages/transaction-interpreter/interpreters/dexes.ts @@ -1,8 +1,16 @@ -import type { InterpretedTransaction } from '@/types.js' +import type { InterpretedTransaction } from '../src/types.js' import type { DecodedTransaction } from '@3loop/transaction-decoder' import { genericSwapInterpreter } from './std.js' +import { InterpreterOptions } from '../src/types.js' + +export function transformEvent(event: DecodedTransaction, options?: InterpreterOptions): InterpretedTransaction { + if (options?.interpretAsUserAddress) { + return genericSwapInterpreter({ + ...event, + fromAddress: options.interpretAsUserAddress, + }) + } -export function transformEvent(event: DecodedTransaction): InterpretedTransaction { return genericSwapInterpreter(event) } diff --git a/packages/transaction-interpreter/interpreters/std.ts b/packages/transaction-interpreter/interpreters/std.ts index 48493db3..24b6222c 100644 --- a/packages/transaction-interpreter/interpreters/std.ts +++ b/packages/transaction-interpreter/interpreters/std.ts @@ -234,21 +234,21 @@ export const formatNumber = (numberString: string, precision = 3): string => { if ((integerPart && integerPart.length < 3 && !decimalPart) || (decimalPart && decimalPart.startsWith('000'))) return numberString + // Apply rounding first to get the correct integer and decimal parts + const rounded = num.toFixed(precision) + const [roundedIntegerPart, roundedDecimalPart] = rounded.split('.') + // Format the integer part manually let formattedIntegerPart = '' - for (let i = 0; i < integerPart.length; i++) { - if (i > 0 && (integerPart.length - i) % 3 === 0) { + for (let i = 0; i < roundedIntegerPart.length; i++) { + if (i > 0 && (roundedIntegerPart.length - i) % 3 === 0) { formattedIntegerPart += ',' } - formattedIntegerPart += integerPart[i] + formattedIntegerPart += roundedIntegerPart[i] } - // Format the decimal part - const formattedDecimalPart = decimalPart - ? parseFloat('0.' + decimalPart) - .toFixed(precision) - .split('.')[1] - : '00' + // Use the already-rounded decimal part + const formattedDecimalPart = roundedDecimalPart || '000' return formattedIntegerPart + '.' + formattedDecimalPart } @@ -284,7 +284,7 @@ export function displayAssets(assets: Payment[]) { // Categorization Functions export function isSwap(event: DecodedTransaction): boolean { - if (event.transfers.some((t) => t.type !== 'ERC20' && t.type !== 'native')) return false + if (event.transfers.some((t: Asset) => t.type !== 'ERC20' && t.type !== 'native')) return false const minted = assetsMinted(event.transfers, event.fromAddress) const burned = assetsBurned(event.transfers, event.fromAddress) @@ -352,8 +352,8 @@ export function genericSwapInterpreter(event: DecodedTransaction): InterpretedTr type: 'swap', action: 'Swapped ' + displayAsset(netSent[0]) + ' for ' + displayAsset(netReceived[0]), context: { - sent: [netSent[0]], - received: [netReceived[0]], + netSent: [netSent[0]], + netReceived: [netReceived[0]], }, } @@ -396,10 +396,10 @@ export function genericInterpreter(event: DecodedTransaction): InterpretedTransa //batch mint if (minted.length > 1) { const price = newEvent.assetsSent.length === 1 ? newEvent.assetsSent[0] : undefined - const uniqueAssets = new Set(minted.map((asset) => asset.asset.address)) + const uniqueAssets = new Set(minted.map((asset: AssetTransfer) => asset.asset.address)) if (uniqueAssets.size === 1) { - const amount = minted.reduce((acc, asset) => acc + Number(asset.amount), 0) + const amount = minted.reduce((acc: number, asset: AssetTransfer) => acc + Number(asset.amount), 0) return { ...newEvent, type: 'mint', diff --git a/packages/transaction-interpreter/interpreters/zeroEx.ts b/packages/transaction-interpreter/interpreters/zeroEx.ts index 23dc6a5e..930f3b2b 100644 --- a/packages/transaction-interpreter/interpreters/zeroEx.ts +++ b/packages/transaction-interpreter/interpreters/zeroEx.ts @@ -1,8 +1,15 @@ import { assetsReceived, genericInterpreter, displayAsset, getNetTransfers, filterTransfers } from './std.js' -import type { InterpretedTransaction } from '@/types.js' +import type { InterpretedTransaction, InterpreterOptions } from '../src/types.js' import type { DecodedTransaction } from '@3loop/transaction-decoder' -export function transformEvent(event: DecodedTransaction): InterpretedTransaction { +export function transformEvent(rawEvent: DecodedTransaction, options?: InterpreterOptions): InterpretedTransaction { + const event = options?.interpretAsUserAddress + ? { + ...rawEvent, + fromAddress: options.interpretAsUserAddress, + } + : rawEvent + const newEvent = genericInterpreter(event) if (newEvent.type !== 'unknown') return newEvent @@ -50,8 +57,8 @@ export function transformEvent(event: DecodedTransaction): InterpretedTransactio type: 'swap', action: 'Swapped ' + displayAsset(netSent[0]) + ' for ' + displayAsset(netReceived[0]), context: { - sent: [netSent[0]], - received: [netReceived[0]], + netSent: [netSent[0]], + netReceived: [netReceived[0]], }, assetsReceived: assetsReceived( filteredTransfers.filter((t) => receivedTokens.includes(t.address)), diff --git a/packages/transaction-interpreter/package.json b/packages/transaction-interpreter/package.json index e028e447..cb31701e 100644 --- a/packages/transaction-interpreter/package.json +++ b/packages/transaction-interpreter/package.json @@ -69,7 +69,7 @@ "eslint-config-prettier": "^8.10.0", "fast-glob": "^3.3.2", "prettier": "^2.8.8", - "quickjs-emscripten": "^0.29.1", + "quickjs-emscripten": "^0.31.0", "rimraf": "^6.0.1", "tsup": "^7.2.0", "tsx": "^4.19.0", diff --git a/packages/transaction-interpreter/src/EvalInterpreter.ts b/packages/transaction-interpreter/src/EvalInterpreter.ts index e19687a4..27d3a591 100644 --- a/packages/transaction-interpreter/src/EvalInterpreter.ts +++ b/packages/transaction-interpreter/src/EvalInterpreter.ts @@ -1,16 +1,24 @@ import { stringify } from './helpers/stringify.js' import type { DecodedTransaction } from '@3loop/transaction-decoder' import { Effect, Layer } from 'effect' -import { Interpreter } from './types.js' +import { Interpreter, InterpreterOptions } from './types.js' import { getInterpreter } from './interpreters.js' import { TransactionInterpreter } from './interpreter.js' +import { InterpreterError } from './quickjs.js' -function localEval(code: string, input: string) { +async function localEval(code: string, input: string) { const fn = new Function(`with(this) { ${code}; return transformEvent(${input}) }`) - return fn.call({}) + const result = fn.call({}) + + // Check if result is a promise and await it + if (result && typeof result.then === 'function') { + return await result + } + + return result } -const make = Effect.succeed({ +const make = { // NOTE: We could export this separately to allow bundling the interpreters separately findInterpreter: (decodedTx: DecodedTransaction) => { if (!decodedTx.toAddress) return undefined @@ -24,29 +32,38 @@ const make = Effect.succeed({ } }, interpretTx: ( - decodedTx: DecodedTransaction, + decodedTransaction: DecodedTransaction, interpreter: Interpreter, options?: { interpretAsUserAddress?: string }, ) => - Effect.sync(() => { - // TODO: add ability to surpress warning on acknowledge - Effect.logWarning('Using eval in production can result in security vulnerabilities. Use at your own risk.') - - let input - if (options?.interpretAsUserAddress) { - input = stringify({ - ...decodedTx, - fromAddress: options.interpretAsUserAddress, - }) - } else { - input = stringify(decodedTx) - } - - const result = localEval(interpreter.schema, input) - return result + Effect.tryPromise({ + try: async () => { + // TODO: add ability to surpress warning on acknowledge + Effect.logWarning('Using eval in production can result in security vulnerabilities. Use at your own risk.') + const input = stringify(decodedTransaction) + (options ? `,${stringify(options)}` : '') + const result = await localEval(interpreter.schema, input) + return result + }, + catch: (error) => new InterpreterError(error), }).pipe(Effect.withSpan('TransactionInterpreter.interpretTx')), -}) -export const EvalInterpreterLive = Layer.scoped(TransactionInterpreter, make) + interpretTransaction: ( + decodedTransaction: DecodedTransaction, + interpreter: Interpreter, + options?: InterpreterOptions, + ) => + Effect.tryPromise({ + try: async () => { + // TODO: add ability to surpress warning on acknowledge + Effect.logWarning('Using eval in production can result in security vulnerabilities. Use at your own risk.') + const input = stringify(decodedTransaction) + (options ? `,${stringify(options)}` : '') + const result = await localEval(interpreter.schema, input) + return result + }, + catch: (error) => new InterpreterError(error), + }).pipe(Effect.withSpan('TransactionInterpreter.interpretTransaction')), +} + +export const EvalInterpreterLive = Layer.scoped(TransactionInterpreter, Effect.succeed(make)) diff --git a/packages/transaction-interpreter/src/QuickjsConfig.ts b/packages/transaction-interpreter/src/QuickjsConfig.ts index 0d2c423e..80d4c80a 100644 --- a/packages/transaction-interpreter/src/QuickjsConfig.ts +++ b/packages/transaction-interpreter/src/QuickjsConfig.ts @@ -5,6 +5,7 @@ export interface RuntimeConfig { timeout?: number memoryLimit?: number maxStackSize?: number + useFetch?: boolean } export interface QuickjsConfig { diff --git a/packages/transaction-interpreter/src/QuickjsInterpreter.ts b/packages/transaction-interpreter/src/QuickjsInterpreter.ts index 4882efd3..b22731d4 100644 --- a/packages/transaction-interpreter/src/QuickjsInterpreter.ts +++ b/packages/transaction-interpreter/src/QuickjsInterpreter.ts @@ -1,7 +1,7 @@ import { stringify } from './helpers/stringify.js' import type { DecodedTransaction } from '@3loop/transaction-decoder' import { Effect, Layer } from 'effect' -import { Interpreter } from './types.js' +import { Interpreter, InterpreterOptions } from './types.js' import { getInterpreter } from './interpreters.js' import { QuickjsVM } from './quickjs.js' import { TransactionInterpreter } from './interpreter.js' @@ -10,7 +10,6 @@ const make = Effect.gen(function* () { const vm = yield* QuickjsVM return { - // NOTE: We could export this separately to allow bundling the interpreters separately findInterpreter: (decodedTx: DecodedTransaction) => { if (!decodedTx.toAddress) return undefined @@ -22,24 +21,30 @@ const make = Effect.gen(function* () { id: `${decodedTx.chainID}:${decodedTx.toAddress}`, } }, + interpretTx: ( - decodedTx: DecodedTransaction, + decodedTransaction: DecodedTransaction, interpreter: Interpreter, options?: { interpretAsUserAddress?: string }, ) => Effect.gen(function* () { - let input - if (options?.interpretAsUserAddress) { - input = stringify({ - ...decodedTx, - fromAddress: options.interpretAsUserAddress, - }) - } else { - input = stringify(decodedTx) - } - const result = yield* vm.eval(interpreter.schema + '\n' + 'transformEvent(' + input + ')') + const input = stringify(decodedTransaction) + (options ? `,${stringify(options)}` : '') + const code = interpreter.schema + '\n' + 'transformEvent(' + input + ')' + const result = yield* vm.eval(code) return result }).pipe(Effect.withSpan('TransactionInterpreter.interpretTx')), + + interpretTransaction: ( + decodedTransaction: DecodedTransaction, + interpreter: Interpreter, + options?: InterpreterOptions, + ) => + Effect.gen(function* () { + const input = stringify(decodedTransaction) + (options ? `,${stringify(options)}` : '') + const code = interpreter.schema + '\n' + 'transformEvent(' + input + ')' + const result = yield* vm.eval(code) + return result + }).pipe(Effect.withSpan('TransactionInterpreter.interpretTransaction')), } }) diff --git a/packages/transaction-interpreter/src/interpreter.ts b/packages/transaction-interpreter/src/interpreter.ts index d04c6d6e..a6854686 100644 --- a/packages/transaction-interpreter/src/interpreter.ts +++ b/packages/transaction-interpreter/src/interpreter.ts @@ -1,10 +1,11 @@ import type { DecodedTransaction } from '@3loop/transaction-decoder' import { Context, Effect } from 'effect' -import { InterpretedTransaction, Interpreter } from './types.js' +import { InterpretedTransaction, Interpreter, InterpreterOptions } from './types.js' import { InterpreterError } from './quickjs.js' export interface TransactionInterpreter { readonly findInterpreter: (decodedTx: DecodedTransaction) => Interpreter | undefined + readonly interpretTx: ( decodedTx: DecodedTransaction, interpreter: Interpreter, @@ -12,6 +13,12 @@ export interface TransactionInterpreter { interpretAsUserAddress?: string }, ) => Effect.Effect + + readonly interpretTransaction: ( + decodedTransaction: DecodedTransaction, + interpreter: Interpreter, + options?: InterpreterOptions, + ) => Effect.Effect } export const TransactionInterpreter = Context.GenericTag('@3loop/TransactionInterpreter') diff --git a/packages/transaction-interpreter/src/quickjs.ts b/packages/transaction-interpreter/src/quickjs.ts index 83326987..465da4e2 100644 --- a/packages/transaction-interpreter/src/quickjs.ts +++ b/packages/transaction-interpreter/src/quickjs.ts @@ -6,6 +6,7 @@ import { newQuickJSWASMModuleFromVariant, QuickJSContext, QuickJSSyncVariant, + Scope, } from 'quickjs-emscripten' import { InterpretedTransaction } from './types.js' import { QuickjsConfig, RuntimeConfig } from './QuickjsConfig.js' @@ -20,6 +21,24 @@ export class InterpreterError extends Data.TaggedError('InterpreterError')<{ } } +const PROMISE_TIMEOUT_MS = 10000 + +const DEFAULT_QUICKJS_CONFIG = { + timeout: -1, + memoryLimit: 1024 * 640, + maxStackSize: 1024 * 320, + useFetch: false, +} + +// Response prototype code for fetch API +const responsePrototypeCode = ` +({ + async json() { + return JSON.parse(await this.text()); + } +}) +` + export interface QuickJSVM { readonly runtime: QuickJSRuntime readonly eval: (code: string) => Effect.Effect @@ -32,7 +51,11 @@ async function initQuickJSVM(config: { }): Promise { const { variant, runtimeConfig = {} } = config const module = variant ? await newQuickJSWASMModuleFromVariant(variant) : await getQuickJS() - const { timeout = -1, memoryLimit = 1024 * 640, maxStackSize = 1024 * 320 } = runtimeConfig + const { + timeout = DEFAULT_QUICKJS_CONFIG.timeout, + memoryLimit = DEFAULT_QUICKJS_CONFIG.memoryLimit, + maxStackSize = DEFAULT_QUICKJS_CONFIG.maxStackSize, + } = runtimeConfig const runtime = module.newRuntime() @@ -46,48 +69,206 @@ async function initQuickJSVM(config: { const acquire = Effect.gen(function* () { const config = yield* QuickjsConfig + const useFetch = + config.runtimeConfig?.useFetch !== undefined ? config.runtimeConfig.useFetch : DEFAULT_QUICKJS_CONFIG.useFetch - const runtime = yield* Effect.promise(() => initQuickJSVM(config)) + const runtime = yield* Effect.promise(() => { + return initQuickJSVM({ variant: config.variant as QuickJSSyncVariant, runtimeConfig: config.runtimeConfig }) + }) const vm: QuickJSContext = runtime.newContext() + const scope = new Scope() + + // Create Response prototype for fetch API + const responsePrototype = scope.manage(vm.unwrapResult(vm.evalCode(responsePrototypeCode))) + // `console.log` - const logHandle = vm.newFunction('log', (...args) => { - const nativeArgs = args.map(vm.dump) - console.log('TxDecoder:', ...nativeArgs) - }) + const logHandle = scope.manage( + vm.newFunction('log', (...args) => { + const nativeArgs = args.map(vm.dump) + console.log('TransactionInterpreter:', ...nativeArgs) + }), + ) // Partially implement `console` object - const consoleHandle = vm.newObject() + const consoleHandle = scope.manage(vm.newObject()) vm.setProp(consoleHandle, 'log', logHandle) vm.setProp(vm.global, 'console', consoleHandle) - consoleHandle.dispose() - logHandle.dispose() + if (useFetch) { + // Provide fetch API with minimal Response object + const fetchHandle = scope.manage( + vm.newFunction('fetch', (urlHandle, optionsHandle) => { + const url = vm.getString(urlHandle) + const options = optionsHandle ? vm.dump(optionsHandle) : undefined + + const qjsPromise = scope.manage(vm.newPromise()) + + globalThis + .fetch(url, { + ...options, + credentials: 'omit', // Security: never include credentials + }) + .then((res) => { + // Check if VM is still alive before proceeding + if (!vm.alive) return + + if (res === undefined) { + qjsPromise.resolve(vm.undefined) + return + } + + // Create minimal Response object with prototype + const responseObj = scope.manage(vm.newObject(responsePrototype)) + vm.setProp(responseObj, 'ok', res.ok ? vm.true : vm.false) + vm.setProp(responseObj, 'status', scope.manage(vm.newNumber(res.status))) + + // Add text() method + const textFn = scope.manage( + vm.newFunction('text', () => { + const textPromise = scope.manage(vm.newPromise()) + res + .text() + .then((str) => { + if (!vm.alive) return + textPromise.resolve(scope.manage(vm.newString(str))) + }) + .catch((e) => { + if (!vm.alive) return + textPromise.reject( + scope.manage( + vm.newError({ + name: 'FetchError', + message: e.message || 'Failed to read response text', + }), + ), + ) + }) + // Execute pending jobs when promise settles + textPromise.settled.then(() => vm.runtime.executePendingJobs()) + return textPromise.handle + }), + ) + vm.setProp(responseObj, 'text', textFn) + + qjsPromise.resolve(responseObj) + }) + .catch((e) => { + if (!vm.alive) return + qjsPromise.reject( + scope.manage( + vm.newError({ + name: 'FetchError', + message: e.message || 'Fetch failed', + }), + ), + ) + }) + + // Execute pending jobs when promise settles + qjsPromise.settled.then(() => vm.runtime.executePendingJobs()) + return qjsPromise.handle + }), + ) + + vm.setProp(vm.global, 'fetch', fetchHandle) + } return { runtime, eval: (code: string) => - Effect.try({ - try: () => { - const result = vm.evalCode(code) - if (result.error) { - const errorObj = vm.dump(result.error) - result.error.dispose() - const error = new Error(errorObj.message) - error.stack = errorObj.stack - throw error + Effect.tryPromise({ + try: async () => { + // Create a new scope for this evaluation to prevent handle accumulation + const evalScope = new Scope() + + try { + const result = vm.evalCode(code) + + if (result.error) { + const errorObj = vm.dump(result.error) + // Dispose error immediately after dumping + result.error.dispose() + const error = new Error(errorObj.message) + error.stack = errorObj.stack + throw error + } + + const resultHandle = evalScope.manage(result.value) + + // Check if result is a promise + const thenProp = vm.getProp(resultHandle, 'then') + const isPromise = vm.typeof(resultHandle) === 'object' && vm.typeof(thenProp) === 'function' + // Dispose check handle immediately + thenProp.dispose() + + if (isPromise) { + // Resolve the promise with a timeout + const promiseResolution = vm.resolvePromise(resultHandle) + + // Execute pending jobs periodically to allow promises to settle + let jobExecutionActive = true + const executeJobsPeriodically = async () => { + while (jobExecutionActive) { + await vm.runtime.executePendingJobs() + await new Promise((resolve) => setTimeout(resolve, 10)) + } + } + + // Start job execution in background + const jobExecution = executeJobsPeriodically() + + try { + // Race between promise settling and timeout + const promiseResult = await Promise.race([ + promiseResolution, + new Promise((_, reject) => { + setTimeout(() => { + reject(new Error(`Promise resolution timeout after ${PROMISE_TIMEOUT_MS}ms`)) + }, PROMISE_TIMEOUT_MS) + }), + ]) + + if (promiseResult.error) { + const errorObj = vm.dump(promiseResult.error) + // Dispose error after dumping + promiseResult.error.dispose() + const error = new Error(errorObj.message) + error.stack = errorObj.stack + throw error + } + + const finalValue = vm.dump(promiseResult.value) + // Dispose result after dumping + promiseResult.value.dispose() + return finalValue + } finally { + // ALWAYS stop job execution, even on error/timeout + jobExecutionActive = false + await jobExecution.catch(() => { + /* ignore */ + }) + } + } else { + // For sync results: just dump and return + const ok = vm.dump(resultHandle) + return ok + } + } finally { + // ALWAYS cleanup evaluation scope, even on error + evalScope.dispose() } - const ok = vm.dump(result.value) - result.value.dispose() - return ok }, catch: (error: unknown) => { return new InterpreterError(error) }, }), dispose: () => { + if (!vm.alive) return vm.runtime.executePendingJobs(-1) + scope.dispose() vm.dispose() + runtime.dispose() }, } }) diff --git a/packages/transaction-interpreter/src/types.ts b/packages/transaction-interpreter/src/types.ts index 1baa96f2..c49b0586 100644 --- a/packages/transaction-interpreter/src/types.ts +++ b/packages/transaction-interpreter/src/types.ts @@ -50,9 +50,9 @@ export type AssetTransfer = { } export interface GenericInterpretedTransaction { + action: string type: TransactionType chain: number - action: string txHash: string timestamp: number user: Address @@ -61,16 +61,18 @@ export interface GenericInterpretedTransaction { assetsReceived: AssetTransfer[] assetsMinted?: AssetTransfer[] assetsBurned?: AssetTransfer[] + //extra and arbitrary context for the transaction + context?: Record } export interface InterpretedSwapTransaction extends GenericInterpretedTransaction { type: 'swap' context: { - sent: { + netSent: { amount: string asset: Asset }[] - received: { + netReceived: { amount: string asset: Asset }[] @@ -78,3 +80,7 @@ export interface InterpretedSwapTransaction extends GenericInterpretedTransactio } export type InterpretedTransaction = GenericInterpretedTransaction | InterpretedSwapTransaction + +export interface InterpreterOptions { + interpretAsUserAddress?: string +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ea1c876..ff692ff1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,8 +82,8 @@ importers: specifier: ^0.36.1 version: 0.36.1(@effect/experimental@0.46.1(@effect/platform@0.82.1(effect@3.15.1))(effect@3.15.1))(@effect/platform@0.82.1(effect@3.15.1))(@effect/sql@0.35.1(@effect/experimental@0.46.1(@effect/platform@0.82.1(effect@3.15.1))(effect@3.15.1))(@effect/platform@0.82.1(effect@3.15.1))(effect@3.15.1))(effect@3.15.1) '@jitl/quickjs-singlefile-browser-release-sync': - specifier: ^0.29.2 - version: 0.29.2 + specifier: ^0.31.0 + version: 0.31.0 '@monaco-editor/react': specifier: ^4.6.0 version: 4.6.0(monaco-editor@0.47.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -361,8 +361,8 @@ importers: specifier: ^2.8.8 version: 2.8.8 quickjs-emscripten: - specifier: ^0.29.1 - version: 0.29.2 + specifier: ^0.31.0 + version: 0.31.0 rimraf: specifier: ^6.0.1 version: 6.0.1 @@ -1453,21 +1453,36 @@ packages: '@jitl/quickjs-ffi-types@0.29.2': resolution: {integrity: sha512-069uQTiEla2PphXg6UpyyJ4QXHkTj3S9TeXgaMCd8NDYz3ODBw5U/rkg6fhuU8SMpoDrWjEzybmV5Mi2Pafb5w==} - '@jitl/quickjs-singlefile-browser-release-sync@0.29.2': - resolution: {integrity: sha512-FLETOame9PaarI9EblrxDPeQFM3gha33roUspNpuQ9f7iYVg4XNaO/L00bsihySqi+PGPvVzAUccMJ09ugyV7w==} + '@jitl/quickjs-ffi-types@0.31.0': + resolution: {integrity: sha512-1yrgvXlmXH2oNj3eFTrkwacGJbmM0crwipA3ohCrjv52gBeDaD7PsTvFYinlAnqU8iPME3LGP437yk05a2oejw==} + + '@jitl/quickjs-singlefile-browser-release-sync@0.31.0': + resolution: {integrity: sha512-JctBiLmRpxEp83gJWhDcBuFqm5X7T683OLmncN9g0chAHkC8+y5cJmgknaAk5Rb/ANDR3pXMMnGdnGXDdysfBQ==} '@jitl/quickjs-wasmfile-debug-asyncify@0.29.2': resolution: {integrity: sha512-YdRw2414pFkxzyyoJGv81Grbo9THp/5athDMKipaSBNNQvFE9FGRrgE9tt2DT2mhNnBx1kamtOGj0dX84Yy9bg==} + '@jitl/quickjs-wasmfile-debug-asyncify@0.31.0': + resolution: {integrity: sha512-YkdzQdr1uaftFhgEnTRjTTZHk2SFZdpWO7XhOmRVbi6CEVsH9g5oNF8Ta1q3OuSJHRwwT8YsuR1YzEiEIJEk6w==} + '@jitl/quickjs-wasmfile-debug-sync@0.29.2': resolution: {integrity: sha512-VgisubjyPMWEr44g+OU0QWGyIxu7VkApkLHMxdORX351cw22aLTJ+Z79DJ8IVrTWc7jh4CBPsaK71RBQDuVB7w==} + '@jitl/quickjs-wasmfile-debug-sync@0.31.0': + resolution: {integrity: sha512-8XvloaaWBONqcHXYs5tWOjdhQVxzULilIfB2hvZfS6S+fI4m2+lFiwQy7xeP8ExHmiZ7D8gZGChNkdLgjGfknw==} + '@jitl/quickjs-wasmfile-release-asyncify@0.29.2': resolution: {integrity: sha512-sf3luCPr8wBVmGV6UV8Set+ie8wcO6mz5wMvDVO0b90UVCKfgnx65A1JfeA+zaSGoaFyTZ3sEpXSGJU+6qJmLw==} + '@jitl/quickjs-wasmfile-release-asyncify@0.31.0': + resolution: {integrity: sha512-uz0BbQYTxNsFkvkurd7vk2dOg57ElTBLCuvNtRl4rgrtbC++NIndD5qv2+AXb6yXDD3Uy1O2PCwmoaH0eXgEOg==} + '@jitl/quickjs-wasmfile-release-sync@0.29.2': resolution: {integrity: sha512-UFIcbY3LxBRUjEqCHq3Oa6bgX5znt51V5NQck8L2US4u989ErasiMLUjmhq6UPC837Sjqu37letEK/ZpqlJ7aA==} + '@jitl/quickjs-wasmfile-release-sync@0.31.0': + resolution: {integrity: sha512-hYduecOByj9AsAfsJhZh5nA6exokmuFC8cls39+lYmTCGY51bgjJJJwReEu7Ff7vBWaQCL6TeDdVlnp2WYz0jw==} + '@jridgewell/gen-mapping@0.3.5': resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} engines: {node: '>=6.0.0'} @@ -5524,10 +5539,17 @@ packages: quickjs-emscripten-core@0.29.2: resolution: {integrity: sha512-jEAiURW4jGqwO/fW01VwlWqa2G0AJxnN5FBy1xnVu8VIVhVhiaxUfCe+bHqS6zWzfjFm86HoO40lzpteusvyJA==} + quickjs-emscripten-core@0.31.0: + resolution: {integrity: sha512-oQz8p0SiKDBc1TC7ZBK2fr0GoSHZKA0jZIeXxsnCyCs4y32FStzCW4d1h6E1sE0uHDMbGITbk2zhNaytaoJwXQ==} + quickjs-emscripten@0.29.2: resolution: {integrity: sha512-SlvkvyZgarReu2nr4rkf+xz1vN0YDUz7sx4WHz8LFtK6RNg4/vzAGcFjE7nfHYBEbKrzfIWvKnMnxZkctQ898w==} engines: {node: '>=16.0.0'} + quickjs-emscripten@0.31.0: + resolution: {integrity: sha512-K7Yt78aRPLjPcqv3fIuLW1jW3pvwO21B9pmFOolsjM/57ZhdVXBr51GqJpalgBlkPu9foAvhEAuuQPnvIGvLvQ==} + engines: {node: '>=16.0.0'} + react-dom@18.2.0: resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} peerDependencies: @@ -8003,26 +8025,44 @@ snapshots: '@jitl/quickjs-ffi-types@0.29.2': {} - '@jitl/quickjs-singlefile-browser-release-sync@0.29.2': + '@jitl/quickjs-ffi-types@0.31.0': {} + + '@jitl/quickjs-singlefile-browser-release-sync@0.31.0': dependencies: - '@jitl/quickjs-ffi-types': 0.29.2 + '@jitl/quickjs-ffi-types': 0.31.0 '@jitl/quickjs-wasmfile-debug-asyncify@0.29.2': dependencies: '@jitl/quickjs-ffi-types': 0.29.2 + '@jitl/quickjs-wasmfile-debug-asyncify@0.31.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.31.0 + '@jitl/quickjs-wasmfile-debug-sync@0.29.2': dependencies: '@jitl/quickjs-ffi-types': 0.29.2 + '@jitl/quickjs-wasmfile-debug-sync@0.31.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.31.0 + '@jitl/quickjs-wasmfile-release-asyncify@0.29.2': dependencies: '@jitl/quickjs-ffi-types': 0.29.2 + '@jitl/quickjs-wasmfile-release-asyncify@0.31.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.31.0 + '@jitl/quickjs-wasmfile-release-sync@0.29.2': dependencies: '@jitl/quickjs-ffi-types': 0.29.2 + '@jitl/quickjs-wasmfile-release-sync@0.31.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.31.0 + '@jridgewell/gen-mapping@0.3.5': dependencies: '@jridgewell/set-array': 1.2.1 @@ -10323,7 +10363,7 @@ snapshots: eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.0) eslint-plugin-jsx-a11y: 6.9.0(eslint@8.57.0) eslint-plugin-react: 7.35.0(eslint@8.57.0) eslint-plugin-react-hooks: 4.6.2(eslint@8.57.0) @@ -10371,7 +10411,7 @@ snapshots: is-bun-module: 1.1.0 is-glob: 4.0.3 optionalDependencies: - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.0) transitivePeerDependencies: - '@typescript-eslint/parser' - eslint-import-resolver-node @@ -10458,7 +10498,7 @@ snapshots: eslint: 8.57.0 ignore: 5.2.4 - eslint-plugin-import@2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0): + eslint-plugin-import@2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.0): dependencies: array-includes: 3.1.8 array.prototype.findlastindex: 1.2.5 @@ -13158,6 +13198,10 @@ snapshots: dependencies: '@jitl/quickjs-ffi-types': 0.29.2 + quickjs-emscripten-core@0.31.0: + dependencies: + '@jitl/quickjs-ffi-types': 0.31.0 + quickjs-emscripten@0.29.2: dependencies: '@jitl/quickjs-wasmfile-debug-asyncify': 0.29.2 @@ -13166,6 +13210,14 @@ snapshots: '@jitl/quickjs-wasmfile-release-sync': 0.29.2 quickjs-emscripten-core: 0.29.2 + quickjs-emscripten@0.31.0: + dependencies: + '@jitl/quickjs-wasmfile-debug-asyncify': 0.31.0 + '@jitl/quickjs-wasmfile-debug-sync': 0.31.0 + '@jitl/quickjs-wasmfile-release-asyncify': 0.31.0 + '@jitl/quickjs-wasmfile-release-sync': 0.31.0 + quickjs-emscripten-core: 0.31.0 + react-dom@18.2.0(react@18.2.0): dependencies: loose-envify: 1.4.0 From 34075d67fa9ce292e55fa1269c381acb614195c3 Mon Sep 17 00:00:00 2001 From: Gheorghe Pinzaru Date: Sun, 26 Oct 2025 09:24:06 +0100 Subject: [PATCH 2/2] Scope VM at interpretation level --- .../src/QuickjsInterpreter.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/transaction-interpreter/src/QuickjsInterpreter.ts b/packages/transaction-interpreter/src/QuickjsInterpreter.ts index b22731d4..bdcc58b9 100644 --- a/packages/transaction-interpreter/src/QuickjsInterpreter.ts +++ b/packages/transaction-interpreter/src/QuickjsInterpreter.ts @@ -5,9 +5,10 @@ import { Interpreter, InterpreterOptions } from './types.js' import { getInterpreter } from './interpreters.js' import { QuickjsVM } from './quickjs.js' import { TransactionInterpreter } from './interpreter.js' +import { QuickjsConfig } from './QuickjsConfig.js' const make = Effect.gen(function* () { - const vm = yield* QuickjsVM + const config = yield* QuickjsConfig return { findInterpreter: (decodedTx: DecodedTransaction) => { @@ -28,11 +29,17 @@ const make = Effect.gen(function* () { options?: { interpretAsUserAddress?: string }, ) => Effect.gen(function* () { + const vm = yield* QuickjsVM + const input = stringify(decodedTransaction) + (options ? `,${stringify(options)}` : '') const code = interpreter.schema + '\n' + 'transformEvent(' + input + ')' const result = yield* vm.eval(code) return result - }).pipe(Effect.withSpan('TransactionInterpreter.interpretTx')), + }).pipe( + Effect.withSpan('TransactionInterpreter.interpretTx'), + Effect.scoped, + Effect.provideService(QuickjsConfig, config), + ), interpretTransaction: ( decodedTransaction: DecodedTransaction, @@ -40,11 +47,17 @@ const make = Effect.gen(function* () { options?: InterpreterOptions, ) => Effect.gen(function* () { + const vm = yield* QuickjsVM + const input = stringify(decodedTransaction) + (options ? `,${stringify(options)}` : '') const code = interpreter.schema + '\n' + 'transformEvent(' + input + ')' const result = yield* vm.eval(code) return result - }).pipe(Effect.withSpan('TransactionInterpreter.interpretTransaction')), + }).pipe( + Effect.withSpan('TransactionInterpreter.interpretTransaction'), + Effect.scoped, + Effect.provideService(QuickjsConfig, config), + ), } })