@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.
- package/dist/plugin/config.d.ts +2 -0
- package/dist/plugin/config.d.ts.map +1 -1
- package/dist/plugin/config.js +16 -0
- package/dist/plugin/constants.d.ts +8 -6
- package/dist/plugin/constants.d.ts.map +1 -1
- package/dist/plugin/constants.js +13 -8
- package/dist/plugin/handler.d.ts.map +1 -1
- package/dist/plugin/handler.js +16 -4
- package/dist/plugin/management.js +11 -14
- package/dist/plugin/pool.d.ts +11 -5
- package/dist/plugin/pool.d.ts.map +1 -1
- package/dist/plugin/pool.js +93 -30
- package/dist/plugin/submit.d.ts +2 -1
- package/dist/plugin/submit.d.ts.map +1 -1
- package/dist/plugin/submit.js +5 -3
- package/package.json +1 -1
package/dist/plugin/config.d.ts
CHANGED
|
@@ -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;
|
|
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"}
|
package/dist/plugin/config.js
CHANGED
|
@@ -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
|
|
27
|
-
readonly
|
|
28
|
-
readonly
|
|
29
|
-
readonly
|
|
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
|
|
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
|
|
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
|
|
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"}
|
package/dist/plugin/constants.js
CHANGED
|
@@ -29,12 +29,17 @@ exports.CONFIG = {
|
|
|
29
29
|
};
|
|
30
30
|
// Pool Constants
|
|
31
31
|
exports.POOL = {
|
|
32
|
-
// TTL
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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"}
|
package/dist/plugin/handler.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
175
|
-
|
|
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
|
|
210
|
+
// 2. Check locks via single listKeys scan — best-effort
|
|
215
211
|
let locked;
|
|
216
212
|
let available;
|
|
217
213
|
try {
|
|
218
|
-
const
|
|
219
|
-
locked =
|
|
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
|
|
272
|
-
const
|
|
267
|
+
async function getLockedRelayerIds(kv, network) {
|
|
268
|
+
const prefix = `${network}:channel:in-use:`;
|
|
273
269
|
try {
|
|
274
|
-
|
|
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
|
|
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: {
|
|
277
|
+
details: { prefix, pattern: `${prefix}*`, message: error instanceof Error ? error.message : String(error) },
|
|
281
278
|
});
|
|
282
279
|
}
|
|
283
280
|
}
|
package/dist/plugin/pool.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
29
|
-
private
|
|
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,
|
|
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"}
|
package/dist/plugin/pool.js
CHANGED
|
@@ -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
|
|
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.
|
|
26
|
+
const maxSpins = constants_1.POOL.ACQUIRE_MAX_SPINS;
|
|
29
27
|
for (let i = 0; i < maxSpins; i++) {
|
|
30
|
-
const
|
|
31
|
-
if (
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
47
|
-
async
|
|
48
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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.
|
|
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' : '
|
|
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,
|
package/dist/plugin/submit.d.ts
CHANGED
|
@@ -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,
|
|
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"}
|
package/dist/plugin/submit.js
CHANGED
|
@@ -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 =
|
|
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,
|
|
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 {
|