Skip to main content
Version: Endpoint V2

DVN and Executor Configuration on Starknet

This guide explains how to configure Decentralized Verifier Networks (DVNs), Executors, and message libraries for your Starknet OApp or OFT using the SDK.

Overview

Configuration is done through the SDK provided by @layerzerolabs/lz-v2-protocol-starknet. All examples use SDK methods to interact with the Endpoint and message library contracts.

Configuration flow:

  1. Set delegate (required if configuring via external account)
  2. Set message libraries (optional)
  3. Configure DVNs for send/receive (optional but recommended)
  4. Set enforced options (optional)
  5. Set peer addresses (required - opens pathway, call last!)
Critical Order

Always configure in this order. Setting peers last ensures the pathway isn't opened until all security settings are in place.

SDK Setup

import {RpcProvider, Account} from 'starknet';
import {
getEndpointV2Contract,
getOAppContract,
getUltraLightNodeContractWithAddress,
encodeUlnConfig,
encodeExecutorConfig,
MessageLibConfigType,
} from '@layerzerolabs/lz-v2-protocol-starknet';
import {EndpointId, ChainName, Environment} from '@layerzerolabs/lz-definitions';

// Setup provider and account
const provider = new RpcProvider({nodeUrl: 'YOUR_RPC_URL'});
const account = new Account({provider, address: accountAddress, signer: privateKey});

// Get contract instances
const oappContract = await getOAppContract(oappAddress, provider);
const endpointContract = await getEndpointV2Contract(
ChainName.STARKNET,
Environment.TESTNET,
provider,
);

// If you already have the Endpoint address, you can use:
// const endpointContract = await getEndpointV2ContractWithAddress(ENDPOINT_ADDRESS, provider);
// This is recommended when running outside the monorepo.

Prerequisite: Set Delegate

Endpoint configuration calls (set_send_library, set_receive_library, set_send_configs, set_receive_configs) require the caller to be the OApp itself or an authorized delegate. If you're configuring from an external account, set a delegate first (owner-only):

const setDelegateCall = oappContract.populateTransaction.set_delegate(accountAddress);
await account.execute([setDelegateCall]);

Use the address of the account that will submit the endpoint configuration transactions.

Default Configuration

LayerZero provides sensible defaults. If you don't configure custom settings:

  • DVN: LayerZero Labs DVN
  • Executor: LayerZero Labs Executor
  • Send Library: ULN302
  • Receive Library: ULN302

You can query the default configuration via the Endpoint or check LayerZero Deployments.


Configuration Methods

Set Peer

import {getOAppContract} from '@layerzerolabs/lz-v2-protocol-starknet';
import {EndpointId} from '@layerzerolabs/lz-definitions';

const oapp = await getOAppContract(oappAddress, provider);

// Peer address as Bytes32 (left-padded for EVM addresses)
const peerBytes32 = {value: BigInt('0x000000000000000000000000' + evmAddress.slice(2))};

const call = oapp.populateTransaction.set_peer(
EndpointId.ETHEREUM_V2_MAINNET, // Remote chain endpoint ID
peerBytes32,
);

await account.execute([call]);

Address format: Use Bytes32 for all peers. EVM addresses (20 bytes) must be left-padded with zeros to 32 bytes.

Set Message Libraries

import {getEndpointV2Contract} from '@layerzerolabs/lz-v2-protocol-starknet';
import {EndpointId, ChainName, Environment} from '@layerzerolabs/lz-definitions';

const endpoint = await getEndpointV2Contract(ChainName.STARKNET, Environment.TESTNET, provider);

// Set custom send library
const setSendLibCall = endpoint.populateTransaction.set_send_library(
oappAddress,
EndpointId.ETHEREUM_V2_MAINNET,
messageLibAddress,
);

// Set custom receive library (with grace period)
const setReceiveLibCall = endpoint.populateTransaction.set_receive_library(
oappAddress,
EndpointId.ETHEREUM_V2_MAINNET,
messageLibAddress,
0, // Grace period in blocks (0 = immediate)
);

