From 87e2bbe874c1f2c98ba162cad3549315bb005a95 Mon Sep 17 00:00:00 2001 From: Esteban Date: Mon, 3 Apr 2023 09:56:02 -0300 Subject: [PATCH] Show user balance of EXT token and update the user balance after mint (#95) * soroban-cli command is just 'soroban' * Add npm install instructions * not use optimizing builds * use SorobanContext name following web3-react standard * Revert "not use optimizing builds" This reverts commit 840e69aa2d95b4381b9bade1e05b7ad02799b78d. * fix: use SorobanContext * use soroban-react folder * change WalletProvider to SorobanReactProvider inside soroban-react folder * use useSorobanReact() instead of React.useContext(SorobanContext) * use ProviderExample in _app.tsx * move Wallet.tsx to types * use connector instead of wallet * React.Node type for children * delete components/WalletProvider.tsx as using soroban-react/SorobanReactProvider.tsx * use getDefaultConnectors instead of getDefaultWallets * realtime react for wallet address or network changes * use @soroban-react library * use @soroban-react/core v1.02 * use @soroban-react/core v1.0.2 * Delete yarn.lock Trying to only have package-lock.json with npm for simplicity of the example repo. * Update wallet/hooks/useNetwork.tsx * handle error when wallets are not funded * feat(user-balance): Add user's token balance section * feat(user-balance): reconects, and hence upload balance after mint transactions * Use early return when not having balance in wallet * Fix an import * Fixing a bunch of typos and compilation errors --------- Co-authored-by: Paul Bellamy Co-authored-by: Paul Bellamy --- components/molecules/form-pledge/index.tsx | 65 +++++++- wallet/WalletChainContext.tsx | 29 ++++ wallet/connectors/freighter/index.tsx | 37 +++++ wallet/connectors/index.ts | 1 + wallet/getDefaultConnectors.tsx | 22 +++ wallet/soroban-react/SorobanContext.tsx | 28 ++++ wallet/soroban-react/SorobanReactProvider.tsx | 142 ++++++++++++++++++ wallet/soroban-react/index.tsx | 3 + wallet/soroban-react/useSorobanReact.tsx | 10 ++ wallet/types/index.tsx | 31 ++++ 10 files changed, 361 insertions(+), 7 deletions(-) create mode 100644 wallet/WalletChainContext.tsx create mode 100644 wallet/connectors/freighter/index.tsx create mode 100644 wallet/connectors/index.ts create mode 100644 wallet/getDefaultConnectors.tsx create mode 100644 wallet/soroban-react/SorobanContext.tsx create mode 100644 wallet/soroban-react/SorobanReactProvider.tsx create mode 100644 wallet/soroban-react/index.tsx create mode 100644 wallet/soroban-react/useSorobanReact.tsx create mode 100644 wallet/types/index.tsx diff --git a/components/molecules/form-pledge/index.tsx b/components/molecules/form-pledge/index.tsx index f7d51a1..8b495f2 100644 --- a/components/molecules/form-pledge/index.tsx +++ b/components/molecules/form-pledge/index.tsx @@ -1,8 +1,9 @@ import React, { FunctionComponent, useState } from 'react' import { AmountInput, Button, Checkbox } from '../../atoms' import { TransactionModal } from '../../molecules/transaction-modal' +import { Utils } from '../../../shared/utils' import styles from './style.module.css' -import { useSendTransaction } from '@soroban-react/contracts' +import { useSendTransaction, useContractValue } from '@soroban-react/contracts' import { useSorobanReact } from '@soroban-react/core' import { useNetwork, @@ -28,10 +29,44 @@ export interface IResultSubmit { scVal?: SorobanClient.xdr.ScVal error?: string value?: number - symbol?: string + symbol?: string } const FormPledge: FunctionComponent = props => { + const sorobanContext = useSorobanReact() + + + // Call the contract to get user's balance of the token + const useLoadToken = (): any => { + return { + userBalance: useContractValue({ + contractId: Constants.TokenId, + method: 'balance', + params: [new SorobanClient.Address(props.account).toScVal()], + sorobanContext + }), + decimals: useContractValue({ + contractId: Constants.TokenId, + method: 'decimals', + sorobanContext + }), + symbol: useContractValue({ + contractId: Constants.TokenId, + method: 'symbol', + sorobanContext + }), + } + } + + let token = useLoadToken() + const userBalance = convert.scvalToBigNumber(token.userBalance.result) + const tokenDecimals = + token.decimals.result && (token.decimals.result?.u32() ?? 7) + const tokenSymbol = + token.symbol.result && convert.scvalToString(token.symbol.result)?.replace("\u0000", "") + + + const [amount, setAmount] = useState() const [resultSubmit, setResultSubmit] = useState() @@ -39,7 +74,6 @@ const FormPledge: FunctionComponent = props => { const [isSubmitting, setSubmitting] = useState(false) const { server } = useNetwork() - const sorobanContext = useSorobanReact() const parsedAmount = BigNumber(amount || 0) const { sendTransaction } = useSendTransaction() @@ -174,6 +208,11 @@ const FormPledge: FunctionComponent = props => { decimals={props.decimals} symbol={props.symbol} /> +
+
+
Your balance: {Utils.formatAmount(userBalance, tokenDecimals)} {tokenSymbol}
+
+
) : null} {resultSubmit && ( @@ -204,12 +243,18 @@ const FormPledge: FunctionComponent = props => { title={`Mint ${amount.toString()} ${symbol}`} onClick={async () => { setSubmitting(true) - if (!server) throw new Error("Not connected to server") - const adminSource = await server.getAccount(Constants.TokenAdmin) - - const walletSource = await server.getAccount(account) + let adminSource, walletSource + try{ + adminSource = await server.getAccount(Constants.TokenAdmin) + walletSource = await server.getAccount(account) + } + catch(error){ + alert("Your wallet or the token admin wallet might not be funded") + setSubmitting(false) + return + } // // 1. Establish a trustline to the admin (if necessary) @@ -225,6 +270,7 @@ const FormPledge: FunctionComponent = props => { // // Today, we establish the trustline unconditionally. try { + console.log("Establishing the trustline...") console.log("sorobanContext: ", sorobanContext) const trustlineResult = await sendTransaction( new SorobanClient.TransactionBuilder(walletSource, { @@ -247,10 +293,12 @@ const FormPledge: FunctionComponent = props => { ) console.debug(trustlineResult) } catch (err) { + console.log("Error while establishing the trustline: ", err) console.error(err) } try { + console.log("Minting the token...") const paymentResult = await sendTransaction( new SorobanClient.TransactionBuilder(adminSource, { networkPassphrase, @@ -272,7 +320,9 @@ const FormPledge: FunctionComponent = props => { } ) console.debug(paymentResult) + sorobanContext.connect() } catch (err) { + console.log("Error while minting the token: ", err) console.error(err) } // @@ -280,6 +330,7 @@ const FormPledge: FunctionComponent = props => { // on the result // setSubmitting(false) + }} disabled={isSubmitting} isLoading={isSubmitting} diff --git a/wallet/WalletChainContext.tsx b/wallet/WalletChainContext.tsx new file mode 100644 index 0000000..41c4e6f --- /dev/null +++ b/wallet/WalletChainContext.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +export interface WalletChain { + id: string; + name?: string; + networkPassphrase: string; + iconBackground?: string; + iconUrl?: string | null; + // TODO: Use this to indicate which chains a dapp supports + unsupported?: boolean; +}; + +export const WalletChainContext = React.createContext([]); + +export const useWalletChains = () => React.useContext(WalletChainContext); + +export const useWalletChainsById = () => { + const walletChains = useWalletChains(); + + return React.useMemo(() => { + const walletChainsById: Record = {}; + + walletChains.forEach(rkChain => { + walletChainsById[rkChain.id] = rkChain; + }); + + return walletChainsById; + }, [walletChains]); +}; diff --git a/wallet/connectors/freighter/index.tsx b/wallet/connectors/freighter/index.tsx new file mode 100644 index 0000000..3ea8fec --- /dev/null +++ b/wallet/connectors/freighter/index.tsx @@ -0,0 +1,37 @@ +/* eslint-disable sort-keys-fix/sort-keys-fix */ +import freighterApi from "@stellar/freighter-api"; +import { WalletChain } from '../../WalletChainContext'; +import { NetworkDetails, Connector } from '../../types'; + +export interface FreighterOptions { + appName?: string; + chains: WalletChain[]; +} + +export function freighter(_: FreighterOptions): Connector { + return { + id: 'freighter', + name: 'Freighter', + iconUrl: async () => '', + // iconUrl: async () => (await import('./freighter.svg')).default, + iconBackground: '#fff', + // TODO: Check this + installed: true, + downloadUrls: { + browserExtension: + 'https://chrome.google.com/webstore/detail/freighter/bcacfldlkkdogcmkkibnjlakofdplcbk?hl=en', + }, + isConnected(): boolean { + return !!freighterApi?.isConnected() + }, + getNetworkDetails(): Promise { + return freighterApi.getNetworkDetails() + }, + getPublicKey(): Promise { + return freighterApi.getPublicKey() + }, + signTransaction(xdr: string, opts?: { network?: string; networkPassphrase?: string; accountToSign?: string }): Promise { + return freighterApi.signTransaction(xdr, opts) + }, + } +}; diff --git a/wallet/connectors/index.ts b/wallet/connectors/index.ts new file mode 100644 index 0000000..66c66b3 --- /dev/null +++ b/wallet/connectors/index.ts @@ -0,0 +1 @@ +export * from "./freighter"; diff --git a/wallet/getDefaultConnectors.tsx b/wallet/getDefaultConnectors.tsx new file mode 100644 index 0000000..93ade08 --- /dev/null +++ b/wallet/getDefaultConnectors.tsx @@ -0,0 +1,22 @@ +import { WalletChain } from './WalletChainContext'; +import { ConnectorList } from './types'; +import { freighter } from './connectors'; + +export const getDefaultConnectors = ( + {appName,chains,}: {appName: string; chains: WalletChain[];}) + : { + + connectors: ConnectorList;} => { + const connectors: ConnectorList = [ + { + groupName: 'Popular', + connectors: [ + freighter({ appName, chains }), + ], + }, + ]; + + return { + connectors, + }; +}; diff --git a/wallet/soroban-react/SorobanContext.tsx b/wallet/soroban-react/SorobanContext.tsx new file mode 100644 index 0000000..c15071c --- /dev/null +++ b/wallet/soroban-react/SorobanContext.tsx @@ -0,0 +1,28 @@ +import React, {createContext} from "react"; +import * as SorobanClient from "soroban-client"; +import { ChainMetadata } from "@soroban-react/types"; +import { Connector, ConnectorList } from "../types"; + +export const defaultSorobanContext: SorobanContextType = { + appName: undefined, + chains: [], + connectors: [], + server: new SorobanClient.Server("https://soroban-rpc.stellar.org"), + async connect() {}, + async disconnect() {}, +}; + +export interface SorobanContextType { + autoconnect?: boolean; + appName?: string; + chains: ChainMetadata[]; + connectors: ConnectorList; + activeChain?: ChainMetadata; + address?: string; + activeWallet?: Connector; + server?: SorobanClient.Server; + connect: () => Promise; + disconnect: () => Promise; +} + +export const SorobanContext = createContext(undefined) diff --git a/wallet/soroban-react/SorobanReactProvider.tsx b/wallet/soroban-react/SorobanReactProvider.tsx new file mode 100644 index 0000000..e2c3af1 --- /dev/null +++ b/wallet/soroban-react/SorobanReactProvider.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import * as SorobanClient from 'soroban-client'; +import { SorobanContext, SorobanContextType, defaultSorobanContext } from '.'; +import { ConnectorList, NetworkDetails } from "../types"; +import { WalletChain, } from '../WalletChainContext'; + +/** + * @param children - A React subtree that needs access to the context. + */ + +export interface SorobanReactProviderProps { + appName?: string; + autoconnect?: boolean; + chains: WalletChain[]; + children: React.ReactNode; + connectors: ConnectorList; +} + +function networkToActiveChain(networkDetails: NetworkDetails | undefined, chains: WalletChain[]) { + const supported = networkDetails && chains.find(c => c.networkPassphrase === networkDetails?.networkPassphrase) + const activeChain = networkDetails && { + id: supported?.id ?? networkDetails.networkPassphrase, + name: supported?.name ?? networkDetails.network, + networkPassphrase: networkDetails.networkPassphrase, + iconBackground: supported?.iconBackground, + iconUrl: supported?.iconUrl, + unsupported: !supported, + } + return activeChain +} + +export function SorobanReactProvider({ + appName, + autoconnect = false, + chains, + children, + connectors, +}: SorobanReactProviderProps) { + + + const flatWallets = connectors.flatMap(w => w.connectors); + const activeWallet = flatWallets.length == 1 ? flatWallets[0] : undefined; + const isConnectedRef = React.useRef(false); + + const [mySorobanContext, setSorobanContext] = React.useState({ + ...defaultSorobanContext, + appName, + autoconnect, + chains, + connectors, + activeWallet, + activeChain: chains.length == 1 ? chains[0] : undefined, + connect: async () => { + let networkDetails = await mySorobanContext.activeWallet?.getNetworkDetails() + let activeChain = networkToActiveChain(networkDetails, chains) + + let address = await mySorobanContext.activeWallet?.getPublicKey() + let server = networkDetails && new SorobanClient.Server( + networkDetails.networkUrl, + { allowHttp: networkDetails.networkUrl.startsWith("http://") } + ) + + // Now we can track that the wallet is finally connected + isConnectedRef.current = true; + + setSorobanContext(c => ({ + ...c, + activeChain, + address, + server, + })); + }, + disconnect: async () => { + isConnectedRef.current = false; + // TODO: Maybe reset address to undefined + } + }); + + // Handle changes of address/network in "realtime" + React.useEffect(() => { + let timeoutId: NodeJS.Timer | null = null; + const freighterCheckIntervalMs = 200; + + async function checkForWalletChanges () { + // Returns if not installed / not active / not connected (TODO: currently always isConnected=true) + if (!mySorobanContext.activeWallet || !mySorobanContext.activeWallet.isConnected() || !isConnectedRef.current) return; + let hasNoticedWalletUpdate = false; + + try { + let chain = networkToActiveChain(await mySorobanContext.activeWallet?.getNetworkDetails(), chains) + let address = await mySorobanContext.activeWallet?.getPublicKey(); + + // No active chain + if (!chain || !mySorobanContext.activeChain) return; + + if (mySorobanContext.address !== address) { + console.log("SorobanReactProvider: address changed from:", mySorobanContext.address," to: ", address); + hasNoticedWalletUpdate = true; + + console.log("SorobanReactProvider: reconnecting") + mySorobanContext.connect(); + + } else if (mySorobanContext.activeChain.networkPassphrase != chain.networkPassphrase) { + console.log( "SorobanReactProvider: networkPassphrase changed from: ", + mySorobanContext.activeChain.networkPassphrase, + " to: ", + chain.networkPassphrase) + hasNoticedWalletUpdate = true; + + console.log("SorobanReactProvider: reconnecting") + mySorobanContext.connect(); + } + } catch (error) { + console.error("SorobanReactProvider: error: ", error); + } finally { + if (!hasNoticedWalletUpdate) timeoutId = setTimeout(checkForWalletChanges, freighterCheckIntervalMs); + } + } + + checkForWalletChanges(); + + return () => { + if (timeoutId != null) clearTimeout(timeoutId); + } + }, [mySorobanContext]); + + React.useEffect(() => { + console.log("Something changing... in SorobanReactProvider.tsx") + if (mySorobanContext.address) return; + if (!mySorobanContext.activeWallet) return; + if (mySorobanContext.autoconnect || mySorobanContext.activeWallet.isConnected()) { + mySorobanContext.connect(); + } + }, [mySorobanContext.address, mySorobanContext.activeWallet, mySorobanContext.autoconnect]); + + + return ( + + {children} + + ); +} diff --git a/wallet/soroban-react/index.tsx b/wallet/soroban-react/index.tsx new file mode 100644 index 0000000..b90a269 --- /dev/null +++ b/wallet/soroban-react/index.tsx @@ -0,0 +1,3 @@ +export * from "./SorobanContext"; +export * from "./SorobanReactProvider"; +export * from "./useSorobanReact"; diff --git a/wallet/soroban-react/useSorobanReact.tsx b/wallet/soroban-react/useSorobanReact.tsx new file mode 100644 index 0000000..ec1479e --- /dev/null +++ b/wallet/soroban-react/useSorobanReact.tsx @@ -0,0 +1,10 @@ +import { useContext, Context } from 'react'; +import { SorobanContext } from "./SorobanContext"; +import { SorobanContextType } from "./SorobanContext"; + +//export function useSorobanReact(): Web3ContextType { +export function useSorobanReact() { + const context = useContext(SorobanContext as Context) + if (!context) throw Error('useWeb3React can only be used within the Web3ReactProvider component') + return context + } \ No newline at end of file diff --git a/wallet/types/index.tsx b/wallet/types/index.tsx new file mode 100644 index 0000000..d015777 --- /dev/null +++ b/wallet/types/index.tsx @@ -0,0 +1,31 @@ +export type InstructionStepName = 'install' | 'create' | 'scan'; + +export interface NetworkDetails { + network: string; + networkUrl: string; + networkPassphrase: string; +} + +export type Connector = { + id: string; + name: string; + shortName?: string; + iconUrl: string | (() => Promise); + iconBackground: string; + installed?: boolean; + downloadUrls?: { + android?: string; + ios?: string; + browserExtension?: string; + qrCode?: string; + }; + isConnected: () => boolean; + getNetworkDetails: () => Promise; + getPublicKey: () => Promise; + signTransaction: (xdr: string, opts?: { network?: string; networkPassphrase?: string; accountToSign?: string }) => Promise; +}; + +export type ConnectorList = { + groupName: string; + connectors: Connector[] +}[];