From 7a21240bed820cc185a9dee02aa481c901889b88 Mon Sep 17 00:00:00 2001 From: Serkan Reis Date: Fri, 24 Mar 2023 10:43:10 +0300 Subject: [PATCH] Add splits contract dashboard & mint revenue payment address --- .env.example | 4 +- components/LinkTabs.data.ts | 23 ++ components/Sidebar.tsx | 9 + .../creation/CollectionDetails.tsx | 8 +- .../collections/creation/MintingDetails.tsx | 38 ++- .../contracts/splits/ExecuteCombobox.hooks.ts | 7 + .../contracts/splits/ExecuteCombobox.tsx | 93 +++++++ components/forms/MemberAttributes.hooks.ts | 33 +++ components/forms/MemberAttributes.tsx | 94 +++++++ contexts/contracts.tsx | 9 +- contracts/splits/contract.ts | 191 ++++++++++++++ contracts/splits/index.ts | 2 + contracts/splits/messages/execute.ts | 79 ++++++ contracts/splits/messages/query.ts | 43 ++++ contracts/splits/useContract.ts | 93 +++++++ env.d.ts | 2 + package.json | 2 +- pages/collections/create.tsx | 19 +- pages/contracts/index.tsx | 3 + pages/contracts/splits/execute.tsx | 135 ++++++++++ pages/contracts/splits/index.tsx | 1 + pages/contracts/splits/instantiate.tsx | 237 ++++++++++++++++++ pages/contracts/splits/migrate.tsx | 133 ++++++++++ pages/contracts/splits/query.tsx | 156 ++++++++++++ utils/constants.ts | 2 + 25 files changed, 1405 insertions(+), 11 deletions(-) create mode 100644 components/contracts/splits/ExecuteCombobox.hooks.ts create mode 100644 components/contracts/splits/ExecuteCombobox.tsx create mode 100644 components/forms/MemberAttributes.hooks.ts create mode 100644 components/forms/MemberAttributes.tsx create mode 100644 contracts/splits/contract.ts create mode 100644 contracts/splits/index.ts create mode 100644 contracts/splits/messages/execute.ts create mode 100644 contracts/splits/messages/query.ts create mode 100644 contracts/splits/useContract.ts create mode 100644 pages/contracts/splits/execute.tsx create mode 100644 pages/contracts/splits/index.tsx create mode 100644 pages/contracts/splits/instantiate.tsx create mode 100644 pages/contracts/splits/migrate.tsx create mode 100644 pages/contracts/splits/query.tsx diff --git a/.env.example b/.env.example index 238f510..6067326 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -APP_VERSION=0.4.8 +APP_VERSION=0.4.9 NEXT_PUBLIC_PINATA_ENDPOINT_URL=https://api.pinata.cloud/pinning/pinFileToIPFS NEXT_PUBLIC_SG721_CODE_ID=1702 @@ -12,6 +12,8 @@ NEXT_PUBLIC_BADGE_HUB_CODE_ID=1336 NEXT_PUBLIC_BADGE_HUB_ADDRESS="stars1dacun0xn7z73qzdcmq27q3xn6xuprg8e2ugj364784al2v27tklqynhuqa" NEXT_PUBLIC_BADGE_NFT_CODE_ID=1337 NEXT_PUBLIC_BADGE_NFT_ADDRESS="stars1vlw4y54dyzt3zg7phj8yey9fg4zj49czknssngwmgrnwymyktztstalg7t" +NEXT_PUBLIC_SPLITS_CODE_ID=1904 +NEXT_PUBLIC_CW4_GROUP_CODE_ID=1905 NEXT_PUBLIC_API_URL=https://nft-api.elgafar-1.stargaze-apis.com diff --git a/components/LinkTabs.data.ts b/components/LinkTabs.data.ts index 4fb6fd4..1168cff 100644 --- a/components/LinkTabs.data.ts +++ b/components/LinkTabs.data.ts @@ -104,3 +104,26 @@ export const badgeHubLinkTabs: LinkTabProps[] = [ href: '/contracts/badgeHub/migrate', }, ] + +export const splitsLinkTabs: LinkTabProps[] = [ + { + title: 'Instantiate', + description: `Initialize a new Splits contract`, + href: '/contracts/splits/instantiate', + }, + { + title: 'Query', + description: `Dispatch queries for your Splits contract`, + href: '/contracts/splits/query', + }, + { + title: 'Execute', + description: `Execute Splits contract actions`, + href: '/contracts/splits/execute', + }, + { + title: 'Migrate', + description: `Migrate Splits contract`, + href: '/contracts/splits/migrate', + }, +] diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 50517d0..cde52fc 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -190,6 +190,15 @@ export const Sidebar = () => { Badge Hub Contract +
  • + Splits Contract +
  • diff --git a/components/collections/creation/CollectionDetails.tsx b/components/collections/creation/CollectionDetails.tsx index bad2dec..7fdbff4 100644 --- a/components/collections/creation/CollectionDetails.tsx +++ b/components/collections/creation/CollectionDetails.tsx @@ -119,9 +119,9 @@ export const CollectionDetails = ({ onChange, uploadMethod, coverImageUrl, minte
    - +
    -
    +
    Does the collection contain explicit content? diff --git a/components/collections/creation/MintingDetails.tsx b/components/collections/creation/MintingDetails.tsx index 6afc367..8928051 100644 --- a/components/collections/creation/MintingDetails.tsx +++ b/components/collections/creation/MintingDetails.tsx @@ -1,10 +1,12 @@ import { FormControl } from 'components/FormControl' import { FormGroup } from 'components/FormGroup' -import { useNumberInputState } from 'components/forms/FormInput.hooks' +import { useInputState, useNumberInputState } from 'components/forms/FormInput.hooks' import { InputDateTime } from 'components/InputDateTime' import React, { useEffect, useState } from 'react' +import { resolveAddress } from 'utils/resolveAddress' -import { NumberInput } from '../../forms/FormInput' +import { useWallet } from '../../../contexts/wallet' +import { NumberInput, TextInput } from '../../forms/FormInput' import type { UploadMethod } from './UploadDetails' interface MintingDetailsProps { @@ -18,9 +20,12 @@ export interface MintingDetailsDataProps { unitPrice: string perAddressLimit: number startTime: string + paymentAddress?: string } export const MintingDetails = ({ onChange, numberOfTokens, uploadMethod }: MintingDetailsProps) => { + const wallet = useWallet() + const [timestamp, setTimestamp] = useState() const numberOfTokensState = useNumberInputState({ @@ -47,6 +52,24 @@ export const MintingDetails = ({ onChange, numberOfTokens, uploadMethod }: Minti placeholder: '1', }) + const paymentAddressState = useInputState({ + id: 'payment-address', + name: 'paymentAddress', + title: 'Payment Address (optional)', + subtitle: 'Address to receive minting revenues (defaults to current wallet address)', + placeholder: 'stars1234567890abcdefghijklmnopqrstuvwxyz...', + }) + + const resolvePaymentAddress = async () => { + await resolveAddress(paymentAddressState.value.trim(), wallet).then((resolvedAddress) => { + paymentAddressState.onChange(resolvedAddress) + }) + } + + useEffect(() => { + void resolvePaymentAddress() + }, [paymentAddressState.value]) + useEffect(() => { if (numberOfTokens) numberOfTokensState.onChange(numberOfTokens) const data: MintingDetailsDataProps = { @@ -54,10 +77,18 @@ export const MintingDetails = ({ onChange, numberOfTokens, uploadMethod }: Minti unitPrice: unitPriceState.value ? (Number(unitPriceState.value) * 1_000_000).toString() : '', perAddressLimit: perAddressLimitState.value, startTime: timestamp ? (timestamp.getTime() * 1_000_000).toString() : '', + paymentAddress: paymentAddressState.value, } onChange(data) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [numberOfTokens, numberOfTokensState.value, unitPriceState.value, perAddressLimitState.value, timestamp]) + }, [ + numberOfTokens, + numberOfTokensState.value, + unitPriceState.value, + perAddressLimitState.value, + timestamp, + paymentAddressState.value, + ]) return (
    @@ -74,6 +105,7 @@ export const MintingDetails = ({ onChange, numberOfTokens, uploadMethod }: Minti setTimestamp(date)} value={timestamp} /> +
    ) } diff --git a/components/contracts/splits/ExecuteCombobox.hooks.ts b/components/contracts/splits/ExecuteCombobox.hooks.ts new file mode 100644 index 0000000..530deed --- /dev/null +++ b/components/contracts/splits/ExecuteCombobox.hooks.ts @@ -0,0 +1,7 @@ +import type { ExecuteListItem } from 'contracts/splits/messages/execute' +import { useState } from 'react' + +export const useExecuteComboboxState = () => { + const [value, setValue] = useState(null) + return { value, onChange: (item: ExecuteListItem) => setValue(item) } +} diff --git a/components/contracts/splits/ExecuteCombobox.tsx b/components/contracts/splits/ExecuteCombobox.tsx new file mode 100644 index 0000000..b56f963 --- /dev/null +++ b/components/contracts/splits/ExecuteCombobox.tsx @@ -0,0 +1,93 @@ +import { Combobox, Transition } from '@headlessui/react' +import clsx from 'clsx' +import { FormControl } from 'components/FormControl' +import { matchSorter } from 'match-sorter' +import { Fragment, useState } from 'react' +import { FaChevronDown, FaInfoCircle } from 'react-icons/fa' + +import type { ExecuteListItem } from '../../../contracts/splits/messages/execute' +import { EXECUTE_LIST } from '../../../contracts/splits/messages/execute' + +export interface ExecuteComboboxProps { + value: ExecuteListItem | null + onChange: (item: ExecuteListItem) => void +} + +export const ExecuteCombobox = ({ value, onChange }: ExecuteComboboxProps) => { + const [search, setSearch] = useState('') + + const filtered = + search === '' ? EXECUTE_LIST : matchSorter(EXECUTE_LIST, search, { keys: ['id', 'name', 'description'] }) + + return ( + +
    + val?.name ?? ''} + id="message-type" + onChange={(event) => setSearch(event.target.value)} + placeholder="Select message type" + /> + + + {({ open }) => + + setSearch('')} as={Fragment}> + + {filtered.length < 1 && ( + + Message type not found. + + )} + {filtered.map((entry) => ( + + clsx('flex relative flex-col py-2 px-4 space-y-1 cursor-pointer', { 'bg-stargaze-80': active }) + } + value={entry} + > + {entry.name} + {entry.description} + + ))} + + +
    + + {value && ( +
    +
    + +
    + {value.description} +
    + )} +
    + ) +} diff --git a/components/forms/MemberAttributes.hooks.ts b/components/forms/MemberAttributes.hooks.ts new file mode 100644 index 0000000..3cc36e2 --- /dev/null +++ b/components/forms/MemberAttributes.hooks.ts @@ -0,0 +1,33 @@ +import { useMemo, useState } from 'react' +import { uid } from 'utils/random' + +import type { Attribute } from './MemberAttributes' + +export function useMemberAttributesState() { + const [record, setRecord] = useState>(() => ({})) + + const entries = useMemo(() => Object.entries(record), [record]) + const values = useMemo(() => Object.values(record), [record]) + + function add(attribute: Attribute = { address: '', weight: 0 }) { + setRecord((prev) => ({ ...prev, [uid()]: attribute })) + } + + function update(key: string, attribute = record[key]) { + setRecord((prev) => ({ ...prev, [key]: attribute })) + } + + function remove(key: string) { + return setRecord((prev) => { + const latest = { ...prev } + delete latest[key] + return latest + }) + } + + function reset() { + setRecord({}) + } + + return { entries, values, add, update, remove, reset } +} diff --git a/components/forms/MemberAttributes.tsx b/components/forms/MemberAttributes.tsx new file mode 100644 index 0000000..e36ed94 --- /dev/null +++ b/components/forms/MemberAttributes.tsx @@ -0,0 +1,94 @@ +import { FormControl } from 'components/FormControl' +import { AddressInput, NumberInput } from 'components/forms/FormInput' +import { useEffect, useId, useMemo } from 'react' +import { FaMinus, FaPlus } from 'react-icons/fa' + +import { useInputState, useNumberInputState } from './FormInput.hooks' + +export interface Attribute { + address: string + weight: number +} + +export interface MemberAttributesProps { + title: string + subtitle?: string + isRequired?: boolean + attributes: [string, Attribute][] + onAdd: () => void + onChange: (key: string, attribute: Attribute) => void + onRemove: (key: string) => void +} + +export function MemberAttributes(props: MemberAttributesProps) { + const { title, subtitle, isRequired, attributes, onAdd, onChange, onRemove } = props + + return ( + + {attributes.map(([id], i) => ( + + ))} + + ) +} + +export interface MemberAttributeProps { + id: string + isLast: boolean + onAdd: MemberAttributesProps['onAdd'] + onChange: MemberAttributesProps['onChange'] + onRemove: MemberAttributesProps['onRemove'] + defaultAttribute: Attribute +} + +export function MemberAttribute({ id, isLast, onAdd, onChange, onRemove, defaultAttribute }: MemberAttributeProps) { + const Icon = useMemo(() => (isLast ? FaPlus : FaMinus), [isLast]) + + const htmlId = useId() + + const addressState = useInputState({ + id: `ma-address-${htmlId}`, + name: `ma-address-${htmlId}`, + title: `Address`, + defaultValue: defaultAttribute.address, + }) + + const weightState = useNumberInputState({ + id: `ma-weight-${htmlId}`, + name: `ma-weight-${htmlId}`, + title: `Weight`, + defaultValue: defaultAttribute.weight, + }) + + useEffect(() => { + onChange(id, { address: addressState.value, weight: weightState.value }) + }, [addressState.value, weightState.value, id]) + + return ( +
    + + + +
    + +
    +
    + ) +} diff --git a/contexts/contracts.tsx b/contexts/contracts.tsx index 4df8488..bfcabeb 100644 --- a/contexts/contracts.tsx +++ b/contexts/contracts.tsx @@ -17,6 +17,9 @@ import { Fragment, useEffect } from 'react' import type { State } from 'zustand' import create from 'zustand' +import type { UseSplitsContractProps } from '../contracts/splits/useContract' +import { useSplitsContract } from '../contracts/splits/useContract' + /** * Contracts store type definitions */ @@ -28,6 +31,7 @@ export interface ContractsStore extends State { vendingFactory: UseVendingFactoryContractProps | null baseFactory: UseBaseFactoryContractProps | null badgeHub: UseBadgeHubContractProps | null + splits: UseSplitsContractProps | null } /** @@ -41,6 +45,7 @@ export const defaultValues: ContractsStore = { vendingFactory: null, baseFactory: null, badgeHub: null, + splits: null, } /** @@ -71,6 +76,7 @@ const ContractsSubscription: VFC = () => { const vendingFactory = useVendingFactoryContract() const baseFactory = useBaseFactoryContract() const badgeHub = useBadgeHubContract() + const splits = useSplitsContract() useEffect(() => { useContracts.setState({ @@ -81,8 +87,9 @@ const ContractsSubscription: VFC = () => { vendingFactory, baseFactory, badgeHub, + splits, }) - }, [sg721, vendingMinter, baseMinter, whitelist, vendingFactory, baseFactory, badgeHub]) + }, [sg721, vendingMinter, baseMinter, whitelist, vendingFactory, baseFactory, badgeHub, splits]) return null } diff --git a/contracts/splits/contract.ts b/contracts/splits/contract.ts new file mode 100644 index 0000000..06593d0 --- /dev/null +++ b/contracts/splits/contract.ts @@ -0,0 +1,191 @@ +import type { SigningCosmWasmClient } from '@cosmjs/cosmwasm-stargate' +import type { Coin } from '@cosmjs/proto-signing' +import type { logs } from '@cosmjs/stargate' + +export interface InstantiateResponse { + readonly contractAddress: string + readonly transactionHash: string +} + +export interface MigrateResponse { + readonly transactionHash: string + readonly logs: readonly logs.Log[] +} + +export interface SplitsInstance { + readonly contractAddress: string + //Query + getAdmin: () => Promise + getMemberWeight: (member: string) => Promise + listMembers: (startAfter?: string, limit?: number) => Promise + getGroup: () => Promise + + //Execute + updateAdmin: (admin: string) => Promise + distribute: () => Promise +} + +export interface SplitsMessages { + updateAdmin: (admin: string) => UpdateAdminMessage + distribute: () => DistributeMessage +} + +export interface UpdateAdminMessage { + sender: string + contract: string + msg: { + update_admin: { admin: string } + } + funds: Coin[] +} + +export interface DistributeMessage { + sender: string + contract: string + msg: { distribute: Record } + funds: Coin[] +} + +export interface SplitsContract { + instantiate: ( + codeId: number, + initMsg: Record, + label: string, + admin?: string, + ) => Promise + + use: (contractAddress: string) => SplitsInstance + + migrate: ( + senderAddress: string, + contractAddress: string, + codeId: number, + migrateMsg: Record, + ) => Promise + + messages: (contractAddress: string) => SplitsMessages +} + +export const Splits = (client: SigningCosmWasmClient, txSigner: string): SplitsContract => { + const use = (contractAddress: string): SplitsInstance => { + ///QUERY + const listMembers = async (startAfter?: string, limit?: number): Promise => { + return client.queryContractSmart(contractAddress, { + list_members: { start_after: startAfter ? startAfter : undefined, limit }, + }) + } + + const getMemberWeight = async (address: string): Promise => { + return client.queryContractSmart(contractAddress, { + member: { address }, + }) + } + + const getAdmin = async (): Promise => { + return client.queryContractSmart(contractAddress, { + admin: {}, + }) + } + + const getGroup = async (): Promise => { + return client.queryContractSmart(contractAddress, { + group: {}, + }) + } + /// EXECUTE + const updateAdmin = async (admin: string): Promise => { + const res = await client.execute( + txSigner, + contractAddress, + { + update_admin: { + admin, + }, + }, + 'auto', + ) + return res.transactionHash + } + + const distribute = async (): Promise => { + const res = await client.execute( + txSigner, + contractAddress, + { + distribute: {}, + }, + 'auto', + ) + return res.transactionHash + } + return { + contractAddress, + updateAdmin, + distribute, + getMemberWeight, + getAdmin, + listMembers, + getGroup, + } + } + + const instantiate = async ( + codeId: number, + initMsg: Record, + label: string, + admin?: string, + ): Promise => { + const result = await client.instantiate(txSigner, codeId, initMsg, label, 'auto', { + admin, + }) + + return { + contractAddress: result.contractAddress, + transactionHash: result.transactionHash, + } + } + + const migrate = async ( + senderAddress: string, + contractAddress: string, + codeId: number, + migrateMsg: Record, + ): Promise => { + const result = await client.migrate(senderAddress, contractAddress, codeId, migrateMsg, 'auto') + return { + transactionHash: result.transactionHash, + logs: result.logs, + } + } + + const messages = (contractAddress: string) => { + const updateAdmin = (admin: string) => { + return { + sender: txSigner, + contract: contractAddress, + msg: { + update_admin: { admin }, + }, + funds: [], + } + } + + const distribute = () => { + return { + sender: txSigner, + contract: contractAddress, + msg: { + distribute: {}, + }, + funds: [], + } + } + + return { + updateAdmin, + distribute, + } + } + + return { use, instantiate, migrate, messages } +} diff --git a/contracts/splits/index.ts b/contracts/splits/index.ts new file mode 100644 index 0000000..6dc6461 --- /dev/null +++ b/contracts/splits/index.ts @@ -0,0 +1,2 @@ +export * from './contract' +export * from './useContract' diff --git a/contracts/splits/messages/execute.ts b/contracts/splits/messages/execute.ts new file mode 100644 index 0000000..9416e5e --- /dev/null +++ b/contracts/splits/messages/execute.ts @@ -0,0 +1,79 @@ +/* eslint-disable eslint-comments/disable-enable-pair */ + +import type { SplitsInstance } from '../index' +import { useSplitsContract } from '../index' + +export type ExecuteType = typeof EXECUTE_TYPES[number] + +export const EXECUTE_TYPES = ['update_admin', 'distribute'] as const + +export interface ExecuteListItem { + id: ExecuteType + name: string + description?: string +} + +export const EXECUTE_LIST: ExecuteListItem[] = [ + { + id: 'update_admin', + name: 'Update Admin', + description: `Update the splits contract admin`, + }, + { + id: 'distribute', + name: 'Distribute', + description: `Distribute the revenue to the group members`, + }, +] + +export interface DispatchExecuteProps { + type: ExecuteType + [k: string]: unknown +} + +type Select = T + +/** @see {@link SplitsInstance} */ +export type DispatchExecuteArgs = { + contract: string + messages?: SplitsInstance +} & ({ type: Select<'update_admin'>; admin: string } | { type: Select<'distribute'> | undefined }) + +export const dispatchExecute = async (args: DispatchExecuteArgs) => { + const { messages } = args + if (!messages) { + throw new Error('Cannot dispatch execute, messages are not defined') + } + switch (args.type) { + case 'update_admin': { + return messages.updateAdmin(args.admin) + } + case 'distribute': { + return messages.distribute() + } + default: { + throw new Error('Unknown execution type') + } + } +} + +export const previewExecutePayload = (args: DispatchExecuteArgs) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const { messages } = useSplitsContract() + const { contract } = args + switch (args.type) { + case 'update_admin': { + return messages(contract)?.updateAdmin(args.admin) + } + case 'distribute': { + return messages(contract)?.distribute() + } + default: { + return {} + } + } +} + +export const isEitherType = (type: unknown, arr: T[]): type is T => { + return arr.some((val) => type === val) +} diff --git a/contracts/splits/messages/query.ts b/contracts/splits/messages/query.ts new file mode 100644 index 0000000..f97f5be --- /dev/null +++ b/contracts/splits/messages/query.ts @@ -0,0 +1,43 @@ +import type { SplitsInstance } from '../contract' + +export type QueryType = typeof QUERY_TYPES[number] + +export const QUERY_TYPES = ['admin', 'group', 'member', 'list_members'] as const + +export interface QueryListItem { + id: QueryType + name: string + description?: string +} + +export const QUERY_LIST: QueryListItem[] = [ + { id: 'list_members', name: 'Query Members', description: 'View the group members' }, + { id: 'member', name: 'Query Member Weight', description: 'Query the weight of a member in the group' }, + { id: 'admin', name: 'Query Admin', description: 'View the splits contract admin' }, + { id: 'group', name: 'Query Group Contract Address', description: 'View the group contract address' }, +] + +export interface DispatchQueryProps { + messages: SplitsInstance | undefined + type: QueryType + address: string + startAfter?: string + limit?: number +} + +export const dispatchQuery = (props: DispatchQueryProps) => { + const { messages, type, address, startAfter, limit } = props + switch (type) { + case 'list_members': + return messages?.listMembers(startAfter, limit) + case 'admin': + return messages?.getAdmin() + case 'member': + return messages?.getMemberWeight(address) + case 'group': + return messages?.getGroup() + default: { + throw new Error('unknown query type') + } + } +} diff --git a/contracts/splits/useContract.ts b/contracts/splits/useContract.ts new file mode 100644 index 0000000..69a1c63 --- /dev/null +++ b/contracts/splits/useContract.ts @@ -0,0 +1,93 @@ +/* eslint-disable eslint-comments/disable-enable-pair */ + +import { useWallet } from 'contexts/wallet' +import { useCallback, useEffect, useState } from 'react' + +import type { InstantiateResponse, MigrateResponse, SplitsContract, SplitsInstance, SplitsMessages } from './contract' +import { Splits as initContract } from './contract' + +export interface UseSplitsContractProps { + instantiate: ( + codeId: number, + initMsg: Record, + label: string, + admin?: string, + ) => Promise + + migrate: (contractAddress: string, codeId: number, migrateMsg: Record) => Promise + + use: (customAddress?: string) => SplitsInstance | undefined + + updateContractAddress: (contractAddress: string) => void + + messages: (contractAddress: string) => SplitsMessages | undefined +} + +export function useSplitsContract(): UseSplitsContractProps { + const wallet = useWallet() + + const [address, setAddress] = useState('') + const [splits, setSplits] = useState() + + useEffect(() => { + setAddress(localStorage.getItem('contract_address') || '') + }, []) + + useEffect(() => { + const splitsContract = initContract(wallet.getClient(), wallet.address) + setSplits(splitsContract) + }, [wallet]) + + const updateContractAddress = (contractAddress: string) => { + setAddress(contractAddress) + } + + const instantiate = useCallback( + (codeId: number, initMsg: Record, label: string, admin?: string): Promise => { + return new Promise((resolve, reject) => { + if (!splits) { + reject(new Error('Contract is not initialized.')) + return + } + splits.instantiate(codeId, initMsg, label, admin).then(resolve).catch(reject) + }) + }, + [splits], + ) + + const migrate = useCallback( + (contractAddress: string, codeId: number, migrateMsg: Record): Promise => { + return new Promise((resolve, reject) => { + if (!splits) { + reject(new Error('Contract is not initialized.')) + return + } + console.log(wallet.address, contractAddress, codeId) + splits.migrate(wallet.address, contractAddress, codeId, migrateMsg).then(resolve).catch(reject) + }) + }, + [splits, wallet], + ) + + const use = useCallback( + (customAddress = ''): SplitsInstance | undefined => { + return splits?.use(address || customAddress) + }, + [splits, address], + ) + + const messages = useCallback( + (customAddress = ''): SplitsMessages | undefined => { + return splits?.messages(address || customAddress) + }, + [splits, address], + ) + + return { + instantiate, + migrate, + use, + updateContractAddress, + messages, + } +} diff --git a/env.d.ts b/env.d.ts index bb733fe..7640b72 100644 --- a/env.d.ts +++ b/env.d.ts @@ -25,6 +25,8 @@ declare namespace NodeJS { readonly NEXT_PUBLIC_BADGE_HUB_ADDRESS: string readonly NEXT_PUBLIC_BADGE_NFT_CODE_ID: string readonly NEXT_PUBLIC_BADGE_NFT_ADDRESS: string + readonly NEXT_PUBLIC_SPLITS_CODE_ID: string + readonly NEXT_PUBLIC_CW4_GROUP_CODE_ID: string readonly NEXT_PUBLIC_PINATA_ENDPOINT_URL: string readonly NEXT_PUBLIC_API_URL: string diff --git a/package.json b/package.json index 2a082d1..150a347 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "stargaze-studio", - "version": "0.4.8", + "version": "0.4.9", "workspaces": [ "packages/*" ], diff --git a/pages/collections/create.tsx b/pages/collections/create.tsx index 5455bdc..ed93a9c 100644 --- a/pages/collections/create.tsx +++ b/pages/collections/create.tsx @@ -408,6 +408,7 @@ const CollectionCreationPage: NextPage = () => { base_token_uri: `${uploadDetails?.uploadMethod === 'new' ? `ipfs://${baseUri}` : `${baseUri}`}`, start_time: mintingDetails?.startTime, num_tokens: mintingDetails?.numTokens, + payment_address: mintingDetails?.paymentAddress ? mintingDetails.paymentAddress.trim() : undefined, mint_price: { amount: mintingDetails?.unitPrice, denom: 'ustars', @@ -761,6 +762,12 @@ const CollectionCreationPage: NextPage = () => { ) if (mintingDetails.startTime === '') throw new Error('Start time is required') if (Number(mintingDetails.startTime) < new Date().getTime() * 1000000) throw new Error('Invalid start time') + if ( + mintingDetails.paymentAddress && + (!isValidAddress(mintingDetails.paymentAddress.trim()) || + !mintingDetails.paymentAddress.trim().startsWith('stars1')) + ) + throw new Error('Invalid payment address') } const checkWhitelistDetails = async () => { @@ -851,11 +858,19 @@ const CollectionCreationPage: NextPage = () => { if (minterType === 'vending' && whitelistDetails?.whitelistType === 'new' && whitelistDetails.memberLimit) { const amountNeeded = Math.ceil(Number(whitelistDetails.memberLimit) / 1000) * 100000000 + 3000000000 if (amountNeeded >= Number(wallet.balance[0].amount)) - throw new Error('Insufficient wallet balance to instantiate the required contracts.') + throw new Error( + `Insufficient wallet balance to instantiate the required contracts. Needed amount: ${( + amountNeeded / 1000000 + ).toString()} STARS`, + ) } else { const amountNeeded = minterType === 'vending' ? 3000000000 : 1000000000 if (amountNeeded >= Number(wallet.balance[0].amount)) - throw new Error('Insufficient wallet balance to instantiate the required contracts.') + throw new Error( + `Insufficient wallet balance to instantiate the required contracts. Needed amount: ${( + amountNeeded / 1000000 + ).toString()} STARS`, + ) } } useEffect(() => { diff --git a/pages/contracts/index.tsx b/pages/contracts/index.tsx index 3f1ef14..7ef81e4 100644 --- a/pages/contracts/index.tsx +++ b/pages/contracts/index.tsx @@ -58,6 +58,9 @@ const HomePage: NextPage = () => { Execute messages and run queries on the Badge Hub contract designed for event organizers. + + Execute messages and run queries on the Splits contract designed for revenue distribution. +
    ) diff --git a/pages/contracts/splits/execute.tsx b/pages/contracts/splits/execute.tsx new file mode 100644 index 0000000..24305e6 --- /dev/null +++ b/pages/contracts/splits/execute.tsx @@ -0,0 +1,135 @@ +import { Button } from 'components/Button' +import { Conditional } from 'components/Conditional' +import { ContractPageHeader } from 'components/ContractPageHeader' +import { ExecuteCombobox } from 'components/contracts/splits/ExecuteCombobox' +import { useExecuteComboboxState } from 'components/contracts/splits/ExecuteCombobox.hooks' +import { FormControl } from 'components/FormControl' +import { AddressInput } from 'components/forms/FormInput' +import { useInputState } from 'components/forms/FormInput.hooks' +import { JsonPreview } from 'components/JsonPreview' +import { LinkTabs } from 'components/LinkTabs' +import { splitsLinkTabs } from 'components/LinkTabs.data' +import { TransactionHash } from 'components/TransactionHash' +import { useContracts } from 'contexts/contracts' +import { useWallet } from 'contexts/wallet' +import type { DispatchExecuteArgs } from 'contracts/splits/messages/execute' +import { dispatchExecute, isEitherType, previewExecutePayload } from 'contracts/splits/messages/execute' +import type { NextPage } from 'next' +import { useRouter } from 'next/router' +import { NextSeo } from 'next-seo' +import type { FormEvent } from 'react' +import { useEffect, useMemo, useState } from 'react' +import { toast } from 'react-hot-toast' +import { FaArrowRight } from 'react-icons/fa' +import { useMutation } from 'react-query' +import { withMetadata } from 'utils/layout' +import { links } from 'utils/links' + +const SplitsExecutePage: NextPage = () => { + const { splits: contract } = useContracts() + const wallet = useWallet() + + const [lastTx, setLastTx] = useState('') + + const comboboxState = useExecuteComboboxState() + const type = comboboxState.value?.id + + const contractState = useInputState({ + id: 'contract-address', + name: 'contract-address', + title: 'Splits Address', + subtitle: 'Address of the Splits contract', + }) + const contractAddress = contractState.value + + const adminAddressState = useInputState({ + id: 'admin-address', + name: 'admin-address', + title: 'Admin Address', + subtitle: 'Address of the new administrator', + }) + + const showAdminAddress = isEitherType(type, ['update_admin']) + + const messages = useMemo(() => contract?.use(contractState.value), [contract, contractState.value]) + const payload: DispatchExecuteArgs = { + contract: contractState.value, + messages, + type, + admin: adminAddressState.value.trim(), + } + const { isLoading, mutate } = useMutation( + async (event: FormEvent) => { + event.preventDefault() + if (!type) { + throw new Error('Please select message type!') + } + if (!wallet.initialized) { + throw new Error('Please connect your wallet.') + } + const txHash = await toast.promise(dispatchExecute(payload), { + error: `${type.charAt(0).toUpperCase() + type.slice(1)} execute failed!`, + loading: 'Executing message...', + success: (tx) => `Transaction ${tx} success!`, + }) + if (txHash) { + setLastTx(txHash) + } + }, + { + onError: (error) => { + toast.error(String(error), { style: { maxWidth: 'none' } }) + }, + }, + ) + + const router = useRouter() + + useEffect(() => { + if (contractAddress.length > 0) { + void router.replace({ query: { contractAddress } }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [contractAddress]) + useEffect(() => { + const initial = new URL(document.URL).searchParams.get('contractAddress') + if (initial && initial.length > 0) contractState.onChange(initial) + }, []) + + return ( +
    + + + + +
    +
    + + + + + +
    +
    +
    + + + + +
    + + + +
    +
    +
    + ) +} + +export default withMetadata(SplitsExecutePage, { center: false }) diff --git a/pages/contracts/splits/index.tsx b/pages/contracts/splits/index.tsx new file mode 100644 index 0000000..561b4b3 --- /dev/null +++ b/pages/contracts/splits/index.tsx @@ -0,0 +1 @@ +export { default } from './instantiate' diff --git a/pages/contracts/splits/instantiate.tsx b/pages/contracts/splits/instantiate.tsx new file mode 100644 index 0000000..88dcc59 --- /dev/null +++ b/pages/contracts/splits/instantiate.tsx @@ -0,0 +1,237 @@ +import { toBase64, toUtf8 } from '@cosmjs/encoding' +import { Alert } from 'components/Alert' +import { Button } from 'components/Button' +import { Conditional } from 'components/Conditional' +import { ContractPageHeader } from 'components/ContractPageHeader' +import { AddressInput } from 'components/forms/FormInput' +import { JsonPreview } from 'components/JsonPreview' +import { LinkTabs } from 'components/LinkTabs' +import { splitsLinkTabs } from 'components/LinkTabs.data' +import { useContracts } from 'contexts/contracts' +import { useWallet } from 'contexts/wallet' +import type { InstantiateResponse } from 'contracts/sg721' +import type { NextPage } from 'next' +import { NextSeo } from 'next-seo' +import { type FormEvent, useEffect, useState } from 'react' +import { toast } from 'react-hot-toast' +import { FaAsterisk } from 'react-icons/fa' +import { useMutation } from 'react-query' +import { isValidAddress } from 'utils/isValidAddress' +import { withMetadata } from 'utils/layout' +import { links } from 'utils/links' + +import { useInputState } from '../../../components/forms/FormInput.hooks' +import type { Attribute } from '../../../components/forms/MemberAttributes' +import { MemberAttributes } from '../../../components/forms/MemberAttributes' +import { useMemberAttributesState } from '../../../components/forms/MemberAttributes.hooks' +import { CW4_GROUP_CODE_ID, SPLITS_CODE_ID } from '../../../utils/constants' +import { resolveAddress } from '../../../utils/resolveAddress' + +export type CW4Method = 'new' | 'existing' + +const SplitsInstantiatePage: NextPage = () => { + const wallet = useWallet() + const { splits: contract } = useContracts() + const [members, setMembers] = useState([]) + const [cw4Method, setCw4Method] = useState('new') + + const cw4GroupAddressState = useInputState({ + id: 'cw4-group-address', + name: 'cw4-group-address', + title: 'CW4 Group Address', + subtitle: 'Address of the CW4 Group contract', + placeholder: 'stars1...', + }) + + const splitsAdminState = useInputState({ + id: 'splits-admin', + name: 'splits-admin', + title: 'Splits Contract Admin', + subtitle: 'Address of the Splits Contract administrator', + defaultValue: wallet.address, + }) + + const cw4GroupAdminState = useInputState({ + id: 'cw4-group-admin', + name: 'cw4-group-admin', + title: 'CW4 Group Admin', + subtitle: 'Address of the CW4 Group administrator', + defaultValue: wallet.address, + }) + + const memberListState = useMemberAttributesState() + + useEffect(() => { + memberListState.reset() + memberListState.add({ + address: '', + weight: 0, + }) + }, []) + + const { data, isLoading, mutate } = useMutation( + async (event: FormEvent): Promise => { + event.preventDefault() + if (!contract) { + throw new Error('Smart contract connection failed') + } + const msg = + cw4Method === 'existing' + ? { + admin: splitsAdminState.value ? splitsAdminState.value : undefined, + group: { cw4_address: cw4GroupAddressState.value }, + } + : { + admin: splitsAdminState.value ? splitsAdminState.value : undefined, + group: { + cw4_instantiate: { + code_id: CW4_GROUP_CODE_ID, + label: 'cw4-group', + msg: toBase64( + toUtf8( + JSON.stringify({ + admin: cw4GroupAdminState.value ? cw4GroupAdminState.value : undefined, + members: [ + ...new Set( + members + .filter( + (member) => + member.address !== '' && + member.weight > 0 && + isValidAddress(member.address) && + member.address.startsWith('stars'), + ) + .map((member) => ({ addr: member.address, weight: member.weight })), + ), + ], + }), + ), + ), + }, + }, + } + return toast.promise(contract.instantiate(SPLITS_CODE_ID, msg, 'Stargaze Splits Contract', wallet.address), { + loading: 'Instantiating contract...', + error: 'Instantiation failed!', + success: 'Instantiation success!', + }) + }, + { + onError: (error) => { + toast.error(String(error), { style: { maxWidth: 'none' } }) + }, + }, + ) + + const resolveMemberAddresses = () => { + const tempMembers: Attribute[] = [] + memberListState.values.map(async (member) => { + await resolveAddress(member.address.trim(), wallet).then((resolvedAddress) => { + tempMembers.push({ address: resolvedAddress, weight: member.weight }) + }) + }) + setMembers(tempMembers) + console.log('Members:', members) + } + + useEffect(() => { + resolveMemberAddresses() + }, [memberListState.values]) + + return ( +
    + + + + + + + Instantiate success! Here is the transaction result containing the contract address and the transaction + hash. + + +
    +
    + +
    +
    +
    + { + setCw4Method('new') + }} + type="radio" + value="New" + /> + +
    +
    + { + setCw4Method('existing') + }} + type="radio" + value="Existing" + /> + +
    +
    +
    +
    +
    + + + + + + + +
    +
    + +
    + +
    +
    +
    +
    + +
    +
    + +
    + + ) +} + +export default withMetadata(SplitsInstantiatePage, { center: false }) diff --git a/pages/contracts/splits/migrate.tsx b/pages/contracts/splits/migrate.tsx new file mode 100644 index 0000000..c03a3a1 --- /dev/null +++ b/pages/contracts/splits/migrate.tsx @@ -0,0 +1,133 @@ +import { Button } from 'components/Button' +import { ContractPageHeader } from 'components/ContractPageHeader' +import { useExecuteComboboxState } from 'components/contracts/splits/ExecuteCombobox.hooks' +import { FormControl } from 'components/FormControl' +import { AddressInput, NumberInput } from 'components/forms/FormInput' +import { useInputState, useNumberInputState } from 'components/forms/FormInput.hooks' +import { JsonPreview } from 'components/JsonPreview' +import { LinkTabs } from 'components/LinkTabs' +import { splitsLinkTabs } from 'components/LinkTabs.data' +import { TransactionHash } from 'components/TransactionHash' +import { useContracts } from 'contexts/contracts' +import { useWallet } from 'contexts/wallet' +import type { MigrateResponse } from 'contracts/splits' +import type { NextPage } from 'next' +import { useRouter } from 'next/router' +import { NextSeo } from 'next-seo' +import type { FormEvent } from 'react' +import { useEffect, useState } from 'react' +import { toast } from 'react-hot-toast' +import { FaArrowRight } from 'react-icons/fa' +import { useMutation } from 'react-query' +import { withMetadata } from 'utils/layout' +import { links } from 'utils/links' + +const SplitsMigratePage: NextPage = () => { + const { splits: contract } = useContracts() + const wallet = useWallet() + + const [lastTx, setLastTx] = useState('') + + const comboboxState = useExecuteComboboxState() + const type = comboboxState.value?.id + const codeIdState = useNumberInputState({ + id: 'code-id', + name: 'code-id', + title: 'Code ID', + subtitle: 'Code ID of the New Splits contract', + placeholder: '1', + }) + + const contractState = useInputState({ + id: 'contract-address', + name: 'contract-address', + title: 'Splits Address', + subtitle: 'Address of the Splits contract', + }) + const contractAddress = contractState.value + + const { data, isLoading, mutate } = useMutation( + async (event: FormEvent): Promise => { + event.preventDefault() + if (!contract) { + throw new Error('Smart contract connection failed') + } + if (!wallet.initialized) { + throw new Error('Please connect your wallet.') + } + + const migrateMsg = {} + + return toast.promise(contract.migrate(contractAddress, codeIdState.value, migrateMsg), { + error: `Migration failed!`, + loading: 'Executing message...', + success: (tx) => { + if (tx) { + setLastTx(tx.transactionHash) + } + return `Transaction success!` + }, + }) + }, + { + onError: (error) => { + toast.error(String(error), { style: { maxWidth: 'none' } }) + }, + }, + ) + + const router = useRouter() + + useEffect(() => { + if (contractAddress.length > 0) { + void router.replace({ query: { contractAddress } }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [contractAddress]) + useEffect(() => { + const initial = new URL(document.URL).searchParams.get('contractAddress') + if (initial && initial.length > 0) contractState.onChange(initial) + }, []) + + return ( +
    + + + + +
    +
    + + +
    +
    +
    + + + + +
    + + + +
    +
    +
    + ) +} + +export default withMetadata(SplitsMigratePage, { center: false }) diff --git a/pages/contracts/splits/query.tsx b/pages/contracts/splits/query.tsx new file mode 100644 index 0000000..b43a418 --- /dev/null +++ b/pages/contracts/splits/query.tsx @@ -0,0 +1,156 @@ +import clsx from 'clsx' +import { Conditional } from 'components/Conditional' +import { ContractPageHeader } from 'components/ContractPageHeader' +import { FormControl } from 'components/FormControl' +import { AddressInput, NumberInput, TextInput } from 'components/forms/FormInput' +import { useInputState, useNumberInputState } from 'components/forms/FormInput.hooks' +import { JsonPreview } from 'components/JsonPreview' +import { LinkTabs } from 'components/LinkTabs' +import { splitsLinkTabs } from 'components/LinkTabs.data' +import { useContracts } from 'contexts/contracts' +import { useWallet } from 'contexts/wallet' +import type { QueryType } from 'contracts/splits/messages/query' +import { dispatchQuery, QUERY_LIST } from 'contracts/splits/messages/query' +import type { NextPage } from 'next' +import { useRouter } from 'next/router' +import { NextSeo } from 'next-seo' +import { useEffect, useState } from 'react' +import { toast } from 'react-hot-toast' +import { useQuery } from 'react-query' +import { withMetadata } from 'utils/layout' +import { links } from 'utils/links' +import { resolveAddress } from 'utils/resolveAddress' + +const SplitsQueryPage: NextPage = () => { + const { splits: contract } = useContracts() + const wallet = useWallet() + + const contractState = useInputState({ + id: 'contract-address', + name: 'contract-address', + title: 'Splits Address', + subtitle: 'Address of the Splits contract', + }) + const contractAddress = contractState.value + + const memberAddressState = useInputState({ + id: 'member-address', + name: 'member-address', + title: 'Member Address', + subtitle: 'Member address to query the weight for', + }) + + const memberAddress = memberAddressState.value + + const startAfterStringState = useInputState({ + id: 'start-after-string', + name: 'start-after-string', + title: 'Start After (optional)', + subtitle: 'The member address to start the pagination after', + }) + + const paginationLimitState = useNumberInputState({ + id: 'pagination-limit', + name: 'pagination-limit', + title: 'Pagination Limit (optional)', + subtitle: 'The number of items to return (max: 30)', + defaultValue: 5, + }) + + const [type, setType] = useState('list_members') + + const { data: response } = useQuery( + [ + contractAddress, + type, + contract, + wallet, + memberAddress, + startAfterStringState.value, + paginationLimitState.value, + ] as const, + async ({ queryKey }) => { + const [_contractAddress, _type, _contract, _wallet, _memberAddress, startAfter, limit] = queryKey + const messages = contract?.use(contractAddress) + const res = await resolveAddress(_memberAddress, wallet).then(async (resolvedAddress) => { + const result = await dispatchQuery({ + messages, + type, + address: resolvedAddress, + startAfter: startAfter.length > 0 ? startAfter : undefined, + limit: limit > 0 ? limit : undefined, + }) + return result + }) + return res + }, + { + placeholderData: null, + onError: (error: any) => { + toast.error(error.message, { style: { maxWidth: 'none' } }) + }, + enabled: Boolean(contractAddress && contract && wallet), + }, + ) + + const router = useRouter() + + useEffect(() => { + if (contractAddress.length > 0) { + void router.replace({ query: { contractAddress } }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [contractAddress]) + useEffect(() => { + const initial = new URL(document.URL).searchParams.get('contractAddress') + if (initial && initial.length > 0) contractState.onChange(initial) + }, []) + + return ( +
    + + + + +
    +
    + + + + + + + + + + + + +
    + +
    +
    + ) +} + +export default withMetadata(SplitsQueryPage, { center: false }) diff --git a/utils/constants.ts b/utils/constants.ts index 9966325..07157a5 100644 --- a/utils/constants.ts +++ b/utils/constants.ts @@ -9,6 +9,8 @@ export const BADGE_HUB_CODE_ID = parseInt(process.env.NEXT_PUBLIC_BADGE_HUB_CODE export const BADGE_HUB_ADDRESS = process.env.NEXT_PUBLIC_BADGE_HUB_ADDRESS export const BADGE_NFT_CODE_ID = parseInt(process.env.NEXT_PUBLIC_BADGE_NFT_CODE_ID, 10) export const BADGE_NFT_ADDRESS = process.env.NEXT_PUBLIC_BADGE_NFT_ADDRESS +export const SPLITS_CODE_ID = parseInt(process.env.NEXT_PUBLIC_SPLITS_CODE_ID, 10) +export const CW4_GROUP_CODE_ID = parseInt(process.env.NEXT_PUBLIC_CW4_GROUP_CODE_ID, 10) export const PINATA_ENDPOINT_URL = process.env.NEXT_PUBLIC_PINATA_ENDPOINT_URL export const NETWORK = process.env.NEXT_PUBLIC_NETWORK