datona-lib
Version:
Core library for access to the Datona IO Platform. Datona Protocol v0.0.2. Adds HTTP and WebSocket support.
744 lines (628 loc) • 22.6 kB
JavaScript
"use strict";
/*
* Datona Blockchain Library
*
* datona-lib blockchain features
*
* Copyright (C) 2020 Datona Labs
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*
*/
const CONFIG = require('../config.json');
const crypto = require('./datona-crypto');
const errors = require('./errors');
const assert = require('./assertions');
const Web3 = (typeof window === 'undefined') ? require('web3') : window.Web3;
const Transaction = require('ethereumjs-tx').Transaction;
const sdacInterface = require("../contracts/SDAC.json");
/*
* Constants
*/
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
const NO_PERMISSIONS = 0x00;
const ALL_PERMISSIONS = 0x07;
const READ_BIT = 0x04;
const WRITE_BIT = 0x02;
const APPEND_BIT = 0x01;
const DIRECTORY_BIT = 0x80;
const ROOT_DIRECTORY = "0x0000000000000000000000000000000000000000";
/*
* Classes
*/
class Permissions {
constructor (permissionsByte) {
if (toString.call(permissionsByte) === '[object Number]') {
this.permissions = permissionsByte;
}
else {
assert.matches(permissionsByte, '^0x[0-9A-Fa-f]{2}$', "invalid string representation of permissions byte",permissionsByte);
this.permissions = parseInt(permissionsByte);
}
}
canRead() { return (this.permissions & READ_BIT) > 0 }
canWrite() { return (this.permissions & WRITE_BIT) > 0 }
canAppend() { return (this.permissions & APPEND_BIT) > 0 }
isDirectory() { return (this.permissions & DIRECTORY_BIT) > 0 }
}
/*
* Represents a Smart Data Access Contract on the blockchain
*/
class Contract {
/*
* If the address is not given this represents a new contract to be deployed.
*/
constructor(abi, address) {
assert.isArray(abi, "Contract abi");
getProvider(); // constructs web3 if not yet constructed
this.web3Contract = new web3.eth.Contract(abi);
this.abi = abi;
if (address !== undefined) this.setAddress(address);
else this.address = undefined;
}
/*
* Sets the address of this contract on the blockchain. Can be used as an
* alternative to passing it in the constructor.
* @throws {BlockchainError} if the address is already set
*/
setAddress(address) {
assert.isAddress(address, "address");
if (this.address !== undefined) throw new errors.BlockchainError("address already set");
this.web3Contract.options.address = address;
this.address = address;
}
/*
* Promises to deploy this contract on the blockchain
*/
deploy(key, bytecode, constructorArgs) {
assert.isInstanceOf(key, "Contract.deploy key", crypto.Key);
assert.isHexString(bytecode, "Contract.deploy bytecode");
if (constructorArgs !== undefined) assert.isArray(constructorArgs, "constructorArgs");
if (this.address !== undefined) throw new errors.BlockchainError("contract already deployed");
try {
// create deployment transaction data from bytecode
const txnData = this.web3Contract.deploy({
data: '0x' + bytecode,
arguments: constructorArgs
}).encodeABI();
// create skeleton transaction
const transaction = {
data: txnData,
gas: web3.utils.toHex(6000000)
}
// function to set the address of this Contract instance once the receipt is received
function storeAddress(receipt) {
this.setAddress(receipt.contractAddress);
return receipt.contractAddress;
}
return sendTransaction(key, transaction)
.then(storeAddress.bind(this));
}
catch (error) {
throw (error instanceof errors.DatonaError) ? error : new errors.BlockchainError(error.message);
}
}
/*
* Promises to get the owner of this contract from the blockchain
*/
getOwner() {
if (this.address === undefined) {
throw new errors.BlockchainError("Contract.getOwner: contract has not been deployed or mapped to an existing contract");
}
if (this.owner !== undefined) {
const thisContract = this;
return new Promise(
function(resolve, reject) {
resolve(thisContract.owner);
});
} else {
const thisContract = this;
return this.call("owner")
.then(function(owner) {
thisContract.owner = owner;
return owner;
})
.catch( function(error){
throw new errors.BlockchainError("Failed to retrieve contract owner from the blockchain: "+error.message);
});
}
}
/*
* Promises to return the expiry status (boolean) of this contract
*/
hasExpired() {
if (this.address === undefined) throw new errors.BlockchainError("Contract.hasExpired: contract has not been deployed or mapped to an existing contract");
return this.call("hasExpired");
}
/*
* Promises to return whether the given requester is permitted to read the given vault file (boolean)
*/
canRead(requester, fileId) {
return this.getPermissions(requester, fileId)
.then( function(permissions){
return permissions.canRead();
});
}
/*
* Promises to return whether the given requester is permitted to write to the given vault file (boolean)
*/
canWrite(requester, fileId) {
return this.getPermissions(requester, fileId)
.then( function(permissions){
return permissions.canWrite();
});
}
/*
* Promises to return whether the given requester is permitted to append to the given vault file (boolean)
*/
canAppend(requester, fileId) {
return this.getPermissions(requester, fileId)
.then( function(permissions){
return permissions.canAppend();
});
}
/*
* Promises to return a byte with the d----rwa permissions of the given file for the given user address.
*/
getPermissions(requester, fileId) {
assert.isAddress(requester, "Contract getPermissions requester");
if (fileId === undefined) fileId = ROOT_DIRECTORY;
else assert.isAddress(fileId, "Contract getPermissions fileId");
if (this.address === undefined) throw new errors.BlockchainError("Contract.getPermissions: contract has not been deployed or mapped to an existing contract");
return this.call("getPermissions", [requester, fileId])
.then( function(permissions){
return new Permissions(permissions);
});
}
/*
* Promises to call the given view or pure method with the given arguments.
* Use 'transact' to call a state-modifying method.
*/
call(method, args = []) {
assert.isString(method, "Contract.call method");
assert.isArray(args, "Contract.call args");
if (this.address === undefined) throw new errors.BlockchainError("Contract.call: contract has not been deployed or mapped to an existing contract");
var methodAbi = this.abi.find(obj => { return obj.name === method; });
if (!methodAbi) throw new errors.BlockchainError("Contract.call method '"+method+"' does not exist");
try{
return this.web3Contract.methods[method](...args).call()
.catch(function(error) {
throw new errors.BlockchainError("Failed to call method " + method, error.message);
});
}
catch (error) {
throw new errors.BlockchainError(error.message);
}
}
/*
* Promises to call the given state-modifying method with the given arguments.
* Use 'call' to call a view or pure method.
*/
transact(key, method, args = [], options) {
assert.isInstanceOf(key, "Contract.transact key", crypto.Key);
assert.isString(method, "Contract.transact method");
assert.isArray(args, "Contract.call args");
if (this.address === undefined) throw new errors.BlockchainError("Contract.call: contract has not been deployed or mapped to an existing contract");
if (this.web3Contract === undefined) throw new errors.BlockchainError("contract does not exist on the blockchain");
try{
var methodAbi = this.abi.find(obj => { return obj.name === method; });
if (!methodAbi) throw new errors.BlockchainError("Contract.transact method '"+method+"' does not exist");
// create transaction data
const txnData = this.web3Contract.methods[method](...args).encodeABI();
var gasPrice = 0;
// function to get the transaction nonce of the signatory
function getTransactionCount(_gasPrice) {
gasPrice = _gasPrice;
return web3.eth.getTransactionCount(key.address, "pending");
}
// function to construct and sign the transaction once the nonce has been calculated
function createTransaction(nonce) {
const rawTxn = {
nonce: nonce,
gasPrice: web3.utils.toHex(gasPrice),
gas: web3.utils.toHex(3000000),
data: txnData,
to: this.address,
chainID: 42
};
for (const field in options) rawTxn[field] = options[field];
const txn = new Transaction(rawTxn, {'chain':chain});
txn.sign(key.privateKey);
const serializedTxn = txn.serialize();
return "0x"+serializedTxn.toString('hex');
}
// get the nonce, construct and sign the transaction then publish it on the blockchain
return web3.eth.getGasPrice()
.then(getTransactionCount)
.then(createTransaction.bind(this))
.then(web3.eth.sendSignedTransaction)
.catch(
function(error) {
throw (error instanceof errors.DatonaError) ? error : new errors.BlockchainError(error.message);
}
);
}
catch (error) {
throw new errors.BlockchainError(error.message);
}
}
/*
* Promises to get the runtime bytecode of this contract from the blockchain
*/
getBytecode() {
if (this.address === undefined) {
throw new errors.BlockchainError("Contract.getBytecode: contract has not been deployed or mapped to an existing contract");
}
else if (this.bytecode !== undefined) {
const thisContract = this;
return new Promise( function(resolve, reject) { resolve(thisContract.bytecode); });
}
else {
const thisContract = this;
return web3.eth.getCode(this.address)
.then(function(bytecode) {
thisContract.bytecode = bytecode.slice(2);
return thisContract.bytecode;
})
.catch(function(error) {
throw new errors.BlockchainError("Could not retrieve contract bytecode", error.message);
});
}
}
/*
* Promises to terminate this contract
*/
terminate(key){
return this.transact(key, "terminate");
}
/*
* Promises to reject if the contract's bytecode does not equal the expected bytecode given.
*/
assertBytecode(expectedBytecode) {
return this.getBytecode()
.then(function(bytecode) {
if (bytecode !== expectedBytecode) {
throw new errors.ContractTypeError("bytecode does not match");
}
});
}
/*
* Promises to reject if the contract's owner address does not equal the expected address given.
*/
assertOwner(expectedOwner) {
assert.isAddress(expectedOwner, "Contract.assertOwner expectedOwner");
return this.getOwner()
.then(function(owner) {
if (owner.toLowerCase() !== expectedOwner.toLowerCase()) {
throw new errors.ContractOwnerError("owner does not match");
}
});
}
/*
* Promises to reject if the contract has expired.
*/
assertNotExpired() {
return this.hasExpired()
.then(function(expired) {
if (expired) {
throw new errors.ContractExpiryError("contract has expired");
}
});
}
/*
* Promises to reject if the contract has not expired.
*/
assertHasExpired() {
return this.hasExpired()
.then(function(expired) {
if (!expired) {
throw new errors.ContractExpiryError("contract has not expired");
}
});
}
/*
* Promises to reject if the given requester does not have permission to access the given vault file.
* Also returns the permissions
*/
assertCanRead(requester, fileId) {
return this.getPermissions(requester, fileId)
.then( function(permissions){
_assertPermitted(permissions.canRead());
return permissions;
});
}
/*
* Promises to reject if the given requester does not have permission to write to the given vault file.
* Also returns the permissions
*/
assertCanWrite(requester, fileId) {
return this.getPermissions(requester, fileId)
.then( function(permissions){
_assertPermitted(permissions.canWrite());
return permissions;
});
}
/*
* Promises to reject if the given requester does not have permission to append to the given vault file.
* Also returns the permissions
*/
assertCanAppend(requester, fileId) {
return this.getPermissions(requester, fileId)
.then( function(permissions){
_assertPermitted(permissions.canAppend());
return permissions;
});
}
}
/*
* Instance of Contract, providing an interface to any SDAC already deployed on
* the blockchain. Maps to a contract at the given address using the standard
* SDAC interface abi
*/
class GenericSmartDataAccessContract extends Contract {
constructor(address) {
super(sdacInterface.abi,address);
}
}
/*
* Variables
*/
var web3;
var subscriptions = [];
var web3Subscribed = false;
var chain = CONFIG.blockchain || "kovan";
/*
* External Functions
*/
/*
* Overrides the default connection to the blockchain (that configured in config.json).
*/
function setProvider(url, blockchain) {
close();
try {
chain = blockchain || chain;
const urlStr = url.scheme+"://" + url.host+ (url.port === undefined || url.port === "" ? "" : ":"+url.port);
switch (url.scheme) {
case "ws":
case "wss":
web3 = new Web3(new Web3.providers.WebsocketProvider(urlStr));
break;
case "http":
case "https":
web3 = new Web3(new Web3.providers.HttpProvider(urlStr));
break;
default:
throw new errors.BlockchainError("Invalid url scheme for the blockchain provider. Valid schemes are ws, wss, http and https");
}
}
catch (error) {
throw new errors.BlockchainError("setProvider url is invalid: "+error.message);
}
}
/*
* Returns the web3 instance and the blockchain name
*/
function getProvider() {
try {
if (web3 === undefined) setProvider(CONFIG.blockchainURL);
}
catch (error) {
throw new errors.BlockchainError("Could not construct web3 provider: "+error.message);
}
return { web3: web3, chain: chain };
}
/*
* Promises to publish a transaction on the blockchain
*/
function sendTransaction(key, transaction) {
assert.isInstanceOf(key, "sendTransaction key", crypto.Key);
assert.isObject(transaction, "sendTransaction transaction");
getProvider();
try {
if (transaction.from === undefined ) transaction.from = key.address;
if (transaction.gas === undefined ) transaction.gas = web3.utils.toHex(6000000);
if (transaction.chainId === undefined ) transaction.chainId = 42;
// function to get the transaction nonce of the signatory
function getTransactionCount(gasPrice) {
if (transaction.gasPrice === undefined ) transaction.gasPrice = web3.utils.toHex(gasPrice);
return web3.eth.getTransactionCount(key.address, "pending");
}
// function to construct and sign the transaction once the nonce has been calculated
function createTransaction(nonce) {
if (transaction.nonce === undefined ) transaction.nonce = nonce;
const txn = new Transaction(transaction, {'chain': chain});
txn.sign(key.privateKey);
const serializedTxn = txn.serialize();
return "0x"+serializedTxn.toString('hex');
}
// function to reject the promise if the transaction was not successful
function checkReceiptStatus(receipt) {
if (receipt.status === true) return receipt;
else throw new errors.BlockchainError("Blockchain VM reverted the transaction", receipt);
}
// get the nonce, construct and sign the transaction then publish it on the blockchain
return getGasPrice()
.then(getTransactionCount)
.then(createTransaction)
.then(web3.eth.sendSignedTransaction)
.then(checkReceiptStatus)
.catch( error => {
throw (error instanceof errors.DatonaError) ? error : new errors.BlockchainError(error.message);
});
}
catch (error) {
throw (error instanceof errors.DatonaError) ? error : new errors.BlockchainError(error.message);
}
}
function getGasPrice() {
return web3.eth.getGasPrice();
}
/*
* Subscribes the client to receive notification of a new contract deployed to the
* blockchain with the given code hash. Optionally, the client can receive notification
* only if the given address is permitted to access the data controlled by the contract.
*/
function subscribe(bytecodeHash, callback, permittedAddress, fileId) {
assert.isHash(bytecodeHash, "subscribe bytecodeHash");
assert.isFunction(callback, "subscribe callback");
if (permittedAddress !== undefined) {
assert.isAddress(permittedAddress, "subscribe permittedAddress");
}
if (fileId !== undefined){
assert.isAddress(fileId, "subscribe fileId");
}
// subscribe to web3 if this is the first client subscription
if (!web3Subscribed) monitorForPendingTransactions();
// push the new bytecodeHash to the subscriptions
const subscription = {
bytecodeHash: bytecodeHash,
callback: callback,
permittedAddress: permittedAddress,
fileId: fileId
};
const subscriptionHash = crypto.hash(JSON.stringify(subscription));
subscription.hash = subscriptionHash;
subscriptions.push(subscription);
return subscriptionHash;
}
/*
* Unsubscribes a previously subscribed client. The client is identified by
* the hash returned from the original call to 'subscribe'
*/
function unsubscribe(subscriptionId) {
var numUnsubscribed = 0;
var i = 0;
while (i < subscriptions.length) {
if (subscriptions[i].hash === subscriptionId) {
subscriptions.splice(i,1);
numUnsubscribed++;
}
else i++;
}
return numUnsubscribed;
}
/*
* Closes any connections to the blockchain. Should be called on program exit
* if any blockchain functions have been used.
*/
function close(){
if (web3 !== undefined){
try{
if (web3.currentProvider.connection !== undefined) web3.currentProvider.connection.close();
web3 = undefined;
}
catch(error){
throw new errors.BlockchainError("Failed to close blockchain connection: "+error.message);
}
}
}
/*
* Exports
*/
module.exports = {
ZERO_ADDRESS: ZERO_ADDRESS,
NO_PERMISSIONS: NO_PERMISSIONS,
ALL_PERMISSIONS: ALL_PERMISSIONS,
READ_BIT: READ_BIT,
WRITE_BIT: WRITE_BIT,
APPEND_BIT: APPEND_BIT,
DIRECTORY_BIT: DIRECTORY_BIT,
ROOT_DIRECTORY: ROOT_DIRECTORY,
setProvider: setProvider,
getProvider: getProvider,
sendTransaction: sendTransaction,
Contract: Contract,
Permissions: Permissions,
GenericSmartDataAccessContract: GenericSmartDataAccessContract,
getGasPrice: getGasPrice,
subscribe: subscribe,
unsubscribe: unsubscribe,
close: close
};
/*
* Internal Functions
*/
/*
* Creates a web3 websocket interface to the blockchain node and subscribes to
* receive all new pending transactions
*/
function monitorForPendingTransactions() {
getProvider();
web3.eth
.subscribe('pendingTransactions', (error, result) => {
if (error) throw new errors.BlockchainError("Failed to subscribe to web3: "+error.message);
else web3Subscribed = true;
})
.on('data', monitorForNewContracts);
}
/*
* Web3 callback. Checks if the given transaction is a new contract and if so
* calls checkAndInformSubscriber to determine if it is a contract type that the
* client has subscribed to and has permission to access.
*/
function monitorForNewContracts(txHash) {
// Get the transaction details
// if the transaction's 'to' field is null then this is a contract creation
// so get the code hash and check if it is subscribed to
web3.eth.getTransaction(txHash)
.then( function(txn){
if (txn.to == null){
var transactionReceipt;
return web3.eth.getTransactionReceipt(txHash)
.then( function(txnReceipt){
transactionReceipt = txnReceipt;
return web3.eth.getCode(transactionReceipt.contractAddress);
})
.then( function(bytecode) {
checkAndInformSubscriber(crypto.hash(bytecode.slice(2)), transactionReceipt.contractAddress);
});
}
});
}
/*
* Checks the given code hash against all subscriptions and informs the client if found.
* If the subscription doesn't have a permittedAddress then the client is informed of the
* new contract. Otherwise the client is only informed if it is permitted to access the
* contract.
*/
function checkAndInformSubscriber(bytecodeHash, contractAddress) {
for ( var i = 0; i < subscriptions.length; i++) {
const subscription = subscriptions[i];
if (bytecodeHash === subscription.bytecodeHash) {
if (subscription.permittedAddress === undefined) {
subscription.callback(contractAddress, subscription.bytecodeHash);
} else {
const contract = new Contract(sdacInterface.abi, contractAddress);
contract.canRead(subscription.permittedAddress, subscription.fileId)
.then(function(result, error) {
if (result === true) {
subscription.callback(contractAddress, subscription.bytecodeHash);
}
if (error) {
console.log(error);
}
});
}
}
}
}
/*
* Local callback function to throw a PermissionError if permitted parameter is not true
*/
function _assertPermitted(permitted) {
if (!permitted) {
throw new errors.PermissionError("permission denied");
}
}