Add proof-of-elapsed-time based consensus algorithm

This commit is contained in:
Garrett Mills 2021-04-10 11:25:59 -05:00
parent fbc3711560
commit 9e4164632c
Signed by: garrettmills
GPG Key ID: D2BF5FBA8298F246
7 changed files with 1071 additions and 767 deletions

View File

@ -12,6 +12,7 @@
"@extollo/di": "^0.4.5", "@extollo/di": "^0.4.5",
"@extollo/lib": "^0.1.5", "@extollo/lib": "^0.1.5",
"@extollo/util": "^0.3.3", "@extollo/util": "^0.3.3",
"axios": "^0.21.1",
"bcrypt": "^5.0.1", "bcrypt": "^5.0.1",
"copyfiles": "^2.4.1", "copyfiles": "^2.4.1",
"firebase-admin": "^9.6.0", "firebase-admin": "^9.6.0",

File diff suppressed because it is too large Load Diff

View File

@ -33,6 +33,18 @@ export class Blockchain extends Controller {
})) }))
} }
/**
* Read the version of the blockchain held by this host, including the host's
* most recent submission, that has NOT been accepted yet.
*/
public async readBlockchainSubmission() {
return many((await this.blockchain.getSubmitChain()).map(x => {
// @ts-ignore
delete x.firebaseID
return x
}))
}
/** /**
* Determine whether the current blockchain is valid. * Determine whether the current blockchain is valid.
*/ */

View File

@ -13,8 +13,12 @@ Route.group('/api/v1', () => {
.pre('DebugOnly') .pre('DebugOnly')
Route.get('/chain', 'api:Blockchain.readBlockchain') Route.get('/chain', 'api:Blockchain.readBlockchain')
Route.get('/check', 'api:Blockchain.check') Route.get('/check', 'api:Blockchain.check')
.pre('api:FirebaseUserOnly') .pre('api:FirebaseUserOnly')
Route.get('/check-debug', 'api:Blockchain.check') Route.get('/check-debug', 'api:Blockchain.check')
.pre('DebugOnly') .pre('DebugOnly')
Route.get('/chain/submit', 'api:Blockchain.readBlockchainSubmission')
}) })

View File

@ -57,6 +57,23 @@ export interface BlockResourceItem extends FirebaseResourceItem {
lastBlockUUID: string; // the UUID of the previous block lastBlockUUID: string; // the UUID of the previous block
proof: string; // the generated proof-of-work string proof: string; // the generated proof-of-work string
timestamp: number; // millisecond unix timestamp when this block was created timestamp: number; // millisecond unix timestamp when this block was created
waitTime: number; // number of milliseconds between last block and this one
}
/**
* Returns true if the given item is a valid BlockResourceItem.
* @param what
*/
export function isBlockResourceItem(what: any): what is BlockResourceItem {
return (
typeof what?.uuid === 'string'
&& Array.isArray(what?.transactions)
&& typeof what?.lastBlockHash === 'string'
&& typeof what?.lastBlockUUID === 'string'
&& typeof what?.proof === 'string'
&& typeof what?.timestamp === 'number'
&& typeof what?.waitTime === 'number'
)
} }
/** /**

View File

@ -0,0 +1,24 @@
import {FirebaseResource, FirebaseResourceItem} from "../FirebaseResource"
import {Injectable} from "@extollo/di"
import {RTDBRef} from "../units/FirebaseUnit"
import {AsyncCollection} from "@extollo/util"
/**
* Interface representing a peer of this node.
*/
export interface PeerResourceItem extends FirebaseResourceItem {
host: string,
name?: string,
}
/**
* A Firebase realtime-database resource for managing blockchain peers.
*/
@Injectable()
export class PeerResource extends FirebaseResource<PeerResourceItem> {
public static collect(): AsyncCollection<PeerResourceItem> {
return new AsyncCollection<PeerResourceItem>(new PeerResource())
}
protected refName: RTDBRef = 'peers'
}

View File

