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
183 changes: 177 additions & 6 deletions README.md

Large diffs are not rendered by default.

25 changes: 24 additions & 1 deletion devtools/config/networks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,37 @@ export const OwnershipTransfer: Partial<Record<EndpointId, string>> = {

/**
* Expansion network configurations
*
* This list is kept as the historical record of every network that has ever been part of the
* mesh, including ones currently being (or already) decommissioned — see `UnwiredNetworks`
* below. Do not remove a network from here to take it out of the mesh; add its eid to
* `UnwiredNetworks` instead, which `generateWireConfig` excludes when building pathways. This
* keeps re-wiring a decommissioned network later a one-line change (removing it from
* `UnwiredNetworks`) rather than having to reconstruct what was deleted.
*/
export const ExpansionNetworks = {
testnet: [EndpointId.SEPOLIA_V2_TESTNET] as EndpointId[],
mainnet: [
EndpointId.BASE_V2_MAINNET,
EndpointId.BERA_V2_MAINNET,
EndpointId.BSC_V2_MAINNET,
EndpointId.BOTANIX_V2_MAINNET,
EndpointId.BOTANIX_V2_MAINNET, // unwired
EndpointId.ETHEREUM_V2_MAINNET,
] as EndpointId[],
}

/**
* Networks that are in the process of being (or have been) removed from the mesh via
* `lz:sdk:unwire` (see devtools/wire/unwire-generator.ts). Listed here, `generateWireConfig`
* excludes them from the pathways it builds, so a routine `lz:sdk:wire` run for an unrelated
* market pair can't silently re-wire a network mid-teardown — without needing to actually
* remove it from `ExpansionNetworks`/`hardhat.config.ts`/etc. `lz:sdk:unwire` itself requires
* its target to be listed here, so an active network can't be accidentally unwired.
*
* To reverse a removal, delete the eid from this list and re-run the normal `lz:sdk:wire`
* flow — no other config changes are needed.
*/
export const UnwiredNetworks = {
testnet: [] as EndpointId[],
mainnet: [EndpointId.BOTANIX_V2_MAINNET] as EndpointId[],
}
4 changes: 3 additions & 1 deletion devtools/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export * from './config'
export * from './types'
export * from './deploy'
export { generateWireConfig } from './wire'
export { generateWireConfig, generateUnwireConfig } from './wire'
export type { UnwirePhase } from './wire'
export * from './payloads'
1 change: 1 addition & 0 deletions devtools/payloads/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './to-safe-batch'
133 changes: 133 additions & 0 deletions devtools/payloads/to-safe-batch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import fs from 'fs'
import path from 'path'

import { getNetworkNameForEid } from '@layerzerolabs/devtools-evm-hardhat'

import { OwnershipTransfer } from '../config'

import type { EndpointId } from '@layerzerolabs/lz-definitions'
import type { HardhatRuntimeEnvironment } from 'hardhat/types'

// Matches the shape LayerZero's `lz:oapp:wire` task writes via `--output-filename` /
// `--generate-payloads` (a raw dump of `OmniTransaction[]`), which is not directly importable
// into Safe's Transaction Builder.
interface RawOmniTransaction {
point: { eid: EndpointId; address: string }
data: string
description?: string
value?: string | number
}

interface SafeBatchTransaction {
to: string
value: string
data: string
contractMethod: null
contractInputsValues: null
}

interface SafeBatchFile {
version: string
chainId: string
createdAt: number
meta: {
name: string
description: string
txBuilderVersion: string
createdFromSafeAddress?: string
}
transactions: SafeBatchTransaction[]
}

function getNativeChainId(networkName: string): string {
const chainIdPath = path.join(process.cwd(), 'deployments', networkName, '.chainId')
if (!fs.existsSync(chainIdPath)) {
throw new Error(`No .chainId file found for network '${networkName}' at ${chainIdPath}`)
}
return fs.readFileSync(chainIdPath, 'utf8').trim()
}

/**
* Groups raw OmniTransactions (as written by `lz:oapp:wire --generate-payloads`) by native
* chain id, producing one Gnosis Safe Transaction Builder batch per chain. Multiple input
* files' transactions can be concatenated by the caller before calling this (e.g. to combine
* a receive-block phase and an unpeer phase into a single atomic Safe batch).
*/
export function groupTransactionsByChain(
transactions: RawOmniTransaction[],
hre: HardhatRuntimeEnvironment,
meta: { name: string; description: string }
): Map<string, SafeBatchFile> {
const batches = new Map<string, SafeBatchFile>()
// Guards against duplicate input files (e.g. an --input glob matching more than one
// timestamped run of the same market pair/phase left over in payloads/) silently doubling
// up a call in the Safe batch. Two transactions with the same target contract and calldata
// are always the same intended change, so it's safe to keep only one.
const seenPerChain = new Map<string, Set<string>>()
let duplicatesDropped = 0

for (const tx of transactions) {
const networkName = getNetworkNameForEid(tx.point.eid, hre)
const chainId = getNativeChainId(networkName)

const seen = seenPerChain.get(chainId) ?? new Set<string>()
seenPerChain.set(chainId, seen)
const dedupeKey = `${tx.point.address.toLowerCase()}:${tx.data.toLowerCase()}`
if (seen.has(dedupeKey)) {
duplicatesDropped++
continue
}
seen.add(dedupeKey)

let batch = batches.get(chainId)
if (!batch) {
batch = {
version: '1.0',
chainId,
createdAt: Date.now(),
meta: {
name: meta.name,
description: meta.description,
txBuilderVersion: '1.16.5',
createdFromSafeAddress: OwnershipTransfer[tx.point.eid],
},
transactions: [],
}
batches.set(chainId, batch)
}

batch.transactions.push({
to: tx.point.address,
value: String(tx.value ?? '0'),
data: tx.data,
contractMethod: null,
contractInputsValues: null,
})
}

if (duplicatesDropped > 0) {
console.warn(
`WARNING: dropped ${duplicatesDropped} duplicate transaction(s) (identical target + calldata) — ` +
`check your --input glob isn't matching multiple stale runs of the same market pair/phase in payloads/`
)
}

return batches
}

export function writeSafeBatchFiles(
batches: Map<string, SafeBatchFile>,
outputDir: string,
filePrefix: string
): string[] {
fs.mkdirSync(outputDir, { recursive: true })
const written: string[] = []

for (const [chainId, batch] of batches) {
const filePath = path.join(outputDir, `${filePrefix}-chain-${chainId}.json`)
fs.writeFileSync(filePath, JSON.stringify(batch, null, 2))
written.push(filePath)
}

return written
}
58 changes: 58 additions & 0 deletions devtools/wire/blocked-library.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { METADATA_URL } from '@layerzerolabs/metadata-tools'

import type { EndpointId } from '@layerzerolabs/lz-definitions'

interface RawMetadataDeployment {
eid: string
chainKey: string
version: number
blockedMessageLib?: { address: string }
}

interface RawMetadata {
[chainKey: string]: { deployments?: RawMetadataDeployment[] }
}

let cachedMetadata: Promise<RawMetadata> | undefined

async function fetchRawMetadata(): Promise<RawMetadata> {
if (!cachedMetadata) {
cachedMetadata = fetch(METADATA_URL).then((res) => {
if (!res.ok) {
throw new Error(
`Failed to fetch LayerZero metadata from ${METADATA_URL}: ${res.status} ${res.statusText}`
)
}
return res.json() as Promise<RawMetadata>
})
}
return cachedMetadata
}

function findV2Deployment(eid: EndpointId, metadata: RawMetadata): RawMetadataDeployment {
const eidStr = eid.toString()
for (const chainKey in metadata) {
for (const deployment of metadata[chainKey].deployments ?? []) {
if (deployment.eid === eidStr && deployment.version === 2) {
return deployment
}
}
}
throw new Error(`Could not find a v2 LayerZero deployment for eid ${eid} in metadata from ${METADATA_URL}`)
}

/**
* Every LayerZero V2 endpoint self-deploys and registers an immutable BlockedMessageLib
* (a SendAndReceive library whose fallback always reverts). Pointing an OApp's send or
* receive library at this address permanently blocks that direction of a pathway.
*/
export async function getBlockedLibraryAddress(eid: EndpointId): Promise<string> {
const deployment = findV2Deployment(eid, await fetchRawMetadata())
const address = deployment.blockedMessageLib?.address
if (!address) {
throw new Error(
`No blockedMessageLib found in LayerZero metadata for eid ${eid} (chainKey: ${deployment.chainKey})`
)
}
return address
}
2 changes: 2 additions & 0 deletions devtools/wire/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export * from './wire-generator'
export * from './config'
export * from './unwire-generator'
export * from './blocked-library'
135 changes: 135 additions & 0 deletions devtools/wire/unwire-generator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import fs from 'fs'
import path from 'path'

import { constants } from 'ethers'

import { getNetworkNameForEid } from '@layerzerolabs/devtools-evm-hardhat'

import { UnwiredNetworks } from '../config'
import { getDeployConfig, validateHubNetworksNotInExpansion } from '../deploy'
import { MarketPairConfig } from '../types'

import { getBlockedLibraryAddress } from './blocked-library'
import { createContracts } from './wire-generator'

import type { EndpointId } from '@layerzerolabs/lz-definitions'
import type { OAppOmniGraphHardhat, OmniPointHardhat } from '@layerzerolabs/toolbox-hardhat'

/**
* The three stages used to remove a chain from the mesh, in order:
* 1. send-block: point sendLibrary at the BlockedMessageLib on both directions.
* Stops new messages without endangering anything already in flight.
* 2. (not a config phase) drain: wait until scan API shows no pending messages.
* 3. receive-block: point receiveLibraryConfig.receiveLibrary at the BlockedMessageLib.
* 4. unpeer: setPeer(eid, bytes32(0)) on both directions.
*/
export type UnwirePhase = 'send-block' | 'receive-block' | 'unpeer'

export function hasDeploymentArtifact(networkName: string, contractName: string): boolean {
const artifactPath = path.join(process.cwd(), 'deployments', networkName, `${contractName}.json`)
return fs.existsSync(artifactPath)
}

/**
* Generates a config graph that only touches the pathways between `targetEid` and every
* other network in the given market pair's mesh, setting only the config field(s) relevant
* to `phase`. Every other config axis (DVN/ULN config, enforced options) is left `undefined`,
* so the existing per-field configurators skip them, exactly as they do for any connection
* with an omitted field.
*
* `targetEid` must be listed in `UnwiredNetworks` (see devtools/config/networks.ts) — this is
* a deliberate guard rail so an active, still-wired network can't be accidentally targeted.
* Marking a network there is also what stops routine `lz:sdk:wire` runs from reverting this
* teardown mid-flight, without needing to remove it from `ExpansionNetworks`/`hardhat.config.ts`.
*/
export async function generateUnwireConfig(
contractType: 'GlvToken' | 'MarketToken',
targetEid: EndpointId,
phase: UnwirePhase,
marketPairKey?: string,
marketPairConfig?: MarketPairConfig
): Promise<OAppOmniGraphHardhat> {
const isMarkedUnwired = [...UnwiredNetworks.mainnet, ...UnwiredNetworks.testnet].includes(targetEid)
if (!isMarkedUnwired) {
throw new Error(
`EID ${targetEid} is not listed in UnwiredNetworks (devtools/config/networks.ts). ` +
`Add it there first — this is a safety check to prevent unwiring an active network by mistake.`
)
}

const config = marketPairConfig || (await getDeployConfig()).marketPairConfig
const key = marketPairKey || (await getDeployConfig()).marketPairKey

validateHubNetworksNotInExpansion(config)

const tokenConfig = contractType === 'GlvToken' ? config.GLV : config.GM
if (!tokenConfig) {
throw new Error(`${contractType === 'GlvToken' ? 'GLV' : 'GM'} token is not configured for market pair ${key}`)
}

if (targetEid === tokenConfig.hubNetwork.eid) {
throw new Error(`Target EID ${targetEid} is the hub network for ${key}; hub removal is not supported`)
}

const adapterContractName = `${contractType}_Adapter_${key}`
const oftContractName = `${contractType}_OFT_${key}`
const toPoint = (eid: EndpointId): OmniPointHardhat => ({
eid,
contractName: tokenConfig.hubNetwork.eid === eid ? adapterContractName : oftContractName,
})

const targetNetworkName = getNetworkNameForEid(targetEid)
const targetContractName = tokenConfig.hubNetwork.eid === targetEid ? adapterContractName : oftContractName
if (!hasDeploymentArtifact(targetNetworkName, targetContractName)) {
throw new Error(
`No deployment artifact found for ${targetContractName} on ${targetNetworkName} (deployments/${targetNetworkName}/${targetContractName}.json); ` +
`this market pair/token type was never deployed to the target network`
)
}

const counterpartEids = [tokenConfig.hubNetwork.eid, ...tokenConfig.expansionNetworks].filter(
(eid) => eid !== targetEid
)

const targetPoint = toPoint(targetEid)
const counterpartPoints = counterpartEids.map(toPoint)

const contracts = createContracts([targetPoint, ...counterpartPoints])

const blockedLibByEid = new Map<EndpointId, string>()
if (phase !== 'unpeer') {
for (const eid of [targetEid, ...counterpartEids]) {
blockedLibByEid.set(eid, await getBlockedLibraryAddress(eid))
}
}

const getBlockedLib = (eid: EndpointId): string => {
const address = blockedLibByEid.get(eid)
if (!address) throw new Error(`No blocked library address resolved for eid ${eid}`)
return address
}

const buildEdge = (from: OmniPointHardhat, to: OmniPointHardhat): OAppOmniGraphHardhat['connections'][number] => {
if (phase === 'unpeer') {
return { from, to: { eid: to.eid, address: constants.AddressZero }, config: {} }
}
if (phase === 'send-block') {
return { from, to, config: { sendLibrary: getBlockedLib(from.eid) } }
}
return {
from,
to,
config: {
receiveLibraryConfig: { receiveLibrary: getBlockedLib(from.eid), gracePeriod: BigInt(0) },
},
}
}

const connections: OAppOmniGraphHardhat['connections'] = []
for (const counterpart of counterpartPoints) {
connections.push(buildEdge(targetPoint, counterpart))
connections.push(buildEdge(counterpart, targetPoint))
}

return { contracts, connections }
}
Loading