diff --git a/.changeset/devtools-move-required-dvns-guard.md b/.changeset/devtools-move-required-dvns-guard.md new file mode 100644 index 0000000000..9936e76450 --- /dev/null +++ b/.changeset/devtools-move-required-dvns-guard.md @@ -0,0 +1,9 @@ +--- +"@layerzerolabs/devtools-move": patch +--- + +Reject an omitted `requiredDVNs` in `buildConfig` with a clear error. `requiredDVNs` is now +optional on the shared `Uln302UlnUserConfig` type, but this encoder maps an empty required set +to the NIL sentinel (pin "no required DVNs") and cannot express "inherit the on-chain default". +Defaulting an omitted value to `[]` would silently pin the least-secure shape, so it now throws +instead — callers must pass the required DVNs explicitly, or `[]` to pin "no required DVNs". diff --git a/.changeset/generator-uln-nil-sentinels.md b/.changeset/generator-uln-nil-sentinels.md new file mode 100644 index 0000000000..2a4a492ac4 --- /dev/null +++ b/.changeset/generator-uln-nil-sentinels.md @@ -0,0 +1,9 @@ +--- +"@layerzerolabs/ua-devtools-evm-hardhat": patch +--- + +Generate ULN configs (both the ULN302 send/receive and the Read library generators) that +round-trip the new NIL-sentinel semantics: a field inheriting the on-chain default is +OMITTED (for both `requiredDVNs` and `optionalDVNs`, which now behave identically) rather +than emitted as an explicit empty value that would pin zero/none on re-apply. Pinned-none +configs continue to emit `[]`/`0n`. diff --git a/.changeset/metadata-tools-optional-dvns-nil.md b/.changeset/metadata-tools-optional-dvns-nil.md new file mode 100644 index 0000000000..10265aa370 --- /dev/null +++ b/.changeset/metadata-tools-optional-dvns-nil.md @@ -0,0 +1,21 @@ +--- +"@layerzerolabs/metadata-tools": minor +--- + +`generateConnectionsConfig` now treats a pathway with no optional DVNs as an explicit +"no optional DVNs" (pinned via the NIL sentinel) instead of a value that inherits the +on-chain default. + +The emitted config still carries `optionalDVNs: []`, but under the new ULN302 sentinel +semantics that empty array now pins "no optional DVNs" on-chain rather than falling back +to the chain default. This is deliberate: the metadata config is the primary way a config +is consumed, and an empty optional-DVN set should be visible in the file rather than +silently inheriting the default. + +Re-wiring a pathway that previously inherited the on-chain default will now pin its +optional-DVN set explicitly. If that default carried optional DVNs (a non-zero threshold), +pinning an empty set drops them — this is intended. The goal is that a team's verification +config is exactly what their config file says, not something that can change underneath them +when a LayerZero-controlled default is updated. An empty optional-DVN set means "no optional +DVNs"; teams that want an optional quorum should list those DVNs explicitly. Required DVNs +are unaffected by this change. 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 new file mode 100644 index 0000000000..7c4eaa0f31 --- /dev/null +++ b/.changeset/uln-nil-sentinels.md @@ -0,0 +1,51 @@ +--- +"@layerzerolabs/protocol-devtools": major +"@layerzerolabs/protocol-devtools-evm": major +"@layerzerolabs/protocol-devtools-solana": major +--- + +Treat explicitly-empty ULN302 config values as NIL sentinels instead of defaults + +This lets a team pin a literal "none"/"zero" so their security configuration is exactly +what their config file says, rather than silently inheriting a default that LayerZero +controls. Being able to opt out of defaults is the point: a pinned config cannot change +underneath a team when a default is updated. + +When serializing an OApp ULN302 / Read config, `requiredDVNs` and `optionalDVNs` now +behave identically — omitted, explicitly-empty, and concrete each map to a distinct +on-chain meaning: + +- Omitting a DVN field (leaving it `undefined`) inherits the on-chain default. +- An explicitly-empty array (`[]`) pins "no DVNs" via `NIL_DVN_COUNT` (`0xff`). +- A concrete array pins those DVNs. +- Likewise `confirmations: 0n` now serializes to `NIL_CONFIRMATIONS` + (`type(uint64).max`), while omitting it inherits the default. + +To make `requiredDVNs` express "inherit" the same way `optionalDVNs` already could, it +is now OPTIONAL on `Uln302UlnUserConfig` and `UlnReadUlnUserConfig` (previously +mandatory). This removes the need for any count override — the count is always derived +from the array, so the three serializers (EVM ULN302, EVM Read, Solana ULN302) share a +single `resolveDVNCount` helper. + +The read types `Uln302UlnConfig`/`UlnReadUlnConfig` carry `optionalDVNCount` (and +`UlnReadUlnConfig` also `requiredDVNCount`) so the stored sentinel round-trips through +the configuration diff, and the on-chain read path normalizes rather than re-applying +the empty→NIL mapping, keeping `hasAppUlnConfig` idempotent on both paths. The +library-wide DEFAULT config continues to serialize literal values (it rejects NIL +sentinels on-chain). On Solana, `confirmations` is now encoded as a `BN` so the `u64` +NIL sentinel survives without precision loss. + +MIGRATION: + +- If you wrote `confirmations: 0`, `requiredDVNs: []`, or `optionalDVNs: []` expecting + the config to inherit the protocol default, OMIT the field instead. An explicit empty + value now pins literal zero/none — for `confirmations` this means zero block + confirmations, and for `requiredDVNs` it means no required DVNs, both + security-relevant. Re-wiring an existing OApp whose config used these empty values + will emit a `setConfig` that flips it from inherit to pinned. +- The read types `Uln302UlnConfig` (gains `optionalDVNCount`) and `UlnReadUlnConfig` + (gains `requiredDVNCount` and `optionalDVNCount`) have new required fields. Any code + that hand-constructs one of these (e.g. mocking an SDK read) must supply the new + fields. +- `requiredDVNs` is no longer required on the user config. Code that always set it + keeps working unchanged; you may now omit it to inherit the on-chain default. 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..1b84dc6587 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, } @@ -471,7 +483,15 @@ function encodeExecutorConfig(config: Uln302ExecutorConfig): string { /** * Encodes the UlnConfig into ABI-encoded bytes. - * @param config Uln302UlnConfig object + * + * The DVN counts are derived from the array lengths rather than read from the config's + * `requiredDVNCount`/`optionalDVNCount`. That is only valid because the callers here feed + * RESOLVED configs (read via `getConfig` -> `getUlnConfig`, which collapses any stored NIL + * sentinel to 0 before returning). Uln301 inherits `UlnBase`, so its stored config CAN hold a + * NIL sentinel — if this were ever pointed at a raw/stored config (`getAppUlnConfig`), the + * length derivation would silently drop that sentinel. Keep it on the resolved-config path. + * + * @param config Uln302UlnConfig object (resolved, not raw/stored) * @returns ABI-encoded string */ function encodeUlnConfig(config: Uln302UlnConfig): string { @@ -481,10 +501,10 @@ function encodeUlnConfig(config: Uln302UlnConfig): string { ], [ [ - config.confirmations, - config.requiredDVNs.length, - config.optionalDVNs.length, - config.optionalDVNThreshold, + config.confirmations ?? 0, + (config.requiredDVNs || []).length, + (config.optionalDVNs || []).length, + config.optionalDVNThreshold ?? 0, config.requiredDVNs || [], config.optionalDVNs || [], ], 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 c91cc0c50e..e7bef17ad9 100644 --- a/packages/devtools-move/tasks/evm/utils/libraryConfigUtils.ts +++ b/packages/devtools-move/tasks/evm/utils/libraryConfigUtils.ts @@ -53,6 +53,15 @@ export function buildConfig( if (!ulnConfig.optionalDVNs) { ulnConfig.optionalDVNs = [] } + // requiredDVNs is optional on the shared user-config type, but the encoder below maps an + // empty required set to the NIL sentinel (pin "no required DVNs") and has no way to express + // "inherit the default" (count 0). Reject an omitted requiredDVNs loudly rather than silently + // pinning the least-secure shape; callers must pass the DVNs explicitly, or [] to pin none. + if (!ulnConfig.requiredDVNs) { + throw new Error( + 'requiredDVNs must be specified for the Move config path; it cannot inherit the on-chain default. Pass the required DVNs explicitly, or [] to pin "no required DVNs".' + ) + } const _optionalDVNs = returnChecksums(ulnConfig.optionalDVNs) const _requiredDVNs = returnChecksums(ulnConfig.requiredDVNs) 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/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/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..8e49d97c89 100644 --- a/packages/protocol-devtools-evm/src/uln302/sdk.ts +++ b/packages/protocol-devtools-evm/src/uln302/sdk.ts @@ -1,10 +1,14 @@ 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, + assertValidDefaultConfig, + resolveConfirmations, + resolveDVNCount, + resolveOptionalDVNThreshold, } from '@layerzerolabs/protocol-devtools' import { OmniAddress, @@ -24,9 +28,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 - export class Uln302 extends OmniSDK implements IUln302 { constructor(provider: Provider, point: OmniPoint) { super({ eid: point.eid, contract: new Contract(point.address, abi).connect(provider) }) @@ -55,6 +56,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 +88,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 +108,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 +213,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 +227,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 +255,69 @@ export class Uln302 extends OmniSDK implements IUln302 { * @param {Uln302UlnUserConfig} config * @returns {SerializedUln302UlnConfig} */ - protected serializeUlnConfig({ - confirmations = BigInt(0), + protected serializeUlnConfig( + { confirmations, requiredDVNs, 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 { + const resolvedRequiredDVNCount = resolveDVNCount(requiredDVNs, useNilSentinels) + const resolvedOptionalDVNCount = resolveDVNCount(optionalDVNs, useNilSentinels) + const optionalDVNThresholdResolved = resolveOptionalDVNThreshold(optionalDVNThreshold, resolvedOptionalDVNCount) + + // 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 { + confirmations: resolveConfirmations(confirmations, useNilSentinels), + optionalDVNThreshold: optionalDVNThresholdResolved, + 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..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 { UlnReadUlnConfigSchema } from '@layerzerolabs/protocol-devtools' +import { + UlnReadUlnConfigSchema, + assertValidDefaultConfig, + resolveDVNCount, + resolveOptionalDVNThreshold, +} from '@layerzerolabs/protocol-devtools' import { OmniAddress, type OmniTransaction, @@ -16,9 +21,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) }) @@ -70,7 +72,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 +97,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 +125,57 @@ 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 { + const resolvedRequiredDVNCount = resolveDVNCount(requiredDVNs, useNilSentinels) + const resolvedOptionalDVNCount = resolveDVNCount(optionalDVNs, useNilSentinels) + const optionalDVNThresholdResolved = resolveOptionalDVNThreshold(optionalDVNThreshold, resolvedOptionalDVNCount) + + // 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 { + executor, + requiredDVNCount: resolvedRequiredDVNCount, + optionalDVNCount: resolvedOptionalDVNCount, + optionalDVNThreshold: optionalDVNThresholdResolved, + 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..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 @@ -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', () => { @@ -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 953ea94244..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 @@ -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', () => { @@ -46,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), @@ -58,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/uln302/nil-sentinels.test.ts b/packages/protocol-devtools-evm/test/uln302/nil-sentinels.test.ts new file mode 100644 index 0000000000..81735ba734 --- /dev/null +++ b/packages/protocol-devtools-evm/test/uln302/nil-sentinels.test.ts @@ -0,0 +1,190 @@ +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' +const OTHER_DVN = '0x0000000000000000000000000000000000000002' + +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)) + }) + + 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' + ) + }) + + 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', () => { + 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 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) + }) + + 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..e91c8ab81a --- /dev/null +++ b/packages/protocol-devtools-evm/test/ulnRead/nil-sentinels.test.ts @@ -0,0 +1,144 @@ +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' +const OTHER_DVN = '0x0000000000000000000000000000000000000002' + +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 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', () => { + expect(serialize({ requiredDVNs: [DVN], optionalDVNs: [] }, false).optionalDVNCount).toBe(0) + }) + }) + + describe('serializeUlnConfig requiredDVNs', () => { + const serialize = (config: UlnReadUlnUserConfig, useNilSentinels?: boolean) => + (ulnSdk as any).serializeUlnConfig(config, useNilSentinels) + + it('maps omitted requiredDVNs to count 0 (inherit the on-chain default)', () => { + expect(serialize({}).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', () => { + expect(serialize({ requiredDVNs: [DVN] }).requiredDVNCount).toBe(1) + }) + + 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' + ) + }) + + 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', () => { + 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) + }) + + 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 () => { + 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: 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: [] }) + ).resolves.toBe(false) + await expect(hasConfig(read({ requiredDVNs: [], requiredDVNCount: 0 }), {})).resolves.toBe(true) + }) + }) +}) 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/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/protocol-devtools-solana/src/endpointv2/schema.ts b/packages/protocol-devtools-solana/src/endpointv2/schema.ts index 8d062bc75e..0c42eda45e 100644 --- a/packages/protocol-devtools-solana/src/endpointv2/schema.ts +++ b/packages/protocol-devtools-solana/src/endpointv2/schema.ts @@ -1,5 +1,6 @@ -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 { z } from 'zod' @@ -14,9 +15,20 @@ 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. 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)), 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..05bd1d925e 100644 --- a/packages/protocol-devtools-solana/src/uln302/sdk.ts +++ b/packages/protocol-devtools-solana/src/uln302/sdk.ts @@ -5,6 +5,9 @@ import { Uln302ExecutorConfig, Uln302UlnConfig, Uln302UlnUserConfig, + resolveConfirmations, + resolveDVNCount, + resolveOptionalDVNThreshold, } from '@layerzerolabs/protocol-devtools' import { OmniAddress, @@ -102,7 +105,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 +206,59 @@ export class Uln302 extends OmniSDK implements IUln302 { * @param {Uln302UlnUserConfig} config * @returns {SerializedUln302UlnConfig} */ - protected serializeUlnConfig({ - confirmations = BigInt(0), + protected serializeUlnConfig( + { confirmations, requiredDVNs, 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 { + const resolvedRequiredDVNCount = resolveDVNCount(requiredDVNs, useNilSentinels) + const resolvedOptionalDVNCount = resolveDVNCount(optionalDVNs, useNilSentinels) + const optionalDVNThresholdResolved = resolveOptionalDVNThreshold(optionalDVNThreshold, resolvedOptionalDVNCount) + + return { + confirmations: resolveConfirmations(confirmations, useNilSentinels), + optionalDVNThreshold: optionalDVNThresholdResolved, + 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, } } 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..f6ba3502dc --- /dev/null +++ b/packages/protocol-devtools-solana/test/uln302/nil-sentinels.test.ts @@ -0,0 +1,138 @@ +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 +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 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', () => { + 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) + }) + }) + + 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..a308adff25 100644 --- a/packages/protocol-devtools/src/uln302/index.ts +++ b/packages/protocol-devtools/src/uln302/index.ts @@ -1,3 +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..d35bdde37d --- /dev/null +++ b/packages/protocol-devtools/src/uln302/resolve.ts @@ -0,0 +1,85 @@ +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). + */ + +/** + * 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 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. + */ +export const resolveConfirmations = (confirmations: bigint | undefined, useNilSentinels: boolean): bigint => + confirmations == null + ? BigInt(0) + : 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/protocol-devtools/src/uln302/schema.ts b/packages/protocol-devtools/src/uln302/schema.ts index 89e4537f62..5959c975b6 100644 --- a/packages/protocol-devtools/src/uln302/schema.ts +++ b/packages/protocol-devtools/src/uln302/schema.ts @@ -13,12 +13,12 @@ export const Uln302UlnConfigSchema = z.object({ requiredDVNCount: UIntNumberSchema, optionalDVNs: z.array(AddressSchema), optionalDVNThreshold: UIntNumberSchema, + optionalDVNCount: UIntNumberSchema, }) satisfies z.ZodSchema 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 bdd8efd6ae..524a6e29b6 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 } /** @@ -130,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 b8f186f60a..13e7adf08c 100644 --- a/packages/protocol-devtools/src/ulnRead/schema.ts +++ b/packages/protocol-devtools/src/ulnRead/schema.ts @@ -5,13 +5,15 @@ 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 export const UlnReadUlnUserConfigSchema = z.object({ executor: AddressSchema.optional(), - requiredDVNs: z.array(AddressSchema), + 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 82e75cdb6c..471b87eee3 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 } /** @@ -64,7 +66,11 @@ export interface UlnReadUlnConfig { export interface UlnReadUlnUserConfig { executor?: string optionalDVNThreshold?: number - requiredDVNs: string[] + /** + * 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/ua-devtools-evm-hardhat/src/oapp-read/typescript/typescript.ts b/packages/ua-devtools-evm-hardhat/src/oapp-read/typescript/typescript.ts index 8dd01c4181..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 { UlnReadUlnConfig } from '@layerzerolabs/protocol-devtools' +import { UlnReadUlnConfig, dvnsFromCount } from '@layerzerolabs/protocol-devtools' import { CONFIG, CONNECTIONS, @@ -154,30 +154,49 @@ 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 / 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( - requiredDVNs.filter((dvn) => dvn != null).map((dvn) => factory.createStringLiteral(dvn)) - ) - ), + factory.createArrayLiteralExpression(requiredDVNsToEmit.map((dvn) => factory.createStringLiteral(dvn))) + ) + ) + } + + const optionalDVNsToEmit = dvnsFromCount(optionalDVNCount, optionalDVNs) + if (optionalDVNsToEmit !== undefined) { + properties.push( factory.createPropertyAssignment( factory.createIdentifier(OPTIONAL_DVNS), - factory.createArrayLiteralExpression( - optionalDVNs.filter((dvn) => dvn != null).map((dvn) => factory.createStringLiteral(dvn)) + factory.createArrayLiteralExpression(optionalDVNsToEmit.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( + factory.createIdentifier(ULN_CONFIG), + factory.createObjectLiteralExpression(properties) ) } 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..3110b32da9 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, + Timeout, + Uln302ExecutorConfig, + Uln302UlnConfig, + dvnsFromCount, +} from '@layerzerolabs/protocol-devtools' import { CONFIG, CONFIRMATIONS, @@ -283,31 +289,61 @@ 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 / 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(requiredDVNsToEmit.map((dvn) => factory.createStringLiteral(dvn))) ) - ), - factory.createPropertyAssignment( - factory.createIdentifier(OPTIONAL_DVN_THRESHOLD), - factory.createNumericLiteral(optionalDVNThreshold) - ), - ]) + ) + } + + const optionalDVNsToEmit = dvnsFromCount(optionalDVNCount, optionalDVNs) + if (optionalDVNsToEmit !== undefined) { + properties.push( + factory.createPropertyAssignment( + factory.createIdentifier(OPTIONAL_DVNS), + factory.createArrayLiteralExpression(optionalDVNsToEmit.map((dvn) => factory.createStringLiteral(dvn))) + ) + ) + if (optionalDVNsToEmit.length > 0) { + properties.push( + factory.createPropertyAssignment( + factory.createIdentifier(OPTIONAL_DVN_THRESHOLD), + factory.createNumericLiteral(optionalDVNThreshold) + ) + ) + } + } + + return factory.createObjectLiteralExpression(properties) } /** 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') + }) +}) 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, } }