UNPKG

lotus-sdk

Version:

Central repository for several classes of tools for integrating with, and building for, the Lotusia ecosystem

408 lines (407 loc) 14.5 kB
import { Hash } from '../crypto/hash.js'; import { Script } from '../script.js'; import { buildScriptPathTaproot, buildKeyPathTaproot, extractTaprootCommitment, extractTaprootState, isPayToTaproot, } from '../taproot.js'; import { Transaction } from '../transaction/transaction.js'; import { Output } from '../transaction/output.js'; import { TaprootInput } from '../transaction/input.js'; import { UnspentOutput, } from '../transaction/unspentoutput.js'; import { Signature } from '../crypto/signature.js'; export class NFT { _script; _address; _metadataHash; _metadata; _satoshis; _txid; _outputIndex; _commitment; _merkleRoot; _leaves; _collectionHash; constructor(config) { this._metadata = config.metadata; this._satoshis = config.satoshis || 1000; this._txid = config.txid; this._outputIndex = config.outputIndex; this._collectionHash = config.collectionHash; if (config.collectionHash) { this._metadataHash = NFTUtil.hashCollectionNFT(config.collectionHash, config.metadata); } else { this._metadataHash = NFTUtil.hashMetadata(config.metadata); } if (config.scriptTree) { const result = buildScriptPathTaproot(config.ownerKey, config.scriptTree, this._metadataHash); this._script = result.script; this._commitment = result.commitment; this._merkleRoot = result.merkleRoot; this._leaves = result.leaves; } else { this._script = buildKeyPathTaproot(config.ownerKey, this._metadataHash); } const address = this._script.toAddress(config.network); if (!address) { throw new Error('Failed to create address from script'); } this._address = address; } static fromScript(script, metadata, satoshis, txid, outputIndex) { if (!isPayToTaproot(script)) { throw new Error('Script is not a valid Pay-To-Taproot script'); } const metadataHash = extractTaprootState(script); if (!metadataHash) { throw new Error('Script does not have state parameter'); } const computedHash = NFTUtil.hashMetadata(metadata); if (!computedHash.equals(metadataHash)) { throw new Error('Metadata does not match on-chain hash'); } const address = script.toAddress(); if (!address) { throw new Error('Failed to create address from script'); } const nft = Object.create(NFT.prototype); nft._script = script; nft._address = address; nft._metadataHash = metadataHash; nft._metadata = metadata; nft._satoshis = satoshis; nft._txid = txid; nft._outputIndex = outputIndex; return nft; } static fromUTXO(utxo, metadata) { if (utxo instanceof UnspentOutput) { return NFT.fromScript(utxo.script, metadata, utxo.satoshis, utxo.txId, utxo.outputIndex); } const scriptData = 'script' in utxo ? utxo.script : utxo.scriptPubKey; if (!scriptData) { throw new Error('UTXO must have script or scriptPubKey'); } const script = scriptData instanceof Script ? scriptData : new Script(scriptData); const txid = 'txId' in utxo && utxo.txId ? utxo.txId : utxo.txid; const outputIndex = 'outputIndex' in utxo ? utxo.outputIndex : utxo.vout; const satoshis = 'satoshis' in utxo && typeof utxo.satoshis === 'number' ? utxo.satoshis : 'amount' in utxo && utxo.amount ? utxo.amount * 1000000 : 1000; return NFT.fromScript(script, metadata, satoshis, txid, outputIndex); } get script() { return this._script; } get address() { return this._address; } get metadataHash() { return this._metadataHash; } get metadata() { return this._metadata; } get satoshis() { return this._satoshis; } get txid() { return this._txid; } get outputIndex() { return this._outputIndex; } get commitment() { return this._commitment; } get merkleRoot() { return this._merkleRoot; } get leaves() { return this._leaves; } get collectionHash() { return this._collectionHash; } hasScriptTree() { return this._leaves !== undefined && this._leaves.length > 0; } isCollectionNFT() { return this._collectionHash !== undefined; } verifyMetadata() { return NFTUtil.verifyMetadata(this._metadata, this._metadataHash); } transfer(newOwnerKey, currentOwnerKey, fee) { if (!this._txid || this._outputIndex === undefined) { throw new Error('Cannot transfer NFT without UTXO information (txid and outputIndex)'); } return NFTUtil.transferNFT({ currentOwnerKey, newOwnerKey, nftUtxo: { txid: this._txid, outputIndex: this._outputIndex, script: this._script, satoshis: this._satoshis, }, metadataHash: this._metadataHash, fee, }); } updateUTXO(txid, outputIndex) { this._txid = txid; this._outputIndex = outputIndex; } getInfo() { const commitment = this._commitment || extractTaprootCommitment(this._script); return { commitment, metadataHash: this._metadataHash, address: this._address, }; } toOutput() { return new Output({ script: this._script, satoshis: this._satoshis, }); } toUnspentOutput() { if (!this._txid || this._outputIndex === undefined) { throw new Error('Cannot create UnspentOutput without UTXO information (txid and outputIndex)'); } return new UnspentOutput({ txid: this._txid, outputIndex: this._outputIndex, script: this._script, satoshis: this._satoshis, address: this._address, }); } getUtxo() { if (!this._txid || this._outputIndex === undefined) { throw new Error('Cannot get UTXO without transaction information (txid and outputIndex)'); } return { txid: this._txid, outputIndex: this._outputIndex, script: this._script, satoshis: this._satoshis, }; } toJSON() { return { script: this._script.toBuffer().toString('hex'), address: this._address.toString(), metadataHash: this._metadataHash.toString('hex'), metadata: this._metadata, satoshis: this._satoshis, txid: this._txid, outputIndex: this._outputIndex, commitment: this._commitment?.toString(), merkleRoot: this._merkleRoot?.toString('hex'), collectionHash: this._collectionHash?.toString('hex'), }; } toObject() { return { script: this._script, address: this._address, metadataHash: this._metadataHash, metadata: this._metadata, satoshis: this._satoshis, txid: this._txid, outputIndex: this._outputIndex, }; } toString() { return `NFT(${this._metadata.name}, ${this._address.toString()})`; } } export class NFTUtil { static hashMetadata(metadata) { const metadataJSON = JSON.stringify(metadata); return Hash.sha256(Buffer.from(metadataJSON, 'utf8')); } static hashCollection(collectionInfo) { const collectionJSON = JSON.stringify(collectionInfo); return Hash.sha256(Buffer.from(collectionJSON, 'utf8')); } static hashCollectionNFT(collectionHash, nftMetadata) { const combinedData = { collection: collectionHash.toString('hex'), nft: nftMetadata, }; const combinedJSON = JSON.stringify(combinedData); return Hash.sha256(Buffer.from(combinedJSON, 'utf8')); } static verifyMetadata(metadata, hash) { const computedHash = NFTUtil.hashMetadata(metadata); return computedHash.equals(hash); } static verifyCollectionNFT(collectionHash, nftMetadata, hash) { const computedHash = NFTUtil.hashCollectionNFT(collectionHash, nftMetadata); return computedHash.equals(hash); } static extractMetadataHash(script) { if (!isPayToTaproot(script)) { throw new Error('Script is not a valid Pay-To-Taproot script'); } return extractTaprootState(script); } static createKeyPathNFT(ownerKey, metadata, satoshis = 1000, network) { const metadataHash = NFTUtil.hashMetadata(metadata); const script = buildKeyPathTaproot(ownerKey, metadataHash); const address = script.toAddress(network); if (!address) { throw new Error('Failed to create address from script'); } return { script, address, metadataHash, metadata, satoshis, }; } static createScriptPathNFT(ownerKey, metadata, scriptTree, satoshis = 1000, network) { const metadataHash = NFTUtil.hashMetadata(metadata); const { script, commitment, merkleRoot, leaves } = buildScriptPathTaproot(ownerKey, scriptTree, metadataHash); const address = script.toAddress(network); if (!address) { throw new Error('Failed to create address from script'); } return { script, address, metadataHash, metadata, satoshis, commitment, merkleRoot, leaves, }; } static createCollectionNFT(ownerKey, collectionHash, nftMetadata, satoshis = 1000, network) { const metadataHash = NFTUtil.hashCollectionNFT(collectionHash, nftMetadata); const script = buildKeyPathTaproot(ownerKey, metadataHash); const address = script.toAddress(network); if (!address) { throw new Error('Failed to create address from script'); } return { script, address, metadataHash, metadata: nftMetadata, satoshis, collectionHash, }; } static mintNFT(config) { const nft = NFTUtil.createKeyPathNFT(config.ownerKey.publicKey, config.metadata, config.satoshis || 1000, config.network); const tx = new Transaction(); tx.addOutput(new Output({ script: nft.script, satoshis: nft.satoshis, })); return tx; } static mintBatch(ownerKey, nftMetadataList, satoshisPerNFT = 1000, network) { const tx = new Transaction(); for (const metadata of nftMetadataList) { const nft = NFTUtil.createKeyPathNFT(ownerKey.publicKey, metadata, satoshisPerNFT, network); tx.addOutput(new Output({ script: nft.script, satoshis: nft.satoshis, })); } return tx; } static mintCollection(ownerKey, collectionInfo, nftMetadataList, satoshisPerNFT = 1000, network) { const collectionHash = NFTUtil.hashCollection(collectionInfo); const tx = new Transaction(); for (const nftMetadata of nftMetadataList) { const nft = NFTUtil.createCollectionNFT(ownerKey.publicKey, collectionHash, nftMetadata, satoshisPerNFT, network); tx.addOutput(new Output({ script: nft.script, satoshis: nft.satoshis, })); } return tx; } static transferNFT(config) { const { currentOwnerKey, newOwnerKey, nftUtxo, metadataHash, fee } = config; const inputState = NFTUtil.extractMetadataHash(nftUtxo.script); if (!inputState || !inputState.equals(metadataHash)) { throw new Error('Input script metadata hash does not match'); } const newNFTScript = buildKeyPathTaproot(newOwnerKey, metadataHash); const outputSatoshis = fee ? nftUtxo.satoshis - fee : nftUtxo.satoshis; if (outputSatoshis < 546) { throw new Error('Output value below dust limit (546 satoshis)'); } const tx = new Transaction(); tx.addInput(new TaprootInput({ prevTxId: Buffer.from(nftUtxo.txid, 'hex'), outputIndex: nftUtxo.outputIndex, output: new Output({ script: nftUtxo.script, satoshis: nftUtxo.satoshis, }), script: new Script(), })); tx.addOutput(new Output({ script: newNFTScript, satoshis: outputSatoshis, })); tx.sign(currentOwnerKey, Signature.SIGHASH_ALL | Signature.SIGHASH_LOTUS, 'schnorr'); return tx; } static validateTransfer(inputScript, outputScript) { const inputState = NFTUtil.extractMetadataHash(inputScript); const outputState = NFTUtil.extractMetadataHash(outputScript); if (!inputState || !outputState) { return false; } return inputState.equals(outputState); } static verifyProvenance(transfers) { if (transfers.length === 0) { return false; } const originalHash = transfers[0].metadataHash; for (const transfer of transfers) { if (transfer.metadataHash !== originalHash) { return false; } } return true; } static isNFT(script) { if (!isPayToTaproot(script)) { return false; } const state = extractTaprootState(script); return state !== null; } static getNFTInfo(script) { if (!NFTUtil.isNFT(script)) { throw new Error('Script is not an NFT'); } const commitment = extractTaprootCommitment(script); const metadataHash = extractTaprootState(script); const address = script.toAddress(); if (!address) { throw new Error('Failed to create address from NFT script'); } return { commitment, metadataHash, address, }; } }