bcp-js
Version:
BlockChain Payloads - OP_RETURN saved pointers to data
322 lines (274 loc) • 9.03 kB
JavaScript
const BN = require('bignumber.js')
const OP_0 = 0x00
const OP_RETURN = 0x6a
const OP_PUSHDATA1 = 0x4c
const OP_PUSHDATA2 = 0x4d
const OP_PUSHDATA4 = 0x4e
const BCP_TYPE_GENERIC = 0x01
const BCP_TYPE_TEXT = 0x02
const BCP_TYPE_IMAGE = 0x03
const BCP_TYPE_AUDIO = 0x04
const BCP_TYPE_VIDEO = 0x05
const BCP_TYPE_JSON = 0x06
const BCP_SRC_NONE = 0x01
const BCP_SRC_TXID = 0x02
const BCP_SRC_URL = 0x03
const BCP_SRC_IPFS = 0x04
const BCP_SRC_ADDR = 0x05
function pushdata(buf) {
if (buf.length === 0) {
return Buffer.from([0x4c, 0x00])
} else if (buf.length < 0x4e) {
return Buffer.concat([Buffer.from([buf.length]), buf])
} else if (buf.length < 0xff) {
return Buffer.concat([Buffer.from([0x4c, buf.length]), buf])
} else if (buf.length < 0xffff) {
const tmp = Buffer.allocUnsafe(2)
tmp.writeUInt16LE(buf.length, 0)
return Buffer.concat([Buffer.from([0x4d]), tmp, buf])
} else if (buf.length < 0xffffffff) {
const tmp = Buffer.allocUnsafe(4)
tmp.writeUInt32LE(buf.length, 0)
return Buffer.concat([Buffer.from([0x4e]), tmp, buf])
}
throw new Error('does not support bigger pushes yet')
}
function BNToInt64BE(bn) {
if (!bn.isInteger()) throw new Error('bn not an integer')
if (!bn.isPositive()) throw new Error('bn not positive integer')
const h = bn.toString(16)
if (h.length > 16) throw new Error('bn outside of range')
return Buffer.from(h.padStart(16, '0'), 'hex')
}
const PARSE_CHECK = (v, str) => {
if (v) throw Error(str)
}
const checkValidTokenId = tokenId => tokenId.length === 32
const validPayload = (source, data) => {
if (typeof data === 'string') {
if (source === BCP_SRC_TXID) return Buffer.from(data, 'hex')
else if ([BCP_SRC_NONE, BCP_SRC_URL, BCP_SRC_IPFS, BCP_SRC_ADDR].includes(source)) return Buffer.from(data)
}
return data
}
class BCP {
constructor(payload) {
this.TYPES = [
BCP_TYPE_GENERIC,
BCP_TYPE_TEXT,
BCP_TYPE_IMAGE,
BCP_TYPE_AUDIO,
BCP_TYPE_VIDEO,
BCP_TYPE_JSON,
]
this.SOURCES = [
BCP_SRC_NONE,
BCP_SRC_TXID,
BCP_SRC_URL,
BCP_SRC_IPFS,
BCP_SRC_ADDR
]
this.payloadType = 0x0
this.payloadSource = 0x0
this.payloadData = {}
this.itObj = Buffer.from('')
this.it = 0 // // position in itObj
this.cit = 0 // // position in chunks
this.chunks = []
}
CHECK_NEXT() {
++this.cit
PARSE_CHECK(this.cit === this.chunks.length, 'parsing ended early')
this.it = 0
this.itObj = this.chunks[this.cit]
}
extractU8() {
const r = this.itObj.readUInt8(this.it)
this.it += 1
return new BN(r)
}
extractU16() {
const r = this.itObj.readUInt16LE(this.it)
this.it += 2
return new BN(r)
}
extractU32() {
const r = this.itObj.readUInt32LE(this.it)
this.it += 4
return new BN(r)
}
extractU64() {
const r1 = this.itObj.readUInt32LE(this.it)
this.it += 4
const r2 = this.itObj.readUInt32LE(this.it)
this.it += 4
return new BN(r2).multipliedBy(2 ** 32).plus(r1)
}
bufferToBN() {
if (this.itObj.length === 1) return this.extractU8()
if (this.itObj.length === 2) return this.extractU16()
if (this.itObj.length === 4) return this.extractU32()
if (this.itObj.length === 8) return this.extractU64()
throw new Error('extraction of number from buffer failed')
}
extractPushdata() {
if (this.it === this.itObj.length) return -1
const cnt = this.extractU8().toNumber()
if (cnt > OP_0 && cnt < OP_PUSHDATA1) {
if (this.it + cnt > this.itObj.length) {
--this.it
return -1
}
return cnt
} else if (cnt === OP_PUSHDATA1) {
if (this.it + 1 >= this.itObj.length) {
--this.it
return -1
}
return this.extractU8().toNumber()
} else if (cnt === OP_PUSHDATA2) {
if (this.it + 2 >= this.itObj.length) {
--this.it
return -1
}
return this.extractU16().toNumber()
} else if (cnt === OP_PUSHDATA4) {
if (this.it + 4 >= this.itObj.length) {
--this.it
return -1
}
return this.extractU32().toNumber()
}
// other opcodes not allowed
--this.it
return -1
}
parse(payload) {
if (typeof payload === 'string') this.itObj = Buffer.from(payload, 'hex')
else this.itObj = payload // Buffer
this.it = 0 // position in this.itObj
this.chunks = [] // pushdata chunks
this.cit = 0 // position in chinks
PARSE_CHECK(this.itObj.length === 0, 'payload cannot be empty')
PARSE_CHECK(this.itObj[this.it] !== OP_RETURN, 'payload not op_return')
++this.it
for (let len = this.extractPushdata(); len >= 0; len = this.extractPushdata()) {
const buf = this.itObj.slice(this.it, this.it + len)
PARSE_CHECK(this.it + len > this.itObj.length, 'pushdata data extraction failed')
this.it += len
this.chunks.push(buf)
if (this.chunks.length === 1) {
const lokadIdStr = this.chunks[0]
PARSE_CHECK(lokadIdStr.length !== 4, 'lokad id wrong size')
PARSE_CHECK(
lokadIdStr[0] !== 'B'.charCodeAt(0) ||
lokadIdStr[1] !== 'C'.charCodeAt(0) ||
lokadIdStr[2] !== 'P'.charCodeAt(0) ||
lokadIdStr[3] !== 0x00, 'BCP not in first chunk')
}
}
PARSE_CHECK(this.it !== this.itObj.length, 'trailing data')
PARSE_CHECK(this.chunks.length === 0, 'chunks empty')
PARSE_CHECK(this.chunks.length < 4, 'wrong number of chunks')
this.CHECK_NEXT()
// Payload Type
const payloadTypeBuf = this.itObj.reverse()
PARSE_CHECK(payloadTypeBuf.length !== 1, 'payload_type string length must be 1')
this.payloadType = this.bufferToBN().toNumber()
PARSE_CHECK(!this.TYPES.includes(this.payloadType), 'invalid payload_type')
this.CHECK_NEXT()
// Payload Source
const payloadSourceBuf = this.itObj.reverse()
PARSE_CHECK(payloadSourceBuf.length !== 1, 'payload_string string length must be 1')
this.payloadSource = this.bufferToBN().toNumber()
PARSE_CHECK(!this.SOURCES.includes(this.payloadSource), 'invalid payload_source')
this.CHECK_NEXT()
const sourceDataBuf = this.itObj
this.payloadData = sourceDataBuf
// tx source: payload is txid
if (this.payloadSource === BCP_SRC_TXID) {
PARSE_CHECK(!checkValidTokenId(sourceDataBuf), 'invalid TxID')
this.payloadData = { tokenId: sourceDataBuf }
}
// tx source: payload is valid URL scheme://authority:path?query#fragment
if (this.payloadSource === BCP_SRC_URL) {
var pattern = RegExp("^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\\?([^#]*))?(#(.*))?")
var matches = sourceDataBuf.toString().match(pattern)
this.payloadData = {
url: sourceDataBuf,
parsed: Buffer.from(
JSON.stringify({
scheme: matches[2],
authority: matches[4],
path: matches[5],
query: matches[7],
fragment: matches[9]
})
)
}
}
if (this.payloadSource === BCP_SRC_ADDR) this.payloadData = { address: sourceDataBuf }
if (this.payloadSource === BCP_SRC_IPFS) {
PARSE_CHECK(sourceDataBuf.length !== 46, 'IPFS hash length must be 46')
PARSE_CHECK(!sourceDataBuf.toString().startsWith('Qm'), 'invalid IPFS hash (Qm...)')
this.payloadData = { hash: sourceDataBuf }
}
// TODO: add more src checks
return {
type: this.payloadType,
source: this.payloadSource,
data: this.payloadData
}
}
create(type, source, data) {
PARSE_CHECK(!this.TYPES.includes(type), 'invalid payload_type')
PARSE_CHECK(!this.SOURCES.includes(source), 'invalid payload_source')
const buf = Buffer.concat([
Buffer.from([0x6a]), // OP_RETURN
pushdata(Buffer.from('BCP\0')), // Lokad 00504342
pushdata(Buffer.from([type])),
pushdata(Buffer.from([source])),
pushdata(validPayload(source, data))
])
return buf
}
createAudio(source, data) {
return this.create(BCP_TYPE_AUDIO, source, data)
}
createVideo(source, data) {
return this.create(BCP_TYPE_VIDEO, source, data)
}
createImage(source, data) {
return this.create(BCP_TYPE_IMAGE, source, data)
}
createText(source, data) {
return this.create(BCP_TYPE_TEXT, source, data)
}
createChainText(data) {
return this.create(BCP_TYPE_TEXT, BCP_SRC_NONE, data)
}
createState(address, type = BCP_TYPE_GENERIC) {
return this.create(type, BCP_SRC_ADDR, address)
}
createFromString(str) {
const type = BCP_TYPE_TEXT
let source = BCP_SRC_NONE
if (str.length === 46 && str.startsWith('Qm')) source = BCP_SRC_IPFS
else if (checkValidTokenId(str)) source = BCP_SRC_TXID
return this.create(type, source, str)
}
}
module.exports = {
BCP,
BCP_TYPE_GENERIC,
BCP_TYPE_TEXT,
BCP_TYPE_IMAGE,
BCP_TYPE_AUDIO,
BCP_TYPE_VIDEO,
BCP_TYPE_JSON,
BCP_SRC_NONE,
BCP_SRC_TXID,
BCP_SRC_URL,
BCP_SRC_IPFS,
BCP_SRC_ADDR
}