diff --git a/package.json b/package.json index 049fbd439..0449ee248 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,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/constants/src/lib/errors.ts b/packages/constants/src/lib/errors.ts index 03ad6b810..757c711d5 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, diff --git a/packages/event-listener/.babelrc b/packages/event-listener/.babelrc new file mode 100644 index 000000000..158083d27 --- /dev/null +++ b/packages/event-listener/.babelrc @@ -0,0 +1,10 @@ +{ + "presets": [ + [ + "@nx/web/babel", + { + "useBuiltIns": "usage" + } + ] + ] +} diff --git a/packages/event-listener/.eslintrc.json b/packages/event-listener/.eslintrc.json new file mode 100644 index 000000000..9d9c0db55 --- /dev/null +++ b/packages/event-listener/.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/event-listener/README.md b/packages/event-listener/README.md new file mode 100644 index 000000000..a6a4ab0d2 --- /dev/null +++ b/packages/event-listener/README.md @@ -0,0 +1,400 @@ +# @lit-protocol/event-listener + +A TypeScript library for creating and managing automated workflows using Lit Protocol. + +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 +- Monitor token balances and price info +- Perform cross-chain messaging and transaction execution +- And more... + +## Installation + +```bash +npm install @lit-protocol/event-listener +# or +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. + +### Actions + +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 +- Run Lit Actions +- Send blockchain transactions +- Run custom code + +### States + +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 + +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 + +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: + +- 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: + +![Run LitAction Interval](./graphs/runLitActionInterval.png) + +```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', + actions: [ + { + key: 'usePkp', + mint: true, + }, + ], + transitions: [{ toState: 'setCapacityNFT' }], + }, + { + key: 'setCapacityNFT', + actions: [ + { + key: 'useCapacityNFT', + mint: true, + daysUntilUTCMidnightExpiration: 10, + requestPerSecond: 1, + }, + ], + transitions: [{ toState: 'runLitAction' }], + }, + { + key: 'runLitAction', + 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), + }, + }, + ], + transitions: [{ toState: 'cooldown' }], + }, + { + key: 'cooldown', + transitions: [ + { + toState: 'runLitAction', + timer: { + // One hour, checking every second + interval: 1000, // one second + until: 1 * 60 * 60, // 3600 times + }, + }, + ], + }, + ], + }); + + // Start the machine at the desired state + await stateMachine.startMachine('setPKP'); +} + +runLitActionInterval().catch(console.error); +``` + +## Functional interface + +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' + +![Listen Ethereum Blocks](./graphs/ethZeroBlockhash.png) + +```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 + +- `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 manually accessed using its `getFromContext`, `setToContext` or `pushToContext` methods to read or write. + +### Advance example + +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. + +![USDC Bridge](./graphs/usdcBridge.png) + +```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', + actions: [ + { + key: 'usePkp', + pkp, // Configure the pkp passed. Not minting a new one + }, + ], + transitions: [{ toState: 'setCapacityNFT' }], + }, + { + key: 'setCapacityNFT', + actions: [ + { + key: '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', + 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 + { + 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 + 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' }], + }, + ], + }); + + await stateMachine.startMachine('setPKP'); +} +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. + +## 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/event-listener`](https://github.com/LIT-Protocol/event-listener) repository and feel free to fork or contribute there. diff --git a/packages/event-listener/graphs/ethZeroBlockhash.png b/packages/event-listener/graphs/ethZeroBlockhash.png new file mode 100644 index 000000000..1f59b26cb Binary files /dev/null and b/packages/event-listener/graphs/ethZeroBlockhash.png differ diff --git a/packages/event-listener/graphs/runLitActionInterval.png b/packages/event-listener/graphs/runLitActionInterval.png new file mode 100644 index 000000000..1d8f2ee73 Binary files /dev/null and b/packages/event-listener/graphs/runLitActionInterval.png differ diff --git a/packages/event-listener/graphs/stateMachine.png b/packages/event-listener/graphs/stateMachine.png new file mode 100644 index 000000000..49bc03de3 Binary files /dev/null and b/packages/event-listener/graphs/stateMachine.png differ diff --git a/packages/event-listener/graphs/usdcBridge.png b/packages/event-listener/graphs/usdcBridge.png new file mode 100644 index 000000000..504f21aa0 Binary files /dev/null and b/packages/event-listener/graphs/usdcBridge.png differ diff --git a/packages/event-listener/jest.config.ts b/packages/event-listener/jest.config.ts new file mode 100644 index 000000000..a31f92f50 --- /dev/null +++ b/packages/event-listener/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/event-listener', + setupFilesAfterEnv: ['../../jest.setup.js'], +}; diff --git a/packages/event-listener/package.json b/packages/event-listener/package.json new file mode 100644 index 000000000..240f1cf14 --- /dev/null +++ b/packages/event-listener/package.json @@ -0,0 +1,32 @@ +{ + "name": "@lit-protocol/event-listener", + "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/event-listener" + }, + "tags": [ + "universal" + ], + "buildOptions": { + "genReact": false + }, + "scripts": { + "generate-lit-actions": "yarn node ./esbuild.config.js" + }, + "version": "7.0.2", + "main": "./dist/src/index.js", + "typings": "./dist/src/index.d.ts" +} diff --git a/packages/event-listener/project.json b/packages/event-listener/project.json new file mode 100644 index 000000000..5274d0c61 --- /dev/null +++ b/packages/event-listener/project.json @@ -0,0 +1,37 @@ +{ + "name": "event-listener", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/event-listener/src", + "projectType": "library", + "targets": { + "build": { + "cache": false, + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "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"] + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["packages/event-listener/**/*.ts"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/packages/event-listener"], + "options": { + "jestConfig": "packages/event-listener/jest.config.ts", + "passWithNoTests": true + } + } + }, + "tags": [] +} diff --git a/packages/event-listener/src/index.ts b/packages/event-listener/src/index.ts new file mode 100644 index 000000000..12fc9ac47 --- /dev/null +++ b/packages/event-listener/src/index.ts @@ -0,0 +1,5 @@ +export * from './lib/listeners'; +export * from './lib/states'; +export * from './lib/state-machine'; +export * from './lib/transitions'; +export * from './lib/types'; diff --git a/packages/event-listener/src/lib/actions/action.ts b/packages/event-listener/src/lib/actions/action.ts new file mode 100644 index 000000000..a1ecdff09 --- /dev/null +++ b/packages/event-listener/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/event-listener/src/lib/actions/index.ts b/packages/event-listener/src/lib/actions/index.ts new file mode 100644 index 000000000..4e9c11b2e --- /dev/null +++ b/packages/event-listener/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/event-listener/src/lib/actions/lit-action.ts b/packages/event-listener/src/lib/actions/lit-action.ts new file mode 100644 index 000000000..b5db207fc --- /dev/null +++ b/packages/event-listener/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/event-listener/src/lib/actions/log-context.ts b/packages/event-listener/src/lib/actions/log-context.ts new file mode 100644 index 000000000..4196d3e74 --- /dev/null +++ b/packages/event-listener/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/event-listener/src/lib/actions/mint-capacity-credit.ts b/packages/event-listener/src/lib/actions/mint-capacity-credit.ts new file mode 100644 index 000000000..827900095 --- /dev/null +++ b/packages/event-listener/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/event-listener/src/lib/actions/mint-pkp.ts b/packages/event-listener/src/lib/actions/mint-pkp.ts new file mode 100644 index 000000000..353b6f79c --- /dev/null +++ b/packages/event-listener/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/event-listener/src/lib/actions/transaction.ts b/packages/event-listener/src/lib/actions/transaction.ts new file mode 100644 index 000000000..e93ff3d91 --- /dev/null +++ b/packages/event-listener/src/lib/actions/transaction.ts @@ -0,0 +1,180 @@ +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'; + +function arrayfy(value: unknown) { + return Array.isArray(value) ? value : [value]; +} + +interface TransactionActionBaseParams { + debug?: boolean; + stateMachine: StateMachine; + evmChainId: ContextOrLiteral; + contractAddress: 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 () => { + 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` + ); + } + + // Resolve transaction data from context + const txChainId = params.stateMachine.resolveContextPathOrLiteral( + params.evmChainId + ); + const chain = getEvmChain(txChainId); + const chainProvider = new ethers.providers.JsonRpcProvider( + chain.rpcUrls[0], + chain.chainId + ); + + const txContractAddress = params.stateMachine.resolveContextPathOrLiteral( + params.contractAddress + ); + const txValue = params.stateMachine.resolveContextPathOrLiteral( + params.value + ); + // 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: txContractAddress, + data: txData, + from: activePkp.ethAddress, + }); + const gasPrice = await chainProvider.getGasPrice(); + const nonce = await chainProvider.getTransactionCount( + activePkp.ethAddress + ); + + const rawTx: ethers.UnsignedTransaction = { + chainId: chain.chainId, + data: txData, + gasLimit: gasLimit.toHexString(), + gasPrice: gasPrice.toHexString(), + nonce, + 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({ + 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', + }, + }); + if (!litActionResponse.success) { + throw new AutomationError( + { + info: { + machineId: params.stateMachine.id, + 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, + }, + }, + `Failed to sign 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 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/event-listener/src/lib/context/index.ts b/packages/event-listener/src/lib/context/index.ts new file mode 100644 index 000000000..a6c919840 --- /dev/null +++ b/packages/event-listener/src/lib/context/index.ts @@ -0,0 +1 @@ +export * from './machine-context'; diff --git a/packages/event-listener/src/lib/context/machine-context.spec.ts b/packages/event-listener/src/lib/context/machine-context.spec.ts new file mode 100644 index 000000000..6742b810d --- /dev/null +++ b/packages/event-listener/src/lib/context/machine-context.spec.ts @@ -0,0 +1,186 @@ +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/event-listener/src/lib/context/machine-context.ts b/packages/event-listener/src/lib/context/machine-context.ts new file mode 100644 index 000000000..e656dcf11 --- /dev/null +++ b/packages/event-listener/src/lib/context/machine-context.ts @@ -0,0 +1,99 @@ +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[]): T { + return getFromObject(this.context, path) as T; + } + + 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/event-listener/src/lib/listeners/constant.spec.ts b/packages/event-listener/src/lib/listeners/constant.spec.ts new file mode 100644 index 000000000..e8c6cee12 --- /dev/null +++ b/packages/event-listener/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/event-listener/src/lib/listeners/constant.ts b/packages/event-listener/src/lib/listeners/constant.ts new file mode 100644 index 000000000..9565b8f19 --- /dev/null +++ b/packages/event-listener/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/event-listener/src/lib/listeners/evm-block.spec.ts b/packages/event-listener/src/lib/listeners/evm-block.spec.ts new file mode 100644 index 000000000..44619c9ee --- /dev/null +++ b/packages/event-listener/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/event-listener/src/lib/listeners/evm-block.ts b/packages/event-listener/src/lib/listeners/evm-block.ts new file mode 100644 index 000000000..0a671b2c8 --- /dev/null +++ b/packages/event-listener/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/event-listener/src/lib/listeners/evm-contract-event.spec.ts b/packages/event-listener/src/lib/listeners/evm-contract-event.spec.ts new file mode 100644 index 000000000..c4191cced --- /dev/null +++ b/packages/event-listener/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/event-listener/src/lib/listeners/evm-contract-event.ts b/packages/event-listener/src/lib/listeners/evm-contract-event.ts new file mode 100644 index 000000000..f444ce6e8 --- /dev/null +++ b/packages/event-listener/src/lib/listeners/evm-contract-event.ts @@ -0,0 +1,58 @@ +import { ethers } from 'ethers'; + +import { Listener } from './listener'; + +export interface ContractEventData { + event: ethers.Event; + args: unknown[]; + blockNumber: number; + transactionHash: string; +} + +export interface ContractInfo { + address: string; + abi: ethers.ContractInterface; +} + +export interface EventInfo { + name: string; + filter?: unknown[]; +} + +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/event-listener/src/lib/listeners/fetch.spec.ts b/packages/event-listener/src/lib/listeners/fetch.spec.ts new file mode 100644 index 000000000..3f881cc83 --- /dev/null +++ b/packages/event-listener/src/lib/listeners/fetch.spec.ts @@ -0,0 +1,55 @@ +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/event-listener/src/lib/listeners/fetch.ts b/packages/event-listener/src/lib/listeners/fetch.ts new file mode 100644 index 000000000..681cd94bb --- /dev/null +++ b/packages/event-listener/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/event-listener/src/lib/listeners/index.ts b/packages/event-listener/src/lib/listeners/index.ts new file mode 100644 index 000000000..6f6e9c0e9 --- /dev/null +++ b/packages/event-listener/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/event-listener/src/lib/listeners/interval.spec.ts b/packages/event-listener/src/lib/listeners/interval.spec.ts new file mode 100644 index 000000000..47ed35616 --- /dev/null +++ b/packages/event-listener/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/event-listener/src/lib/listeners/interval.ts b/packages/event-listener/src/lib/listeners/interval.ts new file mode 100644 index 000000000..2a4f0ea27 --- /dev/null +++ b/packages/event-listener/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/event-listener/src/lib/listeners/listener.spec.ts b/packages/event-listener/src/lib/listeners/listener.spec.ts new file mode 100644 index 000000000..d0713d13d --- /dev/null +++ b/packages/event-listener/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/event-listener/src/lib/listeners/listener.ts b/packages/event-listener/src/lib/listeners/listener.ts new file mode 100644 index 000000000..c085b0939 --- /dev/null +++ b/packages/event-listener/src/lib/listeners/listener.ts @@ -0,0 +1,75 @@ +import { EventEmitter } from 'events'; + +import { onError, voidAsyncFunction } from '../types'; + +export interface ListenerParams { + start?: voidAsyncFunction; + stop?: voidAsyncFunction; + onError?: onError; +} + +/** + * 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: voidAsyncFunction; + + /** + * The stop function called when all listeners are stopped. + */ + public stop: voidAsyncFunction; + + /** + * 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. + */ + constructor({ + start = async () => {}, + stop = async () => {}, + onError, + }: ListenerParams = {}) { + this.start = start; + this.stop = stop; + this.onError = onError; + } + + /** + * 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/event-listener/src/lib/listeners/timer.spec.ts b/packages/event-listener/src/lib/listeners/timer.spec.ts new file mode 100644 index 000000000..84a79a5ec --- /dev/null +++ b/packages/event-listener/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/event-listener/src/lib/listeners/timer.ts b/packages/event-listener/src/lib/listeners/timer.ts new file mode 100644 index 000000000..5e06e7823 --- /dev/null +++ b/packages/event-listener/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/event-listener/src/lib/litActions.ts b/packages/event-listener/src/lib/litActions.ts new file mode 100644 index 000000000..10546e755 --- /dev/null +++ b/packages/event-listener/src/lib/litActions.ts @@ -0,0 +1,84 @@ +import { ethers } from 'ethers'; + +import { LitActionResource } from '@lit-protocol/auth-helpers'; +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'; + +export const signWithLitActionCode = `(async () => { + const signature = await Lit.Actions.signAndCombineEcdsa({ + toSign, + publicKey, + sigName, + }); + + Lit.Actions.setResponse({ response: signature }); + })();`; + +interface ExecuteLitAction { + litNodeClient: LitNodeClient; + capacityTokenId?: string; + pkpEthAddress: string; + pkpPublicKey: string; + authSigner: ethers.Wallet; + ipfsId?: string; + code?: string; + jsParams?: Record; +} + +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: capacityDelegationAuthSig + ? [capacityDelegationAuthSig] + : [], + authMethods: [ + await EthWalletProvider.authenticate({ + signer: authSigner, + litNodeClient: litNodeClient, + expiration, + }), + ], + resourceAbilityRequests: [ + { + resource: new LitActionResource('*'), + ability: LIT_ABILITY.LitActionExecution, + }, + ], + expiration, + }); + + const executeJsResponse = await litNodeClient.executeJs({ + ipfsId, + code, + jsParams, + sessionSigs: pkpSessionSigs, + }); + + return executeJsResponse; +} diff --git a/packages/event-listener/src/lib/state-machine.spec.ts b/packages/event-listener/src/lib/state-machine.spec.ts new file mode 100644 index 000000000..82448ac10 --- /dev/null +++ b/packages/event-listener/src/lib/state-machine.spec.ts @@ -0,0 +1,214 @@ +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, + privateKey: '0xPRIVATE_KEY', +}; + +describe('StateMachine', () => { + let stateMachine: StateMachine; + let listener: Listener; + let check: jest.Mock; + let onMatch: jest.Mock; + let callOrder: string[]; + + beforeEach(() => { + callOrder = []; + stateMachine = new StateMachine(stateMachineParams); + listener = new Listener(); + 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 generate a unique id for each state machine instance', () => { + const anotherStateMachine = new StateMachine(stateMachineParams); + 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', + 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(stateMachineParams); + 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 + ); + }); + }); + + 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['context']['context']).toEqual(initialContext); + }); + + it('should allow getting and setting context values', () => { + machine['context'].set('new.value', 42); + expect(machine['context'].get('new.value')).toBe(42); + }); + }); +}); diff --git a/packages/event-listener/src/lib/state-machine.ts b/packages/event-listener/src/lib/state-machine.ts new file mode 100644 index 000000000..b3b4b9677 --- /dev/null +++ b/packages/event-listener/src/lib/state-machine.ts @@ -0,0 +1,704 @@ +import { ethers } from 'ethers'; + +import { + AutomationError, + UnknownError, + 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, + IntervalListener, + Listener, + TimerListener, +} from './listeners'; +import { State, StateParams } from './states'; +import { CheckFn, Transition } from './transitions'; +import { + ActionDefinition, + BaseStateMachineParams, + ContextOrLiteral, + StateDefinition, + StateMachineDefinition, + TransitionDefinition, + TransitionParams, + voidAsyncFunction, +} from './types'; +import { getEvmChain } from './utils/chain'; +import { getBalanceTransitionCheck, getERC20Balance } from './utils/erc20'; + +export type MachineStatus = 'running' | 'stopped'; + +export const StateMachineInitialContext = Object.freeze({ + activeCapacityTokenId: undefined, + activePkp: undefined, +}); + +/** + * A StateMachine class that manages states and transitions between them. + */ +export class StateMachine { + private readonly debug; + private readonly onError?: (error: unknown, context?: string) => void; + private context: MachineContext; + + public readonly litNodeClient: LitNodeClient; + private readonly privateKey?: string; + public litContracts: LitContracts; + + public id: string; + public status: MachineStatus = 'stopped'; + private states = new Map(); + private transitions = new Map>(); + private currentState?: State; + private onStopCallback?: voidAsyncFunction; + + constructor(params: BaseStateMachineParams) { + this.id = this.generateId(); + this.debug = params.debug ?? false; + this.onError = params.onError; + this.context = new MachineContext({ + ...StateMachineInitialContext, + ...params.context, + }); + + this.litNodeClient = params.litNodeClient; + this.litContracts = params.litContracts; + this.privateKey = params.privateKey; + } + + static fromDefinition(machineConfig: StateMachineDefinition): StateMachine { + const { + debug = false, + context, + onError, + litContracts = {}, + litNodeClient, + privateKey, + states = [], + transitions = [], + } = machineConfig; + + // Create litNodeClient and litContracts instances + const litNodeClientInstance = + 'connect' in litNodeClient + ? litNodeClient + : new LitNodeClient(litNodeClient); + const litContractsInstance = + 'connect' in litContracts + ? litContracts + : new LitContracts({ + privateKey, + ...litContracts, + }); + + if ( + litNodeClientInstance.config.litNetwork !== litContractsInstance.network + ) { + throw new AutomationError( + { + info: { + litNodeClientNetwork: litNodeClientInstance.config.litNetwork, + litContractsNetwork: litContractsInstance.network, + }, + }, + 'litNodeClient and litContracts should not use different networks' + ); + } + + const stateMachine = new StateMachine({ + debug, + context, + litNodeClient: litNodeClientInstance, + litContracts: litContractsInstance, + privateKey, + onError, + }); + + const stateTransitions = [] as TransitionDefinition[]; + states.forEach((stateDefinition) => { + const transitions = stateDefinition.transitions || []; + stateTransitions.push( + ...transitions.map((transition) => ({ + ...transition, + fromState: stateDefinition.key, + })) + ); + + stateMachine.addStateFromDefinition(stateDefinition); + }); + + [...stateTransitions, ...transitions].forEach((transition) => { + stateMachine.addTransitionFromDefinition(transition); + }); + + return stateMachine; + } + + /** + * Indicates if the state machine is running + */ + get isRunning(): boolean { + return this.status === 'running'; + } + + /** + * Returns an ethers Wallet the state machine can use + */ + get signer(): ethers.Wallet { + 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. + */ + addState(params: StateParams): void { + 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 state to the state machine using the declarative interface. + * @param stateDefinition The state definition. + */ + addStateFromDefinition(stateDefinition: StateDefinition): void { + const stateParams: StateParams = { + key: stateDefinition.key, + debug: this.debug, + }; + + // Merge all state actions + const { actions = [] } = stateDefinition; + stateParams.onEnter = this.mergeActions(actions); + + this.addState(stateParams); + } + + /** + * Adds a transition between two states. + * @param params The parameters for the transition. + */ + addTransition({ + actions = [], + fromState, + toState, + listeners, + check, + onMatch, + onMismatch, + }: TransitionParams): void { + if (!this.states.has(fromState)) { + throw new AutomationError( + { + info: { + machineId: this.id, + fromState: fromState, + toState: toState, + }, + }, + `Source state ${fromState} not found` + ); + } + if (!this.states.has(toState)) { + throw new AutomationError( + { + info: { + machineId: this.id, + fromState: fromState, + toState: toState, + }, + }, + `Target state ${toState} not found` + ); + } + + const transitioningOnMatch = async (values: (unknown | undefined)[]) => { + await this.mergeActions(actions)(); + await onMatch?.(values); + 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, + }); + + const stateTransitions = + this.transitions.get(fromState) ?? new Map(); + stateTransitions.set(toState, transition); + this.transitions.set(fromState, stateTransitions); + } + + addTransitionFromDefinition(transitionDefinition: TransitionDefinition) { + const { actions, balances, evmContractEvent, fromState, timer, toState } = + transitionDefinition; + + const transitionConfig: TransitionParams = { + actions, + fromState, + toState, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Listeners can be any type + 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 as Record | undefined, + 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. + * @param onStop Optional callback to execute when the machine is stopped. + */ + async startMachine( + initialState: string, + onStop?: voidAsyncFunction + ): 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'; + + 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[]): 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; + } + + /** + * 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: unknown): 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: unknown): void { + this.context.push(path, value); + } + + /** + * Stops the state machine by exiting the current state and not moving to another one. + */ + public async stopMachine(): Promise { + this.debug && console.log('Stopping state machine...'); + + this.status = 'stopped'; + await this.exitCurrentState(); + await this.onStopCallback?.(); + + this.debug && console.log('State machine stopped'); + } + + /** + * Stops listening on the current state's transitions and exits the current state. + */ + private async exitCurrentState(): Promise { + 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): Promise { + const state = this.states.get(stateKey); + if (!state) { + 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(); + 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): Promise { + const nextState = this.states.get(stateKey); + + if (!nextState) { + throw new UnknownError( + { + machineId: this.id, + currentState: this.currentState, + nextState: stateKey, + }, + `Machine next state not found` + ); + } + if (this.currentState === nextState) { + console.warn( + `State ${stateKey} is already active. Skipping state change.` + ); + 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; + this.handleError(e, `Could not enter state ${stateKey}`); + } + } + + /** + * 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 (typeof action.log?.path === 'string') { + 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 + * @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( + { + 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); + } + } + + /** + * 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/event-listener/src/lib/states/index.ts b/packages/event-listener/src/lib/states/index.ts new file mode 100644 index 000000000..da8854349 --- /dev/null +++ b/packages/event-listener/src/lib/states/index.ts @@ -0,0 +1 @@ +export * from './state'; diff --git a/packages/event-listener/src/lib/states/state.spec.ts b/packages/event-listener/src/lib/states/state.spec.ts new file mode 100644 index 000000000..7f08ebe3d --- /dev/null +++ b/packages/event-listener/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/event-listener/src/lib/states/state.ts b/packages/event-listener/src/lib/states/state.ts new file mode 100644 index 000000000..b9501c62b --- /dev/null +++ b/packages/event-listener/src/lib/states/state.ts @@ -0,0 +1,43 @@ +import { voidAsyncFunction } from '../types'; + +export interface BaseStateParams { + key: string; + onEnter?: voidAsyncFunction; + onExit?: voidAsyncFunction; + debug?: boolean; +} + +export type StateParams = BaseStateParams; + +/** + * A State class that represents a state with optional entry and exit actions. + */ +export class State { + private readonly debug; + public readonly key: string; + public readonly onEnter: voidAsyncFunction | undefined; + public readonly onExit: voidAsyncFunction | undefined; + + constructor(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/event-listener/src/lib/transitions/index.ts b/packages/event-listener/src/lib/transitions/index.ts new file mode 100644 index 000000000..df7a702a1 --- /dev/null +++ b/packages/event-listener/src/lib/transitions/index.ts @@ -0,0 +1 @@ +export * from './transition'; diff --git a/packages/event-listener/src/lib/transitions/transition.spec.ts b/packages/event-listener/src/lib/transitions/transition.spec.ts new file mode 100644 index 000000000..6e954a9f2 --- /dev/null +++ b/packages/event-listener/src/lib/transitions/transition.spec.ts @@ -0,0 +1,164 @@ +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; +} + +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 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); + 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 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 + await expect(onMatch).not.toHaveBeenCalled(); + }); + + it('should stop calling callbacks after stopListening', async () => { + await transition.startListening(); + + // 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 + + 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 flushPromises(); + + 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([]); + }); + + 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/event-listener/src/lib/transitions/transition.ts b/packages/event-listener/src/lib/transitions/transition.ts new file mode 100644 index 000000000..8b7ed60b1 --- /dev/null +++ b/packages/event-listener/src/lib/transitions/transition.ts @@ -0,0 +1,149 @@ +import { Listener } from '../listeners'; +import { onError } from '../types'; + +export type CheckFn = (values: (unknown | undefined)[]) => Promise; +export type resultFn = (values: (unknown | undefined)[]) => Promise; +type Values = (unknown | undefined)[]; + +export interface BaseTransitionParams { + debug?: boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Listeners can be any type + listeners?: Listener[]; + check?: CheckFn; + onMatch: resultFn; + onMismatch?: resultFn; + 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[]; + private readonly values: Values; + private readonly check?: CheckFn; + private readonly onMatch: resultFn; + private readonly onMismatch?: resultFn; + private readonly onError?: onError; + 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. + * + * @param params An object containing listeners, check function, and optional onMatch and onMismatch functions. + */ + constructor({ + debug, + listeners = [], + 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(); + } + + /** + * Sets up listeners for state changes and handles transition logic. + */ + private setupListeners() { + this.listeners.forEach((listener, index) => { + listener.onStateChange(async (value: unknown) => { + this.values[index] = value; + + // Enqueue the updated values + this.queue.push([...this.values]); + + // Process the queue + await this.processQueue(); + }); + listener.onError?.(this.onError); + }); + } + + /** + * Starts all listeners for this transition. + */ + async startListening() { + 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; + } + } + } + + /** + * Stops all listeners for this transition. + */ + async stopListening() { + 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() { + try { + // 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 + } catch (e) { + if (this.onError) { + this.onError(e); + } else { + throw e; + } + } + } +} diff --git a/packages/event-listener/src/lib/types.ts b/packages/event-listener/src/lib/types.ts new file mode 100644 index 000000000..1ec982857 --- /dev/null +++ b/packages/event-listener/src/lib/types.ts @@ -0,0 +1,193 @@ +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 type voidAsyncFunction = () => Promise; +export type onError = (error: unknown) => void; + +export interface PKPInfo { + tokenId: string; + publicKey: string; + ethAddress: string; +} + +export interface OnEvmChain { + evmChainId: ContextOrLiteral; +} + +export interface ContextAccess { + contextPath: string; +} + +// Context Types +export type ContextOrLiteral = T | ContextAccess; + +interface ContextUpdate extends ContextAccess { + dataPath: string; +} + +export interface UpdatesContext { + contextUpdates?: ContextUpdate[]; +} + +// Action Types +export interface LitActionActionDefinition { + key: 'litAction'; + code?: ContextOrLiteral; + ipfsId?: ContextOrLiteral; + jsParams?: Record; +} + +export interface ContextActionDefinition { + key: 'context'; + log?: { + path: string; + }; +} + +interface TransactionActionBaseDefinition extends OnEvmChain { + key: 'transaction'; + contractAddress: 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; +} + +export interface MintPkpActionDefinition extends MintActionDefinition { + key: 'usePkp'; +} + +export interface MintCapacityNFTActionDefinition extends MintActionDefinition { + key: 'useCapacityNFT'; + daysUntilUTCMidnightExpiration: number; + requestPerSecond: number; +} + +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 { + key: string; + actions?: ActionDefinition[]; + transitions?: Omit[]; +} + +// Transition Types +export interface IntervalTransitionDefinition { + interval?: number; +} + +export interface BaseBalanceTransitionDefinition + extends IntervalTransitionDefinition, + OnEvmChain { + address: Address; + amount: string; + comparator: '>' | '>=' | '=' | '!=' | '<=' | '<'; +} + +export interface NativeBalanceTransitionDefinition + extends BaseBalanceTransitionDefinition { + type: 'native'; +} + +export interface ERC20BalanceTransitionDefinition + extends BaseBalanceTransitionDefinition { + tokenAddress: Address; + tokenDecimals: number; + type: 'ERC20'; +} + +// 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 OnEvmChain, + UpdatesContext { + contractABI: ethers.ContractInterface; + contractAddress: Address; + eventName: string; + eventParams?: unknown[]; +} + +export interface TransitionDefinition { + balances?: BalanceTransitionDefinition[]; + evmContractEvent?: EvmContractEventTransitionDefinition; + fromState: string; + timer?: TimerTransitionDefinition; + toState: string; + actions?: ActionDefinition[]; +} + +export interface TransitionParams + extends Omit, + Partial> { + actions?: ActionDefinition[]; + fromState: string; + toState: string; +} + +// Machine Types +export interface BaseStateMachineParams { + context?: Record; + debug?: boolean; + litContracts: LitContracts; + litNodeClient: LitNodeClient; + onError?: (error: unknown, context?: string) => void; + privateKey: string; +} + +export interface StateMachineDefinition + extends Omit { + litNodeClient: LitNodeClient | ConstructorParameters[0]; + litContracts: LitContracts | ConstructorParameters[0]; + states: StateDefinition[]; + transitions?: TransitionDefinition[]; +} diff --git a/packages/event-listener/src/lib/utils/chain.ts b/packages/event-listener/src/lib/utils/chain.ts new file mode 100644 index 000000000..91025ee32 --- /dev/null +++ b/packages/event-listener/src/lib/utils/chain.ts @@ -0,0 +1,19 @@ +import { ethers } from 'ethers'; + +import { LIT_EVM_CHAINS } from '@lit-protocol/constants'; + +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 === evmChainIdNumber + ); + if (!chain) { + throw new Error(`EVM chain with chainId ${evmChainId} not found`); + } + + return chain; +} diff --git a/packages/event-listener/src/lib/utils/erc20.ts b/packages/event-listener/src/lib/utils/erc20.ts new file mode 100644 index 000000000..b1e04abd3 --- /dev/null +++ b/packages/event-listener/src/lib/utils/erc20.ts @@ -0,0 +1,77 @@ +import { ethers } from 'ethers'; + +import { Address, 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: Address, + tokenDecimals: number, + accountAddress: Address +) { + 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; +} diff --git a/packages/event-listener/tsconfig.json b/packages/event-listener/tsconfig.json new file mode 100644 index 000000000..8cb12823a --- /dev/null +++ b/packages/event-listener/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": ["global.d.ts"], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/event-listener/tsconfig.lib.json b/packages/event-listener/tsconfig.lib.json new file mode 100644 index 000000000..8261486ed --- /dev/null +++ b/packages/event-listener/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/event-listener/tsconfig.spec.json b/packages/event-listener/tsconfig.spec.json new file mode 100644 index 000000000..48d6d00bb --- /dev/null +++ b/packages/event-listener/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 e36b3f094..cfe699aa1 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.json b/packages/wrapped-keys-lit-actions/tsconfig.json index d3187ebee..2e798eb14 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, diff --git a/packages/wrapped-keys-lit-actions/tsconfig.lib.json b/packages/wrapped-keys-lit-actions/tsconfig.lib.json index c89e6dbca..ce6170610 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 e36b3f094..f775242d3 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 5dec7b252..a50832294 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5149,6 +5149,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"