diff --git a/.env.example b/.env.example index 6219d01..dfc15fa 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -APP_VERSION=0.4.4 +APP_VERSION=0.4.5 NEXT_PUBLIC_PINATA_ENDPOINT_URL=https://api.pinata.cloud/pinning/pinFileToIPFS NEXT_PUBLIC_SG721_CODE_ID=793 @@ -8,10 +8,15 @@ NEXT_PUBLIC_BASE_FACTORY_ADDRESS="stars1c6juqgd7cm80afpmuszun66rl9zdc4kgfht8fk34 NEXT_PUBLIC_SG721_NAME_ADDRESS="stars1fx74nkqkw2748av8j7ew7r3xt9cgjqduwn8m0ur5lhe49uhlsasszc5fhr" NEXT_PUBLIC_BASE_MINTER_CODE_ID=613 NEXT_PUBLIC_WHITELIST_CODE_ID=277 +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_API_URL=https://nft-api.elgafar-1.stargaze-apis.com NEXT_PUBLIC_BLOCK_EXPLORER_URL=https://testnet-explorer.publicawesome.dev/stargaze NEXT_PUBLIC_NETWORK=testnet NEXT_PUBLIC_STARGAZE_WEBSITE_URL=https://testnet.publicawesome.dev +NEXT_PUBLIC_BADGES_URL=https://badges.publicawesome.dev NEXT_PUBLIC_WEBSITE_URL=https:// \ No newline at end of file diff --git a/components/BadgeAirdropListUpload.tsx b/components/BadgeAirdropListUpload.tsx new file mode 100644 index 0000000..4a035de --- /dev/null +++ b/components/BadgeAirdropListUpload.tsx @@ -0,0 +1,126 @@ +/* eslint-disable eslint-comments/disable-enable-pair */ +/* eslint-disable no-misleading-character-class */ +/* eslint-disable no-control-regex */ +import { toUtf8 } from '@cosmjs/encoding' +import clsx from 'clsx' +import React, { useState } from 'react' +import { toast } from 'react-hot-toast' + +import { useWallet } from '../contexts/wallet' +import { SG721_NAME_ADDRESS } from '../utils/constants' +import { isValidAddress } from '../utils/isValidAddress' + +interface BadgeAirdropListUploadProps { + onChange: (data: string[]) => void +} + +export const BadgeAirdropListUpload = ({ onChange }: BadgeAirdropListUploadProps) => { + const wallet = useWallet() + const [resolvedAddresses, setResolvedAddresses] = useState([]) + + const resolveAddresses = async (names: string[]) => { + await new Promise((resolve) => { + let i = 0 + names.map(async (name) => { + if (!wallet.client) throw new Error('Wallet not connected') + await wallet.client + .queryContractRaw( + SG721_NAME_ADDRESS, + toUtf8( + Buffer.from( + `0006${Buffer.from('tokens').toString('hex')}${Buffer.from(name).toString('hex')}`, + 'hex', + ).toString(), + ), + ) + .then((res) => { + const tokenUri = JSON.parse(new TextDecoder().decode(res as Uint8Array)).token_uri + if (tokenUri && isValidAddress(tokenUri)) resolvedAddresses.push(tokenUri) + else toast.error(`Resolved address is empty or invalid for the name: ${name}.stars`) + }) + .catch((e) => { + console.log(e) + toast.error(`Error resolving address for the name: ${name}.stars`) + }) + + i++ + if (i === names.length) resolve(resolvedAddresses) + }) + }) + return resolvedAddresses + } + + const onFileChange = (event: React.ChangeEvent) => { + setResolvedAddresses([]) + if (!event.target.files) return toast.error('Error opening file') + if (event.target.files.length !== 1) { + toast.error('No file selected') + return onChange([]) + } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (event.target.files[0]?.type !== 'text/plain') { + toast.error('Invalid file type') + return onChange([]) + } + const reader = new FileReader() + reader.onload = async (e: ProgressEvent) => { + const text = e.target?.result?.toString() + let newline = '\n' + if (text?.includes('\r')) newline = '\r' + if (text?.includes('\r\n')) newline = '\r\n' + + const cleanText = text?.toLowerCase().replace(/,/g, '').replace(/"/g, '').replace(/'/g, '').replace(/ /g, '') + const data = cleanText?.split(newline) + const regex = + /[\0-\x1F\x7F-\x9F\xAD\u0378\u0379\u037F-\u0383\u038B\u038D\u03A2\u0528-\u0530\u0557\u0558\u0560\u0588\u058B-\u058E\u0590\u05C8-\u05CF\u05EB-\u05EF\u05F5-\u0605\u061C\u061D\u06DD\u070E\u070F\u074B\u074C\u07B2-\u07BF\u07FB-\u07FF\u082E\u082F\u083F\u085C\u085D\u085F-\u089F\u08A1\u08AD-\u08E3\u08FF\u0978\u0980\u0984\u098D\u098E\u0991\u0992\u09A9\u09B1\u09B3-\u09B5\u09BA\u09BB\u09C5\u09C6\u09C9\u09CA\u09CF-\u09D6\u09D8-\u09DB\u09DE\u09E4\u09E5\u09FC-\u0A00\u0A04\u0A0B-\u0A0E\u0A11\u0A12\u0A29\u0A31\u0A34\u0A37\u0A3A\u0A3B\u0A3D\u0A43-\u0A46\u0A49\u0A4A\u0A4E-\u0A50\u0A52-\u0A58\u0A5D\u0A5F-\u0A65\u0A76-\u0A80\u0A84\u0A8E\u0A92\u0AA9\u0AB1\u0AB4\u0ABA\u0ABB\u0AC6\u0ACA\u0ACE\u0ACF\u0AD1-\u0ADF\u0AE4\u0AE5\u0AF2-\u0B00\u0B04\u0B0D\u0B0E\u0B11\u0B12\u0B29\u0B31\u0B34\u0B3A\u0B3B\u0B45\u0B46\u0B49\u0B4A\u0B4E-\u0B55\u0B58-\u0B5B\u0B5E\u0B64\u0B65\u0B78-\u0B81\u0B84\u0B8B-\u0B8D\u0B91\u0B96-\u0B98\u0B9B\u0B9D\u0BA0-\u0BA2\u0BA5-\u0BA7\u0BAB-\u0BAD\u0BBA-\u0BBD\u0BC3-\u0BC5\u0BC9\u0BCE\u0BCF\u0BD1-\u0BD6\u0BD8-\u0BE5\u0BFB-\u0C00\u0C04\u0C0D\u0C11\u0C29\u0C34\u0C3A-\u0C3C\u0C45\u0C49\u0C4E-\u0C54\u0C57\u0C5A-\u0C5F\u0C64\u0C65\u0C70-\u0C77\u0C80\u0C81\u0C84\u0C8D\u0C91\u0CA9\u0CB4\u0CBA\u0CBB\u0CC5\u0CC9\u0CCE-\u0CD4\u0CD7-\u0CDD\u0CDF\u0CE4\u0CE5\u0CF0\u0CF3-\u0D01\u0D04\u0D0D\u0D11\u0D3B\u0D3C\u0D45\u0D49\u0D4F-\u0D56\u0D58-\u0D5F\u0D64\u0D65\u0D76-\u0D78\u0D80\u0D81\u0D84\u0D97-\u0D99\u0DB2\u0DBC\u0DBE\u0DBF\u0DC7-\u0DC9\u0DCB-\u0DCE\u0DD5\u0DD7\u0DE0-\u0DF1\u0DF5-\u0E00\u0E3B-\u0E3E\u0E5C-\u0E80\u0E83\u0E85\u0E86\u0E89\u0E8B\u0E8C\u0E8E-\u0E93\u0E98\u0EA0\u0EA4\u0EA6\u0EA8\u0EA9\u0EAC\u0EBA\u0EBE\u0EBF\u0EC5\u0EC7\u0ECE\u0ECF\u0EDA\u0EDB\u0EE0-\u0EFF\u0F48\u0F6D-\u0F70\u0F98\u0FBD\u0FCD\u0FDB-\u0FFF\u10C6\u10C8-\u10CC\u10CE\u10CF\u1249\u124E\u124F\u1257\u1259\u125E\u125F\u1289\u128E\u128F\u12B1\u12B6\u12B7\u12BF\u12C1\u12C6\u12C7\u12D7\u1311\u1316\u1317\u135B\u135C\u137D-\u137F\u139A-\u139F\u13F5-\u13FF\u169D-\u169F\u16F1-\u16FF\u170D\u1715-\u171F\u1737-\u173F\u1754-\u175F\u176D\u1771\u1774-\u177F\u17DE\u17DF\u17EA-\u17EF\u17FA-\u17FF\u180F\u181A-\u181F\u1878-\u187F\u18AB-\u18AF\u18F6-\u18FF\u191D-\u191F\u192C-\u192F\u193C-\u193F\u1941-\u1943\u196E\u196F\u1975-\u197F\u19AC-\u19AF\u19CA-\u19CF\u19DB-\u19DD\u1A1C\u1A1D\u1A5F\u1A7D\u1A7E\u1A8A-\u1A8F\u1A9A-\u1A9F\u1AAE-\u1AFF\u1B4C-\u1B4F\u1B7D-\u1B7F\u1BF4-\u1BFB\u1C38-\u1C3A\u1C4A-\u1C4C\u1C80-\u1CBF\u1CC8-\u1CCF\u1CF7-\u1CFF\u1DE7-\u1DFB\u1F16\u1F17\u1F1E\u1F1F\u1F46\u1F47\u1F4E\u1F4F\u1F58\u1F5A\u1F5C\u1F5E\u1F7E\u1F7F\u1FB5\u1FC5\u1FD4\u1FD5\u1FDC\u1FF0\u1FF1\u1FF5\u1FFF\u200B-\u200F\u2020-\u202E\u2060-\u206F\u2072\u2073\u208F\u209D-\u209F\u20BB-\u20CF\u20F1-\u20FF\u218A-\u218F\u23F4-\u23FF\u2427-\u243F\u244B-\u245F\u2700\u2B4D-\u2B4F\u2B5A-\u2BFF\u2C2F\u2C5F\u2CF4-\u2CF8\u2D26\u2D28-\u2D2C\u2D2E\u2D2F\u2D68-\u2D6E\u2D71-\u2D7E\u2D97-\u2D9F\u2DA7\u2DAF\u2DB7\u2DBF\u2DC7\u2DCF\u2DD7\u2DDF\u2E3C-\u2E7F\u2E9A\u2EF4-\u2EFF\u2FD6-\u2FEF\u2FFC-\u2FFF\u3040\u3097\u3098\u3100-\u3104\u312E-\u3130\u318F\u31BB-\u31BF\u31E4-\u31EF\u321F\u32FF\u4DB6-\u4DBF\u9FCD-\u9FFF\uA48D-\uA48F\uA4C7-\uA4CF\uA62C-\uA63F\uA698-\uA69E\uA6F8-\uA6FF\uA78F\uA794-\uA79F\uA7AB-\uA7F7\uA82C-\uA82F\uA83A-\uA83F\uA878-\uA87F\uA8C5-\uA8CD\uA8DA-\uA8DF\uA8FC-\uA8FF\uA954-\uA95E\uA97D-\uA97F\uA9CE\uA9DA-\uA9DD\uA9E0-\uA9FF\uAA37-\uAA3F\uAA4E\uAA4F\uAA5A\uAA5B\uAA7C-\uAA7F\uAAC3-\uAADA\uAAF7-\uAB00\uAB07\uAB08\uAB0F\uAB10\uAB17-\uAB1F\uAB27\uAB2F-\uABBF\uABEE\uABEF\uABFA-\uABFF\uD7A4-\uD7AF\uD7C7-\uD7CA\uD7FC-\uF8FF\uFA6E\uFA6F\uFADA-\uFAFF\uFB07-\uFB12\uFB18-\uFB1C\uFB37\uFB3D\uFB3F\uFB42\uFB45\uFBC2-\uFBD2\uFD40-\uFD4F\uFD90\uFD91\uFDC8-\uFDEF\uFDFE\uFDFF\uFE1A-\uFE1F\uFE27-\uFE2F\uFE53\uFE67\uFE6C-\uFE6F\uFE75\uFEFD-\uFF00\uFFBF-\uFFC1\uFFC8\uFFC9\uFFD0\uFFD1\uFFD8\uFFD9\uFFDD-\uFFDF\uFFE7\uFFEF-\uFFFB\uFFFE\uFFFF]/g + const printableData = data?.map((item) => item.replace(regex, '')) + const names = printableData?.filter((address) => address !== '' && address.endsWith('.stars')) + const strippedNames = names?.map((name) => name.split('.')[0]) + console.log(names) + if (strippedNames?.length) { + await toast + .promise(resolveAddresses(strippedNames), { + loading: 'Resolving addresses...', + success: 'Address resolution finalized.', + error: 'Address resolution failed!', + }) + .then((addresses) => { + console.log(addresses) + }) + .catch((error) => { + console.log(error) + }) + } + + return onChange([ + ...new Set( + printableData + ?.filter((address) => address !== '' && isValidAddress(address) && address.startsWith('stars')) + .concat(resolvedAddresses) || [], + ), + ]) + } + reader.readAsText(event.target.files[0]) + } + + return ( +
+ +
+ ) +} diff --git a/components/BadgeConfirmationModal.tsx b/components/BadgeConfirmationModal.tsx new file mode 100644 index 0000000..1bc6489 --- /dev/null +++ b/components/BadgeConfirmationModal.tsx @@ -0,0 +1,67 @@ +import { useState } from 'react' + +import { Button } from './Button' + +export interface BadgeConfirmationModalProps { + confirm: () => void +} +export const BadgeConfirmationModal = (props: BadgeConfirmationModalProps) => { + const [isChecked, setIsChecked] = useState(false) + return ( +
+ + +
+ ) +} diff --git a/components/BadgeLoadingModal.tsx b/components/BadgeLoadingModal.tsx new file mode 100644 index 0000000..6c295b7 --- /dev/null +++ b/components/BadgeLoadingModal.tsx @@ -0,0 +1,11 @@ +export const BadgeLoadingModal = () => { + return ( +
+ Pixel Logo +

Uploading the image for badge creation, please wait...

+
+ ) +} diff --git a/components/IncomeDashboardDisclaimer.tsx b/components/IncomeDashboardDisclaimer.tsx index b2a5c91..79ea02f 100644 --- a/components/IncomeDashboardDisclaimer.tsx +++ b/components/IncomeDashboardDisclaimer.tsx @@ -53,9 +53,9 @@ export const IncomeDashboardDisclaimer = (props: IncomeDashboardDisclaimerProps) Are you sure to proceed to the Creator Income Dashboard?
- - ) : null, - )} + + Collections + +
+
    +
  • + Create a Collection +
  • +
  • + My Collections +
  • +
  • + Collection Actions +
  • + +
  • + +
  • +
    +
+ + + +
    +
  • + + Badges + +
      +
    • + Create a Badge +
    • +
    • + My Badges +
    • +
    • + Badge Actions +
    • +
    +
  • +
+
+
    +
  • + + Contract Dashboards + +
      + +
    • + Base Minter Contract +
    • +
      +
    • + Vending Minter Contract +
    • +
    • + SG721 Contract +
    • +
    • + Whitelist Contract +
    • + +
    • + Badge Hub Contract +
    • +
      +
    +
  • +
