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
6 changes: 6 additions & 0 deletions .changeset/nice-stingrays-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@layerzerolabs/devtools-move": patch
"@layerzerolabs/oft-move": patch
---

baseX converter does not depend on chainType
26 changes: 0 additions & 26 deletions packages/devtools-move/jest/baseXtoBytes32.test.ts

This file was deleted.

40 changes: 40 additions & 0 deletions packages/devtools-move/jest/basexToBytes32.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { expect } from 'chai'
import { randomBytes } from 'crypto'
import { basexToBytes32 } from '../tasks/shared/basexToBytes32'

describe('basexToBytes32 - Address Format Detection and Conversion', () => {
describe('Base16 format (0x prefix)', () => {
// Iterative test for random hex inputs from 1 to 32 bytes
for (let bytes = 1; bytes <= 32; bytes++) {
it(`should handle random ${bytes}-byte hex string`, () => {
const randomHex = '0x' + randomBytes(bytes).toString('hex')
const result = basexToBytes32(randomHex)

// Should always return 32-byte padded result for EVM addresses
expect(result).to.match(/^0x[a-f0-9]{64}$/)
expect(result).to.have.length(66) // 0x + 64 hex chars
})
}
})

describe('Base58 format', () => {
it('should convert solana address to bytes32', () => {
const address = '76y77prsiCMvXMjuoZ5VRrhG5qYBrUMYTE5WgHqgjEn6'
const bytes32 = basexToBytes32(address)
// Note: This will need to be updated with the actual expected bytes32 value
expect(bytes32).to.equal('0x5aad76da514b6e1dcf11037e904dac3d375f525c9fbafcb19507b78907d8c18b')
})
})

describe('Error handling', () => {
it('should throw error for unsupported format', () => {
const address = 'invalid-address-format'
expect(() => basexToBytes32(address)).to.throw(`Unsupported address format: ${address}`)
})

it('should handle empty string', () => {
const address = ''
expect(() => basexToBytes32(address)).to.throw('Empty address provided')
})
})
})
2 changes: 1 addition & 1 deletion packages/devtools-move/tasks/evm/wire-evm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export async function createEvmOmniContracts(args: any, privateKey: string, chai
peerAddress = WireOAppDeploymentData.address
}

const peer = { eid: toEid, address: basexToBytes32(peerAddress, toEid) }
const peer = { eid: toEid, address: basexToBytes32(peerAddress) }
currPeers.push(peer)

