diff --git a/abi/ERC721.json b/abi/ERC721.json new file mode 100644 index 0000000..d381c01 --- /dev/null +++ b/abi/ERC721.json @@ -0,0 +1,256 @@ +[ + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "approved", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "operator", + "type": "address" + }, + { + "indexed": false, + "internalType": "bool", + "name": "approved", + "type": "bool" + } + ], + "name": "ApprovalForAll", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "inputs": [ + { "internalType": "address", "name": "to", "type": "address" }, + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "name": "", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "owner", "type": "address" } + ], + "name": "balanceOf", + "outputs": [ + { "internalType": "uint256", "name": "balance", "type": "uint256" } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "uint256", "name": "tokenId", "type": "uint256" } + ], + "name": "getApproved", + "outputs": [ + { "internalType": "address", "name": "operator", "type": "address" } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "owner", "type": "address" }, + { + "internalType": "address", + "name": "operator", + "type": "address" + } + ], + "name": "isApprovedForAll", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "name", + "outputs": [{ "internalType": "string", "name": "", "type": "string" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "uint256", "name": "tokenId", "type": "uint256" } + ], + "name": "ownerOf", + "outputs": [ + { "internalType": "address", "name": "owner", "type": "address" } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "from", "type": "address" }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { "internalType": "uint256", "name": "tokenId", "type": "uint256" } + ], + "name": "safeTransferFrom", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "from", "type": "address" }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { "internalType": "uint256", "name": "tokenId", "type": "uint256" }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "safeTransferFrom", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "operator", + "type": "address" + }, + { + "internalType": "bool", + "name": "_approved", + "type": "bool" + } + ], + "name": "setApprovalForAll", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes4", + "name": "interfaceId", + "type": "bytes4" + } + ], + "name": "supportsInterface", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "symbol", + "outputs": [{ "internalType": "string", "name": "", "type": "string" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "uint256", "name": "tokenId", "type": "uint256" } + ], + "name": "tokenURI", + "outputs": [{ "internalType": "string", "name": "", "type": "string" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "from", "type": "address" }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { "internalType": "uint256", "name": "tokenId", "type": "uint256" } + ], + "name": "transferFrom", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] diff --git a/db/migrations/1702358613609-Data.js b/db/migrations/1702358613609-Data.js new file mode 100644 index 0000000..32092bd --- /dev/null +++ b/db/migrations/1702358613609-Data.js @@ -0,0 +1,23 @@ +module.exports = class Data1702358613609 { + name = 'Data1702358613609' + + async up(db) { + await db.query(`CREATE TABLE "nft" ("id" character varying NOT NULL, "name" text, "symbol" text, CONSTRAINT "PK_8f46897c58e23b0e7bf6c8e56b0" PRIMARY KEY ("id"))`) + await db.query(`CREATE TABLE "erc721_deposit" ("id" character varying NOT NULL, "from" text NOT NULL, "token_index" numeric NOT NULL, "token_id" character varying, CONSTRAINT "PK_d23b7232706bd451820114b153e" PRIMARY KEY ("id"))`) + await db.query(`CREATE INDEX "IDX_b79ae2a55e51afa505e904a123" ON "erc721_deposit" ("token_id") `) + await db.query(`ALTER TABLE "input" ADD "erc721_deposit_id" character varying`) + await db.query(`CREATE INDEX "IDX_0f62b1d844bf93606f92d72e73" ON "input" ("erc721_deposit_id") `) + await db.query(`ALTER TABLE "erc721_deposit" ADD CONSTRAINT "FK_b79ae2a55e51afa505e904a1234" FOREIGN KEY ("token_id") REFERENCES "nft"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`) + await db.query(`ALTER TABLE "input" ADD CONSTRAINT "FK_0f62b1d844bf93606f92d72e73f" FOREIGN KEY ("erc721_deposit_id") REFERENCES "erc721_deposit"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`) + } + + async down(db) { + await db.query(`DROP TABLE "nft"`) + await db.query(`DROP TABLE "erc721_deposit"`) + await db.query(`DROP INDEX "public"."IDX_b79ae2a55e51afa505e904a123"`) + await db.query(`ALTER TABLE "input" DROP COLUMN "erc721_deposit_id"`) + await db.query(`DROP INDEX "public"."IDX_0f62b1d844bf93606f92d72e73"`) + await db.query(`ALTER TABLE "erc721_deposit" DROP CONSTRAINT "FK_b79ae2a55e51afa505e904a1234"`) + await db.query(`ALTER TABLE "input" DROP CONSTRAINT "FK_0f62b1d844bf93606f92d72e73f"`) + } +} diff --git a/schema.graphql b/schema.graphql index 0ae447b..f18fa49 100644 --- a/schema.graphql +++ b/schema.graphql @@ -18,7 +18,20 @@ type Token @entity @cardinality(value: 10) { decimals: Int! } -type Erc20Deposit @entity { +type NFT @entity @cardinality(value: 10) { + id: ID! + name: String + symbol: String +} + +type Erc721Deposit @entity @cardinality(value: 100) { + id: ID! + from: String! + token: NFT! + tokenIndex: BigInt! +} + +type Erc20Deposit @entity @cardinality(value: 100) { id: ID! token: Token! from: String! @@ -36,4 +49,5 @@ type Input @entity @cardinality(value: 100) { blockHash: String! transactionHash: String! erc20Deposit: Erc20Deposit + erc721Deposit: Erc721Deposit } diff --git a/src/config.ts b/src/config.ts index 5bf74f7..0f6e658 100644 --- a/src/config.ts +++ b/src/config.ts @@ -12,6 +12,8 @@ export const CartesiDAppFactoryAddress = export const ERC20PortalAddress = mainnet.contracts.ERC20Portal.address.toLowerCase(); export const InputBoxAddress = mainnet.contracts.InputBox.address.toLowerCase(); +export const ERC721PortalAddress = + mainnet.contracts.ERC721Portal.address.toLowerCase(); export type ProcessorConfig = { dataSource: DataSource; diff --git a/src/handlers/InputAdded.ts b/src/handlers/InputAdded.ts index d8c6b0d..1194c07 100644 --- a/src/handlers/InputAdded.ts +++ b/src/handlers/InputAdded.ts @@ -2,15 +2,33 @@ import { BlockData, DataHandlerContext, Log } from '@subsquid/evm-processor'; import { Store } from '@subsquid/typeorm-store'; import { dataSlice, getNumber, getUint } from 'ethers'; import { Contract as ERC20 } from '../abi/ERC20'; +import { Contract as ERC721 } from '../abi/ERC721'; import { events } from '../abi/InputBox'; -import { ERC20PortalAddress, InputBoxAddress } from '../config'; -import { Application, Erc20Deposit, Input, Token } from '../model'; +import { + ERC20PortalAddress, + ERC721PortalAddress, + InputBoxAddress, +} from '../config'; +import { + Application, + Erc20Deposit, + Erc721Deposit, + Input, + NFT, + Token, +} from '../model'; import Handler from './Handler'; +const logErrorAndReturnNull = + (ctx: DataHandlerContext) => (reason: any) => { + ctx.log.error(reason); + return null; + }; + export default class InputAdded implements Handler { constructor( - private tokenStorage: Map, - private depositStorage: Map, + private tokenStorage: Map, + private depositStorage: Map, private applicationStorage: Map, private inputStorage: Map, ) {} @@ -26,7 +44,7 @@ export default class InputAdded implements Handler { const from = dataSlice(input.payload, 21, 41).toLowerCase(); // 20 bytes for address const amount = getUint(dataSlice(input.payload, 41, 73)); // 32 bytes for uint256 - let token = this.tokenStorage.get(tokenAddress); + let token = this.tokenStorage.get(tokenAddress) as Token; if (!token) { const contract = new ERC20(ctx, block.header, tokenAddress); const name = await contract.name(); @@ -44,9 +62,51 @@ export default class InputAdded implements Handler { }); return deposit; } + return undefined; } + async prepareErc721Deposit( + input: Input, + block: BlockData, + ctx: DataHandlerContext, + opts: { + inputId: String; + }, + ) { + if (input.msgSender !== ERC721PortalAddress) return undefined; + + const tokenAddress = dataSlice(input.payload, 0, 20).toLowerCase(); // 20 bytes for address + const from = dataSlice(input.payload, 20, 40).toLowerCase(); // 20 bytes for address + const tokenIndex = getUint(dataSlice(input.payload, 40, 72)); // 32 bytes for uint256 + + let nft = this.tokenStorage.get(tokenAddress) as NFT; + if (!nft) { + const contract = new ERC721(ctx, block.header, tokenAddress); + const name = await contract + .name() + .catch(logErrorAndReturnNull(ctx)); + const symbol = await contract + .symbol() + .catch(logErrorAndReturnNull(ctx)); + nft = new NFT({ id: tokenAddress, name, symbol }); + this.tokenStorage.set(tokenAddress, nft); + ctx.log.info(`${tokenAddress} (NFT) stored`); + } + + const deposit = new Erc721Deposit({ + id: input.id, + from, + token: nft, + tokenIndex, + }); + + this.depositStorage.set(opts.inputId, deposit); + ctx.log.info(`${opts.inputId} (Erc721Deposit) stored`); + + return deposit; + } + async handle(log: Log, block: BlockData, ctx: DataHandlerContext) { if ( log.address === InputBoxAddress && @@ -82,12 +142,23 @@ export default class InputAdded implements Handler { blockHash: log.block.hash, transactionHash: log.transaction?.hash, }); + const erc20Deposit = await this.handlePayload(input, block, ctx); if (erc20Deposit) { this.depositStorage.set(inputId, erc20Deposit); ctx.log.info(`${inputId} (Erc20Deposit) stored`); input.erc20Deposit = erc20Deposit; } + + input.erc721Deposit = await this.prepareErc721Deposit( + input, + block, + ctx, + { + inputId, + }, + ); + this.inputStorage.set(inputId, input); ctx.log.info(`${inputId} (Input) stored`); } diff --git a/tests/handlers/InputAdded.test.ts b/tests/handlers/InputAdded.test.ts index d9a1a9e..1bbc063 100644 --- a/tests/handlers/InputAdded.test.ts +++ b/tests/handlers/InputAdded.test.ts @@ -1,9 +1,18 @@ import { dataSlice, getUint } from 'ethers'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { afterEach } from 'node:test'; +import { MockedObject, beforeEach, describe, expect, test, vi } from 'vitest'; import { Contract } from '../../src/abi/ERC20'; +import { Contract as ERC721 } from '../../src/abi/ERC721'; import InputAdded from '../../src/handlers/InputAdded'; -import { Application, Erc20Deposit, Token } from '../../src/model'; -import { block, ctx, input, logs } from '../stubs/params'; +import { + Application, + Erc20Deposit, + Erc721Deposit, + Input, + NFT, + Token, +} from '../../src/model'; +import { block, ctx, input, logErc721Transfer, logs } from '../stubs/params'; vi.mock('../../src/abi/ERC20', async (importOriginal) => { const actualMods = await importOriginal; @@ -16,22 +25,39 @@ vi.mock('../../src/abi/ERC20', async (importOriginal) => { Contract, }; }); -vi.mock('../../src/model/', async (importOriginal) => { - const actualMods = await importOriginal; + +vi.mock('../../src/abi/ERC721', async (importOriginal) => { + const Contract = vi.fn(); + + Contract.prototype.name = vi.fn(); + Contract.prototype.symbol = vi.fn(); + + return { + Contract, + }; +}); + +vi.mock('../../src/model/', async () => { const Token = vi.fn(); const Erc20Deposit = vi.fn(); const Application = vi.fn(); const Input = vi.fn(); + const Erc721Deposit = vi.fn(); + const NFT = vi.fn(); return { - ...actualMods!, Application, Token, Erc20Deposit, + Erc721Deposit, Input, + NFT, }; }); const ApplicationMock = vi.mocked(Application); +const InputMock = vi.mocked(Input); +const NFTStub = vi.mocked(NFT); +const ERC721DepositStub = vi.mocked(Erc721Deposit); const tokenAddress = dataSlice(input.payload, 1, 21).toLowerCase(); // 20 bytes for address const from = dataSlice(input.payload, 21, 41).toLowerCase(); // 20 bytes for address @@ -40,10 +66,12 @@ const amount = getUint(dataSlice(input.payload, 41, 73)); // 32 bytes for uint25 describe('InputAdded', () => { let inputAdded: InputAdded; let erc20; + let erc721: MockedObject; const mockTokenStorage = new Map(); const mockDepositStorage = new Map(); const mockInputStorage = new Map(); const mockApplicationStorage = new Map(); + beforeEach(() => { inputAdded = new InputAdded( mockTokenStorage, @@ -52,6 +80,7 @@ describe('InputAdded', () => { mockInputStorage, ); erc20 = new Contract(ctx, block.header, tokenAddress); + erc721 = vi.mocked(new ERC721(ctx, block.header, tokenAddress)); mockTokenStorage.clear(); mockDepositStorage.clear(); mockApplicationStorage.clear(); @@ -110,23 +139,27 @@ describe('InputAdded', () => { expect(handlePayload).toBe(undefined); }); }); + describe('handle', async () => { test('call with the correct params', async () => { vi.spyOn(inputAdded, 'handle'); inputAdded.handle(logs[0], block, ctx); expect(inputAdded.handle).toBeCalledWith(logs[0], block, ctx); }); + test('wrong contract address', async () => { await inputAdded.handle(logs[1], block, ctx); expect(mockInputStorage.size).toBe(0); expect(mockApplicationStorage.size).toBe(0); expect(mockDepositStorage.size).toBe(0); }); + test('correct contract address', async () => { await inputAdded.handle(logs[0], block, ctx); expect(mockApplicationStorage.size).toBe(1); expect(mockInputStorage.size).toBe(1); }); + test('Erc20Deposit Stored', async () => { const name = 'SimpleERC20'; const symbol = 'SIM20'; @@ -174,5 +207,101 @@ describe('InputAdded', () => { timestamp, }); }); + + describe('ERC-721 deposits', () => { + const name = 'BrotherNFT'; + const symbol = 'BRUH'; + + beforeEach(() => { + erc721.name.mockResolvedValue(name); + erc721.symbol.mockResolvedValue(symbol); + + // Returning simple object as the Class type for assertion + InputMock.mockImplementationOnce((args) => { + return { ...args } as Input; + }); + + NFTStub.mockImplementationOnce((args) => { + return { ...args } as NFT; + }); + + ERC721DepositStub.mockImplementationOnce((args) => { + return { ...args } as Erc721Deposit; + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + test('should store the token information', async () => { + await inputAdded.handle(logErc721Transfer, block, ctx); + + expect(mockTokenStorage.size).toBe(1); + const token = mockTokenStorage.values().next().value; + expect(token.name).toEqual(name); + expect(token.symbol).toEqual(symbol); + }); + + test('should store the deposit information', async () => { + await inputAdded.handle(logErc721Transfer, block, ctx); + + expect(mockDepositStorage.size).toBe(1); + const deposit = mockDepositStorage.values().next().value; + expect(deposit.id).toEqual( + '0x0be010fa7e70d74fa8b6729fe1ae268787298f54-1', + ); + expect(deposit.from).toEqual( + logErc721Transfer.transaction.from, + ); + expect(deposit.tokenIndex).toEqual(1n); + expect(deposit.token).toEqual({ + id: '0x7a3cc9c0408887a030a0354330c36a9cd681aa7e', + name, + symbol, + }); + }); + + test('should assign the erc721 deposit information correctly into the input', async () => { + await inputAdded.handle(logErc721Transfer, block, ctx); + + expect(mockInputStorage.size).toBe(1); + const input = mockInputStorage.values().next().value; + expect(input.erc721Deposit).toEqual({ + from: '0xa074683b5be015f053b5dceb064c41fc9d11b6e5', + id: '0x0be010fa7e70d74fa8b6729fe1ae268787298f54-1', + token: { + id: '0x7a3cc9c0408887a030a0354330c36a9cd681aa7e', + name, + symbol, + }, + tokenIndex: 1n, + }); + }); + + test('should handle the absence of name and symbol methods in the ERC-721 contract', async () => { + erc721.name.mockRejectedValue( + new Error('No name method implemented on contract'), + ); + erc721.symbol.mockRejectedValue( + new Error('No symbol method implemented on contract'), + ); + + await inputAdded.handle(logErc721Transfer, block, ctx); + + expect(mockInputStorage.size).toBe(1); + const input = mockInputStorage.values().next().value; + expect(input.erc721Deposit).toEqual({ + from: '0xa074683b5be015f053b5dceb064c41fc9d11b6e5', + id: '0x0be010fa7e70d74fa8b6729fe1ae268787298f54-1', + token: { + id: '0x7a3cc9c0408887a030a0354330c36a9cd681aa7e', + name: null, + symbol: null, + }, + tokenIndex: 1n, + }); + }); + }); }); }); diff --git a/tests/stubs/params.ts b/tests/stubs/params.ts index 4be3f29..710c44a 100644 --- a/tests/stubs/params.ts +++ b/tests/stubs/params.ts @@ -5,18 +5,22 @@ import { vi } from 'vitest'; import { CartesiDAppFactoryAddress, ERC20PortalAddress, + InputBoxAddress, } from '../../src/config'; import { Input } from '../../src/model'; + vi.mock('@subsquid/logger', async (importOriginal) => { const actualMods = await importOriginal; const Logger = vi.fn(); Logger.prototype.warn = vi.fn(); Logger.prototype.info = vi.fn(); + Logger.prototype.error = vi.fn(); return { ...actualMods!, Logger, }; }); + vi.mock('@subsquid/typeorm-store', async (importOriginal) => { const actualMods = await importOriginal; const Store = vi.fn(); @@ -28,6 +32,7 @@ vi.mock('@subsquid/typeorm-store', async (importOriginal) => { }); const payload = '0x494e5345525420494e544f20636572746966696572202056414c554553202827307866434432423566316346353562353643306632323464614439394331346234454530393237346433272c3130202c273078664344324235663163463535623536433066323234646144393943313462344545303932373464332729'; + export const input = { id: '0x60a7048c3136293071605a4eaffef49923e981cc-0', application: { @@ -44,17 +49,55 @@ export const input = { blockNumber: 4040941n, blockHash: '0xce6a0d404b4201b3bd4fb8309df0b6a64f6a5d7b71fa89bf2737d4574c58b32f', + erc721Deposit: null, erc20Deposit: null, transactionHash: '0x6a3d76983453c0f74188bd89e01576c35f9d9b02daecdd49f7171aeb2bd3dc78', } satisfies Input; +export const logErc721Transfer = { + id: '0004867730-000035-2c78f', + address: InputBoxAddress, + logIndex: 35, + transactionIndex: 24, + topics: [ + '0x6aaa400068bf4ca337265e2a1e1e841f66b8597fd5b452fdc52a44bed28a0784', + '0x0000000000000000000000000be010fa7e70d74fa8b6729fe1ae268787298f54', + '0x0000000000000000000000000000000000000000000000000000000000000001', + ], + data: '0x000000000000000000000000237f8dd094c0e47f4236f12b4fa01d6dae89fb87000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000c87a3cc9c0408887a030a0354330c36a9cd681aa7ea074683b5be015f053b5dceb064c41fc9d11b6e500000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', + block: { + id: '0004867730-2c78f', + height: 4867730, + hash: '0x2c78fb73f84f2755f65533652983578bcf89a68ad173e756bc631b4d0d242b53', + parentHash: + '0x1bb7d54bde1c3dda41c6cc5ab40ad04b855d1ce5dec4175571dd158d3134ec3e', + timestamp: 1702321200000, + }, + transaction: { + id: '0004867730-000024-2c78f', + transactionIndex: 24, + from: '0xa074683b5be015f053b5dceb064c41fc9d11b6e5', + to: '0x237f8dd094c0e47f4236f12b4fa01d6dae89fb87', + hash: '0x47c53eeddc2f927ef2a7a3dd9a95bfd70ecfda2c4efdf10a16c48ca98c86b881', + value: 0, + block: { + id: '0004867730-2c78f', + height: 4867730, + hash: '0x2c78fb73f84f2755f65533652983578bcf89a68ad173e756bc631b4d0d242b53', + parentHash: + '0x1bb7d54bde1c3dda41c6cc5ab40ad04b855d1ce5dec4175571dd158d3134ec3e', + timestamp: 1702321200000, + }, + }, +}; + export const logs = [ { id: '0004411683-000001-cae3a', logIndex: 1, transactionIndex: 1, - address: '0x59b22d57d4f067708ab0c00552767405926dc768', + address: InputBoxAddress, topics: [ '0x6aaa400068bf4ca337265e2a1e1e841f66b8597fd5b452fdc52a44bed28a0784', '0x0000000000000000000000000be010fa7e70d74fa8b6729fe1ae268787298f54', @@ -161,6 +204,7 @@ export const logs = [ }, }, ]; + export const block = { header: { id: '1234567890', @@ -174,15 +218,18 @@ export const block = { traces: [], stateDiffs: [], }; + export const token = { decimals: 18, id: '0x059c7507b973d1512768c06f32a813bc93d83eb2', name: 'SimpleERC20', symbol: 'SIM20', }; + const consoleSink = vi.fn(); const em = vi.fn(); const logger = new Logger(consoleSink, 'app'); + const store = new Store(em); export const ctx = { _chain: {} as unknown as Chain,