diff --git a/README.md b/README.md index 55d75838..2410fe86 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,67 @@ to approve or reject the session: Upon receiving a `session_request` event, process the request. For instance, if the dApp requests a transaction to be signed: +#### Extension popup + +By default, it is not possible to directly pop up an extension with Wallet Connect. However, to +allow this possibility, the dAppConnector look for extensions. If you create the AppConnector, +it will automatically send a message to the extension to detect if it is installed. In case the +extension is installed, it will be added to the available extensions and its data can be found +at the extensions property of dAppConnector. + +To connect an available extension, use the method `connectExtension()`. This will +link the extension to the signer and session. Whenever you use the signer created for this +session, the extension will automatically open. You can find out if the extension is available +by checking the `extensions` property. + +```javascript +const dAppConnector = new DAppConnector( + dAppMetadata, + LedgerId.TESTNET, + projectId, + Object.values(HederaJsonRpcMethod), + [HederaSessionEvent.ChainChanged, HederaSessionEvent.AccountsChanged], + [HederaChainId.Testnet] +) + +[...] + +dAppConnector?.extensions?.forEach((extension) => { + console.log(extension) +}) + +const extension = dAppConnector?.extensions?.find((extension) => extension.name === '') +if (extension.available) { + await dAppConnector!.connectExtension(extension.id); + const signer = dAppConnector.getSigner(AccountId.fromString('0.0.12345')) + + // This request will open the extension + const response = await signer.signAndExecuteTransaction(transaction) +} +``` + +Wallets that are compatible should be able to receive and respond to the following messages: + +- `"hedera-extension-query"`: The extension is required to respond with + `"hedera-extension-response"` and provide the next set of data in the metadata property. + ```javascript + let metadata = { + id: '', + name: '', + url: '', + icon: '', + description: '', + } + ``` +- `"hedera-extension-open-"`: The extension needs to listen to this message and + automatically open. +- `"hedera-extension-connect-"`: The extension must listen to this message and + utilize the `pairingString` property in order to establish a connection. + +This communication protocol between the wallet and web dApps requires an intermediate script to +use the Chrome API. Refer to the +[Chrome Extensions documentation](https://developer.chrome.com/docs/extensions/develop/concepts/messaging) + ## Demo & docs This repository includes a vanilla html/css/javascript implementation with a dApp and wallet diff --git a/demos/react-dapp/index.html b/demos/react-dapp/index.html new file mode 100644 index 00000000..94e5b5a2 --- /dev/null +++ b/demos/react-dapp/index.html @@ -0,0 +1,23 @@ + + + + + + dApp + + + + +
+

dApp

+

+ This demo dApp requires a project id from WalletConnect. Please see + + https://cloud.walletconnect.com + +

