From 956c8f46ef6c0aaf56702a7d967ec948d2239324 Mon Sep 17 00:00:00 2001 From: dan13ram Date: Wed, 3 Dec 2025 16:50:33 +0530 Subject: [PATCH 01/15] feat: support creator coin deployment --- .../web/src/pages/api/alchemy/token-prices.ts | 70 ++++ packages/constants/src/addresses.ts | 18 + packages/constants/src/swrKeys.ts | 1 + packages/create-proposal-ui/package.json | 2 + .../CreatorCoin/CreatorCoin.tsx | 320 ++++++++++++++++ .../TransactionForm/CreatorCoin/index.ts | 1 + .../CreatorCoin/ipfsUploader.ts | 18 + .../TransactionForm/TransactionForm.tsx | 7 +- packages/hooks/src/index.ts | 1 + packages/hooks/src/useTokenPrices.ts | 126 ++++++ packages/ipfs-service/src/upload.ts | 8 +- .../src/constants/transactionTypes.ts | 15 +- packages/types/src/transaction.ts | 2 + packages/ui/package.json | 4 +- packages/ui/src/CoinForm/CoinForm.schema.ts | 51 +++ packages/ui/src/CoinForm/CoinFormFields.tsx | 225 +++++++++++ packages/ui/src/CoinForm/index.ts | 3 + packages/ui/src/CoinForm/types.ts | 20 + packages/ui/src/index.ts | 2 + packages/ui/src/stores/useCoinFormStore.ts | 42 ++ packages/utils/src/index.ts | 1 + packages/utils/src/poolConfig.test.ts | 255 +++++++++++++ packages/utils/src/poolConfig.ts | 361 ++++++++++++++++++ turbo.json | 1 + 24 files changed, 1548 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/pages/api/alchemy/token-prices.ts create mode 100644 packages/create-proposal-ui/src/components/TransactionForm/CreatorCoin/CreatorCoin.tsx create mode 100644 packages/create-proposal-ui/src/components/TransactionForm/CreatorCoin/index.ts create mode 100644 packages/create-proposal-ui/src/components/TransactionForm/CreatorCoin/ipfsUploader.ts create mode 100644 packages/hooks/src/useTokenPrices.ts create mode 100644 packages/ui/src/CoinForm/CoinForm.schema.ts create mode 100644 packages/ui/src/CoinForm/CoinFormFields.tsx create mode 100644 packages/ui/src/CoinForm/index.ts create mode 100644 packages/ui/src/CoinForm/types.ts create mode 100644 packages/ui/src/stores/useCoinFormStore.ts create mode 100644 packages/utils/src/poolConfig.test.ts create mode 100644 packages/utils/src/poolConfig.ts diff --git a/apps/web/src/pages/api/alchemy/token-prices.ts b/apps/web/src/pages/api/alchemy/token-prices.ts new file mode 100644 index 000000000..fe50ef0da --- /dev/null +++ b/apps/web/src/pages/api/alchemy/token-prices.ts @@ -0,0 +1,70 @@ +import { CHAIN_ID } from '@buildeross/types' +import { NextApiRequest, NextApiResponse } from 'next' +import { getCachedTokenPrices } from 'src/services/alchemyService' +import { withCors } from 'src/utils/api/cors' +import { withRateLimit } from 'src/utils/api/rateLimit' + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const { chainId, addresses } = req.query + + if (!chainId) { + return res.status(400).json({ error: 'Missing chainId parameter' }) + } + + if (!addresses) { + return res.status(400).json({ error: 'Missing addresses parameter' }) + } + + const chainIdNum = parseInt(chainId as string, 10) + + if (!Object.values(CHAIN_ID).includes(chainIdNum)) { + return res.status(400).json({ error: 'Invalid chainId' }) + } + + // Parse addresses - can be comma-separated or array + let addressArray: string[] + if (Array.isArray(addresses)) { + addressArray = addresses + } else { + addressArray = (addresses as string).split(',').map((addr) => addr.trim()) + } + + if (addressArray.length === 0) { + return res.status(400).json({ error: 'No addresses provided' }) + } + + // Limit to 25 addresses per request (Alchemy API batch limit) + if (addressArray.length > 25) { + return res.status(400).json({ error: 'Maximum 25 addresses allowed per request' }) + } + + try { + const result = await getCachedTokenPrices( + chainIdNum as CHAIN_ID, + addressArray as `0x${string}`[] + ) + + // Handle null result (unsupported chain or missing API key) + if (!result) { + return res.status(200).json({ + data: [], + source: 'fetched', + }) + } + + // Data is already sanitized by the service + return res.status(200).json({ + data: result.data, + source: result.source, + }) + } catch (error) { + console.error('Token prices API error:', error) + return res.status(500).json({ error: 'Internal server error' }) + } +} + +export default withCors()( + withRateLimit({ + keyPrefix: 'alchemy:tokenPrices', + })(handler) +) diff --git a/packages/constants/src/addresses.ts b/packages/constants/src/addresses.ts index 51602ba59..6adde4f0f 100644 --- a/packages/constants/src/addresses.ts +++ b/packages/constants/src/addresses.ts @@ -130,3 +130,21 @@ export const ALLOWED_MIGRATION_DAOS: AddressType[] = [ export const PROTOCOL_REWARDS_MANAGER: AddressType = '0x7777777F279eba3d3Ad8F4E708545291A6fDBA8B' + +// Currency addresses for creator coins +export const ETH_ADDRESS: AddressType = '0x0000000000000000000000000000000000000000' +export const ZORA_ADDRESS: AddressType = '0x1111111111166b7fe7bd91427724b487980afc69' +export const BUILDER_ADDRESS: AddressType = '0x0000000000000000000000000000000000000002' // Placeholder - token not deployed yet + +// Builder Treasury addresses by chain (used as referral address) +export const BUILDER_TREASURY_ADDRESS = { + [CHAIN_ID.BASE]: '0xcF325a4C78912216249B818521b0798A0f904C10' as AddressType, + [CHAIN_ID.BASE_SEPOLIA]: '0xc89e4075D630351355b8c0fEe452B414b77582Df' as AddressType, +} + +// Zora Coin Factory addresses by chain +export const ZORA_COIN_FACTORY_ADDRESS = { + [CHAIN_ID.BASE]: '0x777777751622c0d3258f214F9DF38E35BF45baF3' as AddressType, + [CHAIN_ID.BASE_SEPOLIA]: '0x39Fb7730FFe2CBEbd65E3135e98E04D16a64252A' as AddressType, +} as const + diff --git a/packages/constants/src/swrKeys.ts b/packages/constants/src/swrKeys.ts index 75b5d835a..09a9f88e5 100644 --- a/packages/constants/src/swrKeys.ts +++ b/packages/constants/src/swrKeys.ts @@ -36,6 +36,7 @@ export const SWR_KEYS = { DAO_MEMBERSHIP: 'dao-membership', TOKEN_BALANCES: 'token-balances', TOKEN_METADATA: 'token-metadata', + TOKEN_PRICES: 'token-prices', NFT_BALANCES: 'nft-balances', NFT_METADATA: 'nft-metadata', PINNED_ASSETS: 'pinned-assets', diff --git a/packages/create-proposal-ui/package.json b/packages/create-proposal-ui/package.json index 1a61139ab..55b9c290e 100644 --- a/packages/create-proposal-ui/package.json +++ b/packages/create-proposal-ui/package.json @@ -10,6 +10,8 @@ "@walletconnect/types": "2.11.3", "@walletconnect/utils": "2.11.3", "@walletconnect/web3wallet": "1.10.3", + "@zoralabs/coins-sdk": "^0.3.3", + "@zoralabs/protocol-deployments": "^0.6.4", "axios": "^1.12.2", "bs58": "^6.0.0", "dayjs": "^1.11.13", diff --git a/packages/create-proposal-ui/src/components/TransactionForm/CreatorCoin/CreatorCoin.tsx b/packages/create-proposal-ui/src/components/TransactionForm/CreatorCoin/CreatorCoin.tsx new file mode 100644 index 000000000..fc765b3f1 --- /dev/null +++ b/packages/create-proposal-ui/src/components/TransactionForm/CreatorCoin/CreatorCoin.tsx @@ -0,0 +1,320 @@ +import { + ZORA_COIN_FACTORY_ADDRESS, + BUILDER_TREASURY_ADDRESS, + ETH_ADDRESS, + ZORA_ADDRESS, +} from '@buildeross/constants' +import { + getTokenPriceByAddress, + getTokenPriceFromMap, + useTokenPrices, +} from '@buildeross/hooks' +import { useChainStore, useDaoStore, useProposalStore } from '@buildeross/stores' +import { AddressType, CHAIN_ID, TransactionType } from '@buildeross/types' +import { CoinFormFields, coinFormSchema, type CoinFormValues } from '@buildeross/ui' +import { createCreatorPoolConfigFromMinFdv } from '@buildeross/utils' +import { Box, Button, Flex, Stack, Text } from '@buildeross/zord' +import { createMetadataBuilder } from '@zoralabs/coins-sdk' +import { + coinFactoryConfig, + encodeMultiCurvePoolConfig, +} from '@zoralabs/protocol-deployments' +import { Form, Formik, type FormikHelpers } from 'formik' +import { useState } from 'react' +import { type Address, encodeFunctionData, zeroAddress, zeroHash } from 'viem' + +import { IPFSUploader } from './ipfsUploader' + +// Supported chain IDs from Zora's deployment +const SUPPORTED_CHAIN_IDS = [CHAIN_ID.BASE, CHAIN_ID.BASE_SEPOLIA] + +// Default minimum Fully Diluted Valuation (FDV) in USD for pool config calculations +const DEFAULT_MIN_FDV_USD = 10000 // $10k minimum FDV + +export interface CreatorCoinProps { + initialValues?: Partial + onSubmitSuccess?: () => void + showMediaUpload?: boolean + showProperties?: boolean +} + +export const CreatorCoin: React.FC = ({ + initialValues: providedInitialValues, + onSubmitSuccess, + showMediaUpload = false, + showProperties = false, +}) => { + const { treasury } = useDaoStore((state) => state.addresses) + const addTransaction = useProposalStore((state) => state.addTransaction) + const { chain } = useChainStore() + + const [submitError, setSubmitError] = useState() + + // Check if the current chain is supported + const isChainSupported = SUPPORTED_CHAIN_IDS.includes(chain.id) + const factoryAddress = isChainSupported + ? (ZORA_COIN_FACTORY_ADDRESS[chain.id as keyof typeof ZORA_COIN_FACTORY_ADDRESS] as AddressType) + : undefined + + // Determine which currencies to fetch prices for based on chain + const currenciesToFetch = isChainSupported + ? chain.id === CHAIN_ID.BASE_SEPOLIA + ? [ETH_ADDRESS as `0x${string}`] // Base Sepolia only supports ETH + : [ETH_ADDRESS as `0x${string}`, ZORA_ADDRESS as `0x${string}`] // Base mainnet supports ETH and ZORA + : undefined + + // Fetch token prices for available currencies + const { prices: tokenPrices } = useTokenPrices( + isChainSupported ? chain.id : undefined, + currenciesToFetch + ) + + // Initial values from props only + const initialValues: CoinFormValues = { + name: providedInitialValues?.name || '', + symbol: providedInitialValues?.symbol || '', + description: providedInitialValues?.description || '', + imageUrl: providedInitialValues?.imageUrl || '', + mediaUrl: providedInitialValues?.mediaUrl || '', + mediaMimeType: providedInitialValues?.mediaMimeType || '', + properties: providedInitialValues?.properties || {}, + currency: providedInitialValues?.currency || ETH_ADDRESS, + minFdvUsd: providedInitialValues?.minFdvUsd || DEFAULT_MIN_FDV_USD, + } + + const handleSubmit = async ( + values: CoinFormValues, + actions: FormikHelpers + ) => { + if (!treasury) { + setSubmitError('Treasury address not found') + actions.setSubmitting(false) + return + } + + if (!factoryAddress) { + setSubmitError( + `Creator coins are only supported on Base and Base Sepolia. Current chain: ${chain.name}` + ) + actions.setSubmitting(false) + return + } + + setSubmitError(undefined) + + try { + // 1. Create metadata builder and configure metadata + const metadataBuilder = createMetadataBuilder() + .withName(values.name) + .withSymbol(values.symbol) + .withDescription(values.description) + + // Set image URI (already uploaded to IPFS via SingleImageUpload) + if (values.imageUrl) { + metadataBuilder.withImageURI(values.imageUrl) + } + + // Set media URI if provided (already uploaded to IPFS via handleMediaUpload) + if (values.mediaUrl) { + metadataBuilder.withMediaURI(values.mediaUrl, values.mediaMimeType) + } + + // Add custom properties if any + if (values.properties && Object.keys(values.properties).length > 0) { + metadataBuilder.withProperties(values.properties) + } + + // 2. Upload metadata and all files to IPFS using our custom uploader + const uploader = new IPFSUploader() + const { url: metadataUri } = await metadataBuilder.upload(uploader) + + // 3. Get token price for the selected currency + const currency = (values.currency || ETH_ADDRESS) as AddressType + + // Try to get price from fetched data first, fallback to placeholder + let quoteTokenUsd = getTokenPriceFromMap(tokenPrices, currency) + + if (!quoteTokenUsd) { + // Fallback to placeholder prices + quoteTokenUsd = getTokenPriceByAddress(currency) + } + + if (!quoteTokenUsd) { + throw new Error(`Unable to get price for selected currency: ${currency}`) + } + + // 4. Create pool config using the utility with form values + const poolConfig = createCreatorPoolConfigFromMinFdv({ + currency, + quoteTokenUsd, + minFdvUsd: values.minFdvUsd || DEFAULT_MIN_FDV_USD, + }) + + // 5. Encode pool config using Zora's function + const encodedPoolConfig = encodeMultiCurvePoolConfig({ + currency: poolConfig.currency, + tickLower: poolConfig.lowerTicks, + tickUpper: poolConfig.upperTicks, + numDiscoveryPositions: poolConfig.numDiscoveryPositions, + maxDiscoverySupplyShare: poolConfig.maxDiscoverySupplyShares, + }) + + // 6. Get Builder treasury address for platformReferrer, fallback to zero address + const builderTreasuryAddress = + BUILDER_TREASURY_ADDRESS[chain.id as keyof typeof BUILDER_TREASURY_ADDRESS] || + zeroAddress + + // 7. Encode the contract call using Zora's ABI + const calldata = encodeFunctionData({ + abi: coinFactoryConfig.abi, + functionName: 'deployCreatorCoin', + args: [ + treasury as Address, // payoutRecipient + [treasury as Address], // owners array + metadataUri, // uri + values.name, // name + values.symbol, // symbol + encodedPoolConfig, // poolConfig + builderTreasuryAddress as Address, // platformReferrer (Builder treasury for referral, or zero address) + zeroHash, // coinSalt (can be customized if needed) + ], + }) + + // 8. Create transaction object + const transaction = { + target: factoryAddress, + functionSignature: + 'deployCreatorCoin(address,address[],string,string,string,bytes,address,bytes32)', + calldata, + value: '0', + } + + // 9. Add transaction to proposal queue + addTransaction({ + type: TransactionType.CREATOR_COIN, + summary: `Create ${values.symbol} Creator Coin`, + transactions: [transaction], + }) + + // Reset form + actions.resetForm() + + // Call success callback if provided + if (onSubmitSuccess) { + onSubmitSuccess() + } + } catch (error) { + console.error('Error creating creator coin transaction:', error) + setSubmitError( + error instanceof Error ? error.message : 'Failed to create transaction' + ) + } finally { + actions.setSubmitting(false) + } + } + + // If chain is not supported, show message and don't render form + if (!isChainSupported) { + return ( + + + + Network Not Supported + + Creator coins are currently only supported on Base and Base Sepolia + networks. Please switch to a supported network to create a creator coin. + + + + + ) + } + + return ( + + + {(formik) => { + const isDisabled = formik.isSubmitting || formik.isValidating || !treasury + + return ( + + + + Create Creator Coin + + Configure your creator coin metadata and add the transaction to the + proposal queue. + + + + + + {submitError && ( + + + {submitError} + + + )} + + {!treasury && ( + + + Treasury address not found. Please connect to a DAO. + + + )} + + + + + ) + }} + + + ) +} diff --git a/packages/create-proposal-ui/src/components/TransactionForm/CreatorCoin/index.ts b/packages/create-proposal-ui/src/components/TransactionForm/CreatorCoin/index.ts new file mode 100644 index 000000000..da337f4e3 --- /dev/null +++ b/packages/create-proposal-ui/src/components/TransactionForm/CreatorCoin/index.ts @@ -0,0 +1 @@ +export * from './CreatorCoin' diff --git a/packages/create-proposal-ui/src/components/TransactionForm/CreatorCoin/ipfsUploader.ts b/packages/create-proposal-ui/src/components/TransactionForm/CreatorCoin/ipfsUploader.ts new file mode 100644 index 000000000..7ecbe5967 --- /dev/null +++ b/packages/create-proposal-ui/src/components/TransactionForm/CreatorCoin/ipfsUploader.ts @@ -0,0 +1,18 @@ +import { uploadFile } from '@buildeross/ipfs-service' +import type { Uploader, UploadResult } from '@zoralabs/coins-sdk' + +/** + * Custom IPFS uploader that wraps uploadFile from ipfs-service + * and implements the Uploader interface from @zoralabs/coins-sdk + */ +export class IPFSUploader implements Uploader { + async upload(file: File): Promise { + const result = await uploadFile(file) + + return { + url: result.uri as `ipfs://${string}`, + size: file.size, + mimeType: file.type || undefined, + } + } +} diff --git a/packages/create-proposal-ui/src/components/TransactionForm/TransactionForm.tsx b/packages/create-proposal-ui/src/components/TransactionForm/TransactionForm.tsx index 59b1ac2fb..383dd7453 100644 --- a/packages/create-proposal-ui/src/components/TransactionForm/TransactionForm.tsx +++ b/packages/create-proposal-ui/src/components/TransactionForm/TransactionForm.tsx @@ -1,8 +1,9 @@ import { TransactionType } from '@buildeross/types' -import React, { ReactNode } from 'react' +import { ReactNode } from 'react' import { AddArtwork } from './AddArtwork' import { Airdrop } from './Airdrop' +import { CreatorCoin } from './CreatorCoin' import { CustomTransaction } from './CustomTransaction' import { Droposal } from './Droposal' import { Escrow } from './Escrow' @@ -41,6 +42,8 @@ export const TRANSACTION_FORM_OPTIONS = [ TransactionType.DROPOSAL, TransactionType.MIGRATION, TransactionType.CUSTOM, + TransactionType.CREATOR_COIN, + TransactionType.CONTENT_COIN, ] as const export const TransactionForm = ({ type }: TransactionFormProps) => { @@ -61,6 +64,8 @@ export const TransactionForm = ({ type }: TransactionFormProps) => { [TransactionType.ADD_ARTWORK]: , [TransactionType.REPLACE_ARTWORK]: , [TransactionType.MIGRATION]: , + [TransactionType.CREATOR_COIN]: , + [TransactionType.CONTENT_COIN]: , } return <>{FORMS[type]} diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts index 568030c6e..666e295b4 100644 --- a/packages/hooks/src/index.ts +++ b/packages/hooks/src/index.ts @@ -37,6 +37,7 @@ export * from './useScrollDirection' export * from './useTimeout' export * from './useTokenBalances' export * from './useTokenMetadata' +export * from './useTokenPrices' export * from './useTransactionSummary' export * from './useUserDaos' export * from './useVotes' diff --git a/packages/hooks/src/useTokenPrices.ts b/packages/hooks/src/useTokenPrices.ts new file mode 100644 index 000000000..8f35b623c --- /dev/null +++ b/packages/hooks/src/useTokenPrices.ts @@ -0,0 +1,126 @@ +import { + BUILDER_ADDRESS, + ETH_ADDRESS, + ZORA_ADDRESS, +} from '@buildeross/constants/addresses' +import { BASE_URL } from '@buildeross/constants/baseUrl' +import { SWR_KEYS } from '@buildeross/constants/swrKeys' +import type { CHAIN_ID } from '@buildeross/types' +import useSWR, { type KeyedMutator } from 'swr' +import { type Address } from 'viem' + +export type TokenPrice = { + address: string + price: string // USD price as string +} + +export type TokenPricesReturnType = { + prices: Record | undefined // address -> USD price + isValidating: boolean + isLoading: boolean + error: Error | undefined + mutate: KeyedMutator +} + +const fetchTokenPrices = async ( + chainId: CHAIN_ID, + addresses: Address[] +): Promise => { + const params = new URLSearchParams() + params.set('chainId', chainId.toString()) + params.set('addresses', addresses.join(',')) + + const response = await fetch( + `${BASE_URL}/api/alchemy/token-prices?${params.toString()}` + ) + if (!response.ok) { + throw new Error('Failed to fetch token prices') + } + const result = await response.json() + return result.data || [] +} + +/** + * Hook to fetch token prices in USD from Alchemy API + * + * @param chainId - The chain ID to fetch prices for + * @param addresses - Array of token addresses to fetch prices for (max 25) + * @returns An object with token prices mapped by address, loading states, and error + */ +export const useTokenPrices = ( + chainId?: CHAIN_ID, + addresses?: Address[] +): TokenPricesReturnType => { + const { data, error, isLoading, isValidating, mutate } = useSWR( + !!addresses && addresses.length > 0 && !!chainId + ? ([SWR_KEYS.TOKEN_PRICES, chainId, addresses.join(',')] as const) + : null, + async ([, _chainId, _addresses]) => { + const addressArray = (_addresses as string).split(',') as Address[] + return fetchTokenPrices(_chainId as CHAIN_ID, addressArray) + }, + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshInterval: 60000, // Refresh every 60 seconds + } + ) + + // Convert array of TokenPrice to Record + const pricesMap = data?.reduce( + (acc, tokenPrice) => { + acc[tokenPrice.address.toLowerCase()] = parseFloat(tokenPrice.price) || 0 + return acc + }, + {} as Record + ) + + return { + prices: pricesMap, + isLoading, + isValidating, + error, + mutate, + } +} + +/** + * Get the price for a specific token by address + * This is a synchronous helper that works with the hook's result + * + * @param prices - The prices map from useTokenPrices hook + * @param address - The token address + * @returns The price in USD, or null if not found + */ +export const getTokenPriceFromMap = ( + prices: Record | undefined, + address: string +): number | null => { + if (!prices) return null + return prices[address.toLowerCase()] ?? null +} + +/** + * Placeholder function for getting token price by address + * Used for backwards compatibility and SSR contexts + * + * @param address - The token address + * @returns Placeholder price or null + */ +export const getTokenPriceByAddress = (address: string): number | null => { + const lowerAddress = address.toLowerCase() + + if (lowerAddress === ETH_ADDRESS.toLowerCase()) { + return 3000 // $3,000 per ETH (placeholder) + } + + if (lowerAddress === ZORA_ADDRESS.toLowerCase()) { + return 0.5 // $0.50 per ZORA (placeholder) + } + + if (lowerAddress === BUILDER_ADDRESS.toLowerCase()) { + return 1.0 // $1.00 per BUILDER (placeholder) + } + + return null +} diff --git a/packages/ipfs-service/src/upload.ts b/packages/ipfs-service/src/upload.ts index 981b57fad..ccc7c129b 100644 --- a/packages/ipfs-service/src/upload.ts +++ b/packages/ipfs-service/src/upload.ts @@ -253,12 +253,16 @@ export async function uploadFile( ): Promise { console.info('ipfs-service/uploadFile: file:', file) - const { onProgress, cache, type } = { + const { + onProgress, + cache = true, + type = 'file', + } = { ...defaultOptions, ...options, } - const uploadType = (type ?? 'file') as UploadType + const uploadType = type as UploadType const uploadOptions = pinataOptions[uploadType] if (file.size > uploadOptions.max_file_size) { diff --git a/packages/proposal-ui/src/constants/transactionTypes.ts b/packages/proposal-ui/src/constants/transactionTypes.ts index 2aaae4b85..0c5ce35fc 100644 --- a/packages/proposal-ui/src/constants/transactionTypes.ts +++ b/packages/proposal-ui/src/constants/transactionTypes.ts @@ -130,5 +130,16 @@ export const TRANSACTION_TYPES: TransactionTypesPropsMap = { icon: 'pin', iconBackdrop: 'rgba(236, 113, 75, 0.1)', iconFill: 'warning', - }, -} + [TransactionType.CREATOR_COIN]: { + title: 'Creator Coin', + subTitle: 'Create a proposal to mint Creator Coin', + icon: 'collection', + iconBackdrop: 'rgba(0, 163, 255, 0.1)', + }, + [TransactionType.CONTENT_COIN]: { + title: 'Content Coin', + subTitle: 'Create a proposal to mint Content Coin', + icon: 'collection', + iconBackdrop: 'rgba(0, 163, 255, 0.1)', + }, + } diff --git a/packages/types/src/transaction.ts b/packages/types/src/transaction.ts index 59089336c..bb8f99114 100644 --- a/packages/types/src/transaction.ts +++ b/packages/types/src/transaction.ts @@ -20,6 +20,8 @@ export enum TransactionType { WALLET_CONNECT = 'wallet-connect', ADD_ARTWORK = 'add-artwork', PIN_TREASURY_ASSET = 'pin-treasury-asset', + CREATOR_COIN = 'creator-coin', + CONTENT_COIN = 'content-coin', } export type Transaction = { diff --git a/packages/ui/package.json b/packages/ui/package.json index 7a7be1c55..eaba2438f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -13,7 +13,9 @@ "react-portal": "^4.3.0", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", - "remark-gfm": "^4.0.1" + "remark-gfm": "^4.0.1", + "yup": "^1.6.1", + "zustand": "^5.0.4" }, "devDependencies": { "@buildeross/constants": "workspace:*", diff --git a/packages/ui/src/CoinForm/CoinForm.schema.ts b/packages/ui/src/CoinForm/CoinForm.schema.ts new file mode 100644 index 000000000..b6dc5d7ab --- /dev/null +++ b/packages/ui/src/CoinForm/CoinForm.schema.ts @@ -0,0 +1,51 @@ +import * as yup from 'yup' + +export const coinFormSchema = yup.object({ + name: yup + .string() + .required('Name is required') + .min(1, 'Name must be at least 1 character') + .max(100, 'Name must be less than 100 characters'), + symbol: yup + .string() + .required('Symbol is required') + .min(1, 'Symbol must be at least 1 character') + .max(10, 'Symbol must be less than 10 characters') + .matches(/^[A-Z0-9]+$/, 'Symbol must only contain uppercase letters and numbers'), + description: yup + .string() + .required('Description is required') + .min(1, 'Description must be at least 1 character') + .max(1000, 'Description must be less than 1000 characters'), + imageUrl: yup.string().when('imageFile', { + is: (imageFile: File | undefined) => !imageFile, + then: (schema) => schema.required('Image is required'), + otherwise: (schema) => schema, + }), + imageFile: yup.mixed(), + mediaUrl: yup.string(), + mediaFile: yup.mixed(), + mediaMimeType: yup.string(), + properties: yup + .object() + .test( + 'valid-properties', + 'All property keys and values must be strings', + function (value) { + if (!value) return true + + for (const [key, val] of Object.entries(value)) { + if (typeof key !== 'string' || typeof val !== 'string') { + return false + } + } + return true + } + ), + currency: yup.string(), + minFdvUsd: yup + .number() + .positive('Minimum FDV must be a positive number') + .min(1000, 'Minimum FDV must be at least $1,000') + .typeError('Minimum FDV must be a valid number'), +}) diff --git a/packages/ui/src/CoinForm/CoinFormFields.tsx b/packages/ui/src/CoinForm/CoinFormFields.tsx new file mode 100644 index 000000000..91441c069 --- /dev/null +++ b/packages/ui/src/CoinForm/CoinFormFields.tsx @@ -0,0 +1,225 @@ +import { BUILDER_ADDRESS, ETH_ADDRESS, ZORA_ADDRESS } from '@buildeross/constants' +import { CHAIN_ID } from '@buildeross/types' +import { Box, Flex, Stack, Text } from '@buildeross/zord' +import React from 'react' + +import TextArea from '../Fields/TextArea' +import TextInput from '../Fields/TextInput' +import { SingleImageUpload } from '../SingleImageUpload/SingleImageUpload' +import { SingleMediaUpload } from '../SingleMediaUpload/SingleMediaUpload' +import type { CoinFormFieldsProps } from './types' + +// Currency options based on chain +const BASE_MAINNET_CHAIN_ID = CHAIN_ID.BASE +const BASE_SEPOLIA_CHAIN_ID = CHAIN_ID.BASE_SEPOLIA + +export const CoinFormFields: React.FC = ({ + formik, + showMediaUpload = false, + showProperties = false, + chainId, + showCurrencyInput = true, +}) => { + // Determine available currency options based on chain + const isBaseSepolia = chainId === BASE_SEPOLIA_CHAIN_ID + const isBaseMainnet = chainId === BASE_MAINNET_CHAIN_ID + + const currencyOptions = React.useMemo(() => { + if (isBaseSepolia) { + return [{ value: ETH_ADDRESS, label: 'ETH' }] + } + if (isBaseMainnet) { + return [ + { value: ETH_ADDRESS, label: 'ETH' }, + { value: ZORA_ADDRESS, label: 'ZORA' }, + { value: BUILDER_ADDRESS, label: 'BUILDER (Coming Soon)', disabled: true }, + ] + } + return [{ value: ETH_ADDRESS, label: 'ETH' }] + }, [isBaseSepolia, isBaseMainnet]) + + // Handle media upload to store mime type + const handleMediaUploadStart = React.useCallback( + (file: File) => { + formik.setFieldValue('mediaMimeType', file.type) + }, + [formik] + ) + + return ( + + {/* Name Field */} + + + {/* Symbol Field */} + ) => { + const value = e.target.value.toUpperCase() + formik.setFieldValue('symbol', value) + }} + inputLabel="Symbol" + placeholder="COIN" + errorMessage={ + formik.touched.symbol && formik.errors.symbol ? formik.errors.symbol : undefined + } + formik={formik} + /> + + {/* Description Field */} +