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: virtual wallet support + Metamask integration #227

Merged
merged 14 commits into from
Jun 26, 2024
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"test": "vitest"
},
"dependencies": {
"@module-federation/runtime": "^0.1.2",
"@starknet-io/types-js": "^0.7.7"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/__test__/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ describe("getDiscoveryWallets()", () => {
it("should return all discovery wallets", async () => {
const sn = getWallet({})
const discoveryWallets = await sn.getDiscoveryWallets()
expect(discoveryWallets.length).toBe(2)
expect(discoveryWallets.length).toBe(3)
expect(discoveryWallets.map((w) => w.id)).contains(ArgentXMock.id)
expect(discoveryWallets.map((w) => w.id)).contains(BraavosMock.id)
})
Expand Down
30 changes: 20 additions & 10 deletions packages/core/src/__test__/wallet.mock.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,28 @@
import wallets from "../discovery"
import { Permission, type StarknetWindowObject } from "@starknet-io/types-js"

type WalletMock = Pick<StarknetWindowObject, "id" | "name" | "icon" | "request">

export const UnknownWalletAMock: WalletMock = {
export const UnknownWalletAMock: StarknetWindowObject = {
id: "wallet-a",
name: "Wallet A",
version: "0.0.0",
icon: "https://avatars.dicebear.com/api/initials/Wallet%20A.svg",
request: async () => false,
on: () => {},
off: () => {},
}
export const UnknownWalletBMock: WalletMock = {
export const UnknownWalletBMock: StarknetWindowObject = {
id: "wallet-b",
name: "Wallet B",
version: "0.0.0",
icon: "https://avatars.dicebear.com/api/initials/Wallet%20B.svg",
request: async () => false,
on: () => {},
off: () => {},
}

export const ArgentXMock: WalletMock = {
export const ArgentXMock: StarknetWindowObject = {
...wallets.find((w) => w.id === "argentX")!,
version: "0.0.0",
request: async (request) => {
switch (request.type) {
case "wallet_getPermissions":
Expand All @@ -26,10 +31,13 @@ export const ArgentXMock: WalletMock = {
return undefined as any
}
},
on: () => {},
off: () => {},
}

export const BraavosMock: WalletMock = {
export const BraavosMock: StarknetWindowObject = {
...wallets.find((w) => w.id === "braavos")!,
version: "0.0.0",
request: async (request) => {
switch (request.type) {
case "wallet_getPermissions":
Expand All @@ -38,10 +46,12 @@ export const BraavosMock: WalletMock = {
return undefined as any
}
},
on: () => {},
off: () => {},
}

export function makeAuthorized(authorized: boolean) {
return (wallet: WalletMock) =>
return (wallet: StarknetWindowObject) =>
({
...wallet,
request: async (request) => {
Expand All @@ -52,11 +62,11 @@ export function makeAuthorized(authorized: boolean) {
return wallet.request(request)
}
},
} as WalletMock)
} as StarknetWindowObject)
}

export function makeConnected(isConnected: boolean) {
return (wallet: WalletMock) => {
return (wallet: StarknetWindowObject) => {
return {
...makeAuthorized(true)(wallet),
request: async ({ type }) => {
Expand All @@ -67,6 +77,6 @@ export function makeConnected(isConnected: boolean) {
return []
}
},
} as WalletMock
} as StarknetWindowObject
}
}
13 changes: 13 additions & 0 deletions packages/core/src/discovery.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { metaMaskVirtualWallet } from "./wallet/virtualWallets/metaMaskVirtualWallet"

export type WalletProvider = {
id: string
name: string
Expand Down Expand Up @@ -31,6 +33,17 @@ const wallets: WalletProvider[] = [
edge: "https://microsoftedge.microsoft.com/addons/detail/braavos-wallet/hkkpjehhcnhgefhbdcgfkeegglpjchdc",
},
},
{
id: metaMaskVirtualWallet.id,
name: metaMaskVirtualWallet.name,
icon: metaMaskVirtualWallet.icon,
downloads: {
chrome:
"https://chrome.google.com/webstore/detail/metamask/nkbihfbeogaeaoehlefnkodbefgpgknn",
firefox: "https://addons.mozilla.org/en-US/firefox/addon/ether-metamask/",
edge: "https://microsoftedge.microsoft.com/addons/detail/metamask/ejbalbakoplchlghecdalmeeeajnimhm?hl=en-US",
},
},
]

export default wallets
35 changes: 23 additions & 12 deletions packages/core/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,17 @@ import { LocalStorageWrapper } from "./localStorageStore"
import type { GetStarknetOptions, GetStarknetResult } from "./types"
import { pipe } from "./utils"
import { filterBy, filterByAuthorized } from "./wallet/filter"
import { isWalletObj } from "./wallet/isWalletObject"
import {
isFullWallet,
isVirtualWallet,
isWalletObject,
} from "./wallet/isWalletObject"
import { scanObjectForWallets } from "./wallet/scan"
import { sortBy } from "./wallet/sort"
import {
initiateVirtualWallets,
resolveVirtualWallet,
} from "./wallet/virtualWallets"
import { Permission, type StarknetWindowObject } from "@starknet-io/types-js"

export type {
Expand All @@ -31,10 +39,8 @@ export type {
WalletEvents,
} from "@starknet-io/types-js"

export { Permission } from "@starknet-io/types-js"

export { scanObjectForWallets } from "./wallet/scan"
export { isWalletObj } from "./wallet/isWalletObject"
export { isWalletObject } from "./wallet/isWalletObject"

export type {
DisconnectOptions,
Expand All @@ -48,16 +54,10 @@ const ssrSafeWindow = typeof window !== "undefined" ? window : {}

const defaultOptions: GetStarknetOptions = {
windowObject: ssrSafeWindow,
isWalletObject: isWalletObj,
isWalletObject,
storageFactoryImplementation: (name: string) => new LocalStorageWrapper(name),
}

declare global {
interface Window {
[key: `starknet_${string}`]: StarknetWindowObject | undefined
}
}

export function getStarknet(
options: Partial<GetStarknetOptions> = {},
): GetStarknetResult {
Expand All @@ -67,6 +67,8 @@ export function getStarknet(
}
const lastConnectedStore = storageFactoryImplementation("gsw-last")

initiateVirtualWallets(windowObject)

return {
getAvailableWallets: async (options = {}) => {
const availableWallets = scanObjectForWallets(
Expand Down Expand Up @@ -112,7 +114,16 @@ export function getStarknet(

return firstAuthorizedWallet
},
enable: async (wallet, options) => {
enable: async (inputWallet, options) => {
let wallet: StarknetWindowObject
if (isVirtualWallet(inputWallet)) {
wallet = await resolveVirtualWallet(windowObject, inputWallet)
} else if (isFullWallet(inputWallet)) {
wallet = inputWallet
} else {
throw new Error("Invalid wallet object")
}

await wallet.request({
type: "wallet_requestAccounts",
params: {
Expand Down
41 changes: 39 additions & 2 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { WalletProvider } from "./discovery"
import { IStorageWrapper } from "./localStorageStore"
import { ensureKeysArray } from "./utils"
import { FilterList } from "./wallet/filter"
import { Sort } from "./wallet/sort"
import type {
Expand All @@ -11,7 +12,7 @@ export type { WalletProvider } from "./discovery"

export interface GetStarknetOptions {
windowObject: Record<string, any>
isWalletObject: (wallet: any) => boolean
isWalletObject: (wallet: unknown) => boolean
storageFactoryImplementation: (name: string) => IStorageWrapper
}

Expand All @@ -25,6 +26,36 @@ export interface DisconnectOptions {
clearLastWallet?: boolean
}

export interface VirtualWallet {
id: string
name: string
icon: string
windowKey: string
loadWallet: (
windowObject: Record<string, unknown>,
) => Promise<StarknetWindowObject>
hasSupport: (windowObject: Record<string, unknown>) => Promise<boolean>
}

export const virtualWalletKeys = ensureKeysArray<VirtualWallet>({
id: true,
name: true,
icon: true,
windowKey: true,
loadWallet: true,
hasSupport: true,
})

export const fullWalletKeys = ensureKeysArray<StarknetWindowObject>({
id: true,
name: true,
version: true,
icon: true,
request: true,
on: true,
off: true,
})

export interface GetStarknetResult {
getAvailableWallets: (
options?: GetWalletOptions,
Expand All @@ -35,8 +66,14 @@ export interface GetStarknetResult {
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
enable: (
wallet: StarknetWindowObject,
wallet: StarknetWindowObject | VirtualWallet,
options?: RequestAccountsParameters,
) => Promise<StarknetWindowObject> // Connects to a wallet
disconnect: (options?: DisconnectOptions) => Promise<void> // Disconnects from a wallet
}

declare global {
interface Window {
[key: string]: unknown
}
}
6 changes: 6 additions & 0 deletions packages/core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,9 @@ export const pipe =
<T>(...fns: Array<(arg: T) => AllowPromise<T>>): ((arg: T) => Promise<T>) =>
(arg: T) =>
fns.reduce<Promise<T>>((acc, fn) => acc.then(fn), Promise.resolve(arg))

export function ensureKeysArray<T extends object>(keysGuard: {
[k in keyof T]: true
}) {
return Object.keys(keysGuard) as (keyof T)[]
}
16 changes: 10 additions & 6 deletions packages/core/src/wallet/filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,16 @@ export const filterByAuthorized = async (
wallets: StarknetWindowObject[],
): Promise<StarknetWindowObject[]> => {
const preAuthResponses = await Promise.all(
wallets.map((w) =>
w
.request({ type: "wallet_getPermissions" })
.then((result: Permission[]) => result.includes(Permission.ACCOUNTS))
.catch(() => false),
),
wallets.map(async (wallet) => {
try {
const result: Permission[] = await wallet.request({
type: "wallet_getPermissions",
})
return result.includes(Permission.ACCOUNTS)
} catch {
return false
}
}),
)
return wallets.filter((_, i) => preAuthResponses[i])
}
30 changes: 17 additions & 13 deletions packages/core/src/wallet/isWalletObject.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
export const isWalletObj = (wallet: any): boolean => {
try {
import { fullWalletKeys, virtualWalletKeys } from "../types"

function createWalletGuard<T>(keys: (keyof T)[]) {
return function hasKeys(obj: unknown): obj is T {
return (
wallet &&
[
// wallet's must have methods/members, see IStarknetWindowObject
"request",
"on",
"off",
"version",
"id",
"name",
"icon",
].every((key) => key in wallet)
obj !== null && typeof obj === "object" && keys.every((key) => key in obj)
)
}
}

const isFullWallet = createWalletGuard(fullWalletKeys)

const isVirtualWallet = createWalletGuard(virtualWalletKeys)

function isWalletObject(wallet: unknown): boolean {
try {
return isFullWallet(wallet) || isVirtualWallet(wallet)
} catch (err) {}
return false
}

export { isVirtualWallet, isFullWallet, isWalletObject }
31 changes: 31 additions & 0 deletions packages/core/src/wallet/virtualWallets/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { VirtualWallet } from "../../types"
import { metaMaskVirtualWallet } from "./metaMaskVirtualWallet"
import type { StarknetWindowObject } from "@starknet-io/types-js"

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
}
})
}

const virtualWalletsMap: Record<string, StarknetWindowObject> = {}

async function resolveVirtualWallet(
windowObject: Record<string, unknown>,
virtualWallet: VirtualWallet,
) {
let wallet: StarknetWindowObject = virtualWalletsMap[virtualWallet.id]
if (!wallet) {
wallet = await virtualWallet.loadWallet(windowObject)
virtualWalletsMap[virtualWallet.id] = wallet
}

return wallet
}

export { initiateVirtualWallets, resolveVirtualWallet }
Loading