Contract and client library improvements. (#184)

* Added tty check to contract libs.
* Javascript browser-native client.
* Removed hex encoding in user json outputs.
* Updated file contract for new contract library.
This commit is contained in:
Ravin Perera
2020-12-05 09:08:24 +05:30
committed by GitHub
parent a421f13d91
commit b2fd8ae4b5
16 changed files with 2967 additions and 125 deletions

View File

@@ -0,0 +1,294 @@
window.HotPocket = (() => {
const protocols = {
json: "json"
}
Object.freeze(protocols);
const events = {
disconnect: "disconnect",
contractOutput: "contractOutput",
contractReadResponse: "contractReadResponse"
}
Object.freeze(events);
const fromHexString = hexString =>
new Uint8Array(hexString.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
const toHexString = bytes =>
bytes.reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), '');
const KeyGenerator = {
generate: function (privateKeyHex = null) {
if (!privateKeyHex) {
const keys = sodium.crypto_sign_keypair();
return {
privateKey: keys.privateKey,
publicKey: keys.publicKey
}
}
else {
const binPrivateKey = fromHexString(privateKeyHex);
return {
privateKey: Uint8Array.from(binPrivateKey),
publicKey: Uint8Array.from(binPrivateKey.slice(32))
}
}
},
}
function EventEmitter() {
const registrations = {};
this.on = (eventName, listener) => {
if (!registrations[eventName])
registrations[eventName] = [];
registrations[eventName].push(listener);
}
this.emit = (eventName, value) => {
if (registrations[eventName])
registrations[eventName].forEach(listener => listener(value));
}
}
HotPocketClient = function HotPocketClient(contractId, server, keys) {
let ws = null;
const protocol = protocols.json; // We only support json in browser.
const msgHelper = new MessageHelper(keys, protocol);
const emitter = new EventEmitter();
let handshakeResolver = null;
let statResponseResolvers = [];
let contractInputResolvers = {};
this.connect = function () {
return new Promise(resolve => {
handshakeResolver = resolve;
ws = new WebSocket(server);
ws.addEventListener("close", () => {
// If there are any ongoing resolvers resolve them with error output.
handshakeResolver && handshakeResolver(false);
handshakeResolver = null;
statResponseResolvers.forEach(resolver => resolver(null));
statResponseResolvers = [];
Object.values(contractInputResolvers).forEach(resolver => resolver(null));
contractInputResolvers = {};
emitter.emit(events.disconnect);
});
ws.onmessage = async (rcvd) => {
msg = await rcvd.data.text();
try {
m = msgHelper.deserializeMessage(msg);
} catch (e) {
console.log(e);
console.log("Exception deserializing: ");
console.log(msg);
return;
}
if (m.type == 'handshake_challenge') {
// Check whether contract id is matching if specified.
if (contractId && m.contract_id != contractId) {
console.error("Contract id mismatch.")
ws.close();
}
// sign the challenge and send back the response
const response = msgHelper.createHandshakeResponse(m.challenge);
ws.send(JSON.stringify(response));
setTimeout(() => {
// If we are still connected, report handshaking as successful.
// (If websocket disconnects, handshakeResolver will be null)
handshakeResolver && handshakeResolver(true);
handshakeResolver = null;
}, 100);
}
else if (m.type == 'contract_read_response') {
emitter.emit(events.contractReadResponse, msgHelper.deserializeOutput(m.content));
}
else if (m.type == 'contract_input_status') {
const sigKey = (typeof m.input_sig === "string") ? m.input_sig : m.input_sig.toString("hex");
const resolver = contractInputResolvers[sigKey];
if (resolver) {
if (m.status == "accepted")
resolver("ok");
else
resolver(m.reason);
delete contractInputResolvers[sigKey];
}
}
else if (m.type == 'contract_output') {
emitter.emit(events.contractOutput, msgHelper.deserializeOutput(m.content));
}
else if (m.type == "stat_response") {
statResponseResolvers.forEach(resolver => {
resolver({
lcl: m.lcl,
lclSeqNo: m.lcl_seqno
});
})
statResponseResolvers = [];
}
else {
console.log("Received unrecognized message: type:" + m.type);
}
}
});
};
this.on = function (event, listener) {
emitter.on(event, listener);
}
this.close = function () {
return new Promise(resolve => {
try {
ws.addEventListener("close", resolve);
ws.close();
} catch (error) {
resolve();
}
})
}
this.getStatus = function () {
const p = new Promise(resolve => {
statResponseResolvers.push(resolve);
});
// If this is the only awaiting stat request, then send an actual stat request.
// Otherwise simply wait for the previously sent request.
if (statResponseResolvers.length == 1) {
const msg = msgHelper.createStatusRequest();
ws.send(msgHelper.serializeObject(msg));
}
return p;
}
this.sendContractInput = async function (input, nonce = null, maxLclOffset = null) {
if (!maxLclOffset)
maxLclOffset = 10;
if (!nonce)
nonce = (new Date()).getTime().toString();
else
nonce = nonce.toString();
// Acquire the current lcl and add the specified offset.
const stat = await this.getStatus();
if (!stat)
return new Promise(resolve => resolve("ledger_status_error"));
const maxLclSeqNo = stat.lclSeqNo + maxLclOffset;
const msg = msgHelper.createContractInput(input, nonce, maxLclSeqNo);
const sigKey = (typeof msg.sig === "string") ? msg.sig : msg.sig.toString("hex");
const p = new Promise(resolve => {
contractInputResolvers[sigKey] = resolve;
});
ws.send(msgHelper.serializeObject(msg));
return p;
}
this.sendContractReadRequest = function (request) {
const msg = msgHelper.createReadRequest(request);
ws.send(msgHelper.serializeObject(msg));
}
}
function MessageHelper(keys, protocol) {
this.binaryEncode = function (data) {
return toHexString(data);
}
this.serializeObject = function (obj) {
return JSON.stringify(obj);
}
this.deserializeMessage = function (m) {
return JSON.parse(m);
}
this.serializeInput = function (input) {
return (typeof input === 'string' || input instanceof String) ? input : input.toString();
}
this.deserializeOutput = function (content) {
return content;
}
this.createHandshakeResponse = function (challenge) {
// For handshake response encoding Hot Pocket always uses json.
// Handshake response will specify the protocol to use for subsequent messages.
const sigBytes = sodium.crypto_sign_detached(challenge, keys.privateKey);
return {
type: "handshake_response",
challenge: challenge,
sig: toHexString(sigBytes),
pubkey: "ed" + toHexString(keys.publicKey),
protocol: protocol
}
}
this.createContractInput = function (input, nonce, maxLclSeqNo) {
if (input.length == 0)
return null;
const inpContainer = {
input: this.serializeInput(input),
nonce: nonce,
max_lcl_seqno: maxLclSeqNo
}
const serlializedInpContainer = this.serializeObject(inpContainer);
const sigBytes = sodium.crypto_sign_detached(serlializedInpContainer, keys.privateKey);
const signedInpContainer = {
type: "contract_input",
input_container: serlializedInpContainer,
sig: this.binaryEncode(sigBytes)
}
return signedInpContainer;
}
this.createReadRequest = function (request) {
if (request.length == 0)
return null;
return {
type: "contract_read_request",
content: this.serializeInput(request)
}
}
this.createStatusRequest = function () {
return { type: 'stat' };
}
}
return {
KeyGenerator: KeyGenerator,
Client: HotPocketClient,
events: events,
}
})();

