factom
Version:
Library to build applications on the Factom blockchain
490 lines (436 loc) • 16.5 kB
JavaScript
const naclSign = require('tweetnacl/nacl-fast').sign,
{ addressToKey, isValidPrivateEcAddress, isValidPublicEcAddress } = require('./addresses'),
{ MAX_ENTRY_PAYLOAD_SIZE } = require('./constant'),
{ sha256, sha512 } = require('./util');
/**********************
* Entry class
**********************/
/**
* Class representing an Entry.
* @param {EntryBuilder} builder
* @property {Buffer} chainId - Chain ID.
* @property {Buffer[]} extIds - External IDs.
* @property {Buffer} content - Content.
* @property {number} timestamp - Timestamp in milliseconds for the commit.
* @property {EntryBlockContext} blockContext - Block context. This property is *not* populated when using the method getEntry.
* @example
const myEntry = Entry.builder()
.chainId('9107a308f91fd7962fecb321fdadeb37e2ca7d456f1d99d24280136c0afd55f2')
.extId('6d79206578742069642031') // If no encoding parameter is passed as 2nd argument, 'hex' is used as default
.extId('Some external ID', 'utf8')
.content('My new content', 'utf8')
.build();
*/
class Entry {
constructor(builder) {
if (builder instanceof EntryBuilder) {
this.chainId = builder._chainId;
this.content = builder._content;
this.timestamp = builder._timestamp;
this.extIds = Object.freeze(builder._extIds);
this.blockContext = Object.freeze(builder._blockContext);
Object.freeze(this);
} else {
throw new Error('Use `Entry.builder()` syntax to create a new Entry');
}
}
/**
* @returns {string} Chain ID of the entry as hex encoded string.
*/
get chainIdHex() {
return this.chainId.toString('hex');
}
/**
* @returns {string} Entry content as hex encoded string.
*/
get contentHex() {
return this.content.toString('hex');
}
/**
* @returns {string[]} External ids as hex encoded strings.
*/
get extIdsHex() {
return this.extIds.map((extId) => extId.toString('hex'));
}
/**
* Get the entry size.
* @returns {number} The entry size in bytes.
*/
size() {
// Header size is 35 for the first 127 versions
return 35 + this.payloadSize();
}
/**
* Get the entry payload size (excluding the header).
* @returns {number} The entry payload size in bytes.
*/
payloadSize() {
return this.rawDataSize() + 2 * this.extIds.length;
}
/**
* Get the entry raw data size (payload size excluding the 2 byte overhead per extID).
* @returns {number} The entry raw size in bytes.
*/
rawDataSize() {
return this.content.length + this.extIds.reduce((acc, value) => acc + value.length, 0);
}
/**
* Get the number of bytes that can be added to the entry for the same EC cost.
* @returns {number} Remaining number of free bytes.
*/
remainingFreeBytes() {
const size = this.payloadSize();
if (size === 0) {
return 1024;
}
const remainder = size % 1024;
return remainder ? 1024 - remainder : 0;
}
/**
* Get the number of bytes that can be added to the entry before hitting the maximum (10kb).
* @returns {number} Maximum number of bytes that can still be added to the entry.
*/
remainingMaxBytes() {
const remainingMaxBytes = MAX_ENTRY_PAYLOAD_SIZE - this.payloadSize();
if (remainingMaxBytes < 0) {
throw new Error('Entry cannot be larger than 10Kb');
}
return remainingMaxBytes;
}
/**
* Get hash of the entry.
* @returns {Buffer} Hash of the entry.
*/
hash() {
const data = this.marshalBinary();
return sha256(Buffer.concat([sha512(data), data]));
}
/**
* @returns {string} Hash of the entry as hex encoded string.
*/
hashHex() {
return this.hash().toString('hex');
}
/**
* @returns {Buffer} Result of marshaling the entry.
*/
marshalBinary() {
if (this.chainId.length === 0) {
throw new Error('ChainId is missing to marshal the entry');
}
const externalIds = marshalExternalIdsBinary(this.extIds);
const header = marshalHeaderBinary(this.chainId, externalIds.length);
return Buffer.concat([header, externalIds, this.content]);
}
/**
* @returns {string} Result of marshaling the entry as hex encoded string.
*/
marshalBinaryHex() {
return this.marshalBinary().toString('hex');
}
/**
* Get Entry Credit cost of the entry.
* @returns {number} EC cost of the entry.
*/
ecCost() {
const dataLength = this.payloadSize();
if (dataLength > MAX_ENTRY_PAYLOAD_SIZE) {
throw new Error('Entry cannot be larger than 10Kb');
}
return Math.ceil(dataLength / 1024);
}
/**
* Convert to a JavaScript Object representation of the entry. Can be used as argument of {@link EntryBuilder}.
* @returns {Object} JavaScript object representing the entry.
*/
toObject() {
const o = {
chainId: this.chainIdHex,
extIds: this.extIdsHex,
content: this.contentHex,
};
if (this.timestamp) {
o.timestamp = this.timestamp;
}
return o;
}
/**
* Entry builder static factory.
* @param {Entry} [entry] - Optional entry to use to initialize the attributes of the builder.
* @returns {EntryBuilder} A new EntryBuilder.
*/
static builder(entry) {
return new EntryBuilder(entry);
}
}
/**********************
* Entry builder class
**********************/
/**
* Class to build an {@link Entry}
* @param {Entry|Object} [entry] - Optional entry to use to initialize the attributes of the builder.
*/
class EntryBuilder {
constructor(entry) {
if (entry instanceof Object) {
this._chainId = entry.chainId ? Buffer.from(entry.chainId, 'hex') : Buffer.from('');
this._extIds = Array.isArray(entry.extIds)
? entry.extIds.map((extId) => Buffer.from(extId, 'hex'))
: [];
this._content = entry.content ? Buffer.from(entry.content, 'hex') : Buffer.from('');
this._timestamp = entry.timestamp;
} else {
this._extIds = [];
this._content = Buffer.from('');
this._chainId = Buffer.from('');
}
}
/**
* Set content.
* @param {string|Buffer} content | Content.
* @param {string} [enc=hex] - Encoding of the content if it is a string.
* @returns {EntryBuilder} - EntryBuilder instance.
*/
content(content, enc) {
if (content) {
this._content = Buffer.from(content, enc || 'hex');
}
return this;
}
/**
* Set chain ID.
* @param {string|Buffer} chainId - Chain ID.
* @param {string} [enc=hex] - Encoding of the chainId if it is a string.
* @returns {EntryBuilder} - EntryBuilder instance.
*/
chainId(chainId, enc) {
if (chainId) {
this._chainId = Buffer.from(chainId, enc || 'hex');
}
return this;
}
/**
* Set external IDs.
* @param {string[]|Buffer[]} extIds - External IDs.
* @param {string} [enc=hex] - Encoding of the external ids if they are strings.
* @returns {EntryBuilder} - EntryBuilder instance.
*/
extIds(extIds, enc) {
if (Array.isArray(extIds)) {
this._extIds = extIds.map((extId) => Buffer.from(extId, enc || 'hex'));
}
return this;
}
/**
* Add an external ID.
* @param {string|Buffer} extId - External ID.
* @param {string} [enc=hex] - Encoding of the external id if it is a string.
* @returns {EntryBuilder} - EntryBuilder instance.
*/
extId(extId, enc) {
if (extId) {
this._extIds.push(Buffer.from(extId, enc || 'hex'));
}
return this;
}
/**
* Set the timestamp for the entry commit.
* If not set the library will use Date.now() as the commit timestamp.
* @param {number} timestamp - Timestamp in milliseconds.
* @returns {EntryBuilder} - EntryBuilder instance.
*/
timestamp(timestamp) {
this._timestamp = timestamp;
return this;
}
/**
* Set block context. This method is used internally by the library to populate a block context,
* regular users should not have to use this.
* @param {EntryBlockContext} blockContext
* @returns {EntryBuilder} - EntryBuilder instance.
*/
blockContext(blockContext) {
this._blockContext = blockContext;
return this;
}
/**
* Build the Entry.
* @returns {Entry} - Built entry.
*/
build() {
return new Entry(this);
}
}
/**
* Block context of an {@link Entry}.
* @typedef {Object} EntryBlockContext
* @property {number} entryTimestamp - Epoch timestamp (in seconds) of the entry.
* @property {number} directoryBlockHeight - Directory Block height.
* @property {number} entryBlockTimestamp - Epoch timestamp (in seconds) of the Entry Block.
* @property {number} entryBlockSequenceNumber - Entry Block sequence number.
* @property {string} entryBlockKeyMR - Entry Block KeyMR.
*/
/**********************
* Marshal and compose
**********************/
function marshalHeaderBinary(chainId, extIdsSize) {
const header = Buffer.alloc(35);
header.writeInt8(0);
chainId.copy(header, 1);
header.writeInt16BE(extIdsSize, 33);
return header;
}
function marshalExternalIdsBinary(extIds) {
const result = [];
for (let extId of extIds) {
const size = Buffer.alloc(2);
size.writeInt16BE(extId.length);
result.push(size);
result.push(extId);
}
return Buffer.concat(result);
}
/**
* Compose the commit of an Entry, that can then be used as input of the factomd API `commit-entry`.
* Note that if the Entry doesn't have a timestamp set the library will use Date.now() as the default for the commit timestamp.
* @param {Entry} entry - Entry to compose the commit of.
* @param {string} ecAddress - Private Entry Credit address that pays for and sign the commit.
* @param {string} signature - Deprecated. Use {@link composeChainCommitDelegateSig} instead.
* @returns {Buffer} - Entry commit.
*/
function composeEntryCommit(entry, ecAddress, signature) {
validateEntryInstance(entry);
const dataToSign = composeEntryLedger(entry);
let ecPublicKey, sig;
if (isValidPrivateEcAddress(ecAddress)) {
// Sign commit
const secret = addressToKey(ecAddress);
const key = naclSign.keyPair.fromSeed(secret);
ecPublicKey = Buffer.from(key.publicKey);
sig = Buffer.from(naclSign.detached(dataToSign, key.secretKey));
} else if (isValidPublicEcAddress(ecAddress)) {
// Verify the signature manually provided
if (!signature) {
throw new Error('Signature of the commit missing.');
}
console.warn(
'composeEntryCommit with signature is deprecated. Use composeEntryCommitDelegateSig.'
);
ecPublicKey = addressToKey(ecAddress);
sig = Buffer.from(signature, 'hex');
if (!naclSign.detached.verify(dataToSign, sig, ecPublicKey)) {
throw new Error(
'Invalid signature manually provided for the entry commit. (entry timestamp not fixed?)'
);
}
} else {
throw new Error(`${ecAddress} is not a valid EC address`);
}
return Buffer.concat([dataToSign, ecPublicKey, sig]);
}
/**
* Compose the commit of an Entry delegating the signature.
* The commit can then be sent through factomd API `commit-entry`.
* @param {Entry} entry - Entry to compose the commit of.
* @param {string} ecPublicAddress - Public Entry Credit address that pays for the commit.
* @param {function(Buffer, string): (Buffer | string | Promise<Buffer | string>)} sign - Signing function.
* Takes as input the data to sign with the EC public key paying for the commmit
* and should return its signature as a Buffer or a hex encoded string (or a Promise of those).
* The returned signature must have been made by the private key corresponding to the ecPublicAddress argument.
* @returns {Buffer} - Entry commit.
* @async
*/
async function composeEntryCommitDelegateSig(entry, ecPublicAddress, sign) {
validateEntryInstance(entry);
if (!isValidPublicEcAddress(ecPublicAddress)) {
throw new Error(`${ecPublicAddress} is not a valid public EC address`);
}
if (typeof sign !== 'function') {
throw new Error('sign must be a function');
}
const dataToSign = composeEntryLedger(entry);
const signature = Buffer.from(await sign(dataToSign, ecPublicAddress), 'hex');
const ecPublicKey = addressToKey(ecPublicAddress);
if (!naclSign.detached.verify(dataToSign, signature, ecPublicKey)) {
throw new Error('Invalid signature returned by the signing function for the entry commit.');
}
return Buffer.concat([dataToSign, ecPublicKey, signature]);
}
function composeEntryLedger(entry) {
validateEntryInstance(entry);
const buffer = Buffer.alloc(40);
buffer.writeInt8(0);
buffer.writeIntBE(entry.timestamp || Date.now(), 1, 6);
entry.hash().copy(buffer, 7);
buffer.writeInt8(entry.ecCost(), 39);
return buffer;
}
/**
* Compose the reveal of an Entry, that can then be used as input of the factomd API `reveal-entry`.
* @param {Entry} entry - Entry to compose the reveal of.
* @returns {Buffer} - Entry reveal.
*/
function composeEntryReveal(entry) {
validateEntryInstance(entry);
return entry.marshalBinary();
}
/**
* Compose the commit and reveal of an Entry, that can then be used as inputs of the factomd APIs `commit-entry` and `reveal-entry`.
* @param {Entry} entry - Entry to compose the commit and reveal of.
* @param {string} ecAddress - Private Entry Credit address that pays for and sign the commit.
* @param {string} signature - Deprecated. Use {@link composeEntryDelegateSig} instead.
* @returns {{commit:Buffer, reveal:Buffer}} - Entry commit and reveal.
*/
function composeEntry(entry, ecAddress, signature) {
return {
commit: composeEntryCommit(entry, ecAddress, signature),
reveal: composeEntryReveal(entry),
};
}
/**
* Compose the commit and reveal of an Entry using an external signing function.
* The result can then be used as inputs of the factomd APIs `commit-entry` and `reveal-entry`.
* @param {Entry} entry - Entry to compose the commit and reveal of.
* @param {string} ecPublicAddress - Public Entry Credit address that pays for the commit.
* @param {function(Buffer, sign): (Buffer | string | Promise<Buffer | string>)} sign - Signing function.
* Takes as input the data to sign with the EC public key paying for the commmit
* and should return its signature as a Buffer or a hex encoded string (or a Promise of those).
* The returned signature must have been made by the private key corresponding to the ecPublicAddress argument.
* @returns {{commit:Buffer, reveal:Buffer}} - Entry commit and reveal.
* @async
*/
async function composeEntryDelegateSig(entry, ecPublicAddress, sign) {
return {
commit: await composeEntryCommitDelegateSig(entry, ecPublicAddress, sign),
reveal: composeEntryReveal(entry),
};
}
/**********************
* Other functions
**********************/
function validateEntryInstance(entry) {
if (!(entry instanceof Entry)) {
throw new Error('Argument must be an instance of Entry');
}
}
/**
* Compute the transaction ID of the Entry commit. The transaction ID is dependent on the timestamp set in the entry.
* Note that if the timestamp is not set the library uses Date.now() as the default, changing the result of this function if called at different times.
* @param {Entry} entry
* @returns {Buffer} - The transaction id of the Entry commit.
*/
function computeEntryTxId(entry) {
validateEntryInstance(entry);
return sha256(composeEntryLedger(entry));
}
module.exports = {
Entry,
computeEntryTxId,
validateEntryInstance,
composeEntryCommit,
composeEntryCommitDelegateSig,
composeEntryReveal,
composeEntry,
composeEntryDelegateSig,
composeEntryLedger,
};