Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions core/src/__tests__/api/deployments.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,4 +359,52 @@ describe('deployment route handlers', () => {
);
expect(res.statusCode).toBe(404);
});

it('returns an on-demand storage trace for the requested chain step', async () => {
const storageSlotChanges = vi.fn(async () => ({
target: '0x0000000000000000000000000000000000000002' as Hex,
storage: { '0x0000000000000000000000000000000000000002': [] },
}));
const handlers = createDeploymentHandlers({
engine: { launch: vi.fn(), resolveLane: vi.fn(), resume: vi.fn(), abort: vi.fn() } as never,
getProfileManager: async () => ({ getCurrentProfile: () => 'one' }),
storageSlotChanges,
});
const res = reply();
await handlers.getStorageSlotChanges(request({ plan, rpcSelection: { '1': 'rpc' }, chainId: 1, stepId: 's', baseBlock: 123 }) as never, res);
expect(res.statusCode).toBe(200);
expect(storageSlotChanges).toHaveBeenCalledWith(plan, { '1': 'rpc' }, 1, 's', 123, { profileId: 'one' });
});

it('rejects a second concurrent storage trace while the first is running', async () => {
let finish!: () => void;
const pending = new Promise<void>((resolve) => { finish = resolve; });
const handlers = createDeploymentHandlers({
engine: { launch: vi.fn(), resolveLane: vi.fn(), resume: vi.fn(), abort: vi.fn() } as never,
getProfileManager: async () => ({ getCurrentProfile: () => 'one' }),
storageSlotChanges: vi.fn(async () => { await pending; return { storage: {} }; }),
});
const body = { plan, rpcSelection: { '1': 'rpc' }, chainId: 1, stepId: 's' };
const first = handlers.getStorageSlotChanges(request(body) as never, reply());
await Promise.resolve();
const secondReply = reply();
await handlers.getStorageSlotChanges(request(body) as never, secondReply);
expect(secondReply.statusCode).toBe(409);
expect(secondReply.body).toMatchObject({ code: ErrorCodes.DEPLOYMENT_STORAGE_TRACE_BUSY });
finish();
await first;
});

it('returns a cached event signature lookup result', async () => {
const handlers = createDeploymentHandlers({
engine: { launch: vi.fn(), resolveLane: vi.fn(), resume: vi.fn(), abort: vi.fn() } as never,
getProfileManager: async () => ({ getCurrentProfile: () => 'one' }),
lookupEventSignature: vi.fn(async () => 'Ping(uint256)'),
});
const res = reply();
const topic0 = `0x${'11'.repeat(32)}`;
await handlers.lookupEventSignature(request(undefined, undefined, { topic0 }) as never, res);
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ data: { signature: 'Ping(uint256)' } });
});
});
34 changes: 34 additions & 0 deletions core/src/__tests__/deployments/eventSignatures.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { clearEventSignatureCache, lookupEventSignature } from '../../deployments/eventSignatures.js';

const TOPIC = `0x${'11'.repeat(32)}` as `0x${string}`;

describe('event signature lookup', () => {
afterEach(() => clearEventSignatureCache());

it('caches a 4byte event signature result', async () => {
const fetcher = vi.fn(async () => new Response(JSON.stringify({
results: [{ text_signature: 'Transferred(address,address,uint256)' }],
}), { status: 200 }));
await expect(lookupEventSignature(TOPIC, fetcher)).resolves.toBe('Transferred(address,address,uint256)');
await expect(lookupEventSignature(TOPIC, fetcher)).resolves.toBe('Transferred(address,address,uint256)');
expect(fetcher).toHaveBeenCalledOnce();
});

it('tolerates lookup failures', async () => {
const fetcher = vi.fn(async () => { throw new Error('offline'); });
await expect(lookupEventSignature(TOPIC, fetcher)).resolves.toBeUndefined();
await expect(lookupEventSignature(TOPIC, fetcher)).resolves.toBeUndefined();
expect(fetcher).toHaveBeenCalledOnce();
});

it('coalesces concurrent lookups for the same topic', async () => {
let resolve!: (response: Response) => void;
const fetcher = vi.fn(() => new Promise<Response>((done) => { resolve = done; }));
const first = lookupEventSignature(TOPIC, fetcher);
const second = lookupEventSignature(TOPIC, fetcher);
resolve(new Response(JSON.stringify({ results: [{ text_signature: 'Ping(uint256)' }] }), { status: 200 }));
await expect(Promise.all([first, second])).resolves.toEqual(['Ping(uint256)', 'Ping(uint256)']);
expect(fetcher).toHaveBeenCalledOnce();
});
});
35 changes: 33 additions & 2 deletions core/src/__tests__/deployments/forkContainer.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,38 @@
import { describe, expect, it, vi } from 'vitest';
import os from 'node:os';
import { makeForkRunner } from '../../deployments/forkContainer.js';
import { makeForkRunner, storageDiff, storageTraceTarget } from '../../deployments/forkContainer.js';

