Skip to content

Commit ad7ebd0

Browse files
ItsAdelshankars99
authored andcommitted
[INT-454] Add chunking of safe multisig batching + LZ_BATCH_SIZE (#1613)
1 parent 60b23f8 commit ad7ebd0

3 files changed

Lines changed: 316 additions & 19 deletions

File tree

.changeset/dull-dolphins-share.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@layerzerolabs/devtools": patch
3+
---
4+
5+
Add chunking of the multisig batching

packages/devtools/src/transactions/signer.ts

Lines changed: 36 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -195,31 +195,48 @@ const sendBatchedIfAvailable =
195195
return await fallbackLogic(eid, logger, signer, transactions, onSuccess, onError)
196196
}
197197

198-
// For brevity we'll create a variable that holds a string with pluralized label for the transactions
199-
// e.g. "0 transactions" or "1 transaction"
200-
const transactionsName = pluralizeNoun(
201-
transactions.length,
202-
`1 transaction`,
203-
`${transactions.length} transactions`
198+
const DEFAULT_BATCH_SIZE = 20
199+
200+
// Get the batch size from an environment variable or default size
201+
const batchSize = Number(process.env.LZ_BATCH_SIZE) || DEFAULT_BATCH_SIZE
202+
const totalBatches = Math.ceil(transactions.length / batchSize)
203+
logger.debug(
204+
`Sending ${transactions.length} transactions for ${eidName} in ${totalBatches} batches of up to ${batchSize}`
204205
)
205206

206-
try {
207-
logger.debug(`Signing a batch of ${transactionsName} for ${eidName}`)
208-
const response = await signer.signAndSendBatch(transactions)
207+
// Loop through the transactions in chunks of the specified batchSize
208+
for (let i = 0; i < transactions.length; i += batchSize) {
209+
const batch = transactions.slice(i, i + batchSize)
210+
const batchNumber = Math.floor(i / batchSize) + 1
209211

210-
logger.debug(`Signed a batch of ${transactionsName} for ${eidName}, got hash ${response.transactionHash}`)
211-
const receipt = await response.wait()
212+
const transactionsName = pluralizeNoun(batch.length, `1 transaction`, `${batch.length} transactions`)
212213

213-
logger.debug(`Finished a batch of ${transactionsName} for ${eidName}`)
214+
try {
215+
logger.debug(`Signing batch ${batchNumber}/${totalBatches} (${transactionsName}) for ${eidName}`)
216+
const response = await signer.signAndSendBatch(batch)
214217

215-
for (const transaction of transactions) {
216-
onSuccess({ transaction, receipt })
217-
}
218-
} catch (error) {
219-
logger.debug(`Failed to process a batch of ${transactionsName} for ${eidName}: ${error}`)
218+
logger.debug(
219+
`Signed batch ${batchNumber}/${totalBatches} for ${eidName}, got hash ${response.transactionHash}`
220+
)
221+
const receipt = await response.wait()
222+
223+
logger.debug(`Finished batch ${batchNumber}/${totalBatches} for ${eidName}`)
224+
225+
// If the batch was successful, report success for each transaction within it
226+
for (const transaction of batch) {
227+
onSuccess({ transaction, receipt })
228+
}
229+
} catch (error) {
230+
logger.error(`Failed to process batch ${batchNumber}/${totalBatches} for ${eidName}: ${error}`)
231+
232+
// If a batch fails, report an error for each transaction within it
233+
for (const transaction of batch) {
234+
onError({ transaction, error })
235+
}
220236

221-
for (const transaction of transactions) {
222-
onError({ transaction, error })
237+
// Stop processing further batches for this endpoint if one fails
238+
logger.warn(`Halting further batches for ${eidName} due to a failed batch`)
239+
return
223240
}
224241
}
225242
}

packages/devtools/test/transactions/signer.test.ts

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,281 @@ describe('transactions/signer', () => {
537537
})
538538
)
539539
})
540+
541+
describe('chunked batching', () => {
542+
it('should chunk transactions when they exceed LZ_BATCH_SIZE', async () => {
543+
const originalBatchSize = process.env.LZ_BATCH_SIZE
544+
process.env.LZ_BATCH_SIZE = '3'
545+
546+
try {
547+
// Create 7 transactions for the same EID to ensure chunking
548+
const eid = 30101
549+
const transactions = Array.from({ length: 7 }, (_, i) => ({
550+
point: { eid, address: `0x${i}` },
551+
data: `0x${i}`,
552+
}))
553+
554+
const receipt = { transactionHash: '0x0' }
555+
const wait = jest.fn().mockResolvedValue(receipt)
556+
const response: OmniTransactionResponse = {
557+
transactionHash: '0x0',
558+
wait,
559+
}
560+
561+
const signAndSendBatch = jest.fn().mockResolvedValue(response)
562+
const signAndSend = jest.fn().mockRejectedValue('Oh god no')
563+
const sign = jest.fn().mockRejectedValue('Oh god no')
564+
const signerFactory: OmniSignerFactory = jest
565+
.fn()
566+
.mockResolvedValue({ signAndSend, signAndSendBatch, sign })
567+
const signAndSendTransactions = createSignAndSend(signerFactory)
568+
569+
const [successful, errors, pending] = await signAndSendTransactions(transactions)
570+
571+
// Should have 3 calls to signAndSendBatch: [3, 3, 1]
572+
expect(signAndSendBatch).toHaveBeenCalledTimes(3)
573+
expect(signAndSendBatch).toHaveBeenNthCalledWith(1, transactions.slice(0, 3))
574+
expect(signAndSendBatch).toHaveBeenNthCalledWith(2, transactions.slice(3, 6))
575+
expect(signAndSendBatch).toHaveBeenNthCalledWith(3, transactions.slice(6, 7))
576+
577+
expect(successful).toHaveLength(7)
578+
expect(errors).toEqual([])
579+
expect(pending).toEqual([])
580+
} finally {
581+
process.env.LZ_BATCH_SIZE = originalBatchSize
582+
}
583+
})
584+
585+
it('should use default batch size when LZ_BATCH_SIZE is not set', async () => {
586+
const originalBatchSize = process.env.LZ_BATCH_SIZE
587+
delete process.env.LZ_BATCH_SIZE
588+
589+
try {
590+
// Create 25 transactions for the same EID (more than default batch size of 20)
591+
const eid = 30101
592+
const transactions = Array.from({ length: 25 }, (_, i) => ({
593+
point: { eid, address: `0x${i}` },
594+
data: `0x${i}`,
595+
}))
596+
597+
const receipt = { transactionHash: '0x0' }
598+
const wait = jest.fn().mockResolvedValue(receipt)
599+
const response: OmniTransactionResponse = {
600+
transactionHash: '0x0',
601+
wait,
602+
}
603+
604+
const signAndSendBatch = jest.fn().mockResolvedValue(response)
605+
const signAndSend = jest.fn().mockRejectedValue('Oh god no')
606+
const sign = jest.fn().mockRejectedValue('Oh god no')
607+
const signerFactory: OmniSignerFactory = jest
608+
.fn()
609+
.mockResolvedValue({ signAndSend, signAndSendBatch, sign })
610+
const signAndSendTransactions = createSignAndSend(signerFactory)
611+
612+
const [successful, errors, pending] = await signAndSendTransactions(transactions)
613+
614+
// Should have 2 calls to signAndSendBatch: [20, 5] (default batch size is 20)
615+
expect(signAndSendBatch).toHaveBeenCalledTimes(2)
616+
expect(signAndSendBatch).toHaveBeenNthCalledWith(1, transactions.slice(0, 20))
617+
expect(signAndSendBatch).toHaveBeenNthCalledWith(2, transactions.slice(20, 25))
618+
619+
expect(successful).toHaveLength(25)
620+
expect(errors).toEqual([])
621+
expect(pending).toEqual([])
622+
} finally {
623+
process.env.LZ_BATCH_SIZE = originalBatchSize
624+
}
625+
})
626+
627+
it('should not chunk when transactions are within batch size limit', async () => {
628+
const originalBatchSize = process.env.LZ_BATCH_SIZE
629+
process.env.LZ_BATCH_SIZE = '10'
630+
631+
try {
632+
// Create 5 transactions for the same EID (less than batch size)
633+
const eid = 30101
634+
const transactions = Array.from({ length: 5 }, (_, i) => ({
635+
point: { eid, address: `0x${i}` },
636+
data: `0x${i}`,
637+
}))
638+
639+
const receipt = { transactionHash: '0x0' }
640+
const wait = jest.fn().mockResolvedValue(receipt)
641+
const response: OmniTransactionResponse = {
642+
transactionHash: '0x0',
643+
wait,
644+
}
645+
646+
const signAndSendBatch = jest.fn().mockResolvedValue(response)
647+
const signAndSend = jest.fn().mockRejectedValue('Oh god no')
648+
const sign = jest.fn().mockRejectedValue('Oh god no')
649+
const signerFactory: OmniSignerFactory = jest
650+
.fn()
651+
.mockResolvedValue({ signAndSend, signAndSendBatch, sign })
652+
const signAndSendTransactions = createSignAndSend(signerFactory)
653+
654+
const [successful, errors, pending] = await signAndSendTransactions(transactions)
655+
656+
// Should have 1 call to signAndSendBatch with all 5 transactions
657+
expect(signAndSendBatch).toHaveBeenCalledTimes(1)
658+
expect(signAndSendBatch).toHaveBeenNthCalledWith(1, transactions)
659+
660+
expect(successful).toHaveLength(5)
661+
expect(errors).toEqual([])
662+
expect(pending).toEqual([])
663+
} finally {
664+
process.env.LZ_BATCH_SIZE = originalBatchSize
665+
}
666+
})
667+
668+
it('should stop processing batches when one batch fails', async () => {
669+
const originalBatchSize = process.env.LZ_BATCH_SIZE
670+
process.env.LZ_BATCH_SIZE = '3'
671+
672+
try {
673+
// Create 9 transactions for the same EID to ensure chunking
674+
const eid = 30101
675+
const transactions = Array.from({ length: 9 }, (_, i) => ({
676+
point: { eid, address: `0x${i}` },
677+
data: `0x${i}`,
678+
}))
679+
680+
const receipt = { transactionHash: '0x0' }
681+
const wait = jest.fn().mockResolvedValue(receipt)
682+
const successResponse: OmniTransactionResponse = {
683+
transactionHash: '0x0',
684+
wait,
685+
}
686+
687+
const error = new Error('Batch failed')
688+
const signAndSendBatch = jest
689+
.fn()
690+
.mockResolvedValueOnce(successResponse) // First batch succeeds
691+
.mockRejectedValueOnce(error) // Second batch fails
692+
.mockResolvedValue(successResponse) // Third batch would succeed but shouldn't be called
693+
694+
const signAndSend = jest.fn().mockRejectedValue('Oh god no')
695+
const sign = jest.fn().mockRejectedValue('Oh god no')
696+
const signerFactory: OmniSignerFactory = jest
697+
.fn()
698+
.mockResolvedValue({ signAndSend, signAndSendBatch, sign })
699+
const signAndSendTransactions = createSignAndSend(signerFactory)
700+
701+
const [successful, errors, pending] = await signAndSendTransactions(transactions)
702+
703+
// Should have 2 calls to signAndSendBatch (first succeeds, second fails, third not called)
704+
expect(signAndSendBatch).toHaveBeenCalledTimes(2)
705+
expect(signAndSendBatch).toHaveBeenNthCalledWith(1, transactions.slice(0, 3))
706+
expect(signAndSendBatch).toHaveBeenNthCalledWith(2, transactions.slice(3, 6))
707+
708+
// First batch should succeed
709+
expect(successful).toHaveLength(3)
710+
expect(successful).toContainAllValues(
711+
transactions.slice(0, 3).map((transaction) => ({ transaction, receipt }))
712+
)
713+
714+
// Second batch should fail
715+
expect(errors).toHaveLength(3)
716+
expect(errors).toContainAllValues(
717+
transactions.slice(3, 6).map((transaction) => ({ transaction, error }))
718+
)
719+
720+
// Third batch should be pending
721+
expect(pending).toContainAllValues(transactions.slice(3, 9))
722+
} finally {
723+
process.env.LZ_BATCH_SIZE = originalBatchSize
724+
}
725+
})
726+
727+
it('should handle chunking across multiple EIDs correctly', async () => {
728+
const originalBatchSize = process.env.LZ_BATCH_SIZE
729+
process.env.LZ_BATCH_SIZE = '2'
730+
731+
try {
732+
// Create transactions for different EIDs
733+
const transactions = [
734+
{ point: { eid: 30101, address: '0x1' }, data: '0x1' },
735+
{ point: { eid: 30101, address: '0x2' }, data: '0x2' },
736+
{ point: { eid: 30101, address: '0x3' }, data: '0x3' },
737+
{ point: { eid: 30102, address: '0x4' }, data: '0x4' },
738+
{ point: { eid: 30102, address: '0x5' }, data: '0x5' },
739+
{ point: { eid: 30102, address: '0x6' }, data: '0x6' },
740+
]
741+
742+
const receipt = { transactionHash: '0x0' }
743+
const wait = jest.fn().mockResolvedValue(receipt)
744+
const response: OmniTransactionResponse = {
745+
transactionHash: '0x0',
746+
wait,
747+
}
748+
749+
const signAndSendBatch = jest.fn().mockResolvedValue(response)
750+
const signAndSend = jest.fn().mockRejectedValue('Oh god no')
751+
const sign = jest.fn().mockRejectedValue('Oh god no')
752+
const signerFactory: OmniSignerFactory = jest
753+
.fn()
754+
.mockResolvedValue({ signAndSend, signAndSendBatch, sign })
755+
const signAndSendTransactions = createSignAndSend(signerFactory)
756+
757+
const [successful, errors, pending] = await signAndSendTransactions(transactions)
758+
759+
// Should chunk per EID:
760+
// EID 30101: 3 transactions -> 2 batches [2, 1]
761+
// EID 30102: 3 transactions -> 2 batches [2, 1]
762+
// Total: 4 calls to signAndSendBatch
763+
expect(signAndSendBatch).toHaveBeenCalledTimes(4)
764+
765+
expect(successful).toHaveLength(6)
766+
expect(errors).toEqual([])
767+
expect(pending).toEqual([])
768+
} finally {
769+
process.env.LZ_BATCH_SIZE = originalBatchSize
770+
}
771+
})
772+
773+
it('should handle edge case of exact multiple of batch size', async () => {
774+
const originalBatchSize = process.env.LZ_BATCH_SIZE
775+
process.env.LZ_BATCH_SIZE = '3'
776+
777+
try {
778+
// Create exactly 6 transactions (2 * batch size)
779+
const eid = 30101
780+
const transactions = Array.from({ length: 6 }, (_, i) => ({
781+
point: { eid, address: `0x${i}` },
782+
data: `0x${i}`,
783+
}))
784+
785+
const receipt = { transactionHash: '0x0' }
786+
const wait = jest.fn().mockResolvedValue(receipt)
787+
const response: OmniTransactionResponse = {
788+
transactionHash: '0x0',
789+
wait,
790+
}
791+
792+
const signAndSendBatch = jest.fn().mockResolvedValue(response)
793+
const signAndSend = jest.fn().mockRejectedValue('Oh god no')
794+
const sign = jest.fn().mockRejectedValue('Oh god no')
795+
const signerFactory: OmniSignerFactory = jest
796+
.fn()
797+
.mockResolvedValue({ signAndSend, signAndSendBatch, sign })
798+
const signAndSendTransactions = createSignAndSend(signerFactory)
799+
800+
const [successful, errors, pending] = await signAndSendTransactions(transactions)
801+
802+
// Should have exactly 2 calls to signAndSendBatch with 3 transactions each
803+
expect(signAndSendBatch).toHaveBeenCalledTimes(2)
804+
expect(signAndSendBatch).toHaveBeenNthCalledWith(1, transactions.slice(0, 3))
805+
expect(signAndSendBatch).toHaveBeenNthCalledWith(2, transactions.slice(3, 6))
806+
807+
expect(successful).toHaveLength(6)
808+
expect(errors).toEqual([])
809+
expect(pending).toEqual([])
810+
} finally {
811+
process.env.LZ_BATCH_SIZE = originalBatchSize
812+
}
813+
})
814+
})
540815
})
541816
})
542817
})

0 commit comments

Comments
 (0)