-
Notifications
You must be signed in to change notification settings - Fork 2
Submit drand numbers #318
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Submit drand numbers #318
Changes from all commits
8f2cb4f
f24da8e
50f1ed4
57244a3
8c40fdb
acbe5ad
c0de6bf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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() | ||
| } |
| 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 | ||
| } | ||
norswap marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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, | ||
| }) | ||
| } | ||
| } | ||
| 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)) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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) | ||
| } | ||
| } | ||
| 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) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 (Same in other places.)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I created this issue to respond to this comment: https://linear.app/happychain/issue/HAPPY-360/return-the-original-error-inside-the-randomness-service-to-allow |
||
| } | ||
|
|
||
| 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) | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.