View File

@@ -0,0 +1,16 @@
<html>
<head>
<title>HotPocket test page</title>
<script src="hp-browser-client-lib.js"></script>
<script src="test.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/libsodium-wrappers/0.5.4/sodium.min.js"
integrity="sha512-oRfU7aik4u7f0dPAKgOyA4+bb/YRGfAaD5RA4Z3Mb2ycPcGDs+k8qAnDNd7ouruoqlIHSuGVaTTlEs91Gvd37A=="
crossorigin="anonymous"></script>
</head>
<body>
HotPocket browser client test page.
</body>
</html>

View File

@@ -0,0 +1,33 @@
window.sodium = {
onload: async function () {
const keys = HotPocket.KeyGenerator.generate(); // Can provide existing hex private key as parameter as well.
const hpc = new HotPocket.Client(null, "wss://localhost:8081", keys);
if (!await hpc.connect()) {
console.log('Connection failed.');
return;
}
console.log('HotPocket Connected.');
// This will get fired if HP server disconnects unexpectedly.
hpc.on(HotPocket.events.disconnect, () => {
console.log('Server disconnected');
})
// This will get fired when contract sends an output.
hpc.on(HotPocket.events.contractOutput, (output) => {
console.log("Contract output>> " + output);
})
// This will get fired when contract sends a read response.
hpc.on(HotPocket.events.contractReadResponse, (response) => {
console.log("Contract read response>> " + response);
})
hpc.sendContractReadRequest("Hello");
hpc.sendContractInput("World!")
// When we need to close HP connection:
// hpc.close();
}
};

