Skip to content
Merged
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
1 change: 1 addition & 0 deletions apps/randomness/build.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { defineConfig } from "@happy.tech/happybuild"

export default defineConfig({
exports: [".", "./migrate"],
bunConfig: {
minify: false,
target: "node",
Expand Down
2 changes: 1 addition & 1 deletion apps/randomness/migrations/Migration20241210123000.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { Database } from "../src/db/types"
export async function up(db: Kysely<Database>) {
await db.schema
.createTable("randomnesses")
.addColumn("timestamp", "text", (col) => col.notNull())
.addColumn("blockNumber", "text", (col) => col.notNull())
.addColumn("value", "text", (col) => col.notNull())
.addColumn("hashedValue", "text", (col) => col.notNull())
.addColumn("commitmentTransactionIntentId", "text")
Expand Down
12 changes: 12 additions & 0 deletions apps/randomness/migrations/Migration20241219104500.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { Kysely } from "kysely"
import type { Database } from "../src/db/types"

export async function up(db: Kysely<Database>) {
await db.schema
.createTable("drands")
.addColumn("round", "text", (col) => col.notNull())
.addColumn("signature", "text")
.addColumn("status", "text", (col) => col.notNull())
.addColumn("transactionIntentId", "text")
.execute()
}
4 changes: 4 additions & 0 deletions apps/randomness/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
"type": "module",
"main": "./dist/index.es.js",
"module": "./dist/index.es.js",
"exports": {
".": "./dist/index.es.js",
"./migrate": "./dist/migrate.es.js"
},
"dependencies": {
"@happy.tech/common": "workspace:0.1.0",
"@happy.tech/txm": "workspace:0.1.0",
Expand Down
96 changes: 96 additions & 0 deletions apps/randomness/src/Drand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import type { Hex, UUID } from "@happy.tech/common"

export enum DrandStatus {
PENDING = "PENDING",
SUBMITTED = "SUBMITTED",
SUCCESS = "SUCCESS",
FAILED = "FAILED",
}

export class Drand {
#round: bigint
#signature: Hex
#status: DrandStatus
#transactionIntentId: UUID | undefined

static readonly validTransitions: Record<DrandStatus, DrandStatus[]> = {
PENDING: [DrandStatus.SUBMITTED, DrandStatus.FAILED],
SUBMITTED: [DrandStatus.SUCCESS, DrandStatus.FAILED],
SUCCESS: [],
FAILED: [],
}

constructor(params: {
status: DrandStatus
transactionIntentId?: UUID
round: bigint
signature: Hex
}) {
this.#status = params.status
this.#transactionIntentId = params.transactionIntentId
this.#round = params.round
this.#signature = params.signature
}

private setStatus(newStatus: DrandStatus): void {
if (!Drand.validTransitions[this.#status].includes(newStatus)) {
throw new Error(`Invalid status transition from ${this.#status} to ${newStatus}`)
}

this.#status = newStatus
}

executionSuccess(): void {
this.setStatus(DrandStatus.SUCCESS)
}

transactionSubmitted(): void {
this.setStatus(DrandStatus.SUBMITTED)
}

transactionFailed(): void {
this.setStatus(DrandStatus.FAILED)
}

get round(): bigint {
return this.#round
}

get signature(): Hex | undefined {
return this.#signature
}

get status(): DrandStatus {
return this.#status
}

get transactionIntentId(): UUID | undefined {
return this.#transactionIntentId
}

static create(params: {
transactionIntentId?: UUID
round: bigint
signature: Hex
}): Drand {
// Signature is a hex string with 128 characters + 2 for the "0x" prefix
if (params.signature.length !== 130) {
throw new Error("Invalid signature length")
}

if (!params.signature.startsWith("0x")) {
throw new Error("Signature must start with 0x")
}

if (params.round <= 0n) {
throw new Error("Round must be greater than 0")
}

return new Drand({
status: DrandStatus.PENDING,
round: params.round,
signature: params.signature,
transactionIntentId: params.transactionIntentId,
})
}
}
83 changes: 83 additions & 0 deletions apps/randomness/src/DrandRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { type Hex, type UUID, bigIntToZeroPadded, unknownToError } from "@happy.tech/common"
import { type Result, ResultAsync } from "neverthrow"
import { Drand } from "./Drand"
import { DIGITS_MAX_UINT256 } from "./constants"
import { db } from "./db/driver"
import type { DrandRow } from "./db/types"

export class DrandRepository {
private cache: Drand[] = []

private rowToEntity(row: DrandRow): Drand {
return new Drand({
round: BigInt(row.round),
signature: row.signature as Hex,
status: row.status,
transactionIntentId: row.transactionIntentId,
})
}

private entityToRow(entity: Drand): DrandRow {
return {
round: bigIntToZeroPadded(entity.round, DIGITS_MAX_UINT256),
signature: entity.signature,
status: entity.status,
transactionIntentId: entity.transactionIntentId,
}
}

async start(): Promise<void> {
const drandsDb = (await db.selectFrom("drands").selectAll().execute()).map(this.rowToEntity)

this.cache.push(...drandsDb)
}

async saveDrand(drand: Drand): Promise<Result<void, Error>> {
const row = this.entityToRow(drand)

const result = await ResultAsync.fromPromise(db.insertInto("drands").values(row).execute(), unknownToError)

if (result.isOk()) {
this.cache.push(drand)
}

return result.map(() => undefined)
}

getOldestDrandRound(): bigint | undefined {
if (this.cache.length === 0) {
return undefined
}
return this.cache.reduce((acc, drand) => (drand.round < acc ? drand.round : acc), this.cache[0].round)
}

findRoundGapsInRange(startRound: bigint, endRound: bigint): bigint[] {
const roundGaps = []
for (let round = startRound; round <= endRound; round++) {
if (!this.cache.find((drand) => drand.round === round)) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure how much the cache can grow, but this has O(cache_size * range_size) complexity.

Depending on how/when this is called, an alternative would be to make sure the cache is sorted and iterate the cache and range here at the same time.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It shouldn't grow a lot in the happy path scenarios. Actually, its length should always be 1 in the happy paths. However, it's true that if we have a big gap, this is not the most efficient way to implement it. I created this issue to address some of the problems we currently have with drand, and I tagged your comment so we can handle it as well

https://linear.app/happychain/issue/HAPPY-359/improve-how-we-initialize-the-drand-start-round-and-how-we-fill-the

roundGaps.push(round)
}
}
return roundGaps
}

getDrand(round: bigint): Drand | undefined {
return this.cache.find((drand) => drand.round === round)
}

getDrandByTransactionIntentId(transactionIntentId: UUID): Drand | undefined {
return this.cache.find((drand) => drand.transactionIntentId === transactionIntentId)
}

async updateDrand(drand: Drand): Promise<Result<void, Error>> {
const row = this.entityToRow(drand)
return ResultAsync.fromPromise(
db
.updateTable("drands")
.set(row)
.where("round", "=", bigIntToZeroPadded(drand.round, DIGITS_MAX_UINT256))
.execute(),
unknownToError,
).map(() => undefined)
}
}
76 changes: 76 additions & 0 deletions apps/randomness/src/DrandService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { fetchWithRetry, nowInSeconds, unknownToError } from "@happy.tech/common"
import { type Result, ResultAsync, err, ok } from "neverthrow"
import type { Hex } from "viem"
import { z } from "zod"
import { env } from "./env"

const drandBeaconSchema = z.object({
round: z.number().int().positive(),
signature: z
.string()
.transform((s) => s.toLowerCase())
.refine((s) => s.length === 128, {
message: "Signature must be 128 characters long",
})
.transform((s) => `0x${s}` as Hex)
.refine((s) => /^0x[0-9a-f]+$/.test(s), {
message: "Signature must contain only hexadecimal characters",
}),
})

export interface DrandBeacon {
round: number
signature: Hex
}

export enum DrandError {
NetworkError = "NetworkError",
InvalidResponse = "InvalidResponse",
TooEarly = "TooEarly",
Other = "Other",
InvalidRound = "InvalidRound",
}

export class DrandService {
async getDrandBeacon(round: bigint): Promise<Result<DrandBeacon, DrandError>> {
if (round <= 0n) {
return err(DrandError.InvalidRound)
}

const url = `${env.EVM_DRAND_URL}/rounds/${round}`
const response = await ResultAsync.fromPromise(fetchWithRetry(url, {}, 2, 500), unknownToError)

if (response.isErr()) {
return err(DrandError.NetworkError)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be good to provide the original error for context here I feel (like can be a cause field in the returned error — we mostly want it to show in logs / console stack traces).

(Same in other places.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}

if (!response.value.ok) {
if (response.value.status === 425) {
return err(DrandError.TooEarly)
}

console.error("Drand beacon fetch error status", response.value.status)
return err(DrandError.Other)
}

const dataRaw = await response.value.json()

const parsed = drandBeaconSchema.safeParse(dataRaw)

if (!parsed.success) {
return err(DrandError.InvalidResponse)
}

return ok(parsed.data)
}

currentRound(): bigint {
const currentTimestamp = nowInSeconds()
const currentRound = Math.floor(
(currentTimestamp - Number(env.EVM_DRAND_GENESIS_TIMESTAMP_SECONDS)) /
Number(env.EVM_DRAND_PERIOD_SECONDS) +
1,
)
return BigInt(currentRound)
}
}
Loading