Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
fb9da3b
fix(protocol-devtools): treat explicitly-empty ULN config values as N…
St0rmBr3w Jun 22, 2026
c6a92e0
fix(protocol-devtools): address PR review on ULN NIL sentinels
St0rmBr3w Jun 22, 2026
cebc3e9
fix(devtools): complete ULN NIL round-trip for required DVNs + run So…
St0rmBr3w Jun 22, 2026
6f8e19e
fix(protocol-devtools-evm): complete ULN NIL round-trip for the Read …
St0rmBr3w Jun 26, 2026
cc068e2
refactor(protocol-devtools): hoist shared ULN empty->NIL resolution i…
St0rmBr3w Jun 26, 2026
8d97cde
fix(lzapp-migration): guard encodeUlnConfig against omitted DVN arrays
St0rmBr3w Jun 26, 2026
998bc10
refactor(protocol-devtools): make requiredDVNs optional, unify DVN co…
St0rmBr3w Jun 26, 2026
a108405
fix(lzapp-migration): default scalar ULN fields in encodeUlnConfig
St0rmBr3w Jun 26, 2026
5c3d31c
docs(metadata-tools): changeset for empty optionalDVNs pinning NIL
St0rmBr3w Jun 26, 2026
3578560
docs(changeset): reframe NIL semantics around team-controlled config
St0rmBr3w Jun 26, 2026
8a43cf7
fix(protocol-devtools-evm): reject empty requiredDVNs on the default …
St0rmBr3w Jun 27, 2026
2efdd21
test(ua-devtools-evm-hardhat): cover the ULN config generators
St0rmBr3w Jun 27, 2026
05efccd
fix(protocol-devtools-evm): allow optional-only default ULN config
St0rmBr3w Jun 27, 2026
14accc4
docs(lzapp-migration): document encodeUlnConfig resolved-config invar…
St0rmBr3w Jun 27, 2026
b97eb44
fix(devtools-move): guard against omitted requiredDVNs in buildConfig
St0rmBr3w Jun 27, 2026
cda3f1b
fix(devtools-move): reject omitted requiredDVNs instead of pinning NIL
St0rmBr3w Jun 27, 2026
e291989
refactor(protocol-devtools): hoist ULN threshold/default/generator lo…
St0rmBr3w Jun 27, 2026
d49c45b
docs(examples): explain the DVN config tuple in oft / oft-solana configs
St0rmBr3w Jun 29, 2026
61761a6
test(protocol-devtools-solana): cover requiredDVNs sentinel mapping
St0rmBr3w Jun 29, 2026
a7c1d41
test(ua-devtools-evm-hardhat): match generator field omission in expe…
St0rmBr3w Jun 30, 2026
53ff395
docs(oft-example): clarify optionalDVNs pin in simple-workers-mock
St0rmBr3w Jun 30, 2026
e1bbdc1
test(ua-devtools-evm-hardhat): note expected-config helpers assume se…
St0rmBr3w Jun 30, 2026
61b9e5c
refactor(protocol-devtools): review nits — guard clauses, naming, dea…
St0rmBr3w Jun 30, 2026
f348eb7
Merge branch 'main' into krak/fix-uln-nil-sentinels
St0rmBr3w Jun 30, 2026
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
9 changes: 9 additions & 0 deletions .changeset/devtools-move-required-dvns-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@layerzerolabs/devtools-move": patch
---

Reject an omitted `requiredDVNs` in `buildConfig` with a clear error. `requiredDVNs` is now
optional on the shared `Uln302UlnUserConfig` type, but this encoder maps an empty required set
to the NIL sentinel (pin "no required DVNs") and cannot express "inherit the on-chain default".
Defaulting an omitted value to `[]` would silently pin the least-secure shape, so it now throws
instead — callers must pass the required DVNs explicitly, or `[]` to pin "no required DVNs".
9 changes: 9 additions & 0 deletions .changeset/generator-uln-nil-sentinels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@layerzerolabs/ua-devtools-evm-hardhat": patch
---

