diff --git a/apps/namada-interface/package.json b/apps/namada-interface/package.json index 144b5ec1ad..fc8d5f94b5 100644 --- a/apps/namada-interface/package.json +++ b/apps/namada-interface/package.json @@ -7,6 +7,8 @@ "license": "MIT", "private": true, "dependencies": { + "@cosmjs/socket": "^0.32.3", + "@cosmjs/tendermint-rpc": "^0.32.3", "@namada/components": "0.2.1", "@namada/hooks": "0.2.1", "@namada/integrations": "0.2.1", @@ -38,7 +40,8 @@ "stream": "^0.0.2", "styled-components": "^5.3.3", "typescript": "^5.1.3", - "web-vitals": "^2.1.4" + "web-vitals": "^2.1.4", + "xstream": "^11.14.0" }, "scripts": { "bump": "yarn workspace namada run bump --target apps/namada-interface", diff --git a/apps/namada-interface/src/App/App.tsx b/apps/namada-interface/src/App/App.tsx index 014476dc57..47b0e266e2 100644 --- a/apps/namada-interface/src/App/App.tsx +++ b/apps/namada-interface/src/App/App.tsx @@ -20,7 +20,11 @@ import { import { Account } from "@namada/types"; import { Toasts } from "App/Toast"; import { Outlet } from "react-router-dom"; -import { addAccounts, fetchBalances } from "slices/accounts"; +import { + addAccounts, + fetchBalances, + fetchTransparentBalances, +} from "slices/accounts"; import { setChain } from "slices/chain"; import { SettingsState } from "slices/settings"; import { persistor, store, useAppDispatch, useAppSelector } from "store"; @@ -37,6 +41,7 @@ import { TopNavigation } from "./TopNavigation"; import { chainAtom } from "slices/chain"; +import useCatchEventBlock from "hooks/useCatchEventBlock"; import { useOnAccountsChanged, useOnChainChanged, @@ -67,7 +72,7 @@ export const AnimatedTransition = (props: { function App(): JSX.Element { useOnNamadaExtensionAttached(); useOnNamadaExtensionConnected(); - useOnAccountsChanged(); + const { refreshBalances } = useOnAccountsChanged(); useOnChainChanged(); const dispatch = useAppDispatch(); @@ -89,6 +94,16 @@ function App(): JSX.Element { useEffect(() => storeColorMode(colorMode), [colorMode]); + const updateNewBlock = (): void => { + dispatch(fetchTransparentBalances()); + }; + + useCatchEventBlock({ + rpcAddress: chain.rpc, + triggerCallback: updateNewBlock, + refreshBalancesAtom: refreshBalances, + }); + const extensionAttachStatus = useUntilIntegrationAttached(chain); const currentExtensionAttachStatus = extensionAttachStatus[chain.extension.id]; diff --git a/apps/namada-interface/src/App/fetchEffects.ts b/apps/namada-interface/src/App/fetchEffects.ts index 1f14d12f70..dfb1a07efa 100644 --- a/apps/namada-interface/src/App/fetchEffects.ts +++ b/apps/namada-interface/src/App/fetchEffects.ts @@ -55,7 +55,8 @@ export const useOnNamadaExtensionConnected = (): void => { }, [connected]); }; -export const useOnAccountsChanged = (): void => { +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export const useOnAccountsChanged = () => { const accountsLoadable = useAtomValue(loadable(accountsAtom)); const refreshBalances = useSetAtom(balancesAtom); @@ -67,4 +68,5 @@ export const useOnAccountsChanged = (): void => { refreshPublicKeys(); } }, [accountsLoadable]); + return { refreshBalances }; }; diff --git a/apps/namada-interface/src/hooks/useCatchEventBlock.ts b/apps/namada-interface/src/hooks/useCatchEventBlock.ts new file mode 100644 index 0000000000..504e0ecc1b --- /dev/null +++ b/apps/namada-interface/src/hooks/useCatchEventBlock.ts @@ -0,0 +1,74 @@ +import { Comet38Client } from "@cosmjs/tendermint-rpc"; +import { useEffect, useState } from "react"; +import { useAppSelector } from "store"; +import { Subscription } from "xstream"; + +import { AccountsState } from "slices/accounts"; +import { + connectWebsocketClient, + subscribeNewBlock, + validateConnection, +} from "utils/subscribeNewBlock"; + +type TUseCatchEventBlockProps = { + rpcAddress: string; + refreshBalancesAtom: () => void; + triggerCallback: () => void; +}; + +function useCatchEventBlock({ + rpcAddress, + refreshBalancesAtom, + triggerCallback, +}: TUseCatchEventBlockProps): { + connectState: boolean; + tmClient: Comet38Client | undefined; + subscription: Subscription | undefined; +} { + const [connectState, setConnectState] = useState(false); + const [tmClient, setTmClient] = useState(); + const [subscription, setSubscription] = useState(); + + const { derived } = useAppSelector((state) => state.accounts); + + const connect = async (address: string): Promise => { + try { + const isValid = await validateConnection(address); + if (!isValid) { + throw new Error("Invalid network!"); + } + + const tmClient = await connectWebsocketClient(address); + if (!tmClient) { + throw new Error("Can't connect to client!"); + } + + setConnectState(true); + setTmClient(tmClient); + } catch (err) { + console.error(err); + return; + } + }; + + useEffect(() => { + connect(rpcAddress); + }, [rpcAddress]); + + useEffect(() => { + if (tmClient) { + // triggerCallback is function want to trigger when having new block + const subscription = subscribeNewBlock(tmClient, triggerCallback); + setSubscription(subscription); + } + }, [tmClient]); + + useEffect(() => { + // only refresh balance in account slice is not enouge. Need call function refresh balance below + refreshBalancesAtom(); + }, [derived]); + + return { connectState, tmClient, subscription }; +} + +export default useCatchEventBlock; diff --git a/apps/namada-interface/src/slices/accounts.ts b/apps/namada-interface/src/slices/accounts.ts index 34d8cc6c7a..5f26f12498 100644 --- a/apps/namada-interface/src/slices/accounts.ts +++ b/apps/namada-interface/src/slices/accounts.ts @@ -42,6 +42,7 @@ const INITIAL_STATE = { enum AccountsThunkActions { FetchBalances = "fetchBalances", + FetchTransparentBalances = "fetchTransparentBalances", FetchBalance = "fetchBalance", } @@ -59,6 +60,27 @@ export const fetchBalances = createAsyncThunk( } ); +export const fetchTransparentBalances = createAsyncThunk< + void, + void, + { state: RootState } +>( + `${ACCOUNTS_ACTIONS_BASE}/${AccountsThunkActions.FetchTransparentBalances}`, + async (_, thunkApi) => { + const { id } = chains.namada; + + const accounts: Account[] = Object.values( + thunkApi.getState().accounts.derived[id] + ); + + accounts.forEach((account) => { + if (account.details.type === "mnemonic") { + thunkApi.dispatch(fetchBalance(account)); + } + }); + } +); + // TODO: fetchBalance is broken for integrations other than Namada. This // function should be removed and new code should use the jotai atoms instead. export const fetchBalance = createAsyncThunk< diff --git a/apps/namada-interface/src/utils/subscribeNewBlock.ts b/apps/namada-interface/src/utils/subscribeNewBlock.ts new file mode 100644 index 0000000000..43a5750b6b --- /dev/null +++ b/apps/namada-interface/src/utils/subscribeNewBlock.ts @@ -0,0 +1,73 @@ +import { StreamingSocket } from "@cosmjs/socket"; +import { + Comet38Client, + NewBlockEvent, + WebsocketClient, +} from "@cosmjs/tendermint-rpc"; +import { Subscription } from "xstream"; + +const replaceHTTPtoWebsocket = (url: string): string => { + return url.replace("http", "ws"); +}; + +export function subscribeNewBlock( + tmClient: Comet38Client, + callback: (event: NewBlockEvent) => void +): Subscription { + const stream = tmClient.subscribeNewBlock(); + const subscription = stream.subscribe({ + next: (event) => { + callback(event); + }, + error: (err) => { + console.error(err); + subscription.unsubscribe(); + }, + }); + + return subscription; +} + +export async function validateConnection(rpcAddress: string): Promise { + return new Promise((resolve) => { + const wsUrl = replaceHTTPtoWebsocket(rpcAddress); + const path = wsUrl.endsWith("/") ? "websocket" : "/websocket"; + const socket = new StreamingSocket(wsUrl + path, 3000); + console.log(socket); + socket.events.subscribe({ + error: () => { + resolve(false); + }, + }); + + socket.connect(); + socket.connected.then(() => resolve(true)).catch(() => resolve(false)); + return true; + }); +} + +export async function connectWebsocketClient( + rpcAddress: string +): Promise { + return new Promise(async (resolve, reject) => { + try { + const wsUrl = replaceHTTPtoWebsocket(rpcAddress); + const wsClient = new WebsocketClient(wsUrl, (err) => { + reject(err); + }); + const tmClient = await Comet38Client.create(wsClient); + if (!tmClient) { + reject(new Error("cannot create tendermint client")); + } + + const status = await tmClient.status(); + if (!status) { + reject(new Error("cannot get client status")); + } + + resolve(tmClient); + } catch (err) { + reject(err); + } + }); +} diff --git a/yarn.lock b/yarn.lock index 674899d047..954172d003 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4957,6 +4957,21 @@ __metadata: languageName: node linkType: hard +"@cosmjs/crypto@npm:^0.32.3": + version: 0.32.3 + resolution: "@cosmjs/crypto@npm:0.32.3" + dependencies: + "@cosmjs/encoding": "npm:^0.32.3" + "@cosmjs/math": "npm:^0.32.3" + "@cosmjs/utils": "npm:^0.32.3" + "@noble/hashes": "npm:^1" + bn.js: "npm:^5.2.0" + elliptic: "npm:^6.5.4" + libsodium-wrappers-sumo: "npm:^0.7.11" + checksum: 6925ee15c31d2ed6dfbda666834b188f81706d9c83b9afef27d88e4330cf516addcfcb7f9374dc4513bfea27c5fc717ff49679de9c45b282e601c93b67ac7c98 + languageName: node + linkType: hard + "@cosmjs/encoding@npm:0.27.1": version: 0.27.1 resolution: "@cosmjs/encoding@npm:0.27.1" @@ -5012,6 +5027,17 @@ __metadata: languageName: node linkType: hard +"@cosmjs/encoding@npm:^0.32.3": + version: 0.32.3 + resolution: "@cosmjs/encoding@npm:0.32.3" + dependencies: + base64-js: "npm:^1.3.0" + bech32: "npm:^1.1.4" + readonly-date: "npm:^1.0.0" + checksum: 3c3d4b610093c2c8ca13437664e4736d60cdfb309bf2671f492388c59a9bca20f1a75ab4686a7b73d48aa6208f454bee56c84c0fe780015473ea53353a70266a + languageName: node + linkType: hard + "@cosmjs/json-rpc@npm:^0.27.1": version: 0.27.1 resolution: "@cosmjs/json-rpc@npm:0.27.1" @@ -5032,6 +5058,16 @@ __metadata: languageName: node linkType: hard +"@cosmjs/json-rpc@npm:^0.32.3": + version: 0.32.3 + resolution: "@cosmjs/json-rpc@npm:0.32.3" + dependencies: + "@cosmjs/stream": "npm:^0.32.3" + xstream: "npm:^11.14.0" + checksum: 8074cab7b9fcdd27c86329d820edf8be27e5cf12f99b845acb9d2fd8263b9a26557ee0729d293c8965c75117fcccd440d4c32eb314c03eef0d3c4273408302df + languageName: node + linkType: hard + "@cosmjs/launchpad@npm:^0.24.0-alpha.25, @cosmjs/launchpad@npm:^0.24.1": version: 0.24.1 resolution: "@cosmjs/launchpad@npm:0.24.1" @@ -5097,6 +5133,15 @@ __metadata: languageName: node linkType: hard +"@cosmjs/math@npm:^0.32.3": + version: 0.32.3 + resolution: "@cosmjs/math@npm:0.32.3" + dependencies: + bn.js: "npm:^5.2.0" + checksum: cad8b13a0db739ef4a416b334e39ea9f55874315ebdf91dc38772676c2ead6caccaf8a28b9e8803fc48680a72cf5a9fde97564f5efbfbe9a9073c95665f31294 + languageName: node + linkType: hard + "@cosmjs/proto-signing@npm:^0.24.0-alpha.25": version: 0.24.1 resolution: "@cosmjs/proto-signing@npm:0.24.1" @@ -5149,6 +5194,18 @@ __metadata: languageName: node linkType: hard +"@cosmjs/socket@npm:^0.32.3": + version: 0.32.3 + resolution: "@cosmjs/socket@npm:0.32.3" + dependencies: + "@cosmjs/stream": "npm:^0.32.3" + isomorphic-ws: "npm:^4.0.1" + ws: "npm:^7" + xstream: "npm:^11.14.0" + checksum: 25a82bd503d6f41adc3fa0b8c350b21bc4838efb0f1322966d6ebffefee61b5f5220d2fe3795b95932873f17937ceae45b25c5d1de92ed72b13abb7309cbace9 + languageName: node + linkType: hard + "@cosmjs/stargate@npm:^0.29.5": version: 0.29.5 resolution: "@cosmjs/stargate@npm:0.29.5" @@ -5187,6 +5244,15 @@ __metadata: languageName: node linkType: hard +"@cosmjs/stream@npm:^0.32.3": + version: 0.32.3 + resolution: "@cosmjs/stream@npm:0.32.3" + dependencies: + xstream: "npm:^11.14.0" + checksum: 963abad76c044265e6961add2a66060134dd610ced9397edcd331669e5aca2a157cc08db658590110233038c38fc5812a9e8d156babbf524eb291200a3708b3a + languageName: node + linkType: hard + "@cosmjs/tendermint-rpc@npm:^0.29.5, @cosmjs/tendermint-rpc@npm:~0.29.5": version: 0.29.5 resolution: "@cosmjs/tendermint-rpc@npm:0.29.5" @@ -5205,6 +5271,24 @@ __metadata: languageName: node linkType: hard +"@cosmjs/tendermint-rpc@npm:^0.32.3": + version: 0.32.3 + resolution: "@cosmjs/tendermint-rpc@npm:0.32.3" + dependencies: + "@cosmjs/crypto": "npm:^0.32.3" + "@cosmjs/encoding": "npm:^0.32.3" + "@cosmjs/json-rpc": "npm:^0.32.3" + "@cosmjs/math": "npm:^0.32.3" + "@cosmjs/socket": "npm:^0.32.3" + "@cosmjs/stream": "npm:^0.32.3" + "@cosmjs/utils": "npm:^0.32.3" + axios: "npm:^1.6.0" + readonly-date: "npm:^1.0.0" + xstream: "npm:^11.14.0" + checksum: 9ccde526456e9c4be7a2562c3def25a016267404a057e807ecc0f520aeb0cbfc5bf04bfca58ceecd6f7bf61b7089924c7949c13a7d685efc7ad946b71388c3df + languageName: node + linkType: hard + "@cosmjs/utils@npm:0.27.1": version: 0.27.1 resolution: "@cosmjs/utils@npm:0.27.1" @@ -5233,6 +5317,13 @@ __metadata: languageName: node linkType: hard +"@cosmjs/utils@npm:^0.32.3": + version: 0.32.3 + resolution: "@cosmjs/utils@npm:0.32.3" + checksum: e21cb0387d135142fdebe64fadfe2f7c9446b8b974b9d0dff7a02f04e17e79fcfc3946258ad79af1db35b252058d97c38e1f90f2f14e903a37d85316f31efde6 + languageName: node + linkType: hard + "@cspotcode/source-map-support@npm:^0.8.0": version: 0.8.1 resolution: "@cspotcode/source-map-support@npm:0.8.1" @@ -8115,6 +8206,8 @@ __metadata: version: 0.0.0-use.local resolution: "@namada/namada-interface@workspace:apps/namada-interface" dependencies: + "@cosmjs/socket": "npm:^0.32.3" + "@cosmjs/tendermint-rpc": "npm:^0.32.3" "@namada/components": "npm:0.2.1" "@namada/config": "workspace:^" "@namada/hooks": "npm:0.2.1" @@ -8191,6 +8284,7 @@ __metadata: webpack-bundle-analyzer: "npm:^4.10.1" webpack-cli: "npm:^5.0.1" webpack-dev-server: "npm:^4.11.1" + xstream: "npm:^11.14.0" languageName: unknown linkType: soft @@ -12285,6 +12379,17 @@ __metadata: languageName: node linkType: hard +"axios@npm:^1.6.0": + version: 1.6.8 + resolution: "axios@npm:1.6.8" + dependencies: + follow-redirects: "npm:^1.15.6" + form-data: "npm:^4.0.0" + proxy-from-env: "npm:^1.1.0" + checksum: 0f22da6f490335479a89878bc7d5a1419484fbb437b564a80c34888fc36759ae4f56ea28d55a191695e5ed327f0bad56e7ff60fb6770c14d1be6501505d47ab9 + languageName: node + linkType: hard + "axobject-query@npm:^2.2.0": version: 2.2.0 resolution: "axobject-query@npm:2.2.0" @@ -18419,6 +18524,16 @@ __metadata: languageName: node linkType: hard +"follow-redirects@npm:^1.15.6": + version: 1.15.6 + resolution: "follow-redirects@npm:1.15.6" + peerDependenciesMeta: + debug: + optional: true + checksum: 9ff767f0d7be6aa6870c82ac79cf0368cd73e01bbc00e9eb1c2a16fbb198ec105e3c9b6628bb98e9f3ac66fe29a957b9645bcb9a490bb7aa0d35f908b6b85071 + languageName: node + linkType: hard + "for-each@npm:^0.3.3": version: 0.3.3 resolution: "for-each@npm:0.3.3" @@ -24833,6 +24948,22 @@ __metadata: languageName: node linkType: hard +"libsodium-sumo@npm:^0.7.13": + version: 0.7.13 + resolution: "libsodium-sumo@npm:0.7.13" + checksum: 8159205cc36cc4bdf46ee097e5f998d5cac7d11612be7406a8396ca3ee31560871ac17daa69e47ff0e8407eeae9f49313912ea95dbc8715875301b004c28ef5b + languageName: node + linkType: hard + +"libsodium-wrappers-sumo@npm:^0.7.11": + version: 0.7.13 + resolution: "libsodium-wrappers-sumo@npm:0.7.13" + dependencies: + libsodium-sumo: "npm:^0.7.13" + checksum: 51a151d0f73418632dcf9cf0184b14d8eb6e16b9a3f01a652c7401c6d1bf8ead4f5ce40a4f00bd4754c5719a7a5fb71d6125691896aeb7a9c1abcfe4b73afc02 + languageName: node + linkType: hard + "libsodium-wrappers@npm:^0.7.6": version: 0.7.9 resolution: "libsodium-wrappers@npm:0.7.9"