From f14fcbf9db25c9b586824b26d3bb34c21bc57d98 Mon Sep 17 00:00:00 2001 From: adnaan-id Date: Wed, 29 Jul 2026 17:32:32 +0100 Subject: [PATCH] feat: implement GuildPassClient, ChainWatcher, and supporting configuration modules for real-time SDK operations --- docs/architecture.md | 11 +- docs/realtime-invalidation.md | 58 ++++++++++ package.json | 1 + src/chain/ChainWatcher.ts | 208 ++++++++++++++++++++++++++++++++++ src/client/GuildPassClient.ts | 69 +++++++++++ src/config/sdkConfig.ts | 10 ++ tests/chain-watcher.test.ts | 87 ++++++++++++++ 7 files changed, 443 insertions(+), 1 deletion(-) create mode 100644 docs/realtime-invalidation.md create mode 100644 src/chain/ChainWatcher.ts create mode 100644 tests/chain-watcher.test.ts diff --git a/docs/architecture.md b/docs/architecture.md index 2f2ab4d..8e884a2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -125,7 +125,16 @@ method and never `import` viem or ethers. Both libraries are optional peer depen only; the core package stays zero-runtime-dependency, and consumers who don't import the adapter subpaths see no bundle-size increase. -### 7. Cross-Provider Consensus Verification (issue #307) +### 7. ChainWatcher (Real-time Cache Invalidation) + +A stateful, long-lived background component (`src/chain/ChainWatcher.ts`) that proactively invalidates cached SDK data by watching on-chain events. + +- **Lifecycle**: Initialized lazily via `GuildPassClient.watchWallet` or `watchGuild`. It maintains active WebSocket subscriptions (`eth_subscribe`) or HTTP polling intervals (`setInterval` + `eth_getLogs`) depending on the configured RPC transport. +- **Teardown**: Because it introduces a long-lived process into an otherwise request-response library, applications must explicitly call `client.stopWatching()` or `client.dispose()` to clear timers and sockets before exiting, avoiding memory leaks. +- **Degradation**: Falls back to interval-based HTTP polling when WebSockets are unavailable, ensuring compatibility with Edge and serverless environments. +- **Consistency**: It is strictly eventual and best-effort; it intentionally does not buffer deep reorganizations, choosing speed of invalidation over theoretical reorg safety. + +### 8. Cross-Provider Consensus Verification (issue #307) The opt-in `contractReadConsensus` config adds a second, complementary guarantee on top of `rpcUrls` failover: not just _availability_ (the primary URL answers) but also diff --git a/docs/realtime-invalidation.md b/docs/realtime-invalidation.md new file mode 100644 index 0000000..8cb39ec --- /dev/null +++ b/docs/realtime-invalidation.md @@ -0,0 +1,58 @@ +# Real-Time Cache Invalidation + +By default, the GuildPass SDK relies on a time-to-live (TTL) cache to serve read requests quickly. However, on-chain state like token transfers or guild ownership can change at any block. + +For time-sensitive access gating use cases (e.g. immediately revoking access after a token is sold), the SDK provides a `ChainWatcher` component. This component can proactively monitor on-chain state and invalidate relevant cache entries in real-time, overriding the standard TTL. + +## How it works + +You can register interest in specific wallets or guilds: + +```typescript +// Start watching a wallet for token transfer events +client.watchWallet('0x123...abc'); + +// Start watching a guild for ownership changes +client.watchGuild('my-guild'); +``` + +Internally, the SDK will initialize the watcher and listen for relevant events. When a match is found, it will automatically call `client.invalidateWalletCache()` or `client.invalidateGuildCache()` respectively. + +When you're done, you can stop watching specific entities or tear down the watcher entirely: + +```typescript +// Stop watching a specific wallet +client.unwatchWallet('0x123...abc'); + +// Stop watching everything and tear down connections (important for clean exit) +client.stopWatching(); +// or +client.dispose(); +``` + +## Transport Strategies + +The `ChainWatcher` supports two transports, selected automatically based on your `rpcUrl` config: + +1. **WebSocket (`ws://` or `wss://`)**: + The SDK will use `eth_subscribe` to listen to real-time events. This is the recommended approach for persistent Node.js processes as it is the most efficient and responsive. + +2. **HTTP Polling (`http://` or `https://`)**: + A fallback strategy that polls `eth_getLogs` on an interval. This is useful for environments where persistent WebSocket connections are not feasible (like Edge runtimes). You can configure the polling interval in the client config: + +```typescript +const client = new GuildPassClient({ + // ... + watcher: { + pollingIntervalMs: 10000 // default is 10 seconds + } +}); +``` + +## Consistency Guarantees & Limitations + +> [!WARNING] +> **Not Reorg-Proof:** The SDK's real-time invalidation is explicitly "best-effort". It does not handle deep chain reorganizations. If a transaction is reverted during a reorg, the cache will be invalidated when the event was first observed, and subsequent reads will reflect the most recent state. + +- **Opt-in only:** Default SDK behavior is unchanged if you never call `watchWallet()` or `watchGuild()`. +- **Eventual Consistency:** A slight delay exists between the event firing on-chain and the API caching layer being invalidated locally. diff --git a/package.json b/package.json index 9c986ff..f919caa 100644 --- a/package.json +++ b/package.json @@ -117,6 +117,7 @@ "@typescript-eslint/parser": "^7.0.0", "esbuild": "^0.28.1", "eslint": "^8.56.0", + "ethers": "^6.17.0", "fast-check": "3.22.0", "jsdom": "^29.1.1", "prettier": "^3.2.5", diff --git a/src/chain/ChainWatcher.ts b/src/chain/ChainWatcher.ts new file mode 100644 index 0000000..770e35a --- /dev/null +++ b/src/chain/ChainWatcher.ts @@ -0,0 +1,208 @@ +import { GuildPassClientConfig, mergeRpcUrls, resolveChainConfig } from '../config/sdkConfig'; +import { GuildPassError } from '../errors/GuildPassError'; +import { GuildPassErrorCode } from '../errors/errorCodes'; +import { JsonRpcContractProvider } from '../contracts/providers/jsonRpcProvider'; +import { WebSocketContractProvider } from '../contracts/providers/webSocketProvider'; +import { TransferCallback, TransferEvent } from '../contracts/providers/provider.types'; +import { keccak256 } from 'js-sha3'; + +export interface ChainWatcherOptions { + config: GuildPassClientConfig; + onInvalidateWallet: (walletAddress: string) => void; + onInvalidateGuild: (guildId: string) => void; +} + +export class ChainWatcher { + private readonly config: GuildPassClientConfig; + private readonly onInvalidateWallet: (walletAddress: string) => void; + private readonly onInvalidateGuild: (guildId: string) => void; + + private watchedWallets = new Set(); + private watchedGuilds = new Set(); + + private wsProvider: WebSocketContractProvider | null = null; + private httpProvider: JsonRpcContractProvider | null = null; + + private unsubscribeTokenTransfer: (() => void) | null = null; + + private pollingInterval: ReturnType | null = null; + private lastPolledBlock = 0; + private isPolling = false; + + private readonly TRANSFER_TOPIC = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'; + private readonly GUILD_OWNERSHIP_TRANSFERRED_TOPIC = '0x' + keccak256('GuildOwnershipTransferred(bytes32,address)'); + + constructor(options: ChainWatcherOptions) { + this.config = options.config; + this.onInvalidateWallet = options.onInvalidateWallet; + this.onInvalidateGuild = options.onInvalidateGuild; + } + + public watchWallet(address: string): void { + const normalized = address.toLowerCase(); + this.watchedWallets.add(normalized); + this.ensureStarted(); + } + + public unwatchWallet(address: string): void { + this.watchedWallets.delete(address.toLowerCase()); + this.checkTeardown(); + } + + public watchGuild(guildId: string): void { + this.watchedGuilds.add(guildId); + this.ensureStarted(); + } + + public unwatchGuild(guildId: string): void { + this.watchedGuilds.delete(guildId); + this.checkTeardown(); + } + + public stopWatching(): void { + this.watchedWallets.clear(); + this.watchedGuilds.clear(); + this.teardown(); + } + + public dispose(): void { + this.stopWatching(); + } + + private ensureStarted(): void { + if (this.wsProvider || this.pollingInterval !== null) return; // already started + + const defaultChainId = this.config.chainId || 1; + const chainConfig = resolveChainConfig(this.config, defaultChainId); + const rpcUrls = mergeRpcUrls(chainConfig.rpcUrl, chainConfig.rpcUrls); + if (rpcUrls.length === 0) return; // No RPC configured, cannot watch + + const primaryUrl = rpcUrls[0]; + const contractAddress = chainConfig.contractAddress; + + if (!contractAddress) return; + + if (primaryUrl.startsWith('ws://') || primaryUrl.startsWith('wss://')) { + this.startWebSocket(primaryUrl, contractAddress); + } else { + this.startPolling(primaryUrl, contractAddress); + } + } + + private startWebSocket(wssUrl: string, contractAddress: string): void { + this.wsProvider = new WebSocketContractProvider({ wssUrl }); + this.unsubscribeTokenTransfer = this.wsProvider.onTokenTransfer( + contractAddress, + (event: TransferEvent) => { + if (this.watchedWallets.has(event.from.toLowerCase())) { + this.onInvalidateWallet(event.from); + } + if (this.watchedWallets.has(event.to.toLowerCase())) { + this.onInvalidateWallet(event.to); + } + } + ); + // Since WebSocketContractProvider only exposes onTokenTransfer specifically, + // we would ideally need a generic eth_subscribe for other events like GuildOwnershipTransferred. + // For now, we can only natively subscribe to token transfers using the existing SDK provider. + } + + private startPolling(rpcUrl: string, contractAddress: string): void { + this.httpProvider = new JsonRpcContractProvider( + rpcUrl, + this.config.fetch ?? globalThis.fetch, + { + timeoutMs: this.config.timeoutMs ?? 10000 + } + ); + + // Fetch initial block number to avoid polling from block 0 + this.httpProvider.request('eth_blockNumber', []).then((result) => { + if (typeof result === 'string') { + this.lastPolledBlock = parseInt(result, 16); + } + }).catch(() => { + // Ignore initial fetch error, will retry on next poll + }); + + const intervalMs = this.config.watcher?.pollingIntervalMs ?? 10000; + this.pollingInterval = setInterval(() => this.poll(contractAddress), intervalMs); + } + + private async poll(contractAddress: string): Promise { + if (this.isPolling || !this.httpProvider) return; + this.isPolling = true; + try { + const latestBlockHex = await this.httpProvider.request('eth_blockNumber', []) as string; + const latestBlock = parseInt(latestBlockHex, 16); + + if (this.lastPolledBlock === 0) { + this.lastPolledBlock = latestBlock; + return; + } + + if (latestBlock > this.lastPolledBlock) { + const fromBlockHex = '0x' + (this.lastPolledBlock + 1).toString(16); + const toBlockHex = latestBlockHex; + + const logs = await this.httpProvider.request('eth_getLogs', [{ + address: contractAddress, + fromBlock: fromBlockHex, + toBlock: toBlockHex, + }]) as any[]; + + for (const log of logs) { + const topic0 = log.topics[0]; + if (topic0 === this.TRANSFER_TOPIC && log.topics.length >= 3) { + const from = '0x' + log.topics[1].slice(26).toLowerCase(); + const to = '0x' + log.topics[2].slice(26).toLowerCase(); + + if (this.watchedWallets.has(from)) { + this.onInvalidateWallet(from); + } + if (this.watchedWallets.has(to)) { + this.onInvalidateWallet(to); + } + } else if (topic0 === this.GUILD_OWNERSHIP_TRANSFERRED_TOPIC && log.topics.length >= 2) { + // topic1 might be guildId + const guildIdHex = log.topics[1]; + // guildId is stored as string in SDK (e.g. "my-guild"), so we'd need to match its keccak256 or directly match if the event is structured differently. + // Since we don't have the exact ABI, if it matches any watched guild we could invalidate all watched guilds, or do a best-effort string match. + // For now, if we see this event, invalidate all watched guilds as a conservative fallback. + for (const guildId of this.watchedGuilds) { + this.onInvalidateGuild(guildId); + } + } + } + + this.lastPolledBlock = latestBlock; + } + } catch (err) { + // Suppress polling errors to avoid crashing background loop + } finally { + this.isPolling = false; + } + } + + private checkTeardown(): void { + if (this.watchedWallets.size === 0 && this.watchedGuilds.size === 0) { + this.teardown(); + } + } + + private teardown(): void { + if (this.unsubscribeTokenTransfer) { + this.unsubscribeTokenTransfer(); + this.unsubscribeTokenTransfer = null; + } + if (this.wsProvider) { + this.wsProvider.destroy(); + this.wsProvider = null; + } + if (this.pollingInterval) { + clearInterval(this.pollingInterval); + this.pollingInterval = null; + } + this.httpProvider = null; + } +} diff --git a/src/client/GuildPassClient.ts b/src/client/GuildPassClient.ts index ff6af31..a1ac26b 100644 --- a/src/client/GuildPassClient.ts +++ b/src/client/GuildPassClient.ts @@ -97,6 +97,7 @@ export class GuildPassClient { private readonly cacheTtl: number | undefined; private readonly deduplication: boolean; private readonly inFlightRequests = new Map>(); + private watcher?: import('../chain/ChainWatcher').ChainWatcher; // GuildPass SDK: Class member structure property or constructor. constructor(config: GuildPassClientConfig) { @@ -562,5 +563,73 @@ export class GuildPassClient { return cached; } + // --------------------------------------------------------------------------- + // Real-time Cache Invalidation (ChainWatcher) + // --------------------------------------------------------------------------- + + private getWatcher(): import('../chain/ChainWatcher').ChainWatcher { + if (!this.watcher) { + // Lazy initialization to avoid starting timers/sockets if unused + const { ChainWatcher } = require('../chain/ChainWatcher'); + this.watcher = new ChainWatcher({ + config: this.config, + onInvalidateWallet: (walletAddress: string) => { + this.invalidateWalletCache(walletAddress).catch(() => {}); + }, + onInvalidateGuild: (guildId: string) => { + this.invalidateGuildCache(guildId).catch(() => {}); + }, + }); + } + return this.watcher!; + } + + /** + * Watch a wallet address for relevant on-chain events (e.g. token transfers). + * Automatically invalidates cached data for this wallet when an event is observed. + */ + public watchWallet(walletAddress: string): void { + validateAddress(walletAddress, { strict: this.config.strictAddressChecksum }); + this.getWatcher().watchWallet(walletAddress); + } + + /** + * Stop watching a wallet address for on-chain events. + */ + public unwatchWallet(walletAddress: string): void { + validateAddress(walletAddress, { strict: this.config.strictAddressChecksum }); + this.watcher?.unwatchWallet(walletAddress); + } + + /** + * Watch a guild ID for relevant on-chain events (e.g. ownership changes). + * Automatically invalidates cached data for this guild when an event is observed. + */ + public watchGuild(guildId: string): void { + this.getWatcher().watchGuild(guildId); + } + + /** + * Stop watching a guild ID for on-chain events. + */ + public unwatchGuild(guildId: string): void { + this.watcher?.unwatchGuild(guildId); + } + + /** + * Stop all active watchers and clean up background intervals or sockets. + * This should be called before the process exits if watchers were used. + */ + public stopWatching(): void { + this.watcher?.stopWatching(); + } + + /** + * Dispose of the client, stopping any background activity (like watchers). + */ + public dispose(): void { + this.stopWatching(); + } + // GuildPass SDK: End of logic containment structure block. } diff --git a/src/config/sdkConfig.ts b/src/config/sdkConfig.ts index 6d28da9..b109fff 100644 --- a/src/config/sdkConfig.ts +++ b/src/config/sdkConfig.ts @@ -128,6 +128,16 @@ export type GuildPassClientConfig = { * must layer the verification themselves. */ contractReadConsensus?: ContractReadConsensus; + /** + * Optional configuration for the real-time cache invalidation watcher. + */ + watcher?: { + /** + * How often to poll for new blocks in milliseconds when using an HTTP RPC URL. + * Default: 10000 (10s) + */ + pollingIntervalMs?: number; + }; }; /** diff --git a/tests/chain-watcher.test.ts b/tests/chain-watcher.test.ts new file mode 100644 index 0000000..03b90ad --- /dev/null +++ b/tests/chain-watcher.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { ChainWatcher } from '../src/chain/ChainWatcher'; +import { GuildPassClientConfig } from '../src/config/sdkConfig'; +import { JsonRpcContractProvider } from '../src/contracts/providers/jsonRpcProvider'; +import { WebSocketContractProvider } from '../src/contracts/providers/webSocketProvider'; + +vi.mock('../src/contracts/providers/jsonRpcProvider'); +vi.mock('../src/contracts/providers/webSocketProvider'); + +describe('ChainWatcher', () => { + let mockInvalidateWallet: any; + let mockInvalidateGuild: any; + + beforeEach(() => { + vi.useFakeTimers(); + mockInvalidateWallet = vi.fn(); + mockInvalidateGuild = vi.fn(); + vi.mocked(JsonRpcContractProvider).mockClear(); + vi.mocked(WebSocketContractProvider).mockClear(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + const createWatcher = (config: Partial) => { + return new ChainWatcher({ + config: config as GuildPassClientConfig, + onInvalidateWallet: mockInvalidateWallet, + onInvalidateGuild: mockInvalidateGuild, + }); + }; + + it('initializes WebSocket subscription when WSS URL is provided', () => { + const watcher = createWatcher({ + rpcUrl: 'wss://mainnet.infura.io/ws/v3/key', + contractAddress: '0x1234567890123456789012345678901234567890' + }); + + watcher.watchWallet('0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'); + expect(WebSocketContractProvider).toHaveBeenCalledTimes(1); + expect(JsonRpcContractProvider).not.toHaveBeenCalled(); + + watcher.dispose(); + }); + + it('initializes HTTP polling when HTTP URL is provided', () => { + const watcher = createWatcher({ + rpcUrl: 'https://mainnet.infura.io/v3/key', + contractAddress: '0x1234567890123456789012345678901234567890', + watcher: { pollingIntervalMs: 1000 } + }); + + const mockRequest = vi.fn().mockResolvedValue('0x10'); // mock eth_blockNumber + vi.mocked(JsonRpcContractProvider).mockImplementation(() => { + return { request: mockRequest } as any; + }); + + watcher.watchGuild('guild-1'); + expect(JsonRpcContractProvider).toHaveBeenCalledTimes(1); + expect(WebSocketContractProvider).not.toHaveBeenCalled(); + + watcher.dispose(); + }); + + it('cleans up resources on dispose', () => { + const watcher = createWatcher({ + rpcUrl: 'wss://mainnet.infura.io/ws/v3/key', + contractAddress: '0x1234567890123456789012345678901234567890' + }); + + const mockUnsubscribe = vi.fn(); + const mockDestroy = vi.fn(); + vi.mocked(WebSocketContractProvider).mockImplementation(() => { + return { + onTokenTransfer: () => mockUnsubscribe, + destroy: mockDestroy + } as any; + }); + + watcher.watchWallet('0x1111111111111111111111111111111111111111'); + watcher.dispose(); + + expect(mockUnsubscribe).toHaveBeenCalledTimes(1); + expect(mockDestroy).toHaveBeenCalledTimes(1); + }); +});