@openzeppelin/relayer-plugin-channels 0.4.0 → 0.5.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.
@@ -12,6 +12,7 @@ exports.HTTP_STATUS = {
12
12
  UNAUTHORIZED: 401,
13
13
  FORBIDDEN: 403,
14
14
  CONFLICT: 409,
15
+ TOO_MANY_REQUESTS: 429,
15
16
  INTERNAL_SERVER_ERROR: 500,
16
17
  BAD_GATEWAY: 502,
17
18
  SERVICE_UNAVAILABLE: 503,
@@ -20,7 +21,7 @@ exports.HTTP_STATUS = {
20
21
  // Configuration Constants
21
22
  exports.CONFIG = {
22
23
  DEFAULT_LOCK_TTL_SECONDS: 30,
23
- MIN_LOCK_TTL_SECONDS: 10,
24
+ MIN_LOCK_TTL_SECONDS: 3,
24
25
  MAX_LOCK_TTL_SECONDS: 30,
25
26
  };
26
27
  // Pool Constants
@@ -0,0 +1,81 @@
1
+ /**
2
+ * fee-tracking.ts
3
+ *
4
+ * API key fee tracking for rate limiting.
5
+ * Supports custom per-key limits with fallback to default.
6
+ * Supports periodic reset of consumption.
7
+ */
8
+ import { PluginKVStore } from "@openzeppelin/relayer-sdk";
9
+ /** KV data structure for fee consumption */
10
+ export interface FeeData {
11
+ consumed: number;
12
+ periodStart?: number;
13
+ }
14
+ /** Usage info returned by getUsageInfo */
15
+ export interface UsageInfo {
16
+ consumed: number;
17
+ limit?: number;
18
+ remaining?: number;
19
+ periodStartAt?: string;
20
+ periodEndsAt?: string;
21
+ }
22
+ /** Configuration for FeeTracker */
23
+ export interface FeeTrackerConfig {
24
+ kv: PluginKVStore;
25
+ network: "testnet" | "mainnet";
26
+ apiKey: string;
27
+ defaultLimit?: number;
28
+ resetPeriodMs?: number;
29
+ }
30
+ export declare class FeeTracker {
31
+ private readonly kv;
32
+ private readonly network;
33
+ private readonly apiKey;
34
+ private readonly defaultLimit?;
35
+ private readonly resetPeriodMs?;
36
+ constructor(config: FeeTrackerConfig);
37
+ /**
38
+ * Check if API key can afford the given fee.
39
+ * Throws 429 if not. No-op if unlimited.
40
+ */
41
+ checkBudget(fee: number): Promise<void>;
42
+ /**
43
+ * Record fee consumption after transaction.
44
+ * Uses distributed lock for atomicity with retry.
45
+ * Errors are logged but not thrown (non-blocking).
46
+ */
47
+ recordUsage(fee: number): Promise<void>;
48
+ /**
49
+ * Get complete usage info for management API.
50
+ */
51
+ getUsageInfo(): Promise<UsageInfo>;
52
+ /**
53
+ * Get custom limit for this API key.
54
+ */
55
+ getCustomLimit(): Promise<number | undefined>;
56
+ /**
57
+ * Set custom limit for this API key.
58
+ */
59
+ setCustomLimit(limit: number): Promise<void>;
60
+ /**
61
+ * Delete custom limit for this API key.
62
+ */
63
+ deleteCustomLimit(): Promise<void>;
64
+ /**
65
+ * Get effective fee state after applying period expiry.
66
+ * Returns { consumed: 0 } if no data or period expired.
67
+ */
68
+ private getFeeState;
69
+ /**
70
+ * Get effective limit (custom ?? default).
71
+ */
72
+ private getEffectiveLimit;
73
+ /**
74
+ * Check if period has expired.
75
+ */
76
+ private isPeriodExpired;
77
+ private get consumedKey();
78
+ private get limitKey();
79
+ private get lockKey();
80
+ }
81
+ //# sourceMappingURL=fee-tracking.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fee-tracking.d.ts","sourceRoot":"","sources":["../../src/plugin/fee-tracking.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,aAAa,EAAe,MAAM,2BAA2B,CAAC;AAGvE,4CAA4C;AAC5C,MAAM,WAAW,OAAO;IACtB,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAQD,0CAA0C;AAC1C,MAAM,WAAW,SAAS;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,mCAAmC;AACnC,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,aAAa,CAAC;IAClB,OAAO,EAAE,SAAS,GAAG,SAAS,CAAC;IAC/B,MAAM,EAAE,MAAM,CAAC;IACf,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,qBAAa,UAAU;IACrB,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAgB;IACnC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAwB;IAChD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAC,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAC,CAAS;gBAE5B,MAAM,EAAE,gBAAgB;IAUpC;;;OAGG;IACG,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAkB7C;;;;OAIG;IACG,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IA8B7C;;OAEG;IACG,YAAY,IAAI,OAAO,CAAC,SAAS,CAAC;IAqBxC;;OAEG;IACG,cAAc,IAAI,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC;IAKnD;;OAEG;IACG,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIlD;;OAEG;IACG,iBAAiB,IAAI,OAAO,CAAC,IAAI,CAAC;IAMxC;;;OAGG;YACW,WAAW;IASzB;;OAEG;YACW,iBAAiB;IAK/B;;OAEG;IACH,OAAO,CAAC,eAAe;IAKvB,OAAO,KAAK,WAAW,GAEtB;IAED,OAAO,KAAK,QAAQ,GAEnB;IAED,OAAO,KAAK,OAAO,GAElB;CACF"}
@@ -0,0 +1,146 @@
1
+ "use strict";
2
+ /**
3
+ * fee-tracking.ts
4
+ *
5
+ * API key fee tracking for rate limiting.
6
+ * Supports custom per-key limits with fallback to default.
7
+ * Supports periodic reset of consumption.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.FeeTracker = void 0;
11
+ const relayer_sdk_1 = require("@openzeppelin/relayer-sdk");
12
+ const constants_1 = require("./constants");
13
+ class FeeTracker {
14
+ constructor(config) {
15
+ this.kv = config.kv;
16
+ this.network = config.network;
17
+ this.apiKey = config.apiKey;
18
+ this.defaultLimit = config.defaultLimit;
19
+ this.resetPeriodMs = config.resetPeriodMs;
20
+ }
21
+ // === Public API: Transaction Flow ===
22
+ /**
23
+ * Check if API key can afford the given fee.
24
+ * Throws 429 if not. No-op if unlimited.
25
+ */
26
+ async checkBudget(fee) {
27
+ const limit = await this.getEffectiveLimit();
28
+ if (limit === undefined)
29
+ return;
30
+ const { consumed } = await this.getFeeState();
31
+ if (consumed + fee > limit) {
32
+ const remaining = limit - consumed;
33
+ throw (0, relayer_sdk_1.pluginError)(`Transaction fee (${fee} stroops) exceeds remaining budget (${remaining} stroops). Consumed: ${consumed}/${limit} stroops.`, {
34
+ code: "FEE_LIMIT_EXCEEDED",
35
+ status: constants_1.HTTP_STATUS.TOO_MANY_REQUESTS,
36
+ details: { consumed, fee, remaining, limit },
37
+ });
38
+ }
39
+ }
40
+ /**
41
+ * Record fee consumption after transaction.
42
+ * Uses distributed lock for atomicity with retry.
43
+ * Errors are logged but not thrown (non-blocking).
44
+ */
45
+ async recordUsage(fee) {
46
+ const maxRetries = 3;
47
+ for (let i = 0; i < maxRetries; i++) {
48
+ try {
49
+ const result = await this.kv.withLock(this.lockKey, async () => {
50
+ const state = await this.getFeeState();
51
+ const now = Date.now();
52
+ await this.kv.set(this.consumedKey, {
53
+ consumed: state.consumed + fee,
54
+ periodStart: state.periodStart ?? now,
55
+ });
56
+ return true;
57
+ }, { ttlSec: 5, onBusy: "skip" });
58
+ if (result !== null)
59
+ return;
60
+ // Lock busy, retry with jitter
61
+ await new Promise((r) => setTimeout(r, 20 + Math.random() * 50));
62
+ }
63
+ catch (err) {
64
+ console.warn(`[channels] Failed to record fee: ${err}`);
65
+ return;
66
+ }
67
+ }
68
+ console.warn(`[channels] Failed to record fee after ${maxRetries} retries (lock contention)`);
69
+ }
70
+ /**
71
+ * Get complete usage info for management API.
72
+ */
73
+ async getUsageInfo() {
74
+ const [state, limit] = await Promise.all([
75
+ this.getFeeState(),
76
+ this.getEffectiveLimit(),
77
+ ]);
78
+ return {
79
+ consumed: state.consumed,
80
+ limit,
81
+ remaining: limit !== undefined ? Math.max(0, limit - state.consumed) : undefined,
82
+ periodStartAt: state.periodStart
83
+ ? new Date(state.periodStart).toISOString()
84
+ : undefined,
85
+ periodEndsAt: state.periodStart && this.resetPeriodMs
86
+ ? new Date(state.periodStart + this.resetPeriodMs).toISOString()
87
+ : undefined,
88
+ };
89
+ }
90
+ /**
91
+ * Get custom limit for this API key.
92
+ */
93
+ async getCustomLimit() {
94
+ const data = await this.kv.get(this.limitKey);
95
+ return data?.limit;
96
+ }
97
+ /**
98
+ * Set custom limit for this API key.
99
+ */
100
+ async setCustomLimit(limit) {
101
+ await this.kv.set(this.limitKey, { limit });
102
+ }
103
+ /**
104
+ * Delete custom limit for this API key.
105
+ */
106
+ async deleteCustomLimit() {
107
+ await this.kv.del(this.limitKey);
108
+ }
109
+ // === Private ===
110
+ /**
111
+ * Get effective fee state after applying period expiry.
112
+ * Returns { consumed: 0 } if no data or period expired.
113
+ */
114
+ async getFeeState() {
115
+ const data = await this.kv.get(this.consumedKey);
116
+ if (!data || this.isPeriodExpired(data.periodStart)) {
117
+ return { consumed: 0 };
118
+ }
119
+ return data;
120
+ }
121
+ /**
122
+ * Get effective limit (custom ?? default).
123
+ */
124
+ async getEffectiveLimit() {
125
+ const custom = await this.getCustomLimit();
126
+ return custom ?? this.defaultLimit;
127
+ }
128
+ /**
129
+ * Check if period has expired.
130
+ */
131
+ isPeriodExpired(periodStart) {
132
+ if (!this.resetPeriodMs || !periodStart)
133
+ return false;
134
+ return Date.now() - periodStart >= this.resetPeriodMs;
135
+ }
136
+ get consumedKey() {
137
+ return `${this.network}:api-key-fees:${this.apiKey}`;
138
+ }
139
+ get limitKey() {
140
+ return `${this.network}:api-key-limit:${this.apiKey}`;
141
+ }
142
+ get lockKey() {
143
+ return `${this.network}:fee-lock:${this.apiKey}`;
144
+ }
145
+ }
146
+ exports.FeeTracker = FeeTracker;
@@ -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;AA6KvE;;GAEG;AACH,wBAAsB,OAAO,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,GAAG,CAAC,CAGlE"}
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;AAqNvE;;GAEG;AACH,wBAAsB,OAAO,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,GAAG,CAAC,CAGlE"}
@@ -18,13 +18,19 @@ const stellar_sdk_1 = require("@stellar/stellar-sdk");
18
18
  const simulation_1 = require("./simulation");
19
19
  const fee_1 = require("./fee");
20
20
  const tx_1 = require("./tx");
21
- async function handleXdrSubmit(xdrStr, fundRelayer, network, networkPassphrase, api) {
21
+ const fee_tracking_1 = require("./fee-tracking");
22
+ function getApiKey(headers, headerName) {
23
+ const values = headers[headerName];
24
+ return values?.[0]?.trim() || undefined;
25
+ }
26
+ async function handleXdrSubmit(xdrStr, fundRelayer, network, networkPassphrase, api, tracker) {
22
27
  const tx = new stellar_sdk_1.Transaction(xdrStr, networkPassphrase);
23
28
  const validated = (0, tx_1.validateExistingTransactionForSubmitOnly)(tx);
24
29
  const maxFee = (0, fee_1.calculateMaxFee)(validated);
25
- return (0, submit_1.submitWithFeeBumpAndWait)(fundRelayer, validated.toXDR(), network, maxFee, api);
30
+ await tracker?.checkBudget(maxFee);
31
+ return (0, submit_1.submitWithFeeBumpAndWait)(fundRelayer, validated.toXDR(), network, maxFee, api, tracker);
26
32
  }
27
- async function handleFuncAuthSubmit(func, auth, api, pool, fundRelayer, fundAddress, network, networkPassphrase) {
33
+ async function handleFuncAuthSubmit(func, auth, api, pool, fundRelayer, fundAddress, network, networkPassphrase, tracker) {
28
34
  let poolLock;
29
35
  try {
30
36
  poolLock = await pool.acquire();
@@ -52,7 +58,8 @@ async function handleFuncAuthSubmit(func, auth, api, pool, fundRelayer, fundAddr
52
58
  const built = await (0, simulation_1.simulateAndBuildWithChannel)(func, auth, { address: channelInfo.address, sequence: channelStatus.sequence_number }, fundAddress, fundRelayer, networkPassphrase);
53
59
  const signedTx = await (0, submit_1.signWithChannelAndFund)(built, channelRelayer, fundRelayer, channelInfo.address, fundAddress, networkPassphrase);
54
60
  const maxFee = (0, fee_1.calculateMaxFee)(signedTx);
55
- return await (0, submit_1.submitWithFeeBumpAndWait)(fundRelayer, signedTx.toXDR(), network, maxFee, api);
61
+ await tracker?.checkBudget(maxFee);
62
+ return await (0, submit_1.submitWithFeeBumpAndWait)(fundRelayer, signedTx.toXDR(), network, maxFee, api, tracker);
56
63
  }
57
64
  finally {
58
65
  if (poolLock) {
@@ -61,15 +68,35 @@ async function handleFuncAuthSubmit(func, auth, api, pool, fundRelayer, fundAddr
61
68
  }
62
69
  }
63
70
  async function channelAccounts(context) {
64
- const { api, kv, params } = context;
71
+ const { api, kv, params, headers } = context;
65
72
  // Management branch: handle and return immediately
66
73
  if ((0, management_1.isManagementRequest)(params)) {
67
74
  return await (0, management_1.handleManagement)(context);
68
75
  }
69
76
  // Load config and initialize per-request dependencies
70
77
  const config = (0, config_1.loadConfig)();
71
- const pool = new pool_1.ChannelPool(config.network, kv);
78
+ const pool = new pool_1.ChannelPool(config.network, kv, config.lockTtlSeconds);
72
79
  const networkPassphrase = (0, config_1.getNetworkPassphrase)(config.network);
80
+ // Fee tracking setup
81
+ let tracker;
82
+ const apiKey = getApiKey(headers, config.apiKeyHeader);
83
+ // If default limit is set, require API key
84
+ if (config.feeLimit !== undefined && !apiKey) {
85
+ throw (0, relayer_sdk_1.pluginError)("API key required", {
86
+ code: "API_KEY_REQUIRED",
87
+ status: constants_1.HTTP_STATUS.BAD_REQUEST,
88
+ });
89
+ }
90
+ // Create tracker if API key is present (for tracking and custom limits)
91
+ if (apiKey) {
92
+ tracker = new fee_tracking_1.FeeTracker({
93
+ kv,
94
+ network: config.network,
95
+ apiKey,
96
+ defaultLimit: config.feeLimit,
97
+ resetPeriodMs: config.feeResetPeriodMs,
98
+ });
99
+ }
73
100
  try {
74
101
  // 1. Validate and parse request (xdr OR func+auth)
75
102
  const request = (0, validation_1.validateAndParseRequest)(params);
@@ -98,10 +125,10 @@ async function channelAccounts(context) {
98
125
  // 3. Branch by request type
99
126
  if (request.type === "xdr") {
100
127
  console.log(`[channels] Flow: XDR submit-only`);
101
- return await handleXdrSubmit(request.xdr, fundRelayer, config.network, networkPassphrase, api);
128
+ return await handleXdrSubmit(request.xdr, fundRelayer, config.network, networkPassphrase, api, tracker);
102
129
  }
103
130
  console.log(`[channels] Flow: func+auth with channel account`);
104
- return await handleFuncAuthSubmit(request.func, request.auth, api, pool, fundRelayer, fundInfo.address, config.network, networkPassphrase);
131
+ return await handleFuncAuthSubmit(request.func, request.auth, api, pool, fundRelayer, fundInfo.address, config.network, networkPassphrase, tracker);
105
132
  }
106
133
  finally {
107
134
  // Nothing to cleanup here; func-auth path releases locks internally
@@ -1,9 +1,13 @@
1
1
  /**
2
2
  * management.ts
3
3
  *
4
- * Payload-based management API for channel relayerIds.
4
+ * Payload-based management API for channel relayerIds and fee limits.
5
5
  * - listChannelAccounts: returns relayerIds from KV
6
6
  * - setChannelAccounts: replaces relayerIds array in KV (checks lock conflicts)
7
+ * - getFeeUsage: returns fee consumption for an API key
8
+ * - getFeeLimit: returns custom limit for an API key (if set)
9
+ * - setFeeLimit: sets custom limit for an API key
10
+ * - deleteFeeLimit: removes custom limit for an API key
7
11
  */
8
12
  import type { PluginContext } from "@openzeppelin/relayer-sdk";
9
13
  export declare function isManagementRequest(params: any): boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"management.d.ts","sourceRoot":"","sources":["../../src/plugin/management.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAiB,MAAM,2BAA2B,CAAC;AAe9E,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,GAAG,GAAG,OAAO,CAOxD;AAED,wBAAsB,gBAAgB,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,GAAG,CAAC,CAiC3E"}
1
+ {"version":3,"file":"management.d.ts","sourceRoot":"","sources":["../../src/plugin/management.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAiB,MAAM,2BAA2B,CAAC;AAM9E,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,GAAG,GAAG,OAAO,CAOxD;AAED,wBAAsB,gBAAgB,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,GAAG,CAAC,CA8C3E"}
@@ -2,9 +2,13 @@
2
2
  /**
3
3
  * management.ts
4
4
  *
5
- * Payload-based management API for channel relayerIds.
5
+ * Payload-based management API for channel relayerIds and fee limits.
6
6
  * - listChannelAccounts: returns relayerIds from KV
7
7
  * - setChannelAccounts: replaces relayerIds array in KV (checks lock conflicts)
8
+ * - getFeeUsage: returns fee consumption for an API key
9
+ * - getFeeLimit: returns custom limit for an API key (if set)
10
+ * - setFeeLimit: sets custom limit for an API key
11
+ * - deleteFeeLimit: removes custom limit for an API key
8
12
  */
9
13
  Object.defineProperty(exports, "__esModule", { value: true });
10
14
  exports.isManagementRequest = isManagementRequest;
@@ -12,16 +16,7 @@ exports.handleManagement = handleManagement;
12
16
  const relayer_sdk_1 = require("@openzeppelin/relayer-sdk");
13
17
  const config_1 = require("./config");
14
18
  const constants_1 = require("./constants");
15
- function timingSafeEqual(a, b) {
16
- // Basic constant-time comparison without crypto dep
17
- if (a.length !== b.length)
18
- return false;
19
- let result = 0;
20
- for (let i = 0; i < a.length; i++) {
21
- result |= a.charCodeAt(i) ^ b.charCodeAt(i);
22
- }
23
- return result === 0;
24
- }
19
+ const fee_tracking_1 = require("./fee-tracking");
25
20
  function isManagementRequest(params) {
26
21
  return Boolean(params &&
27
22
  typeof params === "object" &&
@@ -30,8 +25,8 @@ function isManagementRequest(params) {
30
25
  }
31
26
  async function handleManagement(context) {
32
27
  const { kv, params } = context;
33
- const adminSecretEnv = (0, config_1.getAdminSecret)();
34
- if (!adminSecretEnv) {
28
+ const config = (0, config_1.loadConfig)();
29
+ if (!config.adminSecret) {
35
30
  throw (0, relayer_sdk_1.pluginError)("Management API disabled", {
36
31
  code: "MANAGEMENT_DISABLED",
37
32
  status: constants_1.HTTP_STATUS.FORBIDDEN,
@@ -39,20 +34,26 @@ async function handleManagement(context) {
39
34
  }
40
35
  const m = params?.management || {};
41
36
  const provided = (m.adminSecret ?? "").toString();
42
- if (!provided || !timingSafeEqual(provided, adminSecretEnv)) {
37
+ if (!provided || provided !== config.adminSecret) {
43
38
  throw (0, relayer_sdk_1.pluginError)("Unauthorized", {
44
39
  code: "UNAUTHORIZED",
45
40
  status: constants_1.HTTP_STATUS.UNAUTHORIZED,
46
41
  });
47
42
  }
48
43
  const action = String(m.action || "");
49
- // Load config (requires env like STELLAR_NETWORK) after auth
50
- const cfg = (0, config_1.loadConfig)();
51
44
  switch (action) {
52
45
  case "listChannelAccounts":
53
- return await listChannelAccounts(kv, cfg.network);
46
+ return await listChannelAccounts(kv, config.network);
54
47
  case "setChannelAccounts":
55
- return await setChannelAccounts(kv, cfg.network, m);
48
+ return await setChannelAccounts(kv, config.network, m);
49
+ case "getFeeUsage":
50
+ return await getFeeUsage(kv, config.network, config.feeLimit, config.feeResetPeriodMs, m);
51
+ case "getFeeLimit":
52
+ return await getFeeLimit(kv, config.network, config.feeLimit, m);
53
+ case "setFeeLimit":
54
+ return await setFeeLimit(kv, config.network, m);
55
+ case "deleteFeeLimit":
56
+ return await deleteFeeLimit(kv, config.network, m);
56
57
  default:
57
58
  throw (0, relayer_sdk_1.pluginError)("Invalid management action", {
58
59
  code: "INVALID_ACTION",
@@ -76,6 +77,100 @@ async function listChannelAccounts(kv, network) {
76
77
  });
77
78
  }
78
79
  }
80
+ async function getFeeUsage(kv, network, defaultLimit, resetPeriodMs, payload) {
81
+ const apiKey = payload?.apiKey;
82
+ if (!apiKey || typeof apiKey !== "string") {
83
+ throw (0, relayer_sdk_1.pluginError)("Invalid payload: apiKey is required", {
84
+ code: "INVALID_PAYLOAD",
85
+ status: constants_1.HTTP_STATUS.BAD_REQUEST,
86
+ });
87
+ }
88
+ try {
89
+ const tracker = new fee_tracking_1.FeeTracker({
90
+ kv,
91
+ network,
92
+ apiKey,
93
+ defaultLimit,
94
+ resetPeriodMs,
95
+ });
96
+ return await tracker.getUsageInfo();
97
+ }
98
+ catch (e) {
99
+ throw (0, relayer_sdk_1.pluginError)("KV error while reading fee usage", {
100
+ code: "KV_ERROR",
101
+ status: constants_1.HTTP_STATUS.INTERNAL_SERVER_ERROR,
102
+ });
103
+ }
104
+ }
105
+ async function getFeeLimit(kv, network, defaultLimit, payload) {
106
+ const apiKey = payload?.apiKey;
107
+ if (!apiKey || typeof apiKey !== "string") {
108
+ throw (0, relayer_sdk_1.pluginError)("Invalid payload: apiKey is required", {
109
+ code: "INVALID_PAYLOAD",
110
+ status: constants_1.HTTP_STATUS.BAD_REQUEST,
111
+ });
112
+ }
113
+ try {
114
+ const tracker = new fee_tracking_1.FeeTracker({ kv, network, apiKey, defaultLimit });
115
+ const customLimit = await tracker.getCustomLimit();
116
+ return {
117
+ limit: customLimit ?? defaultLimit,
118
+ };
119
+ }
120
+ catch (e) {
121
+ throw (0, relayer_sdk_1.pluginError)("KV error while reading fee limit", {
122
+ code: "KV_ERROR",
123
+ status: constants_1.HTTP_STATUS.INTERNAL_SERVER_ERROR,
124
+ });
125
+ }
126
+ }
127
+ async function setFeeLimit(kv, network, payload) {
128
+ const apiKey = payload?.apiKey;
129
+ const limit = payload?.limit;
130
+ if (!apiKey || typeof apiKey !== "string") {
131
+ throw (0, relayer_sdk_1.pluginError)("Invalid payload: apiKey is required", {
132
+ code: "INVALID_PAYLOAD",
133
+ status: constants_1.HTTP_STATUS.BAD_REQUEST,
134
+ });
135
+ }
136
+ if (typeof limit !== "number" || !Number.isFinite(limit) || limit < 0) {
137
+ throw (0, relayer_sdk_1.pluginError)("Invalid payload: limit must be a non-negative number", {
138
+ code: "INVALID_PAYLOAD",
139
+ status: constants_1.HTTP_STATUS.BAD_REQUEST,
140
+ });
141
+ }
142
+ try {
143
+ const tracker = new fee_tracking_1.FeeTracker({ kv, network, apiKey });
144
+ await tracker.setCustomLimit(Math.floor(limit));
145
+ return { ok: true, limit: Math.floor(limit) };
146
+ }
147
+ catch (e) {
148
+ throw (0, relayer_sdk_1.pluginError)("KV error while setting fee limit", {
149
+ code: "KV_ERROR",
150
+ status: constants_1.HTTP_STATUS.INTERNAL_SERVER_ERROR,
151
+ });
152
+ }
153
+ }
154
+ async function deleteFeeLimit(kv, network, payload) {
155
+ const apiKey = payload?.apiKey;
156
+ if (!apiKey || typeof apiKey !== "string") {
157
+ throw (0, relayer_sdk_1.pluginError)("Invalid payload: apiKey is required", {
158
+ code: "INVALID_PAYLOAD",
159
+ status: constants_1.HTTP_STATUS.BAD_REQUEST,
160
+ });
161
+ }
162
+ try {
163
+ const tracker = new fee_tracking_1.FeeTracker({ kv, network, apiKey });
164
+ await tracker.deleteCustomLimit();
165
+ return { ok: true };
166
+ }
167
+ catch (e) {
168
+ throw (0, relayer_sdk_1.pluginError)("KV error while deleting fee limit", {
169
+ code: "KV_ERROR",
170
+ status: constants_1.HTTP_STATUS.INTERNAL_SERVER_ERROR,
171
+ });
172
+ }
173
+ }
79
174
  async function setChannelAccounts(kv, network, payload) {
80
175
  const incoming = payload?.relayerIds;
81
176
  if (!Array.isArray(incoming)) {
@@ -17,7 +17,7 @@ export declare class ChannelPool {
17
17
  private readonly channelLockTtlSec;
18
18
  private readonly mutexTtlSec;
19
19
  private readonly kv;
20
- constructor(network: "testnet" | "mainnet", kv: PluginKVStore);
20
+ constructor(network: "testnet" | "mainnet", kv: PluginKVStore, lockTtlSeconds: number);
21
21
  /** Acquire a relayerId with a token lock */
22
22
  acquire(): Promise<PoolLock>;
23
23
  private withGlobalMutex;
@@ -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;AAKvE,MAAM,MAAM,QAAQ,GAAG;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAI5D,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;IAQ7D,4CAA4C;IACtC,OAAO,IAAI,OAAO,CAAC,QAAQ,CAAC;YAuBpB,eAAe;YASf,iBAAiB;IAwB/B,oCAAoC;IAC9B,OAAO,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IAY5C,OAAO,CAAC,aAAa;IAIrB,OAAO,CAAC,OAAO;YAID,mBAAmB;CAWlC"}
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;AAI5D,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;gBAGjC,OAAO,EAAE,SAAS,GAAG,SAAS,EAC9B,EAAE,EAAE,aAAa,EACjB,cAAc,EAAE,MAAM;IASxB,4CAA4C;IACtC,OAAO,IAAI,OAAO,CAAC,QAAQ,CAAC;YAuBpB,eAAe;YAQf,iBAAiB;IAwB/B,oCAAoC;IAC9B,OAAO,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IAY5C,OAAO,CAAC,aAAa;IAIrB,OAAO,CAAC,OAAO;YAID,mBAAmB;CAWlC"}
@@ -14,14 +14,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
14
14
  exports.ChannelPool = void 0;
15
15
  const relayer_sdk_1 = require("@openzeppelin/relayer-sdk");
16
16
  const crypto_1 = __importDefault(require("crypto"));
17
- const config_1 = require("./config");
18
17
  const constants_1 = require("./constants");
19
18
  class ChannelPool {
20
- constructor(network, kv) {
19
+ constructor(network, kv, lockTtlSeconds) {
21
20
  this.network = network;
22
21
  this.kv = kv;
23
22
  this.globalLockKey = `${this.network}:channel-pool-lock`;
24
- this.channelLockTtlSec = (0, config_1.getLockTtlSeconds)();
23
+ this.channelLockTtlSec = lockTtlSeconds;
25
24
  this.mutexTtlSec = constants_1.POOL.MUTEX_TTL_SECONDS;
26
25
  }
27
26
  /** Acquire a relayerId with a token lock */
@@ -45,11 +44,10 @@ class ChannelPool {
45
44
  }
46
45
  // Run a function under the short-lived global mutex; returns null if busy
47
46
  async withGlobalMutex(fn) {
48
- const r = (await this.kv.withLock(this.globalLockKey, fn, {
47
+ return this.kv.withLock(this.globalLockKey, fn, {
49
48
  ttlSec: this.mutexTtlSec,
50
49
  onBusy: "skip",
51
- }));
52
- return r;
50
+ });
53
51
  }
54
52
  // Inside the mutex: pick an available relayer and set its channel lock
55
53
  async tryLockAnyRelayer() {
@@ -6,6 +6,7 @@
6
6
  import { Transaction } from "@stellar/stellar-sdk";
7
7
  import { Relayer, PluginAPI } from "@openzeppelin/relayer-sdk";
8
8
  import { ChannelAccountsResponse } from "./types";
9
+ import { FeeTracker } from "./fee-tracking";
9
10
  /**
10
11
  * Sign transaction with both channel and fund relayers
11
12
  * - First sign with channel account
@@ -16,5 +17,5 @@ export declare function signWithChannelAndFund(transaction: Transaction, channel
16
17
  /**
17
18
  * Submit transaction with fee bump and wait for confirmation
18
19
  */
19
- export declare function submitWithFeeBumpAndWait(fundRelayer: Relayer, signedXdr: string, network: "testnet" | "mainnet", maxFee: number, api: PluginAPI): Promise<ChannelAccountsResponse>;
20
+ export declare function submitWithFeeBumpAndWait(fundRelayer: Relayer, signedXdr: string, network: "testnet" | "mainnet", maxFee: number, api: PluginAPI, tracker?: FeeTracker): Promise<ChannelAccountsResponse>;
20
21
  //# sourceMappingURL=submit.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"submit.d.ts","sourceRoot":"","sources":["../../src/plugin/submit.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACnD,OAAO,EAEL,OAAO,EAGP,SAAS,EACV,MAAM,2BAA2B,CAAC;AAEnC,OAAO,EAAE,uBAAuB,EAAE,MAAM,SAAS,CAAC;AAElD;;;;;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,CA2BtB;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,GACb,OAAO,CAAC,uBAAuB,CAAC,CAwDlC"}
1
+ {"version":3,"file":"submit.d.ts","sourceRoot":"","sources":["../../src/plugin/submit.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACnD,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;;;;;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,CA2BtB;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,OAAO,CAAC,EAAE,UAAU,GACnB,OAAO,CAAC,uBAAuB,CAAC,CAiElC"}
@@ -39,7 +39,7 @@ async function signWithChannelAndFund(transaction, channelRelayer, _fundRelayer,
39
39
  /**
40
40
  * Submit transaction with fee bump and wait for confirmation
41
41
  */
42
- async function submitWithFeeBumpAndWait(fundRelayer, signedXdr, network, maxFee, api) {
42
+ async function submitWithFeeBumpAndWait(fundRelayer, signedXdr, network, maxFee, api, tracker) {
43
43
  // Submit with fee bump
44
44
  console.debug(`[channels] Sending fee bump tx: network=${network}, maxFee=${maxFee}, xdr_len=${signedXdr.length}`);
45
45
  const payload = {
@@ -58,6 +58,10 @@ async function submitWithFeeBumpAndWait(fundRelayer, signedXdr, network, maxFee,
58
58
  }));
59
59
  // Check if transaction actually succeeded
60
60
  if (final.status === "failed") {
61
+ // Record fee on on-chain failure (transaction was still submitted and consumed fees)
62
+ if (tracker) {
63
+ await tracker.recordUsage(maxFee);
64
+ }
61
65
  throw (0, relayer_sdk_1.pluginError)(final.status_reason || "Transaction failed", {
62
66
  code: "ONCHAIN_FAILED",
63
67
  status: constants_1.HTTP_STATUS.BAD_REQUEST,
@@ -69,6 +73,10 @@ async function submitWithFeeBumpAndWait(fundRelayer, signedXdr, network, maxFee,
69
73
  },
70
74
  });
71
75
  }
76
+ // Record fee on success
77
+ if (tracker) {
78
+ await tracker.recordUsage(maxFee);
79
+ }
72
80
  return {
73
81
  transactionId: final.id,
74
82
  status: final.status,
@@ -80,7 +88,7 @@ async function submitWithFeeBumpAndWait(fundRelayer, signedXdr, network, maxFee,
80
88
  if (error.code === "ONCHAIN_FAILED") {
81
89
  throw error;
82
90
  }
83
- // Otherwise, it's a timeout
91
+ // Otherwise, it's a timeout - don't track fees (status unknown)
84
92
  throw (0, relayer_sdk_1.pluginError)("Transaction wait timeout. It may still submit.", {
85
93
  code: "WAIT_TIMEOUT",
86
94
  status: constants_1.HTTP_STATUS.GATEWAY_TIMEOUT,