+ diff --git a/components/SidebarLayout.tsx b/components/SidebarLayout.tsx index 412d6b2..be89982 100644 --- a/components/SidebarLayout.tsx +++ b/components/SidebarLayout.tsx @@ -15,7 +15,7 @@ export const SidebarLayout = ({ children }: SidebarLayoutProps) => { {/* fixed component */}
void + updateMetadataFileIndex?: (index: number) => void children?: ReactNode } diff --git a/components/WalletLoader.tsx b/components/WalletLoader.tsx index 0c21bc0..57ba343 100644 --- a/components/WalletLoader.tsx +++ b/components/WalletLoader.tsx @@ -16,7 +16,7 @@ export const WalletLoader = () => { const displayName = useWalletStore((store) => store.name || getShortAddress(store.address)) return ( - + {({ close }) => ( <>
@@ -44,7 +44,7 @@ export const WalletLoader = () => { > { + const wallet = useWallet() + const [lastTx, setLastTx] = useState('') + + const [timestamp, setTimestamp] = useState(undefined) + const [airdropAllocationArray, setAirdropAllocationArray] = useState([]) + const [badge, setBadge] = useState() + const [transferrable, setTransferrable] = useState(undefined) + const [resolvedOwnerAddress, setResolvedOwnerAddress] = useState('') + const [editFee, setEditFee] = useState(undefined) + const [triggerDispatch, setTriggerDispatch] = useState(false) + const [keyPairs, setKeyPairs] = useState([]) + const [signature, setSignature] = useState('') + + const actionComboboxState = useActionsComboboxState() + const type = actionComboboxState.value?.id + + const maxSupplyState = useNumberInputState({ + id: 'max-supply', + name: 'max-supply', + title: 'Max Supply', + subtitle: 'Maximum number of badges that can be minted', + }) + + // Metadata related fields + const managerState = useInputState({ + id: 'manager-address', + name: 'manager', + title: 'Manager', + subtitle: 'Badge Hub Manager', + defaultValue: wallet.address, + }) + + const nameState = useInputState({ + id: 'metadata-name', + name: 'metadata-name', + title: 'Name', + subtitle: 'Name of the badge', + }) + + const descriptionState = useInputState({ + id: 'metadata-description', + name: 'metadata-description', + title: 'Description', + subtitle: 'Description of the badge', + }) + + const imageState = useInputState({ + id: 'metadata-image', + name: 'metadata-image', + title: 'Image', + subtitle: 'Badge Image URL', + }) + + const imageDataState = useInputState({ + id: 'metadata-image-data', + name: 'metadata-image-data', + title: 'Image Data', + subtitle: 'Raw SVG image data', + }) + + const externalUrlState = useInputState({ + id: 'metadata-external-url', + name: 'metadata-external-url', + title: 'External URL', + subtitle: 'External URL for the badge', + }) + + const attributesState = useMetadataAttributesState() + + const backgroundColorState = useInputState({ + id: 'metadata-background-color', + name: 'metadata-background-color', + title: 'Background Color', + subtitle: 'Background color of the badge', + }) + + const animationUrlState = useInputState({ + id: 'metadata-animation-url', + name: 'metadata-animation-url', + title: 'Animation URL', + subtitle: 'Animation URL for the badge', + }) + + const youtubeUrlState = useInputState({ + id: 'metadata-youtube-url', + name: 'metadata-youtube-url', + title: 'YouTube URL', + subtitle: 'YouTube URL for the badge', + }) + // Rules related fields + const keyState = useInputState({ + id: 'key', + name: 'key', + title: 'Key', + subtitle: 'The key generated for the badge', + }) + + const ownerState = useInputState({ + id: 'owner-address', + name: 'owner', + title: 'Owner', + subtitle: 'The owner of the badge', + defaultValue: wallet.address, + }) + + const pubkeyState = useInputState({ + id: 'pubkey', + name: 'pubkey', + title: 'Pubkey', + subtitle: 'The public key to check whether it can be used to mint a badge', + }) + + const privateKeyState = useInputState({ + id: 'privateKey', + name: 'privateKey', + title: 'Private Key', + subtitle: 'The private key that was generated during badge creation', + }) + + const nftState = useInputState({ + id: 'nft-address', + name: 'nft-address', + title: 'NFT Contract Address', + subtitle: 'The NFT Contract Address for the badge', + }) + + const limitState = useNumberInputState({ + id: 'limit', + name: 'limit', + title: 'Limit', + subtitle: 'Number of keys/owners to execute the action for', + }) + + const showMetadataField = isEitherType(type, ['edit_badge']) + const showOwnerField = isEitherType(type, ['mint_by_key', 'mint_by_keys']) + const showPrivateKeyField = isEitherType(type, ['mint_by_key', 'mint_by_keys', 'airdrop_by_key']) + const showAirdropFileField = isEitherType(type, ['airdrop_by_key']) + + const payload: DispatchExecuteArgs = { + badge: { + manager: badge?.manager || managerState.value, + metadata: { + name: nameState.value || undefined, + description: descriptionState.value || undefined, + image: imageState.value || undefined, + image_data: imageDataState.value || undefined, + external_url: externalUrlState.value || undefined, + attributes: + attributesState.values[0]?.trait_type && attributesState.values[0]?.value + ? attributesState.values + .map((attr) => ({ + trait_type: attr.trait_type, + value: attr.value, + })) + .filter((attr) => attr.trait_type && attr.value) + : undefined, + background_color: backgroundColorState.value || undefined, + animation_url: animationUrlState.value || undefined, + youtube_url: youtubeUrlState.value || undefined, + }, + transferrable: transferrable === true, + rule: { + by_key: keyState.value, + }, + expiry: timestamp ? timestamp.getTime() * 1000000 : undefined, + max_supply: maxSupplyState.value || undefined, + }, + metadata: { + name: nameState.value || undefined, + description: descriptionState.value || undefined, + image: imageState.value || undefined, + image_data: imageDataState.value || undefined, + external_url: externalUrlState.value || undefined, + attributes: + attributesState.values[0]?.trait_type && attributesState.values[0]?.value + ? attributesState.values + .map((attr) => ({ + trait_type: attr.trait_type, + value: attr.value, + })) + .filter((attr) => attr.trait_type && attr.value) + : undefined, + background_color: backgroundColorState.value || undefined, + animation_url: animationUrlState.value || undefined, + youtube_url: youtubeUrlState.value || undefined, + }, + id: badgeId, + editFee, + owner: resolvedOwnerAddress, + pubkey: pubkeyState.value, + signature, + keys: [], + limit: limitState.value, + owners: [], + recipients: airdropAllocationArray, + privateKey: privateKeyState.value, + nft: nftState.value, + badgeHubMessages, + badgeHubContract: badgeHubContractAddress, + txSigner: wallet.address, + type, + } + + const resolveOwnerAddress = async () => { + await resolveAddress(ownerState.value.trim(), wallet).then((resolvedAddress) => { + setResolvedOwnerAddress(resolvedAddress) + }) + } + useEffect(() => { + void resolveOwnerAddress() + }, [ownerState.value]) + + const resolveManagerAddress = async () => { + await resolveAddress(managerState.value.trim(), wallet).then((resolvedAddress) => { + setBadge({ + manager: resolvedAddress, + metadata: { + name: nameState.value || undefined, + description: descriptionState.value || undefined, + image: imageState.value || undefined, + image_data: imageDataState.value || undefined, + external_url: externalUrlState.value || undefined, + attributes: + attributesState.values[0]?.trait_type && attributesState.values[0]?.value + ? attributesState.values + .map((attr) => ({ + trait_type: attr.trait_type, + value: attr.value, + })) + .filter((attr) => attr.trait_type && attr.value) + : undefined, + background_color: backgroundColorState.value || undefined, + animation_url: animationUrlState.value || undefined, + youtube_url: youtubeUrlState.value || undefined, + }, + transferrable: transferrable === true, + rule: { + by_key: keyState.value, + }, + expiry: timestamp ? timestamp.getTime() * 1000000 : undefined, + max_supply: maxSupplyState.value || undefined, + }) + }) + } + + useEffect(() => { + void resolveManagerAddress() + }, [managerState.value]) + + useEffect(() => { + setBadge({ + manager: managerState.value, + metadata: { + name: nameState.value || undefined, + description: descriptionState.value || undefined, + image: imageState.value || undefined, + image_data: imageDataState.value || undefined, + external_url: externalUrlState.value || undefined, + attributes: + attributesState.values[0]?.trait_type && attributesState.values[0]?.value + ? attributesState.values + .map((attr) => ({ + trait_type: attr.trait_type, + value: attr.value, + })) + .filter((attr) => attr.trait_type && attr.value) + : undefined, + background_color: backgroundColorState.value || undefined, + animation_url: animationUrlState.value || undefined, + youtube_url: youtubeUrlState.value || undefined, + }, + transferrable: transferrable === true, + rule: { + by_key: keyState.value, + }, + expiry: timestamp ? timestamp.getTime() * 1000000 : undefined, + max_supply: maxSupplyState.value || undefined, + }) + }, [ + nameState.value, + descriptionState.value, + imageState.value, + imageDataState.value, + externalUrlState.value, + attributesState.values, + backgroundColorState.value, + animationUrlState.value, + youtubeUrlState.value, + transferrable, + keyState.value, + timestamp, + maxSupplyState.value, + ]) + + useEffect(() => { + if (attributesState.values.length === 0) + attributesState.add({ + trait_type: '', + value: '', + }) + }, []) + + useEffect(() => { + void dispatchEditBadgeMessage().catch((err) => { + toast.error(String(err), { style: { maxWidth: 'none' } }) + }) + }, [triggerDispatch]) + + useEffect(() => { + if (privateKeyState.value.length === 64 && resolvedOwnerAddress) + handleGenerateSignature(badgeId, resolvedOwnerAddress, privateKeyState.value) + }, [privateKeyState.value, resolvedOwnerAddress]) + + const { isLoading, mutate } = useMutation( + async (event: FormEvent) => { + if (!wallet.client) { + throw new Error('Please connect your wallet.') + } + event.preventDefault() + if (!type) { + throw new Error('Please select an action.') + } + if (badgeHubContractAddress === '') { + throw new Error('Please enter the Badge Hub contract addresses.') + } + + if (type === 'mint_by_key' && privateKeyState.value.length !== 64) { + throw new Error('Please enter a valid private key.') + } + + if (wallet.client && type === 'edit_badge') { + const feeRateRaw = await wallet.client.queryContractRaw( + badgeHubContractAddress, + toUtf8(Buffer.from(Buffer.from('fee_rate').toString('hex'), 'hex').toString()), + ) + const feeRate = JSON.parse(new TextDecoder().decode(feeRateRaw as Uint8Array)) + + await toast + .promise( + wallet.client.queryContractSmart(badgeHubContractAddress, { + badge: { id: badgeId }, + }), + { + error: `Edit Fee calculation failed!`, + loading: 'Calculating Edit Fee...', + success: (currentBadge) => { + console.log('Current badge: ', currentBadge) + return `Current metadata is ${ + Number(sizeof(currentBadge.metadata)) + Number(sizeof(currentBadge.metadata.attributes)) + } bytes in size.` + }, + }, + ) + .then((currentBadge) => { + // TODO - Go over the calculation + const currentBadgeMetadataSize = + Number(sizeof(currentBadge.metadata)) + Number(sizeof(currentBadge.metadata.attributes) * 2) + console.log('Current badge metadata size: ', currentBadgeMetadataSize) + const newBadgeMetadataSize = + Number(sizeof(badge?.metadata)) + Number(sizeof(badge?.metadata.attributes)) * 2 + console.log('New badge metadata size: ', newBadgeMetadataSize) + if (newBadgeMetadataSize > currentBadgeMetadataSize) { + const calculatedFee = ((newBadgeMetadataSize - currentBadgeMetadataSize) * Number(feeRate.metadata)) / 2 + setEditFee(calculatedFee) + setTriggerDispatch(!triggerDispatch) + } else { + setEditFee(undefined) + setTriggerDispatch(!triggerDispatch) + } + }) + .catch((error) => { + throw new Error(String(error).substring(String(error).lastIndexOf('Error:') + 7)) + }) + } else { + 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 dispatchEditBadgeMessage = async () => { + if (type) { + 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) + } + } + } + + const airdropFileOnChange = (data: string[]) => { + console.log(data) + setAirdropAllocationArray(data) + } + + const handleGenerateSignature = (id: number, owner: string, privateKey: string) => { + try { + const message = `claim badge ${id} for user ${owner}` + + const privKey = Buffer.from(privateKey, 'hex') + // const pubKey = Buffer.from(secp256k1.publicKeyCreate(privKey, true)) + const msgBytes = Buffer.from(message, 'utf8') + const msgHashBytes = sha256(msgBytes) + const signedMessage = secp256k1.ecdsaSign(msgHashBytes, privKey) + setSignature(Buffer.from(signedMessage.signature).toString('hex')) + } catch (error) { + console.log(error) + toast.error('Error generating signature.') + } + } + + const handleGenerateKeys = (amount: number) => { + for (let i = 0; i < amount; i++) { + let privKey: Buffer + do { + privKey = crypto.randomBytes(32) + } while (!secp256k1.privateKeyVerify(privKey)) + + const privateKey = privKey.toString('hex') + const publicKey = Buffer.from(secp256k1.publicKeyCreate(privKey)).toString('hex') + keyPairs.push(publicKey.concat(',', privateKey)) + } + } + + return ( +
+
+
+ + {showMetadataField && ( +
+ Metadata + + + + + +
+ +
+ + + +
+ )} + {showOwnerField && ( + + )} + {showPrivateKeyField && } + + {showAirdropFileField && ( + + + + )} +
+
+
+ + + + +
+ + + +
+
+
+ ) +} diff --git a/components/badges/actions/Combobox.hooks.ts b/components/badges/actions/Combobox.hooks.ts new file mode 100644 index 0000000..98ad95e --- /dev/null +++ b/components/badges/actions/Combobox.hooks.ts @@ -0,0 +1,8 @@ +import { useState } from 'react' + +import type { ActionListItem } from './actions' + +export const useActionsComboboxState = () => { + const [value, setValue] = useState(null) + return { value, onChange: (item: ActionListItem) => setValue(item) } +} diff --git a/components/badges/actions/Combobox.tsx b/components/badges/actions/Combobox.tsx new file mode 100644 index 0000000..452b2a6 --- /dev/null +++ b/components/badges/actions/Combobox.tsx @@ -0,0 +1,106 @@ +import { Combobox, Transition } from '@headlessui/react' +import clsx from 'clsx' +import { FormControl } from 'components/FormControl' +import { matchSorter } from 'match-sorter' +import { Fragment, useEffect, useState } from 'react' +import { FaChevronDown, FaInfoCircle } from 'react-icons/fa' + +import type { MintRule } from '../creation/ImageUploadDetails' +import type { ActionListItem } from './actions' +import { BY_KEY_ACTION_LIST, BY_KEYS_ACTION_LIST, BY_MINTER_ACTION_LIST } from './actions' + +export interface ActionsComboboxProps { + value: ActionListItem | null + onChange: (item: ActionListItem) => void + mintRule?: MintRule +} + +export const ActionsCombobox = ({ value, onChange, mintRule }: ActionsComboboxProps) => { + const [search, setSearch] = useState('') + const [ACTION_LIST, SET_ACTION_LIST] = useState(BY_KEY_ACTION_LIST) + + useEffect(() => { + if (mintRule === 'by_keys') { + SET_ACTION_LIST(BY_KEYS_ACTION_LIST) + } else if (mintRule === 'by_minter') { + SET_ACTION_LIST(BY_MINTER_ACTION_LIST) + } else { + SET_ACTION_LIST(BY_KEY_ACTION_LIST) + } + }, [mintRule]) + + const filtered = + search === '' ? ACTION_LIST : matchSorter(ACTION_LIST, search, { keys: ['id', 'name', 'description'] }) + + return ( + +
+ val?.name ?? ''} + id="message-type" + onChange={(event) => setSearch(event.target.value)} + placeholder="Select action" + /> + + + {({ open }) => + + setSearch('')} as={Fragment}> + + {filtered.length < 1 && ( + + Action 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/badges/actions/actions.ts b/components/badges/actions/actions.ts new file mode 100644 index 0000000..21c7d03 --- /dev/null +++ b/components/badges/actions/actions.ts @@ -0,0 +1,206 @@ +import type { Badge, BadgeHubInstance, Metadata } from 'contracts/badgeHub' +import { useBadgeHubContract } from 'contracts/badgeHub' + +export type ActionType = typeof ACTION_TYPES[number] + +export const ACTION_TYPES = [ + 'create_badge', + 'edit_badge', + 'add_keys', + 'purge_keys', + 'purge_owners', + 'mint_by_minter', + 'mint_by_key', + 'airdrop_by_key', + 'mint_by_keys', + 'set_nft', +] as const + +export interface ActionListItem { + id: ActionType + name: string + description?: string +} + +export const BY_KEY_ACTION_LIST: ActionListItem[] = [ + { + id: 'edit_badge', + name: 'Edit Badge', + description: `Edit badge metadata for the badge with the specified ID`, + }, + { + id: 'purge_owners', + name: 'Purge Owners', + description: `Purge owners from the badge with the specified ID`, + }, + { + id: 'mint_by_key', + name: 'Mint by Key', + description: `Mint a badge to a specified address`, + }, + { + id: 'airdrop_by_key', + name: 'Airdrop by Key', + description: `Airdrop badges to a list of specified addresses`, + }, +] + +export const BY_KEYS_ACTION_LIST: ActionListItem[] = [ + { + id: 'edit_badge', + name: 'Edit Badge', + description: `Edit badge metadata for the badge with the specified ID`, + }, + { + id: 'add_keys', + name: 'Add Keys', + description: `Add keys to the badge with the specified ID`, + }, + { + id: 'purge_keys', + name: 'Purge Keys', + description: `Purge keys from the badge with the specified ID`, + }, + { + id: 'purge_owners', + name: 'Purge Owners', + description: `Purge owners from the badge with the specified ID`, + }, + { + id: 'mint_by_keys', + name: 'Mint by Keys', + description: `Mint a new badge with a whitelisted private key`, + }, +] + +export const BY_MINTER_ACTION_LIST: ActionListItem[] = [ + { + id: 'edit_badge', + name: 'Edit Badge', + description: `Edit badge metadata for the badge with the specified ID`, + }, + { + id: 'purge_owners', + name: 'Purge Owners', + description: `Purge owners from the badge with the specified ID`, + }, + { + id: 'mint_by_minter', + name: 'Mint by Minter', + description: `Mint a new badge to the specified addresses`, + }, +] + +export interface DispatchExecuteProps { + type: ActionType + [k: string]: unknown +} + +type Select = T + +/** @see {@link BadgeHubInstance}*/ +export type DispatchExecuteArgs = { + badgeHubContract: string + badgeHubMessages?: BadgeHubInstance + txSigner: string +} & ( + | { type: undefined } + | { type: Select<'create_badge'>; badge: Badge } + | { type: Select<'edit_badge'>; id: number; metadata: Metadata; editFee?: number } + | { type: Select<'add_keys'>; id: number; keys: string[] } + | { type: Select<'purge_keys'>; id: number; limit?: number } + | { type: Select<'purge_owners'>; id: number; limit?: number } + | { type: Select<'mint_by_minter'>; id: number; owners: string[] } + | { type: Select<'mint_by_key'>; id: number; owner: string; signature: string } + | { type: Select<'airdrop_by_key'>; id: number; recipients: string[]; privateKey: string } + | { type: Select<'mint_by_keys'>; id: number; owner: string; pubkey: string; signature: string } + | { type: Select<'set_nft'>; nft: string } +) + +export const dispatchExecute = async (args: DispatchExecuteArgs) => { + const { badgeHubMessages, txSigner } = args + if (!badgeHubMessages) { + throw new Error('Cannot execute actions') + } + switch (args.type) { + case 'create_badge': { + return badgeHubMessages.createBadge(txSigner, args.badge) + } + case 'edit_badge': { + return badgeHubMessages.editBadge(txSigner, args.id, args.metadata, args.editFee) + } + case 'add_keys': { + return badgeHubMessages.addKeys(txSigner, args.id, args.keys) + } + case 'purge_keys': { + return badgeHubMessages.purgeKeys(txSigner, args.id, args.limit) + } + case 'purge_owners': { + return badgeHubMessages.purgeOwners(txSigner, args.id, args.limit) + } + case 'mint_by_minter': { + return badgeHubMessages.mintByMinter(txSigner, args.id, args.owners) + } + case 'mint_by_key': { + return badgeHubMessages.mintByKey(txSigner, args.id, args.owner, args.signature) + } + case 'airdrop_by_key': { + return badgeHubMessages.airdropByKey(txSigner, args.id, args.recipients, args.privateKey) + } + case 'mint_by_keys': { + return badgeHubMessages.mintByKeys(txSigner, args.id, args.owner, args.pubkey, args.signature) + } + case 'set_nft': { + return badgeHubMessages.setNft(txSigner, args.nft) + } + default: { + throw new Error('Unknown action') + } + } +} + +export const previewExecutePayload = (args: DispatchExecuteArgs) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const { messages: badgeHubMessages } = useBadgeHubContract() + + const { badgeHubContract } = args + switch (args.type) { + case 'create_badge': { + return badgeHubMessages(badgeHubContract)?.createBadge(args.badge) + } + case 'edit_badge': { + return badgeHubMessages(badgeHubContract)?.editBadge(args.id, args.metadata) + } + case 'add_keys': { + return badgeHubMessages(badgeHubContract)?.addKeys(args.id, args.keys) + } + case 'purge_keys': { + return badgeHubMessages(badgeHubContract)?.purgeKeys(args.id, args.limit) + } + case 'purge_owners': { + return badgeHubMessages(badgeHubContract)?.purgeOwners(args.id, args.limit) + } + case 'mint_by_minter': { + return badgeHubMessages(badgeHubContract)?.mintByMinter(args.id, args.owners) + } + case 'mint_by_key': { + return badgeHubMessages(badgeHubContract)?.mintByKey(args.id, args.owner, args.signature) + } + case 'airdrop_by_key': { + return badgeHubMessages(badgeHubContract)?.airdropByKey(args.id, args.recipients, args.privateKey) + } + case 'mint_by_keys': { + return badgeHubMessages(badgeHubContract)?.mintByKeys(args.id, args.owner, args.pubkey, args.signature) + } + case 'set_nft': { + return badgeHubMessages(badgeHubContract)?.setNft(args.nft) + } + default: { + return {} + } + } +} + +export const isEitherType = (type: unknown, arr: T[]): type is T => { + return arr.some((val) => type === val) +} diff --git a/components/badges/creation/BadgeDetails.tsx b/components/badges/creation/BadgeDetails.tsx new file mode 100644 index 0000000..42ff004 --- /dev/null +++ b/components/badges/creation/BadgeDetails.tsx @@ -0,0 +1,204 @@ +/* eslint-disable eslint-comments/disable-enable-pair */ + +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ + +import clsx from 'clsx' +import { FormControl } from 'components/FormControl' +import { useInputState, useNumberInputState } from 'components/forms/FormInput.hooks' +import { useMetadataAttributesState } from 'components/forms/MetadataAttributes.hooks' +import { InputDateTime } from 'components/InputDateTime' +import { useWallet } from 'contexts/wallet' +import type { Trait } from 'contracts/badgeHub' +import { useEffect, useState } from 'react' +import { toast } from 'react-hot-toast' + +import { AddressInput, NumberInput, TextInput } from '../../forms/FormInput' +import { MetadataAttributes } from '../../forms/MetadataAttributes' +import type { MintRule, UploadMethod } from './ImageUploadDetails' + +interface BadgeDetailsProps { + onChange: (data: BadgeDetailsDataProps) => void + uploadMethod: UploadMethod | undefined + mintRule: MintRule +} + +export interface BadgeDetailsDataProps { + manager: string + name?: string + description?: string + attributes?: Trait[] + expiry?: number + transferrable: boolean + max_supply?: number + image_data?: string + external_url?: string + background_color?: string + animation_url?: string + youtube_url?: string +} + +export const BadgeDetails = ({ onChange }: BadgeDetailsProps) => { + const wallet = useWallet() + const [timestamp, setTimestamp] = useState(undefined) + const [transferrable, setTransferrable] = useState(false) + + const managerState = useInputState({ + id: 'manager-address', + name: 'manager', + title: 'Manager', + subtitle: 'Badge Hub Manager', + defaultValue: wallet.address ? wallet.address : '', + }) + + const nameState = useInputState({ + id: 'name', + name: 'name', + title: 'Name', + placeholder: 'My Awesome Collection', + }) + + const descriptionState = useInputState({ + id: 'description', + name: 'description', + title: 'Description', + placeholder: 'My Awesome Collection Description', + }) + + const imageDataState = useInputState({ + id: 'metadata-image-data', + name: 'metadata-image-data', + title: 'Image Data', + subtitle: 'Raw SVG image data', + }) + + const externalUrlState = useInputState({ + id: 'metadata-external-url', + name: 'metadata-external-url', + title: 'External URL', + subtitle: 'External URL for the badge', + }) + + const attributesState = useMetadataAttributesState() + + const maxSupplyState = useNumberInputState({ + id: 'max-supply', + name: 'max-supply', + title: 'Max Supply', + subtitle: 'Maximum number of badges that can be minted', + }) + + const backgroundColorState = useInputState({ + id: 'metadata-background-color', + name: 'metadata-background-color', + title: 'Background Color', + subtitle: 'Background color of the badge', + }) + + const animationUrlState = useInputState({ + id: 'metadata-animation-url', + name: 'metadata-animation-url', + title: 'Animation URL', + subtitle: 'Animation URL for the badge', + }) + + const youtubeUrlState = useInputState({ + id: 'metadata-youtube-url', + name: 'metadata-youtube-url', + title: 'YouTube URL', + subtitle: 'YouTube URL for the badge', + }) + + useEffect(() => { + try { + const data: BadgeDetailsDataProps = { + manager: managerState.value, + name: nameState.value || undefined, + description: descriptionState.value || undefined, + attributes: + attributesState.values[0]?.trait_type && attributesState.values[0]?.value + ? attributesState.values + .map((attr) => ({ + trait_type: attr.trait_type, + value: attr.value, + })) + .filter((attr) => attr.trait_type && attr.value) + : undefined, + expiry: timestamp ? timestamp.getTime() / 1000 : undefined, + max_supply: maxSupplyState.value || undefined, + transferrable, + image_data: imageDataState.value || undefined, + external_url: externalUrlState.value || undefined, + background_color: backgroundColorState.value || undefined, + animation_url: animationUrlState.value || undefined, + youtube_url: youtubeUrlState.value || undefined, + } + onChange(data) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + toast.error(error.message, { style: { maxWidth: 'none' } }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + managerState.value, + nameState.value, + descriptionState.value, + timestamp, + maxSupplyState.value, + transferrable, + imageDataState.value, + externalUrlState.value, + attributesState.values, + backgroundColorState.value, + animationUrlState.value, + youtubeUrlState.value, + ]) + + useEffect(() => { + if (attributesState.values.length === 0) + attributesState.add({ + trait_type: '', + value: '', + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return ( +
+
+
+ + + + + + + setTimestamp(date)} value={timestamp} /> + +
+ +
+
+
+
+ +
+
+
+
+ ) +} diff --git a/components/badges/creation/ImageUploadDetails.tsx b/components/badges/creation/ImageUploadDetails.tsx new file mode 100644 index 0000000..fb8d725 --- /dev/null +++ b/components/badges/creation/ImageUploadDetails.tsx @@ -0,0 +1,296 @@ +/* eslint-disable eslint-comments/disable-enable-pair */ + +/* eslint-disable no-misleading-character-class */ +/* eslint-disable no-control-regex */ + +import clsx from 'clsx' +import { Anchor } from 'components/Anchor' +import { Conditional } from 'components/Conditional' +import { TextInput } from 'components/forms/FormInput' +import { useInputState } from 'components/forms/FormInput.hooks' +import { SingleAssetPreview } from 'components/SingleAssetPreview' +import type { ChangeEvent } from 'react' +import { useEffect, useRef, useState } from 'react' +import { toast } from 'react-hot-toast' +import type { UploadServiceType } from 'services/upload' + +export type UploadMethod = 'new' | 'existing' +export type MintRule = 'by_key' | 'by_minter' | 'by_keys' | 'not_resolved' + +interface ImageUploadDetailsProps { + onChange: (value: ImageUploadDetailsDataProps) => void + mintRule: MintRule +} + +export interface ImageUploadDetailsDataProps { + assetFile: File | undefined + uploadService: UploadServiceType + nftStorageApiKey?: string + pinataApiKey?: string + pinataSecretKey?: string + uploadMethod: UploadMethod + imageUrl?: string +} + +export const ImageUploadDetails = ({ onChange, mintRule }: ImageUploadDetailsProps) => { + const [assetFile, setAssetFile] = useState() + const [uploadMethod, setUploadMethod] = useState('new') + const [uploadService, setUploadService] = useState('nft-storage') + + const assetFileRef = useRef(null) + + const nftStorageApiKeyState = useInputState({ + id: 'nft-storage-api-key', + name: 'nftStorageApiKey', + title: 'NFT.Storage API Key', + placeholder: 'Enter NFT.Storage API Key', + defaultValue: '', + }) + const pinataApiKeyState = useInputState({ + id: 'pinata-api-key', + name: 'pinataApiKey', + title: 'Pinata API Key', + placeholder: 'Enter Pinata API Key', + defaultValue: '', + }) + const pinataSecretKeyState = useInputState({ + id: 'pinata-secret-key', + name: 'pinataSecretKey', + title: 'Pinata Secret Key', + placeholder: 'Enter Pinata Secret Key', + defaultValue: '', + }) + + const imageUrlState = useInputState({ + id: 'imageUrl', + name: 'imageUrl', + title: 'Image URL', + placeholder: 'ipfs://', + defaultValue: '', + }) + + const selectAsset = (event: ChangeEvent) => { + setAssetFile(undefined) + if (event.target.files === null) return + + let selectedFile: File + const reader = new FileReader() + reader.onload = (e) => { + if (!event.target.files) return toast.error('No file selected.') + if (!e.target?.result) return toast.error('Error parsing file.') + selectedFile = new File([e.target.result], event.target.files[0].name, { type: 'image/jpg' }) + } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (event.target.files[0]) reader.readAsArrayBuffer(event.target.files[0]) + else return toast.error('No file selected.') + reader.onloadend = () => { + if (!event.target.files) return toast.error('No file selected.') + setAssetFile(selectedFile) + } + } + + const regex = + /[\0-\x1F\x7F-\x9F\xAD\u0378\u0379\u037F-\u0383\u038B\u038D\u03A2\u0528-\u0530\u0557\u0558\u0560\u0588\u058B-\u058E\u0590\u05C8-\u05CF\u05EB-\u05EF\u05F5-\u0605\u061C\u061D\u06DD\u070E\u070F\u074B\u074C\u07B2-\u07BF\u07FB-\u07FF\u082E\u082F\u083F\u085C\u085D\u085F-\u089F\u08A1\u08AD-\u08E3\u08FF\u0978\u0980\u0984\u098D\u098E\u0991\u0992\u09A9\u09B1\u09B3-\u09B5\u09BA\u09BB\u09C5\u09C6\u09C9\u09CA\u09CF-\u09D6\u09D8-\u09DB\u09DE\u09E4\u09E5\u09FC-\u0A00\u0A04\u0A0B-\u0A0E\u0A11\u0A12\u0A29\u0A31\u0A34\u0A37\u0A3A\u0A3B\u0A3D\u0A43-\u0A46\u0A49\u0A4A\u0A4E-\u0A50\u0A52-\u0A58\u0A5D\u0A5F-\u0A65\u0A76-\u0A80\u0A84\u0A8E\u0A92\u0AA9\u0AB1\u0AB4\u0ABA\u0ABB\u0AC6\u0ACA\u0ACE\u0ACF\u0AD1-\u0ADF\u0AE4\u0AE5\u0AF2-\u0B00\u0B04\u0B0D\u0B0E\u0B11\u0B12\u0B29\u0B31\u0B34\u0B3A\u0B3B\u0B45\u0B46\u0B49\u0B4A\u0B4E-\u0B55\u0B58-\u0B5B\u0B5E\u0B64\u0B65\u0B78-\u0B81\u0B84\u0B8B-\u0B8D\u0B91\u0B96-\u0B98\u0B9B\u0B9D\u0BA0-\u0BA2\u0BA5-\u0BA7\u0BAB-\u0BAD\u0BBA-\u0BBD\u0BC3-\u0BC5\u0BC9\u0BCE\u0BCF\u0BD1-\u0BD6\u0BD8-\u0BE5\u0BFB-\u0C00\u0C04\u0C0D\u0C11\u0C29\u0C34\u0C3A-\u0C3C\u0C45\u0C49\u0C4E-\u0C54\u0C57\u0C5A-\u0C5F\u0C64\u0C65\u0C70-\u0C77\u0C80\u0C81\u0C84\u0C8D\u0C91\u0CA9\u0CB4\u0CBA\u0CBB\u0CC5\u0CC9\u0CCE-\u0CD4\u0CD7-\u0CDD\u0CDF\u0CE4\u0CE5\u0CF0\u0CF3-\u0D01\u0D04\u0D0D\u0D11\u0D3B\u0D3C\u0D45\u0D49\u0D4F-\u0D56\u0D58-\u0D5F\u0D64\u0D65\u0D76-\u0D78\u0D80\u0D81\u0D84\u0D97-\u0D99\u0DB2\u0DBC\u0DBE\u0DBF\u0DC7-\u0DC9\u0DCB-\u0DCE\u0DD5\u0DD7\u0DE0-\u0DF1\u0DF5-\u0E00\u0E3B-\u0E3E\u0E5C-\u0E80\u0E83\u0E85\u0E86\u0E89\u0E8B\u0E8C\u0E8E-\u0E93\u0E98\u0EA0\u0EA4\u0EA6\u0EA8\u0EA9\u0EAC\u0EBA\u0EBE\u0EBF\u0EC5\u0EC7\u0ECE\u0ECF\u0EDA\u0EDB\u0EE0-\u0EFF\u0F48\u0F6D-\u0F70\u0F98\u0FBD\u0FCD\u0FDB-\u0FFF\u10C6\u10C8-\u10CC\u10CE\u10CF\u1249\u124E\u124F\u1257\u1259\u125E\u125F\u1289\u128E\u128F\u12B1\u12B6\u12B7\u12BF\u12C1\u12C6\u12C7\u12D7\u1311\u1316\u1317\u135B\u135C\u137D-\u137F\u139A-\u139F\u13F5-\u13FF\u169D-\u169F\u16F1-\u16FF\u170D\u1715-\u171F\u1737-\u173F\u1754-\u175F\u176D\u1771\u1774-\u177F\u17DE\u17DF\u17EA-\u17EF\u17FA-\u17FF\u180F\u181A-\u181F\u1878-\u187F\u18AB-\u18AF\u18F6-\u18FF\u191D-\u191F\u192C-\u192F\u193C-\u193F\u1941-\u1943\u196E\u196F\u1975-\u197F\u19AC-\u19AF\u19CA-\u19CF\u19DB-\u19DD\u1A1C\u1A1D\u1A5F\u1A7D\u1A7E\u1A8A-\u1A8F\u1A9A-\u1A9F\u1AAE-\u1AFF\u1B4C-\u1B4F\u1B7D-\u1B7F\u1BF4-\u1BFB\u1C38-\u1C3A\u1C4A-\u1C4C\u1C80-\u1CBF\u1CC8-\u1CCF\u1CF7-\u1CFF\u1DE7-\u1DFB\u1F16\u1F17\u1F1E\u1F1F\u1F46\u1F47\u1F4E\u1F4F\u1F58\u1F5A\u1F5C\u1F5E\u1F7E\u1F7F\u1FB5\u1FC5\u1FD4\u1FD5\u1FDC\u1FF0\u1FF1\u1FF5\u1FFF\u200B-\u200F\u2020-\u202E\u2060-\u206F\u2072\u2073\u208F\u209D-\u209F\u20BB-\u20CF\u20F1-\u20FF\u218A-\u218F\u23F4-\u23FF\u2427-\u243F\u244B-\u245F\u2700\u2B4D-\u2B4F\u2B5A-\u2BFF\u2C2F\u2C5F\u2CF4-\u2CF8\u2D26\u2D28-\u2D2C\u2D2E\u2D2F\u2D68-\u2D6E\u2D71-\u2D7E\u2D97-\u2D9F\u2DA7\u2DAF\u2DB7\u2DBF\u2DC7\u2DCF\u2DD7\u2DDF\u2E3C-\u2E7F\u2E9A\u2EF4-\u2EFF\u2FD6-\u2FEF\u2FFC-\u2FFF\u3040\u3097\u3098\u3100-\u3104\u312E-\u3130\u318F\u31BB-\u31BF\u31E4-\u31EF\u321F\u32FF\u4DB6-\u4DBF\u9FCD-\u9FFF\uA48D-\uA48F\uA4C7-\uA4CF\uA62C-\uA63F\uA698-\uA69E\uA6F8-\uA6FF\uA78F\uA794-\uA79F\uA7AB-\uA7F7\uA82C-\uA82F\uA83A-\uA83F\uA878-\uA87F\uA8C5-\uA8CD\uA8DA-\uA8DF\uA8FC-\uA8FF\uA954-\uA95E\uA97D-\uA97F\uA9CE\uA9DA-\uA9DD\uA9E0-\uA9FF\uAA37-\uAA3F\uAA4E\uAA4F\uAA5A\uAA5B\uAA7C-\uAA7F\uAAC3-\uAADA\uAAF7-\uAB00\uAB07\uAB08\uAB0F\uAB10\uAB17-\uAB1F\uAB27\uAB2F-\uABBF\uABEE\uABEF\uABFA-\uABFF\uD7A4-\uD7AF\uD7C7-\uD7CA\uD7FC-\uF8FF\uFA6E\uFA6F\uFADA-\uFAFF\uFB07-\uFB12\uFB18-\uFB1C\uFB37\uFB3D\uFB3F\uFB42\uFB45\uFBC2-\uFBD2\uFD40-\uFD4F\uFD90\uFD91\uFDC8-\uFDEF\uFDFE\uFDFF\uFE1A-\uFE1F\uFE27-\uFE2F\uFE53\uFE67\uFE6C-\uFE6F\uFE75\uFEFD-\uFF00\uFFBF-\uFFC1\uFFC8\uFFC9\uFFD0\uFFD1\uFFD8\uFFD9\uFFDD-\uFFDF\uFFE7\uFFEF-\uFFFB\uFFFE\uFFFF]/g + + useEffect(() => { + try { + const data: ImageUploadDetailsDataProps = { + assetFile, + uploadService, + nftStorageApiKey: nftStorageApiKeyState.value, + pinataApiKey: pinataApiKeyState.value, + pinataSecretKey: pinataSecretKeyState.value, + uploadMethod, + imageUrl: imageUrlState.value + .replace('IPFS://', 'ipfs://') + .replace(/,/g, '') + .replace(/"/g, '') + .replace(/'/g, '') + .replace(/ /g, '') + .replace(regex, ''), + } + onChange(data) + } catch (error: any) { + toast.error(error.message, { style: { maxWidth: 'none' } }) + } + }, [ + assetFile, + uploadService, + nftStorageApiKeyState.value, + pinataApiKeyState.value, + pinataSecretKeyState.value, + uploadMethod, + imageUrlState.value, + ]) + + useEffect(() => { + if (assetFileRef.current) assetFileRef.current.value = '' + setAssetFile(undefined) + imageUrlState.onChange('') + }, [uploadMethod, mintRule]) + + return ( +
+
+
+ { + setUploadMethod('new') + }} + type="radio" + value="New" + /> + +
+
+ { + setUploadMethod('existing') + }} + type="radio" + value="Existing" + /> + +
+
+ +
+ +
+

+ Though the Badge Hub contract allows for off-chain image storage, it is recommended to use a decentralized + storage solution, such as IPFS.
You may head over to{' '} + + NFT.Storage + {' '} + or{' '} + + Pinata + {' '} + and upload your image manually to get an image URL for your badge. +

+
+ +
+
+
+ + +
+
+
+
+ { + setUploadService('nft-storage') + }} + type="radio" + value="nft-storage" + /> + +
+ +
+ { + setUploadService('pinata') + }} + type="radio" + value="pinata" + /> + +
+
+ +
+ + + + + +
+ + +
+
+ +
+
+
+
+ +
+ +
+
+
+ + + +
+
+
+ +
+
+ ) +} diff --git a/components/badges/queries/Combobox.hooks.ts b/components/badges/queries/Combobox.hooks.ts new file mode 100644 index 0000000..b2aac76 --- /dev/null +++ b/components/badges/queries/Combobox.hooks.ts @@ -0,0 +1,8 @@ +import { useState } from 'react' + +import type { QueryListItem } from './query' + +export const useQueryComboboxState = () => { + const [value, setValue] = useState(null) + return { value, onChange: (item: QueryListItem) => setValue(item) } +} diff --git a/components/badges/queries/Combobox.tsx b/components/badges/queries/Combobox.tsx new file mode 100644 index 0000000..4953343 --- /dev/null +++ b/components/badges/queries/Combobox.tsx @@ -0,0 +1,105 @@ +import { Combobox, Transition } from '@headlessui/react' +import clsx from 'clsx' +import { FormControl } from 'components/FormControl' +import { matchSorter } from 'match-sorter' +import { Fragment, useEffect, useState } from 'react' +import { FaChevronDown, FaInfoCircle } from 'react-icons/fa' + +import type { MintRule } from '../creation/ImageUploadDetails' +import type { QueryListItem } from './query' +import { BY_KEY_QUERY_LIST, BY_KEYS_QUERY_LIST, BY_MINTER_QUERY_LIST } from './query' + +export interface QueryComboboxProps { + value: QueryListItem | null + onChange: (item: QueryListItem) => void + mintRule?: MintRule +} + +export const QueryCombobox = ({ value, onChange, mintRule }: QueryComboboxProps) => { + const [search, setSearch] = useState('') + const [QUERY_LIST, SET_QUERY_LIST] = useState(BY_KEY_QUERY_LIST) + + useEffect(() => { + if (mintRule === 'by_keys') { + SET_QUERY_LIST(BY_KEYS_QUERY_LIST) + } else if (mintRule === 'by_minter') { + SET_QUERY_LIST(BY_MINTER_QUERY_LIST) + } else { + SET_QUERY_LIST(BY_KEY_QUERY_LIST) + } + }, [mintRule]) + + const filtered = search === '' ? QUERY_LIST : matchSorter(QUERY_LIST, search, { keys: ['id', 'name', 'description'] }) + + return ( + +
+ val?.name ?? ''} + id="message-type" + onChange={(event) => setSearch(event.target.value)} + placeholder="Select query" + /> + + + {({ open }) => + + setSearch('')} as={Fragment}> + + {filtered.length < 1 && ( + + Query 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/badges/queries/Queries.tsx b/components/badges/queries/Queries.tsx new file mode 100644 index 0000000..386172e --- /dev/null +++ b/components/badges/queries/Queries.tsx @@ -0,0 +1,116 @@ +import { QueryCombobox } from 'components/badges/queries/Combobox' +import { useQueryComboboxState } from 'components/badges/queries/Combobox.hooks' +import { dispatchQuery } from 'components/badges/queries/query' +import { Conditional } from 'components/Conditional' +import { FormControl } from 'components/FormControl' +import { NumberInput, TextInput } from 'components/forms/FormInput' +import { useInputState, useNumberInputState } from 'components/forms/FormInput.hooks' +import { JsonPreview } from 'components/JsonPreview' +import type { BadgeHubInstance } from 'contracts/badgeHub' +import { toast } from 'react-hot-toast' +import { useQuery } from 'react-query' + +import { useWallet } from '../../../contexts/wallet' +import type { MintRule } from '../creation/ImageUploadDetails' + +interface BadgeQueriesProps { + badgeHubContractAddress: string + badgeId: number + badgeHubMessages: BadgeHubInstance | undefined + mintRule: MintRule +} +export const BadgeQueries = ({ badgeHubContractAddress, badgeId, badgeHubMessages, mintRule }: BadgeQueriesProps) => { + const wallet = useWallet() + + const comboboxState = useQueryComboboxState() + const type = comboboxState.value?.id + + const pubkeyState = useInputState({ + id: 'pubkey', + name: 'pubkey', + title: 'Public Key', + subtitle: 'The public key to check whether it can be used to mint a badge', + }) + + const startAfterNumberState = useNumberInputState({ + id: 'start-after-number', + name: 'start-after-number', + title: 'Start After (optional)', + subtitle: 'The id to start the pagination after', + }) + + const startAfterStringState = useInputState({ + id: 'start-after-string', + name: 'start-after-string', + title: 'Start After (optional)', + subtitle: 'The public key 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 { data: response } = useQuery( + [ + badgeHubMessages, + type, + badgeId, + pubkeyState.value, + startAfterNumberState.value, + startAfterStringState.value, + paginationLimitState.value, + ] as const, + async ({ queryKey }) => { + const [_badgeHubMessages, _type, _badgeId, _pubKey, _startAfterNumber, _startAfterString, _limit] = queryKey + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const result = await dispatchQuery({ + badgeHubMessages: _badgeHubMessages, + id: _badgeId, + startAfterNumber: _startAfterNumber, + startAfterString: _startAfterString, + limit: _limit, + type: _type, + pubkey: _pubKey, + }) + return result + }, + { + placeholderData: null, + onError: (error: any) => { + toast.error(error.message, { style: { maxWidth: 'none' } }) + }, + enabled: Boolean(badgeHubContractAddress && type && badgeId), + retry: false, + }, + ) + + return ( +
+
+ + + + + + + + + + + + + +
+
+ + + +
+
+ ) +} diff --git a/components/badges/queries/query.ts b/components/badges/queries/query.ts new file mode 100644 index 0000000..82c4e50 --- /dev/null +++ b/components/badges/queries/query.ts @@ -0,0 +1,76 @@ +/* eslint-disable eslint-comments/disable-enable-pair */ +/* eslint-disable @typescript-eslint/no-unnecessary-condition */ +import type { BadgeHubInstance } from 'contracts/badgeHub' + +export type QueryType = typeof QUERY_TYPES[number] + +export const QUERY_TYPES = ['config', 'getBadge', 'getBadges', 'getKey', 'getKeys'] as const + +export interface QueryListItem { + id: QueryType + name: string + description?: string +} + +export const BY_KEY_QUERY_LIST: QueryListItem[] = [ + { id: 'config', name: 'Config', description: 'View current config' }, + { id: 'getBadge', name: 'Query Badge', description: 'Query a badge by ID' }, + { id: 'getBadges', name: 'Query Badges', description: 'Query a list of badges' }, +] +export const BY_KEYS_QUERY_LIST: QueryListItem[] = [ + { id: 'config', name: 'Config', description: 'View current config' }, + { id: 'getBadge', name: 'Query Badge', description: 'Query a badge by ID' }, + { id: 'getBadges', name: 'Query Badges', description: 'Query a list of badges' }, + { id: 'getKey', name: 'Query Key', description: "Query a key by ID to see if it's whitelisted" }, + { id: 'getKeys', name: 'Query Keys', description: 'Query the list of whitelisted keys' }, +] +export const BY_MINTER_QUERY_LIST: QueryListItem[] = [ + { id: 'config', name: 'Config', description: 'View current config' }, + { id: 'getBadge', name: 'Query Badge', description: 'Query a badge by ID' }, + { id: 'getBadges', name: 'Query Badges', description: 'Query a list of badges' }, +] + +export interface DispatchExecuteProps { + type: QueryType + [k: string]: unknown +} + +type Select = T + +export type DispatchQueryArgs = { + badgeHubMessages?: BadgeHubInstance +} & ( + | { type: undefined } + | { type: Select<'config'> } + | { type: Select<'getBadge'>; id: number } + | { type: Select<'getBadges'>; startAfterNumber: number; limit: number } + | { type: Select<'getKey'>; id: number; pubkey: string } + | { type: Select<'getKeys'>; id: number; startAfterString: string; limit: number } +) + +export const dispatchQuery = async (args: DispatchQueryArgs) => { + const { badgeHubMessages } = args + if (!badgeHubMessages) { + throw new Error('Cannot perform a query') + } + switch (args.type) { + case 'config': { + return badgeHubMessages?.getConfig() + } + case 'getBadge': { + return badgeHubMessages?.getBadge(args.id) + } + case 'getBadges': { + return badgeHubMessages?.getBadges(args.startAfterNumber, args.limit) + } + case 'getKey': { + return badgeHubMessages?.getKey(args.id, args.pubkey) + } + case 'getKeys': { + return badgeHubMessages?.getKeys(args.id, args.startAfterString, args.limit) + } + default: { + throw new Error('Unknown action') + } + } +} diff --git a/components/contracts/badgeHub/ExecuteCombobox.hooks.ts b/components/contracts/badgeHub/ExecuteCombobox.hooks.ts new file mode 100644 index 0000000..769b85c --- /dev/null +++ b/components/contracts/badgeHub/ExecuteCombobox.hooks.ts @@ -0,0 +1,7 @@ +import type { ExecuteListItem } from 'contracts/badgeHub/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/badgeHub/ExecuteCombobox.tsx b/components/contracts/badgeHub/ExecuteCombobox.tsx new file mode 100644 index 0000000..d8001c3 --- /dev/null +++ b/components/contracts/badgeHub/ExecuteCombobox.tsx @@ -0,0 +1,92 @@ +import { Combobox, Transition } from '@headlessui/react' +import clsx from 'clsx' +import { FormControl } from 'components/FormControl' +import type { ExecuteListItem } from 'contracts/badgeHub/messages/execute' +import { EXECUTE_LIST } from 'contracts/badgeHub/messages/execute' +import { matchSorter } from 'match-sorter' +import { Fragment, useState } from 'react' +import { FaChevronDown, FaInfoCircle } from 'react-icons/fa' + +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/contexts/contracts.tsx b/contexts/contracts.tsx index b0fbd06..4df8488 100644 --- a/contexts/contracts.tsx +++ b/contexts/contracts.tsx @@ -1,3 +1,5 @@ +import type { UseBadgeHubContractProps } from 'contracts/badgeHub' +import { useBadgeHubContract } from 'contracts/badgeHub' import type { UseBaseFactoryContractProps } from 'contracts/baseFactory' import { useBaseFactoryContract } from 'contracts/baseFactory' import type { UseBaseMinterContractProps } from 'contracts/baseMinter' @@ -25,6 +27,7 @@ export interface ContractsStore extends State { whitelist: UseWhiteListContractProps | null vendingFactory: UseVendingFactoryContractProps | null baseFactory: UseBaseFactoryContractProps | null + badgeHub: UseBadgeHubContractProps | null } /** @@ -37,6 +40,7 @@ export const defaultValues: ContractsStore = { whitelist: null, vendingFactory: null, baseFactory: null, + badgeHub: null, } /** @@ -66,6 +70,7 @@ const ContractsSubscription: VFC = () => { const whitelist = useWhiteListContract() const vendingFactory = useVendingFactoryContract() const baseFactory = useBaseFactoryContract() + const badgeHub = useBadgeHubContract() useEffect(() => { useContracts.setState({ @@ -75,8 +80,9 @@ const ContractsSubscription: VFC = () => { whitelist, vendingFactory, baseFactory, + badgeHub, }) - }, [sg721, vendingMinter, baseMinter, whitelist, vendingFactory, baseFactory]) + }, [sg721, vendingMinter, baseMinter, whitelist, vendingFactory, baseFactory, badgeHub]) return null } diff --git a/contracts/badgeHub/contract.ts b/contracts/badgeHub/contract.ts new file mode 100644 index 0000000..dc086c0 --- /dev/null +++ b/contracts/badgeHub/contract.ts @@ -0,0 +1,718 @@ +/* eslint-disable eslint-comments/disable-enable-pair */ + +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable camelcase */ +import type { MsgExecuteContractEncodeObject, SigningCosmWasmClient } from '@cosmjs/cosmwasm-stargate' +import { toUtf8 } from '@cosmjs/encoding' +import type { Coin } from '@cosmjs/proto-signing' +import { coin } from '@cosmjs/proto-signing' +import type { logs } from '@cosmjs/stargate' +import { MsgExecuteContract } from 'cosmjs-types/cosmwasm/wasm/v1/tx' +import sizeof from 'object-sizeof' + +import { generateSignature } from '../../utils/hash' + +export interface InstantiateResponse { + readonly contractAddress: string + readonly transactionHash: string + readonly logs: readonly logs.Log[] +} + +export interface MigrateResponse { + readonly transactionHash: string + readonly logs: readonly logs.Log[] +} + +export interface Rule { + by_key?: string + by_minter?: string + by_keys?: string[] +} + +export interface Trait { + display_type?: string + trait_type: string + value: string +} + +export interface Metadata { + name?: string + image?: string + image_data?: string + external_url?: string + description?: string + attributes?: Trait[] + background_color?: string + animation_url?: string + youtube_url?: string +} + +export interface Badge { + manager: string + metadata: Metadata + transferrable: boolean + rule: Rule + expiry?: number + max_supply?: number +} + +export interface BadgeHubInstance { + readonly contractAddress: string + + //Query + getConfig: () => Promise + getBadge: (id: number) => Promise + getBadges: (start_after?: number, limit?: number) => Promise + getKey: (id: number, pubkey: string) => Promise + getKeys: (id: number, start_after?: string, limit?: number) => Promise + + //Execute + createBadge: (senderAddress: string, badge: Badge) => Promise + editBadge: (senderAddress: string, id: number, metadata: Metadata, editFee?: number) => Promise + addKeys: (senderAddress: string, id: number, keys: string[]) => Promise + purgeKeys: (senderAddress: string, id: number, limit?: number) => Promise + purgeOwners: (senderAddress: string, id: number, limit?: number) => Promise + mintByMinter: (senderAddress: string, id: number, owners: string[]) => Promise + mintByKey: (senderAddress: string, id: number, owner: string, signature: string) => Promise + airdropByKey: (senderAddress: string, id: number, recipients: string[], privateKey: string) => Promise + mintByKeys: (senderAddress: string, id: number, owner: string, pubkey: string, signature: string) => Promise + setNft: (senderAddress: string, nft: string) => Promise +} + +export interface BadgeHubMessages { + createBadge: (badge: Badge) => CreateBadgeMessage + editBadge: (id: number, metadata: Metadata, editFee?: number) => EditBadgeMessage + addKeys: (id: number, keys: string[]) => AddKeysMessage + purgeKeys: (id: number, limit?: number) => PurgeKeysMessage + purgeOwners: (id: number, limit?: number) => PurgeOwnersMessage + mintByMinter: (id: number, owners: string[]) => MintByMinterMessage + mintByKey: (id: number, owner: string, signature: string) => MintByKeyMessage + airdropByKey: (id: number, recipients: string[], privateKey: string) => CustomMessage + mintByKeys: (id: number, owner: string, pubkey: string, signature: string) => MintByKeysMessage + setNft: (nft: string) => SetNftMessage +} + +export interface CreateBadgeMessage { + sender: string + contract: string + msg: { + create_badge: { + manager: string + metadata: Metadata + transferrable: boolean + rule: Rule + expiry?: number + max_supply?: number + } + } + funds: Coin[] +} + +export interface EditBadgeMessage { + sender: string + contract: string + msg: { + edit_badge: { + id: number + metadata: Metadata + } + } + funds: Coin[] +} + +export interface AddKeysMessage { + sender: string + contract: string + msg: { + add_keys: { + id: number + keys: string[] + } + } + funds: Coin[] +} + +export interface PurgeKeysMessage { + sender: string + contract: string + msg: { + purge_keys: { + id: number + limit?: number + } + } + funds: Coin[] +} + +export interface PurgeOwnersMessage { + sender: string + contract: string + msg: { + purge_owners: { + id: number + limit?: number + } + } + funds: Coin[] +} + +export interface MintByMinterMessage { + sender: string + contract: string + msg: { + mint_by_minter: { + id: number + owners: string[] + } + } + funds: Coin[] +} + +export interface MintByKeyMessage { + sender: string + contract: string + msg: { + mint_by_key: { + id: number + owner: string + signature: string + } + } + funds: Coin[] +} + +export interface CustomMessage { + sender: string + contract: string + msg: Record[] + funds: Coin[] +} +export interface MintByKeysMessage { + sender: string + contract: string + msg: { + mint_by_keys: { + id: number + owner: string + pubkey: string + signature: string + } + } + funds: Coin[] +} + +export interface SetNftMessage { + sender: string + contract: string + msg: { + set_nft: { + nft: string + } + } + funds: Coin[] +} + +export interface BadgeHubContract { + instantiate: ( + senderAddress: string, + codeId: number, + initMsg: Record, + label: string, + admin?: string, + funds?: Coin[], + ) => Promise + + migrate: ( + senderAddress: string, + contractAddress: string, + codeId: number, + migrateMsg: Record, + ) => Promise + + use: (contractAddress: string) => BadgeHubInstance + + messages: (contractAddress: string) => BadgeHubMessages +} + +export const badgeHub = (client: SigningCosmWasmClient, txSigner: string): BadgeHubContract => { + const use = (contractAddress: string): BadgeHubInstance => { + //Query + const getConfig = async (): Promise => { + const res = await client.queryContractSmart(contractAddress, { + config: {}, + }) + return res + } + + const getBadge = async (id: number): Promise => { + const res = await client.queryContractSmart(contractAddress, { + badge: { id }, + }) + return res + } + + const getBadges = async (start_after?: number, limit?: number): Promise => { + const res = await client.queryContractSmart(contractAddress, { + badges: { start_after, limit }, + }) + return res + } + + const getKey = async (id: number, pubkey: string): Promise => { + const res = await client.queryContractSmart(contractAddress, { + key: { id, pubkey }, + }) + return res + } + + const getKeys = async (id: number, start_after?: string, limit?: number): Promise => { + const res = await client.queryContractSmart(contractAddress, { + keys: { id, start_after, limit }, + }) + return res + } + + //Execute + const createBadge = async (senderAddress: string, badge: Badge): Promise => { + const feeRateRaw = await client.queryContractRaw( + contractAddress, + toUtf8(Buffer.from(Buffer.from('fee_rate').toString('hex'), 'hex').toString()), + ) + console.log('Fee Rate Raw: ', feeRateRaw) + const feeRate = JSON.parse(new TextDecoder().decode(feeRateRaw as Uint8Array)) + console.log('Fee Rate:', feeRate) + + console.log('badge size: ', sizeof(badge)) + console.log('metadata size', sizeof(badge.metadata)) + console.log('size of attributes ', sizeof(badge.metadata.attributes)) + + console.log('Total: ', Number(sizeof(badge)) + Number(sizeof(badge.metadata.attributes))) + const res = await client.execute( + senderAddress, + contractAddress, + { + create_badge: { + manager: badge.manager, + metadata: badge.metadata, + transferrable: badge.transferrable, + rule: badge.rule, + expiry: badge.expiry, + max_supply: badge.max_supply, + }, + }, + 'auto', + '', + [ + coin( + (Number(sizeof(badge)) + Number(sizeof(badge.metadata.attributes))) * Number(feeRate.metadata), + 'ustars', + ), + ], + //[coin(1, 'ustars')], + ) + + const events = res.logs + .map((log) => log.events) + .flat() + .find( + (event) => + event.attributes.findIndex((attr) => attr.key === 'action' && attr.value === 'badges/hub/create_badge') > 0, + )! + const id = Number(events.attributes.find((attr) => attr.key === 'id')!.value) + + return res.transactionHash.concat(`:${id}`) + } + + const editBadge = async ( + senderAddress: string, + id: number, + metadata: Metadata, + editFee?: number, + ): Promise => { + const res = await client.execute( + senderAddress, + contractAddress, + { + edit_badge: { + id, + metadata, + }, + }, + 'auto', + '', + editFee ? [coin(editFee, 'ustars')] : [], + ) + + return res.transactionHash + } + + const addKeys = async (senderAddress: string, id: number, keys: string[]): Promise => { + const res = await client.execute( + senderAddress, + contractAddress, + { + add_keys: { + id, + keys, + }, + }, + 'auto', + '', + ) + + return res.transactionHash + } + + const purgeKeys = async (senderAddress: string, id: number, limit?: number): Promise => { + const res = await client.execute( + senderAddress, + contractAddress, + { + purge_keys: { + id, + limit, + }, + }, + 'auto', + '', + ) + + return res.transactionHash + } + + const purgeOwners = async (senderAddress: string, id: number, limit?: number): Promise => { + const res = await client.execute( + senderAddress, + contractAddress, + { + purge_owners: { + id, + limit, + }, + }, + 'auto', + '', + ) + + return res.transactionHash + } + + const mintByMinter = async (senderAddress: string, id: number, owners: string[]): Promise => { + const res = await client.execute( + senderAddress, + contractAddress, + { + mint_by_minter: { + id, + owners, + }, + }, + 'auto', + '', + ) + + return res.transactionHash + } + + const mintByKey = async (senderAddress: string, id: number, owner: string, signature: string): Promise => { + const res = await client.execute( + senderAddress, + contractAddress, + { + mint_by_key: { + id, + owner, + signature, + }, + }, + 'auto', + '', + ) + + return res.transactionHash + } + + const airdropByKey = async ( + senderAddress: string, + id: number, + recipients: string[], + privateKey: string, + ): Promise => { + const executeContractMsgs: MsgExecuteContractEncodeObject[] = [] + for (let i = 0; i < recipients.length; i++) { + const msg = { + mint_by_key: { id, owner: recipients[i], signature: generateSignature(id, recipients[i], privateKey) }, + } + const executeContractMsg: MsgExecuteContractEncodeObject = { + typeUrl: '/cosmwasm.wasm.v1.MsgExecuteContract', + value: MsgExecuteContract.fromPartial({ + sender: senderAddress, + contract: contractAddress, + msg: toUtf8(JSON.stringify(msg)), + }), + } + + executeContractMsgs.push(executeContractMsg) + } + + const res = await client.signAndBroadcast(senderAddress, executeContractMsgs, 'auto', 'airdrop_by_key') + + return res.transactionHash + } + + const mintByKeys = async ( + senderAddress: string, + id: number, + owner: string, + pubkey: string, + signature: string, + ): Promise => { + const res = await client.execute( + senderAddress, + contractAddress, + { + mint_by_keys: { + id, + owner, + pubkey, + signature, + }, + }, + 'auto', + '', + ) + + return res.transactionHash + } + + const setNft = async (senderAddress: string, nft: string): Promise => { + const res = await client.execute( + senderAddress, + contractAddress, + { + set_nft: { + nft, + }, + }, + 'auto', + '', + ) + + return res.transactionHash + } + + return { + contractAddress, + getConfig, + getBadge, + getBadges, + getKey, + getKeys, + createBadge, + editBadge, + addKeys, + purgeKeys, + purgeOwners, + mintByMinter, + mintByKey, + airdropByKey, + mintByKeys, + setNft, + } + } + + 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 instantiate = async ( + senderAddress: string, + codeId: number, + initMsg: Record, + label: string, + ): Promise => { + const result = await client.instantiate(senderAddress, codeId, initMsg, label, 'auto') + + return { + contractAddress: result.contractAddress, + transactionHash: result.transactionHash, + logs: result.logs, + } + } + + const messages = (contractAddress: string) => { + const createBadge = (badge: Badge): CreateBadgeMessage => { + return { + sender: txSigner, + contract: contractAddress, + msg: { + create_badge: { + manager: badge.manager, + metadata: badge.metadata, + transferrable: badge.transferrable, + rule: badge.rule, + expiry: badge.expiry, + max_supply: badge.max_supply, + }, + }, + funds: [], + } + } + + const editBadge = (id: number, metadata: Metadata, editFee?: number): EditBadgeMessage => { + return { + sender: txSigner, + contract: contractAddress, + msg: { + edit_badge: { + id, + metadata, + }, + }, + funds: editFee ? [coin(editFee, 'ustars')] : [], + } + } + + const addKeys = (id: number, keys: string[]): AddKeysMessage => { + return { + sender: txSigner, + contract: contractAddress, + msg: { + add_keys: { + id, + keys, + }, + }, + funds: [], + } + } + + const purgeKeys = (id: number, limit?: number): PurgeKeysMessage => { + return { + sender: txSigner, + contract: contractAddress, + msg: { + purge_keys: { + id, + limit, + }, + }, + funds: [], + } + } + + const purgeOwners = (id: number, limit?: number): PurgeOwnersMessage => { + return { + sender: txSigner, + contract: contractAddress, + msg: { + purge_owners: { + id, + limit, + }, + }, + funds: [], + } + } + + const mintByMinter = (id: number, owners: string[]): MintByMinterMessage => { + return { + sender: txSigner, + contract: contractAddress, + msg: { + mint_by_minter: { + id, + owners, + }, + }, + funds: [], + } + } + + const mintByKey = (id: number, owner: string, signature: string): MintByKeyMessage => { + return { + sender: txSigner, + contract: contractAddress, + msg: { + mint_by_key: { + id, + owner, + signature, + }, + }, + funds: [], + } + } + + const airdropByKey = (id: number, recipients: string[], privateKey: string): CustomMessage => { + const msg: Record[] = [] + for (let i = 0; i < recipients.length; i++) { + const signature = generateSignature(id, recipients[i], privateKey) + msg.push({ + mint_by_key: { id, owner: recipients[i], signature }, + }) + } + return { + sender: txSigner, + contract: contractAddress, + msg, + funds: [], + } + } + + const mintByKeys = (id: number, owner: string, pubkey: string, signature: string): MintByKeysMessage => { + return { + sender: txSigner, + contract: contractAddress, + msg: { + mint_by_keys: { + id, + owner, + pubkey, + signature, + }, + }, + funds: [], + } + } + + const setNft = (nft: string): SetNftMessage => { + return { + sender: txSigner, + contract: contractAddress, + msg: { + set_nft: { + nft, + }, + }, + funds: [], + } + } + + return { + createBadge, + editBadge, + addKeys, + purgeKeys, + purgeOwners, + mintByMinter, + mintByKey, + airdropByKey, + mintByKeys, + setNft, + } + } + + return { use, instantiate, migrate, messages } +} diff --git a/contracts/badgeHub/index.ts b/contracts/badgeHub/index.ts new file mode 100644 index 0000000..6dc6461 --- /dev/null +++ b/contracts/badgeHub/index.ts @@ -0,0 +1,2 @@ +export * from './contract' +export * from './useContract' diff --git a/contracts/badgeHub/messages/execute.ts b/contracts/badgeHub/messages/execute.ts new file mode 100644 index 0000000..9c9f595 --- /dev/null +++ b/contracts/badgeHub/messages/execute.ts @@ -0,0 +1,176 @@ +import type { Badge, BadgeHubInstance, Metadata } from '../index' +import { useBadgeHubContract } from '../index' + +export type ExecuteType = typeof EXECUTE_TYPES[number] + +export const EXECUTE_TYPES = [ + 'create_badge', + 'edit_badge', + 'add_keys', + 'purge_keys', + 'purge_owners', + 'mint_by_minter', + 'mint_by_key', + 'mint_by_keys', + 'set_nft', +] as const + +export interface ExecuteListItem { + id: ExecuteType + name: string + description?: string +} + +export const EXECUTE_LIST: ExecuteListItem[] = [ + { + id: 'create_badge', + name: 'Create Badge', + description: `Create a new badge with the specified mint rule and metadata`, + }, + { + id: 'edit_badge', + name: 'Edit Badge', + description: ` Edit badge metadata for the badge with the specified ID`, + }, + // { + // id: 'add_keys', + // name: 'Add Keys', + // description: `Add keys to the badge with the specified ID`, + // }, + // { + // id: 'purge_keys', + // name: 'Purge Keys', + // description: `Purge keys from the badge with the specified ID`, + // }, + { + id: 'purge_owners', + name: 'Purge Owners', + description: `Purge owners from the badge with the specified ID`, + }, + // { + // id: 'mint_by_minter', + // name: 'Mint by Minter', + // description: `Mint a new token by the minter with the specified ID`, + // }, + { + id: 'mint_by_key', + name: 'Mint by Key', + description: `Mint a new token by the key with the specified ID`, + }, + // { + // id: 'mint_by_keys', + // name: 'Mint by Keys', + // description: `Mint a new token by the keys with the specified ID`, + // }, + { + id: 'set_nft', + name: 'Set NFT', + description: `Set the Badge NFT contract address for the Badge Hub contract`, + }, +] + +export interface DispatchExecuteProps { + type: ExecuteType + [k: string]: unknown +} + +type Select = T + +/** @see {@link BadgeHubInstance} */ +export type DispatchExecuteArgs = { + contract: string + messages?: BadgeHubInstance + txSigner: string +} & ( + | { type: undefined } + | { type: Select<'create_badge'>; badge: Badge } + | { type: Select<'edit_badge'>; id: number; metadata: Metadata; editFee?: number } + | { type: Select<'add_keys'>; id: number; keys: string[] } + | { type: Select<'purge_keys'>; id: number; limit?: number } + | { type: Select<'purge_owners'>; id: number; limit?: number } + | { type: Select<'mint_by_minter'>; id: number; owners: string[] } + | { type: Select<'mint_by_key'>; id: number; owner: string; signature: string } + | { type: Select<'mint_by_keys'>; id: number; owner: string; pubkey: string; signature: string } + | { type: Select<'set_nft'>; nft: string } +) + +export const dispatchExecute = async (args: DispatchExecuteArgs) => { + const { messages, txSigner } = args + if (!messages) { + throw new Error('cannot dispatch execute, messages is not defined') + } + switch (args.type) { + case 'create_badge': { + return messages.createBadge(txSigner, args.badge) + } + case 'edit_badge': { + return messages.editBadge(txSigner, args.id, args.metadata, args.editFee) + } + case 'add_keys': { + return messages.addKeys(txSigner, args.id, args.keys) + } + case 'purge_keys': { + return messages.purgeKeys(txSigner, args.id, args.limit) + } + case 'purge_owners': { + return messages.purgeOwners(txSigner, args.id, args.limit) + } + case 'mint_by_minter': { + return messages.mintByMinter(txSigner, args.id, args.owners) + } + case 'mint_by_key': { + return messages.mintByKey(txSigner, args.id, args.owner, args.signature) + } + case 'mint_by_keys': { + return messages.mintByKeys(txSigner, args.id, args.owner, args.pubkey, args.signature) + } + case 'set_nft': { + return messages.setNft(txSigner, args.nft) + } + default: { + throw new Error('unknown execute type') + } + } +} + +export const previewExecutePayload = (args: DispatchExecuteArgs) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const { messages } = useBadgeHubContract() + const { contract } = args + switch (args.type) { + case 'create_badge': { + return messages(contract)?.createBadge(args.badge) + } + case 'edit_badge': { + return messages(contract)?.editBadge(args.id, args.metadata) + } + case 'add_keys': { + return messages(contract)?.addKeys(args.id, args.keys) + } + case 'purge_keys': { + return messages(contract)?.purgeKeys(args.id, args.limit) + } + case 'purge_owners': { + return messages(contract)?.purgeOwners(args.id, args.limit) + } + case 'mint_by_minter': { + return messages(contract)?.mintByMinter(args.id, args.owners) + } + case 'mint_by_key': { + return messages(contract)?.mintByKey(args.id, args.owner, args.signature) + } + case 'mint_by_keys': { + return messages(contract)?.mintByKeys(args.id, args.owner, args.pubkey, args.signature) + } + case 'set_nft': { + return messages(contract)?.setNft(args.nft) + } + default: { + return {} + } + } +} + +export const isEitherType = (type: unknown, arr: T[]): type is T => { + return arr.some((val) => type === val) +} diff --git a/contracts/badgeHub/messages/query.ts b/contracts/badgeHub/messages/query.ts new file mode 100644 index 0000000..39538a7 --- /dev/null +++ b/contracts/badgeHub/messages/query.ts @@ -0,0 +1,53 @@ +import type { BadgeHubInstance } from '../contract' + +export type QueryType = typeof QUERY_TYPES[number] + +export const QUERY_TYPES = ['config', 'getBadge', 'getBadges', 'getKey', 'getKeys'] as const + +export interface QueryListItem { + id: QueryType + name: string + description?: string +} + +export const QUERY_LIST: QueryListItem[] = [ + { id: 'config', name: 'Config', description: 'View current config' }, + { id: 'getBadge', name: 'Query Badge', description: 'Query a badge by ID' }, + { id: 'getBadges', name: 'Query Badges', description: 'Query a list of badges' }, + // { id: 'getKey', name: 'Query Key', description: 'Query a key by ID to see if it's whitelisted' }, + // { id: 'getKeys', name: 'Query Keys', description: 'Query the list of whitelisted keys' }, +] + +export interface DispatchQueryProps { + id: number + pubkey: string + messages: BadgeHubInstance | undefined + type: QueryType + startAfterNumber: number + startAfterString: string + limit: number +} + +export const dispatchQuery = (props: DispatchQueryProps) => { + const { id, pubkey, messages, type, startAfterNumber, startAfterString, limit } = props + switch (type) { + case 'config': { + return messages?.getConfig() + } + case 'getBadge': { + return messages?.getBadge(id) + } + case 'getBadges': { + return messages?.getBadges(startAfterNumber, limit) + } + case 'getKey': { + return messages?.getKey(id, pubkey) + } + case 'getKeys': { + return messages?.getKeys(id, startAfterString, limit) + } + default: { + throw new Error('unknown query type') + } + } +} diff --git a/contracts/badgeHub/useContract.ts b/contracts/badgeHub/useContract.ts new file mode 100644 index 0000000..e196af5 --- /dev/null +++ b/contracts/badgeHub/useContract.ts @@ -0,0 +1,114 @@ +import type { Coin } from '@cosmjs/proto-signing' +import type { logs } from '@cosmjs/stargate' +import { useWallet } from 'contexts/wallet' +import { useCallback, useEffect, useState } from 'react' + +import type { BadgeHubContract, BadgeHubInstance, BadgeHubMessages, MigrateResponse } from './contract' +import { badgeHub as initContract } from './contract' + +/*export interface InstantiateResponse { + /** The address of the newly instantiated contract *-/ + readonly contractAddress: string + readonly logs: readonly logs.Log[] + /** Block height in which the transaction is included *-/ + readonly height: number + /** Transaction hash (might be used as transaction ID). Guaranteed to be non-empty upper-case hex *-/ + readonly transactionHash: string + readonly gasWanted: number + readonly gasUsed: number +}*/ + +interface InstantiateResponse { + readonly contractAddress: string + readonly transactionHash: string + readonly logs: readonly logs.Log[] +} + +export interface UseBadgeHubContractProps { + instantiate: ( + codeId: number, + initMsg: Record, + label: string, + admin?: string, + funds?: Coin[], + ) => Promise + migrate: (contractAddress: string, codeId: number, migrateMsg: Record) => Promise + use: (customAddress: string) => BadgeHubInstance | undefined + updateContractAddress: (contractAddress: string) => void + getContractAddress: () => string | undefined + messages: (contractAddress: string) => BadgeHubMessages | undefined +} + +export function useBadgeHubContract(): UseBadgeHubContractProps { + const wallet = useWallet() + + const [address, setAddress] = useState('') + const [badgeHub, setBadgeHub] = useState() + + useEffect(() => { + setAddress(localStorage.getItem('contract_address') || '') + }, []) + + useEffect(() => { + const BadgeHubBaseContract = initContract(wallet.getClient(), wallet.address) + setBadgeHub(BadgeHubBaseContract) + }, [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 (!badgeHub) { + reject(new Error('Contract is not initialized.')) + return + } + badgeHub.instantiate(wallet.address, codeId, initMsg, label, admin).then(resolve).catch(reject) + }) + }, + [badgeHub, wallet], + ) + + const migrate = useCallback( + (contractAddress: string, codeId: number, migrateMsg: Record): Promise => { + return new Promise((resolve, reject) => { + if (!badgeHub) { + reject(new Error('Contract is not initialized.')) + return + } + console.log(wallet.address, contractAddress, codeId) + badgeHub.migrate(wallet.address, contractAddress, codeId, migrateMsg).then(resolve).catch(reject) + }) + }, + [badgeHub, wallet], + ) + + const use = useCallback( + (customAddress = ''): BadgeHubInstance | undefined => { + return badgeHub?.use(address || customAddress) + }, + [badgeHub, address], + ) + + const getContractAddress = (): string | undefined => { + return address + } + + const messages = useCallback( + (customAddress = ''): BadgeHubMessages | undefined => { + return badgeHub?.messages(address || customAddress) + }, + [badgeHub, address], + ) + + return { + instantiate, + use, + updateContractAddress, + getContractAddress, + messages, + migrate, + } +} diff --git a/env.d.ts b/env.d.ts index 3852406..bb733fe 100644 --- a/env.d.ts +++ b/env.d.ts @@ -21,6 +21,10 @@ declare namespace NodeJS { readonly NEXT_PUBLIC_BASE_FACTORY_ADDRESS: string readonly NEXT_PUBLIC_SG721_NAME_ADDRESS: string readonly NEXT_PUBLIC_BASE_MINTER_CODE_ID: string + readonly NEXT_PUBLIC_BADGE_HUB_CODE_ID: string + 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_PINATA_ENDPOINT_URL: string readonly NEXT_PUBLIC_API_URL: string diff --git a/package.json b/package.json index c03cb4f..29e79d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "stargaze-studio", - "version": "0.4.4", + "version": "0.4.5", "workspaces": [ "packages/*" ], @@ -21,6 +21,7 @@ "@fontsource/jetbrains-mono": "^4", "@fontsource/roboto": "^4", "@headlessui/react": "^1", + "jscrypto": "^1.0.3", "@keplr-wallet/cosmos": "^0.9.16", "@pinata/sdk": "^1.1.26", "@popperjs/core": "^2", @@ -31,10 +32,12 @@ "clsx": "^1", "compare-versions": "^4", "daisyui": "^2.19.0", + "html-to-image": "1.11.11", "match-sorter": "^6", "next": "^12", "next-seo": "^4", "nft.storage": "^6.3.0", + "qrcode.react": "3.1.0", "react": "^18", "react-datetime-picker": "^3", "react-dom": "^18", @@ -45,6 +48,8 @@ "react-query": "^3", "react-tracked": "^1", "scheduler": "^0", + "secp256k1": "^4.0.3", + "tailwindcss-opentype": "1.1.0", "zustand": "^3" }, "devDependencies": { @@ -52,9 +57,11 @@ "@types/node": "^14", "@types/react": "^18", "@types/react-datetime-picker": "^3", + "@types/secp256k1": "^4.0.2", "autoprefixer": "^10", "husky": "^7", "lint-staged": "^12", + "object-sizeof": "^1.6.0", "postcss": "^8", "tailwindcss": "^3", "typescript": "^4" diff --git a/pages/badges/actions.tsx b/pages/badges/actions.tsx new file mode 100644 index 0000000..34c7749 --- /dev/null +++ b/pages/badges/actions.tsx @@ -0,0 +1,214 @@ +/* eslint-disable eslint-comments/disable-enable-pair */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ + +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import type { MintRule } from 'components/badges/creation/ImageUploadDetails' +import { BadgeQueries } from 'components/badges/queries/Queries' +import { ContractPageHeader } from 'components/ContractPageHeader' +import { AddressInput, NumberInput } from 'components/forms/FormInput' +import { useInputState } from 'components/forms/FormInput.hooks' +import { useContracts } from 'contexts/contracts' +import { useWallet } from 'contexts/wallet' +import type { NextPage } from 'next' +import { useRouter } from 'next/router' +import { NextSeo } from 'next-seo' +import { useEffect, useMemo, useState } from 'react' +import toast from 'react-hot-toast' +import { useDebounce } from 'utils/debounce' +import { withMetadata } from 'utils/layout' +import { links } from 'utils/links' + +import { BadgeActions } from '../../components/badges/actions/Action' +import { useNumberInputState } from '../../components/forms/FormInput.hooks' +import { BADGE_HUB_ADDRESS } from '../../utils/constants' + +const BadgeActionsPage: NextPage = () => { + const { badgeHub: badgeHubContract } = useContracts() + const wallet = useWallet() + + const [action, setAction] = useState(false) + const [mintRule, setMintRule] = useState('by_key') + + const badgeHubContractState = useInputState({ + id: 'badge-hub-contract-address', + name: 'badge-hub-contract-address', + title: 'Badge Hub Contract Address', + subtitle: 'Address of the Badge Hub contract', + defaultValue: BADGE_HUB_ADDRESS, + }) + + const badgeIdState = useNumberInputState({ + id: 'badge-id', + name: 'badge-id', + title: 'Badge ID', + subtitle: 'The ID of the badge to interact with', + defaultValue: 1, + }) + + const debouncedBadgeHubContractState = useDebounce(badgeHubContractState.value, 300) + const debouncedBadgeIdState = useDebounce(badgeIdState.value, 300) + + const badgeHubMessages = useMemo( + () => badgeHubContract?.use(badgeHubContractState.value), + [badgeHubContract, badgeHubContractState.value], + ) + + const badgeHubContractAddress = badgeHubContractState.value + const badgeId = badgeIdState.value + + const router = useRouter() + + useEffect(() => { + if (badgeHubContractAddress.length > 0 && badgeId < 1) { + void router.replace({ query: { badgeHubContractAddress } }) + } + if (badgeId > 0 && badgeHubContractAddress.length === 0) { + void router.replace({ query: { badgeId } }) + } + if (badgeId > 0 && badgeHubContractAddress.length > 0) { + void router.replace({ query: { badgeHubContractAddress, badgeId } }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [badgeHubContractAddress, badgeId]) + + useEffect(() => { + const initialBadgeHub = new URL(document.URL).searchParams.get('badgeHubContractAddress') + const initialBadgeId = new URL(document.URL).searchParams.get('badgeId') + if (initialBadgeHub && initialBadgeHub.length > 0) badgeHubContractState.onChange(initialBadgeHub) + if (initialBadgeId && initialBadgeId.length > 0) + badgeIdState.onChange(isNaN(parseInt(initialBadgeId)) ? 0 : parseInt(initialBadgeId)) + }, []) + + useEffect(() => { + async function getMintRule() { + if (wallet.client && debouncedBadgeHubContractState.length > 0 && debouncedBadgeIdState > 0) { + const client = wallet.client + const data = await toast.promise( + client.queryContractSmart(debouncedBadgeHubContractState, { + badge: { + id: badgeId, + }, + }), + { + loading: 'Retrieving Mint Rule...', + error: 'Mint Rule retrieval failed.', + success: 'Mint Rule retrieved.', + }, + ) + console.log(data) + const rule = data.rule + console.log(rule) + return rule + } + } + void getMintRule() + .then((rule) => { + if (JSON.stringify(rule).includes('keys')) { + setMintRule('by_keys') + } else if (JSON.stringify(rule).includes('minter')) { + setMintRule('by_minter') + } else { + setMintRule('by_key') + } + }) + .catch((err) => { + console.log(err) + setMintRule('not_resolved') + console.log('Unable to retrieve Mint Rule. Defaulting to "by_key".') + }) + }, [debouncedBadgeHubContractState, debouncedBadgeIdState, wallet.client]) + + return ( +
+ + + +
+
+ +
+ +
+ Mint Rule: + + {mintRule + .toString() + .split('_') + .map((s) => s.charAt(0).toUpperCase() + s.substring(1)) + .join(' ')} + +
+
+
+
+
+
+
+ { + setAction(false) + }} + type="radio" + value="false" + /> + +
+
+ { + setAction(true) + }} + type="radio" + value="true" + /> + +
+
+
+ {(action && ( + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + + )) || ( + + )} +
+
+
+
+
+ ) +} + +export default withMetadata(BadgeActionsPage, { center: false }) diff --git a/pages/badges/create.tsx b/pages/badges/create.tsx new file mode 100644 index 0000000..8cc7a95 --- /dev/null +++ b/pages/badges/create.tsx @@ -0,0 +1,477 @@ +/* eslint-disable eslint-comments/disable-enable-pair */ + +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ + +//import { coin } from '@cosmjs/proto-signing' +import clsx from 'clsx' +import { Alert } from 'components/Alert' +import { Anchor } from 'components/Anchor' +import { BadgeConfirmationModal } from 'components/BadgeConfirmationModal' +import { BadgeLoadingModal } from 'components/BadgeLoadingModal' +import type { BadgeDetailsDataProps } from 'components/badges/creation/BadgeDetails' +import { BadgeDetails } from 'components/badges/creation/BadgeDetails' +import type { ImageUploadDetailsDataProps, MintRule } from 'components/badges/creation/ImageUploadDetails' +import { ImageUploadDetails } from 'components/badges/creation/ImageUploadDetails' +import { Button } from 'components/Button' +import { Conditional } from 'components/Conditional' +import { TextInput } from 'components/forms/FormInput' +import { useInputState } from 'components/forms/FormInput.hooks' +import { Tooltip } from 'components/Tooltip' +import { useContracts } from 'contexts/contracts' +import { useWallet } from 'contexts/wallet' +import type { DispatchExecuteArgs as BadgeHubDispatchExecuteArgs } from 'contracts/badgeHub/messages/execute' +import { dispatchExecute as badgeHubDispatchExecute } from 'contracts/badgeHub/messages/execute' +import * as crypto from 'crypto' +import { toPng } from 'html-to-image' +import type { NextPage } from 'next' +import { NextSeo } from 'next-seo' +import { QRCodeSVG } from 'qrcode.react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { toast } from 'react-hot-toast' +import { FaCopy, FaSave } from 'react-icons/fa' +import * as secp256k1 from 'secp256k1' +import { upload } from 'services/upload' +import { copy } from 'utils/clipboard' +import { BADGE_HUB_ADDRESS, BLOCK_EXPLORER_URL, NETWORK } from 'utils/constants' +import { withMetadata } from 'utils/layout' +import { links } from 'utils/links' +import { truncateMiddle } from 'utils/text' + +const BadgeCreationPage: NextPage = () => { + const wallet = useWallet() + const { badgeHub: badgeHubContract } = useContracts() + const scrollRef = useRef(null) + + const badgeHubMessages = useMemo(() => badgeHubContract?.use(BADGE_HUB_ADDRESS), [badgeHubContract, wallet.address]) + + const [imageUploadDetails, setImageUploadDetails] = useState(null) + const [badgeDetails, setBadgeDetails] = useState(null) + + const [uploading, setUploading] = useState(false) + const [creatingBadge, setCreatingBadge] = useState(false) + const [readyToCreateBadge, setReadyToCreateBadge] = useState(false) + const [mintRule, setMintRule] = useState('by_key') + + const [badgeId, setBadgeId] = useState(null) + const [imageUrl, setImageUrl] = useState(null) + const [createdBadgeKey, setCreatedBadgeKey] = useState(undefined) + const [transactionHash, setTransactionHash] = useState(null) + const qrRef = useRef(null) + + const keyState = useInputState({ + id: 'key', + name: 'key', + title: 'Public Key', + subtitle: 'Part of the key pair to be utilized for post-creation access control', + }) + + const performBadgeCreationChecks = () => { + try { + setReadyToCreateBadge(false) + checkImageUploadDetails() + checkBadgeDetails() + setTimeout(() => { + setReadyToCreateBadge(true) + }, 100) + } catch (error: any) { + toast.error(error.message, { style: { maxWidth: 'none' } }) + setUploading(false) + setReadyToCreateBadge(false) + } + } + + const handleImageUrl = async () => { + try { + setImageUrl(null) + setBadgeId(null) + setTransactionHash(null) + if (imageUploadDetails?.uploadMethod === 'new') { + setUploading(true) + const coverUrl = await upload( + [imageUploadDetails.assetFile] as File[], + imageUploadDetails.uploadService, + 'cover', + imageUploadDetails.nftStorageApiKey as string, + imageUploadDetails.pinataApiKey as string, + imageUploadDetails.pinataSecretKey as string, + ).then((imageBaseUrl) => { + setUploading(false) + return `ipfs://${imageBaseUrl}/${imageUploadDetails.assetFile?.name as string}` + }) + setImageUrl(coverUrl) + return coverUrl + } + setImageUrl(imageUploadDetails?.imageUrl as string) + return imageUploadDetails?.imageUrl as string + } catch (error: any) { + toast.error(error.message, { style: { maxWidth: 'none' } }) + setCreatingBadge(false) + setUploading(false) + throw new Error("Couldn't upload the image.") + } + } + + const createNewBadge = async () => { + try { + if (!wallet.initialized) throw new Error('Wallet not connected') + if (!badgeHubContract) throw new Error('Contract not found') + setCreatingBadge(true) + const coverUrl = await handleImageUrl() + + const badge = { + manager: badgeDetails?.manager as string, + metadata: { + name: badgeDetails?.name || undefined, + description: badgeDetails?.description || undefined, + image: coverUrl || undefined, + image_data: badgeDetails?.image_data || undefined, + external_url: badgeDetails?.external_url || undefined, + attributes: badgeDetails?.attributes || undefined, + background_color: badgeDetails?.background_color || undefined, + animation_url: badgeDetails?.animation_url || undefined, + youtube_url: badgeDetails?.youtube_url || undefined, + }, + transferrable: badgeDetails?.transferrable as boolean, + rule: { + by_key: keyState.value, + }, + expiry: badgeDetails?.expiry || undefined, + max_supply: badgeDetails?.max_supply || undefined, + } + + const payload: BadgeHubDispatchExecuteArgs = { + contract: BADGE_HUB_ADDRESS, + messages: badgeHubMessages, + txSigner: wallet.address, + badge, + type: 'create_badge', + } + const data = await badgeHubDispatchExecute(payload) + console.log(data) + setCreatingBadge(false) + setTransactionHash(data.split(':')[0]) + setBadgeId(data.split(':')[1]) + } catch (error: any) { + toast.error(error.message, { style: { maxWidth: 'none' } }) + setCreatingBadge(false) + setUploading(false) + } + } + + const checkImageUploadDetails = () => { + if (!wallet.initialized) throw new Error('Wallet not connected.') + if (!imageUploadDetails) { + throw new Error('Please specify the image related details.') + } + + if (imageUploadDetails.uploadMethod === 'new' && imageUploadDetails.assetFile === undefined) { + throw new Error('Please select the image file') + } + if (imageUploadDetails.uploadMethod === 'new') { + if (imageUploadDetails.uploadService === 'nft-storage') { + if (imageUploadDetails.nftStorageApiKey === '') { + throw new Error('Please enter a valid NFT.Storage API key') + } + } else if (imageUploadDetails.pinataApiKey === '' || imageUploadDetails.pinataSecretKey === '') { + throw new Error('Please enter Pinata API and secret keys') + } + } + if (imageUploadDetails.uploadMethod === 'existing' && !imageUploadDetails.imageUrl?.includes('ipfs://')) { + throw new Error('Please specify a valid image URL') + } + } + + const checkBadgeDetails = () => { + if (!badgeDetails) throw new Error('Please fill out the required fields') + if (keyState.value === '' || !createdBadgeKey) throw new Error('Please generate a public key') + if (badgeDetails.external_url) { + try { + const url = new URL(badgeDetails.external_url) + } catch (e: any) { + throw new Error(`Invalid external url: Make sure to include the protocol (e.g. https://)`) + } + } + } + + const handleGenerateKey = () => { + let privKey: Buffer + do { + privKey = crypto.randomBytes(32) + } while (!secp256k1.privateKeyVerify(privKey)) + + const privateKey = privKey.toString('hex') + setCreatedBadgeKey(privateKey) + console.log('Private Key: ', privateKey) + + const publicKey = Buffer.from(secp256k1.publicKeyCreate(privKey)).toString('hex') + setBadgeId(null) + keyState.onChange(publicKey) + } + + const handleDownloadQr = async () => { + const qrElement = qrRef.current + await toPng(qrElement as HTMLElement).then((dataUrl) => { + const link = document.createElement('a') + link.download = `badge-${badgeId as string}.png` + link.href = dataUrl + link.click() + }) + } + + // copy claim url to clipboard + const copyClaimURL = async () => { + const baseURL = NETWORK === 'testnet' ? 'https://badges.publicawesome.dev' : 'https://badges.stargaze.zone' + const claimURL = `${baseURL}/?id=${badgeId as string}&key=${createdBadgeKey as string}` + await navigator.clipboard.writeText(claimURL) + toast.success('Copied claim URL to clipboard') + } + + const checkwalletBalance = () => { + if (!wallet.initialized) throw new Error('Wallet not connected.') + // TODO: estimate creation cost and check wallet balance + } + useEffect(() => { + if (badgeId !== null) scrollRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [badgeId]) + + useEffect(() => { + setImageUrl(imageUploadDetails?.imageUrl as string) + }, [imageUploadDetails?.imageUrl]) + + useEffect(() => { + setBadgeId(null) + setReadyToCreateBadge(false) + }, [imageUploadDetails?.uploadMethod]) + + return ( +
+ + +
+

Create Badge

+ + + + + +

+ Make sure you check our{' '} + + documentation + {' '} + on how to create a new badge. +

+
+
+ + +
+
+
+ +
+
+ + +
+
+
+ Badge ID:{` ${badgeId as string}`} +
+ Private Key: + + + +
+ Transaction Hash: {' '} + + + {transactionHash} + + + + + {transactionHash} + + +
+
+
+ + You may click{' '} + + here + {' '} + or scan the QR code to claim a badge. + +
+
+ You may download the QR code or copy the claim URL to share with others. +
+
+
+
+
+
+
+ +
+
+
+ +
+
+ +
+
+ +
+
+
+ +
+ + +
+ + +
+ +
+ +
+ + + + + +
+ +
+
+
+ ) +} + +export default withMetadata(BadgeCreationPage, { center: false }) diff --git a/pages/badges/index.tsx b/pages/badges/index.tsx new file mode 100644 index 0000000..f23c335 --- /dev/null +++ b/pages/badges/index.tsx @@ -0,0 +1,37 @@ +import { HomeCard } from 'components/HomeCard' +import type { NextPage } from 'next' +// import Brand from 'public/brand/brand.svg' +import { withMetadata } from 'utils/layout' + +const HomePage: NextPage = () => { + return ( +
+
+ {/* */} +
+

Badges

+

+ Here you can create badges, execute badge related actions and query the results. +
+

+ +
+ +
+ +
+ + Select an asset, enter badge metadata and create a new badge. + + + View a list of your badges. + + + Execute badge related actions. + +
+
+ ) +} + +export default withMetadata(HomePage, { center: false }) diff --git a/pages/badges/myBadges.tsx b/pages/badges/myBadges.tsx new file mode 100644 index 0000000..93bf831 --- /dev/null +++ b/pages/badges/myBadges.tsx @@ -0,0 +1,121 @@ +/* eslint-disable eslint-comments/disable-enable-pair */ + +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ + +import axios from 'axios' +import { Alert } from 'components/Alert' +import { Anchor } from 'components/Anchor' +import { Conditional } from 'components/Conditional' +import { ContractPageHeader } from 'components/ContractPageHeader' +import { useWallet } from 'contexts/wallet' +import type { NextPage } from 'next' +import { NextSeo } from 'next-seo' +import { useCallback, useEffect, useState } from 'react' +import { FaSlidersH, FaUser } from 'react-icons/fa' +import { API_URL, BADGE_HUB_ADDRESS, STARGAZE_URL } from 'utils/constants' +import { withMetadata } from 'utils/layout' +import { links } from 'utils/links' + +const BadgeList: NextPage = () => { + const wallet = useWallet() + const [myBadges, setMyBadges] = useState([]) + + useEffect(() => { + const fetchBadges = async () => { + await axios + .get(`${API_URL}/api/v1beta/badges/${wallet.address}`) + .then((response) => { + const badgeData = response.data + setMyBadges(badgeData) + }) + .catch(console.error) + } + fetchBadges().catch(console.error) + }, [wallet.address]) + + const renderTable = useCallback(() => { + return ( +
+ {myBadges.length > 0 && ( + + + + + + + + + {myBadges.map((badge: any, index: any) => { + return ( + + + + + + ) + })} + +
Badge NameBadge Description +
+
+
+
+ Cover +
+
+
+

{badge.name}

+

Badge ID: {badge.tokenId}

+
+
+
+ {badge.description} + {/*
*/} + {/* */} +
+
+ + + + + + + +
+
+ )} +
+ ) + }, [myBadges, wallet.address]) + + return ( +
+ + +
+
{renderTable()}
+
+ + + You currently don't own any badges. + +
+ ) +} +export default withMetadata(BadgeList, { center: false }) diff --git a/pages/contracts/badgeHub/execute.tsx b/pages/contracts/badgeHub/execute.tsx new file mode 100644 index 0000000..4980e61 --- /dev/null +++ b/pages/contracts/badgeHub/execute.tsx @@ -0,0 +1,682 @@ +/* eslint-disable eslint-comments/disable-enable-pair */ +/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */ + +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ + +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { 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 { ExecuteCombobox } from 'components/contracts/badgeHub/ExecuteCombobox' +import { useExecuteComboboxState } from 'components/contracts/badgeHub/ExecuteCombobox.hooks' +import { FormControl } from 'components/FormControl' +import { AddressInput, NumberInput } from 'components/forms/FormInput' +import { useInputState, useNumberInputState } from 'components/forms/FormInput.hooks' +import { InputDateTime } from 'components/InputDateTime' +import { JsonPreview } from 'components/JsonPreview' +import { LinkTabs } from 'components/LinkTabs' +import { badgeHubLinkTabs } from 'components/LinkTabs.data' +import { Tooltip } from 'components/Tooltip' +import { TransactionHash } from 'components/TransactionHash' +import { useContracts } from 'contexts/contracts' +import { useWallet } from 'contexts/wallet' +import type { Badge } from 'contracts/badgeHub' +import type { DispatchExecuteArgs } from 'contracts/badgeHub/messages/execute' +import { dispatchExecute, isEitherType, previewExecutePayload } from 'contracts/badgeHub/messages/execute' +import * as crypto from 'crypto' +import { toPng } from 'html-to-image' +import type { NextPage } from 'next' +import { useRouter } from 'next/router' +import { NextSeo } from 'next-seo' +import sizeof from 'object-sizeof' +import { QRCodeCanvas } from 'qrcode.react' +import type { FormEvent } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { toast } from 'react-hot-toast' +import { FaArrowRight, FaCopy, FaSave } from 'react-icons/fa' +import { useMutation } from 'react-query' +import * as secp256k1 from 'secp256k1' +import { copy } from 'utils/clipboard' +import { NETWORK } from 'utils/constants' +import { sha256 } from 'utils/hash' +import { withMetadata } from 'utils/layout' +import { links } from 'utils/links' +import { resolveAddress } from 'utils/resolveAddress' +import { truncateMiddle } from 'utils/text' + +import { TextInput } from '../../../components/forms/FormInput' +import { MetadataAttributes } from '../../../components/forms/MetadataAttributes' +import { useMetadataAttributesState } from '../../../components/forms/MetadataAttributes.hooks' +import { BADGE_HUB_ADDRESS } from '../../../utils/constants' + +const BadgeHubExecutePage: NextPage = () => { + const { badgeHub: contract } = useContracts() + const wallet = useWallet() + const [lastTx, setLastTx] = useState('') + const [badge, setBadge] = useState() + + const [timestamp, setTimestamp] = useState(undefined) + const [transferrable, setTransferrable] = useState(false) + const [createdBadgeId, setCreatedBadgeId] = useState(null) + const [createdBadgeKey, setCreatedBadgeKey] = useState(undefined) + const [resolvedOwnerAddress, setResolvedOwnerAddress] = useState('') + const [signature, setSignature] = useState('') + const [editFee, setEditFee] = useState(undefined) + const [triggerDispatch, setTriggerDispatch] = useState(false) + const qrRef = useRef(null) + + const comboboxState = useExecuteComboboxState() + const type = comboboxState.value?.id + + const badgeIdState = useNumberInputState({ + id: 'badge-id', + name: 'badgeId', + title: 'Badge ID', + subtitle: 'Enter the badge ID', + defaultValue: 1, + }) + + const maxSupplyState = useNumberInputState({ + id: 'max-supply', + name: 'max-supply', + title: 'Max Supply', + subtitle: 'Maximum number of badges that can be minted', + }) + + const contractState = useInputState({ + id: 'contract-address', + name: 'contract-address', + title: 'Badge Hub Address', + subtitle: 'Address of the Badge Hub contract', + defaultValue: BADGE_HUB_ADDRESS, + }) + const contractAddress = contractState.value + + // Metadata related fields + const managerState = useInputState({ + id: 'manager-address', + name: 'manager', + title: 'Manager', + subtitle: 'Badge Hub Manager', + defaultValue: wallet.address, + }) + + const nameState = useInputState({ + id: 'metadata-name', + name: 'metadata-name', + title: 'Name', + subtitle: 'Name of the badge', + }) + + const descriptionState = useInputState({ + id: 'metadata-description', + name: 'metadata-description', + title: 'Description', + subtitle: 'Description of the badge', + }) + + const imageState = useInputState({ + id: 'metadata-image', + name: 'metadata-image', + title: 'Image', + subtitle: 'Badge Image URL', + }) + + const imageDataState = useInputState({ + id: 'metadata-image-data', + name: 'metadata-image-data', + title: 'Image Data', + subtitle: 'Raw SVG image data', + }) + + const externalUrlState = useInputState({ + id: 'metadata-external-url', + name: 'metadata-external-url', + title: 'External URL', + subtitle: 'External URL for the badge', + }) + + const attributesState = useMetadataAttributesState() + + const backgroundColorState = useInputState({ + id: 'metadata-background-color', + name: 'metadata-background-color', + title: 'Background Color', + subtitle: 'Background color of the badge', + }) + + const animationUrlState = useInputState({ + id: 'metadata-animation-url', + name: 'metadata-animation-url', + title: 'Animation URL', + subtitle: 'Animation URL for the badge', + }) + + const youtubeUrlState = useInputState({ + id: 'metadata-youtube-url', + name: 'metadata-youtube-url', + title: 'YouTube URL', + subtitle: 'YouTube URL for the badge', + }) + // Rules related fields + const keyState = useInputState({ + id: 'key', + name: 'key', + title: 'Public Key', + subtitle: 'Part of the key pair to be utilized for post-creation access control', + }) + + const ownerState = useInputState({ + id: 'owner-address', + name: 'owner', + title: 'Owner', + subtitle: 'The owner of the badge', + }) + + const pubkeyState = useInputState({ + id: 'pubkey', + name: 'pubkey', + title: 'Pubkey', + subtitle: 'The public key for the badge', + }) + + const privateKeyState = useInputState({ + id: 'privateKey', + name: 'privateKey', + title: 'Private Key', + subtitle: 'The private key generated during badge creation', + }) + + const nftState = useInputState({ + id: 'nft-address', + name: 'nft-address', + title: 'NFT Contract Address', + subtitle: 'The NFT Contract Address for the badge', + }) + + const limitState = useNumberInputState({ + id: 'limit', + name: 'limit', + title: 'Limit', + subtitle: 'Number of keys/owners to execute the action for', + }) + + const showBadgeField = type === 'create_badge' + const showMetadataField = isEitherType(type, ['create_badge', 'edit_badge']) + const showIdField = isEitherType(type, ['edit_badge', 'mint_by_key']) + const showNFTField = type === 'set_nft' + const showOwnerField = type === 'mint_by_key' + const showPrivateKeyField = type === 'mint_by_key' + + const messages = useMemo(() => contract?.use(contractState.value), [contract, wallet.address, contractState.value]) + const payload: DispatchExecuteArgs = { + badge: { + manager: badge?.manager || managerState.value, + metadata: { + name: nameState.value || undefined, + description: descriptionState.value || undefined, + image: imageState.value || undefined, + image_data: imageDataState.value || undefined, + external_url: externalUrlState.value || undefined, + attributes: + attributesState.values[0]?.trait_type && attributesState.values[0]?.value + ? attributesState.values + .map((attr) => ({ + trait_type: attr.trait_type, + value: attr.value, + })) + .filter((attr) => attr.trait_type && attr.value) + : undefined, + background_color: backgroundColorState.value || undefined, + animation_url: animationUrlState.value || undefined, + youtube_url: youtubeUrlState.value || undefined, + }, + transferrable, + rule: { + by_key: keyState.value, + }, + expiry: timestamp ? timestamp.getTime() * 1000000 : undefined, + max_supply: maxSupplyState.value || undefined, + }, + metadata: { + name: nameState.value || undefined, + description: descriptionState.value || undefined, + image: imageState.value || undefined, + image_data: imageDataState.value || undefined, + external_url: externalUrlState.value || undefined, + attributes: + attributesState.values[0]?.trait_type && attributesState.values[0]?.value + ? attributesState.values + .map((attr) => ({ + trait_type: attr.trait_type, + value: attr.value, + })) + .filter((attr) => attr.trait_type && attr.value) + : undefined, + background_color: backgroundColorState.value || undefined, + animation_url: animationUrlState.value || undefined, + youtube_url: youtubeUrlState.value || undefined, + }, + id: badgeIdState.value, + owner: ownerState.value, + pubkey: pubkeyState.value, + signature, + keys: [], + limit: limitState.value, + owners: [], + nft: nftState.value, + editFee, + contract: contractState.value, + messages, + txSigner: wallet.address, + type, + } + 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.') + } + if (contractState.value === '') { + throw new Error('Please enter the contract address.') + } + if (wallet.client && type === 'edit_badge') { + const feeRateRaw = await wallet.client.queryContractRaw( + contractAddress, + toUtf8(Buffer.from(Buffer.from('fee_rate').toString('hex'), 'hex').toString()), + ) + const feeRate = JSON.parse(new TextDecoder().decode(feeRateRaw as Uint8Array)) + + await toast + .promise( + wallet.client.queryContractSmart(contractAddress, { + badge: { id: badgeIdState.value }, + }), + { + error: `Edit Fee calculation failed!`, + loading: 'Calculating Edit Fee...', + success: (currentBadge) => { + console.log('Current badge: ', currentBadge) + return `Current metadata is ${ + Number(sizeof(currentBadge.metadata)) + Number(sizeof(currentBadge.metadata.attributes)) + } bytes in size.` + }, + }, + ) + .then((currentBadge) => { + // TODO - Go over the calculation + const currentBadgeMetadataSize = + Number(sizeof(currentBadge.metadata)) + Number(sizeof(currentBadge.metadata.attributes) * 2) + console.log('Current badge metadata size: ', currentBadgeMetadataSize) + const newBadgeMetadataSize = + Number(sizeof(badge?.metadata)) + Number(sizeof(badge?.metadata.attributes)) * 2 + console.log('New badge metadata size: ', newBadgeMetadataSize) + if (newBadgeMetadataSize > currentBadgeMetadataSize) { + const calculatedFee = ((newBadgeMetadataSize - currentBadgeMetadataSize) * Number(feeRate.metadata)) / 2 + setEditFee(calculatedFee) + setTriggerDispatch(!triggerDispatch) + } else { + setEditFee(undefined) + setTriggerDispatch(!triggerDispatch) + } + }) + .catch((error) => { + throw new Error(String(error).substring(String(error).lastIndexOf('Error:') + 7)) + }) + } else { + const txHash = await toast.promise(dispatchExecute(payload), { + error: `${type.charAt(0).toUpperCase() + type.slice(1)} execute failed!`, + loading: 'Executing message...', + success: (tx) => `Transaction ${tx.split(':')[0]} success!`, + }) + if (txHash) { + setLastTx(txHash.split(':')[0]) + setCreatedBadgeId(txHash.split(':')[1]) + } + } + }, + { + onError: (error) => { + toast.error(String(error), { style: { maxWidth: 'none' } }) + }, + }, + ) + + const handleGenerateKey = () => { + let privKey: Buffer + do { + privKey = crypto.randomBytes(32) + } while (!secp256k1.privateKeyVerify(privKey)) + + const privateKey = privKey.toString('hex') + setCreatedBadgeKey(privateKey) + console.log('Private Key: ', privateKey) + + const publicKey = Buffer.from(secp256k1.publicKeyCreate(privKey)).toString('hex') + + keyState.onChange(publicKey) + setCreatedBadgeId(null) + } + + const handleGenerateSignature = (id: number, owner: string, privateKey: string) => { + try { + const message = `claim badge ${id} for user ${owner}` + + const privKey = Buffer.from(privateKey, 'hex') + // const pubKey = Buffer.from(secp256k1.publicKeyCreate(privKey, true)) + const msgBytes = Buffer.from(message, 'utf8') + const msgHashBytes = sha256(msgBytes) + const signedMessage = secp256k1.ecdsaSign(msgHashBytes, privKey) + setSignature(Buffer.from(signedMessage.signature).toString('hex')) + } catch (error) { + console.log(error) + toast.error('Error generating signature.') + } + } + + const handleDownloadQr = async () => { + const qrElement = qrRef.current + await toPng(qrElement as HTMLElement).then((dataUrl) => { + const link = document.createElement('a') + link.download = `badge-${createdBadgeId as string}.png` + link.href = dataUrl + link.click() + }) + } + + const copyClaimURL = async () => { + const baseURL = NETWORK === 'testnet' ? 'https://badges.publicawesome.dev' : 'https://badges.stargaze.zone' + const claimURL = `${baseURL}/?id=${createdBadgeId as string}&key=${createdBadgeKey as string}` + await navigator.clipboard.writeText(claimURL) + toast.success('Copied claim URL to clipboard') + } + + const dispatchEditBadgeMessage = async () => { + if (type) { + 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) + } + } + } + + useEffect(() => { + if (privateKeyState.value.length === 64 && resolvedOwnerAddress) + handleGenerateSignature(badgeIdState.value, resolvedOwnerAddress, privateKeyState.value) + }, [privateKeyState.value, resolvedOwnerAddress]) + + 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) + if (attributesState.values.length === 0) + attributesState.add({ + trait_type: '', + value: '', + }) + }, []) + + useEffect(() => { + void dispatchEditBadgeMessage().catch((err) => { + toast.error(String(err), { style: { maxWidth: 'none' } }) + }) + }, [triggerDispatch]) + + const resolveOwnerAddress = async () => { + await resolveAddress(ownerState.value.trim(), wallet).then((resolvedAddress) => { + setResolvedOwnerAddress(resolvedAddress) + }) + } + useEffect(() => { + void resolveOwnerAddress() + }, [ownerState.value]) + + const resolveManagerAddress = async () => { + await resolveAddress(managerState.value.trim(), wallet).then((resolvedAddress) => { + setBadge({ + manager: resolvedAddress, + metadata: { + name: nameState.value || undefined, + description: descriptionState.value || undefined, + image: imageState.value || undefined, + image_data: imageDataState.value || undefined, + external_url: externalUrlState.value || undefined, + attributes: + attributesState.values[0]?.trait_type && attributesState.values[0]?.value + ? attributesState.values + .map((attr) => ({ + trait_type: attr.trait_type, + value: attr.value, + })) + .filter((attr) => attr.trait_type && attr.value) + : undefined, + background_color: backgroundColorState.value || undefined, + animation_url: animationUrlState.value || undefined, + youtube_url: youtubeUrlState.value || undefined, + }, + transferrable, + rule: { + by_key: keyState.value, + }, + expiry: timestamp ? timestamp.getTime() * 1000000 : undefined, + max_supply: maxSupplyState.value || undefined, + }) + }) + } + + useEffect(() => { + void resolveManagerAddress() + }, [managerState.value]) + + useEffect(() => { + setBadge({ + manager: managerState.value, + metadata: { + name: nameState.value || undefined, + description: descriptionState.value || undefined, + image: imageState.value || undefined, + image_data: imageDataState.value || undefined, + external_url: externalUrlState.value || undefined, + attributes: + attributesState.values[0]?.trait_type && attributesState.values[0]?.value + ? attributesState.values + .map((attr) => ({ + trait_type: attr.trait_type, + value: attr.value, + })) + .filter((attr) => attr.trait_type && attr.value) + : undefined, + background_color: backgroundColorState.value || undefined, + animation_url: animationUrlState.value || undefined, + youtube_url: youtubeUrlState.value || undefined, + }, + transferrable, + rule: { + by_key: keyState.value, + }, + expiry: timestamp ? timestamp.getTime() * 1000000 : undefined, + max_supply: maxSupplyState.value || undefined, + }) + }, [ + managerState.value, + nameState.value, + descriptionState.value, + imageState.value, + imageDataState.value, + externalUrlState.value, + attributesState.values, + backgroundColorState.value, + animationUrlState.value, + youtubeUrlState.value, + transferrable, + keyState.value, + timestamp, + maxSupplyState.value, + ]) + + return ( +
+ + + + + {showBadgeField && createdBadgeId && createdBadgeKey && ( +
+
+
+ +
+ {/*
*/} +
+ + +
+
+
+ +
+ Badge ID: + {createdBadgeId} +
+ + Private Key: + + + +
+
+ + Please make sure to save the Badge ID and the Private Key. + +
+
+ )} +
+
+ + + {showIdField && } + {showBadgeField && } + {showBadgeField && } + {showBadgeField && } + {showMetadataField && ( +
+ Metadata + + + + + +
+ +
+ + + +
+ )} + {showOwnerField && ( + + )} + {showPrivateKeyField && } + {showNFTField && } +
+ +
+
+ + + + +
+ + + +
+ + + setTimestamp(date)} value={timestamp} /> + + + {showBadgeField && } + {showBadgeField && ( +
+ +
+ )} +
+
+
+
+ ) +} + +export default withMetadata(BadgeHubExecutePage, { center: false }) diff --git a/pages/contracts/badgeHub/index.tsx b/pages/contracts/badgeHub/index.tsx new file mode 100644 index 0000000..561b4b3 --- /dev/null +++ b/pages/contracts/badgeHub/index.tsx @@ -0,0 +1 @@ +export { default } from './instantiate' diff --git a/pages/contracts/badgeHub/instantiate.tsx b/pages/contracts/badgeHub/instantiate.tsx new file mode 100644 index 0000000..0b18abb --- /dev/null +++ b/pages/contracts/badgeHub/instantiate.tsx @@ -0,0 +1,119 @@ +import { Alert } from 'components/Alert' +import { Button } from 'components/Button' +import { Conditional } from 'components/Conditional' +import { ContractPageHeader } from 'components/ContractPageHeader' +import { FormGroup } from 'components/FormGroup' +import { NumberInput } from 'components/forms/FormInput' +import { useNumberInputState } from 'components/forms/FormInput.hooks' +import { JsonPreview } from 'components/JsonPreview' +import { LinkTabs } from 'components/LinkTabs' +import { badgeHubLinkTabs } 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 } from 'react' +import { toast } from 'react-hot-toast' +import { FaAsterisk } from 'react-icons/fa' +import { useMutation } from 'react-query' +import { BADGE_HUB_CODE_ID } from 'utils/constants' +import { withMetadata } from 'utils/layout' +import { links } from 'utils/links' + +export interface FeeRate { + metadata: number + key: number +} + +const BadgeHubInstantiatePage: NextPage = () => { + const wallet = useWallet() + const { badgeHub: contract } = useContracts() + + const metadataFeeRateState = useNumberInputState({ + id: 'metadata-fee-rate', + name: 'Metadata Fee Rate', + title: 'Metadata Fee Rate', + subtitle: 'The fee rate, in ustars per byte, for storing metadata on-chain', + placeholder: '500', + }) + + const keyFeeRateState = useNumberInputState({ + id: 'key-fee-rate', + name: 'Key Fee Rate', + title: 'Key Fee Rate', + subtitle: 'The fee rate, in ustars per byte, for storing claim keys on-chain', + placeholder: '500', + }) + + const { data, isLoading, mutate } = useMutation( + async (event: FormEvent): Promise => { + event.preventDefault() + if (!contract) { + throw new Error('Smart contract connection failed') + } + + if (!keyFeeRateState.value) { + throw new Error('Key fee rate is required') + } + if (!metadataFeeRateState.value) { + throw new Error('Metadata fee rate is required') + } + + const msg = { + fee_rate: { + metadata: metadataFeeRateState.value.toString(), + key: keyFeeRateState.value.toString(), + }, + } + return toast.promise( + contract.instantiate(BADGE_HUB_CODE_ID, msg, 'Stargaze Badge Hub Contract', wallet.address), + { + loading: 'Instantiating contract...', + error: 'Instantiation failed!', + success: 'Instantiation success!', + }, + ) + }, + { + onError: (error) => { + toast.error(String(error), { style: { maxWidth: 'none' } }) + }, + }, + ) + + return ( +
+ + + + + + + Instantiate success! Here is the transaction result containing the contract address and the transaction + hash. + + +
+
+ + + + + + +
+
+ +
+ + ) +} + +export default withMetadata(BadgeHubInstantiatePage, { center: false }) diff --git a/pages/contracts/badgeHub/migrate.tsx b/pages/contracts/badgeHub/migrate.tsx new file mode 100644 index 0000000..971090a --- /dev/null +++ b/pages/contracts/badgeHub/migrate.tsx @@ -0,0 +1,132 @@ +import { Button } from 'components/Button' +import { ContractPageHeader } from 'components/ContractPageHeader' +import { useExecuteComboboxState } from 'components/contracts/badgeHub/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 { badgeHubLinkTabs } from 'components/LinkTabs.data' +import { TransactionHash } from 'components/TransactionHash' +import { useContracts } from 'contexts/contracts' +import { useWallet } from 'contexts/wallet' +import type { MigrateResponse } from 'contracts/badgeHub' +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 BadgeHubMigratePage: NextPage = () => { + const { badgeHub: 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 Badge Hub contract', + placeholder: '1', + }) + + const contractState = useInputState({ + id: 'contract-address', + name: 'contract-address', + title: 'Badge Hub Contract Address', + subtitle: 'Address of the Badge Hub 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(BadgeHubMigratePage, { center: false }) diff --git a/pages/contracts/badgeHub/query.tsx b/pages/contracts/badgeHub/query.tsx new file mode 100644 index 0000000..df7b56a --- /dev/null +++ b/pages/contracts/badgeHub/query.tsx @@ -0,0 +1,179 @@ +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 { badgeHubLinkTabs } from 'components/LinkTabs.data' +import { useContracts } from 'contexts/contracts' +import { useWallet } from 'contexts/wallet' +import type { QueryType } from 'contracts/badgeHub/messages/query' +import { dispatchQuery, QUERY_LIST } from 'contracts/badgeHub/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 { BADGE_HUB_ADDRESS } from '../../../utils/constants' + +const BadgeHubQueryPage: NextPage = () => { + const { badgeHub: contract } = useContracts() + const wallet = useWallet() + + const contractState = useInputState({ + id: 'contract-address', + name: 'contract-address', + title: 'Badge Hub Address', + subtitle: 'Address of the Badge Hub contract', + defaultValue: BADGE_HUB_ADDRESS, + }) + const contractAddress = contractState.value + + const idState = useNumberInputState({ + id: 'id', + name: 'id', + title: 'ID', + subtitle: 'The ID of the badge', + defaultValue: 1, + }) + + const pubkeyState = useInputState({ + id: 'pubkey', + name: 'pubkey', + title: 'Public Key', + subtitle: 'The public key to check whether it can be used to mint a badge', + }) + + const startAfterNumberState = useNumberInputState({ + id: 'start-after-number', + name: 'start-after-number', + title: 'Start After (optional)', + subtitle: 'The id to start the pagination after', + }) + + const startAfterStringState = useInputState({ + id: 'start-after-string', + name: 'start-after-string', + title: 'Start After (optional)', + subtitle: 'The public key 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('config') + + const { data: response } = useQuery( + [ + contractAddress, + type, + contract, + wallet, + idState.value, + pubkeyState.value, + startAfterNumberState.value, + startAfterStringState.value, + paginationLimitState.value, + ] as const, + async ({ queryKey }) => { + const [_contractAddress, _type, _contract, _wallet, id, pubkey, startAfterNumber, startAfterString, limit] = + queryKey + const messages = contract?.use(_contractAddress) + const result = await dispatchQuery({ + id, + pubkey, + messages, + type, + startAfterNumber, + startAfterString, + limit, + }) + return result + }, + { + 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(BadgeHubQueryPage, { center: false }) diff --git a/pages/contracts/baseMinter/migrate.tsx b/pages/contracts/baseMinter/migrate.tsx index 60e8b25..b365115 100644 --- a/pages/contracts/baseMinter/migrate.tsx +++ b/pages/contracts/baseMinter/migrate.tsx @@ -70,7 +70,7 @@ const BaseMinterMigratePage: NextPage = () => { }, { onError: (error) => { - toast.error(String(error)) + toast.error(String(error), { style: { maxWidth: 'none' } }) }, }, ) diff --git a/pages/contracts/index.tsx b/pages/contracts/index.tsx index 685a190..3f1ef14 100644 --- a/pages/contracts/index.tsx +++ b/pages/contracts/index.tsx @@ -4,7 +4,7 @@ import type { NextPage } from 'next' // import Brand from 'public/brand/brand.svg' import { withMetadata } from 'utils/layout' -import { BASE_FACTORY_ADDRESS } from '../../utils/constants' +import { BADGE_HUB_ADDRESS, BASE_FACTORY_ADDRESS } from '../../utils/constants' const HomePage: NextPage = () => { return ( @@ -12,9 +12,9 @@ const HomePage: NextPage = () => {
{/* */}
-

Smart Contracts

+

Smart Contract Dashboards

- Here you can invoke and query different smart contracts and see the results. + Here you can execute actions and queries on different smart contracts and see the results.

@@ -27,7 +27,7 @@ const HomePage: NextPage = () => { Execute messages and run queries on Stargaze's Base Minter contract. @@ -35,20 +35,29 @@ const HomePage: NextPage = () => { Execute messages and run queries on Stargaze's Vending Minter contract. - Execute messages and run queries on Stargaze's sg721 contract. + Execute messages and run queries on Stargaze's SG721 contract. - Execute messages and run queries on Stargaze's whitelist contract. + Execute messages and run queries on Stargaze's Whitelist contract. + + + Execute messages and run queries on the Badge Hub contract designed for event organizers. + +
) diff --git a/pages/contracts/sg721/migrate.tsx b/pages/contracts/sg721/migrate.tsx index 2d0e733..03da9d3 100644 --- a/pages/contracts/sg721/migrate.tsx +++ b/pages/contracts/sg721/migrate.tsx @@ -70,7 +70,7 @@ const Sg721MigratePage: NextPage = () => { }, { onError: (error) => { - toast.error(String(error)) + toast.error(String(error), { style: { maxWidth: 'none' } }) }, }, ) diff --git a/pages/contracts/vendingMinter/migrate.tsx b/pages/contracts/vendingMinter/migrate.tsx index 5e4952d..9ad9758 100644 --- a/pages/contracts/vendingMinter/migrate.tsx +++ b/pages/contracts/vendingMinter/migrate.tsx @@ -70,7 +70,7 @@ const VendingMinterMigratePage: NextPage = () => { }, { onError: (error) => { - toast.error(String(error)) + toast.error(String(error), { style: { maxWidth: 'none' } }) }, }, ) diff --git a/pages/contracts/whitelist/instantiate.tsx b/pages/contracts/whitelist/instantiate.tsx index 3e237eb..eb54478 100644 --- a/pages/contracts/whitelist/instantiate.tsx +++ b/pages/contracts/whitelist/instantiate.tsx @@ -25,7 +25,7 @@ import { WHITELIST_CODE_ID } from 'utils/constants' import { withMetadata } from 'utils/layout' import { links } from 'utils/links' -const Sg721InstantiatePage: NextPage = () => { +const WhitelistInstantiatePage: NextPage = () => { const wallet = useWallet() const { whitelist: contract } = useContracts() @@ -148,4 +148,4 @@ const Sg721InstantiatePage: NextPage = () => { ) } -export default withMetadata(Sg721InstantiatePage, { center: false }) +export default withMetadata(WhitelistInstantiatePage, { center: false }) diff --git a/pages/index.tsx b/pages/index.tsx index 0798377..2875eb7 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -22,11 +22,14 @@ const HomePage: NextPage = () => {
- - Upload your assets, enter collection metadata and deploy your collection. + + Create a collection, view a list of your collections or execute collection actions and queries. - - Manage your collections with available actions and queries. + + Create badges, view a list of them or execute badge related actions and queries. + + + Execute actions and queries for a variety of contracts.
diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 0000000..d09b925 --- /dev/null +++ b/public/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tailwind.config.js b/tailwind.config.js index 9a49a04..73bfe07 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -56,6 +56,7 @@ module.exports = { strategy: 'class', }), require('@tailwindcss/line-clamp'), + require('tailwindcss-opentype'), // custom gradient background plugin(({ addUtilities }) => { diff --git a/utils/constants.ts b/utils/constants.ts index 1c0896b..9966325 100644 --- a/utils/constants.ts +++ b/utils/constants.ts @@ -5,6 +5,10 @@ export const VENDING_FACTORY_ADDRESS = process.env.NEXT_PUBLIC_VENDING_FACTORY_A export const BASE_FACTORY_ADDRESS = process.env.NEXT_PUBLIC_BASE_FACTORY_ADDRESS export const SG721_NAME_ADDRESS = process.env.NEXT_PUBLIC_SG721_NAME_ADDRESS export const BASE_MINTER_CODE_ID = parseInt(process.env.NEXT_PUBLIC_VENDING_MINTER_CODE_ID, 10) +export const BADGE_HUB_CODE_ID = parseInt(process.env.NEXT_PUBLIC_BADGE_HUB_CODE_ID, 10) +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 PINATA_ENDPOINT_URL = process.env.NEXT_PUBLIC_PINATA_ENDPOINT_URL export const NETWORK = process.env.NEXT_PUBLIC_NETWORK diff --git a/utils/hash.ts b/utils/hash.ts new file mode 100644 index 0000000..c98cebf --- /dev/null +++ b/utils/hash.ts @@ -0,0 +1,25 @@ +/* eslint-disable eslint-comments/disable-enable-pair */ + +import { Word32Array } from 'jscrypto' +import { SHA256 } from 'jscrypto/SHA256' +import * as secp256k1 from 'secp256k1' + +export function sha256(data: Buffer): Buffer { + return Buffer.from(SHA256.hash(new Word32Array(data)).toUint8Array()) +} + +export function generateSignature(id: number, owner: string, privateKey: string) { + try { + const message = `claim badge ${id} for user ${owner}` + + const privKey = Buffer.from(privateKey, 'hex') + // const pubKey = Buffer.from(secp256k1.publicKeyCreate(privKey, true)) + const msgBytes = Buffer.from(message, 'utf8') + const msgHashBytes = sha256(msgBytes) + const signedMessage = secp256k1.ecdsaSign(msgHashBytes, privKey) + return Buffer.from(signedMessage.signature).toString('hex') + } catch (e) { + console.log(e) + return '' + } +} diff --git a/yarn.lock b/yarn.lock index 159965f..9de7abd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2829,6 +2829,13 @@ resolved "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz" integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew== +"@types/secp256k1@^4.0.2": + version "4.0.3" + resolved "https://registry.yarnpkg.com/@types/secp256k1/-/secp256k1-4.0.3.tgz#1b8e55d8e00f08ee7220b4d59a6abe89c37a901c" + integrity sha512-Da66lEIFeIz9ltsdMZcpQvmrmmoqrfju8pm1BH8WbYjZSwUgCwXLb9C+9XYogwBITnbsSaMdVPb2ekf7TV+03w== + dependencies: + "@types/node" "*" + "@typescript-eslint/eslint-plugin@5.17.0": version "5.17.0" resolved "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.17.0.tgz" @@ -4112,7 +4119,7 @@ electron-to-chromium@^1.4.118: resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.124.tgz" integrity sha512-VhaE9VUYU6d2eIb+4xf83CATD+T+3bTzvxvlADkQE+c2hisiw3sZmvEDtsW704+Zky9WZGhBuQXijDVqSriQLA== -elliptic@^6.4.0, elliptic@^6.5.3: +elliptic@^6.4.0, elliptic@^6.5.3, elliptic@^6.5.4: version "6.5.4" resolved "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz" integrity sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ== @@ -5019,6 +5026,11 @@ hosted-git-info@^4.0.1: dependencies: lru-cache "^6.0.0" +html-to-image@1.11.11: + version "1.11.11" + resolved "https://registry.yarnpkg.com/html-to-image/-/html-to-image-1.11.11.tgz#c0f8a34dc9e4b97b93ff7ea286eb8562642ebbea" + integrity sha512-9gux8QhvjRO/erSnDPv28noDZcPZmYE7e1vFsBLKLlRlKDSqNJYebj6Qz1TGd5lsRV+X+xYyjCKjuZdABinWjA== + human-signals@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz" @@ -5615,6 +5627,11 @@ js-yaml@^4.1.0: dependencies: argparse "^2.0.1" +jscrypto@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/jscrypto/-/jscrypto-1.0.3.tgz#598febca2a939d6f679c54f56e1fe364cef30cc9" + integrity sha512-lryZl0flhodv4SZHOqyb1bx5sKcJxj0VBo0Kzb4QMAg3L021IC9uGpl0RCZa+9KJwlRGSK2C80ITcwbe19OKLQ== + jsesc@^2.5.1: version "2.5.2" resolved "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz" @@ -6253,10 +6270,20 @@ nft.storage@^6.3.0: streaming-iterables "^6.0.0" throttled-queue "^2.1.2" +node-addon-api@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-2.0.2.tgz#432cfa82962ce494b132e9d72a15b29f71ff5d32" + integrity sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA== + node-fetch@^2.6.1, "node-fetch@https://registry.npmjs.org/@achingbrain/node-fetch/-/node-fetch-2.6.7.tgz": version "2.6.7" resolved "https://registry.npmjs.org/@achingbrain/node-fetch/-/node-fetch-2.6.7.tgz" +node-gyp-build@^4.2.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.6.0.tgz#0c52e4cbf54bbd28b709820ef7b6a3c2d6209055" + integrity sha512-NTZVKn9IylLwUzaKjkas1e4u2DLNcV4rdYagA4PWdPwW87Bi7z+BznyKSRwS/761tV/lzCGXplWsiaMjLqP2zQ== + node-releases@^2.0.3: version "2.0.3" resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.3.tgz" @@ -6326,6 +6353,13 @@ object-keys@^1.1.1: resolved "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz" integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== +object-sizeof@^1.6.0: + version "1.6.3" + resolved "https://registry.yarnpkg.com/object-sizeof/-/object-sizeof-1.6.3.tgz#6edbbf26825b971fd7a32125a800ed2a9895af95" + integrity sha512-LGtilAKuDGKCcvu1Xg3UvAhAeJJlFmblo3faltmOQ80xrGwAHxnauIXucalKdTEksHp/Pq9tZGz1hfyEmjFJPQ== + dependencies: + buffer "^5.6.0" + object.assign@^4.1.0, object.assign@^4.1.2: version "4.1.2" resolved "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz" @@ -6693,6 +6727,11 @@ punycode@^2.1.0: resolved "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== +qrcode.react@3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/qrcode.react/-/qrcode.react-3.1.0.tgz#5c91ddc0340f768316fbdb8fff2765134c2aecd8" + integrity sha512-oyF+Urr3oAMUG/OiOuONL3HXM+53wvuH3mtIWQrYmsXoAq0DkvZp2RYUWFSMFtbdOpuS++9v+WAkzNVkMlNW6Q== + queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz" @@ -7130,6 +7169,15 @@ scheduler@^0, scheduler@^0.22.0: dependencies: loose-envify "^1.1.0" +secp256k1@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/secp256k1/-/secp256k1-4.0.3.tgz#c4559ecd1b8d3c1827ed2d1b94190d69ce267303" + integrity sha512-NLZVf+ROMxwtEj3Xa562qgv2BK5e2WNmXPiOdVIPLgs6lyTzMvBq0aWTYMI5XCP9jZMVKOcqZLw/Wc4vDkuxhA== + dependencies: + elliptic "^6.5.4" + node-addon-api "^2.0.0" + node-gyp-build "^4.2.0" + secretjs@^0.17.0: version "0.17.5" resolved "https://registry.npmjs.org/secretjs/-/secretjs-0.17.5.tgz" @@ -7482,6 +7530,11 @@ symbol-observable@^2.0.3: resolved "https://registry.npmjs.org/symbol-observable/-/symbol-observable-2.0.3.tgz" integrity sha512-sQV7phh2WCYAn81oAkakC5qjq2Ml0g8ozqz03wOGnx9dDlG1de6yrF+0RAzSJD8fPUow3PTSMf2SAbOGxb93BA== +tailwindcss-opentype@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/tailwindcss-opentype/-/tailwindcss-opentype-1.1.0.tgz#68aebc6f8a24151167b0a4f372370afe375a3b38" + integrity sha512-d/+/oBITS2JX/Nn+20WSfnxQbYNcSHMNrKeBwmgetxf/9Nmi5k1pOae6OJC4WD+5M8jX9Xb8TnLZ2lkp6qv09A== + tailwindcss@^3, tailwindcss@^3.0, tailwindcss@^3.0.7: version "3.0.24" resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.0.24.tgz"