diff --git a/packages/contracts/source/contracts/evm/instance.ts b/packages/contracts/source/contracts/evm/instance.ts index cc30516fbf..a61f10582a 100644 --- a/packages/contracts/source/contracts/evm/instance.ts +++ b/packages/contracts/source/contracts/evm/instance.ts @@ -36,7 +36,7 @@ export interface Instance extends CommitHandler { importAccountInfos(infos: AccountInfoExtended[]): Promise; importLegacyColdWallets(wallets: ImportLegacyColdWallet[]): Promise; getAccounts(offset: bigint, limit: bigint): Promise; - getLegacyAttributes(address: string, legacyAddress?: string): Promise; + getLegacyAttributes(address: string, legacyAddress?: string): Promise; getLegacyColdWallets(offset: bigint, limit: bigint): Promise; getReceipts(offset: bigint, limit: bigint): Promise; getReceiptsByBlockNumber(blockNumber: bigint): Promise>; diff --git a/packages/contracts/source/contracts/evm/storage.ts b/packages/contracts/source/contracts/evm/storage.ts index 6c31dd028a..3f4ae4de01 100644 --- a/packages/contracts/source/contracts/evm/storage.ts +++ b/packages/contracts/source/contracts/evm/storage.ts @@ -52,10 +52,10 @@ export interface CommitStorageData { export interface Storage { getState(): Promise<{ blockNumber: number; totalRound: number }>; - getBlockHeaderData(blockNumber: number): Promise; - getBlockNumberByHash(blockHash: string): Promise; - getCommitData(blockNumber: number): Promise; - getTransactionData(key: string): Promise; - getTransactionKeyByHash(txHash: string): Promise; + getBlockHeaderData(blockNumber: number): Promise; + getBlockNumberByHash(blockHash: string): Promise; + getCommitData(blockNumber: number): Promise; + getTransactionData(key: string): Promise; + getTransactionKeyByHash(txHash: string): Promise; isEmpty(): Promise; } diff --git a/packages/evm-service/package.json b/packages/evm-service/package.json index 26babf545b..623aa0ae80 100644 --- a/packages/evm-service/package.json +++ b/packages/evm-service/package.json @@ -35,7 +35,9 @@ "@mainsail/crypto-commit": "workspace:*", "@mainsail/crypto-config": "workspace:*", "@mainsail/crypto-hash-bcrypto": "workspace:*", + "@mainsail/crypto-key-pair-bls12-381": "workspace:*", "@mainsail/crypto-key-pair-ecdsa": "workspace:*", + "@mainsail/crypto-signature-bls12-381": "workspace:*", "@mainsail/crypto-signature-ecdsa": "workspace:*", "@mainsail/crypto-transaction": "workspace:*", "@mainsail/crypto-validation": "workspace:*", @@ -45,6 +47,7 @@ "@mainsail/transactions": "workspace:*", "@mainsail/validation": "workspace:*", "@types/tmp": "0.2.6", + "esmock": "2.7.5", "tmp": "0.2.5", "uvu": "0.5.6", "viem": "2.48.11" diff --git a/packages/evm-service/source/instances/evm-empty.test.ts b/packages/evm-service/source/instances/evm-empty.test.ts new file mode 100644 index 0000000000..dae57afb1c --- /dev/null +++ b/packages/evm-service/source/instances/evm-empty.test.ts @@ -0,0 +1,106 @@ +import type { Contracts } from "@mainsail/contracts"; +import { Application } from "@mainsail/kernel"; +import { setGracefulCleanup } from "tmp"; +import { zeroHash } from "viem"; + +import { describe } from "@mainsail/test-runner"; +import { wallets } from "../../test/fixtures/wallets"; +import { prepareSandbox } from "../../test/helpers/prepare-sandbox"; +import { EvmInstance } from "./evm"; + +// A freshly resolved instance with no genesis and nothing committed: every read should return +// its "empty" variant (zero / empty / undefined). +describe<{ + app: Application; + instance: Contracts.Evm.Instance & Contracts.Evm.Storage; +}>("EvmInstance - empty", ({ assert, afterAll, afterEach, beforeEach, it }) => { + afterAll(() => setGracefulCleanup()); + + afterEach(async ({ instance }) => { + await instance.dispose(); + }); + + beforeEach(async (context) => { + await prepareSandbox(context); + context.instance = context.app.resolve(EvmInstance); + }); + + it("isEmpty is true", async ({ instance }) => { + assert.true(await instance.isEmpty()); + }); + + it("getState reports block number 0 and total round 0", async ({ instance }) => { + assert.equal(await instance.getState(), { blockNumber: 0, totalRound: 0 }); + }); + + it("getAccountInfo reports a zero balance and nonce for an unknown account", async ({ instance }) => { + const [wallet] = wallets; + + assert.equal(await instance.getAccountInfo(wallet.address), { balance: 0n, nonce: 0n }); + }); + + it("getAccountInfoExtended reports a zero balance and nonce for an unknown account", async ({ instance }) => { + const [wallet] = wallets; + + assert.equal(await instance.getAccountInfoExtended(wallet.address), { + balance: 0n, + nonce: 0n, + legacyAttributes: {}, + address: wallet.address, + }); + }); + + it("getAccounts is empty", async ({ instance }) => { + const { accounts } = await instance.getAccounts(0n, 100n); + + assert.equal(accounts, []); + }); + + it("getLegacyColdWallets is empty", async ({ instance }) => { + const { wallets: coldWallets } = await instance.getLegacyColdWallets(0n, 100n); + + assert.equal(coldWallets, []); + }); + + it("getLegacyAttributes returns nothing", async ({ instance }) => { + const [wallet] = wallets; + + assert.undefined(await instance.getLegacyAttributes(wallet.address)); + }); + + it("getReceipts is empty", async ({ instance }) => { + const { receipts } = await instance.getReceipts(0n, 100n); + + assert.equal(receipts, []); + }); + + it("getReceiptsByBlockNumber is an empty record", async ({ instance }) => { + assert.equal(await instance.getReceiptsByBlockNumber(0n), {}); + }); + + it("getBlockNumberByHash returns undefined", async ({ instance }) => { + assert.undefined(await instance.getBlockNumberByHash(zeroHash)); + }); + + it("getBlockHeaderData returns nothing", async ({ instance }) => { + assert.undefined(await instance.getBlockHeaderData(0)); + }); + + it("getCommitData returns undefined", async ({ instance }) => { + assert.undefined(await instance.getCommitData(0)); + }); + + it("getTransactionData returns nothing", async ({ instance }) => { + assert.undefined(await instance.getTransactionData(zeroHash)); + }); + + it("getTransactionKeyByHash returns nothing", async ({ instance }) => { + assert.undefined(await instance.getTransactionKeyByHash(zeroHash)); + }); + + it("codeAt returns empty code", async ({ instance }) => { + const [wallet] = wallets; + + assert.equal(await instance.codeAt(wallet.address), "0x"); + }); +}); diff --git a/packages/evm-service/source/instances/evm-genesis.test.ts b/packages/evm-service/source/instances/evm-genesis.test.ts new file mode 100644 index 0000000000..04d35c5812 --- /dev/null +++ b/packages/evm-service/source/instances/evm-genesis.test.ts @@ -0,0 +1,213 @@ +import { Enums } from "@mainsail/constants"; +import type { Contracts } from "@mainsail/contracts"; +import { Application } from "@mainsail/kernel"; +import { setGracefulCleanup } from "tmp"; +import { zeroHash } from "viem"; + +import { describe } from "@mainsail/test-runner"; +import { commitGenesis, processGenesis } from "../../test/helpers/commit-genesis"; +import { prepareSandbox } from "../../test/helpers/prepare-sandbox"; +import { EvmInstance } from "./evm"; + +// The real devnet genesis commit applied once. Every read should now reflect the committed +// block, its transactions, receipts and funded accounts. The reads don't mutate state, so a +// single committed instance is shared across the suite. +describe<{ + app: Application; + instance: Contracts.Evm.Instance & Contracts.Evm.Storage; + genesisCommit: Contracts.Crypto.Commit; +}>("EvmInstance - genesis", ({ assert, afterAll, beforeAll, it }) => { + beforeAll(async (context) => { + await prepareSandbox(context); + context.instance = context.app.resolve(EvmInstance); + context.genesisCommit = await commitGenesis(context.app, context.instance); + }); + + afterAll(async (context) => { + await context.instance.dispose(); + setGracefulCleanup(); + }); + + it("isEmpty is false", async ({ instance }) => { + assert.false(await instance.isEmpty()); + }); + + it("getState reports block number 0 and an increased total round", async ({ instance }) => { + assert.equal(await instance.getState(), { blockNumber: 0, totalRound: 1 }); + }); + + it("getBlockNumberByHash resolves the genesis hash to block 0", async ({ instance, genesisCommit }) => { + assert.equal(await instance.getBlockNumberByHash(genesisCommit.block.hash), 0); + }); + + it("getBlockNumberByHash still returns undefined for an unknown hash", async ({ instance }) => { + assert.undefined(await instance.getBlockNumberByHash(zeroHash)); + }); + + it("getBlockHeaderData returns the genesis header", async ({ instance, genesisCommit }) => { + const header = await instance.getBlockHeaderData(0); + + assert.defined(header); + assert.equal(header!.hash, genesisCommit.block.hash); + }); + + it("getCommitData returns the genesis commit", async ({ instance }) => { + assert.defined(await instance.getCommitData(0)); + }); + + it("exposes every committed transaction by hash and key", async ({ instance, genesisCommit }) => { + for (const transaction of genesisCommit.block.transactions) { + const key = await instance.getTransactionKeyByHash(transaction.hash); + assert.defined(key); + assert.defined(await instance.getTransactionData(key!)); + } + }); + + it("stores one receipt per committed transaction", async ({ instance, genesisCommit }) => { + const receipts = await instance.getReceiptsByBlockNumber(0n); + + assert.equal(Object.keys(receipts).length, genesisCommit.block.transactions.length); + }); + + it("getReceipts returns the committed receipts", async ({ instance, genesisCommit }) => { + const { receipts } = await instance.getReceipts(0n, BigInt(genesisCommit.block.transactions.length + 1)); + + assert.equal(receipts.length, genesisCommit.block.transactions.length); + }); + + it("getReceipt returns a single committed receipt by hash", async ({ instance, genesisCommit }) => { + const [transaction] = genesisCommit.block.transactions; + + assert.defined(await instance.getReceipt(0n, transaction.hash)); + }); + + it("funds the genesis proposer account", async ({ instance, genesisCommit }) => { + const info = await instance.getAccountInfo(genesisCommit.block.proposer); + + assert.true(info.balance > 0n); + }); + + it("getAccounts returns the committed accounts", async ({ instance }) => { + const { accounts } = await instance.getAccounts(0n, 1000n); + + assert.equal(accounts.length, 55); // initial wallet, validators 0x1; + }); + + it("getLegacyColdWallets is empty", async ({ instance }) => { + const { wallets: coldWallets } = await instance.getLegacyColdWallets(0n, 100n); + + assert.equal(coldWallets, []); + }); + + // stateRoot/logsBloom operate on the pending commit, which the shared (already-committed) + // instance no longer has, so this runs the genesis up to — but not through — the commit. + it("computes the stateRoot and logsBloom of the pending genesis commit", async () => { + const context = {} as { app: Application }; + await prepareSandbox(context); + const instance = context.app.resolve(EvmInstance); + + try { + const { genesisCommit, commitKey } = await processGenesis(context.app, instance); + + const stateRoot = await instance.stateRoot(commitKey, genesisCommit.block.parentHash); + assert.string(stateRoot); + assert.length(stateRoot, 64); + // Genesis funds accounts, so the state is non-empty. + assert.false(stateRoot === "0".repeat(64)); + + const logsBloom = await instance.logsBloom(commitKey); + assert.string(logsBloom); + assert.length(logsBloom, 512); + } finally { + await instance.dispose(); + } + }); + + // updateRewardsAndVotes mutates state (credits the proposer + calls the validator contract), + // so it runs on its own instance. + it("updateRewardsAndVotes credits the proposer with the block reward", async () => { + const context = {} as { app: Application }; + await prepareSandbox(context); + const instance = context.app.resolve(EvmInstance); + + try { + const proposer = "0x1111111111111111111111111111111111111111"; + const reward = 2_000_000_000n; + const commitKey = { blockNumber: 0n, round: 0n }; + + // genesis sets the deployer/validator contract addresses updateRewardsAndVotes needs. + await instance.initializeGenesis({ + account: proposer, + deployerAccount: "0x0000000000000000000000000000000000000001", + initialBlockNumber: 0n, + initialSupply: 0n, + usernameContract: "0x0000000000000000000000000000000000000001", + validatorContract: "0x0000000000000000000000000000000000000001", + }); + + // Before: the proposer is unfunded. + assert.equal((await instance.getAccountInfo(proposer)).balance, 0n); + + await instance.prepareNextCommit({ commitKey }); + await instance.updateRewardsAndVotes({ + blockReward: reward, + commitKey, + specId: Enums.Evm.SpecId.SHANGHAI, + timestamp: 12_345n, + validatorAddress: proposer, + }); + await instance.onCommit({ + blockNumber: 0n, + getBlock: () => ({ number: 0n, round: 0n }), + round: 0n, + setAccountUpdates: () => {}, + } as any); + + // After: the block reward has been credited to the proposer. + assert.equal((await instance.getAccountInfo(proposer)).balance, reward); + } finally { + await instance.dispose(); + } + }); + + it("calculateRoundValidators runs after updateRewardsAndVotes", async () => { + const context = {} as { app: Application }; + await prepareSandbox(context); + const instance = context.app.resolve(EvmInstance); + + try { + const proposer = "0x1111111111111111111111111111111111111111"; + const commitKey = { blockNumber: 0n, round: 0n }; + + await instance.initializeGenesis({ + account: proposer, + deployerAccount: "0x0000000000000000000000000000000000000001", + initialBlockNumber: 0n, + initialSupply: 0n, + usernameContract: "0x0000000000000000000000000000000000000001", + validatorContract: "0x0000000000000000000000000000000000000001", + }); + + await instance.prepareNextCommit({ commitKey }); + await instance.updateRewardsAndVotes({ + blockReward: 0n, + commitKey, + specId: Enums.Evm.SpecId.SHANGHAI, + timestamp: 12_345n, + validatorAddress: proposer, + }); + + await assert.resolves(() => + instance.calculateRoundValidators({ + commitKey, + roundValidators: 0n, + specId: Enums.Evm.SpecId.SHANGHAI, + timestamp: 12_345n, + validatorAddress: proposer, + }), + ); + } finally { + await instance.dispose(); + } + }); +}); diff --git a/packages/evm-service/source/instances/evm-logger.test.ts b/packages/evm-service/source/instances/evm-logger.test.ts new file mode 100644 index 0000000000..f3285e50d0 --- /dev/null +++ b/packages/evm-service/source/instances/evm-logger.test.ts @@ -0,0 +1,89 @@ +import { Identifiers } from "@mainsail/constants"; +import * as EvmModule from "@mainsail/evm"; +import { Application } from "@mainsail/kernel"; +import esmock from "esmock"; + +import { describe } from "@mainsail/test-runner"; + +const { LogLevel } = EvmModule; + +// Captures the `logger` callback the EvmInstance hands to the native Evm constructor so the +// LogLevel routing can be driven directly, without booting the real native EVM. +let capturedLogger: ((record: { level: number; message: string }) => void) | undefined; + +class FakeEvm { + public constructor(options: any) { + capturedLogger = options.logger; + } + + public async dispose(): Promise {} +} + +// Keep the real module (LogLevel etc.) but swap the Evm class for the capturing fake. +const { EvmInstance } = await esmock("./evm", { + "@mainsail/evm": { ...EvmModule, Evm: FakeEvm }, +}); + +describe<{ + app: Application; + logger: Record void>; +}>("EvmInstance.logger", ({ assert, beforeEach, it, spy }) => { + beforeEach((context) => { + capturedLogger = undefined; + context.logger = { + alert: () => {}, + debug: () => {}, + error: () => {}, + info: () => {}, + notice: () => {}, + warn: () => {}, + }; + + context.app = new Application(); + // Application auto-binds itself as Application.Instance; replace it with a fake that + // only needs dataPath() for initialize(). + context.app.rebind(Identifiers.Application.Instance).toConstantValue({ dataPath: () => "/tmp/evm-logger" }); + context.app.bind(Identifiers.Services.Log.Service).toConstantValue(context.logger); + + // Resolve to trigger @postConstruct initialize(), which constructs the (fake) Evm. + context.app.resolve(EvmInstance); + assert.defined(capturedLogger); + }); + + const cases: [number, string][] = [ + [LogLevel.Info, "info"], + [LogLevel.Debug, "debug"], + [LogLevel.Notice, "notice"], + [LogLevel.Alert, "alert"], + [LogLevel.Warn, "warn"], + ]; + + for (const [level, method] of cases) { + it(`routes ${method} records to logger.${method} with the evm context`, ({ logger }) => { + const target = spy(logger, method); + + capturedLogger!({ level, message: `${method} message` }); + + target.calledOnce(); + target.calledWith(`${method} message`, "evm"); + }); + } + + it("ignores unmapped log levels", ({ logger }) => { + const spies = Object.keys(logger).map((method) => spy(logger, method)); + + assert.not.throws(() => capturedLogger!({ level: 999, message: "noop" })); + + for (const target of spies) { + target.neverCalled(); + } + }); + + it("swallows errors thrown by the logger", ({ logger }) => { + logger.info = () => { + throw new Error("logger is down"); + }; + + assert.not.throws(() => capturedLogger!({ level: LogLevel.Info, message: "boom" })); + }); +}); diff --git a/packages/evm-service/source/instances/evm.test.ts b/packages/evm-service/source/instances/evm.test.ts index d584c48e25..0ab8c97b94 100644 --- a/packages/evm-service/source/instances/evm.test.ts +++ b/packages/evm-service/source/instances/evm.test.ts @@ -1,7 +1,6 @@ import { randomBytes } from "node:crypto"; import type { Contracts } from "@mainsail/contracts"; import { Application } from "@mainsail/kernel"; -import { Container } from "@mainsail/container"; import { Enums } from "@mainsail/constants"; import { Evm } from "@mainsail/evm"; import { @@ -31,7 +30,7 @@ import { setGracefulCleanup } from "tmp"; describe<{ app: Application; - instance: Contracts.Evm.Instance; + instance: Contracts.Evm.Instance & Contracts.Evm.Storage; }>("Instance", ({ it, assert, afterAll, afterEach, beforeEach }) => { afterAll(() => setGracefulCleanup()); @@ -42,7 +41,7 @@ describe<{ beforeEach(async (context) => { await prepareSandbox(context); - context.instance = context.app.resolve(EvmInstance); + context.instance = context.app.resolve(EvmInstance); }); const deployConfig = { @@ -63,7 +62,7 @@ describe<{ validatorAddress: zeroAddress, }; - it("should deploy contract successfully", async ({ instance }) => { + it("#process - should deploy contract successfully", async ({ instance }) => { const [sender] = wallets; const commitKey = { blockNumber: BigInt(0), round: BigInt(0) }; @@ -82,7 +81,7 @@ describe<{ assert.equal(receipt.contractAddress, "0x0c2485e7d05894BC4f4413c52B080b6D1eca122a"); }); - it("should call log hook", async ({ app, instance }) => { + it("#initialize - should call log hook", async ({ app, instance }) => { let hookCalled = 0; const evm = new Evm({ @@ -1121,6 +1120,54 @@ describe<{ assert.equal(recipientAccountAfter.balance, recipientAccountBefore.balance + 100n); assert.equal(zeroAccountBefore.balance, zeroAccountAfter.balance); }); + + it("should simulate a transaction without persisting state", async ({ instance }) => { + const [sender, recipient] = wallets; + + await instance.initializeGenesis({ + account: sender.address, + deployerAccount: zeroAddress, + initialBlockNumber: 0n, + initialSupply: parseEther("100"), + usernameContract: zeroAddress, + validatorContract: zeroAddress, + }); + + const { receipt } = await instance.simulate({ + blockContext: { ...blockContext, commitKey: { blockNumber: BigInt(0), round: BigInt(0) } }, + data: Buffer.alloc(0), + from: sender.address, + gasLimit: 21_000n, + gasPrice: 0n, + nonce: 0n, + specId: Enums.Evm.SpecId.SHANGHAI, + to: recipient.address, + value: parseEther("1"), + }); + + assert.equal(receipt.status, 1); + // The simulated transfer must not have moved any funds. + assert.equal((await instance.getAccountInfo(recipient.address)).balance, 0n); + }); + + it("should import account balances", async ({ instance }) => { + const [sender] = wallets; + const commitKey = { blockNumber: BigInt(0), round: BigInt(0) }; + + // importAccountInfos must run inside a prepared commit (see snapshot-legacy-importer). + await instance.prepareNextCommit({ commitKey }); + await instance.importAccountInfos([ + { address: sender.address, balance: 1234n, legacyAttributes: {}, nonce: 0n }, + ]); + await instance.onCommit({ + blockNumber: BigInt(0), + getBlock: () => ({ number: BigInt(0), round: BigInt(0) }), + round: BigInt(0), + setAccountUpdates: () => {}, + } as any); + + assert.equal((await instance.getAccountInfo(sender.address)).balance, 1234n); + }); }); const getRandomTxHash = () => Buffer.from(randomBytes(32)).toString("hex"); diff --git a/packages/evm-service/source/instances/evm.ts b/packages/evm-service/source/instances/evm.ts index aa90ff6aee..34be839bbc 100644 --- a/packages/evm-service/source/instances/evm.ts +++ b/packages/evm-service/source/instances/evm.ts @@ -112,8 +112,12 @@ export class EvmInstance implements Contracts.Evm.Instance, Contracts.Evm.Storag public async getLegacyAttributes( address: string, legacyAddress?: string, - ): Promise { - return this.#evm.getLegacyAttributes(address, legacyAddress); + ): Promise { + const result = await this.#evm.getLegacyAttributes(address, legacyAddress); + if (result === null || result === undefined) { + return undefined; + } + return result; } public async getLegacyColdWallets( @@ -178,13 +182,15 @@ export class EvmInstance implements Contracts.Evm.Instance, Contracts.Evm.Storag return { blockNumber: Number(state.blockNumber), totalRound: Number(state.totalRound) }; } - public async getBlockHeaderData( - blockNumber: number, - ): Promise { - return this.#evm.getBlockHeaderData(BigInt(blockNumber)); + public async getBlockHeaderData(blockNumber: number): Promise { + const result = await this.#evm.getBlockHeaderData(BigInt(blockNumber)); + if (result === null || result === undefined) { + return undefined; + } + return result; } - public async getBlockNumberByHash(blockHash: string): Promise { + public async getBlockNumberByHash(blockHash: string): Promise { const result = await this.#evm.getBlockNumberByHash(blockHash); if (result === null || result === undefined) { return undefined; @@ -193,21 +199,29 @@ export class EvmInstance implements Contracts.Evm.Instance, Contracts.Evm.Storag return Number(result); } - public async getCommitData(blockNumber: number): Promise { + public async getCommitData(blockNumber: number): Promise { const result = await this.#evm.getCommitData(BigInt(blockNumber)); - if (!result) { + if (result === null || result === undefined) { return undefined; } return result; } - public async getTransactionData(key: string): Promise { - return this.#evm.getTransactionData(key); + public async getTransactionData(key: string): Promise { + const result = await this.#evm.getTransactionData(key); + if (result === null || result === undefined) { + return undefined; + } + return result; } - public async getTransactionKeyByHash(txHash: string): Promise { - return this.#evm.getTransactionKeyByHash(txHash); + public async getTransactionKeyByHash(txHash: string): Promise { + const result = await this.#evm.getTransactionKeyByHash(txHash); + if (result === null || result === undefined) { + return undefined; + } + return result; } public async isEmpty(): Promise { @@ -222,7 +236,7 @@ export class EvmInstance implements Contracts.Evm.Instance, Contracts.Evm.Storag await this.#evm.rollback(commitKey); } - async #prepareCommitData(unit: Contracts.Processor.ProcessableUnit): Promise { + async #prepareCommitData(unit: Contracts.Processor.ProcessableUnit): Promise { if (!("getCommit" in unit)) { return undefined; } diff --git a/packages/evm-service/source/service-provider.test.ts b/packages/evm-service/source/service-provider.test.ts new file mode 100644 index 0000000000..eb9fad5f34 --- /dev/null +++ b/packages/evm-service/source/service-provider.test.ts @@ -0,0 +1,65 @@ +import { Identifiers } from "@mainsail/constants"; +import { Application } from "@mainsail/kernel"; + +import { describe } from "@mainsail/test-runner"; +import { ServiceProvider } from "./service-provider"; + +const TAGS = ["evm", "validator", "transaction-pool", "rpc"]; + +describe<{ + app: Application; + serviceProvider: ServiceProvider; +}>("ServiceProvider", ({ assert, beforeEach, it, spy }) => { + beforeEach((context) => { + context.app = new Application(); + context.serviceProvider = context.app.resolve(ServiceProvider); + }); + + it("register binds an evm instance for every instance tag", async ({ app, serviceProvider }) => { + for (const tag of TAGS) { + assert.false(app.isBoundTagged(Identifiers.Evm.Instance, "instance", tag)); + } + + await serviceProvider.register(); + + for (const tag of TAGS) { + assert.true(app.isBoundTagged(Identifiers.Evm.Instance, "instance", tag)); + } + }); + + it("boot resolves without doing anything", async ({ serviceProvider }) => { + await assert.resolves(() => serviceProvider.boot()); + }); + + it("dispose disposes every bound tagged instance", async ({ app, serviceProvider }) => { + // Binds the real (native) EvmInstance per tag once a fake is bound instead. + const bindFakeInstances = (app: Application) => { + const instances: Record Promise }> = {}; + for (const tag of TAGS) { + instances[tag] = { dispose: async () => {} }; + app.bind(Identifiers.Evm.Instance).toConstantValue(instances[tag]).whenTagged("instance", tag); + } + return instances; + }; + + const instances = bindFakeInstances(app); + const disposals = TAGS.map((tag) => spy(instances[tag], "dispose")); + + await serviceProvider.dispose(); + + for (const disposal of disposals) { + disposal.calledOnce(); + } + }); + + it("dispose skips tags that are not bound", async ({ app, serviceProvider }) => { + const instance = { dispose: async () => {} }; + app.bind(Identifiers.Evm.Instance).toConstantValue(instance).whenTagged("instance", "evm"); + const disposal = spy(instance, "dispose"); + + // validator / transaction-pool / rpc are unbound; dispose must not throw on them. + await assert.resolves(() => serviceProvider.dispose()); + + disposal.calledOnce(); + }); +}); diff --git a/packages/evm-service/test/helpers/commit-genesis.ts b/packages/evm-service/test/helpers/commit-genesis.ts new file mode 100644 index 0000000000..5b2c60dfdc --- /dev/null +++ b/packages/evm-service/test/helpers/commit-genesis.ts @@ -0,0 +1,95 @@ +import type { Contracts } from "@mainsail/contracts"; +import type { Application } from "@mainsail/kernel"; + +import { Enums, Identifiers } from "@mainsail/constants"; +import { ServiceProvider as CoreCryptoKeyPairBls } from "@mainsail/crypto-key-pair-bls12-381"; +import { ServiceProvider as CoreCryptoSignatureBls } from "@mainsail/crypto-signature-bls12-381"; + +type GenesisCommitKey = { blockHash: string; blockNumber: bigint; round: bigint }; + +// Deserializes the real devnet genesis Commit, initializes genesis state and processes every +// genesis transaction into a *pending* commit (without committing it). Returns the Commit and +// the commit key so callers can inspect the pending block (e.g. stateRoot/logsBloom) before it +// is sealed. The genesis proof carries BLS validator signatures, so the commit deserializer +// needs the BLS signature/key-pair providers (not part of the shared sandbox). +export const processGenesis = async ( + app: Application, + instance: Contracts.Evm.Instance, +): Promise<{ genesisCommit: Contracts.Crypto.Commit; commitKey: GenesisCommitKey }> => { + await app.resolve(CoreCryptoSignatureBls).register(); + await app.resolve(CoreCryptoKeyPairBls).register(); + + const configuration = app.get(Identifiers.Cryptography.Configuration); + const commitFactory = app.get(Identifiers.Cryptography.Commit.Factory); + + const genesisCommit = await commitFactory.fromJson(configuration.getGenesisCommit()); + const { block } = genesisCommit; + + const commitKey = { blockHash: block.hash, blockNumber: BigInt(block.number), round: BigInt(block.round) }; + + await instance.initializeGenesis({ + account: block.proposer, + deployerAccount: "0x0000000000000000000000000000000000000001", + initialBlockNumber: 0n, + initialSupply: 10_000_000_000_000_000_000_000_000_000n, + usernameContract: "0x0000000000000000000000000000000000000001", + validatorContract: "0x0000000000000000000000000000000000000001", + }); + + await instance.prepareNextCommit({ commitKey }); + + for (const transaction of block.transactions) { + const { receipt } = await instance.process({ + blockContext: { + commitKey, + gasLimit: BigInt(10_000_000), + timestamp: BigInt(block.timestamp), + validatorAddress: block.proposer, + }, + data: Buffer.from(transaction.data.slice(2), "hex"), + from: transaction.from, + gasLimit: BigInt(transaction.gasLimit), + gasPrice: BigInt(transaction.gasPrice), + nonce: transaction.nonce, + specId: Enums.Evm.SpecId.LATEST, + to: transaction.to, + txHash: transaction.hash, + value: transaction.value, + }); + + if (receipt.status !== 1) { + throw new Error(`Genesis transaction ${transaction.hash} failed to process`); + } + } + + return { commitKey, genesisCommit }; +}; + +// Processes and commits the genesis block (with a unit that exposes getCommit(), so the full +// commit storage path runs). Returns the Commit so callers can assert against block/txs/proof. +export const commitGenesis = async ( + app: Application, + instance: Contracts.Evm.Instance, +): Promise => { + const { genesisCommit } = await processGenesis(app, instance); + const { block } = genesisCommit; + + await instance.onCommit({ + blockNumber: block.number, + getAccountUpdates: () => [], + getBlock: () => block, + getCommit: async () => genesisCommit, + getProcessorResult: () => ({ + feeUsed: 0n, + gasUsed: 0, + receipts: new Map(), + success: false, + }), + hasProcessorResult: () => false, + round: block.round, + setAccountUpdates: () => {}, + setProcessorResult: () => {}, + }); + + return genesisCommit; +}; diff --git a/packages/evm-service/test/helpers/prepare-sandbox.ts b/packages/evm-service/test/helpers/prepare-sandbox.ts index 9110453896..bad3b8e4c9 100644 --- a/packages/evm-service/test/helpers/prepare-sandbox.ts +++ b/packages/evm-service/test/helpers/prepare-sandbox.ts @@ -1,5 +1,6 @@ -import { Identifiers } from "@mainsail/constants"; import type { Contracts } from "@mainsail/contracts"; + +import { Identifiers } from "@mainsail/constants"; import { ServiceProvider as CoreCryptoAddressBase58 } from "@mainsail/crypto-address-base58"; import { ServiceProvider as CoreCryptoAddressKeccak256 } from "@mainsail/crypto-address-keccak256"; import { ServiceProvider as CoreCryptoBlock } from "@mainsail/crypto-block"; @@ -45,8 +46,8 @@ export const prepareSandbox = async (context: { app?: Application }) => { await context.app.resolve(CoreCryptoWif).register(); context.app.bind(Identifiers.Services.Log.Service).toConstantValue({ - info: (msg) => console.log(msg), - debug: (msg) => console.log(msg), + debug: (message) => console.log(message), + info: (message) => console.log(message), }); context.app.get(Identifiers.Cryptography.Configuration).setConfig(crypto); @@ -57,13 +58,4 @@ export const prepareSandbox = async (context: { app?: Application }) => { await context.app.resolve(CoreTransactions).register(); await context.app.resolve(CoreCryptoBlock).register(); await context.app.resolve(CoreCryptoCommit).register(); - - context.app.bind(Identifiers.State.Store).toConstantValue({ - getLastBlock: () => ({ - data: { - number: 1, - id: "0000000000000000000000000000000000000000000000000000000000000000", - }, - }), - }); }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07fc34c979..cfa8f7bd59 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2159,9 +2159,15 @@ importers: '@mainsail/crypto-hash-bcrypto': specifier: workspace:* version: link:../crypto-hash-bcrypto + '@mainsail/crypto-key-pair-bls12-381': + specifier: workspace:* + version: link:../crypto-key-pair-bls12-381 '@mainsail/crypto-key-pair-ecdsa': specifier: workspace:* version: link:../crypto-key-pair-ecdsa + '@mainsail/crypto-signature-bls12-381': + specifier: workspace:* + version: link:../crypto-signature-bls12-381 '@mainsail/crypto-signature-ecdsa': specifier: workspace:* version: link:../crypto-signature-ecdsa @@ -2189,6 +2195,9 @@ importers: '@types/tmp': specifier: 0.2.6 version: 0.2.6 + esmock: + specifier: 2.7.5 + version: 2.7.5 tmp: specifier: 0.2.5 version: 0.2.5