From 1be326af96546c24499620debc7f51f3b1c9cd5c Mon Sep 17 00:00:00 2001 From: Denis Nebytov Date: Mon, 24 Aug 2026 13:25:10 +0300 Subject: [PATCH 1/2] Mint token batches across chained transactions. Wire planning and CSL mint helpers into TokenTab, including CIP-68 on the last token and progress for each signed chunk. Co-authored-by: Cursor --- src/components/tabs/subtabs/tokenTab.js | 266 ++++++++++++++++-------- src/utils/tokenBatchMint.js | 148 +++++++++++++ 2 files changed, 324 insertions(+), 90 deletions(-) create mode 100644 src/utils/tokenBatchMint.js diff --git a/src/components/tabs/subtabs/tokenTab.js b/src/components/tabs/subtabs/tokenTab.js index f8bdba7..5c10b19 100644 --- a/src/components/tabs/subtabs/tokenTab.js +++ b/src/components/tabs/subtabs/tokenTab.js @@ -1,21 +1,11 @@ import logger from '../../../utils/logger' -import {useState} from 'react' +import {useMemo, useState} from 'react' import useCardano from '../../../hooks/cardanoProvider' -import { - getAddressFromBytes, - getAssetName, - getCslUtxos, - getFixedTxFromBytes, - getLargestFirstMultiAsset, - getNativeScript, - getPubKeyHash, - getTransactionOutputBuilder, - getTransactionWitnessSetFromBytes, - getTxBuilder, - toInt, -} from '../../../utils/cslTools' +import {getTransactionWitnessSetFromBytes} from '../../../utils/cslTools' import {CONNECTED} from '../../../utils/connectionStates' import {firstOrThrow} from '../../../utils/helpFunctions' +import {MAX_BATCH_SIZE, planTokenBatch} from '../../../utils/tokenBatchPlan' +import {buildBatchChunkTx, chainUtxosAfterTx} from '../../../utils/tokenBatchMint' import InputWithLabel from '../../inputWithLabel' const TokenTab = () => { @@ -24,10 +14,15 @@ const TokenTab = () => { const [currentTokenTicker, setCurrentTokenTicker] = useState('') const [currentTokenDescription, setCurrentTokenDescription] = useState('') const [currentQuantity, setCurrentQuantity] = useState('10') + const [isBatch, setIsBatch] = useState(false) + const [batchSize, setBatchSize] = useState('1') + const [cip68LastToken, setCip68LastToken] = useState(false) const [currentErrorState, setCurrentErrorState] = useState(false) const [signingRejected, setSigningRejected] = useState(false) - const [tokenNameErrorState, setTokenNameErrorState] = useState(false) - const [tokenQuantityErrorState, setTokenQuantityErrorState] = useState(false) + const [validationError, setValidationError] = useState('') + const [isMinting, setIsMinting] = useState(false) + const [batchStatus, setBatchStatus] = useState('') + const [submittedTxIds, setSubmittedTxIds] = useState([]) const handleError = (errorObject) => { if (errorObject.code === 2) { @@ -41,14 +36,9 @@ const TokenTab = () => { }, 5000) } - const handleEmptyTokenName = () => { - setTokenNameErrorState(true) - setTimeout(() => setTokenNameErrorState(false), 5000) - } - - const handleEmptyTokenQuantity = () => { - setTokenQuantityErrorState(true) - setTimeout(() => setTokenQuantityErrorState(false), 5000) + const handleValidationError = (message) => { + setValidationError(message) + setTimeout(() => setValidationError(''), 5000) } const handleErrors = () => { @@ -64,93 +54,116 @@ const TokenTab = () => {

!!! The error appeared. Please check logs !!! ) - } else if (tokenNameErrorState) { - return ( -

-

!!! The token name is not suitable !!! -

- ) - } else if (tokenQuantityErrorState) { - return ( -
-

!!! The token quantity is not suitable !!! -

- ) + } else if (validationError) { + return
{validationError}
} else { return <> } } - const mintToken = async () => { - const clearTokenName = currentTokenName.trim() - if (clearTokenName.length === 0) { - handleEmptyTokenName() - logger.error("The token name shouldn't be empty") - return + // Live preview of how the entered batch will be split, or the reason it + // cannot be minted. Reporting the reason while typing matters: the limits + // depend on the other fields (a CIP-68 asset name loses 4 bytes to its label + // prefix), so a name that is fine on its own can become invalid when the + // batch size grows or CIP-68 is ticked. + const batchPreview = useMemo(() => { + // An untouched form is not an error to shout about — pressing Mint reports it. + if (currentTokenName.trim().length === 0) { + return {} } - - if (currentQuantity === '0') { - handleEmptyTokenQuantity() - logger.error("The token quantity isn't suitable") - return + try { + const {tokens, chunks, maxPerTx} = planTokenBatch({ + tokenName: currentTokenName, + ticker: currentTokenTicker, + description: currentTokenDescription, + quantity: currentQuantity, + isBatch, + batchSize, + cip68LastToken, + }) + return {tokenCount: tokens.length, txCount: chunks.length, maxPerTx, firstName: tokens[0].assetName} + } catch (error) { + return {error: error.message} } - let quantityInt = 0 + }, [ + currentTokenName, + currentTokenTicker, + currentTokenDescription, + currentQuantity, + isBatch, + batchSize, + cip68LastToken, + ]) + + const mintTokens = async () => { + setValidationError('') + setSubmittedTxIds([]) + setBatchStatus('') + + let plan try { - quantityInt = toInt(currentQuantity) + plan = planTokenBatch({ + tokenName: currentTokenName, + ticker: currentTokenTicker, + description: currentTokenDescription, + quantity: currentQuantity, + isBatch, + batchSize, + cip68LastToken, + }) } catch (error) { - handleEmptyTokenQuantity() + handleValidationError(error.message) logger.error(error) return } - const txBuilder = getTxBuilder() - - const changeAddress = await api?.getChangeAddress() - logger.debug(`[dApp][Tokens_Tab][mint] changeAddress -> ${changeAddress}`) + logger.debug( + `[TokenTab][mint] ${plan.tokens.length} token(s) in ${plan.chunks.length} tx(s), max ${plan.maxPerTx} per tx`, + ) - const wasmChangeAddress = getAddressFromBytes(changeAddress) + setIsMinting(true) try { + const changeAddressHex = await api?.getChangeAddress() const usedAddresses = await api?.getUsedAddresses() - const usedAddress = getAddressFromBytes(firstOrThrow(usedAddresses, 'No used address available from wallet')) - const pubkeyHash = getPubKeyHash(usedAddress) - const wasmNativeScript = getNativeScript(pubkeyHash) + const usedAddressHex = firstOrThrow(usedAddresses, 'No used address available from wallet') + let hexUtxos = await api?.getUtxos() - // magic should happen here - txBuilder.add_mint_asset_and_output_min_required_coin( - wasmNativeScript, - getAssetName(clearTokenName), - quantityInt, - getTransactionOutputBuilder(wasmChangeAddress), - ) - - logger.debug(`[TokenTab][mint] getting UTxOs`) - const hexInputUtxos = await api?.getUtxos() + for (const [index, chunk] of plan.chunks.entries()) { + const progress = `transaction ${index + 1} of ${plan.chunks.length}` + setBatchStatus(`Building ${progress} (${chunk.length} token(s))...`) + const {fixedTx, explicitOutputCount} = buildBatchChunkTx({ + chunk, + hexUtxos, + changeAddressHex, + usedAddressHex, + }) + logger.log(`[TokenTab][mint] Unsigned Tx (${progress}):`, fixedTx.to_hex()) - logger.debug(`[TokenTab][mint] preparing wasmUTxOs`) - const wasmUtxos = getCslUtxos(hexInputUtxos) + setBatchStatus(`Waiting for signature - ${progress}...`) + const witnessHex = await api?.signTx(fixedTx.to_hex()) + const vkeys = getTransactionWitnessSetFromBytes(witnessHex).vkeys() + for (let i = 0; i < vkeys.len(); i++) { + fixedTx.add_vkey_witness(vkeys.get(i)) + } + const signedTxHex = fixedTx.to_hex() + logger.log(`[TokenTab][mint] Signed Tx (${progress}):`, signedTxHex) - logger.debug(`[TokenTab][mint] adding inputs`) - txBuilder.add_inputs_from(wasmUtxos, getLargestFirstMultiAsset()) - txBuilder.add_required_signer(pubkeyHash) - txBuilder.add_change_if_needed(wasmChangeAddress) + setBatchStatus(`Submitting ${progress}...`) + const txId = await api?.submitTx(signedTxHex) + logger.log(`[TokenTab][mint] Transaction successfully submitted: ${txId}`) + setSubmittedTxIds((previous) => [...previous, txId]) - const wasmUnsignedTransaction = txBuilder.build_tx() - const fixedTx = getFixedTxFromBytes(wasmUnsignedTransaction.to_bytes()) - logger.log('[TokenTab] Unsigned Tx:', fixedTx.to_hex()) - logger.debug(`[TokenTab][mint] signing the tx`) - const witnessHex = await api?.signTx(fixedTx.to_hex()) - const wasmWitnessSet = getTransactionWitnessSetFromBytes(witnessHex) - const vkeys = wasmWitnessSet.vkeys() - for (let i = 0; i < vkeys.len(); i++) { - fixedTx.add_vkey_witness(vkeys.get(i)) + // Chain forward: the wallet still reports the spent UTxOs until this tx + // is confirmed, so the next chunk must build on a locally updated set. + hexUtxos = chainUtxosAfterTx({fixedTx, hexUtxos, explicitOutputCount}) } - const signedTxHex = fixedTx.to_hex() - logger.log('[TokenTab][mint] Signed Tx:', signedTxHex) - const txId = await api?.submitTx(signedTxHex) - logger.log(`[TokenTab][mint] Transaction successfully submitted: ${txId}`) + setBatchStatus(`Done - ${plan.tokens.length} token(s) minted in ${plan.chunks.length} transaction(s).`) } catch (error) { + setBatchStatus('Stopped - see the error above and the logs.') handleError(error) logger.error(error) + } finally { + setIsMinting(false) } } @@ -168,6 +181,9 @@ const TokenTab = () => { The minting policy is hardcoded to basically just use the pubkeyhash of your first used address, so all the tokens you mint here will have the same policy id.

+ Ticker and description are written as CIP-25 (label 721) metadata. A batch is minted into a single + pooled output per transaction, and is split across several transactions when it does not fit into one. +

{handleErrors()} @@ -201,22 +217,92 @@ const TokenTab = () => { { setCurrentQuantity(event.target.value) }} /> +

+ setIsBatch(!isBatch)} + /> + + {isBatch ? ( + setBatchSize(event.target.value)} + helpText={ + batchPreview.tokenCount + ? `${batchPreview.tokenCount} token(s) starting at "${batchPreview.firstName}" - about ` + + `${batchPreview.maxPerTx} fit per transaction, so this needs ${batchPreview.txCount} ` + + `transaction(s) and ${batchPreview.txCount} signature(s). Max ${MAX_BATCH_SIZE}.` + : `Each token gets its index appended: name0, ticker0, "... Token 0.". Max ${MAX_BATCH_SIZE}.` + } + /> + ) : ( + <> + )} +
+
+ setCip68LastToken(!cip68LastToken)} + /> + +

Mints it as a (333) fungible token plus a (100) reference token whose inline datum carries the + metadata. The reference token is sent to your own change address. +

+ {/* Single place for "this batch cannot be minted", shown while + typing rather than only after pressing Mint. */} + {batchPreview.error ? ( +
{batchPreview.error}
+ ) : ( + <> + )}
+ {batchStatus ?
{batchStatus}
: <>} + {submittedTxIds.length > 0 ? ( +
+ +