@1001-digital/components 1.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/base/components/Globals.vue +4 -2
- package/src/base/components/Toasts.vue +14 -1
- package/src/base/composables/toast.ts +1 -0
- package/src/evm/components/EvmConnect.vue +36 -3
- package/src/evm/components/EvmInAppWalletSetup.vue +258 -0
- package/src/evm/components/EvmProfile.vue +5 -1
- package/src/evm/components/EvmSeedPhraseInput.vue +196 -0
- package/src/evm/components/EvmSwitchNetwork.vue +5 -3
- package/src/evm/components/EvmTransactionFlow.vue +1 -0
- package/src/evm/connectors/inAppWallet.ts +220 -0
- package/src/evm/index.ts +5 -0
package/package.json
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
:duration="duration"
|
|
4
4
|
:swipe-direction="swipeDirection"
|
|
5
5
|
>
|
|
6
|
+
<slot />
|
|
7
|
+
|
|
6
8
|
<ToastRoot
|
|
7
9
|
v-for="toast in toasts"
|
|
8
10
|
:key="toast.id"
|
|
@@ -40,7 +42,7 @@
|
|
|
40
42
|
:model-value="toast.progress === true ? null : toast.progress"
|
|
41
43
|
/>
|
|
42
44
|
<ToastAction
|
|
43
|
-
v-if="toast.action"
|
|
45
|
+
v-if="toast.action && !toast.action.persistent"
|
|
44
46
|
:alt-text="toast.action.label"
|
|
45
47
|
:as="Actions"
|
|
46
48
|
class="left"
|
|
@@ -52,6 +54,17 @@
|
|
|
52
54
|
{{ toast.action.label }}
|
|
53
55
|
</Button>
|
|
54
56
|
</ToastAction>
|
|
57
|
+
<Actions
|
|
58
|
+
v-if="toast.action?.persistent"
|
|
59
|
+
class="left"
|
|
60
|
+
>
|
|
61
|
+
<Button
|
|
62
|
+
class="small tertiary"
|
|
63
|
+
@click="toast.action!.onClick()"
|
|
64
|
+
>
|
|
65
|
+
{{ toast.action.label }}
|
|
66
|
+
</Button>
|
|
67
|
+
</Actions>
|
|
55
68
|
</section>
|
|
56
69
|
</ToastRoot>
|
|
57
70
|
|
|
@@ -20,8 +20,13 @@
|
|
|
20
20
|
v-model:open="chooseModalOpen"
|
|
21
21
|
@closed="onModalClosed"
|
|
22
22
|
>
|
|
23
|
+
<EvmInAppWalletSetup
|
|
24
|
+
v-if="showInAppSetup"
|
|
25
|
+
@connected="onInAppConnected"
|
|
26
|
+
@back="showInAppSetup = false"
|
|
27
|
+
/>
|
|
23
28
|
<Alert
|
|
24
|
-
v-if="errorMessage"
|
|
29
|
+
v-else-if="errorMessage"
|
|
25
30
|
type="error"
|
|
26
31
|
>
|
|
27
32
|
{{ errorMessage }}
|
|
@@ -77,6 +82,17 @@
|
|
|
77
82
|
/>
|
|
78
83
|
<span>Safe</span>
|
|
79
84
|
</Button>
|
|
85
|
+
<Button
|
|
86
|
+
v-if="inAppConnector"
|
|
87
|
+
@click="showInAppSetup = true"
|
|
88
|
+
class="choose-connector"
|
|
89
|
+
>
|
|
90
|
+
<img
|
|
91
|
+
:src="`${base}icons/wallets/in-app.svg`"
|
|
92
|
+
alt="Seed Phrase"
|
|
93
|
+
/>
|
|
94
|
+
<span>In App</span>
|
|
95
|
+
</Button>
|
|
80
96
|
<Button
|
|
81
97
|
to="https://ethereum.org/wallets/"
|
|
82
98
|
target="_blank"
|
|
@@ -106,6 +122,7 @@ import Loading from '../../base/components/Loading.vue'
|
|
|
106
122
|
import EvmAccount from './EvmAccount.vue'
|
|
107
123
|
import EvmMetaMaskQR from './EvmMetaMaskQR.vue'
|
|
108
124
|
import EvmWalletConnectWallets from './EvmWalletConnectWallets.vue'
|
|
125
|
+
import EvmInAppWalletSetup from './EvmInAppWalletSetup.vue'
|
|
109
126
|
import { useBaseURL } from '../composables/base'
|
|
110
127
|
|
|
111
128
|
const ICONS: Record<string, string> = {
|
|
@@ -115,6 +132,7 @@ const ICONS: Record<string, string> = {
|
|
|
115
132
|
'Rabby Wallet': 'rabby.svg',
|
|
116
133
|
Rainbow: 'rainbow.svg',
|
|
117
134
|
Safe: 'safe.png',
|
|
135
|
+
'In App': 'in-app.svg',
|
|
118
136
|
WalletConnect: 'walletconnect.svg',
|
|
119
137
|
}
|
|
120
138
|
|
|
@@ -137,6 +155,11 @@ const connectors = useConnectors()
|
|
|
137
155
|
const { mutateAsync: connectAsync } = useConnect()
|
|
138
156
|
const { address, isConnected } = useConnection()
|
|
139
157
|
|
|
158
|
+
const inAppConnector = computed(() =>
|
|
159
|
+
connectors.value.find((c) => c.type === 'inAppWallet'),
|
|
160
|
+
)
|
|
161
|
+
const showInAppSetup = ref(false)
|
|
162
|
+
|
|
140
163
|
const showConnect = computed(() => !isConnected.value)
|
|
141
164
|
const shownConnectors = computed(() => {
|
|
142
165
|
const unique = Array.from(
|
|
@@ -147,8 +170,11 @@ const shownConnectors = computed(() => {
|
|
|
147
170
|
|
|
148
171
|
const filtered =
|
|
149
172
|
unique.length > 1
|
|
150
|
-
? unique.filter(
|
|
151
|
-
|
|
173
|
+
? unique.filter(
|
|
174
|
+
(c) =>
|
|
175
|
+
c.id !== 'injected' && c.id !== 'safe' && c.type !== 'inAppWallet',
|
|
176
|
+
)
|
|
177
|
+
: unique.filter((c) => c.type !== 'inAppWallet')
|
|
152
178
|
|
|
153
179
|
return filtered.sort((a, b) => {
|
|
154
180
|
const priorityA = PRIORITY[a.name] ?? 5
|
|
@@ -247,6 +273,11 @@ const login = async (connector: Connector) => {
|
|
|
247
273
|
}
|
|
248
274
|
}
|
|
249
275
|
|
|
276
|
+
const onInAppConnected = () => {
|
|
277
|
+
chooseModalOpen.value = false
|
|
278
|
+
showInAppSetup.value = false
|
|
279
|
+
}
|
|
280
|
+
|
|
250
281
|
const onModalClosed = () => {
|
|
251
282
|
errorMessage.value = ''
|
|
252
283
|
isConnecting.value = false
|
|
@@ -254,6 +285,7 @@ const onModalClosed = () => {
|
|
|
254
285
|
metaMaskUri.value = ''
|
|
255
286
|
walletConnectUri.value = ''
|
|
256
287
|
safeDeepLink.value = false
|
|
288
|
+
showInAppSetup.value = false
|
|
257
289
|
}
|
|
258
290
|
|
|
259
291
|
const check = () =>
|
|
@@ -276,6 +308,7 @@ onMounted(() => check())
|
|
|
276
308
|
width: 100%;
|
|
277
309
|
inline-size: auto;
|
|
278
310
|
justify-content: flex-start;
|
|
311
|
+
padding-inline-start: var(--ui-padding-inline);
|
|
279
312
|
|
|
280
313
|
img,
|
|
281
314
|
.default-wallet-icon {
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="in-app-wallet-setup">
|
|
3
|
+
<!-- Step: Choose -->
|
|
4
|
+
<div
|
|
5
|
+
v-if="step === 'choose'"
|
|
6
|
+
class="setup-step"
|
|
7
|
+
>
|
|
8
|
+
<div class="setup-options">
|
|
9
|
+
<Button @click="startGenerate">
|
|
10
|
+
<Icon type="plus" />
|
|
11
|
+
<span>Create New Wallet</span>
|
|
12
|
+
</Button>
|
|
13
|
+
<Button @click="step = 'restore'">
|
|
14
|
+
<Icon type="key" />
|
|
15
|
+
<span>I Have a Seed Phrase</span>
|
|
16
|
+
</Button>
|
|
17
|
+
</div>
|
|
18
|
+
<Button
|
|
19
|
+
class="link muted small"
|
|
20
|
+
@click="$emit('back')"
|
|
21
|
+
>
|
|
22
|
+
<Icon type="arrow-left" />
|
|
23
|
+
<span>Back</span>
|
|
24
|
+
</Button>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<!-- Step: Generate -->
|
|
28
|
+
<div
|
|
29
|
+
v-else-if="step === 'generate'"
|
|
30
|
+
class="setup-step"
|
|
31
|
+
>
|
|
32
|
+
<Alert type="info">
|
|
33
|
+
Write down these 12 words in order. You will need them to restore your wallet. They will not be shown again.
|
|
34
|
+
</Alert>
|
|
35
|
+
|
|
36
|
+
<div class="generated-words">
|
|
37
|
+
<div
|
|
38
|
+
v-for="(word, i) in generatedWords"
|
|
39
|
+
:key="i"
|
|
40
|
+
class="generated-word"
|
|
41
|
+
>
|
|
42
|
+
<span class="word-number">{{ i + 1 }}</span>
|
|
43
|
+
<span class="word-text">{{ word }}</span>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<label class="confirm-backup">
|
|
48
|
+
<input
|
|
49
|
+
v-model="backupConfirmed"
|
|
50
|
+
type="checkbox"
|
|
51
|
+
/>
|
|
52
|
+
<span>I've saved my seed phrase</span>
|
|
53
|
+
</label>
|
|
54
|
+
|
|
55
|
+
<Button
|
|
56
|
+
:disabled="!backupConfirmed"
|
|
57
|
+
@click="confirmGenerated"
|
|
58
|
+
>
|
|
59
|
+
Continue
|
|
60
|
+
</Button>
|
|
61
|
+
<Button
|
|
62
|
+
class="link muted small"
|
|
63
|
+
@click="step = 'choose'"
|
|
64
|
+
>
|
|
65
|
+
<Icon type="arrow-left" />
|
|
66
|
+
<span>Back</span>
|
|
67
|
+
</Button>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<!-- Step: Restore -->
|
|
71
|
+
<div
|
|
72
|
+
v-else-if="step === 'restore'"
|
|
73
|
+
class="setup-step"
|
|
74
|
+
>
|
|
75
|
+
<p class="muted">Enter your 12-word seed phrase to restore your wallet.</p>
|
|
76
|
+
|
|
77
|
+
<EvmSeedPhraseInput
|
|
78
|
+
v-model="restorePhrase"
|
|
79
|
+
@valid="restoreValid = $event"
|
|
80
|
+
@submit="restoreWallet"
|
|
81
|
+
/>
|
|
82
|
+
|
|
83
|
+
<Button
|
|
84
|
+
:disabled="!restoreValid"
|
|
85
|
+
@click="restoreWallet"
|
|
86
|
+
>
|
|
87
|
+
Restore Wallet
|
|
88
|
+
</Button>
|
|
89
|
+
<Button
|
|
90
|
+
class="link muted small"
|
|
91
|
+
@click="step = 'choose'"
|
|
92
|
+
>
|
|
93
|
+
<Icon type="arrow-left" />
|
|
94
|
+
<span>Back</span>
|
|
95
|
+
</Button>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<!-- Step: Connecting -->
|
|
99
|
+
<div
|
|
100
|
+
v-else-if="step === 'connecting'"
|
|
101
|
+
class="setup-step"
|
|
102
|
+
>
|
|
103
|
+
<Loading
|
|
104
|
+
txt="Connecting wallet..."
|
|
105
|
+
spinner
|
|
106
|
+
stacked
|
|
107
|
+
/>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
</template>
|
|
111
|
+
|
|
112
|
+
<script setup lang="ts">
|
|
113
|
+
import { ref, computed } from 'vue'
|
|
114
|
+
import { useConnect, useConnectors } from '@wagmi/vue'
|
|
115
|
+
import Button from '../../base/components/Button.vue'
|
|
116
|
+
import Icon from '../../base/components/Icon.vue'
|
|
117
|
+
import Alert from '../../base/components/Alert.vue'
|
|
118
|
+
import Loading from '../../base/components/Loading.vue'
|
|
119
|
+
import EvmSeedPhraseInput from './EvmSeedPhraseInput.vue'
|
|
120
|
+
import { prepareInAppWallet } from '../connectors/inAppWallet'
|
|
121
|
+
|
|
122
|
+
const emit = defineEmits<{
|
|
123
|
+
connected: []
|
|
124
|
+
back: []
|
|
125
|
+
}>()
|
|
126
|
+
|
|
127
|
+
const connectors = useConnectors()
|
|
128
|
+
const { mutateAsync: connectAsync } = useConnect()
|
|
129
|
+
const inAppConnector = computed(() =>
|
|
130
|
+
connectors.value.find((c) => c.type === 'inAppWallet'),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
type Step = 'choose' | 'generate' | 'restore' | 'connecting'
|
|
134
|
+
const step = ref<Step>('choose')
|
|
135
|
+
|
|
136
|
+
// Generate state
|
|
137
|
+
const generatedMnemonic = ref('')
|
|
138
|
+
const generatedWords = ref<string[]>([])
|
|
139
|
+
const backupConfirmed = ref(false)
|
|
140
|
+
|
|
141
|
+
// Restore state
|
|
142
|
+
const restorePhrase = ref('')
|
|
143
|
+
const restoreValid = ref(false)
|
|
144
|
+
|
|
145
|
+
async function startGenerate() {
|
|
146
|
+
const { generateMnemonic, english } = await import('viem/accounts')
|
|
147
|
+
generatedMnemonic.value = generateMnemonic(english)
|
|
148
|
+
generatedWords.value = generatedMnemonic.value.split(' ')
|
|
149
|
+
backupConfirmed.value = false
|
|
150
|
+
step.value = 'generate'
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function connectWithMnemonic(mnemonic: string) {
|
|
154
|
+
await prepareInAppWallet(mnemonic)
|
|
155
|
+
await connectAsync({ connector: inAppConnector.value! })
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function confirmGenerated() {
|
|
159
|
+
step.value = 'connecting'
|
|
160
|
+
try {
|
|
161
|
+
await connectWithMnemonic(generatedMnemonic.value)
|
|
162
|
+
emit('connected')
|
|
163
|
+
} catch (e) {
|
|
164
|
+
console.error('Failed to connect in-app wallet:', e)
|
|
165
|
+
step.value = 'generate'
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function restoreWallet() {
|
|
170
|
+
if (!restoreValid.value) return
|
|
171
|
+
step.value = 'connecting'
|
|
172
|
+
try {
|
|
173
|
+
await connectWithMnemonic(restorePhrase.value)
|
|
174
|
+
emit('connected')
|
|
175
|
+
} catch (e) {
|
|
176
|
+
console.error('Failed to restore in-app wallet:', e)
|
|
177
|
+
step.value = 'restore'
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
</script>
|
|
181
|
+
|
|
182
|
+
<style scoped>
|
|
183
|
+
.in-app-wallet-setup {
|
|
184
|
+
display: grid;
|
|
185
|
+
gap: var(--spacer);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.setup-step {
|
|
189
|
+
display: grid;
|
|
190
|
+
gap: var(--spacer);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.setup-options {
|
|
194
|
+
display: grid;
|
|
195
|
+
gap: var(--spacer);
|
|
196
|
+
|
|
197
|
+
:deep(button),
|
|
198
|
+
:deep(.button) {
|
|
199
|
+
width: 100%;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.generated-words {
|
|
204
|
+
display: grid;
|
|
205
|
+
grid-template-columns: repeat(3, 1fr);
|
|
206
|
+
gap: var(--spacer-sm);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
@media (min-width: 600px) {
|
|
210
|
+
.generated-words {
|
|
211
|
+
grid-template-columns: repeat(4, 1fr);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.generated-word {
|
|
216
|
+
display: flex;
|
|
217
|
+
align-items: center;
|
|
218
|
+
gap: var(--spacer-xs);
|
|
219
|
+
border: var(--border);
|
|
220
|
+
border-radius: var(--border-radius);
|
|
221
|
+
padding: var(--spacer-xs) var(--spacer-sm);
|
|
222
|
+
|
|
223
|
+
.word-number {
|
|
224
|
+
font-size: var(--font-xs);
|
|
225
|
+
color: var(--muted);
|
|
226
|
+
min-width: 1.5em;
|
|
227
|
+
text-align: right;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
.word-text {
|
|
231
|
+
font-size: var(--font-sm);
|
|
232
|
+
font-family: var(--font-mono, monospace);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.confirm-backup {
|
|
237
|
+
display: flex;
|
|
238
|
+
align-items: center;
|
|
239
|
+
gap: var(--spacer-sm);
|
|
240
|
+
cursor: pointer;
|
|
241
|
+
font-size: var(--font-sm);
|
|
242
|
+
user-select: none;
|
|
243
|
+
|
|
244
|
+
input[type='checkbox'] {
|
|
245
|
+
width: auto;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
.link.muted {
|
|
250
|
+
justify-self: center;
|
|
251
|
+
font-size: var(--font-xs);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
p.muted {
|
|
255
|
+
font-size: var(--font-sm);
|
|
256
|
+
color: var(--muted);
|
|
257
|
+
}
|
|
258
|
+
</style>
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
v-model:open="dialogOpen"
|
|
18
18
|
class="evm-profile"
|
|
19
19
|
title="Account"
|
|
20
|
+
compat
|
|
20
21
|
>
|
|
21
22
|
<div class="profile-header">
|
|
22
23
|
<div
|
|
@@ -26,7 +27,10 @@
|
|
|
26
27
|
"
|
|
27
28
|
/>
|
|
28
29
|
<div class="avatar-wrapper">
|
|
29
|
-
<EvmAvatar
|
|
30
|
+
<EvmAvatar
|
|
31
|
+
:address="address"
|
|
32
|
+
large
|
|
33
|
+
/>
|
|
30
34
|
</div>
|
|
31
35
|
</div>
|
|
32
36
|
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="seed-phrase-input">
|
|
3
|
+
<div class="seed-phrase-grid">
|
|
4
|
+
<div
|
|
5
|
+
v-for="(_, i) in 12"
|
|
6
|
+
:key="i"
|
|
7
|
+
class="seed-word"
|
|
8
|
+
:class="{ invalid: words[i] && !isValidWord(words[i]) }"
|
|
9
|
+
>
|
|
10
|
+
<label :for="`seed-word-${i}`">{{ i + 1 }}</label>
|
|
11
|
+
<input
|
|
12
|
+
:id="`seed-word-${i}`"
|
|
13
|
+
:ref="(el) => { if (el) inputRefs[i] = el as HTMLInputElement }"
|
|
14
|
+
v-model="words[i]"
|
|
15
|
+
type="text"
|
|
16
|
+
autocomplete="off"
|
|
17
|
+
autocapitalize="none"
|
|
18
|
+
spellcheck="false"
|
|
19
|
+
:disabled="disabled"
|
|
20
|
+
@keydown="onKeydown($event, i)"
|
|
21
|
+
@paste="onPaste($event, i)"
|
|
22
|
+
@input="onInput(i)"
|
|
23
|
+
/>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
</template>
|
|
28
|
+
|
|
29
|
+
<script setup lang="ts">
|
|
30
|
+
import { ref, watch, computed, onMounted } from 'vue'
|
|
31
|
+
import { english } from 'viem/accounts'
|
|
32
|
+
|
|
33
|
+
const props = defineProps<{
|
|
34
|
+
modelValue?: string
|
|
35
|
+
disabled?: boolean
|
|
36
|
+
}>()
|
|
37
|
+
|
|
38
|
+
const emit = defineEmits<{
|
|
39
|
+
'update:modelValue': [value: string]
|
|
40
|
+
valid: [isValid: boolean]
|
|
41
|
+
submit: []
|
|
42
|
+
}>()
|
|
43
|
+
|
|
44
|
+
const wordSet = new Set(english)
|
|
45
|
+
|
|
46
|
+
const words = ref<string[]>(Array.from({ length: 12 }, () => ''))
|
|
47
|
+
const inputRefs = ref<HTMLInputElement[]>([])
|
|
48
|
+
|
|
49
|
+
function isValidWord(word: string): boolean {
|
|
50
|
+
return wordSet.has(word.trim().toLowerCase())
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const isValid = computed(() =>
|
|
54
|
+
words.value.every((w) => w.trim() !== '' && isValidWord(w)),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
const phrase = computed(() =>
|
|
58
|
+
words.value
|
|
59
|
+
.map((w) => w.trim().toLowerCase())
|
|
60
|
+
.join(' '),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
watch(
|
|
64
|
+
() => props.modelValue,
|
|
65
|
+
(val) => {
|
|
66
|
+
if (!val) return
|
|
67
|
+
const incoming = val.trim().split(/\s+/)
|
|
68
|
+
if (incoming.length === 12) {
|
|
69
|
+
for (let i = 0; i < 12; i++) {
|
|
70
|
+
words.value[i] = incoming[i]
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
{ immediate: true },
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
watch(phrase, (val) => {
|
|
78
|
+
emit('update:modelValue', val)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
watch(isValid, (val) => {
|
|
82
|
+
emit('valid', val)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
function focusInput(index: number) {
|
|
86
|
+
const el = inputRefs.value[index]
|
|
87
|
+
if (el) {
|
|
88
|
+
el.focus()
|
|
89
|
+
el.select()
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function onKeydown(event: KeyboardEvent, index: number) {
|
|
94
|
+
if (event.key === ' ' || (event.key === 'Enter' && index < 11)) {
|
|
95
|
+
event.preventDefault()
|
|
96
|
+
focusInput(index + 1)
|
|
97
|
+
} else if (event.key === 'Enter' && index === 11 && isValid.value) {
|
|
98
|
+
event.preventDefault()
|
|
99
|
+
emit('submit')
|
|
100
|
+
} else if (
|
|
101
|
+
event.key === 'Backspace' &&
|
|
102
|
+
words.value[index] === '' &&
|
|
103
|
+
index > 0
|
|
104
|
+
) {
|
|
105
|
+
event.preventDefault()
|
|
106
|
+
focusInput(index - 1)
|
|
107
|
+
} else if (event.key === 'Tab' && !event.shiftKey && index === 11) {
|
|
108
|
+
// Allow natural tab out
|
|
109
|
+
} else if (event.key === 'Tab' && event.shiftKey && index === 0) {
|
|
110
|
+
// Allow natural tab out
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function onPaste(event: ClipboardEvent, index: number) {
|
|
115
|
+
const text = event.clipboardData?.getData('text')
|
|
116
|
+
if (!text) return
|
|
117
|
+
|
|
118
|
+
const pasted = text.trim().split(/\s+/)
|
|
119
|
+
if (pasted.length > 1) {
|
|
120
|
+
event.preventDefault()
|
|
121
|
+
for (let i = 0; i < pasted.length && index + i < 12; i++) {
|
|
122
|
+
words.value[index + i] = pasted[i].toLowerCase()
|
|
123
|
+
}
|
|
124
|
+
const nextIndex = Math.min(index + pasted.length, 11)
|
|
125
|
+
focusInput(nextIndex)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function onInput(index: number) {
|
|
130
|
+
// Auto-advance if the word contains a space (mobile autocomplete)
|
|
131
|
+
const val = words.value[index]
|
|
132
|
+
if (val.includes(' ')) {
|
|
133
|
+
const parts = val.trim().split(/\s+/)
|
|
134
|
+
words.value[index] = parts[0]
|
|
135
|
+
if (parts.length > 1 && index < 11) {
|
|
136
|
+
for (let i = 1; i < parts.length && index + i < 12; i++) {
|
|
137
|
+
words.value[index + i] = parts[i]
|
|
138
|
+
}
|
|
139
|
+
focusInput(Math.min(index + parts.length, 11))
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
onMounted(() => {
|
|
145
|
+
if (!props.disabled) {
|
|
146
|
+
focusInput(0)
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
</script>
|
|
150
|
+
|
|
151
|
+
<style scoped>
|
|
152
|
+
.seed-phrase-grid {
|
|
153
|
+
display: grid;
|
|
154
|
+
grid-template-columns: repeat(3, 1fr);
|
|
155
|
+
gap: var(--spacer-sm);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
@media (min-width: 600px) {
|
|
159
|
+
.seed-phrase-grid {
|
|
160
|
+
grid-template-columns: repeat(4, 1fr);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.seed-word {
|
|
165
|
+
display: flex;
|
|
166
|
+
align-items: center;
|
|
167
|
+
gap: var(--spacer-xs);
|
|
168
|
+
border: var(--border);
|
|
169
|
+
border-radius: var(--border-radius);
|
|
170
|
+
padding: var(--spacer-xs) var(--spacer-sm);
|
|
171
|
+
transition: border-color var(--speed);
|
|
172
|
+
|
|
173
|
+
&:focus-within {
|
|
174
|
+
border-color: var(--accent);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
&.invalid {
|
|
178
|
+
border-color: var(--error);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
label {
|
|
182
|
+
font-size: var(--font-xs);
|
|
183
|
+
color: var(--muted);
|
|
184
|
+
min-width: 1.5em;
|
|
185
|
+
text-align: right;
|
|
186
|
+
user-select: none;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
input {
|
|
190
|
+
all: unset;
|
|
191
|
+
width: 100%;
|
|
192
|
+
font-size: var(--font-sm);
|
|
193
|
+
font-family: var(--font-mono, monospace);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
</style>
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
title="Switch Network"
|
|
13
13
|
v-model:open="dialogOpen"
|
|
14
14
|
@closed="onClosed"
|
|
15
|
+
compat
|
|
15
16
|
>
|
|
16
17
|
<Alert
|
|
17
18
|
v-if="errorMessage"
|
|
@@ -94,9 +95,10 @@ const switchTo = async (chain: Chain) => {
|
|
|
94
95
|
dialogOpen.value = false
|
|
95
96
|
} catch (e: unknown) {
|
|
96
97
|
const message = e instanceof Error ? e.message : 'Failed to switch network.'
|
|
97
|
-
errorMessage.value =
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
errorMessage.value =
|
|
99
|
+
message.includes('rejected') || message.includes('denied')
|
|
100
|
+
? 'Network switch cancelled.'
|
|
101
|
+
: 'Failed to switch network. Please try again.'
|
|
100
102
|
emit('error', { message: errorMessage.value })
|
|
101
103
|
} finally {
|
|
102
104
|
switching.value = false
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { createConnector } from '@wagmi/core'
|
|
2
|
+
import {
|
|
3
|
+
type Address,
|
|
4
|
+
type Hex,
|
|
5
|
+
createPublicClient,
|
|
6
|
+
createWalletClient,
|
|
7
|
+
custom,
|
|
8
|
+
getAddress,
|
|
9
|
+
hexToBigInt,
|
|
10
|
+
hexToNumber,
|
|
11
|
+
http,
|
|
12
|
+
numberToHex,
|
|
13
|
+
} from 'viem'
|
|
14
|
+
import { privateKeyToAccount, type PrivateKeyAccount } from 'viem/accounts'
|
|
15
|
+
|
|
16
|
+
const STORAGE_KEY = 'evm:in-app-wallet-pk'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Derive a private key from a BIP39 mnemonic and store it in localStorage.
|
|
20
|
+
* Call this before `connectAsync({ connector })`.
|
|
21
|
+
*/
|
|
22
|
+
export async function prepareInAppWallet(mnemonic: string): Promise<Address> {
|
|
23
|
+
const { mnemonicToAccount } = await import('viem/accounts')
|
|
24
|
+
const { bytesToHex } = await import('viem')
|
|
25
|
+
|
|
26
|
+
const normalized = mnemonic.trim().toLowerCase().replace(/\s+/g, ' ')
|
|
27
|
+
const hdAccount = mnemonicToAccount(normalized)
|
|
28
|
+
const hdKey = hdAccount.getHdKey()
|
|
29
|
+
const pk = bytesToHex(hdKey.privateKey!) as `0x${string}`
|
|
30
|
+
|
|
31
|
+
localStorage.setItem(STORAGE_KEY, pk)
|
|
32
|
+
return hdAccount.address
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type InAppWalletParameters = {
|
|
36
|
+
storageKey?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
inAppWallet.type = 'inAppWallet' as const
|
|
40
|
+
|
|
41
|
+
export function inAppWallet(parameters: InAppWalletParameters = {}) {
|
|
42
|
+
const key = parameters.storageKey ?? STORAGE_KEY
|
|
43
|
+
|
|
44
|
+
type Provider =
|
|
45
|
+
ReturnType<typeof custom> extends (...args: infer A) => infer R ? R : never
|
|
46
|
+
|
|
47
|
+
return createConnector<Provider>((config) => {
|
|
48
|
+
let account: PrivateKeyAccount | null = null
|
|
49
|
+
let currentChainId: number = config.chains[0].id
|
|
50
|
+
|
|
51
|
+
function loadAccount(): PrivateKeyAccount | null {
|
|
52
|
+
if (typeof window === 'undefined') return null
|
|
53
|
+
try {
|
|
54
|
+
const stored = localStorage.getItem(key)
|
|
55
|
+
if (stored?.startsWith('0x')) {
|
|
56
|
+
account = privateKeyToAccount(stored as `0x${string}`)
|
|
57
|
+
return account
|
|
58
|
+
}
|
|
59
|
+
} catch {}
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getChain(chainId?: number) {
|
|
64
|
+
return (
|
|
65
|
+
config.chains.find((c) => c.id === (chainId ?? currentChainId)) ??
|
|
66
|
+
config.chains[0]
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
id: 'inAppWallet',
|
|
72
|
+
name: 'In App',
|
|
73
|
+
type: inAppWallet.type,
|
|
74
|
+
|
|
75
|
+
async connect({ chainId } = {}) {
|
|
76
|
+
const acct = account ?? loadAccount()
|
|
77
|
+
if (!acct) throw new Error('No in-app wallet key found in storage')
|
|
78
|
+
|
|
79
|
+
if (chainId) currentChainId = chainId
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
accounts: [getAddress(acct.address)],
|
|
83
|
+
chainId: currentChainId,
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
async disconnect() {
|
|
88
|
+
account = null
|
|
89
|
+
if (typeof window !== 'undefined') {
|
|
90
|
+
localStorage.removeItem(key)
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
async getAccounts() {
|
|
95
|
+
const acct = account ?? loadAccount()
|
|
96
|
+
return acct ? [getAddress(acct.address)] : []
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
async getChainId() {
|
|
100
|
+
return currentChainId
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
async getProvider() {
|
|
104
|
+
const chain = getChain()
|
|
105
|
+
const transport = config.transports?.[chain.id] ?? http()
|
|
106
|
+
|
|
107
|
+
const request = async ({
|
|
108
|
+
method,
|
|
109
|
+
params,
|
|
110
|
+
}: {
|
|
111
|
+
method: string
|
|
112
|
+
params?: unknown[]
|
|
113
|
+
}): Promise<unknown> => {
|
|
114
|
+
// Account methods
|
|
115
|
+
if (method === 'eth_accounts' || method === 'eth_requestAccounts') {
|
|
116
|
+
return account ? [account.address] : []
|
|
117
|
+
}
|
|
118
|
+
if (method === 'eth_chainId') {
|
|
119
|
+
return numberToHex(currentChainId)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Signing methods — handled locally
|
|
123
|
+
if (method === 'personal_sign') {
|
|
124
|
+
if (!account) throw new Error('Not connected')
|
|
125
|
+
const [data] = params as [Hex, Address]
|
|
126
|
+
return account.signMessage({ message: { raw: data } })
|
|
127
|
+
}
|
|
128
|
+
if (method === 'eth_signTypedData_v4') {
|
|
129
|
+
if (!account) throw new Error('Not connected')
|
|
130
|
+
const [, typedDataJson] = params as [Address, string]
|
|
131
|
+
const typedData = JSON.parse(typedDataJson)
|
|
132
|
+
return account.signTypedData(typedData)
|
|
133
|
+
}
|
|
134
|
+
if (method === 'eth_sign') {
|
|
135
|
+
if (!account) throw new Error('Not connected')
|
|
136
|
+
const [, data] = params as [Address, Hex]
|
|
137
|
+
return account.sign!({ hash: data })
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Send transaction — sign locally, broadcast via RPC
|
|
141
|
+
if (method === 'eth_sendTransaction') {
|
|
142
|
+
if (!account) throw new Error('Not connected')
|
|
143
|
+
const [tx] = params as [Record<string, string>]
|
|
144
|
+
const walletClient = createWalletClient({
|
|
145
|
+
account,
|
|
146
|
+
chain,
|
|
147
|
+
transport,
|
|
148
|
+
})
|
|
149
|
+
return walletClient.sendTransaction({
|
|
150
|
+
chain,
|
|
151
|
+
to: tx.to as Address,
|
|
152
|
+
data: tx.data as Hex | undefined,
|
|
153
|
+
value: tx.value ? hexToBigInt(tx.value as Hex) : undefined,
|
|
154
|
+
gas: tx.gas ? hexToBigInt(tx.gas as Hex) : undefined,
|
|
155
|
+
nonce:
|
|
156
|
+
tx.nonce != null ? hexToNumber(tx.nonce as Hex) : undefined,
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Chain switching
|
|
161
|
+
if (method === 'wallet_switchEthereumChain') {
|
|
162
|
+
const [{ chainId: hexChainId }] = params as [
|
|
163
|
+
{ chainId: `0x${string}` },
|
|
164
|
+
]
|
|
165
|
+
const newChainId = hexToNumber(hexChainId)
|
|
166
|
+
const chain = config.chains.find((c) => c.id === newChainId)
|
|
167
|
+
if (!chain) throw new Error('Chain not configured')
|
|
168
|
+
currentChainId = newChainId
|
|
169
|
+
config.emitter.emit('change', { chainId: newChainId })
|
|
170
|
+
return null
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Everything else — forward to RPC
|
|
174
|
+
const publicClient = createPublicClient({ chain, transport })
|
|
175
|
+
return (
|
|
176
|
+
publicClient as unknown as {
|
|
177
|
+
request: (args: {
|
|
178
|
+
method: string
|
|
179
|
+
params?: unknown[]
|
|
180
|
+
}) => Promise<unknown>
|
|
181
|
+
}
|
|
182
|
+
).request({ method, params: params as unknown[] })
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return custom({ request })({ retryCount: 0 })
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
async isAuthorized() {
|
|
189
|
+
const acct = account ?? loadAccount()
|
|
190
|
+
return !!acct
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
async switchChain({ chainId }) {
|
|
194
|
+
const chain = config.chains.find((c) => c.id === chainId)
|
|
195
|
+
if (!chain) throw new Error('Chain not configured')
|
|
196
|
+
currentChainId = chainId
|
|
197
|
+
config.emitter.emit('change', { chainId })
|
|
198
|
+
return chain
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
onAccountsChanged(accounts) {
|
|
202
|
+
if (accounts.length === 0) this.onDisconnect()
|
|
203
|
+
else
|
|
204
|
+
config.emitter.emit('change', {
|
|
205
|
+
accounts: accounts.map((a) => getAddress(a)),
|
|
206
|
+
})
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
onChainChanged(chain) {
|
|
210
|
+
const chainId = Number(chain)
|
|
211
|
+
config.emitter.emit('change', { chainId })
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
onDisconnect() {
|
|
215
|
+
config.emitter.emit('disconnect')
|
|
216
|
+
account = null
|
|
217
|
+
},
|
|
218
|
+
}
|
|
219
|
+
})
|
|
220
|
+
}
|
package/src/evm/index.ts
CHANGED
|
@@ -34,6 +34,9 @@ export { usePriceFeed } from './composables/priceFeed'
|
|
|
34
34
|
export { useWalletExplorer } from './composables/walletExplorer'
|
|
35
35
|
export type { ExplorerWallet } from './composables/walletExplorer'
|
|
36
36
|
|
|
37
|
+
// Connectors
|
|
38
|
+
export { inAppWallet, prepareInAppWallet } from './connectors/inAppWallet'
|
|
39
|
+
|
|
37
40
|
// Components
|
|
38
41
|
export { default as EvmAccount } from './components/EvmAccount.vue'
|
|
39
42
|
export { default as EvmAvatar } from './components/EvmAvatar.vue'
|
|
@@ -45,3 +48,5 @@ export { default as EvmMetaMaskQR } from './components/EvmMetaMaskQR.vue'
|
|
|
45
48
|
export { default as EvmWalletConnectQR } from './components/EvmWalletConnectQR.vue'
|
|
46
49
|
export { default as EvmWalletConnectWallets } from './components/EvmWalletConnectWallets.vue'
|
|
47
50
|
export { default as EvmTransactionFlow } from './components/EvmTransactionFlow.vue'
|
|
51
|
+
export { default as EvmSeedPhraseInput } from './components/EvmSeedPhraseInput.vue'
|
|
52
|
+
export { default as EvmInAppWalletSetup } from './components/EvmInAppWalletSetup.vue'
|