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
45 changes: 45 additions & 0 deletions .github/workflows/event-schema-compatibility.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: Event Schema Compatibility

on:
pull_request:
paths:
- '.github/workflows/event-schema-compatibility.yml'
- 'contract/contracts/hello-world/src/base/events.rs'
- 'listener/package.json'
- 'listener/src/schema/**'
push:
branches:
- main
paths:
- '.github/workflows/event-schema-compatibility.yml'
- 'contract/contracts/hello-world/src/base/events.rs'
- 'listener/package.json'
- 'listener/src/schema/**'
workflow_dispatch:

permissions:
contents: read

jobs:
compatibility:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install listener dependencies
run: npm install --package-lock=false
working-directory: listener
- name: Check contract event compatibility
run: npm run check:event-schema
working-directory: listener
- name: Test compatibility checker with coverage
run: >-
npm test -- --runInBand --coverage
--collectCoverageFrom=src/schema/compatibility-check.ts
src/schema/compatibility-check.test.ts
working-directory: listener
- name: Typecheck listener
run: npm run typecheck
working-directory: listener
91 changes: 0 additions & 91 deletions contract/contracts/hello-world/src/base/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -452,33 +452,6 @@ pub struct OwnershipTransferInitiated {
}

/// Emitted when a two-step ownership transfer is completed.
/// Emitted when an off-chain batch of notifications finishes processing.
#[contractevent(data_format = "single-value")]
#[derive(Clone)]
pub struct BatchProcessingCompleted {
#[topic]
pub batch_id: BytesN<32>,
#[topic]
pub category: NotificationCategory,
#[topic]
pub priority: NotificationPriority,
pub processed_count: u32,
}

/// Emitted when an off-chain batch of notifications finishes processing.
#[contractevent(data_format = "single-value")]
#[derive(Clone)]
pub struct BatchProcessingCompleted {
#[topic]
pub batch_id: BytesN<32>,
#[topic]
pub category: NotificationCategory,
#[topic]
pub priority: NotificationPriority,
pub processed_count: u32,
}

/// Emitted when a scheduled notification's expiry period is extended by an authorized sender.
#[contractevent(data_format = "single-value")]
#[derive(Clone)]
pub struct OwnershipTransferred {
Expand Down Expand Up @@ -579,70 +552,6 @@ pub struct NotificationAccessed {
pub accessed_at: u64,
}

// ============================================================================
// Reputation events
// ============================================================================

/// Emitted when a sender's reputation score is updated.
/// Emitted when a subscriber cancels an active notification subscription.
///
/// Off-chain consumers can key off `(group_id, subscriber)` to track the full
/// subscription lifecycle. The `group_id` identifies the AutoShare group whose
/// subscription was cancelled; `subscriber` is the address that initiated the
/// cancellation.
#[contractevent(data_format = "single-value")]
#[derive(Clone)]
pub struct SubscriptionCancelled {
/// The group whose subscription was cancelled.
#[topic]
pub group_id: BytesN<32>,
/// The address that cancelled the subscription.
#[topic]
pub subscriber: Address,
#[topic]
pub category: NotificationCategory,
#[topic]
pub priority: NotificationPriority,
/// Ledger timestamp (seconds) when the cancellation occurred.
pub cancelled_at: u64,
}

/// Emitted when the current owner initiates a two-step ownership transfer by
/// nominating a `pending_owner`. The transfer is not final until the pending
/// owner calls `accept_ownership`.
///
/// This mirrors the OpenZeppelin `Ownable2Step` `OwnershipTransferStarted` event
/// and lets off-chain consumers track in-progress transfers before they settle.
#[contractevent(data_format = "single-value")]
#[derive(Clone)]
pub struct ReputationUpdated {
#[topic]
pub sender: Address,
#[topic]
pub category: NotificationCategory,
#[topic]
pub priority: NotificationPriority,
pub new_score: i64,
pub successful_count: u32,
pub failed_count: u32,
}

/// Emitted when a sender's reputation tier changes.
#[contractevent(data_format = "single-value")]
#[derive(Clone)]
pub struct ReputationTierChanged {
#[topic]
pub sender: Address,
#[topic]
pub category: NotificationCategory,
#[topic]
pub priority: NotificationPriority,
pub old_tier: u32,
pub new_tier: u32,
pub reputation_score: i64,
pub new_owner: Address,
}

/// Emitted when an authorized user updates a channel's description or metadata.
///
/// Existing subscribers / members are unaffected — only descriptive metadata changes.
Expand Down
1 change: 1 addition & 0 deletions listener/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"lint": "node ./node_modules/typescript/bin/tsc --noEmit",
"format:check": "node ./node_modules/prettier/bin/prettier.cjs --check \"src/**/*.ts\" --config ../.prettierrc",
"test": "node ./node_modules/jest/bin/jest.js",
"check:event-schema": "ts-node src/schema/compatibility-check.ts",
"test:stress": "node ./node_modules/jest/bin/jest.js src/__tests__/stress.test.ts --runInBand --detectOpenHandles",
"stress-test": "ts-node src/scripts/run-stress-tests.ts",
"migrate": "ts-node src/scripts/migrate-db.ts",
Expand Down
161 changes: 161 additions & 0 deletions listener/src/schema/compatibility-check.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
checkEventCompatibility,
checkSchemaCompatibility,
ContractEventSchema,
extractContractEvents,
loadConsumerSchema,
OffConsumerEventSchema,
} from './compatibility-check';

