Skip to content

Commit 4699ac7

Browse files
committed
feat(devtools-starknet): fix ByteArray encoding and add wire support
Key fixes: - Fix fromHex to handle odd-length hex strings by padding with leading '0' (Buffer.from silently truncates odd-length strings) - Fix ByteArray encoding in setEnforcedOptions to use raw calldata instead of string-based encoding (starknet.js UTF-8 re-encodes bytes >= 128) - Update sendStarknet to use createRpcUrlFactory() for RPC URL resolution - Update starknet.js v8 Account constructor format These fixes resolve the "out of bound" error when sending from Starknet OFT caused by corrupted enforced options (byte 0x80 becoming UTF-8 0xc2 0x80).
1 parent e9b0180 commit 4699ac7

9 files changed

Lines changed: 177 additions & 108 deletions

File tree

examples/oft-main/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ deployments/
3030
# Sui build artifacts and deployment info
3131
sui/*/build/
3232
sui/deploy.json
33+
sui/**/oft_deploy.json
34+
sui/**/token_deploy.json
3335

3436
# Starknet deployment info
3537
starknet/deploy.json

examples/oft-main/layerzero.config.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,23 @@ const SUI_ENFORCED_OPTIONS: OAppEnforcedOption[] = [
5656
},
5757
]
5858

59+
const STARKNET_ENFORCED_OPTIONS: OAppEnforcedOption[] = [
60+
{
61+
msgType: 1,
62+
optionType: ExecutorOptionType.LZ_RECEIVE,
63+
gas: 500000, // Cairo steps for lzReceive execution on Starknet
64+
value: 0,
65+
},
66+
]
67+
5968
type SuiDeployment = {
6069
oftPackageId: string
6170
}
6271

