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
JavaScript
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,
};
}
}