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
2 changes: 1 addition & 1 deletion apps/randomness/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ CHAIN_ID=31337
RPC_URL=ws://127.0.0.1:8545
EVM_DRAND_GENESIS_TIMESTAMP_SECONDS=1727521075
EVM_DRAND_PERIOD_SECONDS=3
EVM_DRAND_START_ROUND=
EVM_DRAND_MARGIN=10
EVM_DRAND_URL=https://api.drand.sh/v2/beacons/evmnet
# The example value is the genesis timestamp of the HappyChain testnet
HAPPY_GENESIS_TIMESTAMP_SECONDS=1723165536
Expand Down
6 changes: 5 additions & 1 deletion apps/randomness/src/CustomGasEstimator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ export class CustomGasEstimator extends DefaultGasLimitEstimator {
return ok(75000n)
}
if (transaction.functionName === "revealValue") {
return ok(100000n)
return ok(40000n)
}

if (transaction.functionName === "postDrand") {
Comment thread
aodhgan marked this conversation as resolved.
return ok(300000n)
}

return this.simulateTransactionForGas(transactionManager, transaction)
Expand Down
126 changes: 124 additions & 2 deletions apps/randomness/src/DrandService.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { fetchWithRetry, nowInSeconds, unknownToError } from "@happy.tech/common"
import { fetchWithRetry, nowInSeconds, promiseWithResolvers, sleep, unknownToError } from "@happy.tech/common"
import type { Transaction } from "@happy.tech/txm"
import { type Result, ResultAsync, err, ok } from "neverthrow"
import type { Hex } from "viem"
import { z } from "zod"
import { Drand } from "./Drand"
import type { DrandRepository } from "./DrandRepository"
import type { TransactionFactory } from "./TransactionFactory"
import { env } from "./env"