await account.execute([setSendLibCall, setReceiveLibCall]);

Default: Uses Endpoint defaults if not configured. If you see DEFAULT_SEND_LIB_UNAVAILABLE or UNSUPPORTED_EID, set the send/receive libraries explicitly and ensure the EID is supported by the ULN.

import {getUltraLightNodeContractWithAddress} from '@layerzerolabs/lz-v2-protocol-starknet';

const uln = await getUltraLightNodeContractWithAddress(ulnAddress, provider);
const canSend = await uln.is_supported_send_eid(remoteEid);
const canReceive = await uln.is_supported_receive_eid(remoteEid);

DVN Configuration

Configure Send DVN (Outbound)

import {
getEndpointV2Contract,
encodeUlnConfig,
MessageLibConfigType,
} from '@layerzerolabs/lz-v2-protocol-starknet';
import {EndpointId, ChainName, Environment} from '@layerzerolabs/lz-definitions';

const endpoint = await getEndpointV2Contract(ChainName.STARKNET, Environment.TESTNET, provider);
const remoteEid = EndpointId.ETHEREUM_V2_MAINNET;

// Get the current send library address
const sendLibResponse = await endpoint.get_send_library(oappAddress, remoteEid);
const sendLibAddress = sendLibResponse.lib;

// Encode ULN configuration
const ulnConfig = encodeUlnConfig({
confirmations: 15,
has_confirmations: true,
required_dvns: [LAYERZERO_DVN_ADDRESS, PARTNER_DVN_ADDRESS], // Must be sorted ascending
has_required_dvns: true,
optional_dvns: [],
optional_dvn_threshold: 0,
has_optional_dvns: false,
});

// Set send config
const call = endpoint.populateTransaction.set_send_configs(oappAddress, sendLibAddress, [
{
eid: remoteEid,
config_type: MessageLibConfigType.ULN, // 2
config: ulnConfig,
},
]);

await account.execute([call]);

Configure Receive DVN (Inbound)

// Get the current receive library address
const receiveLibResponse = await endpoint.get_receive_library(oappAddress, remoteEid);
const receiveLibAddress = receiveLibResponse.lib;

// Encode ULN configuration for receive
const ulnConfig = encodeUlnConfig({
confirmations: 15,
has_confirmations: true,
required_dvns: [LAYERZERO_DVN_ADDRESS],
has_required_dvns: true,
optional_dvns: [],
optional_dvn_threshold: 0,
has_optional_dvns: false,
});

// Set receive config
const call = endpoint.populateTransaction.set_receive_configs(oappAddress, receiveLibAddress, [
{
eid: remoteEid,
config_type: MessageLibConfigType.ULN, // 2
config: ulnConfig,
},
]);

await account.execute([call]);

ULN Configuration Structure

FieldTypeDescription
confirmationsnumberBlock confirmations required before verification
has_confirmationsbooleanSet true to use custom value
required_dvnsstring[]DVN addresses (all must verify) - sorted ascending
has_required_dvnsbooleanSet true to use custom DVNs
optional_dvnsstring[]Optional DVN addresses - sorted ascending
optional_dvn_thresholdnumberHow many optional DVNs must verify
has_optional_dvnsbooleanSet true to use custom optional DVNs
has** Fields

The has*\*fields indicate whether the corresponding value should override the default configuration. Set them totrue when you want to use custom values, otherwise the protocol defaults will be used.

DVN Ordering

DVN addresses in required_dvns and optional_dvns must be sorted in ascending order. The contract will revert if unsorted or duplicate DVNs are provided.

Config types: MessageLibConfigType.EXECUTOR = 1, MessageLibConfigType.ULN = 2


Configuring Executor

The Executor delivers messages on the destination chain.

