From fb9da3bce844e61357d96f66027c7aff4c45318d Mon Sep 17 00:00:00 2001 From: Krak Date: Mon, 22 Jun 2026 10:39:09 -0700 Subject: [PATCH 01/17] fix(protocol-devtools): treat explicitly-empty ULN config values as NIL sentinels An explicitly-empty ULN302 OApp config field now pins literal zero/none via the protocol NIL sentinel, while an omitted field still inherits the on-chain default: - confirmations: 0n -> NIL_CONFIRMATIONS (type(uint64).max) - optionalDVNs: [] -> NIL_DVN_COUNT (0xff), matching requiredDVNs: [] - omitted fields continue to inherit the default Adds optionalDVNCount to the Uln302UlnConfig/UlnReadUlnConfig read shapes (and requiredDVNCount to UlnReadUlnConfig) so the stored sentinel round-trips through the config diff. The on-chain read path no longer re-applies the empty->NIL mapping, keeping hasAppUlnConfig idempotent. The library-wide DEFAULT config still serializes literal values. On Solana, confirmations is encoded as a BN so the u64 NIL sentinel survives without precision loss. Covers EVM (uln302 + ulnRead) and Solana. --- .changeset/uln-nil-sentinels.md | 30 ++++ .../src/uln302/blockedSdk.ts | 2 + .../protocol-devtools-evm/src/uln302/sdk.ts | 88 +++++++++- .../protocol-devtools-evm/src/ulnRead/sdk.ts | 64 ++++++- .../integration/nil-dvn-consistency.test.ts | 8 +- .../test/uln302/nil-dvn-count.test.ts | 4 +- .../test/uln302/nil-sentinels.test.ts | 160 ++++++++++++++++++ .../test/uln302/sdk.test.ts | 11 +- .../test/ulnRead/nil-dvn-count.test.ts | 4 +- .../test/ulnRead/nil-sentinels.test.ts | 77 +++++++++ .../test/ulnRead/sdk.test.ts | 12 ++ .../protocol-devtools-solana/package.json | 3 + .../src/endpointv2/schema.ts | 16 +- .../src/uln302/schema.ts | 18 +- .../src/uln302/sdk.ts | 86 ++++++++-- .../test/endpointv2/schema.test.ts | 37 ++++ .../test/endpointv2/sdk.test.ts | 7 +- .../test/uln302/nil-sentinels.test.ts | 77 +++++++++ .../protocol-devtools/src/uln302/schema.ts | 1 + .../protocol-devtools/src/uln302/types.ts | 1 + .../protocol-devtools/src/ulnRead/schema.ts | 2 + .../protocol-devtools/src/ulnRead/types.ts | 2 + pnpm-lock.yaml | 107 +++++++++++- 23 files changed, 758 insertions(+), 59 deletions(-) create mode 100644 .changeset/uln-nil-sentinels.md create mode 100644 packages/protocol-devtools-evm/test/uln302/nil-sentinels.test.ts create mode 100644 packages/protocol-devtools-evm/test/ulnRead/nil-sentinels.test.ts create mode 100644 packages/protocol-devtools-solana/test/endpointv2/schema.test.ts create mode 100644 packages/protocol-devtools-solana/test/uln302/nil-sentinels.test.ts diff --git a/.changeset/uln-nil-sentinels.md b/.changeset/uln-nil-sentinels.md new file mode 100644 index 0000000000..1de9d58b20 --- /dev/null +++ b/.changeset/uln-nil-sentinels.md @@ -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. diff --git a/packages/protocol-devtools-evm/src/uln302/blockedSdk.ts b/packages/protocol-devtools-evm/src/uln302/blockedSdk.ts index 09cd6caadc..17e55b6b7e 100644 --- a/packages/protocol-devtools-evm/src/uln302/blockedSdk.ts +++ b/packages/protocol-devtools-evm/src/uln302/blockedSdk.ts @@ -28,6 +28,7 @@ export class BlockedUln302 extends Uln302 { optionalDVNs: [], optionalDVNThreshold: 0, requiredDVNCount: 255, // type(uint8).max indicates blocked + optionalDVNCount: 0, } } @@ -67,6 +68,7 @@ export class BlockedUln302 extends Uln302 { optionalDVNs: [], optionalDVNThreshold: 0, requiredDVNCount: 255, // type(uint8).max indicates blocked + optionalDVNCount: 0, } } diff --git a/packages/protocol-devtools-evm/src/uln302/sdk.ts b/packages/protocol-devtools-evm/src/uln302/sdk.ts index 0aa44d6f33..cffc846733 100644 --- a/packages/protocol-devtools-evm/src/uln302/sdk.ts +++ b/packages/protocol-devtools-evm/src/uln302/sdk.ts @@ -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) { @@ -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) @@ -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) @@ -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)}`) @@ -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) @@ -223,7 +228,9 @@ export class Uln302 extends OmniSDK implements IUln302 { } async setDefaultUlnConfig(eid: EndpointId, config: Uln302UlnUserConfig): Promise { - 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', [ [ { @@ -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, + } + } + + /** + * 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, } } diff --git a/packages/protocol-devtools-evm/src/ulnRead/sdk.ts b/packages/protocol-devtools-evm/src/ulnRead/sdk.ts index b4cb6ec9cc..608ce84e0d 100644 --- a/packages/protocol-devtools-evm/src/ulnRead/sdk.ts +++ b/packages/protocol-devtools-evm/src/ulnRead/sdk.ts @@ -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)}`) @@ -95,7 +95,9 @@ export class UlnRead extends OmniSDK implements IUlnRead { } async setDefaultUlnConfig(channelId: number, config: UlnReadUlnUserConfig): Promise { - 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', [ [ { @@ -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), diff --git a/packages/protocol-devtools-evm/test/integration/nil-dvn-consistency.test.ts b/packages/protocol-devtools-evm/test/integration/nil-dvn-consistency.test.ts index 1c5d5e8c36..eca0b38261 100644 --- a/packages/protocol-devtools-evm/test/integration/nil-dvn-consistency.test.ts +++ b/packages/protocol-devtools-evm/test/integration/nil-dvn-consistency.test.ts @@ -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', () => { diff --git a/packages/protocol-devtools-evm/test/uln302/nil-dvn-count.test.ts b/packages/protocol-devtools-evm/test/uln302/nil-dvn-count.test.ts index 953ea94244..11dffa3976 100644 --- a/packages/protocol-devtools-evm/test/uln302/nil-dvn-count.test.ts +++ b/packages/protocol-devtools-evm/test/uln302/nil-dvn-count.test.ts @@ -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', () => { diff --git a/packages/protocol-devtools-evm/test/uln302/nil-sentinels.test.ts b/packages/protocol-devtools-evm/test/uln302/nil-sentinels.test.ts new file mode 100644 index 0000000000..b190adc6f6 --- /dev/null +++ b/packages/protocol-devtools-evm/test/uln302/nil-sentinels.test.ts @@ -0,0 +1,160 @@ +import { MainnetV2EndpointId } from '@layerzerolabs/lz-definitions' +import { makeZeroAddress, Provider } from '@layerzerolabs/devtools-evm' +import { Uln302 } from '@/uln302' +import { Uln302ConfigType } from '@layerzerolabs/protocol-devtools' +import type { Uln302UlnConfig, Uln302UlnUserConfig } from '@layerzerolabs/protocol-devtools' +import { JsonRpcProvider } from '@ethersproject/providers' + +const NIL_DVN_COUNT = 255 +const NIL_CONFIRMATIONS = (BigInt(1) << BigInt(64)) - BigInt(1) +const DVN = '0x0000000000000000000000000000000000000001' + +describe('uln302/nil-sentinels', () => { + let provider: Provider, ulnSdk: Uln302 + + beforeEach(async () => { + provider = new JsonRpcProvider() + ulnSdk = new Uln302(provider, { eid: MainnetV2EndpointId.ETHEREUM_V2_MAINNET, address: makeZeroAddress() }) + }) + + const serialize = (config: Uln302UlnUserConfig, useNilSentinels?: boolean) => + (ulnSdk as any).serializeUlnConfig(config, useNilSentinels) + + describe('serializeUlnConfig confirmations', () => { + it('maps an omitted confirmations to 0 (inherit the on-chain default)', () => { + expect(serialize({ requiredDVNs: [DVN] }).confirmations).toBe(BigInt(0)) + }) + + it('maps an explicit zero confirmations to NIL_CONFIRMATIONS (pin literal zero)', () => { + expect(serialize({ requiredDVNs: [DVN], confirmations: BigInt(0) }).confirmations).toBe(NIL_CONFIRMATIONS) + }) + + it('passes a non-zero confirmations through unchanged', () => { + expect(serialize({ requiredDVNs: [DVN], confirmations: BigInt(15) }).confirmations).toBe(BigInt(15)) + }) + + it('keeps an explicit zero literal for the DEFAULT config (no NIL mapping)', () => { + expect(serialize({ requiredDVNs: [DVN], confirmations: BigInt(0) }, false).confirmations).toBe(BigInt(0)) + }) + }) + + describe('serializeUlnConfig optionalDVNs', () => { + it('maps omitted optionalDVNs to count 0 (inherit the on-chain default)', () => { + const serialized = serialize({ requiredDVNs: [DVN] }) + expect(serialized.optionalDVNCount).toBe(0) + expect(serialized.optionalDVNThreshold).toBe(0) + }) + + it('maps an explicitly-empty optionalDVNs to NIL_DVN_COUNT (pin "no optional DVNs")', () => { + const serialized = serialize({ requiredDVNs: [DVN], optionalDVNs: [] }) + expect(serialized.optionalDVNCount).toBe(NIL_DVN_COUNT) + expect(serialized.optionalDVNThreshold).toBe(0) + }) + + it('uses the array length for a non-empty optionalDVNs', () => { + const serialized = serialize({ requiredDVNs: [DVN], optionalDVNs: [DVN], optionalDVNThreshold: 1 }) + expect(serialized.optionalDVNCount).toBe(1) + expect(serialized.optionalDVNThreshold).toBe(1) + }) + + it('keeps an explicitly-empty optionalDVNs literal (count 0) for the DEFAULT config', () => { + expect(serialize({ requiredDVNs: [DVN], optionalDVNs: [] }, false).optionalDVNCount).toBe(0) + }) + }) + + describe('normalizeUlnConfig (on-chain read passthrough)', () => { + const normalize = (config: Uln302UlnConfig) => (ulnSdk as any).normalizeUlnConfig(config) + + it('preserves a never-set read (zeros stay zeros, NOT remapped to NIL)', () => { + const normalized = normalize({ + confirmations: BigInt(0), + optionalDVNThreshold: 0, + requiredDVNs: [], + requiredDVNCount: 0, + optionalDVNs: [], + optionalDVNCount: 0, + }) + expect(normalized.confirmations).toBe(BigInt(0)) + expect(normalized.requiredDVNCount).toBe(0) + expect(normalized.optionalDVNCount).toBe(0) + }) + + it('preserves stored NIL sentinels verbatim', () => { + const normalized = normalize({ + confirmations: NIL_CONFIRMATIONS, + optionalDVNThreshold: 0, + requiredDVNs: [], + requiredDVNCount: NIL_DVN_COUNT, + optionalDVNs: [], + optionalDVNCount: NIL_DVN_COUNT, + }) + expect(normalized.confirmations).toBe(NIL_CONFIRMATIONS) + expect(normalized.requiredDVNCount).toBe(NIL_DVN_COUNT) + expect(normalized.optionalDVNCount).toBe(NIL_DVN_COUNT) + }) + }) + + describe('hasAppUlnConfig idempotency', () => { + const read = (over: Partial): Uln302UlnConfig => ({ + confirmations: BigInt(0), + optionalDVNThreshold: 0, + requiredDVNs: [DVN], + requiredDVNCount: 1, + optionalDVNs: [], + optionalDVNCount: 0, + ...over, + }) + + let spy: jest.SpyInstance + + beforeEach(() => { + spy = jest.spyOn(Uln302.prototype, 'getAppUlnConfig') + }) + + afterEach(() => { + spy.mockRestore() + }) + + const hasConfig = async (current: Uln302UlnConfig, desired: Uln302UlnUserConfig) => { + spy.mockResolvedValue(current) + return ulnSdk.hasAppUlnConfig( + MainnetV2EndpointId.ETHEREUM_V2_MAINNET, + makeZeroAddress(), + desired, + Uln302ConfigType.Receive + ) + } + + it('treats an omitted confirmations as matching a never-set chain value', async () => { + await expect(hasConfig(read({}), { requiredDVNs: [DVN] })).resolves.toBe(true) + }) + + it('treats an explicit zero confirmations as DIFFERENT from a never-set chain value', async () => { + // The fix: the user explicitly pins zero confirmations, so a chain that inherits + // the default (stored 0) is NOT a match and must be (re)configured. + await expect(hasConfig(read({}), { requiredDVNs: [DVN], confirmations: BigInt(0) })).resolves.toBe(false) + }) + + it('treats an explicit zero confirmations as matching a chain that stored the NIL sentinel', async () => { + await expect( + hasConfig(read({ confirmations: NIL_CONFIRMATIONS }), { requiredDVNs: [DVN], confirmations: BigInt(0) }) + ).resolves.toBe(true) + }) + + it('treats omitted optionalDVNs as matching a chain with optionalDVNCount 0', async () => { + await expect(hasConfig(read({ optionalDVNCount: 0 }), { requiredDVNs: [DVN] })).resolves.toBe(true) + }) + + it('treats an explicitly-empty optionalDVNs as matching a chain that stored NIL', async () => { + await expect( + hasConfig(read({ optionalDVNCount: NIL_DVN_COUNT }), { requiredDVNs: [DVN], optionalDVNs: [] }) + ).resolves.toBe(true) + }) + + it('treats an explicitly-empty optionalDVNs as DIFFERENT from a chain with optionalDVNCount 0', async () => { + await expect( + hasConfig(read({ optionalDVNCount: 0 }), { requiredDVNs: [DVN], optionalDVNs: [] }) + ).resolves.toBe(false) + }) + }) +}) diff --git a/packages/protocol-devtools-evm/test/uln302/sdk.test.ts b/packages/protocol-devtools-evm/test/uln302/sdk.test.ts index 13c2e6a8ed..88580414df 100644 --- a/packages/protocol-devtools-evm/test/uln302/sdk.test.ts +++ b/packages/protocol-devtools-evm/test/uln302/sdk.test.ts @@ -38,6 +38,7 @@ describe('uln302/sdk', () => { confirmations: BigInt(100), optionalDVNThreshold: 1, optionalDVNs: [AddressZero, AddressZero], + optionalDVNCount: 2, requiredDVNs: [AddressZero], requiredDVNCount: 1, } @@ -57,6 +58,7 @@ describe('uln302/sdk', () => { confirmations: BigInt(100), optionalDVNThreshold: 0, optionalDVNs: dvns, + optionalDVNCount: dvns.length, requiredDVNs: dvns, requiredDVNCount: dvns.length, } @@ -85,6 +87,7 @@ describe('uln302/sdk', () => { confirmations: BigInt(100), optionalDVNThreshold: 0, optionalDVNs: dvns, + optionalDVNCount: dvns.length, requiredDVNs: dvns, requiredDVNCount: dvns.length, } @@ -135,6 +138,7 @@ describe('uln302/sdk', () => { confirmations: BigInt(0), optionalDVNThreshold: 0, optionalDVNs: [], + optionalDVNCount: 0, requiredDVNCount: dvns.length, } @@ -170,9 +174,13 @@ describe('uln302/sdk', () => { describe('hasAppUlnConfig()', () => { const ulnConfigArbitrary = dvnsArbitrary.chain((dvns) => fc.record({ - confirmations: fc.bigInt(), + // Bounded away from 0 (which an explicit user value maps to NIL) and from the + // NIL_CONFIRMATIONS sentinel, so an on-chain read round-trips identically through + // normalizeUlnConfig and serializeUlnConfig. + confirmations: fc.bigInt({ min: BigInt(1), max: BigInt(1_000_000) }), optionalDVNThreshold: fc.integer({ min: 0, max: dvns.length }), optionalDVNs: fc.constant(dvns), + optionalDVNCount: fc.constant(dvns.length), requiredDVNs: fc.constant(dvns), requiredDVNCount: fc.constant(dvns.length), }) @@ -247,6 +255,7 @@ describe('uln302/sdk', () => { const ulnConfig: Uln302UlnConfig = { requiredDVNs: dvns, optionalDVNs: [], + optionalDVNCount: 0, optionalDVNThreshold: 0, confirmations: BigInt(0), requiredDVNCount: dvns.length, diff --git a/packages/protocol-devtools-evm/test/ulnRead/nil-dvn-count.test.ts b/packages/protocol-devtools-evm/test/ulnRead/nil-dvn-count.test.ts index 865d57e6df..73545a92e9 100644 --- a/packages/protocol-devtools-evm/test/ulnRead/nil-dvn-count.test.ts +++ b/packages/protocol-devtools-evm/test/ulnRead/nil-dvn-count.test.ts @@ -28,7 +28,9 @@ describe('ulnRead/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', () => { diff --git a/packages/protocol-devtools-evm/test/ulnRead/nil-sentinels.test.ts b/packages/protocol-devtools-evm/test/ulnRead/nil-sentinels.test.ts new file mode 100644 index 0000000000..2bec748d3c --- /dev/null +++ b/packages/protocol-devtools-evm/test/ulnRead/nil-sentinels.test.ts @@ -0,0 +1,77 @@ +import { MainnetV2EndpointId } from '@layerzerolabs/lz-definitions' +import { makeZeroAddress, Provider } from '@layerzerolabs/devtools-evm' +import { UlnRead } from '@/ulnRead' +import type { UlnReadUlnConfig, UlnReadUlnUserConfig } from '@layerzerolabs/protocol-devtools' +import { JsonRpcProvider } from '@ethersproject/providers' + +const NIL_DVN_COUNT = 255 +const DVN = '0x0000000000000000000000000000000000000001' + +describe('ulnRead/nil-sentinels', () => { + let provider: Provider, ulnSdk: UlnRead + + beforeEach(async () => { + provider = new JsonRpcProvider() + ulnSdk = new UlnRead(provider, { eid: MainnetV2EndpointId.ETHEREUM_V2_MAINNET, address: makeZeroAddress() }) + }) + + describe('serializeUlnConfig optionalDVNs', () => { + const serialize = (config: UlnReadUlnUserConfig, useNilSentinels?: boolean) => + (ulnSdk as any).serializeUlnConfig(config, useNilSentinels) + + it('maps omitted optionalDVNs to count 0 (inherit the on-chain default)', () => { + expect(serialize({ requiredDVNs: [DVN] }).optionalDVNCount).toBe(0) + }) + + it('maps an explicitly-empty optionalDVNs to NIL_DVN_COUNT (pin "no optional DVNs")', () => { + expect(serialize({ requiredDVNs: [DVN], optionalDVNs: [] }).optionalDVNCount).toBe(NIL_DVN_COUNT) + }) + + it('keeps an explicitly-empty optionalDVNs literal (count 0) for the DEFAULT config', () => { + expect(serialize({ requiredDVNs: [DVN], optionalDVNs: [] }, false).optionalDVNCount).toBe(0) + }) + }) + + describe('hasAppUlnConfig idempotency', () => { + const read = (over: Partial): UlnReadUlnConfig => ({ + executor: makeZeroAddress(), + optionalDVNThreshold: 0, + requiredDVNs: [DVN], + requiredDVNCount: 1, + optionalDVNs: [], + optionalDVNCount: 0, + ...over, + }) + + let spy: jest.SpyInstance + + beforeEach(() => { + spy = jest.spyOn(UlnRead.prototype, 'getAppUlnConfig') + }) + + afterEach(() => { + spy.mockRestore() + }) + + const hasConfig = async (current: UlnReadUlnConfig, desired: UlnReadUlnUserConfig) => { + spy.mockResolvedValue(current) + return ulnSdk.hasAppUlnConfig(1, makeZeroAddress(), desired) + } + + it('treats omitted optionalDVNs as matching a chain with optionalDVNCount 0', async () => { + await expect(hasConfig(read({ optionalDVNCount: 0 }), { requiredDVNs: [DVN] })).resolves.toBe(true) + }) + + it('treats an explicitly-empty optionalDVNs as matching a chain that stored NIL', async () => { + await expect( + hasConfig(read({ optionalDVNCount: NIL_DVN_COUNT }), { requiredDVNs: [DVN], optionalDVNs: [] }) + ).resolves.toBe(true) + }) + + it('treats an explicitly-empty optionalDVNs as DIFFERENT from a chain with optionalDVNCount 0', async () => { + await expect( + hasConfig(read({ optionalDVNCount: 0 }), { requiredDVNs: [DVN], optionalDVNs: [] }) + ).resolves.toBe(false) + }) + }) +}) diff --git a/packages/protocol-devtools-evm/test/ulnRead/sdk.test.ts b/packages/protocol-devtools-evm/test/ulnRead/sdk.test.ts index 53cb5c1b96..b6b692a9fb 100644 --- a/packages/protocol-devtools-evm/test/ulnRead/sdk.test.ts +++ b/packages/protocol-devtools-evm/test/ulnRead/sdk.test.ts @@ -27,7 +27,9 @@ describe('ulnRead/sdk', () => { executor: AddressZero, optionalDVNThreshold: 1, optionalDVNs: [AddressZero, AddressZero], + optionalDVNCount: 2, requiredDVNs: [AddressZero], + requiredDVNCount: 1, } const ulnConfigEncoded = ulnSdk.encodeUlnConfig(ulnConfig) expect(ulnConfigEncoded).toMatchSnapshot() @@ -45,7 +47,9 @@ describe('ulnRead/sdk', () => { executor: AddressZero, optionalDVNThreshold: 0, optionalDVNs: dvns, + optionalDVNCount: dvns.length, requiredDVNs: dvns, + requiredDVNCount: dvns.length, } const ulnConfigSorted: UlnReadUlnConfig = { @@ -72,7 +76,9 @@ describe('ulnRead/sdk', () => { executor: AddressZero, optionalDVNThreshold: 0, optionalDVNs: dvns, + optionalDVNCount: dvns.length, requiredDVNs: dvns, + requiredDVNCount: dvns.length, } const ulnConfigSorted: UlnReadUlnConfig = { @@ -118,9 +124,11 @@ describe('ulnRead/sdk', () => { } const ulnConfig: UlnReadUlnConfig = { requiredDVNs: dvns, + requiredDVNCount: dvns.length, executor: AddressZero, optionalDVNThreshold: 0, optionalDVNs: [], + optionalDVNCount: 0, } // Let's check that both the sorted and the unsorted config produce the same transaction @@ -158,7 +166,9 @@ describe('ulnRead/sdk', () => { executor: evmAddressArbitrary, optionalDVNThreshold: fc.integer({ min: 0, max: dvns.length }), optionalDVNs: fc.constant(dvns), + optionalDVNCount: fc.constant(dvns.length), requiredDVNs: fc.constant(dvns), + requiredDVNCount: fc.constant(dvns.length), }) ) @@ -224,7 +234,9 @@ describe('ulnRead/sdk', () => { } const ulnConfig: UlnReadUlnConfig = { requiredDVNs: dvns, + requiredDVNCount: dvns.length, optionalDVNs: [], + optionalDVNCount: 0, optionalDVNThreshold: 0, executor: AddressZero, } diff --git a/packages/protocol-devtools-solana/package.json b/packages/protocol-devtools-solana/package.json index 92b921f791..0dfa708952 100644 --- a/packages/protocol-devtools-solana/package.json +++ b/packages/protocol-devtools-solana/package.json @@ -52,7 +52,9 @@ "@solana/web3.js": "~1.95.8", "@swc/core": "^1.4.0", "@swc/jest": "^0.2.36", + "@types/bn.js": "~5.1.5", "@types/jest": "^29.5.12", + "bn.js": "^5.2.0", "fast-check": "^3.15.1", "fp-ts": "^2.16.2", "jest": "^29.7.0", @@ -73,6 +75,7 @@ "@layerzerolabs/protocol-devtools": "^3.0.2", "@layerzerolabs/ua-devtools": "^5.0.2", "@solana/web3.js": "^1.95.8", + "bn.js": "^5.2.0", "fp-ts": "^2.16.2", "zod": "^3.22.4" }, diff --git a/packages/protocol-devtools-solana/src/endpointv2/schema.ts b/packages/protocol-devtools-solana/src/endpointv2/schema.ts index 8d062bc75e..aa6da250a8 100644 --- a/packages/protocol-devtools-solana/src/endpointv2/schema.ts +++ b/packages/protocol-devtools-solana/src/endpointv2/schema.ts @@ -1,6 +1,7 @@ import { SetConfigType } from '@layerzerolabs/lz-solana-sdk-v2' import { Uln302ExecutorConfigSchema, Uln302UlnConfigSchema } from '@layerzerolabs/protocol-devtools' import { PublicKey } from '@solana/web3.js' +import BN from 'bn.js' import { z } from 'zod' export const SetConfigSchema = z.union([ @@ -14,9 +15,18 @@ export const SetConfigSchema = z.union([ z.object({ configType: z.union([z.literal(SetConfigType.RECEIVE_ULN), z.literal(SetConfigType.SEND_ULN)]), config: Uln302UlnConfigSchema.transform( - ({ confirmations, requiredDVNs, optionalDVNs, optionalDVNThreshold, requiredDVNCount }) => ({ - confirmations: Number(confirmations), - optionalDvnCount: optionalDVNs.length, + ({ + confirmations, + requiredDVNs, + optionalDVNs, + optionalDVNThreshold, + requiredDVNCount, + optionalDVNCount, + }) => ({ + // confirmations is a u64 on-chain; a NIL sentinel (type(uint64).max) overflows a + // JS number, so it must be passed as a BN to avoid precision loss. + confirmations: new BN(confirmations.toString()), + optionalDvnCount: optionalDVNCount, requiredDvnCount: requiredDVNCount, requiredDvns: requiredDVNs.map((dvn) => new PublicKey(dvn)), optionalDvns: optionalDVNs.map((dvn) => new PublicKey(dvn)), diff --git a/packages/protocol-devtools-solana/src/uln302/schema.ts b/packages/protocol-devtools-solana/src/uln302/schema.ts index caad2cbeb8..cb9cf71103 100644 --- a/packages/protocol-devtools-solana/src/uln302/schema.ts +++ b/packages/protocol-devtools-solana/src/uln302/schema.ts @@ -10,11 +10,15 @@ export const Uln302UlnConfigInputSchema: z.ZodSchema ({ - confirmations, - optionalDVNThreshold: optionalDvnThreshold, - requiredDVNs: requiredDvns, - requiredDVNCount: requiredDvnCount, - optionalDVNs: optionalDvns, - })) + .transform( + ({ confirmations, optionalDvnThreshold, requiredDvns, optionalDvns, requiredDvnCount, optionalDvnCount }) => ({ + confirmations, + optionalDVNThreshold: optionalDvnThreshold, + requiredDVNs: requiredDvns, + requiredDVNCount: requiredDvnCount, + optionalDVNs: optionalDvns, + optionalDVNCount: optionalDvnCount, + }) + ) diff --git a/packages/protocol-devtools-solana/src/uln302/sdk.ts b/packages/protocol-devtools-solana/src/uln302/sdk.ts index 8f757177b0..78b7798a6a 100644 --- a/packages/protocol-devtools-solana/src/uln302/sdk.ts +++ b/packages/protocol-devtools-solana/src/uln302/sdk.ts @@ -102,7 +102,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 App ULN ${type} config: ${printJson(currentSerializedConfig)}`) @@ -203,26 +203,79 @@ 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. The library-wide DEFAULT config + * (which is not settable on Solana) would pass `false`. + */ + 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 threshold must 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: serializeDVNs(requiredDVNs), + optionalDVNs: serializeDVNs(optionalDVNs ?? []), + requiredDVNCount: resolvedRequiredDVNCount, + optionalDVNCount: resolvedOptionalDVNCount, + } + } + + /** + * Normalizes a ULN config read from the chain into the same shape `serializeUlnConfig` + * produces, WITHOUT applying the empty → NIL mapping. + * + * The on-chain account already carries resolved values, so re-applying the user-config + * NIL mapping here would rewrite a stored `0` into NIL and break the idempotency of + * `hasAppUlnConfig`. + * + * @param {Uln302UlnConfig} config + * @returns {SerializedUln302UlnConfig} + */ + protected normalizeUlnConfig({ + confirmations, requiredDVNs, - optionalDVNs = [], - optionalDVNThreshold = 0, requiredDVNCount, - }: Uln302UlnUserConfig): SerializedUln302UlnConfig { - // NIL_DVN_COUNT is used to indicate no DVNs are required - // It has to be used instead of 0, because 0 falls back to default value - const NIL_DVN_COUNT = 255 // type(uint8).max - + optionalDVNs, + optionalDVNCount, + optionalDVNThreshold, + }: Uln302UlnConfig): SerializedUln302UlnConfig { return { confirmations, optionalDVNThreshold, requiredDVNs: serializeDVNs(requiredDVNs), optionalDVNs: serializeDVNs(optionalDVNs), - // If requiredDVNCount is explicitly provided, use it - // Otherwise, calculate based on array length (using NIL_DVN_COUNT for empty arrays) - requiredDVNCount: requiredDVNCount ?? (requiredDVNs.length > 0 ? requiredDVNs.length : NIL_DVN_COUNT), - optionalDVNCount: optionalDVNs.length, + requiredDVNCount, + optionalDVNCount, } } @@ -257,6 +310,11 @@ interface SerializedUln302UlnConfig extends Uln302UlnConfig { */ type SerializedUln302ExecutorConfig = Uln302ExecutorConfig +// 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 = 255 // type(uint8).max +// 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 + const serializeDVNs = (dvns: OmniAddress[]) => dvns .map((address) => new PublicKey(address).toBytes()) diff --git a/packages/protocol-devtools-solana/test/endpointv2/schema.test.ts b/packages/protocol-devtools-solana/test/endpointv2/schema.test.ts new file mode 100644 index 0000000000..662f82628f --- /dev/null +++ b/packages/protocol-devtools-solana/test/endpointv2/schema.test.ts @@ -0,0 +1,37 @@ +import { SetConfigType } from '@layerzerolabs/lz-solana-sdk-v2' +import { SetConfigSchema } from '@/endpointv2/schema' + +const NIL_DVN_COUNT = 255 +const NIL_CONFIRMATIONS = (BigInt(1) << BigInt(64)) - BigInt(1) +const DVN = '4VDjp6XQaxoZf5RGwiPU9NR1EXSZn2TP4ATMmiSzLfhb' + +describe('endpointv2/schema SetConfigSchema (solana)', () => { + const parse = (confirmations: bigint, optionalDVNCount: number) => + SetConfigSchema.parse({ + configType: SetConfigType.SEND_ULN, + config: { + confirmations, + optionalDVNThreshold: 0, + requiredDVNs: [DVN], + requiredDVNCount: 1, + optionalDVNs: [], + optionalDVNCount, + }, + }) + + it('encodes NIL_CONFIRMATIONS (u64 max) as a BN without precision loss', () => { + const parsed: any = parse(NIL_CONFIRMATIONS, 0) + // A plain Number(NIL_CONFIRMATIONS) would lose precision; the BN must round-trip exactly. + expect(parsed.config.confirmations.toString()).toBe(NIL_CONFIRMATIONS.toString()) + }) + + it('passes a regular confirmations value through as a BN', () => { + const parsed: any = parse(BigInt(32), 0) + expect(parsed.config.confirmations.toString()).toBe('32') + }) + + it('forwards the optional DVN NIL sentinel rather than recomputing from the array length', () => { + const parsed: any = parse(BigInt(32), NIL_DVN_COUNT) + expect(parsed.config.optionalDvnCount).toBe(NIL_DVN_COUNT) + }) +}) diff --git a/packages/protocol-devtools-solana/test/endpointv2/sdk.test.ts b/packages/protocol-devtools-solana/test/endpointv2/sdk.test.ts index 329f994330..32e18e359a 100644 --- a/packages/protocol-devtools-solana/test/endpointv2/sdk.test.ts +++ b/packages/protocol-devtools-solana/test/endpointv2/sdk.test.ts @@ -262,7 +262,9 @@ describe('endpointv2/sdk', () => { '4VDjp6XQaxoZf5RGwiPU9NR1EXSZn2TP4ATMmiSzLfhb', 'GPjyWr8vCotGuFubDpTxDxy9Vj1ZeEN4F2dwRmFiaGab', ], + requiredDVNCount: 2, optionalDVNs: [], + optionalDVNCount: 0, }) }) }) @@ -289,7 +291,8 @@ describe('endpointv2/sdk', () => { '4VDjp6XQaxoZf5RGwiPU9NR1EXSZn2TP4ATMmiSzLfhb', 'GPjyWr8vCotGuFubDpTxDxy9Vj1ZeEN4F2dwRmFiaGab', ], - optionalDVNs: [], + // optionalDVNs omitted → inherit the on-chain default (which has none), + // an explicit `[]` would now pin NIL and NOT match the default. }, Uln302ConfigType.Send ) @@ -308,7 +311,7 @@ describe('endpointv2/sdk', () => { 'GPjyWr8vCotGuFubDpTxDxy9Vj1ZeEN4F2dwRmFiaGab', '4VDjp6XQaxoZf5RGwiPU9NR1EXSZn2TP4ATMmiSzLfhb', ], - optionalDVNs: [], + // optionalDVNs omitted → inherit the on-chain default (see above). }, Uln302ConfigType.Send ) diff --git a/packages/protocol-devtools-solana/test/uln302/nil-sentinels.test.ts b/packages/protocol-devtools-solana/test/uln302/nil-sentinels.test.ts new file mode 100644 index 0000000000..58e0a5534e --- /dev/null +++ b/packages/protocol-devtools-solana/test/uln302/nil-sentinels.test.ts @@ -0,0 +1,77 @@ +import { Connection } from '@solana/web3.js' +import { PublicKey } from '@solana/web3.js' +import { MainnetV2EndpointId } from '@layerzerolabs/lz-definitions' +import { Uln302 } from '@/uln302' +import type { Uln302UlnConfig, Uln302UlnUserConfig } from '@layerzerolabs/protocol-devtools' + +const NIL_DVN_COUNT = 255 +const NIL_CONFIRMATIONS = (BigInt(1) << BigInt(64)) - BigInt(1) +// A valid base58 public key to stand in for a DVN. +const DVN = '4VDjp6XQaxoZf5RGwiPU9NR1EXSZn2TP4ATMmiSzLfhb' + +describe('uln302/nil-sentinels (solana)', () => { + let ulnSdk: Uln302 + + beforeEach(() => { + const connection = new Connection('http://localhost:8899') + ulnSdk = new Uln302( + connection, + { eid: MainnetV2EndpointId.SOLANA_V2_MAINNET, address: new PublicKey(DVN).toBase58() }, + new PublicKey(DVN) + ) + }) + + const serialize = (config: Uln302UlnUserConfig, useNilSentinels?: boolean) => + (ulnSdk as any).serializeUlnConfig(config, useNilSentinels) + + describe('serializeUlnConfig confirmations', () => { + it('maps an omitted confirmations to 0 (inherit the on-chain default)', () => { + expect(serialize({ requiredDVNs: [DVN] }).confirmations).toBe(BigInt(0)) + }) + + it('maps an explicit zero confirmations to NIL_CONFIRMATIONS (pin literal zero)', () => { + expect(serialize({ requiredDVNs: [DVN], confirmations: BigInt(0) }).confirmations).toBe(NIL_CONFIRMATIONS) + }) + + it('passes a non-zero confirmations through unchanged', () => { + expect(serialize({ requiredDVNs: [DVN], confirmations: BigInt(15) }).confirmations).toBe(BigInt(15)) + }) + + it('keeps an explicit zero literal when NIL sentinels are disabled', () => { + expect(serialize({ requiredDVNs: [DVN], confirmations: BigInt(0) }, false).confirmations).toBe(BigInt(0)) + }) + }) + + describe('serializeUlnConfig optionalDVNs', () => { + it('maps omitted optionalDVNs to count 0 (inherit the on-chain default)', () => { + expect(serialize({ requiredDVNs: [DVN] }).optionalDVNCount).toBe(0) + }) + + it('maps an explicitly-empty optionalDVNs to NIL_DVN_COUNT (pin "no optional DVNs")', () => { + expect(serialize({ requiredDVNs: [DVN], optionalDVNs: [] }).optionalDVNCount).toBe(NIL_DVN_COUNT) + }) + + it('uses the array length for a non-empty optionalDVNs', () => { + expect( + serialize({ requiredDVNs: [DVN], optionalDVNs: [DVN], optionalDVNThreshold: 1 }).optionalDVNCount + ).toBe(1) + }) + }) + + describe('normalizeUlnConfig (on-chain read passthrough)', () => { + it('preserves a never-set read (zeros stay zeros, NOT remapped to NIL)', () => { + const read: Uln302UlnConfig = { + confirmations: BigInt(0), + optionalDVNThreshold: 0, + requiredDVNs: [], + requiredDVNCount: 0, + optionalDVNs: [], + optionalDVNCount: 0, + } + const normalized = (ulnSdk as any).normalizeUlnConfig(read) + expect(normalized.confirmations).toBe(BigInt(0)) + expect(normalized.requiredDVNCount).toBe(0) + expect(normalized.optionalDVNCount).toBe(0) + }) + }) +}) diff --git a/packages/protocol-devtools/src/uln302/schema.ts b/packages/protocol-devtools/src/uln302/schema.ts index 89e4537f62..a3ecf25ebb 100644 --- a/packages/protocol-devtools/src/uln302/schema.ts +++ b/packages/protocol-devtools/src/uln302/schema.ts @@ -13,6 +13,7 @@ export const Uln302UlnConfigSchema = z.object({ requiredDVNCount: UIntNumberSchema, optionalDVNs: z.array(AddressSchema), optionalDVNThreshold: UIntNumberSchema, + optionalDVNCount: UIntNumberSchema, }) satisfies z.ZodSchema export const Uln302UlnUserConfigSchema = z.object({ diff --git a/packages/protocol-devtools/src/uln302/types.ts b/packages/protocol-devtools/src/uln302/types.ts index bdd8efd6ae..4f305a6e97 100644 --- a/packages/protocol-devtools/src/uln302/types.ts +++ b/packages/protocol-devtools/src/uln302/types.ts @@ -121,6 +121,7 @@ export interface Uln302UlnConfig { requiredDVNs: string[] requiredDVNCount: number optionalDVNs: string[] + optionalDVNCount: number } /** diff --git a/packages/protocol-devtools/src/ulnRead/schema.ts b/packages/protocol-devtools/src/ulnRead/schema.ts index b8f186f60a..e0b5915c5c 100644 --- a/packages/protocol-devtools/src/ulnRead/schema.ts +++ b/packages/protocol-devtools/src/ulnRead/schema.ts @@ -5,7 +5,9 @@ import { UlnReadUlnConfig, UlnReadUlnUserConfig } from './types' export const UlnReadUlnConfigSchema = z.object({ executor: AddressSchema, requiredDVNs: z.array(AddressSchema), + requiredDVNCount: UIntNumberSchema, optionalDVNs: z.array(AddressSchema), + optionalDVNCount: UIntNumberSchema, optionalDVNThreshold: UIntNumberSchema, }) satisfies z.ZodSchema diff --git a/packages/protocol-devtools/src/ulnRead/types.ts b/packages/protocol-devtools/src/ulnRead/types.ts index 82e75cdb6c..3f712cfcf7 100644 --- a/packages/protocol-devtools/src/ulnRead/types.ts +++ b/packages/protocol-devtools/src/ulnRead/types.ts @@ -54,7 +54,9 @@ export interface UlnReadUlnConfig { executor: string optionalDVNThreshold: number requiredDVNs: string[] + requiredDVNCount: number optionalDVNs: string[] + optionalDVNCount: number } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52972fbc99..7f2e9e102a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,7 +89,7 @@ importers: version: link:../../packages/devtools-evm-hardhat '@layerzerolabs/devtools-solana': specifier: ^3.0.5 - version: link:../../packages/devtools-solana + version: 3.0.7(@layerzerolabs/devtools@packages+devtools)(@layerzerolabs/io-devtools@packages+io-devtools)(@layerzerolabs/lz-definitions@3.0.148)(@solana/web3.js@1.98.0)(bn.js@5.2.1)(fastestsmallesttextencoderdecoder@1.0.22)(fp-ts@2.16.2)(typescript@5.5.3)(zod@3.22.4) '@layerzerolabs/export-deployments': specifier: ^0.0.16 version: link:../../packages/export-deployments @@ -967,7 +967,7 @@ importers: version: link:../../packages/devtools-evm-hardhat '@layerzerolabs/devtools-solana': specifier: ~3.0.6 - version: link:../../packages/devtools-solana + version: 3.0.7(@layerzerolabs/devtools@packages+devtools)(@layerzerolabs/io-devtools@packages+io-devtools)(@layerzerolabs/lz-definitions@3.0.86)(@solana/web3.js@1.98.0)(bn.js@5.2.1)(fastestsmallesttextencoderdecoder@1.0.22)(fp-ts@2.16.2)(typescript@5.5.3)(zod@3.22.4) '@layerzerolabs/eslint-config-next': specifier: ~2.3.39 version: 2.3.44(typescript@5.5.3) @@ -2065,7 +2065,7 @@ importers: version: 2.3.44(typescript@5.5.3) '@layerzerolabs/hyperliquid-composer': specifier: ^2.0.5 - version: link:../../packages/hyperliquid-composer + version: 2.0.6(@layerzerolabs/lz-evm-messagelib-v2@3.0.86)(@layerzerolabs/lz-evm-protocol-v2@3.0.86)(@layerzerolabs/lz-evm-v1-0.7@3.0.86)(@layerzerolabs/oapp-evm@packages+oapp-evm)(@openzeppelin/contracts-upgradeable@5.1.0)(@openzeppelin/contracts@5.1.0)(@types/node@18.18.14)(typescript@5.5.3) '@layerzerolabs/lz-definitions': specifier: ^3.0.59 version: 3.0.86 @@ -2396,7 +2396,7 @@ importers: version: link:../../packages/devtools-evm-hardhat '@layerzerolabs/devtools-solana': specifier: ~3.0.6 - version: link:../../packages/devtools-solana + version: 3.0.7(@layerzerolabs/devtools@packages+devtools)(@layerzerolabs/io-devtools@packages+io-devtools)(@layerzerolabs/lz-definitions@3.0.86)(@solana/web3.js@1.98.0)(bn.js@5.2.1)(fastestsmallesttextencoderdecoder@1.0.22)(fp-ts@2.16.2)(typescript@5.5.3)(zod@3.22.4) '@layerzerolabs/eslint-config-next': specifier: ~2.3.39 version: 2.3.44(typescript@5.5.3) @@ -5055,7 +5055,7 @@ importers: version: link:../devtools '@layerzerolabs/devtools-solana': specifier: ~3.0.5 - version: link:../devtools-solana + version: 3.0.7(@layerzerolabs/devtools@packages+devtools)(@layerzerolabs/io-devtools@packages+io-devtools)(@layerzerolabs/lz-definitions@3.0.148)(@solana/web3.js@1.98.0)(bn.js@5.2.1)(fastestsmallesttextencoderdecoder@1.0.22)(fp-ts@2.16.2)(typescript@5.5.3)(zod@3.22.4) '@layerzerolabs/io-devtools': specifier: ~0.3.2 version: link:../io-devtools @@ -5098,9 +5098,15 @@ importers: '@swc/jest': specifier: ^0.2.36 version: 0.2.36(@swc/core@1.4.0) + '@types/bn.js': + specifier: ~5.1.5 + version: 5.1.6 '@types/jest': specifier: ^29.5.12 version: 29.5.12 + bn.js: + specifier: ^5.2.0 + version: 5.2.1 fast-check: specifier: ^3.15.1 version: 3.15.1 @@ -5713,7 +5719,7 @@ importers: version: link:../devtools '@layerzerolabs/devtools-solana': specifier: ~3.0.5 - version: link:../devtools-solana + version: 3.0.7(@layerzerolabs/devtools@packages+devtools)(@layerzerolabs/io-devtools@packages+io-devtools)(@layerzerolabs/lz-definitions@3.0.148)(@solana/web3.js@1.98.0)(bn.js@5.2.1)(fastestsmallesttextencoderdecoder@1.0.22)(fp-ts@2.16.2)(typescript@5.5.3)(zod@3.22.4) '@layerzerolabs/io-devtools': specifier: ~0.3.2 version: link:../io-devtools @@ -10355,6 +10361,61 @@ packages: - utf-8-validate dev: true + /@layerzerolabs/devtools-solana@3.0.7(@layerzerolabs/devtools@packages+devtools)(@layerzerolabs/io-devtools@packages+io-devtools)(@layerzerolabs/lz-definitions@3.0.148)(@solana/web3.js@1.98.0)(bn.js@5.2.1)(fastestsmallesttextencoderdecoder@1.0.22)(fp-ts@2.16.2)(typescript@5.5.3)(zod@3.22.4): + resolution: {integrity: sha512-GJy6LFwKRPFewecRYfeq1svZa9WNzXG3oXK17Z7TcbJoDzjCR7Y7LXr7XsTdgjXXc37qvWwLrxJEK6C0IMOG1g==} + peerDependencies: + '@layerzerolabs/devtools': ~2.0.4 + '@layerzerolabs/io-devtools': ~0.3.2 + '@layerzerolabs/lz-definitions': ^3.0.148 + '@solana/web3.js': ^1.95.8 + bn.js: ^5.2.0 + fp-ts: ^2.16.2 + zod: ^3.22.4 + dependencies: + '@layerzerolabs/devtools': link:packages/devtools + '@layerzerolabs/io-devtools': link:packages/io-devtools + '@layerzerolabs/lz-definitions': 3.0.148 + '@solana-developers/helpers': 2.8.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.5.3) + '@solana/web3.js': 1.98.0 + bn.js: 5.2.1 + fp-ts: 2.16.2 + p-memoize: 4.0.4 + zod: 3.22.4 + transitivePeerDependencies: + - bufferutil + - encoding + - fastestsmallesttextencoderdecoder + - typescript + - utf-8-validate + + /@layerzerolabs/devtools-solana@3.0.7(@layerzerolabs/devtools@packages+devtools)(@layerzerolabs/io-devtools@packages+io-devtools)(@layerzerolabs/lz-definitions@3.0.86)(@solana/web3.js@1.98.0)(bn.js@5.2.1)(fastestsmallesttextencoderdecoder@1.0.22)(fp-ts@2.16.2)(typescript@5.5.3)(zod@3.22.4): + resolution: {integrity: sha512-GJy6LFwKRPFewecRYfeq1svZa9WNzXG3oXK17Z7TcbJoDzjCR7Y7LXr7XsTdgjXXc37qvWwLrxJEK6C0IMOG1g==} + peerDependencies: + '@layerzerolabs/devtools': ~2.0.4 + '@layerzerolabs/io-devtools': ~0.3.2 + '@layerzerolabs/lz-definitions': ^3.0.148 + '@solana/web3.js': ^1.95.8 + bn.js: ^5.2.0 + fp-ts: ^2.16.2 + zod: ^3.22.4 + dependencies: + '@layerzerolabs/devtools': link:packages/devtools + '@layerzerolabs/io-devtools': link:packages/io-devtools + '@layerzerolabs/lz-definitions': 3.0.86 + '@solana-developers/helpers': 2.8.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.5.3) + '@solana/web3.js': 1.98.0 + bn.js: 5.2.1 + fp-ts: 2.16.2 + p-memoize: 4.0.4 + zod: 3.22.4 + transitivePeerDependencies: + - bufferutil + - encoding + - fastestsmallesttextencoderdecoder + - typescript + - utf-8-validate + dev: true + /@layerzerolabs/devtools@0.4.10(@ethersproject/bytes@5.7.0)(@layerzerolabs/io-devtools@0.1.17)(@layerzerolabs/lz-definitions@3.0.86)(zod@3.22.4): resolution: {integrity: sha512-Y9kjUQuyNfm9Vs07+Mk0+KkqHPwHN2cLSzKhe5Tp+52R7d4fI5zsn33IaJsqqGWxSDL1sKq7gFMTdtglTdsA8A==} peerDependencies: @@ -10427,6 +10488,37 @@ packages: typescript: 5.5.3 dev: true + /@layerzerolabs/hyperliquid-composer@2.0.6(@layerzerolabs/lz-evm-messagelib-v2@3.0.86)(@layerzerolabs/lz-evm-protocol-v2@3.0.86)(@layerzerolabs/lz-evm-v1-0.7@3.0.86)(@layerzerolabs/oapp-evm@packages+oapp-evm)(@openzeppelin/contracts-upgradeable@5.1.0)(@openzeppelin/contracts@5.1.0)(@types/node@18.18.14)(typescript@5.5.3): + resolution: {integrity: sha512-mELOIHnCx0Skd2g/BGaEN73TZ7LUTBLv20bCsJpRGx3SCNt0DjklAcJP+szhcRDBv84WgVAiyAwM2WtB+f24Bg==} + hasBin: true + peerDependencies: + '@layerzerolabs/lz-evm-messagelib-v2': ^3.0.12 + '@layerzerolabs/lz-evm-protocol-v2': ^3.0.12 + '@layerzerolabs/lz-evm-v1-0.7': ^3.0.12 + '@layerzerolabs/oapp-evm': ^0.4.1 + '@openzeppelin/contracts': ^4.8.1 || ^5.0.0 + '@openzeppelin/contracts-upgradeable': ^4.8.1 || ^5.0.0 + dependencies: + '@layerzerolabs/lz-evm-messagelib-v2': 3.0.86(@axelar-network/axelar-gmp-sdk-solidity@5.10.0)(@chainlink/contracts-ccip@0.7.6)(@eth-optimism/contracts@0.6.0)(@layerzerolabs/lz-evm-protocol-v2@3.0.86)(@layerzerolabs/lz-evm-v1-0.7@3.0.86)(@openzeppelin/contracts-upgradeable@5.1.0)(@openzeppelin/contracts@5.1.0)(hardhat-deploy@0.12.4)(solidity-bytes-utils@0.8.2) + '@layerzerolabs/lz-evm-protocol-v2': 3.0.86(@openzeppelin/contracts-upgradeable@5.1.0)(@openzeppelin/contracts@5.1.0)(hardhat-deploy@0.12.4)(solidity-bytes-utils@0.8.2) + '@layerzerolabs/lz-evm-v1-0.7': 3.0.86(@openzeppelin/contracts-upgradeable@5.1.0)(@openzeppelin/contracts@5.1.0)(hardhat-deploy@0.12.4) + '@layerzerolabs/oapp-evm': link:packages/oapp-evm + '@openzeppelin/contracts': 5.1.0 + '@openzeppelin/contracts-upgradeable': 5.1.0(@openzeppelin/contracts@5.1.0) + commander: 11.1.0 + hardhat: 2.22.12(ts-node@10.9.2)(typescript@5.5.3) + ts-node: 10.9.2(@swc/core@1.4.0)(@types/node@18.18.14)(typescript@5.5.3) + transitivePeerDependencies: + - '@swc/core' + - '@swc/wasm' + - '@types/node' + - bufferutil + - c-kzg + - supports-color + - typescript + - utf-8-validate + dev: true + /@layerzerolabs/io-devtools@0.1.17(zod@3.22.4): resolution: {integrity: sha512-3Todi2pGZFNlkKGb758AQ4inZOcxiiZHMLRfZyfNIyjslHa2vna2yOh4fQNvfIAZVKjZ0y80XNf6jkgPXz5RsQ==} peerDependencies: @@ -14201,7 +14293,6 @@ packages: - fastestsmallesttextencoderdecoder - typescript - utf-8-validate - dev: false /@solana-developers/helpers@2.8.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.5.3): resolution: {integrity: sha512-xvoOj+ewL18+h6fMrXp1vTss0WBLnhQHnBb6mMPfEQE32w0THlxm8OPXNUY8g4tREX7ugU5cDEP7c2teye1Z7A==} @@ -27387,7 +27478,7 @@ packages: resolution: {integrity: sha512-Q8PfolOJ4eV9TvnTj1TGdZ4RarpSLmHnUnzVxZ/6/NiTfe4maJz99R0ISgwZkntLhLRtw0C7LRJuklzGYCNN3A==} engines: {node: '>=8.0.0'} dependencies: - '@types/bn.js': 5.1.5 + '@types/bn.js': 5.1.6 web3-core: 1.10.4 web3-core-helpers: 1.10.4 web3-core-method: 1.10.4 From c6a92e0a9742a8fbb09c4d454c5967178e9e750b Mon Sep 17 00:00:00 2001 From: Krak Date: Mon, 22 Jun 2026 12:14:16 -0700 Subject: [PATCH 02/17] fix(protocol-devtools): address PR review on ULN NIL sentinels - generator (creatUlnConfig): faithfully distinguish inherit (omit / count 0) from pin-none (emit [] or 0n for NIL), so regenerated configs no longer silently flip optional DVNs / confirmations from inherit to pinned. Migrate the lzapp-migration example configs accordingly. [BLOCK 1] - lockfile: drop the bn.js dependency that de-linked workspace packages; the Solana u64 BN conversion now goes through a new devtools-solana bigIntToBN helper, leaving pnpm-lock.yaml byte-identical to main. [BLOCK 2] - export NIL_DVN_COUNT / NIL_CONFIRMATIONS from protocol-devtools and consume them in the EVM + Solana SDKs instead of duplicated local copies. [NIT 3] - add Solana hasAppUlnConfig idempotency unit tests. [NIT 1] - changeset: note the read-type required-field widening as a breaking change. [NIT 4] - carry requiredDVNCount/optionalDVNCount through the test-setup and example read-config constructors widened by the new required fields. --- .changeset/solana-bigint-to-bn.md | 7 ++ .changeset/uln-nil-sentinels.md | 15 ++- examples/lzapp-migration/layerzero.config.ts | 24 ++-- examples/lzapp-migration/lzapp.config.ts | 16 +-- .../tasks/common/taskHelper.ts | 12 ++ .../devtools-solana/src/common/numbers.ts | 20 ++++ .../protocol-devtools-evm/src/uln302/sdk.ts | 19 ++-- .../protocol-devtools-evm/src/ulnRead/sdk.ts | 5 +- .../protocol-devtools-solana/package.json | 3 - .../src/endpointv2/schema.ts | 10 +- .../src/uln302/sdk.ts | 7 +- .../test/uln302/nil-sentinels.test.ts | 59 ++++++++++ .../protocol-devtools/src/uln302/constants.ts | 11 ++ .../protocol-devtools/src/uln302/index.ts | 1 + .../src/oapp/typescript/typescript.ts | 94 +++++++++++---- pnpm-lock.yaml | 107 ++---------------- .../src/endpointV2/setup.ts | 3 + 17 files changed, 240 insertions(+), 173 deletions(-) create mode 100644 .changeset/solana-bigint-to-bn.md create mode 100644 packages/protocol-devtools/src/uln302/constants.ts diff --git a/.changeset/solana-bigint-to-bn.md b/.changeset/solana-bigint-to-bn.md new file mode 100644 index 0000000000..17c8525a47 --- /dev/null +++ b/.changeset/solana-bigint-to-bn.md @@ -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. diff --git a/.changeset/uln-nil-sentinels.md b/.changeset/uln-nil-sentinels.md index 1de9d58b20..a7095f917a 100644 --- a/.changeset/uln-nil-sentinels.md +++ b/.changeset/uln-nil-sentinels.md @@ -24,7 +24,14 @@ library-wide DEFAULT config continues to serialize literal values (it rejects NI 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. +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. Re-wiring an existing OApp whose config used these + empty values will now 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. diff --git a/examples/lzapp-migration/layerzero.config.ts b/examples/lzapp-migration/layerzero.config.ts index d2aee5e90a..a751748131 100644 --- a/examples/lzapp-migration/layerzero.config.ts +++ b/examples/lzapp-migration/layerzero.config.ts @@ -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). }, }, }, @@ -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 @@ -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: [ diff --git a/examples/lzapp-migration/lzapp.config.ts b/examples/lzapp-migration/lzapp.config.ts index 282695042d..12b12f206f 100644 --- a/examples/lzapp-migration/lzapp.config.ts +++ b/examples/lzapp-migration/lzapp.config.ts @@ -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). }, }, }, @@ -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). }, }, }, diff --git a/examples/lzapp-migration/tasks/common/taskHelper.ts b/examples/lzapp-migration/tasks/common/taskHelper.ts index 2caac9fd04..98cea2aa8f 100644 --- a/examples/lzapp-migration/tasks/common/taskHelper.ts +++ b/examples/lzapp-migration/tasks/common/taskHelper.ts @@ -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, } @@ -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, } @@ -330,7 +334,9 @@ export async function getEpv1DefaultSendConfig( const emptyUlnConfig: Uln302UlnConfig = { confirmations: BigInt(0), requiredDVNs: [zeroAddress], + requiredDVNCount: 0, optionalDVNs: [], + optionalDVNCount: 0, optionalDVNThreshold: 0, } @@ -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, } @@ -386,7 +394,9 @@ export async function getEpv1DefaultReceiveConfig( const emptyUlnConfig: Uln302UlnConfig = { confirmations: BigInt(0), requiredDVNs: [zeroAddress], + requiredDVNCount: 0, optionalDVNs: [], + optionalDVNCount: 0, optionalDVNThreshold: 0, } @@ -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, } diff --git a/packages/devtools-solana/src/common/numbers.ts b/packages/devtools-solana/src/common/numbers.ts index 1836104867..4911b7a8c9 100644 --- a/packages/devtools-solana/src/common/numbers.ts +++ b/packages/devtools-solana/src/common/numbers.ts @@ -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). */ diff --git a/packages/protocol-devtools-evm/src/uln302/sdk.ts b/packages/protocol-devtools-evm/src/uln302/sdk.ts index cffc846733..5e193d3129 100644 --- a/packages/protocol-devtools-evm/src/uln302/sdk.ts +++ b/packages/protocol-devtools-evm/src/uln302/sdk.ts @@ -1,10 +1,12 @@ import type { EndpointId } from '@layerzerolabs/lz-definitions' -import type { - IUln302, - Uln302ConfigType, - Uln302ExecutorConfig, - Uln302UlnConfig, - Uln302UlnUserConfig, +import { + type IUln302, + type Uln302ConfigType, + type Uln302ExecutorConfig, + type Uln302UlnConfig, + type Uln302UlnUserConfig, + NIL_CONFIRMATIONS, + NIL_DVN_COUNT, } from '@layerzerolabs/protocol-devtools' import { OmniAddress, @@ -24,11 +26,6 @@ import { Contract } from '@ethersproject/contracts' // because it contains all the necessary method fragments import { abi } from '@layerzerolabs/lz-evm-sdk-v2/artifacts/contracts/uln/uln302/SendUln302.sol/SendUln302.json' -// 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) { super({ eid: point.eid, contract: new Contract(point.address, abi).connect(provider) }) diff --git a/packages/protocol-devtools-evm/src/ulnRead/sdk.ts b/packages/protocol-devtools-evm/src/ulnRead/sdk.ts index 608ce84e0d..f05ea94c9b 100644 --- a/packages/protocol-devtools-evm/src/ulnRead/sdk.ts +++ b/packages/protocol-devtools-evm/src/ulnRead/sdk.ts @@ -1,5 +1,5 @@ import type { IUlnRead, UlnReadUlnConfig, UlnReadUlnUserConfig } from '@layerzerolabs/protocol-devtools' -import { UlnReadUlnConfigSchema } from '@layerzerolabs/protocol-devtools' +import { NIL_DVN_COUNT, UlnReadUlnConfigSchema } from '@layerzerolabs/protocol-devtools' import { OmniAddress, type OmniTransaction, @@ -16,9 +16,6 @@ import { Contract } from '@ethersproject/contracts' // because it contains all the necessary method fragments import { abi } from '@layerzerolabs/lz-evm-sdk-v2/artifacts/contracts/uln/readlib/ReadLib1002.sol/ReadLib1002.json' -// 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 - export class UlnRead extends OmniSDK implements IUlnRead { constructor(provider: Provider, point: OmniPoint) { super({ eid: point.eid, contract: new Contract(point.address, abi).connect(provider) }) diff --git a/packages/protocol-devtools-solana/package.json b/packages/protocol-devtools-solana/package.json index 0dfa708952..92b921f791 100644 --- a/packages/protocol-devtools-solana/package.json +++ b/packages/protocol-devtools-solana/package.json @@ -52,9 +52,7 @@ "@solana/web3.js": "~1.95.8", "@swc/core": "^1.4.0", "@swc/jest": "^0.2.36", - "@types/bn.js": "~5.1.5", "@types/jest": "^29.5.12", - "bn.js": "^5.2.0", "fast-check": "^3.15.1", "fp-ts": "^2.16.2", "jest": "^29.7.0", @@ -75,7 +73,6 @@ "@layerzerolabs/protocol-devtools": "^3.0.2", "@layerzerolabs/ua-devtools": "^5.0.2", "@solana/web3.js": "^1.95.8", - "bn.js": "^5.2.0", "fp-ts": "^2.16.2", "zod": "^3.22.4" }, diff --git a/packages/protocol-devtools-solana/src/endpointv2/schema.ts b/packages/protocol-devtools-solana/src/endpointv2/schema.ts index aa6da250a8..0c42eda45e 100644 --- a/packages/protocol-devtools-solana/src/endpointv2/schema.ts +++ b/packages/protocol-devtools-solana/src/endpointv2/schema.ts @@ -1,7 +1,7 @@ -import { SetConfigType } from '@layerzerolabs/lz-solana-sdk-v2' +import { SetConfigType, type UlnConfig } from '@layerzerolabs/lz-solana-sdk-v2' import { Uln302ExecutorConfigSchema, Uln302UlnConfigSchema } from '@layerzerolabs/protocol-devtools' +import { bigIntToBN } from '@layerzerolabs/devtools-solana' import { PublicKey } from '@solana/web3.js' -import BN from 'bn.js' import { z } from 'zod' export const SetConfigSchema = z.union([ @@ -24,8 +24,10 @@ export const SetConfigSchema = z.union([ optionalDVNCount, }) => ({ // confirmations is a u64 on-chain; a NIL sentinel (type(uint64).max) overflows a - // JS number, so it must be passed as a BN to avoid precision loss. - confirmations: new BN(confirmations.toString()), + // JS number, so it must be passed as a BN to avoid precision loss. The cast keeps + // the exported schema type nameable via lz-solana-sdk-v2 (a direct dependency) + // rather than leaking bn.js's types. + confirmations: bigIntToBN(confirmations) as UlnConfig['confirmations'], optionalDvnCount: optionalDVNCount, requiredDvnCount: requiredDVNCount, requiredDvns: requiredDVNs.map((dvn) => new PublicKey(dvn)), diff --git a/packages/protocol-devtools-solana/src/uln302/sdk.ts b/packages/protocol-devtools-solana/src/uln302/sdk.ts index 78b7798a6a..ffa3bdf58f 100644 --- a/packages/protocol-devtools-solana/src/uln302/sdk.ts +++ b/packages/protocol-devtools-solana/src/uln302/sdk.ts @@ -1,6 +1,8 @@ import type { EndpointId } from '@layerzerolabs/lz-definitions' import { IUln302, + NIL_CONFIRMATIONS, + NIL_DVN_COUNT, Uln302ConfigType, Uln302ExecutorConfig, Uln302UlnConfig, @@ -310,11 +312,6 @@ interface SerializedUln302UlnConfig extends Uln302UlnConfig { */ type SerializedUln302ExecutorConfig = Uln302ExecutorConfig -// 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 = 255 // type(uint8).max -// 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 - const serializeDVNs = (dvns: OmniAddress[]) => dvns .map((address) => new PublicKey(address).toBytes()) diff --git a/packages/protocol-devtools-solana/test/uln302/nil-sentinels.test.ts b/packages/protocol-devtools-solana/test/uln302/nil-sentinels.test.ts index 58e0a5534e..25f4063f02 100644 --- a/packages/protocol-devtools-solana/test/uln302/nil-sentinels.test.ts +++ b/packages/protocol-devtools-solana/test/uln302/nil-sentinels.test.ts @@ -2,6 +2,7 @@ import { Connection } from '@solana/web3.js' import { PublicKey } from '@solana/web3.js' import { MainnetV2EndpointId } from '@layerzerolabs/lz-definitions' import { Uln302 } from '@/uln302' +import { Uln302ConfigType } from '@layerzerolabs/protocol-devtools' import type { Uln302UlnConfig, Uln302UlnUserConfig } from '@layerzerolabs/protocol-devtools' const NIL_DVN_COUNT = 255 @@ -74,4 +75,62 @@ describe('uln302/nil-sentinels (solana)', () => { expect(normalized.optionalDVNCount).toBe(0) }) }) + + describe('hasAppUlnConfig idempotency', () => { + const read = (over: Partial): Uln302UlnConfig => ({ + confirmations: BigInt(0), + optionalDVNThreshold: 0, + requiredDVNs: [DVN], + requiredDVNCount: 1, + optionalDVNs: [], + optionalDVNCount: 0, + ...over, + }) + + let spy: jest.SpyInstance + + beforeEach(() => { + spy = jest.spyOn(Uln302.prototype, 'getAppUlnConfig') + }) + + afterEach(() => { + spy.mockRestore() + }) + + const hasConfig = async (current: Uln302UlnConfig, desired: Uln302UlnUserConfig) => { + spy.mockResolvedValue(current) + return ulnSdk.hasAppUlnConfig( + MainnetV2EndpointId.SOLANA_V2_MAINNET, + new PublicKey(DVN).toBase58(), + desired, + Uln302ConfigType.Send + ) + } + + it('treats an omitted confirmations as matching a never-set chain value', async () => { + await expect(hasConfig(read({}), { requiredDVNs: [DVN] })).resolves.toBe(true) + }) + + it('treats an explicit zero confirmations as DIFFERENT from a never-set chain value', async () => { + await expect(hasConfig(read({}), { requiredDVNs: [DVN], confirmations: BigInt(0) })).resolves.toBe(false) + }) + + it('treats an explicit zero confirmations as matching a chain that stored the NIL sentinel', async () => { + await expect( + hasConfig(read({ confirmations: NIL_CONFIRMATIONS }), { requiredDVNs: [DVN], confirmations: BigInt(0) }) + ).resolves.toBe(true) + }) + + it('treats an explicitly-empty optionalDVNs as matching a chain that stored NIL', async () => { + await expect( + hasConfig(read({ optionalDVNCount: NIL_DVN_COUNT }), { requiredDVNs: [DVN], optionalDVNs: [] }) + ).resolves.toBe(true) + }) + + it('treats an explicitly-empty optionalDVNs as DIFFERENT from a chain with optionalDVNCount 0', async () => { + await expect( + hasConfig(read({ optionalDVNCount: 0 }), { requiredDVNs: [DVN], optionalDVNs: [] }) + ).resolves.toBe(false) + }) + }) }) diff --git a/packages/protocol-devtools/src/uln302/constants.ts b/packages/protocol-devtools/src/uln302/constants.ts new file mode 100644 index 0000000000..d094244ec0 --- /dev/null +++ b/packages/protocol-devtools/src/uln302/constants.ts @@ -0,0 +1,11 @@ +/** + * Sentinel values for ULN302 configuration, matching the on-chain contract (`UlnBase.sol`). + * + * A config field left at `0` falls back to the default config, so these sentinels are used + * to pin a literal zero/none instead: + * + * - `NIL_DVN_COUNT` pins "no DVNs" (the accompanying DVN array must be empty). + * - `NIL_CONFIRMATIONS` pins "zero confirmations". + */ +export const NIL_DVN_COUNT = (1 << 8) - 1 // type(uint8).max = 255 +export const NIL_CONFIRMATIONS = (BigInt(1) << BigInt(64)) - BigInt(1) // type(uint64).max diff --git a/packages/protocol-devtools/src/uln302/index.ts b/packages/protocol-devtools/src/uln302/index.ts index 1273e86465..10128135e8 100644 --- a/packages/protocol-devtools/src/uln302/index.ts +++ b/packages/protocol-devtools/src/uln302/index.ts @@ -1,3 +1,4 @@ export * from './config' +export * from './constants' export * from './schema' export * from './types' diff --git a/packages/ua-devtools-evm-hardhat/src/oapp/typescript/typescript.ts b/packages/ua-devtools-evm-hardhat/src/oapp/typescript/typescript.ts index 773b913973..d97349345a 100644 --- a/packages/ua-devtools-evm-hardhat/src/oapp/typescript/typescript.ts +++ b/packages/ua-devtools-evm-hardhat/src/oapp/typescript/typescript.ts @@ -17,7 +17,13 @@ import { getEidForNetworkName, } from '@layerzerolabs/devtools-evm-hardhat' import { getReceiveConfig, getSendConfig } from '@/utils/taskHelpers' -import { Timeout, Uln302ExecutorConfig, Uln302UlnConfig } from '@layerzerolabs/protocol-devtools' +import { + NIL_CONFIRMATIONS, + NIL_DVN_COUNT, + Timeout, + Uln302ExecutorConfig, + Uln302UlnConfig, +} from '@layerzerolabs/protocol-devtools' import { CONFIG, CONFIRMATIONS, @@ -283,31 +289,77 @@ export const creatExecutorConfig = ({ maxMessageSize, executor }: Uln302Executor export const creatUlnConfig = ({ confirmations, requiredDVNs, + requiredDVNCount, optionalDVNs, + optionalDVNCount, optionalDVNThreshold, }: Uln302UlnConfig): ObjectLiteralExpression => { - return factory.createObjectLiteralExpression([ - factory.createPropertyAssignment( - factory.createIdentifier(CONFIRMATIONS), - factory.createBigIntLiteral(confirmations.toString()) - ), - factory.createPropertyAssignment( - factory.createIdentifier(REQUIRED_DVNS), - factory.createArrayLiteralExpression( - requiredDVNs.filter((dvn) => dvn != null).map((dvn) => factory.createStringLiteral(dvn)) + const properties: PropertyAssignment[] = [] + + // confirmations: a chain value of 0 means "inherit the default", so we omit the field + // (re-applying an omitted field inherits). The NIL sentinel means "pinned to zero", which + // we emit as `0n` so it serializes back to NIL. Any other value is emitted literally. + if (confirmations === NIL_CONFIRMATIONS) { + properties.push( + factory.createPropertyAssignment(factory.createIdentifier(CONFIRMATIONS), factory.createBigIntLiteral('0')) + ) + } else if (confirmations !== BigInt(0)) { + properties.push( + factory.createPropertyAssignment( + factory.createIdentifier(CONFIRMATIONS), + factory.createBigIntLiteral(confirmations.toString()) ) - ), - factory.createPropertyAssignment( - factory.createIdentifier(OPTIONAL_DVNS), - factory.createArrayLiteralExpression( - optionalDVNs.filter((dvn) => dvn != null).map((dvn) => factory.createStringLiteral(dvn)) + ) + } + + // requiredDVNs is mandatory on the config, so we always emit it. A count of 0 (inherit) is + // emitted as an empty array for backwards compatibility — this predates the NIL work. + if (requiredDVNCount !== NIL_DVN_COUNT) { + properties.push( + factory.createPropertyAssignment( + factory.createIdentifier(REQUIRED_DVNS), + factory.createArrayLiteralExpression( + requiredDVNs.filter((dvn) => dvn != null).map((dvn) => factory.createStringLiteral(dvn)) + ) ) - ), - factory.createPropertyAssignment( - factory.createIdentifier(OPTIONAL_DVN_THRESHOLD), - factory.createNumericLiteral(optionalDVNThreshold) - ), - ]) + ) + } else { + properties.push( + factory.createPropertyAssignment( + factory.createIdentifier(REQUIRED_DVNS), + factory.createArrayLiteralExpression([]) + ) + ) + } + + // optionalDVNs: mirror confirmations. Count 0 means "inherit the default" so we omit the + // field; the NIL sentinel means "pinned to none", emitted as `[]` so it serializes back to + // NIL. Only a concrete set of optional DVNs carries the array and its threshold. + if (optionalDVNCount === NIL_DVN_COUNT) { + properties.push( + factory.createPropertyAssignment( + factory.createIdentifier(OPTIONAL_DVNS), + factory.createArrayLiteralExpression([]) + ) + ) + } else if (optionalDVNCount !== 0) { + properties.push( + factory.createPropertyAssignment( + factory.createIdentifier(OPTIONAL_DVNS), + factory.createArrayLiteralExpression( + optionalDVNs.filter((dvn) => dvn != null).map((dvn) => factory.createStringLiteral(dvn)) + ) + ) + ) + properties.push( + factory.createPropertyAssignment( + factory.createIdentifier(OPTIONAL_DVN_THRESHOLD), + factory.createNumericLiteral(optionalDVNThreshold) + ) + ) + } + + return factory.createObjectLiteralExpression(properties) } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f2e9e102a..52972fbc99 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,7 +89,7 @@ importers: version: link:../../packages/devtools-evm-hardhat '@layerzerolabs/devtools-solana': specifier: ^3.0.5 - version: 3.0.7(@layerzerolabs/devtools@packages+devtools)(@layerzerolabs/io-devtools@packages+io-devtools)(@layerzerolabs/lz-definitions@3.0.148)(@solana/web3.js@1.98.0)(bn.js@5.2.1)(fastestsmallesttextencoderdecoder@1.0.22)(fp-ts@2.16.2)(typescript@5.5.3)(zod@3.22.4) + version: link:../../packages/devtools-solana '@layerzerolabs/export-deployments': specifier: ^0.0.16 version: link:../../packages/export-deployments @@ -967,7 +967,7 @@ importers: version: link:../../packages/devtools-evm-hardhat '@layerzerolabs/devtools-solana': specifier: ~3.0.6 - version: 3.0.7(@layerzerolabs/devtools@packages+devtools)(@layerzerolabs/io-devtools@packages+io-devtools)(@layerzerolabs/lz-definitions@3.0.86)(@solana/web3.js@1.98.0)(bn.js@5.2.1)(fastestsmallesttextencoderdecoder@1.0.22)(fp-ts@2.16.2)(typescript@5.5.3)(zod@3.22.4) + version: link:../../packages/devtools-solana '@layerzerolabs/eslint-config-next': specifier: ~2.3.39 version: 2.3.44(typescript@5.5.3) @@ -2065,7 +2065,7 @@ importers: version: 2.3.44(typescript@5.5.3) '@layerzerolabs/hyperliquid-composer': specifier: ^2.0.5 - version: 2.0.6(@layerzerolabs/lz-evm-messagelib-v2@3.0.86)(@layerzerolabs/lz-evm-protocol-v2@3.0.86)(@layerzerolabs/lz-evm-v1-0.7@3.0.86)(@layerzerolabs/oapp-evm@packages+oapp-evm)(@openzeppelin/contracts-upgradeable@5.1.0)(@openzeppelin/contracts@5.1.0)(@types/node@18.18.14)(typescript@5.5.3) + version: link:../../packages/hyperliquid-composer '@layerzerolabs/lz-definitions': specifier: ^3.0.59 version: 3.0.86 @@ -2396,7 +2396,7 @@ importers: version: link:../../packages/devtools-evm-hardhat '@layerzerolabs/devtools-solana': specifier: ~3.0.6 - version: 3.0.7(@layerzerolabs/devtools@packages+devtools)(@layerzerolabs/io-devtools@packages+io-devtools)(@layerzerolabs/lz-definitions@3.0.86)(@solana/web3.js@1.98.0)(bn.js@5.2.1)(fastestsmallesttextencoderdecoder@1.0.22)(fp-ts@2.16.2)(typescript@5.5.3)(zod@3.22.4) + version: link:../../packages/devtools-solana '@layerzerolabs/eslint-config-next': specifier: ~2.3.39 version: 2.3.44(typescript@5.5.3) @@ -5055,7 +5055,7 @@ importers: version: link:../devtools '@layerzerolabs/devtools-solana': specifier: ~3.0.5 - version: 3.0.7(@layerzerolabs/devtools@packages+devtools)(@layerzerolabs/io-devtools@packages+io-devtools)(@layerzerolabs/lz-definitions@3.0.148)(@solana/web3.js@1.98.0)(bn.js@5.2.1)(fastestsmallesttextencoderdecoder@1.0.22)(fp-ts@2.16.2)(typescript@5.5.3)(zod@3.22.4) + version: link:../devtools-solana '@layerzerolabs/io-devtools': specifier: ~0.3.2 version: link:../io-devtools @@ -5098,15 +5098,9 @@ importers: '@swc/jest': specifier: ^0.2.36 version: 0.2.36(@swc/core@1.4.0) - '@types/bn.js': - specifier: ~5.1.5 - version: 5.1.6 '@types/jest': specifier: ^29.5.12 version: 29.5.12 - bn.js: - specifier: ^5.2.0 - version: 5.2.1 fast-check: specifier: ^3.15.1 version: 3.15.1 @@ -5719,7 +5713,7 @@ importers: version: link:../devtools '@layerzerolabs/devtools-solana': specifier: ~3.0.5 - version: 3.0.7(@layerzerolabs/devtools@packages+devtools)(@layerzerolabs/io-devtools@packages+io-devtools)(@layerzerolabs/lz-definitions@3.0.148)(@solana/web3.js@1.98.0)(bn.js@5.2.1)(fastestsmallesttextencoderdecoder@1.0.22)(fp-ts@2.16.2)(typescript@5.5.3)(zod@3.22.4) + version: link:../devtools-solana '@layerzerolabs/io-devtools': specifier: ~0.3.2 version: link:../io-devtools @@ -10361,61 +10355,6 @@ packages: - utf-8-validate dev: true - /@layerzerolabs/devtools-solana@3.0.7(@layerzerolabs/devtools@packages+devtools)(@layerzerolabs/io-devtools@packages+io-devtools)(@layerzerolabs/lz-definitions@3.0.148)(@solana/web3.js@1.98.0)(bn.js@5.2.1)(fastestsmallesttextencoderdecoder@1.0.22)(fp-ts@2.16.2)(typescript@5.5.3)(zod@3.22.4): - resolution: {integrity: sha512-GJy6LFwKRPFewecRYfeq1svZa9WNzXG3oXK17Z7TcbJoDzjCR7Y7LXr7XsTdgjXXc37qvWwLrxJEK6C0IMOG1g==} - peerDependencies: - '@layerzerolabs/devtools': ~2.0.4 - '@layerzerolabs/io-devtools': ~0.3.2 - '@layerzerolabs/lz-definitions': ^3.0.148 - '@solana/web3.js': ^1.95.8 - bn.js: ^5.2.0 - fp-ts: ^2.16.2 - zod: ^3.22.4 - dependencies: - '@layerzerolabs/devtools': link:packages/devtools - '@layerzerolabs/io-devtools': link:packages/io-devtools - '@layerzerolabs/lz-definitions': 3.0.148 - '@solana-developers/helpers': 2.8.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.5.3) - '@solana/web3.js': 1.98.0 - bn.js: 5.2.1 - fp-ts: 2.16.2 - p-memoize: 4.0.4 - zod: 3.22.4 - transitivePeerDependencies: - - bufferutil - - encoding - - fastestsmallesttextencoderdecoder - - typescript - - utf-8-validate - - /@layerzerolabs/devtools-solana@3.0.7(@layerzerolabs/devtools@packages+devtools)(@layerzerolabs/io-devtools@packages+io-devtools)(@layerzerolabs/lz-definitions@3.0.86)(@solana/web3.js@1.98.0)(bn.js@5.2.1)(fastestsmallesttextencoderdecoder@1.0.22)(fp-ts@2.16.2)(typescript@5.5.3)(zod@3.22.4): - resolution: {integrity: sha512-GJy6LFwKRPFewecRYfeq1svZa9WNzXG3oXK17Z7TcbJoDzjCR7Y7LXr7XsTdgjXXc37qvWwLrxJEK6C0IMOG1g==} - peerDependencies: - '@layerzerolabs/devtools': ~2.0.4 - '@layerzerolabs/io-devtools': ~0.3.2 - '@layerzerolabs/lz-definitions': ^3.0.148 - '@solana/web3.js': ^1.95.8 - bn.js: ^5.2.0 - fp-ts: ^2.16.2 - zod: ^3.22.4 - dependencies: - '@layerzerolabs/devtools': link:packages/devtools - '@layerzerolabs/io-devtools': link:packages/io-devtools - '@layerzerolabs/lz-definitions': 3.0.86 - '@solana-developers/helpers': 2.8.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.5.3) - '@solana/web3.js': 1.98.0 - bn.js: 5.2.1 - fp-ts: 2.16.2 - p-memoize: 4.0.4 - zod: 3.22.4 - transitivePeerDependencies: - - bufferutil - - encoding - - fastestsmallesttextencoderdecoder - - typescript - - utf-8-validate - dev: true - /@layerzerolabs/devtools@0.4.10(@ethersproject/bytes@5.7.0)(@layerzerolabs/io-devtools@0.1.17)(@layerzerolabs/lz-definitions@3.0.86)(zod@3.22.4): resolution: {integrity: sha512-Y9kjUQuyNfm9Vs07+Mk0+KkqHPwHN2cLSzKhe5Tp+52R7d4fI5zsn33IaJsqqGWxSDL1sKq7gFMTdtglTdsA8A==} peerDependencies: @@ -10488,37 +10427,6 @@ packages: typescript: 5.5.3 dev: true - /@layerzerolabs/hyperliquid-composer@2.0.6(@layerzerolabs/lz-evm-messagelib-v2@3.0.86)(@layerzerolabs/lz-evm-protocol-v2@3.0.86)(@layerzerolabs/lz-evm-v1-0.7@3.0.86)(@layerzerolabs/oapp-evm@packages+oapp-evm)(@openzeppelin/contracts-upgradeable@5.1.0)(@openzeppelin/contracts@5.1.0)(@types/node@18.18.14)(typescript@5.5.3): - resolution: {integrity: sha512-mELOIHnCx0Skd2g/BGaEN73TZ7LUTBLv20bCsJpRGx3SCNt0DjklAcJP+szhcRDBv84WgVAiyAwM2WtB+f24Bg==} - hasBin: true - peerDependencies: - '@layerzerolabs/lz-evm-messagelib-v2': ^3.0.12 - '@layerzerolabs/lz-evm-protocol-v2': ^3.0.12 - '@layerzerolabs/lz-evm-v1-0.7': ^3.0.12 - '@layerzerolabs/oapp-evm': ^0.4.1 - '@openzeppelin/contracts': ^4.8.1 || ^5.0.0 - '@openzeppelin/contracts-upgradeable': ^4.8.1 || ^5.0.0 - dependencies: - '@layerzerolabs/lz-evm-messagelib-v2': 3.0.86(@axelar-network/axelar-gmp-sdk-solidity@5.10.0)(@chainlink/contracts-ccip@0.7.6)(@eth-optimism/contracts@0.6.0)(@layerzerolabs/lz-evm-protocol-v2@3.0.86)(@layerzerolabs/lz-evm-v1-0.7@3.0.86)(@openzeppelin/contracts-upgradeable@5.1.0)(@openzeppelin/contracts@5.1.0)(hardhat-deploy@0.12.4)(solidity-bytes-utils@0.8.2) - '@layerzerolabs/lz-evm-protocol-v2': 3.0.86(@openzeppelin/contracts-upgradeable@5.1.0)(@openzeppelin/contracts@5.1.0)(hardhat-deploy@0.12.4)(solidity-bytes-utils@0.8.2) - '@layerzerolabs/lz-evm-v1-0.7': 3.0.86(@openzeppelin/contracts-upgradeable@5.1.0)(@openzeppelin/contracts@5.1.0)(hardhat-deploy@0.12.4) - '@layerzerolabs/oapp-evm': link:packages/oapp-evm - '@openzeppelin/contracts': 5.1.0 - '@openzeppelin/contracts-upgradeable': 5.1.0(@openzeppelin/contracts@5.1.0) - commander: 11.1.0 - hardhat: 2.22.12(ts-node@10.9.2)(typescript@5.5.3) - ts-node: 10.9.2(@swc/core@1.4.0)(@types/node@18.18.14)(typescript@5.5.3) - transitivePeerDependencies: - - '@swc/core' - - '@swc/wasm' - - '@types/node' - - bufferutil - - c-kzg - - supports-color - - typescript - - utf-8-validate - dev: true - /@layerzerolabs/io-devtools@0.1.17(zod@3.22.4): resolution: {integrity: sha512-3Todi2pGZFNlkKGb758AQ4inZOcxiiZHMLRfZyfNIyjslHa2vna2yOh4fQNvfIAZVKjZ0y80XNf6jkgPXz5RsQ==} peerDependencies: @@ -14293,6 +14201,7 @@ packages: - fastestsmallesttextencoderdecoder - typescript - utf-8-validate + dev: false /@solana-developers/helpers@2.8.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.5.3): resolution: {integrity: sha512-xvoOj+ewL18+h6fMrXp1vTss0WBLnhQHnBb6mMPfEQE32w0THlxm8OPXNUY8g4tREX7ugU5cDEP7c2teye1Z7A==} @@ -27478,7 +27387,7 @@ packages: resolution: {integrity: sha512-Q8PfolOJ4eV9TvnTj1TGdZ4RarpSLmHnUnzVxZ/6/NiTfe4maJz99R0ISgwZkntLhLRtw0C7LRJuklzGYCNN3A==} engines: {node: '>=8.0.0'} dependencies: - '@types/bn.js': 5.1.6 + '@types/bn.js': 5.1.5 web3-core: 1.10.4 web3-core-helpers: 1.10.4 web3-core-method: 1.10.4 diff --git a/tests/test-setup-devtools-evm-hardhat/src/endpointV2/setup.ts b/tests/test-setup-devtools-evm-hardhat/src/endpointV2/setup.ts index 11785235be..467bcda033 100644 --- a/tests/test-setup-devtools-evm-hardhat/src/endpointV2/setup.ts +++ b/tests/test-setup-devtools-evm-hardhat/src/endpointV2/setup.ts @@ -122,6 +122,7 @@ export const getDefaultUlnConfig = (dvnAddress: string): Uln302UlnConfig => { requiredDVNs: [dvnAddress], requiredDVNCount: 1, optionalDVNs: [], + optionalDVNCount: 0, optionalDVNThreshold: 0, } } @@ -136,7 +137,9 @@ export const getDefaultUlnReadConfig = (dvnAddress: string, executorAddress: str return { executor: executorAddress, requiredDVNs: [dvnAddress], + requiredDVNCount: 1, optionalDVNs: [], + optionalDVNCount: 0, optionalDVNThreshold: 0, } } From cebc3e911f3d0f3b48ed185845af04caaf247829 Mon Sep 17 00:00:00 2001 From: Krak Date: Mon, 22 Jun 2026 15:05:48 -0700 Subject: [PATCH 03/17] fix(devtools): complete ULN NIL round-trip for required DVNs + run Solana unit tests in CI - generator (creatUlnConfig): the requiredDVNs branch now emits `requiredDVNCount: 0` for the inherit case so a regenerated config re-applies as inherit instead of deriving the NIL sentinel from the empty array (matching the confirmations / optionalDVNs handling). Pinned-none still emits `[]`. [review QUESTION 1] - protocol-devtools-solana bin/test: run the pure unit suites (serializer / schema transforms, mocked reads) unconditionally; keep only the live-RPC endpointv2 SDK suite gated behind LZ_DEVTOOLS_ENABLE_SOLANA_TESTS, so the Solana logic is covered in CI. [review NIT 1] --- .changeset/generator-uln-nil-sentinels.md | 8 +++++ packages/protocol-devtools-solana/bin/test | 6 +++- .../src/oapp/typescript/constants.ts | 1 + .../src/oapp/typescript/typescript.ts | 30 +++++++++++-------- 4 files changed, 32 insertions(+), 13 deletions(-) create mode 100644 .changeset/generator-uln-nil-sentinels.md diff --git a/.changeset/generator-uln-nil-sentinels.md b/.changeset/generator-uln-nil-sentinels.md new file mode 100644 index 0000000000..a33910f9d9 --- /dev/null +++ b/.changeset/generator-uln-nil-sentinels.md @@ -0,0 +1,8 @@ +--- +"@layerzerolabs/ua-devtools-evm-hardhat": patch +--- + +Generate ULN configs that round-trip the new NIL-sentinel semantics: a field inheriting +the on-chain default is emitted as "inherit" (omitted, or `requiredDVNCount: 0` for the +mandatory required-DVNs field) rather than an explicit empty value that would pin +zero/none on re-apply. Pinned-none configs continue to emit `[]`/`0n`. diff --git a/packages/protocol-devtools-solana/bin/test b/packages/protocol-devtools-solana/bin/test index 2274958c98..2bc25f93c4 100755 --- a/packages/protocol-devtools-solana/bin/test +++ b/packages/protocol-devtools-solana/bin/test @@ -1,7 +1,11 @@ #!/usr/bin/env bash if [ -z "${LZ_DEVTOOLS_ENABLE_SOLANA_TESTS}" ]; then - echo 'Solana tests can be enabled by setting LZ_DEVTOOLS_ENABLE_SOLANA_TESTS environment variable to a non-empty value' + # The endpointv2 SDK suite reads live mainnet state and needs a Solana RPC / local node, so it + # stays gated behind LZ_DEVTOOLS_ENABLE_SOLANA_TESTS. The pure unit suites (serializer / schema + # transforms, with mocked reads) need no validator, so they run unconditionally and cover the + # Solana logic in CI. + jest --ci --testPathIgnorePatterns '/node_modules/' 'endpointv2/sdk\.test\.ts' "$@" else jest --ci "$@" fi \ No newline at end of file diff --git a/packages/ua-devtools-evm-hardhat/src/oapp/typescript/constants.ts b/packages/ua-devtools-evm-hardhat/src/oapp/typescript/constants.ts index 4e4e3ebb4a..c3e3a1e36d 100644 --- a/packages/ua-devtools-evm-hardhat/src/oapp/typescript/constants.ts +++ b/packages/ua-devtools-evm-hardhat/src/oapp/typescript/constants.ts @@ -18,6 +18,7 @@ export const RECEIVE_CONFIG: string = 'receiveConfig' export const RECEIVE_LIBRARY: string = 'receiveLibrary' export const RECEIVE_LIBRARY_CONFIG: string = 'receiveLibraryConfig' export const REQUIRED_DVNS: string = 'requiredDVNs' +export const REQUIRED_DVN_COUNT: string = 'requiredDVNCount' export const SEND_CONFIG: string = 'sendConfig' export const SEND_LIBRARY: string = 'sendLibrary' export const TO: string = 'to' diff --git a/packages/ua-devtools-evm-hardhat/src/oapp/typescript/typescript.ts b/packages/ua-devtools-evm-hardhat/src/oapp/typescript/typescript.ts index d97349345a..cabf63380b 100644 --- a/packages/ua-devtools-evm-hardhat/src/oapp/typescript/typescript.ts +++ b/packages/ua-devtools-evm-hardhat/src/oapp/typescript/typescript.ts @@ -45,6 +45,7 @@ import { RECEIVE_LIBRARY, RECEIVE_LIBRARY_CONFIG, REQUIRED_DVNS, + REQUIRED_DVN_COUNT, SEND_CONFIG, SEND_LIBRARY, TO, @@ -312,22 +313,27 @@ export const creatUlnConfig = ({ ) } - // requiredDVNs is mandatory on the config, so we always emit it. A count of 0 (inherit) is - // emitted as an empty array for backwards compatibility — this predates the NIL work. - if (requiredDVNCount !== NIL_DVN_COUNT) { - properties.push( - factory.createPropertyAssignment( - factory.createIdentifier(REQUIRED_DVNS), - factory.createArrayLiteralExpression( - requiredDVNs.filter((dvn) => dvn != null).map((dvn) => factory.createStringLiteral(dvn)) - ) + // requiredDVNs is mandatory on the config, so we always emit the array (empty for both the + // inherit and pin-none cases). To disambiguate them we mirror the sibling fields: + // - count 0 (inherit) → also emit `requiredDVNCount: 0`, which the serializer honors as an + // explicit override so it does NOT derive the NIL sentinel from the empty array. + // - NIL (pin none) → emit just the empty array, which serializes back to NIL. + // - concrete → emit the array; the count is derived from its length. + properties.push( + factory.createPropertyAssignment( + factory.createIdentifier(REQUIRED_DVNS), + factory.createArrayLiteralExpression( + requiredDVNCount === NIL_DVN_COUNT + ? [] + : requiredDVNs.filter((dvn) => dvn != null).map((dvn) => factory.createStringLiteral(dvn)) ) ) - } else { + ) + if (requiredDVNCount === 0) { properties.push( factory.createPropertyAssignment( - factory.createIdentifier(REQUIRED_DVNS), - factory.createArrayLiteralExpression([]) + factory.createIdentifier(REQUIRED_DVN_COUNT), + factory.createNumericLiteral(0) ) ) } From 6f8e19e9384c83dc5a6ac2070c55c5b6697bca6d Mon Sep 17 00:00:00 2001 From: Krak Date: Fri, 26 Jun 2026 11:44:42 -0700 Subject: [PATCH 04/17] fix(protocol-devtools-evm): complete ULN NIL round-trip for the Read library path Mirror the ULN302 completion onto ulnRead: add an optional requiredDVNCount override to UlnReadUlnUserConfig/schema, honor it in serializeUlnConfig, and have the Read config generator emit requiredDVNCount: 0 for inherited required DVNs (and omit optionalDVNs for inherit). Without this, the normalizeUlnConfig swap on the chain side left the user side mapping empty requiredDVNs/optionalDVNs to NIL, flipping an inherited Read config to pinned-none on the next wire. --- .changeset/generator-uln-nil-sentinels.md | 9 +-- .changeset/uln-nil-sentinels.md | 21 +++++-- .../protocol-devtools-evm/src/ulnRead/sdk.ts | 15 ++++- .../test/ulnRead/nil-sentinels.test.ts | 41 ++++++++++++ .../protocol-devtools/src/ulnRead/schema.ts | 1 + .../protocol-devtools/src/ulnRead/types.ts | 9 +++ .../src/oapp-read/typescript/typescript.ts | 63 +++++++++++++++---- 7 files changed, 136 insertions(+), 23 deletions(-) diff --git a/.changeset/generator-uln-nil-sentinels.md b/.changeset/generator-uln-nil-sentinels.md index a33910f9d9..d286e5c9ba 100644 --- a/.changeset/generator-uln-nil-sentinels.md +++ b/.changeset/generator-uln-nil-sentinels.md @@ -2,7 +2,8 @@ "@layerzerolabs/ua-devtools-evm-hardhat": patch --- -Generate ULN configs that round-trip the new NIL-sentinel semantics: a field inheriting -the on-chain default is emitted as "inherit" (omitted, or `requiredDVNCount: 0` for the -mandatory required-DVNs field) rather than an explicit empty value that would pin -zero/none on re-apply. Pinned-none configs continue to emit `[]`/`0n`. +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 +emitted as "inherit" (omitted, or `requiredDVNCount: 0` for the mandatory required-DVNs +field) rather than an explicit empty value that would pin zero/none on re-apply. +Pinned-none configs continue to emit `[]`/`0n`. diff --git a/.changeset/uln-nil-sentinels.md b/.changeset/uln-nil-sentinels.md index a7095f917a..55d8b796e7 100644 --- a/.changeset/uln-nil-sentinels.md +++ b/.changeset/uln-nil-sentinels.md @@ -16,13 +16,21 @@ inherits the on-chain default: - Omitting a field (leaving it `undefined`) continues to inherit the on-chain default. +The same treatment applies to the Read library (`UlnRead`): `optionalDVNs: []` +pins `NIL_DVN_COUNT` and an empty `requiredDVNs` pins `NIL_DVN_COUNT` unless the +inherit case is forced (see below). + 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. +round-trips through the configuration diff. Because `requiredDVNs` is mandatory on +the user config, an empty array is ambiguous between inherit and pin-none, so both +`Uln302UlnUserConfig` and `UlnReadUlnUserConfig` gain an optional `requiredDVNCount` +override: set it to `0` to force inherit (the serializer then keeps `0` instead of +deriving the NIL sentinel from the empty array). The on-chain read path no longer +re-applies the empty→NIL mapping, keeping `hasAppUlnConfig` idempotent on both the +ULN302 and Read 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: @@ -35,3 +43,6 @@ MIGRATION: (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. +- For a Read config that inherits its required DVNs, emit `requiredDVNCount: 0` + alongside the empty `requiredDVNs: []` (the config generator now does this), or the + empty array will be read as pin-none and flip the config on the next wire. diff --git a/packages/protocol-devtools-evm/src/ulnRead/sdk.ts b/packages/protocol-devtools-evm/src/ulnRead/sdk.ts index f05ea94c9b..0411070757 100644 --- a/packages/protocol-devtools-evm/src/ulnRead/sdk.ts +++ b/packages/protocol-devtools-evm/src/ulnRead/sdk.ts @@ -121,7 +121,13 @@ export class UlnRead extends OmniSDK implements IUlnRead { * @returns {SerializedUlnReadUlnConfig} */ protected serializeUlnConfig( - { requiredDVNs, optionalDVNs, optionalDVNThreshold = 0, executor = makeZeroAddress() }: UlnReadUlnUserConfig, + { + requiredDVNs, + requiredDVNCount, + 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 @@ -129,6 +135,11 @@ export class UlnRead extends OmniSDK implements IUlnRead { */ useNilSentinels = true ): SerializedUlnReadUlnConfig { + // requiredDVNs is mandatory on the user config, so the only signal is empty vs non-empty. + // An explicit count override always wins (e.g. `0` to force the inherit case). + 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 = @@ -144,7 +155,7 @@ export class UlnRead extends OmniSDK implements IUlnRead { return { executor, - requiredDVNCount: requiredDVNs.length > 0 ? requiredDVNs.length : useNilSentinels ? NIL_DVN_COUNT : 0, + requiredDVNCount: resolvedRequiredDVNCount, optionalDVNCount: resolvedOptionalDVNCount, optionalDVNThreshold: hasConcreteOptionalDVNs ? optionalDVNThreshold : 0, requiredDVNs: requiredDVNs.map(addChecksum).sort(compareBytes32Ascending), diff --git a/packages/protocol-devtools-evm/test/ulnRead/nil-sentinels.test.ts b/packages/protocol-devtools-evm/test/ulnRead/nil-sentinels.test.ts index 2bec748d3c..67a7104cdf 100644 --- a/packages/protocol-devtools-evm/test/ulnRead/nil-sentinels.test.ts +++ b/packages/protocol-devtools-evm/test/ulnRead/nil-sentinels.test.ts @@ -32,6 +32,27 @@ describe('ulnRead/nil-sentinels', () => { }) }) + describe('serializeUlnConfig requiredDVNs', () => { + const serialize = (config: UlnReadUlnUserConfig, useNilSentinels?: boolean) => + (ulnSdk as any).serializeUlnConfig(config, useNilSentinels) + + it('maps an explicitly-empty requiredDVNs to NIL_DVN_COUNT (pin "no required DVNs")', () => { + expect(serialize({ requiredDVNs: [] }).requiredDVNCount).toBe(NIL_DVN_COUNT) + }) + + it('honors a requiredDVNCount: 0 override (inherit the on-chain default)', () => { + expect(serialize({ requiredDVNs: [], requiredDVNCount: 0 }).requiredDVNCount).toBe(0) + }) + + it('derives the count from a concrete requiredDVNs array', () => { + expect(serialize({ requiredDVNs: [DVN] }).requiredDVNCount).toBe(1) + }) + + it('keeps an explicitly-empty requiredDVNs literal (count 0) for the DEFAULT config', () => { + expect(serialize({ requiredDVNs: [] }, false).requiredDVNCount).toBe(0) + }) + }) + describe('hasAppUlnConfig idempotency', () => { const read = (over: Partial): UlnReadUlnConfig => ({ executor: makeZeroAddress(), @@ -73,5 +94,25 @@ describe('ulnRead/nil-sentinels', () => { hasConfig(read({ optionalDVNCount: 0 }), { requiredDVNs: [DVN], optionalDVNs: [] }) ).resolves.toBe(false) }) + + it('treats an inherited requiredDVNs (count 0 + override) as matching a chain with requiredDVNCount 0', async () => { + await expect( + hasConfig(read({ requiredDVNs: [], requiredDVNCount: 0 }), { requiredDVNs: [], requiredDVNCount: 0 }) + ).resolves.toBe(true) + }) + + it('treats an explicitly-empty requiredDVNs as matching a chain that stored NIL', async () => { + await expect( + hasConfig(read({ requiredDVNs: [], requiredDVNCount: NIL_DVN_COUNT }), { requiredDVNs: [] }) + ).resolves.toBe(true) + }) + + it('does NOT flip an inherited requiredDVNs (chain count 0) to pinned-none', async () => { + // Regression: the desired config inherits (override 0), so wiring must NOT emit a + // setConfig that pins requiredDVNCount to NIL. + await expect( + hasConfig(read({ requiredDVNs: [], requiredDVNCount: 0 }), { requiredDVNs: [], requiredDVNCount: 0 }) + ).resolves.toBe(true) + }) }) }) diff --git a/packages/protocol-devtools/src/ulnRead/schema.ts b/packages/protocol-devtools/src/ulnRead/schema.ts index e0b5915c5c..dff06059d0 100644 --- a/packages/protocol-devtools/src/ulnRead/schema.ts +++ b/packages/protocol-devtools/src/ulnRead/schema.ts @@ -14,6 +14,7 @@ export const UlnReadUlnConfigSchema = z.object({ export const UlnReadUlnUserConfigSchema = z.object({ executor: AddressSchema.optional(), requiredDVNs: z.array(AddressSchema), + requiredDVNCount: UIntNumberSchema.optional(), optionalDVNs: z.array(AddressSchema).optional(), optionalDVNThreshold: UIntNumberSchema.optional(), }) satisfies z.ZodSchema diff --git a/packages/protocol-devtools/src/ulnRead/types.ts b/packages/protocol-devtools/src/ulnRead/types.ts index 3f712cfcf7..27869a3c7a 100644 --- a/packages/protocol-devtools/src/ulnRead/types.ts +++ b/packages/protocol-devtools/src/ulnRead/types.ts @@ -67,6 +67,15 @@ export interface UlnReadUlnUserConfig { executor?: string optionalDVNThreshold?: number requiredDVNs: string[] + /** + * Explicit override for the required DVN count. + * + * `requiredDVNs` is mandatory, so an empty array is ambiguous between "inherit the + * on-chain default" and "pin no required DVNs". Setting this to `0` forces the inherit + * case (the serializer will NOT derive the NIL sentinel from the empty array); leaving + * it omitted lets the count be derived from the array. + */ + requiredDVNCount?: number optionalDVNs?: string[] } diff --git a/packages/ua-devtools-evm-hardhat/src/oapp-read/typescript/typescript.ts b/packages/ua-devtools-evm-hardhat/src/oapp-read/typescript/typescript.ts index 8dd01c4181..9d62ef79b1 100644 --- a/packages/ua-devtools-evm-hardhat/src/oapp-read/typescript/typescript.ts +++ b/packages/ua-devtools-evm-hardhat/src/oapp-read/typescript/typescript.ts @@ -1,7 +1,7 @@ import { ExportAssignment, factory, Identifier, NodeArray, PropertyAssignment, Statement } from 'typescript' import { OmniAddress } from '@layerzerolabs/devtools' import { getReadConfig } from '@/utils/taskHelpers' -import { UlnReadUlnConfig } from '@layerzerolabs/protocol-devtools' +import { NIL_DVN_COUNT, UlnReadUlnConfig } from '@layerzerolabs/protocol-devtools' import { CONFIG, CONNECTIONS, @@ -11,6 +11,7 @@ import { FROM, OPTIONAL_DVN_THRESHOLD, OPTIONAL_DVNS, + REQUIRED_DVN_COUNT, REQUIRED_DVNS, TO, ULN_CONFIG, @@ -154,19 +155,52 @@ export const createReadLibraryConfig = (defaultReadLibrary: string): PropertyAss export const createReadUlnConfig = ({ executor, requiredDVNs, + requiredDVNCount, optionalDVNs, + optionalDVNCount, optionalDVNThreshold, }: UlnReadUlnConfig): PropertyAssignment => { - return factory.createPropertyAssignment( - factory.createIdentifier(ULN_CONFIG), - factory.createObjectLiteralExpression([ - factory.createPropertyAssignment(factory.createIdentifier(EXECUTOR), factory.createStringLiteral(executor)), + const properties: PropertyAssignment[] = [ + factory.createPropertyAssignment(factory.createIdentifier(EXECUTOR), factory.createStringLiteral(executor)), + ] + + // requiredDVNs is mandatory on the config, so we always emit the array (empty for both the + // inherit and pin-none cases). To disambiguate them we mirror the uln302 generator: + // - count 0 (inherit) → also emit `requiredDVNCount: 0`, which the serializer honors as an + // explicit override so it does NOT derive the NIL sentinel from the empty array. + // - NIL (pin none) → emit just the empty array, which serializes back to NIL. + // - concrete → emit the array; the count is derived from its length. + properties.push( + factory.createPropertyAssignment( + factory.createIdentifier(REQUIRED_DVNS), + factory.createArrayLiteralExpression( + requiredDVNCount === NIL_DVN_COUNT + ? [] + : requiredDVNs.filter((dvn) => dvn != null).map((dvn) => factory.createStringLiteral(dvn)) + ) + ) + ) + if (requiredDVNCount === 0) { + properties.push( factory.createPropertyAssignment( - factory.createIdentifier(REQUIRED_DVNS), - factory.createArrayLiteralExpression( - requiredDVNs.filter((dvn) => dvn != null).map((dvn) => factory.createStringLiteral(dvn)) - ) - ), + factory.createIdentifier(REQUIRED_DVN_COUNT), + factory.createNumericLiteral(0) + ) + ) + } + + // optionalDVNs: count 0 means "inherit the default" so we omit the field; the NIL sentinel + // means "pinned to none", emitted as `[]` so it serializes back to NIL. Only a concrete set + // of optional DVNs carries the array and its threshold. + if (optionalDVNCount === NIL_DVN_COUNT) { + properties.push( + factory.createPropertyAssignment( + factory.createIdentifier(OPTIONAL_DVNS), + factory.createArrayLiteralExpression([]) + ) + ) + } else if (optionalDVNCount !== 0) { + properties.push( factory.createPropertyAssignment( factory.createIdentifier(OPTIONAL_DVNS), factory.createArrayLiteralExpression( @@ -176,8 +210,13 @@ export const createReadUlnConfig = ({ factory.createPropertyAssignment( factory.createIdentifier(OPTIONAL_DVN_THRESHOLD), factory.createNumericLiteral(optionalDVNThreshold) - ), - ]) + ) + ) + } + + return factory.createPropertyAssignment( + factory.createIdentifier(ULN_CONFIG), + factory.createObjectLiteralExpression(properties) ) } From cc068e2b9dc45bf646a566843f403ae4dbf68da5 Mon Sep 17 00:00:00 2001 From: Krak Date: Fri, 26 Jun 2026 13:14:16 -0700 Subject: [PATCH 05/17] refactor(protocol-devtools): hoist shared ULN empty->NIL resolution into uln302/resolve The requiredDVNCount/optionalDVNCount/confirmations NIL resolution was duplicated byte-for-byte across the EVM uln302, EVM ulnRead, and Solana uln302 serializers; only the per-chain DVN-array encoding genuinely varied. Extract resolveRequiredDVNCount, resolveOptionalDVNCount, and resolveConfirmations next to the sentinels so the three serializers stay in lock-step. Behavior-preserving. --- .../protocol-devtools-evm/src/uln302/sdk.ts | 28 +++-------- .../protocol-devtools-evm/src/ulnRead/sdk.ts | 24 ++++------ .../src/uln302/sdk.ts | 28 +++-------- .../protocol-devtools/src/uln302/index.ts | 1 + .../protocol-devtools/src/uln302/resolve.ts | 46 +++++++++++++++++++ 5 files changed, 67 insertions(+), 60 deletions(-) create mode 100644 packages/protocol-devtools/src/uln302/resolve.ts diff --git a/packages/protocol-devtools-evm/src/uln302/sdk.ts b/packages/protocol-devtools-evm/src/uln302/sdk.ts index 5e193d3129..1637b97587 100644 --- a/packages/protocol-devtools-evm/src/uln302/sdk.ts +++ b/packages/protocol-devtools-evm/src/uln302/sdk.ts @@ -5,8 +5,10 @@ import { type Uln302ExecutorConfig, type Uln302UlnConfig, type Uln302UlnUserConfig, - NIL_CONFIRMATIONS, NIL_DVN_COUNT, + resolveConfirmations, + resolveOptionalDVNCount, + resolveRequiredDVNCount, } from '@layerzerolabs/protocol-devtools' import { OmniAddress, @@ -269,32 +271,14 @@ export class Uln302 extends OmniSDK implements IUln302 { */ 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 + const resolvedRequiredDVNCount = resolveRequiredDVNCount(requiredDVNs, requiredDVNCount, useNilSentinels) + const resolvedOptionalDVNCount = resolveOptionalDVNCount(optionalDVNs, useNilSentinels) // 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, + confirmations: resolveConfirmations(confirmations, useNilSentinels), optionalDVNThreshold: hasConcreteOptionalDVNs ? optionalDVNThreshold : 0, requiredDVNs: requiredDVNs.map(addChecksum).sort(compareBytes32Ascending), optionalDVNs: (optionalDVNs ?? []).map(addChecksum).sort(compareBytes32Ascending), diff --git a/packages/protocol-devtools-evm/src/ulnRead/sdk.ts b/packages/protocol-devtools-evm/src/ulnRead/sdk.ts index 0411070757..4a1c2a6f37 100644 --- a/packages/protocol-devtools-evm/src/ulnRead/sdk.ts +++ b/packages/protocol-devtools-evm/src/ulnRead/sdk.ts @@ -1,5 +1,10 @@ import type { IUlnRead, UlnReadUlnConfig, UlnReadUlnUserConfig } from '@layerzerolabs/protocol-devtools' -import { NIL_DVN_COUNT, UlnReadUlnConfigSchema } from '@layerzerolabs/protocol-devtools' +import { + NIL_DVN_COUNT, + UlnReadUlnConfigSchema, + resolveOptionalDVNCount, + resolveRequiredDVNCount, +} from '@layerzerolabs/protocol-devtools' import { OmniAddress, type OmniTransaction, @@ -135,21 +140,8 @@ export class UlnRead extends OmniSDK implements IUlnRead { */ useNilSentinels = true ): SerializedUlnReadUlnConfig { - // requiredDVNs is mandatory on the user config, so the only signal is empty vs non-empty. - // An explicit count override always wins (e.g. `0` to force the inherit case). - 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 + const resolvedRequiredDVNCount = resolveRequiredDVNCount(requiredDVNs, requiredDVNCount, useNilSentinels) + const resolvedOptionalDVNCount = resolveOptionalDVNCount(optionalDVNs, useNilSentinels) const hasConcreteOptionalDVNs = resolvedOptionalDVNCount !== 0 && resolvedOptionalDVNCount !== NIL_DVN_COUNT diff --git a/packages/protocol-devtools-solana/src/uln302/sdk.ts b/packages/protocol-devtools-solana/src/uln302/sdk.ts index ffa3bdf58f..51daa8dbb9 100644 --- a/packages/protocol-devtools-solana/src/uln302/sdk.ts +++ b/packages/protocol-devtools-solana/src/uln302/sdk.ts @@ -1,12 +1,14 @@ import type { EndpointId } from '@layerzerolabs/lz-definitions' import { IUln302, - NIL_CONFIRMATIONS, NIL_DVN_COUNT, Uln302ConfigType, Uln302ExecutorConfig, Uln302UlnConfig, Uln302UlnUserConfig, + resolveConfirmations, + resolveOptionalDVNCount, + resolveRequiredDVNCount, } from '@layerzerolabs/protocol-devtools' import { OmniAddress, @@ -218,32 +220,14 @@ export class Uln302 extends OmniSDK implements IUln302 { */ 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 + const resolvedRequiredDVNCount = resolveRequiredDVNCount(requiredDVNs, requiredDVNCount, useNilSentinels) + const resolvedOptionalDVNCount = resolveOptionalDVNCount(optionalDVNs, useNilSentinels) // The threshold must 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, + confirmations: resolveConfirmations(confirmations, useNilSentinels), optionalDVNThreshold: hasConcreteOptionalDVNs ? optionalDVNThreshold : 0, requiredDVNs: serializeDVNs(requiredDVNs), optionalDVNs: serializeDVNs(optionalDVNs ?? []), diff --git a/packages/protocol-devtools/src/uln302/index.ts b/packages/protocol-devtools/src/uln302/index.ts index 10128135e8..a308adff25 100644 --- a/packages/protocol-devtools/src/uln302/index.ts +++ b/packages/protocol-devtools/src/uln302/index.ts @@ -1,4 +1,5 @@ export * from './config' export * from './constants' +export * from './resolve' export * from './schema' export * from './types' diff --git a/packages/protocol-devtools/src/uln302/resolve.ts b/packages/protocol-devtools/src/uln302/resolve.ts new file mode 100644 index 0000000000..a1595e03df --- /dev/null +++ b/packages/protocol-devtools/src/uln302/resolve.ts @@ -0,0 +1,46 @@ +import { NIL_CONFIRMATIONS, NIL_DVN_COUNT } from './constants' + +/** + * Resolution of the empty → NIL-sentinel mapping shared by every ULN serializer + * (ULN302 send/receive and the Read library, across EVM and Solana). The only genuine + * per-chain variation is how the DVN arrays themselves are encoded, so the count/confirmations + * resolution lives here to keep the three serializers in lock-step. + * + * `useNilSentinels` is `true` for an OApp config (an explicitly-empty field pins the literal + * zero/none via a sentinel) and `false` for the library-wide DEFAULT config (which rejects + * NIL sentinels on-chain, so empty/zero values stay literal). + */ + +/** + * `requiredDVNs` is mandatory on the user config, so an empty array is the only "none" signal. + * An explicit `requiredDVNCount` override always wins (e.g. `0` forces the inherit case); + * otherwise the count is the array length, or the NIL sentinel for an empty array under + * `useNilSentinels`. + */ +export const resolveRequiredDVNCount = ( + requiredDVNs: readonly string[], + requiredDVNCount: number | undefined, + useNilSentinels: boolean +): number => requiredDVNCount ?? (requiredDVNs.length > 0 ? requiredDVNs.length : useNilSentinels ? NIL_DVN_COUNT : 0) + +/** + * `optionalDVNs` is optional, so we distinguish omitted (`undefined` → inherit the on-chain + * default, count `0`) from explicitly empty (`[]` → pin "no optional DVNs" via the NIL sentinel + * under `useNilSentinels`). + */ +export const resolveOptionalDVNCount = ( + optionalDVNs: readonly string[] | null | undefined, + useNilSentinels: boolean +): number => + optionalDVNs == null ? 0 : optionalDVNs.length > 0 ? optionalDVNs.length : useNilSentinels ? NIL_DVN_COUNT : 0 + +/** + * An omitted `confirmations` inherits the on-chain default (`0`); an explicit `0n` pins "zero + * confirmations" via the NIL sentinel under `useNilSentinels`; any other value is literal. + */ +export const resolveConfirmations = (confirmations: bigint | undefined, useNilSentinels: boolean): bigint => + confirmations == null + ? BigInt(0) + : confirmations === BigInt(0) && useNilSentinels + ? NIL_CONFIRMATIONS + : confirmations From 8d97cdee7db2c7e3065d564d9ce9c74e139e632e Mon Sep 17 00:00:00 2001 From: Krak Date: Fri, 26 Jun 2026 15:26:53 -0700 Subject: [PATCH 06/17] fix(lzapp-migration): guard encodeUlnConfig against omitted DVN arrays The example configs now omit optionalDVNs to inherit the default, so the local encodeUlnConfig read config.optionalDVNs.length on undefined and threw before producing a diff. Guard the .length reads the same way the array fields below already are (|| []). --- examples/lzapp-migration/tasks/common/taskHelper.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/lzapp-migration/tasks/common/taskHelper.ts b/examples/lzapp-migration/tasks/common/taskHelper.ts index 98cea2aa8f..6dbaf58029 100644 --- a/examples/lzapp-migration/tasks/common/taskHelper.ts +++ b/examples/lzapp-migration/tasks/common/taskHelper.ts @@ -494,8 +494,8 @@ function encodeUlnConfig(config: Uln302UlnConfig): string { [ [ config.confirmations, - config.requiredDVNs.length, - config.optionalDVNs.length, + (config.requiredDVNs || []).length, + (config.optionalDVNs || []).length, config.optionalDVNThreshold, config.requiredDVNs || [], config.optionalDVNs || [], From 998bc102c1c7a9ed6ecdbde1871476b9ec76c9ce Mon Sep 17 00:00:00 2001 From: Krak Date: Fri, 26 Jun 2026 15:27:07 -0700 Subject: [PATCH 07/17] refactor(protocol-devtools): make requiredDVNs optional, unify DVN count resolution requiredDVNs was mandatory on the user config, so an empty array was ambiguous between inherit and pin-none and needed a requiredDVNCount override to express inherit. Make requiredDVNs optional instead, so it behaves exactly like optionalDVNs: omitted -> inherit, [] -> pin none (NIL), concrete -> pin those. This removes the requiredDVNCount override from both user configs, collapses resolveRequiredDVNCount/resolveOptionalDVNCount into a single resolveDVNCount, and simplifies both generators to emit requiredDVNs identically to optionalDVNs (omit for inherit). metadata-tools no longer sets requiredDVNCount (the serializer derives it from the array); generated configs are byte-identical after serialization. --- .changeset/generator-uln-nil-sentinels.md | 6 +- .changeset/uln-nil-sentinels.md | 58 +++++++++---------- .../metadata-tools/src/config-metadata.ts | 7 --- .../config-metadata.test.ts.snap | 36 ------------ .../test/config-metadata.test.ts | 11 ++-- .../protocol-devtools-evm/src/uln302/sdk.ts | 11 ++-- .../protocol-devtools-evm/src/ulnRead/sdk.ts | 21 ++----- .../integration/nil-dvn-consistency.test.ts | 12 ++-- .../test/uln302/nil-dvn-count.test.ts | 7 +-- .../test/ulnRead/nil-sentinels.test.ts | 24 ++++---- .../src/uln302/sdk.ts | 11 ++-- .../protocol-devtools/src/uln302/resolve.ts | 26 ++------- .../protocol-devtools/src/uln302/schema.ts | 3 +- .../protocol-devtools/src/uln302/types.ts | 7 ++- .../protocol-devtools/src/ulnRead/schema.ts | 3 +- .../protocol-devtools/src/ulnRead/types.ts | 11 +--- .../src/oapp-read/typescript/typescript.ts | 31 +++++----- .../src/oapp/typescript/constants.ts | 1 - .../src/oapp/typescript/typescript.ts | 31 +++++----- 19 files changed, 114 insertions(+), 203 deletions(-) diff --git a/.changeset/generator-uln-nil-sentinels.md b/.changeset/generator-uln-nil-sentinels.md index d286e5c9ba..2a4a492ac4 100644 --- a/.changeset/generator-uln-nil-sentinels.md +++ b/.changeset/generator-uln-nil-sentinels.md @@ -4,6 +4,6 @@ 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 -emitted as "inherit" (omitted, or `requiredDVNCount: 0` for the mandatory required-DVNs -field) rather than an explicit empty value that would pin zero/none on re-apply. -Pinned-none configs continue to emit `[]`/`0n`. +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`. diff --git a/.changeset/uln-nil-sentinels.md b/.changeset/uln-nil-sentinels.md index 55d8b796e7..a0e750d817 100644 --- a/.changeset/uln-nil-sentinels.md +++ b/.changeset/uln-nil-sentinels.md @@ -6,43 +6,41 @@ 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: +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: -- `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. +- 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. -The same treatment applies to the Read library (`UlnRead`): `optionalDVNs: []` -pins `NIL_DVN_COUNT` and an empty `requiredDVNs` pins `NIL_DVN_COUNT` unless the -inherit case is forced (see below). +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. -To support this, `Uln302UlnConfig`/`UlnReadUlnConfig` now carry `optionalDVNCount` -(and `UlnReadUlnConfig` also carries `requiredDVNCount`) so the stored sentinel -round-trips through the configuration diff. Because `requiredDVNs` is mandatory on -the user config, an empty array is ambiguous between inherit and pin-none, so both -`Uln302UlnUserConfig` and `UlnReadUlnUserConfig` gain an optional `requiredDVNCount` -override: set it to `0` to force inherit (the serializer then keeps `0` instead of -deriving the NIL sentinel from the empty array). The on-chain read path no longer -re-applies the empty→NIL mapping, keeping `hasAppUlnConfig` idempotent on both the -ULN302 and Read 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. +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` 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. Re-wiring an existing OApp whose config used these - empty values will now emit a `setConfig` that flips it from inherit to pinned. +- 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. -- For a Read config that inherits its required DVNs, emit `requiredDVNCount: 0` - alongside the empty `requiredDVNs: []` (the config generator now does this), or the - empty array will be read as pin-none and flip the config on the next wire. +- `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. diff --git a/packages/metadata-tools/src/config-metadata.ts b/packages/metadata-tools/src/config-metadata.ts index 6ad2550694..a0824aefe1 100644 --- a/packages/metadata-tools/src/config-metadata.ts +++ b/packages/metadata-tools/src/config-metadata.ts @@ -11,7 +11,6 @@ import { MSG_LIB_BLOCK_RECEIVE_ONLY, MSG_LIB_BLOCK_SEND_AND_RECEIVE, MSG_LIB_BLOCK_SEND_ONLY, - NIL_DVN_COUNT, } from './constants' import { getAddress } from '@ethersproject/address' @@ -200,8 +199,6 @@ export async function translatePathwayToConfig( throw new Error(`Optional DVN threshold is greater than the number of optional DVNs.`) } - const requiredDVNCount = requiredDVNs.length > 0 ? requiredDVNs.length : NIL_DVN_COUNT - // Only look up deployment/DVNs/executor for chains we're generating configs for const ALZDeployment = generateAToB ? getEndpointIdDeployment(AContract.eid, metadata) : undefined const BLZDeployment = generateBToA ? getEndpointIdDeployment(BContract.eid, metadata) : undefined @@ -268,7 +265,6 @@ export async function translatePathwayToConfig( ulnConfig: { confirmations: BigInt(AToBConfirmations), requiredDVNs: ARequiredDVNs, - requiredDVNCount, optionalDVNs: AOptionalDVNs, optionalDVNThreshold, }, @@ -282,7 +278,6 @@ export async function translatePathwayToConfig( ulnConfig: { confirmations: BigInt(BToAConfirmations), requiredDVNs: ARequiredDVNs, - requiredDVNCount, optionalDVNs: AOptionalDVNs, optionalDVNThreshold, }, @@ -319,7 +314,6 @@ export async function translatePathwayToConfig( ulnConfig: { confirmations: BigInt(AToBConfirmations), requiredDVNs: BRequiredDVNs, - requiredDVNCount, optionalDVNs: BOptionalDVNs, optionalDVNThreshold, }, @@ -340,7 +334,6 @@ export async function translatePathwayToConfig( ulnConfig: { confirmations: BigInt(BToAConfirmations), requiredDVNs: BRequiredDVNs, - requiredDVNCount, optionalDVNs: BOptionalDVNs, optionalDVNThreshold, }, diff --git a/packages/metadata-tools/test/__snapshots__/config-metadata.test.ts.snap b/packages/metadata-tools/test/__snapshots__/config-metadata.test.ts.snap index f73e0d7a59..a4c70da92e 100644 --- a/packages/metadata-tools/test/__snapshots__/config-metadata.test.ts.snap +++ b/packages/metadata-tools/test/__snapshots__/config-metadata.test.ts.snap @@ -10,7 +10,6 @@ exports[`config-metadata generateConnectionsConfig should allow for call without "confirmations": 1n, "optionalDVNThreshold": 0, "optionalDVNs": [], - "requiredDVNCount": 1, "requiredDVNs": [ "0x9f0e79aeb198750f963b6f30b99d87c6ee5a0467", ], @@ -29,7 +28,6 @@ exports[`config-metadata generateConnectionsConfig should allow for call without "confirmations": 1n, "optionalDVNThreshold": 0, "optionalDVNs": [], - "requiredDVNCount": 1, "requiredDVNs": [ "0x9f0e79aeb198750f963b6f30b99d87c6ee5a0467", ], @@ -54,7 +52,6 @@ exports[`config-metadata generateConnectionsConfig should allow for call without "confirmations": 1n, "optionalDVNThreshold": 0, "optionalDVNs": [], - "requiredDVNCount": 1, "requiredDVNs": [ "4VDjp6XQaxoZf5RGwiPU9NR1EXSZn2TP4ATMmiSzLfhb", ], @@ -73,7 +70,6 @@ exports[`config-metadata generateConnectionsConfig should allow for call without "confirmations": 1n, "optionalDVNThreshold": 0, "optionalDVNs": [], - "requiredDVNCount": 1, "requiredDVNs": [ "4VDjp6XQaxoZf5RGwiPU9NR1EXSZn2TP4ATMmiSzLfhb", ], @@ -103,7 +99,6 @@ exports[`config-metadata generateConnectionsConfig should allow for custom DVNs "confirmations": 1n, "optionalDVNThreshold": 0, "optionalDVNs": [], - "requiredDVNCount": 1, "requiredDVNs": [ "0x9f0e79aeb198750f963b6f30b99d87c6ee5a0467", ], @@ -122,7 +117,6 @@ exports[`config-metadata generateConnectionsConfig should allow for custom DVNs "confirmations": 1n, "optionalDVNThreshold": 0, "optionalDVNs": [], - "requiredDVNCount": 1, "requiredDVNs": [ "0x9f0e79aeb198750f963b6f30b99d87c6ee5a0467", ], @@ -147,7 +141,6 @@ exports[`config-metadata generateConnectionsConfig should allow for custom DVNs "confirmations": 1n, "optionalDVNThreshold": 0, "optionalDVNs": [], - "requiredDVNCount": 1, "requiredDVNs": [ "29EKzmCscUg8mf4f5uskwMqvu2SXM8hKF1gWi1cCBoKT", ], @@ -166,7 +159,6 @@ exports[`config-metadata generateConnectionsConfig should allow for custom DVNs "confirmations": 1n, "optionalDVNThreshold": 0, "optionalDVNs": [], - "requiredDVNCount": 1, "requiredDVNs": [ "29EKzmCscUg8mf4f5uskwMqvu2SXM8hKF1gWi1cCBoKT", ], @@ -196,7 +188,6 @@ exports[`config-metadata generateConnectionsConfig should generate the connectio "confirmations": 1n, "optionalDVNThreshold": 0, "optionalDVNs": [], - "requiredDVNCount": 1, "requiredDVNs": [ "0x9f0e79aeb198750f963b6f30b99d87c6ee5a0467", ], @@ -215,7 +206,6 @@ exports[`config-metadata generateConnectionsConfig should generate the connectio "confirmations": 1n, "optionalDVNThreshold": 0, "optionalDVNs": [], - "requiredDVNCount": 1, "requiredDVNs": [ "0x9f0e79aeb198750f963b6f30b99d87c6ee5a0467", ], @@ -240,7 +230,6 @@ exports[`config-metadata generateConnectionsConfig should generate the connectio "confirmations": 1n, "optionalDVNThreshold": 0, "optionalDVNs": [], - "requiredDVNCount": 1, "requiredDVNs": [ "4VDjp6XQaxoZf5RGwiPU9NR1EXSZn2TP4ATMmiSzLfhb", ], @@ -259,7 +248,6 @@ exports[`config-metadata generateConnectionsConfig should generate the connectio "confirmations": 1n, "optionalDVNThreshold": 0, "optionalDVNs": [], - "requiredDVNCount": 1, "requiredDVNs": [ "4VDjp6XQaxoZf5RGwiPU9NR1EXSZn2TP4ATMmiSzLfhb", ], @@ -289,7 +277,6 @@ exports[`config-metadata generateConnectionsConfig supports NIL_CONFIRMATIONS 1` "confirmations": 18446744073709551615n, "optionalDVNThreshold": 0, "optionalDVNs": [], - "requiredDVNCount": 255, "requiredDVNs": [], }, }, @@ -306,7 +293,6 @@ exports[`config-metadata generateConnectionsConfig supports NIL_CONFIRMATIONS 1` "confirmations": 18446744073709551615n, "optionalDVNThreshold": 0, "optionalDVNs": [], - "requiredDVNCount": 255, "requiredDVNs": [], }, }, @@ -329,7 +315,6 @@ exports[`config-metadata generateConnectionsConfig supports NIL_CONFIRMATIONS 1` "confirmations": 18446744073709551615n, "optionalDVNThreshold": 0, "optionalDVNs": [], - "requiredDVNCount": 255, "requiredDVNs": [], }, }, @@ -346,7 +331,6 @@ exports[`config-metadata generateConnectionsConfig supports NIL_CONFIRMATIONS 1` "confirmations": 18446744073709551615n, "optionalDVNThreshold": 0, "optionalDVNs": [], - "requiredDVNCount": 255, "requiredDVNs": [], }, }, @@ -374,7 +358,6 @@ exports[`config-metadata generateConnectionsConfig supports passing no required "confirmations": 1n, "optionalDVNThreshold": 0, "optionalDVNs": [], - "requiredDVNCount": 255, "requiredDVNs": [], }, }, @@ -391,7 +374,6 @@ exports[`config-metadata generateConnectionsConfig supports passing no required "confirmations": 1n, "optionalDVNThreshold": 0, "optionalDVNs": [], - "requiredDVNCount": 255, "requiredDVNs": [], }, }, @@ -414,7 +396,6 @@ exports[`config-metadata generateConnectionsConfig supports passing no required "confirmations": 1n, "optionalDVNThreshold": 0, "optionalDVNs": [], - "requiredDVNCount": 255, "requiredDVNs": [], }, }, @@ -431,7 +412,6 @@ exports[`config-metadata generateConnectionsConfig supports passing no required "confirmations": 1n, "optionalDVNThreshold": 0, "optionalDVNs": [], - "requiredDVNCount": 255, "requiredDVNs": [], }, }, @@ -459,7 +439,6 @@ exports[`config-metadata generateConnectionsConfig supports using block send and "confirmations": 1n, "optionalDVNThreshold": 0, "optionalDVNs": [], - "requiredDVNCount": 1, "requiredDVNs": [ "0x9f0e79aeb198750f963b6f30b99d87c6ee5a0467", ], @@ -478,7 +457,6 @@ exports[`config-metadata generateConnectionsConfig supports using block send and "confirmations": 1n, "optionalDVNThreshold": 0, "optionalDVNs": [], - "requiredDVNCount": 1, "requiredDVNs": [ "0x9f0e79aeb198750f963b6f30b99d87c6ee5a0467", ], @@ -503,7 +481,6 @@ exports[`config-metadata generateConnectionsConfig supports using block send and "confirmations": 1n, "optionalDVNThreshold": 0, "optionalDVNs": [], - "requiredDVNCount": 1, "requiredDVNs": [ "0x55c175dd5b039331db251424538169d8495c18d1", ], @@ -522,7 +499,6 @@ exports[`config-metadata generateConnectionsConfig supports using block send and "confirmations": 1n, "optionalDVNThreshold": 0, "optionalDVNs": [], - "requiredDVNCount": 1, "requiredDVNs": [ "0x55c175dd5b039331db251424538169d8495c18d1", ], @@ -552,7 +528,6 @@ exports[`config-metadata generateConnectionsConfig supports using block send and "confirmations": 1n, "optionalDVNThreshold": 0, "optionalDVNs": [], - "requiredDVNCount": 1, "requiredDVNs": [ "0x9f0e79aeb198750f963b6f30b99d87c6ee5a0467", ], @@ -571,7 +546,6 @@ exports[`config-metadata generateConnectionsConfig supports using block send and "confirmations": 1n, "optionalDVNThreshold": 0, "optionalDVNs": [], - "requiredDVNCount": 1, "requiredDVNs": [ "0x9f0e79aeb198750f963b6f30b99d87c6ee5a0467", ], @@ -596,7 +570,6 @@ exports[`config-metadata generateConnectionsConfig supports using block send and "confirmations": 1n, "optionalDVNThreshold": 0, "optionalDVNs": [], - "requiredDVNCount": 1, "requiredDVNs": [ "0x55c175dd5b039331db251424538169d8495c18d1", ], @@ -615,7 +588,6 @@ exports[`config-metadata generateConnectionsConfig supports using block send and "confirmations": 1n, "optionalDVNThreshold": 0, "optionalDVNs": [], - "requiredDVNCount": 1, "requiredDVNs": [ "0x55c175dd5b039331db251424538169d8495c18d1", ], @@ -648,7 +620,6 @@ exports[`config-metadata generateConnectionsConfig uses NIL_DVN_COUNT when no re "0x9f0e79aeb198750f963b6f30b99d87c6ee5a0467", "0xdbec329a5e6d7fb0113eb0a098750d2afd61e9ae", ], - "requiredDVNCount": 255, "requiredDVNs": [], }, }, @@ -668,7 +639,6 @@ exports[`config-metadata generateConnectionsConfig uses NIL_DVN_COUNT when no re "0x9f0e79aeb198750f963b6f30b99d87c6ee5a0467", "0xdbec329a5e6d7fb0113eb0a098750d2afd61e9ae", ], - "requiredDVNCount": 255, "requiredDVNs": [], }, }, @@ -694,7 +664,6 @@ exports[`config-metadata generateConnectionsConfig uses NIL_DVN_COUNT when no re "29EKzmCscUg8mf4f5uskwMqvu2SXM8hKF1gWi1cCBoKT", "4VDjp6XQaxoZf5RGwiPU9NR1EXSZn2TP4ATMmiSzLfhb", ], - "requiredDVNCount": 255, "requiredDVNs": [], }, }, @@ -714,7 +683,6 @@ exports[`config-metadata generateConnectionsConfig uses NIL_DVN_COUNT when no re "29EKzmCscUg8mf4f5uskwMqvu2SXM8hKF1gWi1cCBoKT", "4VDjp6XQaxoZf5RGwiPU9NR1EXSZn2TP4ATMmiSzLfhb", ], - "requiredDVNCount": 255, "requiredDVNs": [], }, }, @@ -742,7 +710,6 @@ exports[`config-metadata translatePathwayToConfig should be able to translate a "confirmations": 1n, "optionalDVNThreshold": 0, "optionalDVNs": [], - "requiredDVNCount": 1, "requiredDVNs": [ "0x9f0e79aeb198750f963b6f30b99d87c6ee5a0467", ], @@ -761,7 +728,6 @@ exports[`config-metadata translatePathwayToConfig should be able to translate a "confirmations": 1n, "optionalDVNThreshold": 0, "optionalDVNs": [], - "requiredDVNCount": 1, "requiredDVNs": [ "0x9f0e79aeb198750f963b6f30b99d87c6ee5a0467", ], @@ -786,7 +752,6 @@ exports[`config-metadata translatePathwayToConfig should be able to translate a "confirmations": 1n, "optionalDVNThreshold": 0, "optionalDVNs": [], - "requiredDVNCount": 1, "requiredDVNs": [ "4VDjp6XQaxoZf5RGwiPU9NR1EXSZn2TP4ATMmiSzLfhb", ], @@ -805,7 +770,6 @@ exports[`config-metadata translatePathwayToConfig should be able to translate a "confirmations": 1n, "optionalDVNThreshold": 0, "optionalDVNs": [], - "requiredDVNCount": 1, "requiredDVNs": [ "4VDjp6XQaxoZf5RGwiPU9NR1EXSZn2TP4ATMmiSzLfhb", ], diff --git a/packages/metadata-tools/test/config-metadata.test.ts b/packages/metadata-tools/test/config-metadata.test.ts index 1d1f53d626..b45d98a429 100644 --- a/packages/metadata-tools/test/config-metadata.test.ts +++ b/packages/metadata-tools/test/config-metadata.test.ts @@ -10,7 +10,6 @@ import { MSG_LIB_BLOCK_SEND_AND_RECEIVE, MSG_LIB_BLOCK_SEND_ONLY, NIL_CONFIRMATIONS, - NIL_DVN_COUNT, } from '@/constants' import fujiMetadata from './data/fuji.json' @@ -305,13 +304,15 @@ describe('config-metadata', () => { const config = await generateConnectionsConfig(pathways) expect(config).toMatchSnapshot() - expect(config[0]?.config?.sendConfig?.ulnConfig?.requiredDVNCount).toBe(NIL_DVN_COUNT) + // An empty requiredDVNs array pins "no required DVNs" via the NIL sentinel at + // serialize time (the count is no longer carried on the user config). + expect(config[0]?.config?.sendConfig?.ulnConfig?.requiredDVNs).toEqual([]) expect(config[0]?.config?.sendConfig?.ulnConfig?.optionalDVNThreshold).toBe(1) - expect(config[0]?.config?.receiveConfig?.ulnConfig?.requiredDVNCount).toBe(NIL_DVN_COUNT) + expect(config[0]?.config?.receiveConfig?.ulnConfig?.requiredDVNs).toEqual([]) expect(config[0]?.config?.receiveConfig?.ulnConfig?.optionalDVNThreshold).toBe(1) - expect(config[1]?.config?.sendConfig?.ulnConfig?.requiredDVNCount).toBe(NIL_DVN_COUNT) + expect(config[1]?.config?.sendConfig?.ulnConfig?.requiredDVNs).toEqual([]) expect(config[1]?.config?.sendConfig?.ulnConfig?.optionalDVNThreshold).toBe(1) - expect(config[1]?.config?.receiveConfig?.ulnConfig?.requiredDVNCount).toBe(NIL_DVN_COUNT) + expect(config[1]?.config?.receiveConfig?.ulnConfig?.requiredDVNs).toEqual([]) expect(config[1]?.config?.receiveConfig?.ulnConfig?.optionalDVNThreshold).toBe(1) }) diff --git a/packages/protocol-devtools-evm/src/uln302/sdk.ts b/packages/protocol-devtools-evm/src/uln302/sdk.ts index 1637b97587..146e0e4236 100644 --- a/packages/protocol-devtools-evm/src/uln302/sdk.ts +++ b/packages/protocol-devtools-evm/src/uln302/sdk.ts @@ -7,8 +7,7 @@ import { type Uln302UlnUserConfig, NIL_DVN_COUNT, resolveConfirmations, - resolveOptionalDVNCount, - resolveRequiredDVNCount, + resolveDVNCount, } from '@layerzerolabs/protocol-devtools' import { OmniAddress, @@ -256,7 +255,7 @@ export class Uln302 extends OmniSDK implements IUln302 { * @returns {SerializedUln302UlnConfig} */ protected serializeUlnConfig( - { confirmations, requiredDVNs, requiredDVNCount, optionalDVNs, optionalDVNThreshold = 0 }: Uln302UlnUserConfig, + { confirmations, requiredDVNs, optionalDVNs, optionalDVNThreshold = 0 }: Uln302UlnUserConfig, /** * Whether to encode explicitly-empty fields as NIL sentinels. * @@ -271,8 +270,8 @@ export class Uln302 extends OmniSDK implements IUln302 { */ useNilSentinels = true ): SerializedUln302UlnConfig { - const resolvedRequiredDVNCount = resolveRequiredDVNCount(requiredDVNs, requiredDVNCount, useNilSentinels) - const resolvedOptionalDVNCount = resolveOptionalDVNCount(optionalDVNs, useNilSentinels) + const resolvedRequiredDVNCount = resolveDVNCount(requiredDVNs, useNilSentinels) + const resolvedOptionalDVNCount = resolveDVNCount(optionalDVNs, useNilSentinels) // The contract requires the threshold to be 0 unless there are concrete optional DVNs. const hasConcreteOptionalDVNs = resolvedOptionalDVNCount !== 0 && resolvedOptionalDVNCount !== NIL_DVN_COUNT @@ -280,7 +279,7 @@ export class Uln302 extends OmniSDK implements IUln302 { return { confirmations: resolveConfirmations(confirmations, useNilSentinels), optionalDVNThreshold: hasConcreteOptionalDVNs ? optionalDVNThreshold : 0, - requiredDVNs: requiredDVNs.map(addChecksum).sort(compareBytes32Ascending), + requiredDVNs: (requiredDVNs ?? []).map(addChecksum).sort(compareBytes32Ascending), optionalDVNs: (optionalDVNs ?? []).map(addChecksum).sort(compareBytes32Ascending), requiredDVNCount: resolvedRequiredDVNCount, optionalDVNCount: resolvedOptionalDVNCount, diff --git a/packages/protocol-devtools-evm/src/ulnRead/sdk.ts b/packages/protocol-devtools-evm/src/ulnRead/sdk.ts index 4a1c2a6f37..08962d1526 100644 --- a/packages/protocol-devtools-evm/src/ulnRead/sdk.ts +++ b/packages/protocol-devtools-evm/src/ulnRead/sdk.ts @@ -1,10 +1,5 @@ import type { IUlnRead, UlnReadUlnConfig, UlnReadUlnUserConfig } from '@layerzerolabs/protocol-devtools' -import { - NIL_DVN_COUNT, - UlnReadUlnConfigSchema, - resolveOptionalDVNCount, - resolveRequiredDVNCount, -} from '@layerzerolabs/protocol-devtools' +import { NIL_DVN_COUNT, UlnReadUlnConfigSchema, resolveDVNCount } from '@layerzerolabs/protocol-devtools' import { OmniAddress, type OmniTransaction, @@ -126,13 +121,7 @@ export class UlnRead extends OmniSDK implements IUlnRead { * @returns {SerializedUlnReadUlnConfig} */ protected serializeUlnConfig( - { - requiredDVNs, - requiredDVNCount, - optionalDVNs, - optionalDVNThreshold = 0, - executor = makeZeroAddress(), - }: UlnReadUlnUserConfig, + { 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 @@ -140,8 +129,8 @@ export class UlnRead extends OmniSDK implements IUlnRead { */ useNilSentinels = true ): SerializedUlnReadUlnConfig { - const resolvedRequiredDVNCount = resolveRequiredDVNCount(requiredDVNs, requiredDVNCount, useNilSentinels) - const resolvedOptionalDVNCount = resolveOptionalDVNCount(optionalDVNs, useNilSentinels) + const resolvedRequiredDVNCount = resolveDVNCount(requiredDVNs, useNilSentinels) + const resolvedOptionalDVNCount = resolveDVNCount(optionalDVNs, useNilSentinels) const hasConcreteOptionalDVNs = resolvedOptionalDVNCount !== 0 && resolvedOptionalDVNCount !== NIL_DVN_COUNT @@ -150,7 +139,7 @@ export class UlnRead extends OmniSDK implements IUlnRead { requiredDVNCount: resolvedRequiredDVNCount, optionalDVNCount: resolvedOptionalDVNCount, optionalDVNThreshold: hasConcreteOptionalDVNs ? optionalDVNThreshold : 0, - requiredDVNs: requiredDVNs.map(addChecksum).sort(compareBytes32Ascending), + requiredDVNs: (requiredDVNs ?? []).map(addChecksum).sort(compareBytes32Ascending), optionalDVNs: (optionalDVNs ?? []).map(addChecksum).sort(compareBytes32Ascending), } } diff --git a/packages/protocol-devtools-evm/test/integration/nil-dvn-consistency.test.ts b/packages/protocol-devtools-evm/test/integration/nil-dvn-consistency.test.ts index eca0b38261..b2f0dc607c 100644 --- a/packages/protocol-devtools-evm/test/integration/nil-dvn-consistency.test.ts +++ b/packages/protocol-devtools-evm/test/integration/nil-dvn-consistency.test.ts @@ -96,7 +96,7 @@ describe('NIL_DVN_COUNT consistency across SDKs', () => { }) describe('Edge cases', () => { - it('should distinguish between empty array (255) and explicit zero override', () => { + it('should distinguish between empty array (255) and omitted (inherit, 0)', () => { const uln302Sdk = new Uln302(provider, { eid: MainnetV2EndpointId.ETHEREUM_V2_MAINNET, address: makeZeroAddress(), @@ -112,16 +112,14 @@ describe('NIL_DVN_COUNT consistency across SDKs', () => { const serializedEmpty = (uln302Sdk as any).serializeUlnConfig(emptyConfig) expect(serializedEmpty.requiredDVNCount).toBe(NIL_DVN_COUNT) - // But explicit 0 override should be honored - const explicitZeroConfig: Uln302UlnUserConfig = { - requiredDVNs: [], - requiredDVNCount: 0, // Explicit override to use chain defaults + // But omitting requiredDVNs inherits the chain default (count 0) + const inheritConfig: Uln302UlnUserConfig = { optionalDVNs: [], optionalDVNThreshold: 0, confirmations: BigInt(0), } - const serializedZero = (uln302Sdk as any).serializeUlnConfig(explicitZeroConfig) - expect(serializedZero.requiredDVNCount).toBe(0) + const serializedInherit = (uln302Sdk as any).serializeUlnConfig(inheritConfig) + expect(serializedInherit.requiredDVNCount).toBe(0) }) }) }) diff --git a/packages/protocol-devtools-evm/test/uln302/nil-dvn-count.test.ts b/packages/protocol-devtools-evm/test/uln302/nil-dvn-count.test.ts index 11dffa3976..45c57c6f6c 100644 --- a/packages/protocol-devtools-evm/test/uln302/nil-dvn-count.test.ts +++ b/packages/protocol-devtools-evm/test/uln302/nil-dvn-count.test.ts @@ -48,10 +48,9 @@ describe('uln302/nil-dvn-count', () => { expect(serialized.optionalDVNCount).toBe(1) }) - it('should handle requiredDVNCount override correctly', () => { + it('should inherit the default (count 0) when requiredDVNs is omitted', () => { const config: Uln302UlnUserConfig = { - requiredDVNs: [], - requiredDVNCount: 100, // Explicit override + // requiredDVNs omitted → inherit the on-chain default, exactly like optionalDVNs optionalDVNs: [], optionalDVNThreshold: 0, confirmations: BigInt(0), @@ -60,7 +59,7 @@ describe('uln302/nil-dvn-count', () => { // Use reflection to access the protected method const serialized = (ulnSdk as any).serializeUlnConfig(config) - expect(serialized.requiredDVNCount).toBe(100) // Should use the override + expect(serialized.requiredDVNCount).toBe(0) }) }) }) diff --git a/packages/protocol-devtools-evm/test/ulnRead/nil-sentinels.test.ts b/packages/protocol-devtools-evm/test/ulnRead/nil-sentinels.test.ts index 67a7104cdf..fa545456e7 100644 --- a/packages/protocol-devtools-evm/test/ulnRead/nil-sentinels.test.ts +++ b/packages/protocol-devtools-evm/test/ulnRead/nil-sentinels.test.ts @@ -36,12 +36,12 @@ describe('ulnRead/nil-sentinels', () => { const serialize = (config: UlnReadUlnUserConfig, useNilSentinels?: boolean) => (ulnSdk as any).serializeUlnConfig(config, useNilSentinels) - it('maps an explicitly-empty requiredDVNs to NIL_DVN_COUNT (pin "no required DVNs")', () => { - expect(serialize({ requiredDVNs: [] }).requiredDVNCount).toBe(NIL_DVN_COUNT) + it('maps omitted requiredDVNs to count 0 (inherit the on-chain default)', () => { + expect(serialize({}).requiredDVNCount).toBe(0) }) - it('honors a requiredDVNCount: 0 override (inherit the on-chain default)', () => { - expect(serialize({ requiredDVNs: [], requiredDVNCount: 0 }).requiredDVNCount).toBe(0) + it('maps an explicitly-empty requiredDVNs to NIL_DVN_COUNT (pin "no required DVNs")', () => { + expect(serialize({ requiredDVNs: [] }).requiredDVNCount).toBe(NIL_DVN_COUNT) }) it('derives the count from a concrete requiredDVNs array', () => { @@ -95,10 +95,8 @@ describe('ulnRead/nil-sentinels', () => { ).resolves.toBe(false) }) - it('treats an inherited requiredDVNs (count 0 + override) as matching a chain with requiredDVNCount 0', async () => { - await expect( - hasConfig(read({ requiredDVNs: [], requiredDVNCount: 0 }), { requiredDVNs: [], requiredDVNCount: 0 }) - ).resolves.toBe(true) + it('treats omitted requiredDVNs as matching a chain with requiredDVNCount 0', async () => { + await expect(hasConfig(read({ requiredDVNs: [], requiredDVNCount: 0 }), {})).resolves.toBe(true) }) it('treats an explicitly-empty requiredDVNs as matching a chain that stored NIL', async () => { @@ -108,11 +106,13 @@ describe('ulnRead/nil-sentinels', () => { }) it('does NOT flip an inherited requiredDVNs (chain count 0) to pinned-none', async () => { - // Regression: the desired config inherits (override 0), so wiring must NOT emit a - // setConfig that pins requiredDVNCount to NIL. + // Regression: an explicitly-empty requiredDVNs (pin-none → NIL) must read as DIFFERENT + // from a chain that inherits (count 0), so wiring emits the pin; but an OMITTED + // requiredDVNs (inherit) must match count 0 and NOT emit a flip. await expect( - hasConfig(read({ requiredDVNs: [], requiredDVNCount: 0 }), { requiredDVNs: [], requiredDVNCount: 0 }) - ).resolves.toBe(true) + hasConfig(read({ requiredDVNs: [], requiredDVNCount: 0 }), { requiredDVNs: [] }) + ).resolves.toBe(false) + await expect(hasConfig(read({ requiredDVNs: [], requiredDVNCount: 0 }), {})).resolves.toBe(true) }) }) }) diff --git a/packages/protocol-devtools-solana/src/uln302/sdk.ts b/packages/protocol-devtools-solana/src/uln302/sdk.ts index 51daa8dbb9..488bb6f2ce 100644 --- a/packages/protocol-devtools-solana/src/uln302/sdk.ts +++ b/packages/protocol-devtools-solana/src/uln302/sdk.ts @@ -7,8 +7,7 @@ import { Uln302UlnConfig, Uln302UlnUserConfig, resolveConfirmations, - resolveOptionalDVNCount, - resolveRequiredDVNCount, + resolveDVNCount, } from '@layerzerolabs/protocol-devtools' import { OmniAddress, @@ -208,7 +207,7 @@ export class Uln302 extends OmniSDK implements IUln302 { * @returns {SerializedUln302UlnConfig} */ protected serializeUlnConfig( - { confirmations, requiredDVNs, requiredDVNCount, optionalDVNs, optionalDVNThreshold = 0 }: Uln302UlnUserConfig, + { confirmations, requiredDVNs, optionalDVNs, optionalDVNThreshold = 0 }: Uln302UlnUserConfig, /** * Whether to encode explicitly-empty fields as NIL sentinels. * @@ -220,8 +219,8 @@ export class Uln302 extends OmniSDK implements IUln302 { */ useNilSentinels = true ): SerializedUln302UlnConfig { - const resolvedRequiredDVNCount = resolveRequiredDVNCount(requiredDVNs, requiredDVNCount, useNilSentinels) - const resolvedOptionalDVNCount = resolveOptionalDVNCount(optionalDVNs, useNilSentinels) + const resolvedRequiredDVNCount = resolveDVNCount(requiredDVNs, useNilSentinels) + const resolvedOptionalDVNCount = resolveDVNCount(optionalDVNs, useNilSentinels) // The threshold must be 0 unless there are concrete optional DVNs. const hasConcreteOptionalDVNs = resolvedOptionalDVNCount !== 0 && resolvedOptionalDVNCount !== NIL_DVN_COUNT @@ -229,7 +228,7 @@ export class Uln302 extends OmniSDK implements IUln302 { return { confirmations: resolveConfirmations(confirmations, useNilSentinels), optionalDVNThreshold: hasConcreteOptionalDVNs ? optionalDVNThreshold : 0, - requiredDVNs: serializeDVNs(requiredDVNs), + requiredDVNs: serializeDVNs(requiredDVNs ?? []), optionalDVNs: serializeDVNs(optionalDVNs ?? []), requiredDVNCount: resolvedRequiredDVNCount, optionalDVNCount: resolvedOptionalDVNCount, diff --git a/packages/protocol-devtools/src/uln302/resolve.ts b/packages/protocol-devtools/src/uln302/resolve.ts index a1595e03df..36103ef90c 100644 --- a/packages/protocol-devtools/src/uln302/resolve.ts +++ b/packages/protocol-devtools/src/uln302/resolve.ts @@ -12,27 +12,13 @@ import { NIL_CONFIRMATIONS, NIL_DVN_COUNT } from './constants' */ /** - * `requiredDVNs` is mandatory on the user config, so an empty array is the only "none" signal. - * An explicit `requiredDVNCount` override always wins (e.g. `0` forces the inherit case); - * otherwise the count is the array length, or the NIL sentinel for an empty array under - * `useNilSentinels`. + * Resolves a DVN count from a user-config DVN array. Both `requiredDVNs` and `optionalDVNs` + * are optional, so we distinguish omitted (`undefined` → inherit the on-chain default, count + * `0`) from explicitly empty (`[]` → pin "no DVNs" via the NIL sentinel under `useNilSentinels`). + * A concrete array resolves to its length. */ -export const resolveRequiredDVNCount = ( - requiredDVNs: readonly string[], - requiredDVNCount: number | undefined, - useNilSentinels: boolean -): number => requiredDVNCount ?? (requiredDVNs.length > 0 ? requiredDVNs.length : useNilSentinels ? NIL_DVN_COUNT : 0) - -/** - * `optionalDVNs` is optional, so we distinguish omitted (`undefined` → inherit the on-chain - * default, count `0`) from explicitly empty (`[]` → pin "no optional DVNs" via the NIL sentinel - * under `useNilSentinels`). - */ -export const resolveOptionalDVNCount = ( - optionalDVNs: readonly string[] | null | undefined, - useNilSentinels: boolean -): number => - optionalDVNs == null ? 0 : optionalDVNs.length > 0 ? optionalDVNs.length : useNilSentinels ? NIL_DVN_COUNT : 0 +export const resolveDVNCount = (dvns: readonly string[] | null | undefined, useNilSentinels: boolean): number => + dvns == null ? 0 : dvns.length > 0 ? dvns.length : useNilSentinels ? NIL_DVN_COUNT : 0 /** * An omitted `confirmations` inherits the on-chain default (`0`); an explicit `0n` pins "zero diff --git a/packages/protocol-devtools/src/uln302/schema.ts b/packages/protocol-devtools/src/uln302/schema.ts index a3ecf25ebb..5959c975b6 100644 --- a/packages/protocol-devtools/src/uln302/schema.ts +++ b/packages/protocol-devtools/src/uln302/schema.ts @@ -18,8 +18,7 @@ export const Uln302UlnConfigSchema = z.object({ export const Uln302UlnUserConfigSchema = z.object({ confirmations: UIntBigIntSchema.optional(), - requiredDVNs: z.array(AddressSchema), - requiredDVNCount: UIntNumberSchema.optional(), + requiredDVNs: z.array(AddressSchema).optional(), optionalDVNs: z.array(AddressSchema).optional(), optionalDVNThreshold: UIntNumberSchema.optional(), }) satisfies z.ZodSchema diff --git a/packages/protocol-devtools/src/uln302/types.ts b/packages/protocol-devtools/src/uln302/types.ts index 4f305a6e97..524a6e29b6 100644 --- a/packages/protocol-devtools/src/uln302/types.ts +++ b/packages/protocol-devtools/src/uln302/types.ts @@ -131,8 +131,11 @@ export interface Uln302UlnConfig { export interface Uln302UlnUserConfig { confirmations?: bigint optionalDVNThreshold?: number - requiredDVNs: string[] - requiredDVNCount?: number + /** + * Omitted → inherit the on-chain default; `[]` → pin "no required DVNs" via the NIL + * sentinel; a concrete array pins those DVNs. Mirrors {@link optionalDVNs}. + */ + requiredDVNs?: string[] optionalDVNs?: string[] } diff --git a/packages/protocol-devtools/src/ulnRead/schema.ts b/packages/protocol-devtools/src/ulnRead/schema.ts index dff06059d0..13e7adf08c 100644 --- a/packages/protocol-devtools/src/ulnRead/schema.ts +++ b/packages/protocol-devtools/src/ulnRead/schema.ts @@ -13,8 +13,7 @@ export const UlnReadUlnConfigSchema = z.object({ export const UlnReadUlnUserConfigSchema = z.object({ executor: AddressSchema.optional(), - requiredDVNs: z.array(AddressSchema), - requiredDVNCount: UIntNumberSchema.optional(), + requiredDVNs: z.array(AddressSchema).optional(), optionalDVNs: z.array(AddressSchema).optional(), optionalDVNThreshold: UIntNumberSchema.optional(), }) satisfies z.ZodSchema diff --git a/packages/protocol-devtools/src/ulnRead/types.ts b/packages/protocol-devtools/src/ulnRead/types.ts index 27869a3c7a..471b87eee3 100644 --- a/packages/protocol-devtools/src/ulnRead/types.ts +++ b/packages/protocol-devtools/src/ulnRead/types.ts @@ -66,16 +66,11 @@ export interface UlnReadUlnConfig { export interface UlnReadUlnUserConfig { executor?: string optionalDVNThreshold?: number - requiredDVNs: string[] /** - * Explicit override for the required DVN count. - * - * `requiredDVNs` is mandatory, so an empty array is ambiguous between "inherit the - * on-chain default" and "pin no required DVNs". Setting this to `0` forces the inherit - * case (the serializer will NOT derive the NIL sentinel from the empty array); leaving - * it omitted lets the count be derived from the array. + * Omitted → inherit the on-chain default; `[]` → pin "no required DVNs" via the NIL + * sentinel; a concrete array pins those DVNs. Mirrors {@link optionalDVNs}. */ - requiredDVNCount?: number + requiredDVNs?: string[] optionalDVNs?: string[] } diff --git a/packages/ua-devtools-evm-hardhat/src/oapp-read/typescript/typescript.ts b/packages/ua-devtools-evm-hardhat/src/oapp-read/typescript/typescript.ts index 9d62ef79b1..3067922ea2 100644 --- a/packages/ua-devtools-evm-hardhat/src/oapp-read/typescript/typescript.ts +++ b/packages/ua-devtools-evm-hardhat/src/oapp-read/typescript/typescript.ts @@ -11,7 +11,6 @@ import { FROM, OPTIONAL_DVN_THRESHOLD, OPTIONAL_DVNS, - REQUIRED_DVN_COUNT, REQUIRED_DVNS, TO, ULN_CONFIG, @@ -164,27 +163,23 @@ export const createReadUlnConfig = ({ factory.createPropertyAssignment(factory.createIdentifier(EXECUTOR), factory.createStringLiteral(executor)), ] - // requiredDVNs is mandatory on the config, so we always emit the array (empty for both the - // inherit and pin-none cases). To disambiguate them we mirror the uln302 generator: - // - count 0 (inherit) → also emit `requiredDVNCount: 0`, which the serializer honors as an - // explicit override so it does NOT derive the NIL sentinel from the empty array. - // - NIL (pin none) → emit just the empty array, which serializes back to NIL. - // - concrete → emit the array; the count is derived from its length. - properties.push( - factory.createPropertyAssignment( - factory.createIdentifier(REQUIRED_DVNS), - factory.createArrayLiteralExpression( - requiredDVNCount === NIL_DVN_COUNT - ? [] - : requiredDVNs.filter((dvn) => dvn != null).map((dvn) => factory.createStringLiteral(dvn)) + // requiredDVNs: count 0 means "inherit the default" so we omit the field; the NIL sentinel + // means "pinned to none", emitted as `[]` so it serializes back to NIL. Only a concrete set + // of required DVNs carries the array. (Mirrors the optionalDVNs handling below.) + if (requiredDVNCount === NIL_DVN_COUNT) { + properties.push( + factory.createPropertyAssignment( + factory.createIdentifier(REQUIRED_DVNS), + factory.createArrayLiteralExpression([]) ) ) - ) - if (requiredDVNCount === 0) { + } else if (requiredDVNCount !== 0) { properties.push( factory.createPropertyAssignment( - factory.createIdentifier(REQUIRED_DVN_COUNT), - factory.createNumericLiteral(0) + factory.createIdentifier(REQUIRED_DVNS), + factory.createArrayLiteralExpression( + requiredDVNs.filter((dvn) => dvn != null).map((dvn) => factory.createStringLiteral(dvn)) + ) ) ) } diff --git a/packages/ua-devtools-evm-hardhat/src/oapp/typescript/constants.ts b/packages/ua-devtools-evm-hardhat/src/oapp/typescript/constants.ts index c3e3a1e36d..4e4e3ebb4a 100644 --- a/packages/ua-devtools-evm-hardhat/src/oapp/typescript/constants.ts +++ b/packages/ua-devtools-evm-hardhat/src/oapp/typescript/constants.ts @@ -18,7 +18,6 @@ export const RECEIVE_CONFIG: string = 'receiveConfig' export const RECEIVE_LIBRARY: string = 'receiveLibrary' export const RECEIVE_LIBRARY_CONFIG: string = 'receiveLibraryConfig' export const REQUIRED_DVNS: string = 'requiredDVNs' -export const REQUIRED_DVN_COUNT: string = 'requiredDVNCount' export const SEND_CONFIG: string = 'sendConfig' export const SEND_LIBRARY: string = 'sendLibrary' export const TO: string = 'to' diff --git a/packages/ua-devtools-evm-hardhat/src/oapp/typescript/typescript.ts b/packages/ua-devtools-evm-hardhat/src/oapp/typescript/typescript.ts index cabf63380b..19bd10a04f 100644 --- a/packages/ua-devtools-evm-hardhat/src/oapp/typescript/typescript.ts +++ b/packages/ua-devtools-evm-hardhat/src/oapp/typescript/typescript.ts @@ -45,7 +45,6 @@ import { RECEIVE_LIBRARY, RECEIVE_LIBRARY_CONFIG, REQUIRED_DVNS, - REQUIRED_DVN_COUNT, SEND_CONFIG, SEND_LIBRARY, TO, @@ -313,27 +312,23 @@ export const creatUlnConfig = ({ ) } - // requiredDVNs is mandatory on the config, so we always emit the array (empty for both the - // inherit and pin-none cases). To disambiguate them we mirror the sibling fields: - // - count 0 (inherit) → also emit `requiredDVNCount: 0`, which the serializer honors as an - // explicit override so it does NOT derive the NIL sentinel from the empty array. - // - NIL (pin none) → emit just the empty array, which serializes back to NIL. - // - concrete → emit the array; the count is derived from its length. - properties.push( - factory.createPropertyAssignment( - factory.createIdentifier(REQUIRED_DVNS), - factory.createArrayLiteralExpression( - requiredDVNCount === NIL_DVN_COUNT - ? [] - : requiredDVNs.filter((dvn) => dvn != null).map((dvn) => factory.createStringLiteral(dvn)) + // requiredDVNs: count 0 means "inherit the default" so we omit the field; the NIL sentinel + // means "pinned to none", emitted as `[]` so it serializes back to NIL. Only a concrete set + // of required DVNs carries the array. (Mirrors the optionalDVNs handling below.) + if (requiredDVNCount === NIL_DVN_COUNT) { + properties.push( + factory.createPropertyAssignment( + factory.createIdentifier(REQUIRED_DVNS), + factory.createArrayLiteralExpression([]) ) ) - ) - if (requiredDVNCount === 0) { + } else if (requiredDVNCount !== 0) { properties.push( factory.createPropertyAssignment( - factory.createIdentifier(REQUIRED_DVN_COUNT), - factory.createNumericLiteral(0) + factory.createIdentifier(REQUIRED_DVNS), + factory.createArrayLiteralExpression( + requiredDVNs.filter((dvn) => dvn != null).map((dvn) => factory.createStringLiteral(dvn)) + ) ) ) } From a108405352f6cdd2b951d8f085e9e667324445bd Mon Sep 17 00:00:00 2001 From: Krak Date: Fri, 26 Jun 2026 16:26:30 -0700 Subject: [PATCH 08/17] fix(lzapp-migration): default scalar ULN fields in encodeUlnConfig The example configs omit optionalDVNThreshold (and could omit confirmations), but encodeUlnConfig passed them raw to uint8/uint64 ABI slots, so ethers threw INVALID_ARGUMENT on undefined. Default both scalars (?? 0), matching the .length guards already applied to the DVN arrays and the SDK serializers' own defaulting. --- examples/lzapp-migration/tasks/common/taskHelper.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/lzapp-migration/tasks/common/taskHelper.ts b/examples/lzapp-migration/tasks/common/taskHelper.ts index 6dbaf58029..b35902e2a3 100644 --- a/examples/lzapp-migration/tasks/common/taskHelper.ts +++ b/examples/lzapp-migration/tasks/common/taskHelper.ts @@ -493,10 +493,10 @@ function encodeUlnConfig(config: Uln302UlnConfig): string { ], [ [ - config.confirmations, + config.confirmations ?? 0, (config.requiredDVNs || []).length, (config.optionalDVNs || []).length, - config.optionalDVNThreshold, + config.optionalDVNThreshold ?? 0, config.requiredDVNs || [], config.optionalDVNs || [], ], From 5c3d31c81edc2a6a8d22db20ca8c15209c02bb2f Mon Sep 17 00:00:00 2001 From: Krak Date: Fri, 26 Jun 2026 16:26:38 -0700 Subject: [PATCH 09/17] docs(metadata-tools): changeset for empty optionalDVNs pinning NIL generateConnectionsConfig now emits an empty optional-DVN set as an explicit 'no optional DVNs' (NIL sentinel) rather than inheriting the on-chain default. Document the intended behavior so it is not a silent cascade bump. --- .changeset/metadata-tools-optional-dvns-nil.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .changeset/metadata-tools-optional-dvns-nil.md diff --git a/.changeset/metadata-tools-optional-dvns-nil.md b/.changeset/metadata-tools-optional-dvns-nil.md new file mode 100644 index 0000000000..504e417dc0 --- /dev/null +++ b/.changeset/metadata-tools-optional-dvns-nil.md @@ -0,0 +1,17 @@ +--- +"@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. + +Required DVNs are unaffected. Re-wiring an existing pathway that relied on the old inherit +behavior will emit a `setConfig` pinning no optional DVNs; the effective security is +unchanged, since optional DVNs with a threshold of 0 add no required verification. From 35785604e22637fdd114e6e258feb706723725de Mon Sep 17 00:00:00 2001 From: Krak Date: Fri, 26 Jun 2026 16:53:10 -0700 Subject: [PATCH 10/17] docs(changeset): reframe NIL semantics around team-controlled config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correct the metadata-tools changeset's inaccurate 'effective security is unchanged' claim: pinning an empty optional-DVN set CAN drop an optional quorum a pathway was inheriting from the default. That is intended — the goal is that a team's security config is exactly what their file says and cannot be changed by a LayerZero-controlled default update. State that motivation in the primary changeset too. --- .changeset/metadata-tools-optional-dvns-nil.md | 10 +++++++--- .changeset/uln-nil-sentinels.md | 5 +++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.changeset/metadata-tools-optional-dvns-nil.md b/.changeset/metadata-tools-optional-dvns-nil.md index 504e417dc0..10265aa370 100644 --- a/.changeset/metadata-tools-optional-dvns-nil.md +++ b/.changeset/metadata-tools-optional-dvns-nil.md @@ -12,6 +12,10 @@ to the chain default. This is deliberate: the metadata config is the primary way is consumed, and an empty optional-DVN set should be visible in the file rather than silently inheriting the default. -Required DVNs are unaffected. Re-wiring an existing pathway that relied on the old inherit -behavior will emit a `setConfig` pinning no optional DVNs; the effective security is -unchanged, since optional DVNs with a threshold of 0 add no required verification. +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. diff --git a/.changeset/uln-nil-sentinels.md b/.changeset/uln-nil-sentinels.md index a0e750d817..7c4eaa0f31 100644 --- a/.changeset/uln-nil-sentinels.md +++ b/.changeset/uln-nil-sentinels.md @@ -6,6 +6,11 @@ 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: From 8a43cf7d1c91de0490886b59ee7d99045835d965 Mon Sep 17 00:00:00 2001 From: Krak Date: Fri, 26 Jun 2026 17:02:26 -0700 Subject: [PATCH 11/17] fix(protocol-devtools-evm): reject empty requiredDVNs on the default ULN config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit requiredDVNs is now optional on the user config, but the same type backs setDefaultUlnConfig (serializeUlnConfig(config, false)). A default config with no required DVNs serializes to requiredDVNCount 0, which the contract rejects (_assertAtLeastOneDVN) — previously an always-invalid default typechecked and only failed on-chain. Throw early with a clear message on the default path instead. --- packages/protocol-devtools-evm/src/uln302/sdk.ts | 7 +++++++ packages/protocol-devtools-evm/src/ulnRead/sdk.ts | 7 +++++++ .../test/uln302/nil-sentinels.test.ts | 4 ++++ .../test/ulnRead/nil-sentinels.test.ts | 4 ++-- 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/protocol-devtools-evm/src/uln302/sdk.ts b/packages/protocol-devtools-evm/src/uln302/sdk.ts index 146e0e4236..3183dca2a0 100644 --- a/packages/protocol-devtools-evm/src/uln302/sdk.ts +++ b/packages/protocol-devtools-evm/src/uln302/sdk.ts @@ -270,6 +270,13 @@ export class Uln302 extends OmniSDK implements IUln302 { */ useNilSentinels = true ): SerializedUln302UlnConfig { + // The library-wide DEFAULT config (the only `useNilSentinels=false` caller) must pin at + // least one required DVN — the contract reverts on a zero required count there. Catch it + // here with a clear message instead of an opaque on-chain revert. + if (!useNilSentinels && !requiredDVNs?.length) { + throw new Error('Default ULN config must specify at least one required DVN') + } + const resolvedRequiredDVNCount = resolveDVNCount(requiredDVNs, useNilSentinels) const resolvedOptionalDVNCount = resolveDVNCount(optionalDVNs, useNilSentinels) diff --git a/packages/protocol-devtools-evm/src/ulnRead/sdk.ts b/packages/protocol-devtools-evm/src/ulnRead/sdk.ts index 08962d1526..c4dfeee138 100644 --- a/packages/protocol-devtools-evm/src/ulnRead/sdk.ts +++ b/packages/protocol-devtools-evm/src/ulnRead/sdk.ts @@ -129,6 +129,13 @@ export class UlnRead extends OmniSDK implements IUlnRead { */ useNilSentinels = true ): SerializedUlnReadUlnConfig { + // The library-wide DEFAULT config (the only `useNilSentinels=false` caller) must pin at + // least one required DVN — the contract reverts on a zero required count there. Catch it + // here with a clear message instead of an opaque on-chain revert. + if (!useNilSentinels && !requiredDVNs?.length) { + throw new Error('Default ULN config must specify at least one required DVN') + } + const resolvedRequiredDVNCount = resolveDVNCount(requiredDVNs, useNilSentinels) const resolvedOptionalDVNCount = resolveDVNCount(optionalDVNs, useNilSentinels) diff --git a/packages/protocol-devtools-evm/test/uln302/nil-sentinels.test.ts b/packages/protocol-devtools-evm/test/uln302/nil-sentinels.test.ts index b190adc6f6..ceed6c6e81 100644 --- a/packages/protocol-devtools-evm/test/uln302/nil-sentinels.test.ts +++ b/packages/protocol-devtools-evm/test/uln302/nil-sentinels.test.ts @@ -36,6 +36,10 @@ describe('uln302/nil-sentinels', () => { it('keeps an explicit zero literal for the DEFAULT config (no NIL mapping)', () => { expect(serialize({ requiredDVNs: [DVN], confirmations: BigInt(0) }, false).confirmations).toBe(BigInt(0)) }) + + it('rejects an empty requiredDVNs on the DEFAULT config (the contract needs at least one)', () => { + expect(() => serialize({ requiredDVNs: [] }, false)).toThrow('at least one required DVN') + }) }) describe('serializeUlnConfig optionalDVNs', () => { diff --git a/packages/protocol-devtools-evm/test/ulnRead/nil-sentinels.test.ts b/packages/protocol-devtools-evm/test/ulnRead/nil-sentinels.test.ts index fa545456e7..503b88eb2a 100644 --- a/packages/protocol-devtools-evm/test/ulnRead/nil-sentinels.test.ts +++ b/packages/protocol-devtools-evm/test/ulnRead/nil-sentinels.test.ts @@ -48,8 +48,8 @@ describe('ulnRead/nil-sentinels', () => { expect(serialize({ requiredDVNs: [DVN] }).requiredDVNCount).toBe(1) }) - it('keeps an explicitly-empty requiredDVNs literal (count 0) for the DEFAULT config', () => { - expect(serialize({ requiredDVNs: [] }, false).requiredDVNCount).toBe(0) + it('rejects an empty requiredDVNs on the DEFAULT config (the contract needs at least one)', () => { + expect(() => serialize({ requiredDVNs: [] }, false)).toThrow('at least one required DVN') }) }) From 2efdd216f168afc29f5bd19d5be847b8e14806ec Mon Sep 17 00:00:00 2001 From: Krak Date: Fri, 26 Jun 2026 17:02:31 -0700 Subject: [PATCH 12/17] test(ua-devtools-evm-hardhat): cover the ULN config generators creatUlnConfig/createReadUlnConfig are the count->array inverse of the serializer (inherit->omit, NIL->[], concrete->array) and had no coverage; a flipped branch would silently swap inherit<->pinned on regeneration. Add AST-print tests over the three-state matrix for both generators. --- .../test/oapp-read/typescript.test.ts | 61 ++++++++++++++++ .../test/oapp/typescript.test.ts | 69 +++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 packages/ua-devtools-evm-hardhat/test/oapp-read/typescript.test.ts create mode 100644 packages/ua-devtools-evm-hardhat/test/oapp/typescript.test.ts diff --git a/packages/ua-devtools-evm-hardhat/test/oapp-read/typescript.test.ts b/packages/ua-devtools-evm-hardhat/test/oapp-read/typescript.test.ts new file mode 100644 index 0000000000..225c22af6e --- /dev/null +++ b/packages/ua-devtools-evm-hardhat/test/oapp-read/typescript.test.ts @@ -0,0 +1,61 @@ +import { createPrinter, createSourceFile, EmitHint, Node, ScriptTarget } from 'typescript' +import { createReadUlnConfig } from '@/oapp-read/typescript/typescript' +import { NIL_DVN_COUNT, UlnReadUlnConfig } from '@layerzerolabs/protocol-devtools' + +const EXECUTOR = '0x0000000000000000000000000000000000000009' +const DVN = '0x0000000000000000000000000000000000000001' +const DVN2 = '0x0000000000000000000000000000000000000002' + +const print = (node: Node): string => + createPrinter() + .printNode(EmitHint.Unspecified, node, createSourceFile('test.ts', '', ScriptTarget.Latest)) + .replace(/\s+/g, ' ') + +// Chain-shaped read config. Defaults to the all-inherit state (counts 0) so each test +// overrides only the field(s) under test. +const readConfig = (over: Partial = {}): UlnReadUlnConfig => ({ + executor: EXECUTOR, + requiredDVNs: [], + requiredDVNCount: 0, + optionalDVNs: [], + optionalDVNCount: 0, + optionalDVNThreshold: 0, + ...over, +}) + +describe('oapp-read/typescript createReadUlnConfig', () => { + it('emits only executor when both DVN sets inherit the default (count 0)', () => { + const out = print(createReadUlnConfig(readConfig())) + + expect(out).toContain('executor') + expect(out).not.toContain('requiredDVNs') + expect(out).not.toContain('optionalDVNs') + }) + + it('emits the pin-none sentinels back as `[]` so they re-serialize to NIL', () => { + const out = print( + createReadUlnConfig(readConfig({ requiredDVNCount: NIL_DVN_COUNT, optionalDVNCount: NIL_DVN_COUNT })) + ) + + expect(out).toMatch(/requiredDVNs: \[\s*\]/) + expect(out).toMatch(/optionalDVNs: \[\s*\]/) + }) + + it('emits concrete arrays and the optional threshold unchanged', () => { + const out = print( + createReadUlnConfig( + readConfig({ + requiredDVNs: [DVN], + requiredDVNCount: 1, + optionalDVNs: [DVN2], + optionalDVNCount: 1, + optionalDVNThreshold: 2, + }) + ) + ) + + expect(out).toContain(DVN) + expect(out).toContain(DVN2) + expect(out).toContain('optionalDVNThreshold: 2') + }) +}) diff --git a/packages/ua-devtools-evm-hardhat/test/oapp/typescript.test.ts b/packages/ua-devtools-evm-hardhat/test/oapp/typescript.test.ts new file mode 100644 index 0000000000..2036530c5c --- /dev/null +++ b/packages/ua-devtools-evm-hardhat/test/oapp/typescript.test.ts @@ -0,0 +1,69 @@ +import { createPrinter, createSourceFile, EmitHint, Node, ScriptTarget } from 'typescript' +import { creatUlnConfig } from '@/oapp/typescript/typescript' +import { NIL_CONFIRMATIONS, NIL_DVN_COUNT, Uln302UlnConfig } from '@layerzerolabs/protocol-devtools' + +const DVN = '0x0000000000000000000000000000000000000001' +const DVN2 = '0x0000000000000000000000000000000000000002' + +const print = (node: Node): string => + createPrinter() + .printNode(EmitHint.Unspecified, node, createSourceFile('test.ts', '', ScriptTarget.Latest)) + .replace(/\s+/g, ' ') + +// Chain-shaped config (what the generator reads). Defaults to the all-inherit state +// (counts 0, confirmations 0) so each test overrides only the field(s) under test. +const chainConfig = (over: Partial = {}): Uln302UlnConfig => ({ + confirmations: BigInt(0), + requiredDVNs: [], + requiredDVNCount: 0, + optionalDVNs: [], + optionalDVNCount: 0, + optionalDVNThreshold: 0, + ...over, +}) + +describe('oapp/typescript creatUlnConfig', () => { + it('omits every field that inherits the on-chain default (count 0, confirmations 0)', () => { + const out = print(creatUlnConfig(chainConfig())) + + expect(out).not.toContain('confirmations') + expect(out).not.toContain('requiredDVNs') + expect(out).not.toContain('optionalDVNs') + }) + + it('emits the pin-none sentinels back as `[]` / `0n` so they re-serialize to NIL', () => { + const out = print( + creatUlnConfig( + chainConfig({ + confirmations: NIL_CONFIRMATIONS, + requiredDVNCount: NIL_DVN_COUNT, + optionalDVNCount: NIL_DVN_COUNT, + }) + ) + ) + + expect(out).toContain('confirmations: 0') + expect(out).toMatch(/requiredDVNs: \[\s*\]/) + expect(out).toMatch(/optionalDVNs: \[\s*\]/) + }) + + it('emits concrete values and arrays unchanged', () => { + const out = print( + creatUlnConfig( + chainConfig({ + confirmations: BigInt(15), + requiredDVNs: [DVN], + requiredDVNCount: 1, + optionalDVNs: [DVN2], + optionalDVNCount: 1, + optionalDVNThreshold: 1, + }) + ) + ) + + expect(out).toContain('confirmations: 15') + expect(out).toContain(DVN) + expect(out).toContain(DVN2) + expect(out).toContain('optionalDVNThreshold: 1') + }) +}) From 05efccdb459c9155b6628ede26561b23642e64e4 Mon Sep 17 00:00:00 2001 From: Krak Date: Fri, 26 Jun 2026 17:35:44 -0700 Subject: [PATCH 13/17] fix(protocol-devtools-evm): allow optional-only default ULN config The default-config guard added in 8a43cf7d was stricter than the contract: it rejected any default without required DVNs, but _assertAtLeastOneDVN only reverts when requiredDVNCount == 0 AND optionalDVNThreshold == 0. Run the guard after resolution and check the CLAMPED threshold (a threshold with no concrete optional DVNs clamps to 0, so it is not a real quorum). An optional-only default is now allowed; an empty/threshold-only one still throws early. --- .../protocol-devtools-evm/src/uln302/sdk.ts | 21 ++++++++------ .../protocol-devtools-evm/src/ulnRead/sdk.ts | 21 ++++++++------ .../test/uln302/nil-sentinels.test.ts | 28 ++++++++++++++++--- .../test/ulnRead/nil-sentinels.test.ts | 28 ++++++++++++++++--- 4 files changed, 74 insertions(+), 24 deletions(-) diff --git a/packages/protocol-devtools-evm/src/uln302/sdk.ts b/packages/protocol-devtools-evm/src/uln302/sdk.ts index 3183dca2a0..ff5513acfc 100644 --- a/packages/protocol-devtools-evm/src/uln302/sdk.ts +++ b/packages/protocol-devtools-evm/src/uln302/sdk.ts @@ -270,22 +270,27 @@ export class Uln302 extends OmniSDK implements IUln302 { */ useNilSentinels = true ): SerializedUln302UlnConfig { - // The library-wide DEFAULT config (the only `useNilSentinels=false` caller) must pin at - // least one required DVN — the contract reverts on a zero required count there. Catch it - // here with a clear message instead of an opaque on-chain revert. - if (!useNilSentinels && !requiredDVNs?.length) { - throw new Error('Default ULN config must specify at least one required DVN') - } - const resolvedRequiredDVNCount = resolveDVNCount(requiredDVNs, useNilSentinels) const resolvedOptionalDVNCount = resolveDVNCount(optionalDVNs, useNilSentinels) // The contract requires the threshold to be 0 unless there are concrete optional DVNs. const hasConcreteOptionalDVNs = resolvedOptionalDVNCount !== 0 && resolvedOptionalDVNCount !== NIL_DVN_COUNT + const optionalDVNThresholdResolved = hasConcreteOptionalDVNs ? optionalDVNThreshold : 0 + + // The library-wide DEFAULT config (the only `useNilSentinels=false` caller) must resolve to + // at least one DVN or the contract reverts (`_assertAtLeastOneDVN`: requiredDVNCount 0 AND + // optionalDVNThreshold 0). Mirror that against the RESOLVED values so an optional-only + // default is allowed — but only with concrete optional DVNs, since a threshold without them + // is clamped to 0 above. Catch it here instead of via an opaque on-chain revert. + if (!useNilSentinels && resolvedRequiredDVNCount === 0 && optionalDVNThresholdResolved === 0) { + throw new Error( + 'Default ULN config must specify at least one DVN (a required DVN, or optional DVNs with a threshold)' + ) + } return { confirmations: resolveConfirmations(confirmations, useNilSentinels), - optionalDVNThreshold: hasConcreteOptionalDVNs ? optionalDVNThreshold : 0, + optionalDVNThreshold: optionalDVNThresholdResolved, requiredDVNs: (requiredDVNs ?? []).map(addChecksum).sort(compareBytes32Ascending), optionalDVNs: (optionalDVNs ?? []).map(addChecksum).sort(compareBytes32Ascending), requiredDVNCount: resolvedRequiredDVNCount, diff --git a/packages/protocol-devtools-evm/src/ulnRead/sdk.ts b/packages/protocol-devtools-evm/src/ulnRead/sdk.ts index c4dfeee138..464f341156 100644 --- a/packages/protocol-devtools-evm/src/ulnRead/sdk.ts +++ b/packages/protocol-devtools-evm/src/ulnRead/sdk.ts @@ -129,23 +129,28 @@ export class UlnRead extends OmniSDK implements IUlnRead { */ useNilSentinels = true ): SerializedUlnReadUlnConfig { - // The library-wide DEFAULT config (the only `useNilSentinels=false` caller) must pin at - // least one required DVN — the contract reverts on a zero required count there. Catch it - // here with a clear message instead of an opaque on-chain revert. - if (!useNilSentinels && !requiredDVNs?.length) { - throw new Error('Default ULN config must specify at least one required DVN') - } - const resolvedRequiredDVNCount = resolveDVNCount(requiredDVNs, useNilSentinels) const resolvedOptionalDVNCount = resolveDVNCount(optionalDVNs, useNilSentinels) const hasConcreteOptionalDVNs = resolvedOptionalDVNCount !== 0 && resolvedOptionalDVNCount !== NIL_DVN_COUNT + const optionalDVNThresholdResolved = hasConcreteOptionalDVNs ? optionalDVNThreshold : 0 + + // The library-wide DEFAULT config (the only `useNilSentinels=false` caller) must resolve to + // at least one DVN or the contract reverts (`_assertAtLeastOneDVN`: requiredDVNCount 0 AND + // optionalDVNThreshold 0). Mirror that against the RESOLVED values so an optional-only + // default is allowed — but only with concrete optional DVNs, since a threshold without them + // is clamped to 0 above. Catch it here instead of via an opaque on-chain revert. + if (!useNilSentinels && resolvedRequiredDVNCount === 0 && optionalDVNThresholdResolved === 0) { + throw new Error( + 'Default ULN config must specify at least one DVN (a required DVN, or optional DVNs with a threshold)' + ) + } return { executor, requiredDVNCount: resolvedRequiredDVNCount, optionalDVNCount: resolvedOptionalDVNCount, - optionalDVNThreshold: hasConcreteOptionalDVNs ? optionalDVNThreshold : 0, + optionalDVNThreshold: optionalDVNThresholdResolved, requiredDVNs: (requiredDVNs ?? []).map(addChecksum).sort(compareBytes32Ascending), optionalDVNs: (optionalDVNs ?? []).map(addChecksum).sort(compareBytes32Ascending), } diff --git a/packages/protocol-devtools-evm/test/uln302/nil-sentinels.test.ts b/packages/protocol-devtools-evm/test/uln302/nil-sentinels.test.ts index ceed6c6e81..8e170444eb 100644 --- a/packages/protocol-devtools-evm/test/uln302/nil-sentinels.test.ts +++ b/packages/protocol-devtools-evm/test/uln302/nil-sentinels.test.ts @@ -8,6 +8,7 @@ import { JsonRpcProvider } from '@ethersproject/providers' const NIL_DVN_COUNT = 255 const NIL_CONFIRMATIONS = (BigInt(1) << BigInt(64)) - BigInt(1) const DVN = '0x0000000000000000000000000000000000000001' +const OTHER_DVN = '0x0000000000000000000000000000000000000002' describe('uln302/nil-sentinels', () => { let provider: Provider, ulnSdk: Uln302 @@ -37,8 +38,25 @@ describe('uln302/nil-sentinels', () => { expect(serialize({ requiredDVNs: [DVN], confirmations: BigInt(0) }, false).confirmations).toBe(BigInt(0)) }) - it('rejects an empty requiredDVNs on the DEFAULT config (the contract needs at least one)', () => { - expect(() => serialize({ requiredDVNs: [] }, false)).toThrow('at least one required DVN') + it('rejects a DEFAULT config with no required DVNs and no optional threshold', () => { + expect(() => serialize({ requiredDVNs: [] }, false)).toThrow('at least one DVN') + }) + + it('allows an optional-only DEFAULT config (no required DVNs, optional quorum)', () => { + const serialized = serialize( + { requiredDVNs: [], optionalDVNs: [DVN, OTHER_DVN], optionalDVNThreshold: 1 }, + false + ) + expect(serialized.requiredDVNCount).toBe(0) + expect(serialized.optionalDVNCount).toBe(2) + expect(serialized.optionalDVNThreshold).toBe(1) + }) + + it('rejects a DEFAULT config whose only quorum is a threshold with no optional DVNs', () => { + // a threshold without concrete optional DVNs is clamped to 0, so it is not a real quorum + expect(() => serialize({ requiredDVNs: [], optionalDVNs: [], optionalDVNThreshold: 1 }, false)).toThrow( + 'at least one DVN' + ) }) }) @@ -49,8 +67,10 @@ describe('uln302/nil-sentinels', () => { expect(serialized.optionalDVNThreshold).toBe(0) }) - it('maps an explicitly-empty optionalDVNs to NIL_DVN_COUNT (pin "no optional DVNs")', () => { - const serialized = serialize({ requiredDVNs: [DVN], optionalDVNs: [] }) + it('maps an explicitly-empty optionalDVNs to NIL_DVN_COUNT and clamps the threshold to 0', () => { + // threshold 1 with no concrete optional DVNs must clamp to 0 (the contract rejects a + // non-zero threshold without optional DVNs) + const serialized = serialize({ requiredDVNs: [DVN], optionalDVNs: [], optionalDVNThreshold: 1 }) expect(serialized.optionalDVNCount).toBe(NIL_DVN_COUNT) expect(serialized.optionalDVNThreshold).toBe(0) }) diff --git a/packages/protocol-devtools-evm/test/ulnRead/nil-sentinels.test.ts b/packages/protocol-devtools-evm/test/ulnRead/nil-sentinels.test.ts index 503b88eb2a..b4c60d0b55 100644 --- a/packages/protocol-devtools-evm/test/ulnRead/nil-sentinels.test.ts +++ b/packages/protocol-devtools-evm/test/ulnRead/nil-sentinels.test.ts @@ -6,6 +6,7 @@ import { JsonRpcProvider } from '@ethersproject/providers' const NIL_DVN_COUNT = 255 const DVN = '0x0000000000000000000000000000000000000001' +const OTHER_DVN = '0x0000000000000000000000000000000000000002' describe('ulnRead/nil-sentinels', () => { let provider: Provider, ulnSdk: UlnRead @@ -23,8 +24,10 @@ describe('ulnRead/nil-sentinels', () => { expect(serialize({ requiredDVNs: [DVN] }).optionalDVNCount).toBe(0) }) - it('maps an explicitly-empty optionalDVNs to NIL_DVN_COUNT (pin "no optional DVNs")', () => { - expect(serialize({ requiredDVNs: [DVN], optionalDVNs: [] }).optionalDVNCount).toBe(NIL_DVN_COUNT) + it('maps an explicitly-empty optionalDVNs to NIL_DVN_COUNT and clamps the threshold to 0', () => { + const serialized = serialize({ requiredDVNs: [DVN], optionalDVNs: [], optionalDVNThreshold: 1 }) + expect(serialized.optionalDVNCount).toBe(NIL_DVN_COUNT) + expect(serialized.optionalDVNThreshold).toBe(0) }) it('keeps an explicitly-empty optionalDVNs literal (count 0) for the DEFAULT config', () => { @@ -48,8 +51,25 @@ describe('ulnRead/nil-sentinels', () => { expect(serialize({ requiredDVNs: [DVN] }).requiredDVNCount).toBe(1) }) - it('rejects an empty requiredDVNs on the DEFAULT config (the contract needs at least one)', () => { - expect(() => serialize({ requiredDVNs: [] }, false)).toThrow('at least one required DVN') + it('rejects a DEFAULT config with no required DVNs and no optional threshold', () => { + expect(() => serialize({ requiredDVNs: [] }, false)).toThrow('at least one DVN') + }) + + it('allows an optional-only DEFAULT config (no required DVNs, optional quorum)', () => { + const serialized = serialize( + { requiredDVNs: [], optionalDVNs: [DVN, OTHER_DVN], optionalDVNThreshold: 1 }, + false + ) + expect(serialized.requiredDVNCount).toBe(0) + expect(serialized.optionalDVNCount).toBe(2) + expect(serialized.optionalDVNThreshold).toBe(1) + }) + + it('rejects a DEFAULT config whose only quorum is a threshold with no optional DVNs', () => { + // a threshold without concrete optional DVNs is clamped to 0, so it is not a real quorum + expect(() => serialize({ requiredDVNs: [], optionalDVNs: [], optionalDVNThreshold: 1 }, false)).toThrow( + 'at least one DVN' + ) }) }) From 14accc42655798f02451fa2399d49aa633dfe096 Mon Sep 17 00:00:00 2001 From: Krak Date: Fri, 26 Jun 2026 17:35:47 -0700 Subject: [PATCH 14/17] docs(lzapp-migration): document encodeUlnConfig resolved-config invariant The length-derived DVN counts are valid only because callers feed resolved configs (getConfig -> getUlnConfig collapses NIL to 0). Uln301 inherits UlnBase, so its stored config CAN hold a NIL sentinel; pointing this at a raw config would silently drop it. --- examples/lzapp-migration/tasks/common/taskHelper.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/examples/lzapp-migration/tasks/common/taskHelper.ts b/examples/lzapp-migration/tasks/common/taskHelper.ts index b35902e2a3..1b84dc6587 100644 --- a/examples/lzapp-migration/tasks/common/taskHelper.ts +++ b/examples/lzapp-migration/tasks/common/taskHelper.ts @@ -483,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 { From b97eb44023e887b82585caa1ce5c3def4bc9e616 Mon Sep 17 00:00:00 2001 From: Krak Date: Fri, 26 Jun 2026 17:41:48 -0700 Subject: [PATCH 15/17] fix(devtools-move): guard against omitted requiredDVNs in buildConfig requiredDVNs is now optional on the user config; buildConfig already defaults optionalDVNs to [] but passed requiredDVNs straight to returnChecksums (expects string[]), so a config that omits requiredDVNs would crash at runtime. Default it the same way. --- .changeset/devtools-move-required-dvns-guard.md | 8 ++++++++ .../devtools-move/tasks/evm/utils/libraryConfigUtils.ts | 4 ++++ 2 files changed, 12 insertions(+) create mode 100644 .changeset/devtools-move-required-dvns-guard.md diff --git a/.changeset/devtools-move-required-dvns-guard.md b/.changeset/devtools-move-required-dvns-guard.md new file mode 100644 index 0000000000..a4f8062bd9 --- /dev/null +++ b/.changeset/devtools-move-required-dvns-guard.md @@ -0,0 +1,8 @@ +--- +"@layerzerolabs/devtools-move": patch +--- + +Guard against an omitted `requiredDVNs` in `buildConfig`. `requiredDVNs` is now optional on +`Uln302UlnUserConfig`, so default it to `[]` (mirroring the existing `optionalDVNs` guard) before +passing it to `returnChecksums`, which expects a defined array — otherwise a config that omits +`requiredDVNs` would throw at runtime. diff --git a/packages/devtools-move/tasks/evm/utils/libraryConfigUtils.ts b/packages/devtools-move/tasks/evm/utils/libraryConfigUtils.ts index c91cc0c50e..2321304d00 100644 --- a/packages/devtools-move/tasks/evm/utils/libraryConfigUtils.ts +++ b/packages/devtools-move/tasks/evm/utils/libraryConfigUtils.ts @@ -53,6 +53,10 @@ export function buildConfig( if (!ulnConfig.optionalDVNs) { ulnConfig.optionalDVNs = [] } + // requiredDVNs is optional on the user config; default it the same way as optionalDVNs above + if (!ulnConfig.requiredDVNs) { + ulnConfig.requiredDVNs = [] + } const _optionalDVNs = returnChecksums(ulnConfig.optionalDVNs) const _requiredDVNs = returnChecksums(ulnConfig.requiredDVNs) From cda3f1ba9607cfd53a3f86a9ba44d5888e6cbabe Mon Sep 17 00:00:00 2001 From: Krak Date: Fri, 26 Jun 2026 18:16:00 -0700 Subject: [PATCH 16/17] fix(devtools-move): reject omitted requiredDVNs instead of pinning NIL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous guard defaulted an omitted requiredDVNs to [], but buildConfig's encoder maps an empty required set to NIL_DVN_COUNT (pin 'no required DVNs'), never to 0 (inherit). So omitting requiredDVNs silently wired the least-secure shape — the opposite of the changeset's 'omit to inherit'. This path cannot express inherit, so throw a clear error instead; callers must pass the DVNs explicitly, or [] to pin none. Add buildConfig tests for omitted/empty/concrete. --- .../devtools-move-required-dvns-guard.md | 9 +++-- .../devtools-move/jest/buildConfig.test.ts | 39 +++++++++++++++++++ .../tasks/evm/utils/libraryConfigUtils.ts | 9 ++++- 3 files changed, 51 insertions(+), 6 deletions(-) create mode 100644 packages/devtools-move/jest/buildConfig.test.ts diff --git a/.changeset/devtools-move-required-dvns-guard.md b/.changeset/devtools-move-required-dvns-guard.md index a4f8062bd9..9936e76450 100644 --- a/.changeset/devtools-move-required-dvns-guard.md +++ b/.changeset/devtools-move-required-dvns-guard.md @@ -2,7 +2,8 @@ "@layerzerolabs/devtools-move": patch --- -Guard against an omitted `requiredDVNs` in `buildConfig`. `requiredDVNs` is now optional on -`Uln302UlnUserConfig`, so default it to `[]` (mirroring the existing `optionalDVNs` guard) before -passing it to `returnChecksums`, which expects a defined array — otherwise a config that omits -`requiredDVNs` would throw at runtime. +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". diff --git a/packages/devtools-move/jest/buildConfig.test.ts b/packages/devtools-move/jest/buildConfig.test.ts new file mode 100644 index 0000000000..ea528e5ca9 --- /dev/null +++ b/packages/devtools-move/jest/buildConfig.test.ts @@ -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) + }) +}) diff --git a/packages/devtools-move/tasks/evm/utils/libraryConfigUtils.ts b/packages/devtools-move/tasks/evm/utils/libraryConfigUtils.ts index 2321304d00..e7bef17ad9 100644 --- a/packages/devtools-move/tasks/evm/utils/libraryConfigUtils.ts +++ b/packages/devtools-move/tasks/evm/utils/libraryConfigUtils.ts @@ -53,9 +53,14 @@ export function buildConfig( if (!ulnConfig.optionalDVNs) { ulnConfig.optionalDVNs = [] } - // requiredDVNs is optional on the user config; default it the same way as optionalDVNs above + // 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) { - 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) From e2919897ed7a1017653166aaea3cac0a32533535 Mon Sep 17 00:00:00 2001 From: Krak Date: Fri, 26 Jun 2026 18:16:15 -0700 Subject: [PATCH 17/17] refactor(protocol-devtools): hoist ULN threshold/default/generator logic into resolve.ts Dedup the chain-agnostic logic that had drifted into copies across the serializers and generators, and add the missing contract invariant: - resolveOptionalDVNThreshold: the threshold clamp (0 unless concrete optional DVNs), shared by all three serializers instead of three inline copies. - assertValidDefaultConfig: DEFAULT-path validation mirroring _assertAtLeastOneDVN AND the optionalDVNThreshold <= optionalDVNCount invariant (a clear local error vs an opaque revert). Scoped to the default path so an OApp config diff never throws. - dvnsFromCount: the count->array inverse (NIL->[], 0->omit, else->array) shared by both config generators instead of two hand-rolled copies. Adds default-path threshold>count + threshold-clamp tests across EVM uln302/ulnRead and Solana. --- .../protocol-devtools-evm/src/uln302/sdk.ts | 21 +++----- .../protocol-devtools-evm/src/ulnRead/sdk.ts | 24 ++++----- .../test/uln302/nil-sentinels.test.ts | 6 +++ .../test/ulnRead/nil-sentinels.test.ts | 6 +++ .../src/uln302/sdk.ts | 8 ++- .../test/uln302/nil-sentinels.test.ts | 6 ++- .../protocol-devtools/src/uln302/resolve.ts | 53 +++++++++++++++++++ .../src/oapp-read/typescript/typescript.ts | 47 ++++++---------- .../src/oapp/typescript/typescript.ts | 49 ++++++----------- 9 files changed, 122 insertions(+), 98 deletions(-) diff --git a/packages/protocol-devtools-evm/src/uln302/sdk.ts b/packages/protocol-devtools-evm/src/uln302/sdk.ts index ff5513acfc..8e49d97c89 100644 --- a/packages/protocol-devtools-evm/src/uln302/sdk.ts +++ b/packages/protocol-devtools-evm/src/uln302/sdk.ts @@ -5,9 +5,10 @@ import { type Uln302ExecutorConfig, type Uln302UlnConfig, type Uln302UlnUserConfig, - NIL_DVN_COUNT, + assertValidDefaultConfig, resolveConfirmations, resolveDVNCount, + resolveOptionalDVNThreshold, } from '@layerzerolabs/protocol-devtools' import { OmniAddress, @@ -272,20 +273,12 @@ export class Uln302 extends OmniSDK implements IUln302 { ): SerializedUln302UlnConfig { const resolvedRequiredDVNCount = resolveDVNCount(requiredDVNs, useNilSentinels) const resolvedOptionalDVNCount = resolveDVNCount(optionalDVNs, useNilSentinels) + const optionalDVNThresholdResolved = resolveOptionalDVNThreshold(optionalDVNThreshold, resolvedOptionalDVNCount) - // The contract requires the threshold to be 0 unless there are concrete optional DVNs. - const hasConcreteOptionalDVNs = resolvedOptionalDVNCount !== 0 && resolvedOptionalDVNCount !== NIL_DVN_COUNT - const optionalDVNThresholdResolved = hasConcreteOptionalDVNs ? optionalDVNThreshold : 0 - - // The library-wide DEFAULT config (the only `useNilSentinels=false` caller) must resolve to - // at least one DVN or the contract reverts (`_assertAtLeastOneDVN`: requiredDVNCount 0 AND - // optionalDVNThreshold 0). Mirror that against the RESOLVED values so an optional-only - // default is allowed — but only with concrete optional DVNs, since a threshold without them - // is clamped to 0 above. Catch it here instead of via an opaque on-chain revert. - if (!useNilSentinels && resolvedRequiredDVNCount === 0 && optionalDVNThresholdResolved === 0) { - throw new Error( - 'Default ULN config must specify at least one DVN (a required DVN, or optional DVNs with a threshold)' - ) + // The library-wide DEFAULT config is the only `useNilSentinels=false` caller; validate it + // against the contract's invariants (the OApp path must never throw on a config diff). + if (!useNilSentinels) { + assertValidDefaultConfig(resolvedRequiredDVNCount, resolvedOptionalDVNCount, optionalDVNThresholdResolved) } return { diff --git a/packages/protocol-devtools-evm/src/ulnRead/sdk.ts b/packages/protocol-devtools-evm/src/ulnRead/sdk.ts index 464f341156..a6d95fda1a 100644 --- a/packages/protocol-devtools-evm/src/ulnRead/sdk.ts +++ b/packages/protocol-devtools-evm/src/ulnRead/sdk.ts @@ -1,5 +1,10 @@ import type { IUlnRead, UlnReadUlnConfig, UlnReadUlnUserConfig } from '@layerzerolabs/protocol-devtools' -import { NIL_DVN_COUNT, UlnReadUlnConfigSchema, resolveDVNCount } from '@layerzerolabs/protocol-devtools' +import { + UlnReadUlnConfigSchema, + assertValidDefaultConfig, + resolveDVNCount, + resolveOptionalDVNThreshold, +} from '@layerzerolabs/protocol-devtools' import { OmniAddress, type OmniTransaction, @@ -131,19 +136,12 @@ export class UlnRead extends OmniSDK implements IUlnRead { ): SerializedUlnReadUlnConfig { const resolvedRequiredDVNCount = resolveDVNCount(requiredDVNs, useNilSentinels) const resolvedOptionalDVNCount = resolveDVNCount(optionalDVNs, useNilSentinels) + const optionalDVNThresholdResolved = resolveOptionalDVNThreshold(optionalDVNThreshold, resolvedOptionalDVNCount) - const hasConcreteOptionalDVNs = resolvedOptionalDVNCount !== 0 && resolvedOptionalDVNCount !== NIL_DVN_COUNT - const optionalDVNThresholdResolved = hasConcreteOptionalDVNs ? optionalDVNThreshold : 0 - - // The library-wide DEFAULT config (the only `useNilSentinels=false` caller) must resolve to - // at least one DVN or the contract reverts (`_assertAtLeastOneDVN`: requiredDVNCount 0 AND - // optionalDVNThreshold 0). Mirror that against the RESOLVED values so an optional-only - // default is allowed — but only with concrete optional DVNs, since a threshold without them - // is clamped to 0 above. Catch it here instead of via an opaque on-chain revert. - if (!useNilSentinels && resolvedRequiredDVNCount === 0 && optionalDVNThresholdResolved === 0) { - throw new Error( - 'Default ULN config must specify at least one DVN (a required DVN, or optional DVNs with a threshold)' - ) + // The library-wide DEFAULT config is the only `useNilSentinels=false` caller; validate it + // against the contract's invariants (the OApp path must never throw on a config diff). + if (!useNilSentinels) { + assertValidDefaultConfig(resolvedRequiredDVNCount, resolvedOptionalDVNCount, optionalDVNThresholdResolved) } return { diff --git a/packages/protocol-devtools-evm/test/uln302/nil-sentinels.test.ts b/packages/protocol-devtools-evm/test/uln302/nil-sentinels.test.ts index 8e170444eb..81735ba734 100644 --- a/packages/protocol-devtools-evm/test/uln302/nil-sentinels.test.ts +++ b/packages/protocol-devtools-evm/test/uln302/nil-sentinels.test.ts @@ -58,6 +58,12 @@ describe('uln302/nil-sentinels', () => { 'at least one DVN' ) }) + + it('rejects a DEFAULT config whose optional threshold exceeds the optional DVN count', () => { + expect(() => serialize({ requiredDVNs: [], optionalDVNs: [DVN], optionalDVNThreshold: 2 }, false)).toThrow( + 'cannot exceed the number of optional DVNs' + ) + }) }) describe('serializeUlnConfig optionalDVNs', () => { diff --git a/packages/protocol-devtools-evm/test/ulnRead/nil-sentinels.test.ts b/packages/protocol-devtools-evm/test/ulnRead/nil-sentinels.test.ts index b4c60d0b55..e91c8ab81a 100644 --- a/packages/protocol-devtools-evm/test/ulnRead/nil-sentinels.test.ts +++ b/packages/protocol-devtools-evm/test/ulnRead/nil-sentinels.test.ts @@ -71,6 +71,12 @@ describe('ulnRead/nil-sentinels', () => { 'at least one DVN' ) }) + + it('rejects a DEFAULT config whose optional threshold exceeds the optional DVN count', () => { + expect(() => serialize({ requiredDVNs: [], optionalDVNs: [DVN], optionalDVNThreshold: 2 }, false)).toThrow( + 'cannot exceed the number of optional DVNs' + ) + }) }) describe('hasAppUlnConfig idempotency', () => { diff --git a/packages/protocol-devtools-solana/src/uln302/sdk.ts b/packages/protocol-devtools-solana/src/uln302/sdk.ts index 488bb6f2ce..05bd1d925e 100644 --- a/packages/protocol-devtools-solana/src/uln302/sdk.ts +++ b/packages/protocol-devtools-solana/src/uln302/sdk.ts @@ -1,13 +1,13 @@ import type { EndpointId } from '@layerzerolabs/lz-definitions' import { IUln302, - NIL_DVN_COUNT, Uln302ConfigType, Uln302ExecutorConfig, Uln302UlnConfig, Uln302UlnUserConfig, resolveConfirmations, resolveDVNCount, + resolveOptionalDVNThreshold, } from '@layerzerolabs/protocol-devtools' import { OmniAddress, @@ -221,13 +221,11 @@ export class Uln302 extends OmniSDK implements IUln302 { ): SerializedUln302UlnConfig { const resolvedRequiredDVNCount = resolveDVNCount(requiredDVNs, useNilSentinels) const resolvedOptionalDVNCount = resolveDVNCount(optionalDVNs, useNilSentinels) - - // The threshold must be 0 unless there are concrete optional DVNs. - const hasConcreteOptionalDVNs = resolvedOptionalDVNCount !== 0 && resolvedOptionalDVNCount !== NIL_DVN_COUNT + const optionalDVNThresholdResolved = resolveOptionalDVNThreshold(optionalDVNThreshold, resolvedOptionalDVNCount) return { confirmations: resolveConfirmations(confirmations, useNilSentinels), - optionalDVNThreshold: hasConcreteOptionalDVNs ? optionalDVNThreshold : 0, + optionalDVNThreshold: optionalDVNThresholdResolved, requiredDVNs: serializeDVNs(requiredDVNs ?? []), optionalDVNs: serializeDVNs(optionalDVNs ?? []), requiredDVNCount: resolvedRequiredDVNCount, diff --git a/packages/protocol-devtools-solana/test/uln302/nil-sentinels.test.ts b/packages/protocol-devtools-solana/test/uln302/nil-sentinels.test.ts index 25f4063f02..f6ba3502dc 100644 --- a/packages/protocol-devtools-solana/test/uln302/nil-sentinels.test.ts +++ b/packages/protocol-devtools-solana/test/uln302/nil-sentinels.test.ts @@ -48,8 +48,10 @@ describe('uln302/nil-sentinels (solana)', () => { expect(serialize({ requiredDVNs: [DVN] }).optionalDVNCount).toBe(0) }) - it('maps an explicitly-empty optionalDVNs to NIL_DVN_COUNT (pin "no optional DVNs")', () => { - expect(serialize({ requiredDVNs: [DVN], optionalDVNs: [] }).optionalDVNCount).toBe(NIL_DVN_COUNT) + it('maps an explicitly-empty optionalDVNs to NIL_DVN_COUNT and clamps the threshold to 0', () => { + const serialized = serialize({ requiredDVNs: [DVN], optionalDVNs: [], optionalDVNThreshold: 1 }) + expect(serialized.optionalDVNCount).toBe(NIL_DVN_COUNT) + expect(serialized.optionalDVNThreshold).toBe(0) }) it('uses the array length for a non-empty optionalDVNs', () => { diff --git a/packages/protocol-devtools/src/uln302/resolve.ts b/packages/protocol-devtools/src/uln302/resolve.ts index 36103ef90c..d35bdde37d 100644 --- a/packages/protocol-devtools/src/uln302/resolve.ts +++ b/packages/protocol-devtools/src/uln302/resolve.ts @@ -20,6 +20,24 @@ import { NIL_CONFIRMATIONS, NIL_DVN_COUNT } from './constants' export const resolveDVNCount = (dvns: readonly string[] | null | undefined, useNilSentinels: boolean): number => dvns == null ? 0 : dvns.length > 0 ? dvns.length : useNilSentinels ? NIL_DVN_COUNT : 0 +/** + * Inverse of {@link resolveDVNCount} for config generators: given an on-chain DVN count and the + * DVN array, returns the array a generated config should emit, or `undefined` to OMIT the field + * (inherit the default). Keeps both generators in lock-step with the serializer's resolution: + * - NIL sentinel → `[]` (pin "no DVNs"; re-serializes back to NIL) + * - `0` → `undefined` (omit the field; inherits the on-chain default) + * - concrete count → the DVN array (null entries filtered out) + */ +export const dvnsFromCount = (count: number, dvns: readonly string[]): string[] | undefined => { + if (count === NIL_DVN_COUNT) { + return [] + } + if (count === 0) { + return undefined + } + return dvns.filter((dvn) => dvn != null) +} + /** * An omitted `confirmations` inherits the on-chain default (`0`); an explicit `0n` pins "zero * confirmations" via the NIL sentinel under `useNilSentinels`; any other value is literal. @@ -30,3 +48,38 @@ export const resolveConfirmations = (confirmations: bigint | undefined, useNilSe : confirmations === BigInt(0) && useNilSentinels ? NIL_CONFIRMATIONS : confirmations + +/** + * Resolves (clamps) the optional DVN threshold against the resolved optional DVN count. The + * contract requires the threshold to be 0 unless there are concrete optional DVNs. + */ +export const resolveOptionalDVNThreshold = (optionalDVNThreshold: number, resolvedOptionalDVNCount: number): number => { + const hasConcreteOptionalDVNs = resolvedOptionalDVNCount !== 0 && resolvedOptionalDVNCount !== NIL_DVN_COUNT + return hasConcreteOptionalDVNs ? optionalDVNThreshold : 0 +} + +/** + * Validates a library-wide DEFAULT config against the contract's invariants, surfacing a clear + * error instead of an opaque on-chain revert. Only the DEFAULT-config path calls this — an OApp + * config may legitimately inherit everything, and its desired-vs-chain diff must never throw. + * + * Mirrors `_assertAtLeastOneDVN` (requiredDVNCount 0 AND optionalDVNThreshold 0 reverts) and the + * separate `optionalDVNThreshold <= optionalDVNCount` invariant. Pass the already-resolved + * (clamped) threshold. + */ +export const assertValidDefaultConfig = ( + resolvedRequiredDVNCount: number, + resolvedOptionalDVNCount: number, + resolvedOptionalDVNThreshold: number +): void => { + if (resolvedRequiredDVNCount === 0 && resolvedOptionalDVNThreshold === 0) { + throw new Error( + 'Default ULN config must specify at least one DVN (a required DVN, or optional DVNs with a threshold)' + ) + } + if (resolvedOptionalDVNThreshold > resolvedOptionalDVNCount) { + throw new Error( + `optionalDVNThreshold (${resolvedOptionalDVNThreshold}) cannot exceed the number of optional DVNs (${resolvedOptionalDVNCount})` + ) + } +} diff --git a/packages/ua-devtools-evm-hardhat/src/oapp-read/typescript/typescript.ts b/packages/ua-devtools-evm-hardhat/src/oapp-read/typescript/typescript.ts index 3067922ea2..9930175db7 100644 --- a/packages/ua-devtools-evm-hardhat/src/oapp-read/typescript/typescript.ts +++ b/packages/ua-devtools-evm-hardhat/src/oapp-read/typescript/typescript.ts @@ -1,7 +1,7 @@ import { ExportAssignment, factory, Identifier, NodeArray, PropertyAssignment, Statement } from 'typescript' import { OmniAddress } from '@layerzerolabs/devtools' import { getReadConfig } from '@/utils/taskHelpers' -import { NIL_DVN_COUNT, UlnReadUlnConfig } from '@layerzerolabs/protocol-devtools' +import { UlnReadUlnConfig, dvnsFromCount } from '@layerzerolabs/protocol-devtools' import { CONFIG, CONNECTIONS, @@ -163,50 +163,35 @@ export const createReadUlnConfig = ({ factory.createPropertyAssignment(factory.createIdentifier(EXECUTOR), factory.createStringLiteral(executor)), ] - // requiredDVNs: count 0 means "inherit the default" so we omit the field; the NIL sentinel - // means "pinned to none", emitted as `[]` so it serializes back to NIL. Only a concrete set - // of required DVNs carries the array. (Mirrors the optionalDVNs handling below.) - if (requiredDVNCount === NIL_DVN_COUNT) { + // requiredDVNs / optionalDVNs: dvnsFromCount returns the array to emit, or undefined to OMIT + // the field (inherit the default). An empty array pins "no DVNs" (NIL); a concrete array pins + // those DVNs. Only a concrete optional set carries the threshold. + const requiredDVNsToEmit = dvnsFromCount(requiredDVNCount, requiredDVNs) + if (requiredDVNsToEmit !== undefined) { properties.push( factory.createPropertyAssignment( factory.createIdentifier(REQUIRED_DVNS), - factory.createArrayLiteralExpression([]) - ) - ) - } else if (requiredDVNCount !== 0) { - properties.push( - factory.createPropertyAssignment( - factory.createIdentifier(REQUIRED_DVNS), - factory.createArrayLiteralExpression( - requiredDVNs.filter((dvn) => dvn != null).map((dvn) => factory.createStringLiteral(dvn)) - ) + factory.createArrayLiteralExpression(requiredDVNsToEmit.map((dvn) => factory.createStringLiteral(dvn))) ) ) } - // optionalDVNs: count 0 means "inherit the default" so we omit the field; the NIL sentinel - // means "pinned to none", emitted as `[]` so it serializes back to NIL. Only a concrete set - // of optional DVNs carries the array and its threshold. - if (optionalDVNCount === NIL_DVN_COUNT) { + const optionalDVNsToEmit = dvnsFromCount(optionalDVNCount, optionalDVNs) + if (optionalDVNsToEmit !== undefined) { properties.push( factory.createPropertyAssignment( factory.createIdentifier(OPTIONAL_DVNS), - factory.createArrayLiteralExpression([]) + factory.createArrayLiteralExpression(optionalDVNsToEmit.map((dvn) => factory.createStringLiteral(dvn))) ) ) - } else if (optionalDVNCount !== 0) { - properties.push( - factory.createPropertyAssignment( - factory.createIdentifier(OPTIONAL_DVNS), - factory.createArrayLiteralExpression( - optionalDVNs.filter((dvn) => dvn != null).map((dvn) => factory.createStringLiteral(dvn)) + if (optionalDVNsToEmit.length > 0) { + properties.push( + factory.createPropertyAssignment( + factory.createIdentifier(OPTIONAL_DVN_THRESHOLD), + factory.createNumericLiteral(optionalDVNThreshold) ) - ), - factory.createPropertyAssignment( - factory.createIdentifier(OPTIONAL_DVN_THRESHOLD), - factory.createNumericLiteral(optionalDVNThreshold) ) - ) + } } return factory.createPropertyAssignment( diff --git a/packages/ua-devtools-evm-hardhat/src/oapp/typescript/typescript.ts b/packages/ua-devtools-evm-hardhat/src/oapp/typescript/typescript.ts index 19bd10a04f..3110b32da9 100644 --- a/packages/ua-devtools-evm-hardhat/src/oapp/typescript/typescript.ts +++ b/packages/ua-devtools-evm-hardhat/src/oapp/typescript/typescript.ts @@ -19,10 +19,10 @@ import { import { getReceiveConfig, getSendConfig } from '@/utils/taskHelpers' import { NIL_CONFIRMATIONS, - NIL_DVN_COUNT, Timeout, Uln302ExecutorConfig, Uln302UlnConfig, + dvnsFromCount, } from '@layerzerolabs/protocol-devtools' import { CONFIG, @@ -312,52 +312,35 @@ export const creatUlnConfig = ({ ) } - // requiredDVNs: count 0 means "inherit the default" so we omit the field; the NIL sentinel - // means "pinned to none", emitted as `[]` so it serializes back to NIL. Only a concrete set - // of required DVNs carries the array. (Mirrors the optionalDVNs handling below.) - if (requiredDVNCount === NIL_DVN_COUNT) { + // requiredDVNs / optionalDVNs: dvnsFromCount returns the array to emit, or undefined to OMIT + // the field (inherit the default). An empty array pins "no DVNs" (NIL); a concrete array pins + // those DVNs. Only a concrete optional set carries the threshold. + const requiredDVNsToEmit = dvnsFromCount(requiredDVNCount, requiredDVNs) + if (requiredDVNsToEmit !== undefined) { properties.push( factory.createPropertyAssignment( factory.createIdentifier(REQUIRED_DVNS), - factory.createArrayLiteralExpression([]) - ) - ) - } else if (requiredDVNCount !== 0) { - properties.push( - factory.createPropertyAssignment( - factory.createIdentifier(REQUIRED_DVNS), - factory.createArrayLiteralExpression( - requiredDVNs.filter((dvn) => dvn != null).map((dvn) => factory.createStringLiteral(dvn)) - ) + factory.createArrayLiteralExpression(requiredDVNsToEmit.map((dvn) => factory.createStringLiteral(dvn))) ) ) } - // optionalDVNs: mirror confirmations. Count 0 means "inherit the default" so we omit the - // field; the NIL sentinel means "pinned to none", emitted as `[]` so it serializes back to - // NIL. Only a concrete set of optional DVNs carries the array and its threshold. - if (optionalDVNCount === NIL_DVN_COUNT) { + const optionalDVNsToEmit = dvnsFromCount(optionalDVNCount, optionalDVNs) + if (optionalDVNsToEmit !== undefined) { properties.push( factory.createPropertyAssignment( factory.createIdentifier(OPTIONAL_DVNS), - factory.createArrayLiteralExpression([]) + factory.createArrayLiteralExpression(optionalDVNsToEmit.map((dvn) => factory.createStringLiteral(dvn))) ) ) - } else if (optionalDVNCount !== 0) { - properties.push( - factory.createPropertyAssignment( - factory.createIdentifier(OPTIONAL_DVNS), - factory.createArrayLiteralExpression( - optionalDVNs.filter((dvn) => dvn != null).map((dvn) => factory.createStringLiteral(dvn)) + if (optionalDVNsToEmit.length > 0) { + properties.push( + factory.createPropertyAssignment( + factory.createIdentifier(OPTIONAL_DVN_THRESHOLD), + factory.createNumericLiteral(optionalDVNThreshold) ) ) - ) - properties.push( - factory.createPropertyAssignment( - factory.createIdentifier(OPTIONAL_DVN_THRESHOLD), - factory.createNumericLiteral(optionalDVNThreshold) - ) - ) + } } return factory.createObjectLiteralExpression(properties)