Skip to content
Snippets Groups Projects
Verified Commit 7ea48447 authored by Mateusz Tyszczak's avatar Mateusz Tyszczak :scroll:
Browse files

Add keychain, peakvault and metamask signing functionality

parent 142d0b0b
No related branches found
No related tags found
No related merge requests found
Pipeline #117435 passed
Showing with 279 additions and 108 deletions
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, defineAsyncComponent } from 'vue'; import { ref, onMounted, defineAsyncComponent } from 'vue';
import { useSettingsStore, UsedWallet } from '@/stores/settings.store'; import { useSettingsStore, UsedWallet } from '@/stores/settings.store';
import { useWalletStore } from '@/stores/wallet.store';
import AppHeader from '@/components/AppHeader.vue'; import AppHeader from '@/components/AppHeader.vue';
const WalletOnboarding = defineAsyncComponent(() => import('@/components/onboarding/index')); const WalletOnboarding = defineAsyncComponent(() => import('@/components/onboarding/index'));
const hasUser = ref(true); const hasUser = ref(true);
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const walletStore = useWalletStore();
onMounted(() => { onMounted(() => {
settingsStore.loadSettings(); settingsStore.loadSettings();
hasUser.value = settingsStore.settings.account !== undefined; hasUser.value = settingsStore.settings.account !== undefined;
if (hasUser.value)
void walletStore.createWalletFor(settingsStore.settings);
}); });
const complete = (data: { account: string; wallet: UsedWallet }) => { const complete = (data: { account: string; wallet: UsedWallet }) => {
hasUser.value = true; hasUser.value = true;
settingsStore.setSettings({ const settings = {
account: data.account, account: data.account,
wallet: data.wallet wallet: data.wallet
}); };
settingsStore.setSettings(settings);
void walletStore.createWalletFor(settings);
}; };
</script> </script>
......
<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>
<script setup lang="ts"> <script setup lang="ts">
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import OnboardingButton from "@/components/onboarding/OnboardingWalletButton.vue"; 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 { useMetamaskStore } from "@/stores/metamask.store";
import { getWalletIcon, UsedWallet } from "@/stores/settings.store"; import { getWalletIcon, UsedWallet } from "@/stores/settings.store";
const hasMetamask = ref(false); const hasMetamask = ref(false);
const hasKeychain = ref(false); const hasKeychain = ref(false);
const hasPeakVault = ref(false); const hasPeakVault = ref(false);
let timeoutId: number;
const metamaskStore = useMetamaskStore(); 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(() => { onMounted(() => {
metamaskStore.connect().then(() => hasMetamask.value = true).catch(console.error); timeoutId = setTimeout(() => checkForWallets(), 1500) as unknown as number;
hasKeychain.value = "hive_keychain" in window; checkForWallets();
hasPeakVault.value = "peakvault" in window; });
onUnmounted(() => {
clearTimeout(timeoutId);
}); });
const emit = defineEmits(["walletSelect"]); const emit = defineEmits(["walletSelect"]);
......
...@@ -11,6 +11,7 @@ import { Combobox, ComboboxAnchor, ComboboxTrigger, ComboboxEmpty, ComboboxGroup ...@@ -11,6 +11,7 @@ import { Combobox, ComboboxAnchor, ComboboxTrigger, ComboboxEmpty, ComboboxGroup
import { Check, Search } from 'lucide-vue-next'; import { Check, Search } from 'lucide-vue-next';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import PublicKey from '@/components/hive/PublicKey.vue'; import PublicKey from '@/components/hive/PublicKey.vue';
import { getWax } from '@/stores/wax.store';
const emit = defineEmits(["setaccount", "close"]); const emit = defineEmits(["setaccount", "close"]);
...@@ -46,22 +47,13 @@ const applyPublicKeys = async () => { ...@@ -46,22 +47,13 @@ const applyPublicKeys = async () => {
metamaskPublicKeys.value = publicKeys; metamaskPublicKeys.value = publicKeys;
const response = await (await fetch("https://api.hive.blog", { const wax = await getWax();
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();
if (response.error) const response = await wax.api.account_by_key_api.get_key_references({
throw new Error(response.error.message); 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) { } catch (error) {
if (typeof error === "object" && error && "message" in error) if (typeof error === "object" && error && "message" in error)
errorMsg.value = error.message as string; errorMsg.value = error.message as string;
......
<script setup lang="ts"> <script setup lang="ts">
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { mdiMessageLockOutline } from '@mdi/js'; import { mdiMessageLockOutline } from '@mdi/js';
import { ref } from 'vue'; import { ref, computed } from 'vue';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch'; 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 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> </script>
<template> <template>
...@@ -25,13 +65,13 @@ const isEncrypt = ref(false); ...@@ -25,13 +65,13 @@ const isEncrypt = ref(false);
<Switch v-model="isEncrypt" /> <Switch v-model="isEncrypt" />
<span>Encrypt</span> <span>Encrypt</span>
</div> </div>
<Textarea placeholder="Input" class="my-4"/> <Textarea v-model="inputData" placeholder="Input" class="my-4"/>
<Input v-if="isEncrypt" placeholder="Receiver account or public key" class="mt-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"> <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> </div>
<Button>{{ isEncrypt ? "Encrypt" : "Decrypt" }}</Button> <Button :disabled="!hasWallet" @click="encryptOrDecrypt">{{ isEncrypt ? "Encrypt" : "Decrypt" }}</Button>
<Textarea placeholder="Output" copy-enabled class="my-4" disabled/> <Textarea v-model="outputData" placeholder="Output" copy-enabled class="my-4" disabled/>
</CardContent> </CardContent>
</Card> </Card>
</template> </template>
\ No newline at end of file
<script setup lang="ts"> <script setup lang="ts">
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { mdiFileSign } from '@mdi/js'; import { mdiFileSign } from '@mdi/js';
import { computed, ref } from 'vue';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button'; 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> </script>
<template> <template>
...@@ -15,11 +44,11 @@ import { Button } from '@/components/ui/button'; ...@@ -15,11 +44,11 @@ import { Button } from '@/components/ui/button';
<CardDescription class="mr-4">Use this module to sign the provided transaction</CardDescription> <CardDescription class="mr-4">Use this module to sign the provided transaction</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <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"> <div class="my-4 space-x-4">
<Button>Sign transaction</Button> <Button :disabled="!hasWallet" @click="sign">Sign transaction</Button>
</div> </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> </CardContent>
</Card> </Card>
</template> </template>
\ No newline at end of file
import { defineStore } from "pinia" import { defineStore } from "pinia"
import { connectMetamask, type MetamaskWallet } from "../utils/wallet/metamask" import { connectMetamask, type MetamaskWallet } from "../utils/wallet/metamask"
export const useMetamaskStore = defineStore('wallet', { export const useMetamaskStore = defineStore('metamask', {
state: () => ({ state: () => ({
metamask: undefined as undefined | MetamaskWallet, metamask: undefined as undefined | MetamaskWallet,
performingOperation: false performingOperation: false
......
...@@ -35,6 +35,7 @@ const settings = { ...@@ -35,6 +35,7 @@ const settings = {
wallet: undefined as UsedWallet | undefined, wallet: undefined as UsedWallet | undefined,
account: undefined as string | undefined, account: undefined as string | undefined,
}; };
export type Settings = Required<typeof settings>;
export const useSettingsStore = defineStore('settings', { export const useSettingsStore = defineStore('settings', {
state: () => ({ state: () => ({
......
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;
}
}
})
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>;
}
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;
}
}
import type { MetaMaskInpageProvider } from "@metamask/providers"; import type { MetaMaskInpageProvider } from "@metamask/providers";
import { defaultSnapOrigin, defaultSnapVersion, isLocalSnap } from "./snap"; import { defaultSnapOrigin, defaultSnapVersion, isLocalSnap } from "./snap";
import type { Wallet } from "../abstraction";
import type { TPublicKey, TRole, ITransactionBase } from "@hiveio/wax/vite";
export type MetamaskSnapData = { export type MetamaskSnapData = {
permissionName: string; permissionName: string;
...@@ -9,7 +11,7 @@ export type MetamaskSnapData = { ...@@ -9,7 +11,7 @@ export type MetamaskSnapData = {
}; };
export type MetamaskSnapsResponse = Record<string, MetamaskSnapData>; export type MetamaskSnapsResponse = Record<string, MetamaskSnapData>;
export class MetamaskWallet { export class MetamaskWallet implements Wallet {
/** /**
* Indicates either the snap is installed or not. * Indicates either the snap is installed or not.
* If you want to install or reinstall the snap, use {@link installSnap} * If you want to install or reinstall the snap, use {@link installSnap}
...@@ -33,6 +35,24 @@ export class MetamaskWallet { ...@@ -33,6 +35,24 @@ export class MetamaskWallet {
return this.provider.request(params ? { method, params } : { method }); 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. * Request the snap to be installed or reinstalled.
* You can check if snap is installed using {@link isInstalled} * You can check if snap is installed using {@link isInstalled}
......
...@@ -6,9 +6,9 @@ ...@@ -6,9 +6,9 @@
* don't. Instead, rename `.env.production.dist` to `.env.production` and set the production URL * 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. * 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. * Check if a snap ID is a local snap ID.
......
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;
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment