diff --git a/src/database/migrations/012_slashing_penalty_consistency.sql b/src/database/migrations/012_slashing_penalty_consistency.sql new file mode 100644 index 0000000..673c26d --- /dev/null +++ b/src/database/migrations/012_slashing_penalty_consistency.sql @@ -0,0 +1,34 @@ +-- @up +CREATE TABLE validator_registry ( + validator_id TEXT PRIMARY KEY, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE VIEW active_validators AS +SELECT validator_id, created_at, updated_at +FROM validator_registry +WHERE active = TRUE; + +CREATE TABLE slashing_events ( + event_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + validator_id TEXT NOT NULL CHECK (validator_id <> ''), + misbehavior_type TEXT NOT NULL CHECK (misbehavior_type <> ''), + penalty_amount NUMERIC NOT NULL CHECK (penalty_amount > 0), + base_penalty NUMERIC NOT NULL CHECK (base_penalty > 0), + total_validator_count BIGINT NOT NULL CHECK (total_validator_count > 0), + validator_count_at_slashing BIGINT NOT NULL CHECK ( + validator_count_at_slashing >= 0 + AND validator_count_at_slashing <= total_validator_count + ), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX slashing_events_validator_created_idx + ON slashing_events (validator_id, created_at DESC); + +-- @down +DROP TABLE IF EXISTS slashing_events; +DROP VIEW IF EXISTS active_validators; +DROP TABLE IF EXISTS validator_registry; diff --git a/src/slashing/executor.ts b/src/slashing/executor.ts new file mode 100644 index 0000000..5357562 --- /dev/null +++ b/src/slashing/executor.ts @@ -0,0 +1,158 @@ +import { + BASE_SLASHING_PENALTY, + calculatePenalty, + type PenaltyCalculation, +} from './penaltyCalculator'; + +export interface SlashingQueryResult { + rows: Row[]; +} + +export interface SlashingDatabaseClient { + query(sql: string, params?: unknown[]): Promise>; + release(): void; +} + +export interface SlashingDatabasePool { + connect(): Promise; +} + +export interface ExecuteSlashingInput { + validatorId: string; + misbehaviorType: string; + totalValidators: number; +} + +export interface SlashingEvent extends PenaltyCalculation { + eventId: string; + validatorId: string; + misbehaviorType: string; + validatorCountAtSlashing: number; + createdAt: Date; +} + +interface CountRow { + count: string | number | bigint; +} + +interface SlashingEventRow { + event_id: string | number | bigint; + validator_id: string; + misbehavior_type: string; + penalty_amount: string | number; + base_penalty: string | number; + total_validator_count: string | number | bigint; + validator_count_at_slashing: string | number | bigint; + created_at: Date; +} + +function requireText(value: string, name: string): void { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new TypeError(`${name} must be a non-empty string`); + } +} + +function parseActiveCount(row: CountRow | undefined): number { + if (!row) { + throw new Error('active validator count query returned no row'); + } + const count = Number(row.count); + if (!Number.isSafeInteger(count) || count < 0) { + throw new RangeError('database returned an invalid active validator count'); + } + return count; +} + +/** + * Atomically snapshots active membership, calculates the penalty, and records + * the immutable event. No RPC or other external work occurs in this class. + */ +export class SlashingExecutor { + constructor(private readonly pool: SlashingDatabasePool) {} + + async execute(input: ExecuteSlashingInput): Promise { + requireText(input.validatorId, 'validatorId'); + requireText(input.misbehaviorType, 'misbehaviorType'); + + const client = await this.pool.connect(); + let transactionStarted = false; + try { + await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE'); + transactionStarted = true; + await client.query('LOCK TABLE validator_registry IN SHARE ROW EXCLUSIVE MODE'); + + const countResult = await client.query( + 'SELECT COUNT(*) AS count FROM active_validators', + ); + const validatorCountAtSlashing = parseActiveCount(countResult.rows[0]); + const calculation = calculatePenalty({ + activeValidators: validatorCountAtSlashing, + totalValidators: input.totalValidators, + basePenalty: BASE_SLASHING_PENALTY, + }); + + const eventResult = await client.query( + `INSERT INTO slashing_events ( + validator_id, + misbehavior_type, + penalty_amount, + base_penalty, + total_validator_count, + validator_count_at_slashing + ) VALUES ($1, $2, $3, $4, $5, $6) + RETURNING event_id, validator_id, misbehavior_type, penalty_amount, + base_penalty, total_validator_count, + validator_count_at_slashing, created_at`, + [ + input.validatorId, + input.misbehaviorType, + calculation.penalty, + calculation.basePenalty, + calculation.totalValidators, + validatorCountAtSlashing, + ], + ); + if (!eventResult.rows[0]) { + throw new Error('slashing event insert returned no row'); + } + + await client.query('COMMIT'); + transactionStarted = false; + return this.mapEvent(eventResult.rows[0], calculation); + } catch (error) { + if (transactionStarted) { + try { + await client.query('ROLLBACK'); + } catch { + // Preserve the original transaction/commit failure. + } + } + throw error; + } finally { + client.release(); + } + } + + async slash(input: ExecuteSlashingInput): Promise { + return this.execute(input); + } + + async executeSlashing(input: ExecuteSlashingInput): Promise { + return this.execute(input); + } + + private mapEvent(row: SlashingEventRow, calculation: PenaltyCalculation): SlashingEvent { + return { + eventId: String(row.event_id), + validatorId: row.validator_id, + misbehaviorType: row.misbehavior_type, + penalty: Number(row.penalty_amount), + multiplier: calculation.multiplier, + activeValidators: Number(row.validator_count_at_slashing), + totalValidators: Number(row.total_validator_count), + basePenalty: Number(row.base_penalty), + validatorCountAtSlashing: Number(row.validator_count_at_slashing), + createdAt: row.created_at, + }; + } +} diff --git a/src/slashing/penaltyCalculator.ts b/src/slashing/penaltyCalculator.ts new file mode 100644 index 0000000..06374de --- /dev/null +++ b/src/slashing/penaltyCalculator.ts @@ -0,0 +1,80 @@ +export const BASE_SLASHING_PENALTY = 500; + +export interface PenaltyInputs { + activeValidators: number; + totalValidators: number; + basePenalty?: number; +} + +export interface PenaltyCalculation { + penalty: number; + multiplier: number; + activeValidators: number; + totalValidators: number; + basePenalty: number; +} + +function assertValidatorCount(value: number, name: string, allowZero: boolean): void { + if (!Number.isFinite(value) || !Number.isSafeInteger(value)) { + throw new RangeError(`${name} must be a finite, safe integer`); + } + if (allowZero ? value < 0 : value <= 0) { + throw new RangeError(`${name} must be ${allowZero ? 'non-negative' : 'greater than zero'}`); + } +} + +/** + * Calculate the monetary penalty from the validator-set snapshot. + * + * The positional form is (activeValidators, totalValidators, basePenalty?). + * The object form is also supported to keep call sites self-documenting. + */ +export function calculatePenalty(inputs: PenaltyInputs): PenaltyCalculation; +export function calculatePenalty( + activeValidators: number, + totalValidators: number, + basePenalty?: number, +): PenaltyCalculation; +export function calculatePenalty( + inputsOrActive: PenaltyInputs | number, + positionalTotal?: number, + positionalBase: number = BASE_SLASHING_PENALTY, +): PenaltyCalculation { + const inputs: PenaltyInputs = + typeof inputsOrActive === 'number' + ? { + activeValidators: inputsOrActive, + totalValidators: positionalTotal as number, + basePenalty: positionalBase, + } + : inputsOrActive; + + const { + activeValidators, + totalValidators, + basePenalty = BASE_SLASHING_PENALTY, + } = inputs; + + assertValidatorCount(totalValidators, 'totalValidators', false); + assertValidatorCount(activeValidators, 'activeValidators', true); + if (activeValidators > totalValidators) { + throw new RangeError('activeValidators must not exceed totalValidators'); + } + if (!Number.isFinite(basePenalty) || basePenalty <= 0) { + throw new RangeError('basePenalty must be finite and greater than zero'); + } + + const multiplier = 1 + (totalValidators - activeValidators) / totalValidators; + const penalty = basePenalty * multiplier; + if (!Number.isFinite(penalty)) { + throw new RangeError('calculated penalty must be finite'); + } + + return { + penalty, + multiplier, + activeValidators, + totalValidators, + basePenalty, + }; +} diff --git a/src/staking/validatorRegistry.ts b/src/staking/validatorRegistry.ts new file mode 100644 index 0000000..071b61a --- /dev/null +++ b/src/staking/validatorRegistry.ts @@ -0,0 +1,117 @@ +export interface ValidatorMembership { + validatorId: string; + active: boolean; + createdAt: Date; + updatedAt: Date; +} + +export interface ValidatorRegistryQueryResult { + rows: Row[]; +} + +export interface ValidatorRegistryDatabase { + query(sql: string, params?: unknown[]): Promise>; +} + +interface ValidatorMembershipRow { + validator_id: string; + active: boolean; + created_at: Date; + updated_at: Date; +} + +interface CountRow { + count: string | number | bigint; +} + +function requireValidatorId(validatorId: string): void { + if (typeof validatorId !== 'string' || validatorId.trim().length === 0) { + throw new TypeError('validatorId must be a non-empty string'); + } +} + +function mapMembership(row: ValidatorMembershipRow): ValidatorMembership { + return { + validatorId: row.validator_id, + active: row.active, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function parseCount(value: string | number | bigint): number { + const count = Number(value); + if (!Number.isSafeInteger(count) || count < 0) { + throw new RangeError('database returned an invalid validator count'); + } + return count; +} + +/** PostgreSQL-backed authority for validator active-set membership. */ +export class ValidatorRegistry { + constructor(private readonly database: ValidatorRegistryDatabase) {} + + async registerValidator(validatorId: string, active: boolean = true): Promise { + requireValidatorId(validatorId); + if (typeof active !== 'boolean') { + throw new TypeError('active must be a boolean'); + } + + const result = await this.database.query( + `INSERT INTO validator_registry (validator_id, active) + VALUES ($1, $2) + ON CONFLICT (validator_id) DO UPDATE + SET active = EXCLUDED.active, updated_at = NOW() + RETURNING validator_id, active, created_at, updated_at`, + [validatorId, active], + ); + return mapMembership(result.rows[0]); + } + + async addValidator(validatorId: string, active: boolean = true): Promise { + return this.registerValidator(validatorId, active); + } + + async activateValidator(validatorId: string): Promise { + return this.setActive(validatorId, true); + } + + async deactivateValidator(validatorId: string): Promise { + return this.setActive(validatorId, false); + } + + /** Removing a validator means removing it from the active set, not erasing its identity. */ + async removeValidator(validatorId: string): Promise { + return this.deactivateValidator(validatorId); + } + + async getActiveValidatorIds(): Promise { + const result = await this.database.query<{ validator_id: string }>( + 'SELECT validator_id FROM active_validators ORDER BY validator_id', + ); + return result.rows.map((row) => row.validator_id); + } + + async getActiveValidatorCount(): Promise { + const result = await this.database.query('SELECT COUNT(*) AS count FROM active_validators'); + if (!result.rows[0]) { + throw new Error('active validator count query returned no row'); + } + return parseCount(result.rows[0].count); + } + + private async setActive(validatorId: string, active: boolean): Promise { + requireValidatorId(validatorId); + const result = await this.database.query( + `UPDATE validator_registry + SET active = $2, updated_at = NOW() + WHERE validator_id = $1 + RETURNING validator_id, active, created_at, updated_at`, + [validatorId, active], + ); + if (!result.rows[0]) { + throw new Error(`Validator ${validatorId} is not registered`); + } + return mapMembership(result.rows[0]); + } +} diff --git a/tests/slashing/penalty_calculator_race.test.ts b/tests/slashing/penalty_calculator_race.test.ts new file mode 100644 index 0000000..d3da827 --- /dev/null +++ b/tests/slashing/penalty_calculator_race.test.ts @@ -0,0 +1,547 @@ +import { strict as assert } from 'assert'; +import { + BASE_SLASHING_PENALTY, + calculatePenalty, +} from '../../src/slashing/penaltyCalculator'; +import { + SlashingExecutor, + type SlashingDatabaseClient, + type SlashingDatabasePool, + type SlashingQueryResult, +} from '../../src/slashing/executor'; +import { + ValidatorRegistry, + type ValidatorRegistryDatabase, + type ValidatorRegistryQueryResult, +} from '../../src/staking/validatorRegistry'; + +class Deferred { + readonly promise: Promise; + private resolvePromise!: (value: T | PromiseLike) => void; + + constructor() { + this.promise = new Promise((resolve) => { + this.resolvePromise = resolve; + }); + } + + resolve(value: T extends void ? undefined : T = undefined as T extends void ? undefined : T): void { + this.resolvePromise(value as T); + } +} + +class ExclusiveTableLock { + private owner: object | undefined; + private readonly waiters: Array<{ owner: object; acquired: Deferred }> = []; + + async acquire(owner: object): Promise { + if (!this.owner) { + this.owner = owner; + return; + } + const acquired = new Deferred(); + this.waiters.push({ owner, acquired }); + await acquired.promise; + } + + release(owner: object): void { + assert.equal(this.owner, owner, 'only the table-lock owner can release it'); + const next = this.waiters.shift(); + this.owner = next?.owner; + next?.acquired.resolve(); + } +} + +interface StoredEvent { + event_id: string; + validator_id: string; + misbehavior_type: string; + penalty_amount: number; + base_penalty: number; + total_validator_count: number; + validator_count_at_slashing: number; + created_at: Date; +} + +class LockAwareDatabase implements SlashingDatabasePool, ValidatorRegistryDatabase { + readonly validators = new Map(); + readonly events: StoredEvent[] = []; + readonly queryLog: Array<{ clientId: number | 'registry'; sql: string; params?: unknown[] }> = []; + readonly lockAcquired = new Deferred(); + readonly secondSlashingLockAttempted = new Deferred(); + readonly continueSlashing = new Deferred(); + readonly membershipMutationAttempted = new Deferred(); + connectCount = 0; + releaseCount = 0; + pauseFirstSlashingLock = true; + + private readonly tableLock = new ExclusiveTableLock(); + private nextClientId = 1; + private slashingLockAttempts = 0; + + async connect(): Promise { + this.connectCount += 1; + return new LockAwareClient(this, this.nextClientId++); + } + + async query( + sql: string, + params?: unknown[], + ): Promise> { + this.queryLog.push({ clientId: 'registry', sql, params }); + const normalized = normalize(sql); + + if (normalized.startsWith('UPDATE VALIDATOR_REGISTRY')) { + this.membershipMutationAttempted.resolve(); + const owner = {}; + await this.tableLock.acquire(owner); + try { + const validatorId = String(params?.[0]); + if (!this.validators.has(validatorId)) return { rows: [] }; + const active = Boolean(params?.[1]); + this.validators.set(validatorId, active); + return { + rows: [membershipRow(validatorId, active) as Row], + }; + } finally { + this.tableLock.release(owner); + } + } + + if (normalized.startsWith('INSERT INTO VALIDATOR_REGISTRY')) { + this.membershipMutationAttempted.resolve(); + const owner = {}; + await this.tableLock.acquire(owner); + try { + const validatorId = String(params?.[0]); + const active = Boolean(params?.[1]); + this.validators.set(validatorId, active); + return { rows: [membershipRow(validatorId, active) as Row] }; + } finally { + this.tableLock.release(owner); + } + } + + if (normalized.includes('COUNT(*)') && normalized.includes('ACTIVE_VALIDATORS')) { + return { rows: [{ count: this.activeCount() } as Row] }; + } + if (normalized.startsWith('SELECT VALIDATOR_ID FROM ACTIVE_VALIDATORS')) { + return { + rows: Array.from(this.validators) + .filter(([, active]) => active) + .map(([validator_id]) => ({ validator_id }) as Row), + }; + } + throw new Error(`Unexpected registry query: ${sql}`); + } + + activeCount(): number { + return Array.from(this.validators.values()).filter(Boolean).length; + } + + async acquireSlashingLock(owner: object): Promise { + this.slashingLockAttempts += 1; + if (this.slashingLockAttempts === 2) { + this.secondSlashingLockAttempted.resolve(); + } + await this.tableLock.acquire(owner); + this.lockAcquired.resolve(); + if (this.pauseFirstSlashingLock) { + this.pauseFirstSlashingLock = false; + await this.continueSlashing.promise; + } + } + + releaseSlashingLock(owner: object): void { + this.tableLock.release(owner); + } +} + +class LockAwareClient implements SlashingDatabaseClient { + private inTransaction = false; + private hasTableLock = false; + + constructor( + private readonly database: LockAwareDatabase, + private readonly clientId: number, + ) {} + + async query(sql: string, params?: unknown[]): Promise> { + this.database.queryLog.push({ clientId: this.clientId, sql, params }); + const normalized = normalize(sql); + + if (normalized === 'BEGIN ISOLATION LEVEL SERIALIZABLE') { + this.inTransaction = true; + return { rows: [] }; + } + if (normalized === 'LOCK TABLE VALIDATOR_REGISTRY IN SHARE ROW EXCLUSIVE MODE') { + assert(this.inTransaction); + await this.database.acquireSlashingLock(this); + this.hasTableLock = true; + return { rows: [] }; + } + if (normalized.includes('COUNT(*)') && normalized.includes('ACTIVE_VALIDATORS')) { + assert(this.hasTableLock, 'active membership must not be read before the table lock'); + return { rows: [{ count: this.database.activeCount() } as Row] }; + } + if (normalized.startsWith('INSERT INTO SLASHING_EVENTS')) { + assert(this.hasTableLock, 'event insert must remain in the locked transaction'); + const event: StoredEvent = { + event_id: String(this.database.events.length + 1), + validator_id: String(params?.[0]), + misbehavior_type: String(params?.[1]), + penalty_amount: Number(params?.[2]), + base_penalty: Number(params?.[3]), + total_validator_count: Number(params?.[4]), + validator_count_at_slashing: Number(params?.[5]), + created_at: new Date(), + }; + this.database.events.push(event); + return { rows: [event as Row] }; + } + if (normalized === 'COMMIT' || normalized === 'ROLLBACK') { + this.inTransaction = false; + if (this.hasTableLock) { + this.hasTableLock = false; + this.database.releaseSlashingLock(this); + } + return { rows: [] }; + } + throw new Error(`Unexpected slashing query: ${sql}`); + } + + release(): void { + assert(!this.inTransaction, 'client released with an open transaction'); + this.database.releaseCount += 1; + } +} + +class ScriptedClient implements SlashingDatabaseClient { + readonly queries: string[] = []; + released = false; + + constructor( + private readonly failAt?: 'count' | 'insert' | 'commit' | 'rollback', + private readonly activeCount = 3, + ) {} + + async query(sql: string): Promise> { + this.queries.push(normalize(sql)); + const normalized = normalize(sql); + if (normalized.includes('COUNT(*)')) { + if (this.failAt === 'count') throw new Error('count failed'); + return { rows: [{ count: this.activeCount } as Row] }; + } + if (normalized.startsWith('INSERT INTO SLASHING_EVENTS')) { + if (this.failAt === 'insert') throw new Error('insert failed'); + return { + rows: [ + { + event_id: '1', + validator_id: 'validator-a', + misbehavior_type: 'double-sign', + penalty_amount: 625, + base_penalty: 500, + total_validator_count: 4, + validator_count_at_slashing: this.activeCount, + created_at: new Date(), + } as Row, + ], + }; + } + if (normalized === 'COMMIT' && this.failAt === 'commit') { + throw new Error('commit failed'); + } + if (normalized === 'ROLLBACK' && this.failAt === 'rollback') { + throw new Error('rollback failed'); + } + return { rows: [] }; + } + + release(): void { + this.released = true; + } +} + +function normalize(sql: string): string { + return sql.replace(/\s+/g, ' ').trim().toUpperCase(); +} + +function membershipRow(validatorId: string, active: boolean): object { + const now = new Date(); + return { + validator_id: validatorId, + active, + created_at: now, + updated_at: now, + }; +} + +function seed(database: LockAwareDatabase, active: number, inactive: number): void { + for (let index = 1; index <= active; index += 1) { + database.validators.set(`active-${index}`, true); + } + for (let index = 1; index <= inactive; index += 1) { + database.validators.set(`inactive-${index}`, false); + } +} + +async function testFormulaAndValidation(): Promise { + assert.equal(BASE_SLASHING_PENALTY, 500); + assert.deepEqual(calculatePenalty(10, 10), { + penalty: 500, + multiplier: 1, + activeValidators: 10, + totalValidators: 10, + basePenalty: 500, + }); + assert.equal(calculatePenalty(0, 10).multiplier, 2); + assert.equal(calculatePenalty(0, 10).penalty, 1000); + assert.equal(calculatePenalty({ activeValidators: 3, totalValidators: 4 }).penalty, 625); + + const invalidInputs: Array<() => unknown> = [ + () => calculatePenalty(0, 0), + () => calculatePenalty(-1, 4), + () => calculatePenalty(5, 4), + () => calculatePenalty(1.5, 4), + () => calculatePenalty(1, Number.POSITIVE_INFINITY), + () => calculatePenalty(Number.MAX_SAFE_INTEGER + 1, Number.MAX_SAFE_INTEGER + 1), + () => calculatePenalty({ activeValidators: 1, totalValidators: 2, basePenalty: Number.NaN }), + ]; + invalidInputs.forEach((invoke) => assert.throws(invoke, RangeError)); +} + +async function testRegistryApi(): Promise { + const database = new LockAwareDatabase(); + const registry = new ValidatorRegistry(database); + await registry.addValidator('validator-b', false); + await registry.registerValidator('validator-a'); + assert.deepEqual(await registry.getActiveValidatorIds(), ['validator-a']); + assert.equal(await registry.getActiveValidatorCount(), 1); + await registry.removeValidator('validator-a'); + assert.equal(await registry.getActiveValidatorCount(), 0); + await assert.rejects(registry.activateValidator('missing'), /is not registered/); +} + +async function testSlashWinsActivationRace(): Promise { + const database = new LockAwareDatabase(); + seed(database, 3, 1); + const executor = new SlashingExecutor(database); + const registry = new ValidatorRegistry(database); + + const slashPromise = executor.execute({ + validatorId: 'active-1', + misbehaviorType: 'double-sign', + totalValidators: 4, + }); + await database.lockAcquired.promise; + + let activationFinished = false; + const activationPromise = registry.activateValidator('inactive-1').then(() => { + activationFinished = true; + }); + await database.membershipMutationAttempted.promise; + assert.equal(activationFinished, false); + assert.equal(database.activeCount(), 3); + + database.continueSlashing.resolve(); + const event = await slashPromise; + await activationPromise; + + assert.equal(event.validatorCountAtSlashing, 3); + assert.equal(event.penalty, 625); + assert.equal(database.events[0].validator_count_at_slashing, 3); + assert.equal(database.activeCount(), 4); + assert.equal(database.connectCount, 1); + assert.equal(database.releaseCount, 1); + + const slashingQueries = database.queryLog.filter((entry) => entry.clientId === 1); + assert.deepEqual( + slashingQueries.map((entry) => normalize(entry.sql).split(' ')[0]), + ['BEGIN', 'LOCK', 'SELECT', 'INSERT', 'COMMIT'], + ); + assert(slashingQueries[0].sql.includes('SERIALIZABLE')); + assert.equal( + normalize(slashingQueries[1].sql), + 'LOCK TABLE VALIDATOR_REGISTRY IN SHARE ROW EXCLUSIVE MODE', + ); + assert(normalize(slashingQueries[2].sql).includes('FROM ACTIVE_VALIDATORS')); + assert(normalize(slashingQueries[3].sql).includes('VALIDATOR_COUNT_AT_SLASHING')); + assert.equal(slashingQueries[3].params?.[5], 3); + + await registry.deactivateValidator('active-2'); + assert.equal(database.activeCount(), 3); + assert.equal(database.events[0].validator_count_at_slashing, 3); + assert.equal(database.events[0].penalty_amount, 625); +} + +async function testSlashWinsNewValidatorJoinRace(): Promise { + const database = new LockAwareDatabase(); + seed(database, 3, 0); + const executor = new SlashingExecutor(database); + const registry = new ValidatorRegistry(database); + + const slashPromise = executor.execute({ + validatorId: 'active-1', + misbehaviorType: 'double-sign', + totalValidators: 4, + }); + await database.lockAcquired.promise; + + let registrationFinished = false; + const registrationPromise = registry.registerValidator('joining-validator', true).then(() => { + registrationFinished = true; + }); + await database.membershipMutationAttempted.promise; + + const registrationQuery = database.queryLog.find( + (entry) => + entry.clientId === 'registry' && + normalize(entry.sql).startsWith('INSERT INTO VALIDATOR_REGISTRY'), + ); + assert(registrationQuery, 'concurrent join must use INSERT INTO validator_registry'); + assert.equal(registrationFinished, false); + assert.equal(database.validators.has('joining-validator'), false); + assert.equal(database.activeCount(), 3); + + database.continueSlashing.resolve(); + const event = await slashPromise; + + assert.equal(event.validatorCountAtSlashing, 3); + assert.equal(event.penalty, 625); + assert.equal(event.basePenalty, BASE_SLASHING_PENALTY); + assert.equal(database.events[0].base_penalty, BASE_SLASHING_PENALTY); + assert.equal(database.events[0].validator_count_at_slashing, 3); + + await registrationPromise; + assert.equal(database.activeCount(), 4); + assert.equal(database.events[0].validator_count_at_slashing, 3); + assert.equal(database.events[0].penalty_amount, 625); +} + +async function testMembershipWinsRace(): Promise { + const database = new LockAwareDatabase(); + seed(database, 3, 1); + database.pauseFirstSlashingLock = false; + const registry = new ValidatorRegistry(database); + await registry.activateValidator('inactive-1'); + + const event = await new SlashingExecutor(database).slash({ + validatorId: 'active-1', + misbehaviorType: 'equivocation', + totalValidators: 4, + }); + assert.equal(event.validatorCountAtSlashing, 4); + assert.equal(event.penalty, 500); +} + +async function testSlashWinsDeactivationRace(): Promise { + const database = new LockAwareDatabase(); + seed(database, 4, 0); + const registry = new ValidatorRegistry(database); + const slashPromise = new SlashingExecutor(database).execute({ + validatorId: 'active-1', + misbehaviorType: 'invalid-vote', + totalValidators: 4, + }); + await database.lockAcquired.promise; + let deactivationFinished = false; + const deactivationPromise = registry.deactivateValidator('active-4').then(() => { + deactivationFinished = true; + }); + await database.membershipMutationAttempted.promise; + assert.equal(deactivationFinished, false); + database.continueSlashing.resolve(); + const event = await slashPromise; + await deactivationPromise; + assert.equal(event.validatorCountAtSlashing, 4); + assert.equal(event.penalty, 500); + assert.equal(database.activeCount(), 3); +} + +async function testTwoSlashesSerialize(): Promise { + const database = new LockAwareDatabase(); + seed(database, 3, 1); + const executor = new SlashingExecutor(database); + const first = executor.execute({ + validatorId: 'active-1', + misbehaviorType: 'first', + totalValidators: 4, + }); + await database.lockAcquired.promise; + const second = executor.execute({ + validatorId: 'active-2', + misbehaviorType: 'second', + totalValidators: 4, + }); + await database.secondSlashingLockAttempted.promise; + assert.equal(database.events.length, 0); + database.continueSlashing.resolve(); + const events = await Promise.all([first, second]); + assert.deepEqual(events.map((event) => event.validatorCountAtSlashing), [3, 3]); + assert.equal(database.releaseCount, 2); +} + +async function testRollbackAndReleaseFailures(): Promise { + for (const failAt of ['count', 'insert', 'commit'] as const) { + const client = new ScriptedClient(failAt); + const executor = new SlashingExecutor({ connect: async () => client }); + await assert.rejects( + executor.execute({ + validatorId: 'validator-a', + misbehaviorType: 'double-sign', + totalValidators: 4, + }), + new RegExp(`${failAt} failed`), + ); + assert.equal(client.queries.at(-1), 'ROLLBACK'); + assert.equal(client.released, true); + } + + const invalidCalculationClient = new ScriptedClient(undefined, 5); + await assert.rejects( + new SlashingExecutor({ connect: async () => invalidCalculationClient }).execute({ + validatorId: 'validator-a', + misbehaviorType: 'double-sign', + totalValidators: 4, + }), + /must not exceed/, + ); + assert.equal(invalidCalculationClient.queries.at(-1), 'ROLLBACK'); + assert(!invalidCalculationClient.queries.some((query) => query.startsWith('INSERT'))); + assert.equal(invalidCalculationClient.released, true); + + const rollbackFailureClient = new ScriptedClient('rollback'); + const rollbackFailureExecutor = new SlashingExecutor({ connect: async () => rollbackFailureClient }); + await assert.rejects( + rollbackFailureExecutor.execute({ + validatorId: 'validator-a', + misbehaviorType: 'double-sign', + totalValidators: 2, + }), + /must not exceed/, + ); + assert.equal(rollbackFailureClient.queries.at(-1), 'ROLLBACK'); + assert.equal(rollbackFailureClient.released, true); +} + +async function main(): Promise { + await testFormulaAndValidation(); + await testRegistryApi(); + await testSlashWinsActivationRace(); + await testSlashWinsNewValidatorJoinRace(); + await testMembershipWinsRace(); + await testSlashWinsDeactivationRace(); + await testTwoSlashesSerialize(); + await testRollbackAndReleaseFailures(); + console.log('slashing penalty consistency tests passed'); +} + +const deadlockTimeout = new Promise((_, reject) => { + setTimeout(() => reject(new Error('slashing race test deadlock timeout')), 5_000).unref(); +}); + +Promise.race([main(), deadlockTimeout]).catch((error) => { + console.error(error); + process.exit(1); +});