@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1001-digital/components",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "sideEffects": [
6
6
  "*.css"
@@ -1,6 +1,8 @@
1
1
  <template>
2
- <Toasts />
3
- <ConfirmDialog />
2
+ <Toasts>
3
+ <ConfirmDialog />
4
+ <slot />
5
+ </Toasts>
4
6
  </template>
5
7
 
6
8
  <script setup lang="ts">
@@ -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
 
@@ -3,6 +3,7 @@ import { ref } from 'vue'
3
3
  export interface ToastAction {
4
4
  label: string
5
5
  onClick: () => void
6
+ persistent?: boolean
6
7
  }
7
8
 
8
9
  export type ToastVariant = 'info' | 'success' | 'error'
@@ -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((c) => c.id !== 'injected' && c.id !== 'safe')
151
- : unique
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 :address="address" large />
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 = message.includes('rejected') || message.includes('denied')
98
- ? 'Network switch cancelled.'
99
- : 'Failed to switch network. Please try again.'
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
@@ -261,6 +261,7 @@ const initializeRequest = async (request = cachedRequest.value) => {
261
261
  action: {
262
262
  label: 'View on Block Explorer',
263
263
  onClick: () => window.open(link, '_blank'),
264
+ persistent: true,
264
265
  },
265
266
  })
266
267
 
@@ -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'