Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 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
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
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)
})
})
9 changes: 9 additions & 0 deletions packages/devtools-move/tasks/evm/utils/libraryConfigUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ export function buildConfig(
if (!ulnConfig.optionalDVNs) {
ulnConfig.optionalDVNs = []
}
// requiredDVNs is optional on the shared user-config type, but the encoder below maps an
// empty required set to the NIL sentinel (pin "no required DVNs") and has no way to express
// "inherit the default" (count 0). Reject an omitted requiredDVNs loudly rather than silently
// pinning the least-secure shape; callers must pass the DVNs explicitly, or [] to pin none.
if (!ulnConfig.requiredDVNs) {
throw new Error(
'requiredDVNs must be specified for the Move config path; it cannot inherit the on-chain default. Pass the required DVNs explicitly, or [] to pin "no required DVNs".'
)
}
const _optionalDVNs = returnChecksums(ulnConfig.optionalDVNs)
const _requiredDVNs = returnChecksums(ulnConfig.requiredDVNs)

Expand Down
20 changes: 20 additions & 0 deletions packages/devtools-solana/src/common/numbers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,27 @@
// Compute the maximum whole-token supply for a Solana SPL mint given its local decimals.

import BN from 'bn.js'

const U64_MAX = (BigInt(1) << BigInt(64)) - BigInt(1)

/**
* The big-number type the Solana program instruction builders (via beet) expect for
* `u64`/`u128` fields. Re-exported so consumers can name it without depending on `bn.js`
* directly.
*/
export type Bignum = BN

/**
* Converts a `bigint` to a `bn.js` `BN`, which is the numeric type the Solana program
* instruction builders (via beet) expect for `u64`/`u128` fields.
*
* Going through the decimal string keeps full precision for values that overflow a JS
* number (e.g. the `type(uint64).max` NIL sentinel used in ULN configs).
*/
export function bigIntToBN(value: bigint): Bignum {
return new BN(value.toString())
}

/** Returns the maximum whole-token supply given u64 base-unit cap:
* floor(U64_MAX / 10^localDecimals).
*/
Expand Down
Loading
Loading