} 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 ? (
+
+
+
+
+ ) : (
+ <>>
+ )}
>
diff --git a/src/utils/tokenBatchMint.js b/src/utils/tokenBatchMint.js
new file mode 100644
index 0000000..d80cfec
--- /dev/null
+++ b/src/utils/tokenBatchMint.js
@@ -0,0 +1,155 @@
+import {Buffer} from 'buffer'
+import logger from './logger'
+import {
+ buildAssetOutputWithMinCoin,
+ getAddressFromBytes,
+ getAssetName,
+ getAssetNameFromHex,
+ getCip68Datum,
+ getCslUtxos,
+ getFixedTxFromBytes,
+ getInputKeysFromBody,
+ getLargestFirstMultiAsset,
+ getMintBuilder,
+ getMultiAssetForPolicy,
+ getNativeScript,
+ getNativeScriptMintWitness,
+ getPubKeyHash,
+ getTxBuilder,
+ getUnspentOutputHex,
+ getUtxoFromHex,
+ strToBigNum,
+ toInt,
+} from './cslTools'
+import {chunkMessageTo64Bytes} from './utils'
+import {CIP67_FUNGIBLE_TOKEN_PREFIX, CIP67_REFERENCE_NFT_PREFIX} from './tokenBatchPlan'
+
+// Builds the mint transaction for one chunk of a token batch. Every asset in a
+// chunk lands in ONE pooled output, so the min-ada deposit is paid once per
+// transaction instead of once per token.
+
+// CIP-25 stores a field as a plain string when it fits in one 64-byte chunk, or
+// as an array of <=64-byte chunks when longer. Applied to ticker and description
+// (asset `name` is already capped at 32 bytes).
+const sliceBy64Bytes = (value) => {
+ const chunks = chunkMessageTo64Bytes(value)
+ return chunks.length <= 1 ? value : chunks
+}
+
+const cip67AssetNameHex = (prefix, assetName) => `${prefix}${Buffer.from(assetName, 'utf8').toString('hex')}`
+
+/**
+ * Builds an unsigned mint transaction for one chunk of tokens.
+ *
+ * Build order is load-bearing: mint + metadata must be registered before
+ * inputs/change so coin selection and the fee account for them.
+ *
+ * @returns {{fixedTx, explicitOutputCount: number, policyId: string, pubkeyHash}}
+ */
+export const buildBatchChunkTx = ({chunk, hexUtxos, changeAddressHex, usedAddressHex}) => {
+ const wasmChangeAddress = getAddressFromBytes(changeAddressHex)
+ const pubkeyHash = getPubKeyHash(getAddressFromBytes(usedAddressHex))
+ const wasmNativeScript = getNativeScript(pubkeyHash)
+ const wasmScriptHash = wasmNativeScript.hash()
+ const policyId = wasmScriptHash.to_hex()
+
+ const txBuilder = getTxBuilder()
+ const mintBuilder = getMintBuilder()
+ const mintWitness = getNativeScriptMintWitness(wasmNativeScript)
+
+ const pooledAssets = []
+ const metadata = {[policyId]: {}, version: '1.0'}
+ const referenceOutputs = []
+
+ for (const token of chunk) {
+ if (token.isCip68) {
+ // CIP-68: a (100) reference token carrying the metadata as an inline
+ // datum, plus the (333) fungible token the user actually holds.
+ const referenceAssetName = getAssetNameFromHex(cip67AssetNameHex(CIP67_REFERENCE_NFT_PREFIX, token.assetName))
+ const userAssetName = getAssetNameFromHex(cip67AssetNameHex(CIP67_FUNGIBLE_TOKEN_PREFIX, token.assetName))
+ mintBuilder.add_asset(mintWitness, referenceAssetName, toInt('1'))
+ mintBuilder.add_asset(mintWitness, userAssetName, toInt(token.quantity))
+ pooledAssets.push({assetName: userAssetName, quantity: token.quantity})
+ const datum = getCip68Datum({
+ name: token.assetName,
+ description: token.description,
+ ticker: token.ticker,
+ decimals: 0,
+ })
+ referenceOutputs.push(
+ buildAssetOutputWithMinCoin(
+ wasmChangeAddress,
+ getMultiAssetForPolicy(wasmScriptHash, [{assetName: referenceAssetName, quantity: '1'}]),
+ datum,
+ ),
+ )
+ } else {
+ const wasmAssetName = getAssetName(token.assetName)
+ mintBuilder.add_asset(mintWitness, wasmAssetName, toInt(token.quantity))
+ pooledAssets.push({assetName: wasmAssetName, quantity: token.quantity})
+ metadata[policyId][token.assetName] = {
+ name: token.assetName,
+ ticker: sliceBy64Bytes(token.ticker),
+ description: sliceBy64Bytes(token.description),
+ }
+ }
+ }
+
+ txBuilder.set_mint_builder(mintBuilder)
+ // A chunk holding only the CIP-68 token has no 721 entries — its metadata
+ // lives in the reference token's datum instead.
+ if (Object.keys(metadata[policyId]).length > 0) {
+ logger.debug(`[tokenBatchMint] 721 metadata -> ${JSON.stringify(metadata)}`)
+ txBuilder.add_json_metadatum(strToBigNum('721'), JSON.stringify(metadata))
+ }
+
+ txBuilder.add_output(
+ buildAssetOutputWithMinCoin(wasmChangeAddress, getMultiAssetForPolicy(wasmScriptHash, pooledAssets)),
+ )
+ for (const referenceOutput of referenceOutputs) {
+ txBuilder.add_output(referenceOutput)
+ }
+ const explicitOutputCount = 1 + referenceOutputs.length
+
+ txBuilder.add_inputs_from(getCslUtxos(hexUtxos), getLargestFirstMultiAsset())
+ txBuilder.add_required_signer(pubkeyHash)
+ txBuilder.add_change_if_needed(wasmChangeAddress)
+
+ const fixedTx = getFixedTxFromBytes(txBuilder.build_tx().to_bytes())
+ return {fixedTx, explicitOutputCount, policyId, pubkeyHash}
+}
+
+/**
+ * Advances the local UTxO set after a chunk is submitted: drops the inputs the
+ * transaction spent and adds back its change outputs as synthetic UTxOs. The
+ * wallet cannot do this for us — getUtxos() keeps reporting the pre-submission
+ * set until the transaction is confirmed, so the next chunk would double-spend.
+ *
+ * The pooled mint output (and any CIP-68 reference outputs) are deliberately
+ * NOT added back: they hold the freshly minted assets, and later chunks have
+ * no reason to spend them.
+ */
+export const chainUtxosAfterTx = ({fixedTx, hexUtxos, explicitOutputCount}) => {
+ const body = fixedTx.body()
+ const spentKeys = getInputKeysFromBody(body)
+ const remaining = hexUtxos.filter((hexUtxo) => {
+ const utxo = getUtxoFromHex(hexUtxo)
+ return !spentKeys.has(`${utxo.tx_hash}#${utxo.tx_index}`)
+ })
+
+ const outputs = body.outputs()
+ if (outputs.len() <= explicitOutputCount) {
+ logger.debug('[tokenBatchMint][chainUtxosAfterTx] no change output produced — nothing to chain forward')
+ return remaining
+ }
+
+ // add_change_if_needed appends change after the explicit mint/reference
+ // outputs. Leftover multi-assets can force several change UTxOs when one
+ // would exceed maxValueSize — chain every one of them, not only the last.
+ const txHashHex = fixedTx.transaction_hash().to_hex()
+ for (let changeIndex = explicitOutputCount; changeIndex < outputs.len(); changeIndex++) {
+ remaining.push(getUnspentOutputHex(txHashHex, changeIndex, outputs.get(changeIndex)))
+ }
+
+ return remaining
+}