From 091d66617dc69be5af91e9710f772eedaae17aeb Mon Sep 17 00:00:00 2001 From: shan Date: Tue, 12 Nov 2024 15:07:55 +0800 Subject: [PATCH] solana ledger sign in --- .../huma-shared/src/services/AuthService.ts | 18 ++ .../src/hooks/useAuthErrorHandling.ts | 250 ------------------ .../src/hooks/useAuthErrorHandling/index.ts | 89 +++++++ .../useAuthErrorHandingEvm.ts | 105 ++++++++ .../useAuthErrorHandingSolana.ts | 176 ++++++++++++ 5 files changed, 388 insertions(+), 250 deletions(-) delete mode 100644 packages/huma-web-shared/src/hooks/useAuthErrorHandling.ts create mode 100644 packages/huma-web-shared/src/hooks/useAuthErrorHandling/index.ts create mode 100644 packages/huma-web-shared/src/hooks/useAuthErrorHandling/useAuthErrorHandingEvm.ts create mode 100644 packages/huma-web-shared/src/hooks/useAuthErrorHandling/useAuthErrorHandingSolana.ts diff --git a/packages/huma-shared/src/services/AuthService.ts b/packages/huma-shared/src/services/AuthService.ts index 24daa5cb..08b81e97 100644 --- a/packages/huma-shared/src/services/AuthService.ts +++ b/packages/huma-shared/src/services/AuthService.ts @@ -33,7 +33,25 @@ const verifySignature = async ( }, ) +const verifySolanaTx = async ( + message: string, + serializedTx: number[], + chainId: number, + isDev: boolean = false, +): Promise => + requestPost( + `${configUtil.getAuthServiceUrl( + chainId, + isDev, + )}/verify-signature?chainId=${chainId}`, + { + message, + serializedTx, + }, + ) + export const AuthService = { createSession, verifySignature, + verifySolanaTx, } diff --git a/packages/huma-web-shared/src/hooks/useAuthErrorHandling.ts b/packages/huma-web-shared/src/hooks/useAuthErrorHandling.ts deleted file mode 100644 index 523a3510..00000000 --- a/packages/huma-web-shared/src/hooks/useAuthErrorHandling.ts +++ /dev/null @@ -1,250 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { JsonRpcProvider } from '@ethersproject/providers' -import { - AuthService, - CHAIN_TYPE, - SiwsMessage, - SOLANA_CHAINS, - SolanaChainEnum, -} from '@huma-finance/shared' -import { useWallet } from '@solana/wallet-adapter-react' -import { useWeb3React } from '@web3-react/core' -import axios, { HttpStatusCode } from 'axios' -import bs58 from 'bs58' -import { useCallback, useEffect, useState } from 'react' -import { SiweMessage } from 'siwe' -import { useAsyncError } from './useAsyncError' - -type ErrorType = 'NotSignedIn' | 'UserRejected' | 'Other' - -const createSiweMessage = ( - address: string, - chainId: number, - nonce: string, - expiresAt: string, -) => { - const domain = window.location.hostname - const message = new SiweMessage({ - domain, - address, - statement: 'Please sign in to verify your ownership of this wallet', - uri: window.location.origin, - version: '1', - chainId, - nonce, - expirationTime: expiresAt, - }) - return message.prepareMessage() -} - -const createSiwsMessage = ( - address: string, - chainId: SolanaChainEnum, - nonce: string, - expiresAt: string, -) => { - const domain = window.location.hostname - const message = new SiwsMessage({ - domain, - address, - statement: 'Please sign in to verify your ownership of this wallet', - uri: window.location.origin, - version: '1', - chainId: SOLANA_CHAINS[chainId].name, - nonce, - expirationTime: expiresAt, - }) - return message.prepareMessage() -} - -const verifyEvmOwnership = async ( - address: string, - chainId: number, - isDev: boolean, - provider: JsonRpcProvider, - onVerificationComplete: () => void, -) => { - const { nonce, expiresAt } = await AuthService.createSession(chainId, isDev) - const message = createSiweMessage(address, chainId, nonce, expiresAt) - const signer = await provider.getSigner() - const signature = await signer.signMessage(message) - await AuthService.verifySignature(message, signature, chainId, isDev) - onVerificationComplete() -} - -const verifySolanaOwnership = async ( - address: string, - chainId: number, - isDev: boolean, - solanaSignMessage: (message: Uint8Array) => Promise, - onVerificationComplete: () => void, -) => { - try { - const { nonce, expiresAt } = await AuthService.createSession(chainId, isDev) - const message = createSiwsMessage(address, chainId, nonce, expiresAt) - const encodedMessage = new TextEncoder().encode(message) - const signedMessage = await solanaSignMessage(encodedMessage) - const signatureEncoded = bs58.encode(signedMessage as Uint8Array) - - await AuthService.verifySignature(message, signatureEncoded, chainId, isDev) - onVerificationComplete() - } catch (error) { - console.error(error) - } -} - -export type AuthState = { - isWalletOwnershipVerificationRequired: boolean - isWalletOwnershipVerified: boolean - errorType?: ErrorType - error: unknown - setError: React.Dispatch> - reset: () => void -} - -export const useAuthErrorHandling = ( - isDev: boolean, - chainType: CHAIN_TYPE = CHAIN_TYPE.EVM, -): AuthState => { - const [error, setError] = useState(null) - const [isVerificationRequired, setIsVerificationRequired] = - useState(false) - const [isVerified, setIsVerified] = useState(false) - const throwError = useAsyncError() - const handleVerificationCompletion = () => { - setIsVerified(true) - } - const [errorType, setErrorType] = useState() - - const { - account: evmAccount, - chainId: evmChainId, - provider: evmProvider, - } = useWeb3React() - const { publicKey: solanaPublicKey, signMessage: solanaSignMessage } = - useWallet() - const solanaAccount = solanaPublicKey?.toString() ?? '' - - const getErrorInfo = useCallback((error: any) => { - const isUnauthorizedError = - axios.isAxiosError(error) && - error.response?.status === HttpStatusCode.Unauthorized && - [ - 'IdTokenNotFoundException', - 'InvalidIdTokenException', - 'WalletMismatchException', - ].includes(error.response?.data?.detail?.type) - - const isWalletNotCreatedError = error === 'WalletNotCreatedException' - const isWalletNotSignInError = error === 'WalletNotSignInException' - - return { - isUnauthorizedError, - isWalletNotCreatedError, - isWalletNotSignInError, - } - }, []) - - useEffect(() => { - if (chainType === CHAIN_TYPE.EVM) { - if (!evmAccount || !evmChainId || !error || !evmProvider) { - return - } - - const { - isUnauthorizedError, - isWalletNotCreatedError, - isWalletNotSignInError, - } = getErrorInfo(error) - - if ( - isUnauthorizedError || - isWalletNotCreatedError || - isWalletNotSignInError - ) { - setErrorType('NotSignedIn') - setIsVerificationRequired(true) - if (chainType === CHAIN_TYPE.EVM) { - verifyEvmOwnership( - evmAccount!, - evmChainId!, - isDev, - evmProvider!, - handleVerificationCompletion, - ).catch((e) => setError(e)) - } - } else if ([4001, 'ACTION_REJECTED'].includes((error as any).code)) { - setErrorType('UserRejected') - } else { - setErrorType('Other') - } - } - }, [ - evmChainId, - isDev, - error, - throwError, - evmAccount, - evmProvider, - getErrorInfo, - chainType, - ]) - - useEffect(() => { - if (chainType === CHAIN_TYPE.SOLANA) { - if (!solanaAccount || !error || !solanaSignMessage) { - return - } - - const { - isUnauthorizedError, - isWalletNotCreatedError, - isWalletNotSignInError, - } = getErrorInfo(error) - - if ( - isUnauthorizedError || - isWalletNotCreatedError || - isWalletNotSignInError - ) { - setErrorType('NotSignedIn') - setIsVerificationRequired(true) - verifySolanaOwnership( - solanaAccount, - isDev ? SolanaChainEnum.SolanaDevnet : SolanaChainEnum.SolanaMainnet, - isDev, - solanaSignMessage, - handleVerificationCompletion, - ).catch((e) => setError(e)) - } else if ([4001, 'ACTION_REJECTED'].includes((error as any).code)) { - setErrorType('UserRejected') - } else { - setErrorType('Other') - } - } - }, [ - isDev, - error, - throwError, - chainType, - solanaAccount, - getErrorInfo, - solanaSignMessage, - ]) - - const reset = useCallback(() => { - setIsVerificationRequired(false) - setIsVerified(false) - setError(null) - setErrorType(undefined) - }, []) - - return { - isWalletOwnershipVerificationRequired: isVerificationRequired, - isWalletOwnershipVerified: isVerified, - errorType, - error, - setError, - reset, - } -} diff --git a/packages/huma-web-shared/src/hooks/useAuthErrorHandling/index.ts b/packages/huma-web-shared/src/hooks/useAuthErrorHandling/index.ts new file mode 100644 index 00000000..c3eda398 --- /dev/null +++ b/packages/huma-web-shared/src/hooks/useAuthErrorHandling/index.ts @@ -0,0 +1,89 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { CHAIN_TYPE } from '@huma-finance/shared' +import axios, { HttpStatusCode } from 'axios' +import { useCallback, useState } from 'react' +import { useAuthErrorHandingEvm } from './useAuthErrorHandingEvm' +import { useAuthErrorHandingSolana } from './useAuthErrorHandingSolana' + +export type ErrorType = 'NotSignedIn' | 'UserRejected' | 'Other' + +export type AuthState = { + isWalletOwnershipVerificationRequired: boolean + isWalletOwnershipVerified: boolean + errorType?: ErrorType + error: unknown + setError: React.Dispatch> + reset: () => void +} + +export const useAuthErrorHandling = ( + isDev: boolean, + chainType: CHAIN_TYPE = CHAIN_TYPE.EVM, +): AuthState => { + const [error, setError] = useState(null) + const [isVerified, setIsVerified] = useState(false) + const [errorType, setErrorType] = useState() + const [isVerificationRequired, setIsVerificationRequired] = + useState(false) + + const handleVerificationCompletion = useCallback(() => { + setIsVerified(true) + }, []) + + const getErrorInfo = useCallback((error: any) => { + const isUnauthorizedError = + axios.isAxiosError(error) && + error.response?.status === HttpStatusCode.Unauthorized && + [ + 'IdTokenNotFoundException', + 'InvalidIdTokenException', + 'WalletMismatchException', + ].includes(error.response?.data?.detail?.type) + + const isWalletNotCreatedError = error === 'WalletNotCreatedException' + const isWalletNotSignInError = error === 'WalletNotSignInException' + + return { + isUnauthorizedError, + isWalletNotCreatedError, + isWalletNotSignInError, + } + }, []) + + useAuthErrorHandingEvm( + chainType, + isDev, + error, + getErrorInfo, + setError, + setErrorType, + setIsVerificationRequired, + handleVerificationCompletion, + ) + useAuthErrorHandingSolana( + chainType, + isDev, + error, + getErrorInfo, + setError, + setErrorType, + setIsVerificationRequired, + handleVerificationCompletion, + ) + + const reset = useCallback(() => { + setIsVerificationRequired(false) + setIsVerified(false) + setError(null) + setErrorType(undefined) + }, []) + + return { + isWalletOwnershipVerificationRequired: isVerificationRequired, + isWalletOwnershipVerified: isVerified, + errorType, + error, + setError, + reset, + } +} diff --git a/packages/huma-web-shared/src/hooks/useAuthErrorHandling/useAuthErrorHandingEvm.ts b/packages/huma-web-shared/src/hooks/useAuthErrorHandling/useAuthErrorHandingEvm.ts new file mode 100644 index 00000000..701dac45 --- /dev/null +++ b/packages/huma-web-shared/src/hooks/useAuthErrorHandling/useAuthErrorHandingEvm.ts @@ -0,0 +1,105 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { JsonRpcProvider } from '@ethersproject/providers' +import { AuthService, CHAIN_TYPE } from '@huma-finance/shared' +import { useWeb3React } from '@web3-react/core' +import { useEffect } from 'react' +import { SiweMessage } from 'siwe' +import { ErrorType } from '.' + +const createSiweMessage = ( + address: string, + chainId: number, + nonce: string, + expiresAt: string, +) => { + const domain = window.location.hostname + const message = new SiweMessage({ + domain, + address, + statement: 'Please sign in to verify your ownership of this wallet', + uri: window.location.origin, + version: '1', + chainId, + nonce, + expirationTime: expiresAt, + }) + return message.prepareMessage() +} + +export const verifyOwnershipEvm = async ( + address: string, + chainId: number, + isDev: boolean, + provider: JsonRpcProvider, + onVerificationComplete: () => void, +) => { + const { nonce, expiresAt } = await AuthService.createSession(chainId, isDev) + const message = createSiweMessage(address, chainId, nonce, expiresAt) + const signer = await provider.getSigner() + const signature = await signer.signMessage(message) + await AuthService.verifySignature(message, signature, chainId, isDev) + onVerificationComplete() +} + +export const useAuthErrorHandingEvm = ( + chainType: CHAIN_TYPE, + isDev: boolean, + error: any, + getErrorInfo: (error: any) => { + isUnauthorizedError: boolean + isWalletNotCreatedError: boolean + isWalletNotSignInError: boolean + }, + setError: (error: any) => void, + setErrorType: (errorType: ErrorType) => void, + setIsVerificationRequired: (isVerificationRequired: boolean) => void, + handleVerificationCompletion: () => void, +) => { + const { account, chainId, provider } = useWeb3React() + + useEffect(() => { + if (chainType === CHAIN_TYPE.EVM) { + if (!account || !chainId || !error || !provider) { + return + } + + const { + isUnauthorizedError, + isWalletNotCreatedError, + isWalletNotSignInError, + } = getErrorInfo(error) + + if ( + isUnauthorizedError || + isWalletNotCreatedError || + isWalletNotSignInError + ) { + setErrorType('NotSignedIn') + setIsVerificationRequired(true) + verifyOwnershipEvm( + account, + chainId, + isDev, + provider, + handleVerificationCompletion, + ).catch((e) => setError(e)) + } else if ([4001, 'ACTION_REJECTED'].includes((error as any).code)) { + setErrorType('UserRejected') + } else { + setErrorType('Other') + } + } + }, [ + account, + chainId, + chainType, + error, + getErrorInfo, + handleVerificationCompletion, + isDev, + provider, + setError, + setErrorType, + setIsVerificationRequired, + ]) +} diff --git a/packages/huma-web-shared/src/hooks/useAuthErrorHandling/useAuthErrorHandingSolana.ts b/packages/huma-web-shared/src/hooks/useAuthErrorHandling/useAuthErrorHandingSolana.ts new file mode 100644 index 00000000..b4570fb3 --- /dev/null +++ b/packages/huma-web-shared/src/hooks/useAuthErrorHandling/useAuthErrorHandingSolana.ts @@ -0,0 +1,176 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + AuthService, + CHAIN_TYPE, + SiwsMessage, + SOLANA_CHAINS, + SolanaChainEnum, +} from '@huma-finance/shared' +import { WalletName } from '@solana/wallet-adapter-base' +import { useConnection, useWallet, Wallet } from '@solana/wallet-adapter-react' +import { + LedgerWalletName, + PhantomWalletName, +} from '@solana/wallet-adapter-wallets' +import { + Connection, + PublicKey, + Transaction, + TransactionInstruction, +} from '@solana/web3.js' +import bs58 from 'bs58' +import { useEffect } from 'react' +import { ErrorType } from '.' + +const WALLETS_WITH_SERIALIZED_TX: WalletName[] = [ + LedgerWalletName, + PhantomWalletName, +] + +const createSiwsMessage = ( + address: string, + chainId: SolanaChainEnum, + nonce: string, + expiresAt: string, +) => { + const domain = window.location.hostname + const message = new SiwsMessage({ + domain, + address, + statement: 'Please sign in to verify your ownership of this wallet', + uri: window.location.origin, + version: '1', + chainId: SOLANA_CHAINS[chainId].name, + nonce, + expirationTime: expiresAt, + }) + return message.prepareMessage() +} + +const buildAuthTx = async (nonce: string): Promise => { + const tx = new Transaction() + + const PROGRAM_ID = new PublicKey( + 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr', + ) + + tx.add( + new TransactionInstruction({ + programId: PROGRAM_ID, + keys: [], + data: Buffer.from(nonce, 'utf8'), + }), + ) + return tx +} + +const verifyOwnershipSolana = async ( + wallet: Wallet, + connection: Connection, + address: string, + chainId: number, + isDev: boolean, + signTransaction: (transaction: Transaction) => Promise, + signMessage: (message: Uint8Array) => Promise, + onVerificationComplete: () => void, +) => { + try { + const { nonce, expiresAt } = await AuthService.createSession(chainId, isDev) + const message = createSiwsMessage(address, chainId, nonce, expiresAt) + + // Wallets that require serialized tx for example Ledger. Followed this article: + // https://medium.com/@legendaryangelist/how-to-implement-message-signing-with-the-ledger-on-solana-50a4a925e752 + if (WALLETS_WITH_SERIALIZED_TX.includes(wallet.adapter.name)) { + const tx = await buildAuthTx(message) + tx.feePayer = new PublicKey(address) + tx.recentBlockhash = (await connection.getLatestBlockhash()).blockhash + const signedTx = await signTransaction(tx) + const serializedTx = Array.from(signedTx.serialize()) + await AuthService.verifySolanaTx(message, serializedTx, chainId, isDev) + } else { + const encodedMessage = new TextEncoder().encode(message) + const signedMessage = await signMessage(encodedMessage) + const signatureEncoded = bs58.encode(signedMessage as Uint8Array) + await AuthService.verifySignature( + message, + signatureEncoded, + chainId, + isDev, + ) + } + + onVerificationComplete() + } catch (error) { + console.error(error) + } +} + +export const useAuthErrorHandingSolana = ( + chainType: CHAIN_TYPE, + isDev: boolean, + error: any, + getErrorInfo: (error: any) => { + isUnauthorizedError: boolean + isWalletNotCreatedError: boolean + isWalletNotSignInError: boolean + }, + setError: (error: any) => void, + setErrorType: (errorType: ErrorType) => void, + setIsVerificationRequired: (isVerificationRequired: boolean) => void, + handleVerificationCompletion: () => void, +) => { + const { connection } = useConnection() + const { publicKey, signMessage, wallet, signTransaction } = useWallet() + const account = publicKey?.toString() ?? '' + + useEffect(() => { + if (chainType === CHAIN_TYPE.SOLANA) { + if (!wallet || !account || !error || !signMessage || !signTransaction) { + return + } + + const { + isUnauthorizedError, + isWalletNotCreatedError, + isWalletNotSignInError, + } = getErrorInfo(error) + + if ( + isUnauthorizedError || + isWalletNotCreatedError || + isWalletNotSignInError + ) { + setErrorType('NotSignedIn') + setIsVerificationRequired(true) + verifyOwnershipSolana( + wallet, + connection, + account, + isDev ? SolanaChainEnum.SolanaDevnet : SolanaChainEnum.SolanaMainnet, + isDev, + signTransaction, + signMessage, + handleVerificationCompletion, + ).catch((e) => setError(e)) + } else if ([4001, 'ACTION_REJECTED'].includes((error as any).code)) { + setErrorType('UserRejected') + } else { + setErrorType('Other') + } + } + }, [ + account, + chainType, + connection, + error, + getErrorInfo, + handleVerificationCompletion, + isDev, + setError, + setErrorType, + setIsVerificationRequired, + signMessage, + signTransaction, + wallet, + ]) +}