@openzeppelin/relayer-plugin-channels 0.17.0 → 0.18.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.
@@ -19,6 +19,8 @@ export interface ChannelAccountsConfig {
19
19
  inclusionFeeLimited: number;
20
20
  sequenceNumberCacheMaxAgeMs: number;
21
21
  minSignatureExpirationLedgerBuffer: number;
22
+ globalTimeoutMs: number;
23
+ pollingTimeoutMs: number;
22
24
  }
23
25
  /**
24
26
  * Load configuration from environment variables
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/plugin/config.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAUH,MAAM,WAAW,qBAAqB;IACpC,aAAa,EAAE,MAAM,CAAC;IACtB,wFAAwF;IACxF,qBAAqB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACnC,OAAO,EAAE,SAAS,GAAG,SAAS,CAAC;IAC/B,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,YAAY,EAAE,MAAM,CAAC;IACrB,gBAAgB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC9B,qBAAqB,EAAE,MAAM,CAAC;IAC9B,mBAAmB,EAAE,MAAM,CAAC;IAC5B,mBAAmB,EAAE,MAAM,CAAC;IAC5B,2BAA2B,EAAE,MAAM,CAAC;IACpC,kCAAkC,EAAE,MAAM,CAAC;CAC5C;AAsHD;;GAEG;AACH,wBAAgB,UAAU,IAAI,qBAAqB,CA2BlD;AAED;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,SAAS,GAAG,SAAS,GAAG,MAAM,CAE3E"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/plugin/config.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAUH,MAAM,WAAW,qBAAqB;IACpC,aAAa,EAAE,MAAM,CAAC;IACtB,wFAAwF;IACxF,qBAAqB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACnC,OAAO,EAAE,SAAS,GAAG,SAAS,CAAC;IAC/B,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,YAAY,EAAE,MAAM,CAAC;IACrB,gBAAgB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC9B,qBAAqB,EAAE,MAAM,CAAC;IAC9B,mBAAmB,EAAE,MAAM,CAAC;IAC5B,mBAAmB,EAAE,MAAM,CAAC;IAC5B,2BAA2B,EAAE,MAAM,CAAC;IACpC,kCAAkC,EAAE,MAAM,CAAC;IAC3C,eAAe,EAAE,MAAM,CAAC;IACxB,gBAAgB,EAAE,MAAM,CAAC;CAC1B;AAoID;;GAEG;AACH,wBAAgB,UAAU,IAAI,qBAAqB,CA6BlD;AAED;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,SAAS,GAAG,SAAS,GAAG,MAAM,CAE3E"}
@@ -117,6 +117,20 @@ function parseAllowedFundRelayerIds() {
117
117
  }
118
118
  return ids;
119
119
  }
120
+ function parseGlobalTimeoutMs() {
121
+ const raw = process.env.PLUGIN_GLOBAL_TIMEOUT_MS;
122
+ if (!raw)
123
+ return constants_1.TIMEOUT.DEFAULT_GLOBAL_TIMEOUT_MS;
124
+ const n = Number(raw);
125
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : constants_1.TIMEOUT.DEFAULT_GLOBAL_TIMEOUT_MS;
126
+ }
127
+ function parsePollingTimeoutMs() {
128
+ const raw = process.env.PLUGIN_POLLING_TIMEOUT_MS;
129
+ if (!raw)
130
+ return constants_1.POLLING.DEFAULT_TIMEOUT_MS;
131
+ const n = Number(raw);
132
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : constants_1.POLLING.DEFAULT_TIMEOUT_MS;
133
+ }
120
134
  function parseContractCapacityRatio() {
121
135
  const raw = process.env.CONTRACT_CAPACITY_RATIO;
122
136
  if (!raw)
@@ -154,6 +168,8 @@ function loadConfig() {
154
168
  inclusionFeeLimited: parseInclusionFee('INCLUSION_FEE_LIMITED', DEFAULT_INCLUSION_FEE_LIMITED),
155
169
  sequenceNumberCacheMaxAgeMs: parseSequenceNumberCacheMaxAge(),
156
170
  minSignatureExpirationLedgerBuffer: parseMinAuthExpiryLedgerBuffer(),
171
+ globalTimeoutMs: parseGlobalTimeoutMs(),
172
+ pollingTimeoutMs: parsePollingTimeoutMs(),
157
173
  };
158
174
  }
159
175
  /**
@@ -23,10 +23,12 @@ export declare const CONFIG: {
23
23
  readonly DEFAULT_MIN_SIGNATURE_EXPIRATION_LEDGER_BUFFER: 2;
24
24
  };
25
25
  export declare const POOL: {
26
- readonly MUTEX_TTL_SECONDS: 1;
27
- readonly MUTEX_MAX_SPINS: 30;
28
- readonly MUTEX_RETRY_MIN_MS: 10;
29
- readonly MUTEX_RETRY_MAX_MS: 30;
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;
30
+ readonly CHANNEL_COOLDOWN_MS: 6000;
31
+ readonly LRU_MAP_TTL_SECONDS: 86400;
30
32
  };
31
33
  export declare const TIME: {
32
34
  readonly MAX_TIME_BOUND_OFFSET_SECONDS: 60;
@@ -41,12 +43,12 @@ export declare const SIMULATION: {
41
43
  readonly MIN_SIGNATURE_EXPIRATION_LEDGER_BUFFER: 2;
42
44
  };
43
45
  export declare const TIMEOUT: {
44
- readonly GLOBAL_TIMEOUT_MS: 30000;
46
+ readonly DEFAULT_GLOBAL_TIMEOUT_MS: 30000;
45
47
  readonly BUFFER_MS: 2000;
46
48
  };
47
49
  export declare const POLLING: {
48
50
  readonly INTERVAL_MS: 1000;
49
- readonly TIMEOUT_MS: 25000;
51
+ readonly DEFAULT_TIMEOUT_MS: 25000;
50
52
  };
51
53
  export declare const FEE: {
52
54
  readonly NON_SOROBAN_FEE: 100000;
@@ -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;;;;;CAOP,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;;;;;;;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"}
@@ -29,12 +29,17 @@ exports.CONFIG = {
29
29
  };
30
30
  // Pool Constants
31
31
  exports.POOL = {
32
- // TTL for the short global mutex guarding acquire()
33
- MUTEX_TTL_SECONDS: 1,
34
- // Retry policy when mutex is busy
35
- MUTEX_MAX_SPINS: 30,
36
- MUTEX_RETRY_MIN_MS: 10,
37
- MUTEX_RETRY_MAX_MS: 30,
32
+ // Per-channel claim lock TTL must exceed worst-case callback latency
33
+ // to prevent TTL expiry allowing a second worker into the same claim section
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,
39
+ // Hard-block cooldown for uncertain-outcome channels (~1 Stellar ledger with margin)
40
+ CHANNEL_COOLDOWN_MS: 6000,
41
+ // Housekeeping TTL for the single LRU map document
42
+ LRU_MAP_TTL_SECONDS: 86400,
38
43
  };
39
44
  // Time Constants
40
45
  exports.TIME = {
@@ -52,13 +57,13 @@ exports.SIMULATION = {
52
57
  };
53
58
  // Global plugin timeout budget
54
59
  exports.TIMEOUT = {
55
- GLOBAL_TIMEOUT_MS: 30000,
60
+ DEFAULT_GLOBAL_TIMEOUT_MS: 30000,
56
61
  BUFFER_MS: 2000,
57
62
  };
58
63
  // Polling for transactionWait
59
64
  exports.POLLING = {
60
65
  INTERVAL_MS: 1000,
61
- TIMEOUT_MS: 25000,
66
+ DEFAULT_TIMEOUT_MS: 25000,
62
67
  };
63
68
  exports.FEE = {
64
69
  // For non-Soroban txs: 100,000 stroops (0.01 XLM) per Stellar best practice
@@ -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;AA4SD;;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;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"}
@@ -74,7 +74,7 @@ async function handleXdrSubmit(xdrStr, ctx, skipWait) {
74
74
  contractId,
75
75
  isLimited: contractId ? ctx.acquireOptions.limitedContracts.has(contractId) : false,
76
76
  };
77
- return (0, submit_1.submitWithFeeBumpAndWait)(ctx.fundRelayer, validated.toXDR(), ctx.network, maxFee, ctx.api, ctx.startTime, ctx.tracker, submitContext, skipWait);
77
+ return (0, submit_1.submitWithFeeBumpAndWait)(ctx.fundRelayer, validated.toXDR(), ctx.network, maxFee, ctx.api, ctx.startTime, ctx.tracker, submitContext, skipWait, ctx.config);
78
78
  }
79
79
  async function handleFuncAuthSubmit(func, auth, ctx, skipWait) {
80
80
  // Simulate once — used for both read-only detection and transaction assembly
@@ -90,6 +90,7 @@ async function handleFuncAuthSubmit(func, auth, ctx, skipWait) {
90
90
  };
91
91
  }
92
92
  let poolLock;
93
+ let needsCooldown = false;
93
94
  try {
94
95
  poolLock = await ctx.pool.acquire(ctx.acquireOptions);
95
96
  const channelRelayer = ctx.api.useRelayer(poolLock.relayerId);
@@ -123,9 +124,8 @@ async function handleFuncAuthSubmit(func, auth, ctx, skipWait) {
123
124
  isLimited: contractId ? ctx.acquireOptions.limitedContracts.has(contractId) : false,
124
125
  };
125
126
  try {
126
- const result = await (0, submit_1.submitWithFeeBumpAndWait)(ctx.fundRelayer, signedTx.toXDR(), ctx.network, maxFee, ctx.api, ctx.startTime, ctx.tracker, submitContext, skipWait);
127
+ const result = await (0, submit_1.submitWithFeeBumpAndWait)(ctx.fundRelayer, signedTx.toXDR(), ctx.network, maxFee, ctx.api, ctx.startTime, ctx.tracker, submitContext, skipWait, ctx.config);
127
128
  if (result.status === 'pending' || result.status === 'sent' || result.status === 'submitted') {
128
- // extend lock and clear sequence
129
129
  console.log(`[channels]: extending lock and clearing sequence`);
130
130
  await ctx.pool.extendLock(poolLock);
131
131
  await (0, sequence_1.clearSequence)(ctx.kv, ctx.network, channelInfo.address);
@@ -135,7 +135,9 @@ async function handleFuncAuthSubmit(func, auth, ctx, skipWait) {
135
135
  await (0, sequence_1.commitSequence)(ctx.kv, ctx.network, channelInfo.address, sequence);
136
136
  }
137
137
  else {
138
+ // Unknown status — uncertain outcome → release with cooldown
138
139
  await (0, sequence_1.clearSequence)(ctx.kv, ctx.network, channelInfo.address);
140
+ needsCooldown = true;
139
141
  }
140
142
  return result;
141
143
  }
@@ -146,12 +148,22 @@ async function handleFuncAuthSubmit(func, auth, ctx, skipWait) {
146
148
  await ctx.pool.extendLock(poolLock);
147
149
  poolLock = undefined; // skip release in finally
148
150
  }
151
+ else if (error.code === 'ONCHAIN_FAILED') {
152
+ // Sequence was consumed (tx landed on chain) — cooldown prevents
153
+ // next acquirer from hitting stale RPC and reusing old sequence
154
+ needsCooldown = true;
155
+ }
149
156
  throw error;
150
157
  }
151
158
  }
152
159
  finally {
153
160
  if (poolLock) {
154
- await ctx.pool.release(poolLock);
161
+ if (needsCooldown) {
162
+ await ctx.pool.releaseWithCooldown(poolLock);
163
+ }
164
+ else {
165
+ await ctx.pool.release(poolLock);
166
+ }
155
167
  }
156
168
  }
157
169
  }
@@ -171,12 +171,8 @@ async function setChannelAccounts(kv, network, payload) {
171
171
  const current = await readStoredRelayerIds(kv, listKey);
172
172
  // Check for locked removals
173
173
  const removals = current.filter((id) => !relayerIds.includes(id));
174
- const locked = [];
175
- for (const id of removals) {
176
- if (await isRelayerIdLocked(kv, network, id)) {
177
- locked.push(id);
178
- }
179
- }
174
+ const lockedSet = await getLockedRelayerIds(kv, network);
175
+ const locked = removals.filter((id) => lockedSet.has(id));
180
176
  if (locked.length > 0) {
181
177
  throw (0, relayer_sdk_1.pluginError)('Locked relayer IDs cannot be removed', {
182
178
  code: 'LOCKED_CONFLICT',
@@ -211,12 +207,12 @@ async function getPoolStats(config, kv) {
211
207
  status: constants_1.HTTP_STATUS.INTERNAL_SERVER_ERROR,
212
208
  });
213
209
  }
214
- // 2. Check all locks in parallel (N KV calls) — best-effort
210
+ // 2. Check locks via single listKeys scan — best-effort
215
211
  let locked;
216
212
  let available;
217
213
  try {
218
- const results = await Promise.all(relayerIds.map((id) => kv.exists(`${network}:channel:in-use:${id}`)));
219
- locked = results.filter(Boolean).length;
214
+ const lockedSet = await getLockedRelayerIds(kv, network);
215
+ locked = relayerIds.filter((id) => lockedSet.has(id)).length;
220
216
  available = relayerIds.length - locked;
221
217
  }
222
218
  catch {
@@ -268,16 +264,17 @@ async function readStoredRelayerIds(kv, key) {
268
264
  });
269
265
  }
270
266
  }
271
- async function isRelayerIdLocked(kv, network, id) {
272
- const key = `${network}:channel:in-use:${id}`;
267
+ async function getLockedRelayerIds(kv, network) {
268
+ const prefix = `${network}:channel:in-use:`;
273
269
  try {
274
- return await kv.exists(key);
270
+ const keys = await kv.listKeys(`${prefix}*`);
271
+ return new Set(keys.map((k) => k.slice(prefix.length)));
275
272
  }
276
273
  catch (error) {
277
- throw (0, relayer_sdk_1.pluginError)('KV error while checking relayer lock', {
274
+ throw (0, relayer_sdk_1.pluginError)('KV error while checking relayer locks', {
278
275
  code: 'KV_ERROR',
279
276
  status: constants_1.HTTP_STATUS.INTERNAL_SERVER_ERROR,
280
- details: { relayerId: id, key, message: error instanceof Error ? error.message : String(error) },
277
+ details: { prefix, pattern: `${prefix}*`, message: error instanceof Error ? error.message : String(error) },
281
278
  });
282
279
  }
283
280
  }
@@ -4,7 +4,7 @@
4
4
  * KV-backed, stateless channel pool.
5
5
  * - Membership comes from KV: <network>:channel:relayer-ids
6
6
  * - Per-relayer locks with tokens: <network>:channel:in-use:<relayerId>
7
- * - Uses a short global mutex to make acquire atomic across workers.
7
+ * - Uses per-channel claim locks to make acquire safe across workers.
8
8
  */
