Cat721
CAT721 standard is a part of the Covenant Attested Token (CAT) protocol, which supports non-fungible tokens. Unlike CAT20 tokens, CAT721 tokens cannot be divided into smaller units. Each token is unique and non-interchangeable, i.e., non-fungible. Each NFT stores full data on-chain immutably using Bitcoin transactions.
Deploy
Feature API
cat-sdk
provides a deploy feature to deploy CAT721.
/**
* Deploy a parallel-closed-mint CAT721 NFT minter.
* @param signer a signer, such as {@link DefaultSigner} or {@link UnisatSigner}
* @param utxoProvider a {@link UtxoProvider}
* @param chainProvider a {@link ChainProvider}
* @param ownerAddress the issuer address of the NFT minter
* @param metadata the metadata of the CAT721 collection
* @param feeRate the fee rate for constructing transactions
* @param icon the icon of the CAT721 collection
* @param changeAddress the address to receive change satoshis, use the signer address as the default
* @returns the genesis transaction, the reveal transaction, the estimated genesis transaction vsize and the estimated reveal transaction vsize
*/
export async function deployParallelClosedMinter(
signer: Signer,
utxoProvider: UtxoProvider,
chainProvider: ChainProvider,
ownerAddress: ByteString,
metadata: NftParallelClosedMinterCat721Meta,
feeRate: number,
icon: {
type: string,
body: string,
} | undefined,
changeAddress?: string
): Promise<
Cat721NftInfo<NftParallelClosedMinterCat721Meta> & {
genesisTx: Psbt
revealTx: CatPsbt
}
> {
...
}
Take a look at how this feature is used in cat721-cli.
Design
To deploy a cat721 token, we employ a commit and reveal
scheme utilizing Taproot (P2TR). We call the first transaction the token genesis transaction, and the second the reveal transaction. In the witness script of an input of the reveal transaction, we include a CAT
envelope to embed CAT721 collection information.
Dive into details
To deploy a cat721 token, you need to commit the metadata of the cat721 token and deploy a cat721 minter when it is revealed.
Use getCatCollectionCommitScript() function to get the metadata commit script and commit it.
const commitScript = getCatCollectionCommitScript(toXOnly(pubkey, isP2TR(address)), info, icon)
const { p2trLockingScript } = scriptToP2tr(
Buffer.from(commitScript, 'hex')
)
const changeAddr = btc.Address.fromString(changeAddress)
const changeScript = btc.Script.fromAddress(changeAddr)
const commitTx = new btc.Transaction()
.from(feeUtxos)
.addOutput(
/** the first utxo spent in reveal tx */
new btc.Transaction.Output({
satoshis: Postage.METADATA_POSTAGE,
script: p2trLockingScript,
})
)
.addOutput(
/** the second utxo spent in reveal tx */
new btc.Transaction.Output({
satoshis: revealTxOutputAmount,
script: changeScript,
})
)
.feePerByte(feeRate)
.change(changeAddr)
Revealing metadata and deploying a CAT721 minter at the same time. Instantiate the minter using the first output of the commit transaction (also called the genenis transaction).
const minter = new NftParallelClosedMinterCovenant(
ownerAddress,
`${commitUtxo.txId}_0`,
metadata
)
To deploy a minter requires determining the p2tr locking script for the cat721 token, because it is the state of the minter. Use the address of the minter to get a CAT721Covenant instance; now we have the p2tr locking script of the cat721 token.
const nft = new CAT721Covenant(minter.address)
minter.state = {
nftScript: nft.lockingScriptHex,
nextLocalId: 0n,
}
Constructing metadata reveals transactions.
const commitLockingScript = Buffer.from(commitScript, 'hex')
const { cblock } = scriptToP2tr(commitLockingScript)
const revealTx = CatPsbt.create()
.addCovenantOutput(minter, Postage.MINTER_POSTAGE)
.addInput({
hash: commitUtxo.txId,
index: 0,
witnessUtxo: {
script: Buffer.from(commitUtxo.script, 'hex'),
value: BigInt(commitUtxo.satoshis),
},
tapLeafScript: [
{
leafVersion: LEAF_VERSION_TAPSCRIPT,
script: commitLockingScript,
controlBlock: Buffer.from(cblock, 'hex'),
},
],
finalizer: (self, inputIdx) => {
const witness = [
...self.txState.stateHashList.map((hash) =>
Buffer.from(hash, 'hex')
),
Buffer.from(
self.getSig(inputIdx, { publicKey: pubkey, disableTweakSigner: isP2TR(address) ? false : true }),
'hex'
),
commitLockingScript,
Buffer.from(cblock, 'hex'),
]
return witness
},
})
.addFeeInputs(feeUtxos)
Mint
Feature API
cat-sdk provides a mint feature to mint the CAT721 token.
/**
* Mint a CAT721 NFT
* @param signer a signer, such as {@link DefaultSigner} or {@link UnisatSigner}
* @param utxoProvider a {@link UtxoProvider}
* @param chainProvider a {@link ChainProvider}
* @param ownerAddress the issuer address of the nft minter
* @param cat721MinterUtxo an UTXO that contains the minter of the cat721 nft
* @param collectionId the id of the CAT721 nft collection
* @param metadata the metadata of the CAT721 collection
* @param nftReceiver the recipient's address of the newly minted nft
* @param feeRate the fee rate for constructing transactions
* @param contentType the content type of the newly minted nft
* @param contentBody the content body of the newly minted nft
* @param nftMetadata the metadata of the newly minted nft
* @returns the nft commit transaction, the mint transaction, and the estimated mint transaction vsize
*/
export async function mintNft(
signer: Signer,
utxoProvider: UtxoProvider,
chainProvider: ChainProvider,
ownerAddress: ByteString,
cat721MinterUtxo: Cat721MinterUtxo,
collectionId: string,
metadata: NftParallelClosedMinterCat721Meta,
nftReceiver: Ripemd160,
feeRate: number,
contentType: string,
contentBody: string,
nftMetadata: object,
): Promise<{
mintTx: CatPsbt;
nftTx: Psbt;
estMintTxVSize: number;
}> {
...
}
Take a look at how this feature is used in cat721-cli.
Design
Before minting an NFT, the contentType, contentBody, and metadata of the NFT must first be committed. Find a minter UTXO, unlock it, and simultaneously reveal the committed NFT information. Unlocking the minter will create a cat721 token UTXO, thereby completing the token minting. Revealing the committed NFT information will fully submit the NFT to the blockchain.
Dive into details
Use createNft() to submit the NFT's contentType, contentBody, and metadata.
Unlocking the minter requires instantiating the NftParallelClosedMinterCovenant and binding it to a minter UTXO.
const minter = new NftParallelClosedMinterCovenant(
ownerAddress,
collectionId,
metadata,
cat721MinterUtxo.state,
).bindToUtxo(cat721MinterUtxo.utxo)
Building a transaction to unlock the minter and disclose the committed NFT information.
const mintTx = new CatPsbt()
const { nextMinters } = minter.createNextMinters()
// add next minters outputs
for (const nextMinter of nextMinters) {
mintTx.addCovenantOutput(nextMinter, Postage.MINTER_POSTAGE)
}
const nft = minter.createNft(nftReceiver)
const { cblock } = scriptToP2tr(nftScript);
mintTx
// add nft output
.addCovenantOutput(nft, Postage.TOKEN_POSTAGE)
// add minter input
.addCovenantInput(minter)
.addInput({
hash: commitUtxo.txId,
index: 0,
witnessUtxo: {
script: Buffer.from(commitUtxo.script, 'hex'),
value: BigInt(commitUtxo.satoshis),
},
// tapInternalKey: Buffer.from(TAPROOT_ONLY_SCRIPT_SPENT_KEY, 'hex'),
tapLeafScript: [{
leafVersion: LEAF_VERSION_TAPSCRIPT,
script: nftScript,
controlBlock: Buffer.from(cblock, 'hex'),
}],
finalizer: (self, inputIdx) => {
const witness = [
Buffer.from(self.getSig(inputIdx, { publicKey: issuerPubKey, disableTweakSigner: isP2TR(changeAddress) ? false : true }), 'hex'),
nftScript,
Buffer.from(cblock, 'hex'),
];
return witness
}
})
// add fees
.addFeeInputs(feeUtxos)
// add change output
.change(changeAddress, feeRate, estimatedVSize)
const inputCtxs = mintTx.calculateInputCtxs()
const minterInputIndex = 0
const nftState = nft.state!
const preState = minter.state!
const preTxStatesInfo = {
statesHashRoot: spentMinterTxState.hashRoot,
txoStateHashes: spentMinterTxState.stateHashList,
}
const backTraceInfo = getBackTraceInfo_(
spentMinterTxHex,
spentMinterPreTxHex,
minterInputIndex
)
mintTx.updateCovenantInput(minterInputIndex, minter, {
method: 'mint',
argsBuilder: (curPsbt) => {
const inputCtx = inputCtxs.get(minterInputIndex)
if (!inputCtx) {
throw new Error('Input context is not available')
}
const { shPreimage, prevoutsCtx, spentScriptsCtx } = inputCtx
const args = []
args.push(curPsbt.txState.stateHashList) // curTxoStateHashes
args.push(nftState) // nftMint
args.push(isP2TR(changeAddress) ? '' : pubKeyPrefix(issuerPubKey)) // issuerPubKeyPrefix
args.push(toXOnly(issuerPubKey, isP2TR(changeAddress))) // issuerPubKey
args.push(() =>
Sig(
curPsbt.getSig(0, {
publicKey: issuerPubKey,
})
)
) // issuerSig
args.push(int2ByteString(BigInt(Postage.MINTER_POSTAGE), 8n)) // minterSatoshis
args.push(int2ByteString(BigInt(Postage.TOKEN_POSTAGE), 8n)) // nftSatoshis
args.push(preState) // preState
args.push(preTxStatesInfo) // preTxStatesInfo
args.push(backTraceInfo) // backtraceInfo
args.push(shPreimage) // shPreimage
args.push(prevoutsCtx) // prevoutsCtx
args.push(spentScriptsCtx) // spentScriptsCtx
args.push(curPsbt.getChangeInfo()) // changeInfo
return args
},
})
All CAT721 minter's unlocking script should start with TxoStateHashes and CAT721State,so that the tracker can track newly minted tokens:
@method()
public mint(
curTxoStateHashes: TxoStateHashes,
// contrat logic args
nftMint: CAT721State,
...
NftClosedMinter: As long as the signature is correct, you can unlock Minter to mint tokens
assert(this.issuerAddress == hash160(issuerPubKeyPrefix + issuerPubKey))
assert(this.checkSig(issuerSig, issuerPubKey))
Each minting will generate a CAT721 token and at most one new minter:
let hashString = toByteString('')
let minterOutput = toByteString('')
let stateNumber = 0n
const nextLocalId = preState.nextLocalId + 1n
if (nextLocalId < preState.quotaMaxLocalId) {
// create a new minter
minterOutput = TxUtil.buildOutput(preScript, minterSatoshis)
hashString += hash160(
NftClosedMinterProto.stateHash({
nftScript: preState.nftScript,
quotaMaxLocalId: preState.quotaMaxLocalId,
nextLocalId: preState.nextLocalId + 1n,
})
)
stateNumber += 1n
}
assert(nftMint.localId == preState.nextLocalId)
hashString += hash160(CAT721Proto.stateHash(nftMint))
// create a CAT721 token
const nft = TxUtil.buildOutput(preState.nftScript, nftSatoshis)
NftParallelClosedMinter: Similar to NftClosedMinter, a signature is required to mint. Each mint generates up to two new minters. The split grows exponentially, and when enough minters are generated, each minter can mint tokens at the same time. This speeds up the minting of tokens.
let hashString = toByteString('')
let minterOutput = toByteString('')
let stateNumber = 0n
// next 1
const nextLocalId1 = preState.nextLocalId + preState.nextLocalId + 1n
// next 2
const nextLocalId2 = preState.nextLocalId + preState.nextLocalId + 2n
if (nextLocalId1 < this.max) {
// create a new minter
minterOutput += TxUtil.buildOutput(preScript, minterSatoshis)
hashString += hash160(
NftParallelClosedMinterProto.stateHash({
nftScript: preState.nftScript,
nextLocalId: nextLocalId1,
})
)
stateNumber += 1n
}
if (nextLocalId2 < this.max) {
// create a new minter
minterOutput += TxUtil.buildOutput(preScript, minterSatoshis)
hashString += hash160(
NftParallelClosedMinterProto.stateHash({
nftScript: preState.nftScript,
nextLocalId: nextLocalId2,
})
)
stateNumber += 1n
}
assert(nftMint.localId == preState.nextLocalId)
hashString += hash160(CAT721Proto.stateHash(nftMint))
// create a nft token
const nftOutput = TxUtil.buildOutput(preState.nftScript, nftSatoshis)
NftOpenMinter: Anyone can participate in the casting. The casted nft must exist in the nft set specified during deployment and cannot be repeated. NftOpenMinterMerkleTree ensures that each leaf node is unique. Each time a mint is minted, the isMined segment of the leaf node is updated to true. If incorrect commitScript and localId are used, the correct new merkleRoot cannot be calculated.
const commitScript = spentScriptsCtx[1]
const oldLeaf: NftMerkleLeaf = {
commitScript: commitScript,
localId: preState.nextLocalId,
isMined: false,
}
const oldLeafBytes = NftOpenMinterProto.nftMerkleLeafToString(oldLeaf)
const newLeaf: NftMerkleLeaf = {
commitScript: commitScript,
localId: preState.nextLocalId,
isMined: true,
}
const newLeafBytes = NftOpenMinterProto.nftMerkleLeafToString(newLeaf)
const newMerkleRoot = NftOpenMinterMerkleTree.updateLeaf(
hash160(oldLeafBytes),
hash160(newLeafBytes),
neighbor,
neighborType,
preState.merkleRoot
)
Send
Feature API
cat-sdk provides send feature to send CAT721 token.
/**
* Send CAT721 NFTs
* @param signer a signer, such as {@link DefaultSigner} or {@link UnisatSigner}
* @param utxoProvider a {@link UtxoProvider}
* @param chainProvider a {@link ChainProvider}
* @param minterAddr the minter address of the CAT721 NFT collection
* @param inputNftUtxos CAT721 NFT utxos to be sent
* @param nftReceivers the recipient's addresses
* @param feeRate the fee rate for constructing transactions
* @returns the guard transaction, the send transaction, the estimated guard transaction vsize and the estimated send transaction vsize
*/
export async function singleSendNft(
signer: Signer,
utxoProvider: UtxoProvider,
chainProvider: ChainProvider,
minterAddr: string,
inputNftUtxos: Cat721Utxo[],
nftReceivers: Ripemd160[],
feeRate: number
): Promise<{
guardTx: CatPsbt
sendTx: CatPsbt
estGuardTxVSize: number
estSendTxVSize: number
}> {
...
}
Take a look at how this feature is used in cat721-cli.
Design
CAT721
is the main contract for implementing tokens. It is responsible for checking the following contents:
- Check if the transaction context is correct.
- Check the current CAT721 token status
- Trace the token to make sure it comes from genesis
- Check if guards are spent at the same time
- Check ownership, if the token is held by a private key, check the signature, if the token is held by a contract, check whether the contract holding the token has been spent
NftTransferGuard
ensures that tokens are transferred correctly.
- Check if the transaction context is correct.
- Check the current guard status
- Parse the nft localId to be transferred from preState, and transfer them according to the requirements of ownerAddrOrScriptList, localIdList and nftOutputMaskList.
Dive into details
CAT721 ensures the secure transfer of tokens through guards. Guards must be deployed before transferring CAT721 tokens. NftBurnGuard and NftTransferGuard are the leaf nodes of the taproot tree. The CAT721GuardCovenant
constructs the taproot tree using these two. We use it to deploy guards.
const guard = new CAT721GuardCovenant({
collectionScript: inputInfos[0].nft.lockingScriptHex,
localIdArray: emptyFixedArray().map((_, i) => {
const input = inputInfos.find((info) => info.inputIndex === i)
if (input) {
if (!input.nft.state) {
throw new Error(
`Nft state is missing for nft input ${i}`
)
}
return input.nft.state.localId
} else {
return 0n
}
}) as FixedArray<int32, typeof MAX_INPUT>,
})
Build the transaction to commit the guards.
function buildGuardTx(
guard: CAT721GuardCovenant,
feeUtxo: UTXO,
changeAddress: string,
feeRate: number,
estimatedVSize?: number
) {
if (feeUtxo.satoshis < Postage.GUARD_POSTAGE + feeRate * (estimatedVSize || 1)) {
throw new Error('Insufficient satoshis input amount')
}
const guardTx = new CatPsbt()
.addFeeInputs([feeUtxo])
.addCovenantOutput(guard, Postage.GUARD_POSTAGE)
.change(changeAddress, feeRate, estimatedVSize)
guard.bindToUtxo(guardTx.getUtxo(1))
return guardTx;
}
In the same transaction, unlock the CAT721 token UTXO and the guards' taproot, while specifying the recipient in the outputs to complete the token transfer. Build the transaction to transfer the CAT721 NFT.
function buildSendTx(
tracableNfts: TracedCat721Nft[],
guard: CAT721GuardCovenant,
guardPsbt: CatPsbt,
address: string,
pubKey: string,
outputNfts: (CAT721Covenant | undefined)[],
changeAddress: string,
feeRate: number,
estimatedVSize?: number
) {
const inputNfts = tracableNfts.map((nft) => nft.nft)
if (inputNfts.length + 2 > MAX_INPUT) {
throw new Error(
`Too many inputs that exceed the maximum input limit of ${MAX_INPUT}`
)
}
const sendPsbt = new CatPsbt()
// add nft outputs
for (const outputNft of outputNfts) {
if (outputNft) {
sendPsbt.addCovenantOutput(outputNft, Postage.TOKEN_POSTAGE)
}
}
// add nft inputs
for (const inputNft of inputNfts) {
sendPsbt.addCovenantInput(inputNft)
}
sendPsbt
.addCovenantInput(guard, GuardType.Transfer)
.addFeeInputs([guardPsbt.getUtxo(2)])
.change(changeAddress, feeRate, estimatedVSize)
const inputCtxs = sendPsbt.calculateInputCtxs()
const guardInputIndex = inputNfts.length
// unlock nfts
for (let i = 0; i < inputNfts.length; i++) {
sendPsbt.updateCovenantInput(
i,
inputNfts[i],
inputNfts[i].userSpend(
i,
inputCtxs,
tracableNfts[i].trace,
guard.getGuardInfo(guardInputIndex, guardPsbt.toTxHex()),
isP2TR(address),
pubKey
)
)
}
// unlock guard
sendPsbt.updateCovenantInput(
guardInputIndex,
guard,
guard.transfer(
guardInputIndex,
inputCtxs,
outputNfts,
guardPsbt.toTxHex()
)
)
return sendPsbt
}
If the CAT721 is locked at an address corresponding to a private key, the unlocking requires a signature from the corresponding private key, and isUserSpend should be set to true. If the CAT721 is locked at an address corresponding to a script, the unlocking requires spending the corresponding script, and isUserSpend should be set to false.
const args = []
args.push(
userSpend
? {
isUserSpend: true,
userPubKeyPrefix: userSpend.isP2TR ? '' : pubKeyPrefix(userSpend.pubKey),
userPubKey: toXOnly(userSpend.pubKey, userSpend.isP2TR),
userSig: curPsbt.getSig(inputIndex, { publicKey: userSpend.pubKey, disableTweakSigner: userSpend.isP2TR ? false : true }),
contractInputIndex: -1,
}
: {
isUserSpend: false,
userPubKeyPrefix: '',
userPubKey: '',
userSig: '',
contractInputIndex: contractSpend?.contractInputIndex,
}
) // nftUnlockArgs
Burn
Feature API
To burn CAT721 tokens, you can easily call the corresponding feature in the SDK.
/**
* Burn CAT721 NFTs in a single transaction,
* @param signer a signer, such as {@link DefaultSigner} or {@link UnisatSigner}
* @param utxoProvider a {@link UtxoProvider}
* @param chainProvider a {@link ChainProvider}
* @param minterAddr the minter address of the CAT721 collection
* @param inputNftUtxos CAT721 NFT utxos, which will all be burned
* @param feeRate the fee rate for constructing transactions
* @returns the guard transaction, the burn transaction, the estimated guard transaction vsize and the estimated burn transaction vsize
*/
export async function burnNft(
signer: Signer,
utxoProvider: UtxoProvider,
chainProvider: ChainProvider,
minterAddr: string,
inputNftUtxos: Cat721Utxo[],
feeRate: number
): Promise<{
guardTx: CatPsbt
burnTx: CatPsbt
estGuardTxVSize: number
estSendTxVSize: number
}> {
...
}
Design
Just as the burning schema for CAT20 tokens can be applied to CAT721 tokens. However, it’s important to note that you should use a NftBurnGuard
specifically designed for burning CAT721 tokens.
NftBurnGuard
ensures that tokens are burned completely.
- Check if the transaction context is correct.
- Check the current guard status.
- Build outputs without any token scripts.