Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 22 additions & 12 deletions src/core/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Comment thread
Peolite1 marked this conversation as resolved.
}

try {
// ── Rate limit check (issue #142) ────────────────────────────────
// Block auto-extension if the contract has already hit the maximum
// number of extension transactions allowed per hour.
Expand Down
8 changes: 7 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ms>", "Jitter window in ms applied to extension submissions", parseInt);
Comment thread
Peolite1 marked this conversation as resolved.

registerWatchCommand(program);
registerStatusCommand(program);
Expand All @@ -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();
}
Comment thread
Peolite1 marked this conversation as resolved.
68 changes: 68 additions & 0 deletions tests/core/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Comment thread
Peolite1 marked this conversation as resolved.

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();
});
});
});
Loading