Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[hooks] Add light client #1419

Merged
merged 6 commits into from
Oct 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion utils/gear-hooks/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@gear-js/react-hooks",
"version": "0.7.0",
"version": "0.8.0",
nikitayutanov marked this conversation as resolved.
Show resolved Hide resolved
"description": "React hooks used across Gear applications",
"author": "Gear Technologies",
"license": "GPL-3.0",
Expand Down Expand Up @@ -36,6 +36,7 @@
"@polkadot/util": "12.3.2",
"@rollup/plugin-commonjs": "22.0.2",
"@rollup/plugin-node-resolve": "13.3.0",
"@substrate/connect": "0.7.32",
"@types/react": "18.0.37",
"@types/react-dom": "18.0.11",
"@types/react-transition-group": "4.4.5",
Expand Down
4 changes: 2 additions & 2 deletions utils/gear-hooks/rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ export default [
{
input: 'src/index.ts',
output: [
{ file: packageJson.main, format: 'cjs' },
{ file: packageJson.module, format: 'esm' },
{ file: packageJson.main, format: 'cjs', inlineDynamicImports: true },
{ file: packageJson.module, format: 'esm', inlineDynamicImports: true },
],
plugins: [peerDepsExternal(), resolve(), commonjs(), typescript(), terser()],
},
Expand Down
46 changes: 22 additions & 24 deletions utils/gear-hooks/src/context/Account.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import type { InjectedAccountWithMeta, InjectedExtension } from '@polkadot/extension-inject/types';
import { Balance } from '@polkadot/types/interfaces';
import { web3Accounts, web3AccountsSubscribe, web3Enable } from '@polkadot/extension-dapp';
import { UnsubscribePromise } from '@polkadot/api/types';
import { formatBalance } from '@polkadot/util';
import { decodeAddress } from '@gear-js/api';
import { GearApi, decodeAddress } from '@gear-js/api';
import { useState, createContext, useContext, useEffect } from 'react';
import { LOCAL_STORAGE } from 'consts';
import { isLoggedIn } from 'utils';
Expand Down Expand Up @@ -37,9 +36,9 @@ function AccountProvider({ children }: ProviderProps) {

const handleError = ({ message }: Error) => alert.error(message);

const getBalance = (balance: Balance) => {
const [unit] = api.registry.chainTokens;
const [decimals] = api.registry.chainDecimals;
const getBalance = (_api: GearApi, balance: Balance) => {
const [unit] = _api.registry.chainTokens;
const [decimals] = _api.registry.chainDecimals;

const value = formatBalance(balance.toString(), {
decimals,
Expand All @@ -58,11 +57,17 @@ function AccountProvider({ children }: ProviderProps) {
};

const login = (acc: InjectedAccountWithMeta) =>
api?.balance
.findOut(acc.address)
.then((balance) => ({ ...acc, balance: getBalance(balance), decodedAddress: decodeAddress(acc.address) }))
.then(switchAccount)
.catch(handleError);
isApiReady
? api.balance
.findOut(acc.address)
.then((balance) => ({
...acc,
balance: getBalance(api, balance),
decodedAddress: decodeAddress(acc.address),
}))
.then((result) => switchAccount(result))
.catch(handleError)
: Promise.reject('API is not initialized');

const logout = () => {
localStorage.removeItem(LOCAL_STORAGE.ACCOUNT);
Expand All @@ -84,35 +89,28 @@ function AccountProvider({ children }: ProviderProps) {
useEffect(() => {
if (!isWeb3Enabled) return;

const loggedInAccount = accounts?.find(isLoggedIn);
let unsub: UnsubscribePromise | undefined;
const loggedInAccount = accounts.find(isLoggedIn);

web3AccountsSubscribe((accs) => setAccounts(accs));

if (loggedInAccount) {
login(loggedInAccount).finally(() => setIsAccountReady(true));
} else setIsAccountReady(true);

return () => {
unsub?.then((unsubCallback) => unsubCallback());
};
}, [isWeb3Enabled]);

const updateBalance = (balance: Balance) =>
setAccount((prevAccount) => (prevAccount ? { ...prevAccount, balance: getBalance(balance) } : prevAccount));
const updateBalance = (_api: GearApi, balance: Balance) =>
setAccount((prevAccount) => (prevAccount ? { ...prevAccount, balance: getBalance(_api, balance) } : prevAccount));

useEffect(() => {
let unsub: UnsubscribePromise | undefined;
if (!isApiReady || !address) return;

if (address) {
unsub = api?.gearEvents.subscribeToBalanceChanges(address, updateBalance);
}
const unsub = api.gearEvents.subscribeToBalanceChanges(address, (balance) => updateBalance(api, balance));

return () => {
unsub?.then((callback) => callback());
unsub.then((unsubCallback) => unsubCallback());
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [api, address]);
}, [isApiReady, api, address]);

const { Provider } = AccountContext;
const value = { extensions, accounts, account, isAccountReady, login, logout };
Expand Down
93 changes: 79 additions & 14 deletions utils/gear-hooks/src/context/Api.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,97 @@
import { createContext, useEffect, useState } from 'react';
import { GearApi } from '@gear-js/api';
import { WsProvider, ScProvider } from '@polkadot/api';
import * as Sc from '@substrate/connect';
import { createContext, useEffect, useMemo, useRef, useState } from 'react';

import { ProviderProps } from 'types';
import { WsProvider } from '@polkadot/api';

type Value = {
api: GearApi;
isApiReady: boolean;
type WsProviderArgs = {
endpoint: string | string[];
autoConnectMs?: number | false;
headers?: Record<string, string>;
timeout?: number;
};

type ScProviderArgs = {
spec: string;
sharedSandbox?: ScProvider;
};

type ProviderArgs = WsProviderArgs | ScProviderArgs;

type Value = {
switchNetwork: (args: ProviderArgs) => Promise<void>;
} & (
| {
api: undefined;
isApiReady: false;
}
| {
api: GearApi;
isApiReady: true;
}
);

type Props = ProviderProps & {
providerAddress: string;
timeout?: number;
initialArgs: ProviderArgs;
};

const ApiContext = createContext({} as Value);
const initialValue = {
api: undefined,
isApiReady: false as const,
switchNetwork: () => Promise.resolve(),
};

function ApiProvider({ providerAddress, timeout, children }: Props) {
const ApiContext = createContext<Value>(initialValue);
const { Provider } = ApiContext;

function ApiProvider({ initialArgs, children }: Props) {
const [api, setApi] = useState<GearApi>();
const providerRef = useRef<WsProvider | ScProvider>();

const { Provider } = ApiContext;
const value = { api: api as GearApi, isApiReady: !!api };
const switchNetwork = async (args: ProviderArgs) => {
// disconnect from provider instead of api,
// cuz on failed GearApi.create connection is already established,
// but api state is empty
if (providerRef.current) {
setApi(undefined);
await providerRef.current.disconnect();
}

useEffect(() => {
const provider = new WsProvider(providerAddress, undefined, undefined, timeout);
const isLightClient = 'spec' in args;

const provider = isLightClient
? new ScProvider(Sc, args.spec, args.sharedSandbox)
: new WsProvider(args.endpoint, args.autoConnectMs, args.headers, args.timeout);

providerRef.current = provider;

// on set autoConnectMs connection starts automatically,
// and in case of error it continues to execute via recursive setTimeout.
// cuz of this it's necessary to await empty promise,
// otherwise GearApi.create would be called before established connection.

GearApi.create({ provider }).then(setApi);
// mostly it's a workaround around React.StrictMode hooks behavior to support autoConnect,
// and since it's based on ref and WsProvider's implementation,
// it should be treated carefully
await (isLightClient || (args.autoConnectMs !== undefined && !args.autoConnectMs)
? provider.connect()
: Promise.resolve());

const result = await GearApi.create({ provider });
setApi(result);
};

useEffect(() => {
switchNetwork(initialArgs);
}, []);

const value = useMemo(
() =>
api ? { api, isApiReady: true as const, switchNetwork } : { api, isApiReady: false as const, switchNetwork },
[api],
);

return <Provider value={value}>{children}</Provider>;
}

Expand Down
2 changes: 2 additions & 0 deletions utils/gear-hooks/src/hooks/api/balance/use-balance-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ function useBalanceFormat() {

const valuePerGas = useMemo(() => {
try {
if (!isApiReady) throw new Error('API is not initialized');

return api.valuePerGas.toString();
} catch {
return '1000';
Expand Down
12 changes: 8 additions & 4 deletions utils/gear-hooks/src/hooks/api/useCalculateGas/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ function useUploadCalculateGas(
meta?: ProgramMetadata | undefined,
options?: Options,
) {
const { api } = useContext(ApiContext); // сircular dependency fix
const { api, isApiReady } = useContext(ApiContext); // сircular dependency fix
const { account } = useContext(AccountContext);

const calculateGas = (initPayload: AnyJson, value: AnyNumber = 0): Promise<GasInfo> => {
if (!isApiReady) return Promise.reject(new Error('API is not initialized'));
if (!account) return Promise.reject(new Error('No account address'));
if (!code) return Promise.reject(new Error('No program source'));

Expand All @@ -27,10 +28,11 @@ function useUploadCalculateGas(
}

function useCreateCalculateGas(codeId: HexString | undefined, meta?: ProgramMetadata | undefined, options?: Options) {
const { api } = useContext(ApiContext); // сircular dependency fix
const { api, isApiReady } = useContext(ApiContext); // сircular dependency fix
const { account } = useContext(AccountContext);

const calculateGas = (initPayload: AnyJson, value: AnyNumber = 0): Promise<GasInfo> => {
if (!isApiReady) return Promise.reject(new Error('API is not initialized'));
if (!account) return Promise.reject(new Error('No account address'));
if (!codeId) return Promise.reject(new Error('No program source'));

Expand All @@ -48,10 +50,11 @@ function useHandleCalculateGas(
meta?: ProgramMetadata | undefined,
options?: Options,
) {
const { api } = useContext(ApiContext); // сircular dependency fix
const { api, isApiReady } = useContext(ApiContext); // сircular dependency fix
const { account } = useContext(AccountContext);

const calculateGas = (initPayload: AnyJson, value: AnyNumber = 0): Promise<GasInfo> => {
if (!isApiReady) return Promise.reject(new Error('API is not initialized'));
if (!account) return Promise.reject(new Error('No account address'));
if (!destinationId) return Promise.reject(new Error('No program source'));

Expand All @@ -72,10 +75,11 @@ function useHandleCalculateGas(
}

function useReplyCalculateGas(messageId: HexString | undefined, meta?: ProgramMetadata | undefined, options?: Options) {
const { api } = useContext(ApiContext); // сircular dependency fix
const { api, isApiReady } = useContext(ApiContext); // сircular dependency fix
const { account } = useContext(AccountContext);

const calculateGas = (initPayload: AnyJson, value: AnyNumber = 0) => {
if (!isApiReady) return Promise.reject(new Error('API is not initialized'));
if (!account) return Promise.reject(new Error('No account address'));
if (!messageId) return Promise.reject(new Error('No program source'));

Expand Down
6 changes: 4 additions & 2 deletions utils/gear-hooks/src/hooks/api/useDepricatedSendMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,15 @@ function useDepricatedSendMessage(
metadata: ProgramMetadata | undefined,
{ isMaxGasLimit = false, disableAlerts }: UseDepricatedSendMessageOptions = {},
) {
const { api } = useContext(ApiContext); // сircular dependency fix
const { api, isApiReady } = useContext(ApiContext); // сircular dependency fix
const { account } = useContext(AccountContext);
const alert = useContext(AlertContext);

const title = 'gear.sendMessage';

const handleEventsStatus = (events: EventRecord[], onSuccess?: () => void, onError?: () => void) => {
if (!isApiReady) return;

events.forEach(({ event }) => {
const { method, section } = event;

Expand Down Expand Up @@ -75,7 +77,7 @@ function useDepricatedSendMessage(
};

const sendMessage = (payload: AnyJson, options?: DepricatedSendMessageOptions) => {
if (account && metadata) {
if (account && metadata && isApiReady) {
const alertId = disableAlerts ? '' : alert.loading('Sign In', { title });

const { value = 0, isOtherPanicsAllowed = false, prepaid = false, onSuccess, onError } = options || {};
Expand Down
17 changes: 8 additions & 9 deletions utils/gear-hooks/src/hooks/api/useProgram/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { GasLimit, ProgramMetadata } from '@gear-js/api';
import { AnyJson } from '@polkadot/types/types';
import { useContext } from 'react';
import { AccountContext, AlertContext, ApiContext } from 'context';
import { TransactionName, SingAndSendParams, Options, Code, CodeId, UseProgram } from './types';
import { TransactionName, Options, Code, CodeId, UseProgram } from './types';
import { useHandlers } from './useHandlers';
import { waitForProgramInit } from './utils';

Expand All @@ -26,18 +26,13 @@ function useProgram(
payloadType?: string | undefined,
): UseProgram {
const alert = useContext(AlertContext); // сircular dependency fix
const { api } = useContext(ApiContext);
const { api, isApiReady } = useContext(ApiContext);
const { account } = useContext(AccountContext);

const { handleSignStatus, handleInitStatus, handleError } = useHandlers();

const signAndSend = (params: SingAndSendParams) => {
const { address, signer, ...signHandlerParams } = params;

return api.program.signAndSend(address, { signer }, (result) => handleSignStatus({ result, ...signHandlerParams }));
};

const action = (initPayload: AnyJson, gasLimit: GasLimit, options?: Options) => {
if (!isApiReady) return Promise.reject(new Error('API is not initialized'));
if (!account) return Promise.reject(new Error('No account address'));
if (!codeOrCodeId) return Promise.reject(new Error('No program buffer'));

Expand All @@ -60,7 +55,11 @@ function useProgram(
const initialization = waitForProgramInit(api, programId);

return web3FromSource(source)
.then(({ signer }) => signAndSend({ address, signer, callbacks, alertId, programId }))
.then(({ signer }) =>
api.program.signAndSend(address, { signer }, (result) =>
handleSignStatus({ result, callbacks, alertId, programId }),
),
)
.then(() => initialization)
.then((status) => handleInitStatus({ status, programId, onError }))
.catch(({ message }: Error) => handleError({ message, alertId, onError }));
Expand Down
9 changes: 0 additions & 9 deletions utils/gear-hooks/src/hooks/api/useProgram/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,6 @@ type Callbacks = {
onError?: () => void;
};

type SingAndSendParams = {
address: string;
signer: Signer;
alertId: string;
programId: HexString;
callbacks?: Callbacks;
};

type HandleInitParams = {
status: string;
programId: HexString;
Expand Down Expand Up @@ -93,7 +85,6 @@ type UseProgram = (initPayload: AnyJson, gasLimit: GasLimit, options?: Options)
export { Method, TransactionStatus, ProgramStatus, TransactionName, ProgramError };
export type {
Callbacks,
SingAndSendParams,
HandleInitParams,
HandleErrorParams,
HandleSignStatusParams,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import { getExtrinsicFailedMessage } from 'utils';
import { Callbacks, Method, HandleSignStatusParams, TransactionStatus, ProgramError } from '../types';

function useHandleSignStatus() {
const { api } = useContext(ApiContext); // сircular dependency fix
const { api, isApiReady } = useContext(ApiContext); // сircular dependency fix
const alert = useContext(AlertContext);

const handleEventsStatus = (events: EventRecord[], programId: HexString, callbacks?: Callbacks) => {
if (!isApiReady) throw new Error('API is not initialized');

const { onError, onSuccess } = callbacks || {};

events.forEach(({ event }) => {
Expand Down
Loading
Loading