aladinnetwork-blockstack
Version:
The Aladin Javascript library for authentication, identity, and storage.
767 lines (608 loc) • 22.5 kB
text/typescript
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()
}