diff --git a/.changeset/nice-stingrays-count.md b/.changeset/nice-stingrays-count.md new file mode 100644 index 0000000000..216c532538 --- /dev/null +++ b/.changeset/nice-stingrays-count.md @@ -0,0 +1,6 @@ +--- +"@layerzerolabs/devtools-move": patch +"@layerzerolabs/oft-move": patch +--- + +baseX converter does not depend on chainType diff --git a/packages/devtools-move/jest/baseXtoBytes32.test.ts b/packages/devtools-move/jest/baseXtoBytes32.test.ts deleted file mode 100644 index 1fa95f8133..0000000000 --- a/packages/devtools-move/jest/baseXtoBytes32.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { EndpointId } from '@layerzerolabs/lz-definitions' -import { expect } from 'chai' -import { basexToBytes32 } from '../tasks/shared/basexToBytes32' - -describe('should convert base-x addresses (eth, aptos, solana, etc) to bytes32', () => { - it('should convert solana base-x address to bytes32', () => { - const basexAddress = 'Efvf2QfcPAJc8XCd1MV1MN1JLNDZRKj2ZbJ3xg6pAf7' - const eid = EndpointId.SOLANA_V2_MAINNET.toString() - const bytes32 = basexToBytes32(basexAddress, eid) - expect(bytes32).to.equal('0x038090321ef8b2bbe6d072ded5e3e9ad8d5608e1b04db7d2e9777e39231af2ae') - }) - - it('should convert aptos base-x address to bytes32', () => { - const basexAddress = '0x000000000000000000000000177b58ddda0c81424227ee473e4132044a0dc871' - const eid = EndpointId.APTOS_V2_MAINNET.toString() - const bytes32 = basexToBytes32(basexAddress, eid) - expect(bytes32).to.equal('0x000000000000000000000000177b58ddda0c81424227ee473e4132044a0dc871') - }) - - it('should convert ethereum base-x address to bytes32', () => { - const basexAddress = '0x177B58Ddda0C81424227Ee473e4132044a0DC871' - const eid = EndpointId.ETHEREUM_V2_MAINNET.toString() - const bytes32 = basexToBytes32(basexAddress, eid) - expect(bytes32).to.equal('0x000000000000000000000000177b58ddda0c81424227ee473e4132044a0dc871') - }) -}) diff --git a/packages/devtools-move/jest/basexToBytes32.test.ts b/packages/devtools-move/jest/basexToBytes32.test.ts new file mode 100644 index 0000000000..7439c71c53 --- /dev/null +++ b/packages/devtools-move/jest/basexToBytes32.test.ts @@ -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') + }) + }) +}) diff --git a/packages/devtools-move/tasks/evm/wire-evm.ts b/packages/devtools-move/tasks/evm/wire-evm.ts index 8adc9649fc..1fa0df67bb 100644 --- a/packages/devtools-move/tasks/evm/wire-evm.ts +++ b/packages/devtools-move/tasks/evm/wire-evm.ts @@ -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] = { diff --git a/packages/devtools-move/tasks/move/utils/moveVMOftConfigOps.ts b/packages/devtools-move/tasks/move/utils/moveVMOftConfigOps.ts index bc3205322e..215645d33c 100644 --- a/packages/devtools-move/tasks/move/utils/moveVMOftConfigOps.ts +++ b/packages/devtools-move/tasks/move/utils/moveVMOftConfigOps.ts @@ -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' @@ -50,7 +50,7 @@ export async function createTransferOwnerOAppPayload( eid: EndpointId ): Promise { 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 { @@ -101,7 +101,7 @@ export async function createSetDelegatePayload( ): Promise { 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 { @@ -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 @@ -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 { @@ -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) @@ -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) @@ -488,7 +467,7 @@ export async function checkExecutorConfigEqualsDefault( ): Promise { 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 ) } diff --git a/packages/devtools-move/tasks/shared/basexToBytes32.ts b/packages/devtools-move/tasks/shared/basexToBytes32.ts index 98823529ee..8fc4547684 100644 --- a/packages/devtools-move/tasks/shared/basexToBytes32.ts +++ b/packages/devtools-move/tasks/shared/basexToBytes32.ts @@ -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 = { + 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 } diff --git a/packages/oft-move/tasks/quoteSendOFT.ts b/packages/oft-move/tasks/quoteSendOFT.ts index 302ecb9850..6331d7769c 100644 --- a/packages/oft-move/tasks/quoteSendOFT.ts +++ b/packages/oft-move/tasks/quoteSendOFT.ts @@ -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, @@ -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)) diff --git a/packages/oft-move/tasks/sendFromMoveVm.ts b/packages/oft-move/tasks/sendFromMoveVm.ts index ccb53f7ce6..f56fd3fe73 100644 --- a/packages/oft-move/tasks/sendFromMoveVm.ts +++ b/packages/oft-move/tasks/sendFromMoveVm.ts @@ -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, @@ -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))