+
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 (
+
+ )
+}
+
+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