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
11 changes: 10 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 58 additions & 0 deletions docs/realtime-invalidation.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
208 changes: 208 additions & 0 deletions src/chain/ChainWatcher.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
private watchedGuilds = new Set<string>();

private wsProvider: WebSocketContractProvider | null = null;
private httpProvider: JsonRpcContractProvider | null = null;

private unsubscribeTokenTransfer: (() => void) | null = null;

private pollingInterval: ReturnType<typeof setInterval> | 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<void> {
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;
}
}
69 changes: 69 additions & 0 deletions src/client/GuildPassClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export class GuildPassClient {
private readonly cacheTtl: number | undefined;
private readonly deduplication: boolean;
private readonly inFlightRequests = new Map<string, Promise<any>>();
private watcher?: import('../chain/ChainWatcher').ChainWatcher;

// GuildPass SDK: Class member structure property or constructor.
constructor(config: GuildPassClientConfig) {
Expand Down Expand Up @@ -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.
}
10 changes: 10 additions & 0 deletions src/config/sdkConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
};

/**
Expand Down
Loading
Loading