diff --git a/.changeset/fuzzy-wallet-vaults.md b/.changeset/fuzzy-wallet-vaults.md new file mode 100644 index 00000000..ff72e7f8 --- /dev/null +++ b/.changeset/fuzzy-wallet-vaults.md @@ -0,0 +1,8 @@ +--- +'@1001-digital/components.evm': major +'@1001-digital/layers.evm': major +--- + +Replace the plaintext local in-app wallet with a host-synchronized, +client-encrypted vault, passphrase and passkey unlock UI, and a worker-held +wagmi signer. diff --git a/packages/components.evm/package.json b/packages/components.evm/package.json index 17296aca..ffcb2eaa 100644 --- a/packages/components.evm/package.json +++ b/packages/components.evm/package.json @@ -37,7 +37,7 @@ }, "dependencies": { "@1001-digital/dweb-fetch": "^0.7.0", - "@1001-digital/wagmi-in-app-wallet": "^0.1.0", + "@1001-digital/wagmi-in-app-wallet": "^1.0.0", "@types/qrcode": "^1.5.6", "qrcode": "^1.5.4" }, diff --git a/packages/components.evm/src/client-only.ts b/packages/components.evm/src/client-only.ts index e5d4c76d..3423a547 100644 --- a/packages/components.evm/src/client-only.ts +++ b/packages/components.evm/src/client-only.ts @@ -11,6 +11,8 @@ export const clientOnlyComponents = [ 'EvmConnectionStatus', 'EvmConnectorQR', 'EvmInAppWalletSetup', + 'EvmInAppWalletManager', + 'EvmInAppWalletUnlockDialog', 'EvmMetaMaskQR', 'EvmProfile', 'EvmSidebarProfile', diff --git a/packages/components.evm/src/components/EvmInAppWalletManager.vue b/packages/components.evm/src/components/EvmInAppWalletManager.vue new file mode 100644 index 00000000..51275724 --- /dev/null +++ b/packages/components.evm/src/components/EvmInAppWalletManager.vue @@ -0,0 +1,297 @@ + + + + + diff --git a/packages/components.evm/src/components/EvmInAppWalletSetup.vue b/packages/components.evm/src/components/EvmInAppWalletSetup.vue index 0e99cea4..b724618d 100644 --- a/packages/components.evm/src/components/EvmInAppWalletSetup.vue +++ b/packages/components.evm/src/components/EvmInAppWalletSetup.vue @@ -1,16 +1,36 @@ diff --git a/packages/components.evm/src/composables/inAppWallet.ts b/packages/components.evm/src/composables/inAppWallet.ts new file mode 100644 index 00000000..0dd3ed66 --- /dev/null +++ b/packages/components.evm/src/composables/inAppWallet.ts @@ -0,0 +1,131 @@ +import { + computed, + inject, + readonly, + shallowRef, + type ComputedRef, + type InjectionKey, + type Ref, +} from 'vue' +import { + EncryptedWalletKeyring, + WalletLockedError, + type PasskeyRegistrationOptions, + type UnlockReason, + type WalletKeyringSnapshot, + type WalletVaultStore, +} from '../connectors/inAppWallet' + +export type EvmInAppWalletHost = { + store: WalletVaultStore + scope: string + rpName: string + rpId?: string + userName?: string +} + +let configuredHost: EvmInAppWalletHost | null = null + +export function configureEvmInAppWalletHost( + host: EvmInAppWalletHost | null, +): void { + configuredHost = host +} + +export function getConfiguredEvmInAppWalletHost(): EvmInAppWalletHost | null { + return configuredHost +} + +export class EvmInAppWalletController { + readonly keyring: EncryptedWalletKeyring + readonly host: EvmInAppWalletHost + readonly snapshot: Ref + readonly unlockReason: Readonly> + readonly unlockRequested: ComputedRef + + readonly #pendingReason = shallowRef(null) + #resolveUnlock: (() => void) | null = null + #rejectUnlock: ((error: unknown) => void) | null = null + readonly #unsubscribe: () => void + + constructor(keyring: EncryptedWalletKeyring, host: EvmInAppWalletHost) { + this.keyring = keyring + this.host = host + this.snapshot = shallowRef(keyring.snapshot()) + this.unlockReason = readonly(this.#pendingReason) + this.unlockRequested = computed(() => this.#pendingReason.value !== null) + this.#unsubscribe = keyring.subscribe((snapshot) => { + this.snapshot.value = snapshot + }) + } + + requestUnlock = (reason: UnlockReason): Promise => { + if (this.keyring.isUnlocked) return Promise.resolve() + this.cancelUnlock() + this.#pendingReason.value = reason + return new Promise((resolve, reject) => { + this.#resolveUnlock = resolve + this.#rejectUnlock = reject + }) + } + + async unlockWithPassphrase(passphrase: string): Promise { + await this.keyring.unlockWithPassphrase(passphrase) + this.completeUnlock() + } + + async unlockWithPasskey(slotId?: string): Promise { + await this.keyring.unlockWithPasskey(slotId) + this.completeUnlock() + } + + async addPasskey(label?: string): Promise { + const options: PasskeyRegistrationOptions = { + rpName: this.host.rpName, + rpId: this.host.rpId, + userName: this.host.userName, + label, + } + await this.keyring.addPasskey(options) + } + + completeUnlock(): void { + if (!this.keyring.isUnlocked) throw new WalletLockedError() + this.#resolveUnlock?.() + this.#clearUnlock() + } + + cancelUnlock(): void { + this.#rejectUnlock?.(new WalletLockedError('Wallet unlock cancelled')) + this.#clearUnlock() + } + + destroy(): void { + this.cancelUnlock() + this.#unsubscribe() + this.keyring.destroy() + } + + #clearUnlock(): void { + this.#pendingReason.value = null + this.#resolveUnlock = null + this.#rejectUnlock = null + } +} + +export const EvmInAppWalletControllerKey: InjectionKey = + Symbol('EvmInAppWalletController') + +export function useEvmInAppWallet(): EvmInAppWalletController { + const controller = inject(EvmInAppWalletControllerKey) + if (!controller) { + throw new Error( + 'The in-app wallet is not enabled or its controller was not provided', + ) + } + return controller +} + +export function useOptionalEvmInAppWallet(): EvmInAppWalletController | null { + return inject(EvmInAppWalletControllerKey, null) +} diff --git a/packages/components.evm/src/connectors/inAppWallet.ts b/packages/components.evm/src/connectors/inAppWallet.ts index 1741596c..4ea3690e 100644 --- a/packages/components.evm/src/connectors/inAppWallet.ts +++ b/packages/components.evm/src/connectors/inAppWallet.ts @@ -1,5 +1,17 @@ export { + EncryptedWalletKeyring, + PasskeyPrfUnavailableError, + VaultConflictError, + WalletLockedError, + assertVaultPassphrase, + createLocalEncryptedVaultStore, inAppWallet, - prepareInAppWallet, type InAppWalletParameters, + type EncryptedWalletVaultV1, + type PasskeyRegistrationOptions, + type RequestUnlock, + type SyncedWalletVault, + type UnlockReason, + type WalletKeyringSnapshot, + type WalletVaultStore, } from '@1001-digital/wagmi-in-app-wallet' diff --git a/packages/components.evm/src/index.ts b/packages/components.evm/src/index.ts index 9d519c37..5fdca555 100644 --- a/packages/components.evm/src/index.ts +++ b/packages/components.evm/src/index.ts @@ -131,9 +131,18 @@ export type { SiweSignInOptions, SiweSignInResult, } from './composables/siwe' +export { + configureEvmInAppWalletHost, + EvmInAppWalletController, + EvmInAppWalletControllerKey, + getConfiguredEvmInAppWalletHost, + useEvmInAppWallet, + useOptionalEvmInAppWallet, +} from './composables/inAppWallet' +export type { EvmInAppWalletHost } from './composables/inAppWallet' // Connectors -export { inAppWallet, prepareInAppWallet } from './connectors/inAppWallet' +export * from './connectors/inAppWallet' export { clientOnlyComponents } from './client-only' @@ -161,6 +170,8 @@ export { default as EvmMultiTransactionFlow } from './components/EvmMultiTransac export { default as EvmMultiTransactionFlowDialog } from './components/EvmMultiTransactionFlowDialog.vue' export { default as EvmSeedPhraseInput } from './components/EvmSeedPhraseInput.vue' export { default as EvmInAppWalletSetup } from './components/EvmInAppWalletSetup.vue' +export { default as EvmInAppWalletManager } from './components/EvmInAppWalletManager.vue' +export { default as EvmInAppWalletUnlockDialog } from './components/EvmInAppWalletUnlockDialog.vue' export { default as EvmSiwe } from './components/EvmSiwe.vue' export { default as EvmSiweDialog } from './components/EvmSiweDialog.vue' export { default as EvmConnectAuth } from './components/EvmConnectAuth.vue' diff --git a/packages/components.evm/src/types.ts b/packages/components.evm/src/types.ts index 22049168..01049e7b 100644 --- a/packages/components.evm/src/types.ts +++ b/packages/components.evm/src/types.ts @@ -267,6 +267,11 @@ export interface EvmInAppWalletSetupEmits { back: [] } +export interface EvmInAppWalletUnlockDialogEmits { + unlocked: [] + cancelled: [] +} + // EvmSiwe export interface EvmSiweProps { getNonce: () => Promise diff --git a/packages/layers.evm/app/composables/inAppWallet.ts b/packages/layers.evm/app/composables/inAppWallet.ts new file mode 100644 index 00000000..85db1fa3 --- /dev/null +++ b/packages/layers.evm/app/composables/inAppWallet.ts @@ -0,0 +1,5 @@ +// Re-exported to enable Nuxt auto-imports for layer consumers +export { + useEvmInAppWallet, + useOptionalEvmInAppWallet, +} from '@1001-digital/components.evm' diff --git a/packages/layers.evm/app/plugins/components.ts b/packages/layers.evm/app/plugins/components.ts index eaa38806..5aea3e7f 100644 --- a/packages/layers.evm/app/plugins/components.ts +++ b/packages/layers.evm/app/plugins/components.ts @@ -13,6 +13,8 @@ import { EvmConnectorQR, EvmEthInput, EvmInAppWalletSetup, + EvmInAppWalletManager, + EvmInAppWalletUnlockDialog, EvmMetaMaskQR, EvmMultiTransactionFlow, EvmMultiTransactionFlowDialog, @@ -50,6 +52,8 @@ const shadowed = { EvmConnectorQR, EvmEthInput, EvmInAppWalletSetup, + EvmInAppWalletManager, + EvmInAppWalletUnlockDialog, EvmMetaMaskQR, EvmMultiTransactionFlow, EvmMultiTransactionFlowDialog, diff --git a/packages/layers.evm/app/plugins/wagmi.ts b/packages/layers.evm/app/plugins/wagmi.ts index 084cdd84..670c32b8 100644 --- a/packages/layers.evm/app/plugins/wagmi.ts +++ b/packages/layers.evm/app/plugins/wagmi.ts @@ -1,11 +1,19 @@ import { VueQueryPlugin } from '@tanstack/vue-query' import { WagmiPlugin } from '@wagmi/vue' -import { EvmConfigKey } from '@1001-digital/components.evm' +import { + createLocalEncryptedVaultStore, + EncryptedWalletKeyring, + EvmConfigKey, + EvmInAppWalletController, + EvmInAppWalletControllerKey, + getConfiguredEvmInAppWalletHost, + type EvmInAppWalletHost, +} from '@1001-digital/components.evm' import { createWagmiConfig } from '../wagmi' export default defineNuxtPlugin({ name: 'wagmi', - setup(nuxtApp) { + async setup(nuxtApp) { const appConfig = useAppConfig() const runtimeConfig = nuxtApp.$config.public.evm as { walletConnectProjectId: string @@ -16,6 +24,19 @@ export default defineNuxtPlugin({ const indexers = runtimeConfig.ens?.indexers?.split(/\s+/).filter(Boolean) || [] + let walletController: EvmInAppWalletController | null = null + if (import.meta.client && appConfig.evm?.inAppWallet?.enabled) { + const configuredHost = getConfiguredEvmInAppWalletHost() + const host: EvmInAppWalletHost = configuredHost ?? { + store: createLocalEncryptedVaultStore(), + scope: window.location.host, + rpName: appConfig.evm?.title || 'EVM Layer', + } + const keyring = new EncryptedWalletKeyring({ store: host.store }) + walletController = new EvmInAppWalletController(keyring, host) + await keyring.load() + } + const { wagmiConfig, evmConfig } = createWagmiConfig({ title: appConfig.evm?.title || 'EVM Layer', appLogoUrl: appConfig.evm?.appLogoUrl, @@ -28,7 +49,12 @@ export default defineNuxtPlugin({ ipfsGateway: appConfig.evm?.ipfsGateway, arweaveGateway: appConfig.evm?.arweaveGateway, baseURL: nuxtApp.$config.app.baseURL, - inAppWalletEnabled: appConfig.evm?.inAppWallet?.enabled, + inAppWallet: walletController + ? { + keyring: walletController.keyring, + requestUnlock: walletController.requestUnlock, + } + : undefined, isClient: import.meta.client, }) @@ -37,9 +63,14 @@ export default defineNuxtPlugin({ .use(VueQueryPlugin, {}) .provide(EvmConfigKey, evmConfig) + if (walletController) { + nuxtApp.vueApp.provide(EvmInAppWalletControllerKey, walletController) + } + return { provide: { wagmi: wagmiConfig, + evmInAppWallet: walletController, }, } }, diff --git a/packages/layers.evm/app/shims/components.ts b/packages/layers.evm/app/shims/components.ts index 0958d7fd..45cf9166 100644 --- a/packages/layers.evm/app/shims/components.ts +++ b/packages/layers.evm/app/shims/components.ts @@ -58,6 +58,14 @@ export const EvmInAppWalletSetup = proxy( 'EvmInAppWalletSetup', Originals.EvmInAppWalletSetup, ) +export const EvmInAppWalletManager = proxy( + 'EvmInAppWalletManager', + Originals.EvmInAppWalletManager, +) +export const EvmInAppWalletUnlockDialog = proxy( + 'EvmInAppWalletUnlockDialog', + Originals.EvmInAppWalletUnlockDialog, +) export const EvmMetaMaskQR = proxy('EvmMetaMaskQR', Originals.EvmMetaMaskQR) export const EvmMultiTransactionFlow = proxy( 'EvmMultiTransactionFlow', diff --git a/packages/layers.evm/app/types/in-app-wallet.d.ts b/packages/layers.evm/app/types/in-app-wallet.d.ts new file mode 100644 index 00000000..590be1cd --- /dev/null +++ b/packages/layers.evm/app/types/in-app-wallet.d.ts @@ -0,0 +1,15 @@ +import type { EvmInAppWalletController } from '@1001-digital/components.evm' + +declare module '#app' { + interface NuxtApp { + $evmInAppWallet: EvmInAppWalletController | null + } +} + +declare module 'vue' { + interface ComponentCustomProperties { + $evmInAppWallet: EvmInAppWalletController | null + } +} + +export {} diff --git a/packages/layers.evm/app/utils/connectors.ts b/packages/layers.evm/app/utils/connectors.ts index 4b722e28..d8be9329 100644 --- a/packages/layers.evm/app/utils/connectors.ts +++ b/packages/layers.evm/app/utils/connectors.ts @@ -1,2 +1,6 @@ // Re-exported to enable Nuxt auto-imports for layer consumers -export { inAppWallet, prepareInAppWallet } from '@1001-digital/components.evm' +export { + configureEvmInAppWalletHost, + createLocalEncryptedVaultStore, + inAppWallet, +} from '@1001-digital/components.evm' diff --git a/packages/layers.evm/app/wagmi.ts b/packages/layers.evm/app/wagmi.ts index af27680e..a13bbaf9 100644 --- a/packages/layers.evm/app/wagmi.ts +++ b/packages/layers.evm/app/wagmi.ts @@ -21,6 +21,7 @@ import { resolveChain, inAppWallet, type EvmConfig, + type InAppWalletParameters, } from '@1001-digital/components.evm' interface ChainEntry { @@ -40,7 +41,7 @@ interface CreateOptions { ipfsGateway?: string arweaveGateway?: string baseURL?: string - inAppWalletEnabled?: boolean + inAppWallet?: InAppWalletParameters isClient?: boolean } @@ -60,7 +61,7 @@ export function createWagmiConfig(options: CreateOptions): { ipfsGateway, arweaveGateway, baseURL, - inAppWalletEnabled, + inAppWallet: inAppWalletOptions, isClient, } = options @@ -121,7 +122,9 @@ export function createWagmiConfig(options: CreateOptions): { }), ) - if (inAppWalletEnabled) connectors.push(inAppWallet()) + if (inAppWalletOptions) { + connectors.push(inAppWallet(inAppWalletOptions) as CreateConnectorFn) + } const wagmiConfig: Config = createConfig({ chains: chains as [Chain, ...Chain[]],