+
+
+ + + diff --git a/demos/react-dapp/main.css b/demos/react-dapp/main.css new file mode 100644 index 00000000..17f1d024 --- /dev/null +++ b/demos/react-dapp/main.css @@ -0,0 +1,186 @@ +* { + box-sizing: border-box; + border-radius: 1rem; + appearance: none; +} +body { + margin: 0; + font-family: sans-serif; + color: white; + background: black; +} +main { + padding: 1rem; + margin: 0 auto; + max-width: 1400px; +} + +section { + color: black; + background-color: white; + margin: 3rem 0; + padding: 1rem; + background: + linear-gradient(white, white) padding-box, + linear-gradient(to right, #5281e7, #765aea) border-box; + border: 6px solid transparent; + /* border-radius: 0 10rem 10rem; */ + padding: 4vw 6vw; + box-shadow: 0 10px 25px 0 rgba(0, 0, 0, 0.1); + /* nesting natively supported by some browsers */ + & form fieldset { + display: flex; + flex-direction: column; + + & label { + padding: 1rem 0; + font-weight: bold; + } + } +} + +a { + color: #5381e7; + text-decoration: none; + &:hover { + filter: brightness(1.4); + } +} + +button { + max-width: fit-content; + color: white; + background: linear-gradient(160deg, #3ec878, #21a056); + position: relative; + z-index: 1; + cursor: pointer; + margin: 1rem 0; + appearance: none; + padding: 0.5rem 1rem; + border: 1px solid transparent; + border-radius: 1rem; + transition: opacity 0.3s; + + &::before { + content: ''; + background: linear-gradient(160deg, #21a056, #3ec878); + position: absolute; + border-radius: 15px; + top: -1px; + left: -1px; + width: calc(100% + 2px); + height: calc(100% + 2px); + opacity: 0; + transition: opacity 0.3s; + z-index: -1; + } + + &:hover::before { + opacity: 1; + } + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +input, +select { + width: 100%; + height: 2rem; + border: 1px solid black; + margin: 0.5rem 0; + padding: 0.5rem; + &:disabled { + color: transparent; + border: 1px solid lightgrey; + background-color: #fafafa; + } +} +#init input, +#init select { + &:disabled { + color: black; + border: 1px solid black; + } +} + +hr { + width: 100%; +} + +dialog { + max-width: 60rem; + position: relative; + padding: 1rem; + background: + linear-gradient(white, white) padding-box, + linear-gradient(to right, #5281e7, #765aea) border-box; + border: 6px solid transparent; + box-shadow: 0 10px 25px 0 rgba(0, 0, 0, 0.1); +} + +dialog::backdrop { + background: hsl(0 0% 0% / 50%); +} + +dialog > div:first-child > button { + color: white; + background: linear-gradient(160deg, #5d5f5f, #2d2d2d); + font-size: 0.75em; + + &::before { + content: ''; + background: linear-gradient(160deg, #2d2d2d, #5d5f5f); + } +} + +dialog > div:first-child { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +} + +dialog > div:nth-child(2) { + padding: 2rem; +} + +.loading { + display: flex; + flex-direction: column; + align-items: center; +} + +.loader { + width: 48px; + height: 48px; + border: 3px solid #765aea; + border-radius: 50%; + display: inline-block; + position: relative; + box-sizing: border-box; + animation: rotation 1s linear infinite; +} +.loader::after { + content: ''; + box-sizing: border-box; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 56px; + height: 56px; + border-radius: 50%; + border: 3px solid transparent; + border-bottom-color: #2d2d2d; +} + +@keyframes rotation { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} diff --git a/demos/react-dapp/main.tsx b/demos/react-dapp/main.tsx new file mode 100644 index 00000000..79fc4008 --- /dev/null +++ b/demos/react-dapp/main.tsx @@ -0,0 +1,11 @@ +import React from 'react' +import { createRoot } from 'react-dom/client' +import App from './src/App' + +document.body.innerHTML = '
' + +const appElement = document.getElementById('app') +if (appElement) { + const root = createRoot(appElement) + root.render() +} diff --git a/demos/react-dapp/src/App.tsx b/demos/react-dapp/src/App.tsx new file mode 100644 index 00000000..bd6d5383 --- /dev/null +++ b/demos/react-dapp/src/App.tsx @@ -0,0 +1,638 @@ +import { Buffer } from 'buffer' +import { + AccountId, + AccountInfo, + AccountInfoQuery, + Client, + Hbar, + LedgerId, + PublicKey, + TransactionId, + TransferTransaction, +} from '@hashgraph/sdk' +import { SessionTypes, SignClientTypes } from '@walletconnect/types' + +import { + HederaSessionEvent, + HederaJsonRpcMethod, + queryToBase64String, + DAppConnector, + HederaChainId, + verifyMessageSignature, + ExtensionData, + DAppSigner, + SignMessageParams, + SignAndExecuteTransactionParams, + transactionToBase64String, + SignAndExecuteQueryParams, + ExecuteTransactionParams, +} from '@hashgraph/hedera-wallet-connect' + +import React, { useEffect, useMemo, useState } from 'react' +import Modal from './components/Modal' + +const App: React.FC = () => { + // Connector data states + const [projectId, setProjectId] = useState('') + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [url, setUrl] = useState('') + const [icons, setIcons] = useState('') + + // Session management states + const [dAppConnector, setDAppConnector] = useState(null) + const [sessions, setSessions] = useState([]) + const [signers, setSigners] = useState([]) + const [selectedSigner, setSelectedSigner] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + // Extension Wallet Buttons + const [extensions, setExtensions] = useState([]) + + // Form data states + const [signerAccount, setSignerAccount] = useState('') + const [receiver, setReceiver] = useState('') + const [signerPrivateKey, setSignerPrivateKey] = useState('') + const [amount, setAmount] = useState('') + const [message, setMessage] = useState('') + const [publicKey, setPublicKey] = useState('') + const [selectedTransactionMethod, setSelectedTransactionMethod] = useState( + 'hedera_executeTransaction', + ) + + // Modal states + const [isModalOpen, setModalOpen] = useState(false) + const [isModalLoading, setIsModalLoading] = useState(false) + const [modalData, setModalData] = useState(null) + + useEffect(() => { + const state = JSON.parse(localStorage.getItem('hedera-wc-demos-saved-state') || '{}') + if (state) { + setProjectId(state['projectId']) + setName(state['name']) + setDescription(state['description']) + setUrl(state['url']) + setIcons(state['icons']) + setMessage(state['message']) + setPublicKey(state['publicKey']) + setAmount(state['amount']) + setReceiver(state['receiver']) + } + }, []) + + useEffect(() => { + if (projectId && name && description && url && icons) { + handleInitConnector() + } + }, [projectId]) + + useEffect(() => { + if (dAppConnector) { + setSigners(dAppConnector.signers) + } + }, [sessions]) + + const saveData = () => { + localStorage.setItem( + 'hedera-wc-demos-saved-state', + JSON.stringify({ + projectId, + name, + description, + url, + icons, + message, + publicKey, + amount, + receiver, + }), + ) + } + + const modalWrapper = async (fn: () => Promise) => { + try { + saveData() + setModalOpen(true) + setIsModalLoading(true) + const result = await fn() + setModalData({ + status: 'Success', + message: 'The request has been executed successfully', + result, + }) + } catch (error) { + console.error('Error signing message: ', error) + setModalData({ status: 'Error', message: error.message }) + } finally { + setIsModalLoading(false) + } + } + + /** + * WalletConnect methods + */ + + // 1. hedera_getNodeAddresses + const handleGetNodeAddresses = async () => { + modalWrapper(async () => { + const nodeAddresses = await dAppConnector!.getNodeAddresses() + console.log('NodeAddresses: ', nodeAddresses) + return nodeAddresses + }) + } + + // 2. hedera_executeTransaction + const handleExecuteTransaction = async () => { + if (!signerPrivateKey) throw new Error('Signer private key is required') + const client = Client.forTestnet() + client.setOperator(signerAccount, signerPrivateKey) + + const hbarAmount = new Hbar(Number(amount)) + const transaction = new TransferTransaction() + .setTransactionId(TransactionId.generate(signerAccount)) + .addHbarTransfer(signerAccount, hbarAmount.negated()) + .addHbarTransfer(receiver, hbarAmount) + .freezeWith(client) + + const signedTransaction = await transaction.signWithOperator(client) + const transactionList = transactionToBase64String(signedTransaction) + + const params: ExecuteTransactionParams = { transactionList } + + return await dAppConnector!.executeTransaction(params) + } + + // 3. hedera_signMessage + const handleSignMessage = async () => { + modalWrapper(async () => { + if (!selectedSigner) throw new Error('Selected signer is required') + const params: SignMessageParams = { + signerAccountId: 'hedera:testnet:' + selectedSigner.getAccountId().toString(), + message, + } + + const { signatureMap } = await dAppConnector!.signMessage(params) + const accountPublicKey = PublicKey.fromString(publicKey) + const verified = verifyMessageSignature(message, signatureMap, accountPublicKey) + console.log('SignatureMap: ', signatureMap) + console.log('Verified: ', verified) + return { + signatureMap, + verified, + } + }) + } + + // 4. hedera_signAndExecuteQuery + const handleExecuteQuery = () => { + modalWrapper(async () => { + if (!selectedSigner) throw new Error('Selected signer is required') + const accountId = selectedSigner.getAccountId() + const query = new AccountInfoQuery().setAccountId(accountId) + + const params: SignAndExecuteQueryParams = { + signerAccountId: 'hedera:testnet:' + accountId.toString(), + query: queryToBase64String(query), + } + + const { response } = await dAppConnector!.signAndExecuteQuery(params) + const bytes = Buffer.from(response, 'base64') + const accountInfo = AccountInfo.fromBytes(bytes) + console.log('AccountInfo: ', accountInfo) + return accountInfo + }) + } + + // 5. hedera_signAndExecuteTransaction + const handleHederaSignAndExecuteTransaction = async () => { + const accountId = selectedSigner!.getAccountId() + const hbarAmount = new Hbar(Number(amount)) + + const transaction = new TransferTransaction() + .setTransactionId(TransactionId.generate(accountId!)) + .addHbarTransfer(accountId, hbarAmount.negated()) + .addHbarTransfer(receiver, hbarAmount) + + const params: SignAndExecuteTransactionParams = { + transactionList: transactionToBase64String(transaction), + signerAccountId: 'hedera:testnet:' + accountId.toString(), + } + + const result = await dAppConnector!.signAndExecuteTransaction(params) + + console.log('JSONResponse: ', result) + return result + } + + // 6. hedera_signTransaction + const handleHederaSignTransaction = async () => { + const accountId = selectedSigner!.getAccountId() + const hbarAmount = new Hbar(Number(amount)) + const transaction = new TransferTransaction() + .setTransactionId(TransactionId.generate(accountId!)) + .addHbarTransfer(accountId.toString()!, hbarAmount.negated()) + .addHbarTransfer(receiver, hbarAmount) + + const transactionSigned = await selectedSigner!.signTransaction(transaction) + + console.log('Signed transaction: ', transactionSigned) + return { transaction: transactionSigned } + } + + /** + * Session management methods + */ + + const handleInitConnector = async () => { + const metadata: SignClientTypes.Metadata = { + name, + description, + url, + icons: icons.split(','), + } + + const _dAppConnector = new DAppConnector( + metadata, + LedgerId.TESTNET, + projectId, + Object.values(HederaJsonRpcMethod), + [HederaSessionEvent.ChainChanged, HederaSessionEvent.AccountsChanged], + [HederaChainId.Testnet], + ) + await _dAppConnector.init({ logger: 'error' }) + + _dAppConnector?.extensions?.forEach((extension) => { + console.log('extension: ', extension) + }) + + if (_dAppConnector) { + const extensionData = _dAppConnector.extensions?.filter( + (extension) => extension.available, + ) + if (extensionData) setExtensions(extensionData) + + setDAppConnector(_dAppConnector) + setSigners(_dAppConnector.signers) + setSelectedSigner(_dAppConnector.signers[0]) + const _sessions = _dAppConnector.walletConnectClient?.session.getAll() + if (_sessions && _sessions?.length > 0) { + setSessions(_sessions) + } + } + saveData() + } + + const handleConnect = async (extensionId?: string) => { + try { + if (!dAppConnector) throw new Error('DAppConnector is required') + let session: SessionTypes.Struct + setIsLoading(true) + if (extensionId) session = await dAppConnector.connectExtension(extensionId) + else session = await dAppConnector.openModal() + + setSessions((prev) => [...prev, session]) + const sessionAccount = session.namespaces?.hedera?.accounts?.[0] + const accountId = sessionAccount?.split(':').pop() + if (!accountId) console.error('No account id found in the session') + else setSelectedSigner(dAppConnector?.getSigner(AccountId.fromString(accountId))!) + console.log('New connected session: ', session) + console.log('New connected accounts: ', session.namespaces?.hedera?.accounts) + } finally { + setIsLoading(false) + } + } + + const handleDisconnectSessions = async () => { + modalWrapper(async () => { + await dAppConnector!.disconnectAll() + setSessions([]) + setSigners([]) + setSelectedSigner(null) + setModalData({ status: 'Success', message: 'Session disconnected' }) + }) + } + + const handleClearData = () => { + localStorage.removeItem('hedera-wc-demos-saved-state') + setProjectId('') + setName('') + setDescription('') + setUrl('') + setIcons('') + setMessage('') + setPublicKey('') + setAmount('') + setReceiver('') + } + + const disableButtons = useMemo( + () => !dAppConnector || !selectedSigner, + [dAppConnector, selectedSigner], + ) + + return ( + <> +
+

dApp

+

+ This demo dApp requires a project id from WalletConnect. Please see + + https://cloud.walletconnect.com + +

+
+
+
+ Step 1: Initialize WalletConnect + + + + + +
+ +
+
+
+
+ {isLoading &&

Loading...

} +
+ {sessions.length === 0 ? ( + Step 2: Connect a wallet + ) : ( + <> + Connected Wallets +
    + {sessions.map((session, index) => ( +
  • +

    Session ID: {session.topic}

    +

    Wallet Name: {session.peer.metadata.name}

    +

    Account IDs: {session.namespaces?.hedera?.accounts?.join(' | ')}

    +
  • + ))} +
+ + )} + + {extensions.map((extension, index) => ( + + ))} +
+
+
+
+
+
+ 1. hedera_getNodeAddresses + +
+
+
+
+
+ 3. hedera_signMessage + signer.getAccountId())} + selectedAccount={selectedSigner?.getAccountId() || null} + onSelect={(accountId) => + setSelectedSigner(dAppConnector?.getSigner(accountId)!) + } + /> + + +

