diff --git a/.pnp.cjs b/.pnp.cjs index 68ceb76c751..54c64b8c824 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -7513,7 +7513,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "packageLocation": "./.yarn/cache/cron-npm-2.1.0-80a1fa89dd-9395875c09.zip/node_modules/cron/",\ "packageDependencies": [\ ["cron", "npm:2.1.0"],\ - ["luxon", "npm:1.28.0"]\ + ["luxon", "npm:1.28.1"]\ ],\ "linkType": "HARD"\ }]\ @@ -12314,10 +12314,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["luxon", [\ - ["npm:1.28.0", {\ - "packageLocation": "./.yarn/cache/luxon-npm-1.28.0-51c0607a71-5250cb9f13.zip/node_modules/luxon/",\ + ["npm:1.28.1", {\ + "packageLocation": "./.yarn/cache/luxon-npm-1.28.1-0f675125b8-2c62999ade.zip/node_modules/luxon/",\ "packageDependencies": [\ - ["luxon", "npm:1.28.0"]\ + ["luxon", "npm:1.28.1"]\ ],\ "linkType": "HARD"\ }]\ diff --git a/.yarn/cache/luxon-npm-1.28.0-51c0607a71-5250cb9f13.zip b/.yarn/cache/luxon-npm-1.28.0-51c0607a71-5250cb9f13.zip deleted file mode 100644 index 2365ceaa5cd..00000000000 Binary files a/.yarn/cache/luxon-npm-1.28.0-51c0607a71-5250cb9f13.zip and /dev/null differ diff --git a/.yarn/cache/luxon-npm-1.28.1-0f675125b8-2c62999ade.zip b/.yarn/cache/luxon-npm-1.28.1-0f675125b8-2c62999ade.zip new file mode 100644 index 00000000000..c7b23c23094 Binary files /dev/null and b/.yarn/cache/luxon-npm-1.28.1-0f675125b8-2c62999ade.zip differ diff --git a/packages/js-drive/lib/core/getRandomQuorum.js b/packages/js-drive/lib/core/getRandomQuorum.js deleted file mode 100644 index 894d55e50cb..00000000000 --- a/packages/js-drive/lib/core/getRandomQuorum.js +++ /dev/null @@ -1,65 +0,0 @@ -const BufferWriter = require('@dashevo/dashcore-lib/lib/encoding/bufferwriter'); -const Hash = require('@dashevo/dashcore-lib/lib/crypto/hash'); -const QuorumsNotFoundError = require('./errors/QuorumsNotFoundError'); - -const MIN_QUORUM_VALID_MEMBERS = 90; - -/** - * Calculates scores for validator quorum selection - * it calculates sha256(hash, modifier) per quorumHash - * Please note that this is not a double-sha256 but a single-sha256 - * - * @param {Buffer[]} quorumHashes - * @param {Buffer} modifier - * @return {Object[]} scores - */ -function calculateQuorumHashScores(quorumHashes, modifier) { - return quorumHashes.map((hash) => { - const bufferWriter = new BufferWriter(); - - bufferWriter.write(hash); - bufferWriter.write(modifier); - - return { score: Hash.sha256(bufferWriter.toBuffer()), hash }; - }); -} - -/** - * Gets the current validator set quorum hash for a particular core height - * - * @typedef {getRandomQuorum} - * @param {SimplifiedMNList} sml - * @param {number} quorumType - * @param {Buffer} entropy - the entropy to select the quorum - * @return {QuorumEntry} - the current validator set's quorumHash - */ -function getRandomQuorum(sml, quorumType, entropy) { - const validatorQuorums = sml.getQuorumsOfType(quorumType); - - if (validatorQuorums.length === 0) { - throw new QuorumsNotFoundError(sml, quorumType); - } - - // filter quorum by the number of valid members to choose the most vital ones - let filteredValidatorQuorums = validatorQuorums.filter( - (validatorQuorum) => validatorQuorum.validMembersCount >= MIN_QUORUM_VALID_MEMBERS, - ); - - if (filteredValidatorQuorums.length === 0) { - // if there is no "vital" quorums, we choose among others with default min quorum size - filteredValidatorQuorums = validatorQuorums; - } - - const validatorQuorumHashes = filteredValidatorQuorums - .map((quorum) => Buffer.from(quorum.quorumHash, 'hex')); - - const scoredHashes = calculateQuorumHashScores(validatorQuorumHashes, entropy); - - scoredHashes.sort((a, b) => Buffer.compare(a.score, b.score)); - - const quorumHash = scoredHashes[0].hash.toString('hex'); - - return sml.getQuorum(quorumType, quorumHash); -} - -module.exports = getRandomQuorum; diff --git a/packages/js-drive/lib/core/getRandomQuorumFactory.js b/packages/js-drive/lib/core/getRandomQuorumFactory.js new file mode 100644 index 00000000000..a15563780b1 --- /dev/null +++ b/packages/js-drive/lib/core/getRandomQuorumFactory.js @@ -0,0 +1,108 @@ +const BufferWriter = require('@dashevo/dashcore-lib/lib/encoding/bufferwriter'); +const Hash = require('@dashevo/dashcore-lib/lib/crypto/hash'); +const QuorumsNotFoundError = require('./errors/QuorumsNotFoundError'); +const ValidatorSet = require('../validator/ValidatorSet'); +const llmqMap = require('./llmqMap'); + +const MIN_QUORUM_VALID_MEMBERS = 90; + +/** + * Calculates scores for validator quorum selection + * it calculates sha256(hash, modifier) per quorumHash + * Please note that this is not a double-sha256 but a single-sha256 + * + * @param {Buffer[]} quorumHashes + * @param {Buffer} modifier + * @return {Object[]} scores + */ +function calculateQuorumHashScores(quorumHashes, modifier) { + return quorumHashes.map((hash) => { + const bufferWriter = new BufferWriter(); + + bufferWriter.write(hash); + bufferWriter.write(modifier); + + return { score: Hash.sha256(bufferWriter.toBuffer()), hash }; + }); +} + +/** + * + * @param {RpcClient} coreRpcClient + * @return {getRandomQuorum} + */ +function getRandomQuorumFactory(coreRpcClient) { + /** + * Gets the current validator set quorum hash for a particular core height + * + * @typedef {getRandomQuorum} + * @param {SimplifiedMNList} sml + * @param {number} quorumType + * @param {Buffer} entropy - the entropy to select the quorum + * @param {number} coreHeight + * @return return {Promise} - the current validator set's quorumHash + */ + async function getRandomQuorum(sml, quorumType, entropy, coreHeight) { + const validatorQuorums = sml.getQuorumsOfType(quorumType); + + if (validatorQuorums.length === 0) { + throw new QuorumsNotFoundError(sml, quorumType); + } + + const { result: allValidatorQuorumsExtendedInfo } = await coreRpcClient.quorum('listextended', coreHeight); + + // convert to object + const validatorQuorumsInfo = allValidatorQuorumsExtendedInfo[llmqMap[quorumType]] + .reduce( + (obj, item) => ({ + ...obj, + ...item, + }), + {}, + ); + + const numberOfQuorums = validatorQuorums.length; + const minTtl = ValidatorSet.ROTATION_BLOCK_INTERVAL * 3; + const dkgInterval = 24; + + // filter quorum by the number of valid members to choose the most vital ones + let filteredValidatorQuorums = validatorQuorums + .filter( + (validatorQuorum) => validatorQuorum.validMembersCount >= MIN_QUORUM_VALID_MEMBERS, + ) + .filter((validatorQuorum) => { + const validatorQuorumInfo = validatorQuorumsInfo[validatorQuorum.quorumHash]; + + if (!validatorQuorumInfo) { + return false; + } + + const quorumRemoveHeight = validatorQuorumInfo.creationHeight + + (dkgInterval * numberOfQuorums); + const howMuchInRest = quorumRemoveHeight - coreHeight; + const quorumTtl = howMuchInRest * 2.5; + + return quorumTtl > minTtl; + }); + + if (filteredValidatorQuorums.length === 0) { + // if there is no "vital" quorums, we choose among others with default min quorum size + filteredValidatorQuorums = validatorQuorums; + } + + const validatorQuorumHashes = filteredValidatorQuorums + .map((quorum) => Buffer.from(quorum.quorumHash, 'hex')); + + const scoredHashes = calculateQuorumHashScores(validatorQuorumHashes, entropy); + + scoredHashes.sort((a, b) => Buffer.compare(a.score, b.score)); + + const quorumHash = scoredHashes[0].hash.toString('hex'); + + return sml.getQuorum(quorumType, quorumHash); + } + + return getRandomQuorum; +} + +module.exports = getRandomQuorumFactory; diff --git a/packages/js-drive/lib/core/llmqMap.js b/packages/js-drive/lib/core/llmqMap.js new file mode 100644 index 00000000000..d2b569340ac --- /dev/null +++ b/packages/js-drive/lib/core/llmqMap.js @@ -0,0 +1,25 @@ +module.exports = { + 0xff: 'llmq_none', + + 1: 'llmq_50_60', // 50 members, 30 (60%) threshold, one per hour + 2: 'llmq_400_60', // 400 members, 240 (60%) threshold, one every 12 hours + 3: 'llmq_400_85', // 400 members, 340 (85%) threshold, one every 24 hours + 4: 'llmq_100_67', // 100 members, 67 (67%) threshold, one per hour + 5: 'llmq_60_75', // 60 members, 45 (75%) threshold, one every 12 hours + + // for testing only + 100: 'llmq_test', // 3 members, 2(66%) threshold, one per hour. + + // for devnets only + 101: 'llmq_devnet', // 12 members, 6 (50%) threshold, one per hour. + + // for testing activation of new quorums only + 102: 'llmq_test_v17', // 3 members, 2 (66%) threshold, one per hour. + + // for testing only + 103: 'llmq_test_dip0024', // 4 members, 2 (66%) threshold, one per hour. + 104: 'llmq_test_instantsend', // 3 members, 2 (66%) threshold, one per hour. + + // for devnets only. rotated version (v2) for devnets + 105: 'llmq_devnet_dip0024', // 8 members, 4 (50%) threshold, one per hour. +}; diff --git a/packages/js-drive/lib/createDIContainer.js b/packages/js-drive/lib/createDIContainer.js index 5e30e59a235..b3fe49eed70 100644 --- a/packages/js-drive/lib/createDIContainer.js +++ b/packages/js-drive/lib/createDIContainer.js @@ -113,7 +113,7 @@ const getFeatureFlagForHeightFactory = require('./featureFlag/getFeatureFlagForH const ValidatorSet = require('./validator/ValidatorSet'); const createValidatorSetUpdate = require('./abci/handlers/validator/createValidatorSetUpdate'); const fetchQuorumMembersFactory = require('./core/fetchQuorumMembersFactory'); -const getRandomQuorum = require('./core/getRandomQuorum'); +const getRandomQuorumFactory = require('./core/getRandomQuorumFactory'); const createQueryResponseFactory = require('./abci/handlers/query/response/createQueryResponseFactory'); const BlockExecutionContextRepository = require('./blockExecution/BlockExecutionContextRepository'); @@ -350,8 +350,8 @@ function createDIContainer(options) { latestCoreChainLock: asValue(new LatestCoreChainLock()), simplifiedMasternodeList: asClass(SimplifiedMasternodeList).proxy().singleton(), fetchQuorumMembers: asFunction(fetchQuorumMembersFactory), - getRandomQuorum: asValue(getRandomQuorum), fetchSimplifiedMNList: asFunction(fetchSimplifiedMNListFactory), + getRandomQuorum: asFunction(getRandomQuorumFactory), coreZMQClient: asFunction(( coreZMQHost, coreZMQPort, diff --git a/packages/js-drive/lib/validator/ValidatorSet.js b/packages/js-drive/lib/validator/ValidatorSet.js index f605d9c1b71..ca3d0616bff 100644 --- a/packages/js-drive/lib/validator/ValidatorSet.js +++ b/packages/js-drive/lib/validator/ValidatorSet.js @@ -110,6 +110,7 @@ class ValidatorSet { sml, this.validatorSetLLMQType, rotationEntropy, + coreHeight, ); const quorumMembers = await this.fetchQuorumMembers( diff --git a/packages/js-drive/test/unit/core/getRandomQuorum.spec.js b/packages/js-drive/test/unit/core/getRandomQuorum.spec.js deleted file mode 100644 index 16a2f4d98c8..00000000000 --- a/packages/js-drive/test/unit/core/getRandomQuorum.spec.js +++ /dev/null @@ -1,104 +0,0 @@ -const { QuorumEntry } = require('@dashevo/dashcore-lib'); -const { expect } = require('chai'); -const getRandomQuorum = require('../../../lib/core/getRandomQuorum'); - -describe('getRandomQuorum', () => { - let smlMock; - let quorumType; - let randomQuorum; - - beforeEach(function beforeEach() { - smlMock = { - getQuorumsOfType: this.sinon.stub(), - getQuorum: this.sinon.stub(), - quorumList: [], - blockHash: '0'.repeat(32), - }; - - quorumType = 1; - - smlMock.getQuorumsOfType.returns([ - { - quorumHash: Buffer.alloc(1, 32).toString('hex'), - validMembersCount: 90, - }, - { - quorumHash: Buffer.alloc(1, 64).toString('hex'), - getAllQuorumMembers: 90, - }, - ]); - - randomQuorum = new QuorumEntry(); - - smlMock.getQuorum.returns(randomQuorum); - }); - - it('should return random quorum based on entropy', () => { - const result = getRandomQuorum(smlMock, quorumType, Buffer.alloc(1)); - - expect(smlMock.getQuorumsOfType).to.have.been.calledOnceWithExactly(quorumType); - expect(smlMock.getQuorum).to.have.been.calledOnceWithExactly( - quorumType, '20', - ); - expect(result).to.equals(randomQuorum); - }); - - it('should throw an error if SML does not contain any quorums', () => { - smlMock.getQuorumsOfType.returns([]); - - expect(() => { - getRandomQuorum(smlMock, quorumType, Buffer.alloc(1)); - }).to.throw(`SML at block ${'0'.repeat(32)} contains no quorums of any type`); - }); - - it('should throw an error if SML contains quorums that differ from the specified quorum type', () => { - smlMock.getQuorumsOfType.returns([]); - smlMock.quorumList = [{ llmqType: 999 }]; - - expect(() => { - getRandomQuorum(smlMock, quorumType, Buffer.alloc(1)); - }).to.throw(`SML at block ${'0'.repeat(32)} contains no quorums of type 1, but contains entries for types 999. Please check the Drive configuration`); - }); - - it('should filter quorums by minQuorumMembers', () => { - smlMock.getQuorumsOfType.returns([ - { - quorumHash: Buffer.alloc(1, 64).toString('hex'), - validMembersCount: 90, - }, - { - quorumHash: Buffer.alloc(1, 32).toString('hex'), - validMembersCount: 89, - }, - ]); - - const result = getRandomQuorum(smlMock, quorumType, Buffer.alloc(1)); - - expect(smlMock.getQuorumsOfType).to.have.been.calledOnceWithExactly(quorumType); - expect(smlMock.getQuorum).to.have.been.calledOnceWithExactly( - quorumType, '40', - ); - expect(result).to.equals(randomQuorum); - }); - - it('should choose from all quorums if filtered list is empty', () => { - smlMock.getQuorumsOfType.returns([ - { - quorumHash: Buffer.alloc(1, 64).toString('hex'), - validMembersCount: 10, - }, - { - quorumHash: Buffer.alloc(1, 32).toString('hex'), - validMembersCount: 10, - }, - ]); - - const result = getRandomQuorum(smlMock, quorumType, Buffer.alloc(1)); - - expect(smlMock.getQuorumsOfType).to.have.been.calledOnceWithExactly(quorumType); - expect(smlMock.getQuorum).to.have.been.calledOnceWithExactly( - quorumType, '20', - ); - expect(result).to.equals(randomQuorum); - }); -}); diff --git a/packages/js-drive/test/unit/core/getRandomQuorumFactory.spec.js b/packages/js-drive/test/unit/core/getRandomQuorumFactory.spec.js new file mode 100644 index 00000000000..d75a5c2aea4 --- /dev/null +++ b/packages/js-drive/test/unit/core/getRandomQuorumFactory.spec.js @@ -0,0 +1,196 @@ +const { expect } = require('chai'); +const getRandomQuorumFactory = require('../../../lib/core/getRandomQuorumFactory'); + +describe('getRandomQuorumFactory', () => { + let smlMock; + let quorumType; + let getRandomQuorum; + let coreRpcClientMock; + let randomQuorum; + let coreHeight; + + beforeEach(function beforeEach() { + smlMock = { + getQuorumsOfType: this.sinon.stub(), + getQuorum: this.sinon.stub(), + quorumList: [], + blockHash: '0'.repeat(32), + }; + + coreHeight = 690; + + quorumType = 102; + + smlMock.getQuorum.resolves(randomQuorum); + + smlMock.getQuorumsOfType.returns([ + { + quorumHash: '251fae1f7fe89d38c4ce71781685c96291cc3852e9586c8eb6d5a71b73d6285c', + validMembersCount: 90, + }, + { + quorumHash: '1af5ffbdb862a18b454106b6b99e30bb683fa5bab8278b19a6e91bf742405cce', + getAllQuorumMembers: 90, + }, + ]); + + const quorumListExtended = { + llmq_test: [ + { + '251fae1f7fe89d38c4ce71781685c96291cc3852e9586c8eb6d5a71b73d6285c': { + creationHeight: 672, + minedBlockHash: '1af5ffbdb862a18b454106b6b99e30bb683fa5bab8278b19a6e91bf742405cce', + }, + }, + { + '1af5ffbdb862a18b454106b6b99e30bb683fa5bab8278b19a6e91bf742405cce': { + creationHeight: 672, + minedBlockHash: '251fae1f7fe89d38c4ce71781685c96291cc3852e9586c8eb6d5a71b73d6285c', + }, + }, + ], + llmq_test_instantsend: [ + { + '251fae1f7fe89d38c4ce71781685c96291cc3852e9586c8eb6d5a71b73d6285c': { + creationHeight: 672, + minedBlockHash: '1af5ffbdb862a18b454106b6b99e30bb683fa5bab8278b19a6e91bf742405cce', + }, + }, + { + '1af5ffbdb862a18b454106b6b99e30bb683fa5bab8278b19a6e91bf742405cce': { + creationHeight: 672, + minedBlockHash: '251fae1f7fe89d38c4ce71781685c96291cc3852e9586c8eb6d5a71b73d6285c', + }, + }, + ], + llmq_test_v17: [ + { + '251fae1f7fe89d38c4ce71781685c96291cc3852e9586c8eb6d5a71b73d6285c': { + creationHeight: 672, + minedBlockHash: '1af5ffbdb862a18b454106b6b99e30bb683fa5bab8278b19a6e91bf742405cce', + }, + }, + { + '1af5ffbdb862a18b454106b6b99e30bb683fa5bab8278b19a6e91bf742405cce': { + creationHeight: 672, + minedBlockHash: '251fae1f7fe89d38c4ce71781685c96291cc3852e9586c8eb6d5a71b73d6285c', + }, + }, + ], + }; + + coreRpcClientMock = { + quorum: this.sinon.stub().resolves({ result: quorumListExtended }), + }; + + getRandomQuorum = getRandomQuorumFactory(coreRpcClientMock); + }); + + it('should return random quorum based on entropy', async () => { + const result = await getRandomQuorum(smlMock, quorumType, Buffer.alloc(1), coreHeight); + + expect(smlMock.getQuorumsOfType).to.have.been.calledOnceWithExactly(quorumType); + expect(smlMock.getQuorum).to.have.been.calledOnceWithExactly( + quorumType, '251fae1f7fe89d38c4ce71781685c96291cc3852e9586c8eb6d5a71b73d6285c', + ); + expect(result).to.equals(randomQuorum); + }); + + it('should throw an error if SML does not contain any quorums', async () => { + smlMock.getQuorumsOfType.returns([]); + + try { + await getRandomQuorum(smlMock, quorumType, Buffer.alloc(1), coreHeight); + + expect.fail('should throw an error'); + } catch (e) { + expect(e.message).to.be.equal(`SML at block ${'0'.repeat(32)} contains no quorums of any type`); + } + }); + + it('should throw an error if SML contains quorums that differ from the specified quorum type', async () => { + smlMock.getQuorumsOfType.returns([]); + smlMock.quorumList = [{ llmqType: 999 }]; + + try { + await getRandomQuorum(smlMock, quorumType, Buffer.alloc(1), coreHeight); + + expect.fail('should throw an error'); + } catch (e) { + expect(e.message).to.be.equal(`SML at block ${'0'.repeat(32)} contains no quorums of type ${quorumType}, but contains entries for types 999. Please check the Drive configuration`); + } + }); + + it('should filter quorums by minQuorumMembers', async () => { + smlMock.getQuorumsOfType.returns([ + { + quorumHash: '1af5ffbdb862a18b454106b6b99e30bb683fa5bab8278b19a6e91bf742405cce', + validMembersCount: 90, + }, + { + quorumHash: '251fae1f7fe89d38c4ce71781685c96291cc3852e9586c8eb6d5a71b73d6285c', + validMembersCount: 89, + }, + ]); + + const result = await getRandomQuorum(smlMock, quorumType, Buffer.alloc(1), coreHeight); + + expect(smlMock.getQuorumsOfType).to.have.been.calledOnceWithExactly(quorumType); + expect(smlMock.getQuorum).to.have.been.calledOnceWithExactly( + quorumType, '1af5ffbdb862a18b454106b6b99e30bb683fa5bab8278b19a6e91bf742405cce', + ); + expect(result).to.equals(randomQuorum); + }); + + it('should filter quorums by ttl', async () => { + coreRpcClientMock.quorum.resolves({ + result: { + llmq_test_v17: [ + { + '251fae1f7fe89d38c4ce71781685c96291cc3852e9586c8eb6d5a71b73d6285c': { + creationHeight: 100, + minedBlockHash: '1af5ffbdb862a18b454106b6b99e30bb683fa5bab8278b19a6e91bf742405cce', + }, + }, + { + '1af5ffbdb862a18b454106b6b99e30bb683fa5bab8278b19a6e91bf742405cce': { + creationHeight: 672, + minedBlockHash: '251fae1f7fe89d38c4ce71781685c96291cc3852e9586c8eb6d5a71b73d6285c', + }, + }, + ], + }, + }); + + const result = await getRandomQuorum(smlMock, quorumType, Buffer.alloc(1), coreHeight); + + expect(smlMock.getQuorumsOfType).to.have.been.calledOnceWithExactly(quorumType); + expect(smlMock.getQuorum).to.have.been.calledOnceWithExactly( + quorumType, '1af5ffbdb862a18b454106b6b99e30bb683fa5bab8278b19a6e91bf742405cce', + ); + + expect(coreRpcClientMock.quorum).to.be.calledOnceWithExactly('listextended', coreHeight); + expect(result).to.equals(randomQuorum); + }); + + it('should choose from all quorums if filtered list is empty', async () => { + smlMock.getQuorumsOfType.returns([ + { + quorumHash: Buffer.alloc(1, 64).toString('hex'), + validMembersCount: 10, + }, + { + quorumHash: Buffer.alloc(1, 32).toString('hex'), + validMembersCount: 10, + }, + ]); + + const result = await getRandomQuorum(smlMock, quorumType, Buffer.alloc(1), coreHeight); + + expect(smlMock.getQuorumsOfType).to.have.been.calledOnceWithExactly(quorumType); + expect(smlMock.getQuorum).to.have.been.calledOnceWithExactly( + quorumType, '20', + ); + expect(result).to.equals(randomQuorum); + }); +}); diff --git a/packages/js-drive/test/unit/validator/ValidatorSet.spec.js b/packages/js-drive/test/unit/validator/ValidatorSet.spec.js index 82885a699d3..6c6e4ac0b55 100644 --- a/packages/js-drive/test/unit/validator/ValidatorSet.spec.js +++ b/packages/js-drive/test/unit/validator/ValidatorSet.spec.js @@ -128,6 +128,7 @@ describe('ValidatorSet', () => { smlMock, validatorSetLLMQType, Buffer.from(smlDiffMock.blockHash, 'hex'), + coreHeight, ); expect(fetchQuorumMembersMock).to.be.calledOnceWithExactly( @@ -176,6 +177,7 @@ describe('ValidatorSet', () => { smlMock, validatorSetLLMQType, rotationEntropy, + coreHeight, ); expect(fetchQuorumMembersMock).to.be.calledOnceWithExactly( diff --git a/yarn.lock b/yarn.lock index 2b01b8981ad..444e9c4c8e8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9635,9 +9635,9 @@ fsevents@~2.3.2: linkType: hard "luxon@npm:^1.23.x": - version: 1.28.0 - resolution: "luxon@npm:1.28.0" - checksum: 5250cb9f138b6048eeb0b3a9044a4ac994d0058f680c72a0da4b6aeaec8612460385639cba2b1052ef6d5564879e9ed144d686f26d9d97b38ab920d82e18281c + version: 1.28.1 + resolution: "luxon@npm:1.28.1" + checksum: 2c62999adea79645fd1e931d685f61aeb33eda7f39fbf79b84261a233b62f9590f48336654ee9efd405848bd9e13dc7fe087c701b7126187201a88998dc68b04 languageName: node linkType: hard