Skip to content
Merged
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
95 changes: 95 additions & 0 deletions tests/mutation/ast-mutator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
export interface MutatedVariant {
id: string;
mutationType: string;
originalCode: string;
mutatedCode: string;
description: string;
}

export class ASTMutator {
/**
* Generates mutated variants of contract source code by injecting gas anti-patterns.
* @param code Original valid contract source code
*/
public generateMutations(code: string): MutatedVariant[] {
const mutations: MutatedVariant[] = [];

// Mutation 1: Change uint256 to uint8 (un-optimized storage/stack sizing anti-pattern)
if (code.includes('uint256')) {
const mutatedCode = code.replace(/\buint256\b/g, 'uint8');
mutations.push({
id: `mutant_uint8_${Date.now()}_1`,
mutationType: 'SUBOPTIMAL_TYPE_SIZING',
originalCode: code,
mutatedCode,
description: 'Replaced uint256 with uint8 (incurs masking overhead)',
});
}

// Mutation 2: Un-cache loop length (uncached array length in loop condition)
if (/for\s*\([^;]*;\s*[a-zA-Z0-9_]+\s*<\s*len\b/.test(code)) {
const mutatedCode = code.replace(/<\s*len\b/g, '< arr.length');
mutations.push({
id: `mutant_uncached_loop_${Date.now()}_2`,
mutationType: 'UNCACHED_LOOP_LENGTH',
originalCode: code,
mutatedCode,
description: 'Un-cached loop condition array length evaluation',
});
} else if (/for\s*\([^;]+;/.test(code)) {
// General loop mutation
const mutatedCode = code.replace(
/for\s*\(([^;]+);\s*([^;]+);/g,
'for ($1; $2 && arr.length > 0;'
);
mutations.push({
id: `mutant_loop_condition_${Date.now()}_2`,
mutationType: 'UNCACHED_LOOP_LENGTH',
originalCode: code,
mutatedCode,
description: 'Injected redundant dynamic array condition check inside loop header',
});
}

// Mutation 3: Change calldata parameter to memory (extra copy overhead)
if (code.includes('calldata')) {
const mutatedCode = code.replace(/\bcalldata\b/g, 'memory');
mutations.push({
id: `mutant_calldata_to_memory_${Date.now()}_3`,
mutationType: 'MEMORY_OVER_CALLDATA',
originalCode: code,
mutatedCode,
description: 'Replaced calldata parameter with memory allocation',
});
}

// Mutation 4: Replace ++i with i++ or i = i + 1 (non-prefix increment overhead)
if (code.includes('++i') || code.includes('++j')) {
const mutatedCode = code.replace(/\+\+i/g, 'i++').replace(/\+\+j/g, 'j++');
mutations.push({
id: `mutant_postfix_inc_${Date.now()}_4`,
mutationType: 'POSTFIX_INCREMENT',
originalCode: code,
mutatedCode,
description: 'Replaced prefix ++i increment with postfix i++ increment',
});
}

// Mutation 5: Storage re-read anti-pattern
if (/storage\b/.test(code) || /balances\[/.test(code)) {
const mutatedCode = code.replace(
/(balances\[[^\]]+\])/g,
'$1 + balances[msg.sender]'
);
mutations.push({
id: `mutant_redundant_sload_${Date.now()}_5`,
mutationType: 'REDUNDANT_STORAGE_READ',
originalCode: code,
mutatedCode,
description: 'Injected redundant storage SLOAD read operations',
});
}

return mutations;
}
}
2 changes: 2 additions & 0 deletions tests/mutation/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './ast-mutator';
export * from './mutation-runner';
93 changes: 93 additions & 0 deletions tests/mutation/mutation-runner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { ASTMutator, MutatedVariant } from './ast-mutator';

export interface MutantTestResult {
mutantId: string;
mutationType: string;
status: 'KILLED' | 'SURVIVED';
detectedByRules: string[];
description: string;
}

export interface MutationReport {
totalMutants: number;
killedMutants: number;
survivedMutants: number;
killScorePercentage: number;
results: MutantTestResult[];
}

export type RuleDetectorFn = (code: string) => string[];

export class MutationRunner {
private readonly mutator: ASTMutator;
private readonly ruleDetector: RuleDetectorFn;

constructor(ruleDetector?: RuleDetectorFn) {
this.mutator = new ASTMutator();
this.ruleDetector = ruleDetector || this.defaultGasGuardRuleDetector;
}

/**
* Runs mutation testing suite against target contract code to validate rule detection precision.
* @param contractCode Original valid smart contract code
*/
public runMutationTest(contractCode: string): MutationReport {
const mutants: MutatedVariant[] = this.mutator.generateMutations(contractCode);
const results: MutantTestResult[] = [];
let killedCount = 0;

for (const mutant of mutants) {
const detectedRules = this.ruleDetector(mutant.mutatedCode);
const isKilled = detectedRules.length > 0;

if (isKilled) {
killedCount++;
}

results.push({
mutantId: mutant.id,
mutationType: mutant.mutationType,
status: isKilled ? 'KILLED' : 'SURVIVED',
detectedByRules: detectedRules,
description: mutant.description,
});
}

const totalMutants = mutants.length;
const killScorePercentage =
totalMutants > 0 ? Math.round((killedCount / totalMutants) * 10000) / 100 : 100.0;

return {
totalMutants,
killedMutants: killedCount,
survivedMutants: totalMutants - killedCount,
killScorePercentage,
results,
};
}

/**
* Default fallback detector validating standard GasGuard gas anti-pattern rules.
*/
private defaultGasGuardRuleDetector(code: string): string[] {
const triggeredRules: string[] = [];

if (/\buint8\b/.test(code) && !/uint8\s+\[/.test(code)) {
triggeredRules.push('GAS-001: Suboptimal Type Sizing');
}
if (/< arr\.length/.test(code) || /arr\.length > 0/.test(code)) {
triggeredRules.push('GAS-002: Un-cached Loop Array Length');
}
if (/\bmemory\b/.test(code) && /function\s+[a-zA-Z0-9_]+\s*\([^)]*memory/.test(code)) {
triggeredRules.push('GAS-003: Memory Over Calldata');
}
if (/i\+\+/.test(code) || /j\+\+/.test(code)) {
triggeredRules.push('GAS-004: Postfix Increment Overhead');
}
if (/balances\[msg\.sender\]/.test(code)) {
triggeredRules.push('GAS-005: Redundant Storage Read');
}

return triggeredRules;
}
}
82 changes: 82 additions & 0 deletions tests/mutation/mutation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { ASTMutator } from './ast-mutator';
import { MutationRunner } from './mutation-runner';

describe('ASTMutator', () => {
let mutator: ASTMutator;

beforeEach(() => {
mutator = new ASTMutator();
});

it('should generate mutated code variants from a valid contract', () => {
const validContract = `
function batchTransfer(address[] calldata recipients, uint256 len) external {
for (uint256 i = 0; i < len; ++i) {
balances[recipients[i]] += 100;
}
}
`;

const mutants = mutator.generateMutations(validContract);
expect(mutants.length).toBeGreaterThan(0);

const mutationTypes = mutants.map((m) => m.mutationType);
expect(mutationTypes).toContain('SUBOPTIMAL_TYPE_SIZING');
expect(mutationTypes).toContain('UNCACHED_LOOP_LENGTH');
expect(mutationTypes).toContain('MEMORY_OVER_CALLDATA');
expect(mutationTypes).toContain('POSTFIX_INCREMENT');
});
});

describe('MutationRunner', () => {
let runner: MutationRunner;

beforeEach(() => {
runner = new MutationRunner();
});

it('should run mutation tests and report 100% mutant kill score when all rules detect mutations', () => {
const validContract = `
function processItems(address[] calldata items, uint256 len) external {
for (uint256 i = 0; i < len; ++i) {
balances[items[i]] += 1;
}
}
`;

const report = runner.runMutationTest(validContract);
expect(report.totalMutants).toBeGreaterThan(0);
expect(report.killedMutants).toBe(report.totalMutants);
expect(report.survivedMutants).toBe(0);
expect(report.killScorePercentage).toBe(100.0);

for (const result of report.results) {
expect(result.status).toBe('KILLED');
expect(result.detectedByRules.length).toBeGreaterThan(0);
}
});

it('should track survived mutants when a custom rule detector fails to flag a mutation', () => {
// Rule detector that misses SUBOPTIMAL_TYPE_SIZING
const incompleteDetector = (code: string) => {
const rules: string[] = [];
if (/< arr\.length/.test(code) || /arr\.length > 0/.test(code)) {
rules.push('GAS-002: Un-cached Loop Array Length');
}
return rules;
};

const runnerWithIncompleteRules = new MutationRunner(incompleteDetector);
const validContract = `
function processItems(address[] calldata items, uint256 len) external {
for (uint256 i = 0; i < len; ++i) {
balances[items[i]] += 1;
}
}
`;

const report = runnerWithIncompleteRules.runMutationTest(validContract);
expect(report.survivedMutants).toBeGreaterThan(0);
expect(report.killScorePercentage).toBeLessThan(100.0);
});
});
Loading