@ -1,12 +1,20 @@
import { Singleton, Inject } from "@extollo/di" import { Singleton, Inject } from "@extollo/di"
import { Unit, Logging, Config, Application } from "@extollo/lib" import { Unit, Logging, Config, Application } from "@extollo/lib"
import { FirebaseUnit } from "./FirebaseUnit" import { FirebaseUnit } from "./FirebaseUnit"
import { BlockEncounterTransaction, BlockResource, BlockResourceItem, BlockTransaction } from "../rtdb/BlockResource" import {
BlockEncounterTransaction,
BlockResource,
BlockResourceItem,
BlockTransaction,
isBlockResourceItem
} from "../rtdb/BlockResource"
import { TransactionResourceItem } from "../rtdb/TransactionResource" import { TransactionResourceItem } from "../rtdb/TransactionResource"
import * as openpgp from "openpgp" import * as openpgp from "openpgp"
import * as crypto from "crypto" import * as crypto from "crypto"
import axios from "axios"
import { collect, uuid_v4 } from "@extollo/util" import { collect, uuid_v4 } from "@extollo/util"
import {ExposureResourceItem} from "../rtdb/ExposureResource"; import {ExposureResourceItem} from "../rtdb/ExposureResource"
import {PeerResource} from "../rtdb/PeerResource"
/** /**
* Utility wrapper class for a block in the chain. * Utility wrapper class for a block in the chain.
@ -20,6 +28,7 @@ export class Block implements BlockResourceItem {
lastBlockHash: string lastBlockHash: string
lastBlockUUID: string lastBlockUUID: string
proof: string proof: string
waitTime: number
get config(): Config { get config(): Config {
return Application.getApplication().make(Config) return Application.getApplication().make(Config)
@ -34,6 +43,7 @@ export class Block implements BlockResourceItem {
this.lastBlockUUID = rec.lastBlockUUID this.lastBlockUUID = rec.lastBlockUUID
this.proof = rec.proof this.proof = rec.proof
this.timestamp = rec.timestamp this.timestamp = rec.timestamp
this.waitTime = rec.waitTime
} }
/** Returns true if this is the genesis block. */ /** Returns true if this is the genesis block. */
@ -75,6 +85,7 @@ export class Block implements BlockResourceItem {
lastBlockUUID: this.lastBlockUUID, lastBlockUUID: this.lastBlockUUID,
proof: this.proof, proof: this.proof,
timestamp: this.timestamp, timestamp: this.timestamp,
waitTime: this.waitTime,
} }
} }
@ -82,6 +93,7 @@ export class Block implements BlockResourceItem {
toString() { toString() {
return [ return [
this.uuid, this.uuid,
this.waitTime,
JSON.stringify(this.transactions || [], undefined, 0), JSON.stringify(this.transactions || [], undefined, 0),
this.lastBlockHash, this.lastBlockHash,
this.lastBlockUUID, this.lastBlockUUID,
@ -113,6 +125,17 @@ export class Blockchain extends Unit {
@Inject() @Inject()
protected readonly config!: Config protected readonly config!: Config
/**
* Block transactions that will be attempted as part of this host's
* next block submission.
* @protected
*/
protected pendingTransactions: BlockTransaction[] = []
protected pendingSubmit?: Block
protected isSubmitting: boolean = false
/** /**
* Returns true if the given host is registered as a peer. * Returns true if the given host is registered as a peer.
* @param host * @param host
@ -126,8 +149,23 @@ export class Blockchain extends Unit {
* Get a list of all registered peers. * Get a list of all registered peers.
*/ */
public async getPeers(): Promise<Peer[]> { public async getPeers(): Promise<Peer[]> {
const data = await this.firebase.ref('peers').once('value') return PeerResource.collect().all()
return (data.val() as Peer[]) || [] }
/**
* From a peer, fetch the submission blockchain, if it is valid.
* @param peer
*/
public async getPeerSubmit(peer: Peer): Promise<Block[] | undefined> {
try {
const result = await axios.get(peer.host)
const blocks: unknown = result.data?.data?.records
if ( Array.isArray(blocks) && blocks.every(isBlockResourceItem) ) {
return blocks.map(x => new Block(x))
}
} catch (e) {
return undefined
}
} }
/** /**
@ -136,7 +174,14 @@ export class Blockchain extends Unit {
*/ */
public async registerPeer(peer: Peer) { public async registerPeer(peer: Peer) {
if (!(await this.hasPeer(peer.host))) { if (!(await this.hasPeer(peer.host))) {
await this.firebase.ref('peers').push().set(peer) await (<PeerResource> this.make(PeerResource)).push({
firebaseID: '',
seqID: -1,
name: peer.name,
host: peer.host,
})
this.refresh()
} }
} }
@ -164,8 +209,6 @@ export class Blockchain extends Unit {
return false; return false;
} }
const pass = ( const pass = (
block.lastBlockUUID === previous.uuid block.lastBlockUUID === previous.uuid
&& block.lastBlockHash === previous.hash() && block.lastBlockHash === previous.hash()
@ -187,7 +230,88 @@ export class Blockchain extends Unit {
} }
public async refresh() { public async refresh() {
if ( this.isSubmitting ) {
return
} else {
this.isSubmitting = true
}
const validSeqID = (await this.read()).reverse()[0]?.seqID
const peers = await this.getPeers()
const time_x_block: {[key: string]: Block} = {}
const time_x_peer: {[key: string]: Peer | true} = {}
for ( const peer of peers ) {
const blocks: Block[] | undefined = await this.getPeerSubmit(peer)
if ( blocks && await this.validate(blocks) ) {
const block = blocks.reverse()[0]
if ( !block || block.seqID === validSeqID || !block.seqID ) continue
time_x_block[block.waitTime] = block
time_x_peer[block.waitTime] = peer
}
}
if ( this.pendingTransactions.length && !this.pendingSubmit ) {
await this.attemptSubmit()
}
if ( this.pendingSubmit ) {
time_x_block[this.pendingSubmit.waitTime] = this.pendingSubmit
time_x_peer[this.pendingSubmit.waitTime] = true
}
const min = Math.min(...Object.keys(time_x_block).map(parseFloat))
const block = time_x_block[min]
const peer = time_x_peer[min]
await (<BlockResource>this.app().make(BlockResource)).push(block)
if ( peer === true ) {
this.pendingSubmit = undefined
this.pendingTransactions = []
} else {
this.pendingSubmit = undefined
await this.attemptSubmit()
}
this.isSubmitting = false
}
public async getSubmitChain(): Promise<BlockResourceItem[]> {
const blocks = await this.read()
const submit = await this.attemptSubmit()
if ( submit ) {
submit.seqID = blocks.length > 0 ? collect<BlockResourceItem>(blocks).max('seqID') + 1 : 0
blocks.push(submit.toItem())
}
return blocks
}
public async attemptSubmit() {
if ( !this.pendingSubmit && this.pendingTransactions.length ) {
const lastBlock = await this.getLastBlock()
const waitTime = this.random(3000, 5000)
const proof = await this.generateProofOfWork(lastBlock, waitTime)
const block: BlockResourceItem = {
timestamp: (new Date).getTime(),
uuid: uuid_v4(),
transactions: this.pendingTransactions,
lastBlockHash: lastBlock!.hash(),
lastBlockUUID: lastBlock!.uuid,
proof,
waitTime,
firebaseID: '',
seqID: -1,
}
this.pendingSubmit = new Block(block)
}
return this.pendingSubmit
} }
/** /**
@ -195,7 +319,16 @@ export class Blockchain extends Unit {
* @param group * @param group
*/ */
public async submitTransactions(group: [TransactionResourceItem, TransactionResourceItem]) { public async submitTransactions(group: [TransactionResourceItem, TransactionResourceItem]) {
const lastBlock = await this.getLastBlock() const txes = group.map(item => this.getEncounterTransaction(item))
if ( this.pendingSubmit ) {
this.pendingSubmit.transactions.push(...txes)
}
this.pendingTransactions.push(...txes)
this.refresh()
/*const lastBlock = await this.getLastBlock()
this.logging.verbose('Last block:') this.logging.verbose('Last block:')
this.logging.verbose(lastBlock) this.logging.verbose(lastBlock)
@ -213,7 +346,7 @@ export class Blockchain extends Unit {
} }
await (<BlockResource>this.app().make(BlockResource)).push(block) await (<BlockResource>this.app().make(BlockResource)).push(block)
return new Block(block) return new Block(block)*/
} }
/** /**
@ -221,7 +354,14 @@ export class Blockchain extends Unit {
* @param exposures * @param exposures
*/ */
public async submitExposures(...exposures: ExposureResourceItem[]) { public async submitExposures(...exposures: ExposureResourceItem[]) {
const lastBlock = await this.getLastBlock() if ( this.pendingSubmit ) {
this.pendingSubmit.transactions.push(...exposures)
}
this.pendingTransactions.push(...exposures)
this.refresh()
/*const lastBlock = await this.getLastBlock()
this.logging.verbose('Last block:') this.logging.verbose('Last block:')
this.logging.verbose(lastBlock) this.logging.verbose(lastBlock)
@ -239,7 +379,7 @@ export class Blockchain extends Unit {
} }
await (<BlockResource>this.app().make(BlockResource)).push(block) await (<BlockResource>this.app().make(BlockResource)).push(block)
return new Block(block) return new Block(block)*/
} }
/** /**
@ -263,6 +403,7 @@ export class Blockchain extends Unit {
})), })),
firebaseID: '', firebaseID: '',
seqID: -1, seqID: -1,
waitTime: 0,
}) })
} }
@ -301,13 +442,16 @@ export class Blockchain extends Unit {
/** /**
* Generate a proof of work string for the block that follows lastBlock. * Generate a proof of work string for the block that follows lastBlock.
* @param lastBlock * @param lastBlock
* @param waitTime
* @protected * @protected
*/ */
protected async generateProofOfWork(lastBlock: Block): Promise<string> { protected async generateProofOfWork(lastBlock: Block, waitTime: number): Promise<string> {
const hashString = lastBlock.hash() const hashString = lastBlock.hash()
const privateKey = this.config.get("app.gpg.key.private") const privateKey = this.config.get("app.gpg.key.private")
const message = openpgp.Message.fromText(hashString) const message = openpgp.Message.fromText(hashString)
await this.sleep(waitTime)
// Sign the hash using the server's private key // Sign the hash using the server's private key
return (await openpgp.sign({ return (await openpgp.sign({
message, message,
@ -338,4 +482,20 @@ export class Blockchain extends Unit {
return !!(await result.signatures?.[0]?.verified) return !!(await result.signatures?.[0]?.verified)
} }
/** Sleep for (roughly) the given number of milliseconds. */
async sleep(ms: number) {
await new Promise<void>(res => {
setTimeout(res, ms)
})
}
/**
* Get a random number between two values.
* @param min
* @param max
*/
random(min: number, max: number): number {
return Math.floor(Math.random() * (Math.floor(max) - Math.ceil(min) + 1)) + Math.ceil(min);
}
} }