@1001-digital/components.evm 1.2.2

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.
Files changed (44) hide show
  1. package/package.json +33 -0
  2. package/src/assets/wallets/coinbase.svg +4 -0
  3. package/src/assets/wallets/in-app.svg +5 -0
  4. package/src/assets/wallets/metamask.svg +1 -0
  5. package/src/assets/wallets/phantom.svg +4 -0
  6. package/src/assets/wallets/rabby.svg +24 -0
  7. package/src/assets/wallets/rainbow.svg +59 -0
  8. package/src/assets/wallets/safe.png +0 -0
  9. package/src/assets/wallets/walletconnect.svg +1 -0
  10. package/src/components/EvmAccount.vue +28 -0
  11. package/src/components/EvmAvatar.vue +62 -0
  12. package/src/components/EvmConnect.vue +300 -0
  13. package/src/components/EvmConnectDialog.vue +74 -0
  14. package/src/components/EvmConnectionStatus.vue +13 -0
  15. package/src/components/EvmConnectorQR.vue +85 -0
  16. package/src/components/EvmInAppWalletSetup.vue +247 -0
  17. package/src/components/EvmMetaMaskQR.vue +33 -0
  18. package/src/components/EvmProfile.vue +183 -0
  19. package/src/components/EvmSeedPhraseInput.vue +193 -0
  20. package/src/components/EvmSiwe.vue +188 -0
  21. package/src/components/EvmSiweDialog.vue +92 -0
  22. package/src/components/EvmSwitchNetwork.vue +128 -0
  23. package/src/components/EvmTransactionFlow.vue +348 -0
  24. package/src/components/EvmWalletConnectQR.vue +13 -0
  25. package/src/components/EvmWalletConnectWallets.vue +197 -0
  26. package/src/composables/base.ts +7 -0
  27. package/src/composables/chainId.ts +42 -0
  28. package/src/composables/ens.ts +113 -0
  29. package/src/composables/gasPrice.ts +37 -0
  30. package/src/composables/priceFeed.ts +116 -0
  31. package/src/composables/siwe.ts +89 -0
  32. package/src/composables/uri.ts +12 -0
  33. package/src/composables/walletExplorer.ts +130 -0
  34. package/src/config.ts +35 -0
  35. package/src/connectors/inAppWallet.ts +5 -0
  36. package/src/index.ts +60 -0
  37. package/src/utils/addresses.ts +6 -0
  38. package/src/utils/cache.ts +59 -0
  39. package/src/utils/chains.ts +32 -0
  40. package/src/utils/ens.ts +116 -0
  41. package/src/utils/format-eth.ts +15 -0
  42. package/src/utils/price.ts +17 -0
  43. package/src/utils/siwe.ts +70 -0
  44. package/src/utils/uri.ts +24 -0