import {
getEndpointV2Contract,
encodeExecutorConfig,
MessageLibConfigType,
} from '@layerzerolabs/lz-v2-protocol-starknet';
import {ChainName, Environment} from '@layerzerolabs/lz-definitions';

const endpoint = await getEndpointV2Contract(ChainName.STARKNET, Environment.TESTNET, provider);

// Encode executor configuration
const executorConfig = encodeExecutorConfig({
max_message_size: 10000,
executor: EXECUTOR_ADDRESS,
});

// Set executor config (only for send direction)
const call = endpoint.populateTransaction.set_send_configs(oappAddress, sendLibAddress, [
{
eid: remoteEid,
config_type: MessageLibConfigType.EXECUTOR, // 1
config: executorConfig,
},
]);

await account.execute([call]);

Executor Config Structure

FieldTypeDescription
max_message_sizenumberMaximum message size in bytes
executorstringExecutor contract address

Setting Enforced Options

Enforced options set minimum execution parameters that users cannot override.

This requires the OAppOptionsType3 component (included in OFT contracts). If your ABI doesn't expose set_enforced_options, load your compiled contract artifact and call the entrypoint directly.

import {Contract} from 'starknet';
import {Options} from '@layerzerolabs/lz-v2-utilities';
import compiledArtifact from './path/to/contract_class.json';

// Use your compiled ABI for OFT/OAppOptionsType3 since getOAppContract
// only exposes the base OApp interface.
const oappAbi = compiledArtifact.abi; // Load from target/dev/*.contract_class.json
const oapp = new Contract({abi: oappAbi, address: oappAddress, provider}).typedv2(oappAbi);

// Build options with minimum gas requirements
const options = Options.newOptions()
.addExecutorLzReceiveOption(200000, 0) // 200k gas for lz_receive
.toBytes();

// Set enforced options for SEND message type (1)
const call = oapp.populateTransaction.set_enforced_options([
{
eid: remoteEid,
msg_type: 1,
options,
},
]);

await account.execute([call]);

If you prefer sncast, call the entrypoint directly:

# lzReceive gas = 120000, value = 0 (no compose)
sncast invoke \
--contract-address <OFT_ADDRESS> \
--function set_enforced_options \
--network sepolia \
--arguments 'array![layerzero::oapps::common::oapp_options_type_3::structs::EnforcedOptionParam { eid: <DST_EID>, msg_type: 1, options: core::byte_array::ByteArray { data: array![], pending_word: 0x0003010011010000000000000000000000000001d4c0, pending_word_len: 22 } }]'

Message Types

TypeValueDescription
SEND1Standard OFT transfer

Complete Configuration Example

import {RpcProvider, Account, Contract} from 'starknet';
import {
getEndpointV2Contract,
getOAppContract,
encodeUlnConfig,
encodeExecutorConfig,
MessageLibConfigType,
} from '@layerzerolabs/lz-v2-protocol-starknet';
import {Options} from '@layerzerolabs/lz-v2-utilities';
import {EndpointId, ChainName, Environment} from '@layerzerolabs/lz-definitions';
import compiledArtifact from './path/to/contract_class.json';