72+
type StarknetDeployment = {
73+
oftAddress: string
74+
}
75+
6376
const loadJson = <T>(relativePath: string): T => {
6477
const fullPath = path.join(__dirname, relativePath)
6578
if (!fs.existsSync(fullPath)) {
@@ -69,12 +82,18 @@ const loadJson = <T>(relativePath: string): T => {
6982
}
7083

7184
const suiDeployment = loadJson<SuiDeployment>('./sui/deploy.json')
85+
const starknetDeployment = loadJson<StarknetDeployment>('./starknet/deploy.json')
7286

7387
const suiContract: OmniPointHardhat = {
7488
eid: EndpointId.SUI_V2_MAINNET,
7589
address: suiDeployment.oftPackageId,
7690
}
7791

92+
const starknetContract: OmniPointHardhat = {
93+
eid: EndpointId.STARKNET_V2_MAINNET,
94+
address: starknetDeployment.oftAddress,
95+
}
96+
7897
// Learn about Message Execution Options: https://docs.layerzero.network/v2/developers/solana/oft/overview#message-execution-options
7998
// Learn more about the Simple Config Generator - https://docs.layerzero.network/v2/developers/evm/technical-reference/simple-config
8099
export default async function () {
@@ -88,12 +107,20 @@ export default async function () {
88107
[15, 15],
89108
[SUI_ENFORCED_OPTIONS, EVM_ENFORCED_OPTIONS],
90109
],
110+
[
111+
arbitrumContract,
112+
starknetContract,
113+
[['LayerZero Labs'], []],
114+
[15, 15],
115+
[STARKNET_ENFORCED_OPTIONS, EVM_ENFORCED_OPTIONS],
116+
],
117+
[suiContract, starknetContract, [[], []], [15, 15], [STARKNET_ENFORCED_OPTIONS, SUI_ENFORCED_OPTIONS]],
91118
] as const
92119

93120
const connections = await generateConnectionsConfig(pathways as any)
94121

95122
return {
96-
contracts: [{ contract: arbitrumContract }, { contract: suiContract }],
123+
contracts: [{ contract: arbitrumContract }, { contract: suiContract }, { contract: starknetContract }],
97124
connections,
98125
}
99126
}

examples/oft-main/starknet/deploy-starknet-mainnet.js

Lines changed: 17 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -55,15 +55,6 @@ async function main() {
5555
throw new Error('ERC20 ABI not found or invalid in oft-mint-burn-starknet package');
5656
}
5757
console.log('ERC20 ABI entries:', ERC20_ABI.length);
58-
const txDetails = {
59-
tip: 0n,
60-
resourceBounds: {
61-
l1_gas: { max_amount: 80_000n, max_price_per_unit: 60_000_000_000_000n },
62-
l2_gas: { max_amount: 1_000_000n, max_price_per_unit: 10_000_000_000n },
63-
l1_data_gas: { max_amount: 80_000n, max_price_per_unit: 60_000_000_000_000n },
64-
},
65-
};
66-
6758
const existing = loadExistingDeploy();
6859
let erc20Address = process.env.STARKNET_ERC20_ADDRESS || existing?.erc20Address;
6960
let oftAddress = process.env.STARKNET_OFT_ADDRESS || existing?.oftAddress;
@@ -78,13 +69,10 @@ async function main() {
7869
decimals: ERC20_DECIMALS,
7970
default_admin: ACCOUNT_ADDRESS,
8071
});
81-
const erc20Deploy = await account.deploy(
82-
{
83-
classHash: ERC20_CLASS_HASH,
84-
constructorCalldata: erc20ConstructorCalldata,
85-
},
86-
txDetails
87-
);
72+
const erc20Deploy = await account.deploy({
73+
classHash: ERC20_CLASS_HASH,
74+
constructorCalldata: erc20ConstructorCalldata,
75+
});
8876
await provider.waitForTransaction(erc20Deploy.transaction_hash);
8977
erc20Address = Array.isArray(erc20Deploy.contract_address)
9078
? erc20Deploy.contract_address[0]
@@ -100,20 +88,17 @@ async function main() {
10088

10189
if (!oftAddress) {
10290
console.log('Deploying OFTMintBurnAdapter...');
103-
const oftDeploy = await account.deploy(
104-
{
105-
classHash: OFT_CLASS_HASH,
106-
constructorCalldata: [
107-
erc20Address,
108-
erc20Address,
109-
ENDPOINT_ADDRESS,
110-
ACCOUNT_ADDRESS,
111-
STRK_TOKEN_ADDRESS,
112-
SHARED_DECIMALS,
113-
],
114-
},
115-
txDetails
116-
);
91+
const oftDeploy = await account.deploy({
92+
classHash: OFT_CLASS_HASH,
93+
constructorCalldata: [
94+
erc20Address,
95+
erc20Address,
96+
ENDPOINT_ADDRESS,
97+
ACCOUNT_ADDRESS,
98+
STRK_TOKEN_ADDRESS,
99+
SHARED_DECIMALS,
100+
],
101+
});
117102
await provider.waitForTransaction(oftDeploy.transaction_hash);
118103
oftAddress = Array.isArray(oftDeploy.contract_address)
119104
? oftDeploy.contract_address[0]
@@ -134,12 +119,12 @@ async function main() {
134119

135120
console.log('Granting MINTER_ROLE...');
136121
const grantMinter = erc20.populateTransaction.grant_role(minterRole, oftAddress);
137-
const grantMinterTx = await account.execute([grantMinter], txDetails);
122+
const grantMinterTx = await account.execute([grantMinter]);
138123
await provider.waitForTransaction(grantMinterTx.transaction_hash);
139124

140125
console.log('Granting BURNER_ROLE...');
141126
const grantBurner = erc20.populateTransaction.grant_role(burnerRole, oftAddress);
142-
const grantBurnerTx = await account.execute([grantBurner], txDetails);
127+
const grantBurnerTx = await account.execute([grantBurner]);
143128
await provider.waitForTransaction(grantBurnerTx.transaction_hash);
144129

145130
const out = {

examples/oft-main/tasks/common/sendOFT.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,8 @@ task('lz:oft:send', 'Sends OFT tokens cross‐chain from any supported chain')
116116
if (!args.oftAddress) {
117117
throw new Error('Starknet send requires oftAddress')
118118
}
119-
const { sendStarknet } = await import('../starknet/sendStarknet')
119+
// eslint-disable-next-line @typescript-eslint/no-var-requires
120+
const { sendStarknet } = require('../starknet/sendStarknet')
120121
result = await sendStarknet({
121122
...(args as StarknetArgs),
122123
oftAddress: args.oftAddress,

examples/oft-main/tasks/common/wire.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,15 @@ task(TASK_LZ_OAPP_WIRE)
235235
if (endpointIdToChainType(eid) !== ChainType.STARKNET) {
236236
throw new Error('Not a Starknet EID')
237237
}
238-
return starknetSignerFactory(eid)
238+
try {
239+
logger.debug(`Creating Starknet signer for eid ${eid}`)
240+
const signer = await starknetSignerFactory(eid)
241+
logger.debug(`Successfully created Starknet signer for eid ${eid}`)
242+
return signer
243+
} catch (error) {
244+
logger.error(`Failed to create Starknet signer for eid ${eid}: ${error}`)
245+
throw error
246+
}
239247
},
240248
aptosSignerFactory,
241249
...(solanaSignerFactory ? [solanaSignerFactory] : []),
Lines changed: 30 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
1-
import fs from 'node:fs'
2-
import { createRequire } from 'node:module'
3-
import path from 'node:path'
4-
51
import { Contract } from 'starknet'
62

7-
import { createConnectionFactory } from '@layerzerolabs/devtools-starknet'
3+
import { createConnectionFactory, createRpcUrlFactory } from '@layerzerolabs/devtools-starknet'
84
import { EndpointId, Stage, endpointIdToStage } from '@layerzerolabs/lz-definitions'
95

106
import { SendResult } from '../common/types'
@@ -13,27 +9,21 @@ import { parseDecimalToUnits } from '../solana/utils'
139

1410
import { assertStarknetEid, getStarknetAccountFromEnv } from './utils'
1511

16-
const hexToBytes = (value?: string) =>
17-
value ? Uint8Array.from(Buffer.from(value.replace(/^0x/, ''), 'hex')) : new Uint8Array()
18-
19-
const toCairoByteArray = (hex?: string) => {
20-
const clean = (hex ?? '').replace(/^0x/, '')
21-
if (!clean) {
22-
return { data: [], pending_word: '0x0', pending_word_len: 0 }
23-
}
24-
const bytes = Buffer.from(clean, 'hex')
25-
const chunkSize = 31
26-
const data: string[] = []
27-
for (let offset = 0; offset + chunkSize <= bytes.length; offset += chunkSize) {
28-
data.push(`0x${bytes.subarray(offset, offset + chunkSize).toString('hex')}`)
29-
}
30-
const remainder = bytes.length % chunkSize
31-
const pendingBytes = remainder ? bytes.subarray(bytes.length - remainder) : Buffer.alloc(0)
32-
return {
33-
data,
34-
pending_word: pendingBytes.length ? `0x${pendingBytes.toString('hex')}` : '0x0',
35-
pending_word_len: pendingBytes.length,
12+
/**
13+
* Convert hex string to a string for Cairo ByteArray.
14+
* In starknet.js v8, ByteArray parameters accept plain strings.
15+
*
16+
* NOTE: This uses latin1 encoding which can be corrupted by starknet.js's
17+
* UTF-8 re-encoding for bytes >= 128. For enforced options which typically
18+
* contain 0x80-0xFF bytes, use hexToByteArrayCalldata() with raw calldata.
19+
*/
20+
const hexToString = (hex?: string): string => {
21+
if (!hex || hex === '0x' || hex === '') {
22+
return ''
3623
}
24+
const clean = hex.replace(/^0x/, '')
25+
const buffer = Buffer.from(clean, 'hex')
26+
return buffer.toString('latin1')
3727
}
3828

3929
export interface StarknetArgs {
@@ -61,12 +51,12 @@ export async function sendStarknet({
6151
}: StarknetArgs): Promise<SendResult> {
6252
assertStarknetEid(srcEid)
6353

64-
const providerFactory = createConnectionFactory()
54+
// Use createRpcUrlFactory() to read from environment variables (RPC_URL_STARKNET)
55+
const providerFactory = createConnectionFactory(createRpcUrlFactory())
6556
const provider = await providerFactory(srcEid)
6657
const account = await getStarknetAccountFromEnv(srcEid)
6758

68-
const oftContract = await getOftMintBurnAdapterContract(oftAddress, provider)
69-
oftContract.connect(account)
59+
const oftContract = await getOftMintBurnAdapterContract(oftAddress, account)
7060

7161
const amountUnits = parseDecimalToUnits(amount, tokenDecimals)
7262
const minAmountUnits = minAmount ? parseDecimalToUnits(minAmount, tokenDecimals) : amountUnits
@@ -76,9 +66,9 @@ export async function sendStarknet({
7666
to: { value: BigInt(to) },
7767
amount_ld: amountUnits,
7868
min_amount_ld: minAmountUnits,
79-
extra_options: toCairoByteArray(extraOptions),
80-
compose_msg: toCairoByteArray(composeMsg),
81-
oft_cmd: toCairoByteArray(),
69+
extra_options: hexToString(extraOptions),
70+
compose_msg: hexToString(composeMsg),
71+
oft_cmd: hexToString(),
8272
}
8373

8474
const feeQuote = await oftContract.quote_send(sendParam, false)
@@ -103,35 +93,17 @@ export async function sendStarknet({
10393
}
10494

10595
function getOftMintBurnAdapterAbi(): unknown {
106-
const require = createRequire(import.meta.url)
107-
const pkgRoot = path.dirname(require.resolve('@layerzerolabs/oft-mint-burn-starknet/package.json'))
108-
const candidates = [
109-
'dist/generated/abi/o-f-t-mint-burn-adapter.cjs',
110-
'dist/generated/abi/o-f-t-mint-burn-adapter.js',
111-
'contracts/oft_mint_burn/target/release/oft_mint_burn_OFTMintBurnAdapter.contract_class.json',
112-
'contracts/oft_mint_burn/target/dev/oft_mint_burn_OFTMintBurnAdapter.contract_class.json',
113-
'contracts/oft_mint_burn/target/release/oft_mint_burn_OFTMintBurnAdapter.compiled_contract_class.json',
114-
'contracts/oft_mint_burn/target/dev/oft_mint_burn_OFTMintBurnAdapter.compiled_contract_class.json',
115-
]
116-
117-
for (const relPath of candidates) {
118-
const fullPath = path.join(pkgRoot, relPath)
119-
if (fs.existsSync(fullPath)) {
120-
if (fullPath.endsWith('.js') || fullPath.endsWith('.cjs')) {
121-
return require(fullPath)
122-
}
123-
const json = JSON.parse(fs.readFileSync(fullPath, 'utf8'))
124-
return json.abi ?? json
125-
}
96+
// Load the ABI from the package's main export
97+
// eslint-disable-next-line @typescript-eslint/no-var-requires
98+
const pkg = require('@layerzerolabs/oft-mint-burn-starknet')
99+
const abi = pkg.abi?.oFTMintBurnAdapter
100+
if (!abi) {
101+
throw new Error('Unable to locate OFTMintBurnAdapter ABI in @layerzerolabs/oft-mint-burn-starknet')
126102
}
127-
128-
throw new Error('Unable to locate OFTMintBurnAdapter ABI in @layerzerolabs/oft-mint-burn-starknet')
103+
return abi
129104
}
130105

131-
async function getOftMintBurnAdapterContract(
132-
address: string,
133-
provider: ReturnType<typeof createConnectionFactory> extends () => Promise<infer T> ? T : never
134-
) {
106+
async function getOftMintBurnAdapterContract(address: string, providerOrAccount: any) {
135107
const abi = getOftMintBurnAdapterAbi()
136-
return new Contract({ abi: abi as any, address, providerOrAccount: provider as any })
108+
return new Contract({ abi: abi as any, address, providerOrAccount })
137109
}

examples/oft-main/tasks/starknet/utils.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Account } from 'starknet'
22

33
import { formatEid } from '@layerzerolabs/devtools'
4-
import { createConnectionFactory } from '@layerzerolabs/devtools-starknet'
4+
import { createConnectionFactory, createRpcUrlFactory } from '@layerzerolabs/devtools-starknet'
55
import { ChainType, EndpointId, Stage, endpointIdToChainType, endpointIdToStage } from '@layerzerolabs/lz-definitions'
66

77
export function assertStarknetEid(eid: EndpointId) {
@@ -23,6 +23,9 @@ export async function getStarknetAccountFromEnv(eid: EndpointId): Promise<Accoun
2323
throw new Error('STARKNET_ACCOUNT_ADDRESS and STARKNET_PRIVATE_KEY are required')
2424
}
2525

26-
const providerFactory = createConnectionFactory()
27-
return new Account(await providerFactory(eid), address, privateKey)
26+
// Use createRpcUrlFactory() to read from environment variables (RPC_URL_STARKNET)
27+
const providerFactory = createConnectionFactory(createRpcUrlFactory())
28+
const provider = await providerFactory(eid)
29+
// starknet.js v8+ uses an options object for Account constructor
30+
return new Account({ provider, address, signer: privateKey })
2831
}

packages/devtools/src/common/bytes.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,6 @@ export const denormalizePeer = (bytes: Uint8Array | null | undefined, eid: Endpo
134134
case ChainType.APTOS:
135135
case ChainType.SUI:
136136
case ChainType.STARKNET:
137-
138137
return toHex(toBytes32(bytes))
139138

140139
case ChainType.EVM:
@@ -197,10 +196,18 @@ export const toHex = (bytes: Uint8Array): string => `0x${Buffer.from(bytes).toSt
197196
/**
198197
* Helper utility to convert a hex string (with or without leading `0x`) to `UInt8Array`
199198
*
199+
* Note: Buffer.from(str, 'hex') silently truncates the last character if the string
200+
* has odd length. We pad with a leading '0' to prevent data loss.
201+
*
200202
* @param {string} hex
201203
* @returns {Uint8Array}
202204
*/
203-
export const fromHex = (hex: string): Uint8Array => Uint8Array.from(Buffer.from(hex.replace(/^0x/, ''), 'hex'))
205+
export const fromHex = (hex: string): Uint8Array => {
206+
const stripped = hex.replace(/^0x/, '')
207+
// Pad with leading '0' if odd length to prevent Buffer.from truncation
208+
const padded = stripped.length % 2 === 1 ? '0' + stripped : stripped
209+
return Uint8Array.from(Buffer.from(padded, 'hex'))
210+
}
204211

205212
/**
206213
* Helper utility that returns the leftmost bytes after removing the rightmost `length` bytes from a UInt8Array.

0 commit comments

Comments
 (0)