diff --git a/packages/data/src/ethers.ts b/packages/data/src/ethers.ts index 87080bbdd..8ae7eac1d 100644 --- a/packages/data/src/ethers.ts +++ b/packages/data/src/ethers.ts @@ -295,4 +295,156 @@ export default class SemaphoreEthers { return this._contract.hasMember(groupId, member) } + + /** + * Listens to the GroupCreated event. + * @param callback Called with the groupId of the newly created group. + */ + onGroupCreated(callback: (groupId: string, event: any) => void): void { + this._contract.on("GroupCreated", (groupId, event) => { + callback(groupId.toString(), event) + }) + } + + /** + * Removes all listeners for the GroupCreated event. + * Stop receiving group creation notifications. + */ + offGropupCreated(): void { + this._contract.removeAllListeners("GroupCreated") + } + + /** + * Listens to MemberAdded events. + * @param callback Receives the groupId, identityCommitment and event metadata. + */ + onMemberAdded( + callback: (groupId: string, identityCommitment: string, merkleTreeRoot: string, event: any) => void + ): void { + this._contract.on("MemberAdded", (groupId, _index, identityCommitment, merkleTreeRoot, event) => { + callback(groupId.toString(), identityCommitment.toString(), merkleTreeRoot.toString(), event) + }) + } + + /** + * Removes all listeners for the MemberAdded event. + * Stop tracking when new members are added. + */ + offMemberAdded(): void { + this._contract.removeAllListeners("MemberAdded") + } + + /** + * Listens to MemberUpdated events. + * @param callback Receives the groupId, old identityCommitment, new identityCommitment, and event metadata. + */ + onMemberUpdated( + callback: ( + groupId: string, + oldIdentityCommitment: string, + newIdentityCommitment: string, + merkleTreeRoot: string, + event: any + ) => void + ): void { + this._contract.on( + "MemberUpdated", + (groupId, _index, oldIdentityCommitment, newIdentityCommitment, merkleTreeRoot, event) => { + callback( + groupId.toString(), + oldIdentityCommitment.toString(), + newIdentityCommitment.toString(), + merkleTreeRoot.toString(), + event + ) + } + ) + } + + /** + * Removes all listeners for the MemberUpdated event. + * Stop receiving updates when members change their commitment. + */ + offMemberUpdated(): void { + this._contract.removeAllListeners("MemberUpdated") + } + + /** + * Listens to MemberRemoved events. + * @param callback Receives the groupId, identityCommitment and event metadata. + */ + onMemberRemoved( + callback: (groupId: string, identityCommitment: string, merkleTreeRoot: string, event: any) => void + ): void { + this._contract.on("MemberRemoved", (groupId, _index, identityCommitment, merkleTreeRoot, event) => { + callback(groupId.toString(), identityCommitment.toString(), merkleTreeRoot.toString(), event) + }) + } + + /** + * Removes all listeners for the MemberRemoved event. + * Stop listening for member removals. + */ + offMemberRemoved(): void { + this._contract.removeAllListeners("MemberRemoved") + } + + /** + * Listens to the ProofValidated event. + * @param callback Called with proof parameters and event metadata. + */ + onProofValidated( + callback: (proof: { + groupId: string + merkleTreeDepth: number + merkleTreeRoot: string + nullifier: string + message: string + scope: string + points: string[] + event: any + }) => void + ): void { + this._contract.on( + "ProofValidated", + (groupId, merkleTreeDepth, merkleTreeRoot, nullifier, message, scope, points, event) => { + callback({ + groupId: groupId.toString(), + merkleTreeDepth: Number(merkleTreeDepth), + merkleTreeRoot: merkleTreeRoot.toString(), + nullifier: nullifier.toString(), + message: message.toString(), + scope: scope.toString(), + points: points.map((p: any) => p.toString()), + event + }) + } + ) + } + + /** + * Removes all listeners for the ProofValidated event. + * Stop receiving proof validation notifications. + */ + offProofValidated(): void { + this._contract.removeAllListeners("ProofValidated") + } + + /** + * Listens to the GroupAdminUpdated event. + * @param callback Receives the groupId, old admin and new admin addresses and event metadata. + */ + onGroupAdminUpdated(callback: (groupId: string, oldAdmin: string, newAdmin: string, event: any) => void): void { + this._contract.on("GroupAdminUpdated", (groupId, oldAdmin, newAdmin, event) => { + callback(groupId.toString(), oldAdmin.toString(), newAdmin.toString(), event) + }) + } + + /** + * Removes all listeners for the GroupAdminUpdated event. + * Stop tracking when a group's admin is updated. + */ + offGroupAdminUpdated(): void { + this._contract.removeAllListeners("GroupAdminUpdated") + } } diff --git a/packages/data/tests/ethers.test.ts b/packages/data/tests/ethers.test.ts index 27c8fd19f..7f9db91f5 100644 --- a/packages/data/tests/ethers.test.ts +++ b/packages/data/tests/ethers.test.ts @@ -257,4 +257,140 @@ describe("SemaphoreEthers", () => { expect(isMember).toBeFalsy() }) }) + + describe("Event listeners", () => { + let mockOn: jest.Mock + let mockRemove: jest.Mock + + beforeEach(() => { + mockOn = jest.fn() + mockRemove = jest.fn() + + ContractMocked.mockImplementation( + () => + ({ + on: mockOn, + removeAllListeners: mockRemove + }) as any + ) + }) + + it("onGroupCreated should call the callback with groupId and event", () => { + const semaphore = new SemaphoreEthers("sepolia", { address: "0x0000" }) + const cb = jest.fn() + + semaphore.onGroupCreated(cb) + + const handler = mockOn.mock.calls.find(([e]) => e === "GroupCreated")![1] + const fakeEvent = { blockNumber: 123 } + handler("42", fakeEvent) + + expect(cb).toHaveBeenCalledWith("42", fakeEvent) + expect(mockOn).toHaveBeenCalledWith("GroupCreated", expect.any(Function)) + }) + + it("offGroupCreated should remove all listeners for GroupCreated", () => { + const semaphore = new SemaphoreEthers("sepolia", { address: "0x0000" }) + semaphore.offGropupCreated() + expect(mockRemove).toHaveBeenCalledWith("GroupCreated") + }) + + it("onMemberAdded should call callback with groupId, identityCommitment and event", () => { + const semaphore = new SemaphoreEthers("sepolia", { address: "0x0000" }) + const cb = jest.fn() + + semaphore.onMemberAdded(cb) + const handler = mockOn.mock.calls.find(([e]) => e === "MemberAdded")![1] + const fakeEvent = { txHash: "0xabc" } + + handler("group1", 0, "identity123", "root111", fakeEvent) + + expect(cb).toHaveBeenCalledWith("group1", "identity123", "root111", fakeEvent) + }) + + it("onMemberUpdated should call callback with groupId, old and new identity commitments", () => { + const semaphore = new SemaphoreEthers("sepolia", { address: "0x0000" }) + const cb = jest.fn() + + semaphore.onMemberUpdated(cb) + const handler = mockOn.mock.calls.find(([e]) => e === "MemberUpdated")![1] + const fakeEvent = { blockNumber: 200 } + + handler("groupX", 1, "old123", "new456", "root111", fakeEvent) + + expect(cb).toHaveBeenCalledWith("groupX", "old123", "new456", "root111", fakeEvent) + }) + + it("onMemberRemoved should call callback with groupId, identityCommitment and event", () => { + const semaphore = new SemaphoreEthers("sepolia", { address: "0x0000" }) + const cb = jest.fn() + + semaphore.onMemberRemoved(cb) + const handler = mockOn.mock.calls.find(([e]) => e === "MemberRemoved")![1] + const fakeEvent = { txHash: "0xdeadbeef" } + + handler("groupZ", 2, "identity999", "root111", fakeEvent) + + expect(cb).toHaveBeenCalledWith("groupZ", "identity999", "root111", fakeEvent) + }) + + it("offMemberAdded/Updated/Removed should remove all corresponding listeners", () => { + const semaphore = new SemaphoreEthers("sepolia", { address: "0x0000" }) + + semaphore.offMemberAdded() + semaphore.offMemberUpdated() + semaphore.offMemberRemoved() + + expect(mockRemove).toHaveBeenCalledWith("MemberAdded") + expect(mockRemove).toHaveBeenCalledWith("MemberUpdated") + expect(mockRemove).toHaveBeenCalledWith("MemberRemoved") + }) + + it("onProofValidated should call callback with proof object", () => { + const semaphore = new SemaphoreEthers("sepolia", { address: "0x0000" }) + const cb = jest.fn() + + semaphore.onProofValidated(cb) + const handler = mockOn.mock.calls.find(([e]) => e === "ProofValidated")![1] + + const fakeEvent = { blockNumber: 400 } + handler("group1", 3, "root123", "nullifierXYZ", "msg1", "scope1", ["p1", "p2"], fakeEvent) + + expect(cb).toHaveBeenCalledWith({ + groupId: "group1", + merkleTreeDepth: 3, + merkleTreeRoot: "root123", + nullifier: "nullifierXYZ", + message: "msg1", + scope: "scope1", + points: ["p1", "p2"], + event: fakeEvent + }) + }) + + it("offProofValidated should remove all ProofValidated listeners", () => { + const semaphore = new SemaphoreEthers("sepolia", { address: "0x0000" }) + semaphore.offProofValidated() + expect(mockRemove).toHaveBeenCalledWith("ProofValidated") + }) + + it("onGroupAdminUpdated should call callback with groupId, oldAdmin, newAdmin and event", () => { + const semaphore = new SemaphoreEthers("sepolia", { address: "0x0000" }) + const cb = jest.fn() + + semaphore.onGroupAdminUpdated(cb) + const handler = mockOn.mock.calls.find(([e]) => e === "GroupAdminUpdated")![1] + const fakeEvent = { txHash: "0xbeef" } + + handler("group1", "0xOLD", "0xNEW", fakeEvent) + + expect(cb).toHaveBeenCalledWith("group1", "0xOLD", "0xNEW", fakeEvent) + }) + + it("offGroupAdminUpdated should remove all GroupAdminUpdated listeners", () => { + const semaphore = new SemaphoreEthers("sepolia", { address: "0x0000" }) + semaphore.offGroupAdminUpdated() + expect(mockRemove).toHaveBeenCalledWith("GroupAdminUpdated") + }) + }) })