async function configureOApp() {
const provider = new RpcProvider({nodeUrl: RPC_URL});
const account = new Account({provider, address: ACCOUNT_ADDRESS, signer: PRIVATE_KEY});

const endpoint = await getEndpointV2Contract(ChainName.STARKNET, Environment.TESTNET, provider);
const oapp = await getOAppContract(OFT_ADDRESS, provider);
const oappOptions = new Contract({
abi: compiledArtifact.abi,
address: OFT_ADDRESS,
provider,
}).typedv2(compiledArtifact.abi);
const remoteEid = EndpointId.ETHEREUM_V2_MAINNET;
const ULN_ADDRESS = '0x...'; // ULN302 send library address

// Authorize the account to configure endpoint settings (owner-only)
const setDelegateCall = oapp.populateTransaction.set_delegate(ACCOUNT_ADDRESS);

// Set libraries (required if defaults aren't configured for the EID)
const setSendLibCall = endpoint.populateTransaction.set_send_library(
OFT_ADDRESS,
remoteEid,
ULN_ADDRESS,
);
const setReceiveLibCall = endpoint.populateTransaction.set_receive_library(
OFT_ADDRESS,
remoteEid,
ULN_ADDRESS,
0,
);

// 1. Configure send DVN + Executor
const sendConfigCall = endpoint.populateTransaction.set_send_configs(OFT_ADDRESS, ULN_ADDRESS, [
{
eid: remoteEid,
config_type: MessageLibConfigType.ULN,
config: encodeUlnConfig({
confirmations: 15,
has_confirmations: true,
required_dvns: [LAYERZERO_DVN, PARTNER_DVN],
has_required_dvns: true,
optional_dvns: [],
optional_dvn_threshold: 0,
has_optional_dvns: false,
}),
},
{
eid: remoteEid,
config_type: MessageLibConfigType.EXECUTOR,
config: encodeExecutorConfig({
max_message_size: 10000,
executor: EXECUTOR_ADDRESS,
}),
},
]);

// 2. Configure receive DVN
const receiveConfigCall = endpoint.populateTransaction.set_receive_configs(
OFT_ADDRESS,
ULN_ADDRESS,
[
{
eid: remoteEid,
config_type: MessageLibConfigType.ULN,
config: encodeUlnConfig({
confirmations: 15,
has_confirmations: true,
required_dvns: [LAYERZERO_DVN],
has_required_dvns: true,
optional_dvns: [],
optional_dvn_threshold: 0,
has_optional_dvns: false,
}),
},
],
);

// 3. Set enforced options
const enforcedOptionsCall = oappOptions.populateTransaction.set_enforced_options([
{
eid: remoteEid,
msg_type: 1, // SEND message type
options: Options.newOptions().addExecutorLzReceiveOption(200000, 0).toBytes(),
},
]);

// 4. Set peer (LAST!)
const setPeerCall = oapp.populateTransaction.set_peer(remoteEid, {
value: BigInt('0x000000000000000000000000' + EVM_OFT_ADDRESS.slice(2)),
});

// Execute all in atomic transaction
await account.execute([
setDelegateCall,
setSendLibCall,
setReceiveLibCall,
sendConfigCall,
receiveConfigCall,
enforcedOptionsCall,
setPeerCall,
]);

console.log('Configuration complete!');
}

Reading Configuration

Get Current Send Config

import {getUltraLightNodeContractWithAddress} from '@layerzerolabs/lz-v2-protocol-starknet';

const ulnContract = await getUltraLightNodeContractWithAddress(sendLibAddress, provider);

// Get executor config
const executorConfig = await ulnContract.get_raw_oapp_executor_config(oappAddress, remoteEid);
console.log('Max message size:', executorConfig.max_message_size);
console.log('Executor:', executorConfig.executor);

// Get ULN config
const ulnConfig = await ulnContract.get_raw_oapp_uln_send_config(oappAddress, remoteEid);
console.log('Confirmations:', ulnConfig.confirmations);
console.log('Required DVNs:', ulnConfig.required_dvns);

Get Current Receive Config

const ulnConfig = await ulnContract.get_raw_oapp_uln_receive_config(oappAddress, remoteEid);
console.log('Confirmations:', ulnConfig.confirmations);
console.log('Required DVNs:', ulnConfig.required_dvns);

Get Peer

const oapp = await getOAppContract(oappAddress, provider);
const peer = await oapp.get_peer(remoteEid);
console.log('Peer:', peer.value.toString(16));

Gas Recommendations

OperationRecommended GasNotes
lz_receive (OApp)200,000Basic message handling
lz_receive (OFT)200,000Token credit operation
tip

Always test your specific use case on testnet to determine accurate gas requirements.


Next Steps