@openzeppelin/relayer-plugin-channels 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,12 +24,14 @@ export declare const CONFIG: {
24
24
  };
25
25
  export declare const POOL: {
26
26
  readonly CLAIM_LOCK_TTL_SECONDS: 3;
27
- readonly ACQUIRE_MAX_SPINS: 30;
28
- readonly ACQUIRE_RETRY_MIN_MS: 10;
29
- readonly ACQUIRE_RETRY_MAX_MS: 30;
27
+ readonly ACQUIRE_MAX_SPINS: 12;
28
+ readonly ACQUIRE_BASE_DELAY_MS: 25;
29
+ readonly ACQUIRE_MAX_DELAY_MS: 500;
30
+ readonly MAX_CLAIMS_PER_SPIN: 3;
30
31
  readonly CHANNEL_COOLDOWN_MS: 6000;
31
- readonly LRU_MAP_TTL_SECONDS: 86400;
32
+ readonly LRU_KEY_TTL_SECONDS: 86400;
32
33
  };
34
+ export declare const RELAYER_INFO_CACHE_TTL_SECONDS = 1800;
33
35
  export declare const TIME: {
34
36
  readonly MAX_TIME_BOUND_OFFSET_SECONDS: 60;
35
37
  };
@@ -1 +1 @@
1
- {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../../src/plugin/constants.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,eAAO,MAAM,WAAW;;;;;;;;;;CAUd,CAAC;AAGX,eAAO,MAAM,MAAM;;;;;;;CAOT,CAAC;AAGX,eAAO,MAAM,IAAI;;;;;;;CAYP,CAAC;AAGX,eAAO,MAAM,IAAI;;CAEP,CAAC;AAGX,eAAO,MAAM,UAAU;;;;;;IAMrB,uLAAuL;;CAE/K,CAAC;AAGX,eAAO,MAAM,OAAO;;;CAGV,CAAC;AAGX,eAAO,MAAM,OAAO;;;CAGV,CAAC;AAEX,eAAO,MAAM,GAAG;;CAGN,CAAC"}
1
+ {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../../src/plugin/constants.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,eAAO,MAAM,WAAW;;;;;;;;;;CAUd,CAAC;AAGX,eAAO,MAAM,MAAM;;;;;;;CAOT,CAAC;AAGX,eAAO,MAAM,IAAI;;;;;;;;CAcP,CAAC;AAGX,eAAO,MAAM,8BAA8B,OAAQ,CAAC;AAGpD,eAAO,MAAM,IAAI;;CAEP,CAAC;AAGX,eAAO,MAAM,UAAU;;;;;;IAMrB,uLAAuL;;CAE/K,CAAC;AAGX,eAAO,MAAM,OAAO;;;CAGV,CAAC;AAGX,eAAO,MAAM,OAAO;;;CAGV,CAAC;AAEX,eAAO,MAAM,GAAG;;CAGN,CAAC"}
@@ -5,7 +5,7 @@
5
5
  * Centralized constants for the channel accounts plugin.
6
6
  */
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
- exports.FEE = exports.POLLING = exports.TIMEOUT = exports.SIMULATION = exports.TIME = exports.POOL = exports.CONFIG = exports.HTTP_STATUS = void 0;
8
+ exports.FEE = exports.POLLING = exports.TIMEOUT = exports.SIMULATION = exports.TIME = exports.RELAYER_INFO_CACHE_TTL_SECONDS = exports.POOL = exports.CONFIG = exports.HTTP_STATUS = void 0;
9
9
  // HTTP Status Codes
10
10
  exports.HTTP_STATUS = {
11
11
  BAD_REQUEST: 400,
@@ -32,15 +32,19 @@ exports.POOL = {
32
32
  // Per-channel claim lock TTL — must exceed worst-case callback latency
33
33
  // to prevent TTL expiry allowing a second worker into the same claim section
34
34
  CLAIM_LOCK_TTL_SECONDS: 3,
35
- // Retry policy when all candidates busy
36
- ACQUIRE_MAX_SPINS: 30,
37
- ACQUIRE_RETRY_MIN_MS: 10,
38
- ACQUIRE_RETRY_MAX_MS: 30,
35
+ // Retry policy: exponential backoff with full jitter
36
+ ACQUIRE_MAX_SPINS: 12,
37
+ ACQUIRE_BASE_DELAY_MS: 25,
38
+ ACQUIRE_MAX_DELAY_MS: 500,
39
+ // Max claim-lock attempts per spin (batch size)
40
+ MAX_CLAIMS_PER_SPIN: 3,
39
41
  // Hard-block cooldown for uncertain-outcome channels (~1 Stellar ledger with margin)
40
42
  CHANNEL_COOLDOWN_MS: 6000,
41
- // Housekeeping TTL for the single LRU map document
42
- LRU_MAP_TTL_SECONDS: 86400,
43
+ // Housekeeping TTL for per-channel LRU keys
44
+ LRU_KEY_TTL_SECONDS: 86400,
43
45
  };
46
+ // Relayer info cache — address and network_type are effectively immutable
47
+ exports.RELAYER_INFO_CACHE_TTL_SECONDS = 1800; // 30 minutes
44
48
  // Time Constants
45
49
  exports.TIME = {
46
50
  MAX_TIME_BOUND_OFFSET_SECONDS: 60,
@@ -5,7 +5,20 @@
5
5
  * Orchestrates the transaction processing pipeline using channel accounts with fee bumping.
6
6
  */
7
7
  import { PluginContext } from '@openzeppelin/relayer-sdk';
8
+ import type { Relayer } from '@openzeppelin/relayer-sdk';
8
9
  import { Transaction, xdr } from '@stellar/stellar-sdk';
10
+ /** Subset of relayer metadata used by the plugin (address + network_type). */
11
+ type CachedRelayerInfo = {
12
+ address: string;
13
+ network_type: string;
14
+ };
15
+ /** @internal Exported for test isolation only. */
16
+ export declare function clearRelayerInfoCache(): void;
17
+ /**
18
+ * Return cached relayer info or fetch from the API and cache the result.
19
+ * Returns null if the relayer has no address (misconfigured).
20
+ */
21
+ export declare function getCachedRelayerInfo(network: string, relayerId: string, relayer: Relayer): Promise<CachedRelayerInfo | null>;
9
22
  /**
10
23
  * Extracts func and auth from an unsigned Soroban transaction.
11
24
  * Returns null if the transaction is not a single invokeHostFunction operation.
@@ -18,4 +31,5 @@ export declare function extractFuncAuthFromUnsignedXdr(tx: Transaction): {
18
31
  * Main plugin handler exported for OpenZeppelin Relayer
19
32
  */
20
33
  export declare function handler(context: PluginContext): Promise<any>;
34
+ export {};
21
35
  //# sourceMappingURL=handler.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../../src/plugin/handler.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,aAAa,EAAe,MAAM,2BAA2B,CAAC;AASvE,OAAO,EAAE,WAAW,EAAE,GAAG,EAAE,MAAM,sBAAsB,CAAC;AA2BxD;;;GAGG;AACH,wBAAgB,8BAA8B,CAC5C,EAAE,EAAE,WAAW,GACd;IAAE,IAAI,EAAE,GAAG,CAAC,YAAY,CAAC;IAAC,IAAI,EAAE,GAAG,CAAC,yBAAyB,EAAE,CAAA;CAAE,GAAG,IAAI,CAkB1E;AAyTD;;GAEG;AACH,wBAAsB,OAAO,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,GAAG,CAAC,CAElE"}
1
+ {"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../../src/plugin/handler.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,aAAa,EAAe,MAAM,2BAA2B,CAAC;AACvE,OAAO,KAAK,EAA4B,OAAO,EAAE,MAAM,2BAA2B,CAAC;AAQnF,OAAO,EAAE,WAAW,EAAE,GAAG,EAAE,MAAM,sBAAsB,CAAC;AAOxD,8EAA8E;AAC9E,KAAK,iBAAiB,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,CAAC;AAUnE,kDAAkD;AAClD,wBAAgB,qBAAqB,IAAI,IAAI,CAE5C;AAED;;;GAGG;AACH,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,OAAO,GACf,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAanC;AAsBD;;;GAGG;AACH,wBAAgB,8BAA8B,CAC5C,EAAE,EAAE,WAAW,GACd;IAAE,IAAI,EAAE,GAAG,CAAC,YAAY,CAAC;IAAC,IAAI,EAAE,GAAG,CAAC,yBAAyB,EAAE,CAAA;CAAE,GAAG,IAAI,CAkB1E;AAyTD;;GAEG;AACH,wBAAsB,OAAO,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,GAAG,CAAC,CAElE"}
@@ -6,6 +6,8 @@
6
6
  * Orchestrates the transaction processing pipeline using channel accounts with fee bumping.
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.clearRelayerInfoCache = clearRelayerInfoCache;
10
+ exports.getCachedRelayerInfo = getCachedRelayerInfo;
9
11
  exports.extractFuncAuthFromUnsignedXdr = extractFuncAuthFromUnsignedXdr;
10
12
  exports.handler = handler;
11
13
  const relayer_sdk_1 = require("@openzeppelin/relayer-sdk");
@@ -21,6 +23,35 @@ const fee_1 = require("./fee");
21
23
  const tx_1 = require("./tx");
22
24
  const fee_tracking_1 = require("./fee-tracking");
23
25
  const sequence_1 = require("./sequence");
26
+ /**
27
+ * In-memory cache for relayer info. Avoids a remote API call (getRelayer → HTTP GET)
28
+ * on every request while a channel lock is held. Keyed by `${network}:${relayerId}`.
29
+ * Entries expire after RELAYER_INFO_CACHE_TTL_SECONDS; stale entries are evicted on miss.
30
+ */
31
+ const relayerInfoCache = new Map();
32
+ /** @internal Exported for test isolation only. */
33
+ function clearRelayerInfoCache() {
34
+ relayerInfoCache.clear();
35
+ }
36
+ /**
37
+ * Return cached relayer info or fetch from the API and cache the result.
38
+ * Returns null if the relayer has no address (misconfigured).
39
+ */
40
+ async function getCachedRelayerInfo(network, relayerId, relayer) {
41
+ const cacheKey = `${network}:${relayerId}`;
42
+ const cached = relayerInfoCache.get(cacheKey);
43
+ if (cached && cached.expiresAt > Date.now())
44
+ return cached.info;
45
+ // Evict stale entry so it doesn't linger in memory
46
+ if (cached)
47
+ relayerInfoCache.delete(cacheKey);
48
+ const info = await relayer.getRelayer();
49
+ if (!info?.address)
50
+ return null;
51
+ const entry = { address: info.address, network_type: info.network_type };
52
+ relayerInfoCache.set(cacheKey, { info: entry, expiresAt: Date.now() + constants_1.RELAYER_INFO_CACHE_TTL_SECONDS * 1000 });
53
+ return entry;
54
+ }
24
55
  function getApiKey(headers, headerName) {
25
56
  const values = headers[headerName];
26
57
  return values?.[0]?.trim() || undefined;
@@ -94,7 +125,7 @@ async function handleFuncAuthSubmit(func, auth, ctx, skipWait) {
94
125
  try {
95
126
  poolLock = await ctx.pool.acquire(ctx.acquireOptions);
96
127
  const channelRelayer = ctx.api.useRelayer(poolLock.relayerId);
97
- const channelInfo = await channelRelayer.getRelayer();
128
+ const channelInfo = await getCachedRelayerInfo(ctx.network, poolLock.relayerId, channelRelayer);
98
129
  console.log(`[channels] Acquired channel: ${poolLock.relayerId}`);
99
130
  if (!channelInfo || !channelInfo.address) {
100
131
  throw (0, relayer_sdk_1.pluginError)('Channel relayer not found', {
@@ -224,7 +255,7 @@ async function channelAccounts(context) {
224
255
  hash: stellar.hash ?? null,
225
256
  };
226
257
  }
227
- const fundInfo = await fundRelayer.getRelayer();
258
+ const fundInfo = await getCachedRelayerInfo(config.network, fundRelayerId, fundRelayer);
228
259
  if (!fundInfo || !fundInfo.address) {
229
260
  throw (0, relayer_sdk_1.pluginError)('Fund relayer not found', {
230
261
  code: 'RELAYER_UNAVAILABLE',
@@ -5,6 +5,7 @@
5
5
  * - Membership comes from KV: <network>:channel:relayer-ids
6
6
  * - Per-relayer locks with tokens: <network>:channel:in-use:<relayerId>
7
7
  * - Uses per-channel claim locks to make acquire safe across workers.
8
+ * - LRU ordering via per-channel keys: <network>:channel:lru:<relayerId>
8
9
  */
9
10
  import { PluginKVStore } from '@openzeppelin/relayer-sdk';
10
11
  export type PoolLock = {
@@ -23,8 +24,8 @@ export declare class ChannelPool {
23
24
  constructor(network: 'testnet' | 'mainnet', kv: PluginKVStore, lockTtlSeconds: number);
24
25
  /** Acquire a relayerId with a token lock */
25
26
  acquire(options: AcquireOptions): Promise<PoolLock>;
26
- /** Read phase + claim phase (no global mutex) */
27
- private tryAcquire;
27
+ /** Try to claim one channel from a batch of candidates */
28
+ private tryClaimBatch;
28
29
  /** Attempt to claim a single channel under its per-channel lock */
29
30
  private tryClaimChannel;
30
31
  /** Extend the lock TTL if we still own it (e.g. after WAIT_TIMEOUT) */
@@ -34,11 +35,13 @@ export declare class ChannelPool {
34
35
  /** Release with cooldown: keeps lock alive with short TTL to hard-block the channel. */
35
36
  releaseWithCooldown(lock: PoolLock, cooldownMs?: 6000): Promise<void>;
36
37
  private membershipKey;
37
- private lockKeyPrefix;
38
38
  private lockKey;
39
39
  private claimKey;
40
- private lruMapKey;
41
- private updateLruMap;
40
+ private lruKey;
41
+ /** Read per-channel LRU timestamps in parallel (partial failures keep successful reads) */
42
+ private readLruMap;
43
+ /** Fire-and-forget LRU timestamp update for a single channel */
44
+ private updateLru;
42
45
  private getRelayerIdsFromKV;
43
46
  private getPoolCapacityDetails;
44
47
  }
@@ -1 +1 @@
1
- {"version":3,"file":"pool.d.ts","sourceRoot":"","sources":["../../src/plugin/pool.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,aAAa,EAAe,MAAM,2BAA2B,CAAC;AAIvE,MAAM,MAAM,QAAQ,GAAG;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAE5D,MAAM,MAAM,cAAc,GAAG;IAC3B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gBAAgB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC9B,aAAa,EAAE,MAAM,CAAC;CACvB,CAAC;AAUF,qBAAa,WAAW;IACtB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAwB;IAChD,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAS;IAC3C,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAgB;gBAEvB,OAAO,EAAE,SAAS,GAAG,SAAS,EAAE,EAAE,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM;IAMrF,4CAA4C;IACtC,OAAO,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,QAAQ,CAAC;IAuBzD,iDAAiD;YACnC,UAAU;IAqDxB,mEAAmE;YACrD,eAAe;IAsB7B,uEAAuE;IACjE,UAAU,CAAC,IAAI,EAAE,QAAQ,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAgBhE,oCAAoC;IAC9B,OAAO,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IAY5C,wFAAwF;IAClF,mBAAmB,CAAC,IAAI,EAAE,QAAQ,EAAE,UAAU,OAA2B,GAAG,OAAO,CAAC,IAAI,CAAC;IAa/F,OAAO,CAAC,aAAa;IAIrB,OAAO,CAAC,aAAa;IAIrB,OAAO,CAAC,OAAO;IAIf,OAAO,CAAC,QAAQ;IAIhB,OAAO,CAAC,SAAS;YAIH,YAAY;YAWZ,mBAAmB;YAYnB,sBAAsB;CAUrC"}
1
+ {"version":3,"file":"pool.d.ts","sourceRoot":"","sources":["../../src/plugin/pool.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,aAAa,EAAe,MAAM,2BAA2B,CAAC;AAIvE,MAAM,MAAM,QAAQ,GAAG;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAE5D,MAAM,MAAM,cAAc,GAAG;IAC3B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gBAAgB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC9B,aAAa,EAAE,MAAM,CAAC;CACvB,CAAC;AAUF,qBAAa,WAAW;IACtB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAwB;IAChD,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAS;IAC3C,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAgB;gBAEvB,OAAO,EAAE,SAAS,GAAG,SAAS,EAAE,EAAE,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM;IAMrF,4CAA4C;IACtC,OAAO,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,QAAQ,CAAC;IA8DzD,0DAA0D;YAC5C,aAAa;IAQ3B,mEAAmE;YACrD,eAAe;IAuB7B,uEAAuE;IACjE,UAAU,CAAC,IAAI,EAAE,QAAQ,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAgBhE,oCAAoC;IAC9B,OAAO,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IAY5C,wFAAwF;IAClF,mBAAmB,CAAC,IAAI,EAAE,QAAQ,EAAE,UAAU,OAA2B,GAAG,OAAO,CAAC,IAAI,CAAC;IAa/F,OAAO,CAAC,aAAa;IAIrB,OAAO,CAAC,OAAO;IAIf,OAAO,CAAC,QAAQ;IAIhB,OAAO,CAAC,MAAM;IAId,2FAA2F;YAC7E,UAAU;IASxB,gEAAgE;IAChE,OAAO,CAAC,SAAS;YAMH,mBAAmB;YAYnB,sBAAsB;CAUrC"}
@@ -6,6 +6,7 @@
6
6
  * - Membership comes from KV: <network>:channel:relayer-ids
7
7
  * - Per-relayer locks with tokens: <network>:channel:in-use:<relayerId>
8
8
  * - Uses per-channel claim locks to make acquire safe across workers.
9
+ * - LRU ordering via per-channel keys: <network>:channel:lru:<relayerId>
9
10
  */
10
11
  var __importDefault = (this && this.__importDefault) || function (mod) {
11
12
  return (mod && mod.__esModule) ? mod : { "default": mod };
@@ -24,12 +25,46 @@ class ChannelPool {
24
25
  /** Acquire a relayerId with a token lock */
25
26
  async acquire(options) {
26
27
  const maxSpins = constants_1.POOL.ACQUIRE_MAX_SPINS;
28
+ // --- Read state ONCE for the entire retry loop ---
29
+ let ids = await this.getRelayerIdsFromKV();
30
+ if (ids.length === 0) {
31
+ throw (0, relayer_sdk_1.pluginError)('No channel accounts configured. Use the management API to set channel accounts.', {
32
+ code: 'NO_CHANNELS_CONFIGURED',
33
+ status: constants_1.HTTP_STATUS.SERVICE_UNAVAILABLE,
34
+ });
35
+ }
36
+ if (options.contractId && options.limitedContracts.has(options.contractId)) {
37
+ ids = filterChannelsForLimitedContract(ids, options.capacityRatio);
38
+ }
39
+ // Shuffle all candidates; batch size scales so every spin covers an
40
+ // equal slice of the pool, guaranteeing full coverage across all spins.
41
+ const candidates = ids.slice();
42
+ shuffle(candidates);
43
+ // Read LRU for a small prefix only (capped at maxSpins * POOL.MAX_CLAIMS_PER_SPIN) to guide initial ordering.
44
+ // The rest stay in random shuffled order — no extra Redis reads.
45
+ const lruSampleSize = Math.min(candidates.length, maxSpins * constants_1.POOL.MAX_CLAIMS_PER_SPIN);
46
+ const lruMap = await this.readLruMap(candidates.slice(0, lruSampleSize));
47
+ const prefix = candidates.slice(0, lruSampleSize);
48
+ prefix.sort((a, b) => (lruMap[a] ?? 0) - (lruMap[b] ?? 0));
49
+ for (let j = 0; j < prefix.length; j++)
50
+ candidates[j] = prefix[j];
51
+ // Batch size: cover the full pool across maxSpins iterations
52
+ const batchSize = Math.max(constants_1.POOL.MAX_CLAIMS_PER_SPIN, Math.ceil(candidates.length / maxSpins));
53
+ let offset = 0;
27
54
  for (let i = 0; i < maxSpins; i++) {
28
- const result = await this.tryAcquire(options);
55
+ if (offset >= candidates.length) {
56
+ // Full sweep done — reshuffle for next sweep
57
+ offset = 0;
58
+ shuffle(candidates);
59
+ }
60
+ const batch = candidates.slice(offset, offset + batchSize);
61
+ offset += batchSize;
62
+ const result = await this.tryClaimBatch(batch);
29
63
  if (result)
30
64
  return result;
31
- const jitter = constants_1.POOL.ACQUIRE_RETRY_MIN_MS +
32
- Math.floor(Math.random() * (constants_1.POOL.ACQUIRE_RETRY_MAX_MS - constants_1.POOL.ACQUIRE_RETRY_MIN_MS + 1));
65
+ // Exponential backoff with full jitter
66
+ const baseDelay = Math.min(constants_1.POOL.ACQUIRE_MAX_DELAY_MS, constants_1.POOL.ACQUIRE_BASE_DELAY_MS * Math.pow(2, i));
67
+ const jitter = Math.floor(Math.random() * baseDelay);
33
68
  await sleep(jitter);
34
69
  }
35
70
  const diagnostics = await this.getPoolCapacityDetails(options, maxSpins);
@@ -40,50 +75,9 @@ class ChannelPool {
40
75
  details: diagnostics,
41
76
  });
42
77
  }
43
- /** Read phase + claim phase (no global mutex) */
44
- async tryAcquire(options) {
45
- // --- READ PHASE (no lock) ---
46
- let ids = await this.getRelayerIdsFromKV();
47
- if (ids.length === 0) {
48
- throw (0, relayer_sdk_1.pluginError)('No channel accounts configured. Use the management API to set channel accounts.', {
49
- code: 'NO_CHANNELS_CONFIGURED',
50
- status: constants_1.HTTP_STATUS.SERVICE_UNAVAILABLE,
51
- });
52
- }
53
- if (options.contractId && options.limitedContracts.has(options.contractId)) {
54
- ids = filterChannelsForLimitedContract(ids, options.capacityRatio);
55
- }
56
- const lockPrefix = this.lockKeyPrefix();
57
- let lruMap = {};
58
- try {
59
- lruMap = (await this.kv.get(this.lruMapKey())) ?? {};
60
- }
61
- catch (err) {
62
- console.warn('[channels] LRU map read failed, using empty ordering map', err);
63
- }
64
- let lockedSet;
65
- try {
66
- const lockedKeys = await this.kv.listKeys(`${lockPrefix}*`);
67
- lockedSet = new Set(lockedKeys.map((k) => k.slice(lockPrefix.length)));
68
- }
69
- catch (err) {
70
- // Fallback: if listKeys fails, degrade to O(N) per-channel exists checks.
71
- // This is expensive with many channels — log so persistent failures are observable.
72
- console.warn('[channels] listKeys failed, falling back to per-channel exists checks', err);
73
- const results = await Promise.all(ids.map((id) => this.kv.exists(this.lockKey(id))));
74
- lockedSet = new Set(ids.filter((_, i) => results[i]));
75
- }
76
- const unlocked = ids.filter((id) => !lockedSet.has(id));
77
- if (unlocked.length === 0)
78
- return null;
79
- // Sort by LRU ascending — oldest channel is always first (deterministic).
80
- // Shuffle-then-stable-sort: tie-break among equal timestamps is random,
81
- // spreading contention when multiple channels share the same LRU value.
82
- shuffle(unlocked);
83
- unlocked.sort((a, b) => (lruMap[a] ?? 0) - (lruMap[b] ?? 0));
84
- const candidates = unlocked;
85
- // --- CLAIM PHASE (per-channel lock) ---
86
- for (const candidate of candidates) {
78
+ /** Try to claim one channel from a batch of candidates */
79
+ async tryClaimBatch(batch) {
80
+ for (const candidate of batch) {
87
81
  const result = await this.tryClaimChannel(candidate);
88
82
  if (result)
89
83
  return result;
@@ -98,7 +92,8 @@ class ChannelPool {
98
92
  return null;
99
93
  const token = randomToken();
100
94
  await this.kv.set(this.lockKey(relayerId), { token, lockedAt: new Date().toISOString() }, { ttlSec: this.channelLockTtlSec });
101
- await this.updateLruMap(relayerId);
95
+ // Fire-and-forget: LRU is best-effort, don't hold the claim lock for it
96
+ this.updateLru(relayerId);
102
97
  return { relayerId, token };
103
98
  }, { ttlSec: constants_1.POOL.CLAIM_LOCK_TTL_SECONDS, onBusy: 'skip' });
104
99
  }
@@ -145,28 +140,29 @@ class ChannelPool {
145
140
  membershipKey() {
146
141
  return `${this.network}:channel:relayer-ids`;
147
142
  }
148
- lockKeyPrefix() {
149
- return `${this.network}:channel:in-use:`;
150
- }
151
143
  lockKey(relayerId) {
152
- return `${this.lockKeyPrefix()}${relayerId}`;
144
+ return `${this.network}:channel:in-use:${relayerId}`;
153
145
  }
154
146
  claimKey(relayerId) {
155
147
  return `${this.network}:channel:claim:${relayerId}`;
156
148
  }
157
- lruMapKey() {
158
- return `${this.network}:channel:lru-map`;
149
+ lruKey(relayerId) {
150
+ return `${this.network}:channel:lru:${relayerId}`;
159
151
  }
160
- async updateLruMap(relayerId) {
161
- try {
162
- const lruMap = (await this.kv.get(this.lruMapKey())) ?? {};
163
- lruMap[relayerId] = Date.now();
164
- await this.kv.set(this.lruMapKey(), lruMap, { ttlSec: constants_1.POOL.LRU_MAP_TTL_SECONDS });
165
- }
166
- catch (err) {
167
- console.debug('[channels] failed to update LRU map', err);
168
- // Best-effort: stale LRU map only affects ordering, not correctness
169
- }
152
+ /** Read per-channel LRU timestamps in parallel (partial failures keep successful reads) */
153
+ async readLruMap(ids) {
154
+ const lruMap = {};
155
+ const results = await Promise.allSettled(ids.map((id) => this.kv.get(this.lruKey(id))));
156
+ results.forEach((r, i) => {
157
+ lruMap[ids[i]] = r.status === 'fulfilled' ? (r.value?.ts ?? 0) : 0;
158
+ });
159
+ return lruMap;
160
+ }
161
+ /** Fire-and-forget LRU timestamp update for a single channel */
162
+ updateLru(relayerId) {
163
+ this.kv.set(this.lruKey(relayerId), { ts: Date.now() }, { ttlSec: constants_1.POOL.LRU_KEY_TTL_SECONDS }).catch((err) => {
164
+ console.debug('[channels] failed to update LRU key', err);
165
+ });
170
166
  }
171
167
  async getRelayerIdsFromKV() {
172
168
  try {
@@ -232,8 +228,9 @@ function simpleHash(str) {
232
228
  */
233
229
  function filterChannelsForLimitedContract(ids, ratio) {
234
230
  const k = Math.max(1, Math.floor(ratio * ids.length));
231
+ const hashes = new Map(ids.map((id) => [id, simpleHash(id)]));
235
232
  return ids
236
233
  .slice()
237
- .sort((a, b) => simpleHash(a) - simpleHash(b) || a.localeCompare(b))
234
+ .sort((a, b) => hashes.get(a) - hashes.get(b) || a.localeCompare(b))
238
235
  .slice(0, k);
239
236
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openzeppelin/relayer-plugin-channels",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "OpenZeppelin Relayer Plugin for Stellar Channel Accounts",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",