UNPKG

aladinnetwork-blockstack

Version:

The Aladin Javascript library for authentication, identity, and storage.

767 lines (608 loc) 22.5 kB
import { TransactionBuilder, payments, address as bjsAddress } from 'bitcoinjs-lib' import BN from 'bn.js' import { decodeB40, hash160, hash128, DUST_MINIMUM } from './utils' import { config } from '../config' // support v1 and v2 price API endpoint return values type AmountTypeV1 = number type AmountTypeV2 = { units: string, amount: BN } type AmountType = AmountTypeV1 | AmountTypeV2 // todo : add name length / character verification /** * @ignore */ export class AladinNamespace { namespaceID: string version: number lifetime: number coeff: number base: number buckets: Array<number> nonalphaDiscount: number noVowelDiscount: number constructor(namespaceID: string) { if (namespaceID.length > 19) { throw new Error('Namespace ID too long (19 chars max)') } if (!namespaceID.match('[0123456789abcdefghijklmnopqrstuvwxyz_-]+')) { throw new Error('Namespace ID can only use characters 0123456789abcdefghijklmnopqrstuvwxyz-_') } this.namespaceID = namespaceID this.version = -1 this.lifetime = -1 this.coeff = -1 this.base = -1 this.buckets = [-1] this.nonalphaDiscount = -1 this.noVowelDiscount = -1 } check() { try { this.setVersion(this.version) this.setLifetime(this.lifetime) this.setCoeff(this.coeff) this.setBase(this.base) this.setBuckets(this.buckets) this.setNonalphaDiscount(this.nonalphaDiscount) this.setNoVowelDiscount(this.noVowelDiscount) return true } catch (e) { return false } } setVersion(version: number) { if (version < 0 || version > 2 ** 16 - 1) { throw new Error('Invalid version: must be a 16-bit number') } this.version = version } setLifetime(lifetime: number) { if (lifetime < 0 || lifetime > 2 ** 32 - 1) { throw new Error('Invalid lifetime: must be a 32-bit number') } this.lifetime = lifetime } setCoeff(coeff: number) { if (coeff < 0 || coeff > 255) { throw new Error('Invalid coeff: must be an 8-bit number') } this.coeff = coeff } setBase(base: number) { if (base < 0 || base > 255) { throw new Error('Invalid base: must be an 8-bit number') } this.base = base } setBuckets(buckets: Array<number>) { if (buckets.length !== 16) { throw new Error('Invalid buckets: must have 16 entries') } for (let i = 0; i < buckets.length; i++) { if (buckets[i] < 0 || buckets[i] > 15) { throw new Error('Invalid buckets: must be 4-bit numbers') } } this.buckets = buckets.slice(0) } setNonalphaDiscount(nonalphaDiscount: number) { if (nonalphaDiscount <= 0 || nonalphaDiscount > 15) { throw new Error('Invalid nonalphaDiscount: must be a positive 4-bit number') } this.nonalphaDiscount = nonalphaDiscount } setNoVowelDiscount(noVowelDiscount: number) { if (noVowelDiscount <= 0 || noVowelDiscount > 15) { throw new Error('Invalid noVowelDiscount: must be a positive 4-bit number') } this.noVowelDiscount = noVowelDiscount } toHexPayload() { const lifeHex = `00000000${this.lifetime.toString(16)}`.slice(-8) const coeffHex = `00${this.coeff.toString(16)}`.slice(-2) const baseHex = `00${this.base.toString(16)}`.slice(-2) const bucketHex = this.buckets.map(b => b.toString(16)).reduce((b1, b2) => b1 + b2, '') const discountHex = this.nonalphaDiscount.toString(16) + this.noVowelDiscount.toString(16) const versionHex = `0000${this.version.toString(16)}`.slice(-4) const namespaceIDHex = Buffer.from(this.namespaceID).toString('hex') return lifeHex + coeffHex + baseHex + bucketHex + discountHex + versionHex + namespaceIDHex } } /** * @ignore */ function asAmountV2(amount: AmountType): AmountTypeV2 { // convert an AmountType v1 or v2 to an AmountTypeV2. // the "units" of a v1 amount type are always 'BTC' if (typeof amount === 'number') { return { units: 'BTC', amount: new BN(String(amount)) } } else { return { units: amount.units, amount: amount.amount } } } /** * @ignore */ function makeTXbuilder() { const txb = new TransactionBuilder(config.network.layer1) txb.setVersion(1) return txb } /** * @ignore */ function opEncode(opcode: string): string { // NOTE: must *always* a 3-character string const res = `${config.network.MAGIC_BYTES}${opcode}` if (res.length !== 3) { throw new Error('Runtime error: invalid MAGIC_BYTES') } return res } /** * @ignore */ export function makePreorderSkeleton( fullyQualifiedName: string, consensusHash: string, preorderAddress: string, burnAddress: string, burn: AmountType, registerAddress: string = null ) { // Returns a preorder tx skeleton. // with 3 outputs : 1. the Aladin Preorder OP_RETURN data // 2. the Preorder's change address (5500 satoshi minimum) // 3. the BURN // // 0 2 3 23 39 47 66 // |-----|--|--------------------------------------|--------------|-----------|-------------| // magic op hash160(fqn,scriptPubkey,registerAddr) consensus hash token burn token type // (optional) (optional) // // output 0: name preorder code // output 1: preorder address // output 2: burn address // // Returns an unsigned serialized transaction. const burnAmount = asAmountV2(burn) const network = config.network const nameBuff = Buffer.from(decodeB40(fullyQualifiedName), 'hex') // base40 const scriptPublicKey = bjsAddress.toOutputScript(preorderAddress, network.layer1) const dataBuffers = [nameBuff, scriptPublicKey] if (!!registerAddress) { const registerBuff = Buffer.from(registerAddress, 'ascii') dataBuffers.push(registerBuff) } const dataBuff = Buffer.concat(dataBuffers) const hashed = hash160(dataBuff) const opReturnBufferLen = burnAmount.units === 'BTC' ? 39 : 66 const opReturnBuffer = Buffer.alloc(opReturnBufferLen) opReturnBuffer.write(opEncode('?'), 0, 3, 'ascii') hashed.copy(opReturnBuffer, 3) opReturnBuffer.write(consensusHash, 23, 16, 'hex') if (burnAmount.units !== 'BTC') { const burnHex = burnAmount.amount.toString(16, 2) if (burnHex.length > 16) { // exceeds 2**64; can't fit throw new Error(`Cannot preorder '${fullyQualifiedName}': cannot fit price into 8 bytes`) } const paddedBurnHex = `0000000000000000${burnHex}`.slice(-16) opReturnBuffer.write(paddedBurnHex, 39, 8, 'hex') opReturnBuffer.write(burnAmount.units, 47, burnAmount.units.length, 'ascii') } const nullOutput = payments.embed({ data: [opReturnBuffer] }).output const tx = makeTXbuilder() tx.addOutput(nullOutput, 0) tx.addOutput(preorderAddress, DUST_MINIMUM) if (burnAmount.units === 'BTC') { const btcBurnAmount = burnAmount.amount.toNumber() tx.addOutput(burnAddress, btcBurnAmount) } else { tx.addOutput(burnAddress, DUST_MINIMUM) } return tx.buildIncomplete() } /** * @ignore */ export function makeRegisterSkeleton( fullyQualifiedName: string, ownerAddress: string, valueHash: string = null, burnTokenAmountHex: string = null ) { // Returns a register tx skeleton. // with 2 outputs : 1. The register OP_RETURN // 2. The owner address (can be different from REGISTER address on renewals) // You MUST make the first input a UTXO from the current OWNER *or* the // funder of the PREORDER // in the case of a renewal, this would need to be modified to include a change address // as output (3) before the burn output (4) /* Formats No zonefile hash, and pay with BTC: 0 2 3 39 |----|--|----------------------------------| magic op name.ns_id (up to 37 bytes) With zonefile hash, and pay with BTC: 0 2 3 39 59 |----|--|----------------------------------|-------------------| magic op name.ns_id (37 bytes, 0-padded) zone file hash output 0: name registration code output 1: owner address */ let payload if (!!burnTokenAmountHex && !valueHash) { // empty value hash valueHash = '0000000000000000000000000000000000000000' } if (!!valueHash) { if (valueHash.length !== 40) { throw new Error('Value hash length incorrect. Expecting 20-bytes, hex-encoded') } if (!!burnTokenAmountHex) { if (burnTokenAmountHex.length !== 16) { throw new Error('Burn field length incorrect. Expecting 8-bytes, hex-encoded') } } const payloadLen = burnTokenAmountHex ? 65 : 57 payload = Buffer.alloc(payloadLen, 0) payload.write(fullyQualifiedName, 0, 37, 'ascii') payload.write(valueHash, 37, 20, 'hex') if (!!burnTokenAmountHex) { payload.write(burnTokenAmountHex, 57, 8, 'hex') } } else { payload = Buffer.from(fullyQualifiedName, 'ascii') } const opReturnBuffer = Buffer.concat([Buffer.from(opEncode(':'), 'ascii'), payload]) const nullOutput = payments.embed({ data: [opReturnBuffer] }).output const tx = makeTXbuilder() tx.addOutput(nullOutput, 0) tx.addOutput(ownerAddress, DUST_MINIMUM) return tx.buildIncomplete() } /** * @ignore */ export function makeRenewalSkeleton( fullyQualifiedName: string, nextOwnerAddress: string, lastOwnerAddress: string, burnAddress: string, burn: AmountType, valueHash: string = null ) { /* Formats No zonefile hash, and pay with BTC: 0 2 3 39 |----|--|----------------------------------| magic op name.ns_id (up to 37 bytes) With zonefile hash, and pay with BTC: 0 2 3 39 59 |----|--|----------------------------------|-------------------| magic op name.ns_id (37 bytes, 0-padded) zone file hash With renewal payment in a token: (for register, tokens burned is not included) (for renew, tokens burned is the number of tokens to burn) 0 2 3 39 59 67 |----|--|----------------------------------|-------------------|------------------------------| magic op name.ns_id (37 bytes, 0-padded) zone file hash tokens burned (big-endian) output 0: renewal code output 1: new owner address output 2: current owner address output 3: burn address */ const burnAmount = asAmountV2(burn) const network = config.network const burnTokenAmount = burnAmount.units === 'BTC' ? null : burnAmount.amount const burnBTCAmount = burnAmount.units === 'BTC' ? burnAmount.amount.toNumber() : DUST_MINIMUM let burnTokenHex = null if (!!burnTokenAmount) { const burnHex = burnTokenAmount.toString(16, 2) if (burnHex.length > 16) { // exceeds 2**64; can't fit throw new Error(`Cannot renew '${fullyQualifiedName}': cannot fit price into 8 bytes`) } burnTokenHex = `0000000000000000${burnHex}`.slice(-16) } const registerTX = makeRegisterSkeleton( fullyQualifiedName, nextOwnerAddress, valueHash, burnTokenHex ) const txB = TransactionBuilder.fromTransaction( registerTX, network.layer1 ) txB.addOutput(lastOwnerAddress, DUST_MINIMUM) txB.addOutput(burnAddress, burnBTCAmount) return txB.buildIncomplete() } /** * @ignore */ export function makeTransferSkeleton( fullyQualifiedName: string, consensusHash: string, newOwner: string, keepZonefile: boolean = false ) { // Returns a transfer tx skeleton. // with 2 outputs : 1. the Aladin Transfer OP_RETURN data // 2. the new owner with a DUST_MINIMUM value (5500 satoshi) // // You MUST make the first input a UTXO from the current OWNER // // Returns an unsigned serialized transaction. /* Format 0 2 3 4 20 36 |-----|--|----|-------------------|---------------| magic op keep hash128(name.ns_id) consensus hash data? output 0: transfer code output 1: new owner */ const opRet = Buffer.alloc(36) let keepChar = '~' if (keepZonefile) { keepChar = '>' } opRet.write(opEncode('>'), 0, 3, 'ascii') opRet.write(keepChar, 3, 1, 'ascii') const hashed = hash128(Buffer.from(fullyQualifiedName, 'ascii')) hashed.copy(opRet, 4) opRet.write(consensusHash, 20, 16, 'hex') const opRetPayload = payments.embed({ data: [opRet] }).output const tx = makeTXbuilder() tx.addOutput(opRetPayload, 0) tx.addOutput(newOwner, DUST_MINIMUM) return tx.buildIncomplete() } /** * @ignore */ export function makeUpdateSkeleton( fullyQualifiedName: string, consensusHash: string, valueHash: string ) { // Returns an update tx skeleton. // with 1 output : 1. the Aladin update OP_RETURN // // You MUST make the first input a UTXO from the current OWNER // // Returns an unsigned serialized transaction. // // output 0: the revoke code /* Format: 0 2 3 19 39 |-----|--|-----------------------------------|-----------------------| magic op hash128(name.ns_id,consensus hash) hash160(data) output 0: update code */ const opRet = Buffer.alloc(39) const nameBuff = Buffer.from(fullyQualifiedName, 'ascii') const consensusBuff = Buffer.from(consensusHash, 'ascii') const hashedName = hash128(Buffer.concat( [nameBuff, consensusBuff] )) opRet.write(opEncode('+'), 0, 3, 'ascii') hashedName.copy(opRet, 3) opRet.write(valueHash, 19, 20, 'hex') const opRetPayload = payments.embed({ data: [opRet] }).output const tx = makeTXbuilder() tx.addOutput(opRetPayload, 0) return tx.buildIncomplete() } /** * @ignore */ export function makeRevokeSkeleton(fullyQualifiedName: string) { // Returns a revoke tx skeleton // with 1 output: 1. the Aladin revoke OP_RETURN // // You MUST make the first input a UTXO from the current OWNER // // Returns an unsigned serialized transaction /* Format: 0 2 3 39 |----|--|-----------------------------| magic op name.ns_id (37 bytes) output 0: the revoke code */ const opRet = Buffer.alloc(3) const nameBuff = Buffer.from(fullyQualifiedName, 'ascii') opRet.write(opEncode('~'), 0, 3, 'ascii') const opReturnBuffer = Buffer.concat([opRet, nameBuff]) const nullOutput = payments.embed({ data: [opReturnBuffer] }).output const tx = makeTXbuilder() tx.addOutput(nullOutput, 0) return tx.buildIncomplete() } /** * @ignore */ export function makeNamespacePreorderSkeleton( namespaceID: string, consensusHash: string, preorderAddress: string, registerAddress: string, burn: AmountType ) { // Returns a namespace preorder tx skeleton. // Returns an unsigned serialized transaction. /* Formats: Without STACKS: 0 2 3 23 39 |-----|---|--------------------------------------|----------------| magic op hash(ns_id,script_pubkey,reveal_addr) consensus hash with STACKs: 0 2 3 23 39 47 |-----|---|--------------------------------------|----------------|--------------------------| magic op hash(ns_id,script_pubkey,reveal_addr) consensus hash token fee (big-endian) output 0: namespace preorder code output 1: change address otuput 2: burn address */ const burnAmount = asAmountV2(burn) if (burnAmount.units !== 'BTC' && burnAmount.units !== 'STACKS') { throw new Error(`Invalid burnUnits ${burnAmount.units}`) } const network = config.network const burnAddress = network.getDefaultBurnAddress() const namespaceIDBuff = Buffer.from(decodeB40(namespaceID), 'hex') // base40 const scriptPublicKey = bjsAddress.toOutputScript(preorderAddress, network.layer1) const registerBuff = Buffer.from(registerAddress, 'ascii') const dataBuffers = [namespaceIDBuff, scriptPublicKey, registerBuff] const dataBuff = Buffer.concat(dataBuffers) const hashed = hash160(dataBuff) let btcBurnAmount = DUST_MINIMUM let opReturnBufferLen = 39 if (burnAmount.units === 'STACKS') { opReturnBufferLen = 47 } else { btcBurnAmount = burnAmount.amount.toNumber() } const opReturnBuffer = Buffer.alloc(opReturnBufferLen) opReturnBuffer.write(opEncode('*'), 0, 3, 'ascii') hashed.copy(opReturnBuffer, 3) opReturnBuffer.write(consensusHash, 23, 16, 'hex') if (burnAmount.units === 'STACKS') { const burnHex = burnAmount.amount.toString(16, 2) const paddedBurnHex = `0000000000000000${burnHex}`.slice(-16) opReturnBuffer.write(paddedBurnHex, 39, 8, 'hex') } const nullOutput = payments.embed({ data: [opReturnBuffer] }).output const tx = makeTXbuilder() tx.addOutput(nullOutput, 0) tx.addOutput(preorderAddress, DUST_MINIMUM) tx.addOutput(burnAddress, btcBurnAmount) return tx.buildIncomplete() } /** * @ignore */ export function makeNamespaceRevealSkeleton( namespace: AladinNamespace, revealAddress: string ) { /* Format: 0 2 3 7 8 9 10 11 12 13 14 15 16 17 18 20 39 |-----|---|----|-----|-----|----|----|----|----|----|-----|-----|-----|--------|-------|-------| magic op life coeff. base 1-2 3-4 5-6 7-8 9-10 11-12 13-14 15-16 nonalpha version ns ID bucket exponents no-vowel discounts output 0: namespace reveal code output 1: reveal address */ const hexPayload = namespace.toHexPayload() const opReturnBuffer = Buffer.alloc(3 + hexPayload.length / 2) opReturnBuffer.write(opEncode('&'), 0, 3, 'ascii') opReturnBuffer.write(hexPayload, 3, hexPayload.length / 2, 'hex') const nullOutput = payments.embed({ data: [opReturnBuffer] }).output const tx = makeTXbuilder() tx.addOutput(nullOutput, 0) tx.addOutput(revealAddress, DUST_MINIMUM) return tx.buildIncomplete() } /** * @ignore */ export function makeNamespaceReadySkeleton(namespaceID: string) { /* Format: 0 2 3 4 23 |-----|--|--|------------| magic op . ns_id output 0: namespace ready code */ const opReturnBuffer = Buffer.alloc(3 + namespaceID.length + 1) opReturnBuffer.write(opEncode('!'), 0, 3, 'ascii') opReturnBuffer.write(`.${namespaceID}`, 3, namespaceID.length + 1, 'ascii') const nullOutput = payments.embed({ data: [opReturnBuffer] }).output const tx = makeTXbuilder() tx.addOutput(nullOutput, 0) return tx.buildIncomplete() } // type bitcoin.payments.p2data bitcoin.payments.embed /** * @ignore */ export function makeNameImportSkeleton(name: string, recipientAddr: string, zonefileHash: string) { /* Format: 0 2 3 39 |----|--|-----------------------------| magic op name.ns_id (37 bytes) Output 0: the OP_RETURN Output 1: the recipient Output 2: the zonefile hash */ if (zonefileHash.length !== 40) { throw new Error('Invalid zonefile hash: must be 20 bytes hex-encoded') } const network = config.network const opReturnBuffer = Buffer.alloc(3 + name.length) opReturnBuffer.write(opEncode(';'), 0, 3, 'ascii') opReturnBuffer.write(name, 3, name.length, 'ascii') const nullOutput = payments.embed({ data: [opReturnBuffer] }).output const tx = makeTXbuilder() const zonefileHashB58 = bjsAddress.toBase58Check( Buffer.from(zonefileHash, 'hex'), network.layer1.pubKeyHash ) tx.addOutput(nullOutput, 0) tx.addOutput(recipientAddr, DUST_MINIMUM) tx.addOutput(zonefileHashB58, DUST_MINIMUM) return tx.buildIncomplete() } /** * @ignore */ export function makeAnnounceSkeleton(messageHash: string) { /* Format: 0 2 3 23 |----|--|-----------------------------| magic op message hash (160-bit) output 0: the OP_RETURN */ if (messageHash.length !== 40) { throw new Error('Invalid message hash: must be 20 bytes hex-encoded') } const opReturnBuffer = Buffer.alloc(3 + messageHash.length / 2) opReturnBuffer.write(opEncode('#'), 0, 3, 'ascii') opReturnBuffer.write(messageHash, 3, messageHash.length / 2, 'hex') const nullOutput = payments.embed({ data: [opReturnBuffer] }).output const tx = makeTXbuilder() tx.addOutput(nullOutput, 0) return tx.buildIncomplete() } /** * @ignore */ export function makeTokenTransferSkeleton(recipientAddress: string, consensusHash: string, tokenType: string, tokenAmount: BN, scratchArea: string ) { /* Format: 0 2 3 19 38 46 80 |-----|--|--------------|----------|-----------|-------------------------| magic op consensus_hash token_type amount (BE) scratch area (ns_id) output 0: token transfer code output 1: recipient address */ if (scratchArea.length > 34) { throw new Error('Invalid scratch area: must be no more than 34 bytes') } const opReturnBuffer = Buffer.alloc(46 + scratchArea.length) const tokenTypeHex = Buffer.from(tokenType).toString('hex') const tokenTypeHexPadded = `00000000000000000000000000000000000000${tokenTypeHex}`.slice(-38) const tokenValueHex = tokenAmount.toString(16, 2) if (tokenValueHex.length > 16) { // exceeds 2**64; can't fit throw new Error(`Cannot send tokens: cannot fit ${tokenAmount.toString()} into 8 bytes`) } const tokenValueHexPadded = `0000000000000000${tokenValueHex}`.slice(-16) opReturnBuffer.write(opEncode('$'), 0, 3, 'ascii') opReturnBuffer.write(consensusHash, 3, consensusHash.length / 2, 'hex') opReturnBuffer.write(tokenTypeHexPadded, 19, tokenTypeHexPadded.length / 2, 'hex') opReturnBuffer.write(tokenValueHexPadded, 38, tokenValueHexPadded.length / 2, 'hex') opReturnBuffer.write(scratchArea, 46, scratchArea.length, 'ascii') const nullOutput = payments.embed({ data: [opReturnBuffer] }).output const tx = makeTXbuilder() tx.addOutput(nullOutput, 0) tx.addOutput(recipientAddress, DUST_MINIMUM) return tx.buildIncomplete() }