From f4751c250f73d74267b1e054727b2524f3e94677 Mon Sep 17 00:00:00 2001 From: Chad Ostrowski <221614+chadoh@users.noreply.github.com> Date: Tue, 11 Nov 2025 10:47:42 -0500 Subject: [PATCH 1/3] fix: add proper tx.signAndSend The frontend was not actually signing and sending the transaction. Now it is. to do: - [ ] `WalletButton` is not getting updated with new balance without page refresh, despite `updateBalance` call --- src/components/GuessTheNumber.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/components/GuessTheNumber.tsx b/src/components/GuessTheNumber.tsx index d319ea02..bf3e143e 100644 --- a/src/components/GuessTheNumber.tsx +++ b/src/components/GuessTheNumber.tsx @@ -1,13 +1,16 @@ import { useState } from "react"; import { Button, Code, Input, Text } from "@stellar/design-system"; import { useWallet } from "../hooks/useWallet"; +import { useWalletBalance } from "../hooks/useWalletBalance"; import game from "../contracts/guess_the_number"; import { Box } from "../components/layout/Box"; +import { wallet } from "../util/wallet"; export const GuessTheNumber = () => { const [guessedIt, setGuessedIt] = useState(); const [theGuess, setTheGuess] = useState(); const { address } = useWallet(); + const { updateBalance } = useWalletBalance(); if (!address) { return ( @@ -19,14 +22,19 @@ export const GuessTheNumber = () => { const submitGuess = async () => { if (!theGuess || !address) return; - const { result } = await game.guess({ - a_number: BigInt(theGuess), - guesser: address, + const tx = await game.guess( + { a_number: BigInt(theGuess), guesser: address }, + // @ts-expect-error js-stellar-sdk has bad typings; publicKey is, in fact, allowed + { publicKey: address }, + ); + const { result } = await tx.signAndSend({ + signTransaction: wallet.signTransaction.bind(wallet), }); if (result.isErr()) { console.error(result.unwrapErr()); } else { setGuessedIt(result.unwrap()); + await updateBalance(); } }; From 3686c29a2f126cf72f3784eaf8ad21fba2e58352 Mon Sep 17 00:00:00 2001 From: Chad Ostrowski <221614+chadoh@users.noreply.github.com> Date: Thu, 13 Nov 2025 10:01:47 -0500 Subject: [PATCH 2/3] fix: better simpler wallet balances - remove `useWalletBalance` hook - add `balances` and `updateBalances` to `useWallet` - `balances` is a map so we can `balances?.xlm?.balance ?? '-'` - simplify & improve `useWallet` - `signTransaction` defined top-level, always included in props - `[state, setState]` removed in favor of flatter & easier-to-reason-about state variables such as `[address, setAddress]` - modify stub in src/contracts/guess_the_number.ts to include all GuessTheNumber contract methods Now when you guess a number, your wallet balance in the top bar updates as expected, and working with wallet balances feels much more straightforward. fixup --- src/components/FundAccountButton.tsx | 10 +-- src/components/GuessTheNumber.tsx | 11 +-- src/components/WalletButton.tsx | 8 +- src/contracts/guess_the_number.ts | 43 ++++++---- src/hooks/useWalletBalance.ts | 74 ----------------- src/providers/WalletProvider.tsx | 114 ++++++++++++++++++--------- src/util/wallet.ts | 40 ++++++++-- 7 files changed, 143 insertions(+), 157 deletions(-) delete mode 100644 src/hooks/useWalletBalance.ts diff --git a/src/components/FundAccountButton.tsx b/src/components/FundAccountButton.tsx index 077d1143..25f4c0ff 100644 --- a/src/components/FundAccountButton.tsx +++ b/src/components/FundAccountButton.tsx @@ -3,12 +3,10 @@ import { useNotification } from "../hooks/useNotification.ts"; import { useWallet } from "../hooks/useWallet.ts"; import { Button, Tooltip } from "@stellar/design-system"; import { getFriendbotUrl } from "../util/friendbot"; -import { useWalletBalance } from "../hooks/useWalletBalance.ts"; const FundAccountButton: React.FC = () => { const { addNotification } = useNotification(); const [isPending, startTransition] = useTransition(); - const { isFunded, isLoading } = useWalletBalance(); const [isTooltipVisible, setIsTooltipVisible] = useState(false); const { address } = useWallet(); @@ -52,7 +50,7 @@ const FundAccountButton: React.FC = () => { placement="bottom" triggerEl={ } > -
- {isFunded - ? "Account is already funded" - : "Fund your account using the Stellar Friendbot"} -
+
Account is already funded
); diff --git a/src/components/GuessTheNumber.tsx b/src/components/GuessTheNumber.tsx index bf3e143e..09b6e011 100644 --- a/src/components/GuessTheNumber.tsx +++ b/src/components/GuessTheNumber.tsx @@ -1,16 +1,13 @@ import { useState } from "react"; import { Button, Code, Input, Text } from "@stellar/design-system"; import { useWallet } from "../hooks/useWallet"; -import { useWalletBalance } from "../hooks/useWalletBalance"; import game from "../contracts/guess_the_number"; import { Box } from "../components/layout/Box"; -import { wallet } from "../util/wallet"; export const GuessTheNumber = () => { const [guessedIt, setGuessedIt] = useState(); const [theGuess, setTheGuess] = useState(); - const { address } = useWallet(); - const { updateBalance } = useWalletBalance(); + const { address, updateBalances, signTransaction } = useWallet(); if (!address) { return ( @@ -27,14 +24,12 @@ export const GuessTheNumber = () => { // @ts-expect-error js-stellar-sdk has bad typings; publicKey is, in fact, allowed { publicKey: address }, ); - const { result } = await tx.signAndSend({ - signTransaction: wallet.signTransaction.bind(wallet), - }); + const { result } = await tx.signAndSend({ signTransaction }); if (result.isErr()) { console.error(result.unwrapErr()); } else { setGuessedIt(result.unwrap()); - await updateBalance(); + await updateBalances(); } }; diff --git a/src/components/WalletButton.tsx b/src/components/WalletButton.tsx index 0f02ac9a..190990c2 100644 --- a/src/components/WalletButton.tsx +++ b/src/components/WalletButton.tsx @@ -1,13 +1,11 @@ import { useState } from "react"; import { Button, Text, Modal, Profile } from "@stellar/design-system"; import { useWallet } from "../hooks/useWallet"; -import { useWalletBalance } from "../hooks/useWalletBalance"; import { connectWallet, disconnectWallet } from "../util/wallet"; export const WalletButton = () => { const [showDisconnectModal, setShowDisconnectModal] = useState(false); - const { address, isPending } = useWallet(); - const { xlm, ...balance } = useWalletBalance(); + const { address, isPending, balances } = useWallet(); const buttonLabel = isPending ? "Loading..." : "Connect"; if (!address) { @@ -25,11 +23,11 @@ export const WalletButton = () => { flexDirection: "row", alignItems: "center", gap: "5px", - opacity: balance.isLoading ? 0.6 : 1, + opacity: isPending ? 0.6 : 1, }} > - Wallet Balance: {xlm} XLM + Wallet Balance: {balances?.xlm?.balance ?? "-"} XLM
diff --git a/src/contracts/guess_the_number.ts b/src/contracts/guess_the_number.ts index 0f6549b9..be2a3d91 100644 --- a/src/contracts/guess_the_number.ts +++ b/src/contracts/guess_the_number.ts @@ -1,23 +1,32 @@ // This is a stub file for the GuessTheNumber contract client. // The actual contract client is generated at runtime by the Scaffold Stellar CLI. // This stub allows the build to succeed when the contract hasn't been generated yet. +import { + AssembledTransaction, + AssembledTransactionOptions, + Client as ContractClient, + Result, + Spec as ContractSpec, +} from '@stellar/stellar-sdk/contract'; +import type { u64, i128, Option } from '@stellar/stellar-sdk/contract'; +import { rpcUrl, networkPassphrase } from './util'; -type Result = { - isOk: () => boolean; - isErr: () => boolean; - unwrap: () => T; - unwrapErr: () => E; -}; +interface Client { + reset: (options?: AssembledTransactionOptions) => Promise> + guess: ({a_number, guesser}: {a_number: u64, guesser: string}, options?: AssembledTransactionOptions) => Promise>> + add_funds: ({amount}: {amount: i128}, options?: AssembledTransactionOptions) => Promise> + upgrade: ({new_wasm_hash}: {new_wasm_hash: Buffer}, options?: AssembledTransactionOptions) => Promise> + admin: (options?: AssembledTransactionOptions) => Promise>> + set_admin: ({admin}: {admin: string}, options?: AssembledTransactionOptions) => Promise> +} -const game = { - guess: async (_args: { - a_number: bigint; - guesser: string; - }): Promise<{ result: Result }> => { - throw new Error( - "Contract client not initialized. Run 'stellar scaffold watch --build-clients' to generate contract clients.", - ); - }, -}; +class Client extends ContractClient { + constructor() { + super( + new ContractSpec(["AAAAAAAAABFHZXQgY3VycmVudCBhZG1pbgAAAAAAAAVhZG1pbgAAAAAAAAAAAAABAAAD6AAAABM="]), + { contractId: "STUB", networkPassphrase, rpcUrl, allowHttp: true, publicKey: undefined } + ) + } +} -export default game; +export default new Client(); diff --git a/src/hooks/useWalletBalance.ts b/src/hooks/useWalletBalance.ts deleted file mode 100644 index 9057e360..00000000 --- a/src/hooks/useWalletBalance.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; -import { useWallet } from "./useWallet"; -import { fetchBalance, type Balance } from "../util/wallet"; - -const formatter = new Intl.NumberFormat(); - -const checkFunding = (balances: Balance[]) => - balances.some(({ balance }) => - !Number.isNaN(Number(balance)) ? Number(balance) > 0 : false, - ); - -type WalletBalance = { - balances: Balance[]; - xlm: string; - isFunded: boolean; - isLoading: boolean; - error: Error | null; -}; - -export const useWalletBalance = () => { - const { address } = useWallet(); - const [state, setState] = useState({ - balances: [], - xlm: "-", - isFunded: false, - isLoading: false, - error: null, - }); - - const updateBalance = useCallback(async () => { - if (!address) return; - try { - setState((prev) => ({ ...prev, isLoading: true })); - const balances = await fetchBalance(address); - const isFunded = checkFunding(balances); - const native = balances.find(({ asset_type }) => asset_type === "native"); - setState({ - isLoading: false, - balances, - xlm: native?.balance ? formatter.format(Number(native.balance)) : "-", - isFunded, - error: null, - }); - } catch (err) { - if (err instanceof Error && err.message.match(/not found/i)) { - setState({ - isLoading: false, - balances: [], - xlm: "-", - isFunded: false, - error: new Error("Error fetching balance. Is your wallet funded?"), - }); - } else { - console.error(err); - setState({ - isLoading: false, - balances: [], - xlm: "-", - isFunded: false, - error: new Error("Unknown error fetching balance."), - }); - } - } - }, [address]); - - useEffect(() => { - void updateBalance(); - }, [updateBalance]); - - return { - ...state, - updateBalance, - }; -}; diff --git a/src/providers/WalletProvider.tsx b/src/providers/WalletProvider.tsx index 123cdb25..0c6a803a 100644 --- a/src/providers/WalletProvider.tsx +++ b/src/providers/WalletProvider.tsx @@ -1,5 +1,6 @@ import { createContext, + useCallback, useEffect, useMemo, useRef, @@ -8,53 +9,88 @@ import { } from "react"; import { wallet } from "../util/wallet"; import storage from "../util/storage"; +import { fetchBalances } from "../util/wallet"; +import type { MappedBalances } from "../util/wallet"; + +const signTransaction = wallet.signTransaction.bind(wallet); + +/** + * A good-enough implementation of deepEqual. + * + * Used in this file to compare MappedBalances. + * + * Should maybe add & use a new dependency instead, if needed elsewhere. + */ +function deepEqual(a: T, b: T): boolean { + if (a === b) { + return true; + } + + const bothAreObjects = + a && b && typeof a === "object" && typeof b === "object"; + + return Boolean( + bothAreObjects && + Object.keys(a).length === Object.keys(b).length && + Object.entries(a).every(([k, v]) => deepEqual(v, b[k as keyof T])), + ); +} export interface WalletContextType { address?: string; + balances: MappedBalances; + isPending: boolean; network?: string; networkPassphrase?: string; - isPending: boolean; - signTransaction?: typeof wallet.signTransaction; + signTransaction: typeof wallet.signTransaction; + updateBalances: () => Promise; } -const initialState = { - address: undefined, - network: undefined, - networkPassphrase: undefined, -}; - const POLL_INTERVAL = 1000; export const WalletContext = // eslint-disable-line react-refresh/only-export-components - createContext({ isPending: true }); + createContext({ + isPending: true, + balances: {}, + updateBalances: async () => {}, + signTransaction, + }); export const WalletProvider = ({ children }: { children: React.ReactNode }) => { - const [state, setState] = - useState>(initialState); + const [balances, setBalances] = useState({}); + const [address, setAddress] = useState(); + const [network, setNetwork] = useState(); + const [networkPassphrase, setNetworkPassphrase] = useState(); const [isPending, startTransition] = useTransition(); const popupLock = useRef(false); - const signTransaction = wallet.signTransaction.bind(wallet); const nullify = () => { - updateState(initialState); + setAddress(undefined); + setNetwork(undefined); + setNetworkPassphrase(undefined); + setBalances({}); storage.setItem("walletId", ""); storage.setItem("walletAddress", ""); storage.setItem("walletNetwork", ""); storage.setItem("networkPassphrase", ""); }; - const updateState = (newState: Omit) => { - setState((prev: Omit) => { - if ( - prev.address !== newState.address || - prev.network !== newState.network || - prev.networkPassphrase !== newState.networkPassphrase - ) { - return newState; - } - return prev; + const updateBalances = useCallback(async () => { + if (!address) { + setBalances({}); + return; + } + + const newBalances = await fetchBalances(address); + setBalances((prev) => { + if (deepEqual(newBalances, prev)) return prev; + return newBalances; }); - }; + }, [address]); + + useEffect(() => { + void updateBalances(); + }, [updateBalances]); const updateCurrentWalletState = async () => { // There is no way, with StellarWalletsKit, to check if the wallet is @@ -66,16 +102,14 @@ export const WalletProvider = ({ children }: { children: React.ReactNode }) => { const passphrase = storage.getItem("networkPassphrase"); if ( - !state.address && + !address && walletAddr !== null && walletNetwork !== null && passphrase !== null ) { - updateState({ - address: walletAddr, - network: walletNetwork, - networkPassphrase: passphrase, - }); + setAddress(walletAddr); + setNetwork(walletNetwork); + setNetworkPassphrase(passphrase); } if (!walletId) { @@ -96,12 +130,14 @@ export const WalletProvider = ({ children }: { children: React.ReactNode }) => { if (!a.address) storage.setItem("walletId", ""); if ( - a.address !== state.address || - n.network !== state.network || - n.networkPassphrase !== state.networkPassphrase + a.address !== address || + n.network !== network || + n.networkPassphrase !== networkPassphrase ) { storage.setItem("walletAddress", a.address); - updateState({ ...a, ...n }); + setAddress(a.address); + setNetwork(n.network); + setNetworkPassphrase(n.networkPassphrase); } } catch (e) { // If `getNetwork` or `getAddress` throw errors... sign the user out??? @@ -146,15 +182,19 @@ export const WalletProvider = ({ children }: { children: React.ReactNode }) => { isMounted = false; if (timer) clearTimeout(timer); }; - }, [state]); // eslint-disable-line react-hooks/exhaustive-deps -- it SHOULD only run once per component mount + }, []); // eslint-disable-line react-hooks/exhaustive-deps -- it SHOULD only run once per component mount const contextValue = useMemo( () => ({ - ...state, + address, + network, + networkPassphrase, + balances, + updateBalances, isPending, signTransaction, }), - [state, isPending, signTransaction], + [address, network, networkPassphrase, balances, updateBalances, isPending], ); return {children}; diff --git a/src/util/wallet.ts b/src/util/wallet.ts index 0655f1b4..8a4a1329 100644 --- a/src/util/wallet.ts +++ b/src/util/wallet.ts @@ -68,15 +68,39 @@ function getHorizonHost(mode: string) { } } -export const fetchBalance = async (address: string) => { - const horizon = new Horizon.Server(getHorizonHost(stellarNetwork), { - allowHttp: stellarNetwork === "LOCAL", - }); +const horizon = new Horizon.Server(getHorizonHost(stellarNetwork), { + allowHttp: stellarNetwork === "LOCAL", +}); - const { balances } = await horizon.accounts().accountId(address).call(); - return balances; -}; +const formatter = new Intl.NumberFormat(); -export type Balance = Awaited>[number]; +export type MappedBalances = Record; + +export const fetchBalances = async (address: string) => { + try { + const { balances } = await horizon.accounts().accountId(address).call(); + const mapped = balances.reduce((acc, b) => { + b.balance = formatter.format(Number(b.balance)); + const key = + b.asset_type === "native" + ? "xlm" + : b.asset_type === "liquidity_pool_shares" + ? b.liquidity_pool_id + : `${b.asset_code}:${b.asset_issuer}`; + acc[key] = b; + return acc; + }, {} as MappedBalances); + return mapped; + } catch (err) { + // `not found` is sort of expected, indicating an unfunded wallet, which + // the consumer of `balances` can understand via the lack of `xlm` key. + // If the error does NOT match 'not found', log the error. + // We should also possibly not return `{}` in this case? + if (!(err instanceof Error && err.message.match(/not found/i))) { + console.error(err); + } + return {}; + } +}; export const wallet = kit; From b49e0b32c1d488d5a4c834d931bedb93a656d6ae Mon Sep 17 00:00:00 2001 From: Chad Ostrowski <221614+chadoh@users.noreply.github.com> Date: Mon, 17 Nov 2025 13:19:22 -0500 Subject: [PATCH 3/3] fix: eslintignore target/packages --- eslint.config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/eslint.config.js b/eslint.config.js index 71849373..0d90e1ba 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -12,6 +12,7 @@ export default tseslint.config( globalIgnores([ "dist", "packages", + "target/packages", "src/contracts/*", "!src/contracts/util.ts", ]),