const drandBeaconSchema = z.object({
Expand Down Expand Up @@ -31,14 +35,46 @@ export enum DrandError {
InvalidRound = "InvalidRound",
}

const MS_IN_SECOND = 1000

export class DrandService {
private readonly drandRepository: DrandRepository
private readonly transactionFactory: TransactionFactory
private pendingPostDrandTransactions: Transaction[] = []
private getDrandBeaconLocked = false
private pendingGetDrandBeaconPromises: PromiseWithResolvers<void>[] = []

constructor(drandRepository: DrandRepository, transactionFactory: TransactionFactory) {
this.drandRepository = drandRepository
this.transactionFactory = transactionFactory
}

async start() {
// Synchronize the retrieval of new Drand beacons with the Drand network to request them as soon as they become available.
const periodMs = Number(env.EVM_DRAND_PERIOD_SECONDS) * MS_IN_SECOND
const drandGenesisTimestampMs = Number(env.EVM_DRAND_GENESIS_TIMESTAMP_SECONDS) * MS_IN_SECOND
const now = Date.now()

// Calculates timestamp for the next Drand beacon:
// 1. Obtains the elapsed time since genesis: now - drandGenesisTimestampMs.
// 2. Divides this time by the period (periodMs) and rounds up to get the next round.
// 3. Converts the next round back to an absolute timestamp by multiplying by periodMs and adding drandGenesisTimestampMs.
const nextDrandBeaconTimestamp =
Math.ceil((now - drandGenesisTimestampMs) / periodMs) * periodMs + drandGenesisTimestampMs
await sleep(nextDrandBeaconTimestamp - now)

this.handleNewDrandBeacons()

setInterval(this.handleNewDrandBeacons.bind(this), periodMs)
}

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)
const response = await ResultAsync.fromPromise(fetchWithRetry(url, {}, 2, 1000), unknownToError)

if (response.isErr()) {
return err(DrandError.NetworkError)
Expand Down Expand Up @@ -73,4 +109,90 @@ export class DrandService {
)
return BigInt(currentRound)
}

pullDrandTransactions() {
const returnTransactions = this.pendingPostDrandTransactions
this.pendingPostDrandTransactions = []
return returnTransactions
}

// Implements a mutex to ensure that only one instance of this function executes at a time.
// Calls made while the mutex is locked are queued as pending promises.
// When the current execution completes, the most recent pending promise is immediately resolved,
// allowing it to proceed without waiting for the next interval, while any other queued promises are rejected.
async handleNewDrandBeacons() {
if (this.getDrandBeaconLocked) {
const pending = promiseWithResolvers<void>()
this.pendingGetDrandBeaconPromises.push(pending)

try {
await pending.promise
} catch {
return
}
}

this.getDrandBeaconLocked = true
try {
await this._handleNewDrandBeacons()
} catch (error) {
console.error("Error in handleNewDrandBeacons: ", error)
}
this.getDrandBeaconLocked = false

this.pendingGetDrandBeaconPromises.pop()?.resolve()
this.pendingGetDrandBeaconPromises.forEach((p) => p.reject())
}

private async _handleNewDrandBeacons() {
const currentRound = this.currentRound()
const drandGaps = this.drandRepository.findRoundGapsInRange(currentRound - env.EVM_DRAND_MARGIN, currentRound)

await Promise.all(
drandGaps.map(async (round) => {
let drandBeacon = await this.getDrandBeacon(round)
if (drandBeacon.isErr()) {
if (drandBeacon.error !== DrandError.TooEarly) {
console.error("Failed to get drand beacon", drandBeacon.error)
return
}

await sleep(1000)
drandBeacon = await this.getDrandBeacon(round)

if (drandBeacon.isErr()) {
console.error("Failed to get drand beacon", drandBeacon.error)
return
}
}

const postDrandTransactionResult = this.transactionFactory.createPostDrandTransaction({
round: round,
signature: drandBeacon.value.signature,
})

if (postDrandTransactionResult.isErr()) {
console.error("Failed to create post drand transaction", postDrandTransactionResult.error)
return
}

const postDrandTransaction = postDrandTransactionResult.value

const drand = Drand.create({
round: round,
signature: drandBeacon.value.signature,
transactionIntentId: postDrandTransaction.intentId,
})

const drandSaved = await this.drandRepository.saveDrand(drand)

if (drandSaved.isErr()) {
console.error("Failed to save drand", drandSaved.error)
return
}

this.pendingPostDrandTransactions.push(postDrandTransaction)
}),
)
}
}
2 changes: 1 addition & 1 deletion apps/randomness/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const envSchema = z.object({
EVM_DRAND_URL: z.string().trim(),
EVM_DRAND_GENESIS_TIMESTAMP_SECONDS: z.string().transform((s) => BigInt(s)),
EVM_DRAND_PERIOD_SECONDS: z.string().transform((s) => BigInt(s)),
EVM_DRAND_START_ROUND: z.string().transform((s) => BigInt(s)),
EVM_DRAND_MARGIN: z.string().transform((s) => BigInt(s)),
Comment thread
aodhgan marked this conversation as resolved.
})

const parsedEnv = envSchema.safeParse(process.env)
Expand Down
134 changes: 16 additions & 118 deletions apps/randomness/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,20 @@
import { promiseWithResolvers, sleep } from "@happy.tech/common"
import { abis } from "@happy.tech/contracts/random/anvil"
import { TransactionManager, TransactionStatus, TxmHookType } from "@happy.tech/txm"
import type { LatestBlock, Transaction } from "@happy.tech/txm"
import { CustomGasEstimator } from "./CustomGasEstimator.js"
import { Drand } from "./Drand"
import { DrandRepository } from "./DrandRepository"
import { DrandError, DrandService } from "./DrandService"
import { DrandService } from "./DrandService"
import { Randomness, RandomnessStatus } from "./Randomness.js"
import { RandomnessRepository } from "./RandomnessRepository.js"
import { TransactionFactory } from "./TransactionFactory.js"
import { env } from "./env.js"

const MS_IN_SECOND = 1000

class RandomnessService {
private readonly randomnessRepository: RandomnessRepository
private readonly drandRepository: DrandRepository
private readonly txm: TransactionManager
private readonly transactionFactory: TransactionFactory
private readonly drandService: DrandService
private getDrandBeaconLocked = false
private pendingGetDrandBeaconPromises: PromiseWithResolvers<void>[] = []
private pendingPostDrandTransactions: Transaction[] = []

constructor() {
this.randomnessRepository = new RandomnessRepository()
Expand All @@ -37,7 +30,7 @@ class RandomnessService {
},
})
this.transactionFactory = new TransactionFactory(this.txm, env.RANDOM_CONTRACT_ADDRESS, env.PRECOMMIT_DELAY)
this.drandService = new DrandService()
this.drandService = new DrandService(this.drandRepository, this.transactionFactory)
}

async start() {
Expand All @@ -49,46 +42,7 @@ class RandomnessService {
console.error(description)
})

// Synchronize the retrieval of new Drand beacons with the Drand network to request them as soon as they become available.
const periodMs = Number(env.EVM_DRAND_PERIOD_SECONDS) * MS_IN_SECOND
const drandGenesisTimestampMs = Number(env.EVM_DRAND_GENESIS_TIMESTAMP_SECONDS) * MS_IN_SECOND
const now = Date.now()

// Calculates timestamp for the next Drand beacon:
// 1. Obtains the elapsed time since genesis: now - drandGenesisTimestampMs.
// 2. Divides this time by the period (periodMs) and rounds up to get the next round.
// 3. Converts the next round back to an absolute timestamp by multiplying by periodMs and adding drandGenesisTimestampMs.
const nextDrandBeaconTimestamp =
Math.ceil((now - drandGenesisTimestampMs) / periodMs) * periodMs + drandGenesisTimestampMs
await sleep(nextDrandBeaconTimestamp - now)