omniContracts[fromEid] = {
Expand Down
47 changes: 13 additions & 34 deletions packages/devtools-move/tasks/move/utils/moveVMOftConfigOps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { createSerializableUlnConfig } from './ulnConfigBuilder'
import { ExecutorConfig, UlnConfig } from '.'

import type { OAppOmniGraphHardhat, Uln302ExecutorConfig } from '@layerzerolabs/toolbox-hardhat'
import { decodeSolanaAddress } from '../../shared'
import { basexToBytes32 } from '../../shared'
import { IEndpoint } from '../../../sdk'
import { bcs, MsgExecute } from '@initia/initia.js'

Expand Down Expand Up @@ -50,7 +50,7 @@ export async function createTransferOwnerOAppPayload(
eid: EndpointId
): Promise<TransactionPayload | null> {
const currOwner = await oft.getAdmin()
if (formatAddress(currOwner) == formatAddress(newOwner)) {
if (basexToBytes32(currOwner) == basexToBytes32(newOwner)) {
console.log(`✅ Owner already set to ${newOwner}\n`)
return null
} else {
Expand Down Expand Up @@ -101,7 +101,7 @@ export async function createSetDelegatePayload(
): Promise<TransactionPayload | null> {
const currDelegate = await oft.getDelegate()

if (formatAddress(currDelegate) == formatAddress(delegate)) {
if (basexToBytes32(currDelegate) == basexToBytes32(delegate)) {
console.log(`✅ Delegate already set to ${delegate}\n`)
return null
} else {
Expand All @@ -117,30 +117,6 @@ export async function createSetDelegatePayload(
}
}

function formatAddress(address: string): string {
const hex = address.toLowerCase().replace('0x', '')
return '0x' + hex.padStart(64, '0')
}

export function evmAddressToAptos(address: string, eid: string): string {
if (!address) {
return '0x' + '0'.repeat(64)
}

const chainType = endpointIdToChainType(Number(eid))

// Handle Solana addresses by decoding base58 first
if (chainType === ChainType.SOLANA) {
address = decodeSolanaAddress(address)
}

address = address.toLowerCase()
const hex = address.replace('0x', '')
// Ensure the hex string is exactly 64 chars by padding or truncating
const paddedHex = hex.length > 64 ? hex.slice(-64) : hex.padStart(64, '0')
return '0x' + paddedHex
}

/**
* Helper to determine the EVM deployment address using hardhat-deploy 'deployments' directory. This function also
* validates that there is a corresponding network defined in hardhat.config.ts
Expand Down Expand Up @@ -177,15 +153,18 @@ export async function createSetPeerTx(
if (!contractAddress) {
return null
}
const newPeer = evmAddressToAptos(contractAddress, connection.to.eid.toString())
const newPeer = basexToBytes32(contractAddress)
const currentPeerHex = await getCurrentPeer(oft, connection.to.eid as EndpointId)

if (currentPeerHex === newPeer) {
// Normalize current peer to bytes32 format for proper comparison
const normalizedCurrentPeer = currentPeerHex ? basexToBytes32(currentPeerHex) : ''

if (normalizedCurrentPeer === newPeer) {
printAlreadySet('peer', connection)
return null
} else {
const diffMessage = createDiffMessage('peer', connection)
diffPrinter(diffMessage, { address: currentPeerHex }, { address: newPeer })
diffPrinter(diffMessage, { address: normalizedCurrentPeer }, { address: newPeer })

const payload = oft.setPeerPayload(connection.to.eid as EndpointId, newPeer)
return {
Expand Down Expand Up @@ -262,8 +241,8 @@ export async function createSetReceiveLibraryTx(

// if unset, fallbackToDefault will be true and the receive library should be set regardless of the current value
if (
formatAddress(currentReceiveLibraryAddress) ===
formatAddress(connection.config.receiveLibraryConfig.receiveLibrary) &&
basexToBytes32(currentReceiveLibraryAddress) ===
basexToBytes32(connection.config.receiveLibraryConfig.receiveLibrary) &&
!isFallbackToDefault
) {
printAlreadySet('Receive library', connection)
Expand Down Expand Up @@ -306,7 +285,7 @@ export async function createSetSendLibraryTx(

// if unset, fallbackToDefault will be true and the receive library should be set regardless of the current value
if (
formatAddress(currentSendLibraryAddress) === formatAddress(connection.config.sendLibrary) &&
basexToBytes32(currentSendLibraryAddress) === basexToBytes32(connection.config.sendLibrary) &&
!isFallbackToDefault
) {
printAlreadySet('Send library', connection)
Expand Down Expand Up @@ -488,7 +467,7 @@ export async function checkExecutorConfigEqualsDefault(
): Promise<boolean> {
const defaultExecutorConfig = await msgLib.getDefaultExecutorConfig(eid)
return (
formatAddress(newExecutorConfig.executor_address) === formatAddress(defaultExecutorConfig.executor_address) &&
basexToBytes32(newExecutorConfig.executor_address) === basexToBytes32(defaultExecutorConfig.executor_address) &&
newExecutorConfig.max_message_size === defaultExecutorConfig.max_message_size
)
}
Expand Down
113 changes: 95 additions & 18 deletions packages/devtools-move/tasks/shared/basexToBytes32.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,104 @@
import basex from 'base-x'
import { ethers } from 'ethers'
import { ChainType, endpointIdToChainType } from '@layerzerolabs/lz-definitions'

const BASE58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
type AddressFormat = 'hex' | 'base58'

export function basexToBytes32(basexAddress: string, eid: string): string {
const chainType = endpointIdToChainType(Number(eid))
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'

if (chainType === ChainType.EVM) {
const addressBytes = ethers.utils.zeroPad(basexAddress, 32)
return `0x${Buffer.from(addressBytes).toString('hex')}`
} else if (chainType === ChainType.APTOS) {
return basexAddress
} else if (chainType === ChainType.SOLANA) {
return decodeSolanaAddress(basexAddress)
} else if (chainType === ChainType.INITIA) {
return basexAddress
} else {
throw new Error('Invalid chain type')
/**
* Converts any address format to a consistent bytes32 hex string.
* Auto-detects the input format and converts to left-padded 32-byte hex.
*
* @param address - Address in any supported format
* @returns 32-byte hex string with 0x prefix
*
* @example
* basexToBytes32('0x1234...') // Hex (EVM, etc...) (padded to 32 bytes)
* basexToBytes32('9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM') // Solana base58 (padded to 32 bytes)
* basexToBytes32('1234abcd') // Raw hex without 0x (padded to 32 bytes)
*/
export function basexToBytes32(address: string): string {
const bytes = detectAndDecodeAddress(address)
const paddedBytes = ethers.utils.zeroPad(bytes, 32)
return `0x${Buffer.from(paddedBytes).toString('hex')}`
}

/**
* Auto-detects address format and decodes to bytes.
*
* Detection algorithm (in order of priority):
* 1. Hex character set (with or without 0x) → Raw hex format
* 2. Base58 character set + typical length → Base58 format (Solana, etc.)
*
* Note: When formats overlap, the most specific format is used.
*
* @param address - Address string to decode
* @returns Uint8Array of decoded bytes
* @throws Error if address is empty or unsupported format
*/
function detectAndDecodeAddress(address: string): Uint8Array {
const cleanAddress = address.trim()
if (cleanAddress.length === 0) {
throw new Error('Empty address provided')
}
const isFormatMap: Record<AddressFormat, boolean> = {
hex: isHex(cleanAddress),
base58: isBase58(cleanAddress),
}
const validFormats: AddressFormat[] = Object.keys(isFormatMap).filter(
(key) => isFormatMap[key as AddressFormat]
) as AddressFormat[]
if (validFormats.length === 0) {
throw new Error(`Unsupported address format: ${address}`)
} else if (validFormats.length > 1) {
console.warn(
`Address ${address} is valid for multiple formats: ${validFormats.join(', ')}, ` +
`it will be interpreted as the most specific format: ${validFormats[0]}`
)
}
const format = validFormats[0]
return decodeWithFormat(cleanAddress, format)
}

/**
* Decodes address with explicit format specification.
*/
function decodeWithFormat(address: string, format: AddressFormat): Uint8Array {
try {
switch (format) {
case 'hex': {
const hexAddress = address.startsWith('0x') ? address : `0x${address}`
return ethers.utils.arrayify(hexAddress)
}
case 'base58': {
return basex(BASE58_ALPHABET).decode(address)
}
default:
throw new Error(`Unknown format: ${format}`)
}
} catch (error) {
throw new Error(
`Failed to decode address as ${format}: ${error instanceof Error ? error.message : String(error)}`
)
}
}

/**
* Checks if a string is valid hexadecimal.
*/
function isHex(str: string): boolean {
if (str.startsWith('0x')) {
str = str.slice(2)
}
const hexRegex = /^[0-9a-fA-F]+$/
return hexRegex.test(str) && str.length > 0 && str.length % 2 === 0
}

export function decodeSolanaAddress(address: string): string {
const addressBytes = basex(BASE58).decode(address)
return '0x' + Buffer.from(addressBytes).toString('hex')
/**
* Checks if a string is valid base58.
* Uses regex for performance and consistency with other validation functions.
*/
function isBase58(str: string): boolean {
const base58Regex = new RegExp(`^[${BASE58_ALPHABET}]+$`)
return base58Regex.test(str) && str.length > 0
}
4 changes: 2 additions & 2 deletions packages/oft-move/tasks/quoteSendOFT.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Options } from '@layerzerolabs/lz-v2-utilities'
import { EndpointId } from '@layerzerolabs/lz-definitions'

import { hexAddrToAptosBytesAddr, evmAddressToAptos, TaskContext } from '@layerzerolabs/devtools-move'
import { hexAddrToAptosBytesAddr, basexToBytes32, TaskContext } from '@layerzerolabs/devtools-move'

async function quoteSendOFT(
taskContext: TaskContext,
Expand All @@ -13,7 +13,7 @@ async function quoteSendOFT(
srcAddress: string
) {
// Pad EVM address to 64 chars and convert Solana address to Aptos address
toAddress = evmAddressToAptos(toAddress, dstEid.toString())
toAddress = basexToBytes32(toAddress)
const toAddressBytes = hexAddrToAptosBytesAddr(toAddress)
const options = Options.newOptions().addExecutorLzReceiveOption(BigInt(gasLimit))

Expand Down
4 changes: 2 additions & 2 deletions packages/oft-move/tasks/sendFromMoveVm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { EndpointId, getNetworkForChainId } from '@layerzerolabs/lz-definitions'
import { Options } from '@layerzerolabs/lz-v2-utilities'
import * as readline from 'readline'

import { hexAddrToAptosBytesAddr, sendAllTxs, evmAddressToAptos, TaskContext } from '@layerzerolabs/devtools-move'
import { hexAddrToAptosBytesAddr, sendAllTxs, basexToBytes32, TaskContext } from '@layerzerolabs/devtools-move'

async function sendFromMoveVm(
taskContext: TaskContext,
Expand All @@ -14,7 +14,7 @@ async function sendFromMoveVm(
srcAddress: string
) {
// Pad EVM address to 64 chars and convert Solana address to Aptos address
toAddress = evmAddressToAptos(toAddress, dstEid.toString())
toAddress = basexToBytes32(toAddress)
const toAddressBytes = hexAddrToAptosBytesAddr(toAddress)
const options = Options.newOptions().addExecutorLzReceiveOption(BigInt(gasLimit))

Expand Down