The public key for the account is used to verify the signed message

+
+ +
+
+
+
+
+ 4. hedera_signAndExecuteQuery + + signer.getAccountId())} + selectedAccount={selectedSigner?.getAccountId() || null} + onSelect={(accountId) => + setSelectedSigner(dAppConnector?.getSigner(accountId)!) + } + /> +
+ +
+
+
+

Transaction methods:

+
+ +
+
+ + {selectedTransactionMethod === 'hedera_executeTransaction' ? ( + <> + + + + ) : ( + signer.getAccountId())} + selectedAccount={selectedSigner?.getAccountId() || null} + onSelect={(accountId) => + setSelectedSigner(dAppConnector?.getSigner(accountId)!) + } + /> + )} + + +
+ +
+
+
+

Pairing and session management:

+
+
+ + + +
+
+ setModalOpen(false)}> + {isModalLoading ? ( +
+

Approve request on wallet

+ +
+ ) : ( +
+

{modalData?.status}

+

{modalData?.message}

+
{JSON.stringify(modalData?.result, null, 2)}
+
+ )} +
+
+ + ) +} + +export default App + +interface AccountSelectorProps { + accounts: AccountId[] + selectedAccount: AccountId | null + onSelect: (accountId: AccountId) => void +} + +const AccountSelector = ({ accounts, selectedAccount, onSelect }: AccountSelectorProps) => { + return ( + + ) +} diff --git a/demos/react-dapp/src/components/Modal.tsx b/demos/react-dapp/src/components/Modal.tsx new file mode 100644 index 00000000..672f5147 --- /dev/null +++ b/demos/react-dapp/src/components/Modal.tsx @@ -0,0 +1,55 @@ +import React from 'react' +import { useEffect, useRef, useState } from 'react' + +interface ModalProps { + isOpen: boolean + title: string + onClose?: () => void + children: React.ReactNode +} + +const Modal: React.FC = ({ isOpen, title, onClose, children }) => { + const [isModalOpen, setModalOpen] = useState(isOpen) + const modalRef = useRef(null) + + const handleCloseModal = () => { + if (onClose) { + onClose() + } + setModalOpen(false) + } + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + handleCloseModal() + } + } + + useEffect(() => { + setModalOpen(isOpen) + }, [isOpen]) + + useEffect(() => { + const modalElement = modalRef.current + + if (modalElement) { + if (isModalOpen) { + modalElement.showModal() + } else { + modalElement.close() + } + } + }, [isModalOpen]) + + return ( + +
+ {title} + +
+
{children}
+
+ ) +} + +export default Modal diff --git a/package-lock.json b/package-lock.json index d47c1b70..dc6fb7df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@hashgraph/hedera-wallet-connect": "^1.0.5", "@types/jest": "^29.5.3", "@types/node": "^20.11.10", + "@types/react-dom": "^18.2.21", "@walletconnect/modal": "^2.6.2", "@walletconnect/sign-client": "^2.11.0", "@walletconnect/types": "^2.11.0", @@ -32,6 +33,8 @@ "lokijs": "^1.5.12", "nodemon": "^3.0.3", "prettier": "^3.2.4", + "react": "^18.2.0", + "react-dom": "^18.2.0", "rimraf": "^5.0.5", "ts-jest": "^29.1.2", "ts-node": "^10.9.2", @@ -2871,6 +2874,38 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/prop-types": { + "version": "15.7.11", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", + "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==", + "dev": true + }, + "node_modules/@types/react": { + "version": "18.2.64", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.64.tgz", + "integrity": "sha512-MlmPvHgjj2p3vZaxbQgFUQFvD8QiZwACfGqEdDSWou5yISWxDQ4/74nCAwsUiX7UFLKZz3BbVSPj+YxeoGGCfg==", + "dev": true, + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.2.21", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.21.tgz", + "integrity": "sha512-gnvBA/21SA4xxqNXEwNiVcP0xSGHh/gi1VhWv9Bl46a0ItbTT5nFY+G9VSQpaG/8N/qdJpJ+vftQ4zflTtnjLw==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/scheduler": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", + "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==", + "dev": true + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -4601,6 +4636,12 @@ "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true + }, "node_modules/date-fns": { "version": "2.30.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", @@ -8062,7 +8103,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", "dev": true, - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -8070,6 +8110,19 @@ "node": ">=0.10.0" } }, + "node_modules/react-dom": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", + "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "dev": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.0" + }, + "peerDependencies": { + "react": "^18.2.0" + } + }, "node_modules/react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", @@ -8370,6 +8423,15 @@ "node": ">=10" } }, + "node_modules/scheduler": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", + "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", + "dev": true, + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/secure-json-parse": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", diff --git a/package.json b/package.json index ca1f0dcb..00818c73 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@hashgraph/hedera-wallet-connect": "^1.0.5", "@types/jest": "^29.5.3", "@types/node": "^20.11.10", + "@types/react-dom": "^18.2.21", "@walletconnect/modal": "^2.6.2", "@walletconnect/sign-client": "^2.11.0", "@walletconnect/types": "^2.11.0", @@ -37,6 +38,8 @@ "lokijs": "^1.5.12", "nodemon": "^3.0.3", "prettier": "^3.2.4", + "react": "^18.2.0", + "react-dom": "^18.2.0", "rimraf": "^5.0.5", "ts-jest": "^29.1.2", "ts-node": "^10.9.2", @@ -55,9 +58,11 @@ "build": "npm run build:types && node scripts/lib/build.mjs", "build:types": "tsc --emitDeclarationOnly --declaration --declarationMap --outDir dist/types", "build:example": "node scripts/examples/build.mjs", + "build:demos": "node scripts/demos/build.mjs", "build:docs": "typedoc --options typedoc.json", "watch": "nodemon --watch src/lib/ --ext ts --exec \"npm run build\"", "dev": "rimraf dist && npm run build && concurrently --raw \"npm run watch\" \"node scripts/examples/dev.mjs\"", + "dev:demos": "rimraf dist && npm run build && concurrently --raw \"npm run watch\" \"node scripts/demos/dev.mjs\"", "test": "jest", "test:connect": "jest --testMatch '**/DAppConnector.test.ts' --verbose", "test:signer": "jest --testMatch '**/DAppSigner.test.ts' --verbose", diff --git a/scripts/demos/build.mjs b/scripts/demos/build.mjs new file mode 100644 index 00000000..852185c9 --- /dev/null +++ b/scripts/demos/build.mjs @@ -0,0 +1,50 @@ +/* + * + * Hedera Wallet Connect + * + * Copyright (C) 2023 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import * as esbuild from 'esbuild' +import copy from 'esbuild-plugin-copy' + +export const config = { + bundle: true, + minify: false, + platform: 'browser', + // format: 'esm', + alias: { + '@hashgraph/sdk': './node_modules/@hashgraph/sdk/src/index.js', + '@hashgraph/proto': './node_modules/@hashgraph/proto', + }, + plugins: [ + copy({ + assets: { + from: ['demos/react-dapp/**/*.(html|css|ico|jpg|png)'], + to: ['./'], + }, + watch: true, // for ../dev.mjs + }), + ], + outdir: 'dist/demos/react-dapp', + entryPoints: [ + 'demos/react-dapp/main.tsx', + ], + define: {}, + loader: { '.tsx': 'tsx', '.ts': 'ts' }, +} + +esbuild.build(config) diff --git a/scripts/demos/dev.mjs b/scripts/demos/dev.mjs new file mode 100644 index 00000000..3576406a --- /dev/null +++ b/scripts/demos/dev.mjs @@ -0,0 +1,47 @@ +/* + * + * Hedera Wallet Connect + * + * Copyright (C) 2023 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import * as esbuild from 'esbuild' +import { config } from './build.mjs' + +const devConfig = { + ...config, + define: { + 'process.env.dappUrl': '"http://localhost:8080/dapp/index.html"', + 'process.env.walletUrl': '"http://localhost:8081/wallet/index.html"', + }, +} + +let ctx8080 = await esbuild.context(devConfig) + +/* + * watches for file changes and serves most recent files + */ +async function main() { + const server1 = await ctx8080.serve({ + servedir: 'dist/demos/react-dapp', + host: 'localhost', + port: 3000, + }) + + console.log(`Server running ${server1.host}:${server1.port}`) +} + +await main() diff --git a/src/lib/dapp/DAppSigner.ts b/src/lib/dapp/DAppSigner.ts index 0a2e8477..861dc406 100644 --- a/src/lib/dapp/DAppSigner.ts +++ b/src/lib/dapp/DAppSigner.ts @@ -56,6 +56,7 @@ import { transactionBodyToBase64String, transactionToBase64String, transactionToTransactionBody, + extensionOpen, } from '../shared' const clients: Record = {} @@ -66,6 +67,7 @@ export class DAppSigner implements Signer { private readonly signClient: ISignClient, public readonly topic: string, private readonly ledgerId: LedgerId = LedgerId.MAINNET, + public readonly extensionId?: string, ) {} private _getHederaClient() { @@ -96,6 +98,7 @@ export class DAppSigner implements Signer { } request(request: { method: string; params: any }): Promise { + if (this.extensionId) extensionOpen(this.extensionId) return this.signClient.request({ topic: this.topic, request, diff --git a/src/lib/dapp/index.ts b/src/lib/dapp/index.ts index 73036702..98e4b395 100644 --- a/src/lib/dapp/index.ts +++ b/src/lib/dapp/index.ts @@ -46,6 +46,9 @@ import { SignTransactionParams, SignTransactionRequest, SignTransactionResult, + ExtensionData, + extensionConnect, + findExtensions, } from '../shared' import { DAppSigner } from './DAppSigner' import { JsonRpcResult } from '@walletconnect/jsonrpc-types' @@ -62,6 +65,8 @@ export class DAppConnector { supportedEvents: string[] = [] supportedChains: string[] = [] + extensions: ExtensionData[] = [] + walletConnectClient: SignClient | undefined walletConnectModal: WalletConnectModal signers: DAppSigner[] = [] @@ -90,11 +95,19 @@ export class DAppConnector { this.supportedMethods = methods ?? Object.values(HederaJsonRpcMethod) this.supportedEvents = events ?? [] this.supportedChains = chains ?? [] + this.extensions = [] this.walletConnectModal = new WalletConnectModal({ projectId: projectId, chains: chains, }) + + findExtensions((metadata) => { + this.extensions.push({ + ...metadata, + available: true, + }) + }) } /** @@ -200,21 +213,55 @@ export class DAppConnector { /** * Initiates the WallecConnect connection flow using URI. * @param pairingTopic - The pairing topic for the connection (optional). + * @param extensionId - The id for the extension used to connect (optional). * @returns A Promise that resolves when the connection process is complete. */ public async connect( launchCallback: (uri: string) => void, pairingTopic?: string, - ): Promise { + extensionId?: string, + ): Promise { return this.abortableConnect(async () => { const { uri, approval } = await this.connectURI(pairingTopic) if (!uri) throw new Error('URI is not defined') launchCallback(uri) const session = await approval() + if (extensionId) { + const sessionProperties = { + ...session.sessionProperties, + extensionId, + } + session.sessionProperties = sessionProperties + await this.walletConnectClient?.session.update(session.topic, { + sessionProperties, + }) + } await this.onSessionConnected(session) + return session }) } + /** + * Initiates the WallecConnect connection flow sending a message to the extension. + * @param extensionId - The id for the extension used to connect. + * @param pairingTopic - The pairing topic for the connection (optional). + * @returns A Promise that resolves when the connection process is complete. + */ + public async connectExtension( + extensionId: string, + pairingTopic?: string, + ): Promise { + const extension = this.extensions.find((ext) => ext.id === extensionId) + if (!extension || !extension.available) throw new Error('Extension is not available') + return this.connect( + (uri) => { + extensionConnect(extension.id, uri) + }, + pairingTopic, + extensionId, + ) + } + private abortableConnect = async (callback: () => Promise): Promise => { return new Promise(async (resolve, reject) => { const pairTimeoutMs = 480_000 @@ -225,6 +272,8 @@ export class DAppConnector { try { return resolve(await callback()) + } catch (error) { + reject(error) } finally { clearTimeout(timeout) } @@ -285,7 +334,13 @@ export class DAppConnector { const allNamespaceAccounts = accountAndLedgerFromSession(session) return allNamespaceAccounts.map( ({ account, network }: { account: AccountId; network: LedgerId }) => - new DAppSigner(account, this.walletConnectClient!, session.topic, network), + new DAppSigner( + account, + this.walletConnectClient!, + session.topic, + network, + session.sessionProperties?.extensionId, + ), ) } diff --git a/src/lib/shared/extensionController.ts b/src/lib/shared/extensionController.ts new file mode 100644 index 00000000..f92fdac4 --- /dev/null +++ b/src/lib/shared/extensionController.ts @@ -0,0 +1,44 @@ +export enum EVENTS { + extensionQuery = 'hedera-extension-query', + extensionConnect = 'hedera-extension-connect-', + extensionOpen = 'hedera-extension-open-', + extensionResponse = 'hedera-extension-response', +} + +export type ExtensionData = { + id: string + name?: string + icon?: string + url?: string + available: boolean +} + +export const findExtensions = (onFound: (_metadata: ExtensionData) => void): void => { + if (typeof window === 'undefined') return + + window.addEventListener( + 'message', + (event): void => { + if (event?.data?.type == EVENTS.extensionResponse && event.data.metadata) { + onFound(event.data.metadata) + } + }, + false, + ) + + setTimeout(() => { + extensionQuery() + }, 200) +} + +export const extensionQuery = () => { + window.postMessage({ type: EVENTS.extensionQuery }, '*') +} + +export const extensionConnect = (id: string, pairingString: string) => { + window.postMessage({ type: EVENTS.extensionConnect + id, pairingString }, '*') +} + +export const extensionOpen = (id: string) => { + window.postMessage({ type: EVENTS.extensionOpen + id }, '*') +} diff --git a/src/lib/shared/index.ts b/src/lib/shared/index.ts index 5a65524f..469bf403 100644 --- a/src/lib/shared/index.ts +++ b/src/lib/shared/index.ts @@ -24,3 +24,4 @@ export * from './events' export * from './methods' export * from './payloads' export * from './utils' +export * from './extensionController'