9
9
  import { PluginKVStore } from '@openzeppelin/relayer-sdk';
10
10
  export type PoolLock = {
@@ -18,21 +18,27 @@ export type AcquireOptions = {
18
18
  };
19
19
  export declare class ChannelPool {
20
20
  private readonly network;
21
- private readonly globalLockKey;
22
21
  private readonly channelLockTtlSec;
23
- private readonly mutexTtlSec;
24
22
  private readonly kv;
25
23
  constructor(network: 'testnet' | 'mainnet', kv: PluginKVStore, lockTtlSeconds: number);
26
24
  /** Acquire a relayerId with a token lock */
27
25
  acquire(options: AcquireOptions): Promise<PoolLock>;
28
- private withGlobalMutex;
29
- private tryLockAnyRelayer;
26
+ /** Read phase + claim phase (no global mutex) */
27
+ private tryAcquire;
28
+ /** Attempt to claim a single channel under its per-channel lock */
29
+ private tryClaimChannel;
30
30
  /** Extend the lock TTL if we still own it (e.g. after WAIT_TIMEOUT) */
31
31
  extendLock(lock: PoolLock, ttlSec?: number): Promise<void>;
32
32
  /** Release the lock if we own it */
33
33
  release(lock: PoolLock): Promise<void>;
34
+ /** Release with cooldown: keeps lock alive with short TTL to hard-block the channel. */
35
+ releaseWithCooldown(lock: PoolLock, cooldownMs?: 6000): Promise<void>;
34
36
  private membershipKey;
37
+ private lockKeyPrefix;
35
38
  private lockKey;
39
+ private claimKey;
40
+ private lruMapKey;
41
+ private updateLruMap;
36
42
  private getRelayerIdsFromKV;
37
43
  private getPoolCapacityDetails;
38
44
  }
@@ -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,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAS;IAC3C,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;IACrC,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAgB;gBAEvB,OAAO,EAAE,SAAS,GAAG,SAAS,EAAE,EAAE,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM;IAQrF,4CAA4C;IACtC,OAAO,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,QAAQ,CAAC;YAyB3C,eAAe;YAKf,iBAAiB;IA2B/B,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,OAAO,CAAC,aAAa;IAIrB,OAAO,CAAC,OAAO;YAID,mBAAmB;YAYnB,sBAAsB;CAUrC"}
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"}
@@ -5,7 +5,7 @@
5
5
  * KV-backed, stateless channel pool.
6
6
  * - Membership comes from KV: <network>:channel:relayer-ids
7
7
  * - Per-relayer locks with tokens: <network>:channel:in-use:<relayerId>
8
- * - Uses a short global mutex to make acquire atomic across workers.
8
+ * - Uses per-channel claim locks to make acquire safe across workers.
9
9
  */
10
10
  var __importDefault = (this && this.__importDefault) || function (mod) {
11
11
  return (mod && mod.__esModule) ? mod : { "default": mod };
@@ -19,21 +19,18 @@ class ChannelPool {
19
19
  constructor(network, kv, lockTtlSeconds) {
20
20
  this.network = network;
21
21
  this.kv = kv;
22
- this.globalLockKey = `${this.network}:channel-pool-lock`;
23
22
  this.channelLockTtlSec = lockTtlSeconds;
24
- this.mutexTtlSec = constants_1.POOL.MUTEX_TTL_SECONDS;
25
23
  }
26
24
  /** Acquire a relayerId with a token lock */
27
25
  async acquire(options) {
28
- const maxSpins = constants_1.POOL.MUTEX_MAX_SPINS;
26
+ const maxSpins = constants_1.POOL.ACQUIRE_MAX_SPINS;
29
27
  for (let i = 0; i < maxSpins; i++) {
30
- const r = await this.withGlobalMutex(() => this.tryLockAnyRelayer(options));
31
- if (r === null) {
32
- const jitter = constants_1.POOL.MUTEX_RETRY_MIN_MS + Math.floor(Math.random() * (constants_1.POOL.MUTEX_RETRY_MAX_MS - constants_1.POOL.MUTEX_RETRY_MIN_MS + 1));
33
- await sleep(jitter);
34
- continue;
35
- }
36
- return r;
28
+ const result = await this.tryAcquire(options);
29
+ if (result)
30
+ 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));
33
+ await sleep(jitter);
37
34
  }
38
35
  const diagnostics = await this.getPoolCapacityDetails(options, maxSpins);
39
36
  console.warn('[channels] Pool capacity exhausted', diagnostics);
@@ -43,12 +40,9 @@ class ChannelPool {
43
40
  details: diagnostics,
44
41
  });
45
42
  }
