commit 5686c010153729808f2135729c806993a8ff02b7 Author: xcps Date: Sat Feb 3 21:14:56 2018 +0500 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d5f19d8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +package-lock.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..7c8c5a2 --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ + +cd gostexplr +npm i +npm run initdb db_root_username db_root_password +npm run syncBlockchain +npm run + + +TODO: + - cut possible -000 from transaction + - datetime zone + - search NOT FOUND message + - pagination on index page + - sync blockchain by db transaction + - block page by index + - move database to server + - setup cron + - ? transaction connect vout to transaction + - address balance info + - lint + - tests + - ES7 \ No newline at end of file diff --git a/app.js b/app.js new file mode 100644 index 0000000..34629b6 --- /dev/null +++ b/app.js @@ -0,0 +1,52 @@ +var express = require('express'); +var path = require('path'); +var favicon = require('serve-favicon'); +var logger = require('morgan'); +var cookieParser = require('cookie-parser'); +var bodyParser = require('body-parser'); + +var index = require('./routes/index'); +var address = require('./routes/address'); +var transaction = require('./routes/transaction'); +var block = require('./routes/block'); +var search = require('./routes/search'); + +var app = express(); + +// view engine setup +app.set('views', path.join(__dirname, 'views')); +app.set('view engine', 'jade'); + +// uncomment after placing your favicon in /public +app.use(favicon(path.join(__dirname, 'public', 'favicon.ico'))); +app.use(logger('dev')); +app.use(bodyParser.json()); +app.use(bodyParser.urlencoded({ extended: false })); +app.use(cookieParser()); +app.use(express.static(path.join(__dirname, 'public'))); + +app.use('/', index); +app.use('/address', address); +app.use('/transaction', transaction); +app.use('/block', block); +app.use('/search', search); + +// catch 404 and forward to error handler +app.use(function(req, res, next) { + var err = new Error('Not Found'); + err.status = 404; + next(err); +}); + +// error handler +app.use(function(err, req, res, next) { + // set locals, only providing error in development + res.locals.message = err.message; + res.locals.error = req.app.get('env') === 'development' ? err : {}; + + // render the error page + res.status(err.status || 500); + res.render('error'); +}); + +module.exports = app; diff --git a/bin/initdb.js b/bin/initdb.js new file mode 100644 index 0000000..0e0703e --- /dev/null +++ b/bin/initdb.js @@ -0,0 +1,51 @@ +#!/usr/bin/env node +var exec = require('child_process').exec; +var models = require('../models'); +var env = process.env.NODE_ENV || 'development'; +var config = require(__dirname + '/../config/config.json')[env]; + +if (process.argv.length < 4) { + console.log('Provide root user name and password for mysql'); + process.exit(0); +} + +const dropUserDB = `mysql -u${process.argv[2]} -p${process.argv[3]} -e "drop database ${config.database};drop user ${config.username}"` +const createdb = `mysql -u${process.argv[2]} -p${process.argv[3]} -e "create database ${config.database}"`; +const createUser = `mysql -u${process.argv[2]} -p${process.argv[3]} -e "create user ${config.username} identified by '${config.password}'"`; +const grantAccess = `mysql -u${process.argv[2]} -p${process.argv[3]} -e "grant all on ${config.database}.* to ${config.username}"`; + +exec(dropUserDB, function(err,stdout,stderr) { + console.log(stdout); + exec(createdb, function(err,stdout,stderr) { + if (err) { + console.log(err); + process.exit(0); + } else { + console.log(stdout); + exec(createUser, function(err, stdout, stderr) { + if (err) { + console.log(err); + process.exit(0); + } else { + console.log(stdout); + exec(grantAccess, function(err, stdout, stderr) { + if (err) { + console.log(err); + } else { + console.log(stdout); + models.sequelize.sync({force: true}) + .then(() => { + console.log(`\nUSER (${config.username}) AND DATABASE (${config.database}) CREATED SUCCESSFULLY`); + process.exit(0); + }) + .catch((err) => { + console.log(err); + process.exit(0); + }); + } + }); + } + }); + } + }); +}); diff --git a/bin/reinitTables.js b/bin/reinitTables.js new file mode 100644 index 0000000..1245638 --- /dev/null +++ b/bin/reinitTables.js @@ -0,0 +1,14 @@ +#!/usr/bin/env node +var models = require('../models'); + +models.sequelize.sync({force: true}) +.then(() => { + console.log('REINIT SUCCESS') + process.exit(0); +}) +.catch((err) => { + console.log(err); + process.exit(0); +}); + + diff --git a/bin/syncBlockchain.babel.js b/bin/syncBlockchain.babel.js new file mode 100644 index 0000000..6eadf30 --- /dev/null +++ b/bin/syncBlockchain.babel.js @@ -0,0 +1,154 @@ +var http = require('http'); +var models = require('../models'); + +const username = 'gostcoinrpc'; +const password = 'CEQLt9zrNnmyzosSV7pjb3EksAkuY9qeqoUCwDQc2wPc'; +const hostname = '127.0.0.1'; +const port = 9376; + +function MakeRPCRequest(postData) { + return new Promise(function(resolve, reject) { + var post_options = { + host: hostname, + port: port, + auth: `${username}:${password}`, + path: '/', + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': Buffer.byteLength(postData) + } + }; + var post_req = http.request(post_options, function(res) { + res.setEncoding("utf8"); + let body = ""; + res.on("data", data => { + body += data; + }); + res.on("end", () => { + resolve(body); + }); + }); + post_req.write(postData); + post_req.end(); + }); +} + +async function saveTransaction(txid, blockHeight) { + const res_tx = await MakeRPCRequest(JSON.stringify({ + method: 'getrawtransaction', + params: [txid, 1], + id: 1 + })); + const tx = JSON.parse(res_tx)['result']; + // console.log('tx:', tx); + if (tx === null) { + await models.Failure.create({ + msg: `${txid} fetching failed`, + }); + return; + } + const transaction = await models.Transaction.create({ + txid: tx.txid, + BlockHeight: blockHeight, + }); + for (var i = 0; i < tx.vout.length; i++) { + const vout = tx.vout[i]; + const m_vout = await models.Vout.create({ + value: vout.value, + }); + for (var y = 0; y < vout.scriptPubKey.addresses.length; y++) { + const address = vout.scriptPubKey.addresses[y]; + let m_address = await models.Address.findOne({ + where: { + address, + }, + }); + if (m_address === null) { + m_address = await models.Address.create({ + address, + }); + } + await m_vout.addAddresses(m_address); + } + await transaction.addVouts(m_vout); + } + for (var i = 0; i < tx.vin.length; i++) { + const vin = tx.vin[i]; + if (vin.txid) { + const trans = await models.Transaction.findOne({ + where: { + txid: vin.txid, + }, + }); + if (trans) { + await transaction.addTxtx(trans); + } + } + } +} + +async function syncNextBlock(syncedHeight) { + const height = syncedHeight + 1; + const res_hash = await MakeRPCRequest(JSON.stringify({ + method: 'getblockhash', + params: [height], + id: 1 + })); + const blockHash = JSON.parse(res_hash)['result']; + const res_block = await MakeRPCRequest(JSON.stringify({ + method: 'getblock', + params: [blockHash], + id: 1 + })); + const block = JSON.parse(res_block)['result']; + block.time = new Date(block.time * 1000); + await models.Block.create(block); + // console.log('block:', block); + for (var i = 0; i < block.tx.length; i++) { + await saveTransaction(block.tx[i], block.height); + } + + return height; +} + +async function getCurrentHeight() { + const result = await MakeRPCRequest(JSON.stringify({ + method: 'getblockcount', + params: [], + id: 1 + })); + + return JSON.parse(result)['result']; +} + +async function getSyncedHeight() { + const result = await models.Block.findOne({ + attributes: ['height'], + order: [['height', 'DESC']], + limit: 1 + }); + const height = result ? result.height : -1; + return height; +} + +async function syncBlockchain() { + let syncedHeight = await getSyncedHeight(); + console.log('\x1b[36m%s\x1b[0m', 'syncedHeight is', syncedHeight); + + let currentHeight = await getCurrentHeight(); + console.log('\x1b[36m%s\x1b[0m', 'currentHeight is', currentHeight); + while (syncedHeight < currentHeight) { + syncedHeight = await syncNextBlock(syncedHeight); + console.log('\x1b[36m%s\x1b[0m', 'syncedHeight: ', syncedHeight) + } + process.exit(0); +} + +var postData = JSON.stringify({ + 'method': 'getinfo', + 'params': [], + 'id': 1 + }); + +syncBlockchain(); diff --git a/bin/syncBlockchain.js b/bin/syncBlockchain.js new file mode 100644 index 0000000..09fc16e --- /dev/null +++ b/bin/syncBlockchain.js @@ -0,0 +1,2 @@ +require('babel-register'); +require('./syncBlockchain.babel.js'); diff --git a/bin/www b/bin/www new file mode 100755 index 0000000..176e1b5 --- /dev/null +++ b/bin/www @@ -0,0 +1,92 @@ +#!/usr/bin/env node + +/** + * Module dependencies. + */ + +var app = require('../app'); +var debug = require('debug')('gostexplr:server'); +var http = require('http'); +var models = require('../models'); + +/** + * Get port from environment and store in Express. + */ + +var port = normalizePort(process.env.PORT || '3000'); +app.set('port', port); + +/** + * Create HTTP server. + */ + +var server = http.createServer(app); + +models.sequelize.sync().then(function() { + /** + * Listen on provided port, on all network interfaces. + */ + + server.listen(port); + server.on('error', onError); + server.on('listening', onListening); +}); +/** + * Normalize a port into a number, string, or false. + */ + +function normalizePort(val) { + var port = parseInt(val, 10); + + if (isNaN(port)) { + // named pipe + return val; + } + + if (port >= 0) { + // port number + return port; + } + + return false; +} + +/** + * Event listener for HTTP server "error" event. + */ + +function onError(error) { + if (error.syscall !== 'listen') { + throw error; + } + + var bind = typeof port === 'string' + ? 'Pipe ' + port + : 'Port ' + port; + + // handle specific listen errors with friendly messages + switch (error.code) { + case 'EACCES': + console.error(bind + ' requires elevated privileges'); + process.exit(1); + break; + case 'EADDRINUSE': + console.error(bind + ' is already in use'); + process.exit(1); + break; + default: + throw error; + } +} + +/** + * Event listener for HTTP server "listening" event. + */ + +function onListening() { + var addr = server.address(); + var bind = typeof addr === 'string' + ? 'pipe ' + addr + : 'port ' + addr.port; + debug('Listening on ' + bind); +} diff --git a/config/config.json b/config/config.json new file mode 100644 index 0000000..1aab542 --- /dev/null +++ b/config/config.json @@ -0,0 +1,25 @@ +{ + "development": { + "username": "geuser", + "password": "geuser", + "database": "gedb", + "host": "127.0.0.1", + "dialect": "mysql", + "logging": false + }, + "test": { + "username": "root", + "password": null, + "database": "database_test", + "host": "127.0.0.1", + "dialect": "mysql" + }, + "production": { + "username": "geuser", + "password": "geuser", + "database": "gedb", + "host": "127.0.0.1", + "dialect": "mysql", + "logging": false + } +} diff --git a/models/address.js b/models/address.js new file mode 100644 index 0000000..7d41753 --- /dev/null +++ b/models/address.js @@ -0,0 +1,21 @@ +'use strict'; + +module.exports = (sequelize, DataTypes) => { + const Address = sequelize.define('Address', { + address: DataTypes.STRING(34), + }, { + timestamps: false, + indexes: [{ + unique: true, + fields: ['address'] + }], + }); + + const AddressVout = sequelize.define('AddressVout', {}, { timestamps: false }); + + Address.associate = function (models) { + models.Address.belongsToMany(models.Vout, { through: 'AddressVout' }); + }; + + return Address; +}; \ No newline at end of file diff --git a/models/block.js b/models/block.js new file mode 100644 index 0000000..9b9e978 --- /dev/null +++ b/models/block.js @@ -0,0 +1,33 @@ +'use strict'; +module.exports = (sequelize, DataTypes) => { + const Block = sequelize.define('Block', { + height: { + type: DataTypes.INTEGER.UNSIGNED, + primaryKey: true, + }, + hash: DataTypes.STRING(64), + confirmations: DataTypes.MEDIUMINT.UNSIGNED, + size: DataTypes.MEDIUMINT.UNSIGNED, + version: DataTypes.TINYINT.UNSIGNED, + merkleroot: DataTypes.STRING(64), + time: DataTypes.DATE, + nonce: DataTypes.BIGINT, + bits: DataTypes.STRING(8), + difficulty: DataTypes.DECIMAL(16, 8), + previousblockhash: DataTypes.STRING(64), + nextblockhash: DataTypes.STRING(64), + }, { + timestamps: false, + indexes: [{ + unique: true, + fields: ['hash', 'height'] + }], + freezeTableName: true, + }); + + Block.associate = function(models) { + models.Block.hasMany(models.Transaction); + }; + + return Block; +}; \ No newline at end of file diff --git a/models/failure.js b/models/failure.js new file mode 100644 index 0000000..44356e0 --- /dev/null +++ b/models/failure.js @@ -0,0 +1,12 @@ +'use strict'; + +module.exports = (sequelize, DataTypes) => { + const Failure = sequelize.define('Failure', { + msg: DataTypes.STRING, + }, { + timestamps: true, + updatedAt: false, + }); + + return Failure; +}; \ No newline at end of file diff --git a/models/index.js b/models/index.js new file mode 100644 index 0000000..5662f10 --- /dev/null +++ b/models/index.js @@ -0,0 +1,36 @@ +'use strict'; + +var fs = require('fs'); +var path = require('path'); +var Sequelize = require('sequelize'); +var basename = path.basename(__filename); +var env = process.env.NODE_ENV || 'development'; +var config = require(__dirname + '/../config/config.json')[env]; +var db = {}; + +if (config.use_env_variable) { + var sequelize = new Sequelize(process.env[config.use_env_variable], config); +} else { + var sequelize = new Sequelize(config.database, config.username, config.password, config); +} + +fs + .readdirSync(__dirname) + .filter(file => { + return (file.indexOf('.') !== 0) && (file !== basename) && (file.slice(-3) === '.js'); + }) + .forEach(file => { + var model = sequelize['import'](path.join(__dirname, file)); + db[model.name] = model; + }); + +Object.keys(db).forEach(modelName => { + if (db[modelName].associate) { + db[modelName].associate(db); + } +}); + +db.sequelize = sequelize; +db.Sequelize = Sequelize; + +module.exports = db; diff --git a/models/transaction.js b/models/transaction.js new file mode 100644 index 0000000..dc52897 --- /dev/null +++ b/models/transaction.js @@ -0,0 +1,32 @@ +'use strict'; + +module.exports = (sequelize, DataTypes) => { + const Transaction = sequelize.define('Transaction', { + txid: DataTypes.STRING(64), + }, { + timestamps: false, + indexes: [{ + unique: true, + fields: ['txid'] + }], + }); + + const TxToTx = sequelize.define('TxToTx', {}, { + timestamps: false, + }); + + Transaction.belongsToMany(Transaction, { through: TxToTx, as: 'txtx' }); + + Transaction.associate = function (models) { + models.Transaction.belongsTo(models.Block, { + onDelete: "CASCADE", + foreignKey: { + allowNull: false + } + }); + + models.Transaction.hasMany(models.Vout); + }; + + return Transaction; +}; \ No newline at end of file diff --git a/models/vout.js b/models/vout.js new file mode 100644 index 0000000..8fc46b5 --- /dev/null +++ b/models/vout.js @@ -0,0 +1,21 @@ +'use strict'; + +module.exports = (sequelize, DataTypes) => { + const Vout = sequelize.define('Vout', { + value: DataTypes.DECIMAL(16, 8), + }, { + timestamps: false, + }); + + Vout.associate = function (models) { + models.Vout.belongsTo(models.Transaction, { + onDelete: "CASCADE", + foreignKey: { + allowNull: false + } + }); + models.Vout.belongsToMany(models.Address, { through: 'AddressVout' }); + }; + + return Vout; +}; \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..086b7be --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "gostexplr", + "version": "0.0.0", + "private": true, + "scripts": { + "start": "node ./bin/www", + "initdb": "node ./bin/initdb.js", + "reinitTables": "node ./bin/reinitTables.js", + "syncBlockchain": "node ./bin/syncBlockchain.js" + }, + "dependencies": { + "babel-register": "^6.26.0", + "body-parser": "~1.18.2", + "cookie-parser": "~1.4.3", + "debug": "~2.6.9", + "express": "~4.15.5", + "jade": "~1.11.0", + "morgan": "~1.9.0", + "mysql": "^2.15.0", + "mysql2": "^1.5.1", + "sequelize": "^4.32.2", + "sequelize-cli": "^3.2.0", + "serve-favicon": "~2.4.5" + } +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..e8ade9c Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/images/gostcoin-b.png b/public/images/gostcoin-b.png new file mode 100644 index 0000000..4220243 Binary files /dev/null and b/public/images/gostcoin-b.png differ diff --git a/public/stylesheets/style.css b/public/stylesheets/style.css new file mode 100644 index 0000000..289dd2c --- /dev/null +++ b/public/stylesheets/style.css @@ -0,0 +1,57 @@ +body { + font: 16px monospace; + width: 1024px; + margin: 0 auto 1em; +} + +a { + color: #a7393d; +} + +a:hover { + color: #bf5c5f; +} + +.header h1 a { + color: black; + text-decoration: none; + align: center; +} + +.header h1 a img { + height: 2em; + position: relative; + margin-right: .5em; +} + +.header form input[type="submit"] { + padding: .2em .7em; +} + +@-moz-document url-prefix() { + .header form input[type="submit"] { + padding: .15em .7em; + } +} + +.header form input[type="text"] { + padding: .2em; +} + +.header h1 a span { + position: relative; + top: -0.5em; +} + +.header h1 a span:hover { + border-bottom: 3px solid; + border-bottom-color: #CF7F7F; +} + +.content { + margin-top: 2em; +} + +.capitalize { + text-transform: capitalize; +} \ No newline at end of file diff --git a/routes/address.js b/routes/address.js new file mode 100644 index 0000000..82c93ea --- /dev/null +++ b/routes/address.js @@ -0,0 +1,35 @@ +var models = require('../models'); +var express = require('express'); +var router = express.Router(); + +/* GET home page. */ +router.get('/:address', function(req, res, next) { + + const address = encodeURI(req.params.address); + + models.Address.findOne({ + where: { + address, + }, + include: { + model: models.Vout, + include: { + model: models.Transaction, + }, + }, + }) + .then((address) => { + if (address === null) { + res.status(404).render('404'); + return; + } + const txes = []; + address.Vouts.forEach((vout) => txes.push(vout.Transaction.txid)); + res.render('address', { + address: address.address, + txes, + }); + }); +}); + +module.exports = router; diff --git a/routes/block.js b/routes/block.js new file mode 100644 index 0000000..900be15 --- /dev/null +++ b/routes/block.js @@ -0,0 +1,28 @@ +var models = require('../models'); +var express = require('express'); +var router = express.Router(); + +/* GET home page. */ +router.get('/:hash', function(req, res, next) { + const hash = encodeURI(req.params.hash); + models.Block.findOne({ + where: { + hash, + }, + include: { + model: models.Transaction, + }, + }) + .then((block) => { + if (block === null) { + res.status(404).render('404'); + return; + } + block.dataValues.time = block.time.toUTCString(); + res.render('block', { + block, + }); + }); +}); + +module.exports = router; diff --git a/routes/index.js b/routes/index.js new file mode 100644 index 0000000..9de27f8 --- /dev/null +++ b/routes/index.js @@ -0,0 +1,19 @@ +var models = require('../models'); +var express = require('express'); +var router = express.Router(); + +/* GET home page. */ +router.get('/', function(req, res, next) { + models.Block.findAll({ + order: [['height', 'DESC']], + limit: 30, + }) + .then((blocks) => { + res.render('index', { + blocks, + }); + }); + +}); + +module.exports = router; diff --git a/routes/search.js b/routes/search.js new file mode 100644 index 0000000..2b57aeb --- /dev/null +++ b/routes/search.js @@ -0,0 +1,46 @@ +var models = require('../models'); +var express = require('express'); +var router = express.Router(); + +/* GET home page. */ +router.post('/', function(req, res, next) { + + const search = encodeURI(req.body.search); + + models.Address.findOne({ + where: { + address: search, + }, + }) + .then((address) => { + if (address) { + res.redirect(`/address/${address.address}`); + return; + } + models.Transaction.findOne({ + where: { + txid: search, + }, + }) + .then((transaction) => { + if (transaction) { + res.redirect(`/transaction/${transaction.txid}`); + return; + } + models.Block.findOne({ + where: { + hash: search, + }, + }) + .then((block) => { + if (block) { + res.redirect(`/block/${block.hash}`); + return; + } + res.status(404).render('404'); + }); + }); + }); +}); + +module.exports = router; diff --git a/routes/transaction.js b/routes/transaction.js new file mode 100644 index 0000000..c394532 --- /dev/null +++ b/routes/transaction.js @@ -0,0 +1,48 @@ +var models = require('../models'); +var express = require('express'); +var router = express.Router(); + +/* GET home page. */ +router.get('/:txid', function(req, res, next) { + const txid = encodeURI(req.params.txid); + + models.Transaction.findOne({ + where: { + txid, + }, + include: [{ + attributes: ['hash'], + model: models.Block, + },{ + model: models.Vout, + include: { + model: models.Address, + } + }, { + model: models.Transaction, + as: 'txtx', + }], + }) + .then((transaction) => { + if (transaction === null) { + res.status(404).render('404'); + return; + } + const vouts = []; + transaction.Vouts.forEach((vout) => { + vout.Addresses.forEach((address) => { + vouts.push({ + address: address.address, + value: vout.value, + }); + }); + }); + res.render('transaction', { + transaction, + vouts, + }); + }); + +}); + +module.exports = router; diff --git a/views/404.jade b/views/404.jade new file mode 100644 index 0000000..4082532 --- /dev/null +++ b/views/404.jade @@ -0,0 +1,4 @@ +extends layout + +block content + h3 Not found \ No newline at end of file diff --git a/views/address.jade b/views/address.jade new file mode 100644 index 0000000..b12719b --- /dev/null +++ b/views/address.jade @@ -0,0 +1,11 @@ +extends layout + +block content + h3 Address + div= address + + h3 Transactions + each val in txes + div + a(href='/transaction/#{val}/') #{val} + diff --git a/views/block.jade b/views/block.jade new file mode 100644 index 0000000..6dff623 --- /dev/null +++ b/views/block.jade @@ -0,0 +1,18 @@ +extends layout + +block content + h3 Block + + table + each key in Object.keys(block.dataValues) + if (key === 'Transactions') + -continue + tr + td.capitalize #{key} + td #{block[key]} + + h3 Transactions + each tx in block.Transactions + div + a(href='/transaction/#{tx.txid}/') #{tx.txid} + diff --git a/views/error.jade b/views/error.jade new file mode 100644 index 0000000..51ec12c --- /dev/null +++ b/views/error.jade @@ -0,0 +1,6 @@ +extends layout + +block content + h1= message + h2= error.status + pre #{error.stack} diff --git a/views/index.jade b/views/index.jade new file mode 100644 index 0000000..2d8c05b --- /dev/null +++ b/views/index.jade @@ -0,0 +1,11 @@ +extends layout + +block content + h3 Last 30 blocks + + table + each block in blocks + tr + td #{block.height} + td + a(href='/block/#{block.hash}/') #{block.hash} diff --git a/views/layout.jade b/views/layout.jade new file mode 100644 index 0000000..9b69ea6 --- /dev/null +++ b/views/layout.jade @@ -0,0 +1,18 @@ +doctype html +html + head + title GOSTcoin blockchain explorer + link(rel='stylesheet', href='/stylesheets/style.css') + link(rel='shortcut icon', href='/favicon.ico') + body + div.header + h1 + a(href='/') + img(src='/images/gostcoin-b.png') + span GOSTcoin blockchain explorer + div + form(action="/search/", method="POST") + input(type="text", name="search" placeholder="Search by transaction id, block hash or address", size="68") + input(type="submit", value=">>") + div.content + block content diff --git a/views/transaction.jade b/views/transaction.jade new file mode 100644 index 0000000..063c5a5 --- /dev/null +++ b/views/transaction.jade @@ -0,0 +1,31 @@ +extends layout + +block content + h3 Transaction + + table + tr + td Hash + td #{transaction.txid} + tr + td Block + td + a(href='/block/#{transaction.Block.hash}/') #{transaction.Block.hash} + + h3 In + if transaction.txtx.length + table + each val in transaction.txtx + tr + td + a(href='/transaction/#{val.txid}/') #{val.txid} + else + div Mined + + h3 Out + table + each val in vouts + tr + td + a(href='/address/#{val.address}/') #{val.address} + td #{val.value} \ No newline at end of file