Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add event subscription support on MetaMaskVirtualWallet #19

Merged
merged 17 commits into from
Dec 16, 2024
Merged
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
12 changes: 12 additions & 0 deletions example/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# @starknet-io/get-starknet-example

## 4.0.5

### Patch Changes

- @starknet-io/[email protected]

## 4.0.4

### Patch Changes

- @starknet-io/[email protected]

## 4.0.3

### Patch Changes
Expand Down
2 changes: 1 addition & 1 deletion example/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@starknet-io/get-starknet-example",
"version": "4.0.3",
"version": "4.0.5",
"private": true,
"type": "module",
"scripts": {
Expand Down
14 changes: 14 additions & 0 deletions packages/core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# @starknet-io/get-starknet-core

## 4.0.5

### Patch Changes

- 0263b88: Decouple Virtual Wallet Discovery for async workflow

## 4.0.4

### Patch Changes

- d2fee96: Fix loading MetaMask Virtual Wallet dynamically in WalletAccount and
add support for RPC APIs (wallet_supportedWalletApi and wallet_supportedSpecs)
in get-starknet v4 integration.

## 4.0.3

### Patch Changes
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@starknet-io/get-starknet-core",
"version": "4.0.3",
"version": "4.0.5",
"keywords": [
"starknet",
"starkware",
Expand Down
24 changes: 24 additions & 0 deletions packages/core/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { sortBy } from "./wallet/sort"
import {
initiateVirtualWallets,
resolveVirtualWallet,
virtualWallets,
} from "./wallet/virtualWallets"
import { Permission, type StarknetWindowObject } from "@starknet-io/types-js"

Expand Down Expand Up @@ -116,6 +117,29 @@ export function getStarknet(

return firstAuthorizedWallet
},
discoverVirtualWallets: async (
walletNamesOrIds: string[] = [],
): Promise<void> => {
const walletNamesOrIdsSet = new Set(walletNamesOrIds)

const virtualWalletToDiscover =
walletNamesOrIdsSet.size > 0
? virtualWallets.filter(
(virtualWallet) =>
walletNamesOrIdsSet.has(virtualWallet.name) ||
walletNamesOrIdsSet.has(virtualWallet.id),
)
: virtualWallets

await Promise.all(
virtualWalletToDiscover.map(async (virtualWallet) => {
const hasSupport = await virtualWallet.hasSupport(windowObject)
if (hasSupport) {
windowObject[virtualWallet.windowKey] = virtualWallet
}
}),
)
},
enable: async (inputWallet, options) => {
let wallet: StarknetWindowObject
if (isVirtualWallet(inputWallet)) {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export interface GetStarknetResult {
) => Promise<StarknetWindowObject[]> // Returns only preauthorized wallets available in the window object
getDiscoveryWallets: (options?: GetWalletOptions) => Promise<WalletProvider[]> // Returns all wallets in existence (from discovery file)
getLastConnectedWallet: () => Promise<StarknetWindowObject | null | undefined> // Returns the last wallet connected when it's still connected
discoverVirtualWallets: () => Promise<void> // Discovers the virtual wallets by calling their hasSupport methods
enable: (
wallet: StarknetWindowObject | VirtualWallet,
options?: RequestAccountsParameters,
Expand Down
10 changes: 6 additions & 4 deletions packages/core/src/wallet/virtualWallets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ const virtualWallets: VirtualWallet[] = [metaMaskVirtualWallet]

function initiateVirtualWallets(windowObject: Record<string, unknown>) {
virtualWallets.forEach(async (virtualWallet) => {
const hasSupport = await virtualWallet.hasSupport(windowObject)
if (hasSupport) {
windowObject[virtualWallet.windowKey] = virtualWallet
if (!(virtualWallet.windowKey in windowObject)) {
const hasSupport = await virtualWallet.hasSupport(windowObject)
if (hasSupport) {
windowObject[virtualWallet.windowKey] = virtualWallet
}
}
})
}
Expand All @@ -28,4 +30,4 @@ async function resolveVirtualWallet(
return wallet
}

export { initiateVirtualWallets, resolveVirtualWallet }
export { initiateVirtualWallets, resolveVirtualWallet, virtualWallets }
147 changes: 113 additions & 34 deletions packages/core/src/wallet/virtualWallets/metaMaskVirtualWallet.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { VirtualWallet } from "../../types"
import { init, loadRemote } from "@module-federation/runtime"
import { RpcMessage, StarknetWindowObject } from "@starknet-io/types-js"
import {
RequestFnCall,
RpcMessage,
StarknetWindowObject,
WalletEventHandlers,
} from "@starknet-io/types-js"
import { Mutex } from "async-mutex"

interface MetaMaskProvider {
Expand Down Expand Up @@ -87,17 +92,8 @@ export type Eip6963SupportedWallet = {
provider: MetaMaskProvider | null
}

export type EmptyVirtualWallet = {
swo: StarknetWindowObject | null
on(): void
off(): void
request<Data extends RpcMessage>(
call: Omit<Data, "result">,
): Promise<Data["result"]>
}

class MetaMaskVirtualWallet
implements VirtualWallet, Eip6963SupportedWallet, EmptyVirtualWallet
implements VirtualWallet, Eip6963SupportedWallet, StarknetWindowObject
{
id: string = "metamask"
name: string = "MetaMask"
Expand All @@ -106,13 +102,39 @@ class MetaMaskVirtualWallet
provider: MetaMaskProvider | null = null
swo: StarknetWindowObject | null = null
lock: Mutex
version: string = "v2.0.0"

constructor() {
this.lock = new Mutex()
}

/**
* Load and resolve the `StarknetWindowObject`.
*
* @param windowObject The window object.
* @returns A promise to resolve a `StarknetWindowObject`.
*/
async loadWallet(
windowObject: Record<string, unknown>,
): Promise<StarknetWindowObject> {
// Using `this.#loadSwoSafe` to prevent race condition when the wallet is loading.
await this.#loadSwoSafe(windowObject)
// The `MetaMaskVirtualWallet` object acts as a proxy for the `this.swo` object.
// When `request`, `on`, or `off` is called, the wallet is loaded into `this.swo`,
// and the function call is forwarded to it.
// To maintain consistent behaviour, the `MetaMaskVirtualWallet`
// object (`this`) is returned instead of `this.swo`.
return this
}

/**
* Load the remote `StarknetWindowObject` with module federation.
*
* @param windowObject The window object.
* @returns A promise to resolve a `StarknetWindowObject`.
*/
async #loadSwo(
windowObject: Record<string, unknown>,
): Promise<StarknetWindowObject> {
if (!this.provider) {
this.provider = await detectMetamaskSupport(windowObject)
Expand All @@ -124,7 +146,8 @@ class MetaMaskVirtualWallet
{
name: "MetaMaskStarknetSnapWallet",
alias: "MetaMaskStarknetSnapWallet",
entry: "http://localhost:8082/remoteEntry.js",
entry:
"https://dev.snaps.consensys.io/starknet/get-starknet/v1/remoteEntry.js",
},
],
})
Expand All @@ -148,44 +171,100 @@ class MetaMaskVirtualWallet
)
}

/**
* Verify if the hosting machine supports the Wallet or not without loading the wallet itself.
*
* @param windowObject The window object.
* @returns A promise that resolves to a boolean value to indicate the support status.
*/
async hasSupport(windowObject: Record<string, unknown>) {
this.provider = await detectMetamaskSupport(windowObject)
return this.provider !== null
}

/**
* Proxy the RPC request to the `this.swo` object.
* Load the `this.swo` if not loaded.
*
* @param call The RPC API arguments.
* @returns A promise to resolve a response of the proxy RPC API.
*/
async request<Data extends RpcMessage>(
arg: Omit<Data, "result">,
call: Omit<Data, "result">,
): Promise<Data["result"]> {
const { type } = arg
// `wallet_supportedWalletApi` and `wallet_supportedSpecs` should enabled even if the wallet is not loaded/connected
switch (type) {
case "wallet_supportedWalletApi":
return ["0.7"] as unknown as Data["result"]
case "wallet_supportedSpecs":
return ["0.7"] as unknown as Data["result"]
default:
return this.#handleRequest(arg)
}
return this.#loadSwoSafe().then((swo: StarknetWindowObject) => {
// Forward the request to the `this.swo` object.
// Except RPCs `wallet_supportedSpecs` and `wallet_getPermissions`, other RPCs will trigger the Snap to install if not installed.
return swo.request(
call as unknown as RequestFnCall<Data["type"]>,
) as unknown as Data["result"]
})
}

async #handleRequest<Data extends RpcMessage>(
arg: Omit<RpcMessage, "result">,
): Promise<Data["result"]> {
// Using lock to ensure the load wallet operation is not fall into a racing condirtion
/**
* Subscribe the `accountsChanged` or `networkChanged` event.
* Proxy the subscription to the `this.swo` object.
* Load the `this.swo` if not loaded.
*
* @param event - The event name.
* @param handleEvent - The event handler function.
*/
on<Event extends keyof WalletEventHandlers>(
event: Event,
handleEvent: WalletEventHandlers[Event],
): void {
this.#loadSwoSafe().then((swo: StarknetWindowObject) =>
swo.on(event, handleEvent),
)
}

/**
* Un-subscribe the `accountsChanged` or `networkChanged` event for a given handler.
* Proxy the un-subscribe request to the `this.swo` object.
* Load the `this.swo` if not loaded.
*
* @param event - The event name.
* @param handleEvent - The event handler function.
*/
off<Event extends keyof WalletEventHandlers>(
event: Event,
handleEvent: WalletEventHandlers[Event],
): void {
this.#loadSwoSafe().then((swo: StarknetWindowObject) =>
swo.off(event, handleEvent),
)
}