@@ -0,0 +1,193 @@
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="
14
+ (el) => {
15
+ if (el) inputRefs[i] = el as HTMLInputElement
16
+ }
17
+ "
18
+ v-model="words[i]"
19
+ type="text"
20
+ autocomplete="off"
21
+ autocapitalize="none"
22
+ spellcheck="false"
23
+ :disabled="disabled"
24
+ @keydown="onKeydown($event, i)"
25
+ @paste="onPaste($event, i)"
26
+ @input="onInput(i)"
27
+ />
28
+ </div>
29
+ </div>
30
+ </div>
31
+ </template>
32
+
33
+ <script setup lang="ts">
34
+ import { ref, watch, computed, onMounted } from 'vue'
35
+ import { english } from 'viem/accounts'
36
+
37
+ const props = defineProps<{
38
+ modelValue?: string
39
+ disabled?: boolean
40
+ }>()
41
+
42
+ const emit = defineEmits<{
43
+ 'update:modelValue': [value: string]
44
+ valid: [isValid: boolean]
45
+ submit: []
46
+ }>()
47
+
48
+ const wordSet = new Set(english)
49
+
50
+ const words = ref<string[]>(Array.from({ length: 12 }, () => ''))
51
+ const inputRefs = ref<HTMLInputElement[]>([])
52
+
53
+ function isValidWord(word: string): boolean {
54
+ return wordSet.has(word.trim().toLowerCase())
55
+ }
56
+
57
+ const isValid = computed(() =>
58
+ words.value.every((w) => w.trim() !== '' && isValidWord(w)),
59
+ )
60
+
61
+ const phrase = computed(() =>
62
+ words.value.map((w) => w.trim().toLowerCase()).join(' '),
63
+ )
64
+
65
+ watch(
66
+ () => props.modelValue,
67
+ (val) => {
68
+ if (!val) return
69
+ const incoming = val.trim().split(/\s+/)
70
+ if (incoming.length === 12) {
71
+ for (let i = 0; i < 12; i++) {
72
+ words.value[i] = incoming[i]!
73
+ }
74
+ }
75
+ },
76
+ { immediate: true },
77
+ )
78
+
79
+ watch(phrase, (val) => {
80
+ emit('update:modelValue', val)
81
+ })
82
+
83
+ watch(isValid, (val) => {
84
+ emit('valid', val)
85
+ })
86
+
87
+ function focusInput(index: number) {
88
+ const el = inputRefs.value[index]
89
+ if (el) {
90
+ el.focus()
91
+ el.select()
92
+ }
93
+ }
94
+
95
+ function onKeydown(event: KeyboardEvent, index: number) {
96
+ if (event.key === ' ' || (event.key === 'Enter' && index < 11)) {
97
+ event.preventDefault()
98
+ focusInput(index + 1)
99
+ } else if (event.key === 'Enter' && index === 11 && isValid.value) {
100
+ event.preventDefault()
101
+ emit('submit')
102
+ } else if (
103
+ event.key === 'Backspace' &&
104
+ words.value[index] === '' &&
105
+ index > 0
106
+ ) {
107
+ event.preventDefault()
108
+ focusInput(index - 1)
109
+ } else if (event.key === 'Tab' && !event.shiftKey && index === 11) {
110
+ // Allow natural tab out
111
+ } else if (event.key === 'Tab' && event.shiftKey && index === 0) {
112
+ // Allow natural tab out
113
+ }
114
+ }
115
+
116
+ function onPaste(event: ClipboardEvent, index: number) {
117
+ const text = event.clipboardData?.getData('text')
118
+ if (!text) return
119
+
120
+ const pasted = text.trim().split(/\s+/)
121
+ if (pasted.length > 1) {
122
+ event.preventDefault()
123
+ for (let i = 0; i < pasted.length && index + i < 12; i++) {
124
+ words.value[index + i] = pasted[i]!.toLowerCase()
125
+ }
126
+ const nextIndex = Math.min(index + pasted.length, 11)
127
+ focusInput(nextIndex)
128
+ }
129
+ }
130
+
131
+ function onInput(index: number) {
132
+ // Auto-advance if the word contains a space (mobile autocomplete)
133
+ const val = words.value[index]
134
+ if (val?.includes(' ')) {
135
+ const parts = val.trim().split(/\s+/)
136
+ words.value[index] = parts[0]!
137
+ if (parts.length > 1 && index < 11) {
138
+ for (let i = 1; i < parts.length && index + i < 12; i++) {
139
+ words.value[index + i] = parts[i]!
140
+ }
141
+ focusInput(Math.min(index + parts.length, 11))
142
+ }
143
+ }
144
+ }
145
+
146
+ onMounted(() => {
147
+ if (!props.disabled) {
148
+ focusInput(0)
149
+ }
150
+ })
151
+ </script>
152
+
153
+ <style scoped>
154
+ .seed-phrase-grid {
155
+ display: grid;
156
+ grid-template-columns: repeat(3, 1fr);
157
+ gap: var(--spacer-sm);
158
+ }
159
+
160
+
161
+ .seed-word {
162
+ display: flex;
163
+ align-items: center;
164
+ gap: var(--spacer-sm);
165
+ border: var(--border);
166
+ border-radius: var(--border-radius);
167
+ padding: var(--spacer-sm);
168
+ transition: border-color var(--speed);
169
+
170
+ &:focus-within {
171
+ border-color: var(--accent);
172
+ }
173
+
174
+ &.invalid {
175
+ border-color: var(--error);
176
+ }
177
+
178
+ label {
179
+ font-size: var(--font-xs);
180
+ color: var(--muted);
181
+ min-width: 1.5em;
182
+ text-align: right;
183
+ user-select: none;
184
+ }
185
+
186
+ input {
187
+ all: unset;
188
+ width: 100%;
189
+ font-size: var(--font-sm);
190
+ font-family: var(--font-mono, monospace);
191
+ }
192
+ }
193
+ </style>
@@ -0,0 +1,188 @@
1
+ <template>
2
+ <Loading
3
+ v-if="step === 'signing'"
4
+ spinner
5
+ stacked
6
+ :txt="connector?.name
7
+ ? `Requesting signature from ${connector.name}...`
8
+ : 'Requesting signature...'"
9
+ />
10
+
11
+ <Loading
12
+ v-else-if="step === 'verifying'"
13
+ spinner
14
+ stacked
15
+ txt="Verifying signature..."
16
+ />
17
+
18
+ <template v-else-if="step === 'complete'">
19
+ <slot name="complete">
20
+ <Alert type="info">
21
+ <p>Successfully signed in.</p>
22
+ </Alert>
23
+ </slot>
24
+ </template>
25
+
26
+ <template v-else-if="step === 'error'">
27
+ <Alert type="error">
28
+ <p>{{ errorMessage }}</p>
29
+ </Alert>
30
+ <Button
31
+ class="secondary"
32
+ @click="signIn"
33
+ >Try Again</Button>
34
+ </template>
35
+
36
+ <template v-else>
37
+ <slot name="idle">
38
+ <p v-if="props.statement">{{ props.statement }}</p>
39
+ </slot>
40
+ <Button @click="signIn">
41
+ Sign In
42
+ </Button>
43
+ </template>
44
+ </template>
45
+
46
+ <script setup lang="ts">
47
+ import { ref } from 'vue'
48
+ import { signMessage } from '@wagmi/core'
49
+ import { useConfig, useConnection } from '@wagmi/vue'
50
+ import type { Config } from '@wagmi/vue'
51
+ import { Button, Alert, Loading } from '@1001-digital/components'
52
+ import { createSiweMessage } from '../utils/siwe'
53
+ import { useSiwe } from '../composables/siwe'
54
+
55
+ type Step = 'idle' | 'signing' | 'verifying' | 'complete' | 'error'
56
+
57
+ const props = defineProps<{
58
+ getNonce: () => Promise<string>
59
+ verify: (message: string, signature: string) => Promise<boolean>
60
+ domain?: string
61
+ statement?: string
62
+ uri?: string
63
+ resources?: string[]
64
+ requestId?: string
65
+ expirationTime?: string
66
+ }>()
67
+
68
+ const emit = defineEmits<{
69
+ authenticated: [{ address: `0x${string}`; chainId: number }]
70
+ error: [error: string]
71
+ }>()
72
+
73
+ function isUserRejection(e: unknown): boolean {
74
+ const re = /reject|denied|cancel/i
75
+ let current = e as Record<string, unknown> | undefined
76
+ while (current) {
77
+ if ((current as { code?: number }).code === 4001) return true
78
+ if (re.test((current as { details?: string }).details || '')) return true
79
+ if (re.test((current as { message?: string }).message || '')) return true
80
+ current = current.cause as Record<string, unknown> | undefined
81
+ }
82
+ return false
83
+ }
84
+
85
+ const config = useConfig()
86
+ const { address, chainId, connector } = useConnection()
87
+ const { setSession } = useSiwe()
88
+
89
+ const step = ref<Step>('idle')
90
+ const errorMessage = ref('')
91
+
92
+ const signIn = async () => {
93
+ const currentAddress = address.value
94
+ const currentChainId = chainId.value
95
+
96
+ if (!currentAddress || !currentChainId) {
97
+ errorMessage.value = 'Wallet not connected.'
98
+ step.value = 'error'
99
+ emit('error', errorMessage.value)
100
+ return
101
+ }
102
+
103
+ errorMessage.value = ''
104
+
105
+ // Get nonce
106
+ let nonce: string
107
+ try {
108
+ nonce = await props.getNonce()
109
+ } catch {
110
+ errorMessage.value = 'Failed to get authentication nonce.'
111
+ step.value = 'error'
112
+ emit('error', errorMessage.value)
113
+ return
114
+ }
115
+
116
+ // Sign message
117
+ step.value = 'signing'
118
+ const message = createSiweMessage({
119
+ domain: props.domain || window.location.host,
120
+ address: currentAddress,
121
+ uri: props.uri || window.location.origin,
122
+ chainId: currentChainId,
123
+ nonce,
124
+ statement: props.statement,
125
+ expirationTime: props.expirationTime,
126
+ requestId: props.requestId,
127
+ resources: props.resources,
128
+ })
129
+
130
+ let signature: string
131
+ try {
132
+ signature = await signMessage(config as Config, { message })
133
+ } catch (e: unknown) {
134
+ if (isUserRejection(e)) {
135
+ errorMessage.value = 'Signature rejected by user.'
136
+ } else {
137
+ const err = e as { shortMessage?: string; message?: string }
138
+ errorMessage.value = err.shortMessage || err.message || 'Failed to sign message.'
139
+ }
140
+ step.value = 'error'
141
+ emit('error', errorMessage.value)
142
+ console.error('SIWE signing error:', e)
143
+ return
144
+ }
145
+
146
+ // Verify with backend
147
+ step.value = 'verifying'
148
+ try {
149
+ const verified = await props.verify(message, signature)
150
+
151
+ if (!verified) {
152
+ throw new Error('Signature verification failed')
153
+ }
154
+ } catch (e: unknown) {
155
+ const err = e as { message?: string }
156
+ errorMessage.value = err.message || 'Verification failed.'
157
+ step.value = 'error'
158
+ emit('error', errorMessage.value)
159
+ console.error('SIWE verification error:', e)
160
+ return
161
+ }
162
+
163
+ // Update shared authentication state
164
+ setSession({
165
+ address: currentAddress,
166
+ chainId: currentChainId,
167
+ })
168
+
169
+ step.value = 'complete'
170
+ emit('authenticated', {
171
+ address: currentAddress,
172
+ chainId: currentChainId,
173
+ })
174
+ }
175
+
176
+ const reset = () => {
177
+ step.value = 'idle'
178
+ errorMessage.value = ''
179
+ }
180
+
181
+ defineExpose({ reset })
182
+ </script>
183
+
184
+ <style scoped>
185
+ .secondary {
186
+ margin-top: var(--spacer-sm);
187
+ }
188
+ </style>
@@ -0,0 +1,92 @@
1
+ <template>
2
+ <Button
3
+ v-if="!isAuthenticated"
4
+ @click="open = true"
5
+ :class="className"
6
+ >
7
+ <slot>Sign In</slot>
8
+ </Button>
9
+ <slot
10
+ v-else
11
+ name="authenticated"
12
+ :address="session?.address"
13
+ :sign-out="handleSignOut"
14
+ >
15
+ <Button
16
+ @click="handleSignOut"
17
+ :class="className"
18
+ >Sign Out</Button>
19
+ </slot>
20
+
21
+ <Dialog
22
+ v-if="!isAuthenticated"
23
+ title="Sign In with Ethereum"
24
+ v-model:open="open"
25
+ @closed="onClosed"
26
+ >
27
+ <EvmSiwe
28
+ ref="siweRef"
29
+ :get-nonce="getNonce"
30
+ :verify="verify"
31
+ :domain="domain"
32
+ :statement="statement"
33
+ :uri="uri"
34
+ :resources="resources"
35
+ :request-id="requestId"
36
+ :expiration-time="expirationTime"
37
+ @authenticated="onAuthenticated"
38
+ @error="(e) => emit('error', e)"
39
+ />
40
+ </Dialog>
41
+ </template>
42
+
43
+ <script setup lang="ts">
44
+ import { ref, watch } from 'vue'
45
+ import { Button, Dialog } from '@1001-digital/components'
46
+ import EvmSiwe from './EvmSiwe.vue'
47
+ import { useSiwe } from '../composables/siwe'
48
+
49
+ const props = defineProps<{
50
+ className?: string
51
+ getNonce: () => Promise<string>
52
+ verify: (message: string, signature: string) => Promise<boolean>
53
+ domain?: string
54
+ statement?: string
55
+ uri?: string
56
+ resources?: string[]
57
+ requestId?: string
58
+ expirationTime?: string
59
+ }>()
60
+
61
+ const emit = defineEmits<{
62
+ authenticated: [{ address: `0x${string}`; chainId: number }]
63
+ signedOut: []
64
+ error: [error: string]
65
+ }>()
66
+
67
+ const { isAuthenticated, session, signOut } = useSiwe()
68
+
69
+ const open = ref(false)
70
+ const siweRef = ref<InstanceType<typeof EvmSiwe> | null>(null)
71
+
72
+ const onAuthenticated = (data: { address: `0x${string}`; chainId: number }) => {
73
+ open.value = false
74
+ emit('authenticated', data)
75
+ }
76
+
77
+ const onClosed = () => {
78
+ siweRef.value?.reset()
79
+ }
80
+
81
+ const handleSignOut = () => {
82
+ signOut()
83
+ emit('signedOut')
84
+ }
85
+
86
+ watch(isAuthenticated, (authenticated) => {
87
+ if (!authenticated) {
88
+ open.value = false
89
+ siweRef.value?.reset()
90
+ }
91
+ })
92
+ </script>
@@ -0,0 +1,128 @@
1
+ <template>
2
+ <template v-if="chains.length > 1">
3
+ <Button
4
+ @click="dialogOpen = true"
5
+ :class="className"
6
+ >
7
+ <slot :current-chain="currentChain">
8
+ {{ currentChain?.name || 'Unknown Network' }}
9
+ </slot>
10
+ </Button>
11
+
12
+ <Dialog
13
+ title="Switch Network"
14
+ v-model:open="dialogOpen"
15
+ @closed="onClosed"
16
+ compat
17
+ >
18
+ <Alert
19
+ v-if="errorMessage"
20
+ type="error"
21
+ >
22
+ {{ errorMessage }}
23
+ </Alert>
24
+
25
+ <Loading
26
+ v-if="switching"
27
+ spinner
28
+ stacked
29
+ :txt="`Switching to ${switchingTo}...`"
30
+ />
31
+
32
+ <div
33
+ v-if="!switching"
34
+ class="chain-list"
35
+ >
36
+ <Button
37
+ v-for="chain in chains"
38
+ :key="chain.id"
39
+ :disabled="chain.id === currentChainId || undefined"
40
+ :class="['block', 'chain-item', { active: chain.id === currentChainId }]"
41
+ @click="() => switchTo(chain)"
42
+ >
43
+ <span>{{ chain.name }}</span>
44
+ <Icon
45
+ v-if="chain.id === currentChainId"
46
+ type="check"
47
+ />
48
+ </Button>
49
+ </div>
50
+ </Dialog>
51
+ </template>
52
+ </template>
53
+
54
+ <script setup lang="ts">
55
+ import { ref, computed } from 'vue'
56
+ import type { Chain } from 'viem'
57
+ import { useConfig, useConnection, useSwitchChain } from '@wagmi/vue'
58
+ import { Button, Dialog, Icon, Alert, Loading } from '@1001-digital/components'
59
+
60
+ defineProps<{
61
+ className?: string
62
+ }>()
63
+
64
+ const emit = defineEmits<{
65
+ switched: [{ chainId: number; name: string }]
66
+ error: [{ message: string }]
67
+ }>()
68
+
69
+ const config = useConfig()
70
+ const { chainId: currentChainId } = useConnection()
71
+ const { mutateAsync: switchChainAsync } = useSwitchChain()
72
+
73
+ const chains = computed<readonly Chain[]>(() => config.chains)
74
+ const currentChain = computed(() =>
75
+ chains.value.find((c) => c.id === currentChainId.value),
76
+ )
77
+
78
+ const dialogOpen = ref(false)
79
+ const switching = ref(false)
80
+ const switchingTo = ref('')
81
+ const errorMessage = ref('')
82
+
83
+ const switchTo = async (chain: Chain) => {
84
+ if (chain.id === currentChainId.value) return
85
+
86
+ switching.value = true
87
+ switchingTo.value = chain.name
88
+ errorMessage.value = ''
89
+
90
+ try {
91
+ await switchChainAsync({ chainId: chain.id })
92
+ emit('switched', { chainId: chain.id, name: chain.name })
93
+ dialogOpen.value = false
94
+ } catch (e: unknown) {
95
+ const message = e instanceof Error ? e.message : 'Failed to switch network.'
96
+ errorMessage.value =
97
+ message.includes('rejected') || message.includes('denied')
98
+ ? 'Network switch cancelled.'
99
+ : 'Failed to switch network. Please try again.'
100
+ emit('error', { message: errorMessage.value })
101
+ } finally {
102
+ switching.value = false
103
+ switchingTo.value = ''
104
+ }
105
+ }
106
+
107
+ const onClosed = () => {
108
+ switching.value = false
109
+ switchingTo.value = ''
110
+ errorMessage.value = ''
111
+ }
112
+ </script>
113
+
114
+ <style scoped>
115
+ .chain-list {
116
+ display: grid;
117
+ gap: var(--spacer-sm);
118
+ }
119
+
120
+ .chain-item {
121
+ justify-content: space-between;
122
+
123
+ &.active {
124
+ pointer-events: none;
125
+ opacity: 1;
126
+ }
127
+ }
128
+ </style>