diff --git a/ui/src/app/components/ArcadeDialog.tsx b/ui/src/app/components/ArcadeDialog.tsx index 77f5a5b5a..761f09cd7 100644 --- a/ui/src/app/components/ArcadeDialog.tsx +++ b/ui/src/app/components/ArcadeDialog.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect } from "react"; import { useState } from "react"; import useUIStore from "@/app/hooks/useUIStore"; import { Button } from "./buttons/Button"; @@ -13,16 +13,41 @@ import { AccountInterface, CallData, TransactionFinalityStatus, + uint256, + Call, + Account, + Provider, } from "starknet"; import { useCallback } from "react"; +import { useContracts } from "../hooks/useContracts"; +import { balanceSchema } from "../lib/utils"; + +const MAX_RETRIES = 10; +const RETRY_DELAY = 2000; // 2 seconds +const MIN_BALANCE = 10000000000000; // 0.00001ETH or $0.015 + +const provider = new Provider({ + sequencer: { + baseUrl: "https://alpha4.starknet.io", + }, +}); export const ArcadeDialog = () => { - const { account: MasterAccount, address, connector } = useAccount(); + const { account: walletAccount, address, connector } = useAccount(); const showArcadeDialog = useUIStore((state) => state.showArcadeDialog); const arcadeDialog = useUIStore((state) => state.arcadeDialog); const isWrongNetwork = useUIStore((state) => state.isWrongNetwork); const { connect, connectors, available } = useConnectors(); - const { create, isDeploying, isSettingPermissions } = useBurner(); + const { + getMasterAccount, + create, + isDeploying, + isSettingPermissions, + genNewKey, + isGeneratingNewKey, + } = useBurner(); + const { ethContract } = useContracts(); + const [balances, setBalances] = useState>({}); const arcadeConnectors = useCallback(() => { return available.filter( @@ -31,6 +56,50 @@ export const ArcadeDialog = () => { ); }, [available]); + const fetchBalanceWithRetry = async ( + accountName: string, + retryCount: number = 0 + ): Promise => { + try { + const result = await ethContract!.call( + "balanceOf", + CallData.compile({ account: accountName }) + ); + return uint256.uint256ToBN(balanceSchema.parse(result).balance); + } catch (error) { + if (retryCount < MAX_RETRIES) { + await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY)); // delay before retry + return fetchBalanceWithRetry(accountName, retryCount + 1); + } else { + throw new Error( + `Failed to fetch balance after ${MAX_RETRIES} retries.` + ); + } + } + }; + + const getBalances = async () => { + const localBalances: Record = {}; + const balancePromises = arcadeConnectors().map((account) => { + return fetchBalanceWithRetry(account.name).then((balance) => { + localBalances[account.name] = balance; + return balance; + }); + }); + console.log(balancePromises); + await Promise.all(balancePromises); + setBalances(localBalances); + }; + + const getBalance = async (account: string) => { + const balance = await fetchBalanceWithRetry(account); + setBalances({ ...balances, [account]: balance }); + }; + + useEffect(() => { + getBalances(); + }, [arcadeConnectors]); + if (!connectors) return
; return ( @@ -46,47 +115,51 @@ export const ArcadeDialog = () => {
{((connector?.options as any)?.id == "argentX" || (connector?.options as any)?.id == "braavos") && ( -
-

- Note: This will initiate a transfer of 0.001 ETH from your - connected wallet to the arcade account to cover your transaction - costs from normal gameplay. -
- You may need to refresh after the account has been created! -

- -
- )} +
+

+ Note: This will initiate a transfer of 0.001 ETH from your + connected wallet to the arcade account to cover your transaction + costs from normal gameplay. +
+ You may need to refresh after the account has been created! +

+ +
+ )}
{arcadeConnectors().map((account, index) => { + const masterAccount = getMasterAccount(account.name); return ( ); })} - {/* {isDeploying && ( -
-

Deploying Account...

-
- )} */}
- {isDeploying && ( + {(isDeploying || isGeneratingNewKey) && (

- {isSettingPermissions ? "Setting Permissions" : "Deploying Account"} + {isSettingPermissions + ? "Setting Permissions" + : isGeneratingNewKey + ? "Generating New Key" + : "Deploying Account"}

)} @@ -98,28 +171,36 @@ interface ArcadeAccountCardProps { account: Connector; onClick: (conn: Connector) => void; address: string; - masterAccount: AccountInterface; + walletAccount: AccountInterface; + masterAccountAddress: string; arcadeConnectors: any[]; + genNewKey: (address: string) => void; + balance: bigint; + getBalance: (address: string) => void; } export const ArcadeAccountCard = ({ account, onClick, address, - masterAccount, + walletAccount, + masterAccountAddress, arcadeConnectors, + genNewKey, + balance, + getBalance, }: ArcadeAccountCardProps) => { - const { data } = useBalance({ - address: account.name, - }); const [isCopied, setIsCopied] = useState(false); + const [isToppingUp, setIsToppingUp] = useState(false); + const [isWithdrawing, setIsWithdrawing] = useState(false); const connected = address == account.name; - const balance = parseFloat(data?.formatted!).toFixed(4); + const formatted = (Number(balance) / 10 ** 18).toFixed(4); const transfer = async (address: string, account: AccountInterface) => { try { + setIsToppingUp(true); const { transaction_hash } = await account.execute({ contractAddress: process.env.NEXT_PUBLIC_ETH_CONTRACT_ADDRESS!, entrypoint: "transfer", @@ -135,6 +216,72 @@ export const ArcadeAccountCard = ({ throw new Error("Transaction did not complete successfully."); } + // Get the new balance of the account + getBalance(account.address); + setIsToppingUp(false); + return result; + } catch (error) { + console.error(error); + throw error; + } + }; + + const withdraw = async ( + masterAccountAddress: string, + account: AccountInterface + ) => { + try { + setIsWithdrawing(true); + + // First we need to calculate the fee from withdrawing + + const mainAccount = new Account( + provider, + account.address, + account.signer, + account.cairoVersion + ); + + const call = { + contractAddress: process.env.NEXT_PUBLIC_ETH_CONTRACT_ADDRESS!, + entrypoint: "transfer", + calldata: CallData.compile([ + masterAccountAddress, + balance ?? "0x0", + "0x0", + ]), + }; + + const { suggestedMaxFee: estimatedFee } = await mainAccount.estimateFee( + call + ); + + // Now we negate the fee from balance to withdraw (+10% for safety) + + const newBalance = + BigInt(balance) - estimatedFee * (BigInt(11) / BigInt(10)); + + const { transaction_hash } = await account.execute({ + contractAddress: process.env.NEXT_PUBLIC_ETH_CONTRACT_ADDRESS!, + entrypoint: "transfer", + calldata: CallData.compile([ + masterAccountAddress, + newBalance ?? "0x0", + "0x0", + ]), + }); + + const result = await account.waitForTransaction(transaction_hash, { + retryInterval: 1000, + successStates: [TransactionFinalityStatus.ACCEPTED_ON_L2], + }); + + if (!result) { + throw new Error("Transaction did not complete successfully."); + } + // Get the new balance of the account + getBalance(account.address); + setIsWithdrawing(false); return result; } catch (error) { console.error(error); @@ -152,11 +299,7 @@ export const ArcadeAccountCard = ({ } }; - console.log( - arcadeConnectors.some( - (conn) => conn.options.options.id == masterAccount.address - ) - ); + const minimalBalance = balance < BigInt(MIN_BALANCE); return (
@@ -167,9 +310,15 @@ export const ArcadeAccountCard = ({ > {account.id} - {balance}ETH{" "} + + {formatted === "NaN" ? ( + Loading + ) : ( + `${formatted}ETH` + )} + {" "}
-
+
{!arcadeConnectors.some( - (conn) => conn.options.options.id == masterAccount.address + (conn) => conn.options.options.id == walletAccount.address ) && ( + + )} + {masterAccountAddress == walletAccount.address && ( + + )} + {connected && ( + + )} +
+
+
+ + {!arcadeConnectors.some( + (conn) => conn.options.options.id == walletAccount.address + ) && ( )} +
+
+ {masterAccountAddress == walletAccount.address && ( + + )} + {connected && ( + + )} +
{isCopied && Copied!} diff --git a/ui/src/app/hooks/useContracts.tsx b/ui/src/app/hooks/useContracts.tsx index 3c818c4ed..b96265d74 100644 --- a/ui/src/app/hooks/useContracts.tsx +++ b/ui/src/app/hooks/useContracts.tsx @@ -3,6 +3,67 @@ import Adventurer from "../abi/Adventurer.json"; import Lords_ERC20_Mintable from "../abi/Lords_ERC20_Mintable.json"; import { contracts, mainnet_addr } from "../lib/constants"; +const ethBalanceABIFragment = [ + { + members: [ + { + name: "low", + offset: 0, + type: "felt", + }, + { + name: "high", + offset: 1, + type: "felt", + }, + ], + name: "Uint256", + size: 2, + type: "struct", + }, + { + name: "balanceOf", + type: "function", + inputs: [ + { + name: "account", + type: "felt", + }, + ], + outputs: [ + { + name: "balance", + type: "Uint256", + }, + ], + stateMutability: "view", + }, + { + inputs: [], + name: "symbol", + outputs: [ + { + name: "symbol", + type: "felt", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "decimals", + outputs: [ + { + name: "decimals", + type: "felt", + }, + ], + stateMutability: "view", + type: "function", + }, +]; + export const useContracts = () => { const { account } = useAccount(); @@ -23,8 +84,14 @@ export const useContracts = () => { abi: Lords_ERC20_Mintable, }); + const { contract: ethContract } = useContract({ + abi: ethBalanceABIFragment, + address: process.env.NEXT_PUBLIC_ETH_CONTRACT_ADDRESS!, + }); + return { gameContract, lordsContract, + ethContract, }; }; diff --git a/ui/src/app/lib/burner.tsx b/ui/src/app/lib/burner.tsx index dfe61eba4..dd6a43a6c 100644 --- a/ui/src/app/lib/burner.tsx +++ b/ui/src/app/lib/burner.tsx @@ -40,6 +40,7 @@ export const useBurner = () => { const { account: walletAccount } = useAccount(); const [account, setAccount] = useState(); const [isDeploying, setIsDeploying] = useState(false); + const [isGeneratingNewKey, setIsGeneratingNewKey] = useState(false); const [isSettingPermissions, setIsSettingPermissions] = useState(false); const { gameContract, lordsContract } = useContracts(); @@ -117,6 +118,18 @@ export const useBurner = () => { [walletAccount] ); + const getMasterAccount = useCallback( + (address: string) => { + let storage = Storage.get("burners") || {}; + if (!storage[address]) { + throw new Error("burner not found"); + } + + return storage[address].masterAccount; + }, + [walletAccount] + ); + const create = useCallback(async () => { setIsDeploying(true); const privateKey = stark.randomAddress(); @@ -179,6 +192,7 @@ export const useBurner = () => { publicKey, deployTx, setPermissionsTx, + masterAccount: walletAccount.address, active: true, }; @@ -210,7 +224,7 @@ export const useBurner = () => { entrypoint: "update_whitelisted_calls", calldata: [ "1", - gameContract?.address ?? "", + process.env.NEXT_PUBLIC_ETH_CONTRACT_ADDRESS!, selector.getSelectorFromName("transfer"), "1", ], @@ -226,6 +240,45 @@ export const useBurner = () => { [] ); + const genNewKey = useCallback( + async (burnerAddress: string) => { + setIsGeneratingNewKey(true); + const privateKey = stark.randomAddress(); + const publicKey = ec.starkCurve.getStarkKey(privateKey); + + if (!walletAccount) { + throw new Error("wallet account not found"); + } + + const { transaction_hash } = await walletAccount.execute({ + contractAddress: burnerAddress, + entrypoint: "set_public_key", + calldata: [publicKey], + }); + + await provider.waitForTransaction(transaction_hash); + + // save new keys + let storage = Storage.get("burners") || {}; + for (let address in storage) { + storage[address].active = false; + } + + storage[burnerAddress] = { + privateKey, + publicKey, + masterAccount: walletAccount.address, + active: true, + }; + + Storage.set("burners", storage); + setIsGeneratingNewKey(false); + refresh(); + window.location.reload(); + }, + [walletAccount] + ); + const listConnectors = useCallback(() => { const arcadeAccounts = []; const burners = list(); @@ -253,12 +306,15 @@ export const useBurner = () => { return { get, + getMasterAccount, list, select, create, + genNewKey, account, isDeploying, isSettingPermissions, + isGeneratingNewKey, listConnectors, }; }; diff --git a/ui/src/app/lib/utils/index.ts b/ui/src/app/lib/utils/index.ts index d50526f6e..e6a191eff 100644 --- a/ui/src/app/lib/utils/index.ts +++ b/ui/src/app/lib/utils/index.ts @@ -11,6 +11,7 @@ import { itemMinimumPrice, potionBasePrice, } from "../constants"; +import { z } from "zod"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -343,3 +344,12 @@ export function convertToBoolean(value: number): boolean { export function delay(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } + +export const uint256Schema = z.object({ + low: z.bigint(), + high: z.bigint(), +}); + +export const balanceSchema = z.object({ + balance: uint256Schema, +});