/**
* Load the `StarknetWindowObject` safely with lock.
* And prevent the loading operation fall into a racing condition.
*
* @returns A promise to resolve a `StarknetWindowObject`.
*/
async #loadSwoSafe(
windowObject: Record<string, unknown> = window,
): Promise<StarknetWindowObject> {
return this.lock.runExclusive(async () => {
// Using `this.swo` to prevent the wallet is loaded multiple times
if (!this.swo) {
this.swo = await this.loadWallet(window)
this.swo = await this.#loadSwo(windowObject)
this.#bindSwoProperties()
}
// forward the request to the actual connect wallet object
// it will also trigger the Snap to install if not installed
return this.swo.request(arg) as unknown as Data["result"]
return this.swo
})
}

// MetaMask Snap Wallet does not support `on` and `off` method
on() {}
off() {}
/**
* Bind properties to `MetaMaskVirtualWallet` from `this.swo`.
*/
#bindSwoProperties(): void {
if (this.swo) {
this.version = this.swo.version
this.name = this.swo.name
this.id = this.swo.id
this.icon = this.swo.icon as string
}
}
}
const metaMaskVirtualWallet = new MetaMaskVirtualWallet()

Expand Down
14 changes: 14 additions & 0 deletions packages/ui/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# @starknet-io/get-starknet

## 4.0.5

### Patch Changes

- Updated dependencies [0263b88]
- @starknet-io/[email protected]

## 4.0.4

### Patch Changes

- Updated dependencies [d2fee96]
- @starknet-io/[email protected]

## 4.0.3

### Patch Changes
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@starknet-io/get-starknet",
"version": "4.0.3",
"version": "4.0.5",
"keywords": [
"starknet",
"starkware",
Expand Down