const contractEventsPath = path.resolve(
__dirname,
'../../../contract/contracts/hello-world/src/base/events.rs'
);
const consumerSchemaPath = path.resolve(__dirname, 'consumer-event-schema.json');

function writeTempSchema(value: unknown): string {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'event-schema-'));
const filePath = path.join(directory, 'consumer.json');
fs.writeFileSync(filePath, JSON.stringify(value));
return filePath;
}

function makeEvent(
overrides: Partial<ContractEventSchema> = {}
): ContractEventSchema {
return {
name: 'TaskCreated',
topics: [{ name: 'creator', type: 'Address' }],
dataFields: [{ name: 'id', type: 'BytesN<32>' }],
...overrides,
};
}

function makeConsumer(
overrides: Partial<OffConsumerEventSchema> = {}
): OffConsumerEventSchema {
return {
eventName: 'TaskCreated',
expectedTopics: [{ name: 'creator', type: 'Address' }],
expectedFields: [{ name: 'id', type: 'BytesN<32>' }],
...overrides,
};
}

describe('event schema compatibility check', () => {
it('extracts typed event topics and data fields from the contract source', () => {
const events = extractContractEvents(contractEventsPath);
const created = events.get('AutoshareCreated');

expect(events).toHaveProperty('size', 34);
expect(created).toEqual({
name: 'AutoshareCreated',
topics: [
{ name: 'creator', type: 'Address' },
{ name: 'category', type: 'NotificationCategory' },
{ name: 'priority', type: 'NotificationPriority' },
],
dataFields: [{ name: 'id', type: 'BytesN<32>' }],
});
});

it('accepts the checked-in schema when it matches the contract', () => {
const result = checkSchemaCompatibility(
extractContractEvents(contractEventsPath),
loadConsumerSchema(consumerSchemaPath)
);

expect(result.compatible).toBe(true);
expect(result.breakingChanges).toEqual([]);
expect(result.safeAdditions).toEqual([]);
});

it.each([
['removed field', makeEvent({ dataFields: [] }), makeConsumer()],
[
'reordered field',
makeEvent({ dataFields: [{ name: 'new_id', type: 'BytesN<32>' }] }),
makeConsumer(),
],
[
'incompatible field type',
makeEvent({ dataFields: [{ name: 'id', type: 'String' }] }),
makeConsumer(),
],
[
'incompatible topic type',
makeEvent({ topics: [{ name: 'creator', type: 'String' }] }),
makeConsumer(),
],
])('rejects a %s', (_description, contractEvent, consumerEvent) => {
const result = checkEventCompatibility(
contractEvent as ContractEventSchema,
consumerEvent as OffConsumerEventSchema
);

expect(result.compatible).toBe(false);
expect(result.breakingChanges.length).toBeGreaterThan(0);
});

it('accepts and reports trailing topics and data fields', () => {
const result = checkEventCompatibility(
makeEvent({
topics: [
{ name: 'creator', type: 'Address' },
{ name: 'category', type: 'NotificationCategory' },
],
dataFields: [
{ name: 'id', type: 'BytesN<32>' },
{ name: 'created_at', type: 'u64' },
],
}),
makeConsumer()
);

expect(result.compatible).toBe(true);
expect(result.breakingChanges).toEqual([]);
expect(result.safeAdditions).toEqual([
"Safe addition: Trailing topic 'category' added to 'TaskCreated'",
"Safe addition: Trailing data field 'created_at' added to 'TaskCreated'",
]);
});

it('accepts new contract events and rejects removed expected events', () => {
const contractEvents = new Map([
['TaskCreated', makeEvent()],
['TaskUpdated', makeEvent({ name: 'TaskUpdated' })],
]);
const compatible = checkSchemaCompatibility(contractEvents, {
events: [makeConsumer()],
});
const missing = checkSchemaCompatibility(new Map(), { events: [makeConsumer()] });

expect(compatible.compatible).toBe(true);
expect(compatible.safeAdditions).toEqual([
"Safe addition: New event 'TaskUpdated' is ignored by existing consumers",
]);
expect(missing.compatible).toBe(false);
expect(missing.breakingChanges[0]).toContain("event 'TaskCreated'");
});

it('rejects empty or malformed consumer schemas', () => {
const emptyPath = writeTempSchema({ events: [] });
const malformedPath = writeTempSchema({ events: [{ eventName: 'TaskCreated' }] });
const duplicatePath = writeTempSchema({ events: [makeConsumer(), makeConsumer()] });

expect(() => loadConsumerSchema(emptyPath)).toThrow('valid event names');
expect(() => loadConsumerSchema(malformedPath)).toThrow('valid event names');
expect(() => loadConsumerSchema(duplicatePath)).toThrow('duplicate event names');

fs.rmSync(path.dirname(emptyPath), { recursive: true, force: true });
fs.rmSync(path.dirname(malformedPath), { recursive: true, force: true });
fs.rmSync(path.dirname(duplicatePath), { recursive: true, force: true });
});

it('rejects an empty contract schema', () => {
expect(checkSchemaCompatibility(new Map(), { events: [makeConsumer()] }).compatible).toBe(false);
});
});
Loading