From 9de031b1794b4d04c6021aed2e5be405a02beeb0 Mon Sep 17 00:00:00 2001 From: Luke Rohenaz Date: Mon, 13 May 2024 10:24:48 -0400 Subject: [PATCH 01/17] collection minter wip --- src/app/listings/[tab]/page.tsx | 39 +- src/app/market/[tab]/[id]/page.tsx | 39 - src/app/market/[tab]/new/page.tsx | 59 -- src/app/market/[tab]/page.tsx | 41 +- src/components/Collections/index.tsx | 8 +- src/components/LRC20Listings/index.tsx | 37 - src/components/LRC20Listings/list.tsx | 56 - src/components/artifact/index.tsx | 14 - src/components/artifact/json.tsx | 37 +- src/components/marketMenu/index.tsx | 11 + src/components/pages/TokenMarket/fund.tsx | 5 +- src/components/pages/inscribe/bsv21.tsx | 961 +++++++++--------- src/components/pages/inscribe/collection.tsx | 555 +++++++++- .../pages/inscribe/collectionItemForm.tsx | 103 ++ src/components/pages/inscribe/filePreview.tsx | 44 + src/components/pages/inscribe/html.tsx | 2 +- src/components/pages/inscribe/image.tsx | 444 ++------ src/components/pages/inscribe/index.tsx | 23 +- .../pages/inscribe/inscribeButton.tsx | 96 ++ src/components/pages/inscribe/metaForm.tsx | 147 +++ src/components/pages/inscribe/styles.ts | 12 + src/components/pages/inscribe/tabs/index.tsx | 32 +- .../pages/inscribe/useFileHandler.tsx | 55 + src/components/pages/listings/index.tsx | 8 - src/components/pages/listings/tabs.tsx | 26 +- src/components/pages/market/index.tsx | 8 - .../pages/outpoint/ownerContent.tsx | 2 +- src/components/pages/search/index.tsx | 8 - src/constants.ts | 29 +- src/utils/address.ts | 70 +- src/utils/artifact.ts | 296 +++--- src/utils/assetType.ts | 17 +- src/utils/inscribe.ts | 29 +- 33 files changed, 1901 insertions(+), 1412 deletions(-) delete mode 100644 src/components/LRC20Listings/index.tsx delete mode 100644 src/components/LRC20Listings/list.tsx create mode 100644 src/components/pages/inscribe/collectionItemForm.tsx create mode 100644 src/components/pages/inscribe/filePreview.tsx create mode 100644 src/components/pages/inscribe/inscribeButton.tsx create mode 100644 src/components/pages/inscribe/metaForm.tsx create mode 100644 src/components/pages/inscribe/styles.ts create mode 100644 src/components/pages/inscribe/useFileHandler.tsx diff --git a/src/app/listings/[tab]/page.tsx b/src/app/listings/[tab]/page.tsx index 65992344..bc0561b5 100644 --- a/src/app/listings/[tab]/page.tsx +++ b/src/app/listings/[tab]/page.tsx @@ -1,6 +1,6 @@ import ListingsPage from "@/components/pages/listings"; import { API_HOST, AssetType } from "@/constants"; -import type { BSV20TXO, OrdUtxo } from "@/types/ordinals"; +import type { BSV20TXO } from "@/types/ordinals"; import { getCapitalizedAssetType } from "@/utils/assetType"; import * as http from "@/utils/httpClient"; @@ -29,43 +29,6 @@ const Listings = async ({ params }: { params: { tab: AssetType } }) => { selectedAssetType={AssetType.BSV21} /> ); - case AssetType.LRC20: - const q = { - insc: { - json: { - p: "lrc-20", - }, - }, - }; - - const urlLrc20 = `${API_HOST}/api/market?sort=recent&dir=desc&limit=20&offset=0&q=${btoa( - JSON.stringify(q) - )}`; - const { promise: promiseLrc20 } = - http.customFetch(urlLrc20); - const lrc20Listings = await promiseLrc20; - - const lrc20TokenIds = lrc20Listings - .filter((l) => !!l.origin?.data?.insc?.json?.id) - .map((l) => l.origin?.data?.insc?.json?.id!); - - const urlLrc20Tokens = `${API_HOST}/api/txos/outpoints`; - const { promise: promiseLrc20Tokens } = http.customFetch( - urlLrc20Tokens, - { - method: "POST", - body: JSON.stringify(lrc20TokenIds), - } - ); - const lrc20Tokens = await promiseLrc20Tokens; - - return ( - - ); default: return null; } diff --git a/src/app/market/[tab]/[id]/page.tsx b/src/app/market/[tab]/[id]/page.tsx index d90aee97..9026ea17 100644 --- a/src/app/market/[tab]/[id]/page.tsx +++ b/src/app/market/[tab]/[id]/page.tsx @@ -2,7 +2,6 @@ import MarketPage from "@/components/pages/market"; import { API_HOST, AssetType } from "@/constants"; import { OrdUtxo } from "@/types/ordinals"; import { getCapitalizedAssetType } from "@/utils/assetType"; -import * as http from "@/utils/httpClient"; import { redirect } from "next/navigation"; const Market = async ({ @@ -29,44 +28,6 @@ const Market = async ({ return ( ); - case AssetType.LRC20: { - const q = { - insc: { - json: { - p: "lrc-20", - }, - }, - }; - - const urlLrc20 = `${API_HOST}/api/market?sort=recent&dir=desc&limit=20&offset=0&q=${btoa( - JSON.stringify(q) - )}`; - const { promise: promiseLrc20 } = - http.customFetch(urlLrc20); - const lrc20Listings = await promiseLrc20; - - const lrc20TokenIds = lrc20Listings - .filter((l) => !!l.origin?.data?.insc?.json?.id) - .map((l) => l.origin?.data?.insc?.json?.id!); - - const urlLrc20Tokens = `${API_HOST}/api/txos/outpoints`; - const { promise: promiseLrc20Tokens } = http.customFetch( - urlLrc20Tokens, - { - method: "POST", - body: JSON.stringify(lrc20TokenIds), - } - ); - const lrc20Tokens = await promiseLrc20Tokens; - - return ( - - ); - } default: return null; } diff --git a/src/app/market/[tab]/new/page.tsx b/src/app/market/[tab]/new/page.tsx index 03f8ddae..d027d397 100644 --- a/src/app/market/[tab]/new/page.tsx +++ b/src/app/market/[tab]/new/page.tsx @@ -3,65 +3,6 @@ import type { AssetType } from "@/constants"; import { getCapitalizedAssetType } from "@/utils/assetType"; const Market = async ({ params }: { params: { tab: AssetType } }) => { - // switch (params.tab) { - // case AssetType.Ordinals: - // // TODO: Featured ordinals - // const urlImages = `${API_HOST}/api/market?sort=recent&dir=desc&limit=20&offset=0&type=image/png`; - // const { promise } = http.customFetch(urlImages); - // const imageListings = await promise; - // return ( - // - // ); - // case AssetType.BSV20: - // return ( - // - // ); - // case AssetType.BSV21: - // return ( - // - // ); - // case AssetType.LRC20: - // const q = { - // insc: { - // json: { - // p: "lrc-20", - // }, - // }, - // }; - - // const urlLrc20 = `${API_HOST}/api/market?sort=recent&dir=desc&limit=20&offset=0&q=${btoa( - // JSON.stringify(q) - // )}`; - // const { promise: promiseLrc20 } = http.customFetch(urlLrc20); - // const lrc20Listings = await promiseLrc20; - - // const lrc20TokenIds = lrc20Listings - // .filter((l) => !!l.origin?.data?.insc?.json?.id) - // .map((l) => l.origin?.data?.insc?.json?.id!); - - // const urlLrc20Tokens = `${API_HOST}/api/txos/outpoints`; - // const { promise: promiseLrc20Tokens } = http.customFetch( - // urlLrc20Tokens, - // { - // method: "POST", - // body: JSON.stringify(lrc20TokenIds), - // } - // ); - // const lrc20Tokens = await promiseLrc20Tokens; - - // return ( - - // ); - // default: - // return null; - // } return ; }; export default Market; diff --git a/src/app/market/[tab]/page.tsx b/src/app/market/[tab]/page.tsx index 6696a1e3..a09b3f88 100644 --- a/src/app/market/[tab]/page.tsx +++ b/src/app/market/[tab]/page.tsx @@ -1,8 +1,6 @@ import MarketPage from "@/components/pages/market"; -import { API_HOST, AssetType } from "@/constants"; -import type { OrdUtxo } from "@/types/ordinals"; +import { AssetType } from "@/constants"; import { getCapitalizedAssetType } from "@/utils/assetType"; -import * as http from "@/utils/httpClient"; const Market = async ({ params }: { params: { tab: AssetType } }) => { switch (params.tab) { @@ -21,43 +19,6 @@ const Market = async ({ params }: { params: { tab: AssetType } }) => { return ; case AssetType.BSV21: return ; - case AssetType.LRC20: - const q = { - insc: { - json: { - p: "lrc-20", - }, - }, - }; - - const urlLrc20 = `${API_HOST}/api/market?sort=recent&dir=desc&limit=20&offset=0&q=${btoa( - JSON.stringify(q) - )}`; - const { promise: promiseLrc20 } = - http.customFetch(urlLrc20); - const lrc20Listings = await promiseLrc20; - - const lrc20TokenIds = lrc20Listings - .filter((l) => !!l.origin?.data?.insc?.json?.id) - .map((l) => l.origin?.data?.insc?.json?.id!); - - const urlLrc20Tokens = `${API_HOST}/api/txos/outpoints`; - const { promise: promiseLrc20Tokens } = http.customFetch( - urlLrc20Tokens, - { - method: "POST", - body: JSON.stringify(lrc20TokenIds), - } - ); - const lrc20Tokens = await promiseLrc20Tokens; - - return ( - - ); default: return null; } diff --git a/src/components/Collections/index.tsx b/src/components/Collections/index.tsx index 71f59bbb..01acbfd8 100644 --- a/src/components/Collections/index.tsx +++ b/src/components/Collections/index.tsx @@ -6,6 +6,7 @@ import { useQuery } from "@tanstack/react-query"; import { Noto_Serif } from "next/font/google"; import Image from "next/image"; import Link from "next/link"; +import { FaPlus } from "react-icons/fa6"; import "slick-carousel/slick/slick-theme.css"; import "slick-carousel/slick/slick.css"; import FeaturedCollections from "./featured"; @@ -29,7 +30,12 @@ const Collections = () => { <>

Featured Collections

-

Current Hype

+

+
+ Current Hype +
+ New +

{data?.map((c) => (
diff --git a/src/components/LRC20Listings/index.tsx b/src/components/LRC20Listings/index.tsx deleted file mode 100644 index 6b58b2a6..00000000 --- a/src/components/LRC20Listings/index.tsx +++ /dev/null @@ -1,37 +0,0 @@ -"use client"; - -import { OrdUtxo } from "@/types/ordinals"; -import { Suspense } from "react"; -import TokenListingSkeleton from "../skeletons/listing/Token"; -import List from "./list"; - -interface LRC20ListingsProps { - listings: OrdUtxo[]; - tokens: OrdUtxo[]; -} - - -const LRC20Listings: React.FC = ({ listings, tokens }) => { - return ( -
-
- - - - - - - - - - }> - - - -
TickerAmountSats / TokenTotal Price
-
-
- ); -}; - -export default LRC20Listings; diff --git a/src/components/LRC20Listings/list.tsx b/src/components/LRC20Listings/list.tsx deleted file mode 100644 index 4df3c6c1..00000000 --- a/src/components/LRC20Listings/list.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { OrdUtxo } from "@/types/ordinals"; -import { toBitcoin } from "satoshi-bitcoin-ts"; - -const List = ({ - listings, - tokens, -}: { - listings: OrdUtxo[]; - tokens: OrdUtxo[]; -}) => { - return ( - - {listings - .sort((a, b) => { - // sort by the price sats/token - return satsPerToken(a) < satsPerToken(b) ? -1 : 1; - }) - .map((listing) => { - const token = tokens.find((t) => { - return t.origin?.outpoint === listing.origin?.data?.insc?.json?.id; - })?.origin?.data?.insc?.json; - if (!token || !token?.origin?.outpoint) { - // console.log({id:listing.origin?.data?.insc?.json?.id, tokenOrigin: token?.origin?.outpoint, token}) - } - return ( - - {token?.tick} - {listingAmount(listing)} - {satsPerToken(listing)} - - {toBitcoin(listing.data?.list?.price || "0", true).toString()}{" "} - BSV - - - ); - })} - - ); -}; - -export default List; - -const listingAmount = (listing: OrdUtxo) => { - if (listing.origin?.data?.insc?.json) { - return listing.origin.data.insc.json.amt; - } -}; - -const satsPerToken = (listing: OrdUtxo) => { - if (listing.origin?.data?.insc) { - const price = listing.data?.list?.price || 0; - const amt = parseInt(listing.origin.data.insc.json.amt || "0"); - return Math.floor(price / amt); - } - return 0; -}; diff --git a/src/components/artifact/index.tsx b/src/components/artifact/index.tsx index 385c8f8c..9ffefe86 100644 --- a/src/components/artifact/index.tsx +++ b/src/components/artifact/index.tsx @@ -30,20 +30,6 @@ const Model = dynamic(() => import("../model"), { export enum ArtifactType { All = "All", - // Image = 1, - // Model = 2, - // PDF = 3, - // Video = 4, - // Javascript = 5, - // HTML = 6, - // MarkDown = 7, - // Text = 8, - // JSON = 9, - // BSV20 = 10, - // OPNS = 11, - // Unknown = 12, - // LRC20 = 13, - // Audio = 14, Image = "Image", Model = "Model", PDF = "PDF", diff --git a/src/components/artifact/json.tsx b/src/components/artifact/json.tsx index 283a4665..f8ecca48 100644 --- a/src/components/artifact/json.tsx +++ b/src/components/artifact/json.tsx @@ -1,7 +1,7 @@ import { FetchStatus, ORDFS } from "@/constants"; -import { BSV20 } from "@/types/bsv20"; -import { LRC20 } from "@/types/ordinals"; -import React, { useEffect, useState } from "react"; +import type { BSV20 } from "@/types/bsv20"; +import type React from "react"; +import { useEffect, useState } from "react"; import { LoaderIcon } from "react-hot-toast"; import { FaCode } from "react-icons/fa6"; import { ArtifactType } from "."; @@ -25,7 +25,6 @@ const JsonArtifact: React.FC = ({ }) => { const [json, setJson] = useState(j); const [bsv20, setBsv20] = useState | undefined>(); - const [lrc20, setLrc20] = useState | undefined>(undefined); const [fetchTextStatus, setFetchTextStatus] = useState( FetchStatus.Idle ); @@ -44,12 +43,10 @@ const JsonArtifact: React.FC = ({ setFetchTextStatus(FetchStatus.Success); setJson(resultText); - if (type === ArtifactType.LRC20 || type === ArtifactType.BSV20) { + if (type === ArtifactType.BSV20) { const txJson = artifact.origin; setFetchBsv20Status(FetchStatus.Success); - if (type === ArtifactType.LRC20) { - setLrc20(txJson); - } else if (type === ArtifactType.BSV20) { + if (type === ArtifactType.BSV20) { setBsv20(txJson); } } @@ -84,9 +81,8 @@ const JsonArtifact: React.FC = ({
{!mini && (
           {JSON.stringify(json, null, 2)}
         
@@ -96,25 +92,6 @@ const JsonArtifact: React.FC = ({
)} - {/* {!mini && - type === ArtifactType.BSV20 && - bsv20 && - bsv20.status !== Bsv20Status.Valid && ( -
- {" "} - {`${ - bsv20.status === Bsv20Status.Pending - ? "PENDING VALIDATION" - : "INVALID BSV20" - }`} -
- )} */}
) : ( diff --git a/src/components/marketMenu/index.tsx b/src/components/marketMenu/index.tsx index 6fd8f1c9..ccad4051 100644 --- a/src/components/marketMenu/index.tsx +++ b/src/components/marketMenu/index.tsx @@ -78,6 +78,17 @@ const MarketMenu: React.FC = () => {
NFT
+
  • + +
    Inscribe
    +
    NFT / FT
    + +
  • + +
    Token Market
  • { Listing Price{" "}
    diff --git a/src/components/pages/inscribe/bsv21.tsx b/src/components/pages/inscribe/bsv21.tsx index 98eb7b6b..f4bd77a9 100644 --- a/src/components/pages/inscribe/bsv21.tsx +++ b/src/components/pages/inscribe/bsv21.tsx @@ -1,22 +1,22 @@ "use client"; import Artifact from "@/components/artifact"; -import { B_PREFIX, FetchStatus, toastErrorProps } from "@/constants"; +import { B_PREFIX, FetchStatus, knownImageTypes, toastErrorProps } from "@/constants"; import { - chainInfo, - indexers, - payPk, - pendingTxs, - usdRate, - utxos, + chainInfo, + indexers, + payPk, + pendingTxs, + usdRate, + utxos, } from "@/signals/wallet"; import { fundingAddress, ordAddress } from "@/signals/wallet/address"; import type { TxoData } from "@/types/ordinals"; import { getUtxos } from "@/utils/address"; import { calculateIndexingFee } from "@/utils/bsv20"; import { - inscribeUtf8WithData, - type StringOrBufferArray, + inscribeUtf8WithData, + type StringOrBufferArray, } from "@/utils/inscribe"; import type { Utxo } from "@/utils/js-1sat-ord"; import { computed } from "@preact/signals-react"; @@ -29,487 +29,484 @@ import toast from "react-hot-toast"; import { IoMdWarning } from "react-icons/io"; import { RiSettings2Fill } from "react-icons/ri"; import { IconWithFallback } from "../TokenMarket/heading"; -import { knownImageTypes } from "./image"; import type { InscriptionTab } from "./tabs"; const top10 = ["FREN", "LOVE", "TRMP", "GOLD", "TOPG", "CAAL"]; interface InscribeBsv21Props { - inscribedCallback: () => void; + inscribedCallback: () => void; } const InscribeBsv21: React.FC = ({ inscribedCallback }) => { - useSignals(); - const router = useRouter(); - const params = useSearchParams(); - // const { tab, tick, op } = params.query as { tab: string; tick: string; op: string }; - const tab = params.get("tab") as InscriptionTab; - const tick = params.get("tick"); - const op = params.get("op"); - const [selectedFile, setSelectedFile] = useState(null); - const [isImage, setIsImage] = useState(false); - const [preview, setPreview] = useState(null); - - const [fetchTickerStatus, setFetchTickerStatus] = useState( - FetchStatus.Idle - ); - const [inscribeStatus, setInscribeStatus] = useState( - FetchStatus.Idle - ); - const [limit, setLimit] = useState("1337"); - const [maxSupply, setMaxSupply] = useState("21000000"); - const [decimals, setDecimals] = useState(); - const [amount, setAmount] = useState(); - const [mintError, setMintError] = useState(); - const [showOptionalFields, setShowOptionalFields] = - useState(false); - const [iterations, setIterations] = useState(1); - - const [ticker, setTicker] = useState(tick); - - useEffect(() => { - if (tick) { - setTicker(tick); - } - }, [setTicker, tick]); - - const toggleOptionalFields = useCallback(() => { - setShowOptionalFields(!showOptionalFields); - }, [showOptionalFields]); - - const changeTicker = useCallback( - (e: any) => { - setTicker(e.target.value); - }, - [setTicker] - ); - - const changeMaxSupply = useCallback( - (e: any) => { - setMaxSupply(e.target.value); - }, - [setMaxSupply] - ); - - const changeIterations = useCallback( - (e: any) => { - console.log("changing iterations to", e.target.value); - setIterations(Number.parseInt(e.target.value)); - }, - [setIterations] - ); - - const inSync = computed(() => { - if (!indexers.value || !chainInfo.value) { - return false; - } - - console.log({ indexers: indexers.value, chainInfo: chainInfo.value }); - return ( - indexers.value["bsv20-deploy"] >= chainInfo.value?.blocks && - indexers.value.bsv20 >= chainInfo.value?.blocks - ); - }); - - const totalTokens = useMemo(() => { - return iterations * Number.parseInt(amount || "0"); - }, [amount, iterations]); - - const changeLimit = useCallback( - (e: any) => { - setLimit(e.target.value); - }, - [setLimit] - ); - - const changeDecimals = useCallback( - (e: any) => { - setDecimals( - e.target.value ? Number.parseInt(e.target.value) : undefined - ); - }, - [setDecimals] - ); - - const changeAmount = useCallback( - (e: any) => { - // exclude 0 - if (Number.parseInt(e.target.value) !== 0) { - setAmount(e.target.value); - } - }, - [setAmount] - ); - - const changeFile = useCallback(async (e: any) => { - // TODO: This reads the file twice which is pretty inefficient - // would be nice to get dimensions and ArrayBuffer for preview in one go - - const file = e.target.files[0] as File; - // make sure the width and height are identical - const img = new Image(); - img.onload = () => { - if (img.width !== img.height) { - toast.error("Image must be square", toastErrorProps); - setSelectedFile(null); - setPreview(null); - setIsImage(false); - setMintError("Image must be square"); - return; - } - // max size is 400px - if (img.width > 400) { - toast.error("Width must be 400px or less", toastErrorProps); - setSelectedFile(null); - setPreview(null); - setIsImage(false); - setMintError("Width must be 400px or less"); - return; - } - if (file.size > 100000) { - toast.error("Image must be less than 100KB", toastErrorProps); - setSelectedFile(null); - setPreview(null); - setIsImage(false); - setMintError("Image must be less than 100KB"); - return; - } - setMintError(undefined); - setSelectedFile(file); - if (knownImageTypes.includes(file.type)) { - setIsImage(true); - } - const reader = new FileReader(); - - reader.onloadend = () => { - setPreview(reader.result); - }; - reader.readAsDataURL(file); - }; - img.src = URL.createObjectURL(file); - }, []); - - const artifact = useMemo(async () => { - return ( - selectedFile?.type && - preview && ( - - ) - ); - }, [preview, selectedFile]); - - type DeployBSV21Inscription = { - p: string; - op: string; - icon: string; - sym: string; - amt: string; - dec: string; - }; - - const inscribeBsv21 = useCallback( - async (utxo: Utxo) => { - if (!ticker || ticker?.length === 0 || selectedFile === null) { - return; - } - - setInscribeStatus(FetchStatus.Loading); - - // get a buffer of the file - const fileData = await selectedFile.arrayBuffer(); - - // add B output - const data = [ - B_PREFIX, - fileData, - selectedFile.type, - "binary", - ] as StringOrBufferArray; - try { - const inscription = { - p: "bsv-20", - op: "deploy+mint", - icon: "_1", - } as DeployBSV21Inscription; - - if ( - Number.parseInt(maxSupply) === 0 || - BigInt(maxSupply) > maxMaxSupply - ) { - alert( - `Invalid input: please enter a number less than or equal to ${ - maxMaxSupply - BigInt(1) - }` - ); - return; - } - - inscription.sym = ticker; - inscription.amt = maxSupply; - - // optional fields - if (decimals !== undefined) { - inscription.dec = String(decimals); - } - - const text = JSON.stringify(inscription); - const payments = [ - // { - // to: selectedBsv20.fundAddress, - // amount: 1000n, - // }, - ] as { to: string; amount: bigint }[]; - - const pendingTx = await inscribeUtf8WithData( - text, - "application/bsv-20", - utxo, - undefined, - payments, - data - ); - - pendingTx.returnTo = `/market/bsv21/${pendingTx.txid}_0`; - setInscribeStatus(FetchStatus.Success); - - if (pendingTx) { - pendingTxs.value = [pendingTx]; - inscribedCallback(); - } - } catch (error) { - setInscribeStatus(FetchStatus.Error); - - toast.error(`Failed to inscribe ${error}`, toastErrorProps); - return; - } - }, - [ticker, selectedFile, maxSupply, decimals, inscribedCallback] - ); - - const bulkInscribe = useCallback(async () => { - if (!payPk || !ordAddress || !fundingAddress.value) { - return; - } - - // range up to iterations - for (let i = 0; i < iterations; i++) { - await getUtxos(fundingAddress.value); - const sortedUtxos = utxos.value?.sort((a, b) => - a.satoshis > b.satoshis ? -1 : 1 - ); - const u = head(sortedUtxos); - if (!u) { - console.log("no utxo"); - return; - } - - return await inscribeBsv21(u); - } - }, [iterations, inscribeBsv21]); - - const clickInscribe = useCallback(async () => { - if (!payPk.value || !ordAddress.value || !fundingAddress.value) { - return; - } - - const utxos = await getUtxos(fundingAddress.value); - const sortedUtxos = utxos.sort((a, b) => - a.satoshis > b.satoshis ? -1 : 1 - ); - const u = head(sortedUtxos); - if (!u) { - console.log("no utxo"); - return; - } - - return await inscribeBsv21(u); - }, [inscribeBsv21]); - - const submitDisabled = useMemo(() => { - return ( - !ticker?.length || - inscribeStatus === FetchStatus.Loading || - fetchTickerStatus === FetchStatus.Loading || - !maxSupply || - (!!selectedFile && !isImage) - ); - }, [ - ticker?.length, - inscribeStatus, - fetchTickerStatus, - maxSupply, - selectedFile, - isImage, - ]); - - const listingFee = computed(() => { - if (!usdRate.value) { - return minFee; - } - return calculateIndexingFee(usdRate.value); - }); - - return ( -
    -
    - Deploy New Token -
    -
    - -
    - -
    -
    - {(!selectedFile || !preview) && ( -
    - -
    - )} - {selectedFile && preview && isImage && artifact} - {selectedFile && !isImage && ( -
    - X -
    - )} -
    - -
    -
    - -
    - - {!showOptionalFields && ( -
    - More Options -
    - )} - - {showOptionalFields && ( - <> -
    - -
    - - )} -
    - -
    - {preview &&
    } - - -
    - ); + useSignals(); + const router = useRouter(); + const params = useSearchParams(); + // const { tab, tick, op } = params.query as { tab: string; tick: string; op: string }; + const tab = params.get("tab") as InscriptionTab; + const tick = params.get("tick"); + const op = params.get("op"); + const [selectedFile, setSelectedFile] = useState(null); + const [isImage, setIsImage] = useState(false); + const [preview, setPreview] = useState(null); + + const [fetchTickerStatus, setFetchTickerStatus] = useState( + FetchStatus.Idle + ); + const [inscribeStatus, setInscribeStatus] = useState( + FetchStatus.Idle + ); + const [limit, setLimit] = useState("1337"); + const [maxSupply, setMaxSupply] = useState("21000000"); + const [decimals, setDecimals] = useState(); + const [amount, setAmount] = useState(); + const [mintError, setMintError] = useState(); + const [showOptionalFields, setShowOptionalFields] = + useState(false); + const [iterations, setIterations] = useState(1); + + const [ticker, setTicker] = useState(tick); + + useEffect(() => { + if (tick) { + setTicker(tick); + } + }, [setTicker, tick]); + + const toggleOptionalFields = useCallback(() => { + setShowOptionalFields(!showOptionalFields); + }, [showOptionalFields]); + + const changeTicker = useCallback( + (e: any) => { + setTicker(e.target.value); + }, + [setTicker] + ); + + const changeMaxSupply = useCallback( + (e: any) => { + setMaxSupply(e.target.value); + }, + [setMaxSupply] + ); + + const changeIterations = useCallback( + (e: any) => { + console.log("changing iterations to", e.target.value); + setIterations(Number.parseInt(e.target.value)); + }, + [setIterations] + ); + + const inSync = computed(() => { + if (!indexers.value || !chainInfo.value) { + return false; + } + + console.log({ indexers: indexers.value, chainInfo: chainInfo.value }); + return ( + indexers.value["bsv20-deploy"] >= chainInfo.value?.blocks && + indexers.value.bsv20 >= chainInfo.value?.blocks + ); + }); + + const totalTokens = useMemo(() => { + return iterations * Number.parseInt(amount || "0"); + }, [amount, iterations]); + + const changeLimit = useCallback( + (e: any) => { + setLimit(e.target.value); + }, + [setLimit] + ); + + const changeDecimals = useCallback( + (e: any) => { + setDecimals( + e.target.value ? Number.parseInt(e.target.value) : undefined + ); + }, + [setDecimals] + ); + + const changeAmount = useCallback( + (e: any) => { + // exclude 0 + if (Number.parseInt(e.target.value) !== 0) { + setAmount(e.target.value); + } + }, + [setAmount] + ); + + const changeFile = useCallback(async (e: any) => { + // TODO: This reads the file twice which is pretty inefficient + // would be nice to get dimensions and ArrayBuffer for preview in one go + + const file = e.target.files[0] as File; + // make sure the width and height are identical + const img = new Image(); + img.onload = () => { + if (img.width !== img.height) { + toast.error("Image must be square", toastErrorProps); + setSelectedFile(null); + setPreview(null); + setIsImage(false); + setMintError("Image must be square"); + return; + } + // max size is 400px + if (img.width > 400) { + toast.error("Width must be 400px or less", toastErrorProps); + setSelectedFile(null); + setPreview(null); + setIsImage(false); + setMintError("Width must be 400px or less"); + return; + } + if (file.size > 100000) { + toast.error("Image must be less than 100KB", toastErrorProps); + setSelectedFile(null); + setPreview(null); + setIsImage(false); + setMintError("Image must be less than 100KB"); + return; + } + setMintError(undefined); + setSelectedFile(file); + if (knownImageTypes.includes(file.type)) { + setIsImage(true); + } + const reader = new FileReader(); + + reader.onloadend = () => { + setPreview(reader.result); + }; + reader.readAsDataURL(file); + }; + img.src = URL.createObjectURL(file); + }, []); + + const artifact = useMemo(async () => { + return ( + selectedFile?.type && + preview && ( + + ) + ); + }, [preview, selectedFile]); + + type DeployBSV21Inscription = { + p: string; + op: string; + icon: string; + sym: string; + amt: string; + dec: string; + }; + + const inscribeBsv21 = useCallback( + async (utxo: Utxo) => { + if (!ticker || ticker?.length === 0 || selectedFile === null) { + return; + } + + setInscribeStatus(FetchStatus.Loading); + + // get a buffer of the file + const fileData = await selectedFile.arrayBuffer(); + + // add B output + const data = [ + B_PREFIX, + fileData, + selectedFile.type, + "binary", + ] as StringOrBufferArray; + try { + const inscription = { + p: "bsv-20", + op: "deploy+mint", + icon: "_1", + } as DeployBSV21Inscription; + + if ( + Number.parseInt(maxSupply) === 0 || + BigInt(maxSupply) > maxMaxSupply + ) { + alert( + `Invalid input: please enter a number less than or equal to ${maxMaxSupply - BigInt(1) + }` + ); + return; + } + + inscription.sym = ticker; + inscription.amt = maxSupply; + + // optional fields + if (decimals !== undefined) { + inscription.dec = String(decimals); + } + + const text = JSON.stringify(inscription); + const payments = [ + // { + // to: selectedBsv20.fundAddress, + // amount: 1000n, + // }, + ] as { to: string; amount: bigint }[]; + + const pendingTx = await inscribeUtf8WithData( + text, + "application/bsv-20", + utxo, + undefined, + payments, + data + ); + + pendingTx.returnTo = `/market/bsv21/${pendingTx.txid}_0`; + setInscribeStatus(FetchStatus.Success); + + if (pendingTx) { + pendingTxs.value = [pendingTx]; + inscribedCallback(); + } + } catch (error) { + setInscribeStatus(FetchStatus.Error); + + toast.error(`Failed to inscribe ${error}`, toastErrorProps); + return; + } + }, + [ticker, selectedFile, maxSupply, decimals, inscribedCallback] + ); + + const bulkInscribe = useCallback(async () => { + if (!payPk || !ordAddress || !fundingAddress.value) { + return; + } + + // range up to iterations + for (let i = 0; i < iterations; i++) { + await getUtxos(fundingAddress.value); + const sortedUtxos = utxos.value?.sort((a, b) => + a.satoshis > b.satoshis ? -1 : 1 + ); + const u = head(sortedUtxos); + if (!u) { + console.log("no utxo"); + return; + } + + return await inscribeBsv21(u); + } + }, [iterations, inscribeBsv21]); + + const clickInscribe = useCallback(async () => { + if (!payPk.value || !ordAddress.value || !fundingAddress.value) { + return; + } + + const utxos = await getUtxos(fundingAddress.value); + const sortedUtxos = utxos.sort((a, b) => + a.satoshis > b.satoshis ? -1 : 1 + ); + const u = head(sortedUtxos); + if (!u) { + console.log("no utxo"); + return; + } + + return await inscribeBsv21(u); + }, [inscribeBsv21]); + + const submitDisabled = useMemo(() => { + return ( + !ticker?.length || + inscribeStatus === FetchStatus.Loading || + fetchTickerStatus === FetchStatus.Loading || + !maxSupply || + (!!selectedFile && !isImage) + ); + }, [ + ticker?.length, + inscribeStatus, + fetchTickerStatus, + maxSupply, + selectedFile, + isImage, + ]); + + const listingFee = computed(() => { + if (!usdRate.value) { + return minFee; + } + return calculateIndexingFee(usdRate.value); + }); + + return ( +
    +
    + Deploy New Token +
    +
    + +
    + +
    +
    + {(!selectedFile || !preview) && ( +
    + +
    + )} + {selectedFile && preview && isImage && artifact} + {selectedFile && !isImage && ( +
    + X +
    + )} +
    + +
    +
    + +
    + + {!showOptionalFields && ( +
    + More Options +
    + )} + + {showOptionalFields && ( + <> +
    + +
    + + )} +
    + +
    + {preview &&
    } + + +
    + ); }; export default InscribeBsv21; diff --git a/src/components/pages/inscribe/collection.tsx b/src/components/pages/inscribe/collection.tsx index 8b396867..33ede73b 100644 --- a/src/components/pages/inscribe/collection.tsx +++ b/src/components/pages/inscribe/collection.tsx @@ -1,15 +1,568 @@ "use client" +import Artifact from "@/components/artifact"; +import { knownImageTypes, toastErrorProps } from "@/constants"; +import { ordPk, payPk, pendingTxs } from "@/signals/wallet"; +import { fundingAddress, ordAddress } from "@/signals/wallet/address"; +import type { FileEvent } from "@/types/file"; +import type { TxoData } from "@/types/ordinals"; +import { getUtxos } from "@/utils/address"; +import { inscribeFile } from "@/utils/inscribe"; import { useSignals } from "@preact/signals-react/runtime"; +import { head } from "lodash"; +import mime from "mime"; import type React from "react"; +import { useCallback, useMemo, useState } from "react"; +import toast from "react-hot-toast"; +import { IoMdClose } from "react-icons/io"; +import { IconWithFallback } from "../TokenMarket/heading"; + +type CollectionInscription = { + name: string; + description: string; + quantity?: string; + rarityLabels?: string; + traits?: string; + previewUrl?: string; + royalties?: string; +}; interface InscribeCollectionProps { inscribedCallback: () => void; } +interface Rarity { + label: string; + percentage: string; +} + +interface Trait { + name: string; + values: string[]; + occurancePercentages: string[]; +} + +interface Royalty { + type: "paymail" | "address"; + destination: string; + percentage: string; +} + const InscribeCollection: React.FC = ({ inscribedCallback }) => { useSignals(); - return
    ; + + const [collectionName, setCollectionName] = useState(""); + const [collectionDescription, setCollectionDescription] = useState(""); + const [collectionQuantity, setCollectionQuantity] = useState(""); + const [collectionRarities, setCollectionRarities] = useState([]); + const [collectionTraits, setCollectionTraits] = useState([]); + const [collectionCoverImage, setCollectionCoverImage] = useState(null); + const [collectionRoyalties, setCollectionRoyalties] = useState([]); + const [preview, setPreview] = useState(null); + const [isImage, setIsImage] = useState(false); + const [mintError, setMintError] = useState(); + + const validateTraits = () => { + for (const trait of collectionTraits) { + if (trait.values.length !== trait.occurancePercentages.length) { + return "The number of trait values and occurance percentages must match."; + } + + const totalPercentage = trait.occurancePercentages.reduce( + (sum, percentage) => sum + Number.parseFloat(percentage), + 0 + ); + if (totalPercentage !== 100) { + return "The occurance percentages for each trait must total up to exactly 100."; + } + } + return null; + }; + + const validateRarities = () => { + if (collectionRarities.length === 1) { + return "You must have at least two rarities."; + } + + const totalPercentage = collectionRarities.reduce( + (sum, rarity) => sum + Number.parseFloat(rarity.percentage), + 0 + ); + if (totalPercentage !== 0 && totalPercentage !== 100) { + return "The rarity percentages must total up to exactly 100."; + } + return null; + }; + + const validateRoyalties = () => { + const totalPercentage = collectionRoyalties.reduce( + (sum, royalty) => sum + Number.parseFloat(royalty.percentage), + 0 + ); + if (totalPercentage > 7) { + return "The collection royalties must collectively total no more than 7%."; + } + return null; + }; + + + const inscribeCollection = useCallback(async () => { + if (!payPk.value || !ordPk.value || !ordAddress.value || !fundingAddress.value) { + return; + } + + if (!collectionCoverImage) { + toast.error("Please upload a cover image", toastErrorProps); + return; + } + + const traitError = validateTraits(); + if (traitError) { + toast.error(traitError, toastErrorProps); + return; + } + + const rarityError = validateRarities(); + if (rarityError) { + toast.error(rarityError, toastErrorProps); + return; + } + + const royaltyError = validateRoyalties(); + if (royaltyError) { + toast.error(royaltyError, toastErrorProps); + return; + } + + const utxos = await getUtxos(fundingAddress.value); + const sortedUtxos = utxos.sort((a, b) => (a.satoshis > b.satoshis ? -1 : 1)); + const u = head(sortedUtxos); + if (!u) { + console.error("no utxo"); + return; + } + + const metadata = { + app: "1sat.market", + type: "ord", + subType: "collection", + name: collectionName, + description: collectionDescription, + quantity: collectionQuantity, + } as CollectionInscription; + + if (collectionRarities.length > 0) { + metadata.rarityLabels = JSON.stringify(collectionRarities.reduce((acc, rarity) => { + acc[rarity.label] = rarity.percentage; + return acc; + }, {} as Record)); + } + + if (collectionTraits.length > 0) { + metadata.traits = JSON.stringify(collectionTraits.map((trait) => ({ + [trait.name]: { + values: trait.values, + occurancePercentages: trait.occurancePercentages, + }, + }))) + } + + if (collectionRoyalties.length > 0) { + metadata.royalties = JSON.stringify(collectionRoyalties) + } + + let file: File | undefined; + if (collectionCoverImage.type === "") { + const newType = mime.getType(collectionCoverImage.name); + if (newType !== null) { + file = new File([collectionCoverImage], collectionCoverImage.name, { type: newType }); + } + } + if (!file) { + file = collectionCoverImage; + } + + const pendingTx = await inscribeFile(u, file, metadata, ordPk.value); + if (pendingTx) { + pendingTxs.value = [pendingTx]; + inscribedCallback(); + } + }, [ + collectionCoverImage, + collectionDescription, + collectionName, + collectionQuantity, + collectionRarities, + collectionRoyalties, + collectionTraits, + pendingTxs.value, + ]); + + const addRarity = () => { + setCollectionRarities([...collectionRarities, { label: "", percentage: "" }]); + }; + + const removeRarity = (index: number) => { + setCollectionRarities(collectionRarities.filter((_, i) => i !== index)); + }; + + const updateRarity = (index: number, field: keyof Rarity, value: string) => { + setCollectionRarities( + collectionRarities.map((rarity, i) => (i === index ? { ...rarity, [field]: value } : rarity)) + ); + }; + + const addTrait = () => { + setCollectionTraits([...collectionTraits, { name: "", values: [], occurancePercentages: [] }]); + }; + + const removeTrait = (index: number) => { + setCollectionTraits(collectionTraits.filter((_, i) => i !== index)); + }; + + const updateTrait = (index: number, field: keyof Trait, value: string | string[]) => { + setCollectionTraits( + collectionTraits.map((trait, i) => (i === index ? { ...trait, [field]: value } : trait)) + ); + }; + + const addRoyalty = () => { + setCollectionRoyalties([...collectionRoyalties, { type: "paymail", destination: "", percentage: "" }]); + }; + + const removeRoyalty = (index: number) => { + setCollectionRoyalties(collectionRoyalties.filter((_, i) => i !== index)); + }; + + const updateRoyalty = useCallback((index: number, field: keyof Royalty, value: string) => { + if (field === "percentage") { + const percentage = Number.parseFloat(value); + if (Number.isNaN(percentage) || percentage < 0 || percentage > 7) { + return; + } + } + setCollectionRoyalties( + collectionRoyalties.map((royalty, i) => (i === index ? { ...royalty, [field]: value } : royalty)) + ); + }, [collectionRoyalties]); + + // (e) => setCollectionCoverImage(e.target.files?.[0] || null)} + + + const changeFile = useCallback(async (e: FileEvent) => { + // TODO: This reads the file twice which is pretty inefficient + // would be nice to get dimensions and ArrayBuffer for preview in one go + + const file = e.target.files[0] as File; + // make sure the width and height are identical + const img = new Image(); + img.onload = () => { + if (img.width !== img.height) { + toast.error("Image must be square", toastErrorProps); + setCollectionCoverImage(null); + setPreview(null); + setIsImage(false); + setMintError("Image must be square"); + return; + } + // max size is 400px + if (img.width > 2048) { + toast.error("Width must be 2048px or less", toastErrorProps); + setCollectionCoverImage(null); + setPreview(null); + setIsImage(false); + setMintError("Width must be 2048px or less"); + return; + } + if (file.size > 1024 * 1024) { + toast.error("Image must be less than 1MB", toastErrorProps); + setCollectionCoverImage(null); + setPreview(null); + setIsImage(false); + setMintError("Image must be less than 1MB"); + return; + } + setMintError(undefined); + setCollectionCoverImage(file); + if (knownImageTypes.includes(file.type)) { + setIsImage(true); + } + const reader = new FileReader(); + + reader.onloadend = () => { + setPreview(reader.result); + }; + reader.readAsDataURL(file); + }; + img.src = URL.createObjectURL(file); + }, []); + + const artifact = useMemo(async () => { + return ( + collectionCoverImage?.type && + preview && ( + + ) + ); + }, [preview, collectionCoverImage]); + + + return ( +
    +

    Inscribe Collection

    +

    + Creating a new collection inscription enables minting images to this collection. Member items are created later. +

    + +
    + +
    +
    + {(!collectionCoverImage || !preview) && ( +
    + +
    + )} + {collectionCoverImage && preview && isImage && artifact} + {collectionCoverImage && !isImage && ( +
    + X +
    + )} +
    + +
    + +
    + + setCollectionName(e.target.value)} + required + /> +
    + +
    + +