Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/** @type {import('jest').Config} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
}
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
},
"devDependencies": {
"@types/aws-lambda": "^8.10.80",
"@types/jest": "^30.0.0",
"@types/node": "20.x",
"@types/serverless": "^3",
"aws-sdk": "^2.952.0",
Expand All @@ -28,6 +29,7 @@
"serverless": "^3.40.0",
"serverless-esbuild": "^1.57.0",
"serverless-offline": "^13.9.0",
"ts-jest": "^29.4.11",
"typescript": "^5.8.0"
}
}
90 changes: 87 additions & 3 deletions src/schema.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,95 @@
import { SchemaComposer } from 'graphql-compose'
import { GraphQLError } from 'graphql'
import { getBlockWithEnergy, getDailyEnergy } from './services/blockEnergy'
import { getWalletEnergy } from './services/walletEnergy'

const schemaComposer = new SchemaComposer()

const TransactionTC = schemaComposer.createObjectTC(`
type Transaction {
txId: String!
size: Int!
energyConsumptionKwh: Float!
}
`)

const BlockTC = schemaComposer.createObjectTC({
name: 'Block',
fields: {
hash: 'String!',
height: 'Int!',
time: 'Int!',
transactions: [TransactionTC],
totalEnergyConsumptionKwh: 'Float!',
},
})

const DailyEnergyConsumptionTC = schemaComposer.createObjectTC(`
type DailyEnergyConsumption {
date: String!
blockCount: Int!
totalEnergyConsumptionKwh: Float!
}
`)

const WalletEnergyConsumptionTC = schemaComposer.createObjectTC(`
type WalletEnergyConsumption {
address: String!
transactionCount: Int!
totalEnergyConsumptionKwh: Float!
}
`)

schemaComposer.Query.addFields({
hello: {
type: () => 'String!',
resolve: () => 'Hi there, good luck with the assignment!',
block: {
type: BlockTC,
args: { hash: 'String!' },
resolve: async (_source, args: { hash: string }) => {
try {
return await getBlockWithEnergy(args.hash)
} catch (error) {
throw new GraphQLError(
`Could not find block with hash "${args.hash}"`,
undefined,
undefined,
undefined,
undefined,
error instanceof Error ? error : undefined,
)
}
},
},
dailyEnergyConsumption: {
type: [DailyEnergyConsumptionTC],
args: {
days: 'Int!',
// Dev/demo escape to keep this inside sync request timeouts
maxBlocksPerDay: 'Int',
},
resolve: async (_source, args: { days: number; maxBlocksPerDay?: number }) =>
getDailyEnergy(args.days, args.maxBlocksPerDay),
},
walletEnergyConsumption: {
type: WalletEnergyConsumptionTC,
args: {
address: 'String!',
// Dev/demo escape to keep this inside sync request timeouts
maxTransactions: 'Int',
},
resolve: async (_source, args: { address: string; maxTransactions?: number }) => {
try {
return await getWalletEnergy(args.address, args.maxTransactions)
} catch (error) {
throw new GraphQLError(
`Could not find wallet with address "${args.address}"`,
undefined,
undefined,
undefined,
undefined,
error instanceof Error ? error : undefined,
)
}
},
},
})

Expand Down
80 changes: 80 additions & 0 deletions src/services/blockEnergy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { getBlockWithEnergy, getDailyEnergy } from './blockEnergy'
import { getBlock, getBlocksForDay } from './blockchainApi'
import { KWH_PER_BYTE } from './energy'
import type { RawBlock, RawBlockSummary } from '../types/blockchain'

jest.mock('./blockchainApi')

const mockGetBlock = getBlock as jest.MockedFunction<typeof getBlock>
const mockGetBlocksForDay = getBlocksForDay as jest.MockedFunction<typeof getBlocksForDay>

afterEach(() => {
jest.resetAllMocks()
})

describe('getBlockWithEnergy', () => {
it('computes per-transaction and total energy for a block', async () => {
const block: RawBlock = {
hash: 'block-1',
height: 100,
time: 123,
tx: [
{ txId: 'tx-1', size: 200 },
{ txId: 'tx-2', size: 300 },
],
}
mockGetBlock.mockResolvedValue(block)

const result = await getBlockWithEnergy('block-1')

expect(mockGetBlock).toHaveBeenCalledWith('block-1')
expect(result.hash).toBe('block-1')
expect(result.transactions).toEqual([
{ txId: 'tx-1', size: 200, energyConsumptionKwh: 200 * KWH_PER_BYTE },
{ txId: 'tx-2', size: 300, energyConsumptionKwh: 300 * KWH_PER_BYTE },
])
expect(result.totalEnergyConsumptionKwh).toBeCloseTo(500 * KWH_PER_BYTE)
})
})

describe('getDailyEnergy', () => {
const summaries: RawBlockSummary[] = [
{ hash: 'b1', height: 1, time: 1 },
{ hash: 'b2', height: 2, time: 2 },
{ hash: 'b3', height: 3, time: 3 },
]

const blocksByHash: Record<string, RawBlock> = {
b1: { hash: 'b1', height: 1, time: 1, tx: [{ txId: 't1', size: 100 }] },
b2: { hash: 'b2', height: 2, time: 2, tx: [{ txId: 't2', size: 200 }] },
b3: { hash: 'b3', height: 3, time: 3, tx: [{ txId: 't3', size: 300 }] },
}

beforeEach(() => {
mockGetBlocksForDay.mockResolvedValue(summaries)
mockGetBlock.mockImplementation(async (hash: string) => blocksByHash[hash])
})

it('sums energy across every block for each requested day', async () => {
const results = await getDailyEnergy(2)

expect(results).toHaveLength(2)
expect(mockGetBlocksForDay).toHaveBeenCalledTimes(2)
for (const day of results) {
expect(day.blockCount).toBe(3)
expect(day.totalEnergyConsumptionKwh).toBeCloseTo((100 + 200 + 300) * KWH_PER_BYTE)
}
})

it('truncates to maxBlocksPerDay when provided (dev/demo escape hatch)', async () => {
const results = await getDailyEnergy(1, 2)

expect(mockGetBlock).toHaveBeenCalledTimes(2)
expect(mockGetBlock).toHaveBeenCalledWith('b1')
expect(mockGetBlock).toHaveBeenCalledWith('b2')
expect(mockGetBlock).not.toHaveBeenCalledWith('b3')

expect(results[0].blockCount).toBe(2)
expect(results[0].totalEnergyConsumptionKwh).toBeCloseTo((100 + 200) * KWH_PER_BYTE)
})
})
69 changes: 69 additions & 0 deletions src/services/blockEnergy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { getBlock, getBlocksForDay } from './blockchainApi'
import { blockEnergyKwh, transactionEnergyKwh } from './energy'

export interface TransactionWithEnergy {
txId: string
size: number
energyConsumptionKwh: number
}

export interface BlockWithEnergy {
hash: string
height: number
time: number
transactions: TransactionWithEnergy[]
totalEnergyConsumptionKwh: number
}

export async function getBlockWithEnergy(hash: string): Promise<BlockWithEnergy> {
const block = await getBlock(hash)
return {
hash: block.hash,
height: block.height,
time: block.time,
transactions: block.tx.map((tx) => ({
txId: tx.txId,
size: tx.size,
energyConsumptionKwh: transactionEnergyKwh(tx.size),
})),
totalEnergyConsumptionKwh: blockEnergyKwh(block),
}
}

export interface DailyEnergyConsumption {
date: string
blockCount: number
totalEnergyConsumptionKwh: number
}

// Walks backwards from today (UTC calendar days) for days, fetching
// every block mined each day and summing their transactions' energy cost.
// Known scaling limit: a BTC day has around 150 blocks, and blockchain.info's
// /rawblock response (the only way to get per-transaction sizes) can be
// several MB per block. Recomputing this on every request exceeds timeout.
// In real world, we usually have an indexer, that runs as a background job,
// and stores the daily energy consumption in a DB for fast retrieval.

// `maxBlocksPerDay` is a dev/demo escape : it truncates the
// blocks summed per day so the query returns inside the sync timeout for testing.

export async function getDailyEnergy(days: number, maxBlocksPerDay?: number): Promise<DailyEnergyConsumption[]> {
const today = new Date()
const results: DailyEnergyConsumption[] = []

for (let i = 0; i < days; i++) {
const day = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - i))
const blockSummaries = await getBlocksForDay(day.getTime())
const limitedSummaries =
maxBlocksPerDay === undefined ? blockSummaries : blockSummaries.slice(0, maxBlocksPerDay)
const blocks = await Promise.all(limitedSummaries.map((summary) => getBlock(summary.hash)))

results.push({
date: day.toISOString().slice(0, 10),
blockCount: blocks.length,
totalEnergyConsumptionKwh: blocks.reduce((total, block) => total + blockEnergyKwh(block), 0),
})
}

return results
}
68 changes: 68 additions & 0 deletions src/services/blockchainApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { RawAddressPage, RawBlock, RawBlockSummary } from '../types/blockchain'
import { getOrSet } from './cache'

const BASE_URL = 'https://blockchain.info'

// A block's own data (by hash) never changes once mined, so it's cached
// forever. The day-blocks list and an address's transaction history can
// both still be growing (new blocks/transactions arrive over time), so
// they get a short TTL instead of "forever".
const BLOCK_TTL_SECONDS = null
const BLOCKS_FOR_DAY_TTL_SECONDS = 300
const ADDRESS_PAGE_TTL_SECONDS = 300

// Every network call to blockchain.info funnels through this one function,
// wrapped in the in-memory cache from ./cache (see that file for its
// scope/limitations).
async function fetchJson<T>(path: string, ttlSeconds: number | null): Promise<T> {
return getOrSet(`blockchain-api:${path}`, ttlSeconds, async () => {
const response = await fetch(`${BASE_URL}${path}`)
if (!response.ok) {
throw new Error(`blockchain.info request failed (${response.status}): ${path}`)
}
return (await response.json()) as T
})
}

interface RawBlockResponse {
hash: string
height: number
time: number
tx: Array<{ hash: string; size: number }>
}

export async function getBlock(hash: string): Promise<RawBlock> {
const raw = await fetchJson<RawBlockResponse>(`/rawblock/${hash}`, BLOCK_TTL_SECONDS)
return {
hash: raw.hash,
height: raw.height,
time: raw.time,
tx: raw.tx.map((t) => ({ txId: t.hash, size: t.size })),
}
}

export async function getBlocksForDay(dayStartMs: number): Promise<RawBlockSummary[]> {
return fetchJson<RawBlockSummary[]>(`/blocks/${dayStartMs}?format=json`, BLOCKS_FOR_DAY_TTL_SECONDS)
}

interface RawAddressResponse {
address: string
n_tx: number
txs: Array<{ hash: string; size: number }>
}

export async function getAddressTransactionsPage(
address: string,
limit: number,
offset: number,
): Promise<RawAddressPage> {
const raw = await fetchJson<RawAddressResponse>(
`/rawaddr/${address}?limit=${limit}&offset=${offset}`,
ADDRESS_PAGE_TTL_SECONDS,
)
return {
address: raw.address,
totalTxCount: raw.n_tx,
transactions: raw.txs.map((t) => ({ txId: t.hash, size: t.size })),
}
}
26 changes: 26 additions & 0 deletions src/services/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
interface CacheEntry {
value: unknown
expiresAt: number | null // epoch ms; null = never expires
}

const store = new Map<string, CacheEntry>()

// In-memory cache using Map. Would not work in a production environment
// with multiple containers. In production use Redis.
export async function getOrSet<T>(
key: string,
ttlSeconds: number | null,
fetcher: () => Promise<T>,
): Promise<T> {
const entry = store.get(key)
if (entry && (entry.expiresAt === null || entry.expiresAt > Date.now())) {
return entry.value as T
}

const value = await fetcher()
store.set(key, {
value,
expiresAt: ttlSeconds === null ? null : Date.now() + ttlSeconds * 1000,
})
return value
}
30 changes: 30 additions & 0 deletions src/services/energy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { blockEnergyKwh, KWH_PER_BYTE, transactionEnergyKwh } from './energy'
import type { RawBlock } from '../types/blockchain'

describe('transactionEnergyKwh', () => {
it('multiplies size in bytes by the per-byte energy cost', () => {
expect(transactionEnergyKwh(100)).toBe(100 * KWH_PER_BYTE)
expect(transactionEnergyKwh(0)).toBe(0)
})
})

describe('blockEnergyKwh', () => {
it('sums each transaction\'s energy cost', () => {
const block: RawBlock = {
hash: 'block-hash',
height: 1,
time: 0,
tx: [
{ txId: 'a', size: 100 },
{ txId: 'b', size: 250 },
],
}

expect(blockEnergyKwh(block)).toBeCloseTo(100 * KWH_PER_BYTE + 250 * KWH_PER_BYTE)
})

it('returns 0 for a block with no transactions', () => {
const block: RawBlock = { hash: 'empty', height: 1, time: 0, tx: [] }
expect(blockEnergyKwh(block)).toBe(0)
})
})
Loading