describe('fork container lifecycle', () => {
it('normalizes prestate tracer storage diffs and retains touched accounts', () => {
expect(storageDiff({
pre: {
'0x0000000000000000000000000000000000000001': {
storage: { '0x1': '0x02' },
},
},
post: {
'0x0000000000000000000000000000000000000001': {
storage: { '0x1': '0x03', '0x2': '0x04' },
},
'0x0000000000000000000000000000000000000002': {},
},
})).toEqual({
'0x0000000000000000000000000000000000000001': [
{ slot: `0x${'0'.repeat(63)}1`, before: `0x${'0'.repeat(62)}02`, after: `0x${'0'.repeat(62)}03` },
{ slot: `0x${'0'.repeat(63)}2`, before: `0x${'0'.repeat(64)}`, after: `0x${'0'.repeat(62)}04` },
],
'0x0000000000000000000000000000000000000002': [],
});
});

it('prioritizes produced deploy addresses over the CREATE2 proxy for storage traces', () => {
const produced = '0x0000000000000000000000000000000000000002' as const;
expect(storageTraceTarget({ stepId: 'deploy', kind: 'tx', to: '0x0000000000000000000000000000000001', predictedAddress: produced })).toBe(produced);
expect(storageTraceTarget({ stepId: 'deploy', kind: 'tx', to: '0x0000000000000000000000000000000001', address: produced, predictedAddress: '0x0000000000000000000000000000000003' })).toBe(produced);
expect(storageTraceTarget({ stepId: 'create', kind: 'tx', to: null, address: produced })).toBe(produced);
expect(storageTraceTarget({ stepId: 'call', kind: 'tx', to: '0x0000000000000000000000000000000001' })).toBe('0x0000000000000000000000000000000001');
});

it('does not pull a missing foundry image', async () => {
const inspectImage = vi.fn(async () => {
throw new Error('missing');
Expand Down Expand Up @@ -35,7 +65,7 @@ describe('fork container lifecycle', () => {
}));

await makeForkRunner(
{ rpcUrl: 'https://secret.example', chainId: 1 },
{ rpcUrl: 'https://secret.example', chainId: 1, forkBlockNumber: 123 },
{
docker: {
inspectImage: vi.fn(async () => undefined),
Expand All @@ -53,6 +83,7 @@ describe('fork container lifecycle', () => {
'ignite.pid': String(process.pid),
'ignite.host': os.hostname(),
}),
Cmd: [expect.stringContaining('--fork-block-number 123')],
})
);
expect(remove).toHaveBeenCalledWith({ force: true, v: true });
Expand Down
12 changes: 7 additions & 5 deletions core/src/__tests__/deployments/simulation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ describe('simulateChain', () => {
blocks: [
{
calls: [
{ status: 'success', gasUsed: '10' },
{ status: 'success', gasUsed: '10', logs: [{ address: A, topics: [`0x${'11'.repeat(32)}`], data: '0x1234' }] },
{ status: 'success', gasUsed: '11' },
{ status: 'success', gasUsed: '12' },
],
Expand Down Expand Up @@ -71,7 +71,7 @@ describe('simulateChain', () => {
tier: 'simulateV1',
baseBlock: 99,
perStep: {
one: { gasUsed: '10' },
one: { gasUsed: '10', logs: [{ address: A, topics: [`0x${'11'.repeat(32)}`], data: '0x1234' }] },
two: { gasUsed: '11' },
three: { gasUsed: '12' },
},
Expand All @@ -88,8 +88,9 @@ describe('simulateChain', () => {
it('falls through unsupported simulateV1 to the fork runner', async () => {
const fork = {
run: vi.fn(async () => ({
one: { status: 'ok' as const, gasUsed: '42' },
one: { status: 'ok' as const, gasUsed: '42', logs: [{ address: A, topics: [], data: '0x' as Hex }] },
})),
storageSlotChanges: vi.fn(),
};
const outcome = await simulateChain({
chainId: 1,
Expand All @@ -111,7 +112,7 @@ describe('simulateChain', () => {
});
expect(outcome).toMatchObject({
tier: 'fork',
perStep: { one: { status: 'ok', gasUsed: '42' } },
perStep: { one: { status: 'ok', gasUsed: '42', logs: [{ address: A, topics: [], data: '0x' }] } },
});
expect(outcome.fallthrough[0]).toContain(
'SIMULATION_SIMULATEV1_UNAVAILABLE'
Expand Down Expand Up @@ -172,8 +173,9 @@ describe('simulateChain', () => {
},
getFork: async () => ({
run: async () => ({
one: { status: 'ok', gasUsed: '1', createdAddress: B },
one: { status: 'ok', gasUsed: '1', createdAddress: B, logs: [] },
}),
storageSlotChanges: async () => ({ storage: {} }),
}),
})
).rejects.toMatchObject({ code: 'SIMULATION_ADDRESS_DIVERGENCE' });
Expand Down
23 changes: 22 additions & 1 deletion core/src/__tests__/deployments/validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
type FrozenInputs,
type RepoContractSource,
} from '@ignite/api';
import { validatePlan } from '../../deployments/validation.js';
import { storageSlotChanges, validatePlan } from '../../deployments/validation.js';
import { initcodeHashOf, predictCreate2Address } from '../../deployments/create2.js';
import { buildChainPredictions, buildInitcode, clearProvisionalPredictionCache } from '../../deployments/schedule.js';
import { DeploymentTypeService } from '../../deployments/DeploymentTypeService.js';
Expand Down Expand Up @@ -676,3 +676,24 @@ describe('validatePlan', () => {
}
});
});

describe('storageSlotChanges', () => {
it('rebuilds the schedule and delegates the requested step to a fork fake', async () => {
const trace = vi.fn(async () => ({ storage: { [ADDRESS]: [] } }));
const runner = { run: vi.fn(), storageSlotChanges: trace };
const makeForkRunner = vi.fn(async () => runner);
const getTransactionCount = vi.fn(async () => 0);
await expect(storageSlotChanges(plan(), { '1': 'rpc-1' }, 1, 'deploy-token', 123, {
freezeContractTypes: vi.fn(async () => ({})),
freezeInputs: vi.fn(async () => frozen),
resolveRpcEndpoint: vi.fn(async () => ({ id: 'rpc-1', url: 'https://rpc.example/secret' })),
listAccounts: vi.fn(async () => [{ pluginId: 'key', name: 'Key', state: 'ok', accounts: [{ id: 'account', address: ADDRESS }] }]),
createClient: vi.fn(() => ({ estimateGas: async () => 0n, getBalance: async () => 0n, estimateFeesPerGas: async () => ({}), getTransactionCount, getCode: async () => '0x' as `0x${string}` })),
deploymentTypes: { prepare: vi.fn(), list: vi.fn(), validate: vi.fn() },
makeForkRunner,
})).resolves.toEqual({ storage: { [ADDRESS]: [] } });
expect(makeForkRunner).toHaveBeenCalledWith(expect.objectContaining({ forkBlockNumber: 123 }));
expect(getTransactionCount).toHaveBeenCalledWith(expect.objectContaining({ blockNumber: 123n }));
expect(trace).toHaveBeenCalledWith(expect.arrayContaining([expect.objectContaining({ stepId: 'deploy-token' })]), 'deploy-token');
});
});
67 changes: 66 additions & 1 deletion core/src/api/deployments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ import type {
RunRecord,
ValidateDeploymentData,
ValidateDeploymentRequest,
StorageSlotChangesData,
StorageSlotChangesRequest,
EventSignatureData,
EventSignatureQuery,
WorkflowDocument,
WorkflowRunBinding,
WorkflowRunRequest,
Expand Down Expand Up @@ -40,10 +44,13 @@ import { sendCaughtError } from './utils/errors.js';
import { RepoService } from '../repos/RepoService.js';
import { WorkflowHttpError, readWorkflowDocument } from './workflows.js';
import { InstalledWorkflowStore } from '../workflows/InstalledWorkflowStore.js';
import { storageSlotChanges } from '../deployments/validation.js';
import { lookupEventSignature } from '../deployments/eventSignatures.js';

type ProfileSource = { getCurrentProfile(): string };
type RunIdParams = { runId: string };
type ResolveLaneParams = { runId: string; chainId: string };
let storageTraceInFlight = false;
export interface DeploymentHandlerDeps {
engine: Pick<DeployEngine, 'launch' | 'resolveLane' | 'resume' | 'abort'>;
getProfileManager: () => Promise<ProfileSource>;
Expand Down Expand Up @@ -73,6 +80,15 @@ export interface DeploymentHandlerDeps {
deploymentTypes: Pick<DeploymentTypeService, 'prepare'>;
readWorkflow: (repoPathOrUrl: string, name: string) => Promise<{ document: WorkflowDocument; raw: string; docHash: string }>;
installedWorkflows: Pick<InstalledWorkflowStore, 'get'>;
storageSlotChanges: (
plan: StorageSlotChangesRequest['plan'],
rpcSelection: StorageSlotChangesRequest['rpcSelection'],
chainId: number,
stepId: string,
baseBlock?: number,
opts?: { profileId?: string },
) => Promise<StorageSlotChangesData>;
lookupEventSignature: (topic0: EventSignatureQuery['topic0']) => Promise<string | undefined>;
}

export function createDeploymentHandlers(
Expand Down Expand Up @@ -110,6 +126,11 @@ export function createDeploymentHandlers(
readWorkflow:
deps?.readWorkflow ?? ((repoPathOrUrl, name) => readWorkflowDocument(RepoService.getInstance(), repoPathOrUrl, name, process.env.NODE_ENV === 'development')),
installedWorkflows: deps?.installedWorkflows ?? new InstalledWorkflowStore(),
storageSlotChanges:
deps?.storageSlotChanges ??
((plan, rpcSelection, chainId, stepId, baseBlock, opts) =>
storageSlotChanges(plan, rpcSelection, chainId, stepId, baseBlock, opts)),
lookupEventSignature: deps?.lookupEventSignature ?? lookupEventSignature,
};
const profileId = async () =>
(await d.getProfileManager()).getCurrentProfile();
Expand All @@ -132,7 +153,8 @@ export function createDeploymentHandlers(
? 500
: error.code === ErrorCodes.DEPLOYMENT_RUN_NOT_FOUND
? 404
: error.code === ErrorCodes.STALE_RESOLVE
: error.code === ErrorCodes.STALE_RESOLVE ||
error.code === ErrorCodes.DEPLOYMENT_STORAGE_TRACE_BUSY
? 409
: 400;
return reply
Expand Down Expand Up @@ -201,6 +223,49 @@ export function createDeploymentHandlers(
return deploymentError(reply, error);
}
},
getStorageSlotChanges: async (
request: FastifyRequest<{ Body: StorageSlotChangesRequest }>,
reply: FastifyReply,
): Promise<IApiResponse<StorageSlotChangesData>> => {
let ownsStorageTrace = false;
try {
if (storageTraceInFlight)
throw new IgniteError(
'A storage trace is already running. Try again after it finishes.',
ErrorCodes.DEPLOYMENT_STORAGE_TRACE_BUSY,
);
storageTraceInFlight = true;
ownsStorageTrace = true;
const body = request.body;
const data = await d.storageSlotChanges(
body.plan,
body.rpcSelection,
body.chainId,
body.stepId,
body.baseBlock,
{ profileId: await profileId() },
);
return reply.status(200).send({ data });
} catch (error) {
return deploymentError(reply, error instanceof IgniteError ? error : new IgniteError(
error instanceof Error ? error.message : String(error),
ErrorCodes.DEPLOYMENT_VALIDATION_FAILED,
));
} finally {
if (ownsStorageTrace) storageTraceInFlight = false;
}
},
lookupEventSignature: async (
request: FastifyRequest<{ Querystring: EventSignatureQuery }>,
reply: FastifyReply,
): Promise<IApiResponse<EventSignatureData>> => {
try {
const signature = await d.lookupEventSignature(request.query.topic0);
return reply.status(200).send({ data: signature ? { signature } : {} });
} catch (error) {
return deploymentError(reply, error);
}
},
prepareDeploymentStep: async (
request: FastifyRequest<{ Body: PrepareStepRequest }>,
reply: FastifyReply
Expand Down
Loading