46
- // Run a function under the short-lived global mutex; returns null if busy
47
- async withGlobalMutex(fn) {
48
- return this.kv.withLock(this.globalLockKey, fn, { ttlSec: this.mutexTtlSec, onBusy: 'skip' });
49
- }
50
- // Inside the mutex: pick an available relayer and set its channel lock
51
- async tryLockAnyRelayer(options) {
43
+ /** Read phase + claim phase (no global mutex) */
44
+ async tryAcquire(options) {
45
+ // --- READ PHASE (no lock) ---
52
46
  let ids = await this.getRelayerIdsFromKV();
53
47
  if (ids.length === 0) {
54
48
  throw (0, relayer_sdk_1.pluginError)('No channel accounts configured. Use the management API to set channel accounts.', {
@@ -56,23 +50,58 @@ class ChannelPool {
56
50
  status: constants_1.HTTP_STATUS.SERVICE_UNAVAILABLE,
57
51
  });
58
52
  }
59
- // If contract is limited, filter to allowed subset
60
53
  if (options.contractId && options.limitedContracts.has(options.contractId)) {
61
54
  ids = filterChannelsForLimitedContract(ids, options.capacityRatio);
62
55
  }
63
- shuffle(ids);
64
- for (const relayerId of ids) {
65
- const key = this.lockKey(relayerId);
66
- const exists = await this.kv.exists(key);
67
- if (exists)
68
- continue;
69
- const token = randomToken();
70
- const entry = { token, lockedAt: new Date().toISOString() };
71
- await this.kv.set(key, entry, { ttlSec: this.channelLockTtlSec });
72
- return { relayerId, token };
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) {
87
+ const result = await this.tryClaimChannel(candidate);
88
+ if (result)
89
+ return result;
73
90
  }
74
91
  return null;
75
92
  }
93
+ /** Attempt to claim a single channel under its per-channel lock */
94
+ async tryClaimChannel(relayerId) {
95
+ return this.kv.withLock(this.claimKey(relayerId), async () => {
96
+ // Double-check: another worker may have claimed it between our scan and this lock
97
+ if (await this.kv.exists(this.lockKey(relayerId)))
98
+ return null;
99
+ const token = randomToken();
100
+ await this.kv.set(this.lockKey(relayerId), { token, lockedAt: new Date().toISOString() }, { ttlSec: this.channelLockTtlSec });
101
+ await this.updateLruMap(relayerId);
102
+ return { relayerId, token };
103
+ }, { ttlSec: constants_1.POOL.CLAIM_LOCK_TTL_SECONDS, onBusy: 'skip' });
104
+ }
76
105
  /** Extend the lock TTL if we still own it (e.g. after WAIT_TIMEOUT) */
77
106
  async extendLock(lock, ttlSec) {
78
107
  try {
@@ -99,11 +128,45 @@ class ChannelPool {
99
128
  // ignore release errors
100
129
  }
101
130
  }
131
+ /** Release with cooldown: keeps lock alive with short TTL to hard-block the channel. */
132
+ async releaseWithCooldown(lock, cooldownMs = constants_1.POOL.CHANNEL_COOLDOWN_MS) {
133
+ try {
134
+ const key = this.lockKey(lock.relayerId);
135
+ const current = await this.kv.get(key);
136
+ if (current?.token === lock.token) {
137
+ const cooldownSec = Math.max(1, Math.ceil(cooldownMs / 1000));
138
+ await this.kv.set(key, current, { ttlSec: cooldownSec });
139
+ }
140
+ }
141
+ catch {
142
+ // ignore
143
+ }
144
+ }
102
145
  membershipKey() {
103
146
  return `${this.network}:channel:relayer-ids`;
104
147
  }
148
+ lockKeyPrefix() {
149
+ return `${this.network}:channel:in-use:`;
150
+ }
105
151
  lockKey(relayerId) {
106
- return `${this.network}:channel:in-use:${relayerId}`;
152
+ return `${this.lockKeyPrefix()}${relayerId}`;
153
+ }
154
+ claimKey(relayerId) {
155
+ return `${this.network}:channel:claim:${relayerId}`;
156
+ }
157
+ lruMapKey() {
158
+ return `${this.network}:channel:lru-map`;
159
+ }
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
+ }
107
170
  }
108
171
  async getRelayerIdsFromKV() {
109
172
  try {
@@ -121,7 +184,7 @@ class ChannelPool {
121
184
  async getPoolCapacityDetails(options, maxSpins) {
122
185
  const isLimited = !!(options.contractId && options.limitedContracts.has(options.contractId));
123
186
  return {
124
- reason: isLimited ? 'limited_contract_capacity' : 'all_channels_busy_or_mutex_contention',
187
+ reason: isLimited ? 'limited_contract_capacity' : 'all_channels_busy_or_claim_contention',
125
188
  contractId: options.contractId,
126
189
  capacityRatio: options.capacityRatio,
127
190
  maxSpins,
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { Transaction } from '@stellar/stellar-sdk';
7
7
  import { Relayer, PluginAPI } from '@openzeppelin/relayer-sdk';
8
+ import type { ChannelAccountsConfig } from './config';
8
9
  import { ChannelAccountsResponse } from './types';
9
10
  import { FeeTracker } from './fee-tracking';
10
11
  export interface SubmitContext {
@@ -25,7 +26,7 @@ export declare function signWithChannelAndFund(transaction: Transaction, channel
25
26
  /**
26
27
  * Submit transaction with fee bump and wait for confirmation
27
28
  */
28
- export declare function submitWithFeeBumpAndWait(fundRelayer: Relayer, signedXdr: string, network: 'testnet' | 'mainnet', maxFee: number, api: PluginAPI, startTime: number, tracker?: FeeTracker, context?: SubmitContext, skipWait?: boolean): Promise<ChannelAccountsResponse>;
29
+ export declare function submitWithFeeBumpAndWait(fundRelayer: Relayer, signedXdr: string, network: 'testnet' | 'mainnet', maxFee: number, api: PluginAPI, startTime: number, tracker?: FeeTracker, context?: SubmitContext, skipWait?: boolean, config?: Pick<ChannelAccountsConfig, 'globalTimeoutMs' | 'pollingTimeoutMs'>): Promise<ChannelAccountsResponse>;
29
30
  /** Try to decode a transaction result XDR from the reason string */
30
31
  export declare function decodeTransactionResult(reason: string): DecodedTransactionResult | null;
31
32
  export declare function buildStellarLabTransactionUrl(network: 'testnet' | 'mainnet', txHash: string): string;
@@ -1 +1 @@
1
- {"version":3,"file":"submit.d.ts","sourceRoot":"","sources":["../../src/plugin/submit.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,WAAW,EAAO,MAAM,sBAAsB,CAAC;AACxD,OAAO,EAEL,OAAO,EAGP,SAAS,EACV,MAAM,2BAA2B,CAAC;AAEnC,OAAO,EAAE,uBAAuB,EAAE,MAAM,SAAS,CAAC;AAClD,OAAO,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAE5C,MAAM,WAAW,aAAa;IAC5B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,UAAU,wBAAwB;IAChC,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;;GAKG;AACH,wBAAsB,sBAAsB,CAC1C,WAAW,EAAE,WAAW,EACxB,cAAc,EAAE,OAAO,EACvB,YAAY,EAAE,OAAO,EACrB,cAAc,EAAE,MAAM,EACtB,YAAY,EAAE,MAAM,EACpB,iBAAiB,EAAE,MAAM,GACxB,OAAO,CAAC,WAAW,CAAC,CAuBtB;AAED;;GAEG;AACH,wBAAsB,wBAAwB,CAC5C,WAAW,EAAE,OAAO,EACpB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,SAAS,GAAG,SAAS,EAC9B,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,SAAS,EACd,SAAS,EAAE,MAAM,EACjB,OAAO,CAAC,EAAE,UAAU,EACpB,OAAO,CAAC,EAAE,aAAa,EACvB,QAAQ,CAAC,EAAE,OAAO,GACjB,OAAO,CAAC,uBAAuB,CAAC,CAiHlC;AASD,oEAAoE;AACpE,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,MAAM,GAAG,wBAAwB,GAAG,IAAI,CAkCvF;AAeD,wBAAgB,6BAA6B,CAAC,OAAO,EAAE,SAAS,GAAG,SAAS,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAepG;AAED,wFAAwF;AACxF,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAOrD"}
1
+ {"version":3,"file":"submit.d.ts","sourceRoot":"","sources":["../../src/plugin/submit.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,WAAW,EAAO,MAAM,sBAAsB,CAAC;AACxD,OAAO,EAEL,OAAO,EAGP,SAAS,EACV,MAAM,2BAA2B,CAAC;AAEnC,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,UAAU,CAAC;AACtD,OAAO,EAAE,uBAAuB,EAAE,MAAM,SAAS,CAAC;AAClD,OAAO,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAE5C,MAAM,WAAW,aAAa;IAC5B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,UAAU,wBAAwB;IAChC,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;;GAKG;AACH,wBAAsB,sBAAsB,CAC1C,WAAW,EAAE,WAAW,EACxB,cAAc,EAAE,OAAO,EACvB,YAAY,EAAE,OAAO,EACrB,cAAc,EAAE,MAAM,EACtB,YAAY,EAAE,MAAM,EACpB,iBAAiB,EAAE,MAAM,GACxB,OAAO,CAAC,WAAW,CAAC,CAuBtB;AAED;;GAEG;AACH,wBAAsB,wBAAwB,CAC5C,WAAW,EAAE,OAAO,EACpB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,SAAS,GAAG,SAAS,EAC9B,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,SAAS,EACd,SAAS,EAAE,MAAM,EACjB,OAAO,CAAC,EAAE,UAAU,EACpB,OAAO,CAAC,EAAE,aAAa,EACvB,QAAQ,CAAC,EAAE,OAAO,EAClB,MAAM,CAAC,EAAE,IAAI,CAAC,qBAAqB,EAAE,iBAAiB,GAAG,kBAAkB,CAAC,GAC3E,OAAO,CAAC,uBAAuB,CAAC,CAmHlC;AASD,oEAAoE;AACpE,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,MAAM,GAAG,wBAAwB,GAAG,IAAI,CAkCvF;AAeD,wBAAgB,6BAA6B,CAAC,OAAO,EAAE,SAAS,GAAG,SAAS,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAepG;AAED,wFAAwF;AACxF,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAOrD"}
@@ -42,7 +42,7 @@ async function signWithChannelAndFund(transaction, channelRelayer, _fundRelayer,
42
42
  /**
43
43
  * Submit transaction with fee bump and wait for confirmation
44
44
  */
45
- async function submitWithFeeBumpAndWait(fundRelayer, signedXdr, network, maxFee, api, startTime, tracker, context, skipWait) {
45
+ async function submitWithFeeBumpAndWait(fundRelayer, signedXdr, network, maxFee, api, startTime, tracker, context, skipWait, config) {
46
46
  // Submit with fee bump
47
47
  console.debug(`[channels] Sending fee bump tx: network=${network}, maxFee=${maxFee}, xdr_len=${signedXdr.length}`);
48
48
  const payload = {
@@ -66,8 +66,10 @@ async function submitWithFeeBumpAndWait(fundRelayer, signedXdr, network, maxFee,
66
66
  };
67
67
  }
68
68
  // Compute dynamic timeout from remaining global budget
69
+ const globalTimeout = config?.globalTimeoutMs ?? constants_1.TIMEOUT.DEFAULT_GLOBAL_TIMEOUT_MS;
70
+ const pollingTimeout = config?.pollingTimeoutMs ?? constants_1.POLLING.DEFAULT_TIMEOUT_MS;
69
71
  const elapsedMs = Date.now() - startTime;
70
- const remainingMs = constants_1.TIMEOUT.GLOBAL_TIMEOUT_MS - constants_1.TIMEOUT.BUFFER_MS - elapsedMs;
72
+ const remainingMs = globalTimeout - constants_1.TIMEOUT.BUFFER_MS - elapsedMs;
71
73
  if (remainingMs <= 0) {
72
74
  throw (0, relayer_sdk_1.pluginError)('No time remaining for transaction wait', {
73
75
  code: 'WAIT_TIMEOUT',
@@ -79,7 +81,7 @@ async function submitWithFeeBumpAndWait(fundRelayer, signedXdr, network, maxFee,
79
81
  },
80
82
  });
81
83
  }
82
- const timeout = Math.min(remainingMs, constants_1.POLLING.TIMEOUT_MS);
84
+ const timeout = Math.min(remainingMs, pollingTimeout);
83
85
  console.debug(`[channels] Transaction wait: elapsed=${elapsedMs}ms, timeout=${timeout}ms`);
84
86
  // Wait for confirmation
85
87
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openzeppelin/relayer-plugin-channels",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "OpenZeppelin Relayer Plugin for Stellar Channel Accounts",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",