diff --git a/.env.example b/.env.example index 866de6b..e0eba3e 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -APP_VERSION=0.6.3 +APP_VERSION=0.6.4 NEXT_PUBLIC_PINATA_ENDPOINT_URL=https://api.pinata.cloud/pinning/pinFileToIPFS NEXT_PUBLIC_SG721_CODE_ID=2092 diff --git a/components/AirdropUpload.tsx b/components/AirdropUpload.tsx index a73ffad..81711de 100644 --- a/components/AirdropUpload.tsx +++ b/components/AirdropUpload.tsx @@ -38,7 +38,7 @@ export const AirdropUpload = ({ onChange }: AirdropUploadProps) => { .then((res) => { const tokenUri = JSON.parse(new TextDecoder().decode(res as Uint8Array)).token_uri if (tokenUri && isValidAddress(tokenUri)) - resolvedAllocationData.push({ address: tokenUri, amount: data.amount }) + resolvedAllocationData.push({ address: tokenUri, amount: data.amount, tokenId: data.tokenId }) else toast.error(`Resolved address is empty or invalid for the name: ${data.address}`) }) .catch((e) => { @@ -80,8 +80,15 @@ export const AirdropUpload = ({ onChange }: AirdropUploadProps) => { .map((data) => ({ address: data.address.trim(), amount: data.amount, + tokenId: data.tokenId, })) - .concat(resolvedAllocationData), + .concat( + resolvedAllocationData.map((data) => ({ + address: data.address, + amount: data.amount, + tokenId: data.tokenId, + })), + ), ) }, ) diff --git a/components/collections/actions/Action.tsx b/components/collections/actions/Action.tsx index c628b30..7c509ee 100644 --- a/components/collections/actions/Action.tsx +++ b/components/collections/actions/Action.tsx @@ -185,7 +185,7 @@ export const CollectionActions = ({ 'batch_transfer', 'batch_mint_for', ]) - const showAirdropFileField = type === 'airdrop' + const showAirdropFileField = isEitherType(type, ['airdrop', 'airdrop_specific']) const showPriceField = isEitherType(type, ['update_mint_price', 'update_discount_price']) const showDescriptionField = type === 'update_collection_info' const showImageField = type === 'update_collection_info' @@ -211,6 +211,7 @@ export const CollectionActions = ({ sg721Messages, recipient: resolvedRecipientAddress, recipients: airdropArray, + tokenRecipients: airdropAllocationArray, txSigner: wallet.address, type, price: priceState.value.toString(), @@ -477,7 +478,11 @@ export const CollectionActions = ({ )} {showAirdropFileField && ( diff --git a/components/collections/actions/actions.ts b/components/collections/actions/actions.ts index 63ab38d..9b1a82d 100644 --- a/components/collections/actions/actions.ts +++ b/components/collections/actions/actions.ts @@ -5,6 +5,7 @@ import type { CollectionInfo, SG721Instance } from 'contracts/sg721' import { useSG721Contract } from 'contracts/sg721' import type { VendingMinterInstance } from 'contracts/vendingMinter' import { useVendingMinterContract } from 'contracts/vendingMinter' +import type { AirdropAllocation } from 'utils/isValidAccountsFile' import type { BaseMinterInstance } from '../../../contracts/baseMinter/contract' @@ -31,6 +32,7 @@ export const ACTION_TYPES = [ 'batch_mint_for', 'shuffle', 'airdrop', + 'airdrop_specific', 'burn_remaining', 'update_token_metadata', 'batch_update_token_metadata', @@ -183,6 +185,11 @@ export const VENDING_ACTION_LIST: ActionListItem[] = [ name: 'Airdrop Tokens', description: 'Airdrop tokens to given addresses', }, + { + id: 'airdrop_specific', + name: 'Airdrop Specific Tokens', + description: 'Airdrop specific tokens to given addresses', + }, { id: 'burn_remaining', name: 'Burn Remaining Tokens', @@ -237,6 +244,7 @@ export interface DispatchExecuteArgs { limit: number tokenIds: string recipients: string[] + tokenRecipients: AirdropAllocation[] collectionInfo: CollectionInfo | undefined baseUri: string } @@ -319,6 +327,9 @@ export const dispatchExecute = async (args: DispatchExecuteArgs) => { case 'airdrop': { return vendingMinterMessages.airdrop(txSigner, args.recipients) } + case 'airdrop_specific': { + return vendingMinterMessages.airdropSpecificTokens(txSigner, args.tokenRecipients) + } case 'burn_remaining': { return vendingMinterMessages.burnRemaining(txSigner) } @@ -409,6 +420,9 @@ export const previewExecutePayload = (args: DispatchExecuteArgs) => { case 'airdrop': { return vendingMinterMessages(minterContract)?.airdrop(args.recipients) } + case 'airdrop_specific': { + return vendingMinterMessages(minterContract)?.airdropSpecificTokens(args.tokenRecipients) + } case 'burn_remaining': { return vendingMinterMessages(minterContract)?.burnRemaining() } diff --git a/contracts/vendingMinter/contract.ts b/contracts/vendingMinter/contract.ts index 8483fa3..2f5fcf0 100644 --- a/contracts/vendingMinter/contract.ts +++ b/contracts/vendingMinter/contract.ts @@ -5,6 +5,7 @@ import { coin } from '@cosmjs/proto-signing' import type { logs } from '@cosmjs/stargate' import type { Timestamp } from '@stargazezone/types/contracts/minter/shared-types' import { MsgExecuteContract } from 'cosmjs-types/cosmwasm/wasm/v1/tx' +import type { AirdropAllocation } from 'utils/isValidAccountsFile' export interface InstantiateResponse { readonly contractAddress: string @@ -47,6 +48,7 @@ export interface VendingMinterInstance { shuffle: (senderAddress: string) => Promise withdraw: (senderAddress: string) => Promise airdrop: (senderAddress: string, recipients: string[]) => Promise + airdropSpecificTokens: (senderAddress: string, tokenRecipients: AirdropAllocation[]) => Promise burnRemaining: (senderAddress: string) => Promise updateDiscountPrice: (senderAddress: string, price: string) => Promise removeDiscountPrice: (senderAddress: string) => Promise @@ -67,6 +69,7 @@ export interface VendingMinterMessages { shuffle: () => ShuffleMessage withdraw: () => WithdrawMessage airdrop: (recipients: string[]) => CustomMessage + airdropSpecificTokens: (recipients: AirdropAllocation[]) => CustomMessage burnRemaining: () => BurnRemainingMessage updateDiscountPrice: (price: string) => UpdateDiscountPriceMessage removeDiscountPrice: () => RemoveDiscountPriceMessage @@ -553,6 +556,29 @@ export const vendingMinter = (client: SigningCosmWasmClient, txSigner: string): return res.transactionHash } + const airdropSpecificTokens = async (senderAddress: string, recipients: AirdropAllocation[]): Promise => { + const executeContractMsgs: MsgExecuteContractEncodeObject[] = [] + for (let i = 0; i < recipients.length; i++) { + const msg = { + mint_for: { recipient: recipients[i].address, token_id: Number(recipients[i].tokenId) }, + } + 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_specific_tokens') + + return res.transactionHash + } + const shuffle = async (senderAddress: string): Promise => { const res = await client.execute( senderAddress, @@ -617,6 +643,7 @@ export const vendingMinter = (client: SigningCosmWasmClient, txSigner: string): batchMintFor, batchMint, airdrop, + airdropSpecificTokens, shuffle, withdraw, burnRemaining, @@ -838,6 +865,19 @@ export const vendingMinter = (client: SigningCosmWasmClient, txSigner: string): } } + const airdropSpecificTokens = (recipients: AirdropAllocation[]): CustomMessage => { + const msg: Record[] = [] + for (let i = 0; i < recipients.length; i++) { + msg.push({ mint_for: { recipient: recipients[i].address, token_id: recipients[i].tokenId } }) + } + return { + sender: txSigner, + contract: contractAddress, + msg, + funds: [], + } + } + const shuffle = (): ShuffleMessage => { return { sender: txSigner, @@ -886,6 +926,7 @@ export const vendingMinter = (client: SigningCosmWasmClient, txSigner: string): batchMintFor, batchMint, airdrop, + airdropSpecificTokens, shuffle, withdraw, burnRemaining, diff --git a/package.json b/package.json index daf9fbb..9853fe4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "stargaze-studio", - "version": "0.6.3", + "version": "0.6.4", "workspaces": [ "packages/*" ], diff --git a/utils/csvToArray.ts b/utils/csvToArray.ts index eb3e44c..def2bcd 100644 --- a/utils/csvToArray.ts +++ b/utils/csvToArray.ts @@ -5,11 +5,11 @@ export const csvToArray = (str: string, delimiter = ',') => { if (str.includes('\r')) newline = '\r' if (str.includes('\r\n')) newline = '\r\n' - const headers = str.slice(0, str.indexOf(newline)).split(delimiter) + const headers = str.trim().slice(0, str.indexOf(newline)).split(delimiter) if (headers.length !== 2) { throw new Error('Invalid accounts file') } - if (headers[0] !== 'address' || headers[1] !== 'amount') { + if (headers[0] !== 'address' || (headers[1] !== 'amount' && headers[1] !== 'tokenId')) { throw new Error('Invalid accounts file') } diff --git a/utils/isValidAccountsFile.ts b/utils/isValidAccountsFile.ts index 91c3ded..28b7c03 100644 --- a/utils/isValidAccountsFile.ts +++ b/utils/isValidAccountsFile.ts @@ -4,7 +4,8 @@ import { isValidAddress } from './isValidAddress' export interface AirdropAllocation { address: string - amount: string + amount?: string + tokenId?: string } export const isValidAccountsFile = (file: AirdropAllocation[]) => { @@ -28,10 +29,19 @@ export const isValidAccountsFile = (file: AirdropAllocation[]) => { if (!account.address.trim().startsWith('stars') && !account.address.trim().endsWith('.stars')) { return { address: false } } + + if (!account.amount && !account.tokenId) { + return { amount: false, tokenId: false } + } + // Check if amount is valid - if (!Number.isInteger(Number(account.amount)) || !(Number(account.amount) > 0)) { + if (account.amount && (!Number.isInteger(Number(account.amount)) || !(Number(account.amount) > 0))) { return { amount: false } } + // Check if tokenId is valid + if (account.tokenId && (!Number.isInteger(Number(account.tokenId)) || !(Number(account.tokenId) > 0))) { + return { tokenId: false } + } return null }) @@ -47,11 +57,22 @@ export const isValidAccountsFile = (file: AirdropAllocation[]) => { toast.error('Invalid address in file') return false } + + if (checks.filter((check) => check?.amount === false && check.tokenId === false).length > 0) { + toast.error('No amount or token ID found in the file. Please check the header.') + return false + } + if (checks.filter((check) => check?.amount === false).length > 0) { toast.error('Invalid amount in file. Amount must be a positive integer.') return false } + if (checks.filter((check) => check?.tokenId === false).length > 0) { + toast.error('Invalid token ID in file. Token ID must be a positive integer.') + return false + } + // if (duplicateCheck.length > 0) { // toast.error('The file contains duplicate addresses.') // return false