From d458cdaa0e351eb64cfb296dd237aeadf4b6a452 Mon Sep 17 00:00:00 2001 From: Rayane Charif Date: Mon, 9 Feb 2026 13:50:30 +0000 Subject: [PATCH 01/10] PoC: Race condition in worker prevention for cron execution to ensure it runs one at a time across all worker instances Signed-off-by: Rayane Charif --- .../db/migrations/0001_initial_schema.sql | 6 ++ packages/lib/src/test-helpers/init_db.ts | 1 + packages/sui-indexer/src/index.ts | 34 +++++--- packages/sui-indexer/src/storage.test.ts | 79 +++++++++++++++++++ packages/sui-indexer/src/storage.ts | 24 ++++++ 5 files changed, 135 insertions(+), 9 deletions(-) diff --git a/packages/btcindexer/db/migrations/0001_initial_schema.sql b/packages/btcindexer/db/migrations/0001_initial_schema.sql index dc290c1d..3afa4855 100644 --- a/packages/btcindexer/db/migrations/0001_initial_schema.sql +++ b/packages/btcindexer/db/migrations/0001_initial_schema.sql @@ -124,3 +124,9 @@ CREATE TABLE IF NOT EXISTS presign_objects ( ) STRICT; CREATE INDEX IF NOT EXISTS presign_objects_sui_network_created_at ON presign_objects(sui_network, created_at); + +CREATE TABLE IF NOT EXISTS cron_locks ( + lock_name TEXT NOT NULL PRIMARY KEY, + acquired_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +) STRICT; \ No newline at end of file diff --git a/packages/lib/src/test-helpers/init_db.ts b/packages/lib/src/test-helpers/init_db.ts index 62e61e13..481c362c 100644 --- a/packages/lib/src/test-helpers/init_db.ts +++ b/packages/lib/src/test-helpers/init_db.ts @@ -42,6 +42,7 @@ export const tables = [ "btc_blocks", "indexer_state", "presign_objects", + "cron_locks", "setups", ]; diff --git a/packages/sui-indexer/src/index.ts b/packages/sui-indexer/src/index.ts index e75dd993..31caa18d 100644 --- a/packages/sui-indexer/src/index.ts +++ b/packages/sui-indexer/src/index.ts @@ -24,16 +24,32 @@ export default { }, async scheduled(_event: ScheduledController, env: Env, _ctx: ExecutionContext): Promise { const storage = new D1Storage(env.DB); - const activeNetworks = await storage.getActiveNetworks(); - - // Run both indexer and redeem solver tasks in parallel - const results = await Promise.allSettled([ - runSuiIndexer(storage, env, activeNetworks), - runRedeemSolver(storage, env, activeNetworks), - ]); + const lockAcquired = await storage.acquireLock("sui-indexer-cron", 5 * 60 * 1000); // 5 minutes + if (!lockAcquired) { + logger.warn({ + msg: "Cron job already running, skipping this execution", + lockName: "sui-indexer-cron", + }); + return; + } - // Check for any rejected promises and log errors - reportErrors(results, "scheduled", "Scheduled task error", ["SuiIndexer", "RedeemSolver"]); + try { + const activeNetworks = await storage.getActiveNetworks(); + + // Run both indexer and redeem solver tasks in parallel + const results = await Promise.allSettled([ + runSuiIndexer(storage, env, activeNetworks), + runRedeemSolver(storage, env, activeNetworks), + ]); + + // Check for any rejected promises and log errors + reportErrors(results, "scheduled", "Scheduled task error", [ + "SuiIndexer", + "RedeemSolver", + ]); + } finally { + await storage.releaseLock("sui-indexer-cron"); + } }, } satisfies ExportedHandler; diff --git a/packages/sui-indexer/src/storage.test.ts b/packages/sui-indexer/src/storage.test.ts index 59d88a44..68a9eff5 100644 --- a/packages/sui-indexer/src/storage.test.ts +++ b/packages/sui-indexer/src/storage.test.ts @@ -597,4 +597,83 @@ describe("IndexerStorage", () => { expect(inputs[0]!.input_index).toBe(0); expect(inputs[1]!.input_index).toBe(1); }); + + describe("Distributed Lock", () => { + it("should acquire lock when none exists", async () => { + const acquired = await storage.acquireLock("test-lock", 60000); + expect(acquired).toBe(true); + const lock = await db + .prepare("SELECT * FROM cron_locks WHERE lock_name = ?") + .bind("test-lock") + .first<{ lock_name: string; acquired_at: number; expires_at: number }>(); + expect(lock).not.toBeNull(); + expect(lock!.lock_name).toBe("test-lock"); + }); + + it("should fail to acquire lock when already held (not expired)", async () => { + const first = await storage.acquireLock("test-lock", 60000); + expect(first).toBe(true); + const second = await storage.acquireLock("test-lock", 60000); + expect(second).toBe(false); + }); + + it("should acquire lock when existing lock is expired", async () => { + const expiredTime = Date.now() - 10000; + await db + .prepare( + "INSERT INTO cron_locks (lock_name, acquired_at, expires_at) VALUES (?, ?, ?)", + ) + .bind("test-lock", expiredTime - 60000, expiredTime) + .run(); + + const acquired = await storage.acquireLock("test-lock", 60000); + expect(acquired).toBe(true); + + const lock = await db + .prepare("SELECT * FROM cron_locks WHERE lock_name = ?") + .bind("test-lock") + .first<{ expires_at: number }>(); + expect(lock!.expires_at).toBeGreaterThan(Date.now()); + }); + + it("should release lock", async () => { + await storage.acquireLock("test-lock", 60000); + + let lock = await db + .prepare("SELECT * FROM cron_locks WHERE lock_name = ?") + .bind("test-lock") + .first(); + expect(lock).not.toBeNull(); + + await storage.releaseLock("test-lock"); + + lock = await db + .prepare("SELECT * FROM cron_locks WHERE lock_name = ?") + .bind("test-lock") + .first(); + expect(lock).toBeNull(); + }); + + it("should allow reacquiring lock after release", async () => { + const first = await storage.acquireLock("test-lock", 60000); + expect(first).toBe(true); + await storage.releaseLock("test-lock"); + const second = await storage.acquireLock("test-lock", 60000); + expect(second).toBe(true); + }); + + it("should handle multiple different locks independently", async () => { + const lock1 = await storage.acquireLock("lock-1", 60000); + const lock2 = await storage.acquireLock("lock-2", 60000); + + expect(lock1).toBe(true); + expect(lock2).toBe(true); + await storage.releaseLock("lock-1"); + const lock1Again = await storage.acquireLock("lock-1", 60000); + const lock2Again = await storage.acquireLock("lock-2", 60000); + + expect(lock1Again).toBe(true); + expect(lock2Again).toBe(false); + }); + }); }); diff --git a/packages/sui-indexer/src/storage.ts b/packages/sui-indexer/src/storage.ts index 84b72249..97b5524c 100644 --- a/packages/sui-indexer/src/storage.ts +++ b/packages/sui-indexer/src/storage.ts @@ -780,6 +780,30 @@ export class D1Storage { sui_network: toSuiNet(result.sui_network), }; } + + async acquireLock(lockName: string, ttlMs: number): Promise { + const now = Date.now(); + try { + const result = await this.db + .prepare( + `INSERT INTO cron_locks (lock_name, acquired_at, expires_at) + VALUES (?, ?, ?) + ON CONFLICT(lock_name) DO UPDATE + SET acquired_at = excluded.acquired_at, expires_at = excluded.expires_at + WHERE cron_locks.expires_at < excluded.acquired_at + RETURNING 1`, + ) + .bind(lockName, now, now + ttlMs) + .first(); + return !!result; + } catch { + return false; + } + } + + async releaseLock(lockName: string): Promise { + await this.db.prepare(`DELETE FROM cron_locks WHERE lock_name = ?`).bind(lockName).run(); + } } export async function insertRedeemRequest( From f6b000d3b3e4af130f4ce4104e0c73bd30d15571 Mon Sep 17 00:00:00 2001 From: Rayane Charif Date: Tue, 10 Feb 2026 16:43:50 +0000 Subject: [PATCH 02/10] Resolved comments Signed-off-by: Rayane Charif --- bun.lock | 26 ++++++++++++++++++++++++ packages/sui-indexer/src/storage.test.ts | 20 +++++------------- packages/sui-indexer/src/storage.ts | 5 +++-- 3 files changed, 34 insertions(+), 17 deletions(-) diff --git a/bun.lock b/bun.lock index 7c23142b..dab947ff 100644 --- a/bun.lock +++ b/bun.lock @@ -45,6 +45,16 @@ "merkletreejs": "^0.5.2", }, }, + "packages/compliance": { + "name": "@gonative-cc/compliance", + "version": "0.0.1", + "dependencies": { + "@gonative-cc/lib": "workspace:*", + }, + "devDependencies": { + "miniflare": "4.20251008.0", + }, + }, "packages/lib": { "name": "@gonative-cc/lib", "version": "0.0.1", @@ -173,6 +183,8 @@ "@gonative-cc/btcindexer": ["@gonative-cc/btcindexer@workspace:packages/btcindexer"], + "@gonative-cc/compliance": ["@gonative-cc/compliance@workspace:packages/compliance"], + "@gonative-cc/lib": ["@gonative-cc/lib@workspace:packages/lib"], "@gonative-cc/nbtc": ["@gonative-cc/nbtc@0.1.1", "", { "dependencies": { "@mysten/sui": "^1.45.0" }, "peerDependencies": { "typescript": "^5" } }, "sha512-NpnogoK2346wvl9j2yAcyZkMcjd8KBTMQ6jimHRy3Rg2HuPBR8RIDuwDh5/nvpJI2kql6sjCS+SyeiLQ6mJIZg=="], @@ -787,6 +799,8 @@ "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "@gonative-cc/compliance/miniflare": ["miniflare@4.20251008.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "sharp": "^0.33.5", "stoppable": "1.1.0", "undici": "7.14.0", "workerd": "1.20251008.0", "ws": "8.18.0", "youch": "4.1.0-beta.10", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-sKCNYNzXG6l8qg0Oo7y8WcDKcpbgw0qwZsxNpdZilFTR4EavRow2TlcwuPSVN99jqAjhz0M4VXvTdSGdtJ2VfQ=="], + "@gonative-cc/sui-indexer/bitcoinjs-lib": ["bitcoinjs-lib@7.0.1", "", { "dependencies": { "@noble/hashes": "^1.2.0", "bech32": "^2.0.0", "bip174": "^3.0.0", "bs58check": "^4.0.0", "uint8array-tools": "^0.0.9", "valibot": "^1.2.0", "varuint-bitcoin": "^2.0.0" } }, "sha512-vwEmpL5Tpj0I0RBdNkcDMXePoaYSTeKY6mL6/l5esbnTs+jGdPDuLp4NY1hSh6Zk5wSgePygZ4Wx5JJao30Pww=="], "@gonative-cc/sui-indexer/miniflare": ["miniflare@4.20251008.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "sharp": "^0.33.5", "stoppable": "1.1.0", "undici": "7.14.0", "workerd": "1.20251008.0", "ws": "8.18.0", "youch": "4.1.0-beta.10", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-sKCNYNzXG6l8qg0Oo7y8WcDKcpbgw0qwZsxNpdZilFTR4EavRow2TlcwuPSVN99jqAjhz0M4VXvTdSGdtJ2VfQ=="], @@ -823,6 +837,8 @@ "@cloudflare/vitest-pool-workers/wrangler/workerd": ["workerd@1.20251011.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20251011.0", "@cloudflare/workerd-darwin-arm64": "1.20251011.0", "@cloudflare/workerd-linux-64": "1.20251011.0", "@cloudflare/workerd-linux-arm64": "1.20251011.0", "@cloudflare/workerd-windows-64": "1.20251011.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-Dq35TLPEJAw7BuYQMkN3p9rge34zWMU2Gnd4DSJFeVqld4+DAO2aPG7+We2dNIAyM97S8Y9BmHulbQ00E0HC7Q=="], + "@gonative-cc/compliance/miniflare/workerd": ["workerd@1.20251008.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20251008.0", "@cloudflare/workerd-darwin-arm64": "1.20251008.0", "@cloudflare/workerd-linux-64": "1.20251008.0", "@cloudflare/workerd-linux-arm64": "1.20251008.0", "@cloudflare/workerd-windows-64": "1.20251008.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-HwaJmXO3M1r4S8x2ea2vy8Rw/y/38HRQuK/gNDRQ7w9cJXn6xSl1sIIqKCffULSUjul3wV3I3Nd/GfbmsRReEA=="], + "@gonative-cc/sui-indexer/bitcoinjs-lib/bip174": ["bip174@3.0.0", "", { "dependencies": { "uint8array-tools": "^0.0.9", "varuint-bitcoin": "^2.0.0" } }, "sha512-N3vz3rqikLEu0d6yQL8GTrSkpYb35NQKWMR7Hlza0lOj6ZOlvQ3Xr7N9Y+JPebaCVoEUHdBeBSuLxcHr71r+Lw=="], "@gonative-cc/sui-indexer/bitcoinjs-lib/bs58check": ["bs58check@4.0.0", "", { "dependencies": { "@noble/hashes": "^1.2.0", "bs58": "^6.0.0" } }, "sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g=="], @@ -955,6 +971,16 @@ "@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20251011.0", "", { "os": "win32", "cpu": "x64" }, "sha512-RIXUQRchFdqEvaUqn1cXZXSKjpqMaSaVAkI5jNZ8XzAw/bw2bcdOVUtakrflgxDprltjFb0PTNtuss1FKtH9Jg=="], + "@gonative-cc/compliance/miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20251008.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-yph0H+8mMOK5Z9oDwjb8rI96oTVt4no5lZ43aorcbzsWG9VUIaXSXlBBoB3von6p4YCRW+J3n36fBM9XZ6TLaA=="], + + "@gonative-cc/compliance/miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20251008.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Yc4lMGSbM4AEtYRpyDpmk77MsHb6X2BSwJgMgGsLVPmckM7ZHivZkJChfcNQjZ/MGR6nkhYc4iF6TcVS+UMEVw=="], + + "@gonative-cc/compliance/miniflare/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20251008.0", "", { "os": "linux", "cpu": "x64" }, "sha512-AjoQnylw4/5G6SmfhZRsli7EuIK7ZMhmbxtU0jkpciTlVV8H01OsFOgS1d8zaTXMfkWamEfMouy8oH/L7B9YcQ=="], + + "@gonative-cc/compliance/miniflare/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20251008.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-hRy9yyvzVq1HsqHZUmFkAr0C8JGjAD/PeeVEGCKL3jln3M9sNCKIrbDXiL+efe+EwajJNNlDxpO+s30uVWVaRg=="], + + "@gonative-cc/compliance/miniflare/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20251008.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Gm0RR+ehfNMsScn2pUcn3N9PDUpy7FyvV9ecHEyclKttvztyFOcmsF14bxEaSVv7iM4TxWEBn1rclmYHxDM4ow=="], + "@gonative-cc/sui-indexer/bitcoinjs-lib/bs58check/bs58": ["bs58@6.0.0", "", { "dependencies": { "base-x": "^5.0.0" } }, "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw=="], "@gonative-cc/sui-indexer/bitcoinjs-lib/varuint-bitcoin/uint8array-tools": ["uint8array-tools@0.0.8", "", {}, "sha512-xS6+s8e0Xbx++5/0L+yyexukU7pz//Yg6IHg3BKhXotg1JcYtgxVcUctQ0HxLByiJzpAkNFawz1Nz5Xadzo82g=="], diff --git a/packages/sui-indexer/src/storage.test.ts b/packages/sui-indexer/src/storage.test.ts index 6465cbaa..28c7f594 100644 --- a/packages/sui-indexer/src/storage.test.ts +++ b/packages/sui-indexer/src/storage.test.ts @@ -602,10 +602,11 @@ describe("IndexerStorage", () => { it("should acquire lock when none exists", async () => { const acquired = await storage.acquireLock("test-lock", 60000); expect(acquired).toBe(true); + const lock = await db .prepare("SELECT * FROM cron_locks WHERE lock_name = ?") .bind("test-lock") - .first<{ lock_name: string; acquired_at: number; expires_at: number }>(); + .first<{ lock_name: string }>(); expect(lock).not.toBeNull(); expect(lock!.lock_name).toBe("test-lock"); }); @@ -613,6 +614,7 @@ describe("IndexerStorage", () => { it("should fail to acquire lock when already held (not expired)", async () => { const first = await storage.acquireLock("test-lock", 60000); expect(first).toBe(true); + const second = await storage.acquireLock("test-lock", 60000); expect(second).toBe(false); }); @@ -657,23 +659,11 @@ describe("IndexerStorage", () => { it("should allow reacquiring lock after release", async () => { const first = await storage.acquireLock("test-lock", 60000); expect(first).toBe(true); + await storage.releaseLock("test-lock"); + const second = await storage.acquireLock("test-lock", 60000); expect(second).toBe(true); }); - - it("should handle multiple different locks independently", async () => { - const lock1 = await storage.acquireLock("lock-1", 60000); - const lock2 = await storage.acquireLock("lock-2", 60000); - - expect(lock1).toBe(true); - expect(lock2).toBe(true); - await storage.releaseLock("lock-1"); - const lock1Again = await storage.acquireLock("lock-1", 60000); - const lock2Again = await storage.acquireLock("lock-2", 60000); - - expect(lock1Again).toBe(true); - expect(lock2Again).toBe(false); - }); }); }); diff --git a/packages/sui-indexer/src/storage.ts b/packages/sui-indexer/src/storage.ts index 97b5524c..bc9c6922 100644 --- a/packages/sui-indexer/src/storage.ts +++ b/packages/sui-indexer/src/storage.ts @@ -790,13 +790,14 @@ export class D1Storage { VALUES (?, ?, ?) ON CONFLICT(lock_name) DO UPDATE SET acquired_at = excluded.acquired_at, expires_at = excluded.expires_at - WHERE cron_locks.expires_at < excluded.acquired_at + WHERE cron_locks.expires_at <= excluded.acquired_at RETURNING 1`, ) .bind(lockName, now, now + ttlMs) .first(); return !!result; - } catch { + } catch (error) { + logError({ msg: "Failed to acquire lock", method: "acquireLock", lockName }, error); return false; } } From 526eed90a187adb17e7bde6e13b46ebdba66d2a1 Mon Sep 17 00:00:00 2001 From: Rayane Charif Date: Tue, 17 Feb 2026 15:43:05 +0100 Subject: [PATCH 03/10] Resolved comments Signed-off-by: Rayane Charif --- packages/sui-indexer/src/index.ts | 6 +-- packages/sui-indexer/src/storage.test.ts | 50 +++++++++++++++--------- packages/sui-indexer/src/storage.ts | 17 ++++---- 3 files changed, 44 insertions(+), 29 deletions(-) diff --git a/packages/sui-indexer/src/index.ts b/packages/sui-indexer/src/index.ts index 03a762da..79a40ef8 100644 --- a/packages/sui-indexer/src/index.ts +++ b/packages/sui-indexer/src/index.ts @@ -25,8 +25,8 @@ export default { }, async scheduled(_event: ScheduledController, env: Env, _ctx: ExecutionContext): Promise { const storage = new D1Storage(env.DB); - const lockAcquired = await storage.acquireLock("sui-indexer-cron", 5 * 60 * 1000); // 5 minutes - if (!lockAcquired) { + const lockToken = await storage.acquireLock("sui-indexer-cron", 5 * 60 * 1000); // 5 minutes + if (lockToken === null) { logger.warn({ msg: "Cron job already running, skipping this execution", lockName: "sui-indexer-cron", @@ -52,7 +52,7 @@ export default { "RedeemSolver", ]); } finally { - await storage.releaseLock("sui-indexer-cron"); + await storage.releaseLock("sui-indexer-cron", lockToken); } }, } satisfies ExportedHandler; diff --git a/packages/sui-indexer/src/storage.test.ts b/packages/sui-indexer/src/storage.test.ts index 28c7f594..5b6f88a2 100644 --- a/packages/sui-indexer/src/storage.test.ts +++ b/packages/sui-indexer/src/storage.test.ts @@ -600,8 +600,8 @@ describe("IndexerStorage", () => { describe("Distributed Lock", () => { it("should acquire lock when none exists", async () => { - const acquired = await storage.acquireLock("test-lock", 60000); - expect(acquired).toBe(true); + const token = await storage.acquireLock("test-lock", 60000); + expect(token).not.toBeNull(); const lock = await db .prepare("SELECT * FROM cron_locks WHERE lock_name = ?") @@ -613,10 +613,10 @@ describe("IndexerStorage", () => { it("should fail to acquire lock when already held (not expired)", async () => { const first = await storage.acquireLock("test-lock", 60000); - expect(first).toBe(true); + expect(first).not.toBeNull(); const second = await storage.acquireLock("test-lock", 60000); - expect(second).toBe(false); + expect(second).toBeNull(); }); it("should acquire lock when existing lock is expired", async () => { @@ -628,8 +628,8 @@ describe("IndexerStorage", () => { .bind("test-lock", expiredTime - 60000, expiredTime) .run(); - const acquired = await storage.acquireLock("test-lock", 60000); - expect(acquired).toBe(true); + const token = await storage.acquireLock("test-lock", 60000); + expect(token).not.toBeNull(); const lock = await db .prepare("SELECT * FROM cron_locks WHERE lock_name = ?") @@ -638,18 +638,13 @@ describe("IndexerStorage", () => { expect(lock!.expires_at).toBeGreaterThan(Date.now()); }); - it("should release lock", async () => { - await storage.acquireLock("test-lock", 60000); + it("should release lock with matching token", async () => { + const token = await storage.acquireLock("test-lock", 60000); + expect(token).not.toBeNull(); - let lock = await db - .prepare("SELECT * FROM cron_locks WHERE lock_name = ?") - .bind("test-lock") - .first(); - expect(lock).not.toBeNull(); + await storage.releaseLock("test-lock", token!); - await storage.releaseLock("test-lock"); - - lock = await db + const lock = await db .prepare("SELECT * FROM cron_locks WHERE lock_name = ?") .bind("test-lock") .first(); @@ -658,12 +653,29 @@ describe("IndexerStorage", () => { it("should allow reacquiring lock after release", async () => { const first = await storage.acquireLock("test-lock", 60000); - expect(first).toBe(true); + expect(first).not.toBeNull(); - await storage.releaseLock("test-lock"); + await storage.releaseLock("test-lock", first!); const second = await storage.acquireLock("test-lock", 60000); - expect(second).toBe(true); + expect(second).not.toBeNull(); + }); + + it("should not release another instance's lock after expiry", async () => { + const tokenA = await storage.acquireLock("test-lock", 10); + expect(tokenA).not.toBeNull(); + await new Promise((resolve) => setTimeout(resolve, 20)); + const tokenB = await storage.acquireLock("test-lock", 60000); + expect(tokenB).not.toBeNull(); + + await storage.releaseLock("test-lock", tokenA!); + + const lock = await db + .prepare("SELECT * FROM cron_locks WHERE lock_name = ?") + .bind("test-lock") + .first<{ acquired_at: number }>(); + expect(lock).not.toBeNull(); + expect(lock!.acquired_at).toBe(tokenB!); }); }); }); diff --git a/packages/sui-indexer/src/storage.ts b/packages/sui-indexer/src/storage.ts index d05763f8..5d757db2 100644 --- a/packages/sui-indexer/src/storage.ts +++ b/packages/sui-indexer/src/storage.ts @@ -855,7 +855,7 @@ export class D1Storage { }; } - async acquireLock(lockName: string, ttlMs: number): Promise { + async acquireLock(lockName: string, ttlMs: number): Promise { const now = Date.now(); try { const result = await this.db @@ -865,19 +865,22 @@ export class D1Storage { ON CONFLICT(lock_name) DO UPDATE SET acquired_at = excluded.acquired_at, expires_at = excluded.expires_at WHERE cron_locks.expires_at <= excluded.acquired_at - RETURNING 1`, + RETURNING acquired_at`, ) .bind(lockName, now, now + ttlMs) - .first(); - return !!result; + .first("acquired_at"); + return result ?? null; } catch (error) { logError({ msg: "Failed to acquire lock", method: "acquireLock", lockName }, error); - return false; + return null; } } - async releaseLock(lockName: string): Promise { - await this.db.prepare(`DELETE FROM cron_locks WHERE lock_name = ?`).bind(lockName).run(); + async releaseLock(lockName: string, acquiredAt: number): Promise { + await this.db + .prepare(`DELETE FROM cron_locks WHERE lock_name = ? AND acquired_at = ?`) + .bind(lockName, acquiredAt) + .run(); } } From 629511df1a2bdffa96bf0e6c4157797b14ad6b48 Mon Sep 17 00:00:00 2001 From: Rayane Charif Date: Tue, 17 Feb 2026 15:43:31 +0100 Subject: [PATCH 04/10] Resolved prettier fix Signed-off-by: Rayane Charif --- AGENTS.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 78f67340..d3bb444b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,7 @@ This is a Cloudflare Workers project that implements various services for [Go Na It uses [Bun](https://bun.com/) for JavaScript and Typescript runtime and package management (instead of Nodejs + npm). The content is organized into a Bun workspace. See @README.md for: + - High-level architecture and component interactions - Detailed setup instructions - Functional flow documentation @@ -15,13 +16,13 @@ The content is organized into a Bun workspace. See @README.md for: All packages are in the `./packages` directory: -| Package | Type | Purpose | -|---------|------|---------| -| `btcindexer` | Service (Worker) | Bitcoin-to-Sui bridging and minting | -| `sui-indexer` | Service (Worker) | Sui blockchain monitoring and redemption | -| `block-ingestor` | Service (Worker) | Receives Bitcoin blocks via REST API | -| `compliance` | Service (Worker) | Sanctions and geo-blocking data | -| `lib` | Shared Library | Common utilities and types | +| Package | Type | Purpose | +| ---------------- | ---------------- | ---------------------------------------- | +| `btcindexer` | Service (Worker) | Bitcoin-to-Sui bridging and minting | +| `sui-indexer` | Service (Worker) | Sui blockchain monitoring and redemption | +| `block-ingestor` | Service (Worker) | Receives Bitcoin blocks via REST API | +| `compliance` | Service (Worker) | Sanctions and geo-blocking data | +| `lib` | Shared Library | Common utilities and types | ### Core Technologies From af04a0b289624777a3c3d438639f547c35763a77 Mon Sep 17 00:00:00 2001 From: Rayane Charif Date: Wed, 18 Feb 2026 12:28:25 +0100 Subject: [PATCH 05/10] Resolved comments Signed-off-by: Rayane Charif --- packages/sui-indexer/src/storage.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/sui-indexer/src/storage.ts b/packages/sui-indexer/src/storage.ts index 5d757db2..389c8cf0 100644 --- a/packages/sui-indexer/src/storage.ts +++ b/packages/sui-indexer/src/storage.ts @@ -872,15 +872,23 @@ export class D1Storage { return result ?? null; } catch (error) { logError({ msg: "Failed to acquire lock", method: "acquireLock", lockName }, error); - return null; + throw error; } } async releaseLock(lockName: string, acquiredAt: number): Promise { - await this.db - .prepare(`DELETE FROM cron_locks WHERE lock_name = ? AND acquired_at = ?`) - .bind(lockName, acquiredAt) - .run(); + try { + await this.db + .prepare(`DELETE FROM cron_locks WHERE lock_name = ? AND acquired_at = ?`) + .bind(lockName, acquiredAt) + .run(); + } catch (error) { + logError( + { msg: "Failed to release lock", method: "releaseLock", lockName, acquiredAt }, + error, + ); + throw error; + } } } From 6df75c33869cdf213d420c773195f5be3c658820 Mon Sep 17 00:00:00 2001 From: Rayane Charif Date: Wed, 18 Feb 2026 12:29:23 +0100 Subject: [PATCH 06/10] Taking agent.md file from master Signed-off-by: Rayane Charif --- AGENTS.md | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d3bb444b..78f67340 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,6 @@ This is a Cloudflare Workers project that implements various services for [Go Na It uses [Bun](https://bun.com/) for JavaScript and Typescript runtime and package management (instead of Nodejs + npm). The content is organized into a Bun workspace. See @README.md for: - - High-level architecture and component interactions - Detailed setup instructions - Functional flow documentation @@ -16,13 +15,13 @@ The content is organized into a Bun workspace. See @README.md for: All packages are in the `./packages` directory: -| Package | Type | Purpose | -| ---------------- | ---------------- | ---------------------------------------- | -| `btcindexer` | Service (Worker) | Bitcoin-to-Sui bridging and minting | -| `sui-indexer` | Service (Worker) | Sui blockchain monitoring and redemption | -| `block-ingestor` | Service (Worker) | Receives Bitcoin blocks via REST API | -| `compliance` | Service (Worker) | Sanctions and geo-blocking data | -| `lib` | Shared Library | Common utilities and types | +| Package | Type | Purpose | +|---------|------|---------| +| `btcindexer` | Service (Worker) | Bitcoin-to-Sui bridging and minting | +| `sui-indexer` | Service (Worker) | Sui blockchain monitoring and redemption | +| `block-ingestor` | Service (Worker) | Receives Bitcoin blocks via REST API | +| `compliance` | Service (Worker) | Sanctions and geo-blocking data | +| `lib` | Shared Library | Common utilities and types | ### Core Technologies From af58a86c82488cf00915bf1664969e01410a17e1 Mon Sep 17 00:00:00 2001 From: Rayane Charif Date: Wed, 18 Feb 2026 12:50:41 +0100 Subject: [PATCH 07/10] Fixed agent.md lint and prettier error Signed-off-by: Rayane Charif --- AGENTS.md | 49 +++++++++++++++++++++++++------------------------ 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 78f67340..17812ca6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,7 @@ This is a Cloudflare Workers project that implements various services for [Go Na It uses [Bun](https://bun.com/) for JavaScript and Typescript runtime and package management (instead of Nodejs + npm). The content is organized into a Bun workspace. See @README.md for: + - High-level architecture and component interactions - Detailed setup instructions - Functional flow documentation @@ -15,13 +16,13 @@ The content is organized into a Bun workspace. See @README.md for: All packages are in the `./packages` directory: -| Package | Type | Purpose | -|---------|------|---------| -| `btcindexer` | Service (Worker) | Bitcoin-to-Sui bridging and minting | -| `sui-indexer` | Service (Worker) | Sui blockchain monitoring and redemption | -| `block-ingestor` | Service (Worker) | Receives Bitcoin blocks via REST API | -| `compliance` | Service (Worker) | Sanctions and geo-blocking data | -| `lib` | Shared Library | Common utilities and types | +| Package | Type | Purpose | +| ---------------- | ---------------- | ---------------------------------------- | +| `btcindexer` | Service (Worker) | Bitcoin-to-Sui bridging and minting | +| `sui-indexer` | Service (Worker) | Sui blockchain monitoring and redemption | +| `block-ingestor` | Service (Worker) | Receives Bitcoin blocks via REST API | +| `compliance` | Service (Worker) | Sanctions and geo-blocking data | +| `lib` | Shared Library | Common utilities and types | ### Core Technologies @@ -96,7 +97,7 @@ See @README.md for Cloudflare RPC documentation and examples. **Location**: `./packages/btcindexer` -### Architecture +### BTCIndexer Architecture - `src/index.ts` - Main entry with HTTP handlers and scheduled cron - `src/btcindexer.ts` - Bitcoin indexing and deposit detection logic @@ -105,14 +106,14 @@ See @README.md for Cloudflare RPC documentation and examples. - `src/cf-storage.ts` - Cloudflare D1/KV storage layer - `src/bitcoin-merkle-tree.ts` - Merkle proof generation -### Key Features +### BTCIndexer Key Features 1. **Bitcoin Block Processing**: Cron job runs every minute to scan blocks, identify nBTC deposits via OP_RETURN outputs 2. **Cross-Chain Minting**: Tracks deposits and mints corresponding nBTC on Sui with Merkle proof validation 3. **Data Storage**: Uses D1 for transaction data, KV for block storage 4. **Queue Consumption**: Consumes blocks from `block-queue` populated by block-ingestor -### Configuration +### BTCIndexer Configuration - **Cron**: Every minute (`* * * * *`) - **D1 Database**: `btcindexer-dev` @@ -121,7 +122,7 @@ See @README.md for Cloudflare RPC documentation and examples. - **Service Bindings**: `SuiIndexer`, `Compliance` - **Secrets**: `NBTC_MINTING_SIGNER_MNEMONIC` (via Secrets Store) -### Database Schema +### BTCIndexer Database Schema See migration files in `packages/btcindexer/db/migrations/`: @@ -138,7 +139,7 @@ See migration files in `packages/btcindexer/db/migrations/`: **Location**: `./packages/sui-indexer` -### Architecture +### Sui Indexer Architecture - `src/index.ts` - Entry point with scheduled task - `src/processor.ts` - Sui event indexing @@ -156,13 +157,13 @@ The Sui Indexer integrates with IKA (MPC service) for threshold signature operat - Manages presign objects for Bitcoin transaction signing - Implements coin selection logic for redemption transactions -### Key Features +### Sui Indexer Key Features 1. **Event Monitoring**: Indexes Sui events for nBTC redemption requests 2. **Redemption Processing**: Handles burn-and-redeem flow with IKA MPC 3. **UTXO Management**: Manages UTXO lifecycle (available → locked → spent) -### Configuration +### Sui Indexer Configuration - **Cron**: Every minute (`* * * * *`) - **D1 Database**: Shared `btcindexer-dev` @@ -176,18 +177,18 @@ The Sui Indexer integrates with IKA (MPC service) for threshold signature operat See @packages/block-ingestor/README.md for detailed architecture. -### Architecture +### Block Ingestor Architecture - `src/index.ts` - HTTP router and handlers - `src/ingest.ts` - Block ingestion logic - `src/api/put-blocks.ts` - msgpack encoding/decoding - `src/api/client.ts` - Client for sending blocks -### Key Features +### Block Ingestor Key Features Receives Bitcoin blocks via REST API, validates them, and enqueues to `block-queue` for processing by BTCIndexer. -### Configuration +### Block Ingestor Configuration - **KV Namespace**: `BtcBlocks` (shared with btcindexer) - **Queue Producer**: `block-queue` @@ -197,25 +198,25 @@ Receives Bitcoin blocks via REST API, validates them, and enqueues to `block-que **Location**: `./packages/compliance` -### Architecture +### Compliance Architecture - `src/index.ts` - Scheduled worker entry point - `src/sanction.ts` - Sanctions list updating logic - `src/storage.ts` - D1 storage for sanctions - `src/rpc.ts` - RPC interface for other services to query compliance data -### Key Features +### Compliance Key Features 1. **Sanctions List Updates**: Daily cron job fetches and updates sanctions data 2. **Compliance API**: Exposes RPC methods for other services to check addresses 3. **Geo-blocking**: Supports geo-blocking rules -### Configuration +### Compliance Configuration - **Cron**: Daily at 1am (`0 1 * * *`) - **D1 Database**: `compliance` -### Database Schema +### Compliance Database Schema See migration files in `packages/compliance/db/migrations/`. @@ -223,7 +224,7 @@ See migration files in `packages/compliance/db/migrations/`. **Location**: `./packages/lib` -### Architecture +### Lib Architecture Shared library package containing utilities used across all services: @@ -237,7 +238,7 @@ Shared library package containing utilities used across all services: - `src/rpc-types.ts` - Shared RPC type definitions - `src/test-helpers/` - Test utilities including D1 initialization -### Key Features +### Lib Key Features 1. **Shared Types**: Network enums, block records, transaction status types 2. **Testing Support**: Mock RPC implementations and D1 test helpers @@ -255,7 +256,7 @@ Shared library package containing utilities used across all services: - **Framework**: Bun's built-in test framework with Miniflare for mocking Workers - **Mock RPC**: Each service provides `RPCMock` implementation for isolated testing -- **Test Data**: Real Bitcoin regtest blocks from https://learnmeabitcoin.com/explorer/ +- **Test Data**: Real Bitcoin regtest blocks from [learnmeabitcoin.com](https://learnmeabitcoin.com/explorer/) - **Test Helpers**: Located in `packages/lib/src/test-helpers/` ### Configuration Pattern From 70b1aa20b0db107f7bc3bebd7781b7d003f4edab Mon Sep 17 00:00:00 2001 From: Rayane Charif Date: Thu, 19 Feb 2026 14:36:37 +0100 Subject: [PATCH 08/10] Resolved merged conflicts Signed-off-by: Rayane Charif --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 17812ca6..6ee11972 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -256,7 +256,7 @@ Shared library package containing utilities used across all services: - **Framework**: Bun's built-in test framework with Miniflare for mocking Workers - **Mock RPC**: Each service provides `RPCMock` implementation for isolated testing -- **Test Data**: Real Bitcoin regtest blocks from [learnmeabitcoin.com](https://learnmeabitcoin.com/explorer/) +- **Test Data**: Real Bitcoin regtest blocks from - **Test Helpers**: Located in `packages/lib/src/test-helpers/` ### Configuration Pattern From 71dd3060d41ff07b286fd1d0fb629a133bc8e97f Mon Sep 17 00:00:00 2001 From: Rayane Charif Date: Thu, 19 Feb 2026 15:47:20 +0100 Subject: [PATCH 09/10] Resolved comments Signed-off-by: Rayane Charif --- packages/sui-indexer/src/index.ts | 2 +- packages/sui-indexer/src/storage.test.ts | 60 ++++++------------------ packages/sui-indexer/src/storage.ts | 11 ++--- 3 files changed, 19 insertions(+), 54 deletions(-) diff --git a/packages/sui-indexer/src/index.ts b/packages/sui-indexer/src/index.ts index 79a40ef8..cb79d30f 100644 --- a/packages/sui-indexer/src/index.ts +++ b/packages/sui-indexer/src/index.ts @@ -52,7 +52,7 @@ export default { "RedeemSolver", ]); } finally { - await storage.releaseLock("sui-indexer-cron", lockToken); + await storage.releaseLock("sui-indexer-cron"); } }, } satisfies ExportedHandler; diff --git a/packages/sui-indexer/src/storage.test.ts b/packages/sui-indexer/src/storage.test.ts index 5b6f88a2..f1101ade 100644 --- a/packages/sui-indexer/src/storage.test.ts +++ b/packages/sui-indexer/src/storage.test.ts @@ -599,22 +599,22 @@ describe("IndexerStorage", () => { }); describe("Distributed Lock", () => { - it("should acquire lock when none exists", async () => { + async function getLock(lockName: string) { + return db + .prepare("SELECT * FROM cron_locks WHERE lock_name = ?") + .bind(lockName) + .first<{ lock_name: string; acquired_at: number; expires_at: number }>(); + } + + it("should acquire lock and reject duplicate", async () => { const token = await storage.acquireLock("test-lock", 60000); expect(token).not.toBeNull(); - const lock = await db - .prepare("SELECT * FROM cron_locks WHERE lock_name = ?") - .bind("test-lock") - .first<{ lock_name: string }>(); + const lock = await getLock("test-lock"); expect(lock).not.toBeNull(); expect(lock!.lock_name).toBe("test-lock"); - }); - - it("should fail to acquire lock when already held (not expired)", async () => { - const first = await storage.acquireLock("test-lock", 60000); - expect(first).not.toBeNull(); + // second acquire should fail const second = await storage.acquireLock("test-lock", 60000); expect(second).toBeNull(); }); @@ -631,51 +631,19 @@ describe("IndexerStorage", () => { const token = await storage.acquireLock("test-lock", 60000); expect(token).not.toBeNull(); - const lock = await db - .prepare("SELECT * FROM cron_locks WHERE lock_name = ?") - .bind("test-lock") - .first<{ expires_at: number }>(); + const lock = await getLock("test-lock"); expect(lock!.expires_at).toBeGreaterThan(Date.now()); }); - it("should release lock with matching token", async () => { + it("should release lock and allow reacquiring", async () => { const token = await storage.acquireLock("test-lock", 60000); expect(token).not.toBeNull(); - await storage.releaseLock("test-lock", token!); - - const lock = await db - .prepare("SELECT * FROM cron_locks WHERE lock_name = ?") - .bind("test-lock") - .first(); - expect(lock).toBeNull(); - }); - - it("should allow reacquiring lock after release", async () => { - const first = await storage.acquireLock("test-lock", 60000); - expect(first).not.toBeNull(); - - await storage.releaseLock("test-lock", first!); + await storage.releaseLock("test-lock"); + expect(await getLock("test-lock")).toBeNull(); const second = await storage.acquireLock("test-lock", 60000); expect(second).not.toBeNull(); }); - - it("should not release another instance's lock after expiry", async () => { - const tokenA = await storage.acquireLock("test-lock", 10); - expect(tokenA).not.toBeNull(); - await new Promise((resolve) => setTimeout(resolve, 20)); - const tokenB = await storage.acquireLock("test-lock", 60000); - expect(tokenB).not.toBeNull(); - - await storage.releaseLock("test-lock", tokenA!); - - const lock = await db - .prepare("SELECT * FROM cron_locks WHERE lock_name = ?") - .bind("test-lock") - .first<{ acquired_at: number }>(); - expect(lock).not.toBeNull(); - expect(lock!.acquired_at).toBe(tokenB!); - }); }); }); diff --git a/packages/sui-indexer/src/storage.ts b/packages/sui-indexer/src/storage.ts index 389c8cf0..a54f7a27 100644 --- a/packages/sui-indexer/src/storage.ts +++ b/packages/sui-indexer/src/storage.ts @@ -876,17 +876,14 @@ export class D1Storage { } } - async releaseLock(lockName: string, acquiredAt: number): Promise { + async releaseLock(lockName: string): Promise { try { await this.db - .prepare(`DELETE FROM cron_locks WHERE lock_name = ? AND acquired_at = ?`) - .bind(lockName, acquiredAt) + .prepare(`DELETE FROM cron_locks WHERE lock_name = ?`) + .bind(lockName) .run(); } catch (error) { - logError( - { msg: "Failed to release lock", method: "releaseLock", lockName, acquiredAt }, - error, - ); + logError({ msg: "Failed to release lock", method: "releaseLock", lockName }, error); throw error; } } From da5e35dd04b44ba82397c16701914dd6c40cd1c2 Mon Sep 17 00:00:00 2001 From: Rayane Charif Date: Thu, 19 Feb 2026 16:04:30 +0100 Subject: [PATCH 10/10] Resolved comments Signed-off-by: Rayane Charif --- packages/sui-indexer/src/index.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/sui-indexer/src/index.ts b/packages/sui-indexer/src/index.ts index cb79d30f..087f534e 100644 --- a/packages/sui-indexer/src/index.ts +++ b/packages/sui-indexer/src/index.ts @@ -25,11 +25,14 @@ export default { }, async scheduled(_event: ScheduledController, env: Env, _ctx: ExecutionContext): Promise { const storage = new D1Storage(env.DB); - const lockToken = await storage.acquireLock("sui-indexer-cron", 5 * 60 * 1000); // 5 minutes + // Distributed lock to prevent overlapping cron executions from selecting + // same Sui coins. Since CF Workers doesn't guarantee sequential cron running, + // if a run exceeds the 1-min interval, the next trigger starts concurrently. + const lockToken = await storage.acquireLock("cron-sui-indexer", 5 * 60 * 1000); if (lockToken === null) { logger.warn({ msg: "Cron job already running, skipping this execution", - lockName: "sui-indexer-cron", + lockName: "cron-sui-indexer", }); return; } @@ -52,7 +55,7 @@ export default { "RedeemSolver", ]); } finally { - await storage.releaseLock("sui-indexer-cron"); + await storage.releaseLock("cron-sui-indexer"); } }, } satisfies ExportedHandler;