diff --git a/.mocharc.json b/.mocharc.json new file mode 100644 index 000000000..2ac7457bc --- /dev/null +++ b/.mocharc.json @@ -0,0 +1,4 @@ +{ + "require": ["test/ts-node-register"], + "recursive": true +} diff --git a/package-lock.json b/package-lock.json index b4d812461..e69ffe5a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "bip32": "^5.0.0", "bip66": "^1.1.0", "bs58check": "^2.0.0", + "canonicalize": "^2.1.0", "create-hash": "^1.1.0", "create-hmac": "^1.1.3", "merkle-lib": "^2.0.10", @@ -986,6 +987,15 @@ } ] }, + "node_modules/canonicalize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/canonicalize/-/canonicalize-2.1.0.tgz", + "integrity": "sha512-F705O3xrsUtgt98j7leetNhTWPe+5S72rlL5O4jA1pKqBVQ/dT1O1D6PFxmSXvc0SUOinWS57DKx0I3CHrXJHQ==", + "license": "Apache-2.0", + "bin": { + "canonicalize": "bin/canonicalize.js" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", diff --git a/package.json b/package.json index 4b188e542..6a81dde05 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "bip32": "^5.0.0", "bip66": "^1.1.0", "bs58check": "^2.0.0", + "canonicalize": "^2.1.0", "create-hash": "^1.1.0", "create-hmac": "^1.1.3", "merkle-lib": "^2.0.10", diff --git a/src/index.js b/src/index.js index 1c8d26cd1..7e5beb9b8 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,6 @@ 'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); -exports.NetworkId = exports.TransactionBuilder = exports.Transaction = exports.opcodes = exports.Psbt = exports.Block = exports.script = exports.payments = exports.networks = exports.crypto = exports.bufferutils = exports.bip32 = exports.address = exports.ECPair = void 0; +exports.NetworkId = exports.TransactionBuilder = exports.Transaction = exports.opcodes = exports.Psbt = exports.Metadata = exports.Block = exports.script = exports.payments = exports.networks = exports.crypto = exports.bufferutils = exports.bip32 = exports.address = exports.ECPair = void 0; const bip32_1 = require('bip32'); const ecc = require('tiny-secp256k1'); const address = require('./address'); @@ -26,6 +26,13 @@ Object.defineProperty(exports, 'Block', { return block_1.Block; }, }); +var metadata_1 = require('./metadata'); +Object.defineProperty(exports, 'Metadata', { + enumerable: true, + get: function() { + return metadata_1.Metadata; + }, +}); var psbt_1 = require('./psbt'); Object.defineProperty(exports, 'Psbt', { enumerable: true, diff --git a/src/metadata.js b/src/metadata.js new file mode 100644 index 000000000..bdc66f790 --- /dev/null +++ b/src/metadata.js @@ -0,0 +1,373 @@ +'use strict'; +Object.defineProperty(exports, '__esModule', { value: true }); +exports.Metadata = void 0; +const crypto = require('./crypto'); +const payments = require('./payments'); +const canonicalize = require('canonicalize'); +const ecc = require('tiny-secp256k1'); +const MAX_NAME_LENGTH = 64; +const MAX_SYMBOL_LENGTH = 12; +const MAX_DECIMALS = 18; +const MAX_DESCRIPTION_LENGTH = 256; +const MAX_DATA_URI_SIZE = 32768; +const COLOR_ID_REISSUABLE = 0xc1; +const COLOR_ID_NON_REISSUABLE = 0xc2; +const COLOR_ID_NFT = 0xc3; +const VALID_TOKEN_TYPES = ['reissuable', 'non_reissuable', 'nft']; +function isHttpsUrl(value) { + try { + const url = new URL(value); + return url.protocol === 'https:'; + } catch (_a) { + return false; + } +} +function isDataUri(value) { + return value.startsWith('data:'); +} +function isValidEmail(value) { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); +} +function getDataUriSize(value) { + const base64Match = value.match(/^data:[^,]+;base64,(.*)$/); + if (base64Match) { + return Buffer.from(base64Match[1], 'base64').length; + } + const plainMatch = value.match(/^data:[^,]*,(.*)$/); + if (plainMatch) { + return Buffer.from(decodeURIComponent(plainMatch[1])).length; + } + return value.length; +} +class Metadata { + static fromJSON(json) { + const fields = JSON.parse(json); + return new Metadata(fields); + } + static validate(fields) { + // version + if (!fields.version) { + throw new Error('version is required'); + } + if (fields.version !== '1.0') { + throw new Error('version must be 1.0'); + } + // tokenType + if (!fields.tokenType) { + throw new Error('tokenType is required'); + } + if (!VALID_TOKEN_TYPES.includes(fields.tokenType)) { + throw new Error('tokenType must be reissuable, non_reissuable, or nft'); + } + // name + if (!fields.name) { + throw new Error('name is required'); + } + if (fields.name.length > MAX_NAME_LENGTH) { + throw new Error('name must be 64 characters or less'); + } + // symbol + if (!fields.symbol) { + throw new Error('symbol is required'); + } + if (fields.symbol.length > MAX_SYMBOL_LENGTH) { + throw new Error('symbol must be 12 characters or less'); + } + // decimals + if (fields.decimals !== undefined) { + if (fields.decimals < 0 || fields.decimals > MAX_DECIMALS) { + throw new Error('decimals must be between 0 and 18'); + } + } + // description + if (fields.description !== undefined) { + if (fields.description.length > MAX_DESCRIPTION_LENGTH) { + throw new Error('description must be 256 characters or less'); + } + } + // website + if (fields.website !== undefined) { + if (!isHttpsUrl(fields.website)) { + throw new Error('website must be an HTTPS URL'); + } + } + // terms + if (fields.terms !== undefined) { + if (!isHttpsUrl(fields.terms)) { + throw new Error('terms must be an HTTPS URL'); + } + } + // external_url + if (fields.external_url !== undefined) { + if (!isHttpsUrl(fields.external_url)) { + throw new Error('external_url must be an HTTPS URL'); + } + } + // icon + if (fields.icon !== undefined) { + if (!isHttpsUrl(fields.icon) && !isDataUri(fields.icon)) { + throw new Error('icon must be an HTTPS URL or Data URI'); + } + if ( + isDataUri(fields.icon) && + getDataUriSize(fields.icon) > MAX_DATA_URI_SIZE + ) { + throw new Error('icon must be an HTTPS URL or Data URI'); + } + } + // image + if (fields.image !== undefined) { + if (!isHttpsUrl(fields.image) && !isDataUri(fields.image)) { + throw new Error('image must be an HTTPS URL or Data URI'); + } + if ( + isDataUri(fields.image) && + getDataUriSize(fields.image) > MAX_DATA_URI_SIZE + ) { + throw new Error('image must be an HTTPS URL or Data URI'); + } + } + // animation_url + if (fields.animation_url !== undefined) { + if ( + !isHttpsUrl(fields.animation_url) && + !isDataUri(fields.animation_url) + ) { + throw new Error('animation_url must be an HTTPS URL or Data URI'); + } + if ( + isDataUri(fields.animation_url) && + getDataUriSize(fields.animation_url) > MAX_DATA_URI_SIZE + ) { + throw new Error('animation_url must be an HTTPS URL or Data URI'); + } + } + // issuer + if (fields.issuer !== undefined) { + if (fields.issuer.url !== undefined) { + if (!isHttpsUrl(fields.issuer.url)) { + throw new Error('issuer.url must be an HTTPS URL'); + } + } + if (fields.issuer.email !== undefined) { + if (!isValidEmail(fields.issuer.email)) { + throw new Error('issuer.email must be a valid email address'); + } + } + } + // NFT-only fields validation + if (fields.tokenType !== 'nft') { + if (fields.image !== undefined) { + throw new Error('image is only allowed for nft token type'); + } + if (fields.animation_url !== undefined) { + throw new Error('animation_url is only allowed for nft token type'); + } + if (fields.external_url !== undefined) { + throw new Error('external_url is only allowed for nft token type'); + } + if (fields.attributes !== undefined) { + throw new Error('attributes is only allowed for nft token type'); + } + } + } + constructor(fields) { + Metadata.validate(fields); + this.version = fields.version; + this.name = fields.name; + this.symbol = fields.symbol; + this.tokenType = fields.tokenType; + if (fields.decimals !== undefined && fields.decimals !== 0) { + this.decimals = fields.decimals; + } + if (fields.description) { + this.description = fields.description; + } + if (fields.icon) { + this.icon = fields.icon; + } + if (fields.website) { + this.website = fields.website; + } + if (fields.issuer) { + this.issuer = fields.issuer; + } + if (fields.terms) { + this.terms = fields.terms; + } + if (fields.properties && Object.keys(fields.properties).length > 0) { + this.properties = fields.properties; + } + if (fields.image) { + this.image = fields.image; + } + if (fields.animation_url) { + this.animation_url = fields.animation_url; + } + if (fields.external_url) { + this.external_url = fields.external_url; + } + if (fields.attributes && fields.attributes.length > 0) { + this.attributes = fields.attributes; + } + } + toCanonical() { + const obj = {}; + if (this.animation_url !== undefined) { + obj.animation_url = this.animation_url; + } + if (this.attributes !== undefined) { + obj.attributes = this.attributes; + } + if (this.decimals !== undefined) { + obj.decimals = this.decimals; + } + if (this.description !== undefined) { + obj.description = this.description; + } + if (this.external_url !== undefined) { + obj.external_url = this.external_url; + } + if (this.icon !== undefined) { + obj.icon = this.icon; + } + if (this.image !== undefined) { + obj.image = this.image; + } + if (this.issuer !== undefined) { + obj.issuer = this.issuer; + } + obj.name = this.name; + if (this.properties !== undefined) { + obj.properties = this.properties; + } + obj.symbol = this.symbol; + if (this.terms !== undefined) { + obj.terms = this.terms; + } + obj.version = this.version; + if (this.website !== undefined) { + obj.website = this.website; + } + return canonicalize(obj); + } + digest() { + const canonical = this.toCanonical(); + return crypto.sha256(Buffer.from(canonical, 'utf8')); + } + toObject() { + const obj = { + version: this.version, + name: this.name, + symbol: this.symbol, + tokenType: this.tokenType, + }; + if (this.decimals !== undefined) { + obj.decimals = this.decimals; + } + if (this.description !== undefined) { + obj.description = this.description; + } + if (this.icon !== undefined) { + obj.icon = this.icon; + } + if (this.website !== undefined) { + obj.website = this.website; + } + if (this.issuer !== undefined) { + obj.issuer = this.issuer; + } + if (this.terms !== undefined) { + obj.terms = this.terms; + } + if (this.properties !== undefined) { + obj.properties = this.properties; + } + if (this.image !== undefined) { + obj.image = this.image; + } + if (this.animation_url !== undefined) { + obj.animation_url = this.animation_url; + } + if (this.external_url !== undefined) { + obj.external_url = this.external_url; + } + if (this.attributes !== undefined) { + obj.attributes = this.attributes; + } + return obj; + } + commitment(publicKey) { + if (publicKey.length !== 33) { + throw new Error('publicKey must be 33 bytes (compressed)'); + } + const h = this.digest(); + return crypto.sha256(Buffer.concat([publicKey, h])); + } + p2cPublicKey(publicKey) { + const c = this.commitment(publicKey); + const result = ecc.pointAddScalar(publicKey, c); + if (!result) { + throw new Error('Failed to derive P2C public key'); + } + return Buffer.from(result); + } + p2cAddress(publicKey, network) { + const p2cPubKey = this.p2cPublicKey(publicKey); + const { address } = payments.p2pkh({ pubkey: p2cPubKey, network }); + return address; + } + deriveColorId(publicKey, outPoint) { + switch (this.tokenType) { + case 'reissuable': { + if (!publicKey) { + throw new Error('publicKey is required for reissuable token'); + } + const p2cPubKey = this.p2cPublicKey(publicKey); + const pubKeyHash = crypto.hash160(p2cPubKey); + // P2PKH script: OP_DUP OP_HASH160 <20bytes> OP_EQUALVERIFY OP_CHECKSIG + const script = Buffer.alloc(25); + script[0] = 0x76; // OP_DUP + script[1] = 0xa9; // OP_HASH160 + script[2] = 0x14; // 20 bytes + pubKeyHash.copy(script, 3); + script[23] = 0x88; // OP_EQUALVERIFY + script[24] = 0xac; // OP_CHECKSIG + const scriptHash = crypto.sha256(script); + const colorId = Buffer.alloc(33); + colorId[0] = COLOR_ID_REISSUABLE; + scriptHash.copy(colorId, 1); + return colorId; + } + case 'non_reissuable': { + if (!outPoint) { + throw new Error('outPoint is required for non_reissuable token'); + } + const outPointPayload = Buffer.alloc(36); + outPoint.txid.copy(outPointPayload, 0); + outPointPayload.writeUInt32LE(outPoint.index, 32); + const outPointHash = crypto.sha256(outPointPayload); + const colorId = Buffer.alloc(33); + colorId[0] = COLOR_ID_NON_REISSUABLE; + outPointHash.copy(colorId, 1); + return colorId; + } + case 'nft': { + if (!outPoint) { + throw new Error('outPoint is required for nft token'); + } + const outPointPayload = Buffer.alloc(36); + outPoint.txid.copy(outPointPayload, 0); + outPointPayload.writeUInt32LE(outPoint.index, 32); + const outPointHash = crypto.sha256(outPointPayload); + const colorId = Buffer.alloc(33); + colorId[0] = COLOR_ID_NFT; + outPointHash.copy(colorId, 1); + return colorId; + } + default: + throw new Error('Invalid token type'); + } + } +} +exports.Metadata = Metadata; diff --git a/test/fixtures/tip0020_metadata.json b/test/fixtures/tip0020_metadata.json new file mode 100644 index 000000000..9daa01a1e --- /dev/null +++ b/test/fixtures/tip0020_metadata.json @@ -0,0 +1,574 @@ +{ + "base_point": "02aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "valid_test_cases": [ + { + "name": "minimal", + "description": "Required fields only (version, name and symbol)", + "metadata": { + "version": "1.0", + "name": "Minimal Token", + "symbol": "MIN" + }, + "canonical": "{\"name\":\"Minimal Token\",\"symbol\":\"MIN\",\"version\":\"1.0\"}", + "hash": "82a8c79ac45f4501c052e3a37b6e2e05d420e990b0eeaa84796b1cd6b7d23cfc", + "p2c_address": "17qYW6CYYnQRzJ8Qs8Ppyr6kLE8mFshoRC" + }, + { + "name": "reissuable", + "description": "Reissuable token with decimals and website", + "metadata": { + "version": "1.0", + "name": "Reissuable Token", + "symbol": "REIS", + "decimals": 8, + "description": "A reissuable token for testing", + "website": "https://example.com/reissuable" + }, + "canonical": "{\"decimals\":8,\"description\":\"A reissuable token for testing\",\"name\":\"Reissuable Token\",\"symbol\":\"REIS\",\"version\":\"1.0\",\"website\":\"https://example.com/reissuable\"}", + "hash": "961987187ad59ce689ec72e342737e040fc2ffbe3b75d8d405ccb28f76566fb6", + "p2c_address": "18sStC8Ye5dgibFCYWj61UxMHFuZGhfRSo" + }, + { + "name": "non_reissuable", + "description": "Non-reissuable token with description", + "metadata": { + "version": "1.0", + "name": "Non-Reissuable Token", + "symbol": "NREIS", + "description": "A non-reissuable token for testing" + }, + "canonical": "{\"description\":\"A non-reissuable token for testing\",\"name\":\"Non-Reissuable Token\",\"symbol\":\"NREIS\",\"version\":\"1.0\"}", + "hash": "87a561aee769401d7d5d2615a4d220cd1f0300c51f95caf1f88f450d02dcff48", + "p2c_address": "1Av46erXVYZaZipMqdhXHKu45WSwezg76Q" + }, + { + "name": "nft", + "description": "NFT with icon", + "metadata": { + "version": "1.0", + "name": "Test NFT", + "symbol": "TNFT", + "description": "A unique NFT for testing", + "icon": "https://example.com/nft-icon.png" + }, + "canonical": "{\"description\":\"A unique NFT for testing\",\"icon\":\"https://example.com/nft-icon.png\",\"name\":\"Test NFT\",\"symbol\":\"TNFT\",\"version\":\"1.0\"}", + "hash": "d1aeec668af4d354dff05fcbdcb6bf3bcb9230696a7479cd0e7ab5f0e796390c", + "p2c_address": "1M3jA5poKvVYLVCQEXLo4o7RYcmHvMNhxb" + }, + { + "name": "with_issuer", + "description": "Token with issuer information", + "metadata": { + "version": "1.0", + "name": "Issued Token", + "symbol": "ISS", + "decimals": 6, + "description": "A token with issuer information", + "issuer": { + "name": "Test Issuer", + "url": "https://issuer.example.com" + }, + "website": "https://example.com/issued" + }, + "canonical": "{\"decimals\":6,\"description\":\"A token with issuer information\",\"issuer\":{\"name\":\"Test Issuer\",\"url\":\"https://issuer.example.com\"},\"name\":\"Issued Token\",\"symbol\":\"ISS\",\"version\":\"1.0\",\"website\":\"https://example.com/issued\"}", + "hash": "835fd6b8ada9b5a2305a901f0b56ebce5c10b4d2a3c7f27db467c753a9019760", + "p2c_address": "1PcumvP1fJYLP7PpHBdiocuvvmXnqL1yqB" + }, + { + "name": "max_length_name", + "description": "Token with maximum length name (64 characters)", + "metadata": { + "version": "1.0", + "name": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "symbol": "MAX" + }, + "canonical": "{\"name\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"symbol\":\"MAX\",\"version\":\"1.0\"}", + "hash": "49b5aa451a439153e4192a5d05f9e6d186bf3c17191242dcf689e62534561f39", + "p2c_address": "1H3oacwGfwKpnaPSFPKh7YBvoZnXnKSD7m" + }, + { + "name": "max_length_symbol", + "description": "Token with maximum length symbol (12 characters)", + "metadata": { + "version": "1.0", + "name": "Max Symbol Token", + "symbol": "AAAAAAAAAAAA" + }, + "canonical": "{\"name\":\"Max Symbol Token\",\"symbol\":\"AAAAAAAAAAAA\",\"version\":\"1.0\"}", + "hash": "5802454047341920a7ebccc12c4e9bb55dc84950ab03864f9abe5152f2c4cb39", + "p2c_address": "1Fb3VGsg9zXx3E1op7kwRVm6T4gUMNLcGR" + }, + { + "name": "decimals_zero", + "description": "Token with decimals explicitly set to 0 (should not appear in canonical form)", + "metadata": { + "version": "1.0", + "name": "Zero Decimals Token", + "symbol": "ZERO", + "decimals": 0 + }, + "canonical": "{\"name\":\"Zero Decimals Token\",\"symbol\":\"ZERO\",\"version\":\"1.0\"}", + "hash": "5d94bc858b44e02ec9af623e7d12799256dc8adfbf120357d3d388c873a24972", + "p2c_address": "1GUKyxLRnMmnta2mjYZ8jjEbg7RMVtcN77" + }, + { + "name": "decimals_max", + "description": "Token with maximum decimals (18)", + "metadata": { + "version": "1.0", + "name": "Max Decimals Token", + "symbol": "MAXD", + "decimals": 18 + }, + "canonical": "{\"decimals\":18,\"name\":\"Max Decimals Token\",\"symbol\":\"MAXD\",\"version\":\"1.0\"}", + "hash": "e2cbad785199d9f1e85566051e4be290ba25acec263b5b9561716826d4a211aa", + "p2c_address": "1DatAcNVjWehF6e5VHn9ZbfgXAWDheWsUC" + }, + { + "name": "data_uri_icon", + "description": "Token with Data URI icon", + "metadata": { + "version": "1.0", + "name": "Data URI Icon Token", + "symbol": "DURI", + "icon": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + }, + "canonical": "{\"icon\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\"name\":\"Data URI Icon Token\",\"symbol\":\"DURI\",\"version\":\"1.0\"}", + "hash": "f152fc720df61e987a39153c09066bdb7ab2785aa079919e960b6855467bd186", + "p2c_address": "1FraxRVNWbvvLepSubTDQgqhbfC3toKFvP" + }, + { + "name": "with_terms", + "description": "Token with terms of service URL", + "metadata": { + "version": "1.0", + "name": "Terms Token", + "symbol": "TERM", + "terms": "https://example.com/terms" + }, + "canonical": "{\"name\":\"Terms Token\",\"symbol\":\"TERM\",\"terms\":\"https://example.com/terms\",\"version\":\"1.0\"}", + "hash": "6af2bfa50634919c0ba90b1a3a2b5bd8c275d9f4e17f7b07f1211d97b48ac54c", + "p2c_address": "1Dh4q16jBrWfafVx8RV1fCiGmE8S1CwTKC" + }, + { + "name": "with_properties", + "description": "Token with custom properties", + "metadata": { + "version": "1.0", + "name": "Properties Token", + "symbol": "PROP", + "properties": { + "category": "utility", + "tier": "gold" + } + }, + "canonical": "{\"name\":\"Properties Token\",\"properties\":{\"category\":\"utility\",\"tier\":\"gold\"},\"symbol\":\"PROP\",\"version\":\"1.0\"}", + "hash": "2c5a7344b332c0bd825a6306cb115add8ce5046d17b22170da21e62ce6b48e83", + "p2c_address": "1DyPRkuVuAV6f39RtbdXbZsMxvuYSFMcHE" + }, + { + "name": "nft_with_image", + "description": "NFT with image URL", + "metadata": { + "version": "1.0", + "name": "NFT with Image", + "symbol": "NFTI", + "description": "An NFT with image", + "image": "https://example.com/nft-image.png" + }, + "canonical": "{\"description\":\"An NFT with image\",\"image\":\"https://example.com/nft-image.png\",\"name\":\"NFT with Image\",\"symbol\":\"NFTI\",\"version\":\"1.0\"}", + "hash": "e876e1e7c2c8c092e27596e79a0e0f57cee074ff804c19b864e52689ece78ace", + "p2c_address": "1D4geu7Dx9spzR2A8kMXmKmDGELcKpd2NC" + }, + { + "name": "nft_with_animation", + "description": "NFT with animation URL", + "metadata": { + "version": "1.0", + "name": "NFT with Animation", + "symbol": "NFTA", + "description": "An NFT with animation", + "animation_url": "https://example.com/nft-video.mp4" + }, + "canonical": "{\"animation_url\":\"https://example.com/nft-video.mp4\",\"description\":\"An NFT with animation\",\"name\":\"NFT with Animation\",\"symbol\":\"NFTA\",\"version\":\"1.0\"}", + "hash": "a2bcde25d0f93bc86e8b6ce44f5306169a158105b0c2c98b3bbfc1b32ef1647b", + "p2c_address": "1Hxd83j8Y8cvUFt7BGB4e3t9uXANZrCb12" + }, + { + "name": "nft_with_external_url", + "description": "NFT with external URL", + "metadata": { + "version": "1.0", + "name": "NFT with External URL", + "symbol": "NFTE", + "description": "An NFT with external URL", + "external_url": "https://example.com/view/nft/123" + }, + "canonical": "{\"description\":\"An NFT with external URL\",\"external_url\":\"https://example.com/view/nft/123\",\"name\":\"NFT with External URL\",\"symbol\":\"NFTE\",\"version\":\"1.0\"}", + "hash": "b5432f21fc4efb26e59e965e54f2eff88d303f677a638eb5527b001a001e2f4a", + "p2c_address": "1HUtSroSh6zmHf4Azzf6EF4L2Qcj8xSbKH" + }, + { + "name": "nft_with_attributes", + "description": "NFT with attributes array", + "metadata": { + "version": "1.0", + "name": "NFT with Attributes", + "symbol": "NFTAT", + "description": "An NFT with attributes", + "attributes": [ + { + "trait_type": "Color", + "value": "Blue" + }, + { + "trait_type": "Rarity", + "value": "Legendary" + }, + { + "trait_type": "Power", + "value": 100, + "display_type": "number" + } + ] + }, + "canonical": "{\"attributes\":[{\"trait_type\":\"Color\",\"value\":\"Blue\"},{\"trait_type\":\"Rarity\",\"value\":\"Legendary\"},{\"display_type\":\"number\",\"trait_type\":\"Power\",\"value\":100}],\"description\":\"An NFT with attributes\",\"name\":\"NFT with Attributes\",\"symbol\":\"NFTAT\",\"version\":\"1.0\"}", + "hash": "78c3df250de6ea36b92129fdb3e0d7258bfae21324eedf4fc1918c6e6dbae3ee", + "p2c_address": "164dnpynjPcHDjqnVmQkiDYF1JfnnsdDYo" + }, + { + "name": "issuer_with_email", + "description": "Token with issuer including valid email", + "metadata": { + "version": "1.0", + "name": "Email Issuer Token", + "symbol": "EISS", + "issuer": { + "name": "Test Issuer", + "url": "https://issuer.example.com", + "email": "contact@issuer.example.com" + } + }, + "canonical": "{\"issuer\":{\"email\":\"contact@issuer.example.com\",\"name\":\"Test Issuer\",\"url\":\"https://issuer.example.com\"},\"name\":\"Email Issuer Token\",\"symbol\":\"EISS\",\"version\":\"1.0\"}", + "hash": "b54dfb8394ae3f29b07090b75f9eee59d4a338fee2a0e53df16bed2c1bc67813", + "p2c_address": "1F784QZ8d6bEoHSi1BCiqPCi2y1961BDNd" + }, + { + "name": "nft_full", + "description": "NFT with all NFT-specific fields", + "metadata": { + "version": "1.0", + "name": "Full NFT", + "symbol": "FNFT", + "description": "A full NFT with all fields", + "image": "https://example.com/full-nft.png", + "animation_url": "https://example.com/full-nft.mp4", + "external_url": "https://example.com/view/full-nft", + "attributes": [ + { + "trait_type": "Level", + "value": 5 + } + ] + }, + "canonical": "{\"animation_url\":\"https://example.com/full-nft.mp4\",\"attributes\":[{\"trait_type\":\"Level\",\"value\":5}],\"description\":\"A full NFT with all fields\",\"external_url\":\"https://example.com/view/full-nft\",\"image\":\"https://example.com/full-nft.png\",\"name\":\"Full NFT\",\"symbol\":\"FNFT\",\"version\":\"1.0\"}", + "hash": "4fe9273bdc7b71fce97d16917fc59ae4403991d2c52f38555f91929d85dd4211", + "p2c_address": "17Kdw185CVTbRu9PTjbZ4vQWDtS77TCJyr" + } + ], + "invalid_test_cases": [ + { + "name": "missing_version", + "description": "Missing required field: version", + "metadata": { + "version": "", + "name": "Test Token", + "symbol": "TEST" + }, + "error": "version is required" + }, + { + "name": "invalid_version", + "description": "Unsupported version (only 1.0 is supported)", + "metadata": { + "version": "2.0", + "name": "Test Token", + "symbol": "TEST" + }, + "error": "version must be 1.0" + }, + { + "name": "missing_name", + "description": "Missing required field: name", + "metadata": { + "version": "1.0", + "symbol": "TEST" + }, + "error": "name is required" + }, + { + "name": "empty_name", + "description": "Empty name string", + "metadata": { + "version": "1.0", + "name": "", + "symbol": "TEST" + }, + "error": "name is required" + }, + { + "name": "name_too_long", + "description": "Name exceeds 64 characters (65 characters)", + "metadata": { + "version": "1.0", + "name": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "symbol": "TEST" + }, + "error": "name must be 64 characters or less" + }, + { + "name": "missing_symbol", + "description": "Missing required field: symbol", + "metadata": { + "version": "1.0", + "name": "Test Token" + }, + "error": "symbol is required" + }, + { + "name": "empty_symbol", + "description": "Empty symbol string", + "metadata": { + "version": "1.0", + "name": "Test Token", + "symbol": "" + }, + "error": "symbol is required" + }, + { + "name": "symbol_too_long", + "description": "Symbol exceeds 12 characters (13 characters)", + "metadata": { + "version": "1.0", + "name": "Test Token", + "symbol": "AAAAAAAAAAAAA" + }, + "error": "symbol must be 12 characters or less" + }, + { + "name": "decimals_negative", + "description": "Decimals is negative", + "metadata": { + "version": "1.0", + "name": "Test Token", + "symbol": "TEST", + "decimals": -1 + }, + "error": "decimals must be between 0 and 18" + }, + { + "name": "decimals_too_large", + "description": "Decimals exceeds maximum (19)", + "metadata": { + "version": "1.0", + "name": "Test Token", + "symbol": "TEST", + "decimals": 19 + }, + "error": "decimals must be between 0 and 18" + }, + { + "name": "description_too_long", + "description": "Description exceeds 256 characters (257 characters)", + "metadata": { + "version": "1.0", + "name": "Test Token", + "symbol": "TEST", + "description": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "error": "description must be 256 characters or less" + }, + { + "name": "website_http", + "description": "Website uses HTTP instead of HTTPS", + "metadata": { + "version": "1.0", + "name": "Test Token", + "symbol": "TEST", + "website": "http://example.com" + }, + "error": "website must be an HTTPS URL" + }, + { + "name": "website_invalid_url", + "description": "Website is not a valid URL", + "metadata": { + "version": "1.0", + "name": "Test Token", + "symbol": "TEST", + "website": "not-a-url" + }, + "error": "website must be an HTTPS URL" + }, + { + "name": "icon_http", + "description": "Icon uses HTTP instead of HTTPS", + "metadata": { + "version": "1.0", + "name": "Test Token", + "symbol": "TEST", + "icon": "http://example.com/icon.png" + }, + "error": "icon must be an HTTPS URL or Data URI" + }, + { + "name": "icon_invalid_format", + "description": "Icon is not a valid URL or Data URI", + "metadata": { + "version": "1.0", + "name": "Test Token", + "symbol": "TEST", + "icon": "not-a-valid-icon" + }, + "error": "icon must be an HTTPS URL or Data URI" + }, + { + "name": "terms_http", + "description": "Terms uses HTTP instead of HTTPS", + "metadata": { + "version": "1.0", + "name": "Test Token", + "symbol": "TEST", + "terms": "http://example.com/terms" + }, + "error": "terms must be an HTTPS URL" + }, + { + "name": "terms_invalid_url", + "description": "Terms is not a valid URL", + "metadata": { + "version": "1.0", + "name": "Test Token", + "symbol": "TEST", + "terms": "not-a-url" + }, + "error": "terms must be an HTTPS URL" + }, + { + "name": "image_http", + "description": "Image uses HTTP instead of HTTPS", + "metadata": { + "version": "1.0", + "name": "Test Token", + "symbol": "TEST", + "image": "http://example.com/image.png" + }, + "error": "image must be an HTTPS URL or Data URI" + }, + { + "name": "image_invalid_url", + "description": "Image is not a valid URL or Data URI", + "metadata": { + "version": "1.0", + "name": "Test Token", + "symbol": "TEST", + "image": "not-a-valid-url" + }, + "error": "image must be an HTTPS URL or Data URI" + }, + { + "name": "animation_url_http", + "description": "Animation URL uses HTTP instead of HTTPS", + "metadata": { + "version": "1.0", + "name": "Test Token", + "symbol": "TEST", + "animation_url": "http://example.com/video.mp4" + }, + "error": "animation_url must be an HTTPS URL or Data URI" + }, + { + "name": "animation_url_invalid", + "description": "Animation URL is not a valid URL or Data URI", + "metadata": { + "version": "1.0", + "name": "Test Token", + "symbol": "TEST", + "animation_url": "not-a-valid-url" + }, + "error": "animation_url must be an HTTPS URL or Data URI" + }, + { + "name": "external_url_http", + "description": "External URL uses HTTP instead of HTTPS", + "metadata": { + "version": "1.0", + "name": "Test Token", + "symbol": "TEST", + "external_url": "http://example.com/nft/123" + }, + "error": "external_url must be an HTTPS URL" + }, + { + "name": "external_url_invalid", + "description": "External URL is not a valid URL", + "metadata": { + "version": "1.0", + "name": "Test Token", + "symbol": "TEST", + "external_url": "not-a-valid-url" + }, + "error": "external_url must be an HTTPS URL" + }, + { + "name": "issuer_url_http", + "description": "Issuer URL uses HTTP instead of HTTPS", + "metadata": { + "version": "1.0", + "name": "Test Token", + "symbol": "TEST", + "issuer": { + "name": "Test Issuer", + "url": "http://example.com" + } + }, + "error": "issuer.url must be an HTTPS URL" + }, + { + "name": "issuer_url_invalid", + "description": "Issuer URL is not a valid URL", + "metadata": { + "version": "1.0", + "name": "Test Token", + "symbol": "TEST", + "issuer": { + "name": "Test Issuer", + "url": "not-a-url" + } + }, + "error": "issuer.url must be an HTTPS URL" + }, + { + "name": "issuer_email_invalid", + "description": "Issuer email is not a valid email address", + "metadata": { + "version": "1.0", + "name": "Test Token", + "symbol": "TEST", + "issuer": { + "name": "Test Issuer", + "email": "not-an-email" + } + }, + "error": "issuer.email must be a valid email address" + } + ], + "notes": { + "data_uri_size_limit": { + "description": "Data URI fields (icon, image, animation_url) have a 32KB (32768 bytes) size limit. This cannot be tested via JSON fixture due to file size. Test at runtime by generating a Data URI larger than 32KB.", + "max_size_bytes": 32768, + "error": "icon must be an HTTPS URL or Data URI", + "example": "data:image/png;base64, + base64_encode(random_bytes(33000))" + } + } +} diff --git a/test/metadata.spec.ts b/test/metadata.spec.ts new file mode 100644 index 000000000..d90709dca --- /dev/null +++ b/test/metadata.spec.ts @@ -0,0 +1,416 @@ +import * as assert from 'assert'; +import { describe, it } from 'mocha'; +import { ECPair, Metadata } from '..'; +import * as fixtures from './fixtures/tip0020_metadata.json'; + +// Helper to determine tokenType based on test case +function getTokenType(f: any): 'reissuable' | 'nft' { + const nftFields = ['image', 'animation_url', 'external_url', 'attributes']; + const hasNftField = nftFields.some(field => f.metadata[field] !== undefined); + return hasNftField ? 'nft' : 'reissuable'; +} + +const basePoint = Buffer.from((fixtures as any).base_point, 'hex'); + +describe('Metadata', () => { + describe('valid test cases', () => { + fixtures.valid_test_cases.forEach((f: any) => { + it(f.name + ': ' + f.description, () => { + const metadata = new Metadata({ + ...f.metadata, + tokenType: getTokenType(f), + }); + + assert.strictEqual(metadata.toCanonical(), f.canonical); + assert.strictEqual(metadata.digest().toString('hex'), f.hash); + + // P2C address derivation test + if (f.p2c_address) { + assert.strictEqual(metadata.p2cAddress(basePoint), f.p2c_address); + } + }); + }); + }); + + describe('invalid test cases', () => { + fixtures.invalid_test_cases.forEach((f: any) => { + it(f.name + ': ' + f.description, () => { + assert.throws(() => { + new Metadata({ ...f.metadata, tokenType: getTokenType(f) }); + }, new RegExp(f.error)); + }); + }); + }); + + describe('digest', () => { + it('returns a 32-byte buffer', () => { + const metadata = new Metadata({ + version: '1.0', + name: 'Test Token', + symbol: 'TEST', + tokenType: 'reissuable', + }); + const digest = metadata.digest(); + assert.strictEqual(digest.length, 32); + }); + }); + + describe('decimals handling', () => { + it('excludes decimals=0 from canonical form', () => { + const metadata = new Metadata({ + version: '1.0', + name: 'Zero Decimals Token', + symbol: 'ZERO', + tokenType: 'reissuable', + decimals: 0, + }); + assert.strictEqual(metadata.decimals, undefined); + const canonical = metadata.toCanonical(); + assert.ok(!canonical.includes('decimals')); + }); + + it('includes non-zero decimals in canonical form', () => { + const metadata = new Metadata({ + version: '1.0', + name: 'Test Token', + symbol: 'TEST', + tokenType: 'reissuable', + decimals: 8, + }); + assert.strictEqual(metadata.decimals, 8); + const canonical = metadata.toCanonical(); + assert.ok(canonical.includes('"decimals":8')); + }); + }); + + describe('toObject', () => { + it('returns MetadataFields object', () => { + const metadata = new Metadata({ + version: '1.0', + name: 'Test Token', + symbol: 'TEST', + tokenType: 'reissuable', + decimals: 8, + description: 'A test token', + }); + const obj = metadata.toObject(); + assert.strictEqual(obj.version, '1.0'); + assert.strictEqual(obj.name, 'Test Token'); + assert.strictEqual(obj.symbol, 'TEST'); + assert.strictEqual(obj.tokenType, 'reissuable'); + assert.strictEqual(obj.decimals, 8); + assert.strictEqual(obj.description, 'A test token'); + }); + + it('excludes undefined fields', () => { + const metadata = new Metadata({ + version: '1.0', + name: 'Test Token', + symbol: 'TEST', + tokenType: 'nft', + }); + const obj = metadata.toObject(); + assert.strictEqual(obj.decimals, undefined); + assert.strictEqual(obj.description, undefined); + }); + }); + + describe('fromJSON', () => { + it('parses JSON string and creates Metadata', () => { + const json = + '{"version":"1.0","name":"Test Token","symbol":"TEST","tokenType":"reissuable","decimals":8}'; + const metadata = Metadata.fromJSON(json); + assert.strictEqual(metadata.version, '1.0'); + assert.strictEqual(metadata.name, 'Test Token'); + assert.strictEqual(metadata.symbol, 'TEST'); + assert.strictEqual(metadata.tokenType, 'reissuable'); + assert.strictEqual(metadata.decimals, 8); + }); + + it('throws on invalid JSON', () => { + assert.throws(() => { + Metadata.fromJSON('{"version":"1.0","name":"Test","tokenType":"reissuable"}'); + }, /symbol is required/); + }); + }); + + describe('commitment', () => { + it('returns a 32-byte buffer', () => { + const metadata = new Metadata({ + version: '1.0', + name: 'Test Token', + symbol: 'TEST', + tokenType: 'reissuable', + }); + const keyPair = ECPair.makeRandom(); + const commitment = metadata.commitment(keyPair.publicKey); + assert.strictEqual(commitment.length, 32); + }); + + it('throws for invalid public key length', () => { + const metadata = new Metadata({ + version: '1.0', + name: 'Test Token', + symbol: 'TEST', + tokenType: 'reissuable', + }); + assert.throws(() => { + metadata.commitment(Buffer.alloc(32)); + }, /publicKey must be 33 bytes/); + }); + + it('produces deterministic commitment', () => { + const metadata = new Metadata({ + version: '1.0', + name: 'Test Token', + symbol: 'TEST', + tokenType: 'reissuable', + }); + const publicKey = Buffer.from( + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + 'hex', + ); + const c1 = metadata.commitment(publicKey); + const c2 = metadata.commitment(publicKey); + assert.deepStrictEqual(c1, c2); + }); + }); + + describe('p2cPublicKey', () => { + it('returns a 33-byte compressed public key', () => { + const metadata = new Metadata({ + version: '1.0', + name: 'Test Token', + symbol: 'TEST', + tokenType: 'reissuable', + }); + const keyPair = ECPair.makeRandom(); + const p2cPubKey = metadata.p2cPublicKey(keyPair.publicKey); + assert.strictEqual(p2cPubKey.length, 33); + assert.ok(p2cPubKey[0] === 0x02 || p2cPubKey[0] === 0x03); + }); + + it('produces different public key from original', () => { + const metadata = new Metadata({ + version: '1.0', + name: 'Test Token', + symbol: 'TEST', + tokenType: 'reissuable', + }); + const keyPair = ECPair.makeRandom(); + const p2cPubKey = metadata.p2cPublicKey(keyPair.publicKey); + assert.notDeepStrictEqual(p2cPubKey, keyPair.publicKey); + }); + + it('produces deterministic P2C public key', () => { + const metadata = new Metadata({ + version: '1.0', + name: 'Test Token', + symbol: 'TEST', + tokenType: 'reissuable', + }); + const publicKey = Buffer.from( + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + 'hex', + ); + const p2c1 = metadata.p2cPublicKey(publicKey); + const p2c2 = metadata.p2cPublicKey(publicKey); + assert.deepStrictEqual(p2c1, p2c2); + }); + }); + + describe('deriveColorId', () => { + describe('reissuable', () => { + it('returns a 33-byte color id starting with 0xc1', () => { + const metadata = new Metadata({ + version: '1.0', + name: 'Reissuable Token', + symbol: 'REIS', + tokenType: 'reissuable', + }); + const keyPair = ECPair.makeRandom(); + const colorId = metadata.deriveColorId(keyPair.publicKey); + assert.strictEqual(colorId.length, 33); + assert.strictEqual(colorId[0], 0xc1); + }); + + it('throws without publicKey', () => { + const metadata = new Metadata({ + version: '1.0', + name: 'Reissuable Token', + symbol: 'REIS', + tokenType: 'reissuable', + }); + assert.throws(() => { + metadata.deriveColorId(); + }, /publicKey is required for reissuable token/); + }); + + it('produces deterministic color id', () => { + const metadata = new Metadata({ + version: '1.0', + name: 'Reissuable Token', + symbol: 'REIS', + tokenType: 'reissuable', + }); + const publicKey = Buffer.from( + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + 'hex', + ); + const c1 = metadata.deriveColorId(publicKey); + const c2 = metadata.deriveColorId(publicKey); + assert.deepStrictEqual(c1, c2); + }); + }); + + describe('non_reissuable', () => { + it('returns a 33-byte color id starting with 0xc2', () => { + const metadata = new Metadata({ + version: '1.0', + name: 'Non-Reissuable Token', + symbol: 'NREIS', + tokenType: 'non_reissuable', + }); + const outPoint = { + txid: Buffer.alloc(32, 0x01), + index: 0, + }; + const colorId = metadata.deriveColorId(undefined, outPoint); + assert.strictEqual(colorId.length, 33); + assert.strictEqual(colorId[0], 0xc2); + }); + + it('throws without outPoint', () => { + const metadata = new Metadata({ + version: '1.0', + name: 'Non-Reissuable Token', + symbol: 'NREIS', + tokenType: 'non_reissuable', + }); + assert.throws(() => { + metadata.deriveColorId(); + }, /outPoint is required for non_reissuable token/); + }); + }); + + describe('nft', () => { + it('returns a 33-byte color id starting with 0xc3', () => { + const metadata = new Metadata({ + version: '1.0', + name: 'NFT Token', + symbol: 'NFT', + tokenType: 'nft', + }); + const outPoint = { + txid: Buffer.alloc(32, 0x02), + index: 1, + }; + const colorId = metadata.deriveColorId(undefined, outPoint); + assert.strictEqual(colorId.length, 33); + assert.strictEqual(colorId[0], 0xc3); + }); + + it('throws without outPoint', () => { + const metadata = new Metadata({ + version: '1.0', + name: 'NFT Token', + symbol: 'NFT', + tokenType: 'nft', + }); + assert.throws(() => { + metadata.deriveColorId(); + }, /outPoint is required for nft token/); + }); + }); + }); + + describe('tokenType validation', () => { + it('throws on missing tokenType', () => { + assert.throws(() => { + new Metadata({ + version: '1.0', + name: 'Test Token', + symbol: 'TEST', + } as any); + }, /tokenType is required/); + }); + + it('throws on invalid tokenType', () => { + assert.throws(() => { + new Metadata({ + version: '1.0', + name: 'Test Token', + symbol: 'TEST', + tokenType: 'invalid' as any, + }); + }, /tokenType must be reissuable, non_reissuable, or nft/); + }); + }); + + describe('NFT-only fields validation', () => { + it('throws when image is used with reissuable', () => { + assert.throws(() => { + new Metadata({ + version: '1.0', + name: 'Test Token', + symbol: 'TEST', + tokenType: 'reissuable', + image: 'https://example.com/image.png', + }); + }, /image is only allowed for nft token type/); + }); + + it('throws when animation_url is used with non_reissuable', () => { + assert.throws(() => { + new Metadata({ + version: '1.0', + name: 'Test Token', + symbol: 'TEST', + tokenType: 'non_reissuable', + animation_url: 'https://example.com/video.mp4', + }); + }, /animation_url is only allowed for nft token type/); + }); + + it('throws when external_url is used with reissuable', () => { + assert.throws(() => { + new Metadata({ + version: '1.0', + name: 'Test Token', + symbol: 'TEST', + tokenType: 'reissuable', + external_url: 'https://example.com/nft/123', + }); + }, /external_url is only allowed for nft token type/); + }); + + it('throws when attributes is used with reissuable', () => { + assert.throws(() => { + new Metadata({ + version: '1.0', + name: 'Test Token', + symbol: 'TEST', + tokenType: 'reissuable', + attributes: [{ trait_type: 'Color', value: 'Blue' }], + }); + }, /attributes is only allowed for nft token type/); + }); + + it('allows NFT-only fields with nft tokenType', () => { + const metadata = new Metadata({ + version: '1.0', + name: 'NFT Token', + symbol: 'NFT', + tokenType: 'nft', + image: 'https://example.com/image.png', + animation_url: 'https://example.com/video.mp4', + external_url: 'https://example.com/nft/123', + attributes: [{ trait_type: 'Color', value: 'Blue' }], + }); + assert.strictEqual(metadata.image, 'https://example.com/image.png'); + assert.strictEqual(metadata.animation_url, 'https://example.com/video.mp4'); + assert.strictEqual(metadata.external_url, 'https://example.com/nft/123'); + assert.deepStrictEqual(metadata.attributes, [{ trait_type: 'Color', value: 'Blue' }]); + }); + }); +}); diff --git a/ts_src/index.ts b/ts_src/index.ts index f972bcff3..617e369c7 100644 --- a/ts_src/index.ts +++ b/ts_src/index.ts @@ -23,6 +23,14 @@ export { }; export { Block } from './block'; +export { + Metadata, + Issuer, + Attribute, + MetadataFields, + TokenType, + OutPoint, +} from './metadata'; export { Psbt, PsbtTxInput, PsbtTxOutput } from './psbt'; export { OPS as opcodes } from './script'; export { Transaction } from './transaction'; diff --git a/ts_src/metadata.ts b/ts_src/metadata.ts new file mode 100644 index 000000000..e27419426 --- /dev/null +++ b/ts_src/metadata.ts @@ -0,0 +1,461 @@ +import * as crypto from './crypto'; +import { Network } from './networks'; +import * as payments from './payments'; +const canonicalize = require('canonicalize'); +const ecc = require('tiny-secp256k1'); + +export interface Issuer { + name?: string; + url?: string; + email?: string; +} + +export interface Attribute { + // tslint:disable-next-line:variable-name + trait_type: string; + value: string | number; + // tslint:disable-next-line:variable-name + display_type?: string; +} + +export type TokenType = 'reissuable' | 'non_reissuable' | 'nft'; + +export interface OutPoint { + txid: Buffer; + index: number; +} + +export interface MetadataFields { + version: string; + name: string; + symbol: string; + tokenType: TokenType; + decimals?: number; + description?: string; + icon?: string; + website?: string; + issuer?: Issuer; + terms?: string; + properties?: Record; + image?: string; + // tslint:disable-next-line:variable-name + animation_url?: string; + // tslint:disable-next-line:variable-name + external_url?: string; + attributes?: Attribute[]; +} + +const MAX_NAME_LENGTH = 64; +const MAX_SYMBOL_LENGTH = 12; +const MAX_DECIMALS = 18; +const MAX_DESCRIPTION_LENGTH = 256; +const MAX_DATA_URI_SIZE = 32768; + +const COLOR_ID_REISSUABLE = 0xc1; +const COLOR_ID_NON_REISSUABLE = 0xc2; +const COLOR_ID_NFT = 0xc3; + +const VALID_TOKEN_TYPES: TokenType[] = ['reissuable', 'non_reissuable', 'nft']; + +function isHttpsUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === 'https:'; + } catch { + return false; + } +} + +function isDataUri(value: string): boolean { + return value.startsWith('data:'); +} + +function isValidEmail(value: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); +} + +function getDataUriSize(value: string): number { + const base64Match = value.match(/^data:[^,]+;base64,(.*)$/); + if (base64Match) { + return Buffer.from(base64Match[1], 'base64').length; + } + const plainMatch = value.match(/^data:[^,]*,(.*)$/); + if (plainMatch) { + return Buffer.from(decodeURIComponent(plainMatch[1])).length; + } + return value.length; +} + +export class Metadata { + static fromJSON(json: string): Metadata { + const fields = JSON.parse(json) as MetadataFields; + return new Metadata(fields); + } + + private static validate(fields: MetadataFields): void { + // version + if (!fields.version) { + throw new Error('version is required'); + } + if (fields.version !== '1.0') { + throw new Error('version must be 1.0'); + } + + // tokenType + if (!fields.tokenType) { + throw new Error('tokenType is required'); + } + if (!VALID_TOKEN_TYPES.includes(fields.tokenType)) { + throw new Error('tokenType must be reissuable, non_reissuable, or nft'); + } + + // name + if (!fields.name) { + throw new Error('name is required'); + } + if (fields.name.length > MAX_NAME_LENGTH) { + throw new Error('name must be 64 characters or less'); + } + + // symbol + if (!fields.symbol) { + throw new Error('symbol is required'); + } + if (fields.symbol.length > MAX_SYMBOL_LENGTH) { + throw new Error('symbol must be 12 characters or less'); + } + + // decimals + if (fields.decimals !== undefined) { + if (fields.decimals < 0 || fields.decimals > MAX_DECIMALS) { + throw new Error('decimals must be between 0 and 18'); + } + } + + // description + if (fields.description !== undefined) { + if (fields.description.length > MAX_DESCRIPTION_LENGTH) { + throw new Error('description must be 256 characters or less'); + } + } + + // website + if (fields.website !== undefined) { + if (!isHttpsUrl(fields.website)) { + throw new Error('website must be an HTTPS URL'); + } + } + + // terms + if (fields.terms !== undefined) { + if (!isHttpsUrl(fields.terms)) { + throw new Error('terms must be an HTTPS URL'); + } + } + + // external_url + if (fields.external_url !== undefined) { + if (!isHttpsUrl(fields.external_url)) { + throw new Error('external_url must be an HTTPS URL'); + } + } + + // icon + if (fields.icon !== undefined) { + if (!isHttpsUrl(fields.icon) && !isDataUri(fields.icon)) { + throw new Error('icon must be an HTTPS URL or Data URI'); + } + if ( + isDataUri(fields.icon) && + getDataUriSize(fields.icon) > MAX_DATA_URI_SIZE + ) { + throw new Error('icon must be an HTTPS URL or Data URI'); + } + } + + // image + if (fields.image !== undefined) { + if (!isHttpsUrl(fields.image) && !isDataUri(fields.image)) { + throw new Error('image must be an HTTPS URL or Data URI'); + } + if ( + isDataUri(fields.image) && + getDataUriSize(fields.image) > MAX_DATA_URI_SIZE + ) { + throw new Error('image must be an HTTPS URL or Data URI'); + } + } + + // animation_url + if (fields.animation_url !== undefined) { + if (!isHttpsUrl(fields.animation_url) && !isDataUri(fields.animation_url)) { + throw new Error('animation_url must be an HTTPS URL or Data URI'); + } + if ( + isDataUri(fields.animation_url) && + getDataUriSize(fields.animation_url) > MAX_DATA_URI_SIZE + ) { + throw new Error('animation_url must be an HTTPS URL or Data URI'); + } + } + + // issuer + if (fields.issuer !== undefined) { + if (fields.issuer.url !== undefined) { + if (!isHttpsUrl(fields.issuer.url)) { + throw new Error('issuer.url must be an HTTPS URL'); + } + } + if (fields.issuer.email !== undefined) { + if (!isValidEmail(fields.issuer.email)) { + throw new Error('issuer.email must be a valid email address'); + } + } + } + + // NFT-only fields validation + if (fields.tokenType !== 'nft') { + if (fields.image !== undefined) { + throw new Error('image is only allowed for nft token type'); + } + if (fields.animation_url !== undefined) { + throw new Error('animation_url is only allowed for nft token type'); + } + if (fields.external_url !== undefined) { + throw new Error('external_url is only allowed for nft token type'); + } + if (fields.attributes !== undefined) { + throw new Error('attributes is only allowed for nft token type'); + } + } + } + + readonly version: string; + readonly name: string; + readonly symbol: string; + readonly tokenType: TokenType; + readonly decimals?: number; + readonly description?: string; + readonly icon?: string; + readonly website?: string; + readonly issuer?: Issuer; + readonly terms?: string; + readonly properties?: Record; + readonly image?: string; + // tslint:disable-next-line:variable-name + readonly animation_url?: string; + // tslint:disable-next-line:variable-name + readonly external_url?: string; + readonly attributes?: Attribute[]; + + constructor(fields: MetadataFields) { + Metadata.validate(fields); + + this.version = fields.version; + this.name = fields.name; + this.symbol = fields.symbol; + this.tokenType = fields.tokenType; + if (fields.decimals !== undefined && fields.decimals !== 0) { + this.decimals = fields.decimals; + } + if (fields.description) { + this.description = fields.description; + } + if (fields.icon) { + this.icon = fields.icon; + } + if (fields.website) { + this.website = fields.website; + } + if (fields.issuer) { + this.issuer = fields.issuer; + } + if (fields.terms) { + this.terms = fields.terms; + } + if (fields.properties && Object.keys(fields.properties).length > 0) { + this.properties = fields.properties; + } + if (fields.image) { + this.image = fields.image; + } + if (fields.animation_url) { + this.animation_url = fields.animation_url; + } + if (fields.external_url) { + this.external_url = fields.external_url; + } + if (fields.attributes && fields.attributes.length > 0) { + this.attributes = fields.attributes; + } + } + + toCanonical(): string { + const obj: Record = {}; + + if (this.animation_url !== undefined) { + obj.animation_url = this.animation_url; + } + if (this.attributes !== undefined) { + obj.attributes = this.attributes; + } + if (this.decimals !== undefined) { + obj.decimals = this.decimals; + } + if (this.description !== undefined) { + obj.description = this.description; + } + if (this.external_url !== undefined) { + obj.external_url = this.external_url; + } + if (this.icon !== undefined) { + obj.icon = this.icon; + } + if (this.image !== undefined) { + obj.image = this.image; + } + if (this.issuer !== undefined) { + obj.issuer = this.issuer; + } + obj.name = this.name; + if (this.properties !== undefined) { + obj.properties = this.properties; + } + obj.symbol = this.symbol; + if (this.terms !== undefined) { + obj.terms = this.terms; + } + obj.version = this.version; + if (this.website !== undefined) { + obj.website = this.website; + } + + return canonicalize(obj); + } + + digest(): Buffer { + const canonical = this.toCanonical(); + return crypto.sha256(Buffer.from(canonical, 'utf8')); + } + + toObject(): MetadataFields { + const obj: MetadataFields = { + version: this.version, + name: this.name, + symbol: this.symbol, + tokenType: this.tokenType, + }; + + if (this.decimals !== undefined) { + obj.decimals = this.decimals; + } + if (this.description !== undefined) { + obj.description = this.description; + } + if (this.icon !== undefined) { + obj.icon = this.icon; + } + if (this.website !== undefined) { + obj.website = this.website; + } + if (this.issuer !== undefined) { + obj.issuer = this.issuer; + } + if (this.terms !== undefined) { + obj.terms = this.terms; + } + if (this.properties !== undefined) { + obj.properties = this.properties; + } + if (this.image !== undefined) { + obj.image = this.image; + } + if (this.animation_url !== undefined) { + obj.animation_url = this.animation_url; + } + if (this.external_url !== undefined) { + obj.external_url = this.external_url; + } + if (this.attributes !== undefined) { + obj.attributes = this.attributes; + } + + return obj; + } + + commitment(publicKey: Buffer): Buffer { + if (publicKey.length !== 33) { + throw new Error('publicKey must be 33 bytes (compressed)'); + } + const h = this.digest(); + return crypto.sha256(Buffer.concat([publicKey, h])); + } + + p2cPublicKey(publicKey: Buffer): Buffer { + const c = this.commitment(publicKey); + const result = ecc.pointAddScalar(publicKey, c); + if (!result) { + throw new Error('Failed to derive P2C public key'); + } + return Buffer.from(result); + } + + p2cAddress(publicKey: Buffer, network?: Network): string { + const p2cPubKey = this.p2cPublicKey(publicKey); + const { address } = payments.p2pkh({ pubkey: p2cPubKey, network }); + return address!; + } + + deriveColorId(publicKey?: Buffer, outPoint?: OutPoint): Buffer { + switch (this.tokenType) { + case 'reissuable': { + if (!publicKey) { + throw new Error('publicKey is required for reissuable token'); + } + const p2cPubKey = this.p2cPublicKey(publicKey); + const pubKeyHash = crypto.hash160(p2cPubKey); + // P2PKH script: OP_DUP OP_HASH160 <20bytes> OP_EQUALVERIFY OP_CHECKSIG + const script = Buffer.alloc(25); + script[0] = 0x76; // OP_DUP + script[1] = 0xa9; // OP_HASH160 + script[2] = 0x14; // 20 bytes + pubKeyHash.copy(script, 3); + script[23] = 0x88; // OP_EQUALVERIFY + script[24] = 0xac; // OP_CHECKSIG + const scriptHash = crypto.sha256(script); + const colorId = Buffer.alloc(33); + colorId[0] = COLOR_ID_REISSUABLE; + scriptHash.copy(colorId, 1); + return colorId; + } + case 'non_reissuable': { + if (!outPoint) { + throw new Error('outPoint is required for non_reissuable token'); + } + const outPointPayload = Buffer.alloc(36); + outPoint.txid.copy(outPointPayload, 0); + outPointPayload.writeUInt32LE(outPoint.index, 32); + const outPointHash = crypto.sha256(outPointPayload); + const colorId = Buffer.alloc(33); + colorId[0] = COLOR_ID_NON_REISSUABLE; + outPointHash.copy(colorId, 1); + return colorId; + } + case 'nft': { + if (!outPoint) { + throw new Error('outPoint is required for nft token'); + } + const outPointPayload = Buffer.alloc(36); + outPoint.txid.copy(outPointPayload, 0); + outPointPayload.writeUInt32LE(outPoint.index, 32); + const outPointHash = crypto.sha256(outPointPayload); + const colorId = Buffer.alloc(33); + colorId[0] = COLOR_ID_NFT; + outPointHash.copy(colorId, 1); + return colorId; + } + default: + throw new Error('Invalid token type'); + } + } +} diff --git a/types/index.d.ts b/types/index.d.ts index 6ad3eebc6..e20c74c4b 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -9,6 +9,7 @@ import * as script from './script'; declare const bip32: import("bip32").BIP32API; export { BIP32Interface, ECPair, address, bip32, bufferutils, crypto, networks, payments, script, }; export { Block } from './block'; +export { Metadata, Issuer, Attribute, MetadataFields, TokenType, OutPoint, } from './metadata'; export { Psbt, PsbtTxInput, PsbtTxOutput } from './psbt'; export { OPS as opcodes } from './script'; export { Transaction } from './transaction'; diff --git a/types/metadata.d.ts b/types/metadata.d.ts new file mode 100644 index 000000000..c120e7fba --- /dev/null +++ b/types/metadata.d.ts @@ -0,0 +1,60 @@ +import { Network } from './networks'; +export interface Issuer { + name?: string; + url?: string; + email?: string; +} +export interface Attribute { + trait_type: string; + value: string | number; + display_type?: string; +} +export type TokenType = 'reissuable' | 'non_reissuable' | 'nft'; +export interface OutPoint { + txid: Buffer; + index: number; +} +export interface MetadataFields { + version: string; + name: string; + symbol: string; + tokenType: TokenType; + decimals?: number; + description?: string; + icon?: string; + website?: string; + issuer?: Issuer; + terms?: string; + properties?: Record; + image?: string; + animation_url?: string; + external_url?: string; + attributes?: Attribute[]; +} +export declare class Metadata { + static fromJSON(json: string): Metadata; + private static validate; + readonly version: string; + readonly name: string; + readonly symbol: string; + readonly tokenType: TokenType; + readonly decimals?: number; + readonly description?: string; + readonly icon?: string; + readonly website?: string; + readonly issuer?: Issuer; + readonly terms?: string; + readonly properties?: Record; + readonly image?: string; + readonly animation_url?: string; + readonly external_url?: string; + readonly attributes?: Attribute[]; + constructor(fields: MetadataFields); + toCanonical(): string; + digest(): Buffer; + toObject(): MetadataFields; + commitment(publicKey: Buffer): Buffer; + p2cPublicKey(publicKey: Buffer): Buffer; + p2cAddress(publicKey: Buffer, network?: Network): string; + deriveColorId(publicKey?: Buffer, outPoint?: OutPoint): Buffer; +}