@reality.eth/reality-eth-lib
Version:
Tools for handling questions in the reality.eth fact verification platform
584 lines (520 loc) • 21.6 kB
JavaScript
'use strict;';
const BN = require('bn.js');
const BigNumber = require('bignumber.js');
const ethereumjs_abi = require('ethereumjs-abi')
const vsprintf = require("sprintf-js").vsprintf
const QUESTION_MAX_OUTCOMES = 128;
const TEMPLATE_MAX_PLACEHOLDERS = 128;
const marked = require('marked');
const DOMPurify = require('isomorphic-dompurify');
const { convert} = require('html-to-text');
marked.setOptions({headerIds: false});
exports.delimiter = function() {
return '\u241f'; // Thought about '\u0000' but it seems to break something;
}
exports.contentHash = function(template_id, opening_ts, content) {
return "0x" + ethereumjs_abi.soliditySHA3(
["uint256", "uint32", "string"],
[ new BN(template_id), new BN(opening_ts), content]
).toString('hex');
}
exports.questionID = function(template_id, question, arbitrator, timeout, opening_ts, sender, nonce, min_bond, contract, version) {
// If there's a min_bond or a contract, insist we also get a version parameter
if (typeof version === 'undefined') {
if (typeof min_bond === 'string' || typeof contract === 'string') {
throw Error("Version not defined");
}
}
if (!version) {
version = '2.0';
}
const vernum = parseInt(version);
if (isNaN(vernum) || vernum <2 || vernum > 4) {
throw Error("Version not recognized");
}
if (vernum >= 3) {
if (typeof min_bond !== 'string') {
throw Error('min_bond not supplied or invalid. Required in v3. Pass "0x0" for a zero bond')
}
}
var content_hash = module.exports.contentHash(template_id, opening_ts, question);
let qid;
if (vernum < 3) {
qid = ethereumjs_abi.soliditySHA3(
//["bytes32", "address", "uint256", "address", "uint256"],
["uint256", "address", "uint32", "address", "uint256"],
[ new BN(content_hash.replace(/^0x/, ''), 16), arbitrator, new BN(timeout), sender, new BN(nonce)]
);
} else {
// bytes32 question_id = keccak256(abi.encodePacked(content_hash, arbitrator, timeout, uint256(min_bond), address(this), msg.sender, nonce));
qid = ethereumjs_abi.soliditySHA3(
["uint256", "address", "uint32", "uint256", "address", "address", "uint256"],
[ new BN(content_hash.replace(/^0x/, ''), 16), arbitrator, new BN(timeout), new BN(min_bond.replace(/^0x/, ''), 16), contract, sender, new BN(nonce)]
);
}
// The seems to be something wrong with how soliditySHA3 handles bytes32, so tell it we gave it uint256
return "0x" + qid.toString('hex');
}
exports.minNumber = function(qjson) {
var is_signed = (qjson['type'] == 'int');
if (!is_signed) {
return new BigNumber(0);
}
return module.exports.maxNumber(qjson).neg();
}
exports.maxNumber = function(qjson) {
var is_signed = (qjson['type'] == 'int');
var divby = new BigNumber(1);
if (qjson['decimals']) {
divby = new BigNumber(10).pow(new BigNumber(qjson['decimals']));
}
if (is_signed) {
divby = divby.times(2);
}
return new BigNumber("0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff").dividedBy(divby);
}
exports.arrayToBitmaskBigNumber = function(selections) {
// console.log('selections are ', selections);
var bitstr = '';
for (var i=0; i<selections.length; i++) {
var item = selections[i] ? '1' : '0';
bitstr = item + bitstr;
}
//console.log('bitstr',bitstr);
return new BigNumber(bitstr, 2);
}
exports.answerToBytes32 = function(answer, qjson) {
var qtype = qjson['type'];
if (qtype == 'hash') {
return module.exports.bytes32ToString(answer, qjson);
}
if (qtype == 'multiple-select') {
answer = module.exports.arrayToBitmaskBigNumber(answer);
}
var decimals = (qtype == 'uint') ? parseInt(qjson['decimals']) : 0;
if (!decimals) {
decimals = 0;
}
if (decimals > 0) {
var multiplier = new BigNumber(10).pow(new BigNumber(decimals));
answer = new BigNumber(answer).times(multiplier).toString(16);
} else {
answer = new BigNumber(answer).toString(16);
}
//console.log('muliplied to ',answer.toString());
var bn;
if (qtype == 'int') {
bn = new BN(answer, 16).toTwos(256);
} else if (qtype == 'uint') {
bn = new BN(answer, 16);
} else {
return module.exports.padToBytes32(new BigNumber(answer).toString(16));
}
return module.exports.padToBytes32(bn.toString(16));
}
exports.bytes32ToString = function(bytes32str, qjson) {
var qtype = qjson['type'];
var decimals = parseInt(qjson['decimals']);
if (!decimals) {
decimals = 0;
}
bytes32str = bytes32str.replace(/^0x/, '');
var bn;
if (qtype == 'int') {
var bn = new BN(bytes32str, 16).fromTwos(256);
} else if (qtype == 'uint' || qtype == 'datetime') {
var bn = new BN(bytes32str, 16);
} else if (qtype == 'hash') {
var bn = new BN(bytes32str, 16);
return module.exports.padToBytes32(bn.toString('hex')).toLowerCase();
} else {
throw Error("Unrecognized answer type " + qtype);
}
var ans = bn.toString();
// Do the decimals with BigNumber as it seems to work better
if (decimals > 0) {
var multiplier = new BigNumber(10).pow(new BigNumber(decimals));
ans = new BigNumber(ans).dividedBy(multiplier).toString();
}
return ans.toString();
}
exports.padToBytes32 = function(n, raw) {
while (n.length < 64) {
n = "0" + n;
}
if (raw) {
return n;
}
return "0x" + n;
}
exports.convertTsToString = function(ts) {
if (typeof ts.toNumber === 'function') {
ts = ts.toNumber();
}
let date = new Date();
date.setTime(ts * 1000);
return date.toISOString();
}
exports.secondsTodHms = function(sec) {
sec = Number(sec);
let d = Math.floor(sec / (3600 * 24));
let h = Math.floor(sec % (3600 * 24) / 3600);
let m = Math.floor(sec % (3600 * 24) % 3600 / 60);
let s = Math.floor(sec % (3600 * 24) % 3600 % 60);
let dDisplay = d > 0 ? d + (d == 1 ? " day " : " days ") : "";
let hDisplay = h > 0 ? h + (h == 1 ? " hour " : " hours ") : "";
let mDisplay = m > 0 ? m + (m == 1 ? " minute " : " minutes ") : "";
let sDisplay = s > 0 ? s + (s == 1 ? " second" : " seconds") : "";
return dDisplay + hDisplay + mDisplay + sDisplay;
}
exports.parseQuestionJSON = function(data, errors_to_title) {
// Strip unicode null-terminated-string control characters if there are any.
// These seem to be stripped already if we got data via The Graph, and only passed to us on RPC.
data = data.replace(/\u0000/g, "");
var question_json;
try {
question_json = JSON.parse(data);
} catch(e) {
question_json = {
'title': '[Badly formatted question]: ' + data,
'type': 'broken-question',
'errors': {"json_parse_failed": true}
};
}
if (question_json['outcomes'] && question_json['outcomes'].length > QUESTION_MAX_OUTCOMES) {
if (!question_json['errors']) question_json['errors'] = {};
question_json['errors']['too_many_outcomes'] = true;
}
if ('type' in question_json && question_json['type'] == 'datetime' && 'precision' in question_json) {
if (!(['Y', 'm', 'd', 'H', 'i', 's'].includes(question_json['precision']))) {
if (!question_json['errors']) question_json['errors'] = {};
question_json['errors']['invalid_precision'] = true;
}
}
// If errors_to_title is specified, we add any error message to the title to make sure we don't lose it
if (errors_to_title) {
if ('errors' in question_json) {
const prependers = {
'invalid_precision': 'Invalid date format',
'too_many_outcomes': 'Too many outcomes'
}
for (const e in question_json['errors']) {
if (e in prependers) {
question_json['title'] = '['+prependers[e]+'] ' + question_json['title'];
}
}
}
}
if (!question_json['format']) {
question_json['format'] = 'text/plain';
}
if (question_json['format'] == 'text/plain') {
question_json['title_text'] = question_json['title'];
} else if (question_json['format'] == 'text/markdown') {
try{
const safeMarkdown = DOMPurify.sanitize(question_json['title'], { USE_PROFILES: {html: false}});
if (safeMarkdown !== question_json['title']) {
if (!question_json['errors']) question_json['errors'] = {};
question_json['errors']['unsafe_markdown'] = true;
} else {
question_json['title_html'] = marked.parse(safeMarkdown).replace(/<img.*src=\"(.*?)\".*alt=\"(.*?)\".*\/?>/, '<a href="$1">$2</a>');
question_json['title_text'] = convert(question_json['title_html'], {
selectors: [
{selector: 'h1', options: { uppercase: false }},
{selector: 'h2', options: { uppercase: false }},
{selector: 'hr', format: 'skip'}
]
});
}
} catch(e){
if (!question_json['errors']) question_json['errors'] = {};
question_json['errors']['markdown_parse_failed'] = true
}
} else {
if (!question_json['errors']) question_json['errors'] = {};
question_json['errors']['invalid_format'] = true;
}
// If errors_to_title is specified, we add any error message to the title to make sure we don't lose it
if (errors_to_title) {
if ('errors' in question_json) {
const prependers = {
'invalid_format': 'Invalid format',
'unsafe_markdown': 'Unsafe markdown',
'markdown_parse_failed': 'Bad markdown parse'
}
for (const e in question_json['errors']) {
if (e in prependers) {
question_json['title'] = '['+prependers[e]+'] ' + question_json['title'];
}
}
}
}
return question_json;
}
exports.populatedJSONForTemplate = function(template, question, errors_to_title) {
var qbits = question.split(module.exports.delimiter());
//console.log('pp', template);
//console.log('qbits', qbits);
var interpolated = vsprintf(template, qbits);
//console.log('resulting template', interpolated);
return module.exports.parseQuestionJSON(interpolated, errors_to_title);
}
// Encode text, assuming the template has placeholders in the order specified in the params.
// Additional information about the template can be passed in as template_definitions.
// If not specified, we'll assume it works like our standard built-in templates.
exports.encodeCustomText = function(params) {
var items = [];
for (const p in params) {
var val = params[p];
if (typeof val === 'string') {
// Stringify puts quotation marks around the string, so strip them
val = JSON.stringify(val).replace(/^"|"$/g, '');
} else if (typeof val === 'object') {
// An array of values should be stringified as a JSON array.
// However template should do "outcomes: [%s]" instead of "outcomes: %s".
// In that case strip the closing brackets.
val = JSON.stringify(val).replace(/^\[/, '').replace(/\]$/, '');
} else {
val = null;
}
items.push(val);
}
const ret = items.join(module.exports.delimiter());
return ret;
}
exports.guessTemplateConfig = function(template) {
// Use the hash of the template as a temporary placeholder
// Since you can't hash yourself we can be confident this won't collide.
const placeholder = '0x' + ethereumjs_abi.soliditySHA3(['string'], [template]).toString('hex');
const arr_placeholder = '0x' + ethereumjs_abi.soliditySHA3(['string'], [template + "_arr"]).toString('hex');
var pl_arr = new Array(TEMPLATE_MAX_PLACEHOLDERS);
pl_arr.fill(placeholder);
var interpolated = vsprintf(template, pl_arr);
// Quote the placeholders in any arrays that didn't originally have quotes
interpolated = interpolated.replaceAll('['+placeholder+']', '"'+arr_placeholder+'"');
const fake_json = module.exports.parseQuestionJSON(interpolated, false);
/*
Meta example:
{
'labels': {'dog': 'Dogs', 'cat': 'Cats'},
'tags': {'2': 'dog'}
'defaults': {'lang': 'en_US'}
}
*/
// If there's a section called __META, use that for titling columns etc
var meta = '__META' in fake_json ? fake_json['__META'] : {};
var labels = 'labels' in meta ? meta['labels'] : {};
var fields = {};
for (const k in fake_json) {
if (k == 'title_text' || k == 'title_html' || k == '__META') {
// Fields we add automatically
continue;
}
// We assume that every placeholder is either the value for a field, or contained in it.
// ie we handle
// "myfield": "%s"
// "myfield": "A %s and another %s."
// "myfield": [%s]
var fdef = {};
fdef['label'] = (k in labels) ? labels[k] : k;
const regexp = new RegExp('('+placeholder+'|'+arr_placeholder+')');
//const regexp = new RegExp('('+placeholder+')');
const bits = fake_json[k].split(regexp);
// console.log(bits);
var parts = [];
var part_i = 0;
for(const b in bits) {
if (bits[b] == '') {
continue;
}
if (bits[b] == placeholder || bits[b] == arr_placeholder) {
let part_label = k + '_' + part_i;
if (part_label in labels) {
part_label = labels[part_label];
}
if (bits[b] == placeholder) {
parts.push({'part': 'parameter', 'part_index': part_i, 'label': part_label})
} else {
parts.push({'part': 'array_parameter', 'part_index': part_i, 'label': part_label})
}
part_i++;
} else {
parts.push({'part': 'text', 'value': bits[b]});
}
}
fdef['parts'] = parts;
fields[k] = fdef;
}
var tags = {};
if ('tags' in meta) {
tags = meta['tags']
}
return {
'fields': fields,
'tags': tags
};
}
exports.encodeText = function(qtype, txt, outcomes, category, lang) {
var def = {};
def['title'] = txt;
if (qtype == 'single-select' || qtype == 'multiple-select') {
def['outcomes'] = outcomes;
}
def['category'] = category;
def['lang'] = lang;
return module.exports.encodeCustomText(def);
}
// A value used to denote that the question is invalid or can't be answered
exports.getInvalidValue = function(question_json) {
// equivalent to -1 in twos complement
return '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff';
}
// A value used to denote that the question is invalid or can't be answered
exports.getAnsweredTooSoonValue = function(question_json) {
// equivalent to -2 in twos complement
return '0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe';
}
exports.getLanguage = function(question_json) {
if ( typeof question_json['lang'] == 'undefined' || question_json['lang'] == '') {
return 'en_US';
}
return question_json['lang'];
}
exports.hasInvalidOption = function(question_json, contract_version) {
return !('has_invalid' in question_json && !question_json['has_invalid']);
}
exports.hasAnsweredTooSoonOption = function(question_json, contract_version) {
const bits = contract_version.split('.');
return (parseInt(bits[0]) >= 3);
}
exports.getAnswerString = function(question_json, answer) {
if (answer === null) {
return 'null';
}
if (answer == module.exports.getInvalidValue(question_json)) {
return 'Invalid';
}
if (answer == module.exports.getAnsweredTooSoonValue(question_json)) {
return 'Answered too soon';
}
var label = '';
switch (question_json['type']) {
case 'uint':
label = module.exports.bytes32ToString(answer, question_json);
break;
case 'int':
label = module.exports.bytes32ToString(answer, question_json);
break;
case 'hash':
label = module.exports.bytes32ToString(answer, question_json);
break;
case 'bool':
if (new BigNumber(answer).toNumber() === 1) {
label = 'Yes';
} else if (new BigNumber(answer).toNumber() === 0) {
label = 'No';
}
break;
case 'single-select':
if (typeof question_json['outcomes'] !== 'undefined' && question_json['outcomes'].length > 0) {
var idx = new BigNumber(answer).toNumber();
label = question_json['outcomes'][idx];
}
break;
case 'multiple-select':
if (typeof question_json['outcomes'] !== 'undefined' && question_json['outcomes'].length > 0) {
var answer_bits = new BigNumber(answer).toString(2);
var length = answer_bits.length;
var entries = [];
for (var i = question_json['outcomes'].length - 1; i >= 0; i--) {
if (answer_bits[i] === '1') {
var idx = answer_bits.length - 1 - i;
entries.push(question_json['outcomes'][idx]);
}
}
return entries.join(' / ');
}
break;
case 'datetime':
let precision = 'd';
if ('precision' in question_json && ['Y', 'm', 'd', 'H', 'i', 's'].includes(question_json['precision'])) {
precision = question_json['precision'];
}
let ts = parseInt(module.exports.bytes32ToString(answer, question_json));
let dateObj = new Date(ts * 1000);
const year = dateObj.getUTCFullYear();
const month = dateObj.getUTCMonth() + 1;
const date = dateObj.getUTCDate();
const hour = dateObj.getUTCHours();
const min = dateObj.getUTCMinutes();
const sec = dateObj.getUTCSeconds();
// We need whatever the precision states, plus anything above
const needy = true;
const needm = (precision != 'Y');
const needd = needm && (precision != 'm');
const needH = needd && (precision != 'd');
const needi = needH && (precision != 'H');
const needs = needi && (precision != 'i');
// If anything is set then we've got it.
// We also consider that anything required by the precision is there, but set to 0
const hass = needs || sec > 0;
const hasi = needi || hass || min > 0;
const hasH = needH || hasi || hour > 0;
const hasd = needd || hasH || date > 1;
const hasm = needm || hasd || month > 1;
const hasy = true;
// We'll show an invalid warning if we've got a more precise date than the precision demands
let invalid = false;
if (!needm && hasm) invalid = true;
if (!needd && hasd) invalid = true;
if (!needH && hasH) invalid = true;
if (!needi && hasi) invalid = true;
if (!needs && hass) invalid = true;
if (invalid) {
label = '[Invalid datetime]: ';
}
function pad2(n) { return ("0" + n).slice(-2); }
if (hasy) {
label += year;
}
if (hasm) {
label += '-'+pad2(month);
}
if (hasd) {
label += '-'+pad2(date);
}
if (hasH) {
label += ' '+pad2(hour);
}
if (hasi) {
label += ':'+pad2(min);
} else if (hasH) {
// "2021-12-23 11" without the minutes at the end is hard to understand so add "hr"
label += 'hr';
}
if (hass) {
label += ':'+pad2(sec);
}
break;
}
return label;
}
exports.commitmentID = function(question_id, answer_hash, bond) {
const bond_hex = (typeof(bond) === 'string') ? bond : bond.toString(16);
return "0x" + ethereumjs_abi.soliditySHA3(
["uint256", "uint256", "uint256"],
[ new BN(question_id.replace(/^0x/, ''), 16), new BN(answer_hash.replace(/^0x/, ''), 16), new BN(bond_hex.replace(/^0x/, ''), 16)]
).toString('hex');
}
exports.answerHash = function(answer_plaintext, nonce) {
return "0x" + ethereumjs_abi.soliditySHA3(
["uint256", "uint256"],
[ new BN(answer_plaintext.replace(/^0x/, ''), 16), new BN(nonce.replace(/^0x/, ''), 16)]
).toString('hex');
}
exports.shortDisplayQuestionID = function(question_id) {
// Question ID may or may not have the contract address prepended
const bits = question_id.split('-');
const qid = bits[bits.length-1];
return qid.substring(2,9) + '...' + qid.slice(-7);
}