Skip to content

Commit

Permalink
feat: event handling in get-starknet (#413)
Browse files Browse the repository at this point in the history
* feat: event handling in get-starknet

* fix: use one polling controller

* chore: refactor with pollingFunction

* fix: add min max polling delay

* chore: handle comments

* chore: fix comments

* chore: fix comments

* chore: fix comments

* fix: use set for event polling
  • Loading branch information
khanti42 authored Dec 13, 2024
1 parent 5b72ef9 commit 009e8d4
Show file tree
Hide file tree
Showing 2 changed files with 138 additions and 8 deletions.
5 changes: 5 additions & 0 deletions packages/get-starknet/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,8 @@ export const SupportedStarknetSpecVersion = ['0.7'];

// The wallet API support is 0.7.2 but the RPC specs requests xx.yy. Hence we skip the last digits.
export const SupportedWalletApi = ['0.7'];

export enum WalletEvent {
AccountsChanged = 'accountsChanged',
NetworkChanged = 'networkChanged',
}
141 changes: 133 additions & 8 deletions packages/get-starknet/src/wallet.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import type { MutexInterface } from 'async-mutex';
import { Mutex } from 'async-mutex';
import type { WalletEventHandlers } from 'get-starknet-core';
import type { AccountChangeEventHandler, NetworkChangeEventHandler, WalletEventHandlers } from 'get-starknet-core';
import { type RpcMessage, type StarknetWindowObject } from 'get-starknet-core';
import type { AccountInterface, ProviderInterface } from 'starknet';
import { Provider } from 'starknet';

import { MetaMaskAccount } from './accounts';
import { RpcMethod, WalletIconMetaData } from './constants';
import { RpcMethod, WalletEvent, WalletIconMetaData } from './constants';
import {
WalletSupportedSpecs,
WalletSupportedWalletApi,
Expand All @@ -26,6 +26,14 @@ import type { MetaMaskProvider, Network } from './type';
import type { IStarknetWalletRpc } from './utils';
import { WalletRpcError, WalletRpcErrorCode } from './utils/error';

type CallbackFunction = (...args: any[]) => any;

const resolver = async (func: CallbackFunction, arg1: string | string[], arg2?: string[]): Promise<any> => {
return new Promise((resolve) => {
resolve(func(arg1, arg2));
});
};

export class MetaMaskSnapWallet implements StarknetWindowObject {
id: string;

Expand Down Expand Up @@ -55,6 +63,16 @@ export class MetaMaskSnapWallet implements StarknetWindowObject {

lock: MutexInterface;

#pollingController: AbortController | undefined;

#accountChangeHandlers: Set<AccountChangeEventHandler> = new Set();

#networkChangeHandlers: Set<NetworkChangeEventHandler> = new Set();

static readonly pollingDelayMs = 100;

static readonly pollingTimeoutMs = 5000;

// eslint-disable-next-line @typescript-eslint/naming-convention, no-restricted-globals
static readonly snapId = process.env.SNAP_ID ?? 'npm:@consensys/starknet-snap';

Expand Down Expand Up @@ -196,6 +214,9 @@ export class MetaMaskSnapWallet implements StarknetWindowObject {
this.#provider = undefined;
// account is depends on address and provider, if network changes, address will update,
// hence set account to undefine for reinitialization
// TODO : This should be removed. The walletAccount is created with the SWO as input.
// This means account is not managed from within the SWO but from outside.
// Event handling helps ensure that the correct address is set.
this.#account = undefined;
}

Expand All @@ -220,13 +241,117 @@ export class MetaMaskSnapWallet implements StarknetWindowObject {
return true;
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
on<Event extends keyof WalletEventHandlers>(_event: Event, _handleEvent: WalletEventHandlers[Event]): void {
// No operation for now
/**
* Subscribe the `accountsChanged` or `networkChanged` event.
*
* @param event - The event name ('accountsChanged' or 'networkChanged').
* @param handleEvent - The event handler function.
*/
on<Event extends keyof WalletEventHandlers>(event: Event, handleEvent: WalletEventHandlers[Event]): void {
if (event === WalletEvent.AccountsChanged) {
this.#accountChangeHandlers.add(handleEvent as AccountChangeEventHandler);
} else if (event === WalletEvent.NetworkChanged) {
this.#networkChangeHandlers.add(handleEvent as NetworkChangeEventHandler);
} else {
throw new Error(`Unsupported event: ${String(event)}`);
}
if (!this.#pollingController) {
this.#startPolling();
}
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
off<Event extends keyof WalletEventHandlers>(_event: Event, _handleEvent?: WalletEventHandlers[Event]): void {
// No operation for now
/**
* Un-subscribe the `accountsChanged` or `networkChanged` event for a given handler.
*
* @param event - The event name ('accountsChanged' or 'networkChanged').
* @param handleEvent - The event handler function to un-subscribe.
*/
off<Event extends keyof WalletEventHandlers>(event: Event, handleEvent: WalletEventHandlers[Event]): void {
if (event === WalletEvent.AccountsChanged) {
this.#accountChangeHandlers.delete(handleEvent as AccountChangeEventHandler);
} else if (event === WalletEvent.NetworkChanged) {
this.#networkChangeHandlers.delete(handleEvent as NetworkChangeEventHandler);
} else {
throw new Error(`Unsupported event: ${String(event)}`);
}
if (this.#accountChangeHandlers.size + this.#networkChangeHandlers.size === 0) {
this.#stopPolling();
}
}

/**
* Polling function for detecting changes with a maximum delay.
* The function balances between responsiveness (handling changes as quickly as possible)
* and efficiency (ensuring a controlled polling frequency).
*
* 1. Polling operation: Runs with a timeout to prevent infinite hangs.
* 2. Minimum delay: Introduces a small delay (e.g., 100ms) between iterations
* to avoid excessive resource usage while still scanning for updates promptly.
*/
#pollingFunction = async (): Promise<void> => {
if (!this.#pollingController) {
return; // Abort if the polling controller is not initialized
}
const { signal } = this.#pollingController;

while (!signal.aborted) {
// Early exit if there are no handlers left
if (this.#accountChangeHandlers.size + this.#networkChangeHandlers.size === 0) {
this.#stopPolling();
return;
}

const previousNetwork = this.#chainId;
const previousAddress = this.#selectedAddress;

try {
// Perform the polling operation with a timeout.
// Ensures the operation completes within a maximum time frame to avoid infinite hangs.
// If `this.#init()` takes too long, the timeout will reject and continue to the next iteration.
await Promise.race([
// Fetch network, assign address and chainId for thread safe.
this.#init(),
new Promise((_, reject) =>
// Timeout after `MetaMaskSnapWallet.pollingTimeoutMs`.
setTimeout(() => reject(new Error('Polling timeout exceeded')), MetaMaskSnapWallet.pollingTimeoutMs),
),
]);

// Check for network change
if (previousNetwork !== this.#chainId) {
await Promise.allSettled(
Array.from(this.#networkChangeHandlers).map(async (callback) =>
resolver(callback, this.#chainId, [this.#selectedAddress]),
),
);
}

// Check for account change
if (previousAddress !== this.#selectedAddress) {
await Promise.allSettled(
Array.from(this.#accountChangeHandlers).map(async (callback) =>
resolver(callback, [this.#selectedAddress]),
),
);
}
} catch (_error) {
// Silently handle errors to avoid breaking the loop
}

await new Promise((resolve) => setTimeout(resolve, MetaMaskSnapWallet.pollingDelayMs));
}
};

#startPolling(): void {
this.#pollingController = new AbortController();
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.#pollingFunction();
}

#stopPolling(): void {
if (this.#pollingController) {
this.#pollingController.abort();
this.#pollingController = undefined;
}
}
}

0 comments on commit 009e8d4

Please sign in to comment.