From 327964eeb1b641e18169fcfb3dcb43679c4362e2 Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Wed, 27 Nov 2024 16:36:37 +0100 Subject: [PATCH 01/43] feat: add automation package building blocks, including state machine, listeners, state and transitions --- package.json | 1 + packages/automation/.babelrc | 10 + packages/automation/.eslintrc.json | 18 ++ packages/automation/README.md | 3 + packages/automation/jest.config.ts | 16 ++ packages/automation/package.json | 32 ++++ packages/automation/project.json | 37 ++++ packages/automation/src/index.ts | 3 + .../src/lib/listeners/constant.spec.ts | 36 ++++ .../automation/src/lib/listeners/constant.ts | 17 ++ .../src/lib/listeners/evm-block.spec.ts | 56 ++++++ .../automation/src/lib/listeners/evm-block.ts | 27 +++ .../lib/listeners/evm-contract-event.spec.ts | 81 ++++++++ .../src/lib/listeners/evm-contract-event.ts | 57 ++++++ .../src/lib/listeners/fetch.spec.ts | 53 ++++++ .../automation/src/lib/listeners/fetch.ts | 49 +++++ .../automation/src/lib/listeners/index.ts | 7 + .../src/lib/listeners/interval.spec.ts | 63 +++++++ .../automation/src/lib/listeners/interval.ts | 21 +++ .../src/lib/listeners/listener.spec.ts | 67 +++++++ .../automation/src/lib/listeners/listener.ts | 65 +++++++ .../src/lib/listeners/timer.spec.ts | 55 ++++++ .../automation/src/lib/listeners/timer.ts | 25 +++ .../automation/src/lib/state-machine.spec.ts | 167 +++++++++++++++++ packages/automation/src/lib/state-machine.ts | 176 ++++++++++++++++++ packages/automation/src/lib/states/index.ts | 2 + .../src/lib/states/mint-pkp.spec.ts | 78 ++++++++ .../automation/src/lib/states/mint-pkp.ts | 32 ++++ .../automation/src/lib/states/state.spec.ts | 54 ++++++ packages/automation/src/lib/states/state.ts | 41 ++++ .../automation/src/lib/transitions/index.ts | 1 + .../src/lib/transitions/transition.spec.ts | 114 ++++++++++++ .../src/lib/transitions/transition.ts | 82 ++++++++ packages/automation/tsconfig.json | 25 +++ packages/automation/tsconfig.lib.json | 12 ++ packages/automation/tsconfig.spec.json | 11 ++ .../wrapped-keys-lit-actions/jest.config.ts | 2 +- .../tsconfig.lib.json | 2 +- packages/wrapped-keys/jest.config.ts | 2 +- yarn.lock | 51 ++++- 40 files changed, 1641 insertions(+), 10 deletions(-) create mode 100644 packages/automation/.babelrc create mode 100644 packages/automation/.eslintrc.json create mode 100644 packages/automation/README.md create mode 100644 packages/automation/jest.config.ts create mode 100644 packages/automation/package.json create mode 100644 packages/automation/project.json create mode 100644 packages/automation/src/index.ts create mode 100644 packages/automation/src/lib/listeners/constant.spec.ts create mode 100644 packages/automation/src/lib/listeners/constant.ts create mode 100644 packages/automation/src/lib/listeners/evm-block.spec.ts create mode 100644 packages/automation/src/lib/listeners/evm-block.ts create mode 100644 packages/automation/src/lib/listeners/evm-contract-event.spec.ts create mode 100644 packages/automation/src/lib/listeners/evm-contract-event.ts create mode 100644 packages/automation/src/lib/listeners/fetch.spec.ts create mode 100644 packages/automation/src/lib/listeners/fetch.ts create mode 100644 packages/automation/src/lib/listeners/index.ts create mode 100644 packages/automation/src/lib/listeners/interval.spec.ts create mode 100644 packages/automation/src/lib/listeners/interval.ts create mode 100644 packages/automation/src/lib/listeners/listener.spec.ts create mode 100644 packages/automation/src/lib/listeners/listener.ts create mode 100644 packages/automation/src/lib/listeners/timer.spec.ts create mode 100644 packages/automation/src/lib/listeners/timer.ts create mode 100644 packages/automation/src/lib/state-machine.spec.ts create mode 100644 packages/automation/src/lib/state-machine.ts create mode 100644 packages/automation/src/lib/states/index.ts create mode 100644 packages/automation/src/lib/states/mint-pkp.spec.ts create mode 100644 packages/automation/src/lib/states/mint-pkp.ts create mode 100644 packages/automation/src/lib/states/state.spec.ts create mode 100644 packages/automation/src/lib/states/state.ts create mode 100644 packages/automation/src/lib/transitions/index.ts create mode 100644 packages/automation/src/lib/transitions/transition.spec.ts create mode 100644 packages/automation/src/lib/transitions/transition.ts create mode 100644 packages/automation/tsconfig.json create mode 100644 packages/automation/tsconfig.lib.json create mode 100644 packages/automation/tsconfig.spec.json diff --git a/package.json b/package.json index 5499153639..109120e0f3 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "@nx/web": "17.3.0", "@solana/web3.js": "^1.95.3", "@types/depd": "^1.1.36", + "@types/events": "^3.0.3", "@types/jest": "27.4.1", "@types/node": "18.19.18", "@types/secp256k1": "^4.0.6", diff --git a/packages/automation/.babelrc b/packages/automation/.babelrc new file mode 100644 index 0000000000..158083d278 --- /dev/null +++ b/packages/automation/.babelrc @@ -0,0 +1,10 @@ +{ + "presets": [ + [ + "@nx/web/babel", + { + "useBuiltIns": "usage" + } + ] + ] +} diff --git a/packages/automation/.eslintrc.json b/packages/automation/.eslintrc.json new file mode 100644 index 0000000000..9d9c0db55b --- /dev/null +++ b/packages/automation/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/packages/automation/README.md b/packages/automation/README.md new file mode 100644 index 0000000000..f6f64c5793 --- /dev/null +++ b/packages/automation/README.md @@ -0,0 +1,3 @@ +# Quick Start + +This submodule is used to automate different actions using the Lit Protocol network or other useful events providing listening and responding abilities based on state machines. diff --git a/packages/automation/jest.config.ts b/packages/automation/jest.config.ts new file mode 100644 index 0000000000..46f114b5e6 --- /dev/null +++ b/packages/automation/jest.config.ts @@ -0,0 +1,16 @@ +/* eslint-disable */ +export default { + displayName: 'types', + preset: '../../jest.preset.js', + globals: { + 'ts-jest': { + tsconfig: '/tsconfig.spec.json', + }, + }, + transform: { + '^.+\\.[t]s$': 'ts-jest', + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../coverage/packages/automation', + setupFilesAfterEnv: ['../../jest.setup.js'], +}; diff --git a/packages/automation/package.json b/packages/automation/package.json new file mode 100644 index 0000000000..d2191c6cdc --- /dev/null +++ b/packages/automation/package.json @@ -0,0 +1,32 @@ +{ + "name": "@lit-protocol/automation", + "type": "commonjs", + "license": "MIT", + "homepage": "https://github.com/Lit-Protocol/js-sdk", + "repository": { + "type": "git", + "url": "https://github.com/LIT-Protocol/js-sdk" + }, + "keywords": [ + "library" + ], + "bugs": { + "url": "https://github.com/LIT-Protocol/js-sdk/issues" + }, + "publishConfig": { + "access": "public", + "directory": "../../dist/packages/automation" + }, + "tags": [ + "universal" + ], + "buildOptions": { + "genReact": false + }, + "scripts": { + "generate-lit-actions": "yarn node ./esbuild.config.js" + }, + "version": "7.0.0", + "main": "./dist/src/index.js", + "typings": "./dist/src/index.d.ts" +} diff --git a/packages/automation/project.json b/packages/automation/project.json new file mode 100644 index 0000000000..52dc6246c0 --- /dev/null +++ b/packages/automation/project.json @@ -0,0 +1,37 @@ +{ + "name": "automation", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/automation/src", + "projectType": "library", + "targets": { + "build": { + "cache": false, + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/packages/automation", + "main": "packages/automation/src/index.ts", + "tsConfig": "packages/automation/tsconfig.lib.json", + "assets": ["packages/automation/*.md"], + "updateBuildableProjectDepsInPackageJson": true + }, + "dependsOn": ["^build"] + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["packages/automation/**/*.ts"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/packages/automation"], + "options": { + "jestConfig": "packages/automation/jest.config.ts", + "passWithNoTests": true + } + } + }, + "tags": [] +} diff --git a/packages/automation/src/index.ts b/packages/automation/src/index.ts new file mode 100644 index 0000000000..1feb0e620e --- /dev/null +++ b/packages/automation/src/index.ts @@ -0,0 +1,3 @@ +import { StateMachine } from './lib/state-machine'; + +export { StateMachine }; diff --git a/packages/automation/src/lib/listeners/constant.spec.ts b/packages/automation/src/lib/listeners/constant.spec.ts new file mode 100644 index 0000000000..e8c6cee123 --- /dev/null +++ b/packages/automation/src/lib/listeners/constant.spec.ts @@ -0,0 +1,36 @@ +import { ConstantListener } from './constant'; + +describe('ConstantListener', () => { + let constantListener: ConstantListener; + const valueToEmit = 42; + + beforeEach(() => { + constantListener = new ConstantListener(valueToEmit); + }); + + it('should emit the constant value immediately when started', async () => { + const callback = jest.fn(); + constantListener.onStateChange(callback); + + await constantListener.start(); + + // Advance event loop + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(callback).toHaveBeenCalledWith(valueToEmit); + }); + + it('should not emit any value after being stopped', async () => { + const callback = jest.fn(); + constantListener.onStateChange(callback); + + await constantListener.start(); + await constantListener.stop(); + + // Advance event loop + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Ensure no additional calls were made after stop + expect(callback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/automation/src/lib/listeners/constant.ts b/packages/automation/src/lib/listeners/constant.ts new file mode 100644 index 0000000000..9565b8f19c --- /dev/null +++ b/packages/automation/src/lib/listeners/constant.ts @@ -0,0 +1,17 @@ +import { Listener } from './listener'; + +/** + * A simple listener that emits a constant value immediately when started + */ +export class ConstantListener extends Listener { + constructor(private value: T) { + super({ + start: async () => { + // Emit value on next tick simulating a state change and respecting event architecture + setTimeout(() => { + this.emit(this.value); + }, 0); + }, + }); + } +} diff --git a/packages/automation/src/lib/listeners/evm-block.spec.ts b/packages/automation/src/lib/listeners/evm-block.spec.ts new file mode 100644 index 0000000000..44619c9ee1 --- /dev/null +++ b/packages/automation/src/lib/listeners/evm-block.spec.ts @@ -0,0 +1,56 @@ +import { ethers } from 'ethers'; + +import { EVMBlockListener } from './evm-block'; + +jest.mock('ethers'); + +describe('EVMBlockListener', () => { + let evmBlockListener: EVMBlockListener; + let providerMock: jest.Mocked; + + beforeEach(() => { + providerMock = { + on: jest.fn(), + removeAllListeners: jest.fn(), + getBlock: jest.fn().mockResolvedValue({ number: 123, hash: '0xabc' }), + } as unknown as jest.Mocked; + + ( + ethers.providers.JsonRpcProvider as unknown as jest.Mock + ).mockImplementation(() => providerMock); + + evmBlockListener = new EVMBlockListener('http://example-rpc-url.com'); + }); + + afterEach(async () => { + await evmBlockListener.stop(); + jest.clearAllMocks(); + }); + + it('should start listening to block events', async () => { + await evmBlockListener.start(); + + expect(providerMock.on).toHaveBeenCalledWith('block', expect.any(Function)); + }); + + it('should emit block data on block event', async () => { + const callback = jest.fn(); + evmBlockListener.onStateChange(callback); + + await evmBlockListener.start(); + + // Simulate block event + const blockEventCallback = providerMock.on.mock.calls[0][1]; + await blockEventCallback(123); + + expect(providerMock.getBlock).toHaveBeenCalledWith(123); + expect(callback).toHaveBeenCalledWith({ number: 123, hash: '0xabc' }); + }); + + it('should stop listening to block events', async () => { + await evmBlockListener.start(); + await evmBlockListener.stop(); + + expect(providerMock.removeAllListeners).toHaveBeenCalledWith('block'); + }); +}); diff --git a/packages/automation/src/lib/listeners/evm-block.ts b/packages/automation/src/lib/listeners/evm-block.ts new file mode 100644 index 0000000000..0a671b2c8f --- /dev/null +++ b/packages/automation/src/lib/listeners/evm-block.ts @@ -0,0 +1,27 @@ +import { ethers } from 'ethers'; + +import { LIT_EVM_CHAINS } from '@lit-protocol/constants'; + +import { Listener } from './listener'; + +export type BlockData = ethers.providers.Block; + +export class EVMBlockListener extends Listener { + constructor(rpcUrl: string = LIT_EVM_CHAINS['ethereum'].rpcUrls[0]) { + const provider = new ethers.providers.JsonRpcProvider(rpcUrl); + + super({ + start: async () => { + provider.on('block', async (blockNumber) => { + const block = await provider.getBlock(blockNumber); + if (block) { + this.emit(block); + } + }); + }, + stop: async () => { + provider.removeAllListeners('block'); + }, + }); + } +} diff --git a/packages/automation/src/lib/listeners/evm-contract-event.spec.ts b/packages/automation/src/lib/listeners/evm-contract-event.spec.ts new file mode 100644 index 0000000000..c4191ccedf --- /dev/null +++ b/packages/automation/src/lib/listeners/evm-contract-event.spec.ts @@ -0,0 +1,81 @@ +import { ethers } from 'ethers'; + +import { + EVMContractEventListener, + ContractInfo, + EventInfo, +} from './evm-contract-event'; + +jest.mock('ethers'); + +describe('EVMContractEventListener', () => { + let evmContractEventListener: EVMContractEventListener; + let contractMock: jest.Mocked; + const rpcUrl = 'http://example-rpc-url.com'; + const contractInfo: ContractInfo = { + address: '0x123', + abi: [], + }; + const eventInfo: EventInfo = { + name: 'TestEvent', + }; + + beforeEach(() => { + contractMock = { + on: jest.fn(), + removeAllListeners: jest.fn(), + filters: { + TestEvent: jest.fn().mockReturnValue({}), + }, + } as unknown as jest.Mocked; + + (ethers.Contract as unknown as jest.Mock).mockImplementation( + () => contractMock + ); + + evmContractEventListener = new EVMContractEventListener( + rpcUrl, + contractInfo, + eventInfo + ); + }); + + afterEach(async () => { + await evmContractEventListener.stop(); + jest.clearAllMocks(); + }); + + it('should start listening to contract events', async () => { + await evmContractEventListener.start(); + + expect(contractMock.on).toHaveBeenCalledWith({}, expect.any(Function)); + }); + + it('should emit event data on contract event', async () => { + const callback = jest.fn(); + evmContractEventListener.onStateChange(callback); + + await evmContractEventListener.start(); + + // Simulate contract event + const eventCallback = contractMock.on.mock.calls[0][1]; + const mockEvent = { blockNumber: 123, transactionHash: '0xabc' }; + eventCallback('arg1', 'arg2', mockEvent); + + expect(callback).toHaveBeenCalledWith({ + event: mockEvent, + args: ['arg1', 'arg2'], + blockNumber: 123, + transactionHash: '0xabc', + }); + }); + + it('should stop listening to contract events', async () => { + await evmContractEventListener.start(); + await evmContractEventListener.stop(); + + expect(contractMock.removeAllListeners).toHaveBeenCalledWith( + eventInfo.name + ); + }); +}); diff --git a/packages/automation/src/lib/listeners/evm-contract-event.ts b/packages/automation/src/lib/listeners/evm-contract-event.ts new file mode 100644 index 0000000000..43040885dd --- /dev/null +++ b/packages/automation/src/lib/listeners/evm-contract-event.ts @@ -0,0 +1,57 @@ +import { ethers } from 'ethers'; +import { Listener } from './listener'; + +export type ContractEventData = { + event: ethers.Event; + args: any[]; + blockNumber: number; + transactionHash: string; +}; + +export interface ContractInfo { + address: string; + abi: ethers.ContractInterface; +} + +export interface EventInfo { + name: string; + filter?: any[]; +} + +export class EVMContractEventListener extends Listener { + constructor( + rpcUrl: string, + contractInfo: ContractInfo, + eventInfo: EventInfo + ) { + const provider = new ethers.providers.JsonRpcProvider(rpcUrl); + const contract = new ethers.Contract( + contractInfo.address, + contractInfo.abi, + provider + ); + + super({ + start: async () => { + const eventFilter = contract.filters[eventInfo.name]( + ...(eventInfo.filter || []) + ); + + contract.on(eventFilter, (...args) => { + const event = args[args.length - 1] as ethers.Event; + const eventArgs = args.slice(0, -1); + + this.emit({ + event, + args: eventArgs, + blockNumber: event.blockNumber, + transactionHash: event.transactionHash, + }); + }); + }, + stop: async () => { + contract.removeAllListeners(eventInfo.name); + }, + }); + } +} diff --git a/packages/automation/src/lib/listeners/fetch.spec.ts b/packages/automation/src/lib/listeners/fetch.spec.ts new file mode 100644 index 0000000000..ed0098ff01 --- /dev/null +++ b/packages/automation/src/lib/listeners/fetch.spec.ts @@ -0,0 +1,53 @@ +import { FetchListener } from './fetch'; + +describe('FetchListener', () => { + let fetchListener: FetchListener; + let fetchMock: jest.Mock; + + beforeEach(() => { + jest.useFakeTimers(); + fetchMock = jest.fn().mockResolvedValue({ + json: jest.fn().mockResolvedValue({ data: { value: 42 } }), + }); + global.fetch = fetchMock; + + fetchListener = new FetchListener('http://example.com', { + fetchConfig: {}, + listenerConfig: { + pollInterval: 1000, + pathResponse: 'data.value', + }, + }); + }); + + afterEach(async () => { + await fetchListener.stop(); + jest.clearAllMocks(); + jest.useRealTimers(); + }); + + it('should fetch data and emit the correct value', async () => { + let callbackCalled: () => void; + const callbackPromise = new Promise(resolve => callbackCalled = resolve); + + const callback = jest.fn(async () => { + callbackCalled(); + }); + fetchListener.onStateChange(callback); + + await fetchListener.start(); + jest.advanceTimersByTime(1000); + await callbackPromise; + + expect(fetchMock).toHaveBeenCalledWith('http://example.com', {}); + expect(callback).toHaveBeenCalledWith(42); + }); + + it('should stop polling when stopped', async () => { + await fetchListener.start(); + await fetchListener.stop(); + + jest.advanceTimersByTime(2000); + expect(fetchMock).toHaveBeenCalledTimes(0); + }); +}); diff --git a/packages/automation/src/lib/listeners/fetch.ts b/packages/automation/src/lib/listeners/fetch.ts new file mode 100644 index 0000000000..681cd94bba --- /dev/null +++ b/packages/automation/src/lib/listeners/fetch.ts @@ -0,0 +1,49 @@ +import { Listener } from './listener'; + +interface FetchListenerConfig { + fetchConfig?: RequestInit; + listenerConfig?: { + pollInterval?: number; + pathResponse?: string; + }; +} + +export class FetchListener extends Listener { + private readonly url: string; + private config: FetchListenerConfig; + private intervalId: ReturnType | null = null; + + constructor(url: string, config: FetchListenerConfig = {}) { + super({ + start: async () => { + const { pollInterval = 1000, pathResponse = '' } = + this.config.listenerConfig ?? {}; + + this.intervalId = setInterval(async () => { + try { + const response = await fetch(this.url, this.config.fetchConfig); + const data = await response.json(); + const value = pathResponse + ? pathResponse + .split('.') + .reduce((acc, part) => acc && acc[part], data) + : data; + if (value !== undefined) { + this.emit(value); + } + } catch (error) { + console.error('FetchListener error:', error); + } + }, pollInterval); + }, + stop: async () => { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + }, + }); + this.url = url; + this.config = config; + } +} diff --git a/packages/automation/src/lib/listeners/index.ts b/packages/automation/src/lib/listeners/index.ts new file mode 100644 index 0000000000..6f6e9c0e9e --- /dev/null +++ b/packages/automation/src/lib/listeners/index.ts @@ -0,0 +1,7 @@ +export * from './constant'; +export * from './evm-block'; +export * from './evm-contract-event'; +export * from './fetch'; +export * from './interval'; +export * from './listener'; +export * from './timer'; diff --git a/packages/automation/src/lib/listeners/interval.spec.ts b/packages/automation/src/lib/listeners/interval.spec.ts new file mode 100644 index 0000000000..47ed35616b --- /dev/null +++ b/packages/automation/src/lib/listeners/interval.spec.ts @@ -0,0 +1,63 @@ +import { IntervalListener } from './interval'; + +describe('IntervalListener', () => { + let intervalListener: IntervalListener; + let callback: jest.Mock; + const interval = 1000; + + beforeEach(() => { + jest.useFakeTimers(); + callback = jest.fn().mockResolvedValue(42); + intervalListener = new IntervalListener(callback, interval); + }); + + afterEach(async () => { + await intervalListener.stop(); + jest.clearAllMocks(); + jest.useRealTimers(); + }); + + it('should call the callback at specified intervals', async () => { + let firstStateCallbackResolve: () => void; + const firstStateCallbackPromise = new Promise( + (resolve) => (firstStateCallbackResolve = resolve) + ); + const firstStateCallbackMock = jest.fn(async () => + firstStateCallbackResolve() + ); + intervalListener.onStateChange(firstStateCallbackMock); + + await intervalListener.start(); + + jest.advanceTimersByTime(interval); + await firstStateCallbackPromise; + + expect(callback).toHaveBeenCalledTimes(1); + expect(firstStateCallbackMock).toHaveBeenCalledWith(42); + + let secondStateCallbackResolve: () => void; + const secondStateCallbackPromise = new Promise( + (resolve) => (secondStateCallbackResolve = resolve) + ); + const secondStateCallbackMock = jest.fn(async () => + secondStateCallbackResolve() + ); + intervalListener.onStateChange(secondStateCallbackMock); + + jest.advanceTimersByTime(interval); + await secondStateCallbackPromise; + + expect(callback).toHaveBeenCalledTimes(2); + expect(secondStateCallbackMock).toHaveBeenCalledWith(42); + }); + + it('should stop calling the callback when stopped', async () => { + await intervalListener.start(); + await intervalListener.stop(); + + jest.advanceTimersByTime(interval * 2); + await Promise.resolve(); + + expect(callback).toHaveBeenCalledTimes(0); + }); +}); diff --git a/packages/automation/src/lib/listeners/interval.ts b/packages/automation/src/lib/listeners/interval.ts new file mode 100644 index 0000000000..e012961198 --- /dev/null +++ b/packages/automation/src/lib/listeners/interval.ts @@ -0,0 +1,21 @@ +import { Listener } from './listener'; + +export class IntervalListener extends Listener { + private intervalId?: ReturnType; + + constructor(callback: () => Promise, interval = 1000) { + super({ + start: async () => { + this.intervalId = setInterval(async () => { + const value = await callback(); + this.emit(value); + }, interval); + }, + stop: async () => { + if (this.intervalId) { + clearInterval(this.intervalId); + } + }, + }); + } +} diff --git a/packages/automation/src/lib/listeners/listener.spec.ts b/packages/automation/src/lib/listeners/listener.spec.ts new file mode 100644 index 0000000000..d0713d13db --- /dev/null +++ b/packages/automation/src/lib/listeners/listener.spec.ts @@ -0,0 +1,67 @@ +import { Listener } from './listener'; + +describe('Listener', () => { + let listener: Listener; + let setup: jest.Mock; + let teardown: jest.Mock; + + beforeEach(() => { + setup = jest.fn(); + teardown = jest.fn(); + listener = new (class extends Listener { + constructor() { + super({ + start: setup, + stop: teardown, + }); + } + + // Expose emit for testing + public testEmit(value: number) { + this.emit(value); + } + })(); + }); + + it('should call setup on start', async () => { + await listener.start(); + expect(setup).toHaveBeenCalled(); + }); + + it('should call teardown on stop', async () => { + await listener.stop(); + expect(teardown).toHaveBeenCalled(); + }); + + it('should notify listeners of state changes with the new value', () => { + const callback = jest.fn(); + listener.onStateChange(callback); + (listener as any).testEmit(5); + expect(callback).toHaveBeenCalledWith(5); + }); + + it('should not remove listeners on stop', async () => { + const callback = jest.fn(); + listener.onStateChange(callback); + await listener.stop(); + (listener as any).testEmit(5); + expect(callback).toHaveBeenCalled(); + }); + + it('should replace previous callback when registering a new one', () => { + const callback1 = jest.fn(); + const callback2 = jest.fn(); + + // Register first callback + listener.onStateChange(callback1); + (listener as any).testEmit(5); + expect(callback1).toHaveBeenCalledWith(5); + expect(callback2).not.toHaveBeenCalled(); + + // Register second callback - should replace the first one + listener.onStateChange(callback2); + (listener as any).testEmit(10); + expect(callback1).toHaveBeenCalledTimes(1); // Should not receive the second emit + expect(callback2).toHaveBeenCalledWith(10); + }); +}); diff --git a/packages/automation/src/lib/listeners/listener.ts b/packages/automation/src/lib/listeners/listener.ts new file mode 100644 index 0000000000..694de9a3d3 --- /dev/null +++ b/packages/automation/src/lib/listeners/listener.ts @@ -0,0 +1,65 @@ +import { EventEmitter } from 'events'; + +export interface ListenerParams { + start?: () => Promise; + stop?: () => Promise; +} + +/** + * A Listener class that manages event listeners for state changes. + * @template T The type of the value being listened to. Defaults to unknown. + */ +export class Listener { + private emitter = new EventEmitter(); + private currentCallback: ((value: T) => Promise) | null = null; + + /** + * The start function called when all listeners are started. + */ + public start: () => Promise; + + /** + * The stop function called when all listeners are stopped. + */ + public stop: () => Promise; + + /** + * Constructor for the Listener class. + * @param params The parameters object containing start and stop functions. + */ + constructor({ + start = async () => {}, + stop = async () => {}, + }: ListenerParams = {}) { + this.start = start; + this.stop = stop; + } + + /** + * Removes all listeners from the emitter. + */ + removeAllListeners() { + this.emitter.removeAllListeners(); + } + + /** + * Registers a callback to be called when the state changes. + * If a callback was previously registered, it will be replaced with the new one. + * @param callback The function to call with the new state value. + */ + onStateChange(callback: (value: T) => Promise) { + if (this.currentCallback) { + this.emitter.removeListener('stateChange', this.currentCallback); + } + this.currentCallback = callback; + this.emitter.on('stateChange', callback); + } + + /** + * Emits a state change event with the given value. + * @param value The state value to emit. + */ + protected emit(value: T) { + this.emitter.emit('stateChange', value); + } +} diff --git a/packages/automation/src/lib/listeners/timer.spec.ts b/packages/automation/src/lib/listeners/timer.spec.ts new file mode 100644 index 0000000000..84a79a5ec3 --- /dev/null +++ b/packages/automation/src/lib/listeners/timer.spec.ts @@ -0,0 +1,55 @@ +import { TimerListener } from './timer'; + +describe('TimerListener', () => { + let timerListener: TimerListener; + const interval = 1000; + const offset = 0; + const step = 1; + + beforeEach(() => { + jest.useFakeTimers(); + timerListener = new TimerListener(interval, offset, step); + }); + + afterEach(async () => { + await timerListener.stop(); + jest.clearAllMocks(); + jest.useRealTimers(); + }); + + it('should emit incremented values at specified intervals', async () => { + const callback = jest.fn(); + timerListener.onStateChange(callback); + + await timerListener.start(); + + jest.advanceTimersByTime(interval); + await Promise.resolve(); + + expect(callback).toHaveBeenCalledWith(1); + + jest.advanceTimersByTime(interval); + await Promise.resolve(); + + expect(callback).toHaveBeenCalledWith(2); + }); + + it('should reset count to offset when stopped', async () => { + const callback = jest.fn(); + timerListener.onStateChange(callback); + + await timerListener.start(); + + jest.advanceTimersByTime(interval * 3); + await Promise.resolve(); + + expect(callback).toHaveBeenCalledWith(3); + + await timerListener.stop(); + + jest.advanceTimersByTime(interval); + await Promise.resolve(); + + expect(callback).toHaveBeenCalledTimes(3); // No additional calls after stop + }); +}); diff --git a/packages/automation/src/lib/listeners/timer.ts b/packages/automation/src/lib/listeners/timer.ts new file mode 100644 index 0000000000..5e06e7823e --- /dev/null +++ b/packages/automation/src/lib/listeners/timer.ts @@ -0,0 +1,25 @@ +import { Listener } from './listener'; + +export class TimerListener extends Listener { + private intervalId?: ReturnType; + private count = 0; + + constructor(interval = 1000, offset = 0, step = 1) { + super({ + start: async () => { + this.intervalId = setInterval(() => { + this.count += step; + this.emit(this.count); + }, interval); + }, + stop: async () => { + this.count = offset; + if (this.intervalId) { + clearInterval(this.intervalId); + } + }, + }); + + this.count = offset; + } +} diff --git a/packages/automation/src/lib/state-machine.spec.ts b/packages/automation/src/lib/state-machine.spec.ts new file mode 100644 index 0000000000..ff7410b9e1 --- /dev/null +++ b/packages/automation/src/lib/state-machine.spec.ts @@ -0,0 +1,167 @@ +import { StateMachine } from './state-machine'; +import { Listener } from './listeners'; + +describe('StateMachine', () => { + let stateMachine: StateMachine; + let listener: Listener; + let check: jest.Mock; + let onMatch: jest.Mock; + let callOrder: string[]; + + beforeEach(() => { + callOrder = []; + stateMachine = new StateMachine(); + listener = new Listener({ + start: async () => {}, + stop: async () => {}, + }); + check = jest.fn(() => true); + onMatch = jest.fn(); + + stateMachine.addState({ + key: 'A', + onEnter: async () => { + callOrder.push('enter A'); + }, + onExit: async () => { + callOrder.push('exit A'); + }, + }); + stateMachine.addState({ + key: 'B', + onEnter: async () => { + callOrder.push('enter B'); + }, + onExit: async () => { + callOrder.push('exit B'); + }, + }); + }); + + it('should add states and transitions correctly', () => { + stateMachine.addTransition({ + fromState: 'A', + toState: 'B', + listeners: [listener], + check, + onMatch, + }); + expect(() => + stateMachine.addTransition({ + fromState: 'A', + toState: 'B', + listeners: [listener], + check, + onMatch, + }) + ).not.toThrow(); + }); + + it('should start the machine and trigger transitions in the correct order', async () => { + stateMachine.addTransition({ + fromState: 'A', + toState: 'B', + listeners: [listener], + check, + onMatch, + }); + await stateMachine.startMachine('A'); + + // Simulate transition action + await stateMachine['transitionTo']('B'); + + // Check the order of calls + await expect(callOrder).toEqual(['enter A', 'exit A', 'enter B']); + }); + + it('should not allow duplicate transitions with the same from-to combination', () => { + const newCheck = jest.fn(async () => false); + const newOnMatch = jest.fn(); + stateMachine.addTransition({ + fromState: 'A', + toState: 'B', + listeners: [listener], + check, + onMatch, + }); + stateMachine.addTransition({ + fromState: 'A', + toState: 'B', + listeners: [listener], + check: newCheck, + onMatch: newOnMatch, + }); + + const transitions = stateMachine['transitions'].get('A'); + const transition = transitions?.get('B'); + expect(transition).toBeDefined(); + expect(transition?.['check']).toBe(newCheck); + }); + + describe('stopMachine', () => { + it('should do nothing if no current state', async () => { + await stateMachine.stopMachine(); + expect(callOrder).toEqual([]); + }); + + it('should cleanup current state and transitions', async () => { + stateMachine.addTransition({ + fromState: 'A', + toState: 'B', + listeners: [listener], + check, + onMatch, + }); + + await stateMachine.startMachine('A'); + expect(callOrder).toEqual(['enter A']); + + await stateMachine.stopMachine(); + + expect(callOrder).toEqual(['enter A', 'exit A']); + }); + + it('should call onStop callback when provided', async () => { + const onStop = jest.fn(); + stateMachine.addTransition({ + fromState: 'A', + toState: 'B', + listeners: [listener], + check, + onMatch, + }); + + await stateMachine.startMachine('A', onStop); + expect(callOrder).toEqual(['enter A']); + + await stateMachine.stopMachine(); + + expect(onStop).toHaveBeenCalled(); + expect(callOrder).toEqual(['enter A', 'exit A']); + }); + + it('should handle errors in onStop callback', async () => { + const errorMessage = 'onStop error'; + const onStop = jest.fn().mockRejectedValue(new Error(errorMessage)); + + await stateMachine.startMachine('A', onStop); + await expect(stateMachine.stopMachine()).rejects.toThrow(errorMessage); + }); + + it('should handle errors during cleanup', async () => { + const errorStateMachine = new StateMachine(); + const errorMessage = 'Exit error'; + errorStateMachine.addState({ + key: 'error', + onExit: async () => { + throw new Error(errorMessage); + }, + }); + await errorStateMachine.startMachine('error'); + + await expect(errorStateMachine.stopMachine()).rejects.toThrow( + errorMessage + ); + }); + }); +}); diff --git a/packages/automation/src/lib/state-machine.ts b/packages/automation/src/lib/state-machine.ts new file mode 100644 index 0000000000..c85dcd816d --- /dev/null +++ b/packages/automation/src/lib/state-machine.ts @@ -0,0 +1,176 @@ +import { State, StateParams } from './states'; +import { Transition, BaseTransitionParams } from './transitions'; + +export interface BaseStateMachineParams { + debug?: boolean; +} + +export interface TransitionParams + extends Omit, + Partial> { + fromState: string; + toState: string; +} + +type MachineStatus = 'running' | 'stopped'; + +/** + * A StateMachine class that manages states and transitions between them. + */ +export class StateMachine { + private status: MachineStatus = 'stopped'; + private states = new Map(); + private transitions = new Map>(); + private currentState?: State; + private onStopCallback?: () => Promise; + private debug = false; + + constructor(params: BaseStateMachineParams = {}) { + this.debug = params.debug ?? false; + } + + get isRunning() { + return this.status === 'running'; + } + + /** + * Adds a state to the state machine. + * @param params The parameters for the state. + */ + addState(params: StateParams) { + const state = new State(params); + this.states.set(state.key, state); + if (!this.transitions.has(state.key)) { + this.transitions.set(state.key, new Map()); + } + } + + /** + * Adds a transition between two states. + * @param params The parameters for the transition. + */ + addTransition({ + fromState, + toState, + listeners, + check, + onMatch, + onMismatch, + }: TransitionParams) { + if (!this.states.has(fromState)) { + throw new Error(`Source state ${fromState} not found`); + } + if (!this.states.has(toState)) { + throw new Error(`Target state ${toState} not found`); + } + + const transitioningOnMatch = async (values: (unknown | undefined)[]) => { + await onMatch?.(values); + await this.transitionTo(toState); + }; + + const transition = new Transition({ + listeners, + check, + onMatch: transitioningOnMatch, + onMismatch, + }); + + const stateTransitions = + this.transitions.get(fromState) ?? new Map(); + stateTransitions.set(toState, transition); + this.transitions.set(fromState, stateTransitions); + } + + /** + * Starts the state machine with the given initial state. + * @param initialState The key of the initial state. + * @param onStop Optional callback to execute when the machine is stopped. + */ + async startMachine(initialState: string, onStop?: () => Promise) { + this.debug && console.log('Starting state machine...'); + + this.onStopCallback = onStop; + await this.enterState(initialState); + this.status = 'running'; + + this.debug && console.log('State machine started'); + } + + /** + * Stops the state machine by exiting the current state and not moving to another one. + */ + async stopMachine() { + this.debug && console.log('Stopping state machine...'); + + await this.exitCurrentState(); + await this.onStopCallback?.(); + this.status = 'stopped'; + + this.debug && console.log('State machine stopped'); + } + + /** + * Stops listening on the current state's transitions and exits the current state. + */ + private async exitCurrentState() { + if (!this.isRunning) { + return; + } + + this.debug && console.log('exitCurrentState', this.currentState?.key); + + const currentTransitions = + this.transitions.get(this.currentState?.key ?? '') ?? + new Map(); + await Promise.all( + Array.from(currentTransitions.values()).map((t) => t.stopListening()) + ); + await this.currentState?.exit(); + this.currentState = undefined; + } + + /** + * Moves to a new state. + * @param stateKey The key of the new state. + */ + private async enterState(stateKey: string) { + const state = this.states.get(stateKey); + if (!state) { + throw new Error(`State ${stateKey} not found`); + } + this.debug && console.log('enterState', state.key); + await state.enter(); + const nextTransitions = + this.transitions.get(state.key) ?? new Map(); + await Promise.all( + Array.from(nextTransitions.values()).map((t) => t.startListening()) + ); + this.currentState = state; + } + + /** + * Triggers a transition to a new state. + * @param stateKey The key of the target state. + */ + private async transitionTo(stateKey: string) { + const nextState = this.states.get(stateKey); + + if (!nextState) { + throw new Error(`State ${stateKey} not found`); + } + if (this.currentState === nextState) { + console.warn(`State ${stateKey} is already active. Skipping transition.`); + return; + } + + try { + // Machine consumer can call stopMachine() while we are in the middle of a transition + this.isRunning && (await this.exitCurrentState()); + this.isRunning && (await this.enterState(stateKey)); + } catch (e) { + this.currentState = undefined; + throw new Error(`Could not enter state ${stateKey}`); + } + } +} diff --git a/packages/automation/src/lib/states/index.ts b/packages/automation/src/lib/states/index.ts new file mode 100644 index 0000000000..a7b5e19c3b --- /dev/null +++ b/packages/automation/src/lib/states/index.ts @@ -0,0 +1,2 @@ +export * from './mint-pkp'; +export * from './state'; diff --git a/packages/automation/src/lib/states/mint-pkp.spec.ts b/packages/automation/src/lib/states/mint-pkp.spec.ts new file mode 100644 index 0000000000..c9cb6b085a --- /dev/null +++ b/packages/automation/src/lib/states/mint-pkp.spec.ts @@ -0,0 +1,78 @@ +import { LitContracts } from '@lit-protocol/contracts-sdk'; + +import { MintPKPState, MintPKPStateParams } from './mint-pkp'; + +describe('MintPKPState', () => { + let mockLitContracts: LitContracts; + let mockCallback: jest.Mock; + let mockMint: jest.Mock; + + beforeEach(() => { + mockMint = jest.fn().mockResolvedValue({ + pkp: { + tokenId: '123', + publicKey: '0xPublicKey', + ethAddress: '0xEthAddress', + }, + }); + + mockLitContracts = { + pkpNftContractUtils: { + write: { + mint: mockMint, + }, + }, + } as unknown as LitContracts; + + mockCallback = jest.fn(); + }); + + it('should mint a PKP and call the callback with PKP info', async () => { + const params: MintPKPStateParams = { + key: 'MintPKPState', + litContracts: mockLitContracts, + callback: mockCallback, + }; + + const state = new MintPKPState(params); + + await state.enter(); + + expect(mockMint).toHaveBeenCalled(); + expect(mockCallback).toHaveBeenCalledWith({ + tokenId: '123', + publicKey: '0xPublicKey', + ethAddress: '0xEthAddress', + }); + }); + + it('should handle errors during minting', async () => { + mockMint.mockRejectedValue(new Error('Minting error')); + + const params: MintPKPStateParams = { + key: 'MintPKPState', + litContracts: mockLitContracts, + callback: mockCallback, + }; + + const state = new MintPKPState(params); + + await expect(state.enter()).rejects.toThrow('Minting error'); + }); + + it('should execute onEnter callback if provided', async () => { + const onEnter = jest.fn(); + const params: MintPKPStateParams = { + key: 'MintPKPState', + litContracts: mockLitContracts, + callback: mockCallback, + onEnter, + }; + + const state = new MintPKPState(params); + + await state.enter(); + + expect(onEnter).toHaveBeenCalled(); + }); +}); diff --git a/packages/automation/src/lib/states/mint-pkp.ts b/packages/automation/src/lib/states/mint-pkp.ts new file mode 100644 index 0000000000..a22003f269 --- /dev/null +++ b/packages/automation/src/lib/states/mint-pkp.ts @@ -0,0 +1,32 @@ +import { LitContracts } from '@lit-protocol/contracts-sdk'; + +import { State, StateParams } from './state'; + +export interface PKPInfo { + tokenId: string; + publicKey: string; + ethAddress: string; +} + +export interface MintPKPStateParams extends StateParams { + litContracts: LitContracts; + callback: (pkpInfo: PKPInfo) => void; +} + +export class MintPKPState extends State { + constructor(params: MintPKPStateParams) { + const superParams: StateParams = { + key: params.key, + debug: params.debug, + onExit: params.onExit, + onEnter: async () => { + const mintingReceipt = + await params.litContracts.pkpNftContractUtils.write.mint(); + params.callback(mintingReceipt.pkp); + await params.onEnter?.(); + }, + }; + + super(superParams); + } +} diff --git a/packages/automation/src/lib/states/state.spec.ts b/packages/automation/src/lib/states/state.spec.ts new file mode 100644 index 0000000000..7f08ebe3da --- /dev/null +++ b/packages/automation/src/lib/states/state.spec.ts @@ -0,0 +1,54 @@ +import { State } from './state'; + +describe('State', () => { + it('should create state with name', () => { + const state = new State({ key: 'TestState' }); + expect(state.key).toBe('TestState'); + }); + + it('should execute onEnter callback when entering state', async () => { + const onEnter = jest.fn(); + const state = new State({ key: 'TestState', onEnter }); + + await state.enter(); + + expect(onEnter).toHaveBeenCalled(); + }); + + it('should execute onExit callback when exiting state', async () => { + const onExit = jest.fn(); + const state = new State({ key: 'TestState', onExit }); + + await state.exit(); + + expect(onExit).toHaveBeenCalled(); + }); + + it('should not throw when entering state without onEnter callback', async () => { + const state = new State({ key: 'TestState' }); + await expect(() => state.enter()).not.toThrow(); + }); + + it('should not throw when exiting state without onExit callback', async () => { + const state = new State({ key: 'TestState' }); + await expect(() => state.exit()).not.toThrow(); + }); + + it('should handle throwing onEnter callback', async () => { + const onEnter = jest.fn().mockImplementation(() => { + throw new Error('Enter error'); + }); + const state = new State({ key: 'TestState', onEnter }); + + await expect(() => state.enter()).rejects.toThrow('Enter error'); + }); + + it('should handle throwing onExit callback', async () => { + const onExit = jest.fn().mockImplementation(() => { + throw new Error('Exit error'); + }); + const state = new State({ key: 'TestState', onExit }); + + await expect(() => state.exit()).rejects.toThrow('Exit error'); + }); +}); diff --git a/packages/automation/src/lib/states/state.ts b/packages/automation/src/lib/states/state.ts new file mode 100644 index 0000000000..5b5b84018b --- /dev/null +++ b/packages/automation/src/lib/states/state.ts @@ -0,0 +1,41 @@ +export interface BaseStateParams { + key: string; + onEnter?: () => Promise; + onExit?: () => Promise; + debug?: boolean; +} + +export type StateParams = BaseStateParams; + +/** + * A State class that represents a state with optional entry and exit actions. + */ +export class State { + public readonly key: string; + public readonly onEnter: (() => Promise) | undefined; + public readonly onExit: (() => Promise) | undefined; + private debug = false; + + constructor(private params: BaseStateParams) { + this.key = params.key; + this.onEnter = params.onEnter; + this.onExit = params.onExit; + this.debug = params.debug ?? false; + } + + /** + * Executes the onEnter action for the state. + */ + async enter() { + this.debug && console.log(`enter ${this.key}`); + await this.onEnter?.(); + } + + /** + * Executes the onExit action for the state. + */ + async exit() { + this.debug && console.log(`exit ${this.key}`); + await this.onExit?.(); + } +} diff --git a/packages/automation/src/lib/transitions/index.ts b/packages/automation/src/lib/transitions/index.ts new file mode 100644 index 0000000000..df7a702a10 --- /dev/null +++ b/packages/automation/src/lib/transitions/index.ts @@ -0,0 +1 @@ +export * from './transition'; diff --git a/packages/automation/src/lib/transitions/transition.spec.ts b/packages/automation/src/lib/transitions/transition.spec.ts new file mode 100644 index 0000000000..62b295681c --- /dev/null +++ b/packages/automation/src/lib/transitions/transition.spec.ts @@ -0,0 +1,114 @@ +import { TimerListener } from '../listeners'; +import { Transition } from './transition'; + +function coalesce(value: number | undefined) { + return value ?? 0; +} + +describe('Transition', () => { + let transition: Transition; + let listener1: TimerListener; + let listener2: TimerListener; + let check: jest.Mock; + let onMatch: jest.Mock; + let onMismatch: jest.Mock; + + beforeEach(() => { + jest.useFakeTimers(); + check = jest.fn((values: (number | undefined)[]) => { + const [val1, val2] = values.map(coalesce); + return val1 >= 3 && val2 >= 2; + }); + onMatch = jest.fn(); + onMismatch = jest.fn(); + listener1 = new TimerListener(1000); + listener2 = new TimerListener(2000); + transition = new Transition({ + listeners: [listener1, listener2], + check, + onMatch, + onMismatch, + }); + }); + + it('should call onMatch when check is true', async () => { + await transition.startListening(); + + // After 4 seconds (listener1 counter = 4, listener2 counter = 2) + jest.advanceTimersByTime(4000); + await expect(check).toHaveBeenCalledTimes(6); + await expect(onMismatch).toHaveBeenCalledTimes(5); // 4 for listener1, 2 for listener2. But last one matched + await expect(onMatch).toHaveBeenCalledTimes(1); + await expect(onMatch).toHaveBeenCalledWith([4, 2]); // The last one is matched + }); + + it('should call onMismatch when check is false', async () => { + await transition.startListening(); + + // After 3 seconds (listener1 counter = 3, listener2 counter = 1) + jest.advanceTimersByTime(3000); + await expect(check).toHaveBeenCalledTimes(4); + await expect(onMismatch).toHaveBeenCalledTimes(4); // 3 for listener1, 1 for listener2 + await expect(onMismatch).toHaveBeenCalledWith([3, 1]); // Last of failing values + await expect(onMatch).not.toHaveBeenCalled(); + }); + + it('should stop calling callbacks after stopListening', async () => { + await transition.startListening(); + + // After 2 seconds + jest.advanceTimersByTime(3000); + await expect(check).toHaveBeenCalledTimes(4); + await expect(onMismatch).toHaveBeenCalledTimes(4); // 3 for listener1, 1 for listener2 + await expect(onMismatch).toHaveBeenCalledWith([3, 1]); // Example of checking values + + await transition.stopListening(); + + // After another 2 seconds + jest.advanceTimersByTime(2000); + await expect(check).toHaveBeenCalledTimes(4); // No additional calls + await expect(onMismatch).toHaveBeenCalledTimes(4); // No additional calls + await expect(onMatch).not.toHaveBeenCalled(); + }); + + it('should handle missing listeners, check and onMismatch callbacks gracefully', async () => { + const basicTransition = new Transition({ + onMatch, + }); + await basicTransition.startListening(); + + // Advance time without callbacks + jest.advanceTimersByTime(6000); + await expect(() => basicTransition.stopListening()).not.toThrow(); + }); + + it('should automatically call onMatch if check is not provided', async () => { + const autoMatchTransition = new Transition({ + listeners: [listener1, listener2], + onMatch, + }); + await autoMatchTransition.startListening(); + + // After 2 seconds (listener1 counter = 2, listener2 counter = 1) + jest.advanceTimersByTime(2000); + await expect(onMatch).toHaveBeenCalledTimes(3); // Called for each state change + await expect(onMatch).toHaveBeenCalledWith([2, 1]); + }); + + it('should automatically call onMatch if there are no listeners and no check function', async () => { + const noListenerTransition = new Transition({ + onMatch, + }); + await noListenerTransition.startListening(); + + // Since there are no listeners, onMatch should be called immediately + jest.runAllTimers(); + await expect(onMatch).toHaveBeenCalledTimes(1); + await expect(onMatch).toHaveBeenCalledWith([]); + }); + + afterEach(async () => { + await transition.stopListening(); + jest.useRealTimers(); + }); +}); diff --git a/packages/automation/src/lib/transitions/transition.ts b/packages/automation/src/lib/transitions/transition.ts new file mode 100644 index 0000000000..d764aa745a --- /dev/null +++ b/packages/automation/src/lib/transitions/transition.ts @@ -0,0 +1,82 @@ +import { Listener } from '../listeners'; + +/** + * A Transition class that manages state transitions based on listeners and conditions. + */ +export interface BaseTransitionParams { + listeners?: Listener[]; + check?: (values: (any | undefined)[]) => Promise; + onMatch: (values: (any | undefined)[]) => Promise; + onMismatch?: (values: (any | undefined)[]) => Promise; +} + +export class Transition { + private debug = false; + private listeners: Listener[]; + private readonly values: (any | undefined)[]; + private readonly check?: (values: (any | undefined)[]) => Promise; + private readonly onMatch: (values: (any | undefined)[]) => Promise; + private readonly onMismatch?: (values: (any | undefined)[]) => Promise; + + /** + * Creates a new Transition instance. If no listeners are provided, the transition will automatically match on the next event loop. + * + * @param params An object containing listeners, check function, and optional onMatch and onMismatch functions. + */ + constructor({ + listeners = [], + check, + onMatch, + onMismatch, + }: BaseTransitionParams) { + this.listeners = listeners; + this.check = check; + this.onMatch = onMatch; + this.onMismatch = onMismatch; + this.values = new Array(listeners.length).fill(undefined); + this.setupListeners(); + } + + /** + * Sets up listeners for state changes and handles transition logic. + */ + private setupListeners() { + this.listeners.forEach((listener, index) => { + listener.onStateChange(async (value: any) => { + this.values[index] = value; + const isMatch = this.check ? await this.check(this.values) : true; + if (isMatch) { + this.debug && console.log('match', this.values); + await this.onMatch?.(this.values); + } else { + this.debug && console.log('mismatch', this.values); + await this.onMismatch?.(this.values); + } + }); + }); + } + + /** + * Starts all listeners for this transition. + */ + async startListening() { + this.debug && console.log('startListening'); + await Promise.all(this.listeners.map((listener) => listener.start())); + + if (!this.listeners.length) { + // If the transition does not have any listeners it will never emit. Therefore, we "emit" automatically on next event loop + setTimeout(() => { + this.debug && console.log('Transition without listeners: auto match'); + this.onMatch([]); + }, 0); + } + } + + /** + * Stops all listeners for this transition. + */ + async stopListening() { + this.debug && console.log('stopListening'); + await Promise.all(this.listeners.map((listener) => listener.stop())); + } +} diff --git a/packages/automation/tsconfig.json b/packages/automation/tsconfig.json new file mode 100644 index 0000000000..d3187ebeee --- /dev/null +++ b/packages/automation/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "system", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "allowJs": true, + "checkJs": false, + "resolveJsonModule": false + }, + "files": [], + "include": ["global.d.ts"], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/automation/tsconfig.lib.json b/packages/automation/tsconfig.lib.json new file mode 100644 index 0000000000..8261486edc --- /dev/null +++ b/packages/automation/tsconfig.lib.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": [], + "allowJs": true, + "checkJs": false + }, + "include": ["**/*.ts"], + "exclude": ["jest.config.ts", "**/*.spec.ts", "**/*.test.ts"] +} diff --git a/packages/automation/tsconfig.spec.json b/packages/automation/tsconfig.spec.json new file mode 100644 index 0000000000..48d6d00bb4 --- /dev/null +++ b/packages/automation/tsconfig.spec.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"], + "allowJs": true, + "checkJs": false + }, + "include": ["jest.config.ts", "**/*.test.ts", "**/*.spec.ts", "**/*.d.ts"] +} diff --git a/packages/wrapped-keys-lit-actions/jest.config.ts b/packages/wrapped-keys-lit-actions/jest.config.ts index e36b3f094b..cfe699aa1b 100644 --- a/packages/wrapped-keys-lit-actions/jest.config.ts +++ b/packages/wrapped-keys-lit-actions/jest.config.ts @@ -11,6 +11,6 @@ export default { '^.+\\.[t]s$': 'ts-jest', }, moduleFileExtensions: ['ts', 'js', 'html'], - coverageDirectory: '../../coverage/packages/types', + coverageDirectory: '../../coverage/packages/wrapped-keys-lit-actions', setupFilesAfterEnv: ['../../jest.setup.js'], }; diff --git a/packages/wrapped-keys-lit-actions/tsconfig.lib.json b/packages/wrapped-keys-lit-actions/tsconfig.lib.json index c89e6dbca4..ce61706108 100644 --- a/packages/wrapped-keys-lit-actions/tsconfig.lib.json +++ b/packages/wrapped-keys-lit-actions/tsconfig.lib.json @@ -7,6 +7,6 @@ "allowJs": true, "checkJs": false }, - "include": ["**/*.ts", "esbuild.config.js", "esbuild.config.js"], + "include": ["**/*.ts", "esbuild.config.js"], "exclude": ["jest.config.ts", "**/*.spec.ts", "**/*.test.ts"] } diff --git a/packages/wrapped-keys/jest.config.ts b/packages/wrapped-keys/jest.config.ts index e36b3f094b..f775242d3f 100644 --- a/packages/wrapped-keys/jest.config.ts +++ b/packages/wrapped-keys/jest.config.ts @@ -11,6 +11,6 @@ export default { '^.+\\.[t]s$': 'ts-jest', }, moduleFileExtensions: ['ts', 'js', 'html'], - coverageDirectory: '../../coverage/packages/types', + coverageDirectory: '../../coverage/packages/wrapped-keys', setupFilesAfterEnv: ['../../jest.setup.js'], }; diff --git a/yarn.lock b/yarn.lock index 452aa24cc1..55660ae082 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5495,6 +5495,11 @@ dependencies: "@types/node" "*" +"@types/events@^3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.3.tgz#a8ef894305af28d1fc6d2dfdfc98e899591ea529" + integrity sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g== + "@types/graceful-fs@^4.1.2", "@types/graceful-fs@^4.1.3": version "4.1.9" resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.9.tgz#2a06bc0f68a20ab37b3e36aa238be6abdf49e8b4" @@ -6866,7 +6871,7 @@ argv-formatter@~1.0.0: resolved "https://registry.yarnpkg.com/argv-formatter/-/argv-formatter-1.0.0.tgz#a0ca0cbc29a5b73e836eebe1cbf6c5e0e4eb82f9" integrity sha512-F2+Hkm9xFaRg+GkaNnbwXNDV5O6pnCFEmqyhvfC/Ic5LbgOWjJh3L+mN/s91rxVL3znE7DYVpW0GJFT+4YBgWw== -aria-query@5.1.3: +aria-query@5.1.3, aria-query@~5.1.3: version "5.1.3" resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.1.3.tgz#19db27cd101152773631396f7a95a3b58c22c35e" integrity sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ== @@ -7176,7 +7181,7 @@ aws4@^1.8.0: resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.13.2.tgz#0aa167216965ac9474ccfa83892cfb6b3e1e52ef" integrity sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw== -axe-core@^4.10.0: +axe-core@^4.10.0, axe-core@^4.9.1: version "4.10.2" resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.10.2.tgz#85228e3e1d8b8532a27659b332e39b7fa0e022df" integrity sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w== @@ -7218,6 +7223,13 @@ axobject-query@^4.1.0: resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-4.1.0.tgz#28768c76d0e3cff21bc62a9e2d0b6ac30042a1ee" integrity sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ== +axobject-query@~3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-3.1.1.tgz#3b6e5c6d4e43ca7ba51c5babf99d22a9c68485e1" + integrity sha512-goKlv8DZrK9hUh975fnHzhNIO4jUnFCfv/dszV5VwUGDFjI6vQ2VwoyjYjYNEbBE8AH87TduWP5uyDR1D+Iteg== + dependencies: + deep-equal "^2.0.5" + babel-code-frame@^6.26.0: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b" @@ -11085,7 +11097,7 @@ es-get-iterator@^1.1.3: isarray "^2.0.5" stop-iteration-iterator "^1.0.0" -es-iterator-helpers@^1.1.0: +es-iterator-helpers@^1.0.19, es-iterator-helpers@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/es-iterator-helpers/-/es-iterator-helpers-1.2.0.tgz#2f1a3ab998b30cb2d10b195b587c6d9ebdebf152" integrity sha512-tpxqxncxnpw3c93u8n3VOzACmRFoVmWJqbWXvX/JfKbkhBw1oslgPrUfeSt2psuqyEJFD6N/9lg5i7bsKpoq+Q== @@ -21942,7 +21954,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -21960,6 +21972,15 @@ string-width@^1.0.1: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + string-width@^2.0.0, string-width@^2.1.0: version "2.1.1" resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" @@ -21986,7 +22007,7 @@ string-width@^5.0.1, string-width@^5.1.2: emoji-regex "^9.2.2" strip-ansi "^7.0.1" -string.prototype.includes@^2.0.1: +string.prototype.includes@^2.0.0, string.prototype.includes@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz#eceef21283640761a81dbe16d6c7171a4edf7d92" integrity sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg== @@ -22081,7 +22102,7 @@ stringify-package@^1.0.1: resolved "https://registry.yarnpkg.com/stringify-package/-/stringify-package-1.0.1.tgz#e5aa3643e7f74d0f28628b72f3dad5cecfc3ba85" integrity sha512-sa4DUQsYciMP1xhKWGuFM04fB0LG/9DlluZoSVywUMRNvzid6XucHK0/90xGxRoHrAaROrcHK1aPKaijCtSrhg== -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -22109,6 +22130,13 @@ strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -24251,7 +24279,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -24286,6 +24314,15 @@ wrap-ansi@^6.0.1, wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.0.1, wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" From 40bbafe202c19022058193eb35a3a4a562789d19 Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Wed, 27 Nov 2024 20:29:08 +0100 Subject: [PATCH 02/43] feat: add ids in state machines --- packages/automation/src/lib/listeners/fetch.spec.ts | 4 +++- packages/automation/src/lib/state-machine.spec.ts | 7 +++++++ packages/automation/src/lib/state-machine.ts | 8 +++++++- packages/automation/tsconfig.json | 7 ++----- packages/wrapped-keys-lit-actions/tsconfig.json | 2 +- 5 files changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/automation/src/lib/listeners/fetch.spec.ts b/packages/automation/src/lib/listeners/fetch.spec.ts index ed0098ff01..3f881cc838 100644 --- a/packages/automation/src/lib/listeners/fetch.spec.ts +++ b/packages/automation/src/lib/listeners/fetch.spec.ts @@ -28,7 +28,9 @@ describe('FetchListener', () => { it('should fetch data and emit the correct value', async () => { let callbackCalled: () => void; - const callbackPromise = new Promise(resolve => callbackCalled = resolve); + const callbackPromise = new Promise( + (resolve) => (callbackCalled = resolve) + ); const callback = jest.fn(async () => { callbackCalled(); diff --git a/packages/automation/src/lib/state-machine.spec.ts b/packages/automation/src/lib/state-machine.spec.ts index ff7410b9e1..7d3649fc45 100644 --- a/packages/automation/src/lib/state-machine.spec.ts +++ b/packages/automation/src/lib/state-machine.spec.ts @@ -38,6 +38,13 @@ describe('StateMachine', () => { }); }); + it('should generate a unique id for each state machine instance', () => { + const anotherStateMachine = new StateMachine(); + expect(stateMachine.id).toBeDefined(); + expect(anotherStateMachine.id).toBeDefined(); + expect(stateMachine.id).not.toEqual(anotherStateMachine.id); + }); + it('should add states and transitions correctly', () => { stateMachine.addTransition({ fromState: 'A', diff --git a/packages/automation/src/lib/state-machine.ts b/packages/automation/src/lib/state-machine.ts index c85dcd816d..e915ae2695 100644 --- a/packages/automation/src/lib/state-machine.ts +++ b/packages/automation/src/lib/state-machine.ts @@ -18,7 +18,8 @@ type MachineStatus = 'running' | 'stopped'; * A StateMachine class that manages states and transitions between them. */ export class StateMachine { - private status: MachineStatus = 'stopped'; + public id: string; + public status: MachineStatus = 'stopped'; private states = new Map(); private transitions = new Map>(); private currentState?: State; @@ -26,6 +27,7 @@ export class StateMachine { private debug = false; constructor(params: BaseStateMachineParams = {}) { + this.id = this.generateId(); this.debug = params.debug ?? false; } @@ -173,4 +175,8 @@ export class StateMachine { throw new Error(`Could not enter state ${stateKey}`); } } + + private generateId(): string { + return Math.random().toString(36).substring(2); + } } diff --git a/packages/automation/tsconfig.json b/packages/automation/tsconfig.json index d3187ebeee..8cb12823a1 100644 --- a/packages/automation/tsconfig.json +++ b/packages/automation/tsconfig.json @@ -1,16 +1,13 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "module": "system", + "module": "commonjs", "forceConsistentCasingInFileNames": true, "strict": true, "noImplicitOverride": true, "noPropertyAccessFromIndexSignature": true, "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "allowJs": true, - "checkJs": false, - "resolveJsonModule": false + "noFallthroughCasesInSwitch": true }, "files": [], "include": ["global.d.ts"], diff --git a/packages/wrapped-keys-lit-actions/tsconfig.json b/packages/wrapped-keys-lit-actions/tsconfig.json index d3187ebeee..2e798eb140 100644 --- a/packages/wrapped-keys-lit-actions/tsconfig.json +++ b/packages/wrapped-keys-lit-actions/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "module": "system", + "module": "commonjs", "forceConsistentCasingInFileNames": true, "strict": true, "noImplicitOverride": true, From 1f76cc69f18ed22a47368057b1539d93d03df46c Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Fri, 29 Nov 2024 19:41:22 +0100 Subject: [PATCH 03/43] feat: state machine creation using declarative config --- packages/automation/src/index.ts | 7 +- .../src/lib/listeners/evm-contract-event.ts | 1 + packages/automation/src/lib/state-machine.ts | 153 +++++++++++++++++- packages/automation/src/lib/states/state.ts | 2 +- .../src/lib/transitions/transition.ts | 7 +- 5 files changed, 161 insertions(+), 9 deletions(-) diff --git a/packages/automation/src/index.ts b/packages/automation/src/index.ts index 1feb0e620e..e556f8ac77 100644 --- a/packages/automation/src/index.ts +++ b/packages/automation/src/index.ts @@ -1,3 +1,4 @@ -import { StateMachine } from './lib/state-machine'; - -export { StateMachine }; +export * from './lib/listeners'; +export * from './lib/states'; +export * from './lib/transitions'; +export * from './lib/state-machine'; diff --git a/packages/automation/src/lib/listeners/evm-contract-event.ts b/packages/automation/src/lib/listeners/evm-contract-event.ts index 43040885dd..7d21f594d4 100644 --- a/packages/automation/src/lib/listeners/evm-contract-event.ts +++ b/packages/automation/src/lib/listeners/evm-contract-event.ts @@ -1,4 +1,5 @@ import { ethers } from 'ethers'; + import { Listener } from './listener'; export type ContractEventData = { diff --git a/packages/automation/src/lib/state-machine.ts b/packages/automation/src/lib/state-machine.ts index e915ae2695..d3372bf8aa 100644 --- a/packages/automation/src/lib/state-machine.ts +++ b/packages/automation/src/lib/state-machine.ts @@ -1,8 +1,56 @@ +import { ethers } from 'ethers'; + +import { LIT_EVM_CHAINS } from '@lit-protocol/constants'; +import { LitContracts } from '@lit-protocol/contracts-sdk'; +import { LitNodeClient } from '@lit-protocol/lit-node-client'; + +import { + ContractEventData, + EVMContractEventListener, + Listener, + TimerListener, +} from './listeners'; import { State, StateParams } from './states'; -import { Transition, BaseTransitionParams } from './transitions'; +import { Check, Transition, BaseTransitionParams } from './transitions'; export interface BaseStateMachineParams { debug?: boolean; + litNodeClient: LitNodeClient; + litContracts: LitContracts; +} + +export interface StateDefinition { + key: string; +} + +interface TimerTransitionDefinition { + interval: number; + offset: number; + step: number; + until: number; +} + +interface EvmContractEventTransitionDefinition { + evmChainId: number; + contractAddress: string; + abi: ethers.ContractInterface; + eventName: string; + eventParams?: any; +} + +export interface TransitionDefinition { + fromState: string; + toState: string; + timer?: TimerTransitionDefinition; + evmContractEvent?: EvmContractEventTransitionDefinition; +} + +export interface StateMachineDefinition + extends Omit { + litNodeClient: LitNodeClient | ConstructorParameters[0]; + litContracts: LitContracts | ConstructorParameters[0]; + states: StateDefinition[]; + transitions: TransitionDefinition[]; } export interface TransitionParams @@ -12,23 +60,119 @@ export interface TransitionParams toState: string; } -type MachineStatus = 'running' | 'stopped'; +export type MachineStatus = 'running' | 'stopped'; /** * A StateMachine class that manages states and transitions between them. */ export class StateMachine { + private debug = false; + + private litNodeClient: LitNodeClient; + private litContracts: LitContracts; + public id: string; public status: MachineStatus = 'stopped'; private states = new Map(); private transitions = new Map>(); private currentState?: State; private onStopCallback?: () => Promise; - private debug = false; - constructor(params: BaseStateMachineParams = {}) { + constructor(params: BaseStateMachineParams) { this.id = this.generateId(); this.debug = params.debug ?? false; + + this.litNodeClient = params.litNodeClient; + this.litContracts = params.litContracts; + } + + static fromDefinition(machineConfig: StateMachineDefinition): StateMachine { + const { litNodeClient, litContracts = {} } = machineConfig; + + const litNodeClientInstance = + 'connect' in litNodeClient + ? litNodeClient + : new LitNodeClient(litNodeClient); + const litContractsInstance = + 'connect' in litContracts ? litContracts : new LitContracts(litContracts); + + if ( + litNodeClientInstance.config.litNetwork !== litContractsInstance.network + ) { + throw new Error( + 'litNodeClient and litContracts should not use different networks' + ); + } + + const stateMachine = new StateMachine({ + debug: machineConfig.debug, + litNodeClient: litNodeClientInstance, + litContracts: litContractsInstance, + }); + + machineConfig.states.forEach((state) => { + stateMachine.addState(state); + }); + + machineConfig.transitions.forEach((transition, index) => { + const { fromState, toState, timer, evmContractEvent } = transition; + + const transitionConfig: TransitionParams = { + fromState, + toState, + }; + + const listeners: Listener[] = []; + const checks: Check[] = []; + + if (timer) { + listeners.push( + new TimerListener(timer.interval, timer.offset, timer.step) + ); + checks.push(async (values) => values[index] === timer.until); + } + + if (evmContractEvent) { + const chain = Object.values(LIT_EVM_CHAINS).find( + (chain) => chain.chainId === evmContractEvent.evmChainId + ); + if (!chain) { + throw new Error( + `EVM chain with chainId ${evmContractEvent.evmChainId} not found` + ); + } + + listeners.push( + new EVMContractEventListener( + chain.rpcUrls[0], + { + address: evmContractEvent.contractAddress, + abi: evmContractEvent.abi, + }, + { + name: evmContractEvent.eventName, + filter: evmContractEvent.eventParams, + } + ) + ); + checks.push(async (values) => { + const eventData = values[index] as ContractEventData; + return eventData.event.event === evmContractEvent.eventName; + }); + } + + // Add all listeners to the transition + transitionConfig.listeners = listeners; + // Aggregate (AND) all listener checks to a single function result + transitionConfig.check = async (values) => + Promise.all(checks.map((check) => check(values))).then((results) => + results.every((result) => result) + ); + + stateMachine.addTransition(transitionConfig); + }); + + return stateMachine; } get isRunning() { @@ -72,6 +216,7 @@ export class StateMachine { }; const transition = new Transition({ + debug: this.debug, listeners, check, onMatch: transitioningOnMatch, diff --git a/packages/automation/src/lib/states/state.ts b/packages/automation/src/lib/states/state.ts index 5b5b84018b..abd994368f 100644 --- a/packages/automation/src/lib/states/state.ts +++ b/packages/automation/src/lib/states/state.ts @@ -11,10 +11,10 @@ export type StateParams = BaseStateParams; * A State class that represents a state with optional entry and exit actions. */ export class State { + private debug = false; public readonly key: string; public readonly onEnter: (() => Promise) | undefined; public readonly onExit: (() => Promise) | undefined; - private debug = false; constructor(private params: BaseStateParams) { this.key = params.key; diff --git a/packages/automation/src/lib/transitions/transition.ts b/packages/automation/src/lib/transitions/transition.ts index d764aa745a..506d4a56b1 100644 --- a/packages/automation/src/lib/transitions/transition.ts +++ b/packages/automation/src/lib/transitions/transition.ts @@ -1,9 +1,12 @@ import { Listener } from '../listeners'; +export type Check = (values: (any | undefined)[]) => Promise; + /** * A Transition class that manages state transitions based on listeners and conditions. */ export interface BaseTransitionParams { + debug?: boolean; listeners?: Listener[]; check?: (values: (any | undefined)[]) => Promise; onMatch: (values: (any | undefined)[]) => Promise; @@ -14,7 +17,7 @@ export class Transition { private debug = false; private listeners: Listener[]; private readonly values: (any | undefined)[]; - private readonly check?: (values: (any | undefined)[]) => Promise; + private readonly check?: Check; private readonly onMatch: (values: (any | undefined)[]) => Promise; private readonly onMismatch?: (values: (any | undefined)[]) => Promise; @@ -24,11 +27,13 @@ export class Transition { * @param params An object containing listeners, check function, and optional onMatch and onMismatch functions. */ constructor({ + debug, listeners = [], check, onMatch, onMismatch, }: BaseTransitionParams) { + this.debug = debug ?? false; this.listeners = listeners; this.check = check; this.onMatch = onMatch; From 720cce31f270e419c58e100062d6e818144d606c Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Tue, 3 Dec 2024 18:34:28 +0100 Subject: [PATCH 04/43] feat: native and erc20 balances monitoring transitions declarative interfaces. File restructuring. Fix multiple transitions in same state --- packages/automation/src/lib/state-machine.ts | 161 +++++++++++-------- packages/automation/src/lib/types.ts | 88 ++++++++++ packages/automation/src/lib/utils/chain.ts | 14 ++ packages/automation/src/lib/utils/erc20.ts | 77 +++++++++ 4 files changed, 275 insertions(+), 65 deletions(-) create mode 100644 packages/automation/src/lib/types.ts create mode 100644 packages/automation/src/lib/utils/chain.ts create mode 100644 packages/automation/src/lib/utils/erc20.ts diff --git a/packages/automation/src/lib/state-machine.ts b/packages/automation/src/lib/state-machine.ts index d3372bf8aa..2686039677 100644 --- a/packages/automation/src/lib/state-machine.ts +++ b/packages/automation/src/lib/state-machine.ts @@ -1,64 +1,36 @@ import { ethers } from 'ethers'; -import { LIT_EVM_CHAINS } from '@lit-protocol/constants'; import { LitContracts } from '@lit-protocol/contracts-sdk'; import { LitNodeClient } from '@lit-protocol/lit-node-client'; import { ContractEventData, EVMContractEventListener, + IntervalListener, Listener, TimerListener, } from './listeners'; import { State, StateParams } from './states'; -import { Check, Transition, BaseTransitionParams } from './transitions'; - -export interface BaseStateMachineParams { - debug?: boolean; - litNodeClient: LitNodeClient; - litContracts: LitContracts; -} - -export interface StateDefinition { - key: string; -} - -interface TimerTransitionDefinition { - interval: number; - offset: number; - step: number; - until: number; -} - -interface EvmContractEventTransitionDefinition { - evmChainId: number; - contractAddress: string; - abi: ethers.ContractInterface; - eventName: string; - eventParams?: any; -} - -export interface TransitionDefinition { - fromState: string; - toState: string; - timer?: TimerTransitionDefinition; - evmContractEvent?: EvmContractEventTransitionDefinition; -} - -export interface StateMachineDefinition - extends Omit { - litNodeClient: LitNodeClient | ConstructorParameters[0]; - litContracts: LitContracts | ConstructorParameters[0]; - states: StateDefinition[]; - transitions: TransitionDefinition[]; -} - -export interface TransitionParams - extends Omit, - Partial> { - fromState: string; - toState: string; -} +import { Check, Transition } from './transitions'; +import { getChain } from './utils/chain'; +import { getBalanceTransitionCheck, getERC20Balance } from './utils/erc20'; + +import type { + Address, + BalanceTransitionDefinition, + BaseBalanceTransitionDefinition, + BaseStateMachineParams, + ERC20BalanceTransitionDefinition, + EvmContractEventTransitionDefinition, + IntervalTransitionDefinition, + NativeBalanceTransitionDefinition, + OnEvmChainEvent, + StateDefinition, + StateMachineDefinition, + TimerTransitionDefinition, + TransitionDefinition, + TransitionParams, +} from './types'; export type MachineStatus = 'running' | 'stopped'; @@ -114,8 +86,9 @@ export class StateMachine { stateMachine.addState(state); }); - machineConfig.transitions.forEach((transition, index) => { - const { fromState, toState, timer, evmContractEvent } = transition; + machineConfig.transitions.forEach((transition) => { + const { balances, evmContractEvent, fromState, timer, toState } = + transition; const transitionConfig: TransitionParams = { fromState, @@ -126,21 +99,16 @@ export class StateMachine { const checks: Check[] = []; if (timer) { + const transitionIndex = checks.length; listeners.push( new TimerListener(timer.interval, timer.offset, timer.step) ); - checks.push(async (values) => values[index] === timer.until); + checks.push(async (values) => values[transitionIndex] === timer.until); } if (evmContractEvent) { - const chain = Object.values(LIT_EVM_CHAINS).find( - (chain) => chain.chainId === evmContractEvent.evmChainId - ); - if (!chain) { - throw new Error( - `EVM chain with chainId ${evmContractEvent.evmChainId} not found` - ); - } + const transitionIndex = checks.length; + const chain = getChain(evmContractEvent); listeners.push( new EVMContractEventListener( @@ -156,18 +124,76 @@ export class StateMachine { ) ); checks.push(async (values) => { - const eventData = values[index] as ContractEventData; - return eventData.event.event === evmContractEvent.eventName; + const eventData = values[transitionIndex] as + | ContractEventData + | undefined; + return eventData?.event.event === evmContractEvent.eventName; + }); + } + + if (balances) { + balances.forEach((balance) => { + const transitionIndex = checks.length; + const chain = getChain(balance); + + const chainProvider = new ethers.providers.JsonRpcProvider( + chain.rpcUrls[0], + chain.chainId + ); + + switch (balance.type) { + case 'native': + listeners.push( + new IntervalListener( + () => chainProvider.getBalance(balance.address), + balance.interval + ) + ); + checks.push(getBalanceTransitionCheck(transitionIndex, balance)); + break; + case 'ERC20': + listeners.push( + new IntervalListener( + () => + getERC20Balance( + chainProvider, + balance.tokenAddress, + balance.tokenDecimals, + balance.address + ), + balance.interval + ) + ); + checks.push(getBalanceTransitionCheck(transitionIndex, balance)); + break; + // case 'ERC721': + // case 'ERC1155': + default: + throw new Error( + `TODO balance check type ${balance['type']} unknown or not yet implemented` + ); + } }); } // Add all listeners to the transition transitionConfig.listeners = listeners; // Aggregate (AND) all listener checks to a single function result - transitionConfig.check = async (values) => - Promise.all(checks.map((check) => check(values))).then((results) => - results.every((result) => result) + transitionConfig.check = async (values) => { + console.log( + `${transition.fromState} -> ${transition.toState} values`, + values ); + return Promise.all(checks.map((check) => check(values))).then( + (results) => { + console.log( + `${transition.fromState} -> ${transition.toState} results`, + results + ); + return results.every((result) => result); + } + ); + }; stateMachine.addTransition(transitionConfig); }); @@ -237,6 +263,11 @@ export class StateMachine { async startMachine(initialState: string, onStop?: () => Promise) { this.debug && console.log('Starting state machine...'); + await Promise.all([ + this.litContracts.connect(), + this.litNodeClient.connect(), + ]); + this.onStopCallback = onStop; await this.enterState(initialState); this.status = 'running'; diff --git a/packages/automation/src/lib/types.ts b/packages/automation/src/lib/types.ts new file mode 100644 index 0000000000..64dcca2638 --- /dev/null +++ b/packages/automation/src/lib/types.ts @@ -0,0 +1,88 @@ +import { ethers } from 'ethers'; + +import { LitContracts } from '@lit-protocol/contracts-sdk'; +import { LitNodeClient } from '@lit-protocol/lit-node-client'; + +import { BaseTransitionParams } from './transitions'; + +export type Address = `0x${string}`; + +export interface StateDefinition { + key: string; +} + +export interface OnEvmChainEvent { + evmChainId: number; +} + +export interface IntervalTransitionDefinition { + interval?: number; +} + +export interface BaseBalanceTransitionDefinition + extends IntervalTransitionDefinition, + OnEvmChainEvent { + address: Address; + comparator: '>' | '>=' | '=' | '!=' | '<=' | '<'; + amount: string; +} + +export interface NativeBalanceTransitionDefinition + extends BaseBalanceTransitionDefinition { + type: 'native'; +} + +export interface ERC20BalanceTransitionDefinition + extends BaseBalanceTransitionDefinition { + type: 'ERC20'; + tokenAddress: string; + tokenDecimals: number; +} + +// TODO add ERC721 and ERC1155 +export type BalanceTransitionDefinition = + | NativeBalanceTransitionDefinition + | ERC20BalanceTransitionDefinition; + +export interface TimerTransitionDefinition + extends IntervalTransitionDefinition { + offset?: number; + step?: number; + until: number; +} + +export interface EvmContractEventTransitionDefinition extends OnEvmChainEvent { + contractAddress: string; + abi: ethers.ContractInterface; + eventName: string; + eventParams?: any; +} + +export interface TransitionDefinition { + balances?: BalanceTransitionDefinition[]; + evmContractEvent?: EvmContractEventTransitionDefinition; + fromState: string; + timer?: TimerTransitionDefinition; + toState: string; +} + +export interface BaseStateMachineParams { + debug?: boolean; + litNodeClient: LitNodeClient; + litContracts: LitContracts; +} + +export interface StateMachineDefinition + extends Omit { + litNodeClient: LitNodeClient | ConstructorParameters[0]; + litContracts: LitContracts | ConstructorParameters[0]; + states: StateDefinition[]; + transitions: TransitionDefinition[]; +} + +export interface TransitionParams + extends Omit, + Partial> { + fromState: string; + toState: string; +} diff --git a/packages/automation/src/lib/utils/chain.ts b/packages/automation/src/lib/utils/chain.ts new file mode 100644 index 0000000000..b2992bff7c --- /dev/null +++ b/packages/automation/src/lib/utils/chain.ts @@ -0,0 +1,14 @@ +import { LIT_EVM_CHAINS } from '@lit-protocol/constants'; + +import { OnEvmChainEvent } from '../types'; + +export function getChain(event: OnEvmChainEvent) { + const chain = Object.values(LIT_EVM_CHAINS).find( + (chain) => chain.chainId === event.evmChainId + ); + if (!chain) { + throw new Error(`EVM chain with chainId ${event.evmChainId} not found`); + } + + return chain; +} diff --git a/packages/automation/src/lib/utils/erc20.ts b/packages/automation/src/lib/utils/erc20.ts new file mode 100644 index 0000000000..da4092322f --- /dev/null +++ b/packages/automation/src/lib/utils/erc20.ts @@ -0,0 +1,77 @@ +import { ethers } from 'ethers'; + +import { BalanceTransitionDefinition } from '../types'; + +export const ERC20ABI = [ + { + constant: true, + inputs: [ + { + name: '_owner', + type: 'address', + }, + ], + name: 'balanceOf', + outputs: [ + { + name: 'balance', + type: 'uint256', + }, + ], + payable: false, + type: 'function', + }, +]; + +export async function getERC20Balance( + provider: ethers.providers.Provider, + tokenAddress: string, + tokenDecimals: number, + accountAddress: string +) { + const contract = new ethers.Contract(tokenAddress, ERC20ABI, provider); + const balance = (await contract['balanceOf']( + accountAddress + )) as ethers.BigNumber; + + const adjustedBalance = ethers.utils.parseUnits( + balance.toString(), + 18 - tokenDecimals + ); + + return adjustedBalance; +} + +export function getBalanceTransitionCheck( + transitionIndex: number, + balance: BalanceTransitionDefinition +): (values: any[]) => Promise { + const balanceCheck = async (values: any[]) => { + const { amount, comparator } = balance; + const targetAmount = ethers.utils.parseUnits(amount); + const addressBalance = values[transitionIndex] as + | ethers.BigNumber + | undefined; + + if (!addressBalance) return false; + + switch (comparator) { + case '<': + return addressBalance.lt(targetAmount); + case '<=': + return addressBalance.lte(targetAmount); + case '=': + return addressBalance.eq(targetAmount); + case '!=': + return !addressBalance.eq(targetAmount); + case '>=': + return addressBalance.gte(targetAmount); + case '>': + return addressBalance.gt(targetAmount); + default: + throw new Error(`Unrecognized comparator ${comparator}`); + } + }; + + return balanceCheck; +} From be62dbc9743037fba8d7da10fc9bc63cdf63b7ee Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Tue, 3 Dec 2024 21:26:10 +0100 Subject: [PATCH 05/43] feat: add lit action run state machine capability as a state --- packages/automation/src/lib/state-machine.ts | 86 +++++++++++++++++++- packages/automation/src/lib/types.ts | 9 ++ 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/packages/automation/src/lib/state-machine.ts b/packages/automation/src/lib/state-machine.ts index 2686039677..3dbe12a0b7 100644 --- a/packages/automation/src/lib/state-machine.ts +++ b/packages/automation/src/lib/state-machine.ts @@ -1,6 +1,19 @@ import { ethers } from 'ethers'; +import { + // createSiweMessageWithRecaps, + // generateAuthSig, + LitActionResource, + // LitPKPResource, +} from '@lit-protocol/auth-helpers'; +import { + LIT_ABILITY, + LIT_EVM_CHAINS, + LIT_RPC, + LIT_NETWORK, +} from '@lit-protocol/constants'; import { LitContracts } from '@lit-protocol/contracts-sdk'; +import { EthWalletProvider } from '@lit-protocol/lit-auth-client'; import { LitNodeClient } from '@lit-protocol/lit-node-client'; import { @@ -34,6 +47,15 @@ import type { export type MachineStatus = 'running' | 'stopped'; +const ethPrivateKey = process.env['ETHEREUM_PRIVATE_KEY']; +if (!ethPrivateKey) { + throw new Error('ethPrivateKey not defined'); +} +const yellowstoneSigner = new ethers.Wallet( + ethPrivateKey, + new ethers.providers.JsonRpcProvider(LIT_RPC.CHRONICLE_YELLOWSTONE) +); + /** * A StateMachine class that manages states and transitions between them. */ @@ -66,7 +88,12 @@ export class StateMachine { ? litNodeClient : new LitNodeClient(litNodeClient); const litContractsInstance = - 'connect' in litContracts ? litContracts : new LitContracts(litContracts); + 'connect' in litContracts + ? litContracts + : new LitContracts({ + signer: yellowstoneSigner, + ...litContracts, + }); if ( litNodeClientInstance.config.litNetwork !== litContractsInstance.network @@ -83,7 +110,62 @@ export class StateMachine { }); machineConfig.states.forEach((state) => { - stateMachine.addState(state); + const { litAction } = state; + + const stateConfig: StateParams = { + key: state.key, + }; + + if (litAction) { + let pkpPublicKey: string = litAction.pkpPublicKey; + + stateConfig.onEnter = async () => { + const yellowstoneSigner = new ethers.Wallet( + litAction.pkpOwnerKey, + new ethers.providers.JsonRpcProvider(LIT_RPC.CHRONICLE_YELLOWSTONE) + ); + + if (!pkpPublicKey) { + console.log(`No PKP for LitAction, minting one...`); + const mintingReceipt = + await litContractsInstance.pkpNftContractUtils.write.mint(); + const pkp = mintingReceipt.pkp; + pkpPublicKey = pkp.publicKey; + console.log(`Minted PKP: ${pkp}`); + } + + const pkpSessionSigs = await litNodeClientInstance.getPkpSessionSigs({ + pkpPublicKey, + capabilityAuthSigs: [], + authMethods: [ + await EthWalletProvider.authenticate({ + signer: yellowstoneSigner, + litNodeClient: litNodeClientInstance, + expiration: new Date(Date.now() + 1000 * 60 * 10).toISOString(), // 10 minutes + }), + ], + resourceAbilityRequests: [ + { + resource: new LitActionResource('*'), + ability: LIT_ABILITY.LitActionExecution, + }, + ], + }); + + // Run a LitAction + const executeJsResponse = await litNodeClientInstance.executeJs({ + sessionSigs: pkpSessionSigs, + ipfsId: litAction.ipfsId, + code: litAction.code, + jsParams: litAction.jsParams, + }); + + // TODO send user this result with a webhook maybe + console.log(`============ executeJsResponse:`, executeJsResponse); + }; + } + + stateMachine.addState(stateConfig); }); machineConfig.transitions.forEach((transition) => { diff --git a/packages/automation/src/lib/types.ts b/packages/automation/src/lib/types.ts index 64dcca2638..59725e7ebd 100644 --- a/packages/automation/src/lib/types.ts +++ b/packages/automation/src/lib/types.ts @@ -7,8 +7,17 @@ import { BaseTransitionParams } from './transitions'; export type Address = `0x${string}`; +export interface LitActionStateDefinition { + pkpOwnerKey: string; + pkpPublicKey: string; + ipfsId?: string; // TODO separate into another without code + code: string; + jsParams: Record; +} + export interface StateDefinition { key: string; + litAction?: LitActionStateDefinition; } export interface OnEvmChainEvent { From 47cc68aa72f84739c8a8f7968d7671ebfa764a9c Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Thu, 5 Dec 2024 19:10:17 +0100 Subject: [PATCH 06/43] feat: give state machine ability to sign and send transactions and to have several actions in one state --- packages/automation/src/lib/litActions.ts | 9 + packages/automation/src/lib/state-machine.ts | 195 ++++++++++++++----- packages/automation/src/lib/types.ts | 33 +++- packages/automation/src/lib/utils/chain.ts | 4 +- 4 files changed, 178 insertions(+), 63 deletions(-) create mode 100644 packages/automation/src/lib/litActions.ts diff --git a/packages/automation/src/lib/litActions.ts b/packages/automation/src/lib/litActions.ts new file mode 100644 index 0000000000..42aad1b4c8 --- /dev/null +++ b/packages/automation/src/lib/litActions.ts @@ -0,0 +1,9 @@ +export const signWithLitActionCode = `(async () => { + const signature = await Lit.Actions.signAndCombineEcdsa({ + toSign, + publicKey, + sigName, + }); + + Lit.Actions.setResponse({ response: signature }); + })();`; diff --git a/packages/automation/src/lib/state-machine.ts b/packages/automation/src/lib/state-machine.ts index 3dbe12a0b7..63bc58dc67 100644 --- a/packages/automation/src/lib/state-machine.ts +++ b/packages/automation/src/lib/state-machine.ts @@ -1,17 +1,7 @@ import { ethers } from 'ethers'; -import { - // createSiweMessageWithRecaps, - // generateAuthSig, - LitActionResource, - // LitPKPResource, -} from '@lit-protocol/auth-helpers'; -import { - LIT_ABILITY, - LIT_EVM_CHAINS, - LIT_RPC, - LIT_NETWORK, -} from '@lit-protocol/constants'; +import { LitActionResource } from '@lit-protocol/auth-helpers'; +import { LIT_ABILITY, LIT_RPC } from '@lit-protocol/constants'; import { LitContracts } from '@lit-protocol/contracts-sdk'; import { EthWalletProvider } from '@lit-protocol/lit-auth-client'; import { LitNodeClient } from '@lit-protocol/lit-node-client'; @@ -23,25 +13,15 @@ import { Listener, TimerListener, } from './listeners'; +import { signWithLitActionCode } from './litActions'; import { State, StateParams } from './states'; import { Check, Transition } from './transitions'; import { getChain } from './utils/chain'; import { getBalanceTransitionCheck, getERC20Balance } from './utils/erc20'; import type { - Address, - BalanceTransitionDefinition, - BaseBalanceTransitionDefinition, BaseStateMachineParams, - ERC20BalanceTransitionDefinition, - EvmContractEventTransitionDefinition, - IntervalTransitionDefinition, - NativeBalanceTransitionDefinition, - OnEvmChainEvent, - StateDefinition, StateMachineDefinition, - TimerTransitionDefinition, - TransitionDefinition, TransitionParams, } from './types'; @@ -51,11 +31,58 @@ const ethPrivateKey = process.env['ETHEREUM_PRIVATE_KEY']; if (!ethPrivateKey) { throw new Error('ethPrivateKey not defined'); } -const yellowstoneSigner = new ethers.Wallet( +const yellowstoneLitSigner = new ethers.Wallet( ethPrivateKey, new ethers.providers.JsonRpcProvider(LIT_RPC.CHRONICLE_YELLOWSTONE) ); +// TODO improve and move +interface ExecuteLitAction { + litNodeClient: LitNodeClient; + pkpPublicKey: string; + machineSigner: ethers.Wallet; + ipfsId?: string; + code: string; + jsParams: Record; +} + +async function executeLitAction({ + litNodeClient, + pkpPublicKey, + machineSigner, + ipfsId, + code, + jsParams, +}: ExecuteLitAction) { + const pkpSessionSigs = await litNodeClient.getPkpSessionSigs({ + pkpPublicKey, + capabilityAuthSigs: [], + authMethods: [ + await EthWalletProvider.authenticate({ + signer: machineSigner, + litNodeClient: litNodeClient, + expiration: new Date(Date.now() + 1000 * 60 * 10).toISOString(), // 10 minutes + }), + ], + resourceAbilityRequests: [ + { + resource: new LitActionResource('*'), + ability: LIT_ABILITY.LitActionExecution, + }, + ], + }); + + // Run a LitAction + const executeJsResponse = await litNodeClient.executeJs({ + ipfsId, + code, + jsParams, + sessionSigs: pkpSessionSigs, + }); + + return executeJsResponse; +} + /** * A StateMachine class that manages states and transitions between them. */ @@ -83,6 +110,7 @@ export class StateMachine { static fromDefinition(machineConfig: StateMachineDefinition): StateMachine { const { litNodeClient, litContracts = {} } = machineConfig; + // Create litNodeClient and litContracts instances const litNodeClientInstance = 'connect' in litNodeClient ? litNodeClient @@ -91,7 +119,7 @@ export class StateMachine { 'connect' in litContracts ? litContracts : new LitContracts({ - signer: yellowstoneSigner, + signer: yellowstoneLitSigner, ...litContracts, }); @@ -110,17 +138,19 @@ export class StateMachine { }); machineConfig.states.forEach((state) => { - const { litAction } = state; + const { litAction, transaction } = state; const stateConfig: StateParams = { key: state.key, }; + const onEnterFunctions = [] as (() => Promise)[]; + if (litAction) { let pkpPublicKey: string = litAction.pkpPublicKey; - stateConfig.onEnter = async () => { - const yellowstoneSigner = new ethers.Wallet( + onEnterFunctions.push(async () => { + const yellowstoneMachineSigner = new ethers.Wallet( litAction.pkpOwnerKey, new ethers.providers.JsonRpcProvider(LIT_RPC.CHRONICLE_YELLOWSTONE) ); @@ -134,37 +164,99 @@ export class StateMachine { console.log(`Minted PKP: ${pkp}`); } - const pkpSessionSigs = await litNodeClientInstance.getPkpSessionSigs({ + const litActionResponse = await executeLitAction({ + litNodeClient: litNodeClientInstance, pkpPublicKey, - capabilityAuthSigs: [], - authMethods: [ - await EthWalletProvider.authenticate({ - signer: yellowstoneSigner, - litNodeClient: litNodeClientInstance, - expiration: new Date(Date.now() + 1000 * 60 * 10).toISOString(), // 10 minutes - }), - ], - resourceAbilityRequests: [ - { - resource: new LitActionResource('*'), - ability: LIT_ABILITY.LitActionExecution, - }, - ], - }); - - // Run a LitAction - const executeJsResponse = await litNodeClientInstance.executeJs({ - sessionSigs: pkpSessionSigs, + machineSigner: yellowstoneMachineSigner, ipfsId: litAction.ipfsId, code: litAction.code, jsParams: litAction.jsParams, }); - // TODO send user this result with a webhook maybe - console.log(`============ executeJsResponse:`, executeJsResponse); - }; + // TODO send user this result with a webhook and log + console.log(`============ litActionResponse:`, litActionResponse); + }); + } + + if (transaction) { + onEnterFunctions.push(async () => { + const yellowstoneMachineSigner = new ethers.Wallet( + transaction.pkpOwnerKey, + new ethers.providers.JsonRpcProvider(LIT_RPC.CHRONICLE_YELLOWSTONE) + ); + + const chain = getChain(transaction); + const chainProvider = new ethers.providers.JsonRpcProvider( + chain.rpcUrls[0], + chain.chainId + ); + + const contract = new ethers.Contract( + transaction.contractAddress, + transaction.contractABI, + chainProvider + ); + + const txData = await contract.populateTransaction[transaction.method]( + ...(transaction.params || []) + ); + const gasLimit = await chainProvider.estimateGas({ + to: transaction.contractAddress, + data: txData.data, + from: transaction.pkpEthAddress, + }); + const gasPrice = await chainProvider.getGasPrice(); + const nonce = await chainProvider.getTransactionCount( + transaction.pkpEthAddress + ); + + const rawTx = { + chainId: chain.chainId, + data: txData.data, + gasLimit: gasLimit.toHexString(), + gasPrice: gasPrice.toHexString(), + nonce, + to: transaction.contractAddress, + }; + const rawTxHash = ethers.utils.keccak256( + ethers.utils.serializeTransaction(rawTx) + ); + + // Sign with the PKP in a LitAction + const litActionResponse = await executeLitAction({ + litNodeClient: litNodeClientInstance, + pkpPublicKey: transaction.pkpPublicKey, + machineSigner: yellowstoneMachineSigner, + code: signWithLitActionCode, + jsParams: { + toSign: ethers.utils.arrayify(rawTxHash), + publicKey: transaction.pkpPublicKey, + sigName: 'signedTransaction', + }, + }); + + const signature = litActionResponse.response as string; + const jsonSignature = JSON.parse(signature); + jsonSignature.r = '0x' + jsonSignature.r.substring(2); + jsonSignature.s = '0x' + jsonSignature.s; + const hexSignature = ethers.utils.joinSignature(jsonSignature); + + const signedTx = ethers.utils.serializeTransaction( + rawTx, + hexSignature + ); + + const receipt = await chainProvider.sendTransaction(signedTx); + + // TODO send user this result with a webhook and log + console.log('Transaction Receipt:', receipt); + }); } + stateConfig.onEnter = async () => { + await Promise.all(onEnterFunctions.map((onEnter) => onEnter())); + }; + stateMachine.addState(stateConfig); }); @@ -430,6 +522,7 @@ export class StateMachine { this.isRunning && (await this.enterState(stateKey)); } catch (e) { this.currentState = undefined; + console.error(e); throw new Error(`Could not enter state ${stateKey}`); } } diff --git a/packages/automation/src/lib/types.ts b/packages/automation/src/lib/types.ts index 59725e7ebd..f42bff0978 100644 --- a/packages/automation/src/lib/types.ts +++ b/packages/automation/src/lib/types.ts @@ -7,21 +7,34 @@ import { BaseTransitionParams } from './transitions'; export type Address = `0x${string}`; -export interface LitActionStateDefinition { +export interface OnEvmChain { + evmChainId: number; +} + +export interface UsesPkp { pkpOwnerKey: string; pkpPublicKey: string; + pkpEthAddress: Address; +} + +export interface LitActionStateDefinition extends UsesPkp { ipfsId?: string; // TODO separate into another without code code: string; jsParams: Record; } +export interface TransactionStateDefinition extends UsesPkp, OnEvmChain { + contractAddress: Address; + contractABI: ethers.ContractInterface; + method: string; + value?: string; + params?: any[]; +} + export interface StateDefinition { key: string; litAction?: LitActionStateDefinition; -} - -export interface OnEvmChainEvent { - evmChainId: number; + transaction?: TransactionStateDefinition; } export interface IntervalTransitionDefinition { @@ -30,7 +43,7 @@ export interface IntervalTransitionDefinition { export interface BaseBalanceTransitionDefinition extends IntervalTransitionDefinition, - OnEvmChainEvent { + OnEvmChain { address: Address; comparator: '>' | '>=' | '=' | '!=' | '<=' | '<'; amount: string; @@ -44,7 +57,7 @@ export interface NativeBalanceTransitionDefinition export interface ERC20BalanceTransitionDefinition extends BaseBalanceTransitionDefinition { type: 'ERC20'; - tokenAddress: string; + tokenAddress: Address; tokenDecimals: number; } @@ -60,9 +73,9 @@ export interface TimerTransitionDefinition until: number; } -export interface EvmContractEventTransitionDefinition extends OnEvmChainEvent { - contractAddress: string; - abi: ethers.ContractInterface; +export interface EvmContractEventTransitionDefinition extends OnEvmChain { + contractAddress: Address; + abi: ethers.ContractInterface; // TODO rename a contractABI eventName: string; eventParams?: any; } diff --git a/packages/automation/src/lib/utils/chain.ts b/packages/automation/src/lib/utils/chain.ts index b2992bff7c..844c0a047d 100644 --- a/packages/automation/src/lib/utils/chain.ts +++ b/packages/automation/src/lib/utils/chain.ts @@ -1,8 +1,8 @@ import { LIT_EVM_CHAINS } from '@lit-protocol/constants'; -import { OnEvmChainEvent } from '../types'; +import { OnEvmChain } from '../types'; -export function getChain(event: OnEvmChainEvent) { +export function getChain(event: OnEvmChain) { const chain = Object.values(LIT_EVM_CHAINS).find( (chain) => chain.chainId === event.evmChainId ); From 4b3942438790410ca02d40732334b2169ad16849 Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Fri, 6 Dec 2024 16:01:07 +0100 Subject: [PATCH 07/43] feat: add machine PKP and private key handling and refactor LitAction execution --- packages/automation/src/lib/litActions.ts | 62 +++++++++ packages/automation/src/lib/state-machine.ts | 139 ++++++++----------- packages/automation/src/lib/types.ts | 8 ++ 3 files changed, 129 insertions(+), 80 deletions(-) diff --git a/packages/automation/src/lib/litActions.ts b/packages/automation/src/lib/litActions.ts index 42aad1b4c8..63059cd4cf 100644 --- a/packages/automation/src/lib/litActions.ts +++ b/packages/automation/src/lib/litActions.ts @@ -1,3 +1,10 @@ +import { ethers } from 'ethers'; + +import { LitActionResource } from '@lit-protocol/auth-helpers'; +import { LIT_ABILITY } from '@lit-protocol/constants'; +import { EthWalletProvider } from '@lit-protocol/lit-auth-client'; +import { LitNodeClient } from '@lit-protocol/lit-node-client'; + export const signWithLitActionCode = `(async () => { const signature = await Lit.Actions.signAndCombineEcdsa({ toSign, @@ -7,3 +14,58 @@ export const signWithLitActionCode = `(async () => { Lit.Actions.setResponse({ response: signature }); })();`; + +interface ExecuteLitActionBase { + litNodeClient: LitNodeClient; + pkpPublicKey: string; + authSigner: ethers.Wallet; + ipfsId?: string; + code: string; + jsParams: Record; +} + +interface ExecuteCodeLitAction extends ExecuteLitActionBase { + code: string; +} + +interface ExecuteIPFSLitAction extends ExecuteLitActionBase { + ipfsId: string; +} + +type ExecuteLitAction = ExecuteCodeLitAction | ExecuteIPFSLitAction; + +export async function executeLitAction({ + litNodeClient, + pkpPublicKey, + authSigner, + ipfsId, + code, + jsParams, +}: ExecuteLitAction) { + const pkpSessionSigs = await litNodeClient.getPkpSessionSigs({ + pkpPublicKey, + capabilityAuthSigs: [], + authMethods: [ + await EthWalletProvider.authenticate({ + signer: authSigner, + litNodeClient: litNodeClient, + expiration: new Date(Date.now() + 1000 * 60 * 10).toISOString(), // 10 minutes + }), + ], + resourceAbilityRequests: [ + { + resource: new LitActionResource('*'), + ability: LIT_ABILITY.LitActionExecution, + }, + ], + }); + + const executeJsResponse = await litNodeClient.executeJs({ + ipfsId, + code, + jsParams, + sessionSigs: pkpSessionSigs, + }); + + return executeJsResponse; +} diff --git a/packages/automation/src/lib/state-machine.ts b/packages/automation/src/lib/state-machine.ts index 63bc58dc67..1fe5a32839 100644 --- a/packages/automation/src/lib/state-machine.ts +++ b/packages/automation/src/lib/state-machine.ts @@ -1,9 +1,7 @@ import { ethers } from 'ethers'; -import { LitActionResource } from '@lit-protocol/auth-helpers'; -import { LIT_ABILITY, LIT_RPC } from '@lit-protocol/constants'; +import { LIT_RPC } from '@lit-protocol/constants'; import { LitContracts } from '@lit-protocol/contracts-sdk'; -import { EthWalletProvider } from '@lit-protocol/lit-auth-client'; import { LitNodeClient } from '@lit-protocol/lit-node-client'; import { @@ -13,76 +11,21 @@ import { Listener, TimerListener, } from './listeners'; -import { signWithLitActionCode } from './litActions'; +import { signWithLitActionCode, executeLitAction } from './litActions'; import { State, StateParams } from './states'; import { Check, Transition } from './transitions'; import { getChain } from './utils/chain'; import { getBalanceTransitionCheck, getERC20Balance } from './utils/erc20'; -import type { +import { BaseStateMachineParams, + PKPInfo, StateMachineDefinition, TransitionParams, } from './types'; export type MachineStatus = 'running' | 'stopped'; -const ethPrivateKey = process.env['ETHEREUM_PRIVATE_KEY']; -if (!ethPrivateKey) { - throw new Error('ethPrivateKey not defined'); -} -const yellowstoneLitSigner = new ethers.Wallet( - ethPrivateKey, - new ethers.providers.JsonRpcProvider(LIT_RPC.CHRONICLE_YELLOWSTONE) -); - -// TODO improve and move -interface ExecuteLitAction { - litNodeClient: LitNodeClient; - pkpPublicKey: string; - machineSigner: ethers.Wallet; - ipfsId?: string; - code: string; - jsParams: Record; -} - -async function executeLitAction({ - litNodeClient, - pkpPublicKey, - machineSigner, - ipfsId, - code, - jsParams, -}: ExecuteLitAction) { - const pkpSessionSigs = await litNodeClient.getPkpSessionSigs({ - pkpPublicKey, - capabilityAuthSigs: [], - authMethods: [ - await EthWalletProvider.authenticate({ - signer: machineSigner, - litNodeClient: litNodeClient, - expiration: new Date(Date.now() + 1000 * 60 * 10).toISOString(), // 10 minutes - }), - ], - resourceAbilityRequests: [ - { - resource: new LitActionResource('*'), - ability: LIT_ABILITY.LitActionExecution, - }, - ], - }); - - // Run a LitAction - const executeJsResponse = await litNodeClient.executeJs({ - ipfsId, - code, - jsParams, - sessionSigs: pkpSessionSigs, - }); - - return executeJsResponse; -} - /** * A StateMachine class that manages states and transitions between them. */ @@ -91,6 +34,8 @@ export class StateMachine { private litNodeClient: LitNodeClient; private litContracts: LitContracts; + private privateKey?: string; + private pkp?: PKPInfo; public id: string; public status: MachineStatus = 'stopped'; @@ -105,10 +50,20 @@ export class StateMachine { this.litNodeClient = params.litNodeClient; this.litContracts = params.litContracts; + this.privateKey = params.privateKey; + this.pkp = params.pkp; } static fromDefinition(machineConfig: StateMachineDefinition): StateMachine { - const { litNodeClient, litContracts = {} } = machineConfig; + const { + debug = false, + litNodeClient, + litContracts = {}, + privateKey, + pkp, + states = [], + transitions = [], + } = machineConfig; // Create litNodeClient and litContracts instances const litNodeClientInstance = @@ -119,7 +74,7 @@ export class StateMachine { 'connect' in litContracts ? litContracts : new LitContracts({ - signer: yellowstoneLitSigner, + privateKey, ...litContracts, }); @@ -132,9 +87,11 @@ export class StateMachine { } const stateMachine = new StateMachine({ - debug: machineConfig.debug, + debug, litNodeClient: litNodeClientInstance, litContracts: litContractsInstance, + privateKey, + pkp, }); machineConfig.states.forEach((state) => { @@ -143,31 +100,28 @@ export class StateMachine { const stateConfig: StateParams = { key: state.key, }; + stateTransitions.push( + ...transitions.map((transition) => ({ + ...transition, + fromState: state.key, + })) + ); const onEnterFunctions = [] as (() => Promise)[]; if (litAction) { - let pkpPublicKey: string = litAction.pkpPublicKey; - onEnterFunctions.push(async () => { - const yellowstoneMachineSigner = new ethers.Wallet( - litAction.pkpOwnerKey, + await stateMachine.validateMachinePKP(); + + const signer = new ethers.Wallet( + stateMachine.privateKey!, new ethers.providers.JsonRpcProvider(LIT_RPC.CHRONICLE_YELLOWSTONE) ); - if (!pkpPublicKey) { - console.log(`No PKP for LitAction, minting one...`); - const mintingReceipt = - await litContractsInstance.pkpNftContractUtils.write.mint(); - const pkp = mintingReceipt.pkp; - pkpPublicKey = pkp.publicKey; - console.log(`Minted PKP: ${pkp}`); - } - const litActionResponse = await executeLitAction({ litNodeClient: litNodeClientInstance, - pkpPublicKey, - machineSigner: yellowstoneMachineSigner, + pkpPublicKey: stateMachine.pkp!.publicKey, + authSigner: signer, ipfsId: litAction.ipfsId, code: litAction.code, jsParams: litAction.jsParams, @@ -226,7 +180,7 @@ export class StateMachine { const litActionResponse = await executeLitAction({ litNodeClient: litNodeClientInstance, pkpPublicKey: transaction.pkpPublicKey, - machineSigner: yellowstoneMachineSigner, + authSigner: yellowstoneMachineSigner, code: signWithLitActionCode, jsParams: { toSign: ethers.utils.arrayify(rawTxHash), @@ -462,6 +416,31 @@ export class StateMachine { this.debug && console.log('State machine stopped'); } + /** + * Validates whether a PKP (Private Key Pair) is configured in the state machine. + * If a PKP is not present, it initiates the minting of a new PKP through the + * associated `litContracts`. Once minted, the state machine configures itself + * to use the newly minted PKP. + * + * @remarks + * This validation ensures that the state machine has a PKP to operate suitably + * within its workflow, avoiding any disruptions due to lack of a necessary PKP. + */ + private async validateMachinePKP() { + if (this.pkp) { + console.log(`PKP in state machine is configured. No need to mint one`); + } else { + console.log(`No PKP in state machine, minting one...`); + const mintingReceipt = + await this.litContracts.pkpNftContractUtils.write.mint(); + const pkp = mintingReceipt.pkp; + console.log(`Minted PKP: ${pkp}. Machine will now use it`); + this.pkp = pkp; + } + + console.log(`Machine is using PKP: ${this.pkp.tokenId}`); + } + /** * Stops listening on the current state's transitions and exits the current state. */ diff --git a/packages/automation/src/lib/types.ts b/packages/automation/src/lib/types.ts index f42bff0978..8913c3d46e 100644 --- a/packages/automation/src/lib/types.ts +++ b/packages/automation/src/lib/types.ts @@ -7,6 +7,12 @@ import { BaseTransitionParams } from './transitions'; export type Address = `0x${string}`; +export type PKPInfo = { + tokenId: string; + publicKey: string; + ethAddress: string; +}; + export interface OnEvmChain { evmChainId: number; } @@ -92,6 +98,8 @@ export interface BaseStateMachineParams { debug?: boolean; litNodeClient: LitNodeClient; litContracts: LitContracts; + privateKey?: string; + pkp?: PKPInfo; } export interface StateMachineDefinition From 6a1b481bb4d4d3fb8f219a4b714c682f61f95833 Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Fri, 6 Dec 2024 16:01:47 +0100 Subject: [PATCH 08/43] feat: include transitions definitions inside states definitions --- packages/automation/src/lib/state-machine.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/automation/src/lib/state-machine.ts b/packages/automation/src/lib/state-machine.ts index 1fe5a32839..3723f56162 100644 --- a/packages/automation/src/lib/state-machine.ts +++ b/packages/automation/src/lib/state-machine.ts @@ -21,6 +21,7 @@ import { BaseStateMachineParams, PKPInfo, StateMachineDefinition, + TransitionDefinition, TransitionParams, } from './types'; @@ -94,8 +95,9 @@ export class StateMachine { pkp, }); - machineConfig.states.forEach((state) => { - const { litAction, transaction } = state; + const stateTransitions = [] as TransitionDefinition[]; + states.forEach((state) => { + const { litAction, transaction, transitions = [] } = state; const stateConfig: StateParams = { key: state.key, @@ -214,7 +216,7 @@ export class StateMachine { stateMachine.addState(stateConfig); }); - machineConfig.transitions.forEach((transition) => { + [...stateTransitions, ...transitions].forEach((transition) => { const { balances, evmContractEvent, fromState, timer, toState } = transition; From 3e37a568310c72c7db54bfc4a0ffdf897b5eeb07 Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Fri, 6 Dec 2024 16:02:54 +0100 Subject: [PATCH 09/43] fix: missing change, transitions property in StateDefinition --- packages/automation/src/lib/types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/automation/src/lib/types.ts b/packages/automation/src/lib/types.ts index 8913c3d46e..a10bc2976c 100644 --- a/packages/automation/src/lib/types.ts +++ b/packages/automation/src/lib/types.ts @@ -41,6 +41,7 @@ export interface StateDefinition { key: string; litAction?: LitActionStateDefinition; transaction?: TransactionStateDefinition; + transitions?: Omit[]; } export interface IntervalTransitionDefinition { From 751b1dd0968caa296519408416af50865577f065 Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Fri, 6 Dec 2024 17:47:47 +0100 Subject: [PATCH 10/43] feat: refactor types --- packages/automation/src/lib/state-machine.ts | 2 +- packages/automation/src/lib/types.ts | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/automation/src/lib/state-machine.ts b/packages/automation/src/lib/state-machine.ts index 3723f56162..857ff131cc 100644 --- a/packages/automation/src/lib/state-machine.ts +++ b/packages/automation/src/lib/state-machine.ts @@ -245,7 +245,7 @@ export class StateMachine { chain.rpcUrls[0], { address: evmContractEvent.contractAddress, - abi: evmContractEvent.abi, + abi: evmContractEvent.contractABI, }, { name: evmContractEvent.eventName, diff --git a/packages/automation/src/lib/types.ts b/packages/automation/src/lib/types.ts index a10bc2976c..8235feebda 100644 --- a/packages/automation/src/lib/types.ts +++ b/packages/automation/src/lib/types.ts @@ -30,11 +30,11 @@ export interface LitActionStateDefinition extends UsesPkp { } export interface TransactionStateDefinition extends UsesPkp, OnEvmChain { - contractAddress: Address; contractABI: ethers.ContractInterface; + contractAddress: Address; method: string; - value?: string; params?: any[]; + value?: string; } export interface StateDefinition { @@ -52,8 +52,8 @@ export interface BaseBalanceTransitionDefinition extends IntervalTransitionDefinition, OnEvmChain { address: Address; - comparator: '>' | '>=' | '=' | '!=' | '<=' | '<'; amount: string; + comparator: '>' | '>=' | '=' | '!=' | '<=' | '<'; } export interface NativeBalanceTransitionDefinition @@ -63,9 +63,9 @@ export interface NativeBalanceTransitionDefinition export interface ERC20BalanceTransitionDefinition extends BaseBalanceTransitionDefinition { - type: 'ERC20'; tokenAddress: Address; tokenDecimals: number; + type: 'ERC20'; } // TODO add ERC721 and ERC1155 @@ -81,8 +81,8 @@ export interface TimerTransitionDefinition } export interface EvmContractEventTransitionDefinition extends OnEvmChain { + contractABI: ethers.ContractInterface; contractAddress: Address; - abi: ethers.ContractInterface; // TODO rename a contractABI eventName: string; eventParams?: any; } From 06a6da5d2476b13195f70deaec89376ec3b608ff Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Wed, 11 Dec 2024 18:54:25 +0100 Subject: [PATCH 11/43] feat: add state machine context to dynamically share information between state machine components --- packages/automation/package.json | 2 +- packages/automation/src/lib/context/index.ts | 1 + .../src/lib/context/machine-context.spec.ts | 178 ++++++++++++++++++ .../src/lib/context/machine-context.ts | 89 +++++++++ .../automation/src/lib/state-machine.spec.ts | 55 +++++- packages/automation/src/lib/state-machine.ts | 63 ++++++- .../src/lib/transitions/transition.ts | 4 +- packages/automation/src/lib/types.ts | 28 ++- 8 files changed, 401 insertions(+), 19 deletions(-) create mode 100644 packages/automation/src/lib/context/index.ts create mode 100644 packages/automation/src/lib/context/machine-context.spec.ts create mode 100644 packages/automation/src/lib/context/machine-context.ts diff --git a/packages/automation/package.json b/packages/automation/package.json index d2191c6cdc..3ef2ab5724 100644 --- a/packages/automation/package.json +++ b/packages/automation/package.json @@ -26,7 +26,7 @@ "scripts": { "generate-lit-actions": "yarn node ./esbuild.config.js" }, - "version": "7.0.0", + "version": "7.0.2", "main": "./dist/src/index.js", "typings": "./dist/src/index.d.ts" } diff --git a/packages/automation/src/lib/context/index.ts b/packages/automation/src/lib/context/index.ts new file mode 100644 index 0000000000..0921c041c5 --- /dev/null +++ b/packages/automation/src/lib/context/index.ts @@ -0,0 +1 @@ +export * from './machine-context'; \ No newline at end of file diff --git a/packages/automation/src/lib/context/machine-context.spec.ts b/packages/automation/src/lib/context/machine-context.spec.ts new file mode 100644 index 0000000000..afd674d7a9 --- /dev/null +++ b/packages/automation/src/lib/context/machine-context.spec.ts @@ -0,0 +1,178 @@ +import { MachineContext } from './machine-context'; + +const deepCopy = (obj: T): T => JSON.parse(JSON.stringify(obj)); + +describe('MachineContext', () => { + let context: MachineContext; + const initialContext = { + contracts: { + token: '0x123...', + }, + values: { + amount: 100 + }, + existingArray: [1, 2, 3] + }; + + beforeEach(() => { + context = new MachineContext(deepCopy(initialContext)); + }); + + it('should initialize with provided context', () => { + expect(context.get()).toEqual(initialContext); + }); + + it('should initialize empty when no context provided', () => { + const emptyContext = new MachineContext(); + expect(emptyContext.get()).toEqual({}); + }); + + it('should get context values using dot notation', () => { + expect(context.get('contracts.token')).toBe('0x123...'); + expect(context.get('values.amount')).toBe(100); + }); + + it('should get context values using array notation', () => { + expect(context.get(['contracts', 'token'])).toBe('0x123...'); + expect(context.get(['values', 'amount'])).toBe(100); + }); + + it('should set context values using dot notation', () => { + context.set('new.value', 42); + expect(context.get('new.value')).toBe(42); + }); + + it('should set context values using array notation', () => { + context.set(['deeply', 'nested', 'value'], 'test'); + expect(context.get('deeply.nested.value')).toBe('test'); + }); + + it('should handle missing context paths gracefully', () => { + expect(context.get('non.existent.path')).toBeUndefined(); + }); + + it('should create intermediate objects when setting deep paths', () => { + context.set('a.b.c', 'value'); + expect(context.get()).toEqual(expect.objectContaining({ + a: { + b: { + c: 'value' + } + } + })); + }); + + it('should override existing values', () => { + context.set('contracts.token', '0xnew...'); + expect(context.get('contracts.token')).toBe('0xnew...'); + }); + + it('should create new array when path does not exist', () => { + context.push('newArray', 1); + expect(context.get('newArray')).toEqual([1]); + }); + + it('should push to existing array', () => { + context.push('existingArray', 4); + expect(context.get('existingArray')).toEqual([1, 2, 3, 4]); + }); + + it('should convert non-array value to array when pushing', () => { + context.push('contracts.token', '0xnew...'); + expect(context.get('contracts.token')).toEqual(['0x123...', '0xnew...']); + }); + + it('should work with array notation', () => { + context.push(['deeply', 'nested', 'array'], 'value'); + expect(context.get('deeply.nested.array')).toEqual(['value']); + }); + + it('should maintain array reference when pushing', () => { + const before = context.get('existingArray'); + context.push('existingArray', 4); + const after = context.get('existingArray'); + expect(before).toBe(after); // Same array reference + }); + + it('should handle pushing multiple values', () => { + context.push('newArray', 1); + context.push('newArray', 2); + context.push('newArray', 3); + expect(context.get('newArray')).toEqual([1, 2, 3]); + }); + + it('should handle pushing to nested paths', () => { + context.push('nested.path.to.array', 'first'); + context.push('nested.path.to.array', 'second'); + expect(context.get('nested.path.to.array')).toEqual(['first', 'second']); + }); + + it('should convert non-array values in nested paths', () => { + context.set('deep.nested.value', 'original'); + context.push('deep.nested.value', 'new'); + expect(context.get('deep.nested.value')).toEqual(['original', 'new']); + }); + + describe('array indexing', () => { + beforeEach(() => { + context = new MachineContext(deepCopy({ + simple: ['a', 'b', 'c'], + complex: [ + { id: 1, value: { foo: 'bar' } }, + { id: 2, value: { foo: 'baz' } } + ], + nested: { + arrays: [ + [1, 2], + [3, 4] + ] + } + })); + }); + + it('should access array elements using index notation', () => { + expect(context.get('simple[1]')).toBe('b'); + expect(context.get('complex[0].id')).toBe(1); + expect(context.get('complex[0].value.foo')).toBe('bar'); + }); + + it('should access nested array elements', () => { + expect(context.get('nested.arrays[1][0]')).toBe(3); + }); + + it('should set array elements using index notation', () => { + context.set('simple[1]', 'x'); + expect(context.get('simple')).toEqual(['a', 'x', 'c']); + }); + + it('should set nested array elements', () => { + context.set('complex[1].value.foo', 'qux'); + expect(context.get('complex[1].value.foo')).toBe('qux'); + }); + + it('should create arrays when setting with index notation', () => { + context.set('new[2].foo', 'bar'); + expect(context.get('new')).toEqual([undefined, undefined, { foo: 'bar' }]); + }); + + it('should handle array notation with dot notation mixed', () => { + context.set('mixed.array[0].nested.value[1]', 42); + expect(context.get('mixed.array[0].nested.value[1]')).toBe(42); + }); + + it('should work with array paths', () => { + expect(context.get(['complex', '0', 'value', 'foo'])).toBe('bar'); + }); + + it('should push to arrays accessed via index notation', () => { + context.push('nested.arrays[0]', 3); + expect(context.get('nested.arrays[0]')).toEqual([1, 2, 3]); + }); + + it('should handle out of bounds indices by filling with empty objects', () => { + context.set('sparse[5].value', 'test'); + expect((context.get('sparse') as any[]).length).toBe(6); + expect(context.get('sparse[5].value')).toBe('test'); + }); + }); +}); diff --git a/packages/automation/src/lib/context/machine-context.ts b/packages/automation/src/lib/context/machine-context.ts new file mode 100644 index 0000000000..350eb8f450 --- /dev/null +++ b/packages/automation/src/lib/context/machine-context.ts @@ -0,0 +1,89 @@ +function parsePath(path?: string | string[]): string[] { + if (!path) return []; + + if (Array.isArray(path)) return path; + + // Match either dot notation or array notation: foo.bar[0].baz or ['foo', 'bar', '0', 'baz'] + return path.split(/\.|\[|\]/).filter(Boolean); +} + +function getFromObject(object: Record, path?: string | string[]) { + if (!path) return object; + + const parts = parsePath(path); + return parts.reduce((obj, key) => { + const index = parseInt(key); + if (!isNaN(index) && Array.isArray(obj)) { + return obj[index]; + } + return obj?.[key]; + }, object); +} + +export class MachineContext { + private readonly context: Record = {}; + + constructor(initialContext?: Record) { + this.context = initialContext ?? {}; + } + + public get(path?: string | string[]): unknown { + return getFromObject(this.context, path); + } + + public set(path: string | string[], value: unknown = undefined): void { + const parts = parsePath(path); + + let current = this.context; + + for (let i = 0; i < parts.length; i++) { + const key = parts[i]; + const isLast = i === parts.length - 1; + + const index = parseInt(key); + + if (!isNaN(index)) { + if (Array.isArray(current)) { + if (isLast) { + current[index] = value; + } else { + current[index] = current[index] ?? (isNaN(parseInt(parts[i + 1])) ? {} : []); + current = current[index]; + } + } else { + if (isLast) { + current[key] = value; + } else { + current[key] = current[key] ?? (isNaN(parseInt(parts[i + 1])) ? {} : []); + current = current[key]; + } + } + } else { + if (isLast) { + current[key] = value; + } else { + current = current[key] = current[key] ?? (isNaN(parseInt(parts[i + 1])) ? {} : []); + } + } + } + } + + public push(path: string | string[], value: unknown): void { + const currentValue = this.get(path); + + if (currentValue === undefined) { + this.set(path, [value]); + } else if (Array.isArray(currentValue)) { + currentValue.push(value); + } else { + this.set(path, [currentValue, value]); + } + } + + public setFromData(location: string | string[], data?: Record, path?: string | string[]) { + if (!data) return; + + const value = getFromObject(data, path); + this.set(location, value); + } +} diff --git a/packages/automation/src/lib/state-machine.spec.ts b/packages/automation/src/lib/state-machine.spec.ts index 7d3649fc45..c402420ed8 100644 --- a/packages/automation/src/lib/state-machine.spec.ts +++ b/packages/automation/src/lib/state-machine.spec.ts @@ -1,5 +1,19 @@ -import { StateMachine } from './state-machine'; +import { LIT_NETWORK } from '@lit-protocol/constants'; +import { LitContracts } from '@lit-protocol/contracts-sdk'; +import { LitNodeClient } from '@lit-protocol/lit-node-client'; + import { Listener } from './listeners'; +import { StateMachine } from './state-machine'; +import { BaseStateMachineParams } from './types'; + +const litContracts = new LitContracts(); +const litNodeClient = new LitNodeClient({ + litNetwork: LIT_NETWORK.DatilDev, +}); +const stateMachineParams: BaseStateMachineParams = { + litContracts, + litNodeClient, +}; describe('StateMachine', () => { let stateMachine: StateMachine; @@ -10,11 +24,8 @@ describe('StateMachine', () => { beforeEach(() => { callOrder = []; - stateMachine = new StateMachine(); - listener = new Listener({ - start: async () => {}, - stop: async () => {}, - }); + stateMachine = new StateMachine(stateMachineParams); + listener = new Listener(); check = jest.fn(() => true); onMatch = jest.fn(); @@ -39,7 +50,7 @@ describe('StateMachine', () => { }); it('should generate a unique id for each state machine instance', () => { - const anotherStateMachine = new StateMachine(); + const anotherStateMachine = new StateMachine(stateMachineParams); expect(stateMachine.id).toBeDefined(); expect(anotherStateMachine.id).toBeDefined(); expect(stateMachine.id).not.toEqual(anotherStateMachine.id); @@ -156,7 +167,7 @@ describe('StateMachine', () => { }); it('should handle errors during cleanup', async () => { - const errorStateMachine = new StateMachine(); + const errorStateMachine = new StateMachine(stateMachineParams); const errorMessage = 'Exit error'; errorStateMachine.addState({ key: 'error', @@ -171,4 +182,32 @@ describe('StateMachine', () => { ); }); }); + + describe('Context', () => { + let machine: StateMachine; + const initialContext = { + contracts: { + token: '0x123...', + }, + values: { + amount: 100 + } + }; + + beforeEach(() => { + machine = new StateMachine({ + ...stateMachineParams, + context: initialContext + }); + }); + + it('should initialize with context', () => { + expect(machine.getContext()).toEqual(initialContext); + }); + + it('should allow getting and setting context values', () => { + machine.setContext('new.value', 42); + expect(machine.getContext('new.value')).toBe(42); + }); + }); }); diff --git a/packages/automation/src/lib/state-machine.ts b/packages/automation/src/lib/state-machine.ts index 857ff131cc..406a48a87c 100644 --- a/packages/automation/src/lib/state-machine.ts +++ b/packages/automation/src/lib/state-machine.ts @@ -24,6 +24,7 @@ import { TransitionDefinition, TransitionParams, } from './types'; +import { MachineContext } from './context/machine-context'; export type MachineStatus = 'running' | 'stopped'; @@ -32,6 +33,7 @@ export type MachineStatus = 'running' | 'stopped'; */ export class StateMachine { private debug = false; + private context: MachineContext; private litNodeClient: LitNodeClient; private litContracts: LitContracts; @@ -48,6 +50,7 @@ export class StateMachine { constructor(params: BaseStateMachineParams) { this.id = this.generateId(); this.debug = params.debug ?? false; + this.context = new MachineContext(params.context); this.litNodeClient = params.litNodeClient; this.litContracts = params.litContracts; @@ -55,6 +58,14 @@ export class StateMachine { this.pkp = params.pkp; } + public getContext(path?: string | string[]): any { + return this.context.get(path); + } + + public setContext(path: string | string[], value: any): void { + this.context.set(path, value); + } + static fromDefinition(machineConfig: StateMachineDefinition): StateMachine { const { debug = false, @@ -64,6 +75,7 @@ export class StateMachine { pkp, states = [], transitions = [], + context, } = machineConfig; // Create litNodeClient and litContracts instances @@ -93,11 +105,17 @@ export class StateMachine { litContracts: litContractsInstance, privateKey, pkp, + context, }); const stateTransitions = [] as TransitionDefinition[]; states.forEach((state) => { - const { litAction, transaction, transitions = [] } = state; + const { + litAction, + context: contextAction, + transaction, + transitions = [], + } = state; const stateConfig: StateParams = { key: state.key, @@ -110,6 +128,26 @@ export class StateMachine { ); const onEnterFunctions = [] as (() => Promise)[]; + const onExitFunctions = [] as (() => Promise)[]; + + if (contextAction) { + if (contextAction.log?.atEnter) { + onEnterFunctions.push(async () => { + console.log( + `MachineContext at state ${state.key} enter: `, + stateMachine.context.get(contextAction.log?.path), + ); + }); + } + if (contextAction.log?.atExit) { + onExitFunctions.push(async () => { + console.log( + `MachineContext at state ${state.key} exit: `, + stateMachine.context.get(contextAction.log?.path), + ); + }); + } + } if (litAction) { onEnterFunctions.push(async () => { @@ -130,7 +168,7 @@ export class StateMachine { }); // TODO send user this result with a webhook and log - console.log(`============ litActionResponse:`, litActionResponse); + stateMachine.context.set('lastLitActionResponse', litActionResponse); }); } @@ -153,9 +191,12 @@ export class StateMachine { chainProvider ); - const txData = await contract.populateTransaction[transaction.method]( - ...(transaction.params || []) + const txParams = (transaction.params || []).map(param => + 'contextPath' in param + ? stateMachine.context.get(param.contextPath) + : param ); + const txData = await contract.populateTransaction[transaction.method](...txParams); const gasLimit = await chainProvider.estimateGas({ to: transaction.contractAddress, data: txData.data, @@ -205,13 +246,16 @@ export class StateMachine { const receipt = await chainProvider.sendTransaction(signedTx); // TODO send user this result with a webhook and log - console.log('Transaction Receipt:', receipt); + stateMachine.context.set('lastTransactionReceipt', receipt); }); } stateConfig.onEnter = async () => { await Promise.all(onEnterFunctions.map((onEnter) => onEnter())); }; + stateConfig.onExit = async () => { + await Promise.all(onExitFunctions.map((onExit) => onExit())); + }; stateMachine.addState(stateConfig); }); @@ -257,6 +301,15 @@ export class StateMachine { const eventData = values[transitionIndex] as | ContractEventData | undefined; + + evmContractEvent.contextUpdates?.forEach(contextUpdate => + stateMachine.context.setFromData( + contextUpdate.contextPath, + eventData, + contextUpdate.dataPath, + ), + ); + return eventData?.event.event === evmContractEvent.eventName; }); } diff --git a/packages/automation/src/lib/transitions/transition.ts b/packages/automation/src/lib/transitions/transition.ts index 506d4a56b1..f000c2da39 100644 --- a/packages/automation/src/lib/transitions/transition.ts +++ b/packages/automation/src/lib/transitions/transition.ts @@ -14,8 +14,8 @@ export interface BaseTransitionParams { } export class Transition { - private debug = false; - private listeners: Listener[]; + private readonly debug: boolean; + private readonly listeners: Listener[]; private readonly values: (any | undefined)[]; private readonly check?: Check; private readonly onMatch: (values: (any | undefined)[]) => Promise; diff --git a/packages/automation/src/lib/types.ts b/packages/automation/src/lib/types.ts index 8235feebda..2918b03c31 100644 --- a/packages/automation/src/lib/types.ts +++ b/packages/automation/src/lib/types.ts @@ -17,6 +17,18 @@ export interface OnEvmChain { evmChainId: number; } +interface ContextRead { + contextPath: string; +} + +interface ContextUpdate extends ContextRead { + dataPath: string; +} + +export interface UpdatesContext { + contextUpdates: ContextUpdate[]; +} + export interface UsesPkp { pkpOwnerKey: string; pkpPublicKey: string; @@ -29,15 +41,24 @@ export interface LitActionStateDefinition extends UsesPkp { jsParams: Record; } +export interface ContextStateDefinition { + log?: { + atEnter?: boolean; + atExit?: boolean; + path?: string; + }; +} + export interface TransactionStateDefinition extends UsesPkp, OnEvmChain { contractABI: ethers.ContractInterface; contractAddress: Address; method: string; - params?: any[]; + params?: (ContextRead | any)[]; value?: string; } export interface StateDefinition { + context?: ContextStateDefinition; key: string; litAction?: LitActionStateDefinition; transaction?: TransactionStateDefinition; @@ -80,7 +101,7 @@ export interface TimerTransitionDefinition until: number; } -export interface EvmContractEventTransitionDefinition extends OnEvmChain { +export interface EvmContractEventTransitionDefinition extends OnEvmChain, UpdatesContext { contractABI: ethers.ContractInterface; contractAddress: Address; eventName: string; @@ -96,9 +117,10 @@ export interface TransitionDefinition { } export interface BaseStateMachineParams { + context?: Record; debug?: boolean; - litNodeClient: LitNodeClient; litContracts: LitContracts; + litNodeClient: LitNodeClient; privateKey?: string; pkp?: PKPInfo; } From 3889f78080f31bc283ab9164bf52eed47813283d Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Thu, 12 Dec 2024 17:10:53 +0100 Subject: [PATCH 12/43] feat: generalize context usage to states variables --- packages/automation/src/lib/context/index.ts | 2 +- .../src/lib/context/machine-context.spec.ts | 54 +++++++++-------- .../src/lib/context/machine-context.ts | 15 +++-- packages/automation/src/lib/litActions.ts | 18 ++---- .../automation/src/lib/state-machine.spec.ts | 6 +- packages/automation/src/lib/state-machine.ts | 58 +++++++++++-------- packages/automation/src/lib/types.ts | 34 ++++++----- packages/automation/src/lib/utils/chain.ts | 8 +-- 8 files changed, 108 insertions(+), 87 deletions(-) diff --git a/packages/automation/src/lib/context/index.ts b/packages/automation/src/lib/context/index.ts index 0921c041c5..a6c919840b 100644 --- a/packages/automation/src/lib/context/index.ts +++ b/packages/automation/src/lib/context/index.ts @@ -1 +1 @@ -export * from './machine-context'; \ No newline at end of file +export * from './machine-context'; diff --git a/packages/automation/src/lib/context/machine-context.spec.ts b/packages/automation/src/lib/context/machine-context.spec.ts index afd674d7a9..6742b810da 100644 --- a/packages/automation/src/lib/context/machine-context.spec.ts +++ b/packages/automation/src/lib/context/machine-context.spec.ts @@ -9,9 +9,9 @@ describe('MachineContext', () => { token: '0x123...', }, values: { - amount: 100 + amount: 100, }, - existingArray: [1, 2, 3] + existingArray: [1, 2, 3], }; beforeEach(() => { @@ -53,13 +53,15 @@ describe('MachineContext', () => { it('should create intermediate objects when setting deep paths', () => { context.set('a.b.c', 'value'); - expect(context.get()).toEqual(expect.objectContaining({ - a: { - b: { - c: 'value' - } - } - })); + expect(context.get()).toEqual( + expect.objectContaining({ + a: { + b: { + c: 'value', + }, + }, + }) + ); }); it('should override existing values', () => { @@ -115,19 +117,21 @@ describe('MachineContext', () => { describe('array indexing', () => { beforeEach(() => { - context = new MachineContext(deepCopy({ - simple: ['a', 'b', 'c'], - complex: [ - { id: 1, value: { foo: 'bar' } }, - { id: 2, value: { foo: 'baz' } } - ], - nested: { - arrays: [ - [1, 2], - [3, 4] - ] - } - })); + context = new MachineContext( + deepCopy({ + simple: ['a', 'b', 'c'], + complex: [ + { id: 1, value: { foo: 'bar' } }, + { id: 2, value: { foo: 'baz' } }, + ], + nested: { + arrays: [ + [1, 2], + [3, 4], + ], + }, + }) + ); }); it('should access array elements using index notation', () => { @@ -152,7 +156,11 @@ describe('MachineContext', () => { it('should create arrays when setting with index notation', () => { context.set('new[2].foo', 'bar'); - expect(context.get('new')).toEqual([undefined, undefined, { foo: 'bar' }]); + expect(context.get('new')).toEqual([ + undefined, + undefined, + { foo: 'bar' }, + ]); }); it('should handle array notation with dot notation mixed', () => { diff --git a/packages/automation/src/lib/context/machine-context.ts b/packages/automation/src/lib/context/machine-context.ts index 350eb8f450..f2124b9b9c 100644 --- a/packages/automation/src/lib/context/machine-context.ts +++ b/packages/automation/src/lib/context/machine-context.ts @@ -47,14 +47,16 @@ export class MachineContext { if (isLast) { current[index] = value; } else { - current[index] = current[index] ?? (isNaN(parseInt(parts[i + 1])) ? {} : []); + current[index] = + current[index] ?? (isNaN(parseInt(parts[i + 1])) ? {} : []); current = current[index]; } } else { if (isLast) { current[key] = value; } else { - current[key] = current[key] ?? (isNaN(parseInt(parts[i + 1])) ? {} : []); + current[key] = + current[key] ?? (isNaN(parseInt(parts[i + 1])) ? {} : []); current = current[key]; } } @@ -62,7 +64,8 @@ export class MachineContext { if (isLast) { current[key] = value; } else { - current = current[key] = current[key] ?? (isNaN(parseInt(parts[i + 1])) ? {} : []); + current = current[key] = + current[key] ?? (isNaN(parseInt(parts[i + 1])) ? {} : []); } } } @@ -80,7 +83,11 @@ export class MachineContext { } } - public setFromData(location: string | string[], data?: Record, path?: string | string[]) { + public setFromData( + location: string | string[], + data?: Record, + path?: string | string[] + ) { if (!data) return; const value = getFromObject(data, path); diff --git a/packages/automation/src/lib/litActions.ts b/packages/automation/src/lib/litActions.ts index 63059cd4cf..c251268570 100644 --- a/packages/automation/src/lib/litActions.ts +++ b/packages/automation/src/lib/litActions.ts @@ -15,24 +15,16 @@ export const signWithLitActionCode = `(async () => { Lit.Actions.setResponse({ response: signature }); })();`; -interface ExecuteLitActionBase { +interface ExecuteLitAction { litNodeClient: LitNodeClient; pkpPublicKey: string; authSigner: ethers.Wallet; ipfsId?: string; - code: string; - jsParams: Record; + code?: string; + jsParams?: Record; } -interface ExecuteCodeLitAction extends ExecuteLitActionBase { - code: string; -} - -interface ExecuteIPFSLitAction extends ExecuteLitActionBase { - ipfsId: string; -} - -type ExecuteLitAction = ExecuteCodeLitAction | ExecuteIPFSLitAction; +const ONE_MINUTE = 1 * 60 * 1000; export async function executeLitAction({ litNodeClient, @@ -49,7 +41,7 @@ export async function executeLitAction({ await EthWalletProvider.authenticate({ signer: authSigner, litNodeClient: litNodeClient, - expiration: new Date(Date.now() + 1000 * 60 * 10).toISOString(), // 10 minutes + expiration: new Date(Date.now() + ONE_MINUTE).toISOString(), }), ], resourceAbilityRequests: [ diff --git a/packages/automation/src/lib/state-machine.spec.ts b/packages/automation/src/lib/state-machine.spec.ts index c402420ed8..e72c722cfc 100644 --- a/packages/automation/src/lib/state-machine.spec.ts +++ b/packages/automation/src/lib/state-machine.spec.ts @@ -190,14 +190,14 @@ describe('StateMachine', () => { token: '0x123...', }, values: { - amount: 100 - } + amount: 100, + }, }; beforeEach(() => { machine = new StateMachine({ ...stateMachineParams, - context: initialContext + context: initialContext, }); }); diff --git a/packages/automation/src/lib/state-machine.ts b/packages/automation/src/lib/state-machine.ts index 406a48a87c..c13b5059d6 100644 --- a/packages/automation/src/lib/state-machine.ts +++ b/packages/automation/src/lib/state-machine.ts @@ -14,11 +14,12 @@ import { import { signWithLitActionCode, executeLitAction } from './litActions'; import { State, StateParams } from './states'; import { Check, Transition } from './transitions'; -import { getChain } from './utils/chain'; +import { getEvmChain } from './utils/chain'; import { getBalanceTransitionCheck, getERC20Balance } from './utils/erc20'; import { BaseStateMachineParams, + ContextOrLiteral, PKPInfo, StateMachineDefinition, TransitionDefinition, @@ -135,7 +136,7 @@ export class StateMachine { onEnterFunctions.push(async () => { console.log( `MachineContext at state ${state.key} enter: `, - stateMachine.context.get(contextAction.log?.path), + stateMachine.context.get(contextAction.log?.path) ); }); } @@ -143,7 +144,7 @@ export class StateMachine { onExitFunctions.push(async () => { console.log( `MachineContext at state ${state.key} exit: `, - stateMachine.context.get(contextAction.log?.path), + stateMachine.context.get(contextAction.log?.path) ); }); } @@ -162,8 +163,8 @@ export class StateMachine { litNodeClient: litNodeClientInstance, pkpPublicKey: stateMachine.pkp!.publicKey, authSigner: signer, - ipfsId: litAction.ipfsId, - code: litAction.code, + ipfsId: stateMachine.resolveValue(litAction.ipfsId), + code: stateMachine.resolveValue(litAction.code), jsParams: litAction.jsParams, }); @@ -175,36 +176,38 @@ export class StateMachine { if (transaction) { onEnterFunctions.push(async () => { const yellowstoneMachineSigner = new ethers.Wallet( - transaction.pkpOwnerKey, + stateMachine.resolveValue(transaction.pkpOwnerKey), new ethers.providers.JsonRpcProvider(LIT_RPC.CHRONICLE_YELLOWSTONE) ); - const chain = getChain(transaction); + const chainId = stateMachine.resolveValue(transaction.evmChainId); + const chain = getEvmChain(chainId); const chainProvider = new ethers.providers.JsonRpcProvider( chain.rpcUrls[0], chain.chainId ); const contract = new ethers.Contract( - transaction.contractAddress, + stateMachine.resolveValue(transaction.contractAddress), transaction.contractABI, chainProvider ); - const txParams = (transaction.params || []).map(param => - 'contextPath' in param - ? stateMachine.context.get(param.contextPath) - : param + const txParams = (transaction.params || []).map( + stateMachine.resolveValue.bind(stateMachine) + ); + const txMethod = stateMachine.resolveValue(transaction.method); + const txData = await contract.populateTransaction[txMethod]( + ...txParams ); - const txData = await contract.populateTransaction[transaction.method](...txParams); const gasLimit = await chainProvider.estimateGas({ - to: transaction.contractAddress, + to: stateMachine.resolveValue(transaction.contractAddress), data: txData.data, - from: transaction.pkpEthAddress, + from: stateMachine.resolveValue(transaction.pkpEthAddress), }); const gasPrice = await chainProvider.getGasPrice(); const nonce = await chainProvider.getTransactionCount( - transaction.pkpEthAddress + stateMachine.resolveValue(transaction.pkpEthAddress) ); const rawTx = { @@ -213,7 +216,7 @@ export class StateMachine { gasLimit: gasLimit.toHexString(), gasPrice: gasPrice.toHexString(), nonce, - to: transaction.contractAddress, + to: stateMachine.resolveValue(transaction.contractAddress), }; const rawTxHash = ethers.utils.keccak256( ethers.utils.serializeTransaction(rawTx) @@ -222,7 +225,7 @@ export class StateMachine { // Sign with the PKP in a LitAction const litActionResponse = await executeLitAction({ litNodeClient: litNodeClientInstance, - pkpPublicKey: transaction.pkpPublicKey, + pkpPublicKey: stateMachine.resolveValue(transaction.pkpPublicKey), authSigner: yellowstoneMachineSigner, code: signWithLitActionCode, jsParams: { @@ -282,7 +285,8 @@ export class StateMachine { if (evmContractEvent) { const transitionIndex = checks.length; - const chain = getChain(evmContractEvent); + const chainId = stateMachine.resolveValue(evmContractEvent.evmChainId); + const chain = getEvmChain(chainId); listeners.push( new EVMContractEventListener( @@ -302,12 +306,12 @@ export class StateMachine { | ContractEventData | undefined; - evmContractEvent.contextUpdates?.forEach(contextUpdate => + evmContractEvent.contextUpdates?.forEach((contextUpdate) => stateMachine.context.setFromData( contextUpdate.contextPath, eventData, - contextUpdate.dataPath, - ), + contextUpdate.dataPath + ) ); return eventData?.event.event === evmContractEvent.eventName; @@ -317,7 +321,8 @@ export class StateMachine { if (balances) { balances.forEach((balance) => { const transitionIndex = checks.length; - const chain = getChain(balance); + const chainId = stateMachine.resolveValue(balance.evmChainId); + const chain = getEvmChain(chainId); const chainProvider = new ethers.providers.JsonRpcProvider( chain.rpcUrls[0], @@ -471,6 +476,13 @@ export class StateMachine { this.debug && console.log('State machine stopped'); } + public resolveValue(value: ContextOrLiteral | T): T { + if (value && typeof value === 'object' && 'contextPath' in value) { + return this.context.get(value.contextPath) as T; + } + return value; + } + /** * Validates whether a PKP (Private Key Pair) is configured in the state machine. * If a PKP is not present, it initiates the minting of a new PKP through the diff --git a/packages/automation/src/lib/types.ts b/packages/automation/src/lib/types.ts index 2918b03c31..a8aae2c141 100644 --- a/packages/automation/src/lib/types.ts +++ b/packages/automation/src/lib/types.ts @@ -14,14 +14,16 @@ export type PKPInfo = { }; export interface OnEvmChain { - evmChainId: number; + evmChainId: ContextOrLiteral; } -interface ContextRead { +export interface ReadsContext { contextPath: string; } -interface ContextUpdate extends ContextRead { +export type ContextOrLiteral = T | ReadsContext; + +interface ContextUpdate extends ReadsContext { dataPath: string; } @@ -30,15 +32,15 @@ export interface UpdatesContext { } export interface UsesPkp { - pkpOwnerKey: string; - pkpPublicKey: string; - pkpEthAddress: Address; + pkpOwnerKey: ContextOrLiteral; + pkpPublicKey: ContextOrLiteral; + pkpEthAddress: ContextOrLiteral
; } -export interface LitActionStateDefinition extends UsesPkp { - ipfsId?: string; // TODO separate into another without code - code: string; - jsParams: Record; +interface LitActionStateDefinition extends UsesPkp { + code?: ContextOrLiteral; + ipfsId?: ContextOrLiteral; + jsParams?: Record; } export interface ContextStateDefinition { @@ -51,10 +53,10 @@ export interface ContextStateDefinition { export interface TransactionStateDefinition extends UsesPkp, OnEvmChain { contractABI: ethers.ContractInterface; - contractAddress: Address; - method: string; - params?: (ContextRead | any)[]; - value?: string; + contractAddress: ContextOrLiteral
; + method: ContextOrLiteral; + params?: ContextOrLiteral[]; + value?: ContextOrLiteral; } export interface StateDefinition { @@ -101,7 +103,9 @@ export interface TimerTransitionDefinition until: number; } -export interface EvmContractEventTransitionDefinition extends OnEvmChain, UpdatesContext { +export interface EvmContractEventTransitionDefinition + extends OnEvmChain, + UpdatesContext { contractABI: ethers.ContractInterface; contractAddress: Address; eventName: string; diff --git a/packages/automation/src/lib/utils/chain.ts b/packages/automation/src/lib/utils/chain.ts index 844c0a047d..cbfc943d08 100644 --- a/packages/automation/src/lib/utils/chain.ts +++ b/packages/automation/src/lib/utils/chain.ts @@ -1,13 +1,11 @@ import { LIT_EVM_CHAINS } from '@lit-protocol/constants'; -import { OnEvmChain } from '../types'; - -export function getChain(event: OnEvmChain) { +export function getEvmChain(evmChainId: number) { const chain = Object.values(LIT_EVM_CHAINS).find( - (chain) => chain.chainId === event.evmChainId + (chain) => chain.chainId === evmChainId ); if (!chain) { - throw new Error(`EVM chain with chainId ${event.evmChainId} not found`); + throw new Error(`EVM chain with chainId ${evmChainId} not found`); } return chain; From c4786f93b1883a6692e676bac9548bdcf535a629 Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Thu, 12 Dec 2024 17:39:56 +0100 Subject: [PATCH 13/43] feat: types improvement --- .../src/lib/context/machine-context.ts | 7 ++++-- packages/automation/src/lib/state-machine.ts | 6 ++--- packages/automation/src/lib/states/state.ts | 4 +-- .../src/lib/transitions/transition.ts | 25 ++++++++++--------- packages/automation/src/lib/types.ts | 6 ++--- 5 files changed, 26 insertions(+), 22 deletions(-) diff --git a/packages/automation/src/lib/context/machine-context.ts b/packages/automation/src/lib/context/machine-context.ts index f2124b9b9c..cd695f2bce 100644 --- a/packages/automation/src/lib/context/machine-context.ts +++ b/packages/automation/src/lib/context/machine-context.ts @@ -7,7 +7,10 @@ function parsePath(path?: string | string[]): string[] { return path.split(/\.|\[|\]/).filter(Boolean); } -function getFromObject(object: Record, path?: string | string[]) { +function getFromObject( + object: Record, + path?: string | string[] +) { if (!path) return object; const parts = parsePath(path); @@ -85,7 +88,7 @@ export class MachineContext { public setFromData( location: string | string[], - data?: Record, + data?: Record, path?: string | string[] ) { if (!data) return; diff --git a/packages/automation/src/lib/state-machine.ts b/packages/automation/src/lib/state-machine.ts index c13b5059d6..bd1daa3977 100644 --- a/packages/automation/src/lib/state-machine.ts +++ b/packages/automation/src/lib/state-machine.ts @@ -13,7 +13,7 @@ import { } from './listeners'; import { signWithLitActionCode, executeLitAction } from './litActions'; import { State, StateParams } from './states'; -import { Check, Transition } from './transitions'; +import { CheckFn, Transition } from './transitions'; import { getEvmChain } from './utils/chain'; import { getBalanceTransitionCheck, getERC20Balance } from './utils/erc20'; @@ -33,7 +33,7 @@ export type MachineStatus = 'running' | 'stopped'; * A StateMachine class that manages states and transitions between them. */ export class StateMachine { - private debug = false; + private readonly debug; private context: MachineContext; private litNodeClient: LitNodeClient; @@ -273,7 +273,7 @@ export class StateMachine { }; const listeners: Listener[] = []; - const checks: Check[] = []; + const checks: CheckFn[] = []; if (timer) { const transitionIndex = checks.length; diff --git a/packages/automation/src/lib/states/state.ts b/packages/automation/src/lib/states/state.ts index abd994368f..d0d563c622 100644 --- a/packages/automation/src/lib/states/state.ts +++ b/packages/automation/src/lib/states/state.ts @@ -11,12 +11,12 @@ export type StateParams = BaseStateParams; * A State class that represents a state with optional entry and exit actions. */ export class State { - private debug = false; + private readonly debug; public readonly key: string; public readonly onEnter: (() => Promise) | undefined; public readonly onExit: (() => Promise) | undefined; - constructor(private params: BaseStateParams) { + constructor(params: BaseStateParams) { this.key = params.key; this.onEnter = params.onEnter; this.onExit = params.onExit; diff --git a/packages/automation/src/lib/transitions/transition.ts b/packages/automation/src/lib/transitions/transition.ts index f000c2da39..4d7f092b71 100644 --- a/packages/automation/src/lib/transitions/transition.ts +++ b/packages/automation/src/lib/transitions/transition.ts @@ -1,25 +1,26 @@ import { Listener } from '../listeners'; -export type Check = (values: (any | undefined)[]) => Promise; +export type CheckFn = (values: (unknown | undefined)[]) => Promise; +export type resultFn = (values: (unknown | undefined)[]) => Promise; /** * A Transition class that manages state transitions based on listeners and conditions. */ export interface BaseTransitionParams { debug?: boolean; - listeners?: Listener[]; - check?: (values: (any | undefined)[]) => Promise; - onMatch: (values: (any | undefined)[]) => Promise; - onMismatch?: (values: (any | undefined)[]) => Promise; + listeners?: Listener[]; // should be unknown but that makes callers to cast listeners + check?: CheckFn; + onMatch: resultFn; + onMismatch?: resultFn; } export class Transition { private readonly debug: boolean; - private readonly listeners: Listener[]; - private readonly values: (any | undefined)[]; - private readonly check?: Check; - private readonly onMatch: (values: (any | undefined)[]) => Promise; - private readonly onMismatch?: (values: (any | undefined)[]) => Promise; + private readonly listeners: Listener[]; + private readonly values: (unknown | undefined)[]; + private readonly check?: CheckFn; + private readonly onMatch: resultFn; + private readonly onMismatch?: resultFn; /** * Creates a new Transition instance. If no listeners are provided, the transition will automatically match on the next event loop. @@ -47,7 +48,7 @@ export class Transition { */ private setupListeners() { this.listeners.forEach((listener, index) => { - listener.onStateChange(async (value: any) => { + listener.onStateChange(async (value: unknown) => { this.values[index] = value; const isMatch = this.check ? await this.check(this.values) : true; if (isMatch) { @@ -69,7 +70,7 @@ export class Transition { await Promise.all(this.listeners.map((listener) => listener.start())); if (!this.listeners.length) { - // If the transition does not have any listeners it will never emit. Therefore, we "emit" automatically on next event loop + // If the transition does not have any listeners it will never emit. Therefore, we "match" automatically on next event loop setTimeout(() => { this.debug && console.log('Transition without listeners: auto match'); this.onMatch([]); diff --git a/packages/automation/src/lib/types.ts b/packages/automation/src/lib/types.ts index a8aae2c141..b0d3dd756f 100644 --- a/packages/automation/src/lib/types.ts +++ b/packages/automation/src/lib/types.ts @@ -17,13 +17,13 @@ export interface OnEvmChain { evmChainId: ContextOrLiteral; } -export interface ReadsContext { +export interface ContextAccess { contextPath: string; } -export type ContextOrLiteral = T | ReadsContext; +export type ContextOrLiteral = T | ContextAccess; -interface ContextUpdate extends ReadsContext { +interface ContextUpdate extends ContextAccess { dataPath: string; } From 759844841eb4852a78916f6420d84d48a2b3a684 Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Thu, 12 Dec 2024 19:11:58 +0100 Subject: [PATCH 14/43] fix: queue events in transition to avoid multiple racing calls to check and the onMatch/onMismatch functions. Fixes when multiple events trigger transitions at nearly the same time --- .../src/lib/transitions/transition.spec.ts | 54 ++++++++++++++++++- .../src/lib/transitions/transition.ts | 48 +++++++++++++---- 2 files changed, 91 insertions(+), 11 deletions(-) diff --git a/packages/automation/src/lib/transitions/transition.spec.ts b/packages/automation/src/lib/transitions/transition.spec.ts index 62b295681c..6e954a9f28 100644 --- a/packages/automation/src/lib/transitions/transition.spec.ts +++ b/packages/automation/src/lib/transitions/transition.spec.ts @@ -1,6 +1,10 @@ -import { TimerListener } from '../listeners'; +import { ConstantListener, TimerListener } from '../listeners'; import { Transition } from './transition'; +function flushPromises() { + return new Promise(jest.requireActual('timers').setImmediate); +} + function coalesce(value: number | undefined) { return value ?? 0; } @@ -36,6 +40,8 @@ describe('Transition', () => { // After 4 seconds (listener1 counter = 4, listener2 counter = 2) jest.advanceTimersByTime(4000); + await flushPromises(); + await expect(check).toHaveBeenCalledTimes(6); await expect(onMismatch).toHaveBeenCalledTimes(5); // 4 for listener1, 2 for listener2. But last one matched await expect(onMatch).toHaveBeenCalledTimes(1); @@ -47,6 +53,8 @@ describe('Transition', () => { // After 3 seconds (listener1 counter = 3, listener2 counter = 1) jest.advanceTimersByTime(3000); + await flushPromises(); + await expect(check).toHaveBeenCalledTimes(4); await expect(onMismatch).toHaveBeenCalledTimes(4); // 3 for listener1, 1 for listener2 await expect(onMismatch).toHaveBeenCalledWith([3, 1]); // Last of failing values @@ -56,8 +64,10 @@ describe('Transition', () => { it('should stop calling callbacks after stopListening', async () => { await transition.startListening(); - // After 2 seconds + // After 3 seconds jest.advanceTimersByTime(3000); + await flushPromises(); + await expect(check).toHaveBeenCalledTimes(4); await expect(onMismatch).toHaveBeenCalledTimes(4); // 3 for listener1, 1 for listener2 await expect(onMismatch).toHaveBeenCalledWith([3, 1]); // Example of checking values @@ -91,6 +101,8 @@ describe('Transition', () => { // After 2 seconds (listener1 counter = 2, listener2 counter = 1) jest.advanceTimersByTime(2000); + await flushPromises(); + await expect(onMatch).toHaveBeenCalledTimes(3); // Called for each state change await expect(onMatch).toHaveBeenCalledWith([2, 1]); }); @@ -107,6 +119,44 @@ describe('Transition', () => { await expect(onMatch).toHaveBeenCalledWith([]); }); + it('should handle multiple simultaneous listener updates and call onMatch only once when it stops listeners', async () => { + const listener1 = new ConstantListener(1000); + const listener2 = new ConstantListener(2000); + const transition = new Transition({ + listeners: [listener1, listener2], + check, + onMatch, + onMismatch, + }); + // Overload onMatch + const stoppingOnMatch = jest.fn(() => { + transition.stopListening(); + }); + // @ts-expect-error overwriting a readonly property + transition['onMatch'] = stoppingOnMatch; + + await transition.startListening(); + + // Simulate rapid listener updates + listener1['emit'](1); + listener1['emit'](2); + listener1['emit'](3); + listener2['emit'](1); + listener2['emit'](2); // This call should match. No more calls to anything after this + listener2['emit'](2); // Since this event, transition doesn't call check more values + listener2['emit'](2); + listener1['emit'](3); + listener1['emit'](3); + + jest.runAllTimers(); + await flushPromises(); + + await expect(check).toHaveBeenCalledTimes(5); // Check should only be called once for each queued values + await expect(onMismatch).toHaveBeenCalledTimes(4); // onMismatch should be called always until a match is found, but not more + await expect(stoppingOnMatch).toHaveBeenCalledTimes(1); // onMatch should only be called once + await expect(stoppingOnMatch).toHaveBeenCalledWith([3, 2]); + }); + afterEach(async () => { await transition.stopListening(); jest.useRealTimers(); diff --git a/packages/automation/src/lib/transitions/transition.ts b/packages/automation/src/lib/transitions/transition.ts index 4d7f092b71..812e9a3686 100644 --- a/packages/automation/src/lib/transitions/transition.ts +++ b/packages/automation/src/lib/transitions/transition.ts @@ -2,6 +2,7 @@ import { Listener } from '../listeners'; export type CheckFn = (values: (unknown | undefined)[]) => Promise; export type resultFn = (values: (unknown | undefined)[]) => Promise; +type Values = (unknown | undefined)[]; /** * A Transition class that manages state transitions based on listeners and conditions. @@ -17,10 +18,12 @@ export interface BaseTransitionParams { export class Transition { private readonly debug: boolean; private readonly listeners: Listener[]; - private readonly values: (unknown | undefined)[]; + private readonly values: Values; private readonly check?: CheckFn; private readonly onMatch: resultFn; private readonly onMismatch?: resultFn; + private readonly queue: Values[] = []; + private isProcessingQueue = false; /** * Creates a new Transition instance. If no listeners are provided, the transition will automatically match on the next event loop. @@ -50,14 +53,12 @@ export class Transition { this.listeners.forEach((listener, index) => { listener.onStateChange(async (value: unknown) => { this.values[index] = value; - const isMatch = this.check ? await this.check(this.values) : true; - if (isMatch) { - this.debug && console.log('match', this.values); - await this.onMatch?.(this.values); - } else { - this.debug && console.log('mismatch', this.values); - await this.onMismatch?.(this.values); - } + + // Enqueue the updated values + this.queue.push([...this.values]); + + // Process the queue + await this.processQueue(); }); }); } @@ -83,6 +84,35 @@ export class Transition { */ async stopListening() { this.debug && console.log('stopListening'); + this.queue.length = 0; // Flush the queue as there might be more value arrays to check await Promise.all(this.listeners.map((listener) => listener.stop())); } + + private async processQueue() { + // Prevent concurrent queue processing + if (this.isProcessingQueue) { + return; + } + this.isProcessingQueue = true; + + while (this.queue.length > 0) { + const currentValues = this.queue.shift(); + + if (!currentValues) { + continue; + } + + const isMatch = this.check ? await this.check(currentValues) : true; + + if (isMatch) { + this.debug && console.log('match', currentValues); + await this.onMatch?.(currentValues); + } else { + this.debug && console.log('mismatch', currentValues); + await this.onMismatch?.(currentValues); + } + } + + this.isProcessingQueue = false; // Allow new queue processing + } } From 3e12d72b23b9f0e44e89b4d813bc204e20299efa Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Thu, 12 Dec 2024 19:27:51 +0100 Subject: [PATCH 15/43] fix: remove unnecessary context functions in machine --- packages/automation/src/lib/state-machine.spec.ts | 6 +++--- packages/automation/src/lib/state-machine.ts | 8 -------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/packages/automation/src/lib/state-machine.spec.ts b/packages/automation/src/lib/state-machine.spec.ts index e72c722cfc..02d6d5cecb 100644 --- a/packages/automation/src/lib/state-machine.spec.ts +++ b/packages/automation/src/lib/state-machine.spec.ts @@ -202,12 +202,12 @@ describe('StateMachine', () => { }); it('should initialize with context', () => { - expect(machine.getContext()).toEqual(initialContext); + expect(machine['context']['context']).toEqual(initialContext); }); it('should allow getting and setting context values', () => { - machine.setContext('new.value', 42); - expect(machine.getContext('new.value')).toBe(42); + machine['context'].set('new.value', 42); + expect(machine['context'].get('new.value')).toBe(42); }); }); }); diff --git a/packages/automation/src/lib/state-machine.ts b/packages/automation/src/lib/state-machine.ts index bd1daa3977..90792285c1 100644 --- a/packages/automation/src/lib/state-machine.ts +++ b/packages/automation/src/lib/state-machine.ts @@ -59,14 +59,6 @@ export class StateMachine { this.pkp = params.pkp; } - public getContext(path?: string | string[]): any { - return this.context.get(path); - } - - public setContext(path: string | string[], value: any): void { - this.context.set(path, value); - } - static fromDefinition(machineConfig: StateMachineDefinition): StateMachine { const { debug = false, From 2c26270a6f3dc434614db79055e48afd97d9d401 Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Thu, 12 Dec 2024 20:49:20 +0100 Subject: [PATCH 16/43] feat: error handling while in automation execution --- .../automation/src/lib/listeners/listener.ts | 10 +++ packages/automation/src/lib/state-machine.ts | 76 ++++++++++++---- .../src/lib/transitions/transition.ts | 90 ++++++++++++------- packages/automation/src/lib/types.ts | 2 + packages/constants/src/lib/errors.ts | 6 ++ 5 files changed, 137 insertions(+), 47 deletions(-) diff --git a/packages/automation/src/lib/listeners/listener.ts b/packages/automation/src/lib/listeners/listener.ts index 694de9a3d3..87a2eb1ef9 100644 --- a/packages/automation/src/lib/listeners/listener.ts +++ b/packages/automation/src/lib/listeners/listener.ts @@ -1,8 +1,11 @@ import { EventEmitter } from 'events'; +import { onError } from '../types'; + export interface ListenerParams { start?: () => Promise; stop?: () => Promise; + onError?: onError; } /** @@ -23,6 +26,11 @@ export class Listener { */ public stop: () => Promise; + /** + * The error handling function to call when an error occurs. + */ + public onError?: onError; + /** * Constructor for the Listener class. * @param params The parameters object containing start and stop functions. @@ -30,9 +38,11 @@ export class Listener { constructor({ start = async () => {}, stop = async () => {}, + onError, }: ListenerParams = {}) { this.start = start; this.stop = stop; + this.onError = onError; } /** diff --git a/packages/automation/src/lib/state-machine.ts b/packages/automation/src/lib/state-machine.ts index 90792285c1..d4f680498a 100644 --- a/packages/automation/src/lib/state-machine.ts +++ b/packages/automation/src/lib/state-machine.ts @@ -1,6 +1,10 @@ import { ethers } from 'ethers'; -import { LIT_RPC } from '@lit-protocol/constants'; +import { + AutomationError, + UnknownError, + LIT_RPC, +} from '@lit-protocol/constants'; import { LitContracts } from '@lit-protocol/contracts-sdk'; import { LitNodeClient } from '@lit-protocol/lit-node-client'; @@ -34,6 +38,7 @@ export type MachineStatus = 'running' | 'stopped'; */ export class StateMachine { private readonly debug; + private readonly onError?: (error: unknown, context?: string) => void; private context: MachineContext; private litNodeClient: LitNodeClient; @@ -51,6 +56,7 @@ export class StateMachine { constructor(params: BaseStateMachineParams) { this.id = this.generateId(); this.debug = params.debug ?? false; + this.onError = params.onError; this.context = new MachineContext(params.context); this.litNodeClient = params.litNodeClient; @@ -62,6 +68,7 @@ export class StateMachine { static fromDefinition(machineConfig: StateMachineDefinition): StateMachine { const { debug = false, + onError, litNodeClient, litContracts = {}, privateKey, @@ -94,6 +101,7 @@ export class StateMachine { const stateMachine = new StateMachine({ debug, + onError, litNodeClient: litNodeClientInstance, litContracts: litContractsInstance, privateKey, @@ -360,16 +368,18 @@ export class StateMachine { transitionConfig.listeners = listeners; // Aggregate (AND) all listener checks to a single function result transitionConfig.check = async (values) => { - console.log( - `${transition.fromState} -> ${transition.toState} values`, - values - ); + debug && + console.log( + `${transition.fromState} -> ${transition.toState} values`, + values + ); return Promise.all(checks.map((check) => check(values))).then( (results) => { - console.log( - `${transition.fromState} -> ${transition.toState} results`, - results - ); + debug && + console.log( + `${transition.fromState} -> ${transition.toState} results`, + results + ); return results.every((result) => result); } ); @@ -421,10 +431,15 @@ export class StateMachine { await this.transitionTo(toState); }; + const onTransitionError = async (error: unknown) => { + this.handleError(error, `Error at ${fromState} -> ${toState} transition`); + }; + const transition = new Transition({ debug: this.debug, listeners, check, + onError: onTransitionError, onMatch: transitioningOnMatch, onMismatch, }); @@ -461,9 +476,9 @@ export class StateMachine { async stopMachine() { this.debug && console.log('Stopping state machine...'); + this.status = 'stopped'; await this.exitCurrentState(); await this.onStopCallback?.(); - this.status = 'stopped'; this.debug && console.log('State machine stopped'); } @@ -504,10 +519,6 @@ export class StateMachine { * Stops listening on the current state's transitions and exits the current state. */ private async exitCurrentState() { - if (!this.isRunning) { - return; - } - this.debug && console.log('exitCurrentState', this.currentState?.key); const currentTransitions = @@ -547,7 +558,13 @@ export class StateMachine { const nextState = this.states.get(stateKey); if (!nextState) { - throw new Error(`State ${stateKey} not found`); + throw new UnknownError( + { + currentState: this.currentState, + nextState: stateKey, + }, + `Machine next state not found` + ); } if (this.currentState === nextState) { console.warn(`State ${stateKey} is already active. Skipping transition.`); @@ -560,8 +577,33 @@ export class StateMachine { this.isRunning && (await this.enterState(stateKey)); } catch (e) { this.currentState = undefined; - console.error(e); - throw new Error(`Could not enter state ${stateKey}`); + this.handleError(e, `Could not enter state ${stateKey}`); + } + } + + private handleError(error: unknown, context: string) { + // Try to halt machine if it is still running + if (this.isRunning) { + const publicError = new AutomationError( + { + info: { + stateMachineId: this.id, + status: this.status, + currentState: this.currentState, + }, + cause: error, + }, + context ?? 'Error running state machine' + ); + if (this.onError) { + this.onError(publicError); + } else { + // This throw will likely crash the server + throw publicError; + } + + // Throwing when stopping could hide above error + this.stopMachine().catch(console.error); } } diff --git a/packages/automation/src/lib/transitions/transition.ts b/packages/automation/src/lib/transitions/transition.ts index 812e9a3686..1868066704 100644 --- a/packages/automation/src/lib/transitions/transition.ts +++ b/packages/automation/src/lib/transitions/transition.ts @@ -1,4 +1,5 @@ import { Listener } from '../listeners'; +import { onError } from '../types'; export type CheckFn = (values: (unknown | undefined)[]) => Promise; export type resultFn = (values: (unknown | undefined)[]) => Promise; @@ -13,6 +14,7 @@ export interface BaseTransitionParams { check?: CheckFn; onMatch: resultFn; onMismatch?: resultFn; + onError?: onError; } export class Transition { @@ -22,6 +24,7 @@ export class Transition { private readonly check?: CheckFn; private readonly onMatch: resultFn; private readonly onMismatch?: resultFn; + private readonly onError?: onError; private readonly queue: Values[] = []; private isProcessingQueue = false; @@ -36,12 +39,14 @@ export class Transition { check, onMatch, onMismatch, + onError, }: BaseTransitionParams) { this.debug = debug ?? false; this.listeners = listeners; this.check = check; this.onMatch = onMatch; this.onMismatch = onMismatch; + this.onError = onError; this.values = new Array(listeners.length).fill(undefined); this.setupListeners(); } @@ -60,6 +65,7 @@ export class Transition { // Process the queue await this.processQueue(); }); + listener.onError?.(this.onError); }); } @@ -67,15 +73,23 @@ export class Transition { * Starts all listeners for this transition. */ async startListening() { - this.debug && console.log('startListening'); - await Promise.all(this.listeners.map((listener) => listener.start())); - - if (!this.listeners.length) { - // If the transition does not have any listeners it will never emit. Therefore, we "match" automatically on next event loop - setTimeout(() => { - this.debug && console.log('Transition without listeners: auto match'); - this.onMatch([]); - }, 0); + try { + this.debug && console.log('startListening'); + await Promise.all(this.listeners.map((listener) => listener.start())); + + if (!this.listeners.length) { + // If the transition does not have any listeners it will never emit. Therefore, we "match" automatically on next event loop + setTimeout(() => { + this.debug && console.log('Transition without listeners: auto match'); + this.onMatch([]); + }, 0); + } + } catch (e) { + if (this.onError) { + this.onError(e); + } else { + throw e; + } } } @@ -83,36 +97,52 @@ export class Transition { * Stops all listeners for this transition. */ async stopListening() { - this.debug && console.log('stopListening'); - this.queue.length = 0; // Flush the queue as there might be more value arrays to check - await Promise.all(this.listeners.map((listener) => listener.stop())); + try { + this.debug && console.log('stopListening'); + this.queue.length = 0; // Flush the queue as there might be more value arrays to check + await Promise.all(this.listeners.map((listener) => listener.stop())); + } catch (e) { + if (this.onError) { + this.onError(e); + } else { + throw e; + } + } } private async processQueue() { - // Prevent concurrent queue processing - if (this.isProcessingQueue) { - return; - } - this.isProcessingQueue = true; + try { + // Prevent concurrent queue processing + if (this.isProcessingQueue) { + return; + } + this.isProcessingQueue = true; - while (this.queue.length > 0) { - const currentValues = this.queue.shift(); + while (this.queue.length > 0) { + const currentValues = this.queue.shift(); - if (!currentValues) { - continue; - } + if (!currentValues) { + continue; + } - const isMatch = this.check ? await this.check(currentValues) : true; + const isMatch = this.check ? await this.check(currentValues) : true; - if (isMatch) { - this.debug && console.log('match', currentValues); - await this.onMatch?.(currentValues); + if (isMatch) { + this.debug && console.log('match', currentValues); + await this.onMatch?.(currentValues); + } else { + this.debug && console.log('mismatch', currentValues); + await this.onMismatch?.(currentValues); + } + } + + this.isProcessingQueue = false; // Allow new queue processing + } catch (e) { + if (this.onError) { + this.onError(e); } else { - this.debug && console.log('mismatch', currentValues); - await this.onMismatch?.(currentValues); + throw e; } } - - this.isProcessingQueue = false; // Allow new queue processing } } diff --git a/packages/automation/src/lib/types.ts b/packages/automation/src/lib/types.ts index b0d3dd756f..062a40057b 100644 --- a/packages/automation/src/lib/types.ts +++ b/packages/automation/src/lib/types.ts @@ -6,6 +6,7 @@ import { LitNodeClient } from '@lit-protocol/lit-node-client'; import { BaseTransitionParams } from './transitions'; export type Address = `0x${string}`; +export type onError = (error: unknown) => void; export type PKPInfo = { tokenId: string; @@ -125,6 +126,7 @@ export interface BaseStateMachineParams { debug?: boolean; litContracts: LitContracts; litNodeClient: LitNodeClient; + onError?: (error: unknown, context?: string) => void; privateKey?: string; pkp?: PKPInfo; } diff --git a/packages/constants/src/lib/errors.ts b/packages/constants/src/lib/errors.ts index 03ad6b810a..757c711d5c 100644 --- a/packages/constants/src/lib/errors.ts +++ b/packages/constants/src/lib/errors.ts @@ -217,6 +217,11 @@ export const LIT_ERROR: Record = { code: 'transaction_error', kind: LitErrorKind.Unexpected, }, + AUTOMATION_ERROR: { + name: 'AutomationError', + code: 'automation_error', + kind: LitErrorKind.Unexpected, + }, }; export const LIT_ERROR_CODE = { @@ -292,6 +297,7 @@ const MultiError = VError.MultiError; export { MultiError }; export const { + AutomationError, InitError, InvalidAccessControlConditions, InvalidArgumentException, From fbe2cce798bddb4d64e83061912fb5dafb9e981b Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Fri, 13 Dec 2024 19:53:41 +0100 Subject: [PATCH 17/43] feat: - move pkp config to context to allow for dynamic switching of pkps - add support from capacityCreditTokens and therefore paid Lit networks - add pkp and capacity credit nft minting states - restricted state machine public interface - use verrors in remaining normal errors --- packages/automation/src/index.ts | 3 +- packages/automation/src/lib/litActions.ts | 27 +- .../automation/src/lib/state-machine.spec.ts | 1 + packages/automation/src/lib/state-machine.ts | 710 +++++++++++------- .../automation/src/lib/states/mint-pkp.ts | 7 +- packages/automation/src/lib/types.ts | 41 +- packages/automation/src/lib/utils/erc20.ts | 6 +- 7 files changed, 477 insertions(+), 318 deletions(-) diff --git a/packages/automation/src/index.ts b/packages/automation/src/index.ts index e556f8ac77..12fc9ac474 100644 --- a/packages/automation/src/index.ts +++ b/packages/automation/src/index.ts @@ -1,4 +1,5 @@ export * from './lib/listeners'; export * from './lib/states'; -export * from './lib/transitions'; export * from './lib/state-machine'; +export * from './lib/transitions'; +export * from './lib/types'; diff --git a/packages/automation/src/lib/litActions.ts b/packages/automation/src/lib/litActions.ts index c251268570..9576af761f 100644 --- a/packages/automation/src/lib/litActions.ts +++ b/packages/automation/src/lib/litActions.ts @@ -1,7 +1,7 @@ import { ethers } from 'ethers'; import { LitActionResource } from '@lit-protocol/auth-helpers'; -import { LIT_ABILITY } from '@lit-protocol/constants'; +import { LIT_ABILITY, LIT_NETWORK } from '@lit-protocol/constants'; import { EthWalletProvider } from '@lit-protocol/lit-auth-client'; import { LitNodeClient } from '@lit-protocol/lit-node-client'; @@ -17,6 +17,8 @@ export const signWithLitActionCode = `(async () => { interface ExecuteLitAction { litNodeClient: LitNodeClient; + capacityTokenId?: string; + pkpEthAddress: string; pkpPublicKey: string; authSigner: ethers.Wallet; ipfsId?: string; @@ -28,20 +30,38 @@ const ONE_MINUTE = 1 * 60 * 1000; export async function executeLitAction({ litNodeClient, + capacityTokenId, + pkpEthAddress, pkpPublicKey, authSigner, ipfsId, code, jsParams, }: ExecuteLitAction) { + let capacityDelegationAuthSig; + if (litNodeClient.config.litNetwork !== LIT_NETWORK.DatilDev) { + const capacityDelegationAuthSigRes = + await litNodeClient.createCapacityDelegationAuthSig({ + dAppOwnerWallet: authSigner, + capacityTokenId, + delegateeAddresses: [pkpEthAddress], + uses: '1', + }); + capacityDelegationAuthSig = + capacityDelegationAuthSigRes.capacityDelegationAuthSig; + } + + const expiration = new Date(Date.now() + ONE_MINUTE).toISOString(); const pkpSessionSigs = await litNodeClient.getPkpSessionSigs({ pkpPublicKey, - capabilityAuthSigs: [], + capabilityAuthSigs: capacityDelegationAuthSig + ? [capacityDelegationAuthSig] + : [], authMethods: [ await EthWalletProvider.authenticate({ signer: authSigner, litNodeClient: litNodeClient, - expiration: new Date(Date.now() + ONE_MINUTE).toISOString(), + expiration, }), ], resourceAbilityRequests: [ @@ -50,6 +70,7 @@ export async function executeLitAction({ ability: LIT_ABILITY.LitActionExecution, }, ], + expiration, }); const executeJsResponse = await litNodeClient.executeJs({ diff --git a/packages/automation/src/lib/state-machine.spec.ts b/packages/automation/src/lib/state-machine.spec.ts index 02d6d5cecb..82448ac106 100644 --- a/packages/automation/src/lib/state-machine.spec.ts +++ b/packages/automation/src/lib/state-machine.spec.ts @@ -13,6 +13,7 @@ const litNodeClient = new LitNodeClient({ const stateMachineParams: BaseStateMachineParams = { litContracts, litNodeClient, + privateKey: '0xPRIVATE_KEY', }; describe('StateMachine', () => { diff --git a/packages/automation/src/lib/state-machine.ts b/packages/automation/src/lib/state-machine.ts index d4f680498a..ef65dd85f5 100644 --- a/packages/automation/src/lib/state-machine.ts +++ b/packages/automation/src/lib/state-machine.ts @@ -25,6 +25,7 @@ import { BaseStateMachineParams, ContextOrLiteral, PKPInfo, + StateDefinition, StateMachineDefinition, TransitionDefinition, TransitionParams, @@ -33,6 +34,11 @@ import { MachineContext } from './context/machine-context'; export type MachineStatus = 'running' | 'stopped'; +export const StateMachineInitialContext = Object.freeze({ + activeCapacityTokenId: undefined, + activePkp: undefined, +}); + /** * A StateMachine class that manages states and transitions between them. */ @@ -44,7 +50,6 @@ export class StateMachine { private litNodeClient: LitNodeClient; private litContracts: LitContracts; private privateKey?: string; - private pkp?: PKPInfo; public id: string; public status: MachineStatus = 'stopped'; @@ -57,25 +62,26 @@ export class StateMachine { this.id = this.generateId(); this.debug = params.debug ?? false; this.onError = params.onError; - this.context = new MachineContext(params.context); + this.context = new MachineContext({ + ...StateMachineInitialContext, + ...params.context, + }); this.litNodeClient = params.litNodeClient; this.litContracts = params.litContracts; this.privateKey = params.privateKey; - this.pkp = params.pkp; } static fromDefinition(machineConfig: StateMachineDefinition): StateMachine { const { debug = false, + context, onError, - litNodeClient, litContracts = {}, + litNodeClient, privateKey, - pkp, states = [], transitions = [], - context, } = machineConfig; // Create litNodeClient and litContracts instances @@ -94,317 +100,298 @@ export class StateMachine { if ( litNodeClientInstance.config.litNetwork !== litContractsInstance.network ) { - throw new Error( + throw new AutomationError( + { + info: { + litNodeClientNetwork: litNodeClientInstance.config.litNetwork, + litContractsNetwork: litContractsInstance.network, + }, + }, 'litNodeClient and litContracts should not use different networks' ); } const stateMachine = new StateMachine({ debug, - onError, + context, litNodeClient: litNodeClientInstance, litContracts: litContractsInstance, privateKey, - pkp, - context, + onError, }); const stateTransitions = [] as TransitionDefinition[]; - states.forEach((state) => { - const { - litAction, - context: contextAction, - transaction, - transitions = [], - } = state; - - const stateConfig: StateParams = { - key: state.key, - }; + states.forEach((stateDefinition) => { + const transitions = stateDefinition.transitions || []; stateTransitions.push( ...transitions.map((transition) => ({ ...transition, - fromState: state.key, + fromState: stateDefinition.key, })) ); - const onEnterFunctions = [] as (() => Promise)[]; - const onExitFunctions = [] as (() => Promise)[]; + stateMachine.addStateFromDefinition(stateDefinition); + }); - if (contextAction) { - if (contextAction.log?.atEnter) { - onEnterFunctions.push(async () => { - console.log( - `MachineContext at state ${state.key} enter: `, - stateMachine.context.get(contextAction.log?.path) - ); - }); - } - if (contextAction.log?.atExit) { - onExitFunctions.push(async () => { - console.log( - `MachineContext at state ${state.key} exit: `, - stateMachine.context.get(contextAction.log?.path) - ); - }); - } - } + [...stateTransitions, ...transitions].forEach((transition) => { + stateMachine.addTransitionFromDefinition(transition); + }); - if (litAction) { - onEnterFunctions.push(async () => { - await stateMachine.validateMachinePKP(); + return stateMachine; + } - const signer = new ethers.Wallet( - stateMachine.privateKey!, - new ethers.providers.JsonRpcProvider(LIT_RPC.CHRONICLE_YELLOWSTONE) - ); + get isRunning() { + return this.status === 'running'; + } - const litActionResponse = await executeLitAction({ - litNodeClient: litNodeClientInstance, - pkpPublicKey: stateMachine.pkp!.publicKey, - authSigner: signer, - ipfsId: stateMachine.resolveValue(litAction.ipfsId), - code: stateMachine.resolveValue(litAction.code), - jsParams: litAction.jsParams, - }); + /** + * Adds a custom state to the state machine. + * @param params The parameters for the state. + */ + addState(params: StateParams) { + const state = new State(params); + this.states.set(state.key, state); + if (!this.transitions.has(state.key)) { + this.transitions.set(state.key, new Map()); + } + } - // TODO send user this result with a webhook and log - stateMachine.context.set('lastLitActionResponse', litActionResponse); - }); - } + /** + * Adds a state to the state machine using the declarative interface. + * @param stateDefinition The state definition. + */ + addStateFromDefinition(stateDefinition: StateDefinition) { + const stateParams: StateParams = { + key: stateDefinition.key, + debug: this.debug, + }; + + const onEnterFunctions = [] as (() => Promise)[]; + const onExitFunctions = [] as (() => Promise)[]; - if (transaction) { + const { + context: contextAction, + key, + litAction, + transaction, + useCapacityNFT, + usePkp, + } = stateDefinition; + + if (contextAction) { + if (contextAction.log?.atEnter) { onEnterFunctions.push(async () => { - const yellowstoneMachineSigner = new ethers.Wallet( - stateMachine.resolveValue(transaction.pkpOwnerKey), - new ethers.providers.JsonRpcProvider(LIT_RPC.CHRONICLE_YELLOWSTONE) + console.log( + `MachineContext at state ${key} enter: `, + this.context.get(contextAction.log?.path) ); - - const chainId = stateMachine.resolveValue(transaction.evmChainId); - const chain = getEvmChain(chainId); - const chainProvider = new ethers.providers.JsonRpcProvider( - chain.rpcUrls[0], - chain.chainId + }); + } + if (contextAction.log?.atExit) { + onExitFunctions.push(async () => { + console.log( + `MachineContext at state ${key} exit: `, + this.context.get(contextAction.log?.path) ); + }); + } + } - const contract = new ethers.Contract( - stateMachine.resolveValue(transaction.contractAddress), - transaction.contractABI, - chainProvider + if (litAction) { + onEnterFunctions.push(async () => { + const activePkp = this.resolveContextPathOrLiteral({ + contextPath: 'activePkp', + }) as unknown as PKPInfo; + if (!activePkp) { + throw new AutomationError( + { + info: { + machineId: this.id, + activePkp, + }, + }, + `There is no active pkp. Must configure it to run a Lit Action` ); + } - const txParams = (transaction.params || []).map( - stateMachine.resolveValue.bind(stateMachine) - ); - const txMethod = stateMachine.resolveValue(transaction.method); - const txData = await contract.populateTransaction[txMethod]( - ...txParams - ); - const gasLimit = await chainProvider.estimateGas({ - to: stateMachine.resolveValue(transaction.contractAddress), - data: txData.data, - from: stateMachine.resolveValue(transaction.pkpEthAddress), - }); - const gasPrice = await chainProvider.getGasPrice(); - const nonce = await chainProvider.getTransactionCount( - stateMachine.resolveValue(transaction.pkpEthAddress) - ); + const signer = new ethers.Wallet( + this.privateKey!, + new ethers.providers.JsonRpcProvider(LIT_RPC.CHRONICLE_YELLOWSTONE) + ); - const rawTx = { - chainId: chain.chainId, - data: txData.data, - gasLimit: gasLimit.toHexString(), - gasPrice: gasPrice.toHexString(), - nonce, - to: stateMachine.resolveValue(transaction.contractAddress), - }; - const rawTxHash = ethers.utils.keccak256( - ethers.utils.serializeTransaction(rawTx) - ); + const litActionResponse = await executeLitAction({ + litNodeClient: this.litNodeClient, + capacityTokenId: this.resolveContextPathOrLiteral({ + contextPath: 'activeCapacityTokenId', + }) as unknown as string, + pkpEthAddress: activePkp.ethAddress, + pkpPublicKey: activePkp.publicKey, + authSigner: signer, + ipfsId: this.resolveContextPathOrLiteral(litAction.ipfsId), + code: this.resolveContextPathOrLiteral(litAction.code), + jsParams: litAction.jsParams, + }); + + // TODO send user this result with a webhook and log + this.context.set('lastLitActionResponse', litActionResponse); + }); + } - // Sign with the PKP in a LitAction - const litActionResponse = await executeLitAction({ - litNodeClient: litNodeClientInstance, - pkpPublicKey: stateMachine.resolveValue(transaction.pkpPublicKey), - authSigner: yellowstoneMachineSigner, - code: signWithLitActionCode, - jsParams: { - toSign: ethers.utils.arrayify(rawTxHash), - publicKey: transaction.pkpPublicKey, - sigName: 'signedTransaction', + if (transaction) { + onEnterFunctions.push(async () => { + const activePkp = this.resolveContextPathOrLiteral({ + contextPath: 'activePkp', + }) as unknown as PKPInfo; + if (!activePkp.ethAddress) { + throw new AutomationError( + { + info: { + machineId: this.id, + activePkp, + }, }, - }); + `There is no active pkp. Must configure it to run a transaction` + ); + } - const signature = litActionResponse.response as string; - const jsonSignature = JSON.parse(signature); - jsonSignature.r = '0x' + jsonSignature.r.substring(2); - jsonSignature.s = '0x' + jsonSignature.s; - const hexSignature = ethers.utils.joinSignature(jsonSignature); + const yellowstoneMachineSigner = new ethers.Wallet( + this.privateKey!, + new ethers.providers.JsonRpcProvider(LIT_RPC.CHRONICLE_YELLOWSTONE) + ); - const signedTx = ethers.utils.serializeTransaction( - rawTx, - hexSignature - ); + const chainId = this.resolveContextPathOrLiteral( + transaction.evmChainId + ); + const chain = getEvmChain(chainId); + const chainProvider = new ethers.providers.JsonRpcProvider( + chain.rpcUrls[0], + chain.chainId + ); - const receipt = await chainProvider.sendTransaction(signedTx); + const contract = new ethers.Contract( + this.resolveContextPathOrLiteral(transaction.contractAddress), + transaction.contractABI, + chainProvider + ); - // TODO send user this result with a webhook and log - stateMachine.context.set('lastTransactionReceipt', receipt); + const txParams = (transaction.params || []).map( + this.resolveContextPathOrLiteral.bind(this) + ); + const txMethod = this.resolveContextPathOrLiteral(transaction.method); + const txData = await contract.populateTransaction[txMethod]( + ...txParams + ); + const gasLimit = await chainProvider.estimateGas({ + to: this.resolveContextPathOrLiteral(transaction.contractAddress), + data: txData.data, + from: activePkp.ethAddress, }); - } - - stateConfig.onEnter = async () => { - await Promise.all(onEnterFunctions.map((onEnter) => onEnter())); - }; - stateConfig.onExit = async () => { - await Promise.all(onExitFunctions.map((onExit) => onExit())); - }; + const gasPrice = await chainProvider.getGasPrice(); + const nonce = await chainProvider.getTransactionCount( + activePkp.ethAddress + ); - stateMachine.addState(stateConfig); - }); + const rawTx = { + chainId: chain.chainId, + data: txData.data, + gasLimit: gasLimit.toHexString(), + gasPrice: gasPrice.toHexString(), + nonce, + to: this.resolveContextPathOrLiteral(transaction.contractAddress), + }; + const rawTxHash = ethers.utils.keccak256( + ethers.utils.serializeTransaction(rawTx) + ); - [...stateTransitions, ...transitions].forEach((transition) => { - const { balances, evmContractEvent, fromState, timer, toState } = - transition; + // Sign with the PKP in a LitAction + const litActionResponse = await executeLitAction({ + litNodeClient: this.litNodeClient, + capacityTokenId: this.resolveContextPathOrLiteral({ + contextPath: 'activeCapacityTokenId', + }) as unknown as string, + pkpEthAddress: activePkp.ethAddress, + pkpPublicKey: activePkp.publicKey, + authSigner: yellowstoneMachineSigner, + code: signWithLitActionCode, + jsParams: { + toSign: ethers.utils.arrayify(rawTxHash), + publicKey: activePkp.publicKey, + sigName: 'signedTransaction', + }, + }); - const transitionConfig: TransitionParams = { - fromState, - toState, - }; + const signature = litActionResponse.response as string; + const jsonSignature = JSON.parse(signature); + jsonSignature.r = '0x' + jsonSignature.r.substring(2); + jsonSignature.s = '0x' + jsonSignature.s; + const hexSignature = ethers.utils.joinSignature(jsonSignature); - const listeners: Listener[] = []; - const checks: CheckFn[] = []; + const signedTx = ethers.utils.serializeTransaction(rawTx, hexSignature); - if (timer) { - const transitionIndex = checks.length; - listeners.push( - new TimerListener(timer.interval, timer.offset, timer.step) - ); - checks.push(async (values) => values[transitionIndex] === timer.until); - } + const receipt = await chainProvider.sendTransaction(signedTx); - if (evmContractEvent) { - const transitionIndex = checks.length; - const chainId = stateMachine.resolveValue(evmContractEvent.evmChainId); - const chain = getEvmChain(chainId); + // TODO send user this result with a webhook and log + this.context.set('lastTransactionReceipt', receipt); + }); + } - listeners.push( - new EVMContractEventListener( - chain.rpcUrls[0], - { - address: evmContractEvent.contractAddress, - abi: evmContractEvent.contractABI, - }, - { - name: evmContractEvent.eventName, - filter: evmContractEvent.eventParams, - } - ) + if (usePkp) { + if ('pkp' in usePkp) { + this.context.set( + 'activePkp', + this.resolveContextPathOrLiteral(usePkp.pkp) ); - checks.push(async (values) => { - const eventData = values[transitionIndex] as - | ContractEventData - | undefined; - - evmContractEvent.contextUpdates?.forEach((contextUpdate) => - stateMachine.context.setFromData( - contextUpdate.contextPath, - eventData, - contextUpdate.dataPath - ) - ); - - return eventData?.event.event === evmContractEvent.eventName; + } else if ('mint' in usePkp) { + onEnterFunctions.push(async () => { + const mintingReceipt = + await this.litContracts!.pkpNftContractUtils.write.mint(); + const pkp = mintingReceipt.pkp; + this.debug && console.log(`Minted PKP: ${pkp}`); + this.context.set('activePkp', pkp); }); } + if (this.debug) { + const activePkp = this.context.get('activePkp'); + console.log(`Machine configured to use pkp ${activePkp}`); + } + } - if (balances) { - balances.forEach((balance) => { - const transitionIndex = checks.length; - const chainId = stateMachine.resolveValue(balance.evmChainId); - const chain = getEvmChain(chainId); - - const chainProvider = new ethers.providers.JsonRpcProvider( - chain.rpcUrls[0], - chain.chainId - ); - - switch (balance.type) { - case 'native': - listeners.push( - new IntervalListener( - () => chainProvider.getBalance(balance.address), - balance.interval - ) - ); - checks.push(getBalanceTransitionCheck(transitionIndex, balance)); - break; - case 'ERC20': - listeners.push( - new IntervalListener( - () => - getERC20Balance( - chainProvider, - balance.tokenAddress, - balance.tokenDecimals, - balance.address - ), - balance.interval - ) - ); - checks.push(getBalanceTransitionCheck(transitionIndex, balance)); - break; - // case 'ERC721': - // case 'ERC1155': - default: - throw new Error( - `TODO balance check type ${balance['type']} unknown or not yet implemented` - ); - } + if (useCapacityNFT) { + if ('capacityTokenId' in useCapacityNFT) { + this.context.set( + 'activeCapacityTokenId', + this.resolveContextPathOrLiteral(useCapacityNFT.capacityTokenId) + ); + } else if ('mint' in useCapacityNFT) { + onEnterFunctions.push(async () => { + const capacityCreditNFT = + await this.litContracts.mintCapacityCreditsNFT({ + requestsPerSecond: useCapacityNFT.requestPerSecond, + daysUntilUTCMidnightExpiration: + useCapacityNFT.daysUntilUTCMidnightExpiration, + }); + const capacityTokeId = capacityCreditNFT.capacityTokenIdStr; + this.debug && console.log(`Minted PKP: ${capacityTokeId}`); + this.context.set(`activeCapacityTokenId`, capacityTokeId); }); } - - // Add all listeners to the transition - transitionConfig.listeners = listeners; - // Aggregate (AND) all listener checks to a single function result - transitionConfig.check = async (values) => { - debug && - console.log( - `${transition.fromState} -> ${transition.toState} values`, - values - ); - return Promise.all(checks.map((check) => check(values))).then( - (results) => { - debug && - console.log( - `${transition.fromState} -> ${transition.toState} results`, - results - ); - return results.every((result) => result); - } + if (this.debug) { + const activeCapacityTokenId = this.context.get('activePkp'); + console.log( + `Machine configured to use capacity token ${activeCapacityTokenId}` ); - }; - - stateMachine.addTransition(transitionConfig); - }); - - return stateMachine; - } + } + } - get isRunning() { - return this.status === 'running'; - } + // Merge all state functions + stateParams.onEnter = async () => { + await Promise.all(onEnterFunctions.map((onEnter) => onEnter())); + }; + stateParams.onExit = async () => { + await Promise.all(onExitFunctions.map((onExit) => onExit())); + }; - /** - * Adds a state to the state machine. - * @param params The parameters for the state. - */ - addState(params: StateParams) { - const state = new State(params); - this.states.set(state.key, state); - if (!this.transitions.has(state.key)) { - this.transitions.set(state.key, new Map()); - } + this.addState(stateParams); } /** @@ -420,10 +407,28 @@ export class StateMachine { onMismatch, }: TransitionParams) { if (!this.states.has(fromState)) { - throw new Error(`Source state ${fromState} not found`); + throw new AutomationError( + { + info: { + machineId: this.id, + fromState: fromState, + toState: toState, + }, + }, + `Source state ${fromState} not found` + ); } if (!this.states.has(toState)) { - throw new Error(`Target state ${toState} not found`); + throw new AutomationError( + { + info: { + machineId: this.id, + fromState: fromState, + toState: toState, + }, + }, + `Target state ${toState} not found` + ); } const transitioningOnMatch = async (values: (unknown | undefined)[]) => { @@ -450,6 +455,139 @@ export class StateMachine { this.transitions.set(fromState, stateTransitions); } + addTransitionFromDefinition(transitionDefinition: TransitionDefinition) { + const { balances, evmContractEvent, fromState, timer, toState } = + transitionDefinition; + + const transitionConfig: TransitionParams = { + fromState, + toState, + }; + + const listeners: Listener[] = []; + const checks: CheckFn[] = []; + + if (timer) { + const transitionIndex = checks.length; + listeners.push( + new TimerListener(timer.interval, timer.offset, timer.step) + ); + checks.push(async (values) => values[transitionIndex] === timer.until); + } + + if (evmContractEvent) { + const transitionIndex = checks.length; + const chainId = this.resolveContextPathOrLiteral( + evmContractEvent.evmChainId + ); + const chain = getEvmChain(chainId); + + listeners.push( + new EVMContractEventListener( + chain.rpcUrls[0], + { + address: evmContractEvent.contractAddress, + abi: evmContractEvent.contractABI, + }, + { + name: evmContractEvent.eventName, + filter: evmContractEvent.eventParams, + } + ) + ); + checks.push(async (values) => { + const eventData = values[transitionIndex] as + | ContractEventData + | undefined; + + evmContractEvent.contextUpdates?.forEach((contextUpdate) => + this.context.setFromData( + contextUpdate.contextPath, + eventData, + contextUpdate.dataPath + ) + ); + + return eventData?.event.event === evmContractEvent.eventName; + }); + } + + if (balances) { + balances.forEach((balance) => { + const transitionIndex = checks.length; + const chainId = this.resolveContextPathOrLiteral(balance.evmChainId); + const chain = getEvmChain(chainId); + + const chainProvider = new ethers.providers.JsonRpcProvider( + chain.rpcUrls[0], + chain.chainId + ); + + switch (balance.type) { + case 'native': + listeners.push( + new IntervalListener( + () => chainProvider.getBalance(balance.address), + balance.interval + ) + ); + checks.push(getBalanceTransitionCheck(transitionIndex, balance)); + break; + case 'ERC20': + listeners.push( + new IntervalListener( + () => + getERC20Balance( + chainProvider, + balance.tokenAddress, + balance.tokenDecimals, + balance.address + ), + balance.interval + ) + ); + checks.push(getBalanceTransitionCheck(transitionIndex, balance)); + break; + // case 'ERC721': + // case 'ERC1155': + default: + throw new AutomationError( + { + info: { + machineId: this.id, + balance, + }, + }, + `TODO balance check type ${balance['type']} unknown or not yet implemented` + ); + } + }); + } + + // Add all listeners to the transition + transitionConfig.listeners = listeners; + // Aggregate (AND) all listener checks to a single function result + transitionConfig.check = async (values) => { + this.debug && + console.log( + `${transitionDefinition.fromState} -> ${transitionDefinition.toState} values`, + values + ); + return Promise.all(checks.map((check) => check(values))).then( + (results) => { + this.debug && + console.log( + `${transitionDefinition.fromState} -> ${transitionDefinition.toState} results`, + results + ); + return results.every((result) => result); + } + ); + }; + + this.addTransition(transitionConfig); + } + /** * Starts the state machine with the given initial state. * @param initialState The key of the initial state. @@ -483,38 +621,15 @@ export class StateMachine { this.debug && console.log('State machine stopped'); } - public resolveValue(value: ContextOrLiteral | T): T { + private resolveContextPathOrLiteral( + value: ContextOrLiteral | T + ): T { if (value && typeof value === 'object' && 'contextPath' in value) { return this.context.get(value.contextPath) as T; } return value; } - /** - * Validates whether a PKP (Private Key Pair) is configured in the state machine. - * If a PKP is not present, it initiates the minting of a new PKP through the - * associated `litContracts`. Once minted, the state machine configures itself - * to use the newly minted PKP. - * - * @remarks - * This validation ensures that the state machine has a PKP to operate suitably - * within its workflow, avoiding any disruptions due to lack of a necessary PKP. - */ - private async validateMachinePKP() { - if (this.pkp) { - console.log(`PKP in state machine is configured. No need to mint one`); - } else { - console.log(`No PKP in state machine, minting one...`); - const mintingReceipt = - await this.litContracts.pkpNftContractUtils.write.mint(); - const pkp = mintingReceipt.pkp; - console.log(`Minted PKP: ${pkp}. Machine will now use it`); - this.pkp = pkp; - } - - console.log(`Machine is using PKP: ${this.pkp.tokenId}`); - } - /** * Stops listening on the current state's transitions and exits the current state. */ @@ -538,7 +653,17 @@ export class StateMachine { private async enterState(stateKey: string) { const state = this.states.get(stateKey); if (!state) { - throw new Error(`State ${stateKey} not found`); + throw new AutomationError( + { + info: { + machineId: this.id, + state: stateKey, + currentState: this.currentState, + isRunning: this.isRunning, + }, + }, + `State ${stateKey} not found` + ); } this.debug && console.log('enterState', state.key); await state.enter(); @@ -560,6 +685,7 @@ export class StateMachine { if (!nextState) { throw new UnknownError( { + machineId: this.id, currentState: this.currentState, nextState: stateKey, }, diff --git a/packages/automation/src/lib/states/mint-pkp.ts b/packages/automation/src/lib/states/mint-pkp.ts index a22003f269..4051857a47 100644 --- a/packages/automation/src/lib/states/mint-pkp.ts +++ b/packages/automation/src/lib/states/mint-pkp.ts @@ -1,12 +1,7 @@ import { LitContracts } from '@lit-protocol/contracts-sdk'; import { State, StateParams } from './state'; - -export interface PKPInfo { - tokenId: string; - publicKey: string; - ethAddress: string; -} +import { PKPInfo } from '../types'; export interface MintPKPStateParams extends StateParams { litContracts: LitContracts; diff --git a/packages/automation/src/lib/types.ts b/packages/automation/src/lib/types.ts index 062a40057b..60ce375ee1 100644 --- a/packages/automation/src/lib/types.ts +++ b/packages/automation/src/lib/types.ts @@ -8,11 +8,11 @@ import { BaseTransitionParams } from './transitions'; export type Address = `0x${string}`; export type onError = (error: unknown) => void; -export type PKPInfo = { +export interface PKPInfo { tokenId: string; publicKey: string; ethAddress: string; -}; +} export interface OnEvmChain { evmChainId: ContextOrLiteral; @@ -32,13 +32,7 @@ export interface UpdatesContext { contextUpdates: ContextUpdate[]; } -export interface UsesPkp { - pkpOwnerKey: ContextOrLiteral; - pkpPublicKey: ContextOrLiteral; - pkpEthAddress: ContextOrLiteral
; -} - -interface LitActionStateDefinition extends UsesPkp { +interface LitActionStateDefinition { code?: ContextOrLiteral; ipfsId?: ContextOrLiteral; jsParams?: Record; @@ -52,7 +46,7 @@ export interface ContextStateDefinition { }; } -export interface TransactionStateDefinition extends UsesPkp, OnEvmChain { +export interface TransactionStateDefinition extends OnEvmChain { contractABI: ethers.ContractInterface; contractAddress: ContextOrLiteral
; method: ContextOrLiteral; @@ -60,12 +54,33 @@ export interface TransactionStateDefinition extends UsesPkp, OnEvmChain { value?: ContextOrLiteral; } +export interface MintStateDefinition { + mint: boolean; +} + +export interface usePkpStateDefinition { + pkp: ContextOrLiteral; +} + +export interface mintCapacityNFTStateDefinition extends MintStateDefinition { + daysUntilUTCMidnightExpiration: number; + requestPerSecond: number; +} + +export interface useCapacityNFTStateDefinition { + capacityTokenId: ContextOrLiteral; +} + export interface StateDefinition { context?: ContextStateDefinition; key: string; litAction?: LitActionStateDefinition; transaction?: TransactionStateDefinition; transitions?: Omit[]; + useCapacityNFT?: + | useCapacityNFTStateDefinition + | mintCapacityNFTStateDefinition; + usePkp?: usePkpStateDefinition | MintStateDefinition; } export interface IntervalTransitionDefinition { @@ -122,13 +137,13 @@ export interface TransitionDefinition { } export interface BaseStateMachineParams { + capacityToken?: string; context?: Record; debug?: boolean; litContracts: LitContracts; litNodeClient: LitNodeClient; onError?: (error: unknown, context?: string) => void; - privateKey?: string; - pkp?: PKPInfo; + privateKey: string; } export interface StateMachineDefinition @@ -136,7 +151,7 @@ export interface StateMachineDefinition litNodeClient: LitNodeClient | ConstructorParameters[0]; litContracts: LitContracts | ConstructorParameters[0]; states: StateDefinition[]; - transitions: TransitionDefinition[]; + transitions?: TransitionDefinition[]; } export interface TransitionParams diff --git a/packages/automation/src/lib/utils/erc20.ts b/packages/automation/src/lib/utils/erc20.ts index da4092322f..b1e04abd37 100644 --- a/packages/automation/src/lib/utils/erc20.ts +++ b/packages/automation/src/lib/utils/erc20.ts @@ -1,6 +1,6 @@ import { ethers } from 'ethers'; -import { BalanceTransitionDefinition } from '../types'; +import { Address, BalanceTransitionDefinition } from '../types'; export const ERC20ABI = [ { @@ -25,9 +25,9 @@ export const ERC20ABI = [ export async function getERC20Balance( provider: ethers.providers.Provider, - tokenAddress: string, + tokenAddress: Address, tokenDecimals: number, - accountAddress: string + accountAddress: Address ) { const contract = new ethers.Contract(tokenAddress, ERC20ABI, provider); const balance = (await contract['balanceOf']( From 74d59c2de3459587f9a02d4f1e266b87a4546e44 Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Fri, 13 Dec 2024 21:47:48 +0100 Subject: [PATCH 18/43] feat: remove unnecessary things and restore state machine context manipulating methods to be used in functional interface --- .../automation/src/lib/listeners/interval.ts | 2 +- packages/automation/src/lib/state-machine.ts | 29 +++++++ packages/automation/src/lib/states/index.ts | 1 - .../src/lib/states/mint-pkp.spec.ts | 78 ------------------- .../automation/src/lib/states/mint-pkp.ts | 27 ------- packages/automation/src/lib/types.ts | 1 - 6 files changed, 30 insertions(+), 108 deletions(-) delete mode 100644 packages/automation/src/lib/states/mint-pkp.spec.ts delete mode 100644 packages/automation/src/lib/states/mint-pkp.ts diff --git a/packages/automation/src/lib/listeners/interval.ts b/packages/automation/src/lib/listeners/interval.ts index e012961198..2a4f0ea275 100644 --- a/packages/automation/src/lib/listeners/interval.ts +++ b/packages/automation/src/lib/listeners/interval.ts @@ -1,6 +1,6 @@ import { Listener } from './listener'; -export class IntervalListener extends Listener { +export class IntervalListener extends Listener { private intervalId?: ReturnType; constructor(callback: () => Promise, interval = 1000) { diff --git a/packages/automation/src/lib/state-machine.ts b/packages/automation/src/lib/state-machine.ts index ef65dd85f5..4f9ff8306e 100644 --- a/packages/automation/src/lib/state-machine.ts +++ b/packages/automation/src/lib/state-machine.ts @@ -608,6 +608,35 @@ export class StateMachine { this.debug && console.log('State machine started'); } + /** + * Gets a value from the machine context + * If value or path do not exist it returns undefined + * @param path the context path to read + */ + public getFromContext(path?: string | string[]): any { + return this.context.get(path); + } + + /** + * Sets a value in the machine context + * If path does not exist, it is created + * @param path the context path to write + * @param value the value to write in the context path + */ + public setToContext(path: string | string[], value: any): void { + this.context.set(path, value); + } + + /** + * Pushes a value in the machine context. The value will be converted to an array if it is not + * If path does not exist, it is created + * @param path the context path to write + * @param value the value to write in the context path + */ + public pushToContext(path: string | string[], value: any): void { + this.context.push(path, value); + } + /** * Stops the state machine by exiting the current state and not moving to another one. */ diff --git a/packages/automation/src/lib/states/index.ts b/packages/automation/src/lib/states/index.ts index a7b5e19c3b..da88543493 100644 --- a/packages/automation/src/lib/states/index.ts +++ b/packages/automation/src/lib/states/index.ts @@ -1,2 +1 @@ -export * from './mint-pkp'; export * from './state'; diff --git a/packages/automation/src/lib/states/mint-pkp.spec.ts b/packages/automation/src/lib/states/mint-pkp.spec.ts deleted file mode 100644 index c9cb6b085a..0000000000 --- a/packages/automation/src/lib/states/mint-pkp.spec.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { LitContracts } from '@lit-protocol/contracts-sdk'; - -import { MintPKPState, MintPKPStateParams } from './mint-pkp'; - -describe('MintPKPState', () => { - let mockLitContracts: LitContracts; - let mockCallback: jest.Mock; - let mockMint: jest.Mock; - - beforeEach(() => { - mockMint = jest.fn().mockResolvedValue({ - pkp: { - tokenId: '123', - publicKey: '0xPublicKey', - ethAddress: '0xEthAddress', - }, - }); - - mockLitContracts = { - pkpNftContractUtils: { - write: { - mint: mockMint, - }, - }, - } as unknown as LitContracts; - - mockCallback = jest.fn(); - }); - - it('should mint a PKP and call the callback with PKP info', async () => { - const params: MintPKPStateParams = { - key: 'MintPKPState', - litContracts: mockLitContracts, - callback: mockCallback, - }; - - const state = new MintPKPState(params); - - await state.enter(); - - expect(mockMint).toHaveBeenCalled(); - expect(mockCallback).toHaveBeenCalledWith({ - tokenId: '123', - publicKey: '0xPublicKey', - ethAddress: '0xEthAddress', - }); - }); - - it('should handle errors during minting', async () => { - mockMint.mockRejectedValue(new Error('Minting error')); - - const params: MintPKPStateParams = { - key: 'MintPKPState', - litContracts: mockLitContracts, - callback: mockCallback, - }; - - const state = new MintPKPState(params); - - await expect(state.enter()).rejects.toThrow('Minting error'); - }); - - it('should execute onEnter callback if provided', async () => { - const onEnter = jest.fn(); - const params: MintPKPStateParams = { - key: 'MintPKPState', - litContracts: mockLitContracts, - callback: mockCallback, - onEnter, - }; - - const state = new MintPKPState(params); - - await state.enter(); - - expect(onEnter).toHaveBeenCalled(); - }); -}); diff --git a/packages/automation/src/lib/states/mint-pkp.ts b/packages/automation/src/lib/states/mint-pkp.ts deleted file mode 100644 index 4051857a47..0000000000 --- a/packages/automation/src/lib/states/mint-pkp.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { LitContracts } from '@lit-protocol/contracts-sdk'; - -import { State, StateParams } from './state'; -import { PKPInfo } from '../types'; - -export interface MintPKPStateParams extends StateParams { - litContracts: LitContracts; - callback: (pkpInfo: PKPInfo) => void; -} - -export class MintPKPState extends State { - constructor(params: MintPKPStateParams) { - const superParams: StateParams = { - key: params.key, - debug: params.debug, - onExit: params.onExit, - onEnter: async () => { - const mintingReceipt = - await params.litContracts.pkpNftContractUtils.write.mint(); - params.callback(mintingReceipt.pkp); - await params.onEnter?.(); - }, - }; - - super(superParams); - } -} diff --git a/packages/automation/src/lib/types.ts b/packages/automation/src/lib/types.ts index 60ce375ee1..351aabd1c4 100644 --- a/packages/automation/src/lib/types.ts +++ b/packages/automation/src/lib/types.ts @@ -137,7 +137,6 @@ export interface TransitionDefinition { } export interface BaseStateMachineParams { - capacityToken?: string; context?: Record; debug?: boolean; litContracts: LitContracts; From cdf071518342f588bfa1af91bd13faf5ef95b9b6 Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Fri, 13 Dec 2024 21:48:01 +0100 Subject: [PATCH 19/43] feat: update README.md --- packages/automation/README.md | 339 +++++++++++++++++++++++++++++++++- 1 file changed, 337 insertions(+), 2 deletions(-) diff --git a/packages/automation/README.md b/packages/automation/README.md index f6f64c5793..5111957556 100644 --- a/packages/automation/README.md +++ b/packages/automation/README.md @@ -1,3 +1,338 @@ -# Quick Start +# @lit-protocol/automation -This submodule is used to automate different actions using the Lit Protocol network or other useful events providing listening and responding abilities based on state machines. +A TypeScript library for creating and managing state machines that can automate complex workflows involving the Lit Protocol network, blockchain events, and other triggers. + +## Overview + +The automation package provides a flexible state machine implementation that allows you to: + +- Create automated workflows that respond to blockchain events +- Execute Lit Actions based on custom triggers +- Mint PKPs and Capacity Delegation Tokens +- Monitor token balances and prices +- Bridge tokens across chains automatically using PKPs +- Automate PKP (Programmable Key Pair) operations overall + +## Installation + +```bash +npm install @lit-protocol/automation +# or +yarn add @lit-protocol/automation +``` + +## Core Concepts + +### State Machine +A state machine consists of states, and transitions between those states which are triggered based on a collection of Listeners. + +### States +States represent different phases of your automation. Each state can: +- Execute code when entered and/or exited +- Configure PKPs and Capacity Credits for the machine +- Run Lit Actions +- Send blockchain transactions +- Run custom code + +### Transitions +Transitions define how the machine moves between states. They can be triggered automatically or by any combination of: +- Blockchain events +- Token balance changes +- Timers and intervals +- HTTP requests (polling) +- Custom conditions + +### Listeners +Listeners monitor various events and feed data to transitions: +- EVMBlockListener: Monitors new blocks +- EVMContractEventListener: Monitors EVM smart contract events +- TimerListener: Triggers based on time +- FetchListener: Polls an HTTP endpoint at regular intervals +- IntervalListener: Runs a function at regular intervals + +## Basic Example + +Here's a simple example that mints a PKP, a Capacity Delegation NFT and then runs a Lit Action every hour: + +```typescript +async function runLitActionInterval() { + const stateMachine = StateMachine.fromDefinition({ + privateKey: '0xPRIVATE_KEY_WITH_LIT_TOKENS', + litNodeClient: { + litNetwork: 'datil-test', + }, + litContracts: { + network: 'datil-test', + }, + states: [ + { + key: 'setPKP', + usePkp: { + mint: true, + }, + transitions: [{ toState: 'setCapacityNFT' }], + }, + { + key: 'setCapacityNFT', + useCapacityNFT: { + mint: true, + daysUntilUTCMidnightExpiration: 10, + requestPerSecond: 1, + }, + transitions: [{ toState: 'runLitAction' }], + }, + { + key: 'runLitAction', + litAction: { + code: `(async () => { + if (magicNumber >= 42) { + LitActions.setResponse({ response:"The number is greater than or equal to 42!" }); + } else { + LitActions.setResponse({ response: "The number is less than 42!" }); + } + })();`, + jsParams: { + magicNumber: Math.floor(Math.random() * 100), + }, + }, + transitions: [{ toState: 'cooldown' }], + }, + { + key: 'cooldown', + transitions: [ + { + toState: 'runLitAction', + timer: { + until: 1 * 60 * 60 * 1000, + }, + }, + ], + }, + ], + }); + + // Start the machine at the desired state + await stateMachine.startMachine('setPKP'); +} + +runLitActionInterval().catch(console.error); +``` + +## Functional interface + +There care cases where such a declarative interface won't be enough for your use case. When that happens, the machines can also accept generic states, transitions and listeners where it is possible to write any logic. + + +Here is an example that listens to Ethereum blocks looking one whose numbers ends in 0 + +```typescript +async function monitorEthereumBlocksWithHashEndingWithZero() { + const litNodeClient = new LitNodeClient({ + litNetwork: 'datil-dev' + }); + const litContracts = new LitContracts({ + network: 'datil-dev', + }); + const stateMachine = new StateMachine({ + // When the machine doesn't mint nor use Lit, these values do not matter + privateKey: 'NOT_USED', + litNodeClient, + litContracts, + }); + // const stateMachine = StateMachine.fromDefinition({...}) also works to extend a base definition + + // Add each state individually + stateMachine.addState({ + key: 'listenBlocks', + onEnter: async () => console.log('Waiting for a block with a hash ending in 0'), + onExit: async () => console.log('Found a block whose hash ends in 0!'), + }); + stateMachine.addState({ + key: 'autoAdvancingState', + }); + + // Then add transitions between states + stateMachine.addTransition({ + // Because this transition does not have any listeners, it will be triggered automatically when the machine enters fromState + fromState: 'autoAdvancingState', + toState: 'listenBlocks', + }); + stateMachine.addTransition({ + fromState: 'listenBlocks', + toState: 'autoAdvancingState', + // listeners are the ones that will produce the values that the transition will monitor + listeners: [new EVMBlockListener(LIT_EVM_CHAINS.ethereum.rpcUrls[0])], + // check is the function that will evaluate all values produced by listeners and define if there is a match or not + check: async (values): Promise => { + // values are the results of all listeners + const blockData = values[0] as BlockData; + if (!blockData) return false; + console.log(`New block: ${blockData.number} (${blockData.hash})`); + return blockData.hash.endsWith('0'); + }, + // when check finds a match (returns true) this function gets executed and the machine moves to toState + onMatch: async (values) => { + // values are the results of all listeners + console.log('We have matching values here'); + }, + onMismatch: undefined, // when check returns false (there is a mismatch) this function gets executed but the machine does not change state + onError: undefined, + }); + + await stateMachine.startMachine('listenBlocks'); +} +monitorEthereumBlocksWithHashEndingWithZero().catch(console.error); +``` + +Last machine could have been implemented with just the `listenBlocks` state and a `listenBlocks` -> `listenBlocks` transition, but the machine realizes that the state does not change and therefore does not exit nor enter the state, however it runs the transition `onMatch` function. + +## Context + +Each State Machine has its own information repository called `context`. + +When using the defined states in the declarative interface, some values are already populated and then used later +- `StateDefinition.usePkp` populates `context.activePkp` with the minted PKP data +- `StateDefinition.useCapacityNFT` populates `context.activeCapacityTokenId` with the minted Capacity Token Id +- `StateDefinition.litAction` populates `context.lastLitActionResponse` with the lit action response +- `StateDefinition.transaction` populates `context.lastTransactionReceipt` with the transaction receipt + +Several places in the machine definition can read values from the context. Instead of passing a literal value, pass an object with the `contextPath` property, like in the following example. + +The machine context can be accessed using its `getFromContext`, `setToContext` or `pushToContext` methods to read or write. + +### Advance example + +By leveraging the State Machine context and the ability of Lit PKPs to sign transaction of a variety of chains, it is possible to implement a Token Bridge that composes multiple chains and even offchain interaction if needed among other uses cases. + +In this example, when a State Machine PKP receives USDC in Base Sepolia, it will send the same amount to the sender but in Ethereum Sepolia + +```typescript +async function bridgeBaseSepoliaUSDCToEthereumSepolia() { + const evmSourceNetwork = LIT_EVM_CHAINS.baseSepolia; + const evmDestinationNetwork = LIT_EVM_CHAINS.sepolia; + const pkp = { + tokenId: '0x123...', + publicKey: '456...', + ethAddress: '0x789...', + } as PKPInfo; // Minted Previously + const capacityTokenId = '123456'; // Minted previously + // Because the pkp and the capacity token nft were minted previously, this private key only needs to be an authorized signer of the pkp. It can be empty, without funds of any kind + const ethPrivateKey = '0xTHE_PKP_AUTHORIZED_SIGNER_PRIVATE_KEY'; + + const stateMachine = StateMachine.fromDefinition({ + privateKey: ethPrivateKey, // Used only for authorization here, minting was done previously + context: { + // We can prepopulate the context, for example setting the pkp here instead of using state.usePkp later + // activePkp: pkp, + }, + litNodeClient: { + litNetwork: 'datil', + }, + litContracts: { + network: 'datil', + }, + states: [ + { + key: 'setPKP', + usePkp: { + pkp, // Configure the pkp passed. Not minting a new one + }, + transitions: [{ toState: 'setCapacityNFT' }], + }, + { + key: 'setCapacityNFT', + useCapacityNFT: { + capacityTokenId: capacityTokenId, // Configure the capacity token to use. Not minting a new one + }, + transitions: [{ toState: 'waitForFunds' }], + }, + { + key: 'waitForFunds', + // Waits for our emitting PKP to have some USDC and native balance in destination chain + transitions: [ + { + toState: 'waitForTransfer', + balances: [ + { + address: pkp.ethAddress as Address, + evmChainId: evmDestinationNetwork.chainId, + type: 'native' as const, + comparator: '>=' as const, + amount: '0.001', + }, + { + address: pkp.ethAddress as Address, + evmChainId: evmDestinationNetwork.chainId, + type: 'ERC20' as const, + tokenAddress: USDC_ETH_SEPOLIA_ADDRESS, + tokenDecimals: 6, + comparator: '>=' as const, + amount: '20', + }, + ], + }, + ], + }, + { + key: 'waitForTransfer', + context: { + log: { + atEnter: true, + atExit: true, + }, + }, + transitions: [ + // Waits to receive an USDC transfer in our listening chain + { + toState: 'transferFunds', + evmContractEvent: { + evmChainId: evmSourceNetwork.chainId, + contractAddress: USDC_BASE_SEPOLIA_ADDRESS, + contractABI: USDC_ABI, + eventName: 'Transfer', + // Filter events using params for just listening the pkp.ethAddress as destination + eventParams: [null, pkp.ethAddress], + contextUpdates: [ // The transition can perform some updates to the context + { + contextPath: 'transfer.sender', // The context path to update + dataPath: 'event.args[0]', // The value from the event to save in the context + }, + { + contextPath: 'transfer.amount', + dataPath: 'event.args[2]', + }, + ], + }, + }, + ], + }, + { + key: 'transferFunds', + // Sends a transaction to transfer some USDC in destination chain + transaction: { + evmChainId: evmDestinationNetwork.chainId, + contractAddress: USDC_ETH_SEPOLIA_ADDRESS, + contractABI: [ + 'function transfer(address to, uint256 amount) public returns (bool)', + ], + method: 'transfer', + params: [ + // Params can be hardcoded values such as ['0x123...', '100'] or values from the state machine context + { + contextPath: 'transfer.sender', + }, + { + contextPath: 'transfer.amount', + }, + ], + }, + // Going back to waitForFunds to suspend machine if we need more sepolia eth or sepolia USDC + transitions: [{ toState: 'waitForFunds' }], + }, + ], + }); + + await stateMachine.startMachine('setPKP'); +} +bridgeBaseSepoliaUSDCToEthereumSepolia().catch(console.error); +``` From a829079bab89bbd12dbbbde65b14c8d081a8ed58 Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Fri, 13 Dec 2024 22:18:44 +0100 Subject: [PATCH 20/43] chore: fmt --- packages/automation/README.md | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/automation/README.md b/packages/automation/README.md index 5111957556..16bd99816e 100644 --- a/packages/automation/README.md +++ b/packages/automation/README.md @@ -24,10 +24,13 @@ yarn add @lit-protocol/automation ## Core Concepts ### State Machine + A state machine consists of states, and transitions between those states which are triggered based on a collection of Listeners. ### States + States represent different phases of your automation. Each state can: + - Execute code when entered and/or exited - Configure PKPs and Capacity Credits for the machine - Run Lit Actions @@ -35,7 +38,9 @@ States represent different phases of your automation. Each state can: - Run custom code ### Transitions + Transitions define how the machine moves between states. They can be triggered automatically or by any combination of: + - Blockchain events - Token balance changes - Timers and intervals @@ -43,7 +48,9 @@ Transitions define how the machine moves between states. They can be triggered a - Custom conditions ### Listeners + Listeners monitor various events and feed data to transitions: + - EVMBlockListener: Monitors new blocks - EVMContractEventListener: Monitors EVM smart contract events - TimerListener: Triggers based on time @@ -122,13 +129,12 @@ runLitActionInterval().catch(console.error); There care cases where such a declarative interface won't be enough for your use case. When that happens, the machines can also accept generic states, transitions and listeners where it is possible to write any logic. - Here is an example that listens to Ethereum blocks looking one whose numbers ends in 0 ```typescript async function monitorEthereumBlocksWithHashEndingWithZero() { const litNodeClient = new LitNodeClient({ - litNetwork: 'datil-dev' + litNetwork: 'datil-dev', }); const litContracts = new LitContracts({ network: 'datil-dev', @@ -144,7 +150,8 @@ async function monitorEthereumBlocksWithHashEndingWithZero() { // Add each state individually stateMachine.addState({ key: 'listenBlocks', - onEnter: async () => console.log('Waiting for a block with a hash ending in 0'), + onEnter: async () => + console.log('Waiting for a block with a hash ending in 0'), onExit: async () => console.log('Found a block whose hash ends in 0!'), }); stateMachine.addState({ @@ -191,6 +198,7 @@ Last machine could have been implemented with just the `listenBlocks` state and Each State Machine has its own information repository called `context`. When using the defined states in the declarative interface, some values are already populated and then used later + - `StateDefinition.usePkp` populates `context.activePkp` with the minted PKP data - `StateDefinition.useCapacityNFT` populates `context.activeCapacityTokenId` with the minted Capacity Token Id - `StateDefinition.litAction` populates `context.lastLitActionResponse` with the lit action response @@ -292,7 +300,8 @@ async function bridgeBaseSepoliaUSDCToEthereumSepolia() { eventName: 'Transfer', // Filter events using params for just listening the pkp.ethAddress as destination eventParams: [null, pkp.ethAddress], - contextUpdates: [ // The transition can perform some updates to the context + contextUpdates: [ + // The transition can perform some updates to the context { contextPath: 'transfer.sender', // The context path to update dataPath: 'event.args[0]', // The value from the event to save in the context From c554d2c7a3afb194094ea16056591ae973554d05 Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Mon, 16 Dec 2024 18:30:47 +0100 Subject: [PATCH 21/43] fix: readme timer example --- packages/automation/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/automation/README.md b/packages/automation/README.md index 16bd99816e..22d28ded83 100644 --- a/packages/automation/README.md +++ b/packages/automation/README.md @@ -110,7 +110,9 @@ async function runLitActionInterval() { { toState: 'runLitAction', timer: { - until: 1 * 60 * 60 * 1000, + // One hour, checking every second + until: 1 * 60 * 60, // 3600 times + interval: 1000, // one second }, }, ], From 8ec1b83334d15b1f5be34a4c4b16a4dcd63e49bf Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Mon, 16 Dec 2024 18:32:00 +0100 Subject: [PATCH 22/43] feat: improve comments --- packages/automation/src/lib/state-machine.ts | 28 ++++++++++++++++++- .../src/lib/transitions/transition.ts | 2 +- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/automation/src/lib/state-machine.ts b/packages/automation/src/lib/state-machine.ts index 4f9ff8306e..e1564d29ee 100644 --- a/packages/automation/src/lib/state-machine.ts +++ b/packages/automation/src/lib/state-machine.ts @@ -140,10 +140,34 @@ export class StateMachine { return stateMachine; } + /** + * Indicates if the state machine is running + */ get isRunning() { return this.status === 'running'; } + /** + * Returns an ethers Wallet the state machine can use + */ + get signer() { + if (!this.privateKey) { + throw new AutomationError( + { + info: {}, + }, + `Cannot use state machine signer without a private key. Pass a PK to the machine when creating it` + ); + } + + return new ethers.Wallet( + this.privateKey, + new ethers.providers.JsonRpcProvider( + RPC_URL_BY_NETWORK[this.litNodeClient.config.litNetwork] + ) + ); + } + /** * Adds a custom state to the state machine. * @param params The parameters for the state. @@ -722,7 +746,9 @@ export class StateMachine { ); } if (this.currentState === nextState) { - console.warn(`State ${stateKey} is already active. Skipping transition.`); + console.warn( + `State ${stateKey} is already active. Skipping state change.` + ); return; } diff --git a/packages/automation/src/lib/transitions/transition.ts b/packages/automation/src/lib/transitions/transition.ts index 1868066704..a8c4a23f12 100644 --- a/packages/automation/src/lib/transitions/transition.ts +++ b/packages/automation/src/lib/transitions/transition.ts @@ -10,7 +10,7 @@ type Values = (unknown | undefined)[]; */ export interface BaseTransitionParams { debug?: boolean; - listeners?: Listener[]; // should be unknown but that makes callers to cast listeners + listeners?: Listener[]; // should be unknown but that demands callers to cast listeners to their correct type check?: CheckFn; onMatch: resultFn; onMismatch?: resultFn; From a67756808db6e9a74f7023bcffc314e661e6e697 Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Mon, 16 Dec 2024 18:33:24 +0100 Subject: [PATCH 23/43] feat: improve state machine context methods types --- .../src/lib/context/machine-context.ts | 4 +-- packages/automation/src/lib/state-machine.ts | 30 +++++++++++-------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/packages/automation/src/lib/context/machine-context.ts b/packages/automation/src/lib/context/machine-context.ts index cd695f2bce..e656dcf118 100644 --- a/packages/automation/src/lib/context/machine-context.ts +++ b/packages/automation/src/lib/context/machine-context.ts @@ -30,8 +30,8 @@ export class MachineContext { this.context = initialContext ?? {}; } - public get(path?: string | string[]): unknown { - return getFromObject(this.context, path); + public get(path?: string | string[]): T { + return getFromObject(this.context, path) as T; } public set(path: string | string[], value: unknown = undefined): void { diff --git a/packages/automation/src/lib/state-machine.ts b/packages/automation/src/lib/state-machine.ts index e1564d29ee..ebd0b5c53c 100644 --- a/packages/automation/src/lib/state-machine.ts +++ b/packages/automation/src/lib/state-machine.ts @@ -637,8 +637,21 @@ export class StateMachine { * If value or path do not exist it returns undefined * @param path the context path to read */ - public getFromContext(path?: string | string[]): any { - return this.context.get(path); + public getFromContext(path?: string | string[]): T { + return this.context.get(path); + } + + /** + * Resolves a value from the context if it specifies a path or returns it as a literal + * @param value the literal value or read context object + */ + public resolveContextPathOrLiteral( + value: ContextOrLiteral | T + ): T { + if (value && typeof value === 'object' && 'contextPath' in value) { + return this.context.get(value.contextPath); + } + return value; } /** @@ -647,7 +660,7 @@ export class StateMachine { * @param path the context path to write * @param value the value to write in the context path */ - public setToContext(path: string | string[], value: any): void { + public setToContext(path: string | string[], value: unknown): void { this.context.set(path, value); } @@ -657,7 +670,7 @@ export class StateMachine { * @param path the context path to write * @param value the value to write in the context path */ - public pushToContext(path: string | string[], value: any): void { + public pushToContext(path: string | string[], value: unknown): void { this.context.push(path, value); } @@ -674,15 +687,6 @@ export class StateMachine { this.debug && console.log('State machine stopped'); } - private resolveContextPathOrLiteral( - value: ContextOrLiteral | T - ): T { - if (value && typeof value === 'object' && 'contextPath' in value) { - return this.context.get(value.contextPath) as T; - } - return value; - } - /** * Stops listening on the current state's transitions and exits the current state. */ From 00435e94b8876f4107420f2f501da81380b5daae Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Mon, 16 Dec 2024 18:39:25 +0100 Subject: [PATCH 24/43] fix: replace jsParams anys for unknowns --- packages/automation/src/lib/litActions.ts | 2 +- packages/automation/src/lib/types.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/automation/src/lib/litActions.ts b/packages/automation/src/lib/litActions.ts index 9576af761f..10546e7559 100644 --- a/packages/automation/src/lib/litActions.ts +++ b/packages/automation/src/lib/litActions.ts @@ -23,7 +23,7 @@ interface ExecuteLitAction { authSigner: ethers.Wallet; ipfsId?: string; code?: string; - jsParams?: Record; + jsParams?: Record; } const ONE_MINUTE = 1 * 60 * 1000; diff --git a/packages/automation/src/lib/types.ts b/packages/automation/src/lib/types.ts index 351aabd1c4..33484328b9 100644 --- a/packages/automation/src/lib/types.ts +++ b/packages/automation/src/lib/types.ts @@ -35,7 +35,7 @@ export interface UpdatesContext { interface LitActionStateDefinition { code?: ContextOrLiteral; ipfsId?: ContextOrLiteral; - jsParams?: Record; + jsParams?: Record; } export interface ContextStateDefinition { @@ -125,7 +125,7 @@ export interface EvmContractEventTransitionDefinition contractABI: ethers.ContractInterface; contractAddress: Address; eventName: string; - eventParams?: any; + eventParams?: any[]; } export interface TransitionDefinition { From 27284143f7ede6ef185af58faa9bcadfe4206017 Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Mon, 16 Dec 2024 18:45:27 +0100 Subject: [PATCH 25/43] feat: refactor state functions into Actions --- packages/automation/src/lib/actions/action.ts | 20 ++ packages/automation/src/lib/actions/index.ts | 6 + .../automation/src/lib/actions/lit-action.ts | 65 +++++ .../automation/src/lib/actions/log-context.ts | 24 ++ .../src/lib/actions/mint-capacity-credit.ts | 29 +++ .../automation/src/lib/actions/mint-pkp.ts | 24 ++ .../automation/src/lib/actions/transaction.ts | 128 ++++++++++ packages/automation/src/lib/state-machine.ts | 229 +++++------------- packages/automation/src/lib/types.ts | 3 +- 9 files changed, 353 insertions(+), 175 deletions(-) create mode 100644 packages/automation/src/lib/actions/action.ts create mode 100644 packages/automation/src/lib/actions/index.ts create mode 100644 packages/automation/src/lib/actions/lit-action.ts create mode 100644 packages/automation/src/lib/actions/log-context.ts create mode 100644 packages/automation/src/lib/actions/mint-capacity-credit.ts create mode 100644 packages/automation/src/lib/actions/mint-pkp.ts create mode 100644 packages/automation/src/lib/actions/transaction.ts diff --git a/packages/automation/src/lib/actions/action.ts b/packages/automation/src/lib/actions/action.ts new file mode 100644 index 0000000000..a1ecdff095 --- /dev/null +++ b/packages/automation/src/lib/actions/action.ts @@ -0,0 +1,20 @@ +import { voidAsyncFunction } from '../types'; + +export interface ActionParams { + debug?: boolean; + function: voidAsyncFunction; +} + +export class Action { + protected readonly debug; + private readonly function: voidAsyncFunction; + + constructor(params: ActionParams) { + this.debug = params.debug; + this.function = params.function; + } + + async run() { + return this.function(); + } +} diff --git a/packages/automation/src/lib/actions/index.ts b/packages/automation/src/lib/actions/index.ts new file mode 100644 index 0000000000..4e9c11b2eb --- /dev/null +++ b/packages/automation/src/lib/actions/index.ts @@ -0,0 +1,6 @@ +export * from './action'; +export * from './lit-action'; +export * from './log-context'; +export * from './mint-capacity-credit'; +export * from './mint-pkp'; +export * from './transaction'; diff --git a/packages/automation/src/lib/actions/lit-action.ts b/packages/automation/src/lib/actions/lit-action.ts new file mode 100644 index 0000000000..b5db207fcd --- /dev/null +++ b/packages/automation/src/lib/actions/lit-action.ts @@ -0,0 +1,65 @@ +import { AutomationError } from '@lit-protocol/constants'; + +import { Action } from './action'; +import { executeLitAction } from '../litActions'; +import { StateMachine } from '../state-machine'; +import { ContextOrLiteral, PKPInfo } from '../types'; + +interface LitActionActionParams { + debug?: boolean; + stateMachine: StateMachine; + code?: ContextOrLiteral; + ipfsId?: ContextOrLiteral; + jsParams?: Record; +} + +export class LitActionAction extends Action { + constructor(params: LitActionActionParams) { + const litActionFunction = async () => { + const activePkp = params.stateMachine.resolveContextPathOrLiteral({ + contextPath: 'activePkp', + }) as unknown as PKPInfo; + if (!activePkp) { + throw new AutomationError( + { + info: { + machineId: params.stateMachine.id, + activePkp, + }, + }, + `There is no active pkp. Must configure it to run a Lit Action` + ); + } + + const litActionResponse = await executeLitAction({ + litNodeClient: params.stateMachine.litNodeClient, + capacityTokenId: params.stateMachine.resolveContextPathOrLiteral({ + contextPath: 'activeCapacityTokenId', + }) as unknown as string, + pkpEthAddress: activePkp.ethAddress, + pkpPublicKey: activePkp.publicKey, + authSigner: params.stateMachine.signer, + ipfsId: + 'ipfsId' in params + ? params.stateMachine.resolveContextPathOrLiteral(params.ipfsId) + : undefined, + code: + 'code' in params + ? params.stateMachine.resolveContextPathOrLiteral(params.code) + : undefined, + jsParams: params.jsParams, + }); + + // TODO send user this result with a webhook and log + params.stateMachine.setToContext( + 'lastLitActionResponse', + litActionResponse + ); + }; + + super({ + debug: params.debug, + function: litActionFunction, + }); + } +} diff --git a/packages/automation/src/lib/actions/log-context.ts b/packages/automation/src/lib/actions/log-context.ts new file mode 100644 index 0000000000..4196d3e74c --- /dev/null +++ b/packages/automation/src/lib/actions/log-context.ts @@ -0,0 +1,24 @@ +import { Action } from './action'; +import { StateMachine } from '../state-machine'; + +interface LogContextActionParams { + debug?: boolean; + stateMachine: StateMachine; + path?: string; +} + +export class LogContextAction extends Action { + constructor(params: LogContextActionParams) { + const logContextFunction = async () => { + console.log( + `State Machine context: `, + params.stateMachine.getFromContext(params.path) + ); + }; + + super({ + debug: params.debug, + function: logContextFunction, + }); + } +} diff --git a/packages/automation/src/lib/actions/mint-capacity-credit.ts b/packages/automation/src/lib/actions/mint-capacity-credit.ts new file mode 100644 index 0000000000..827900095a --- /dev/null +++ b/packages/automation/src/lib/actions/mint-capacity-credit.ts @@ -0,0 +1,29 @@ +import { Action } from './action'; +import { StateMachine } from '../state-machine'; + +interface MintPkpActionParams { + debug?: boolean; + stateMachine: StateMachine; + daysUntilUTCMidnightExpiration: number; + requestPerSecond: number; +} + +export class MintCapacityCreditAction extends Action { + constructor(params: MintPkpActionParams) { + const mintPkpFunction = async () => { + const capacityCreditNFT = + await params.stateMachine.litContracts.mintCapacityCreditsNFT({ + requestsPerSecond: params.requestPerSecond, + daysUntilUTCMidnightExpiration: params.daysUntilUTCMidnightExpiration, + }); + const capacityTokeId = capacityCreditNFT.capacityTokenIdStr; + params.debug && console.log(`Minted PKP: ${capacityTokeId}`); + params.stateMachine.setToContext(`activeCapacityTokenId`, capacityTokeId); + }; + + super({ + debug: params.debug, + function: mintPkpFunction, + }); + } +} diff --git a/packages/automation/src/lib/actions/mint-pkp.ts b/packages/automation/src/lib/actions/mint-pkp.ts new file mode 100644 index 0000000000..353b6f79c7 --- /dev/null +++ b/packages/automation/src/lib/actions/mint-pkp.ts @@ -0,0 +1,24 @@ +import { Action } from './action'; +import { StateMachine } from '../state-machine'; + +interface MintPkpActionParams { + debug?: boolean; + stateMachine: StateMachine; +} + +export class MintPkpAction extends Action { + constructor(params: MintPkpActionParams) { + const mintPkpFunction = async () => { + const mintingReceipt = + await params.stateMachine.litContracts.pkpNftContractUtils.write.mint(); + const pkp = mintingReceipt.pkp; + params.debug && console.log(`Minted PKP: ${pkp}`); + params.stateMachine.setToContext('activePkp', pkp); + }; + + super({ + debug: params.debug, + function: mintPkpFunction, + }); + } +} diff --git a/packages/automation/src/lib/actions/transaction.ts b/packages/automation/src/lib/actions/transaction.ts new file mode 100644 index 0000000000..1d8694c735 --- /dev/null +++ b/packages/automation/src/lib/actions/transaction.ts @@ -0,0 +1,128 @@ +import { ethers } from 'ethers'; + +import { AutomationError } from '@lit-protocol/constants'; + +import { Action } from './action'; +import { executeLitAction, signWithLitActionCode } from '../litActions'; +import { StateMachine } from '../state-machine'; +import { Address, ContextOrLiteral, PKPInfo } from '../types'; +import { getEvmChain } from '../utils/chain'; + +interface TransactionActionParams { + debug?: boolean; + stateMachine: StateMachine; + evmChainId: ContextOrLiteral; + contractABI: ethers.ContractInterface; + contractAddress: ContextOrLiteral
; + method: ContextOrLiteral; + params?: ContextOrLiteral[]; + value?: ContextOrLiteral; +} + +export class TransactionAction extends Action { + constructor(params: TransactionActionParams) { + const litActionFunction = async () => { + const activePkp = params.stateMachine.resolveContextPathOrLiteral({ + contextPath: 'activePkp', + }) as unknown as PKPInfo; + if (!activePkp.ethAddress) { + throw new AutomationError( + { + info: { + machineId: params.stateMachine.id, + activePkp, + }, + }, + `There is no active pkp. Must configure it to run a transaction` + ); + } + + const yellowstoneMachineSigner = params.stateMachine.signer; + + const chainId = params.stateMachine.resolveContextPathOrLiteral( + params.evmChainId + ); + const chain = getEvmChain(chainId); + const chainProvider = new ethers.providers.JsonRpcProvider( + chain.rpcUrls[0], + chain.chainId + ); + + const contract = new ethers.Contract( + params.stateMachine.resolveContextPathOrLiteral(params.contractAddress), + params.contractABI, + chainProvider + ); + + const txParams = (params.params || []).map( + params.stateMachine.resolveContextPathOrLiteral.bind( + params.stateMachine + ) + ); + const txMethod = params.stateMachine.resolveContextPathOrLiteral( + params.method + ); + const txData = await contract.populateTransaction[txMethod](...txParams); + const gasLimit = await chainProvider.estimateGas({ + to: params.stateMachine.resolveContextPathOrLiteral( + params.contractAddress + ), + data: txData.data, + from: activePkp.ethAddress, + }); + const gasPrice = await chainProvider.getGasPrice(); + const nonce = await chainProvider.getTransactionCount( + activePkp.ethAddress + ); + + const rawTx = { + chainId: chain.chainId, + data: txData.data, + gasLimit: gasLimit.toHexString(), + gasPrice: gasPrice.toHexString(), + nonce, + to: params.stateMachine.resolveContextPathOrLiteral( + params.contractAddress + ), + }; + const rawTxHash = ethers.utils.keccak256( + ethers.utils.serializeTransaction(rawTx) + ); + + // Sign with the PKP in a LitAction + const litActionResponse = await executeLitAction({ + litNodeClient: params.stateMachine.litNodeClient, + capacityTokenId: params.stateMachine.resolveContextPathOrLiteral({ + contextPath: 'activeCapacityTokenId', + }) as unknown as string, + pkpEthAddress: activePkp.ethAddress, + pkpPublicKey: activePkp.publicKey, + authSigner: yellowstoneMachineSigner, + code: signWithLitActionCode, + jsParams: { + toSign: ethers.utils.arrayify(rawTxHash), + publicKey: activePkp.publicKey, + sigName: 'signedTransaction', + }, + }); + + const signature = litActionResponse.response as string; + const jsonSignature = JSON.parse(signature); + jsonSignature.r = '0x' + jsonSignature.r.substring(2); + jsonSignature.s = '0x' + jsonSignature.s; + const hexSignature = ethers.utils.joinSignature(jsonSignature); + + const signedTx = ethers.utils.serializeTransaction(rawTx, hexSignature); + + const receipt = await chainProvider.sendTransaction(signedTx); + + // TODO send user this result with a webhook and log + params.stateMachine.setToContext('lastTransactionReceipt', receipt); + }; + + super({ + debug: params.debug, + function: litActionFunction, + }); + } +} diff --git a/packages/automation/src/lib/state-machine.ts b/packages/automation/src/lib/state-machine.ts index ebd0b5c53c..ad67876124 100644 --- a/packages/automation/src/lib/state-machine.ts +++ b/packages/automation/src/lib/state-machine.ts @@ -3,11 +3,20 @@ import { ethers } from 'ethers'; import { AutomationError, UnknownError, - LIT_RPC, + RPC_URL_BY_NETWORK, } from '@lit-protocol/constants'; import { LitContracts } from '@lit-protocol/contracts-sdk'; import { LitNodeClient } from '@lit-protocol/lit-node-client'; +import { + Action, + LitActionAction, + LogContextAction, + MintCapacityCreditAction, + MintPkpAction, + TransactionAction, +} from './actions'; +import { MachineContext } from './context/machine-context'; import { ContractEventData, EVMContractEventListener, @@ -15,22 +24,18 @@ import { Listener, TimerListener, } from './listeners'; -import { signWithLitActionCode, executeLitAction } from './litActions'; import { State, StateParams } from './states'; import { CheckFn, Transition } from './transitions'; -import { getEvmChain } from './utils/chain'; -import { getBalanceTransitionCheck, getERC20Balance } from './utils/erc20'; - import { BaseStateMachineParams, ContextOrLiteral, - PKPInfo, StateDefinition, StateMachineDefinition, TransitionDefinition, TransitionParams, } from './types'; -import { MachineContext } from './context/machine-context'; +import { getEvmChain } from './utils/chain'; +import { getBalanceTransitionCheck, getERC20Balance } from './utils/erc20'; export type MachineStatus = 'running' | 'stopped'; @@ -47,9 +52,9 @@ export class StateMachine { private readonly onError?: (error: unknown, context?: string) => void; private context: MachineContext; - private litNodeClient: LitNodeClient; - private litContracts: LitContracts; - private privateKey?: string; + public readonly litNodeClient: LitNodeClient; + private readonly privateKey?: string; + public litContracts: LitContracts; public id: string; public status: MachineStatus = 'stopped'; @@ -190,12 +195,11 @@ export class StateMachine { debug: this.debug, }; - const onEnterFunctions = [] as (() => Promise)[]; - const onExitFunctions = [] as (() => Promise)[]; + const onEnterActions = [] as Action[]; + const onExitActions = [] as Action[]; const { context: contextAction, - key, litAction, transaction, useCapacityNFT, @@ -204,159 +208,41 @@ export class StateMachine { if (contextAction) { if (contextAction.log?.atEnter) { - onEnterFunctions.push(async () => { - console.log( - `MachineContext at state ${key} enter: `, - this.context.get(contextAction.log?.path) - ); - }); + onEnterActions.push( + new LogContextAction({ + debug: this.debug, + stateMachine: this, + path: contextAction.log?.path, + }) + ); } if (contextAction.log?.atExit) { - onExitFunctions.push(async () => { - console.log( - `MachineContext at state ${key} exit: `, - this.context.get(contextAction.log?.path) - ); - }); + onExitActions.push( + new LogContextAction({ + debug: this.debug, + stateMachine: this, + path: contextAction.log?.path, + }) + ); } } if (litAction) { - onEnterFunctions.push(async () => { - const activePkp = this.resolveContextPathOrLiteral({ - contextPath: 'activePkp', - }) as unknown as PKPInfo; - if (!activePkp) { - throw new AutomationError( - { - info: { - machineId: this.id, - activePkp, - }, - }, - `There is no active pkp. Must configure it to run a Lit Action` - ); - } - - const signer = new ethers.Wallet( - this.privateKey!, - new ethers.providers.JsonRpcProvider(LIT_RPC.CHRONICLE_YELLOWSTONE) - ); - - const litActionResponse = await executeLitAction({ - litNodeClient: this.litNodeClient, - capacityTokenId: this.resolveContextPathOrLiteral({ - contextPath: 'activeCapacityTokenId', - }) as unknown as string, - pkpEthAddress: activePkp.ethAddress, - pkpPublicKey: activePkp.publicKey, - authSigner: signer, - ipfsId: this.resolveContextPathOrLiteral(litAction.ipfsId), - code: this.resolveContextPathOrLiteral(litAction.code), - jsParams: litAction.jsParams, - }); - - // TODO send user this result with a webhook and log - this.context.set('lastLitActionResponse', litActionResponse); + const litActionAction = new LitActionAction({ + debug: this.debug, + stateMachine: this, + ...litAction, }); + onEnterActions.push(litActionAction); } if (transaction) { - onEnterFunctions.push(async () => { - const activePkp = this.resolveContextPathOrLiteral({ - contextPath: 'activePkp', - }) as unknown as PKPInfo; - if (!activePkp.ethAddress) { - throw new AutomationError( - { - info: { - machineId: this.id, - activePkp, - }, - }, - `There is no active pkp. Must configure it to run a transaction` - ); - } - - const yellowstoneMachineSigner = new ethers.Wallet( - this.privateKey!, - new ethers.providers.JsonRpcProvider(LIT_RPC.CHRONICLE_YELLOWSTONE) - ); - - const chainId = this.resolveContextPathOrLiteral( - transaction.evmChainId - ); - const chain = getEvmChain(chainId); - const chainProvider = new ethers.providers.JsonRpcProvider( - chain.rpcUrls[0], - chain.chainId - ); - - const contract = new ethers.Contract( - this.resolveContextPathOrLiteral(transaction.contractAddress), - transaction.contractABI, - chainProvider - ); - - const txParams = (transaction.params || []).map( - this.resolveContextPathOrLiteral.bind(this) - ); - const txMethod = this.resolveContextPathOrLiteral(transaction.method); - const txData = await contract.populateTransaction[txMethod]( - ...txParams - ); - const gasLimit = await chainProvider.estimateGas({ - to: this.resolveContextPathOrLiteral(transaction.contractAddress), - data: txData.data, - from: activePkp.ethAddress, - }); - const gasPrice = await chainProvider.getGasPrice(); - const nonce = await chainProvider.getTransactionCount( - activePkp.ethAddress - ); - - const rawTx = { - chainId: chain.chainId, - data: txData.data, - gasLimit: gasLimit.toHexString(), - gasPrice: gasPrice.toHexString(), - nonce, - to: this.resolveContextPathOrLiteral(transaction.contractAddress), - }; - const rawTxHash = ethers.utils.keccak256( - ethers.utils.serializeTransaction(rawTx) - ); - - // Sign with the PKP in a LitAction - const litActionResponse = await executeLitAction({ - litNodeClient: this.litNodeClient, - capacityTokenId: this.resolveContextPathOrLiteral({ - contextPath: 'activeCapacityTokenId', - }) as unknown as string, - pkpEthAddress: activePkp.ethAddress, - pkpPublicKey: activePkp.publicKey, - authSigner: yellowstoneMachineSigner, - code: signWithLitActionCode, - jsParams: { - toSign: ethers.utils.arrayify(rawTxHash), - publicKey: activePkp.publicKey, - sigName: 'signedTransaction', - }, - }); - - const signature = litActionResponse.response as string; - const jsonSignature = JSON.parse(signature); - jsonSignature.r = '0x' + jsonSignature.r.substring(2); - jsonSignature.s = '0x' + jsonSignature.s; - const hexSignature = ethers.utils.joinSignature(jsonSignature); - - const signedTx = ethers.utils.serializeTransaction(rawTx, hexSignature); - - const receipt = await chainProvider.sendTransaction(signedTx); - - // TODO send user this result with a webhook and log - this.context.set('lastTransactionReceipt', receipt); + const transactionAction = new TransactionAction({ + debug: this.debug, + stateMachine: this, + ...transaction, }); + onEnterActions.push(transactionAction); } if (usePkp) { @@ -366,13 +252,11 @@ export class StateMachine { this.resolveContextPathOrLiteral(usePkp.pkp) ); } else if ('mint' in usePkp) { - onEnterFunctions.push(async () => { - const mintingReceipt = - await this.litContracts!.pkpNftContractUtils.write.mint(); - const pkp = mintingReceipt.pkp; - this.debug && console.log(`Minted PKP: ${pkp}`); - this.context.set('activePkp', pkp); + const mintPkpAction = new MintPkpAction({ + debug: this.debug, + stateMachine: this, }); + onEnterActions.push(mintPkpAction); } if (this.debug) { const activePkp = this.context.get('activePkp'); @@ -387,17 +271,14 @@ export class StateMachine { this.resolveContextPathOrLiteral(useCapacityNFT.capacityTokenId) ); } else if ('mint' in useCapacityNFT) { - onEnterFunctions.push(async () => { - const capacityCreditNFT = - await this.litContracts.mintCapacityCreditsNFT({ - requestsPerSecond: useCapacityNFT.requestPerSecond, - daysUntilUTCMidnightExpiration: - useCapacityNFT.daysUntilUTCMidnightExpiration, - }); - const capacityTokeId = capacityCreditNFT.capacityTokenIdStr; - this.debug && console.log(`Minted PKP: ${capacityTokeId}`); - this.context.set(`activeCapacityTokenId`, capacityTokeId); + const mintCapacityCreditAction = new MintCapacityCreditAction({ + daysUntilUTCMidnightExpiration: + useCapacityNFT.daysUntilUTCMidnightExpiration, + debug: this.debug, + requestPerSecond: useCapacityNFT.requestPerSecond, + stateMachine: this, }); + onEnterActions.push(mintCapacityCreditAction); } if (this.debug) { const activeCapacityTokenId = this.context.get('activePkp'); @@ -407,12 +288,12 @@ export class StateMachine { } } - // Merge all state functions + // Merge all state actions stateParams.onEnter = async () => { - await Promise.all(onEnterFunctions.map((onEnter) => onEnter())); + await Promise.all(onEnterActions.map((action) => action.run())); }; stateParams.onExit = async () => { - await Promise.all(onExitFunctions.map((onExit) => onExit())); + await Promise.all(onExitActions.map((action) => action.run())); }; this.addState(stateParams); diff --git a/packages/automation/src/lib/types.ts b/packages/automation/src/lib/types.ts index 33484328b9..586f86d23e 100644 --- a/packages/automation/src/lib/types.ts +++ b/packages/automation/src/lib/types.ts @@ -6,6 +6,7 @@ import { LitNodeClient } from '@lit-protocol/lit-node-client'; import { BaseTransitionParams } from './transitions'; export type Address = `0x${string}`; +export type voidAsyncFunction = () => void; export type onError = (error: unknown) => void; export interface PKPInfo { @@ -32,7 +33,7 @@ export interface UpdatesContext { contextUpdates: ContextUpdate[]; } -interface LitActionStateDefinition { +export interface LitActionStateDefinition { code?: ContextOrLiteral; ipfsId?: ContextOrLiteral; jsParams?: Record; From a9ee7ae325eec5ba679881bebec5182d6fa3fae2 Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Mon, 16 Dec 2024 21:15:12 +0100 Subject: [PATCH 26/43] feat: update readme with new Actions concept, removed from State --- packages/automation/README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/automation/README.md b/packages/automation/README.md index 22d28ded83..33db32198e 100644 --- a/packages/automation/README.md +++ b/packages/automation/README.md @@ -27,9 +27,9 @@ yarn add @lit-protocol/automation A state machine consists of states, and transitions between those states which are triggered based on a collection of Listeners. -### States +### Actions -States represent different phases of your automation. Each state can: +Actions are the different tasks the state machine will do. Each action can: - Execute code when entered and/or exited - Configure PKPs and Capacity Credits for the machine @@ -37,6 +37,10 @@ States represent different phases of your automation. Each state can: - Send blockchain transactions - Run custom code +### States + +States represent different states the machine will stand and move between. Each state can have a combination of Actions to perform when entering or exiting itself. + ### Transitions Transitions define how the machine moves between states. They can be triggered automatically or by any combination of: From 0b55dd431977da6249f2bd8d5b93af4731a88dde Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Mon, 16 Dec 2024 22:10:51 +0100 Subject: [PATCH 27/43] fix: remove some anys in types --- packages/automation/src/lib/types.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/automation/src/lib/types.ts b/packages/automation/src/lib/types.ts index 586f86d23e..bf911a59e9 100644 --- a/packages/automation/src/lib/types.ts +++ b/packages/automation/src/lib/types.ts @@ -51,7 +51,7 @@ export interface TransactionStateDefinition extends OnEvmChain { contractABI: ethers.ContractInterface; contractAddress: ContextOrLiteral
; method: ContextOrLiteral; - params?: ContextOrLiteral[]; + params?: ContextOrLiteral[]; value?: ContextOrLiteral; } @@ -126,7 +126,7 @@ export interface EvmContractEventTransitionDefinition contractABI: ethers.ContractInterface; contractAddress: Address; eventName: string; - eventParams?: any[]; + eventParams?: unknown[]; } export interface TransitionDefinition { @@ -138,7 +138,7 @@ export interface TransitionDefinition { } export interface BaseStateMachineParams { - context?: Record; + context?: Record; debug?: boolean; litContracts: LitContracts; litNodeClient: LitNodeClient; From 0e6ac5c67cb837218e1c280a83da09356fa808b4 Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Tue, 17 Dec 2024 13:35:35 +0100 Subject: [PATCH 28/43] feat: replace states function properties with a generic actions array --- packages/automation/src/lib/state-machine.ts | 172 +++++++++---------- packages/automation/src/lib/types.ts | 68 +++++--- 2 files changed, 126 insertions(+), 114 deletions(-) diff --git a/packages/automation/src/lib/state-machine.ts b/packages/automation/src/lib/state-machine.ts index ad67876124..93daf3762b 100644 --- a/packages/automation/src/lib/state-machine.ts +++ b/packages/automation/src/lib/state-machine.ts @@ -198,95 +198,91 @@ export class StateMachine { const onEnterActions = [] as Action[]; const onExitActions = [] as Action[]; - const { - context: contextAction, - litAction, - transaction, - useCapacityNFT, - usePkp, - } = stateDefinition; - - if (contextAction) { - if (contextAction.log?.atEnter) { - onEnterActions.push( - new LogContextAction({ - debug: this.debug, - stateMachine: this, - path: contextAction.log?.path, - }) - ); - } - if (contextAction.log?.atExit) { - onExitActions.push( - new LogContextAction({ - debug: this.debug, - stateMachine: this, - path: contextAction.log?.path, - }) - ); - } - } - - if (litAction) { - const litActionAction = new LitActionAction({ - debug: this.debug, - stateMachine: this, - ...litAction, - }); - onEnterActions.push(litActionAction); - } - - if (transaction) { - const transactionAction = new TransactionAction({ - debug: this.debug, - stateMachine: this, - ...transaction, - }); - onEnterActions.push(transactionAction); - } - - if (usePkp) { - if ('pkp' in usePkp) { - this.context.set( - 'activePkp', - this.resolveContextPathOrLiteral(usePkp.pkp) - ); - } else if ('mint' in usePkp) { - const mintPkpAction = new MintPkpAction({ - debug: this.debug, - stateMachine: this, - }); - onEnterActions.push(mintPkpAction); - } - if (this.debug) { - const activePkp = this.context.get('activePkp'); - console.log(`Machine configured to use pkp ${activePkp}`); - } - } - - if (useCapacityNFT) { - if ('capacityTokenId' in useCapacityNFT) { - this.context.set( - 'activeCapacityTokenId', - this.resolveContextPathOrLiteral(useCapacityNFT.capacityTokenId) - ); - } else if ('mint' in useCapacityNFT) { - const mintCapacityCreditAction = new MintCapacityCreditAction({ - daysUntilUTCMidnightExpiration: - useCapacityNFT.daysUntilUTCMidnightExpiration, - debug: this.debug, - requestPerSecond: useCapacityNFT.requestPerSecond, - stateMachine: this, - }); - onEnterActions.push(mintCapacityCreditAction); - } - if (this.debug) { - const activeCapacityTokenId = this.context.get('activePkp'); - console.log( - `Machine configured to use capacity token ${activeCapacityTokenId}` - ); + const { actions = [] } = stateDefinition; + + actions.forEach((action) => { + switch (action.key) { + case 'context': + if (action.log?.path) { + onEnterActions.push( + new LogContextAction({ + debug: this.debug, + stateMachine: this, + path: action.log.path, + }) + ); + } + break; + case 'litAction': + onEnterActions.push( + new LitActionAction({ + debug: this.debug, + stateMachine: this, + ...action, + }) + ); + break; + case 'transaction': + onEnterActions.push( + new TransactionAction({ + debug: this.debug, + stateMachine: this, + ...action, + }) + ); + break; + case 'useCapacityNFT': + if ('capacityTokenId' in action) { + this.context.set( + 'activeCapacityTokenId', + this.resolveContextPathOrLiteral(action.capacityTokenId) + ); + } else if ('mint' in action) { + const mintCapacityCreditAction = new MintCapacityCreditAction({ + daysUntilUTCMidnightExpiration: + action.daysUntilUTCMidnightExpiration, + debug: this.debug, + requestPerSecond: action.requestPerSecond, + stateMachine: this, + }); + onEnterActions.push(mintCapacityCreditAction); + } + if (this.debug) { + const activeCapacityTokenId = this.context.get('activePkp'); + console.log( + `Machine configured to use capacity token ${activeCapacityTokenId}` + ); + } + break; + case 'usePkp': + if ('pkp' in action) { + this.context.set( + 'activePkp', + this.resolveContextPathOrLiteral(action.pkp) + ); + } else if ('mint' in action) { + const mintPkpAction = new MintPkpAction({ + debug: this.debug, + stateMachine: this, + }); + onEnterActions.push(mintPkpAction); + } + if (this.debug) { + const activePkp = this.context.get('activePkp'); + console.log(`Machine configured to use pkp ${activePkp}`); + } + break; + default: + throw new AutomationError( + { + info: { + action, + }, + }, + `Unknown action. Check error info.` + ); } - } + }); // Merge all state actions stateParams.onEnter = async () => { diff --git a/packages/automation/src/lib/types.ts b/packages/automation/src/lib/types.ts index bf911a59e9..2502335d9b 100644 --- a/packages/automation/src/lib/types.ts +++ b/packages/automation/src/lib/types.ts @@ -23,6 +23,7 @@ export interface ContextAccess { contextPath: string; } +// Context Types export type ContextOrLiteral = T | ContextAccess; interface ContextUpdate extends ContextAccess { @@ -33,21 +34,23 @@ export interface UpdatesContext { contextUpdates: ContextUpdate[]; } -export interface LitActionStateDefinition { +// Action Types +export interface LitActionActionDefinition { + key: 'litAction'; code?: ContextOrLiteral; ipfsId?: ContextOrLiteral; jsParams?: Record; } -export interface ContextStateDefinition { +export interface ContextActionDefinition { + key: 'context'; log?: { - atEnter?: boolean; - atExit?: boolean; - path?: string; + path: string; }; } -export interface TransactionStateDefinition extends OnEvmChain { +export interface TransactionActionDefinition extends OnEvmChain { + key: 'transaction'; contractABI: ethers.ContractInterface; contractAddress: ContextOrLiteral
; method: ContextOrLiteral; @@ -55,35 +58,47 @@ export interface TransactionStateDefinition extends OnEvmChain { value?: ContextOrLiteral; } -export interface MintStateDefinition { - mint: boolean; +export interface MintActionDefinition { + mint: true; } -export interface usePkpStateDefinition { - pkp: ContextOrLiteral; +export interface MintPkpActionDefinition extends MintActionDefinition { + key: 'usePkp'; } -export interface mintCapacityNFTStateDefinition extends MintStateDefinition { +export interface MintCapacityNFTActionDefinition extends MintActionDefinition { + key: 'useCapacityNFT'; daysUntilUTCMidnightExpiration: number; requestPerSecond: number; } -export interface useCapacityNFTStateDefinition { +export interface UsePkpActionDefinition { + key: 'usePkp'; + pkp: ContextOrLiteral; +} + +export interface UseCapacityNFTActionDefinition { + key: 'useCapacityNFT'; capacityTokenId: ContextOrLiteral; } +export type ActionDefinition = + | ContextActionDefinition + | LitActionActionDefinition + | MintCapacityNFTActionDefinition + | MintPkpActionDefinition + | TransactionActionDefinition + | UseCapacityNFTActionDefinition + | UsePkpActionDefinition; + +// State Types export interface StateDefinition { - context?: ContextStateDefinition; key: string; - litAction?: LitActionStateDefinition; - transaction?: TransactionStateDefinition; + actions?: ActionDefinition[]; transitions?: Omit[]; - useCapacityNFT?: - | useCapacityNFTStateDefinition - | mintCapacityNFTStateDefinition; - usePkp?: usePkpStateDefinition | MintStateDefinition; } +// Transition Types export interface IntervalTransitionDefinition { interval?: number; } @@ -137,6 +152,14 @@ export interface TransitionDefinition { toState: string; } +export interface TransitionParams + extends Omit, + Partial> { + fromState: string; + toState: string; +} + +// Machine Types export interface BaseStateMachineParams { context?: Record; debug?: boolean; @@ -153,10 +176,3 @@ export interface StateMachineDefinition states: StateDefinition[]; transitions?: TransitionDefinition[]; } - -export interface TransitionParams - extends Omit, - Partial> { - fromState: string; - toState: string; -} From ae7cade70ef0a79cfbecc72961a1147f8fca301d Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Tue, 17 Dec 2024 13:46:07 +0100 Subject: [PATCH 29/43] feat: update readme with actions in states --- packages/automation/README.md | 120 ++++++++++++++++++++-------------- 1 file changed, 71 insertions(+), 49 deletions(-) diff --git a/packages/automation/README.md b/packages/automation/README.md index 33db32198e..1c6bad248a 100644 --- a/packages/automation/README.md +++ b/packages/automation/README.md @@ -39,7 +39,7 @@ Actions are the different tasks the state machine will do. Each action can: ### States -States represent different states the machine will stand and move between. Each state can have a combination of Actions to perform when entering or exiting itself. +States represent different states the machine will stand and move between. Each state can have an array of Actions to perform when entering or exiting itself. ### Transitions @@ -78,34 +78,43 @@ async function runLitActionInterval() { states: [ { key: 'setPKP', - usePkp: { - mint: true, - }, + actions: [ + { + key: 'usePkp', + mint: true, + }, + ], transitions: [{ toState: 'setCapacityNFT' }], }, { key: 'setCapacityNFT', - useCapacityNFT: { - mint: true, - daysUntilUTCMidnightExpiration: 10, - requestPerSecond: 1, - }, + actions: [ + { + key: 'useCapacityNFT', + mint: true, + daysUntilUTCMidnightExpiration: 10, + requestPerSecond: 1, + }, + ], transitions: [{ toState: 'runLitAction' }], }, { key: 'runLitAction', - litAction: { - code: `(async () => { + actions: [ + { + key: 'litAction', + code: `(async () => { if (magicNumber >= 42) { LitActions.setResponse({ response:"The number is greater than or equal to 42!" }); } else { LitActions.setResponse({ response: "The number is less than 42!" }); } })();`, - jsParams: { - magicNumber: Math.floor(Math.random() * 100), + jsParams: { + magicNumber: Math.floor(Math.random() * 100), + }, }, - }, + ], transitions: [{ toState: 'cooldown' }], }, { @@ -115,8 +124,8 @@ async function runLitActionInterval() { toState: 'runLitAction', timer: { // One hour, checking every second - until: 1 * 60 * 60, // 3600 times interval: 1000, // one second + until: 1 * 60 * 60, // 3600 times }, }, ], @@ -133,7 +142,7 @@ runLitActionInterval().catch(console.error); ## Functional interface -There care cases where such a declarative interface won't be enough for your use case. When that happens, the machines can also accept generic states, transitions and listeners where it is possible to write any logic. +There care cases where such a declarative interface won't be enough for your use case. When that happens, the machines can also accept generic states, actions, transitions and listeners where it is possible to write any logic. Here is an example that listens to Ethereum blocks looking one whose numbers ends in 0 @@ -205,14 +214,16 @@ Each State Machine has its own information repository called `context`. When using the defined states in the declarative interface, some values are already populated and then used later -- `StateDefinition.usePkp` populates `context.activePkp` with the minted PKP data -- `StateDefinition.useCapacityNFT` populates `context.activeCapacityTokenId` with the minted Capacity Token Id -- `StateDefinition.litAction` populates `context.lastLitActionResponse` with the lit action response -- `StateDefinition.transaction` populates `context.lastTransactionReceipt` with the transaction receipt +- `usePkp` action populates `context.activePkp` with the minted PKP data +- `useCapacityNFT` action populates `context.activeCapacityTokenId` with the minted Capacity Token Id +- `litAction` action populates `context.lastLitActionResponse` with the lit action response +- `transaction` action populates `context.lastTransactionReceipt` with the transaction receipt + +When executing a `litAction` or `transaction` action, the `context` must have `activePkp` and `activeCapacityTokenId` (if needed) populated. Several places in the machine definition can read values from the context. Instead of passing a literal value, pass an object with the `contextPath` property, like in the following example. -The machine context can be accessed using its `getFromContext`, `setToContext` or `pushToContext` methods to read or write. +The machine context can be manually accessed using its `getFromContext`, `setToContext` or `pushToContext` methods to read or write. ### Advance example @@ -248,16 +259,22 @@ async function bridgeBaseSepoliaUSDCToEthereumSepolia() { states: [ { key: 'setPKP', - usePkp: { - pkp, // Configure the pkp passed. Not minting a new one - }, + actions: [ + { + key: 'usePkp', + pkp, // Configure the pkp passed. Not minting a new one + }, + ], transitions: [{ toState: 'setCapacityNFT' }], }, { key: 'setCapacityNFT', - useCapacityNFT: { - capacityTokenId: capacityTokenId, // Configure the capacity token to use. Not minting a new one - }, + actions: [ + { + key: 'useCapacityNFT', + capacityTokenId: capacityTokenId, // Configure the capacity token to use. Not minting a new one + }, + ], transitions: [{ toState: 'waitForFunds' }], }, { @@ -289,12 +306,14 @@ async function bridgeBaseSepoliaUSDCToEthereumSepolia() { }, { key: 'waitForTransfer', - context: { - log: { - atEnter: true, - atExit: true, + actions: [ + { + key: 'context', + log: { + path: '', // We want to log the full context for debugging + }, }, - }, + ], transitions: [ // Waits to receive an USDC transfer in our listening chain { @@ -324,23 +343,26 @@ async function bridgeBaseSepoliaUSDCToEthereumSepolia() { { key: 'transferFunds', // Sends a transaction to transfer some USDC in destination chain - transaction: { - evmChainId: evmDestinationNetwork.chainId, - contractAddress: USDC_ETH_SEPOLIA_ADDRESS, - contractABI: [ - 'function transfer(address to, uint256 amount) public returns (bool)', - ], - method: 'transfer', - params: [ - // Params can be hardcoded values such as ['0x123...', '100'] or values from the state machine context - { - contextPath: 'transfer.sender', - }, - { - contextPath: 'transfer.amount', - }, - ], - }, + actions: [ + { + key: 'transaction', + evmChainId: evmDestinationNetwork.chainId, + contractAddress: USDC_ETH_SEPOLIA_ADDRESS, + contractABI: [ + 'function transfer(address to, uint256 amount) public returns (bool)', + ], + method: 'transfer', + params: [ + // Params can be hardcoded values such as ['0x123...', '100'] or values from the state machine context + { + contextPath: 'transfer.sender', + }, + { + contextPath: 'transfer.amount', + }, + ], + }, + ], // Going back to waitForFunds to suspend machine if we need more sepolia eth or sepolia USDC transitions: [{ toState: 'waitForFunds' }], }, From 48e47e0d770a01fe78271ec246c1b4efd6532c65 Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Tue, 17 Dec 2024 15:56:51 +0100 Subject: [PATCH 30/43] feat: improve types --- .../src/lib/listeners/evm-contract-event.ts | 8 ++-- .../automation/src/lib/listeners/listener.ts | 10 ++--- packages/automation/src/lib/state-machine.ts | 40 +++++++++++++------ packages/automation/src/lib/states/state.ts | 10 +++-- .../src/lib/transitions/transition.ts | 6 +-- packages/automation/src/lib/types.ts | 2 +- 6 files changed, 47 insertions(+), 29 deletions(-) diff --git a/packages/automation/src/lib/listeners/evm-contract-event.ts b/packages/automation/src/lib/listeners/evm-contract-event.ts index 7d21f594d4..f444ce6e88 100644 --- a/packages/automation/src/lib/listeners/evm-contract-event.ts +++ b/packages/automation/src/lib/listeners/evm-contract-event.ts @@ -2,12 +2,12 @@ import { ethers } from 'ethers'; import { Listener } from './listener'; -export type ContractEventData = { +export interface ContractEventData { event: ethers.Event; - args: any[]; + args: unknown[]; blockNumber: number; transactionHash: string; -}; +} export interface ContractInfo { address: string; @@ -16,7 +16,7 @@ export interface ContractInfo { export interface EventInfo { name: string; - filter?: any[]; + filter?: unknown[]; } export class EVMContractEventListener extends Listener { diff --git a/packages/automation/src/lib/listeners/listener.ts b/packages/automation/src/lib/listeners/listener.ts index 87a2eb1ef9..c085b09395 100644 --- a/packages/automation/src/lib/listeners/listener.ts +++ b/packages/automation/src/lib/listeners/listener.ts @@ -1,10 +1,10 @@ import { EventEmitter } from 'events'; -import { onError } from '../types'; +import { onError, voidAsyncFunction } from '../types'; export interface ListenerParams { - start?: () => Promise; - stop?: () => Promise; + start?: voidAsyncFunction; + stop?: voidAsyncFunction; onError?: onError; } @@ -19,12 +19,12 @@ export class Listener { /** * The start function called when all listeners are started. */ - public start: () => Promise; + public start: voidAsyncFunction; /** * The stop function called when all listeners are stopped. */ - public stop: () => Promise; + public stop: voidAsyncFunction; /** * The error handling function to call when an error occurs. diff --git a/packages/automation/src/lib/state-machine.ts b/packages/automation/src/lib/state-machine.ts index 93daf3762b..023a7f0b5b 100644 --- a/packages/automation/src/lib/state-machine.ts +++ b/packages/automation/src/lib/state-machine.ts @@ -33,6 +33,7 @@ import { StateMachineDefinition, TransitionDefinition, TransitionParams, + voidAsyncFunction, } from './types'; import { getEvmChain } from './utils/chain'; import { getBalanceTransitionCheck, getERC20Balance } from './utils/erc20'; @@ -61,7 +62,7 @@ export class StateMachine { private states = new Map(); private transitions = new Map>(); private currentState?: State; - private onStopCallback?: () => Promise; + private onStopCallback?: voidAsyncFunction; constructor(params: BaseStateMachineParams) { this.id = this.generateId(); @@ -148,14 +149,14 @@ export class StateMachine { /** * Indicates if the state machine is running */ - get isRunning() { + get isRunning(): boolean { return this.status === 'running'; } /** * Returns an ethers Wallet the state machine can use */ - get signer() { + get signer(): ethers.Wallet { if (!this.privateKey) { throw new AutomationError( { @@ -177,7 +178,7 @@ export class StateMachine { * Adds a custom state to the state machine. * @param params The parameters for the state. */ - addState(params: StateParams) { + addState(params: StateParams): void { const state = new State(params); this.states.set(state.key, state); if (!this.transitions.has(state.key)) { @@ -189,7 +190,7 @@ export class StateMachine { * Adds a state to the state machine using the declarative interface. * @param stateDefinition The state definition. */ - addStateFromDefinition(stateDefinition: StateDefinition) { + addStateFromDefinition(stateDefinition: StateDefinition): void { const stateParams: StateParams = { key: stateDefinition.key, debug: this.debug, @@ -306,7 +307,7 @@ export class StateMachine { check, onMatch, onMismatch, - }: TransitionParams) { + }: TransitionParams): void { if (!this.states.has(fromState)) { throw new AutomationError( { @@ -404,7 +405,7 @@ export class StateMachine { evmContractEvent.contextUpdates?.forEach((contextUpdate) => this.context.setFromData( contextUpdate.contextPath, - eventData, + eventData as Record | undefined, contextUpdate.dataPath ) ); @@ -494,7 +495,10 @@ export class StateMachine { * @param initialState The key of the initial state. * @param onStop Optional callback to execute when the machine is stopped. */ - async startMachine(initialState: string, onStop?: () => Promise) { + async startMachine( + initialState: string, + onStop?: voidAsyncFunction + ): Promise { this.debug && console.log('Starting state machine...'); await Promise.all([ @@ -554,7 +558,7 @@ export class StateMachine { /** * Stops the state machine by exiting the current state and not moving to another one. */ - async stopMachine() { + public async stopMachine(): Promise { this.debug && console.log('Stopping state machine...'); this.status = 'stopped'; @@ -567,7 +571,7 @@ export class StateMachine { /** * Stops listening on the current state's transitions and exits the current state. */ - private async exitCurrentState() { + private async exitCurrentState(): Promise { this.debug && console.log('exitCurrentState', this.currentState?.key); const currentTransitions = @@ -584,7 +588,7 @@ export class StateMachine { * Moves to a new state. * @param stateKey The key of the new state. */ - private async enterState(stateKey: string) { + private async enterState(stateKey: string): Promise { const state = this.states.get(stateKey); if (!state) { throw new AutomationError( @@ -613,7 +617,7 @@ export class StateMachine { * Triggers a transition to a new state. * @param stateKey The key of the target state. */ - private async transitionTo(stateKey: string) { + private async transitionTo(stateKey: string): Promise { const nextState = this.states.get(stateKey); if (!nextState) { @@ -644,6 +648,13 @@ export class StateMachine { } private handleError(error: unknown, context: string) { + /** + * Handles errors in the state machine. + * @param error + * @param context + * @private + */ + private handleError(error: unknown, context: string): void { // Try to halt machine if it is still running if (this.isRunning) { const publicError = new AutomationError( @@ -669,6 +680,11 @@ export class StateMachine { } } + /** + * Generates a unique identifier for the state machine. + * @returns A unique identifier string. + * @private + */ private generateId(): string { return Math.random().toString(36).substring(2); } diff --git a/packages/automation/src/lib/states/state.ts b/packages/automation/src/lib/states/state.ts index d0d563c622..b9501c62ba 100644 --- a/packages/automation/src/lib/states/state.ts +++ b/packages/automation/src/lib/states/state.ts @@ -1,7 +1,9 @@ +import { voidAsyncFunction } from '../types'; + export interface BaseStateParams { key: string; - onEnter?: () => Promise; - onExit?: () => Promise; + onEnter?: voidAsyncFunction; + onExit?: voidAsyncFunction; debug?: boolean; } @@ -13,8 +15,8 @@ export type StateParams = BaseStateParams; export class State { private readonly debug; public readonly key: string; - public readonly onEnter: (() => Promise) | undefined; - public readonly onExit: (() => Promise) | undefined; + public readonly onEnter: voidAsyncFunction | undefined; + public readonly onExit: voidAsyncFunction | undefined; constructor(params: BaseStateParams) { this.key = params.key; diff --git a/packages/automation/src/lib/transitions/transition.ts b/packages/automation/src/lib/transitions/transition.ts index a8c4a23f12..8722b30bc9 100644 --- a/packages/automation/src/lib/transitions/transition.ts +++ b/packages/automation/src/lib/transitions/transition.ts @@ -5,9 +5,6 @@ export type CheckFn = (values: (unknown | undefined)[]) => Promise; export type resultFn = (values: (unknown | undefined)[]) => Promise; type Values = (unknown | undefined)[]; -/** - * A Transition class that manages state transitions based on listeners and conditions. - */ export interface BaseTransitionParams { debug?: boolean; listeners?: Listener[]; // should be unknown but that demands callers to cast listeners to their correct type @@ -17,6 +14,9 @@ export interface BaseTransitionParams { onError?: onError; } +/** + * A Transition class that manages state transitions based on listeners and conditions. + */ export class Transition { private readonly debug: boolean; private readonly listeners: Listener[]; diff --git a/packages/automation/src/lib/types.ts b/packages/automation/src/lib/types.ts index 2502335d9b..ec8f0e3e04 100644 --- a/packages/automation/src/lib/types.ts +++ b/packages/automation/src/lib/types.ts @@ -6,7 +6,7 @@ import { LitNodeClient } from '@lit-protocol/lit-node-client'; import { BaseTransitionParams } from './transitions'; export type Address = `0x${string}`; -export type voidAsyncFunction = () => void; +export type voidAsyncFunction = () => Promise; export type onError = (error: unknown) => void; export interface PKPInfo { From ddf12615c44e403d6f38a61befff275ac8161869 Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Tue, 17 Dec 2024 15:58:44 +0100 Subject: [PATCH 31/43] feat: improve types --- packages/automation/src/lib/state-machine.ts | 1 + packages/automation/src/lib/transitions/transition.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/automation/src/lib/state-machine.ts b/packages/automation/src/lib/state-machine.ts index 023a7f0b5b..ffcfd40c9c 100644 --- a/packages/automation/src/lib/state-machine.ts +++ b/packages/automation/src/lib/state-machine.ts @@ -366,6 +366,7 @@ export class StateMachine { toState, }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Listeners can be any type const listeners: Listener[] = []; const checks: CheckFn[] = []; diff --git a/packages/automation/src/lib/transitions/transition.ts b/packages/automation/src/lib/transitions/transition.ts index 8722b30bc9..8b7ed60b1c 100644 --- a/packages/automation/src/lib/transitions/transition.ts +++ b/packages/automation/src/lib/transitions/transition.ts @@ -7,7 +7,8 @@ type Values = (unknown | undefined)[]; export interface BaseTransitionParams { debug?: boolean; - listeners?: Listener[]; // should be unknown but that demands callers to cast listeners to their correct type + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Listeners can be any type + listeners?: Listener[]; check?: CheckFn; onMatch: resultFn; onMismatch?: resultFn; From 407c97ef6b9ed0958844a29e545245f6ffa201e8 Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Tue, 17 Dec 2024 15:59:47 +0100 Subject: [PATCH 32/43] feat: allow actions in transitions triggered when a match is found --- packages/automation/README.md | 4 + packages/automation/src/lib/state-machine.ts | 206 ++++++++++--------- packages/automation/src/lib/types.ts | 2 + 3 files changed, 115 insertions(+), 97 deletions(-) diff --git a/packages/automation/README.md b/packages/automation/README.md index 1c6bad248a..d6a749c33d 100644 --- a/packages/automation/README.md +++ b/packages/automation/README.md @@ -51,6 +51,10 @@ Transitions define how the machine moves between states. They can be triggered a - HTTP requests (polling) - Custom conditions +When a Transition gets new values, it uses its `check` function to determine if the values are a match or not. + +Depending on the `check` result, it calls the `onMatch` or `onMismatch` function. Also, when there is a match, it can trigger actions and move the state machine to the next state. + ### Listeners Listeners monitor various events and feed data to transitions: diff --git a/packages/automation/src/lib/state-machine.ts b/packages/automation/src/lib/state-machine.ts index ffcfd40c9c..38a56ab6cf 100644 --- a/packages/automation/src/lib/state-machine.ts +++ b/packages/automation/src/lib/state-machine.ts @@ -27,6 +27,7 @@ import { import { State, StateParams } from './states'; import { CheckFn, Transition } from './transitions'; import { + ActionDefinition, BaseStateMachineParams, ContextOrLiteral, StateDefinition, @@ -196,102 +197,9 @@ export class StateMachine { debug: this.debug, }; - const onEnterActions = [] as Action[]; - const onExitActions = [] as Action[]; - - const { actions = [] } = stateDefinition; - - actions.forEach((action) => { - switch (action.key) { - case 'context': - if (action.log?.path) { - onEnterActions.push( - new LogContextAction({ - debug: this.debug, - stateMachine: this, - path: action.log.path, - }) - ); - } - break; - case 'litAction': - onEnterActions.push( - new LitActionAction({ - debug: this.debug, - stateMachine: this, - ...action, - }) - ); - break; - case 'transaction': - onEnterActions.push( - new TransactionAction({ - debug: this.debug, - stateMachine: this, - ...action, - }) - ); - break; - case 'useCapacityNFT': - if ('capacityTokenId' in action) { - this.context.set( - 'activeCapacityTokenId', - this.resolveContextPathOrLiteral(action.capacityTokenId) - ); - } else if ('mint' in action) { - const mintCapacityCreditAction = new MintCapacityCreditAction({ - daysUntilUTCMidnightExpiration: - action.daysUntilUTCMidnightExpiration, - debug: this.debug, - requestPerSecond: action.requestPerSecond, - stateMachine: this, - }); - onEnterActions.push(mintCapacityCreditAction); - } - if (this.debug) { - const activeCapacityTokenId = this.context.get('activePkp'); - console.log( - `Machine configured to use capacity token ${activeCapacityTokenId}` - ); - } - break; - case 'usePkp': - if ('pkp' in action) { - this.context.set( - 'activePkp', - this.resolveContextPathOrLiteral(action.pkp) - ); - } else if ('mint' in action) { - const mintPkpAction = new MintPkpAction({ - debug: this.debug, - stateMachine: this, - }); - onEnterActions.push(mintPkpAction); - } - if (this.debug) { - const activePkp = this.context.get('activePkp'); - console.log(`Machine configured to use pkp ${activePkp}`); - } - break; - default: - throw new AutomationError( - { - info: { - action, - }, - }, - `Unknown action. Check error info.` - ); - } - }); - // Merge all state actions - stateParams.onEnter = async () => { - await Promise.all(onEnterActions.map((action) => action.run())); - }; - stateParams.onExit = async () => { - await Promise.all(onExitActions.map((action) => action.run())); - }; + const { actions = [] } = stateDefinition; + stateParams.onEnter = this.mergeActions(actions); this.addState(stateParams); } @@ -301,6 +209,7 @@ export class StateMachine { * @param params The parameters for the transition. */ addTransition({ + actions = [], fromState, toState, listeners, @@ -334,6 +243,7 @@ export class StateMachine { } const transitioningOnMatch = async (values: (unknown | undefined)[]) => { + await this.mergeActions(actions)(); await onMatch?.(values); await this.transitionTo(toState); }; @@ -358,10 +268,11 @@ export class StateMachine { } addTransitionFromDefinition(transitionDefinition: TransitionDefinition) { - const { balances, evmContractEvent, fromState, timer, toState } = + const { actions, balances, evmContractEvent, fromState, timer, toState } = transitionDefinition; const transitionConfig: TransitionParams = { + actions, fromState, toState, }; @@ -648,7 +559,108 @@ export class StateMachine { } } - private handleError(error: unknown, context: string) { + /** + * Merges the given action definitions into a single function that executes all actions concurrently. + * @param actionDefinitions + * @returns A function that executes all actions concurrently. + * @private + */ + private mergeActions( + actionDefinitions: ActionDefinition[] + ): voidAsyncFunction { + const actions = [] as Action[]; + + actionDefinitions.forEach((action) => { + switch (action.key) { + case 'context': + if (action.log?.path) { + actions.push( + new LogContextAction({ + debug: this.debug, + stateMachine: this, + path: action.log.path, + }) + ); + } + break; + case 'litAction': + actions.push( + new LitActionAction({ + debug: this.debug, + stateMachine: this, + ...action, + }) + ); + break; + case 'transaction': + actions.push( + new TransactionAction({ + debug: this.debug, + stateMachine: this, + ...action, + }) + ); + break; + case 'useCapacityNFT': + if ('capacityTokenId' in action) { + this.context.set( + 'activeCapacityTokenId', + this.resolveContextPathOrLiteral(action.capacityTokenId) + ); + } else if ('mint' in action) { + const mintCapacityCreditAction = new MintCapacityCreditAction({ + daysUntilUTCMidnightExpiration: + action.daysUntilUTCMidnightExpiration, + debug: this.debug, + requestPerSecond: action.requestPerSecond, + stateMachine: this, + }); + actions.push(mintCapacityCreditAction); + } + if (this.debug) { + const activeCapacityTokenId = this.context.get('activePkp'); + console.log( + `Machine configured to use capacity token ${activeCapacityTokenId}` + ); + } + break; + case 'usePkp': + if ('pkp' in action) { + this.context.set( + 'activePkp', + this.resolveContextPathOrLiteral(action.pkp) + ); + } else if ('mint' in action) { + const mintPkpAction = new MintPkpAction({ + debug: this.debug, + stateMachine: this, + }); + actions.push(mintPkpAction); + } + if (this.debug) { + const activePkp = this.context.get('activePkp'); + console.log(`Machine configured to use pkp ${activePkp}`); + } + break; + default: + throw new AutomationError( + { + info: { + action, + }, + }, + `Unknown action. Check error info.` + ); + } + }); + + return async () => { + await Promise.all(actions.map((action) => action.run())).catch((err) => { + this.handleError(err, `Error running actions. Check details.`); + }); + }; + } + /** * Handles errors in the state machine. * @param error diff --git a/packages/automation/src/lib/types.ts b/packages/automation/src/lib/types.ts index ec8f0e3e04..2b5aa25a74 100644 --- a/packages/automation/src/lib/types.ts +++ b/packages/automation/src/lib/types.ts @@ -150,11 +150,13 @@ export interface TransitionDefinition { fromState: string; timer?: TimerTransitionDefinition; toState: string; + actions?: ActionDefinition[]; } export interface TransitionParams extends Omit, Partial> { + actions?: ActionDefinition[]; fromState: string; toState: string; } From 8cebae7078e642f2d1880ee854660494bce34a1a Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Wed, 18 Dec 2024 13:33:53 +0100 Subject: [PATCH 33/43] feat: readme descriptions update --- packages/automation/README.md | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/automation/README.md b/packages/automation/README.md index d6a749c33d..4430516df5 100644 --- a/packages/automation/README.md +++ b/packages/automation/README.md @@ -1,17 +1,14 @@ # @lit-protocol/automation -A TypeScript library for creating and managing state machines that can automate complex workflows involving the Lit Protocol network, blockchain events, and other triggers. - -## Overview +A TypeScript library for creating and managing automated workflows using Lit Protocol. The automation package provides a flexible state machine implementation that allows you to: -- Create automated workflows that respond to blockchain events -- Execute Lit Actions based on custom triggers -- Mint PKPs and Capacity Delegation Tokens -- Monitor token balances and prices -- Bridge tokens across chains automatically using PKPs -- Automate PKP (Programmable Key Pair) operations overall +- Execute automated Lit Action workflows based on custom triggers, such as events on blockchains or off-chain platforms +- Automate the minting of PKPs, Capacity Credits, and other Lit operations +- Monitor token balances and price info +- Perform cross-chain messaging and transaction execution +- And more... ## Installation @@ -148,7 +145,7 @@ runLitActionInterval().catch(console.error); There care cases where such a declarative interface won't be enough for your use case. When that happens, the machines can also accept generic states, actions, transitions and listeners where it is possible to write any logic. -Here is an example that listens to Ethereum blocks looking one whose numbers ends in 0 +Here is an example that listens to Ethereum block hashes, looking for those that end in '0' ```typescript async function monitorEthereumBlocksWithHashEndingWithZero() { @@ -231,9 +228,9 @@ The machine context can be manually accessed using its `getFromContext`, `setToC ### Advance example -By leveraging the State Machine context and the ability of Lit PKPs to sign transaction of a variety of chains, it is possible to implement a Token Bridge that composes multiple chains and even offchain interaction if needed among other uses cases. +By leveraging context from the State Machine in combination with Lit PKPs, it is possible to implement a cross-chain messaging service that can be used to read and write data across virtually any blockchain. -In this example, when a State Machine PKP receives USDC in Base Sepolia, it will send the same amount to the sender but in Ethereum Sepolia +In this example, when a State Machine PKP receives USDC in Base Sepolia, it will send the same amount to the sender but in Ethereum Sepolia. ```typescript async function bridgeBaseSepoliaUSDCToEthereumSepolia() { From c4c873136e5ed40fbc4005970891cde1d8434929 Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Wed, 18 Dec 2024 14:42:18 +0100 Subject: [PATCH 34/43] fix: log all context path check --- packages/automation/src/lib/state-machine.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/automation/src/lib/state-machine.ts b/packages/automation/src/lib/state-machine.ts index 38a56ab6cf..b3b4b96777 100644 --- a/packages/automation/src/lib/state-machine.ts +++ b/packages/automation/src/lib/state-machine.ts @@ -573,7 +573,7 @@ export class StateMachine { actionDefinitions.forEach((action) => { switch (action.key) { case 'context': - if (action.log?.path) { + if (typeof action.log?.path === 'string') { actions.push( new LogContextAction({ debug: this.debug, From 40f260066d44708bc8450e59be3b60bb6b804753 Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Wed, 18 Dec 2024 16:47:54 +0100 Subject: [PATCH 35/43] fix: make contextUpdates optional --- packages/automation/src/lib/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/automation/src/lib/types.ts b/packages/automation/src/lib/types.ts index 2b5aa25a74..19b44be5bb 100644 --- a/packages/automation/src/lib/types.ts +++ b/packages/automation/src/lib/types.ts @@ -31,7 +31,7 @@ interface ContextUpdate extends ContextAccess { } export interface UpdatesContext { - contextUpdates: ContextUpdate[]; + contextUpdates?: ContextUpdate[]; } // Action Types From fc570e3f266ef10f17bfd4cdd65809e5ab96a0f0 Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Wed, 18 Dec 2024 16:48:45 +0100 Subject: [PATCH 36/43] feat: throw if lit action response is not successful --- packages/automation/src/lib/actions/transaction.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/automation/src/lib/actions/transaction.ts b/packages/automation/src/lib/actions/transaction.ts index 1d8694c735..979f123061 100644 --- a/packages/automation/src/lib/actions/transaction.ts +++ b/packages/automation/src/lib/actions/transaction.ts @@ -105,6 +105,20 @@ export class TransactionAction extends Action { sigName: 'signedTransaction', }, }); + if (!litActionResponse.success) { + throw new AutomationError( + { + info: { + machineId: params.stateMachine.id, + evmChainId: params.evmChainId, + contractAddress: params.contractAddress, + value: params.value, + logs: litActionResponse.logs, + }, + }, + `Failed to sign transaction` + ); + } const signature = litActionResponse.response as string; const jsonSignature = JSON.parse(signature); From c3131068cad1ad33d902ba18dd79061698706404 Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Wed, 18 Dec 2024 16:50:04 +0100 Subject: [PATCH 37/43] feat: allow transaction definitions with data instead of populating a contract call and also using an array from context for params --- .../automation/src/lib/actions/transaction.ts | 98 +++++++++++++------ packages/automation/src/lib/types.ts | 21 +++- 2 files changed, 85 insertions(+), 34 deletions(-) diff --git a/packages/automation/src/lib/actions/transaction.ts b/packages/automation/src/lib/actions/transaction.ts index 979f123061..e93ff3d914 100644 --- a/packages/automation/src/lib/actions/transaction.ts +++ b/packages/automation/src/lib/actions/transaction.ts @@ -8,17 +8,33 @@ import { StateMachine } from '../state-machine'; import { Address, ContextOrLiteral, PKPInfo } from '../types'; import { getEvmChain } from '../utils/chain'; -interface TransactionActionParams { +function arrayfy(value: unknown) { + return Array.isArray(value) ? value : [value]; +} + +interface TransactionActionBaseParams { debug?: boolean; stateMachine: StateMachine; evmChainId: ContextOrLiteral; - contractABI: ethers.ContractInterface; contractAddress: ContextOrLiteral
; - method: ContextOrLiteral; - params?: ContextOrLiteral[]; value?: ContextOrLiteral; } +interface TransactionActionWithoutDataParams + extends TransactionActionBaseParams { + contractABI: ethers.ContractInterface; + method: ContextOrLiteral; + params?: ContextOrLiteral | ContextOrLiteral[]; +} + +interface TransactionActionWithDataParams extends TransactionActionBaseParams { + data?: ContextOrLiteral; +} + +type TransactionActionParams = + | TransactionActionWithoutDataParams + | TransactionActionWithDataParams; + export class TransactionAction extends Action { constructor(params: TransactionActionParams) { const litActionFunction = async () => { @@ -37,37 +53,54 @@ export class TransactionAction extends Action { ); } - const yellowstoneMachineSigner = params.stateMachine.signer; - - const chainId = params.stateMachine.resolveContextPathOrLiteral( + // Resolve transaction data from context + const txChainId = params.stateMachine.resolveContextPathOrLiteral( params.evmChainId ); - const chain = getEvmChain(chainId); + const chain = getEvmChain(txChainId); const chainProvider = new ethers.providers.JsonRpcProvider( chain.rpcUrls[0], chain.chainId ); - const contract = new ethers.Contract( - params.stateMachine.resolveContextPathOrLiteral(params.contractAddress), - params.contractABI, - chainProvider - ); - - const txParams = (params.params || []).map( - params.stateMachine.resolveContextPathOrLiteral.bind( - params.stateMachine - ) + const txContractAddress = params.stateMachine.resolveContextPathOrLiteral( + params.contractAddress ); - const txMethod = params.stateMachine.resolveContextPathOrLiteral( - params.method + const txValue = params.stateMachine.resolveContextPathOrLiteral( + params.value ); - const txData = await contract.populateTransaction[txMethod](...txParams); + // transaction can have data or the fields necessary to populate it + let txData: ethers.BytesLike | undefined; + if (!('contractABI' in params)) { + txData = params.stateMachine.resolveContextPathOrLiteral(params.data); + } else { + const txMethod = params.stateMachine.resolveContextPathOrLiteral( + params.method + ); + const txParams = arrayfy( + !Array.isArray(params.params) + ? params.stateMachine.resolveContextPathOrLiteral(params.params) + : params.params.map( + params.stateMachine.resolveContextPathOrLiteral.bind( + params.stateMachine + ) + ) + ); + + const contract = new ethers.Contract( + txContractAddress, + params.contractABI, + chainProvider + ); + const populatedTx = await contract.populateTransaction[txMethod]( + ...txParams + ); + txData = populatedTx.data; + } + const gasLimit = await chainProvider.estimateGas({ - to: params.stateMachine.resolveContextPathOrLiteral( - params.contractAddress - ), - data: txData.data, + to: txContractAddress, + data: txData, from: activePkp.ethAddress, }); const gasPrice = await chainProvider.getGasPrice(); @@ -75,21 +108,21 @@ export class TransactionAction extends Action { activePkp.ethAddress ); - const rawTx = { + const rawTx: ethers.UnsignedTransaction = { chainId: chain.chainId, - data: txData.data, + data: txData, gasLimit: gasLimit.toHexString(), gasPrice: gasPrice.toHexString(), nonce, - to: params.stateMachine.resolveContextPathOrLiteral( - params.contractAddress - ), + to: txContractAddress, + value: txValue, }; const rawTxHash = ethers.utils.keccak256( ethers.utils.serializeTransaction(rawTx) ); // Sign with the PKP in a LitAction + const yellowstoneMachineSigner = params.stateMachine.signer; const litActionResponse = await executeLitAction({ litNodeClient: params.stateMachine.litNodeClient, capacityTokenId: params.stateMachine.resolveContextPathOrLiteral({ @@ -113,6 +146,11 @@ export class TransactionAction extends Action { evmChainId: params.evmChainId, contractAddress: params.contractAddress, value: params.value, + data: 'data' in params ? params.data : undefined, + contractABI: + 'contractABI' in params ? params.contractABI : undefined, + method: 'method' in params ? params.method : undefined, + params: 'params' in params ? params.params : undefined, logs: litActionResponse.logs, }, }, diff --git a/packages/automation/src/lib/types.ts b/packages/automation/src/lib/types.ts index 19b44be5bb..1ec982857c 100644 --- a/packages/automation/src/lib/types.ts +++ b/packages/automation/src/lib/types.ts @@ -49,15 +49,28 @@ export interface ContextActionDefinition { }; } -export interface TransactionActionDefinition extends OnEvmChain { +interface TransactionActionBaseDefinition extends OnEvmChain { key: 'transaction'; - contractABI: ethers.ContractInterface; contractAddress: ContextOrLiteral
; - method: ContextOrLiteral; - params?: ContextOrLiteral[]; value?: ContextOrLiteral; } +interface TransactionActionWithoutDataDefinition + extends TransactionActionBaseDefinition { + contractABI: ethers.ContractInterface; + method: ContextOrLiteral; + params?: ContextOrLiteral | ContextOrLiteral[]; +} + +interface TransactionActionWithDataDefinition + extends TransactionActionBaseDefinition { + data?: ContextOrLiteral; +} + +export type TransactionActionDefinition = + | TransactionActionWithoutDataDefinition + | TransactionActionWithDataDefinition; + export interface MintActionDefinition { mint: true; } From 0f4df3b7e6142c4cdd06097186ff679534ec4d2b Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Thu, 19 Dec 2024 20:09:26 +0100 Subject: [PATCH 38/43] feat: increase support for evmChainIds in different types using ethers BigNumberish --- packages/automation/src/lib/utils/chain.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/automation/src/lib/utils/chain.ts b/packages/automation/src/lib/utils/chain.ts index cbfc943d08..91025ee327 100644 --- a/packages/automation/src/lib/utils/chain.ts +++ b/packages/automation/src/lib/utils/chain.ts @@ -1,8 +1,15 @@ +import { ethers } from 'ethers'; + import { LIT_EVM_CHAINS } from '@lit-protocol/constants'; -export function getEvmChain(evmChainId: number) { +export function getEvmChain(evmChainId: ethers.BigNumberish) { + const evmChainIdNumber = ethers.BigNumber.from(evmChainId).toNumber(); + if (evmChainIdNumber === 0) { + throw new Error('EVM chainId cannot be 0'); + } + const chain = Object.values(LIT_EVM_CHAINS).find( - (chain) => chain.chainId === evmChainId + (chain) => chain.chainId === evmChainIdNumber ); if (!chain) { throw new Error(`EVM chain with chainId ${evmChainId} not found`); From 528bc0133e7296083b1b2cb7472a8dda6b2009a1 Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Fri, 20 Dec 2024 17:18:06 +0100 Subject: [PATCH 39/43] feat: rename automation package to event-listener --- .../{automation => event-listener}/.babelrc | 0 .../.eslintrc.json | 0 .../{automation => event-listener}/README.md | 8 ++++---- .../jest.config.ts | 2 +- .../package.json | 4 ++-- .../project.json | 18 +++++++++--------- .../src/index.ts | 0 .../src/lib/actions/action.ts | 0 .../src/lib/actions/index.ts | 0 .../src/lib/actions/lit-action.ts | 0 .../src/lib/actions/log-context.ts | 0 .../src/lib/actions/mint-capacity-credit.ts | 0 .../src/lib/actions/mint-pkp.ts | 0 .../src/lib/actions/transaction.ts | 0 .../src/lib/context/index.ts | 0 .../src/lib/context/machine-context.spec.ts | 0 .../src/lib/context/machine-context.ts | 0 .../src/lib/listeners/constant.spec.ts | 0 .../src/lib/listeners/constant.ts | 0 .../src/lib/listeners/evm-block.spec.ts | 0 .../src/lib/listeners/evm-block.ts | 0 .../lib/listeners/evm-contract-event.spec.ts | 0 .../src/lib/listeners/evm-contract-event.ts | 0 .../src/lib/listeners/fetch.spec.ts | 0 .../src/lib/listeners/fetch.ts | 0 .../src/lib/listeners/index.ts | 0 .../src/lib/listeners/interval.spec.ts | 0 .../src/lib/listeners/interval.ts | 0 .../src/lib/listeners/listener.spec.ts | 0 .../src/lib/listeners/listener.ts | 0 .../src/lib/listeners/timer.spec.ts | 0 .../src/lib/listeners/timer.ts | 0 .../src/lib/litActions.ts | 0 .../src/lib/state-machine.spec.ts | 0 .../src/lib/state-machine.ts | 0 .../src/lib/states/index.ts | 0 .../src/lib/states/state.spec.ts | 0 .../src/lib/states/state.ts | 0 .../src/lib/transitions/index.ts | 0 .../src/lib/transitions/transition.spec.ts | 0 .../src/lib/transitions/transition.ts | 0 .../src/lib/types.ts | 0 .../src/lib/utils/chain.ts | 0 .../src/lib/utils/erc20.ts | 0 .../tsconfig.json | 0 .../tsconfig.lib.json | 0 .../tsconfig.spec.json | 0 47 files changed, 16 insertions(+), 16 deletions(-) rename packages/{automation => event-listener}/.babelrc (100%) rename packages/{automation => event-listener}/.eslintrc.json (100%) rename packages/{automation => event-listener}/README.md (98%) rename packages/{automation => event-listener}/jest.config.ts (84%) rename packages/{automation => event-listener}/package.json (86%) rename packages/{automation => event-listener}/project.json (54%) rename packages/{automation => event-listener}/src/index.ts (100%) rename packages/{automation => event-listener}/src/lib/actions/action.ts (100%) rename packages/{automation => event-listener}/src/lib/actions/index.ts (100%) rename packages/{automation => event-listener}/src/lib/actions/lit-action.ts (100%) rename packages/{automation => event-listener}/src/lib/actions/log-context.ts (100%) rename packages/{automation => event-listener}/src/lib/actions/mint-capacity-credit.ts (100%) rename packages/{automation => event-listener}/src/lib/actions/mint-pkp.ts (100%) rename packages/{automation => event-listener}/src/lib/actions/transaction.ts (100%) rename packages/{automation => event-listener}/src/lib/context/index.ts (100%) rename packages/{automation => event-listener}/src/lib/context/machine-context.spec.ts (100%) rename packages/{automation => event-listener}/src/lib/context/machine-context.ts (100%) rename packages/{automation => event-listener}/src/lib/listeners/constant.spec.ts (100%) rename packages/{automation => event-listener}/src/lib/listeners/constant.ts (100%) rename packages/{automation => event-listener}/src/lib/listeners/evm-block.spec.ts (100%) rename packages/{automation => event-listener}/src/lib/listeners/evm-block.ts (100%) rename packages/{automation => event-listener}/src/lib/listeners/evm-contract-event.spec.ts (100%) rename packages/{automation => event-listener}/src/lib/listeners/evm-contract-event.ts (100%) rename packages/{automation => event-listener}/src/lib/listeners/fetch.spec.ts (100%) rename packages/{automation => event-listener}/src/lib/listeners/fetch.ts (100%) rename packages/{automation => event-listener}/src/lib/listeners/index.ts (100%) rename packages/{automation => event-listener}/src/lib/listeners/interval.spec.ts (100%) rename packages/{automation => event-listener}/src/lib/listeners/interval.ts (100%) rename packages/{automation => event-listener}/src/lib/listeners/listener.spec.ts (100%) rename packages/{automation => event-listener}/src/lib/listeners/listener.ts (100%) rename packages/{automation => event-listener}/src/lib/listeners/timer.spec.ts (100%) rename packages/{automation => event-listener}/src/lib/listeners/timer.ts (100%) rename packages/{automation => event-listener}/src/lib/litActions.ts (100%) rename packages/{automation => event-listener}/src/lib/state-machine.spec.ts (100%) rename packages/{automation => event-listener}/src/lib/state-machine.ts (100%) rename packages/{automation => event-listener}/src/lib/states/index.ts (100%) rename packages/{automation => event-listener}/src/lib/states/state.spec.ts (100%) rename packages/{automation => event-listener}/src/lib/states/state.ts (100%) rename packages/{automation => event-listener}/src/lib/transitions/index.ts (100%) rename packages/{automation => event-listener}/src/lib/transitions/transition.spec.ts (100%) rename packages/{automation => event-listener}/src/lib/transitions/transition.ts (100%) rename packages/{automation => event-listener}/src/lib/types.ts (100%) rename packages/{automation => event-listener}/src/lib/utils/chain.ts (100%) rename packages/{automation => event-listener}/src/lib/utils/erc20.ts (100%) rename packages/{automation => event-listener}/tsconfig.json (100%) rename packages/{automation => event-listener}/tsconfig.lib.json (100%) rename packages/{automation => event-listener}/tsconfig.spec.json (100%) diff --git a/packages/automation/.babelrc b/packages/event-listener/.babelrc similarity index 100% rename from packages/automation/.babelrc rename to packages/event-listener/.babelrc diff --git a/packages/automation/.eslintrc.json b/packages/event-listener/.eslintrc.json similarity index 100% rename from packages/automation/.eslintrc.json rename to packages/event-listener/.eslintrc.json diff --git a/packages/automation/README.md b/packages/event-listener/README.md similarity index 98% rename from packages/automation/README.md rename to packages/event-listener/README.md index 4430516df5..bc0c660a9c 100644 --- a/packages/automation/README.md +++ b/packages/event-listener/README.md @@ -1,8 +1,8 @@ -# @lit-protocol/automation +# @lit-protocol/event-listener A TypeScript library for creating and managing automated workflows using Lit Protocol. -The automation package provides a flexible state machine implementation that allows you to: +The event listener package provides a flexible state machine implementation that allows you to: - Execute automated Lit Action workflows based on custom triggers, such as events on blockchains or off-chain platforms - Automate the minting of PKPs, Capacity Credits, and other Lit operations @@ -13,9 +13,9 @@ The automation package provides a flexible state machine implementation that all ## Installation ```bash -npm install @lit-protocol/automation +npm install @lit-protocol/event-listener # or -yarn add @lit-protocol/automation +yarn add @lit-protocol/event-listener ``` ## Core Concepts diff --git a/packages/automation/jest.config.ts b/packages/event-listener/jest.config.ts similarity index 84% rename from packages/automation/jest.config.ts rename to packages/event-listener/jest.config.ts index 46f114b5e6..a31f92f507 100644 --- a/packages/automation/jest.config.ts +++ b/packages/event-listener/jest.config.ts @@ -11,6 +11,6 @@ export default { '^.+\\.[t]s$': 'ts-jest', }, moduleFileExtensions: ['ts', 'js', 'html'], - coverageDirectory: '../../coverage/packages/automation', + coverageDirectory: '../../coverage/packages/event-listener', setupFilesAfterEnv: ['../../jest.setup.js'], }; diff --git a/packages/automation/package.json b/packages/event-listener/package.json similarity index 86% rename from packages/automation/package.json rename to packages/event-listener/package.json index 3ef2ab5724..240f1cf14c 100644 --- a/packages/automation/package.json +++ b/packages/event-listener/package.json @@ -1,5 +1,5 @@ { - "name": "@lit-protocol/automation", + "name": "@lit-protocol/event-listener", "type": "commonjs", "license": "MIT", "homepage": "https://github.com/Lit-Protocol/js-sdk", @@ -15,7 +15,7 @@ }, "publishConfig": { "access": "public", - "directory": "../../dist/packages/automation" + "directory": "../../dist/packages/event-listener" }, "tags": [ "universal" diff --git a/packages/automation/project.json b/packages/event-listener/project.json similarity index 54% rename from packages/automation/project.json rename to packages/event-listener/project.json index 52dc6246c0..5274d0c615 100644 --- a/packages/automation/project.json +++ b/packages/event-listener/project.json @@ -1,7 +1,7 @@ { - "name": "automation", + "name": "event-listener", "$schema": "../../node_modules/nx/schemas/project-schema.json", - "sourceRoot": "packages/automation/src", + "sourceRoot": "packages/event-listener/src", "projectType": "library", "targets": { "build": { @@ -9,10 +9,10 @@ "executor": "@nx/js:tsc", "outputs": ["{options.outputPath}"], "options": { - "outputPath": "dist/packages/automation", - "main": "packages/automation/src/index.ts", - "tsConfig": "packages/automation/tsconfig.lib.json", - "assets": ["packages/automation/*.md"], + "outputPath": "dist/packages/event-listener", + "main": "packages/event-listener/src/index.ts", + "tsConfig": "packages/event-listener/tsconfig.lib.json", + "assets": ["packages/event-listener/*.md"], "updateBuildableProjectDepsInPackageJson": true }, "dependsOn": ["^build"] @@ -21,14 +21,14 @@ "executor": "@nx/linter:eslint", "outputs": ["{options.outputFile}"], "options": { - "lintFilePatterns": ["packages/automation/**/*.ts"] + "lintFilePatterns": ["packages/event-listener/**/*.ts"] } }, "test": { "executor": "@nx/jest:jest", - "outputs": ["{workspaceRoot}/coverage/packages/automation"], + "outputs": ["{workspaceRoot}/coverage/packages/event-listener"], "options": { - "jestConfig": "packages/automation/jest.config.ts", + "jestConfig": "packages/event-listener/jest.config.ts", "passWithNoTests": true } } diff --git a/packages/automation/src/index.ts b/packages/event-listener/src/index.ts similarity index 100% rename from packages/automation/src/index.ts rename to packages/event-listener/src/index.ts diff --git a/packages/automation/src/lib/actions/action.ts b/packages/event-listener/src/lib/actions/action.ts similarity index 100% rename from packages/automation/src/lib/actions/action.ts rename to packages/event-listener/src/lib/actions/action.ts diff --git a/packages/automation/src/lib/actions/index.ts b/packages/event-listener/src/lib/actions/index.ts similarity index 100% rename from packages/automation/src/lib/actions/index.ts rename to packages/event-listener/src/lib/actions/index.ts diff --git a/packages/automation/src/lib/actions/lit-action.ts b/packages/event-listener/src/lib/actions/lit-action.ts similarity index 100% rename from packages/automation/src/lib/actions/lit-action.ts rename to packages/event-listener/src/lib/actions/lit-action.ts diff --git a/packages/automation/src/lib/actions/log-context.ts b/packages/event-listener/src/lib/actions/log-context.ts similarity index 100% rename from packages/automation/src/lib/actions/log-context.ts rename to packages/event-listener/src/lib/actions/log-context.ts diff --git a/packages/automation/src/lib/actions/mint-capacity-credit.ts b/packages/event-listener/src/lib/actions/mint-capacity-credit.ts similarity index 100% rename from packages/automation/src/lib/actions/mint-capacity-credit.ts rename to packages/event-listener/src/lib/actions/mint-capacity-credit.ts diff --git a/packages/automation/src/lib/actions/mint-pkp.ts b/packages/event-listener/src/lib/actions/mint-pkp.ts similarity index 100% rename from packages/automation/src/lib/actions/mint-pkp.ts rename to packages/event-listener/src/lib/actions/mint-pkp.ts diff --git a/packages/automation/src/lib/actions/transaction.ts b/packages/event-listener/src/lib/actions/transaction.ts similarity index 100% rename from packages/automation/src/lib/actions/transaction.ts rename to packages/event-listener/src/lib/actions/transaction.ts diff --git a/packages/automation/src/lib/context/index.ts b/packages/event-listener/src/lib/context/index.ts similarity index 100% rename from packages/automation/src/lib/context/index.ts rename to packages/event-listener/src/lib/context/index.ts diff --git a/packages/automation/src/lib/context/machine-context.spec.ts b/packages/event-listener/src/lib/context/machine-context.spec.ts similarity index 100% rename from packages/automation/src/lib/context/machine-context.spec.ts rename to packages/event-listener/src/lib/context/machine-context.spec.ts diff --git a/packages/automation/src/lib/context/machine-context.ts b/packages/event-listener/src/lib/context/machine-context.ts similarity index 100% rename from packages/automation/src/lib/context/machine-context.ts rename to packages/event-listener/src/lib/context/machine-context.ts diff --git a/packages/automation/src/lib/listeners/constant.spec.ts b/packages/event-listener/src/lib/listeners/constant.spec.ts similarity index 100% rename from packages/automation/src/lib/listeners/constant.spec.ts rename to packages/event-listener/src/lib/listeners/constant.spec.ts diff --git a/packages/automation/src/lib/listeners/constant.ts b/packages/event-listener/src/lib/listeners/constant.ts similarity index 100% rename from packages/automation/src/lib/listeners/constant.ts rename to packages/event-listener/src/lib/listeners/constant.ts diff --git a/packages/automation/src/lib/listeners/evm-block.spec.ts b/packages/event-listener/src/lib/listeners/evm-block.spec.ts similarity index 100% rename from packages/automation/src/lib/listeners/evm-block.spec.ts rename to packages/event-listener/src/lib/listeners/evm-block.spec.ts diff --git a/packages/automation/src/lib/listeners/evm-block.ts b/packages/event-listener/src/lib/listeners/evm-block.ts similarity index 100% rename from packages/automation/src/lib/listeners/evm-block.ts rename to packages/event-listener/src/lib/listeners/evm-block.ts diff --git a/packages/automation/src/lib/listeners/evm-contract-event.spec.ts b/packages/event-listener/src/lib/listeners/evm-contract-event.spec.ts similarity index 100% rename from packages/automation/src/lib/listeners/evm-contract-event.spec.ts rename to packages/event-listener/src/lib/listeners/evm-contract-event.spec.ts diff --git a/packages/automation/src/lib/listeners/evm-contract-event.ts b/packages/event-listener/src/lib/listeners/evm-contract-event.ts similarity index 100% rename from packages/automation/src/lib/listeners/evm-contract-event.ts rename to packages/event-listener/src/lib/listeners/evm-contract-event.ts diff --git a/packages/automation/src/lib/listeners/fetch.spec.ts b/packages/event-listener/src/lib/listeners/fetch.spec.ts similarity index 100% rename from packages/automation/src/lib/listeners/fetch.spec.ts rename to packages/event-listener/src/lib/listeners/fetch.spec.ts diff --git a/packages/automation/src/lib/listeners/fetch.ts b/packages/event-listener/src/lib/listeners/fetch.ts similarity index 100% rename from packages/automation/src/lib/listeners/fetch.ts rename to packages/event-listener/src/lib/listeners/fetch.ts diff --git a/packages/automation/src/lib/listeners/index.ts b/packages/event-listener/src/lib/listeners/index.ts similarity index 100% rename from packages/automation/src/lib/listeners/index.ts rename to packages/event-listener/src/lib/listeners/index.ts diff --git a/packages/automation/src/lib/listeners/interval.spec.ts b/packages/event-listener/src/lib/listeners/interval.spec.ts similarity index 100% rename from packages/automation/src/lib/listeners/interval.spec.ts rename to packages/event-listener/src/lib/listeners/interval.spec.ts diff --git a/packages/automation/src/lib/listeners/interval.ts b/packages/event-listener/src/lib/listeners/interval.ts similarity index 100% rename from packages/automation/src/lib/listeners/interval.ts rename to packages/event-listener/src/lib/listeners/interval.ts diff --git a/packages/automation/src/lib/listeners/listener.spec.ts b/packages/event-listener/src/lib/listeners/listener.spec.ts similarity index 100% rename from packages/automation/src/lib/listeners/listener.spec.ts rename to packages/event-listener/src/lib/listeners/listener.spec.ts diff --git a/packages/automation/src/lib/listeners/listener.ts b/packages/event-listener/src/lib/listeners/listener.ts similarity index 100% rename from packages/automation/src/lib/listeners/listener.ts rename to packages/event-listener/src/lib/listeners/listener.ts diff --git a/packages/automation/src/lib/listeners/timer.spec.ts b/packages/event-listener/src/lib/listeners/timer.spec.ts similarity index 100% rename from packages/automation/src/lib/listeners/timer.spec.ts rename to packages/event-listener/src/lib/listeners/timer.spec.ts diff --git a/packages/automation/src/lib/listeners/timer.ts b/packages/event-listener/src/lib/listeners/timer.ts similarity index 100% rename from packages/automation/src/lib/listeners/timer.ts rename to packages/event-listener/src/lib/listeners/timer.ts diff --git a/packages/automation/src/lib/litActions.ts b/packages/event-listener/src/lib/litActions.ts similarity index 100% rename from packages/automation/src/lib/litActions.ts rename to packages/event-listener/src/lib/litActions.ts diff --git a/packages/automation/src/lib/state-machine.spec.ts b/packages/event-listener/src/lib/state-machine.spec.ts similarity index 100% rename from packages/automation/src/lib/state-machine.spec.ts rename to packages/event-listener/src/lib/state-machine.spec.ts diff --git a/packages/automation/src/lib/state-machine.ts b/packages/event-listener/src/lib/state-machine.ts similarity index 100% rename from packages/automation/src/lib/state-machine.ts rename to packages/event-listener/src/lib/state-machine.ts diff --git a/packages/automation/src/lib/states/index.ts b/packages/event-listener/src/lib/states/index.ts similarity index 100% rename from packages/automation/src/lib/states/index.ts rename to packages/event-listener/src/lib/states/index.ts diff --git a/packages/automation/src/lib/states/state.spec.ts b/packages/event-listener/src/lib/states/state.spec.ts similarity index 100% rename from packages/automation/src/lib/states/state.spec.ts rename to packages/event-listener/src/lib/states/state.spec.ts diff --git a/packages/automation/src/lib/states/state.ts b/packages/event-listener/src/lib/states/state.ts similarity index 100% rename from packages/automation/src/lib/states/state.ts rename to packages/event-listener/src/lib/states/state.ts diff --git a/packages/automation/src/lib/transitions/index.ts b/packages/event-listener/src/lib/transitions/index.ts similarity index 100% rename from packages/automation/src/lib/transitions/index.ts rename to packages/event-listener/src/lib/transitions/index.ts diff --git a/packages/automation/src/lib/transitions/transition.spec.ts b/packages/event-listener/src/lib/transitions/transition.spec.ts similarity index 100% rename from packages/automation/src/lib/transitions/transition.spec.ts rename to packages/event-listener/src/lib/transitions/transition.spec.ts diff --git a/packages/automation/src/lib/transitions/transition.ts b/packages/event-listener/src/lib/transitions/transition.ts similarity index 100% rename from packages/automation/src/lib/transitions/transition.ts rename to packages/event-listener/src/lib/transitions/transition.ts diff --git a/packages/automation/src/lib/types.ts b/packages/event-listener/src/lib/types.ts similarity index 100% rename from packages/automation/src/lib/types.ts rename to packages/event-listener/src/lib/types.ts diff --git a/packages/automation/src/lib/utils/chain.ts b/packages/event-listener/src/lib/utils/chain.ts similarity index 100% rename from packages/automation/src/lib/utils/chain.ts rename to packages/event-listener/src/lib/utils/chain.ts diff --git a/packages/automation/src/lib/utils/erc20.ts b/packages/event-listener/src/lib/utils/erc20.ts similarity index 100% rename from packages/automation/src/lib/utils/erc20.ts rename to packages/event-listener/src/lib/utils/erc20.ts diff --git a/packages/automation/tsconfig.json b/packages/event-listener/tsconfig.json similarity index 100% rename from packages/automation/tsconfig.json rename to packages/event-listener/tsconfig.json diff --git a/packages/automation/tsconfig.lib.json b/packages/event-listener/tsconfig.lib.json similarity index 100% rename from packages/automation/tsconfig.lib.json rename to packages/event-listener/tsconfig.lib.json diff --git a/packages/automation/tsconfig.spec.json b/packages/event-listener/tsconfig.spec.json similarity index 100% rename from packages/automation/tsconfig.spec.json rename to packages/event-listener/tsconfig.spec.json From 947367b7191eafa6c30c56a0066cfe5c2f648d0f Mon Sep 17 00:00:00 2001 From: zach-is-my-name Date: Sat, 21 Dec 2024 23:49:31 -0500 Subject: [PATCH 40/43] typo --- packages/event-listener/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/event-listener/README.md b/packages/event-listener/README.md index bc0c660a9c..82195984f9 100644 --- a/packages/event-listener/README.md +++ b/packages/event-listener/README.md @@ -143,7 +143,7 @@ runLitActionInterval().catch(console.error); ## Functional interface -There care cases where such a declarative interface won't be enough for your use case. When that happens, the machines can also accept generic states, actions, transitions and listeners where it is possible to write any logic. +There are cases where such a declarative interface won't be enough for your use case. When that happens, the machines can also accept generic states, actions, transitions and listeners where it is possible to write any logic. Here is an example that listens to Ethereum block hashes, looking for those that end in '0' From 026103a4d1bf78429081e8e1015ab85a235cd207 Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Mon, 23 Dec 2024 15:07:16 +0100 Subject: [PATCH 41/43] feat: add machine drawings in README.md to clarify explanation --- packages/event-listener/README.md | 16 ++++++++++++++++ .../event-listener/graphs/ethZeroBlockhash.png | Bin 0 -> 16652 bytes .../graphs/runLitActionInterval.png | Bin 0 -> 25369 bytes packages/event-listener/graphs/stateMachine.png | Bin 0 -> 75958 bytes packages/event-listener/graphs/usdcBridge.png | Bin 0 -> 51559 bytes 5 files changed, 16 insertions(+) create mode 100644 packages/event-listener/graphs/ethZeroBlockhash.png create mode 100644 packages/event-listener/graphs/runLitActionInterval.png create mode 100644 packages/event-listener/graphs/stateMachine.png create mode 100644 packages/event-listener/graphs/usdcBridge.png diff --git a/packages/event-listener/README.md b/packages/event-listener/README.md index bc0c660a9c..bbe2fe521b 100644 --- a/packages/event-listener/README.md +++ b/packages/event-listener/README.md @@ -20,6 +20,8 @@ yarn add @lit-protocol/event-listener ## Core Concepts +![State Machine](./graphs/stateMachine.png) + ### State Machine A state machine consists of states, and transitions between those states which are triggered based on a collection of Listeners. @@ -66,6 +68,8 @@ Listeners monitor various events and feed data to transitions: Here's a simple example that mints a PKP, a Capacity Delegation NFT and then runs a Lit Action every hour: +![Run LitAction Interval](./graphs/runLitActionInterval.png) + ```typescript async function runLitActionInterval() { const stateMachine = StateMachine.fromDefinition({ @@ -147,6 +151,8 @@ There care cases where such a declarative interface won't be enough for your use Here is an example that listens to Ethereum block hashes, looking for those that end in '0' +![Listen Ethereum Blocks](./graphs/ethZeroBlockhash.png) + ```typescript async function monitorEthereumBlocksWithHashEndingWithZero() { const litNodeClient = new LitNodeClient({ @@ -232,6 +238,8 @@ By leveraging context from the State Machine in combination with Lit PKPs, it is In this example, when a State Machine PKP receives USDC in Base Sepolia, it will send the same amount to the sender but in Ethereum Sepolia. +![USDC Bridge](./graphs/usdcBridge.png) + ```typescript async function bridgeBaseSepoliaUSDCToEthereumSepolia() { const evmSourceNetwork = LIT_EVM_CHAINS.baseSepolia; @@ -374,3 +382,11 @@ async function bridgeBaseSepoliaUSDCToEthereumSepolia() { } bridgeBaseSepoliaUSDCToEthereumSepolia().catch(console.error); ``` + +### Chain Signatures example + +With some minor modifications, the previous example can be adapted to listen transaction requests in a source chain and broadcast transactions in, another, destination chain. + +To see the example with the full implementation, check the [Chain Signatures example](https://github.com/LIT-Protocol/chain-signatures). + +This opens up a wide range of possibilities, such as cross-chain messaging, token swaps, gas sponsorship, offchain multisigs, and more. diff --git a/packages/event-listener/graphs/ethZeroBlockhash.png b/packages/event-listener/graphs/ethZeroBlockhash.png new file mode 100644 index 0000000000000000000000000000000000000000..1f59b26cb348747a07e07f33bb4a9ee83e0670d1 GIT binary patch literal 16652 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 D`wv-e literal 0 HcmV?d00001 diff --git a/packages/event-listener/graphs/stateMachine.png b/packages/event-listener/graphs/stateMachine.png new file mode 100644 index 0000000000000000000000000000000000000000..49bc03de30f8f5286af80bd9e044dc3723d99c5f GIT binary patch literal 75958 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

v^=u*6;9xL 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 literal 0 HcmV?d00001 From 331dc2db046196b5c871089474db670709246317 Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Mon, 23 Dec 2024 15:15:10 +0100 Subject: [PATCH 42/43] feat: add reference to the open source machine running server --- packages/event-listener/README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/event-listener/README.md b/packages/event-listener/README.md index bbe2fe521b..f34568b367 100644 --- a/packages/event-listener/README.md +++ b/packages/event-listener/README.md @@ -390,3 +390,11 @@ With some minor modifications, the previous example can be adapted to listen tra To see the example with the full implementation, check the [Chain Signatures example](https://github.com/LIT-Protocol/chain-signatures). This opens up a wide range of possibilities, such as cross-chain messaging, token swaps, gas sponsorship, offchain multisigs, and more. + +## Long-running machines + +Most likely you would want to run the state machines in a long-running process, such as a server. This way, the machine can keep running and listening to events, executing actions, and transitioning between states. + +We offer a basic express server that can be used to receive state machines declarative definition and run/stop them when needed. + +Check [`LIT-Protocol/automation`](https://github.com/LIT-Protocol/automation) repository and feel free to fork or contribute there. From d4bf15a5c77ff85f5b2db68a0637dfd9dce5c933 Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Mon, 23 Dec 2024 18:59:26 +0100 Subject: [PATCH 43/43] fix: rename event-listener repo reference --- packages/event-listener/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/event-listener/README.md b/packages/event-listener/README.md index 10c18948f9..a6a4ab0d2a 100644 --- a/packages/event-listener/README.md +++ b/packages/event-listener/README.md @@ -397,4 +397,4 @@ Most likely you would want to run the state machines in a long-running process, We offer a basic express server that can be used to receive state machines declarative definition and run/stop them when needed. -Check [`LIT-Protocol/automation`](https://github.com/LIT-Protocol/automation) repository and feel free to fork or contribute there. +Check [`LIT-Protocol/event-listener`](https://github.com/LIT-Protocol/event-listener) repository and feel free to fork or contribute there.