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",
]),
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 d319ea02..09b6e011 100644
--- a/src/components/GuessTheNumber.tsx
+++ b/src/components/GuessTheNumber.tsx
@@ -7,7 +7,7 @@ import { Box } from "../components/layout/Box";
export const GuessTheNumber = () => {
const [guessedIt, setGuessedIt] = useState();
const [theGuess, setTheGuess] = useState();
- const { address } = useWallet();
+ const { address, updateBalances, signTransaction } = useWallet();
if (!address) {
return (
@@ -19,14 +19,17 @@ 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 });
if (result.isErr()) {
console.error(result.unwrapErr());
} else {
setGuessedIt(result.unwrap());
+ 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;