From 863e0f8bf8a5d74a88cc0132d39cbc15e8cb6787 Mon Sep 17 00:00:00 2001 From: Ben Kremer Date: Wed, 16 Mar 2022 18:00:20 +0100 Subject: [PATCH] feat(dapp-v2): integrates solana + RPC methods --- dapps/react-dapp-v2/package.json | 2 + dapps/react-dapp-v2/src/App.tsx | 35 ++++- dapps/react-dapp-v2/src/chains/solana.ts | 19 +++ .../src/contexts/ClientContext.tsx | 22 ++- .../src/contexts/JsonRpcContext.tsx | 131 +++++++++++++++++- dapps/react-dapp-v2/src/helpers/solana.ts | 18 +++ dapps/react-dapp-v2/yarn.lock | 23 ++- 7 files changed, 237 insertions(+), 13 deletions(-) create mode 100644 dapps/react-dapp-v2/src/helpers/solana.ts diff --git a/dapps/react-dapp-v2/package.json b/dapps/react-dapp-v2/package.json index 3637266..9b2d9e8 100644 --- a/dapps/react-dapp-v2/package.json +++ b/dapps/react-dapp-v2/package.json @@ -35,6 +35,7 @@ "@walletconnect/utils": "2.0.0-beta.23", "axios": "^0.21.1", "blockies-ts": "^1.0.0", + "bs58": "^5.0.0", "caip-api": "^2.0.0-beta.1", "cosmos-wallet": "^1.1.0", "eth-sig-util": "^2.5.3", @@ -45,6 +46,7 @@ "react": "^17.0.2", "react-dom": "^17.0.2", "react-scripts": "^4.0.3", + "solana-wallet": "^1.0.1", "styled-components": "^5.2.0", "typescript": "^4.3.2", "web-vitals": "^0.2.4" diff --git a/dapps/react-dapp-v2/src/App.tsx b/dapps/react-dapp-v2/src/App.tsx index 0b094a7..5dc69ab 100644 --- a/dapps/react-dapp-v2/src/App.tsx +++ b/dapps/react-dapp-v2/src/App.tsx @@ -6,8 +6,8 @@ import Blockchain from "./components/Blockchain"; import Column from "./components/Column"; import Header from "./components/Header"; import Modal from "./components/Modal"; -import { DEFAULT_MAIN_CHAINS, DEFAULT_TEST_CHAINS } from "./constants"; -import { AccountAction, getLocalStorageTestnetFlag, setLocaleStorageTestnetFlag } from "./helpers"; +import { DEFAULT_MAIN_CHAINS, DEFAULT_SOLANA_METHODS, DEFAULT_TEST_CHAINS } from "./constants"; +import { AccountAction, setLocaleStorageTestnetFlag } from "./helpers"; import Toggle from "./components/Toggle"; import RequestModal from "./modals/RequestModal"; import PairingModal from "./modals/PairingModal"; @@ -26,8 +26,6 @@ import { useWalletConnectClient } from "./contexts/ClientContext"; import { useJsonRpc } from "./contexts/JsonRpcContext"; export default function App() { - const [isTestnet, setIsTestnet] = useState(getLocalStorageTestnetFlag()); - const [modal, setModal] = useState(""); const closeModal = () => setModal(""); @@ -50,7 +48,17 @@ export default function App() { } = useWalletConnectClient(); // Use `JsonRpcContext` to provide us with relevant RPC methods and states. - const { chainData, ping, ethereumRpc, cosmosRpc, isRpcRequestPending, rpcResult } = useJsonRpc(); + const { + chainData, + ping, + ethereumRpc, + cosmosRpc, + solanaRpc, + isRpcRequestPending, + rpcResult, + isTestnet, + setIsTestnet, + } = useJsonRpc(); // Close the pairing modal after a session is established. useEffect(() => { @@ -122,6 +130,21 @@ export default function App() { ]; }; + const getSolanaActions = (): AccountAction[] => { + const onSignTransaction = async (chainId: string, address: string) => { + openRequestModal(); + await solanaRpc.testSignTransaction(chainId, address); + }; + const onSignMessage = async (chainId: string, address: string) => { + openRequestModal(); + await solanaRpc.testSignMessage(chainId, address); + }; + return [ + { method: DEFAULT_SOLANA_METHODS.SOL_SIGN_TRANSACTION, callback: onSignTransaction }, + { method: DEFAULT_SOLANA_METHODS.SOL_SIGN_MESSAGE, callback: onSignMessage }, + ]; + }; + const getBlockchainActions = (chainId: string) => { const [namespace] = chainId.split(":"); switch (namespace) { @@ -129,6 +152,8 @@ export default function App() { return getEthereumActions(); case "cosmos": return getCosmosActions(); + case "solana": + return getSolanaActions(); default: break; } diff --git a/dapps/react-dapp-v2/src/chains/solana.ts b/dapps/react-dapp-v2/src/chains/solana.ts index 71cd1c3..7ba50c7 100644 --- a/dapps/react-dapp-v2/src/chains/solana.ts +++ b/dapps/react-dapp-v2/src/chains/solana.ts @@ -1,5 +1,24 @@ +import { ChainsMap } from "caip-api"; import { NamespaceMetadata, ChainMetadata } from "../helpers"; +// TODO: add `solana` namespace to `caip-api` package to avoid manual specification here. +export const SolanaChainData: ChainsMap = { + "4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ": { + id: "solana:4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ", + name: "Solana Mainnet", + rpc: ["https://api.mainnet-beta.solana.com", "https://solana-api.projectserum.com"], + slip44: 501, + testnet: false, + }, + "8E9rvCKLFQia2Y35HXjjpWzj8weVo44K": { + id: "solana:8E9rvCKLFQia2Y35HXjjpWzj8weVo44K", + name: "Solana Devnet", + rpc: ["https://api.devnet.solana.com"], + slip44: 501, + testnet: true, + }, +}; + export const SolanaMetadata: NamespaceMetadata = { // Solana Mainnet "4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ": { diff --git a/dapps/react-dapp-v2/src/contexts/ClientContext.tsx b/dapps/react-dapp-v2/src/contexts/ClientContext.tsx index 90e712a..e709d26 100644 --- a/dapps/react-dapp-v2/src/contexts/ClientContext.tsx +++ b/dapps/react-dapp-v2/src/contexts/ClientContext.tsx @@ -10,6 +10,8 @@ import { useMemo, useState, } from "react"; +import { PublicKey } from "@solana/web3.js"; + import { DEFAULT_APP_METADATA, DEFAULT_COSMOS_METHODS, @@ -17,9 +19,11 @@ import { DEFAULT_LOGGER, DEFAULT_PROJECT_ID, DEFAULT_RELAY_URL, + DEFAULT_SOLANA_METHODS, } from "../constants"; import { AccountBalances, apiGetAccountBalance } from "../helpers"; import { ERROR, getAppMetadata } from "@walletconnect/utils"; +import { getPublicKeysFromAccounts } from "../helpers/solana"; /** * Types @@ -33,6 +37,7 @@ interface IContext { chains: string[]; pairings: string[]; accounts: string[]; + solanaPublicKeys?: Record; balances: AccountBalances; isFetchingBalances: boolean; setChains: any; @@ -56,6 +61,7 @@ export function ClientContextProvider({ children }: { children: ReactNode | Reac const [balances, setBalances] = useState({}); const [accounts, setAccounts] = useState([]); + const [solanaPublicKeys, setSolanaPublicKeys] = useState>(); const [chains, setChains] = useState([]); const resetApp = () => { @@ -110,6 +116,8 @@ export function ClientContextProvider({ children }: { children: ReactNode | Reac return DEFAULT_EIP155_METHODS; case "cosmos": return DEFAULT_COSMOS_METHODS; + case "solana": + return Object.values(DEFAULT_SOLANA_METHODS); default: throw new Error(`No default methods for namespace: ${namespace}`); } @@ -119,11 +127,12 @@ export function ClientContextProvider({ children }: { children: ReactNode | Reac return supportedMethods; }; - const onSessionConnected = useCallback(async (incomingSession: SessionTypes.Settled) => { - setSession(incomingSession); - setChains(incomingSession.permissions.blockchain.chains); - setAccounts(incomingSession.state.accounts); - await getAccountBalances(incomingSession.state.accounts); + const onSessionConnected = useCallback(async (_session: SessionTypes.Settled) => { + setSession(_session); + setChains(_session.permissions.blockchain.chains); + setAccounts(_session.state.accounts); + setSolanaPublicKeys(getPublicKeysFromAccounts(_session.state.accounts)); + await getAccountBalances(_session.state.accounts); }, []); const connect = useCallback( @@ -135,6 +144,7 @@ export function ClientContextProvider({ children }: { children: ReactNode | Reac try { const supportedNamespaces = getSupportedNamespaces(); const methods = getSupportedMethods(supportedNamespaces); + const session = await client.connect({ metadata: getAppMetadata() || DEFAULT_APP_METADATA, pairing, @@ -254,6 +264,7 @@ export function ClientContextProvider({ children }: { children: ReactNode | Reac balances, isFetchingBalances, accounts, + solanaPublicKeys, chains, client, session, @@ -267,6 +278,7 @@ export function ClientContextProvider({ children }: { children: ReactNode | Reac balances, isFetchingBalances, accounts, + solanaPublicKeys, chains, client, session, diff --git a/dapps/react-dapp-v2/src/contexts/JsonRpcContext.tsx b/dapps/react-dapp-v2/src/contexts/JsonRpcContext.tsx index 4cbf180..9e72712 100644 --- a/dapps/react-dapp-v2/src/contexts/JsonRpcContext.tsx +++ b/dapps/react-dapp-v2/src/contexts/JsonRpcContext.tsx @@ -2,18 +2,24 @@ 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_SOLANA_METHODS } from "../constants"; +import { clusterApiUrl, Connection, Keypair, SystemProgram, Transaction } from "@solana/web3.js"; +import { SolanaChainData } from "../chains/solana"; /** * Types @@ -45,9 +51,15 @@ interface IContext { testSignDirect: TRpcRequestCallback; testSignAmino: TRpcRequestCallback; }; + solanaRpc: { + testSignMessage: TRpcRequestCallback; + testSignTransaction: TRpcRequestCallback; + }; chainData: ChainNamespaces; rpcResult?: IRpcResult | null; isRpcRequestPending: boolean; + isTestnet: boolean; + setIsTestnet: (isTestnet: boolean) => void; } /** @@ -62,8 +74,9 @@ export function JsonRpcContextProvider({ children }: { children: ReactNode | Rea const [pending, setPending] = useState(false); const [result, setResult] = useState(); const [chainData, setChainData] = useState({}); + const [isTestnet, setIsTestnet] = useState(getLocalStorageTestnetFlag()); - const { client, session, accounts, balances } = useWalletConnectClient(); + const { client, session, accounts, balances, solanaPublicKeys } = useWalletConnectClient(); useEffect(() => { loadChainData(); @@ -76,7 +89,11 @@ export function JsonRpcContextProvider({ children }: { children: ReactNode | Rea namespaces.map(async namespace => { let chains: ChainsMap | undefined; try { - chains = await apiGetChainNamespace(namespace); + if (namespace === "solana") { + chains = SolanaChainData; + } else { + chains = await apiGetChainNamespace(namespace); + } } catch (e) { // ignore error } @@ -85,6 +102,7 @@ export function JsonRpcContextProvider({ children }: { children: ReactNode | Rea } }), ); + setChainData(chainData); }; @@ -437,6 +455,112 @@ export function JsonRpcContextProvider({ children }: { children: ReactNode | Rea }), }; + // -------- 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} diff --git a/dapps/react-dapp-v2/src/helpers/solana.ts b/dapps/react-dapp-v2/src/helpers/solana.ts new file mode 100644 index 0000000..3c97955 --- /dev/null +++ b/dapps/react-dapp-v2/src/helpers/solana.ts @@ -0,0 +1,18 @@ +import { PublicKey } from "@solana/web3.js"; + +export function getPublicKeysFromAccounts(accounts: string[]) { + return ( + accounts + // Filter out any non-solana accounts. + .filter(account => account.startsWith("solana:")) + // Create a map of Solana address -> publicKey. + .reduce((map: Record, account) => { + const address = account.split(":").pop(); + if (!address) { + throw new Error(`Could not derive Solana address from CAIP account: ${account}`); + } + map[address] = new PublicKey(address); + return map; + }, {}) + ); +} diff --git a/dapps/react-dapp-v2/yarn.lock b/dapps/react-dapp-v2/yarn.lock index 68bdf0b..b6ffc0b 100644 --- a/dapps/react-dapp-v2/yarn.lock +++ b/dapps/react-dapp-v2/yarn.lock @@ -1960,7 +1960,7 @@ dependencies: buffer "~6.0.3" -"@solana/web3.js@^1.36.0": +"@solana/web3.js@^1.35.1", "@solana/web3.js@^1.36.0": version "1.36.0" resolved "https://registry.yarnpkg.com/@solana/web3.js/-/web3.js-1.36.0.tgz#79d7d5217b49b80139f4de68953adc5b9a9a264f" integrity sha512-RNT1451iRR7TyW7EJKMCrH/0OXawIe4zVm0DWQASwXlR/u1jmW6FrmH0lujIh7cGTlfOVbH+2ZU9AVUPLBFzwA== @@ -3639,6 +3639,11 @@ base-x@^3.0.2: dependencies: safe-buffer "^5.0.1" +base-x@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/base-x/-/base-x-4.0.0.tgz#d0e3b7753450c73f8ad2389b5c018a4af7b2224a" + integrity sha512-FuwxlW4H5kh37X/oW59pwTzzTKRzfrrQwhmyspRM7swOEZcHtDZSCt45U6oKgtuFE+WYPblePMVIPR4RZrh/hw== + base64-js@^1.0.2, base64-js@^1.3.0, base64-js@^1.3.1: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" @@ -3924,6 +3929,13 @@ bs58@^4.0.0, bs58@^4.0.1: dependencies: base-x "^3.0.2" +bs58@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/bs58/-/bs58-5.0.0.tgz#865575b4d13c09ea2a84622df6c8cbeb54ffc279" + integrity sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ== + dependencies: + base-x "^4.0.0" + bs58check@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/bs58check/-/bs58check-2.1.2.tgz#53b018291228d82a5aa08e7d796fdafda54aebfc" @@ -11651,6 +11663,15 @@ sockjs@^0.3.21: uuid "^8.3.2" websocket-driver "^0.7.4" +solana-wallet@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/solana-wallet/-/solana-wallet-1.0.1.tgz#4e1bfebbb99640105ed42abd1445baab4f6e0b57" + integrity sha512-rkYu9gwayAdVMhWGdZSz6a+IaOJXs3TNtYXWuSQsdMJUndkQ+puy7cB9/u5pwZTjqzxtd1DN9cbAFKFH5648xQ== + dependencies: + "@solana/web3.js" "^1.35.1" + bs58 "^5.0.0" + tweetnacl "^1.0.3" + sonic-boom@^1.0.2: version "1.4.1" resolved "https://registry.yarnpkg.com/sonic-boom/-/sonic-boom-1.4.1.tgz#d35d6a74076624f12e6f917ade7b9d75e918f53e"