// Implements a mutex to ensure that only one instance of this function executes at a time.
// Calls made while the mutex is locked are queued as pending promises.
// When the current execution completes, the most recent pending promise is immediately resolved,
// allowing it to proceed without waiting for the next interval, while any other queued promises are rejected.
setInterval(async () => {
if (this.getDrandBeaconLocked) {
const pending = promiseWithResolvers<void>()
this.pendingGetDrandBeaconPromises.push(pending)

try {
await pending.promise
} catch {
return
}
}

this.getDrandBeaconLocked = true
try {
await this.handleNewDrandBeacons()
} catch (error) {
console.error("Error in handleNewDrandBeacons: ", error)
}
this.getDrandBeaconLocked = false

this.pendingGetDrandBeaconPromises.pop()?.resolve()
this.pendingGetDrandBeaconPromises.forEach((p) => p.reject())
}, Number(env.EVM_DRAND_PERIOD_SECONDS) * MS_IN_SECOND)
await this.drandService.start()
}

private onTransactionStatusChange(transaction: Transaction) {
Expand Down Expand Up @@ -166,59 +120,6 @@ class RandomnessService {
}
}

private async handleNewDrandBeacons() {
const currentRound = this.drandService.currentRound()
const oldestDrand = this.drandRepository.getOldestDrandRound() ?? env.EVM_DRAND_START_ROUND
const drandGaps = this.drandRepository.findRoundGapsInRange(oldestDrand, currentRound)

await Promise.all(
drandGaps.map(async (round) => {
let drandBeacon = await this.drandService.getDrandBeacon(round)
if (drandBeacon.isErr()) {
if (drandBeacon.error !== DrandError.TooEarly) {
console.error("Failed to get drand beacon", drandBeacon.error)
return
}

await sleep(1000)
drandBeacon = await this.drandService.getDrandBeacon(round)

if (drandBeacon.isErr()) {
console.error("Failed to get drand beacon", drandBeacon.error)
return
}
}

const postDrandTransactionResult = this.transactionFactory.createPostDrandTransaction({
round: round,
signature: drandBeacon.value.signature,
})

if (postDrandTransactionResult.isErr()) {
console.error("Failed to create post drand transaction", postDrandTransactionResult.error)
return
}

const postDrandTransaction = postDrandTransactionResult.value

const drand = Drand.create({
round: round,
signature: drandBeacon.value.signature,
transactionIntentId: postDrandTransaction.intentId,
})

const drandSaved = await this.drandRepository.saveDrand(drand)

if (drandSaved.isErr()) {
console.error("Failed to save drand", drandSaved.error)
return
}

this.pendingPostDrandTransactions.push(postDrandTransaction)
}),
)
}

private async onCollectTransactions(block: LatestBlock): Promise<Transaction[]> {
const transactions: Transaction[] = []

Expand Down Expand Up @@ -260,26 +161,25 @@ class RandomnessService {

const randomnessToReveal = this.randomnessRepository.getRandomnessForBlockNumber(nextBlockNumber)

if (!randomnessToReveal) {
console.warn("Not found randomness to reveal with block number", nextBlockNumber)
return transactions
}
if (randomnessToReveal) {
const revealValueTransaction = this.transactionFactory.createRevealValueTransaction(randomnessToReveal)

const revealValueTransaction = this.transactionFactory.createRevealValueTransaction(randomnessToReveal)
transactions.unshift(revealValueTransaction)

transactions.unshift(revealValueTransaction)
randomnessToReveal.addRevealTransactionIntentId(revealValueTransaction.intentId)

randomnessToReveal.addRevealTransactionIntentId(revealValueTransaction.intentId)
this.randomnessRepository.updateRandomness(randomnessToReveal).then((result) => {
if (result.isErr()) {
console.error("Failed to update randomness", result.error)
}
})
}

this.randomnessRepository.updateRandomness(randomnessToReveal).then((result) => {
if (result.isErr()) {
console.error("Failed to update randomness", result.error)
}
})
const drandTransactions = this.drandService.pullDrandTransactions()

transactions.push(...this.pendingPostDrandTransactions)
transactions.push(...drandTransactions)

this.pendingPostDrandTransactions.map(async (transaction) => {
drandTransactions.map(async (transaction) => {
const drand = this.drandRepository.getDrandByTransactionIntentId(transaction.intentId)

if (!drand) {
Expand All @@ -293,8 +193,6 @@ class RandomnessService {
})
})

this.pendingPostDrandTransactions = []

return transactions
}
}
Expand Down
10 changes: 0 additions & 10 deletions apps/randomness/start-local-randomness.sh
Original file line number Diff line number Diff line change
Expand Up @@ -209,18 +209,8 @@ fi

echo "Contracts deployed"

echo "Fetching Drand round..."

round=$(curl -s $DRAND_URL | jq -r '.round')

echo "Drand round: $round"

echo "Setting environment variable $DRAND_ROUND_ENV_VAR to $round"
set_env_var .env $DRAND_ROUND_ENV_VAR $round false

make -C $SCRIPT_DIR/../../packages/txm build


empty_sqlite_db $TXM_DB_PATH
echo $TXM_DB_PATH
export TXM_DB_PATH=$TXM_DB_PATH
Expand Down
Loading