diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/_common/form.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/_common/form.ts index a48db4a3bd7..66ba225f465 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/_common/form.ts +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/_common/form.ts @@ -42,16 +42,29 @@ export type CreateNFTCollectionFunctions = { address: string; }[]; chain: string; + gasless: boolean; }) => Promise; erc721: { - deployContract: (values: CreateNFTCollectionAllValues) => Promise<{ + deployContract: (params: { + values: CreateNFTCollectionAllValues; + gasless: boolean; + }) => Promise<{ contractAddress: string; }>; - setClaimConditions: (values: CreateNFTCollectionAllValues) => Promise; - lazyMintNFTs: (values: CreateNFTCollectionAllValues) => Promise; + setClaimConditions: (params: { + values: CreateNFTCollectionAllValues; + gasless: boolean; + }) => Promise; + lazyMintNFTs: (params: { + values: CreateNFTCollectionAllValues; + gasless: boolean; + }) => Promise; }; erc1155: { - deployContract: (values: CreateNFTCollectionAllValues) => Promise<{ + deployContract: (params: { + values: CreateNFTCollectionAllValues; + gasless: boolean; + }) => Promise<{ contractAddress: string; }>; setClaimConditions: (params: { @@ -60,8 +73,12 @@ export type CreateNFTCollectionFunctions = { startIndex: number; count: number; }; + gasless: boolean; + }) => Promise; + lazyMintNFTs: (params: { + values: CreateNFTCollectionAllValues; + gasless: boolean; }) => Promise; - lazyMintNFTs: (values: CreateNFTCollectionAllValues) => Promise; }; }; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page.tsx index ccb54f5ae5b..c786e79a002 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page.tsx @@ -21,6 +21,7 @@ import { import { grantRole } from "thirdweb/extensions/permissions"; import { useActiveAccount } from "thirdweb/react"; import { maxUint256 } from "thirdweb/utils"; +import { create7702MinimalAccount } from "thirdweb/wallets/smart"; import { revalidatePathAction } from "@/actions/revalidate"; import { reportAssetCreationFailed, @@ -42,15 +43,26 @@ export function CreateNFTPage(props: { teamPlan: Team["billingPlan"]; }) { const activeAccount = useActiveAccount(); - const addContractToProject = useAddContractToProject(); const contractAddressRef = useRef(undefined); - function getContractAndAccount(params: { chain: string }) { + function getAccount(params: { gasless: boolean }) { if (!activeAccount) { throw new Error("Wallet is not connected"); } + if (params.gasless) { + return create7702MinimalAccount({ + adminAccount: activeAccount, + client: props.client, + sponsorGas: true, + }); + } + + return activeAccount; + } + + function getDeployedContract(params: { chain: string }) { // eslint-disable-next-line no-restricted-syntax const chain = defineChain(Number(params.chain)); @@ -60,30 +72,26 @@ export function CreateNFTPage(props: { throw new Error("Contract not found"); } - const contract = getContract({ + return getContract({ address: contractAddress, chain, client: props.client, }); - - return { - activeAccount, - contract, - }; } async function handleContractDeploy(params: { values: CreateNFTCollectionAllValues; ercType: "erc721" | "erc1155"; + gasless: boolean; }) { const { values: formValues, ercType } = params; const { collectionInfo, sales } = formValues; const contractType = ercType === "erc721" ? ("DropERC721" as const) : ("DropERC1155" as const); - if (!activeAccount) { - throw new Error("Wallet is not connected"); - } + const account = getAccount({ + gasless: params.gasless, + }); // eslint-disable-next-line no-restricted-syntax const chain = defineChain(Number(collectionInfo.chain)); @@ -93,7 +101,7 @@ export function CreateNFTPage(props: { if (ercType === "erc721") { contractAddress = await deployERC721Contract({ - account: activeAccount, + account, chain: chain, client: props.client, params: { @@ -110,7 +118,7 @@ export function CreateNFTPage(props: { }); } else { contractAddress = await deployERC1155Contract({ - account: activeAccount, + account, chain: chain, client: props.client, params: { @@ -166,27 +174,32 @@ export function CreateNFTPage(props: { } async function handleLazyMintNFTs(params: { - formValues: CreateNFTCollectionAllValues; + values: CreateNFTCollectionAllValues; ercType: "erc721" | "erc1155"; + gasless: boolean; }) { - const { formValues, ercType } = params; + const { values, ercType } = params; const contractType = ercType === "erc721" ? ("DropERC721" as const) : ("DropERC1155" as const); - const { contract, activeAccount } = getContractAndAccount({ - chain: formValues.collectionInfo.chain, + const contract = getDeployedContract({ + chain: values.collectionInfo.chain, + }); + + const account = getAccount({ + gasless: params.gasless, }); const lazyMintFn = ercType === "erc721" ? lazyMint721 : lazyMint1155; const transaction = lazyMintFn({ contract, - nfts: formValues.nfts, + nfts: values.nfts, }); try { await sendAndConfirmTransaction({ - account: activeAccount, + account, transaction, }); } catch (error) { @@ -205,14 +218,19 @@ export function CreateNFTPage(props: { } async function handleSetClaimConditionsERC721(params: { - formValues: CreateNFTCollectionAllValues; + values: CreateNFTCollectionAllValues; + gasless: boolean; }) { - const { formValues } = params; - const { contract, activeAccount } = getContractAndAccount({ - chain: formValues.collectionInfo.chain, + const { values } = params; + const contract = getDeployedContract({ + chain: values.collectionInfo.chain, }); - const firstNFT = formValues.nfts[0]; + const account = getAccount({ + gasless: params.gasless, + }); + + const firstNFT = values.nfts[0]; if (!firstNFT) { throw new Error("No NFTs found"); } @@ -235,7 +253,7 @@ export function CreateNFTPage(props: { try { await sendAndConfirmTransaction({ - account: activeAccount, + account, transaction, }); } catch (error) { @@ -259,12 +277,17 @@ export function CreateNFTPage(props: { startIndex: number; count: number; }; + gasless: boolean; }) { const { values, batch } = params; - const { contract, activeAccount } = getContractAndAccount({ + const contract = getDeployedContract({ chain: values.collectionInfo.chain, }); + const account = getAccount({ + gasless: params.gasless, + }); + const endIndexExclusive = batch.startIndex + batch.count; const nfts = values.nfts.slice(batch.startIndex, endIndexExclusive); @@ -315,7 +338,7 @@ export function CreateNFTPage(props: { try { await sendAndConfirmTransaction({ - account: activeAccount, + account, transaction: tx, }); } catch (error) { @@ -339,15 +362,20 @@ export function CreateNFTPage(props: { admins: { address: string; }[]; + gasless: boolean; chain: string; }) { - const { contract, activeAccount } = getContractAndAccount({ + const contract = getDeployedContract({ chain: params.chain, }); + const account = getAccount({ + gasless: params.gasless, + }); + // remove the current account from the list - its already an admin, don't have to add it again const adminsToAdd = params.admins.filter( - (admin) => admin.address !== activeAccount.address, + (admin) => admin.address !== account.address, ); const encodedTxs = await Promise.all( @@ -369,7 +397,7 @@ export function CreateNFTPage(props: { try { await sendAndConfirmTransaction({ - account: activeAccount, + account, transaction: tx, }); } catch (e) { @@ -392,35 +420,35 @@ export function CreateNFTPage(props: { {...props} createNFTFunctions={{ erc721: { - deployContract: (formValues) => { + deployContract: (params) => { return handleContractDeploy({ ercType: "erc721", - values: formValues, + ...params, }); }, - lazyMintNFTs: (formValues) => { + lazyMintNFTs: (params) => { return handleLazyMintNFTs({ ercType: "erc721", - formValues, + ...params, }); }, - setClaimConditions: async (formValues) => { + setClaimConditions: async (params) => { return handleSetClaimConditionsERC721({ - formValues, + ...params, }); }, }, erc1155: { - deployContract: (formValues) => { + deployContract: (params) => { return handleContractDeploy({ ercType: "erc1155", - values: formValues, + ...params, }); }, - lazyMintNFTs: (formValues) => { + lazyMintNFTs: (params) => { return handleLazyMintNFTs({ ercType: "erc1155", - formValues, + ...params, }); }, setClaimConditions: async (params) => { diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/launch/launch-nft.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/launch/launch-nft.tsx index 3828489267d..a8876f83373 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/launch/launch-nft.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/launch/launch-nft.tsx @@ -19,6 +19,7 @@ import { reportAssetCreationSuccessful, } from "@/analytics/report"; import type { Team } from "@/api/team"; +import { GatedSwitch } from "@/components/blocks/GatedSwitch"; import type { MultiStepState } from "@/components/blocks/multi-step-status/multi-step-status"; import { MultiStepStatus } from "@/components/blocks/multi-step-status/multi-step-status"; import { WalletAddress } from "@/components/blocks/wallet-address"; @@ -154,18 +155,28 @@ export function LaunchNFT(props: { return shouldDeployERC721 ? "erc721" : "erc1155"; }, [formValues.nfts]); + const canEnableGasless = + props.teamPlan !== "free" && activeWallet?.id === "inApp"; + const [isGasless, setIsGasless] = useState(canEnableGasless); + const showGaslessSection = activeWallet?.id === "inApp"; + const contractLink = contractAddressRef.current ? `/team/${props.teamSlug}/${props.projectSlug}/contract/${formValues.collectionInfo.chain}/${contractAddressRef.current}` : null; async function executeStep(steps: MultiStepState[], stepId: StepId) { if (stepId === "deploy-contract") { - const result = - await props.createNFTFunctions[ercType].deployContract(formValues); + const result = await props.createNFTFunctions[ercType].deployContract({ + gasless: isGasless, + values: formValues, + }); contractAddressRef.current = result.contractAddress; } else if (stepId === "set-claim-conditions") { if (ercType === "erc721") { - await props.createNFTFunctions.erc721.setClaimConditions(formValues); + await props.createNFTFunctions.erc721.setClaimConditions({ + gasless: isGasless, + values: formValues, + }); } else { if (batchCount > 1) { const batchStartIndex = batchesProcessedRef.current; @@ -190,6 +201,7 @@ export function LaunchNFT(props: { count: batchSize, startIndex: batchIndex * batchSize, }, + gasless: isGasless, values: formValues, }); @@ -201,12 +213,16 @@ export function LaunchNFT(props: { count: formValues.nfts.length, startIndex: 0, }, + gasless: isGasless, values: formValues, }); } } } else if (stepId === "mint-nfts") { - await props.createNFTFunctions[ercType].lazyMintNFTs(formValues); + await props.createNFTFunctions[ercType].lazyMintNFTs({ + gasless: isGasless, + values: formValues, + }); } else if (stepId === "set-admins") { // this is type guard, this can never happen if (!contractAddressRef.current) { @@ -218,6 +234,7 @@ export function LaunchNFT(props: { chain: formValues.collectionInfo.chain, contractAddress: contractAddressRef.current, contractType: ercType === "erc721" ? "DropERC721" : "DropERC1155", + gasless: isGasless, }); } } @@ -311,6 +328,7 @@ export function LaunchNFT(props: { custom: ( - Launch NFT + Launch NFT Collection ), type: "custom", @@ -327,7 +345,7 @@ export function LaunchNFT(props: { prevButton={{ onClick: props.onPrevious, }} - title="Launch NFT" + title="Launch NFT Collection" > + {/* NFTs */}

NFTs

@@ -509,6 +528,7 @@ export function LaunchNFT(props: {
+ {/* sales and fees */}

Sales and Fees

@@ -537,6 +557,31 @@ export function LaunchNFT(props: {
+ + {/* gasless */} + {showGaslessSection && ( +
+
+
+

Sponsor Gas

+

+ Sponsor gas fees for launching your NFT collection.
This + allows you to launch the NFT collection without requiring any + balance in your wallet +

+
+ +
+
+ )} ); } diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page-impl.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page-impl.tsx index 40d170391df..6afaa8ea606 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page-impl.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page-impl.tsx @@ -1,7 +1,7 @@ "use client"; import { useRef } from "react"; -import { toast } from "sonner"; import { + defineChain, getAddress, getContract, NATIVE_TOKEN_ADDRESS, @@ -18,6 +18,7 @@ import { transferBatch, } from "thirdweb/extensions/erc20"; import { useActiveAccount } from "thirdweb/react"; +import { create7702MinimalAccount } from "thirdweb/wallets/smart"; import { revalidatePathAction } from "@/actions/revalidate"; import { reportAssetCreationFailed, @@ -45,18 +46,52 @@ export function CreateTokenAssetPage(props: { projectSlug: string; teamPlan: Team["billingPlan"]; }) { - const account = useActiveAccount(); + const activeAccount = useActiveAccount(); const { idToChain } = useAllChainsData(); const addContractToProject = useAddContractToProject(); const contractAddressRef = useRef(undefined); - async function deployContract(formValues: CreateAssetFormValues) { - if (!account) { - toast.error("No Connected Wallet"); + function getAccount(gasless: boolean) { + if (!activeAccount) { throw new Error("No Connected Wallet"); } - const socialUrls = formValues.socialUrls.reduce( + if (gasless) { + return create7702MinimalAccount({ + adminAccount: activeAccount, + client: props.client, + sponsorGas: true, + }); + } + return activeAccount; + } + + function getDeployedContract(params: { chain: string }) { + const contractAddress = contractAddressRef.current; + + if (!contractAddress) { + throw new Error("Contract address not set"); + } + + // eslint-disable-next-line no-restricted-syntax + const chain = defineChain(Number(params.chain)); + + return getContract({ + address: contractAddress, + chain, + client: props.client, + }); + } + + async function deployContract(params: { + values: CreateAssetFormValues; + gasless: boolean; + }) { + const { values, gasless } = params; + + const account = getAccount(gasless); + + const socialUrls = values.socialUrls.reduce( (acc, url) => { if (url.url && url.platform) { acc[url.platform] = url.url; @@ -71,29 +106,31 @@ export function CreateTokenAssetPage(props: { account, // eslint-disable-next-line no-restricted-syntax chain: defineDashboardChain( - Number(formValues.chain), - idToChain.get(Number(formValues.chain)), + Number(values.chain), + idToChain.get(Number(values.chain)), ), client: props.client, params: { - description: formValues.description, - image: formValues.image, + description: values.description, + image: values.image, // metadata - name: formValues.name, + name: values.name, // platform fees platformFeeBps: BigInt(DEFAULT_FEE_BPS_NEW), platformFeeRecipient: DEFAULT_FEE_RECIPIENT, // primary sale saleRecipient: account.address, social_urls: socialUrls, - symbol: formValues.symbol, + symbol: values.symbol, }, type: "DropERC20", }); + contractAddressRef.current = contractAddress; + // add contract to project in background addContractToProject.mutateAsync({ - chainId: formValues.chain, + chainId: values.chain, contractAddress: contractAddress, contractType: "DropERC20", deploymentType: "asset", @@ -103,18 +140,17 @@ export function CreateTokenAssetPage(props: { reportContractDeployed({ address: contractAddress, - chainId: Number(formValues.chain), + chainId: Number(values.chain), contractName: "DropERC20", deploymentType: "asset", publisher: "deployer.thirdweb.eth", }); - contractAddressRef.current = contractAddress; - return { contractAddress: contractAddress, }; } catch (e) { + console.error(e); const parsedError = parseError(e); const errorMessage = typeof parsedError === "string" ? parsedError : "Unknown error"; @@ -131,32 +167,25 @@ export function CreateTokenAssetPage(props: { } } - async function airdropTokens(formValues: CreateAssetFormValues) { - const contractAddress = contractAddressRef.current; + async function airdropTokens(params: { + values: CreateAssetFormValues; + gasless: boolean; + }) { + const { values, gasless } = params; - if (!contractAddress) { - throw new Error("No contract address"); - } + const contract = getDeployedContract({ + chain: values.chain, + }); + + const account = getAccount(gasless); if (!account) { throw new Error("No connected account"); } - // eslint-disable-next-line no-restricted-syntax - const chain = defineDashboardChain( - Number(formValues.chain), - idToChain.get(Number(formValues.chain)), - ); - - const contract = getContract({ - address: contractAddress, - chain, - client: props.client, - }); - try { const airdropTx = transferBatch({ - batch: formValues.airdropAddresses.map((recipient) => ({ + batch: values.airdropAddresses.map((recipient) => ({ amount: recipient.quantity, to: recipient.address, })), @@ -181,28 +210,18 @@ export function CreateTokenAssetPage(props: { } } - async function mintTokens(formValues: CreateAssetFormValues) { - const contractAddress = contractAddressRef.current; - if (!contractAddress) { - throw new Error("No contract address"); - } + async function mintTokens(params: { + values: CreateAssetFormValues; + gasless: boolean; + }) { + const { values, gasless } = params; - if (!account) { - throw new Error("No connected account"); - } - - // eslint-disable-next-line no-restricted-syntax - const chain = defineDashboardChain( - Number(formValues.chain), - idToChain.get(Number(formValues.chain)), - ); - - const contract = getContract({ - address: contractAddress, - chain, - client: props.client, + const contract = getDeployedContract({ + chain: values.chain, }); + const account = getAccount(gasless); + // poll until claim conditions are set before moving on to minting await pollWithTimeout({ shouldStop: async () => { @@ -214,9 +233,9 @@ export function CreateTokenAssetPage(props: { timeoutMs: 30000, }); - const totalSupply = Number(formValues.supply); - const salePercent = formValues.saleEnabled - ? Number(formValues.saleAllocationPercentage) + const totalSupply = Number(values.supply); + const salePercent = values.saleEnabled + ? Number(values.saleAllocationPercentage) : 0; const ownerAndAirdropPercent = 100 - salePercent; @@ -248,48 +267,36 @@ export function CreateTokenAssetPage(props: { } } - async function setClaimConditions(formValues: CreateAssetFormValues) { - const contractAddress = contractAddressRef.current; - - if (!contractAddress) { - throw new Error("No contract address"); - } - - if (!account) { - throw new Error("No connected account"); - } - - // eslint-disable-next-line no-restricted-syntax - const chain = defineDashboardChain( - Number(formValues.chain), - idToChain.get(Number(formValues.chain)), - ); - - const contract = getContract({ - address: contractAddress, - chain, - client: props.client, + async function setClaimConditions(params: { + values: CreateAssetFormValues; + gasless: boolean; + }) { + const { values, gasless } = params; + const contract = getDeployedContract({ + chain: values.chain, }); - const salePercent = formValues.saleEnabled - ? Number(formValues.saleAllocationPercentage) + const account = getAccount(gasless); + + const salePercent = values.saleEnabled + ? Number(values.saleAllocationPercentage) : 0; - const totalSupply = Number(formValues.supply); + const totalSupply = Number(values.supply); const totalSupplyWei = toUnits(totalSupply.toString(), 18); const phases: ClaimConditionsInput[] = [ { currencyAddress: - getAddress(formValues.saleTokenAddress) === + getAddress(values.saleTokenAddress) === getAddress(NATIVE_TOKEN_ADDRESS) ? undefined - : formValues.saleTokenAddress, - maxClaimablePerWallet: formValues.saleEnabled ? undefined : 0n, + : values.saleTokenAddress, + maxClaimablePerWallet: values.saleEnabled ? undefined : 0n, maxClaimableSupply: totalSupplyWei, metadata: { name: - formValues.saleEnabled && salePercent > 0 + values.saleEnabled && salePercent > 0 ? "Coin Sale phase" : "Only Owner phase", }, @@ -300,10 +307,7 @@ export function CreateTokenAssetPage(props: { price: "0", }, ], - price: - formValues.saleEnabled && salePercent > 0 - ? formValues.salePrice - : "0", + price: values.saleEnabled && salePercent > 0 ? values.salePrice : "0", startTime: new Date(), }, ]; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page.client.tsx index 3e0fb138e99..baa1db8b59f 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page.client.tsx @@ -22,13 +22,18 @@ import { TokenDistributionFieldset } from "./distribution/token-distribution"; import { LaunchTokenStatus } from "./launch/launch-token"; import { TokenInfoFieldset } from "./token-info/token-info-fieldset"; +type CreateTokenFunctionsParams = { + values: CreateAssetFormValues; + gasless: boolean; +}; + export type CreateTokenFunctions = { - deployContract: (values: CreateAssetFormValues) => Promise<{ + deployContract: (params: CreateTokenFunctionsParams) => Promise<{ contractAddress: string; }>; - setClaimConditions: (values: CreateAssetFormValues) => Promise; - mintTokens: (values: CreateAssetFormValues) => Promise; - airdropTokens: (values: CreateAssetFormValues) => Promise; + setClaimConditions: (params: CreateTokenFunctionsParams) => Promise; + mintTokens: (params: CreateTokenFunctionsParams) => Promise; + airdropTokens: (params: CreateTokenFunctionsParams) => Promise; }; const checksummedNativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS); diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/launch/launch-token.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/launch/launch-token.tsx index bbd5f0cea00..535af3782ec 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/launch/launch-token.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/launch/launch-token.tsx @@ -13,6 +13,7 @@ import { reportAssetCreationSuccessful, } from "@/analytics/report"; import type { Team } from "@/api/team"; +import { GatedSwitch } from "@/components/blocks/GatedSwitch"; import { type MultiStepState, MultiStepStatus, @@ -26,6 +27,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { useAllChainsData } from "@/hooks/chains/allChains"; import { parseError } from "@/utils/errorParser"; import { ChainOverview } from "../../_common/chain-overview"; import { FilePreview } from "../../_common/file-preview"; @@ -62,6 +64,13 @@ export function LaunchTokenStatus(props: { const activeWallet = useActiveWallet(); const walletRequiresApproval = activeWallet?.id !== "inApp"; + const canEnableGasless = + props.teamPlan !== "free" && activeWallet?.id === "inApp"; + const [isGasless, setIsGasless] = useState(canEnableGasless); + const showGaslessSection = activeWallet?.id === "inApp"; + const { idToChain } = useAllChainsData(); + const chainMetadata = idToChain.get(Number(formValues.chain)); + function updateStatus( index: number, newStatus: MultiStepState["status"], @@ -104,30 +113,36 @@ export function LaunchTokenStatus(props: { setSteps(initialSteps); setIsModalOpen(true); - executeSteps(initialSteps, 0); + executeSteps(initialSteps, 0, isGasless); } const isComplete = steps.every((step) => step.status.type === "completed"); const isPending = steps.some((step) => step.status.type === "pending"); - async function executeStep(stepId: StepId) { + async function executeStep(stepId: StepId, gasless: boolean) { + const params = { + gasless, + values: formValues, + }; + if (stepId === "deploy-contract") { - const result = await createTokenFunctions.deployContract(formValues); + const result = await createTokenFunctions.deployContract(params); setContractLink( `/team/${props.teamSlug}/${props.projectSlug}/contract/${formValues.chain}/${result.contractAddress}`, ); } else if (stepId === "set-claim-conditions") { - await createTokenFunctions.setClaimConditions(formValues); + await createTokenFunctions.setClaimConditions(params); } else if (stepId === "mint-tokens") { - await createTokenFunctions.mintTokens(formValues); + await createTokenFunctions.mintTokens(params); } else if (stepId === "airdrop-tokens") { - await createTokenFunctions.airdropTokens(formValues); + await createTokenFunctions.airdropTokens(params); } } async function executeSteps( steps: MultiStepState[], startIndex: number, + gasless: boolean, ) { for (let i = startIndex; i < steps.length; i++) { const currentStep = steps[i]; @@ -140,7 +155,7 @@ export function LaunchTokenStatus(props: { type: "pending", }); - await executeStep(currentStep.id); + await executeStep(currentStep.id, gasless); updateStatus(i, { type: "completed", @@ -172,20 +187,22 @@ export function LaunchTokenStatus(props: { props.onLaunchSuccess(); } - async function handleRetry(step: MultiStepState) { + async function handleRetry(step: MultiStepState, gasless: boolean) { const startIndex = steps.findIndex((s) => s.id === step.id); if (startIndex === -1) { return; } - await executeSteps(steps, startIndex); + await executeSteps(steps, startIndex, gasless); } + return ( + {/* gasless */} + {showGaslessSection && ( +
+
+
+

Sponsor Gas

+

+ Sponsor gas fees for launching your coin.
This allows you + to launch the coin without requiring any balance in your wallet +

+
+ +
+
+ )} + handleRetry(step, isGasless)} renderError={(step, errorMessage) => { if ( props.teamPlan === "free" && @@ -282,13 +323,40 @@ export function LaunchTokenStatus(props: { ) { return ( handleRetry(step)} + onRetry={() => handleRetry(step, isGasless)} teamSlug={props.teamSlug} trackingCampaign="create-coin" /> ); } + if ( + errorMessage + .toLowerCase() + .includes("does not support eip-7702") + ) { + return ( +
+

+ Gas Sponsorship is not supported on{" "} + {chainMetadata?.name || "selected chain"} +

+ + +
+ ); + } + return null; }} steps={steps} diff --git a/packages/thirdweb/src/utils/fetch.test.ts b/packages/thirdweb/src/utils/fetch.test.ts index 8f510c52c59..2da07fa5de9 100644 --- a/packages/thirdweb/src/utils/fetch.test.ts +++ b/packages/thirdweb/src/utils/fetch.test.ts @@ -72,6 +72,25 @@ describe("getClientFetch", () => { expect(headers.get("authorization")).toBe(null); }); + it("should send clientId, teamId and jwt for bundler requests", () => { + vi.spyOn(global, "fetch").mockResolvedValue(new Response()); + const clientFetch = getClientFetch({ + clientId: "test-client-id", + secretKey: "foo.bar.baz", + teamId: "test-team-id", + }); + + clientFetch("https://84532.bundler.thirdweb-dev.com/v2", { + useAuthToken: true, // bundler requests have useAuthToken set to true + }); + + // biome-ignore lint/suspicious/noExplicitAny: `any` type ok for tests + const headers = (global.fetch as any).mock.calls[0][1].headers; + expect(headers.get("x-client-id")).toBe("test-client-id"); + expect(headers.get("x-team-id")).toBe("test-team-id"); + expect(headers.get("authorization")).toBe("Bearer foo.bar.baz"); + }); + it("should send a bearer token if secret key is a JWT and useAuthToken is true", () => { vi.spyOn(global, "fetch").mockResolvedValue(new Response()); const clientFetch = getClientFetch({ diff --git a/packages/thirdweb/src/utils/fetch.ts b/packages/thirdweb/src/utils/fetch.ts index 61005c73c4c..e09dcfe80bb 100644 --- a/packages/thirdweb/src/utils/fetch.ts +++ b/packages/thirdweb/src/utils/fetch.ts @@ -57,13 +57,22 @@ export function getClientFetch(client: ThirdwebClient, ecosystem?: Ecosystem) { : undefined; const clientId = client.clientId; + if (authToken && isBundlerUrl(urlString)) { + headers.set("authorization", `Bearer ${authToken}`); + if (client.teamId) { + headers.set("x-team-id", client.teamId); + } + + if (clientId) { + headers.set("x-client-id", clientId); + } + } // if we have an auth token set, use that (thirdweb dashboard sets this for the user) // pay urls should never send the auth token, because we always want the "developer" to be the one making the request, not the "end user" - if ( + else if ( authToken && !isPayUrl(urlString) && - !isInAppWalletUrl(urlString) && - !isBundlerUrl(urlString) + !isInAppWalletUrl(urlString) ) { headers.set("authorization", `Bearer ${authToken}`); // if we have a specific teamId set, add it to the request headers diff --git a/packages/thirdweb/src/wallets/smart/lib/bundler.ts b/packages/thirdweb/src/wallets/smart/lib/bundler.ts index bec1d5a0b93..d8264c29f08 100644 --- a/packages/thirdweb/src/wallets/smart/lib/bundler.ts +++ b/packages/thirdweb/src/wallets/smart/lib/bundler.ts @@ -423,6 +423,7 @@ async function sendBundlerRequest(args: { const bundlerUrl = options.bundlerUrl ?? getDefaultBundlerUrl(options.chain); const fetchWithHeaders = getClientFetch(options.client); const response = await fetchWithHeaders(bundlerUrl, { + useAuthToken: true, body: stringify({ id: 1, jsonrpc: "2.0",