diff --git a/apps/dapp/src/components/Pages/Core/DappPages/SpiceBazaar/Bid/Chart/BarChart.tsx b/apps/dapp/src/components/Pages/Core/DappPages/SpiceBazaar/Bid/Chart/BarChart.tsx index b6913423d..cbcd4b307 100644 --- a/apps/dapp/src/components/Pages/Core/DappPages/SpiceBazaar/Bid/Chart/BarChart.tsx +++ b/apps/dapp/src/components/Pages/Core/DappPages/SpiceBazaar/Bid/Chart/BarChart.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { ResponsiveContainer, BarChart, @@ -37,6 +37,7 @@ export default function CustomBarChart({ yAxisDomain, yAxisTicks, xTickFormatter, + yTickFormatter, tooltipLabelFormatter, tooltipValuesFormatter, }: React.PropsWithChildren>) { @@ -44,6 +45,46 @@ export default function CustomBarChart({ const isPhoneOrAbove = useMediaQuery({ query: queryPhone }); const [activeIndex, setActiveIndex] = useState(null); + const yAxisNumberFormatter = useMemo( + () => new Intl.NumberFormat('en-US', { maximumSignificantDigits: 6 }), + [] + ); + + const getYAxisLabel = useCallback( + (value: number, index: number) => { + if (yTickFormatter) return yTickFormatter(value, index); + const abbreviated = formatNumberAbbreviated(value); + if (abbreviated.thousandsSuffix) { + return `$${abbreviated.string}`; + } + return `$${yAxisNumberFormatter.format(value)}`; + }, + [yTickFormatter, yAxisNumberFormatter] + ); + + const yAxisWidth = useMemo(() => { + const values = + yAxisTicks && yAxisTicks.length + ? yAxisTicks + : chartData.map((entry) => Number(entry[yDataKey] ?? 0)); + let maxLength = 0; + + values.forEach((value, index) => { + const numericValue = typeof value === 'number' ? value : Number(value); + if (Number.isNaN(numericValue)) return; + const label = getYAxisLabel(numericValue, index); + maxLength = Math.max(maxLength, label.length); + }); + + const estimatedCharWidth = isPhoneOrAbove ? 7 : 6; + const padding = isPhoneOrAbove ? 22 : 16; + const maxWidth = isPhoneOrAbove ? 220 : 160; + return Math.min( + maxWidth, + Math.max(60, maxLength * estimatedCharWidth + padding) + ); + }, [chartData, yAxisTicks, yDataKey, getYAxisLabel, isPhoneOrAbove]); + const barColors = [ '#FFE3D4', '#DCD28B', @@ -145,8 +186,8 @@ export default function CustomBarChart({ ticks={yAxisTicks} axisLine={false} tickLine={false} - tick={({ x, y, payload }) => { - const { string } = formatNumberAbbreviated(payload.value); + width={yAxisWidth} + tick={({ x, y, payload, index }) => { return ( ({ }} > - {`$${string}`} + {getYAxisLabel(payload.value, index)} ); diff --git a/apps/dapp/src/components/Pages/Core/DappPages/SpiceBazaar/Spend/AuctionCountdown.tsx b/apps/dapp/src/components/Pages/Core/DappPages/SpiceBazaar/Spend/AuctionCountdown.tsx index ae1ebdcbf..b93d57621 100644 --- a/apps/dapp/src/components/Pages/Core/DappPages/SpiceBazaar/Spend/AuctionCountdown.tsx +++ b/apps/dapp/src/components/Pages/Core/DappPages/SpiceBazaar/Spend/AuctionCountdown.tsx @@ -1,6 +1,7 @@ import { useSpiceAuctionCountdown } from 'hooks/spicebazaar/use-spice-auction-countdown'; import { SpiceAuctionInfo } from 'providers/SpiceAuctionProvider'; import styled from 'styled-components'; +import { AuctionState } from 'utils/spice-auction-state'; const AuctionTimeStamp = styled.div` font-size: 13px; @@ -18,19 +19,38 @@ const ScheduledText = styled.p` margin: 0px; `; +const EndedText = styled.p` + font-size: 13px; + font-weight: 700; + line-height: 15px; + color: ${({ theme }) => theme.palette.brand}; + margin: 0px; +`; + interface AuctionCountdownProps { auction: SpiceAuctionInfo; - isLive: boolean; } -export const AuctionCountdown = ({ - auction, - isLive, -}: AuctionCountdownProps) => { - const countdown = useSpiceAuctionCountdown(auction); - return isLive ? ( - Ends in {countdown} - ) : ( - Starts in {countdown} - ); +const formatEndDate = (date: Date | null): string => { + if (!date) return ''; + const day = String(date.getDate()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const year = date.getFullYear(); + return `${day}/${month}/${year}`; +}; + +export const AuctionCountdown = ({ auction }: AuctionCountdownProps) => { + const { countdown, state, endDate } = useSpiceAuctionCountdown(auction); + + switch (state) { + case AuctionState.LIVE: + return Ends in {countdown}; + case AuctionState.SCHEDULED: + return Starts in {countdown}; + case AuctionState.ENDED: + return on {formatEndDate(endDate)}; + case AuctionState.NOT_SCHEDULED: + default: + return Starts in TBD; + } }; diff --git a/apps/dapp/src/components/Pages/Core/DappPages/SpiceBazaar/Spend/Chart/BarChart.tsx b/apps/dapp/src/components/Pages/Core/DappPages/SpiceBazaar/Spend/Chart/BarChart.tsx index 674244c23..33f84debf 100644 --- a/apps/dapp/src/components/Pages/Core/DappPages/SpiceBazaar/Spend/Chart/BarChart.tsx +++ b/apps/dapp/src/components/Pages/Core/DappPages/SpiceBazaar/Spend/Chart/BarChart.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { ResponsiveContainer, BarChart, @@ -37,12 +37,52 @@ export default function CustomBarChart({ yAxisDomain, yAxisTicks, xTickFormatter, + yTickFormatter, tooltipLabelFormatter, tooltipValuesFormatter, }: React.PropsWithChildren>) { const theme = useTheme(); const isPhoneOrAbove = useMediaQuery({ query: queryPhone }); const [activeIndex, setActiveIndex] = useState(null); + const yAxisNumberFormatter = useMemo( + () => new Intl.NumberFormat('en-US', { maximumSignificantDigits: 6 }), + [] + ); + + const getYAxisLabel = useCallback( + (value: number, index: number) => { + if (yTickFormatter) return yTickFormatter(value, index); + const abbreviated = formatNumberAbbreviated(value); + if (abbreviated.thousandsSuffix) { + return `${abbreviated.string} TGLD`; + } + return `${yAxisNumberFormatter.format(value)} TGLD`; + }, + [yTickFormatter, yAxisNumberFormatter] + ); + + const yAxisWidth = useMemo(() => { + const values = + yAxisTicks && yAxisTicks.length + ? yAxisTicks + : chartData.map((entry) => Number(entry[yDataKey] ?? 0)); + let maxLength = 0; + + values.forEach((value, index) => { + const numericValue = typeof value === 'number' ? value : Number(value); + if (Number.isNaN(numericValue)) return; + const label = getYAxisLabel(numericValue, index); + maxLength = Math.max(maxLength, label.length); + }); + + const estimatedChartWidth = isPhoneOrAbove ? 7 : 6; + const padding = isPhoneOrAbove ? 22 : 16; + const maxWidth = isPhoneOrAbove ? 220 : 160; + return Math.min( + maxWidth, + Math.max(60, maxLength * estimatedChartWidth + padding) + ); + }, [chartData, yAxisTicks, yDataKey, getYAxisLabel, isPhoneOrAbove]); const barColors = [ '#FFE3D4', @@ -122,8 +162,9 @@ export default function CustomBarChart({ ticks={yAxisTicks} axisLine={false} tickLine={false} - tick={({ x, y, payload }) => { - const { string } = formatNumberAbbreviated(payload.value); + width={yAxisWidth} + tick={({ x, y, payload, index }) => { + const label = getYAxisLabel(payload.value, index); return ( ({ }} > - {`${string} TGLD`} + {label} ); diff --git a/apps/dapp/src/components/Pages/Core/DappPages/SpiceBazaar/Spend/Details/Details.tsx b/apps/dapp/src/components/Pages/Core/DappPages/SpiceBazaar/Spend/Details/Details.tsx index 32267c724..34ea0b5f2 100644 --- a/apps/dapp/src/components/Pages/Core/DappPages/SpiceBazaar/Spend/Details/Details.tsx +++ b/apps/dapp/src/components/Pages/Core/DappPages/SpiceBazaar/Spend/Details/Details.tsx @@ -1,6 +1,8 @@ import active from 'assets/icons/active.svg?react'; import scheduled from 'assets/icons/scheduled.svg?react'; +import closed from 'assets/icons/closed.svg?react'; import linkSvg from 'assets/icons/link.svg?react'; +import { AuctionState, getAuctionState } from 'utils/spice-auction-state'; import styled from 'styled-components'; import { useEffect, useState, useMemo } from 'react'; import { Button } from 'components/Button/Button'; @@ -111,7 +113,7 @@ export const Details = () => { query: queryPhone, }); - const countdown = useSpiceAuctionCountdown(auction || null); + const { state } = useSpiceAuctionCountdown(auction || null); const formatDate = (timestamp: number | undefined) => { if (!timestamp) return 'DD/MM/YYYY'; @@ -201,14 +203,17 @@ export const Details = () => { ) : ( <> - - {auction?.currentEpochAuctionLive ? ( + + {state === AuctionState.LIVE ? ( <> ACTIVE + ) : state === AuctionState.ENDED ? ( + <> + + ENDED + ) : ( <> @@ -219,7 +224,8 @@ export const Details = () => { Start - {auction?.currentEpochAuctionLive ? ( + {state === AuctionState.LIVE || + state === AuctionState.ENDED ? ( <> {formatDate(auction?.auctionStartTime)} @@ -250,7 +256,8 @@ export const Details = () => { End - {auction?.currentEpochAuctionLive ? ( + {state === AuctionState.LIVE || + state === AuctionState.ENDED ? ( <> {formatDate(auction?.auctionEndTime)} @@ -524,14 +531,27 @@ const HeaderRight = styled.div` `)} `; -const StatusBadge = styled.div<{ isLive: boolean }>` +const getStatusBadgeBorderColor = (state: AuctionState): string => { + switch (state) { + case AuctionState.LIVE: + return '#588f22'; + case AuctionState.ENDED: + return '#6B7280'; + case AuctionState.SCHEDULED: + case AuctionState.NOT_SCHEDULED: + default: + return '#EAB85B'; + } +}; + +const StatusBadge = styled.div<{ state: AuctionState }>` display: flex; flex-direction: row; align-items: center; justify-content: center; background: ${({ theme }) => theme.palette.black}; border: solid 1px; - border-color: ${({ isLive }) => (isLive ? '#588f22' : '#EAB85B')}; + border-color: ${({ state }) => getStatusBadgeBorderColor(state)}; border-radius: 10px; padding: 8px 24px; gap: 10px; @@ -589,6 +609,8 @@ const Active = styled(active)``; const Scheduled = styled(scheduled)``; +const Closed = styled(closed)``; + const CurrentEpochNr = styled.h3` display: flex; justify-content: center; diff --git a/apps/dapp/src/components/Pages/Core/DappPages/SpiceBazaar/Spend/index.tsx b/apps/dapp/src/components/Pages/Core/DappPages/SpiceBazaar/Spend/index.tsx index 47a6a6025..504e6f876 100644 --- a/apps/dapp/src/components/Pages/Core/DappPages/SpiceBazaar/Spend/index.tsx +++ b/apps/dapp/src/components/Pages/Core/DappPages/SpiceBazaar/Spend/index.tsx @@ -3,7 +3,9 @@ import { useState, useEffect, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import active from 'assets/icons/active.svg?react'; import scheduled from 'assets/icons/scheduled.svg?react'; +import closed from 'assets/icons/closed.svg?react'; import * as breakpoints from 'styles/breakpoints'; +import { AuctionState, getAuctionState } from 'utils/spice-auction-state'; import ena from 'assets/icons/ena_logo.svg?react'; import kami from 'assets/icons/kami_logo.svg?react'; import ori from 'assets/icons/ori_logo.svg?react'; @@ -62,6 +64,7 @@ const AuctionCard = ({ const navigate = useNavigate(); const { wallet } = useWallet(); const [, connect] = useConnectWallet(); + const state = getAuctionState(auction); const { data: userMetrics, isLoading: userMetricsLoading } = useAuctionUserMetrics(auction.address, wallet); @@ -92,21 +95,29 @@ const AuctionCard = ({ - {auction.currentEpochAuctionLive + {state === AuctionState.LIVE ? 'Total amount currently in auction' + : state === AuctionState.ENDED + ? 'Total amount was in auction' : 'Total amount scheduled for bidding'} - {auction.currentEpochAuctionLive ? ( + {state === AuctionState.LIVE ? ( Active - + + ) : state === AuctionState.ENDED ? ( + + + Ended + + ) : ( - + )} @@ -588,6 +599,8 @@ const Active = styled(active)``; const Scheduled = styled(scheduled)``; +const Closed = styled(closed)``; + const ActiveContainer = styled.div` display: flex; flex-direction: row; @@ -610,6 +623,21 @@ const ScheduledContainer = styled.div` gap: 8px; `; +const EndedContainer = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; +`; + +const EndedText = styled.p` + font-size: 13px; + font-weight: 700; + line-height: 15px; + color: ${({ theme }) => theme.palette.brand}; + margin: 0px; +`; + const ButtonsContainer = styled.div` display: flex; flex-direction: row; diff --git a/apps/dapp/src/components/Pages/Core/DappPages/SpiceBazaar/components/SpiceBazaarTOS.tsx b/apps/dapp/src/components/Pages/Core/DappPages/SpiceBazaar/components/SpiceBazaarTOS.tsx index f4c2080ff..ace3ae8a3 100644 --- a/apps/dapp/src/components/Pages/Core/DappPages/SpiceBazaar/components/SpiceBazaarTOS.tsx +++ b/apps/dapp/src/components/Pages/Core/DappPages/SpiceBazaar/components/SpiceBazaarTOS.tsx @@ -11,7 +11,7 @@ import { SpiceBazaarTOSContent } from './SpiceBazaarTOSContent'; import { buildSpiceBazaarTosMessage, getSpiceBazaarTosStorageKey, - isSpiceBazaarTosSignatureValid, + isSpiceBazaarTosSignatureValidAsync, } from 'utils/spiceBazaarTos'; interface SpiceBazaarTOSProps { @@ -79,11 +79,18 @@ export const SpiceBazaarTOS = ({ throw new Error('Empty signature'); } - const isValid = isSpiceBazaarTosSignatureValid( - messageWallet, - timestamp, - signature - ); + const provider = activeSigner.provider; + if (!provider) { + throw new Error('No provider available'); + } + + const { isValid, isContractWallet } = + await isSpiceBazaarTosSignatureValidAsync( + messageWallet, + timestamp, + signature, + provider + ); if (!isValid) { throw new Error('Signature does not match connected wallet'); } @@ -93,6 +100,7 @@ export const SpiceBazaarTOS = ({ signature, timestamp, walletAddress: messageWallet, + isContractWallet, }); window.localStorage[getSpiceBazaarTosStorageKey(normalizedWallet)] = tosData; diff --git a/apps/dapp/src/hooks/spicebazaar/use-spice-auction-countdown.tsx b/apps/dapp/src/hooks/spicebazaar/use-spice-auction-countdown.tsx index 0b5a530a0..9944a95ab 100644 --- a/apps/dapp/src/hooks/spicebazaar/use-spice-auction-countdown.tsx +++ b/apps/dapp/src/hooks/spicebazaar/use-spice-auction-countdown.tsx @@ -1,18 +1,28 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { SpiceAuctionInfo } from 'providers/SpiceAuctionProvider'; +import { AuctionState, getAuctionState } from 'utils/spice-auction-state'; + +export interface CountdownResult { + countdown: string; + state: AuctionState; + endDate: Date | null; +} export const useSpiceAuctionCountdown = ( spiceAuctionInfo: SpiceAuctionInfo | null -) => { - const [countdown, setCountdown] = useState(''); +): CountdownResult => { + const [result, setResult] = useState({ + countdown: '', + state: AuctionState.NOT_SCHEDULED, + endDate: null, + }); const timerRef = useRef(); const calculateTimeLeft = useCallback((endTime: number) => { const difference = endTime - Date.now(); - // if end date is the past, show TBD - if (endTime < Date.now()) { - return 'TBD'; + if (difference <= 0) { + return ''; } const days = Math.floor(difference / (1000 * 60 * 60 * 24)); @@ -23,52 +33,79 @@ export const useSpiceAuctionCountdown = ( return `${days}d ${hours}h ${minutes}m ${seconds}s`; }, []); - useEffect(() => { - if (!spiceAuctionInfo?.auctionEndTime) { - setCountdown(''); - return; + const calculateResult = useCallback((): CountdownResult => { + if (!spiceAuctionInfo) { + return { + countdown: '', + state: AuctionState.NOT_SCHEDULED, + endDate: null, + }; + } + + const state = getAuctionState(spiceAuctionInfo); + const now = Date.now(); + + switch (state) { + case AuctionState.LIVE: { + // Countdown to end time + const countdown = calculateTimeLeft(spiceAuctionInfo.auctionEndTime); + return { + countdown, + state, + endDate: new Date(spiceAuctionInfo.auctionEndTime), + }; + } + case AuctionState.SCHEDULED: { + // Countdown to start time + const countdown = calculateTimeLeft(spiceAuctionInfo.auctionStartTime); + return { + countdown, + state, + endDate: null, + }; + } + case AuctionState.ENDED: { + // No countdown, but provide end date + return { + countdown: '', + state, + endDate: new Date(spiceAuctionInfo.auctionEndTime), + }; + } + case AuctionState.NOT_SCHEDULED: + default: { + return { + countdown: 'TBD', + state, + endDate: null, + }; + } } + }, [spiceAuctionInfo, calculateTimeLeft]); + useEffect(() => { // Clear any existing timer if (timerRef.current) { clearInterval(timerRef.current); } - // TODO: Simplify: Countdown to start time if in the future - // OR else countdown to end time if it's started. - // If the auction ended, then show "Starts in TBD" - - const targetTime = - spiceAuctionInfo.auctionStartTime > Date.now() - ? spiceAuctionInfo.auctionStartTime // countdown to start time if it is in the future, meaning auction is about to start - : spiceAuctionInfo.auctionEndTime; // otherwise is end time. + // Calculate initial result + setResult(calculateResult()); - // // Calculate initial countdown - // // either the start time in the future - // // or end time in the future - // const targetTime = spiceAuctionInfo.currentEpochAuctionLive - // ? spiceAuctionInfo.auctionEndTime - // : spiceAuctionInfo.auctionEndTime + spiceAuctionInfo.auctionDuration; - // // Note typically the auction duration is 0 - // // So we just end up showing "TBD" or starts soon - - setCountdown(calculateTimeLeft(targetTime)); - - // Set up interval for updates - timerRef.current = setInterval(() => { - const targetTime = - spiceAuctionInfo.auctionStartTime > Date.now() - ? spiceAuctionInfo.auctionStartTime // countdown to start time if it is in the future, meaning auction is about to start - : spiceAuctionInfo.auctionEndTime; // otherwise is end time. - setCountdown(calculateTimeLeft(targetTime)); - }, 1000); + // Set up interval for updates (only if we need countdown) + const state = getAuctionState(spiceAuctionInfo); + if (state === AuctionState.LIVE || state === AuctionState.SCHEDULED) { + timerRef.current = setInterval(() => { + setResult(calculateResult()); + }, 1000); + } return () => { if (timerRef.current) { clearInterval(timerRef.current); } }; - }, [spiceAuctionInfo, calculateTimeLeft]); + }, [spiceAuctionInfo, calculateResult]); - return countdown; + return result; }; diff --git a/apps/dapp/src/hooks/spicebazaar/use-tos-verification.tsx b/apps/dapp/src/hooks/spicebazaar/use-tos-verification.tsx index 4dec51ca3..7e036f4f9 100644 --- a/apps/dapp/src/hooks/spicebazaar/use-tos-verification.tsx +++ b/apps/dapp/src/hooks/spicebazaar/use-tos-verification.tsx @@ -35,6 +35,13 @@ export const useTOSVerification = () => { return false; } + // For contract wallets (multisigs), we validated via EIP-1271 at sign time. + // Trust the stored validation since we can't re-verify without a provider. + if (parsed.isContractWallet === true) { + return true; + } + + // For EOA wallets, verify the signature using ecrecover return ( isSpiceBazaarTosSignatureValid( storedWallet, diff --git a/apps/dapp/src/utils/spice-auction-state.ts b/apps/dapp/src/utils/spice-auction-state.ts new file mode 100644 index 000000000..681d1d8c4 --- /dev/null +++ b/apps/dapp/src/utils/spice-auction-state.ts @@ -0,0 +1,41 @@ +import { SpiceAuctionInfo } from 'providers/SpiceAuctionProvider'; + +export enum AuctionState { + NOT_SCHEDULED = 'NOT_SCHEDULED', + SCHEDULED = 'SCHEDULED', + LIVE = 'LIVE', + ENDED = 'ENDED', +} + +export function getAuctionState( + auction: SpiceAuctionInfo | null +): AuctionState { + if (!auction) { + return AuctionState.NOT_SCHEDULED; + } + + const now = Date.now(); + + // If the auction is currently live + if (auction.currentEpochAuctionLive) { + return AuctionState.LIVE; + } + + // If auction start time is in the future, it's scheduled + if (auction.auctionStartTime > now) { + return AuctionState.SCHEDULED; + } + + // If auction end time has passed and epoch > 0, it has ended + if (auction.auctionEndTime < now && auction.currentEpoch > 0) { + return AuctionState.ENDED; + } + + // If epoch is 0 and no auction start time configured, not scheduled + if (auction.currentEpoch === 0 && !auction.auctionStartTime) { + return AuctionState.NOT_SCHEDULED; + } + + // Default to not scheduled + return AuctionState.NOT_SCHEDULED; +} diff --git a/apps/dapp/src/utils/spiceBazaarTos.ts b/apps/dapp/src/utils/spiceBazaarTos.ts index 29ed364bf..cf0fa4f9e 100644 --- a/apps/dapp/src/utils/spiceBazaarTos.ts +++ b/apps/dapp/src/utils/spiceBazaarTos.ts @@ -3,6 +3,9 @@ import { ethers } from 'ethers'; export const SPICE_BAZAAR_TOS_URL = 'https://templedao.link/spice-bazaar-disclaimer'; +// EIP-1271 magic value: bytes4(keccak256("isValidSignature(bytes32,bytes)")) +const EIP1271_MAGIC_VALUE = '0x1626ba7e'; + export const getSpiceBazaarTosStorageKey = (walletAddress: string) => `templedao.spicebazaar.tos.${walletAddress.toLowerCase()}`; @@ -12,6 +15,10 @@ export const buildSpiceBazaarTosMessage = ( ) => `I agree to the Spice Bazaar Terms & Conditions at:\n\n${SPICE_BAZAAR_TOS_URL}\n\nWallet: ${walletAddress}\nTimestamp: ${timestamp}`; +/** + * Validates signature for EOA wallets using ecrecover. + * Returns true if the recovered address matches the wallet address. + */ export const isSpiceBazaarTosSignatureValid = ( walletAddress: string, timestamp: string, @@ -25,3 +32,63 @@ export const isSpiceBazaarTosSignatureValid = ( return false; } }; + +/** + * Validates signature using EIP-1271 for smart contract wallets (e.g., multisigs). + * Calls isValidSignature on the contract to verify the signature. + */ +const isEIP1271SignatureValid = async ( + walletAddress: string, + messageHash: string, + signature: string, + provider: ethers.providers.Provider +): Promise => { + try { + const eip1271Abi = [ + 'function isValidSignature(bytes32 hash, bytes signature) view returns (bytes4)', + ]; + const contract = new ethers.Contract(walletAddress, eip1271Abi, provider); + const result = await contract.isValidSignature(messageHash, signature); + return result === EIP1271_MAGIC_VALUE; + } catch { + return false; + } +}; + +/** + * Async signature validation that supports both EOA and smart contract wallets. + * For EOA wallets, uses standard ecrecover. + * For smart contract wallets (multisigs), uses EIP-1271 isValidSignature. + */ +export const isSpiceBazaarTosSignatureValidAsync = async ( + walletAddress: string, + timestamp: string, + signature: string, + provider: ethers.providers.Provider +): Promise<{ isValid: boolean; isContractWallet: boolean }> => { + // First try EOA validation (standard ecrecover) + if (isSpiceBazaarTosSignatureValid(walletAddress, timestamp, signature)) { + return { isValid: true, isContractWallet: false }; + } + + // Check if the wallet is a smart contract + const code = await provider.getCode(walletAddress); + const isContract = code !== '0x'; + + if (!isContract) { + // Not a contract, and EOA validation failed + return { isValid: false, isContractWallet: false }; + } + + // Try EIP-1271 validation for smart contract wallets + const message = buildSpiceBazaarTosMessage(walletAddress, timestamp); + const messageHash = ethers.utils.hashMessage(message); + const isValid = await isEIP1271SignatureValid( + walletAddress, + messageHash, + signature, + provider + ); + + return { isValid, isContractWallet: true }; +};