import { BigNumber, utils } from "ethers"; import { createContext, ReactNode, useContext, useEffect, useState } from "react"; import * as encoding from "@walletconnect/encoding"; import { formatDirectSignDoc, stringifySignDocValues } from "cosmos-wallet"; import bs58 from "bs58"; import { verifyMessageSignature } from "solana-wallet"; import { ChainNamespaces, eip712, formatTestTransaction, getAllChainNamespaces, getLocalStorageTestnetFlag, hashPersonalMessage, verifySignature, } from "../helpers"; import { useWalletConnectClient } from "./ClientContext"; import { apiGetChainNamespace, ChainsMap } from "caip-api"; import { TypedDataField } from "@ethersproject/abstract-signer"; import { DEFAULT_COSMOS_METHODS, DEFAULT_EIP155_METHODS, DEFAULT_SOLANA_METHODS, } from "../constants"; import { clusterApiUrl, Connection, Keypair, SystemProgram, Transaction } from "@solana/web3.js"; import { SolanaChainData } from "../chains/solana"; /** * Types */ interface IFormattedRpcResponse { method: string; address: string; valid: boolean; result: string; } interface IRpcResult { method: string; valid: boolean; } type TRpcRequestCallback = (chainId: string, address: string) => Promise; interface IContext { ping: () => Promise; ethereumRpc: { testSendTransaction: TRpcRequestCallback; testSignTransaction: TRpcRequestCallback; testEthSign: TRpcRequestCallback; testSignPersonalMessage: TRpcRequestCallback; testSignTypedData: TRpcRequestCallback; }; cosmosRpc: { testSignDirect: TRpcRequestCallback; testSignAmino: TRpcRequestCallback; }; solanaRpc: { testSignMessage: TRpcRequestCallback; testSignTransaction: TRpcRequestCallback; }; chainData: ChainNamespaces; rpcResult?: IRpcResult | null; isRpcRequestPending: boolean; isTestnet: boolean; setIsTestnet: (isTestnet: boolean) => void; } /** * Context */ export const JsonRpcContext = createContext({} as IContext); /** * Provider */ export function JsonRpcContextProvider({ children }: { children: ReactNode | ReactNode[] }) { const [pending, setPending] = useState(false); const [result, setResult] = useState(); const [chainData, setChainData] = useState({}); const [isTestnet, setIsTestnet] = useState(getLocalStorageTestnetFlag()); const { client, session, accounts, balances, solanaPublicKeys } = useWalletConnectClient(); useEffect(() => { loadChainData(); }, []); const loadChainData = async () => { const namespaces = getAllChainNamespaces(); const chainData: ChainNamespaces = {}; await Promise.all( namespaces.map(async namespace => { let chains: ChainsMap | undefined; try { if (namespace === "solana") { chains = SolanaChainData; } else { chains = await apiGetChainNamespace(namespace); } } catch (e) { // ignore error } if (typeof chains !== "undefined") { chainData[namespace] = chains; } }), ); setChainData(chainData); }; const _createJsonRpcRequestHandler = (rpcRequest: (chainId: string, address: string) => Promise) => async (chainId: string, address: string) => { if (typeof client === "undefined") { throw new Error("WalletConnect is not initialized"); } if (typeof session === "undefined") { throw new Error("Session is not connected"); } try { setPending(true); const result = await rpcRequest(chainId, address); setResult(result); } catch (err) { console.error(err); setResult(null); } finally { setPending(false); } }; const ping = async () => { if (typeof client === "undefined") { throw new Error("WalletConnect is not initialized"); } if (typeof session === "undefined") { throw new Error("Session is not connected"); } try { setPending(true); let valid = false; try { await client.session.ping(session.topic); valid = true; } catch (e) { valid = false; } // display result setResult({ method: "ping", valid, }); } catch (e) { console.error(e); setResult(null); } finally { setPending(false); } }; // -------- ETHEREUM/EIP155 RPC METHODS -------- const ethereumRpc = { testSendTransaction: _createJsonRpcRequestHandler(async (chainId: string, address: string) => { const caipAccountAddress = `${chainId}:${address}`; const account = accounts.find(account => account === caipAccountAddress); if (account === undefined) throw new Error(`Account for ${caipAccountAddress} not found`); const tx = await formatTestTransaction(account); const balance = BigNumber.from(balances[account][0].balance || "0"); if (balance.lt(BigNumber.from(tx.gasPrice).mul(tx.gasLimit))) { return { method: DEFAULT_EIP155_METHODS.ETH_SEND_TRANSACTION, address, valid: false, result: "Insufficient funds for intrinsic transaction cost", }; } const result = await client!.request({ topic: session!.topic, chainId, request: { method: DEFAULT_EIP155_METHODS.ETH_SEND_TRANSACTION, params: [tx], }, }); // format displayed result return { method: DEFAULT_EIP155_METHODS.ETH_SEND_TRANSACTION, address, valid: true, result, }; }), testSignTransaction: _createJsonRpcRequestHandler(async (chainId: string, address: string) => { const caipAccountAddress = `${chainId}:${address}`; const account = accounts.find(account => account === caipAccountAddress); if (account === undefined) throw new Error(`Account for ${caipAccountAddress} not found`); const tx = await formatTestTransaction(account); const result: string = await client!.request({ topic: session!.topic, chainId, request: { method: DEFAULT_EIP155_METHODS.ETH_SIGN_TRANSACTION, params: [tx], }, }); return { method: DEFAULT_EIP155_METHODS.ETH_SIGN_TRANSACTION, address, valid: true, result, }; }), testSignPersonalMessage: _createJsonRpcRequestHandler( async (chainId: string, address: string) => { // test message const message = `My email is john@doe.com - ${Date.now()}`; // encode message (hex) const hexMsg = encoding.utf8ToHex(message, true); // personal_sign params const params = [hexMsg, address]; // send message const result: string = await client!.request({ topic: session!.topic, chainId, request: { method: DEFAULT_EIP155_METHODS.PERSONAL_SIGN, params, }, }); // split chainId const [namespace, reference] = chainId.split(":"); const targetChainData = chainData[namespace][reference]; if (typeof targetChainData === "undefined") { throw new Error(`Missing chain data for chainId: ${chainId}`); } const rpcUrl = targetChainData.rpc[0]; // verify signature const hash = hashPersonalMessage(message); const valid = await verifySignature(address, result, hash, rpcUrl); // format displayed result return { method: DEFAULT_EIP155_METHODS.PERSONAL_SIGN, address, valid, result, }; }, ), testEthSign: _createJsonRpcRequestHandler(async (chainId: string, address: string) => { // test message const message = `My email is john@doe.com - ${Date.now()}`; // encode message (hex) const hexMsg = encoding.utf8ToHex(message, true); // eth_sign params const params = [address, hexMsg]; // send message const result: string = await client!.request({ topic: session!.topic, chainId, request: { method: DEFAULT_EIP155_METHODS.ETH_SIGN, params, }, }); // split chainId const [namespace, reference] = chainId.split(":"); const targetChainData = chainData[namespace][reference]; if (typeof targetChainData === "undefined") { throw new Error(`Missing chain data for chainId: ${chainId}`); } const rpcUrl = targetChainData.rpc[0]; // verify signature const hash = hashPersonalMessage(message); const valid = await verifySignature(address, result, hash, rpcUrl); // format displayed result return { method: DEFAULT_EIP155_METHODS.ETH_SIGN + " (standard)", address, valid, result, }; }), testSignTypedData: _createJsonRpcRequestHandler(async (chainId: string, address: string) => { const message = JSON.stringify(eip712.example); // eth_signTypedData params const params = [address, message]; // send message const signature = await client!.request({ topic: session!.topic, chainId, request: { method: DEFAULT_EIP155_METHODS.ETH_SIGN_TYPED_DATA, params, }, }); // Separate `EIP712Domain` type from remaining types to verify, otherwise `ethers.utils.verifyTypedData` // will throw due to "unused" `EIP712Domain` type. const { EIP712Domain, ...nonDomainTypes }: Record = eip712.example.types; const valid = utils.verifyTypedData( eip712.example.domain, nonDomainTypes, eip712.example.message, signature, ) === address; return { method: DEFAULT_EIP155_METHODS.ETH_SIGN_TYPED_DATA, address, valid, result: signature, }; }), }; // -------- COSMOS RPC METHODS -------- const cosmosRpc = { testSignDirect: _createJsonRpcRequestHandler(async (chainId: string, address: string) => { // test direct sign doc inputs const inputs = { fee: [{ amount: "2000", denom: "ucosm" }], pubkey: "AgSEjOuOr991QlHCORRmdE5ahVKeyBrmtgoYepCpQGOW", gasLimit: 200000, accountNumber: 1, sequence: 1, bodyBytes: "0a90010a1c2f636f736d6f732e62616e6b2e763162657461312e4d736753656e6412700a2d636f736d6f7331706b707472653766646b6c366766727a6c65736a6a766878686c63337234676d6d6b38727336122d636f736d6f7331717970717870713971637273737a673270767871367273307a716733797963356c7a763778751a100a0575636f736d120731323334353637", authInfoBytes: "0a500a460a1f2f636f736d6f732e63727970746f2e736563703235366b312e5075624b657912230a21034f04181eeba35391b858633a765c4a0c189697b40d216354d50890d350c7029012040a020801180112130a0d0a0575636f736d12043230303010c09a0c", }; // split chainId const [namespace, reference] = chainId.split(":"); // format sign doc const signDoc = formatDirectSignDoc( inputs.fee, inputs.pubkey, inputs.gasLimit, inputs.accountNumber, inputs.sequence, inputs.bodyBytes, reference, ); // cosmos_signDirect params const params = { signerAddress: address, signDoc: stringifySignDocValues(signDoc), }; // send message const result = await client!.request({ topic: session!.topic, chainId, request: { method: DEFAULT_COSMOS_METHODS.COSMOS_SIGN_DIRECT, params, }, }); const targetChainData = chainData[namespace][reference]; if (typeof targetChainData === "undefined") { throw new Error(`Missing chain data for chainId: ${chainId}`); } // TODO: check if valid const valid = true; // format displayed result return { method: DEFAULT_COSMOS_METHODS.COSMOS_SIGN_DIRECT, address, valid, result: result.signature, }; }), testSignAmino: _createJsonRpcRequestHandler(async (chainId: string, address: string) => { // split chainId const [namespace, reference] = chainId.split(":"); // test amino sign doc const signDoc = { msgs: [], fee: { amount: [], gas: "23" }, chain_id: "foochain", memo: "hello, world", account_number: "7", sequence: "54", }; // cosmos_signAmino params const params = { signerAddress: address, signDoc }; // send message const result = await client!.request({ topic: session!.topic, chainId, request: { method: DEFAULT_COSMOS_METHODS.COSMOS_SIGN_AMINO, params, }, }); const targetChainData = chainData[namespace][reference]; if (typeof targetChainData === "undefined") { throw new Error(`Missing chain data for chainId: ${chainId}`); } // TODO: check if valid const valid = true; // format displayed result return { method: DEFAULT_COSMOS_METHODS.COSMOS_SIGN_AMINO, address, valid, result: result.signature, }; }), }; // -------- SOLANA RPC METHODS -------- const solanaRpc = { testSignTransaction: _createJsonRpcRequestHandler( async (chainId: string, address: string): Promise => { if (!solanaPublicKeys) { throw new Error("Could not find Solana PublicKeys."); } const senderPublicKey = solanaPublicKeys[address]; const connection = new Connection(clusterApiUrl(isTestnet ? "testnet" : "mainnet-beta")); // Using deprecated `getRecentBlockhash` over `getLatestBlockhash` here, since `mainnet-beta` // cluster only seems to support `connection.getRecentBlockhash` currently. const { blockhash } = await connection.getRecentBlockhash(); const transaction = new Transaction({ feePayer: senderPublicKey, recentBlockhash: blockhash, }).add( SystemProgram.transfer({ fromPubkey: senderPublicKey, toPubkey: Keypair.generate().publicKey, lamports: 1, }), ); try { const { signature } = await client!.request({ topic: session!.topic, request: { method: DEFAULT_SOLANA_METHODS.SOL_SIGN_TRANSACTION, params: { feePayer: transaction.feePayer!.toBase58(), recentBlockhash: transaction.recentBlockhash, instructions: transaction.instructions.map(i => ({ programId: i.programId.toBase58(), data: bs58.encode(i.data), keys: i.keys.map(k => ({ isSigner: k.isSigner, isWritable: k.isWritable, pubkey: k.pubkey.toBase58(), })), })), }, }, }); // We only need `Buffer.from` here to satisfy the `Buffer` param type for `addSignature`. // The resulting `UInt8Array` is equivalent to just `bs58.decode(...)`. transaction.addSignature(senderPublicKey, Buffer.from(bs58.decode(signature))); const valid = transaction.verifySignatures(); return { method: DEFAULT_SOLANA_METHODS.SOL_SIGN_TRANSACTION, address, valid, result: signature, }; } catch (error: any) { throw new Error(error); } }, ), testSignMessage: _createJsonRpcRequestHandler( async (chainId: string, address: string): Promise => { if (!solanaPublicKeys) { throw new Error("Could not find Solana PublicKeys."); } const senderPublicKey = solanaPublicKeys[address]; // Encode message to `UInt8Array` first via `TextEncoder` so we can pass it to `bs58.encode`. const message = bs58.encode( new TextEncoder().encode(`This is an example message to be signed - ${Date.now()}`), ); try { const { signature } = await client!.request({ topic: session!.topic, request: { method: DEFAULT_SOLANA_METHODS.SOL_SIGN_MESSAGE, params: { pubkey: senderPublicKey.toBase58(), message, }, }, }); const valid = verifyMessageSignature(senderPublicKey.toBase58(), signature, message); return { method: DEFAULT_SOLANA_METHODS.SOL_SIGN_MESSAGE, address, valid, result: signature, }; } catch (error: any) { throw new Error(error); } }, ), }; return ( {children} ); } export function useJsonRpc() { const context = useContext(JsonRpcContext); if (context === undefined) { throw new Error("useJsonRpc must be used within a JsonRpcContextProvider"); } return context; }