From 064a697e0c042a4f52339f2a5c2dc6415c499f29 Mon Sep 17 00:00:00 2001 From: marshall <99344331+marshall2112@users.noreply.github.com> Date: Mon, 5 Jan 2026 18:45:41 -0500 Subject: [PATCH 1/3] feat: Batch txn UI --- .../components/InputSelect/InputSelect.tsx | 14 +- .../Pages/Safe/admin/BatchAuctionForm.tsx | 563 ++++++++++++++++++ .../src/components/Pages/Safe/admin/index.tsx | 108 ++-- apps/dapp/src/constants/env/local.tsx | 6 + apps/dapp/src/constants/env/preview.tsx | 8 +- apps/dapp/src/constants/env/production.tsx | 10 +- apps/dapp/src/constants/env/types.ts | 6 + apps/dapp/src/safe/safeContext.tsx | 10 +- apps/dapp/src/safe/sdk/use-safe-sdk.tsx | 137 ++++- apps/dapp/src/safe/sdk/utils/utils.ts | 4 +- 10 files changed, 807 insertions(+), 59 deletions(-) create mode 100644 apps/dapp/src/components/Pages/Safe/admin/BatchAuctionForm.tsx diff --git a/apps/dapp/src/components/InputSelect/InputSelect.tsx b/apps/dapp/src/components/InputSelect/InputSelect.tsx index bd1a3e126..e1211e2ca 100644 --- a/apps/dapp/src/components/InputSelect/InputSelect.tsx +++ b/apps/dapp/src/components/InputSelect/InputSelect.tsx @@ -17,6 +17,7 @@ export interface SelectTempleDaoProps { // use to limit the number of elements shown in the menu at anytime maxMenuItems?: number; isSearchable?: boolean; + isDisabled?: boolean; width?: CSS.Property.Width; fontSize?: CSS.Property.FontSize; fontWeight?: CSS.Property.FontWeight; @@ -52,7 +53,7 @@ export const InputSelect = (props: SelectTempleDaoProps) => { borderRadius: 0, })} styles={{ - control: (base) => ({ + control: (base, state) => ({ ...base, border: `0.0625rem /* 1/16 */ solid ${theme.palette.brand}`, borderRadius: `calc(${selectHeight} / 4)`, @@ -60,10 +61,14 @@ export const InputSelect = (props: SelectTempleDaoProps) => { fontSize: '1rem', textAlign: 'left', padding: '0 0.5rem', - cursor: 'pointer', + cursor: state.isDisabled ? 'not-allowed' : 'pointer', height: selectHeight, zIndex: props.zIndex ? Number(props.zIndex) + 1 : 2, // place it above the menu 👇 width: props.width ?? '100%', + backgroundColor: state.isDisabled + ? theme.palette.brand25 || `${theme.palette.brand}40` + : theme.palette.dark, + opacity: state.isDisabled ? 0.7 : 1, }), menu: (base, state) => ({ ...base, @@ -111,12 +116,15 @@ export const InputSelect = (props: SelectTempleDaoProps) => { padding: 0, }), dropdownIndicator: (base, state) => ({ - color: state.isFocused + color: state.isDisabled + ? theme.palette.brandDark + : state.isFocused ? theme.palette.brandLight : theme.palette.brand, display: 'flex', transform: state.isFocused ? 'rotateX(180deg)' : 'none', transition: 'transform 250ms linear', + opacity: state.isDisabled ? 0.5 : 1, }), }} /> diff --git a/apps/dapp/src/components/Pages/Safe/admin/BatchAuctionForm.tsx b/apps/dapp/src/components/Pages/Safe/admin/BatchAuctionForm.tsx new file mode 100644 index 000000000..42612ee17 --- /dev/null +++ b/apps/dapp/src/components/Pages/Safe/admin/BatchAuctionForm.tsx @@ -0,0 +1,563 @@ +import styled from 'styled-components'; +import { useState } from 'react'; +import { InputSelect, Option } from 'components/InputSelect/InputSelect'; +import { Button } from 'components/Button/Button'; +import env from 'constants/env'; +import { getAppConfig } from 'constants/newenv'; +import { SpiceAuction__factory } from 'types/typechain/factories/contracts/templegold/SpiceAuction__factory'; +import { ERC20__factory } from 'types/typechain/factories/@openzeppelin/contracts/token/ERC20/ERC20__factory'; +import { useSafeTransactions } from 'safe/safeContext'; +import { OperationType } from '@safe-global/safe-core-sdk-types'; +import { useNotification } from 'providers/NotificationProvider'; +import { useWallet } from 'providers/WalletProvider'; +import { parseUnits } from 'ethers/lib/utils'; + +// Get auction options from app config +const getAuctionOptions = (): Option[] => { + const spiceAuctions = getAppConfig().spiceBazaar.spiceAuctions || []; + if (spiceAuctions.length === 0) { + return [{ label: 'No auctions available', value: '' }]; + } + return spiceAuctions.map((auction) => ({ + label: `TGLD/${auction.auctionTokenSymbol}`, + value: auction.contractConfig.address, + })); +}; + +const AUCTION_OPTIONS = getAuctionOptions(); + +// Bid token options - currently only TGLD +// TODO: In the future, derive from auction config +const BID_TOKEN_OPTIONS: Option[] = [{ label: 'TGLD', value: 'tgld' }]; + +type TabType = 'config' | 'fund'; + +export const BatchAuctionForm = () => { + const [activeTab, setActiveTab] = useState('config'); + const { proposeTransaction } = useSafeTransactions(); + const { openNotification } = useNotification(); + const { wallet } = useWallet(); + + // Wallet selections + const configWallet = env.spiceAuctionAdmin.multisigAddress; + const fundWallet = env.spiceAuctionAdmin.cosechaSegundaAddress; + + // Shared auction selection + const [selectedAuctionAddress, setSelectedAuctionAddress] = useState( + String(AUCTION_OPTIONS[0]?.value || '') + ); + + // Helper to get full auction config + const getSelectedAuctionConfig = () => { + return getAppConfig().spiceBazaar.spiceAuctions.find( + (a) => a.contractConfig.address === selectedAuctionAddress + ); + }; + + // Config form state + const [duration, setDuration] = useState(''); + const [waitPeriod, setWaitPeriod] = useState(''); + const [minimumDistributedAmount, setMinimumDistributedAmount] = + useState(''); + const [isTempleGoldAuctionToken, setIsTempleGoldAuctionToken] = + useState(false); + const [recipient, setRecipient] = useState(''); + + // Funding form state + const [tokenAmount, setTokenAmount] = useState(''); + const [startTime, setStartTime] = useState(''); + + const handleCreateBatch = async () => { + if (!proposeTransaction) { + openNotification({ + title: 'Please connect your wallet', + hash: '', + isError: true, + }); + return; + } + + const auctionConfig = getSelectedAuctionConfig(); + + if (!auctionConfig) { + openNotification({ + title: 'No auction selected', + hash: '', + isError: true, + }); + return; + } + + try { + const spiceAuctionInterface = SpiceAuction__factory.createInterface(); + const erc20Interface = ERC20__factory.createInterface(); + + if (activeTab === 'config') { + // Encode setAuctionConfig call + const configData = spiceAuctionInterface.encodeFunctionData( + 'setAuctionConfig', + [ + { + duration: Number(duration), + waitPeriod: Number(waitPeriod), + minimumDistributedAuctionToken: parseUnits( + minimumDistributedAmount, + 18 + ).toString(), + isTempleGoldAuctionToken: false, + recipient: recipient, + }, + ] + ); + + await proposeTransaction( + [ + { + to: auctionConfig.contractConfig.address, + value: '0', + data: configData, + operation: OperationType.Call, + }, + ], + configWallet + ); + + openNotification({ + title: 'Auction config transaction proposed', + hash: '', + }); + } else { + // Fund tab - encode approve + fundNextAuction + const bidTokenAddress = auctionConfig.templeGoldToken.address; + const amountWei = parseUnits(tokenAmount, 18).toString(); + const startTimeUnix = Math.floor(new Date(startTime).getTime() / 1000); + + // Approve transaction + const approveData = erc20Interface.encodeFunctionData('approve', [ + auctionConfig.contractConfig.address, + amountWei, + ]); + + // Fund auction transaction + const fundData = spiceAuctionInterface.encodeFunctionData( + 'fundNextAuction', + [amountWei, startTimeUnix] + ); + + await proposeTransaction( + [ + { + to: bidTokenAddress, + value: '0', + data: approveData, + operation: OperationType.Call, + }, + { + to: auctionConfig.contractConfig.address, + value: '0', + data: fundData, + operation: OperationType.Call, + }, + ], + fundWallet + ); + + openNotification({ + title: 'Approve & fund transaction proposed', + hash: '', + }); + } + } catch (error) { + console.error('Error proposing transaction:', error); + const errorMessage = + error instanceof Error ? error.message : 'Unknown error occurred'; + openNotification({ + title: `Error proposing transaction: ${errorMessage}`, + hash: '', + isError: true, + }); + } + }; + + const renderConfigTab = () => { + // Convert minimum amount to wei for preview + const minAmountWei = minimumDistributedAmount + ? parseUnits(minimumDistributedAmount, 18).toString() + : '0'; + + return ( + + + + + Executor Multisig + {configWallet} + + + + + + setSelectedAuctionAddress(e.value)} + isSearchable={false} + width="250px" + /> + + + + + + setDuration(e.target.value)} + /> + + + + + setWaitPeriod(e.target.value)} + /> + + + + + + setMinimumDistributedAmount(e.target.value)} + /> + + + + + setRecipient(e.target.value)} + /> + + + + Preview + + Wallet: Executor Multisig ({configWallet.slice(0, 8)}... + {configWallet.slice(-6)}) + + + 1. setAuctionConfig({`{`} + duration: {duration || '0'}, waitPeriod: {waitPeriod || '0'}, + minAmount: {minAmountWei}, isTGLD:{' '} + {isTempleGoldAuctionToken.toString()}, recipient:{' '} + {recipient || '0x...'} + {`}`}) + + + + +