Generate ULN configs (both the ULN302 send/receive and the Read library generators) that
round-trip the new NIL-sentinel semantics: a field inheriting the on-chain default is
OMITTED (for both `requiredDVNs` and `optionalDVNs`, which now behave identically) rather
than emitted as an explicit empty value that would pin zero/none on re-apply. Pinned-none
configs continue to emit `[]`/`0n`.
21 changes: 21 additions & 0 deletions .changeset/metadata-tools-optional-dvns-nil.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@layerzerolabs/metadata-tools": minor
---

`generateConnectionsConfig` now treats a pathway with no optional DVNs as an explicit
"no optional DVNs" (pinned via the NIL sentinel) instead of a value that inherits the
on-chain default.

The emitted config still carries `optionalDVNs: []`, but under the new ULN302 sentinel
semantics that empty array now pins "no optional DVNs" on-chain rather than falling back
to the chain default. This is deliberate: the metadata config is the primary way a config
is consumed, and an empty optional-DVN set should be visible in the file rather than
silently inheriting the default.

Re-wiring a pathway that previously inherited the on-chain default will now pin its
optional-DVN set explicitly. If that default carried optional DVNs (a non-zero threshold),
pinning an empty set drops them — this is intended. The goal is that a team's verification
config is exactly what their config file says, not something that can change underneath them
when a LayerZero-controlled default is updated. An empty optional-DVN set means "no optional
DVNs"; teams that want an optional quorum should list those DVNs explicitly. Required DVNs
are unaffected by this change.
7 changes: 7 additions & 0 deletions .changeset/solana-bigint-to-bn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@layerzerolabs/devtools-solana": minor
---

Add `bigIntToBN` helper (and `Bignum` type) for converting a `bigint` to the `BN` type
the Solana program instruction builders expect, preserving full precision for `u64`
values that overflow a JS number.
51 changes: 51 additions & 0 deletions .changeset/uln-nil-sentinels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
"@layerzerolabs/protocol-devtools": major
"@layerzerolabs/protocol-devtools-evm": major
"@layerzerolabs/protocol-devtools-solana": major
---

Treat explicitly-empty ULN302 config values as NIL sentinels instead of defaults

This lets a team pin a literal "none"/"zero" so their security configuration is exactly
what their config file says, rather than silently inheriting a default that LayerZero
controls. Being able to opt out of defaults is the point: a pinned config cannot change
underneath a team when a default is updated.

When serializing an OApp ULN302 / Read config, `requiredDVNs` and `optionalDVNs` now
behave identically — omitted, explicitly-empty, and concrete each map to a distinct
on-chain meaning:

- Omitting a DVN field (leaving it `undefined`) inherits the on-chain default.
- An explicitly-empty array (`[]`) pins "no DVNs" via `NIL_DVN_COUNT` (`0xff`).
- A concrete array pins those DVNs.
- Likewise `confirmations: 0n` now serializes to `NIL_CONFIRMATIONS`
(`type(uint64).max`), while omitting it inherits the default.

To make `requiredDVNs` express "inherit" the same way `optionalDVNs` already could, it
is now OPTIONAL on `Uln302UlnUserConfig` and `UlnReadUlnUserConfig` (previously
mandatory). This removes the need for any count override — the count is always derived
from the array, so the three serializers (EVM ULN302, EVM Read, Solana ULN302) share a
single `resolveDVNCount` helper.

The read types `Uln302UlnConfig`/`UlnReadUlnConfig` carry `optionalDVNCount` (and
`UlnReadUlnConfig` also `requiredDVNCount`) so the stored sentinel round-trips through
the configuration diff, and the on-chain read path normalizes rather than re-applying
the empty→NIL mapping, keeping `hasAppUlnConfig` idempotent on both paths. The
library-wide DEFAULT config continues to serialize literal values (it rejects NIL
sentinels on-chain). On Solana, `confirmations` is now encoded as a `BN` so the `u64`
NIL sentinel survives without precision loss.

