From 7ea4844761482df4539a03dab9dc5a3981c2de11 Mon Sep 17 00:00:00 2001 From: mtyszczak <mateusz.tyszczak@gmail.com> Date: Tue, 11 Mar 2025 16:21:59 +0100 Subject: [PATCH] Add keychain, peakvault and metamask signing functionality --- src/App.vue | 10 ++- src/components/MetamaskExample.vue | 75 ------------------- src/components/onboarding/SelectWallet.vue | 21 +++++- .../wallets/metamask/MetamaskConnect.vue | 20 ++--- src/components/utilcards/MemoEncryptCard.vue | 52 +++++++++++-- .../utilcards/SignTransactionCard.vue | 35 ++++++++- src/stores/metamask.store.ts | 2 +- src/stores/settings.store.ts | 1 + src/stores/wallet.store.ts | 45 +++++++++++ src/utils/wallet/abstraction.ts | 7 ++ src/utils/wallet/keychain/index.ts | 62 +++++++++++++++ src/utils/wallet/metamask/metamask.ts | 22 +++++- src/utils/wallet/metamask/snap.ts | 4 +- src/utils/wallet/peakvault/index.ts | 31 ++++++++ 14 files changed, 279 insertions(+), 108 deletions(-) delete mode 100644 src/components/MetamaskExample.vue create mode 100644 src/stores/wallet.store.ts create mode 100644 src/utils/wallet/abstraction.ts create mode 100644 src/utils/wallet/keychain/index.ts create mode 100644 src/utils/wallet/peakvault/index.ts diff --git a/src/App.vue b/src/App.vue index c4e788b..32f2705 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,22 +1,28 @@ <script setup lang="ts"> import { ref, onMounted, defineAsyncComponent } from 'vue'; import { useSettingsStore, UsedWallet } from '@/stores/settings.store'; +import { useWalletStore } from '@/stores/wallet.store'; import AppHeader from '@/components/AppHeader.vue'; const WalletOnboarding = defineAsyncComponent(() => import('@/components/onboarding/index')); const hasUser = ref(true); const settingsStore = useSettingsStore(); +const walletStore = useWalletStore(); onMounted(() => { settingsStore.loadSettings(); hasUser.value = settingsStore.settings.account !== undefined; + if (hasUser.value) + void walletStore.createWalletFor(settingsStore.settings); }); const complete = (data: { account: string; wallet: UsedWallet }) => { hasUser.value = true; - settingsStore.setSettings({ + const settings = { account: data.account, wallet: data.wallet - }); + }; + settingsStore.setSettings(settings); + void walletStore.createWalletFor(settings); }; </script> diff --git a/src/components/MetamaskExample.vue b/src/components/MetamaskExample.vue deleted file mode 100644 index a1fb975..0000000 --- a/src/components/MetamaskExample.vue +++ /dev/null @@ -1,75 +0,0 @@ -<script setup lang="ts"> -import { ref, onMounted, computed } from 'vue'; -import { useMetamaskStore } from '@/stores/metamask.store'; -import { Button } from '@/components/ui/button'; -import { useSettingsStore } from '@/stores/settings.store'; - -const walletStore = useMetamaskStore(); - -const metamaskFound = ref(false); -const isConnected = computed(() => walletStore.isConnected); -const isFlask = computed(() => walletStore.isFlask); -const performingOperation = computed(() => walletStore.performingOperation); -const isInstalled = computed(() => walletStore.isInstalled); - -const frontError = ref<undefined | string>(); -const frontResult = ref<undefined | string>(); - -const safeCall = async(storeFn: (...args: any) => any) => { - frontResult.value = undefined; - frontError.value = undefined; - - try { - frontResult.value = JSON.stringify(await storeFn(), undefined, 2); - } catch(error) { - frontError.value = error instanceof Error ? error.message : `Unknown error: ${error}`; - } -}; - -const settingsStore = useSettingsStore(); - -const connect = safeCall.bind(undefined, () => walletStore.connect()); -const install = safeCall.bind(undefined, () => walletStore.install()); -const call = (method: string, params?: any) => safeCall(() => walletStore.call(method, params)); -const getPublicKeys = () => call('hive_getPublicKeys', { keys: [ { role: 'memo' }, { role: 'posting' }, { role: 'active' }, { role: 'owner' } ] }); - -// Automatically try to connect on mount (client-side) -onMounted(() => { - walletStore.connect().then(() => metamaskFound.value = true).catch(error => { - console.error(error); - }); -}); -</script> - -<template> - <div> - <h1>Metamask Example</h1> - <p>Has supported extension: {{ metamaskFound }}</p> - <p v-if="metamaskFound">Connected: {{ isConnected }}</p> - <div v-if="isConnected"> - <p>isFlask: {{ isFlask }}</p> - <p>isInstalled: {{ isInstalled }}</p> - <Button :disabled="performingOperation" @click="connect">Reconnect</Button> - <Button :disabled="performingOperation" @click="install">{{ isInstalled ? "Reinstall" : "Install" }}</Button> - <Button :disabled="performingOperation" @click="getPublicKeys" v-if="isInstalled">getPublicKeys</Button> - <Button @click="settingsStore.resetSettings">logout</Button> - </div> - <div v-else-if="metamaskFound"> - <Button :disabled="performingOperation" @click="connect">Connect</Button> - </div> - <div v-if="frontError"> - <p :style="{ color: 'darkred' }">Error:</p> - <code><pre>{{ frontError }}</pre></code> - </div> - <div v-if="frontResult"> - <p :style="{ color: 'darkgreen' }">Result:</p> - <code><pre>{{ frontResult }}</pre></code> - </div> - </div> -</template> - -<style scoped> -pre { - text-align: left; -} -</style> diff --git a/src/components/onboarding/SelectWallet.vue b/src/components/onboarding/SelectWallet.vue index 7aa938b..d4e0362 100644 --- a/src/components/onboarding/SelectWallet.vue +++ b/src/components/onboarding/SelectWallet.vue @@ -1,20 +1,33 @@ <script setup lang="ts"> import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import OnboardingButton from "@/components/onboarding/OnboardingWalletButton.vue"; -import { onMounted, ref } from 'vue'; +import { onMounted, onUnmounted, ref } from 'vue'; import { useMetamaskStore } from "@/stores/metamask.store"; import { getWalletIcon, UsedWallet } from "@/stores/settings.store"; const hasMetamask = ref(false); const hasKeychain = ref(false); const hasPeakVault = ref(false); +let timeoutId: number; const metamaskStore = useMetamaskStore(); +const checkForWallets = () => { + if (!hasMetamask.value) + metamaskStore.connect().then(() => hasMetamask.value = true).catch(console.error); + if (!hasKeychain.value) + hasKeychain.value = "hive_keychain" in window; + if (!hasPeakVault.value) + hasPeakVault.value = "peakvault" in window; +}; + onMounted(() => { - metamaskStore.connect().then(() => hasMetamask.value = true).catch(console.error); - hasKeychain.value = "hive_keychain" in window; - hasPeakVault.value = "peakvault" in window; + timeoutId = setTimeout(() => checkForWallets(), 1500) as unknown as number; + checkForWallets(); +}); + +onUnmounted(() => { + clearTimeout(timeoutId); }); const emit = defineEmits(["walletSelect"]); diff --git a/src/components/onboarding/wallets/metamask/MetamaskConnect.vue b/src/components/onboarding/wallets/metamask/MetamaskConnect.vue index 9fe0b38..9a1d92c 100644 --- a/src/components/onboarding/wallets/metamask/MetamaskConnect.vue +++ b/src/components/onboarding/wallets/metamask/MetamaskConnect.vue @@ -11,6 +11,7 @@ import { Combobox, ComboboxAnchor, ComboboxTrigger, ComboboxEmpty, ComboboxGroup import { Check, Search } from 'lucide-vue-next'; import { Separator } from '@/components/ui/separator'; import PublicKey from '@/components/hive/PublicKey.vue'; +import { getWax } from '@/stores/wax.store'; const emit = defineEmits(["setaccount", "close"]); @@ -46,22 +47,13 @@ const applyPublicKeys = async () => { metamaskPublicKeys.value = publicKeys; - const response = await (await fetch("https://api.hive.blog", { - method: "POST", - body: JSON.stringify({ - method: "account_by_key_api.get_key_references", - jsonrpc:"2.0", - id: "1", - params: { - keys: publicKeys.map((node: { publicKey: string }) => node.publicKey) - } - }) - })).json(); + const wax = await getWax(); - if (response.error) - throw new Error(response.error.message); + const response = await wax.api.account_by_key_api.get_key_references({ + keys: publicKeys.map((node: { publicKey: string }) => node.publicKey) + }); - accountsMatchingKeys.value = [...new Set(response.result.accounts.flatMap((node: string[]) => node))] as string[]; + accountsMatchingKeys.value = [...new Set(response.accounts.flatMap((node: string[]) => node))] as string[]; } catch (error) { if (typeof error === "object" && error && "message" in error) errorMsg.value = error.message as string; diff --git a/src/components/utilcards/MemoEncryptCard.vue b/src/components/utilcards/MemoEncryptCard.vue index a4717ef..0cd93dc 100644 --- a/src/components/utilcards/MemoEncryptCard.vue +++ b/src/components/utilcards/MemoEncryptCard.vue @@ -1,13 +1,53 @@ <script setup lang="ts"> import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { mdiMessageLockOutline } from '@mdi/js'; -import { ref } from 'vue'; +import { ref, computed } from 'vue'; import { Textarea } from '@/components/ui/textarea'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Switch } from '@/components/ui/switch'; +import { useWalletStore } from '@/stores/wallet.store'; +import { getWax } from '@/stores/wax.store'; +import { useSettingsStore } from '@/stores/settings.store'; + +const walletStore = useWalletStore(); +const settingsStore = useSettingsStore(); + +const hasWallet = computed(() => walletStore.hasWallet); +const wallet = computed(() => walletStore.wallet); const isEncrypt = ref(false); +const encryptForKey = ref(''); +const inputData = ref(''); +const outputData = ref(''); + +const getMemoKeyForUser = async(user: string) => { + const wax = await getWax(); + const response = await wax.api.database_api.find_accounts({ + accounts: [user.startsWith('@') ? user.slice(1) : user], + delayed_votes_active: true + }); + return response.accounts[0].memo_key; +} + +const useMyMemoKey = async () => { + encryptForKey.value = await getMemoKeyForUser(settingsStore.account!); +} + +const encryptOrDecrypt = async () => { + if (isEncrypt.value) { + let publicKey: string; + let accountOrKey = encryptForKey.value; + if (accountOrKey.startsWith('STM')) { + publicKey = accountOrKey; + } else { + publicKey = await getMemoKeyForUser(accountOrKey); + } + outputData.value = await wallet.value!.encrypt(inputData.value, publicKey); + } else { + outputData.value = await wallet.value!.decrypt(inputData.value); + } +}; </script> <template> @@ -25,13 +65,13 @@ const isEncrypt = ref(false); <Switch v-model="isEncrypt" /> <span>Encrypt</span> </div> - <Textarea placeholder="Input" class="my-4"/> - <Input v-if="isEncrypt" placeholder="Receiver account or public key" class="mt-4"/> + <Textarea v-model="inputData" placeholder="Input" class="my-4"/> + <Input v-model="encryptForKey" v-if="isEncrypt" placeholder="Receiver account or public key" class="mt-4"/> <div class="flex mb-4 underline text-sm" v-if="isEncrypt"> - <a class="ml-auto mr-1 cursor-pointer" style="color: hsla(var(--foreground) / 70%)" @click="">Use my memo key</a> + <a @click="useMyMemoKey" class="ml-auto mr-1 cursor-pointer" style="color: hsla(var(--foreground) / 70%)">Use my memo key</a> </div> - <Button>{{ isEncrypt ? "Encrypt" : "Decrypt" }}</Button> - <Textarea placeholder="Output" copy-enabled class="my-4" disabled/> + <Button :disabled="!hasWallet" @click="encryptOrDecrypt">{{ isEncrypt ? "Encrypt" : "Decrypt" }}</Button> + <Textarea v-model="outputData" placeholder="Output" copy-enabled class="my-4" disabled/> </CardContent> </Card> </template> \ No newline at end of file diff --git a/src/components/utilcards/SignTransactionCard.vue b/src/components/utilcards/SignTransactionCard.vue index 9b5ae54..aa738da 100644 --- a/src/components/utilcards/SignTransactionCard.vue +++ b/src/components/utilcards/SignTransactionCard.vue @@ -1,8 +1,37 @@ <script setup lang="ts"> import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { mdiFileSign } from '@mdi/js'; +import { computed, ref } from 'vue'; import { Textarea } from '@/components/ui/textarea'; import { Button } from '@/components/ui/button'; +import { useWalletStore } from '@/stores/wallet.store'; +import { getWax } from '@/stores/wax.store'; +import type { TRole } from '@hiveio/wax/vite'; + +const walletStore = useWalletStore(); + +const hasWallet = computed(() => walletStore.hasWallet); +const wallet = computed(() => walletStore.wallet); + +const inputData = ref(''); +const outputData = ref(''); + +const sign = async () => { + const wax = await getWax(); + + const tx = wax.createTransactionFromJson(inputData.value); + + const authorities = tx.requiredAuthorities; + let authorityLevel: TRole = 'posting'; + if (authorities.owner.size) + authorityLevel = 'owner'; + else if (authorities.active.size) + authorityLevel = 'active'; + + // TODO: Handle "other" authority + + outputData.value = await wallet.value!.signTransaction(tx, authorityLevel); +}; </script> <template> @@ -15,11 +44,11 @@ import { Button } from '@/components/ui/button'; <CardDescription class="mr-4">Use this module to sign the provided transaction</CardDescription> </CardHeader> <CardContent> - <Textarea placeholder="Transaction in API JSON form" class="my-4"/> + <Textarea v-model="inputData" placeholder="Transaction in API JSON form" class="my-4"/> <div class="my-4 space-x-4"> - <Button>Sign transaction</Button> + <Button :disabled="!hasWallet" @click="sign">Sign transaction</Button> </div> - <Textarea placeholder="Signed Transaction output" copy-enabled class="my-4" disabled/> + <Textarea v-model="outputData" placeholder="Signed Transaction output" copy-enabled class="my-4" disabled/> </CardContent> </Card> </template> \ No newline at end of file diff --git a/src/stores/metamask.store.ts b/src/stores/metamask.store.ts index d3ca2c9..81bc758 100644 --- a/src/stores/metamask.store.ts +++ b/src/stores/metamask.store.ts @@ -1,7 +1,7 @@ import { defineStore } from "pinia" import { connectMetamask, type MetamaskWallet } from "../utils/wallet/metamask" -export const useMetamaskStore = defineStore('wallet', { +export const useMetamaskStore = defineStore('metamask', { state: () => ({ metamask: undefined as undefined | MetamaskWallet, performingOperation: false diff --git a/src/stores/settings.store.ts b/src/stores/settings.store.ts index 3cae3bb..aa135c2 100644 --- a/src/stores/settings.store.ts +++ b/src/stores/settings.store.ts @@ -35,6 +35,7 @@ const settings = { wallet: undefined as UsedWallet | undefined, account: undefined as string | undefined, }; +export type Settings = Required<typeof settings>; export const useSettingsStore = defineStore('settings', { state: () => ({ diff --git a/src/stores/wallet.store.ts b/src/stores/wallet.store.ts new file mode 100644 index 0000000..1da5f95 --- /dev/null +++ b/src/stores/wallet.store.ts @@ -0,0 +1,45 @@ +import { defineStore } from "pinia"; +import type { Wallet } from "@/utils/wallet/abstraction"; +import { type Settings, UsedWallet } from "./settings.store"; +import { useMetamaskStore } from "./metamask.store"; +import { createKeychainWalletFor } from "@/utils/wallet/keychain"; +import { createPeakVaultWalletFor } from "@/utils/wallet/peakvault"; + +export const useWalletStore = defineStore('wallet', { + state: () => ({ + wallet: undefined as undefined | Wallet + }), + getters: { + hasWallet: state => !!state.wallet, + }, + actions: { + async createWalletFor(settings: Settings) { + switch(settings.wallet) { + case UsedWallet.METAMASK: { + const metamaskStore = useMetamaskStore(); + + await metamaskStore.connect(); + + this.wallet = metamaskStore.metamask; + + break; + } + case UsedWallet.KEYCHAIN: { + this.wallet = createKeychainWalletFor(settings.account!); + + break; + } + case UsedWallet.PEAKVAULT: { + this.wallet = createPeakVaultWalletFor(settings.account!); + + break; + } + default: + throw new Error("Unsupported wallet"); + } + }, + resetWallet() { + this.wallet = undefined; + } + } +}) diff --git a/src/utils/wallet/abstraction.ts b/src/utils/wallet/abstraction.ts new file mode 100644 index 0000000..6addbee --- /dev/null +++ b/src/utils/wallet/abstraction.ts @@ -0,0 +1,7 @@ +import type { TPublicKey, TRole, ITransactionBase } from "@hiveio/wax/vite"; + +export interface Wallet { + signTransaction(transaction: ITransactionBase, role: TRole): Promise<string>; + encrypt(buffer: string, recipient: TPublicKey): Promise<string>; + decrypt(buffer: string): Promise<string>; +} diff --git a/src/utils/wallet/keychain/index.ts b/src/utils/wallet/keychain/index.ts new file mode 100644 index 0000000..2c42db4 --- /dev/null +++ b/src/utils/wallet/keychain/index.ts @@ -0,0 +1,62 @@ +import type { TRole, TPublicKey, TAccountName, ITransactionBase } from "@hiveio/wax/vite"; +import type { Wallet } from "../abstraction"; + +export const createKeychainWalletFor = (account: TAccountName) => { + return new KeychainWallet(account); +}; + +export class KeychainWallet implements Wallet { + public constructor( + private readonly account: TAccountName + ) {} + + public async signTransaction(transaction: ITransactionBase, role: TRole): Promise<string> { + const response = await new Promise((resolve, reject) => (window as any).hive_keychain.requestSignTx( + this.account, + JSON.parse(transaction.toLegacyApi()), + role, + (response: any) => { + if (response.error) + reject(response); + else + resolve(response); + } + )) as any; + + return response.result.signatures; + } + + public async encrypt(buffer: string, recipient: TPublicKey): Promise<string> { + const response = await new Promise((resolve, reject) => (window as any).hive_keychain.requestEncodeWithKeys( + this.account, + [recipient], + buffer.startsWith("#") ? buffer : `#${buffer}`, + "memo", + (response: any) => { + if (response.error) + reject(response); + else + resolve(response); + } + )) as any; + + return Object.values(response.result)[0] as string; + } + + public async decrypt(buffer: string): Promise<string> { + const response = await new Promise((resolve, reject) => (window as any).hive_keychain.requestVerifyKey( + this.account, + buffer, + "memo", + (response: any) => { + if (response.error) + reject(response); + else + resolve(response); + } + )) as any; + + + return response.result; + } +} diff --git a/src/utils/wallet/metamask/metamask.ts b/src/utils/wallet/metamask/metamask.ts index 1da6ed0..bfc8e5b 100644 --- a/src/utils/wallet/metamask/metamask.ts +++ b/src/utils/wallet/metamask/metamask.ts @@ -1,5 +1,7 @@ import type { MetaMaskInpageProvider } from "@metamask/providers"; import { defaultSnapOrigin, defaultSnapVersion, isLocalSnap } from "./snap"; +import type { Wallet } from "../abstraction"; +import type { TPublicKey, TRole, ITransactionBase } from "@hiveio/wax/vite"; export type MetamaskSnapData = { permissionName: string; @@ -9,7 +11,7 @@ export type MetamaskSnapData = { }; export type MetamaskSnapsResponse = Record<string, MetamaskSnapData>; -export class MetamaskWallet { +export class MetamaskWallet implements Wallet { /** * Indicates either the snap is installed or not. * If you want to install or reinstall the snap, use {@link installSnap} @@ -33,6 +35,24 @@ export class MetamaskWallet { return this.provider.request(params ? { method, params } : { method }); } + public async signTransaction(transaction: ITransactionBase, role: TRole) { + const response = await this.invokeSnap('hive_signTransaction', { transaction: transaction.toApi(), keys: [{ role }] }) as any; + + return response.signatures[0]; + } + + public async encrypt(buffer: string, recipient: TPublicKey): Promise<string> { + const response = await this.invokeSnap('hive_encrypt', { buffer, firstKey: { role: "memo" as TRole }, secondKey: recipient }) as any; + + return response.buffer; + } + + public async decrypt(buffer: string): Promise<string> { + const response = await this.invokeSnap('hive_decrypt', { buffer, firstKey: { role: "memo" as TRole } }) as any; + + return response.buffer; + } + /** * Request the snap to be installed or reinstalled. * You can check if snap is installed using {@link isInstalled} diff --git a/src/utils/wallet/metamask/snap.ts b/src/utils/wallet/metamask/snap.ts index 8d313df..ffb7a9b 100644 --- a/src/utils/wallet/metamask/snap.ts +++ b/src/utils/wallet/metamask/snap.ts @@ -6,9 +6,9 @@ * don't. Instead, rename `.env.production.dist` to `.env.production` and set the production URL * there. Running `yarn build` will automatically use the production environment variables. */ -export const defaultSnapOrigin = import.meta.env.SNAP_ORIGIN ?? `npm:@hiveio/metamask-snap`; +export const defaultSnapOrigin = import.meta.env.SNAP_ORIGIN ?? `npm:@hiveio/metamask-snap`; // local:http://localhost:8080 -export const defaultSnapVersion: string | undefined = import.meta.env.SNAP_VERSION ?? '1.0.1'; +export const defaultSnapVersion: string | undefined = import.meta.env.SNAP_VERSION ?? '1.2.1'; /** * Check if a snap ID is a local snap ID. diff --git a/src/utils/wallet/peakvault/index.ts b/src/utils/wallet/peakvault/index.ts new file mode 100644 index 0000000..98836cb --- /dev/null +++ b/src/utils/wallet/peakvault/index.ts @@ -0,0 +1,31 @@ + +import type { TRole, TPublicKey, TAccountName, ITransactionBase } from "@hiveio/wax/vite"; +import type { Wallet } from "../abstraction"; + +export const createPeakVaultWalletFor = (account: TAccountName) => { + return new PeakVaultWallet(account); +}; + +export class PeakVaultWallet implements Wallet { + public constructor( + private readonly account: TAccountName + ) {} + + public async signTransaction(transaction: ITransactionBase, role: TRole): Promise<string> { + const response = await (window as any).peakvault.requestSignTx(this.account, JSON.parse(transaction.toLegacyApi()), role); + + return response.result.signatures[0]; + } + + public async encrypt(buffer: string, recipient: TPublicKey): Promise<string> { + const response = await (window as any).peakvault.requestEncodeWithKeys(this.account, "memo", [recipient], buffer.startsWith("#") ? buffer : `#${buffer}`); + + return response.result[0]; + } + + public async decrypt(buffer: string): Promise<string> { + const response = await (window as any).peakvault.requestDecode(this.account, buffer, "memo"); + + return response.result; + } +} -- GitLab