mirror of
https://github.com/status-im/status-web.git
synced 2026-08-31 06:11:10 +00:00
feat: integrate li.fi widget to swap token on ETH network
* feat: add wagmi, viem and @lifi/widget dependencies * feat: add ethereum contract call and signing API functions * feat: add sendContractCall ethereum wallet function * feat: add signer context for wallet password and transaction signing * feat: add custom wagmi status connector * feat: add wagmi provider setup * feat: add exchange drawer UI component * feat: integrate LiFi widget for token swaps * feat: update root route with new providers * feat: add exchange button to token component * chore: update CSP and improve password modal accessibility * feat(wallet): enhance exchange drawer UI * chore(wallet): update lifi, wagmi & viem versions * chore(changeset): revert useless quotes change * chore(changeset): remove useless formatting change * chore(wallet): remove useless async prefix * chore(wallet/status-connector): turn code handling provider requests into a function for better readability * chore: add changeset * fix(wallet): properly type status-connector connect method
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@status-im/wallet': patch
|
||||
'wallet': patch
|
||||
---
|
||||
|
||||
feat: integrate li.fi widget to swap tokens on ETH network
|
||||
@@ -31,6 +31,7 @@
|
||||
"@cardano-sdk/crypto": "^0.2.3",
|
||||
"@cardano-sdk/key-management": "^0.27.5",
|
||||
"@hookform/resolvers": "^3.1.1",
|
||||
"@lifi/widget": "^3.40.1",
|
||||
"@radix-ui/react-dialog": "^1.1.1",
|
||||
"@status-im/colors": "workspace:*",
|
||||
"@status-im/components": "workspace:*",
|
||||
@@ -63,7 +64,9 @@
|
||||
"trpc-chrome": "^1.0.0",
|
||||
"ts-pattern": "^5.7.1",
|
||||
"unified": "^11.0.5",
|
||||
"viem": "^2.44.1",
|
||||
"vite-plugin-node-polyfills": "^0.23.0",
|
||||
"wagmi": "^3.3.2",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
|
||||
import { LiFiWidget, type WidgetConfig } from '@lifi/widget'
|
||||
import {
|
||||
type Account,
|
||||
ExchangeDrawer as ExchangeDrawerUI,
|
||||
PasswordModal,
|
||||
} from '@status-im/wallet/components'
|
||||
import { useAccount } from 'wagmi'
|
||||
|
||||
const NATIVE_ETH_ADDRESS = '0x0000000000000000000000000000000000000000'
|
||||
const ETHEREUM_MAINNET_CHAIN_ID = 1
|
||||
|
||||
export type ExchangeDrawerProps = {
|
||||
children: React.ReactElement
|
||||
account?: Account
|
||||
fromChain?: number
|
||||
fromToken?: string
|
||||
isUnlocked?: boolean
|
||||
onUnlock?: (password: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
export const ExchangeDrawer = (props: ExchangeDrawerProps) => {
|
||||
const {
|
||||
children,
|
||||
account,
|
||||
fromChain = ETHEREUM_MAINNET_CHAIN_ID,
|
||||
fromToken,
|
||||
isUnlocked = false,
|
||||
onUnlock,
|
||||
} = props
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false)
|
||||
|
||||
const { isConnected, address: connectedAddress } = useAccount()
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(isOpen: boolean) => {
|
||||
if (isOpen && !isUnlocked) {
|
||||
setShowPasswordModal(true)
|
||||
return
|
||||
}
|
||||
setOpen(isOpen)
|
||||
},
|
||||
[isUnlocked],
|
||||
)
|
||||
|
||||
const handlePasswordConfirm = useCallback(
|
||||
async (password: string) => {
|
||||
if (!onUnlock) return
|
||||
|
||||
const success = await onUnlock(password)
|
||||
if (success) {
|
||||
setShowPasswordModal(false)
|
||||
setOpen(true)
|
||||
} else {
|
||||
throw new Error('Invalid password')
|
||||
}
|
||||
},
|
||||
[onUnlock],
|
||||
)
|
||||
|
||||
const widgetConfig: WidgetConfig = useMemo(
|
||||
() => ({
|
||||
integrator: 'StatusWallet',
|
||||
variant: 'compact',
|
||||
fromChain,
|
||||
fromToken: fromToken || NATIVE_ETH_ADDRESS,
|
||||
fromAddress: isConnected ? connectedAddress : account?.address,
|
||||
chains: {
|
||||
allow: [1],
|
||||
},
|
||||
hiddenUI: ['appearance'],
|
||||
theme: {
|
||||
container: {
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
borderRadius: '16px',
|
||||
},
|
||||
},
|
||||
}),
|
||||
[isConnected, connectedAddress, account?.address, fromChain, fromToken],
|
||||
)
|
||||
|
||||
if (!account) {
|
||||
return null
|
||||
}
|
||||
|
||||
const widgetContent =
|
||||
isUnlocked && open ? (
|
||||
<LiFiWidget
|
||||
key={connectedAddress}
|
||||
integrator={widgetConfig.integrator}
|
||||
config={widgetConfig}
|
||||
/>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<ExchangeDrawerUI
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
trigger={children}
|
||||
>
|
||||
{widgetContent}
|
||||
</ExchangeDrawerUI>
|
||||
|
||||
{onUnlock && (
|
||||
<PasswordModal
|
||||
open={showPasswordModal}
|
||||
onOpenChange={setShowPasswordModal}
|
||||
onConfirm={handlePasswordConfirm}
|
||||
buttonLabel="Unlock & Exchange"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,10 @@ import { createTRPCProxyClient } from '@trpc/client'
|
||||
import { initTRPC } from '@trpc/server'
|
||||
import superjson from 'superjson'
|
||||
import { createChromeHandler } from 'trpc-chrome/adapter'
|
||||
import {
|
||||
signMessage as viemSignMessage,
|
||||
signTypedData as viemSignTypedData,
|
||||
} from 'viem/accounts'
|
||||
import { z } from 'zod'
|
||||
|
||||
import * as bitcoin from './bitcoin/bitcoin'
|
||||
@@ -373,6 +377,142 @@ const apiRouter = router({
|
||||
id,
|
||||
}
|
||||
}),
|
||||
|
||||
sendContractCall: t.procedure
|
||||
.input(
|
||||
z.object({
|
||||
walletId: z.string(),
|
||||
password: z.string(),
|
||||
fromAddress: z.string(),
|
||||
toAddress: z.string(),
|
||||
gasLimit: z.string(),
|
||||
maxFeePerGas: z.string(),
|
||||
maxInclusionFeePerGas: z.string(),
|
||||
data: z.string(),
|
||||
value: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { keyStore, walletCore } = ctx
|
||||
|
||||
const wallet = await keyStore.load(input.walletId)
|
||||
|
||||
const account = wallet.activeAccounts.find(
|
||||
account => account.address === input.fromAddress,
|
||||
)
|
||||
|
||||
if (!account) {
|
||||
throw new Error('From address not found')
|
||||
}
|
||||
|
||||
const privateKey = await keyStore.getKey(
|
||||
wallet.id,
|
||||
input.password,
|
||||
account,
|
||||
)
|
||||
|
||||
const id = await ethereum.sendContractCall({
|
||||
walletCore,
|
||||
walletPrivateKey: privateKey,
|
||||
chainID: '01',
|
||||
toAddress: input.toAddress,
|
||||
fromAddress: input.fromAddress,
|
||||
gasLimit: input.gasLimit,
|
||||
maxFeePerGas: input.maxFeePerGas,
|
||||
maxInclusionFeePerGas: input.maxInclusionFeePerGas,
|
||||
data: input.data,
|
||||
value: input.value,
|
||||
})
|
||||
|
||||
return {
|
||||
id,
|
||||
}
|
||||
}),
|
||||
|
||||
signMessage: t.procedure
|
||||
.input(
|
||||
z.object({
|
||||
walletId: z.string(),
|
||||
password: z.string(),
|
||||
fromAddress: z.string(),
|
||||
message: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { keyStore, walletCore } = ctx
|
||||
|
||||
const wallet = await keyStore.load(input.walletId)
|
||||
const account = wallet.activeAccounts.find(
|
||||
acc => acc.address === input.fromAddress,
|
||||
)
|
||||
|
||||
if (!account) {
|
||||
throw new Error('From address not found')
|
||||
}
|
||||
|
||||
const privateKey = await keyStore.getKey(
|
||||
wallet.id,
|
||||
input.password,
|
||||
account,
|
||||
)
|
||||
|
||||
const privateKeyHex = walletCore.HexCoding.encode(privateKey.data())
|
||||
const message = input.message.startsWith('0x')
|
||||
? { raw: input.message as `0x${string}` }
|
||||
: input.message
|
||||
|
||||
const signature = await viemSignMessage({
|
||||
message,
|
||||
privateKey: privateKeyHex as `0x${string}`,
|
||||
})
|
||||
|
||||
return { signature }
|
||||
}),
|
||||
|
||||
signTypedData: t.procedure
|
||||
.input(
|
||||
z.object({
|
||||
walletId: z.string(),
|
||||
password: z.string(),
|
||||
fromAddress: z.string(),
|
||||
domain: z.record(z.unknown()),
|
||||
types: z.record(
|
||||
z.array(z.object({ name: z.string(), type: z.string() })),
|
||||
),
|
||||
primaryType: z.string(),
|
||||
message: z.record(z.unknown()),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { keyStore, walletCore } = ctx
|
||||
|
||||
const wallet = await keyStore.load(input.walletId)
|
||||
const account = wallet.activeAccounts.find(
|
||||
acc => acc.address === input.fromAddress,
|
||||
)
|
||||
|
||||
if (!account) {
|
||||
throw new Error('From address not found')
|
||||
}
|
||||
|
||||
const privateKey = await keyStore.getKey(
|
||||
wallet.id,
|
||||
input.password,
|
||||
account,
|
||||
)
|
||||
|
||||
const privateKeyHex = walletCore.HexCoding.encode(privateKey.data())
|
||||
|
||||
const signature = await viemSignTypedData({
|
||||
domain: input.domain,
|
||||
types: input.types,
|
||||
primaryType: input.primaryType,
|
||||
message: input.message,
|
||||
privateKey: privateKeyHex as `0x${string}`,
|
||||
})
|
||||
|
||||
return { signature }
|
||||
}),
|
||||
}),
|
||||
|
||||
bitcoin: router({
|
||||
|
||||
@@ -92,6 +92,81 @@ export async function send({
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendContractCall({
|
||||
walletCore,
|
||||
walletPrivateKey,
|
||||
chainID,
|
||||
toAddress,
|
||||
fromAddress,
|
||||
network = 'ethereum',
|
||||
gasLimit,
|
||||
maxFeePerGas,
|
||||
maxInclusionFeePerGas,
|
||||
data,
|
||||
value = '0',
|
||||
}: {
|
||||
walletCore: WalletCore
|
||||
walletPrivateKey: InstanceType<WalletCore['PrivateKey']>
|
||||
chainID: string
|
||||
toAddress: string
|
||||
fromAddress: string
|
||||
network?: string
|
||||
gasLimit: string
|
||||
maxFeePerGas: string
|
||||
maxInclusionFeePerGas: string
|
||||
data: string
|
||||
value?: string
|
||||
}) {
|
||||
const nonceHex = await getNonceHex(fromAddress, network)
|
||||
const chainIdHex = getChainIdHex(chainID)
|
||||
const cleanData = data.replace(/^0x/, '')
|
||||
const cleanValue = padHex(value)
|
||||
|
||||
const txInput = encoder.Ethereum.Proto.SigningInput.create({
|
||||
chainId: Uint8Array.from(Buffer.from(chainIdHex, 'hex')),
|
||||
nonce: Uint8Array.from(Buffer.from(nonceHex, 'hex')),
|
||||
gasLimit: Uint8Array.from(Buffer.from(padHex(gasLimit), 'hex')),
|
||||
maxFeePerGas: Uint8Array.from(Buffer.from(padHex(maxFeePerGas), 'hex')),
|
||||
maxInclusionFeePerGas: Uint8Array.from(
|
||||
Buffer.from(padHex(maxInclusionFeePerGas), 'hex'),
|
||||
),
|
||||
toAddress,
|
||||
transaction: {
|
||||
contractGeneric: {
|
||||
amount: Uint8Array.from(Buffer.from(cleanValue, 'hex')),
|
||||
data: Uint8Array.from(Buffer.from(cleanData, 'hex')),
|
||||
},
|
||||
},
|
||||
privateKey: walletPrivateKey.data(),
|
||||
txMode: encoder.Ethereum.Proto.TransactionMode.Enveloped,
|
||||
})
|
||||
|
||||
const inputEncoded =
|
||||
encoder.Ethereum.Proto.SigningInput.encode(txInput).finish()
|
||||
const outputData = walletCore.AnySigner.sign(
|
||||
inputEncoded,
|
||||
walletCore.CoinType.ethereum,
|
||||
)
|
||||
const output = encoder.Ethereum.Proto.SigningOutput.decode(outputData)
|
||||
const rawTx = walletCore.HexCoding.encode(output.encoded)
|
||||
|
||||
const response = await fetch(BROADCAST_TRANSACTION_URL.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ json: { txHex: rawTx, network } }),
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to broadcast transaction')
|
||||
}
|
||||
|
||||
const body = await response.json()
|
||||
const txid = body.result.data.json
|
||||
|
||||
return { txid }
|
||||
}
|
||||
|
||||
export async function sendErc20({
|
||||
walletCore,
|
||||
walletPrivateKey,
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import {
|
||||
type Address,
|
||||
createPublicClient,
|
||||
type Hex,
|
||||
http,
|
||||
numberToHex,
|
||||
} from 'viem'
|
||||
import { createConnector } from 'wagmi'
|
||||
import { mainnet } from 'wagmi/chains'
|
||||
|
||||
export type StatusConnectorOptions = {
|
||||
getAddress: () => Address | undefined
|
||||
signAndSendTransaction: (tx: {
|
||||
to: Address
|
||||
value: bigint
|
||||
data?: Hex
|
||||
gas?: bigint
|
||||
maxFeePerGas?: bigint
|
||||
maxPriorityFeePerGas?: bigint
|
||||
}) => Promise<Hex>
|
||||
signMessage: (message: Hex) => Promise<Hex>
|
||||
signTypedData: (typedData: string) => Promise<Hex>
|
||||
}
|
||||
|
||||
function handleProviderRequest(
|
||||
method: string,
|
||||
params: unknown[] | undefined,
|
||||
options: {
|
||||
getAddress: () => Address | undefined
|
||||
signAndSendTransaction: StatusConnectorOptions['signAndSendTransaction']
|
||||
signMessage: StatusConnectorOptions['signMessage']
|
||||
signTypedData: StatusConnectorOptions['signTypedData']
|
||||
emitter: { emit: (event: string, ...args: unknown[]) => void }
|
||||
},
|
||||
): Promise<unknown> {
|
||||
const {
|
||||
getAddress,
|
||||
signAndSendTransaction,
|
||||
signMessage,
|
||||
signTypedData,
|
||||
emitter,
|
||||
} = options
|
||||
const address = getAddress()
|
||||
|
||||
switch (method) {
|
||||
case 'eth_requestAccounts':
|
||||
case 'eth_accounts': {
|
||||
const accounts = address ? [address] : []
|
||||
if (method === 'eth_requestAccounts' && accounts.length > 0) {
|
||||
emitter.emit('connect', {
|
||||
accounts,
|
||||
chainId: mainnet.id,
|
||||
})
|
||||
}
|
||||
return Promise.resolve(accounts)
|
||||
}
|
||||
|
||||
case 'eth_chainId':
|
||||
return Promise.resolve(numberToHex(mainnet.id))
|
||||
|
||||
case 'eth_sendTransaction': {
|
||||
const txParams = (params as Record<string, unknown>[])?.[0]
|
||||
if (!txParams) throw new Error('No transaction params')
|
||||
|
||||
return signAndSendTransaction({
|
||||
to: txParams.to as Address,
|
||||
value: txParams.value ? BigInt(txParams.value as string) : 0n,
|
||||
data: txParams.data as Hex | undefined,
|
||||
gas: txParams.gas ? BigInt(txParams.gas as string) : undefined,
|
||||
maxFeePerGas: txParams.maxFeePerGas
|
||||
? BigInt(txParams.maxFeePerGas as string)
|
||||
: undefined,
|
||||
maxPriorityFeePerGas: txParams.maxPriorityFeePerGas
|
||||
? BigInt(txParams.maxPriorityFeePerGas as string)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
case 'wallet_switchEthereumChain':
|
||||
return Promise.resolve(null)
|
||||
|
||||
case 'wallet_getCapabilities': {
|
||||
const [requestedAddress, chainIds] = (params || []) as [
|
||||
Address | undefined,
|
||||
string[] | undefined,
|
||||
]
|
||||
const address = requestedAddress || getAddress()
|
||||
if (!address) {
|
||||
return Promise.resolve({})
|
||||
}
|
||||
const requestedChains = chainIds || [numberToHex(mainnet.id)]
|
||||
const capabilities: Record<string, Record<string, unknown>> = {}
|
||||
for (const chainId of requestedChains) {
|
||||
capabilities[chainId] = {
|
||||
atomicBatch: {
|
||||
supported: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
return Promise.resolve(capabilities)
|
||||
}
|
||||
|
||||
case 'personal_sign': {
|
||||
const [message] = params as [Hex]
|
||||
return signMessage(message)
|
||||
}
|
||||
|
||||
case 'eth_signTypedData_v4': {
|
||||
const [, typedData] = params as [Address, string]
|
||||
return signTypedData(typedData)
|
||||
}
|
||||
|
||||
default: {
|
||||
const client = createPublicClient({
|
||||
chain: mainnet,
|
||||
transport: http(),
|
||||
})
|
||||
|
||||
return client.request({
|
||||
method: method as never,
|
||||
params: params as never,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function statusConnector(options: StatusConnectorOptions) {
|
||||
const { getAddress, signAndSendTransaction, signMessage, signTypedData } =
|
||||
options
|
||||
|
||||
return createConnector(config => {
|
||||
const createProvider = () => {
|
||||
const provider = {
|
||||
isStatus: true,
|
||||
isMetaMask: true,
|
||||
get connected() {
|
||||
return !!getAddress()
|
||||
},
|
||||
chainId: numberToHex(mainnet.id),
|
||||
get selectedAddress() {
|
||||
return getAddress()
|
||||
},
|
||||
request: async ({
|
||||
method,
|
||||
params,
|
||||
}: {
|
||||
method: string
|
||||
params?: unknown[]
|
||||
}) => {
|
||||
return handleProviderRequest(method, params, {
|
||||
getAddress,
|
||||
signAndSendTransaction,
|
||||
signMessage,
|
||||
signTypedData,
|
||||
emitter: config.emitter as unknown as {
|
||||
emit: (event: string, ...args: unknown[]) => void
|
||||
},
|
||||
})
|
||||
},
|
||||
on(event: string, handler: (...args: unknown[]) => void) {
|
||||
config.emitter.on(event as never, handler)
|
||||
},
|
||||
removeListener(event: string, handler: (...args: unknown[]) => void) {
|
||||
config.emitter.off(event as never, handler)
|
||||
},
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
const provider = createProvider()
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
;(window as { ethereum?: unknown }).ethereum = provider
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'status-wallet',
|
||||
name: 'Status Wallet',
|
||||
type: 'injected' as const,
|
||||
|
||||
async connect<withCapabilities extends boolean = false>() {
|
||||
const address = getAddress()
|
||||
if (!address) {
|
||||
throw new Error('No wallet connected')
|
||||
}
|
||||
return {
|
||||
accounts: [address] as readonly Address[],
|
||||
chainId: mainnet.id,
|
||||
} as {
|
||||
accounts: withCapabilities extends true
|
||||
? readonly {
|
||||
address: Address
|
||||
capabilities: Record<string, unknown>
|
||||
}[]
|
||||
: readonly Address[]
|
||||
chainId: number
|
||||
}
|
||||
},
|
||||
|
||||
// required by wagmi's connector interface
|
||||
// empty because we delegate connection state to wallet-context through getAddress()
|
||||
async disconnect() {},
|
||||
|
||||
async getAccounts() {
|
||||
const address = getAddress()
|
||||
return address ? [address] : []
|
||||
},
|
||||
|
||||
async getChainId() {
|
||||
return mainnet.id
|
||||
},
|
||||
|
||||
async isAuthorized() {
|
||||
return !!getAddress()
|
||||
},
|
||||
|
||||
async switchChain({ chainId }) {
|
||||
const chain = config.chains.find(c => c.id === chainId)
|
||||
if (!chain) throw new Error('Chain not found')
|
||||
config.emitter.emit('change', { chainId })
|
||||
return chain
|
||||
},
|
||||
|
||||
onAccountsChanged(accounts) {
|
||||
if (accounts.length === 0) {
|
||||
config.emitter.emit('disconnect')
|
||||
} else {
|
||||
config.emitter.emit('change', { accounts: accounts as Address[] })
|
||||
}
|
||||
},
|
||||
|
||||
onChainChanged(chainId) {
|
||||
config.emitter.emit('change', {
|
||||
chainId: Number(chainId),
|
||||
})
|
||||
},
|
||||
|
||||
onDisconnect() {
|
||||
config.emitter.emit('disconnect')
|
||||
},
|
||||
|
||||
async getProvider() {
|
||||
return provider
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
import { type Address, createPublicClient, type Hex, http } from 'viem'
|
||||
import { mainnet } from 'viem/chains'
|
||||
|
||||
import { apiClient } from './api-client'
|
||||
import { useWallet } from './wallet-context'
|
||||
|
||||
type SignerContextValue = {
|
||||
address: Address | undefined
|
||||
isUnlocked: boolean
|
||||
unlock: (password: string) => Promise<boolean>
|
||||
lock: () => void
|
||||
signAndSendTransaction: (tx: {
|
||||
to: Address
|
||||
value: bigint
|
||||
data?: Hex
|
||||
gas?: bigint
|
||||
maxFeePerGas?: bigint
|
||||
maxPriorityFeePerGas?: bigint
|
||||
}) => Promise<Hex>
|
||||
signMessage: (message: Hex) => Promise<Hex>
|
||||
signTypedData: (typedData: string) => Promise<Hex>
|
||||
requestUnlock: () => Promise<string | null>
|
||||
setUnlockHandler: (handler: () => Promise<string | null>) => void
|
||||
}
|
||||
|
||||
const SignerContext = createContext<SignerContextValue | undefined>(undefined)
|
||||
|
||||
export function useWalletSigner() {
|
||||
const context = useContext(SignerContext)
|
||||
if (!context) {
|
||||
throw new Error('useWalletSigner must be used within SignerProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function SignerProvider({ children }: { children: React.ReactNode }) {
|
||||
const { currentWallet } = useWallet()
|
||||
const [sessionPassword, setSessionPassword] = useState<string | null>(null)
|
||||
const [unlockHandler, setUnlockHandler] = useState<
|
||||
(() => Promise<string | null>) | null
|
||||
>(null)
|
||||
|
||||
const address = useMemo(() => {
|
||||
return currentWallet?.activeAccounts[0]?.address as Address | undefined
|
||||
}, [currentWallet])
|
||||
|
||||
const unlock = useCallback(
|
||||
async (password: string): Promise<boolean> => {
|
||||
if (!currentWallet?.id) return false
|
||||
try {
|
||||
await apiClient.wallet.get.query({
|
||||
walletId: currentWallet.id,
|
||||
password,
|
||||
})
|
||||
setSessionPassword(password)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
[currentWallet?.id],
|
||||
)
|
||||
|
||||
const lock = useCallback(() => {
|
||||
setSessionPassword(null)
|
||||
}, [])
|
||||
|
||||
const requestUnlock = useCallback(async (): Promise<string | null> => {
|
||||
if (sessionPassword) return sessionPassword
|
||||
if (unlockHandler) {
|
||||
const password = await unlockHandler()
|
||||
if (password) {
|
||||
setSessionPassword(password)
|
||||
}
|
||||
return password
|
||||
}
|
||||
return null
|
||||
}, [sessionPassword, unlockHandler])
|
||||
|
||||
const ensurePassword = useCallback(async (): Promise<string> => {
|
||||
let password = sessionPassword
|
||||
if (!password) {
|
||||
password = await requestUnlock()
|
||||
if (!password) {
|
||||
throw new Error('Wallet not unlocked')
|
||||
}
|
||||
}
|
||||
return password
|
||||
}, [sessionPassword, requestUnlock])
|
||||
|
||||
const signAndSendTransaction = useCallback(
|
||||
async (tx: {
|
||||
to: Address
|
||||
value: bigint
|
||||
data?: Hex
|
||||
gas?: bigint
|
||||
maxFeePerGas?: bigint
|
||||
maxPriorityFeePerGas?: bigint
|
||||
}): Promise<Hex> => {
|
||||
if (!currentWallet?.id || !address) {
|
||||
throw new Error('No wallet connected')
|
||||
}
|
||||
|
||||
const password = await ensurePassword()
|
||||
|
||||
let maxFeePerGas = tx.maxFeePerGas?.toString(16)
|
||||
let maxPriorityFeePerGas = tx.maxPriorityFeePerGas?.toString(16)
|
||||
let gasLimit = tx.gas?.toString(16)
|
||||
|
||||
if (!maxFeePerGas || !maxPriorityFeePerGas || !gasLimit) {
|
||||
const publicClient = createPublicClient({
|
||||
chain: mainnet,
|
||||
transport: http(),
|
||||
})
|
||||
|
||||
if (!maxFeePerGas || !maxPriorityFeePerGas) {
|
||||
const [block, priorityFee] = await Promise.all([
|
||||
publicClient.getBlock({ blockTag: 'latest' }),
|
||||
publicClient.request({
|
||||
method: 'eth_maxPriorityFeePerGas',
|
||||
}),
|
||||
])
|
||||
|
||||
const baseFee = block.baseFeePerGas || 0n
|
||||
const priorityFeeBigInt = BigInt(priorityFee as string)
|
||||
maxPriorityFeePerGas = priorityFeeBigInt.toString(16)
|
||||
// maxFeePerGas calculation - https://www.blocknative.com/blog/eip-1559-fees
|
||||
maxFeePerGas = (baseFee * 2n + priorityFeeBigInt).toString(16)
|
||||
}
|
||||
|
||||
if (!gasLimit) {
|
||||
const estimatedGas = await publicClient.estimateGas({
|
||||
account: address,
|
||||
to: tx.to,
|
||||
value: tx.value,
|
||||
data: tx.data,
|
||||
})
|
||||
gasLimit = (estimatedGas + estimatedGas / 10n).toString(16)
|
||||
}
|
||||
}
|
||||
|
||||
const extractTxHash = (id: unknown): string | undefined => {
|
||||
if (typeof id === 'string') {
|
||||
return id
|
||||
}
|
||||
if (id && typeof id === 'object') {
|
||||
const obj = id as Record<string, unknown>
|
||||
if ('result' in obj && typeof obj.result === 'string') {
|
||||
return obj.result
|
||||
}
|
||||
if ('txid' in obj) {
|
||||
return obj.txid as string
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (tx.data) {
|
||||
const ERC20_TRANSFER_SIGNATURE = '0xa9059cbb'
|
||||
const isErc20Transfer = tx.data
|
||||
.toLowerCase()
|
||||
.startsWith(ERC20_TRANSFER_SIGNATURE)
|
||||
|
||||
if (isErc20Transfer) {
|
||||
const result =
|
||||
await apiClient.wallet.account.ethereum.sendErc20.mutate({
|
||||
walletId: currentWallet.id,
|
||||
password,
|
||||
fromAddress: address,
|
||||
toAddress: tx.to,
|
||||
gasLimit,
|
||||
maxFeePerGas,
|
||||
maxInclusionFeePerGas: maxPriorityFeePerGas,
|
||||
data: tx.data,
|
||||
})
|
||||
|
||||
const txHash = extractTxHash(result.id.txid)
|
||||
if (!txHash) throw new Error('Transaction failed')
|
||||
return txHash as Hex
|
||||
}
|
||||
|
||||
const valueHex = tx.value.toString(16)
|
||||
const result =
|
||||
await apiClient.wallet.account.ethereum.sendContractCall.mutate({
|
||||
walletId: currentWallet.id,
|
||||
password,
|
||||
fromAddress: address,
|
||||
toAddress: tx.to,
|
||||
gasLimit,
|
||||
maxFeePerGas,
|
||||
maxInclusionFeePerGas: maxPriorityFeePerGas,
|
||||
data: tx.data,
|
||||
value: valueHex,
|
||||
})
|
||||
|
||||
if (result.id.txid?.error) {
|
||||
console.error('Contract call error:', result.id.txid.error)
|
||||
throw new Error(result.id.txid.error)
|
||||
}
|
||||
|
||||
const txHash = extractTxHash(result.id.txid)
|
||||
if (!txHash) throw new Error('Transaction failed')
|
||||
return txHash as Hex
|
||||
}
|
||||
|
||||
const amountHex = tx.value.toString(16)
|
||||
const result = await apiClient.wallet.account.ethereum.send.mutate({
|
||||
walletId: currentWallet.id,
|
||||
password,
|
||||
fromAddress: address,
|
||||
toAddress: tx.to,
|
||||
amount: amountHex,
|
||||
gasLimit,
|
||||
maxFeePerGas,
|
||||
maxInclusionFeePerGas: maxPriorityFeePerGas,
|
||||
})
|
||||
|
||||
const txHash = extractTxHash(result.id.txid)
|
||||
if (!txHash) throw new Error('Transaction failed')
|
||||
return txHash as Hex
|
||||
},
|
||||
[currentWallet?.id, address, ensurePassword],
|
||||
)
|
||||
|
||||
const signMessage = useCallback(
|
||||
async (message: Hex): Promise<Hex> => {
|
||||
if (!currentWallet?.id || !address) {
|
||||
throw new Error('No wallet connected')
|
||||
}
|
||||
|
||||
const password = await ensurePassword()
|
||||
|
||||
const result = await apiClient.wallet.account.ethereum.signMessage.mutate(
|
||||
{
|
||||
walletId: currentWallet.id,
|
||||
password,
|
||||
fromAddress: address,
|
||||
message,
|
||||
},
|
||||
)
|
||||
|
||||
return result.signature as Hex
|
||||
},
|
||||
[currentWallet?.id, address, ensurePassword],
|
||||
)
|
||||
|
||||
const signTypedData = useCallback(
|
||||
async (typedData: string): Promise<Hex> => {
|
||||
if (!currentWallet?.id || !address) {
|
||||
throw new Error('No wallet connected')
|
||||
}
|
||||
|
||||
const password = await ensurePassword()
|
||||
|
||||
const parsed = JSON.parse(typedData)
|
||||
|
||||
const result =
|
||||
await apiClient.wallet.account.ethereum.signTypedData.mutate({
|
||||
walletId: currentWallet.id,
|
||||
password,
|
||||
fromAddress: address,
|
||||
domain: parsed.domain,
|
||||
types: parsed.types,
|
||||
primaryType: parsed.primaryType,
|
||||
message: parsed.message,
|
||||
})
|
||||
|
||||
return result.signature as Hex
|
||||
},
|
||||
[currentWallet?.id, address, ensurePassword],
|
||||
)
|
||||
|
||||
const handleSetUnlockHandler = useCallback(
|
||||
(handler: () => Promise<string | null>) => {
|
||||
setUnlockHandler(() => handler)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const value: SignerContextValue = useMemo(
|
||||
() => ({
|
||||
address,
|
||||
isUnlocked: !!sessionPassword,
|
||||
unlock,
|
||||
lock,
|
||||
signAndSendTransaction,
|
||||
signMessage,
|
||||
signTypedData,
|
||||
requestUnlock,
|
||||
setUnlockHandler: handleSetUnlockHandler,
|
||||
}),
|
||||
[
|
||||
address,
|
||||
sessionPassword,
|
||||
unlock,
|
||||
lock,
|
||||
signAndSendTransaction,
|
||||
signMessage,
|
||||
signTypedData,
|
||||
requestUnlock,
|
||||
handleSetUnlockHandler,
|
||||
],
|
||||
)
|
||||
|
||||
return (
|
||||
<SignerContext.Provider value={value}>{children}</SignerContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useRef } from 'react'
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { createConfig, http, WagmiProvider } from 'wagmi'
|
||||
import { mainnet } from 'wagmi/chains'
|
||||
|
||||
import { statusConnector } from '../lib/status-connector'
|
||||
import { useWalletSigner } from './signer-context'
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function WagmiConfigProvider({ children }: Props) {
|
||||
const { address, signAndSendTransaction, signMessage, signTypedData } =
|
||||
useWalletSigner()
|
||||
const addressRef = useRef(address)
|
||||
const signAndSendTransactionRef = useRef(signAndSendTransaction)
|
||||
const signMessageRef = useRef(signMessage)
|
||||
const signTypedDataRef = useRef(signTypedData)
|
||||
|
||||
addressRef.current = address
|
||||
signAndSendTransactionRef.current = signAndSendTransaction
|
||||
signMessageRef.current = signMessage
|
||||
signTypedDataRef.current = signTypedData
|
||||
|
||||
const config = useMemo(() => {
|
||||
return createConfig({
|
||||
chains: [mainnet],
|
||||
connectors: [
|
||||
statusConnector({
|
||||
getAddress: () => addressRef.current,
|
||||
signAndSendTransaction: async tx => {
|
||||
return signAndSendTransactionRef.current(tx)
|
||||
},
|
||||
signMessage: async message => {
|
||||
return signMessageRef.current(message)
|
||||
},
|
||||
signTypedData: async typedData => {
|
||||
return signTypedDataRef.current(typedData)
|
||||
},
|
||||
}),
|
||||
],
|
||||
transports: {
|
||||
[mainnet.id]: http(),
|
||||
},
|
||||
ssr: false,
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<WagmiProvider config={config} reconnectOnMount={true}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</WagmiProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export { WagmiConfigProvider }
|
||||
@@ -1,27 +1,17 @@
|
||||
// import { Suspense } from 'react'
|
||||
|
||||
// import OnboardingPage from '../../../portfolio/src/app/page'
|
||||
import { ToastContainer } from '@status-im/components'
|
||||
import { Navbar } from '@status-im/wallet/components'
|
||||
// import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import {
|
||||
createRootRouteWithContext,
|
||||
HeadContent,
|
||||
// Navigate,
|
||||
Outlet,
|
||||
redirect,
|
||||
} from '@tanstack/react-router'
|
||||
|
||||
// import { TanStackRouterDevtools } from '@tanstack/router-devtools'
|
||||
// import { NotAllowed } from '../../../portfolio/src/app/_components/not-allowed'
|
||||
// import { AccountsProvider } from '../../../portfolio/src/app/_providers/accounts-context'
|
||||
// import { ConnectKitProvider } from '../../../portfolio/src/app/_providers/connectkit-provider'
|
||||
import { QueryClientProvider } from '../../../portfolio/src/app/_providers/query-client-provider'
|
||||
// import { StatusProvider } from '../../../portfolio/src/app/_providers/status-provider'
|
||||
import { WagmiProvider } from '../../../portfolio/src/app/_providers/wagmi-provider'
|
||||
import { Link } from '../components/link'
|
||||
import { apiClient } from '../providers/api-client'
|
||||
import { PendingTransactionsProvider } from '../providers/pending-transactions-context'
|
||||
import { SignerProvider } from '../providers/signer-context'
|
||||
import { WagmiConfigProvider } from '../providers/wagmi-provider'
|
||||
import { WalletProvider } from '../providers/wallet-context'
|
||||
|
||||
// import { Inter } from 'next/font/google'
|
||||
@@ -131,29 +121,12 @@ export const Route = createRootRouteWithContext<{
|
||||
function RootComponent() {
|
||||
return (
|
||||
<>
|
||||
{/* <div className="min-h-screen bg-neutral-100 text-white-100">
|
||||
<div className="flex gap-4 p-4">
|
||||
<Link to="/" className="[&.active]:font-bold">
|
||||
Home
|
||||
</Link>
|
||||
<Link to="/onboarding" className="[&.active]:font-bold">
|
||||
Onboarding
|
||||
</Link>
|
||||
</div>
|
||||
<hr />
|
||||
<Outlet />
|
||||
</div> */}
|
||||
|
||||
<HeadContent />
|
||||
|
||||
<div id="app" className="isolate" data-customisation="blue">
|
||||
{/* <StatusProvider> */}
|
||||
<WagmiProvider>
|
||||
<QueryClientProvider>
|
||||
{/* <Suspense fallback={<div>Loading...</div>}> */}
|
||||
{/* <AccountsProvider> */}
|
||||
{/* <ConnectKitProvider> */}
|
||||
<WalletProvider>
|
||||
<WalletProvider>
|
||||
<SignerProvider>
|
||||
<WagmiConfigProvider>
|
||||
<PendingTransactionsProvider>
|
||||
<div className="flex min-h-[56px] items-center px-2">
|
||||
<Navbar hasFeedback linkComponent={Link} />
|
||||
@@ -169,21 +142,10 @@ function RootComponent() {
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</PendingTransactionsProvider>
|
||||
</WalletProvider>
|
||||
{/* </ConnectKitProvider> */}
|
||||
{/* </AccountsProvider> */}
|
||||
{/* </Suspense> */}
|
||||
</QueryClientProvider>
|
||||
</WagmiProvider>
|
||||
{/* </StatusProvider> */}
|
||||
</WagmiConfigProvider>
|
||||
</SignerProvider>
|
||||
</WalletProvider>
|
||||
</div>
|
||||
{/* <ReactQueryDevtools buttonPosition="bottom-right" />
|
||||
<TanStackRouterDevtools position="bottom-left" />
|
||||
<div className="fixed inset-x-0 bottom-0 flex justify-center gap-4 bg-blur-neutral-100/70 p-4 text-white-100">
|
||||
<Link to="/">/index</Link>
|
||||
<Link to="/onboarding">/onboarding</Link>
|
||||
<Link to="/portfolio/assets">/portfolio</Link>
|
||||
</div> */}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
BuyIcon,
|
||||
ReceiveBlurIcon,
|
||||
SendBlurIcon,
|
||||
SwapIcon,
|
||||
} from '@status-im/icons/20'
|
||||
import {
|
||||
type Account,
|
||||
@@ -41,7 +42,9 @@ import { useEthBalance } from '@/hooks/use-eth-balance'
|
||||
import { renderMarkdown } from '@/lib/markdown'
|
||||
import { apiClient } from '@/providers/api-client'
|
||||
import { usePendingTransactions } from '@/providers/pending-transactions-context'
|
||||
import { useWalletSigner } from '@/providers/signer-context'
|
||||
import { useWallet } from '@/providers/wallet-context'
|
||||
import { ExchangeDrawer } from '~/components/exchange-drawer'
|
||||
|
||||
import { AssetChart } from './asset-chart'
|
||||
import {
|
||||
@@ -80,6 +83,7 @@ const Token = (props: Props) => {
|
||||
const toast = useToast()
|
||||
const { currentWallet } = useWallet()
|
||||
const { addPendingTransaction } = usePendingTransactions()
|
||||
const { isUnlocked, unlock } = useWalletSigner()
|
||||
|
||||
const [activeDataType, setActiveDataType] =
|
||||
useState<ChartDataType>(DEFAULT_DATA_TYPE)
|
||||
@@ -305,6 +309,12 @@ const Token = (props: Props) => {
|
||||
decimals: asset.decimals ?? 18,
|
||||
}
|
||||
|
||||
const isNativeETH = finalTokenDetail.summary.symbol === 'ETH'
|
||||
const fromTokenAddress = isNativeETH
|
||||
? '0x0000000000000000000000000000000000000000'
|
||||
: finalTokenDetail.summary.contracts?.ethereum ||
|
||||
(ticker.startsWith('0x') ? ticker : undefined)
|
||||
|
||||
// Mock wallet data. Replace with actual wallet data from the user's account.
|
||||
const account: Account = {
|
||||
address,
|
||||
@@ -469,6 +479,19 @@ const Token = (props: Props) => {
|
||||
<span className="block max-w-20 truncate">Buy</span>
|
||||
</Button>
|
||||
</BuyCryptoDrawer>
|
||||
{fromTokenAddress && (
|
||||
<ExchangeDrawer
|
||||
account={account}
|
||||
fromChain={1}
|
||||
fromToken={fromTokenAddress}
|
||||
isUnlocked={isUnlocked}
|
||||
onUnlock={unlock}
|
||||
>
|
||||
<Button size="32" iconBefore={<SwapIcon />} variant="outline">
|
||||
<span className="block max-w-20 truncate">Exchange</span>
|
||||
</Button>
|
||||
</ExchangeDrawer>
|
||||
)}
|
||||
<ReceiveCryptoDrawer account={account} onCopy={copy}>
|
||||
<Button
|
||||
size="32"
|
||||
@@ -523,6 +546,20 @@ const Token = (props: Props) => {
|
||||
</Button>
|
||||
</BuyCryptoDrawer>
|
||||
|
||||
{fromTokenAddress && (
|
||||
<ExchangeDrawer
|
||||
account={account}
|
||||
fromChain={1}
|
||||
fromToken={fromTokenAddress}
|
||||
isUnlocked={isUnlocked}
|
||||
onUnlock={unlock}
|
||||
>
|
||||
<Button size="32" iconBefore={<SwapIcon />} variant="outline">
|
||||
Exchange
|
||||
</Button>
|
||||
</ExchangeDrawer>
|
||||
)}
|
||||
|
||||
<ReceiveCryptoDrawer account={account} onCopy={copy}>
|
||||
<Button
|
||||
size="32"
|
||||
|
||||
@@ -18,10 +18,12 @@ export default defineConfig({
|
||||
mode === 'production'
|
||||
? "'self' 'wasm-unsafe-eval'"
|
||||
: "'self' 'wasm-unsafe-eval' http://localhost:4000/ http://localhost:8097/"
|
||||
const rpcEndpoints =
|
||||
'https://eth.merkle.io/ https://ethereum-rpc.publicnode.com/ https://rpc.ankr.com/ https://nodes.mewapi.io/ https://mainnet.infura.io/ https://cloudflare-eth.com/ https://rpc.flashbots.net/ https://rpc.tenderly.co/ https://rpc.ethernode.com/ https://ethereum.publicnode.com/ https://eth.drpc.org/'
|
||||
const connectSrc =
|
||||
mode === 'production'
|
||||
? 'https://status-api-status-im-web.vercel.app/ https://status-api-status-im-web.vercel.app/api/'
|
||||
: 'ws: http://localhost:3030/ https://localhost:3030/'
|
||||
? `https://status-api-status-im-web.vercel.app/ https://status-api-status-im-web.vercel.app/api/ https://li.quest/ https://registry.npmjs.org/ ${rpcEndpoints}`
|
||||
: `ws: http://localhost:3030/ https://localhost:3030/ https://li.quest/ https://registry.npmjs.org/ ${rpcEndpoints}`
|
||||
|
||||
return {
|
||||
version: '0.1.0',
|
||||
|
||||
@@ -13,7 +13,7 @@ const Close = forwardRef<
|
||||
React.ComponentRef<typeof Dialog.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof Dialog.Close> & { children?: never }
|
||||
>((props, ref) => (
|
||||
<div className="absolute right-3 top-3">
|
||||
<div className="absolute right-3 top-3 z-50">
|
||||
<Dialog.Close {...props} ref={ref} asChild>
|
||||
<Button
|
||||
icon={<CloseIcon />}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import * as Drawer from '../drawer'
|
||||
|
||||
export type ExchangeDrawerProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
trigger: React.ReactElement
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export const ExchangeDrawer = (props: ExchangeDrawerProps) => {
|
||||
const { open, onOpenChange, trigger, children } = props
|
||||
|
||||
return (
|
||||
<Drawer.Root modal open={open} onOpenChange={onOpenChange}>
|
||||
<Drawer.Trigger asChild>{trigger}</Drawer.Trigger>
|
||||
<Drawer.Content className="p-0">
|
||||
<Drawer.Title className="sr-only">Exchange</Drawer.Title>
|
||||
<Drawer.Body className="flex h-full flex-col overflow-hidden">
|
||||
<div className="h-10 flex-shrink-0" />
|
||||
<div className="min-h-0 flex-1">{children}</div>
|
||||
</Drawer.Body>
|
||||
</Drawer.Content>
|
||||
</Drawer.Root>
|
||||
)
|
||||
}
|
||||
@@ -33,9 +33,11 @@ export {
|
||||
} from './create-password-form'
|
||||
export { CurrencyAmount } from './currency-amount'
|
||||
export { DeleteAddressAlert } from './delete-address-alert'
|
||||
export * as Drawer from './drawer'
|
||||
export { DropdownSort } from './dropdown-sort'
|
||||
export { EmptyState } from './empty-state'
|
||||
export { EmptyStateActions } from './empty-state-actions'
|
||||
export { ExchangeDrawer, type ExchangeDrawerProps } from './exchange-drawer'
|
||||
export { FeedbackPopover, FeedbackSection } from './feedback'
|
||||
export { Image, type ImageProps } from './image'
|
||||
export {
|
||||
|
||||
@@ -106,11 +106,13 @@ const PasswordModal = (props: PasswordModalProps) => {
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
<p className="mb-5 text-13 text-neutral-50">To sign transaction</p>
|
||||
<Dialog.Description className="mb-5 text-13 text-neutral-50">
|
||||
To sign transaction
|
||||
</Dialog.Description>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex flex-1 flex-col place-content-between content-between justify-between"
|
||||
className="flex flex-1 flex-col place-content-between"
|
||||
>
|
||||
<div className="mb-4 w-full">
|
||||
<Controller
|
||||
@@ -122,6 +124,7 @@ const PasswordModal = (props: PasswordModalProps) => {
|
||||
{...field}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Type password"
|
||||
aria-label="Password"
|
||||
isInvalid={!!errors.password}
|
||||
isDisabled={isLoading}
|
||||
onKeyDown={e => {
|
||||
@@ -133,6 +136,9 @@ const PasswordModal = (props: PasswordModalProps) => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
aria-label={
|
||||
showPassword ? 'Hide password' : 'Show password'
|
||||
}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-neutral-50 hover:text-neutral-70"
|
||||
>
|
||||
{showPassword ? <HideIcon /> : <RevealIcon />}
|
||||
|
||||
Generated
+6361
-356
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user