MIGRATION:

- If you wrote `confirmations: 0`, `requiredDVNs: []`, or `optionalDVNs: []` expecting
the config to inherit the protocol default, OMIT the field instead. An explicit empty
value now pins literal zero/none — for `confirmations` this means zero block
confirmations, and for `requiredDVNs` it means no required DVNs, both
security-relevant. Re-wiring an existing OApp whose config used these empty values
will emit a `setConfig` that flips it from inherit to pinned.
- The read types `Uln302UlnConfig` (gains `optionalDVNCount`) and `UlnReadUlnConfig`
(gains `requiredDVNCount` and `optionalDVNCount`) have new required fields. Any code
that hand-constructs one of these (e.g. mocking an SDK read) must supply the new
fields.
- `requiredDVNs` is no longer required on the user config. Code that always set it
keeps working unchanged; you may now omit it to inherit the on-chain default.
24 changes: 10 additions & 14 deletions examples/lzapp-migration/layerzero.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,16 @@ const config: OAppOmniGraphHardhat = {
ulnConfig: {
confirmations: BigInt(15),
requiredDVNs: ['0x8eebf8b423b73bfca51a1db4b7354aa0bfca9193'], // LayerZero Labs DVN
optionalDVNs: [],
optionalDVNThreshold: 0,
// optionalDVNs omitted → inherit the default. To pin "no optional DVNs"
// explicitly, set `optionalDVNs: []` (it now serializes to the NIL sentinel).
},
},
receiveConfig: {
ulnConfig: {
confirmations: BigInt(32),
requiredDVNs: ['0x8eebf8b423b73bfca51a1db4b7354aa0bfca9193'],
optionalDVNs: [],
optionalDVNThreshold: 0,
// optionalDVNs omitted → inherit the default. To pin "no optional DVNs"
// explicitly, set `optionalDVNs: []` (it now serializes to the NIL sentinel).
},
},
},
Expand Down Expand Up @@ -85,11 +85,9 @@ const config: OAppOmniGraphHardhat = {
requiredDVNs: [
'4VDjp6XQaxoZf5RGwiPU9NR1EXSZn2TP4ATMmiSzLfhb', // LayerZero
],
// The address of the DVNs you will pay to verify a sent message on the source chain ).
// The destination tx will wait until the configured threshold of `optionalDVNs` verify a message.
optionalDVNs: [],
// The number of `optionalDVNs` that need to successfully verify the message for it to be considered Verified.
optionalDVNThreshold: 0,
// optionalDVNs omitted → inherit the default. To pin "no optional DVNs"
// explicitly, set `optionalDVNs: []` (it now serializes to the NIL sentinel),
// with `optionalDVNThreshold: 0`.
},
},
// Optional Receive Configuration
Expand All @@ -103,11 +101,9 @@ const config: OAppOmniGraphHardhat = {
requiredDVNs: [
'4VDjp6XQaxoZf5RGwiPU9NR1EXSZn2TP4ATMmiSzLfhb', // LayerZero
],
// The address of the DVNs you will pay to verify a sent message on the source chain ).
// The destination tx will wait until the configured threshold of `optionalDVNs` verify a message.
optionalDVNs: [],
// The number of `optionalDVNs` that need to successfully verify the message for it to be considered Verified.
optionalDVNThreshold: 0,
// optionalDVNs omitted → inherit the default. To pin "no optional DVNs"
// explicitly, set `optionalDVNs: []` (it now serializes to the NIL sentinel),
// with `optionalDVNThreshold: 0`.
},
},
enforcedOptions: [
Expand Down
16 changes: 8 additions & 8 deletions examples/lzapp-migration/lzapp.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,16 +40,16 @@ const config: OAppOmniGraphHardhat = {
ulnConfig: {
confirmations: BigInt(1),
requiredDVNs: ['0x53f488e93b4f1b60e8e83aa374dbe1780a1ee8a8'], // LayerZero Labs DVN for Arbitrum Sepolia
optionalDVNs: [],
optionalDVNThreshold: 0,
// optionalDVNs omitted → inherit the default. Set `optionalDVNs: []` to
// pin "no optional DVNs" (it now serializes to the NIL sentinel).
},
},
receiveConfig: {
ulnConfig: {
confirmations: BigInt(1),
requiredDVNs: ['0x53f488e93b4f1b60e8e83aa374dbe1780a1ee8a8'], // LayerZero Labs DVN for Arbitrum Sepolia
optionalDVNs: [],
optionalDVNThreshold: 0,
// optionalDVNs omitted → inherit the default. Set `optionalDVNs: []` to
// pin "no optional DVNs" (it now serializes to the NIL sentinel).
},
},
},
Expand All @@ -71,16 +71,16 @@ const config: OAppOmniGraphHardhat = {
ulnConfig: {
confirmations: BigInt(1),
requiredDVNs: ['0x8eebf8b423b73bfca51a1db4b7354aa0bfca9193'], // LayerZero Labs DVN on Ethereum Sepolia
optionalDVNs: [],
optionalDVNThreshold: 0,
// optionalDVNs omitted → inherit the default. Set `optionalDVNs: []` to
// pin "no optional DVNs" (it now serializes to the NIL sentinel).
},
},
receiveConfig: {
ulnConfig: {
confirmations: BigInt(1),
requiredDVNs: ['0x8eebf8b423b73bfca51a1db4b7354aa0bfca9193'], // LayerZero Labs DVN on Ethereum Sepolia
optionalDVNs: [],
optionalDVNThreshold: 0,
// optionalDVNs omitted → inherit the default. Set `optionalDVNs: []` to
// pin "no optional DVNs" (it now serializes to the NIL sentinel).
},
},
},
Expand Down
30 changes: 25 additions & 5 deletions examples/lzapp-migration/tasks/common/taskHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,9 @@ export async function getEpv1SendUlnConfig(
const ulnConfig: Uln302UlnConfig = {
confirmations: ulnConfigRaw.confirmations.toNumber(),
requiredDVNs: ulnConfigRaw.requiredDVNs,
requiredDVNCount: ulnConfigRaw.requiredDVNCount,
optionalDVNs: ulnConfigRaw.optionalDVNs,
optionalDVNCount: ulnConfigRaw.optionalDVNCount,
optionalDVNThreshold: ulnConfigRaw.optionalDVNThreshold,
}

Expand Down Expand Up @@ -141,7 +143,9 @@ export async function getEpv1ReceiveUlnConfig(
const ulnConfig: Uln302UlnConfig = {
confirmations: ulnConfigRaw.confirmations.toNumber(),
requiredDVNs: ulnConfigRaw.requiredDVNs,
requiredDVNCount: ulnConfigRaw.requiredDVNCount,
optionalDVNs: ulnConfigRaw.optionalDVNs,
optionalDVNCount: ulnConfigRaw.optionalDVNCount,
optionalDVNThreshold: ulnConfigRaw.optionalDVNThreshold,
}

Expand Down Expand Up @@ -330,7 +334,9 @@ export async function getEpv1DefaultSendConfig(
const emptyUlnConfig: Uln302UlnConfig = {
confirmations: BigInt(0),
requiredDVNs: [zeroAddress],
requiredDVNCount: 0,
optionalDVNs: [],
optionalDVNCount: 0,
optionalDVNThreshold: 0,
}

Expand All @@ -348,7 +354,9 @@ export async function getEpv1DefaultSendConfig(
const ulnConfig: Uln302UlnConfig = {
confirmations: ulnConfigRaw.confirmations.toNumber(),
requiredDVNs: ulnConfigRaw.requiredDVNs,
requiredDVNCount: ulnConfigRaw.requiredDVNCount,
optionalDVNs: ulnConfigRaw.optionalDVNs,
optionalDVNCount: ulnConfigRaw.optionalDVNCount,
optionalDVNThreshold: ulnConfigRaw.optionalDVNThreshold,
}

Expand Down Expand Up @@ -386,7 +394,9 @@ export async function getEpv1DefaultReceiveConfig(
const emptyUlnConfig: Uln302UlnConfig = {
confirmations: BigInt(0),
requiredDVNs: [zeroAddress],
requiredDVNCount: 0,
optionalDVNs: [],
optionalDVNCount: 0,
optionalDVNThreshold: 0,
}

Expand All @@ -404,7 +414,9 @@ export async function getEpv1DefaultReceiveConfig(
const ulnConfig: Uln302UlnConfig = {
confirmations: ulnConfigRaw.confirmations.toNumber(),
requiredDVNs: ulnConfigRaw.requiredDVNs,
requiredDVNCount: ulnConfigRaw.requiredDVNCount,
optionalDVNs: ulnConfigRaw.optionalDVNs,
optionalDVNCount: ulnConfigRaw.optionalDVNCount,
optionalDVNThreshold: ulnConfigRaw.optionalDVNThreshold,
}

Expand Down Expand Up @@ -471,7 +483,15 @@ function encodeExecutorConfig(config: Uln302ExecutorConfig): string {

/**
* Encodes the UlnConfig into ABI-encoded bytes.
* @param config Uln302UlnConfig object
*
* The DVN counts are derived from the array lengths rather than read from the config's
* `requiredDVNCount`/`optionalDVNCount`. That is only valid because the callers here feed
* RESOLVED configs (read via `getConfig` -> `getUlnConfig`, which collapses any stored NIL
* sentinel to 0 before returning). Uln301 inherits `UlnBase`, so its stored config CAN hold a
* NIL sentinel — if this were ever pointed at a raw/stored config (`getAppUlnConfig`), the
* length derivation would silently drop that sentinel. Keep it on the resolved-config path.
*
* @param config Uln302UlnConfig object (resolved, not raw/stored)
* @returns ABI-encoded string
*/
function encodeUlnConfig(config: Uln302UlnConfig): string {
Expand All @@ -481,10 +501,10 @@ function encodeUlnConfig(config: Uln302UlnConfig): string {
],
[
[
config.confirmations,
config.requiredDVNs.length,
config.optionalDVNs.length,
config.optionalDVNThreshold,
config.confirmations ?? 0,
(config.requiredDVNs || []).length,
(config.optionalDVNs || []).length,
config.optionalDVNThreshold ?? 0,
config.requiredDVNs || [],
config.optionalDVNs || [],
],
Expand Down
4 changes: 4 additions & 0 deletions examples/oft-solana/layerzero.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ export default async function () {
[
arbitrumContract, // Chain A contract
solanaContract, // Chain B contract
// DVN config: [ requiredDVN[], [ optionalDVN[], optionalDVNThreshold ] ]
// - requiredDVNs: every listed DVN must verify each message — set these explicitly.
// - optionalDVNs: an empty array pins "no optional DVNs"; it does NOT inherit the
// on-chain default. For an optional quorum, pass [[...DVNs], threshold].
[['LayerZero Labs'], []], // [ requiredDVN[], [ optionalDVN[], threshold ] ]
[15, 32], // [A to B confirmations, B to A confirmations]
[SOLANA_ENFORCED_OPTIONS, EVM_ENFORCED_OPTIONS], // Chain B enforcedOptions, Chain A enforcedOptions
Expand Down
4 changes: 4 additions & 0 deletions examples/oft/layerzero.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ const pathways: TwoWayConfig[] = [
[
baseContract, // Chain A contract
arbitrumContract, // Chain B contract
// DVN config: [ requiredDVN[], [ optionalDVN[], optionalDVNThreshold ] ]
// - requiredDVNs: every listed DVN must verify each message — set these explicitly.
Comment thread
tinom9 marked this conversation as resolved.
// - optionalDVNs: an empty array pins "no optional DVNs"; it does NOT inherit the
// on-chain default. For an optional quorum, pass [[...DVNs], threshold].
[['LayerZero Labs'], []], // [ requiredDVN[], [ optionalDVN[], threshold ] ]
[1, 1], // [A to B confirmations, B to A confirmations]
[EVM_ENFORCED_OPTIONS, EVM_ENFORCED_OPTIONS], // Chain B enforcedOptions, Chain A enforcedOptions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,14 @@ export async function setReceiveConfig(
address: receiveLibrary,
})

// Configure ULN with SimpleDVNMock using V2 interface
// Configure ULN with SimpleDVNMock using V2 interface.
// optionalDVNs: [] explicitly pins "no optional DVNs" (NIL sentinel) for a deterministic mock
// — it does NOT inherit the on-chain default. The threshold is clamped to 0 with no optional
// DVNs, so it is omitted.
const ulnConfig = uln302.encodeUlnConfig({
confirmations: BigInt(1),
requiredDVNs: [dvnAddress],
optionalDVNs: [],
optionalDVNThreshold: 0,
})

const setConfigParams: SetConfigParam[] = [
Expand Down
6 changes: 4 additions & 2 deletions examples/oft/tasks/simple-workers-mock/utils/setSendConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,14 @@ export async function setSendConfig(endpointContract: Contract, params: SetSendC
executor: executorAddress,
})

// Configure ULN with SimpleDVNMock using V2 interface
// Configure ULN with SimpleDVNMock using V2 interface.
// optionalDVNs: [] explicitly pins "no optional DVNs" (NIL sentinel) for a deterministic mock
// — it does NOT inherit the on-chain default. The threshold is clamped to 0 with no optional
// DVNs, so it is omitted.
const ulnConfig = uln302.encodeUlnConfig({
confirmations: BigInt(1),
requiredDVNs: [dvnAddress],
optionalDVNs: [],
optionalDVNThreshold: 0,
})

const setConfigParams: SetConfigParam[] = [
Expand Down
39 changes: 39 additions & 0 deletions packages/devtools-move/jest/buildConfig.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { expect } from 'chai'
import { utils } from 'ethers'
import { buildConfig } from '../tasks/evm/utils/libraryConfigUtils'
import type { Uln302UlnUserConfig } from '@layerzerolabs/toolbox-hardhat'

const DVN = '0x0000000000000000000000000000000000000001'
const ULN_TUPLE = [
'tuple(uint64 confirmations, uint8 requiredDVNCount, uint8 optionalDVNCount, uint8 optionalDVNThreshold, address[] requiredDVNs, address[] optionalDVNs)',
]

const decodeRequiredDVNCount = (ulnConfigBytes: string): number =>
Number(utils.defaultAbiCoder.decode(ULN_TUPLE, ulnConfigBytes)[0].requiredDVNCount)

describe('buildConfig requiredDVNs handling', () => {
it('throws when requiredDVNs is omitted (this path cannot inherit the default)', () => {
const config = { confirmations: BigInt(0), optionalDVNs: [], optionalDVNThreshold: 0 } as Uln302UlnUserConfig
expect(() => buildConfig(config)).to.throw('requiredDVNs must be specified')
})

it('encodes an explicitly-empty requiredDVNs as the NIL sentinel (255)', () => {
const { ulnConfigBytes } = buildConfig({
confirmations: BigInt(0),
requiredDVNs: [],
optionalDVNs: [],
optionalDVNThreshold: 0,
})
expect(decodeRequiredDVNCount(ulnConfigBytes)).to.equal(255)
})

it('encodes a concrete requiredDVNs by its length', () => {
const { ulnConfigBytes } = buildConfig({
confirmations: BigInt(0),
requiredDVNs: [DVN],
optionalDVNs: [],
optionalDVNThreshold: 0,
})
expect(decodeRequiredDVNCount(ulnConfigBytes)).to.equal(1)
})
})
Loading
Loading