Dollarydoo 2 - The Back End
Dollarydoo’s Back End
Originally I built the back end, or The Server, for Dollarydoo using Typescript, Node.js, and Express. I had a single app.ts file that exposed the different endpoints via Express and I kept this file pretty lean. It did input validation before calling functions on an instance of my Blockchain
class.
It got the job done, it worked, but it cost money. It costs money to run EC2 instances all the time. This is a Potato Developer system. It had to be cheap if not free! After getting the whole back end up and running using AWS Elastic Beanstalk I noticed some pretty bad architectural decisions had been made in my haste to get something built. That’s fine. Cheap prototypes are something to strive for, and the best code you can write is code that can be deleted.
When the Node.js app restarted my blockchain was wiped from memory and everyone’s accomplishments (read: me, mining my own toy crypto) was lost forever. Kinda bad.
I rebuilt it in 2020.
Thankfully the vast majority of my Blockchain
class was copied over verbatim, and all of my unit tests still passed. I just changed the way the blockchain was persisted. I know we said NO DATABASES earlier, and so with that on mind my method of persistence changed to serialising the JSON object (the blockchain’s chain
property) to a file in S3.
The application itself was cut up. The app.ts file for the Express app was turned into multiple discrete serverless lambdas. Those are free if you don’t get much traffic, and I could fill a football stadium with the amount of traffic I wasn’t getting. Perfect.
I mucked around for a while getting the Cloud Formation written, IAM policies are a fun hurdle to get over and probably the hardest part. Oh, except for API Gateway, that was not very easy. Definitely that was hardest part except for the Github actions. I was spiralling into devops hell for a few days, and then everything was gravy.
…
I still don’t like yaml.
Anyway the really really high level setup looks like this:
Route 53 -> Cloudfront -> API Gateway -> Lambda ---sometimes---> S3 Bucket
Let’s talk about how Dollarydoo’s Blockchain worked. This is the entire Blockchain class. It’s imported by each of my lambdas and manages the whole show - except for business logic that I’ve extracted out for unit testing purposes.
// blockchain.ts
import * as crypto from "crypto"
import { decryptData, getHash } from "../../../common/crypto_helpers"
import { getBalance, proportionalRewardForMember, isValidProof } from "../../../common/business_logic"
import {
INVALID_DETAILS,
FRAUDULENT_TRANSACTION,
INSUFFICIENT_FUNDS,
STRING_TO_FIND,
REWARD_PER_PROOF,
MiningMember,
Transaction,
Block,
DOLLARYDOO_PRIME_MINISTER
} from "../../../common/constants"
import { S3 } from "aws-sdk"
import fetch from "node-fetch"
import { TRANSACTIONS_URL } from "./constants"
import { PutObjectOutput } from "aws-sdk/clients/s3"
export class Blockchain {
public chain: Array<Block>
public readonly publicKey: string
private ecdh: crypto.ECDH
constructor() {
this.ecdh = crypto.createECDH("secp256k1")
this.ecdh.generateKeys()
// there is no way that this can go bad
this.ecdh.setPrivateKey(
"Believe it or not, Secrets Manager costs money to keep a single secret. This is not the real private key",
"hex"
)
this.publicKey = this.ecdh.getPublicKey("hex", "compressed")
}
/**
* Grab the blockchain from S3
* @param url file in S3
*/
public async loadChain(url: string): Promise<boolean> {
return fetch(url).then(
(response) => {
return response.json().then((json) => {
this.chain = json
return true
})
},
(error) => {
console.log(error)
return false
}
)
}
/**
* Append a block to the blockchain
* @param proof proof of work (number)
* @param previous_hash the hash of the previous block in the blockchain
* @param transactions the list of transactions to be baked into this block
*/
private addBlock(proof: number, previous_hash: string, transactions: Transaction[]): Block {
const block: Block = {
index: this.chain.length,
proof: proof,
previous_hash: previous_hash,
timestamp: Date.now(),
transactions
}
this.chain.push(block)
return block
}
protected async decryptMessage(message: string, senderPublicKey: string): Promise<any> {
const sharedSecret = this.ecdh.computeSecret(senderPublicKey, "hex")
return decryptData(message, sharedSecret).then((message: string) => {
return message
})
}
// APIs
public async attemptToSubmitProof(proof: number, from: string): Promise<Block> {
return new Promise<Block>((resolve, reject) => {
const lastBlock = this.chain[this.chain.length - 1]
// see if it's a valid proof
const validProof = isValidProof(lastBlock.proof, proof, STRING_TO_FIND)
if (!validProof) {
throw Error("Proof not accepted. Someone may have beaten you to it!")
}
this.getPendingTransactionsFromS3(TRANSACTIONS_URL).then((transactions) => {
// reward the brave soul who found this valid proof of work
const transaction: Transaction = {
from: DOLLARYDOO_PRIME_MINISTER,
to: from,
amount: REWARD_PER_PROOF
}
transactions.push(transaction)
const chainTail = JSON.stringify(this.chain[this.chain.length - 1])
const hashedChainTail = getHash(chainTail)
// Add a new block to the blockchain
this.addBlock(proof, hashedChainTail, transactions)
return this.putBlockChainInS3().then(
() => {
// clear out the pending transactions in the bucket
return this.putPendingTransactionsInS3([]).then(() => {
// return the chain tail to the original caller
resolve(this.chain[this.chain.length - 1])
})
},
(error) => {
reject(error)
}
)
})
})
}
public async attemptToSubmitTransaction(
encryptedBlob: string,
senderPublicKey: string,
transaction: Transaction
): Promise<Transaction> {
//short circuit early
// you can not submit a transaction `to` yourself
// you can not submit a transaction where it isn't `from` you
if (transaction.to === senderPublicKey || transaction.from !== senderPublicKey) {
throw Error(INVALID_DETAILS)
}
if (transaction.amount <= 0) {
throw Error(INVALID_DETAILS)
}
const validTransaction = await this.decryptMessage(encryptedBlob, senderPublicKey)
.then(async (decrypted: any) => {
const shaResult = getHash(JSON.stringify(transaction))
if (shaResult === decrypted) {
return this.getPendingTransactionsFromS3(TRANSACTIONS_URL).then(async (transactions) => {
const balance = await getBalance(senderPublicKey, this.chain, transactions)
if (balance < transaction.amount) {
throw Error(INSUFFICIENT_FUNDS)
}
transactions.push(transaction)
return this.putPendingTransactionsInS3(transactions).then(() => {
return transaction
})
})
}
})
.catch((error: Error) => {
if ([FRAUDULENT_TRANSACTION, INSUFFICIENT_FUNDS, INVALID_DETAILS].indexOf(error.message) < 0) {
throw Error(FRAUDULENT_TRANSACTION)
} else {
throw error
}
})
return validTransaction
}
public async pendingTransactions(senderPublicKey: string): Promise<Transaction[]> {
const pendingTransactions = await this.getPendingTransactionsFromS3(TRANSACTIONS_URL)
const promise = new Promise<Transaction[]>((resolve, reject) => {
const pending = pendingTransactions.filter((transaction: Transaction) => {
return transaction.from === senderPublicKey
})
resolve(pending)
})
return promise
}
public async putBlockChainInS3(): Promise<PutObjectOutput> {
const s3 = new S3({ region: "ap-southeast-2" })
return s3
.putObject({
Bucket: "dollarydoo.potatodeveloper.com",
Key: "chain.json",
ContentType: "application/json",
CacheControl: "public, max-age=30",
Body: JSON.stringify(this.chain)
})
.promise()
}
public async getPendingTransactionsFromS3(url: string): Promise<Transaction[]> {
return fetch(url, { method: "GET" }).then(
(response) => {
return response.json().then((final) => {
return final
})
},
(error) => {
console.log(error)
return false
}
)
}
public async putPendingTransactionsInS3(transactions: Transaction[]): Promise<PutObjectOutput> {
const s3 = new S3({ region: "ap-southeast-2" })
return s3
.putObject({
Bucket: "dollarydoo.potatodeveloper.com",
Key: "pending_transactions.json",
ContentType: "application/json",
CacheControl: "no-cache, max-age=0",
Body: JSON.stringify(transactions)
})
.promise()
}
}
And here is the constants file that it references
export const STRING_TO_FIND = "123456"
export const INVALID_DETAILS = "Pobodies Nerfect! Invalid transaction details"
export const INSUFFICIENT_FUNDS = "That's not a knife that's a spoon. Also you have insufficient funds"
export const FRAUDULENT_TRANSACTION = "Fraudulent transaction detected. Discouraging the boot is a bootable offence."
export const REWARD_PER_PROOF = 5
export const DOLLARYDOO_PRIME_MINISTER = "Andy, drinking a Fosters in a dam."
export const FONT_FAMILY = "Verdana, Geneva, sans-serif"
export const API_BASE_URL = "https://dollarydooapi.potatodeveloper.com"
export interface Transaction {
from: string
to: string | MiningMember[]
amount: number
}
export interface MiningMember {
publicKey: string
percentOfEffort: number
}
export interface Block {
index: number
proof: number
previous_hash: string
timestamp: number
transactions: Array<Transaction>
}
And finally the business logic file
import { Block, Transaction } from "./constants"
import { getHash } from "./crypto_helpers"
import { MiningMember, DOLLARYDOO_PRIME_MINISTER } from "./constants"
const transactionElements = (
publicKey: string,
chain: Block[],
pendingTransactions?: Transaction[],
fromPersonOnly?: string
) => {
const transactionsForPublicKey: Array<Transaction> = []
// Gather all the transactions for a certain publicKey
chain.forEach((block: Block, index: number, all: Block[]) => {
const trans = block.transactions.filter((transaction: Transaction, index: number, all: Transaction[]) => {
if (fromPersonOnly) {
return transaction.from === fromPersonOnly
}
return transaction.to === publicKey || transaction.from === publicKey
})
transactionsForPublicKey.push(...trans)
})
// How much have they been GIVEN so far
const additions = transactionsForPublicKey.reduce((total, transaction, index, array) => {
if (transaction.to === publicKey) {
return total + transaction.amount
}
return total
}, 0)
// How much have they GIVEN AWAY so far
const deductions = transactionsForPublicKey.reduce((total, transaction, index, array) => {
if (transaction.from === publicKey) {
return total + transaction.amount
}
return total
}, 0)
// How much have the promised to GIVE AWAY when the next block is created
let pendingDeductions = 0
if (pendingTransactions) {
pendingDeductions = pendingTransactions.reduce((total, transaction, index, array) => {
if (transaction.from === publicKey) {
return total + transaction.amount
}
return total
}, 0)
}
return {
additions: additions,
deductions: deductions,
pendingDeductions: pendingDeductions
}
}
export const getDollarydooProofRewardAmount = (
publicKey: string,
chain: Block[],
pendingTransactions?: Transaction[]
): number => {
const { additions, deductions, pendingDeductions } = transactionElements(
publicKey,
chain,
pendingTransactions,
DOLLARYDOO_PRIME_MINISTER
)
return additions - deductions - pendingDeductions
}
export const getBalanceSync = (publicKey: string, chain: Block[], pendingTransactions?: Transaction[]): number => {
const { additions, deductions, pendingDeductions } = transactionElements(publicKey, chain, pendingTransactions)
return additions - deductions - pendingDeductions
}
export const getBalance = async (
publicKey: string,
chain: Block[],
pendingTransactions?: Transaction[]
): Promise<number> => {
const promise = new Promise<number>((resolve, reject) => {
const { additions, deductions, pendingDeductions } = transactionElements(publicKey, chain, pendingTransactions)
resolve(additions - deductions - pendingDeductions)
})
return promise
}
export const proportionalRewardForMember = (member: MiningMember, rewardPerProof: number): number => {
return Number((rewardPerProof * (member.percentOfEffort / 100)).toFixed(3))
}
export const isValidProof = (lastKnownProof: number, proof: number, stringToFind: string): boolean => {
const concatProofs = lastKnownProof.toString() + proof.toString()
const guess = getHash(concatProofs)
const guessLength = guess.length
const halfWay = parseInt((guessLength / 2).toFixed(0))
const shouldBeInFirstHalf = lastKnownProof % 2 === 0
const firstHalf = guess.substring(0, halfWay)
const secondHalf = guess.substring(halfWay, guessLength)
if (shouldBeInFirstHalf) {
return firstHalf.indexOf(stringToFind) >= 0
} else {
return secondHalf.indexOf(stringToFind) >= 0
}
}
Aside from my unit tests, there wasn’t much more to it. This is essentially all the core code for the whole lot. Now I needed to expose this functionality to my lambda functions. I had several operations that I wanted to expose via a restful API.
chain.json
https://dollarydoo.potatodeveloper.com/chain.json
This is a file in S3 that is publicly readable. This is my blockchain.
pending_transactions.json
https://dollarydoo.potatodeveloper.com/pending_transactions.json
This is a file in S3 that is publicly readable. This is where all the pending transactions sit. When someone submits a transaction, it gets put here. When someone submits a proof of work, these transactions are stored in that block, and the file is wiped (and the process starts again).
The Functions
GET /publickey
import { Context } from "aws-lambda"
import { responseBuilder } from "../core/response-builder"
import { Blockchain } from "../core/blockchain"
import { LambdaResponse } from "../core/types"
export async function handler(event: unknown, context: Context): Promise<LambdaResponse> {
const chain = new Blockchain()
return responseBuilder(200, { publicKey: chain.publicKey }, { "Cache-Control": "max-age=30, Public" })
}
This function returns The Server’s public key. This is fundamental for clients to be able to interact with my blockchain. The Server’s public key is used by all clients to create an Elliptic Curve Public / Private key shared secret. See the first Dollarydoo post for a refresher.
As you can tell by the constructor code in the Blockchain
class, the public key is available immediately after an instance is created. The public key is always the same, because the private key is SET to the same value every time. In my Potato Developer system I hard coded the seed data to create a private key. I would never hard code a secret like this into a real app. I will also not give Jeff Bezos my personal money to store a secret in his Secrets Manager for my Potato Developer side project. So here we are. The public key is going to be the same, every time. If it was different each time I created a new instance of the Blockchain class then I would be sunk, the whole system would not work.
The Nodejs crypto API I used is here: Nodejs Crypto docs.
POST /solve
import { Context } from "aws-lambda"
import { responseBuilder } from "../core/response-builder"
import { LambdaResponse } from "../core/types"
import { Blockchain } from "../core/blockchain"
import { BLOCKCHAIN_URL } from "../core/constants"
interface Event {
body: string
}
interface Submitted {
from: string
proof: number
}
const blockChain = new Blockchain()
let chainLoaded = false
export async function handler(event: Event, context: Context): Promise<LambdaResponse> {
if (!chainLoaded) {
await blockChain.loadChain(BLOCKCHAIN_URL).then(() => {
chainLoaded = true
})
}
const submittedData: Submitted = JSON.parse(event.body)
return blockChain.attemptToSubmitProof(submittedData.proof, submittedData.from).then(
(block) => {
return responseBuilder(200, block)
},
(error) => {
return responseBuilder(500, error.message)
}
)
}
This is where someone can submit their Proof of Work and get given Dollarydoos as a reward by The Server. Full details about what makes up a valid proof of work will be given in another post. Hang in there, just for now imagine that it’s some number that nobody could predict, given all of the public information available in the blockchain.
POST /transact
import { Context } from "aws-lambda"
import { responseBuilder } from "../core/response-builder"
import { LambdaResponse } from "../core/types"
import { Blockchain } from "../core/blockchain"
import { BLOCKCHAIN_URL } from "../core/constants"
import { Transaction } from "../../../common/constants"
interface Event {
body: string
}
interface Submitted {
from: string
to: string
amount: number
sender: string
encryptedBlob: string
}
const blockChain = new Blockchain()
let chainLoaded = false
export async function handler(event: Event, context: Context): Promise<LambdaResponse> {
if (!chainLoaded) {
await blockChain.loadChain(BLOCKCHAIN_URL).then(() => {
chainLoaded = true
})
}
const submittedData: Submitted = JSON.parse(event.body)
const transaction: Transaction = {
from: submittedData.from,
to: submittedData.to,
amount: submittedData.amount
}
return blockChain.attemptToSubmitTransaction(submittedData.encryptedBlob, submittedData.sender, transaction).then(
(transaction) => {
return responseBuilder(200, transaction)
},
(error) => {
return responseBuilder(500, { error: error.message })
}
)
}
This is where a person who has previously been given Dollarydoos before can give Dollarydoos to someone else. The function takes in a proposed transaction, and an encryptedBlock
object that I’ll give detail about further on. It’s the way that the server knows that the sender is true, valid, is the rightful owner of the from
address.
GET /pendingtransactions
import { Context } from "aws-lambda"
import { responseBuilder } from "../core/response-builder"
import { LambdaResponse } from "../core/types"
import { Blockchain } from "../core/blockchain"
import { BLOCKCHAIN_URL } from "../core/constants"
interface Event {
queryStringParameters: {
publickey: string
}
}
const blockChain = new Blockchain()
let chainLoaded = false
export async function handler(event: Event, context: Context): Promise<LambdaResponse> {
if (!chainLoaded) {
await blockChain.loadChain(BLOCKCHAIN_URL).then(() => {
chainLoaded = true
})
}
let publickey
if (event.queryStringParameters) {
publickey = event.queryStringParameters.publickey
}
if (publickey) {
const transactions = await blockChain.pendingTransactions(publickey)
return responseBuilder(200, transactions)
} else {
return responseBuilder(400, "Missing 'publickey' query string")
}
}
The pending transactions gives a list of the pending transactions for a given public key. I.e. for a certain public key xyz
, how many dollarydoos have they already promised to someone else. The api returns an array of Transaction
objects, and it’s up to the caller of this API to then determine what to do with this data.
Response Builder
//response-builder.ts
import { LambdaResponse, Headers } from "./types"
export function responseBuilder(code = 200, body: any = {}, headers: Headers = {}): Promise<LambdaResponse> {
headers = {
...headers,
"Access-Control-Allow-Origin": "https://dollarydoo.potatodeveloper.com"
}
return Promise.resolve({
statusCode: code,
body: `${JSON.stringify(body)}`,
headers
})
}
Back ends cool and all, but we need a way to interact with them
No system is complete without a user interface.
Up next - The Front End.