libp2p-middleware-evm
Version:
EVM middleware for libp2p
308 lines (265 loc) • 11.4 kB
text/typescript
/* eslint-disable no-console */
import { type AbortOptions, type Connection, type Logger, type Startable, type Stream } from '@libp2p/interface'
import { type Wallet, verifyMessage } from 'ethers'
import { ruleDefinitionSchema, type EVMRuleEngine, type RuleDefinition } from 'evm-rule-engine'
import { lpStream } from 'it-length-prefixed-stream'
import { v7 as uuidv7 } from 'uuid'
import { z } from 'zod'
import { MAX_INBOUND_STREAMS, MAX_OUTBOUND_STREAMS, PROTOCOL_NAME, PROTOCOL_PREFIX, PROTOCOL_VERSION, TIMEOUT } from './constants.js'
import type { MiddlewareEVMComponents, MiddlewareEVMInit } from './index.js'
interface ToSign {
rules: RuleDefinition[]
timestamp: number
peerId: string
nonce: string
}
export class MiddlewareEVM implements Startable {
public readonly protocol: string
private readonly components: MiddlewareEVMComponents
private started: boolean
public readonly timeout: number
private readonly maxInboundStreams: number
private readonly maxOutboundStreams: number
private readonly runOnLimitedConnection: boolean
private readonly log: Logger
private readonly decoratedConnections: Set<string>
public evmRuleEngine: EVMRuleEngine
private readonly signer: Wallet
constructor (components: MiddlewareEVMComponents, init: MiddlewareEVMInit) {
this.components = components
this.log = components.logger.forComponent('libp2p:middleware-evm')
this.started = false
this.protocol = `/${init.protocolPrefix ?? PROTOCOL_PREFIX}/${PROTOCOL_NAME}/${PROTOCOL_VERSION}`
this.timeout = init.timeout ?? TIMEOUT
this.maxInboundStreams = init.maxInboundStreams ?? MAX_INBOUND_STREAMS
this.maxOutboundStreams = init.maxOutboundStreams ?? MAX_OUTBOUND_STREAMS
this.runOnLimitedConnection = init.runOnLimitedConnection ?? true
this.signer = init.signer
if (init.evmRuleEngine == null) {
throw new Error('EVM engine required')
}
this.evmRuleEngine = init.evmRuleEngine
this.decoratedConnections = new Set<string>()
this.handle = this.handle.bind(this)
this.handler = this.handler.bind(this)
}
readonly [Symbol.toStringTag] = 'libp2p-middleware-evm'
async start (): Promise<void> {
if (this.started) return
// Check if the protocol is already registered before trying to register it
try {
// Try to get existing handler first
this.components.registrar.getHandler(this.protocol)
// If we get here, the protocol is already registered
this.log(`Protocol ${this.protocol} already registered, skipping`)
} catch (err: any) {
// handle registering protocol
if (err.name === 'UnhandledProtocolError') {
await this.components.registrar.handle(this.protocol, this.handler, {
maxInboundStreams: this.maxInboundStreams,
maxOutboundStreams: this.maxOutboundStreams,
runOnLimitedConnection: this.runOnLimitedConnection
})
this.log(`Registered handler for ${this.protocol}`)
} else {
throw err
}
}
this.log(`Started evm middleware with protocol ${this.protocol}`)
this.started = true
}
async stop (): Promise<void> {
if (!this.started) return
// Unregister the protocol handler
try {
// Make sure the protocol is registered before trying to unregister it
this.components.registrar.getHandler(this.protocol)
await this.components.registrar.unhandle(this.protocol)
this.log(`Unregistered handler for ${this.protocol}`)
} catch (err: any) {
// If it's an UnhandledProtocolError, the protocol is already unregistered
if (err.name === 'UnhandledProtocolError') {
this.log(`Protocol ${this.protocol} already unregistered, skipping`)
} else {
// Unexpected error, log but don't throw (allow cleanup to continue)
this.log.error(`Error unregistering protocol ${this.protocol}: ${err.message}`)
}
}
this.decoratedConnections.clear()
this.started = false
this.log('Stopped evm middleware')
}
isStarted (): boolean {
return this.started
}
isDecorated (connection: Connection): boolean {
if (!this.started) return false
return this.decoratedConnections.has(connection.id)
}
wrappedRulesToSign (): string {
const toSign: ToSign = {
rules: this.evmRuleEngine.getRuleDefinitions(),
timestamp: Date.now(),
peerId: this.components.peerId.toString(),
nonce: uuidv7()
}
return JSON.stringify(toSign)
}
handler ({ stream, connection }: { stream: Stream, connection: Connection }): void {
try {
const promise = this.handle(stream, connection)
promise.catch(err => {
this.log('Error handling request', err)
})
} catch (err) {
this.log('Synchronous error in handler', err)
}
}
// Handle inbound EVM challenge-response requests from clients
public async handle (stream: Stream, connection: Connection): Promise<void> {
this.log('Received evm middleware connection request', connection.id, connection.remotePeer.toString())
try {
const lp = lpStream(stream)
this.log('created lpstream')
const wrappedRules = this.wrappedRulesToSign()
this.log('ping2: wrappedRules', wrappedRules)
try {
this.log('Sending EVM challenge to client')
await lp.write(new TextEncoder().encode(wrappedRules), { signal: AbortSignal.timeout(this.timeout) })
this.log('EVM Challenge sent successfully to client')
} catch (err: any) {
this.log.error('Error sending EVM challenge to client:', err.message)
connection.abort(new Error('Error sending EVM challenge to client'))
return
}
try {
this.log('Reading response to challenge')
const res = await lp.read({ signal: AbortSignal.timeout(this.timeout) })
this.log('Read response', res)
const responseSig = new TextDecoder().decode(res.slice())
const recoveredAddress = verifyMessage(wrappedRules, responseSig)
this.log('Recovered address:', recoveredAddress)
const evalRes = await this.evmRuleEngine.evaluate(recoveredAddress)
if (!evalRes.result) {
this.log('Failed EVM rule evaluation', evalRes)
connection.abort(new Error('Failed EVM rule evaluation'))
return
}
} catch (err: any) {
this.log.error('Error reading response:', err.message)
connection.abort(new Error('Error reading response'))
return
}
try {
this.decoratedConnections.add(connection.id)
this.log(`Connection ${connection.id} middleware negotiated successfully, sending OK`)
await lp.write(new TextEncoder().encode('OK'), { signal: AbortSignal.timeout(this.timeout) })
this.log('Sent OK to client, closing stream')
// await stream.close()
} catch (err: any) {
this.log.error('Error sending challenge to client:', err.message)
connection.abort(new Error('Error sending challenge to client'))
}
} catch (err: any) {
this.log.error('Error handling request', err)
}
}
// Authentication methods
public async decorate (stream: Stream, connection: Connection, abortOptions?: AbortOptions): Promise<boolean> {
this.log.trace('Decorate attempt for connection:', connection.id)
if (!this.started) {
this.log.error('middleware not started')
return false
}
// If already authenticated, return true
if (this.isDecorated(connection)) {
this.log('Connection middleware already applied:', connection.id)
return true
}
// We're going to initiate middleware with the server
// The server will send us a challenge that we need to respond to
this.log(`Initiating middleware for connection ${connection.id}`)
try {
// Open a stream to the remote peer using the EVM protocol
this.log('Opening EVM stream to peer', connection.remotePeer.toString(), 'on protocol', this.protocol)
const authStream = await connection.newStream(this.protocol, { signal: AbortSignal.timeout(this.timeout) })
const lp = lpStream(authStream)
try {
this.log('Waiting to receive challenge from server...')
const challengeBytes = await lp.read({ signal: AbortSignal.timeout(this.timeout) })
const challenge = new TextDecoder().decode(challengeBytes.slice())
this.log(`Received challenge from server: [${challenge}] (length: ${challenge.length})`)
const toSignSchema = z.object({
rules: z.array(ruleDefinitionSchema),
timestamp: z.number().refine(
(val) => {
const now = Date.now()
return Math.abs(now - val) <= 60000 // within 1 minute (60000 ms)
},
{
message: 'Timestamp must be within ±1 minute of the current time.'
}
),
peerId: z.string().refine(
(val) => {
return val === connection.remotePeer.toString()
},
{
message: 'Peer ID must be a valid PeerId'
}
),
nonce: z.string().uuid()
})
const validateRes = toSignSchema.safeParse(JSON.parse(challenge))
if (!validateRes.success) {
this.log.error(validateRes.error)
connection.abort(new Error('challenge failed validation'))
return false
}
const toSign = validateRes.data
if (!this.evmRuleEngine.validateRules(toSign.rules)) {
this.log.error('Error validation challenge')
connection.abort(new Error('Error validating challenge'))
return false
}
const sig = await this.signer.signMessage(challenge)
this.log(`Signed message : ${sig}`)
// Send response to server
try {
await lp.write(new TextEncoder().encode(sig), { signal: AbortSignal.timeout(this.timeout) })
this.log('Response sent successfully to server')
} catch (err: any) {
this.log.error('Error sending response to server:', err.message)
connection.abort(new Error('Error sending response to server'))
return false
}
try {
const isOK = await lp.read({ signal: AbortSignal.timeout(this.timeout) })
this.log('Read challenge ok')
// eslint-disable-next-line max-depth
if (new TextDecoder().decode(isOK.slice()) !== 'OK') {
this.log.error('reading challenge ok failed')
connection.abort(new Error('reading challenge ok failed'))
return false
}
this.decoratedConnections.add(connection.id)
await authStream.close()
return true
} catch (err: any) {
this.log.error('Error reading response from server:', err.message)
connection.abort(new Error('Error reading response from server'))
return false
}
} catch (err: any) {
this.log.error('Error sending response to server:', err.message)
connection.abort(new Error('Error sending response to server'))
return false
}
} catch (err: any) {
// eslint-disable-next-line no-console
this.log.error('Middleware error for connection', connection.id, err)
connection.abort(err)
return false
}
}
}