From 79488fdcaccf60deaa64aad492b8c12e720e8ea4 Mon Sep 17 00:00:00 2001 From: Peolite1 Date: Wed, 29 Jul 2026 02:35:03 +0100 Subject: [PATCH] feat(core): implement configurable extension jitter (#500) --- src/core/extension.ts | 34 +++++++++++------- src/index.ts | 8 ++++- tests/core/extension.test.ts | 68 ++++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 13 deletions(-) diff --git a/src/core/extension.ts b/src/core/extension.ts index b2ec7cd9..f4ddf5af 100644 --- a/src/core/extension.ts +++ b/src/core/extension.ts @@ -322,21 +322,31 @@ export async function runAutoExtensions( result.contractsChecked = eligibleContracts.length; - await Promise.all(eligibleContracts.map(async contract => { + const extJitterStr = process.env.EXTENSION_JITTER_MS; + const extensionJitterMs = extJitterStr ? parseInt(extJitterStr, 10) : 0; + + const eligibleTasks = []; + for (const contract of eligibleContracts) { const policy = getExtensionPolicy(db, contract.id)!; + const entries = getEntriesForContract(db, contract.id); + const needsExtension = entries.filter(e => { + if (!e.live_until_ledger) return false; + const remaining = e.live_until_ledger - latestLedger; + return remaining >= 0 && remaining < policy.extend_when_below_ledgers; + }); + + if (needsExtension.length > 0) { + eligibleTasks.push({ contract, policy, needsExtension }); + } + } - try { - const entries = getEntriesForContract(db, contract.id); - - const needsExtension = entries.filter(e => { - if (!e.live_until_ledger) return false; - const remaining = e.live_until_ledger - latestLedger; - return remaining >= 0 && remaining < policy.extend_when_below_ledgers; - }); - - if (needsExtension.length === 0) return; - + await Promise.all(eligibleTasks.map(async ({ contract, policy, needsExtension }) => { + if (extensionJitterMs > 0 && eligibleTasks.length > 1) { + const delay = Math.floor(Math.random() * extensionJitterMs); + await new Promise(resolve => setTimeout(resolve, delay)); + } + try { // ── Rate limit check (issue #142) ──────────────────────────────── // Block auto-extension if the contract has already hit the maximum // number of extension transactions allowed per hour. diff --git a/src/index.ts b/src/index.ts index 774cb2d0..c47cda1e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,7 +29,8 @@ program .description( "Sorokeep — The missing operations layer for deployed Soroban smart contracts", ) - .version("0.1.2"); + .version("0.1.2") + .option("--extension-jitter-ms ", "Jitter window in ms applied to extension submissions", parseInt); registerWatchCommand(program); registerStatusCommand(program); @@ -51,3 +52,8 @@ registerPauseCommand(program); registerResumeCommand(program); program.parse(process.argv); + +const opts = program.opts(); +if (opts.extensionJitterMs) { + process.env.EXTENSION_JITTER_MS = opts.extensionJitterMs.toString(); +} diff --git a/tests/core/extension.test.ts b/tests/core/extension.test.ts index 05a54d00..cdee97c8 100644 --- a/tests/core/extension.test.ts +++ b/tests/core/extension.test.ts @@ -810,5 +810,73 @@ describe("Core Extension Logic", () => { const anomaly = history.find(h => h.tx_hash === "anomaly-tx"); expect(anomaly!.is_anomaly).toBe(1); }); + it("with jitter disabled (default), submission timing is unchanged from current behavior", async () => { + const id1 = seedContract(db, { id: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYS1" }); + const id2 = seedContract(db, { id: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYS2" }); + + for (const id of [id1, id2]) { + upsertEntry(db, { + contract_id: id, entry_key_xdr: `instance-${id}`, entry_type: "instance", live_until_ledger: 2410000, + }); + upsertExtensionPolicy(db, { + contract_id: id, enabled: true, target_ttl_ledgers: 100000, extend_when_below_ledgers: 20000, keypair_source: "env:TEST_SECRET_KEY", + }); + } + setEnv("TEST_SECRET_KEY", "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + mockGetCurrentLedger.mockResolvedValue(2400000); + mockSubmitExtension.mockResolvedValue({ success: true, txHash: "tx", ledger: 2400100 }); + mockGetEntryTTLs.mockResolvedValue({ + latestLedger: 2400100, + entries: [ + { entryKeyXdr: `instance-${id1}`, latestLedger: 2400100, liveUntilLedgerSeq: 2500100, remainingTTL: 100000 }, + { entryKeyXdr: `instance-${id2}`, latestLedger: 2400100, liveUntilLedgerSeq: 2500100, remainingTTL: 100000 }, + ], + }); + + const startTime = Date.now(); + await runAutoExtensions(db, "testnet"); + const duration = Date.now() - startTime; + + // Should execute instantly without jitter + expect(duration).toBeLessThan(100); + }); + + it("with jitter enabled, multiple queued extensions are not submitted synchronously back-to-back", async () => { + const id1 = seedContract(db, { id: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYS1" }); + const id2 = seedContract(db, { id: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYS2" }); + + for (const id of [id1, id2]) { + upsertEntry(db, { + contract_id: id, entry_key_xdr: `instance-${id}`, entry_type: "instance", live_until_ledger: 2410000, + }); + upsertExtensionPolicy(db, { + contract_id: id, enabled: true, target_ttl_ledgers: 100000, extend_when_below_ledgers: 20000, keypair_source: "env:TEST_SECRET_KEY", + }); + } + setEnv("TEST_SECRET_KEY", "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + setEnv("EXTENSION_JITTER_MS", "300"); // 300ms jitter + + mockGetCurrentLedger.mockResolvedValue(2400000); + mockSubmitExtension.mockResolvedValue({ success: true, txHash: "tx", ledger: 2400100 }); + mockGetEntryTTLs.mockResolvedValue({ + latestLedger: 2400100, + entries: [ + { entryKeyXdr: `instance-${id1}`, latestLedger: 2400100, liveUntilLedgerSeq: 2500100, remainingTTL: 100000 }, + { entryKeyXdr: `instance-${id2}`, latestLedger: 2400100, liveUntilLedgerSeq: 2500100, remainingTTL: 100000 }, + ], + }); + + // Mock Math.random to always return 0.9 to ensure ~270ms delay per task + const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.9); + + const startTime = Date.now(); + await runAutoExtensions(db, "testnet"); + const duration = Date.now() - startTime; + + // Expected delay is at least 270ms (0.9 * 300) since there are multiple queued extensions + expect(duration).toBeGreaterThanOrEqual(270); + + randomSpy.mockRestore(); + }); }); });