View File

@@ -88,7 +88,12 @@ void process_user_message(const struct hp_user *user, const void *buf, const uin
char tsbuf[st.st_size];
if (read(fd, tsbuf, st.st_size) > 0)
{
hp_write_user_msg(user, tsbuf, st.st_size);
for (int i = 0; i < st.st_size; i++)
{
if (tsbuf[i] == '\n' || tsbuf[i] == 0)
tsbuf[i] = ' ';
}
hp_write_user_msg(user, tsbuf, st.st_size - 1);
}
}
close(fd);

View File

@@ -19,41 +19,51 @@
#define HP_KEY_SIZE 64
#define HP_HASH_SIZE 64
#define __HP_ASSIGN_STRING(dest, elem) \
if (elem->value->type == json_type_string) \
{ \
const struct json_string_s *value = (struct json_string_s *)elem->value->payload; \
memcpy(dest, value->string, sizeof(dest)); \
#define __HP_ASSIGN_STRING(dest, elem) \
{ \
if (elem->value->type == json_type_string) \
{ \
const struct json_string_s *value = (struct json_string_s *)elem->value->payload; \
memcpy(dest, value->string, sizeof(dest)); \
} \
}
#define __HP_ASSIGN_UINT64(dest, elem) \
if (elem->value->type == json_type_number) \
{ \
const struct json_number_s *value = (struct json_number_s *)elem->value->payload; \
dest = strtoull(value->number, NULL, 0); \
#define __HP_ASSIGN_UINT64(dest, elem) \
{ \
if (elem->value->type == json_type_number) \
{ \
const struct json_number_s *value = (struct json_number_s *)elem->value->payload; \
dest = strtoull(value->number, NULL, 0); \
} \
}
#define __HP_ASSIGN_INT(dest, elem) \
if (elem->value->type == json_type_number) \
{ \
const struct json_number_s *value = (struct json_number_s *)elem->value->payload; \
dest = atoi(value->number); \
#define __HP_ASSIGN_INT(dest, elem) \
{ \
if (elem->value->type == json_type_number) \
{ \
const struct json_number_s *value = (struct json_number_s *)elem->value->payload; \
dest = atoi(value->number); \
} \
}
#define __HP_ASSIGN_BOOL(dest, elem) \
if (elem->value->type == json_type_true) \
dest = true; \
else if (elem->value->type == json_type_false) \
dest = false;
#define __HP_ASSIGN_BOOL(dest, elem) \
{ \
if (elem->value->type == json_type_true) \
dest = true; \
else if (elem->value->type == json_type_false) \
dest = false; \
}
#define __HP_FROM_BE(buf, pos) \
((uint8_t)buf[pos + 0] << 24 | (uint8_t)buf[pos + 1] << 16 | (uint8_t)buf[pos + 2] << 8 | (uint8_t)buf[pos + 3])
#define __HP_TO_BE(num, buf, pos) \
buf[pos] = num >> 24; \
buf[1 + pos] = num >> 16; \
buf[2 + pos] = num >> 8; \
buf[3 + pos] = num;
{ \
buf[pos] = num >> 24; \
buf[1 + pos] = num >> 16; \
buf[2 + pos] = num >> 8; \
buf[3 + pos] = num; \
}
struct hp_user_input
{
@@ -134,6 +144,13 @@ int hp_init_contract()
if (__hpc.cctx)
return -1; // Already initialized.
// Check whether we are running from terminal and produce warning.
if (isatty(STDIN_FILENO) == 1)
{
fprintf(stderr, "Error: Hot Pocket smart contracts must be executed via Hot Pocket.\n");
return -1;
}
char buf[4096];
const size_t len = read(STDIN_FILENO, buf, sizeof(buf));
if (len == -1)

View File

@@ -1 +1,2 @@
node_modules
node_modules
dist

View File

@@ -3,7 +3,7 @@ const readline = require('readline');
const { exit } = require('process');
const bson = require('bson');
var path = require("path");
const HotPocket = require('./hp-client-lib');
const HotPocket = require('./hp-node-client-lib');
async function main() {

View File

@@ -103,8 +103,7 @@ function HotPocketClient(contractId, server, keys, protocol = protocols.json) {
if (m.type == 'handshake_challenge') {
// Check whether contract id is matching if specified.
if (contractId && m.contract_id != contractId)
{
if (contractId && m.contract_id != contractId) {
console.error("Contract id mismatch.")
ws.close();
}
@@ -121,8 +120,7 @@ function HotPocketClient(contractId, server, keys, protocol = protocols.json) {
}, 100);
}
else if (m.type == 'contract_read_response') {
const decoded = msgHelper.binaryDecode(m.content);
emitter.emit(events.contractReadResponse, decoded);
emitter.emit(events.contractReadResponse, msgHelper.deserializeOutput(m.content));
}
else if (m.type == 'contract_input_status') {
const sigKey = (typeof m.input_sig === "string") ? m.input_sig : m.input_sig.toString("hex");
@@ -136,8 +134,7 @@ function HotPocketClient(contractId, server, keys, protocol = protocols.json) {
}
}
else if (m.type == 'contract_output') {
const decoded = msgHelper.binaryDecode(m.content);
emitter.emit(events.contractOutput, decoded);
emitter.emit(events.contractOutput, msgHelper.deserializeOutput(m.content));
}
else if (m.type == "stat_response") {
statResponseResolvers.forEach(resolver => {
@@ -224,10 +221,6 @@ function MessageHelper(keys, protocol) {
return protocol == protocols.json ? buffer.toString("hex") : buffer;
}
this.binaryDecode = function (content) {
return (protocol == protocols.json) ? Buffer.from(content, "hex") : content.buffer;
}
this.serializeObject = function (obj) {
return protocol == protocols.json ? JSON.stringify(obj) : bson.serialize(obj);
}
@@ -242,6 +235,10 @@ function MessageHelper(keys, protocol) {
Buffer.isBuffer(input) ? input : Buffer.from(input);
}
this.deserializeOutput = function (content) {
return (protocol == protocols.json) ? content : content.buffer;
}
this.createHandshakeResponse = function (challenge) {
// For handshake response encoding Hot Pocket always uses json.
// Handshake response will specify the protocol to use for subsequent messages.

View File

@@ -1,4 +1,4 @@
const { HotPocketClient, HotPocketKeyGenerator, HotPocketEvents } = require('./hp-client-lib');
const { HotPocketClient, HotPocketKeyGenerator, HotPocketEvents } = require('./hp-node-client-lib');
async function main() {

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,8 @@
{
"scripts": {
"buildnode": "browserify --node -p tinyify hp-node-client-lib.js -o dist/hp-node-client-lib.js",
"buildbrowser": "browserify -p tinyify hp-node-client-lib.js -o dist/hp-browsercompat-client-lib.js"
},
"dependencies": {
"libsodium-wrappers": "0.7.6",
"ws": "7.1.2",
@@ -6,5 +10,9 @@
"bson": "4.0.4",
"utf-8-validate": "5.0.2",
"bufferutil": "4.0.1"
},
"devDependencies": {
"browserify": "16.5.2",
"tinyify": "3.0.0"
}
}

View File

@@ -1,6 +1,6 @@
const readline = require('readline');
const { exit } = require('process');
const HotPocket = require('./hp-client-lib');
const HotPocket = require('./hp-node-client-lib');
async function main() {
const keys = await HotPocket.KeyGenerator.generate();
@@ -28,12 +28,12 @@ async function main() {
// This will get fired when contract sends an output.
hpc.on(HotPocket.events.contractOutput, (output) => {
console.log("Contract output>> " + Buffer.from(output, "hex"));
console.log("Contract output>> " + output);
})
// This will get fired when contract sends a read response.
hpc.on(HotPocket.events.contractReadResponse, (response) => {
console.log("Contract read response>> " + Buffer.from(response, "hex"));
console.log("Contract read response>> " + response);
})
// On ctrl + c we should close HP connection gracefully.

View File

@@ -15,7 +15,7 @@ const echoContract = async (ctx) => {
for (const user of ctx.users.list()) {
// This user's pubkey can be accessed from 'user.pubKey'
for (const input of user.inputs) {
inputHandlers.push(new Promise(async (resolve) => {
@@ -23,10 +23,11 @@ const echoContract = async (ctx) => {
const buf = await ctx.users.read(input);
const msg = buf.toString();
if (msg == "ts")
await user.send(fs.readFileSync("exects.txt"));
else
await user.send("Echoing: " + msg);
const output = (msg == "ts") ? fs.readFileSync("exects.txt").toString() : ("Echoing: " + msg);
// Stringify to escape JSON characters and remove surrounding double quotes.
const stringified = JSON.stringify(output);
await user.send(stringified.substr(1, stringified.length - 2));
resolve();
}));

View File

@@ -3,72 +3,77 @@ const fs = require('fs');
const bson = require('bson');
const fileContract = async (ctx) => {
await ctx.users.onMessage(async (user, buf) => {
const msg = bson.deserialize(buf);
if (msg.type == "upload") {
if (fs.existsSync(msg.fileName)) {
await user.send(bson.serialize({
type: "uploadResult",
status: "already_exists",
fileName: msg.fileName
}));
for (const user of ctx.users.list()) {
for (const input of user.inputs) {
const buf = await ctx.users.read(input);
const msg = bson.deserialize(buf);
if (msg.type == "upload") {
if (fs.existsSync(msg.fileName)) {
await user.send(bson.serialize({
type: "uploadResult",
status: "already_exists",
fileName: msg.fileName
}));
}
else if (msg.content.length > 10 * 1024 * 1024) { // 10MB
await user.send(bson.serialize({
type: "uploadResult",
status: "too_large",
fileName: msg.fileName
}));
}
else {
// Save the file.
fs.writeFileSync(msg.fileName, msg.content.buffer);
await user.send(bson.serialize({
type: "uploadResult",
status: "ok",
fileName: msg.fileName
}));
}
}
else if (msg.content.length > 10 * 1024 * 1024) { // 10MB
await user.send(bson.serialize({
type: "uploadResult",
status: "too_large",
fileName: msg.fileName
}));
else if (msg.type == "delete") {
if (fs.existsSync(msg.fileName)) {
fs.unlinkSync(msg.fileName);
await user.send(bson.serialize({
type: "deleteResult",
status: "ok",
fileName: msg.fileName
}));
}
else {
await user.send(bson.serialize({
type: "deleteResult",
status: "not_found",
fileName: msg.fileName
}));
}
}
else {
// Save the file.
fs.writeFileSync(msg.fileName, msg.content.buffer);
await user.send(bson.serialize({
type: "uploadResult",
status: "ok",
fileName: msg.fileName
}));
else if (msg.type == "download") {
if (fs.existsSync(msg.fileName)) {
const fileContent = fs.readFileSync(msg.fileName);
await user.send(bson.serialize({
type: "downloadResult",
status: "ok",
fileName: msg.fileName,
content: fileContent
}));
}
else {
await user.send(bson.serialize({
type: "downloadResult",
status: "not_found",
fileName: msg.fileName
}));
}
}
}
else if (msg.type == "delete") {
if (fs.existsSync(msg.fileName)) {
fs.unlinkSync(msg.fileName);
await user.send(bson.serialize({
type: "deleteResult",
status: "ok",
fileName: msg.fileName
}));
}
else {
await user.send(bson.serialize({
type: "deleteResult",
status: "not_found",
fileName: msg.fileName
}));
}
}
else if (msg.type == "download") {
if (fs.existsSync(msg.fileName)) {
const fileContent = fs.readFileSync(msg.fileName);
await user.send(bson.serialize({
type: "downloadResult",
status: "ok",
fileName: msg.fileName,
content: fileContent
}));
}
else {
await user.send(bson.serialize({
type: "downloadResult",
status: "not_found",
fileName: msg.fileName
}));
}
}
});
}
};
const hpc = new HotPocketContract();

View File

@@ -1,4 +1,6 @@
const fs = require('fs');
const tty = require('tty');
require('process');
const MAX_SEQ_PACKET_SIZE = 128 * 1024;
const CONTROL_MESSAGE = {
@@ -14,14 +16,20 @@ class HotPocketContract {
init(contractFunc) {
if (this.#controlChannel) // Already initialized.
return;
return false;
if (tty.isatty(process.stdin.fd)) {
console.error("Error: Hot Pocket smart contracts must be executed via Hot Pocket.");
return false;
}
// Parse HotPocket args.
const argsJson = fs.readFileSync(0, 'utf8');
const argsJson = fs.readFileSync(process.stdin.fd, 'utf8');
const hpargs = JSON.parse(argsJson);
this.#controlChannel = new ControlChannel(hpargs.controlfd);
this.#executeContract(hpargs, contractFunc);
return true;
}
#executeContract = (hpargs, contractFunc) => {

View File

@@ -140,18 +140,12 @@ namespace msg::usrmsg::json
* Message format:
* {
* "type": "contract_read_response",
* "content": "<hex encoded contract output>"
* "content": "<response string>"
* }
* @param content The contract binary output content to be put in the message.
*/
void create_contract_read_response_container(std::vector<uint8_t> &msg, std::string_view content)
{
std::string contenthex;
util::bin2hex(
contenthex,
reinterpret_cast<const unsigned char *>(content.data()),
content.length());
msg.reserve(256);
msg += "{\"";
msg += msg::usrmsg::FLD_TYPE;
@@ -160,7 +154,7 @@ namespace msg::usrmsg::json
msg += SEP_COMMA;
msg += msg::usrmsg::FLD_CONTENT;
msg += SEP_COLON;
msg += contenthex;
msg += content;
msg += "\"}";
}
@@ -172,18 +166,12 @@ namespace msg::usrmsg::json
* "type": "contract_output",
* "lcl": "<lcl id>"
* "lcl_seqno": <integer>,
* "content": "<hex encoded contract output>"
* "content": "<contract output string>"
* }
* @param content The contract binary output content to be put in the message.
*/
void create_contract_output_container(std::vector<uint8_t> &msg, std::string_view content, const uint64_t lcl_seq_no, std::string_view lcl)
{
std::string contenthex;
util::bin2hex(
contenthex,
reinterpret_cast<const unsigned char *>(content.data()),
content.length());
msg.reserve(256);
msg += "{\"";
msg += msg::usrmsg::FLD_TYPE;
@@ -200,7 +188,7 @@ namespace msg::usrmsg::json
msg += SEP_COMMA_NOQUOTE;
msg += msg::usrmsg::FLD_CONTENT;
msg += SEP_COLON;
msg += contenthex;
msg += content;
msg += "\"}";
}