Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
30 changes: 30 additions & 0 deletions .changeset/uln-nil-sentinels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
"@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

When serializing an OApp ULN302 config, an explicitly-empty field now pins the
literal zero/none via the protocol's NIL sentinel, while an omitted field still
inherits the on-chain default:

- `confirmations: 0n` now serializes to `NIL_CONFIRMATIONS` (`type(uint64).max`).
- `optionalDVNs: []` now serializes to `NIL_DVN_COUNT` (`0xff`), matching the
existing behavior of `requiredDVNs: []`.
- Omitting a field (leaving it `undefined`) continues to inherit the on-chain
default.

To support this, `Uln302UlnConfig`/`UlnReadUlnConfig` now carry `optionalDVNCount`
(and `UlnReadUlnConfig` also carries `requiredDVNCount`) so the stored sentinel
round-trips through the configuration diff. The on-chain read path no longer
re-applies the empty→NIL mapping, keeping `hasAppUlnConfig` idempotent. 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` 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, which is security-relevant.
2 changes: 2 additions & 0 deletions packages/protocol-devtools-evm/src/uln302/blockedSdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export class BlockedUln302 extends Uln302 {
optionalDVNs: [],
optionalDVNThreshold: 0,
requiredDVNCount: 255, // type(uint8).max indicates blocked
optionalDVNCount: 0,
}
}

Expand Down Expand Up @@ -67,6 +68,7 @@ export class BlockedUln302 extends Uln302 {
optionalDVNs: [],
optionalDVNThreshold: 0,
requiredDVNCount: 255, // type(uint8).max indicates blocked
optionalDVNCount: 0,
}
}

Expand Down
88 changes: 79 additions & 9 deletions packages/protocol-devtools-evm/src/uln302/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import { abi } from '@layerzerolabs/lz-evm-sdk-v2/artifacts/contracts/uln/uln302

// A value used to indicate that no DVNs are required. It has to be used instead of 0, because 0 falls back to default value.
const NIL_DVN_COUNT = (1 << 8) - 1 // type(uint8).max = 255
// A value used to indicate that no confirmations are required. It has to be used instead of 0, because 0 falls back to default value.
const NIL_CONFIRMATIONS = (BigInt(1) << BigInt(64)) - BigInt(1) // type(uint64).max

export class Uln302 extends OmniSDK implements IUln302 {
constructor(provider: Provider, point: OmniPoint) {
Expand Down Expand Up @@ -55,6 +57,7 @@ export class Uln302 extends OmniSDK implements IUln302 {
requiredDVNs: config.requiredDVNs,
requiredDVNCount: config.requiredDVNCount,
optionalDVNs: config.optionalDVNs,
optionalDVNCount: config.optionalDVNCount,
optionalDVNThreshold: config.optionalDVNThreshold ?? 0,
}
return Uln302UlnConfigSchema.parse(parsed)
Expand Down Expand Up @@ -86,6 +89,7 @@ export class Uln302 extends OmniSDK implements IUln302 {
requiredDVNs: config.requiredDVNs,
requiredDVNCount: config.requiredDVNCount,
optionalDVNs: config.optionalDVNs,
optionalDVNCount: config.optionalDVNCount,
optionalDVNThreshold: config.optionalDVNThreshold ?? 0,
}
return Uln302UlnConfigSchema.parse(parsed)
Expand All @@ -105,7 +109,7 @@ export class Uln302 extends OmniSDK implements IUln302 {
)

const currentConfig = await this.getAppUlnConfig(eid, oapp, type)
const currentSerializedConfig = this.serializeUlnConfig(currentConfig)
const currentSerializedConfig = this.normalizeUlnConfig(currentConfig)
const serializedConfig = this.serializeUlnConfig(config)

this.logger.debug(`Current ULN ${type} config: ${printJson(currentSerializedConfig)}`)
Expand Down Expand Up @@ -210,6 +214,7 @@ export class Uln302 extends OmniSDK implements IUln302 {
requiredDVNs: rtnConfig.requiredDVNs,
requiredDVNCount: rtnConfig.requiredDVNCount,
optionalDVNs: rtnConfig.optionalDVNs,
optionalDVNCount: rtnConfig.optionalDVNCount,
optionalDVNThreshold: rtnConfig.optionalDVNThreshold ?? 0,
}
return Uln302UlnConfigSchema.parse(parsed)
Expand All @@ -223,7 +228,9 @@ export class Uln302 extends OmniSDK implements IUln302 {
}

async setDefaultUlnConfig(eid: EndpointId, config: Uln302UlnUserConfig): Promise<OmniTransaction> {
const serializedConfig = this.serializeUlnConfig(config)
// The library-wide DEFAULT config stores literal values and rejects NIL sentinels,
// so we serialize without the empty → NIL mapping.
const serializedConfig = this.serializeUlnConfig(config, false)
const data = this.contract.contract.interface.encodeFunctionData('setDefaultUlnConfigs', [
[
{
Expand All @@ -249,20 +256,83 @@ export class Uln302 extends OmniSDK implements IUln302 {
* @param {Uln302UlnUserConfig} config
* @returns {SerializedUln302UlnConfig}
*/
protected serializeUlnConfig({
confirmations = BigInt(0),
protected serializeUlnConfig(
{ confirmations, requiredDVNs, requiredDVNCount, optionalDVNs, optionalDVNThreshold = 0 }: Uln302UlnUserConfig,
/**
* Whether to encode explicitly-empty fields as NIL sentinels.
*
* For an OApp config this must be `true`: an omitted field inherits the
* on-chain default (stored as `0`), whereas an explicitly-empty field
* (`confirmations: 0n`, `requiredDVNs: []`, `optionalDVNs: []`) pins the
* literal zero/none via a NIL sentinel.
*
* For the library-wide DEFAULT config this must be `false`: the contract
* rejects NIL sentinels there (see `setDefaultUlnConfigs`), so empty/zero
* values must stay literal.
*/
useNilSentinels = true
): SerializedUln302UlnConfig {
// requiredDVNs is mandatory on the user config, so the only signal is empty vs non-empty.
// An explicit count override always wins.
const resolvedRequiredDVNCount =
requiredDVNCount ?? (requiredDVNs.length > 0 ? requiredDVNs.length : useNilSentinels ? NIL_DVN_COUNT : 0)

// optionalDVNs is optional, so we distinguish omitted (undefined → inherit default)
// from explicitly empty (`[]` → pin "no optional DVNs" via NIL).
const resolvedOptionalDVNCount =
optionalDVNs == null
? 0
: optionalDVNs.length > 0
? optionalDVNs.length
: useNilSentinels
? NIL_DVN_COUNT
: 0

// The contract requires the threshold to be 0 unless there are concrete optional DVNs.
const hasConcreteOptionalDVNs = resolvedOptionalDVNCount !== 0 && resolvedOptionalDVNCount !== NIL_DVN_COUNT

return {
confirmations:
confirmations == null
? BigInt(0)
: confirmations === BigInt(0) && useNilSentinels
? NIL_CONFIRMATIONS
: confirmations,
optionalDVNThreshold: hasConcreteOptionalDVNs ? optionalDVNThreshold : 0,
requiredDVNs: requiredDVNs.map(addChecksum).sort(compareBytes32Ascending),
optionalDVNs: (optionalDVNs ?? []).map(addChecksum).sort(compareBytes32Ascending),
requiredDVNCount: resolvedRequiredDVNCount,
optionalDVNCount: resolvedOptionalDVNCount,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

encodeUlnConfig ignores chain semantics

High Severity

encodeUlnConfig still runs every input through user-oriented serializeUlnConfig, so chain-shaped configs (e.g. from getAppUlnConfig or decode→encode) are mis-encoded: stored inherit confirmations of 0 becomes NIL_CONFIRMATIONS, and optionalDVNCount: 0 with optionalDVNs: [] becomes NIL_DVN_COUNT because optionalDVNCount is not honored like requiredDVNCount.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit fb9da3b. Configure here.

}
}

/**
* Normalizes a ULN config read from the chain into the same shape `serializeUlnConfig`
* produces, WITHOUT applying the empty → NIL mapping.
*
* The on-chain struct already carries resolved values — `0`/empty means "inherit
* default", a NIL sentinel means "explicitly none". Re-applying the user-config NIL
* mapping here would rewrite a stored `0` into NIL and break the idempotency of
* `hasAppUlnConfig` (an omitted user field would never match a never-set chain value).
*
* @param {Uln302UlnConfig} config
* @returns {SerializedUln302UlnConfig}
*/
protected normalizeUlnConfig({
confirmations,
requiredDVNs,
requiredDVNCount = requiredDVNs.length > 0 ? requiredDVNs.length : NIL_DVN_COUNT,
optionalDVNs = [],
optionalDVNThreshold = 0,
}: Uln302UlnUserConfig): SerializedUln302UlnConfig {
requiredDVNCount,
optionalDVNs,
optionalDVNCount,
optionalDVNThreshold,
}: Uln302UlnConfig): SerializedUln302UlnConfig {
return {
confirmations,
optionalDVNThreshold,
requiredDVNs: requiredDVNs.map(addChecksum).sort(compareBytes32Ascending),
optionalDVNs: optionalDVNs.map(addChecksum).sort(compareBytes32Ascending),
requiredDVNCount,
optionalDVNCount: optionalDVNs.length,
optionalDVNCount,
}
}

Expand Down
64 changes: 55 additions & 9 deletions packages/protocol-devtools-evm/src/ulnRead/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export class UlnRead extends OmniSDK implements IUlnRead {
this.logger.verbose(`Checking whether ULN read configs for channelId ${channelId} and OApp ${oapp} match`)

const currentConfig = await this.getAppUlnConfig(channelId, oapp)
const currentSerializedConfig = this.serializeUlnConfig(currentConfig)
const currentSerializedConfig = this.normalizeUlnConfig(currentConfig)
const serializedConfig = this.serializeUlnConfig(config)

this.logger.debug(`Current ULN read config: ${printJson(currentSerializedConfig)}`)
Expand All @@ -95,7 +95,9 @@ export class UlnRead extends OmniSDK implements IUlnRead {
}

async setDefaultUlnConfig(channelId: number, config: UlnReadUlnUserConfig): Promise<OmniTransaction> {
const serializedConfig = this.serializeUlnConfig(config)
// The library-wide DEFAULT config stores literal values and rejects NIL sentinels,
// so we serialize without the empty → NIL mapping.
const serializedConfig = this.serializeUlnConfig(config, false)
const data = this.contract.contract.interface.encodeFunctionData('setDefaultReadLibConfigs', [
[
{
Expand All @@ -121,16 +123,60 @@ export class UlnRead extends OmniSDK implements IUlnRead {
* @param {UlnReadUlnUserConfig} config
* @returns {SerializedUlnReadUlnConfig}
*/
protected serializeUlnConfig({
protected serializeUlnConfig(
{ requiredDVNs, optionalDVNs, optionalDVNThreshold = 0, executor = makeZeroAddress() }: UlnReadUlnUserConfig,
/**
* Whether to encode explicitly-empty fields as NIL sentinels. `true` for an OApp
* config (explicit `[]` pins "no DVNs"), `false` for the library-wide DEFAULT config
* (which rejects NIL sentinels on-chain).
*/
useNilSentinels = true
): SerializedUlnReadUlnConfig {
// optionalDVNs is optional, so we distinguish omitted (undefined → inherit default)
// from explicitly empty (`[]` → pin "no optional DVNs" via NIL).
const resolvedOptionalDVNCount =
optionalDVNs == null
? 0
: optionalDVNs.length > 0
? optionalDVNs.length
: useNilSentinels
? NIL_DVN_COUNT
: 0

const hasConcreteOptionalDVNs = resolvedOptionalDVNCount !== 0 && resolvedOptionalDVNCount !== NIL_DVN_COUNT

return {
executor,
requiredDVNCount: requiredDVNs.length > 0 ? requiredDVNs.length : useNilSentinels ? NIL_DVN_COUNT : 0,
optionalDVNCount: resolvedOptionalDVNCount,
optionalDVNThreshold: hasConcreteOptionalDVNs ? optionalDVNThreshold : 0,
requiredDVNs: requiredDVNs.map(addChecksum).sort(compareBytes32Ascending),
optionalDVNs: (optionalDVNs ?? []).map(addChecksum).sort(compareBytes32Ascending),
}
}

/**
* Normalizes a ULN read config read from the chain into the same shape
* `serializeUlnConfig` produces, WITHOUT applying the empty → NIL mapping.
*
* Re-applying the user-config NIL mapping to an on-chain read would rewrite a stored
* `0` (inherit default) into NIL and break the idempotency of `hasAppUlnConfig`.
*
* @param {UlnReadUlnConfig} config
* @returns {SerializedUlnReadUlnConfig}
*/
protected normalizeUlnConfig({
executor,
requiredDVNs,
optionalDVNs = [],
optionalDVNThreshold = 0,
executor = makeZeroAddress(),
}: UlnReadUlnUserConfig): SerializedUlnReadUlnConfig {
requiredDVNCount,
optionalDVNs,
optionalDVNCount,
optionalDVNThreshold,
}: UlnReadUlnConfig): SerializedUlnReadUlnConfig {
return {
executor,
requiredDVNCount: requiredDVNs.length > 0 ? requiredDVNs.length : NIL_DVN_COUNT,
optionalDVNCount: optionalDVNs.length,
requiredDVNCount,
optionalDVNCount,
optionalDVNThreshold,
requiredDVNs: requiredDVNs.map(addChecksum).sort(compareBytes32Ascending),
optionalDVNs: optionalDVNs.map(addChecksum).sort(compareBytes32Ascending),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,13 @@ describe('NIL_DVN_COUNT consistency across SDKs', () => {
const serialized302 = (uln302Sdk as any).serializeUlnConfig(uln302Config)
const serializedRead = (ulnReadSdk as any).serializeUlnConfig(ulnReadConfig)

// Both should use NIL_DVN_COUNT
// Both should use NIL_DVN_COUNT for the explicitly-empty required DVNs
expect(serialized302.requiredDVNCount).toBe(NIL_DVN_COUNT)
expect(serializedRead.requiredDVNCount).toBe(NIL_DVN_COUNT)

// Optional DVN count should still be 0
expect(serialized302.optionalDVNCount).toBe(0)
expect(serializedRead.optionalDVNCount).toBe(0)
// And both should use NIL_DVN_COUNT for the explicitly-empty optional DVNs
expect(serialized302.optionalDVNCount).toBe(NIL_DVN_COUNT)
expect(serializedRead.optionalDVNCount).toBe(NIL_DVN_COUNT)
})

it('should handle non-empty arrays consistently', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ describe('uln302/nil-dvn-count', () => {
const serialized = (ulnSdk as any).serializeUlnConfig(config)

expect(serialized.requiredDVNCount).toBe(NIL_DVN_COUNT)
expect(serialized.optionalDVNCount).toBe(0) // Optional DVNs should still use array length
// An explicitly-empty optionalDVNs array pins "no optional DVNs" via NIL,
// exactly like requiredDVNs (omit the field to inherit the default instead).
expect(serialized.optionalDVNCount).toBe(NIL_DVN_COUNT)
})

it('should use actual array length when requiredDVNs is not empty', () => {
Expand Down
Loading
Loading