Skip to content

Commit 5e255be

Browse files
cpb80100xSulpiride
andauthored
ECDSA client examples (#115)
* feat: ECDSA client with single owner * chore: remove console.log * feat: update aa factory abi * fix: import types * feat: add examples as ecdsa docs So this fails when attempting to send with the error message: "eth_signTypedData_v4" does not exist / is not available. But the rest of it looks great in terms of usage examples! * fix: linting and import Not sure how it fails the sdk, since it works locally? * chore: add another signer type It's not clear this is helpful, but I'm quite stumped on how this keeps failing in CI * fix: use account instead of ecdsa wallet signer This fixes the missing method on the signer * fix: code review comments --------- Co-authored-by: Sulpiride <sobirovutkir@gmail.com>
1 parent fd85eb8 commit 5e255be

File tree

18 files changed

+919
-39
lines changed

18 files changed

+919
-39
lines changed

examples/bank-demo/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ with a Passkey, and stake some ETH with a fully embedded wallet.
55

66
## Running the demo locally
77

8+
From the packages/contracts directory, deploy the contracts to a local node:
9+
10+
```bash
11+
pnpm run deploy --file ../../examples/bank-demo/local-node.json
12+
```
13+
814
Run the following command from the root of the monorepo:
915

1016
```bash

examples/bank-demo/components/app/AddCryptoButton.vue

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,21 @@
99
<span v-if="!isLoading">Add Crypto account</span>
1010
<CommonSpinner v-else class="h-6"/>
1111
</ZkButton>
12+
<div>
13+
<NoPasskeyDialog v-if="showModal" />
14+
</div>
1215
</template>
1316

1417
<script setup lang="ts">
15-
import { createWalletClient, http, type Address, type Chain } from "viem";
16-
import { privateKeyToAccount } from "viem/accounts";
18+
import NoPasskeyDialog from "~/components/app/NoPasskeyDialog.vue";
19+
import type { Hex } from "viem";
1720
import { deployAccount } from "zksync-sso/client";
1821
import { registerNewPasskey } from "zksync-sso/client/passkey";
22+
import { getDeployerClient } from "../common/CryptoDeployer";
1923
2024
const { appMeta, userDisplay, userId, contracts, deployerKey } = useAppMeta();
2125
const isLoading = ref(false);
26+
const showModal = ref(false);
2227
2328
// Convert Uin8Array to string
2429
const u8ToString = (input: Uint8Array): string => {
@@ -32,40 +37,45 @@ const onClickAddCrypto = async () => {
3237
isLoading.value = false;
3338
};
3439
35-
const createCryptoAccount = async () => {
36-
let credentialPublicKey: Uint8Array;
37-
40+
const getPublicPasskey = async () => {
3841
// Create new Passkey
39-
if (!appMeta.value || !appMeta.value.credentialPublicKey) {
42+
if (!appMeta.value || !appMeta.value.credentialPublicKey || !appMeta.value.credentialId) {
4043
try {
41-
const { credentialPublicKey: newCredentialPublicKey } = await registerNewPasskey({
44+
const newPasskey = await registerNewPasskey({
4245
userDisplayName: userDisplay, // Display name of the user
4346
userName: userId, // User's unique ID
4447
});
4548
appMeta.value = {
4649
...appMeta.value,
47-
credentialPublicKey: u8ToString(newCredentialPublicKey),
50+
credentialPublicKey: u8ToString(newPasskey.credentialPublicKey),
51+
credentialId: newPasskey.credentialId,
4852
};
49-
credentialPublicKey = newCredentialPublicKey;
53+
return newPasskey;
5054
} catch (error) {
5155
console.error("Passkey registration failed:", error);
52-
return;
56+
return false;
5357
}
5458
} else {
55-
credentialPublicKey = new Uint8Array(JSON.parse(appMeta.value.credentialPublicKey));
59+
return {
60+
credentialPublicKey: new Uint8Array(JSON.parse(appMeta.value.credentialPublicKey)),
61+
credentialId: appMeta.value.credentialId,
62+
};
63+
}
64+
};
65+
66+
const createAccountWithPasskey = async () => {
67+
const publicPassKey = await getPublicPasskey();
68+
if (!publicPassKey) {
69+
return false;
5670
}
5771
5872
// Configure deployer account to pay for Account creation
59-
const config = useRuntimeConfig();
60-
const deployerClient = createWalletClient({
61-
account: privateKeyToAccount(deployerKey as Address),
62-
chain: config.public.network as Chain,
63-
transport: http()
64-
});
73+
const deployerClient = await getDeployerClient(deployerKey as Hex);
6574
6675
try {
6776
const { address, transactionReceipt } = await deployAccount(deployerClient, {
68-
credentialPublicKey,
77+
credentialPublicKey: publicPassKey.credentialPublicKey,
78+
credentialId: publicPassKey.credentialId,
6979
contracts,
7080
});
7181
@@ -75,11 +85,19 @@ const createCryptoAccount = async () => {
7585
};
7686
console.log(`Successfully created account: ${address}`);
7787
console.log(`Transaction receipt: ${transactionReceipt.transactionHash}`);
88+
return true;
7889
} catch (error) {
7990
console.error(error);
80-
return;
91+
return false;
8192
}
93+
};
8294
83-
navigateTo("/crypto-account");
95+
const createCryptoAccount = async () => {
96+
if (!await createAccountWithPasskey()) {
97+
console.log("showing non-passkey modal!");
98+
showModal.value = true;
99+
} else {
100+
navigateTo("/crypto-account");
101+
};
84102
};
85103
</script>
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
<!--
2+
Copyright 2025 cbe
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
https://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
-->
16+
17+
<template>
18+
<div>
19+
<div class="fixed inset-0 z-50 overflow-y-auto">
20+
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
21+
<div class="fixed inset-0 transition-opacity" aria-hidden="true">
22+
<div class="absolute inset-0 bg-gray-500 opacity-75" />
23+
</div>
24+
25+
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
26+
27+
<div
28+
class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
29+
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
30+
<div class="sm:flex sm:items-start">
31+
<div
32+
class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
33+
<svg
34+
class="h-6 w-6 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
35+
stroke="currentColor" aria-hidden="true">
36+
<path
37+
stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
38+
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
39+
</svg>
40+
</div>
41+
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
42+
<h3 class="text-lg leading-6 font-medium text-gray-900">
43+
Create Crypto Account
44+
</h3>
45+
<p>
46+
Choose a password to protect your account
47+
</p>
48+
<div class="mt-2 relative">
49+
<input
50+
v-model="password" :type="showPassword ? 'text' : 'password'" placeholder="Password"
51+
class="w-full border border-gray-300 px-3 py-2 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
52+
<button
53+
type="button" class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-500"
54+
@click="showPassword = !showPassword">
55+
<svg
56+
v-if="showPassword" class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
57+
xmlns="http://www.w3.org/2000/svg">
58+
<path
59+
stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
60+
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
61+
<path
62+
stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
63+
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
64+
</svg>
65+
<svg
66+
v-else class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
67+
xmlns="http://www.w3.org/2000/svg">
68+
<path
69+
stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
70+
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
71+
</svg>
72+
</button>
73+
</div>
74+
<div class="mt-2 relative">
75+
<input
76+
v-model="confirmPassword" :type="showConfirmPassword ? 'text' : 'password'"
77+
placeholder="Confirm Password"
78+
class="w-full border border-gray-300 px-3 py-2 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
79+
<button
80+
type="button" class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-500"
81+
@click="showConfirmPassword = !showConfirmPassword">
82+
<svg
83+
v-if="showConfirmPassword" class="h-5 w-5" fill="none" stroke="currentColor"
84+
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
85+
<path
86+
stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
87+
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
88+
<path
89+
stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
90+
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
91+
</svg>
92+
<svg
93+
v-else class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
94+
xmlns="http://www.w3.org/2000/svg">
95+
<path
96+
stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
97+
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
98+
</svg>
99+
</button>
100+
</div>
101+
<div v-if="showMatchError" class="text-red-500 text-sm mt-2">Passwords do not match!</div>
102+
</div>
103+
</div>
104+
</div>
105+
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
106+
<button
107+
type="button"
108+
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm"
109+
@click="checkPassword">
110+
Confirm
111+
</button>
112+
<button
113+
type="button"
114+
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
115+
@click="$emit('cancel')">
116+
Cancel
117+
</button>
118+
</div>
119+
</div>
120+
</div>
121+
</div>
122+
</div>
123+
</template>
124+
125+
<script setup lang="ts">
126+
import { toHex, type HDAccount, type Hex } from "viem";
127+
import { english, generateMnemonic, mnemonicToAccount } from "viem/accounts";
128+
import { deployAccount, fetchAccount } from "zksync-sso/client/ecdsa";
129+
import { getDeployerClient } from "../common/CryptoDeployer";
130+
131+
const { appMeta, contracts, deployerKey } = useAppMeta();
132+
133+
const password = ref("");
134+
const showPassword = ref(false);
135+
const confirmPassword = ref("");
136+
const showConfirmPassword = ref(false);
137+
const showMatchError = ref(false);
138+
const account = ref<HDAccount>();
139+
const mnemonic = ref<string>();
140+
141+
// create account with EOA owner
142+
onMounted(() => {
143+
mnemonic.value = generateMnemonic(english);
144+
account.value = mnemonicToAccount(mnemonic.value);
145+
});
146+
147+
async function encryptMessage(cryptoSecretKey: string, password: string, iv: Uint8Array, salt: Uint8Array) {
148+
// hashed pin/password
149+
const keyMaterial = await window.crypto.subtle.importKey(
150+
"raw",
151+
new TextEncoder().encode(password),
152+
"PBKDF2",
153+
false,
154+
["deriveBits", "deriveKey"],
155+
);
156+
const keyEncryptionKey = await window.crypto.subtle.deriveKey(
157+
{
158+
name: "PBKDF2",
159+
salt,
160+
iterations: 100000,
161+
hash: "SHA-256",
162+
},
163+
keyMaterial,
164+
{ name: "AES-GCM", length: 256 },
165+
true,
166+
["encrypt", "decrypt"],
167+
);
168+
const encryptedCryptoSecret = await window.crypto.subtle.encrypt(
169+
{
170+
name: "AES-GCM",
171+
iv: iv
172+
},
173+
keyEncryptionKey,
174+
new TextEncoder().encode(cryptoSecretKey),
175+
);
176+
177+
return { encryptedCryptoSecret, keyEncryptionKey };
178+
}
179+
180+
async function decryptMessage(encryptedKey: ArrayBuffer, key: CryptoKey, iv: Uint8Array) {
181+
const decryptedBytes = await window.crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, encryptedKey);
182+
return new TextDecoder().decode(decryptedBytes);
183+
}
184+
185+
async function checkPassword() {
186+
if (!mnemonic.value) {
187+
console.warn("Private key not set");
188+
return;
189+
}
190+
if (!account.value) {
191+
console.warn("account not ready");
192+
return;
193+
}
194+
if (password.value !== confirmPassword.value) {
195+
console.warn("no password match");
196+
showMatchError.value = true;
197+
return;
198+
}
199+
// TODO: get iv and salt from server!
200+
const iv = new Uint8Array(12);
201+
const salt = new Uint8Array(16);
202+
if (!password.value) {
203+
console.warn("Pin not set");
204+
return;
205+
}
206+
const encrypted = await encryptMessage(mnemonic.value, password.value, iv, salt);
207+
if (!encrypted) {
208+
console.error("Encryption failed", mnemonic.value);
209+
return;
210+
}
211+
const testDecrypted = await decryptMessage(
212+
encrypted.encryptedCryptoSecret, encrypted.keyEncryptionKey, iv);
213+
if (mnemonic.value != testDecrypted) {
214+
console.error("Decryption failed", mnemonic.value, testDecrypted);
215+
return;
216+
}
217+
console.log("Decryption success", encrypted);
218+
219+
const address = await deployAddress(account.value);
220+
const pk = account.value.getHdKey().privateKey;
221+
if (!pk) {
222+
console.warn("account has no private key!");
223+
return;
224+
}
225+
226+
appMeta.value = {
227+
...appMeta.value,
228+
cryptoAccountAddress: address,
229+
privateKey: toHex(pk),
230+
};
231+
232+
downloadArrayBuffer("encrypted-private-key.txt", encrypted.encryptedCryptoSecret);
233+
navigateTo("/crypto-account");
234+
confirm();
235+
}
236+
237+
async function deployAddress(accountAddress: HDAccount) {
238+
const deployerClient = await getDeployerClient(deployerKey as Hex);
239+
try {
240+
const fetchedAccount = await fetchAccount(deployerClient, {
241+
contracts,
242+
prefix: "bank-demo",
243+
owner: accountAddress.address,
244+
});
245+
return fetchedAccount.address;
246+
} catch (err) {
247+
console.info("account does not exist, deploy!", err);
248+
const deployedAccount = await deployAccount(deployerClient, {
249+
contracts,
250+
prefix: "bank-demo",
251+
owner: accountAddress.address,
252+
});
253+
return deployedAccount.address;
254+
}
255+
}
256+
257+
function downloadArrayBuffer(filename: string, buffer: ArrayBuffer, mimeType: string = "application/octet-stream") {
258+
const blob = new Blob([buffer], { type: mimeType });
259+
const url = window.URL.createObjectURL(blob);
260+
261+
const a = document.createElement("a");
262+
a.href = url;
263+
a.download = filename;
264+
265+
a.style.display = "none";
266+
document.body.appendChild(a);
267+
268+
a.click();
269+
270+
window.URL.revokeObjectURL(url);
271+
document.body.removeChild(a);
272+
}
273+
274+
defineEmits(["confirm", "cancel"]);
275+
</script>

0 commit comments

Comments
 (0)