@openzeppelin/relayer-plugin-channels 0.10.0 → 0.11.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.
@@ -15,6 +15,7 @@ export interface ChannelAccountsConfig {
15
15
  contractCapacityRatio: number;
16
16
  inclusionFeeDefault: number;
17
17
  inclusionFeeLimited: number;
18
+ sequenceNumberCacheMaxAgeMs: number;
18
19
  }
19
20
  /**
20
21
  * 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,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;CAC7B;AA4FD;;GAEG;AACH,wBAAgB,UAAU,IAAI,qBAAqB,CAsBlD;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,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;CACrC;AAmGD;;GAEG;AACH,wBAAgB,UAAU,IAAI,qBAAqB,CAuBlD;AAED;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,SAAS,GAAG,SAAS,GAAG,MAAM,CAE3E"}
@@ -91,6 +91,13 @@ function parseInclusionFee(envVar, defaultValue) {
91
91
  return defaultValue;
92
92
  return Math.floor(n);
93
93
  }
94
+ function parseSequenceNumberCacheMaxAge() {
95
+ const raw = process.env.SEQUENCE_NUMBER_CACHE_MAX_AGE_MS;
96
+ if (!raw)
97
+ return constants_1.CONFIG.DEFAULT_SEQUENCE_NUMBER_CACHE_MAX_AGE_MS;
98
+ const n = Number(raw);
99
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : constants_1.CONFIG.DEFAULT_SEQUENCE_NUMBER_CACHE_MAX_AGE_MS;
100
+ }
94
101
  function parseContractCapacityRatio() {
95
102
  const raw = process.env.CONTRACT_CAPACITY_RATIO;
96
103
  if (!raw)
@@ -124,6 +131,7 @@ function loadConfig() {
124
131
  contractCapacityRatio: parseContractCapacityRatio(),
125
132
  inclusionFeeDefault: parseInclusionFee('INCLUSION_FEE_DEFAULT', DEFAULT_INCLUSION_FEE_DEFAULT),
126
133
  inclusionFeeLimited: parseInclusionFee('INCLUSION_FEE_LIMITED', DEFAULT_INCLUSION_FEE_LIMITED),
134
+ sequenceNumberCacheMaxAgeMs: parseSequenceNumberCacheMaxAge(),
127
135
  };
128
136
  }
129
137
  /**
@@ -19,6 +19,7 @@ export declare const CONFIG: {
19
19
  readonly MIN_LOCK_TTL_SECONDS: 3;
20
20
  readonly MAX_LOCK_TTL_SECONDS: 30;
21
21
  readonly DEFAULT_CONTRACT_CAPACITY_RATIO: 0.8;
22
+ readonly DEFAULT_SEQUENCE_NUMBER_CACHE_MAX_AGE_MS: 120000;
22
23
  };
23
24
  export declare const POOL: {
24
25
  readonly MUTEX_TTL_SECONDS: 1;
@@ -34,6 +35,7 @@ export declare const SIMULATION: {
34
35
  readonly MIN_TIME_BOUND: 0;
35
36
  readonly MAX_TIME_BOUND_OFFSET_SECONDS: 120;
36
37
  readonly MAX_FUTURE_TIME_BOUND_SECONDS: 120;
38
+ readonly SIMULATION_AUTH_MODE: "enforce";
37
39
  };
38
40
  export declare const POLLING: {
39
41
  readonly INTERVAL_MS: 1000;
@@ -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;;;;;CAKT,CAAC;AAGX,eAAO,MAAM,IAAI;;;;;CAOP,CAAC;AAGX,eAAO,MAAM,IAAI;;CAEP,CAAC;AAGX,eAAO,MAAM,UAAU;;;;;CAKb,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;;;;;;CAMT,CAAC;AAGX,eAAO,MAAM,IAAI;;;;;CAOP,CAAC;AAGX,eAAO,MAAM,IAAI;;CAEP,CAAC;AAGX,eAAO,MAAM,UAAU;;;;;;CAMb,CAAC;AAGX,eAAO,MAAM,OAAO;;;CAGV,CAAC;AAEX,eAAO,MAAM,GAAG;;CAGN,CAAC"}
@@ -24,6 +24,7 @@ exports.CONFIG = {
24
24
  MIN_LOCK_TTL_SECONDS: 3,
25
25
  MAX_LOCK_TTL_SECONDS: 30,
26
26
  DEFAULT_CONTRACT_CAPACITY_RATIO: 0.8,
27
+ DEFAULT_SEQUENCE_NUMBER_CACHE_MAX_AGE_MS: 120000,
27
28
  };
28
29
  // Pool Constants
29
30
  exports.POOL = {
@@ -44,6 +45,7 @@ exports.SIMULATION = {
44
45
  MIN_TIME_BOUND: 0,
45
46
  MAX_TIME_BOUND_OFFSET_SECONDS: 120,
46
47
  MAX_FUTURE_TIME_BOUND_SECONDS: 120,
48
+ SIMULATION_AUTH_MODE: 'enforce',
47
49
  };
48
50
  // Polling for transactionWait
49
51
  exports.POLLING = {
@@ -5,7 +5,6 @@
5
5
  * Orchestrates the transaction processing pipeline using channel accounts with fee bumping.
6
6
  */
7
7
  import { PluginContext } from '@openzeppelin/relayer-sdk';
8
- import type { Relayer } from '@openzeppelin/relayer-sdk';
9
8
  import { Transaction, xdr } from '@stellar/stellar-sdk';
10
9
  /**
11
10
  * Extracts func and auth from an unsigned Soroban transaction.
@@ -15,7 +14,6 @@ export declare function extractFuncAuthFromUnsignedXdr(tx: Transaction): {
15
14
  func: xdr.HostFunction;
16
15
  auth: xdr.SorobanAuthorizationEntry[];
17
16
  } | null;
18
- export declare function getAccountSequence(relayer: Relayer, address: string): Promise<string>;
19
17
  /**
20
18
  * Main plugin handler exported for OpenZeppelin Relayer
21
19
  */
@@ -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;AACvE,OAAO,KAAK,EAAa,OAAO,EAAE,MAAM,2BAA2B,CAAC;AAQpE,OAAO,EAAW,WAAW,EAAE,GAAG,EAAE,MAAM,sBAAsB,CAAC;AAWjE;;;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;AAKD,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAsH3F;AA2PD;;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;AA0BxD;;;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;AAqPD;;GAEG;AACH,wBAAsB,OAAO,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,GAAG,CAAC,CAElE"}
@@ -7,7 +7,6 @@
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
9
  exports.extractFuncAuthFromUnsignedXdr = extractFuncAuthFromUnsignedXdr;
10
- exports.getAccountSequence = getAccountSequence;
11
10
  exports.handler = handler;
12
11
  const relayer_sdk_1 = require("@openzeppelin/relayer-sdk");
13
12
  const pool_1 = require("./pool");
@@ -21,6 +20,7 @@ const simulation_1 = require("./simulation");
21
20
  const fee_1 = require("./fee");
22
21
  const tx_1 = require("./tx");
23
22
  const fee_tracking_1 = require("./fee-tracking");
23
+ const sequence_1 = require("./sequence");
24
24
  function getApiKey(headers, headerName) {
25
25
  const values = headers[headerName];
26
26
  return values?.[0]?.trim() || undefined;
@@ -45,122 +45,8 @@ function extractFuncAuthFromUnsignedXdr(tx) {
45
45
  auth: invokeHostFn.auth(),
46
46
  };
47
47
  }
48
- async function getAccountSequence(relayer, address) {
49
- let accountKey;
50
- try {
51
- accountKey = stellar_sdk_1.xdr.LedgerKey.account(new stellar_sdk_1.xdr.LedgerKeyAccount({
52
- accountId: stellar_sdk_1.Keypair.fromPublicKey(address).xdrPublicKey(),
53
- }));
54
- }
55
- catch (error) {
56
- console.error('[channels] Sequence fetch failed', {
57
- event: 'invalid_channel_account_address',
58
- code: 'FAILED_TO_GET_SEQUENCE',
59
- address,
60
- message: error instanceof Error ? error.message : String(error),
61
- });
62
- throw (0, relayer_sdk_1.pluginError)('Invalid channel account address', {
63
- code: 'FAILED_TO_GET_SEQUENCE',
64
- status: constants_1.HTTP_STATUS.BAD_GATEWAY,
65
- details: { address, message: error instanceof Error ? error.message : String(error) },
66
- });
67
- }
68
- let response;
69
- try {
70
- response = await relayer.rpc({
71
- jsonrpc: '2.0',
72
- id: Math.floor(Math.random() * 1e8).toString(),
73
- method: 'getLedgerEntries',
74
- params: {
75
- keys: [accountKey.toXDR('base64')],
76
- },
77
- });
78
- }
79
- catch (error) {
80
- console.error('[channels] Sequence fetch failed', {
81
- event: 'sequence_rpc_request_failed',
82
- code: 'FAILED_TO_GET_SEQUENCE',
83
- address,
84
- message: error instanceof Error ? error.message : String(error),
85
- });
86
- throw (0, relayer_sdk_1.pluginError)('Failed to get sequence from channel relayer', {
87
- code: 'FAILED_TO_GET_SEQUENCE',
88
- status: constants_1.HTTP_STATUS.BAD_GATEWAY,
89
- details: { message: error instanceof Error ? error.message : String(error) },
90
- });
91
- }
92
- if (response.error) {
93
- console.error('[channels] Sequence fetch failed', {
94
- event: 'sequence_rpc_error_response',
95
- code: 'FAILED_TO_GET_SEQUENCE',
96
- address,
97
- message: response.error.message,
98
- });
99
- throw (0, relayer_sdk_1.pluginError)('Failed to get sequence from channel relayer', {
100
- code: 'FAILED_TO_GET_SEQUENCE',
101
- status: constants_1.HTTP_STATUS.BAD_GATEWAY,
102
- details: { message: response.error.message },
103
- });
104
- }
105
- const result = response.result;
106
- const entries = result?.entries;
107
- if (!Array.isArray(entries)) {
108
- console.error('[channels] Sequence fetch failed', {
109
- event: 'sequence_rpc_invalid_result_shape',
110
- code: 'FAILED_TO_GET_SEQUENCE',
111
- address,
112
- });
113
- throw (0, relayer_sdk_1.pluginError)('Invalid RPC response for account sequence', {
114
- code: 'FAILED_TO_GET_SEQUENCE',
115
- status: constants_1.HTTP_STATUS.BAD_GATEWAY,
116
- details: { address },
117
- });
118
- }
119
- if (!entries || entries.length === 0) {
120
- console.warn('[channels] Sequence fetch returned no account entries', {
121
- event: 'sequence_account_not_found',
122
- code: 'ACCOUNT_NOT_FOUND',
123
- address,
124
- });
125
- throw (0, relayer_sdk_1.pluginError)('Channel account not found on ledger', {
126
- code: 'ACCOUNT_NOT_FOUND',
127
- status: constants_1.HTTP_STATUS.BAD_GATEWAY,
128
- details: { address },
129
- });
130
- }
131
- const firstEntryXdr = entries[0]?.xdr;
132
- if (typeof firstEntryXdr !== 'string') {
133
- console.error('[channels] Sequence fetch failed', {
134
- event: 'sequence_rpc_invalid_entry_xdr',
135
- code: 'FAILED_TO_GET_SEQUENCE',
136
- address,
137
- });
138
- throw (0, relayer_sdk_1.pluginError)('Invalid RPC response for account sequence', {
139
- code: 'FAILED_TO_GET_SEQUENCE',
140
- status: constants_1.HTTP_STATUS.BAD_GATEWAY,
141
- details: { address },
142
- });
143
- }
144
- try {
145
- const accountEntry = stellar_sdk_1.xdr.LedgerEntryData.fromXDR(firstEntryXdr, 'base64');
146
- return accountEntry.account().seqNum().toString();
147
- }
148
- catch (error) {
149
- console.error('[channels] Sequence fetch failed', {
150
- event: 'sequence_xdr_decode_failed',
151
- code: 'FAILED_TO_GET_SEQUENCE',
152
- address,
153
- message: error instanceof Error ? error.message : String(error),
154
- });
155
- throw (0, relayer_sdk_1.pluginError)('Failed to decode account sequence from ledger entry', {
156
- code: 'FAILED_TO_GET_SEQUENCE',
157
- status: constants_1.HTTP_STATUS.BAD_GATEWAY,
158
- details: { address, message: error instanceof Error ? error.message : String(error) },
159
- });
160
- }
161
- }
162
- async function handleXdrSubmit(xdrStr, fundRelayer, fundAddress, network, networkPassphrase, api, pool, acquireOptions, fees, tracker) {
163
- const tx = new stellar_sdk_1.Transaction(xdrStr, networkPassphrase);
48
+ async function handleXdrSubmit(xdrStr, ctx) {
49
+ const tx = new stellar_sdk_1.Transaction(xdrStr, ctx.networkPassphrase);
164
50
  // Unsigned XDR: extract func+auth and route through channel path
165
51
  if (tx.signatures.length === 0) {
166
52
  const extracted = extractFuncAuthFromUnsignedXdr(tx);
@@ -177,22 +63,22 @@ async function handleXdrSubmit(xdrStr, fundRelayer, fundAddress, network, networ
177
63
  console.log(`[channels] Unsigned XDR detected, extracting func+auth and routing through channel path`);
178
64
  // Update acquireOptions with contractId from extracted func
179
65
  const contractId = (0, fee_1.getContractIdFromFunc)(extracted.func);
180
- const updatedOptions = { ...acquireOptions, contractId };
181
- return handleFuncAuthSubmit(extracted.func, extracted.auth, api, pool, fundRelayer, fundAddress, network, networkPassphrase, updatedOptions, fees, tracker);
66
+ const updatedOptions = { ...ctx.acquireOptions, contractId };
67
+ return handleFuncAuthSubmit(extracted.func, extracted.auth, { ...ctx, acquireOptions: updatedOptions });
182
68
  }
183
69
  const validated = (0, tx_1.validateExistingTransactionForSubmitOnly)(tx);
184
- const maxFee = (0, fee_1.calculateMaxFee)(validated, acquireOptions.limitedContracts, fees);
70
+ const maxFee = (0, fee_1.calculateMaxFee)(validated, ctx.acquireOptions.limitedContracts, ctx.fees);
185
71
  const contractId = (0, fee_1.getContractIdFromTransaction)(validated);
186
- await tracker?.checkBudget(maxFee);
72
+ await ctx.tracker?.checkBudget(maxFee);
187
73
  const submitContext = {
188
74
  contractId,
189
- isLimited: contractId ? acquireOptions.limitedContracts.has(contractId) : false,
75
+ isLimited: contractId ? ctx.acquireOptions.limitedContracts.has(contractId) : false,
190
76
  };
191
- return (0, submit_1.submitWithFeeBumpAndWait)(fundRelayer, validated.toXDR(), network, maxFee, api, tracker, submitContext);
77
+ return (0, submit_1.submitWithFeeBumpAndWait)(ctx.fundRelayer, validated.toXDR(), ctx.network, maxFee, ctx.api, ctx.tracker, submitContext);
192
78
  }
193
- async function handleFuncAuthSubmit(func, auth, api, pool, fundRelayer, fundAddress, network, networkPassphrase, acquireOptions, fees, tracker) {
79
+ async function handleFuncAuthSubmit(func, auth, ctx) {
194
80
  // Simulate once — used for both read-only detection and transaction assembly
195
- const simulation = await (0, simulation_1.simulateTransaction)(func, auth, fundAddress, fundRelayer, networkPassphrase);
81
+ const simulation = await (0, simulation_1.simulateTransaction)(func, auth, ctx.fundAddress, ctx.fundRelayer, ctx.networkPassphrase);
196
82
  if (simulation.isReadOnly) {
197
83
  console.log(`[channels] Read-only call detected, returning simulation result`);
198
84
  return {
@@ -205,8 +91,8 @@ async function handleFuncAuthSubmit(func, auth, api, pool, fundRelayer, fundAddr
205
91
  }
206
92
  let poolLock;
207
93
  try {
208
- poolLock = await pool.acquire(acquireOptions);
209
- const channelRelayer = api.useRelayer(poolLock.relayerId);
94
+ poolLock = await ctx.pool.acquire(ctx.acquireOptions);
95
+ const channelRelayer = ctx.api.useRelayer(poolLock.relayerId);
210
96
  const channelInfo = await channelRelayer.getRelayer();
211
97
  console.log(`[channels] Acquired channel: ${poolLock.relayerId}`);
212
98
  if (!channelInfo || !channelInfo.address) {
@@ -223,22 +109,35 @@ async function handleFuncAuthSubmit(func, auth, api, pool, fundRelayer, fundAddr
223
109
  details: { network_type: channelInfo.network_type, relayerId: poolLock.relayerId },
224
110
  });
225
111
  }
226
- const sequence = await getAccountSequence(channelRelayer, channelInfo.address);
112
+ const sequence = await (0, sequence_1.getSequence)(ctx.kv, ctx.network, channelRelayer, channelInfo.address, ctx.sequenceNumberCacheMaxAgeMs);
227
113
  // Assemble the transaction using the cached simulation result — no second RPC call
228
- const built = (0, simulation_1.buildWithChannel)(func, auth, { address: channelInfo.address, sequence }, networkPassphrase, simulation.rawSimResult);
229
- const signedTx = await (0, submit_1.signWithChannelAndFund)(built, channelRelayer, fundRelayer, channelInfo.address, fundAddress, networkPassphrase);
230
- const maxFee = (0, fee_1.calculateMaxFee)(signedTx, acquireOptions.limitedContracts, fees);
114
+ const built = (0, simulation_1.buildWithChannel)(func, auth, { address: channelInfo.address, sequence }, ctx.networkPassphrase, simulation.rawSimResult);
115
+ const signedTx = await (0, submit_1.signWithChannelAndFund)(built, channelRelayer, ctx.fundRelayer, channelInfo.address, ctx.fundAddress, ctx.networkPassphrase);
116
+ const maxFee = (0, fee_1.calculateMaxFee)(signedTx, ctx.acquireOptions.limitedContracts, ctx.fees);
231
117
  const contractId = (0, fee_1.getContractIdFromFunc)(func);
232
- await tracker?.checkBudget(maxFee);
118
+ await ctx.tracker?.checkBudget(maxFee);
233
119
  const submitContext = {
234
120
  contractId,
235
- isLimited: contractId ? acquireOptions.limitedContracts.has(contractId) : false,
121
+ isLimited: contractId ? ctx.acquireOptions.limitedContracts.has(contractId) : false,
236
122
  };
237
- return await (0, submit_1.submitWithFeeBumpAndWait)(fundRelayer, signedTx.toXDR(), network, maxFee, api, tracker, submitContext);
123
+ try {
124
+ const result = await (0, submit_1.submitWithFeeBumpAndWait)(ctx.fundRelayer, signedTx.toXDR(), ctx.network, maxFee, ctx.api, ctx.tracker, submitContext);
125
+ if (result.status === 'confirmed') {
126
+ await (0, sequence_1.commitSequence)(ctx.kv, ctx.network, channelInfo.address, sequence);
127
+ }
128
+ else {
129
+ await (0, sequence_1.clearSequence)(ctx.kv, ctx.network, channelInfo.address);
130
+ }
131
+ return result;
132
+ }
133
+ catch (error) {
134
+ await (0, sequence_1.clearSequence)(ctx.kv, ctx.network, channelInfo.address);
135
+ throw error;
136
+ }
238
137
  }
239
138
  finally {
240
139
  if (poolLock) {
241
- await pool.release(poolLock);
140
+ await ctx.pool.release(poolLock);
242
141
  }
243
142
  }
244
143
  }
@@ -301,16 +200,30 @@ async function channelAccounts(context) {
301
200
  inclusionFeeDefault: config.inclusionFeeDefault,
302
201
  inclusionFeeLimited: config.inclusionFeeLimited,
303
202
  };
304
- // 4. Branch by request type
203
+ // 4. Build pipeline context
204
+ const ctx = {
205
+ api,
206
+ kv,
207
+ pool,
208
+ fundRelayer: fundRelayer,
209
+ fundAddress: fundInfo.address,
210
+ network: config.network,
211
+ networkPassphrase,
212
+ acquireOptions,
213
+ fees,
214
+ tracker,
215
+ sequenceNumberCacheMaxAgeMs: config.sequenceNumberCacheMaxAgeMs,
216
+ };
217
+ // 5. Branch by request type
305
218
  if (request.type === 'xdr') {
306
219
  console.log(`[channels] Flow: XDR submit-only`);
307
- return await handleXdrSubmit(request.xdr, fundRelayer, fundInfo.address, config.network, networkPassphrase, api, pool, acquireOptions, fees, tracker);
220
+ return await handleXdrSubmit(request.xdr, ctx);
308
221
  }
309
222
  // Extract contractId for func+auth flow
310
223
  const contractId = (0, fee_1.getContractIdFromFunc)(request.func);
311
224
  const funcAcquireOptions = { ...acquireOptions, contractId };
312
225
  console.log(`[channels] Flow: func+auth with channel account`);
313
- return await handleFuncAuthSubmit(request.func, request.auth, api, pool, fundRelayer, fundInfo.address, config.network, networkPassphrase, funcAcquireOptions, fees, tracker);
226
+ return await handleFuncAuthSubmit(request.func, request.auth, { ...ctx, acquireOptions: funcAcquireOptions });
314
227
  }
315
228
  /**
316
229
  * Main plugin handler exported for OpenZeppelin Relayer
@@ -0,0 +1,30 @@
1
+ /**
2
+ * sequence.ts
3
+ *
4
+ * KV-based sequence number cache for channel accounts.
5
+ * After a transaction is confirmed on-chain, the RPC `getLedgerEntries` can
6
+ * still return the old (pre-increment) sequence number due to read-after-write
7
+ * lag. Caching the next expected sequence in KV avoids `tx_bad_seq` errors.
8
+ */
9
+ import type { PluginKVStore, Relayer } from '@openzeppelin/relayer-sdk';
10
+ /**
11
+ * Fetch the current sequence number for an account directly from chain
12
+ * via `getLedgerEntries` RPC. Throws on any RPC or decoding failure.
13
+ */
14
+ export declare function getAccountSequence(relayer: Relayer, address: string): Promise<string>;
15
+ /**
16
+ * Get the current sequence number for a channel account.
17
+ * Reads from KV cache first; falls back to chain via `getAccountSequence`.
18
+ */
19
+ export declare function getSequence(kv: PluginKVStore, network: string, relayer: Relayer, address: string, sequenceNumberCacheMaxAgeMs: number): Promise<string>;
20
+ /**
21
+ * Store the next expected sequence number in KV after a transaction
22
+ * has been confirmed on-chain.
23
+ */
24
+ export declare function commitSequence(kv: PluginKVStore, network: string, address: string, usedSequence: string): Promise<void>;
25
+ /**
26
+ * Clear cached sequence — forces a re-fetch from chain on next request.
27
+ * Used when transaction outcome is uncertain (e.g. timeout).
28
+ */
29
+ export declare function clearSequence(kv: PluginKVStore, network: string, address: string): Promise<void>;
30
+ //# sourceMappingURL=sequence.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sequence.d.ts","sourceRoot":"","sources":["../../src/plugin/sequence.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM,2BAA2B,CAAC;AAwBxE;;;GAGG;AACH,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAsH3F;AAED;;;GAGG;AACH,wBAAsB,WAAW,CAC/B,EAAE,EAAE,aAAa,EACjB,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,MAAM,EACf,2BAA2B,EAAE,MAAM,GAClC,OAAO,CAAC,MAAM,CAAC,CAwBjB;AAED;;;GAGG;AACH,wBAAsB,cAAc,CAClC,EAAE,EAAE,aAAa,EACjB,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,YAAY,EAAE,MAAM,GACnB,OAAO,CAAC,IAAI,CAAC,CAaf;AAED;;;GAGG;AACH,wBAAsB,aAAa,CAAC,EAAE,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAWtG"}
@@ -0,0 +1,212 @@
1
+ "use strict";
2
+ /**
3
+ * sequence.ts
4
+ *
5
+ * KV-based sequence number cache for channel accounts.
6
+ * After a transaction is confirmed on-chain, the RPC `getLedgerEntries` can
7
+ * still return the old (pre-increment) sequence number due to read-after-write
8
+ * lag. Caching the next expected sequence in KV avoids `tx_bad_seq` errors.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.getAccountSequence = getAccountSequence;
12
+ exports.getSequence = getSequence;
13
+ exports.commitSequence = commitSequence;
14
+ exports.clearSequence = clearSequence;
15
+ const relayer_sdk_1 = require("@openzeppelin/relayer-sdk");
16
+ const stellar_sdk_1 = require("@stellar/stellar-sdk");
17
+ const constants_1 = require("./constants");
18
+ function seqKey(network, address) {
19
+ return `${network}:channel:seq:${address}`;
20
+ }
21
+ /**
22
+ * Validates that a sequence number string is a non-negative integer.
23
+ * Returns true for strings like "0", "42", "123456789012345".
24
+ */
25
+ function isValidSequence(seq) {
26
+ return /^\d+$/.test(seq);
27
+ }
28
+ /**
29
+ * Fetch the current sequence number for an account directly from chain
30
+ * via `getLedgerEntries` RPC. Throws on any RPC or decoding failure.
31
+ */
32
+ async function getAccountSequence(relayer, address) {
33
+ let accountKey;
34
+ try {
35
+ accountKey = stellar_sdk_1.xdr.LedgerKey.account(new stellar_sdk_1.xdr.LedgerKeyAccount({
36
+ accountId: stellar_sdk_1.Keypair.fromPublicKey(address).xdrPublicKey(),
37
+ }));
38
+ }
39
+ catch (error) {
40
+ console.error('[channels] Sequence fetch failed', {
41
+ event: 'invalid_channel_account_address',
42
+ code: 'FAILED_TO_GET_SEQUENCE',
43
+ address,
44
+ message: error instanceof Error ? error.message : String(error),
45
+ });
46
+ throw (0, relayer_sdk_1.pluginError)('Invalid channel account address', {
47
+ code: 'FAILED_TO_GET_SEQUENCE',
48
+ status: constants_1.HTTP_STATUS.BAD_GATEWAY,
49
+ details: { address, message: error instanceof Error ? error.message : String(error) },
50
+ });
51
+ }
52
+ let response;
53
+ try {
54
+ response = await relayer.rpc({
55
+ jsonrpc: '2.0',
56
+ id: Math.floor(Math.random() * 1e8).toString(),
57
+ method: 'getLedgerEntries',
58
+ params: {
59
+ keys: [accountKey.toXDR('base64')],
60
+ },
61
+ });
62
+ }
63
+ catch (error) {
64
+ console.error('[channels] Sequence fetch failed', {
65
+ event: 'sequence_rpc_request_failed',
66
+ code: 'FAILED_TO_GET_SEQUENCE',
67
+ address,
68
+ message: error instanceof Error ? error.message : String(error),
69
+ });
70
+ throw (0, relayer_sdk_1.pluginError)('Failed to get sequence from channel relayer', {
71
+ code: 'FAILED_TO_GET_SEQUENCE',
72
+ status: constants_1.HTTP_STATUS.BAD_GATEWAY,
73
+ details: { message: error instanceof Error ? error.message : String(error) },
74
+ });
75
+ }
76
+ if (response.error) {
77
+ console.error('[channels] Sequence fetch failed', {
78
+ event: 'sequence_rpc_error_response',
79
+ code: 'FAILED_TO_GET_SEQUENCE',
80
+ address,
81
+ message: response.error.message,
82
+ });
83
+ throw (0, relayer_sdk_1.pluginError)('Failed to get sequence from channel relayer', {
84
+ code: 'FAILED_TO_GET_SEQUENCE',
85
+ status: constants_1.HTTP_STATUS.BAD_GATEWAY,
86
+ details: { message: response.error.message },
87
+ });
88
+ }
89
+ const result = response.result;
90
+ const entries = result?.entries;
91
+ if (!Array.isArray(entries)) {
92
+ console.error('[channels] Sequence fetch failed', {
93
+ event: 'sequence_rpc_invalid_result_shape',
94
+ code: 'FAILED_TO_GET_SEQUENCE',
95
+ address,
96
+ });
97
+ throw (0, relayer_sdk_1.pluginError)('Invalid RPC response for account sequence', {
98
+ code: 'FAILED_TO_GET_SEQUENCE',
99
+ status: constants_1.HTTP_STATUS.BAD_GATEWAY,
100
+ details: { address },
101
+ });
102
+ }
103
+ if (!entries || entries.length === 0) {
104
+ console.warn('[channels] Sequence fetch returned no account entries', {
105
+ event: 'sequence_account_not_found',
106
+ code: 'ACCOUNT_NOT_FOUND',
107
+ address,
108
+ });
109
+ throw (0, relayer_sdk_1.pluginError)('Channel account not found on ledger', {
110
+ code: 'ACCOUNT_NOT_FOUND',
111
+ status: constants_1.HTTP_STATUS.BAD_GATEWAY,
112
+ details: { address },
113
+ });
114
+ }
115
+ const firstEntryXdr = entries[0]?.xdr;
116
+ if (typeof firstEntryXdr !== 'string') {
117
+ console.error('[channels] Sequence fetch failed', {
118
+ event: 'sequence_rpc_invalid_entry_xdr',
119
+ code: 'FAILED_TO_GET_SEQUENCE',
120
+ address,
121
+ });
122
+ throw (0, relayer_sdk_1.pluginError)('Invalid RPC response for account sequence', {
123
+ code: 'FAILED_TO_GET_SEQUENCE',
124
+ status: constants_1.HTTP_STATUS.BAD_GATEWAY,
125
+ details: { address },
126
+ });
127
+ }
128
+ try {
129
+ const accountEntry = stellar_sdk_1.xdr.LedgerEntryData.fromXDR(firstEntryXdr, 'base64');
130
+ return accountEntry.account().seqNum().toString();
131
+ }
132
+ catch (error) {
133
+ console.error('[channels] Sequence fetch failed', {
134
+ event: 'sequence_xdr_decode_failed',
135
+ code: 'FAILED_TO_GET_SEQUENCE',
136
+ address,
137
+ message: error instanceof Error ? error.message : String(error),
138
+ });
139
+ throw (0, relayer_sdk_1.pluginError)('Failed to decode account sequence from ledger entry', {
140
+ code: 'FAILED_TO_GET_SEQUENCE',
141
+ status: constants_1.HTTP_STATUS.BAD_GATEWAY,
142
+ details: { address, message: error instanceof Error ? error.message : String(error) },
143
+ });
144
+ }
145
+ }
146
+ /**
147
+ * Get the current sequence number for a channel account.
148
+ * Reads from KV cache first; falls back to chain via `getAccountSequence`.
149
+ */
150
+ async function getSequence(kv, network, relayer, address, sequenceNumberCacheMaxAgeMs) {
151
+ const key = seqKey(network, address);
152
+ try {
153
+ const cached = await kv.get(key);
154
+ if (cached?.sequence) {
155
+ if (!isValidSequence(cached.sequence)) {
156
+ console.warn(`[channels] Sequence cache invalid: address=${address}, value=${cached.sequence}, refetching`);
157
+ }
158
+ else {
159
+ const age = Date.now() - (cached.storedAt ?? 0);
160
+ if (age < sequenceNumberCacheMaxAgeMs) {
161
+ console.debug(`[channels] Sequence cache hit: address=${address}, seq=${cached.sequence}, age=${age}ms`);
162
+ return cached.sequence;
163
+ }
164
+ console.debug(`[channels] Sequence cache stale: address=${address}, age=${age}ms, refetching`);
165
+ }
166
+ }
167
+ }
168
+ catch (err) {
169
+ console.warn(`[channels] Failed to read sequence from KV, falling back to chain`, {
170
+ address,
171
+ key,
172
+ error: err instanceof Error ? err.message : String(err),
173
+ });
174
+ }
175
+ return getAccountSequence(relayer, address);
176
+ }
177
+ /**
178
+ * Store the next expected sequence number in KV after a transaction
179
+ * has been confirmed on-chain.
180
+ */
181
+ async function commitSequence(kv, network, address, usedSequence) {
182
+ const next = (BigInt(usedSequence) + 1n).toString();
183
+ const key = seqKey(network, address);
184
+ try {
185
+ await kv.set(key, { sequence: next, storedAt: Date.now() });
186
+ console.debug(`[channels] Sequence committed: address=${address}, next=${next}`);
187
+ }
188
+ catch (err) {
189
+ console.warn(`[channels] Failed to commit sequence to KV`, {
190
+ address,
191
+ next,
192
+ error: err instanceof Error ? err.message : String(err),
193
+ });
194
+ }
195
+ }
196
+ /**
197
+ * Clear cached sequence — forces a re-fetch from chain on next request.
198
+ * Used when transaction outcome is uncertain (e.g. timeout).
199
+ */
200
+ async function clearSequence(kv, network, address) {
201
+ const key = seqKey(network, address);
202
+ try {
203
+ await kv.del(key);
204
+ console.debug(`[channels] Sequence cleared: address=${address}`);
205
+ }
206
+ catch (err) {
207
+ console.warn(`[channels] Failed to clear sequence from KV`, {
208
+ address,
209
+ error: err instanceof Error ? err.message : String(err),
210
+ });
211
+ }
212
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"simulation.d.ts","sourceRoot":"","sources":["../../src/plugin/simulation.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAsB,GAAG,EAAE,WAAW,EAAsB,GAAG,EAAE,MAAM,sBAAsB,CAAC;AACrG,OAAO,EAAgD,OAAO,EAAE,MAAM,2BAA2B,CAAC;AAGlG,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,gBAAgB;IAC/B,yEAAyE;IACzE,UAAU,EAAE,OAAO,CAAC;IACpB,8EAA8E;IAC9E,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,wCAAwC;IACxC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,6EAA6E;IAC7E,YAAY,EAAE,GAAG,CAAC,GAAG,CAAC,8BAA8B,CAAC;CACtD;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,mBAAmB,CACvC,IAAI,EAAE,GAAG,CAAC,YAAY,EACtB,IAAI,EAAE,GAAG,CAAC,yBAAyB,EAAE,GAAG,SAAS,EACjD,aAAa,EAAE,MAAM,EACrB,OAAO,EAAE,OAAO,EAChB,iBAAiB,EAAE,MAAM,GACxB,OAAO,CAAC,gBAAgB,CAAC,CA8E3B;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,GAAG,CAAC,YAAY,EACtB,IAAI,EAAE,GAAG,CAAC,yBAAyB,EAAE,GAAG,SAAS,EACjD,OAAO,EAAE,cAAc,EACvB,iBAAiB,EAAE,MAAM,EACzB,SAAS,EAAE,GAAG,CAAC,GAAG,CAAC,8BAA8B,GAChD,WAAW,CAsCb;AACD,0FAA0F;AAC1F,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAe1D"}
1
+ {"version":3,"file":"simulation.d.ts","sourceRoot":"","sources":["../../src/plugin/simulation.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAsB,GAAG,EAAE,WAAW,EAAsB,GAAG,EAAE,MAAM,sBAAsB,CAAC;AACrG,OAAO,EAAgD,OAAO,EAAE,MAAM,2BAA2B,CAAC;AAGlG,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,gBAAgB;IAC/B,yEAAyE;IACzE,UAAU,EAAE,OAAO,CAAC;IACpB,8EAA8E;IAC9E,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,wCAAwC;IACxC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,6EAA6E;IAC7E,YAAY,EAAE,GAAG,CAAC,GAAG,CAAC,8BAA8B,CAAC;CACtD;AAOD;;;;;;;;;;;GAWG;AACH,wBAAsB,mBAAmB,CACvC,IAAI,EAAE,GAAG,CAAC,YAAY,EACtB,IAAI,EAAE,GAAG,CAAC,yBAAyB,EAAE,GAAG,SAAS,EACjD,aAAa,EAAE,MAAM,EACrB,OAAO,EAAE,OAAO,EAChB,iBAAiB,EAAE,MAAM,GACxB,OAAO,CAAC,gBAAgB,CAAC,CAiF3B;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,GAAG,CAAC,YAAY,EACtB,IAAI,EAAE,GAAG,CAAC,yBAAyB,EAAE,GAAG,SAAS,EACjD,OAAO,EAAE,cAAc,EACvB,iBAAiB,EAAE,MAAM,EACzB,SAAS,EAAE,GAAG,CAAC,GAAG,CAAC,8BAA8B,GAChD,WAAW,CAsCb;AACD,0FAA0F;AAC1F,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAe1D"}
@@ -41,7 +41,8 @@ async function simulateTransaction(func, auth, sourceAddress, relayer, networkPa
41
41
  jsonrpc: '2.0',
42
42
  id: Math.floor(Math.random() * 1e8).toString(),
43
43
  method: 'simulateTransaction',
44
- params: { transaction: transaction.toXDR() },
44
+ // Enforce mode validates auth entry signatures during simulation.
45
+ params: { transaction: transaction.toXDR(), authMode: constants_1.SIMULATION.SIMULATION_AUTH_MODE },
45
46
  });
46
47
  }
47
48
  catch (err) {
@@ -65,11 +66,13 @@ async function simulateTransaction(func, auth, sourceAddress, relayer, networkPa
65
66
  ...rpcResponse.result,
66
67
  };
67
68
  if ('error' in simResult && simResult.error) {
69
+ const parsedError = parseSimulationError(simResult.error);
70
+ const failure = classifySimulationFailure(simResult.error, parsedError);
68
71
  console.error(`[channels] Simulation error: ${simResult.error}`);
69
- throw (0, relayer_sdk_1.pluginError)('Simulation failed', {
70
- code: 'SIMULATION_FAILED',
72
+ throw (0, relayer_sdk_1.pluginError)(failure.message, {
73
+ code: failure.code,
71
74
  status: constants_1.HTTP_STATUS.BAD_REQUEST,
72
- details: { error: parseSimulationError(simResult.error) },
75
+ details: { error: parsedError, authMode: constants_1.SIMULATION.SIMULATION_AUTH_MODE },
73
76
  });
74
77
  }
75
78
  // Read-only detection
@@ -148,3 +151,25 @@ function parseSimulationError(error) {
148
151
  }
149
152
  return firstLine;
150
153
  }
154
+ function classifySimulationFailure(rawError, parsedError) {
155
+ const isEnforcedAuthValidation = constants_1.SIMULATION.SIMULATION_AUTH_MODE === 'enforce' &&
156
+ (/\bError\(Auth,/i.test(rawError) ||
157
+ /\brequire_auth\b/i.test(rawError) ||
158
+ /\binvalid\s+signature\b/i.test(rawError) ||
159
+ /\bsignature\s+has\s+expired\b/i.test(rawError) ||
160
+ /\bsignature\s+expired\b/i.test(rawError) ||
161
+ /\bsignature\s+verification\s+failed\b/i.test(rawError) ||
162
+ /\bbad[_\s]?signature\b/i.test(rawError) ||
163
+ /\btx_bad_auth\b/i.test(rawError) ||
164
+ /\bbad[_\s]?auth\b/i.test(rawError));
165
+ if (isEnforcedAuthValidation) {
166
+ return {
167
+ code: 'SIMULATION_SIGNED_AUTH_VALIDATION_FAILED',
168
+ message: `Signed auth entry validation failed in enforce simulation: ${parsedError}`,
169
+ };
170
+ }
171
+ return {
172
+ code: 'SIMULATION_FAILED',
173
+ message: 'Simulation failed',
174
+ };
175
+ }
@@ -11,6 +11,10 @@ export interface SubmitContext {
11
11
  contractId?: string;
12
12
  isLimited?: boolean;
13
13
  }
14
+ interface DecodedTransactionResult {
15
+ feeCharged: number;
16
+ resultCode: string;
17
+ }
14
18
  /**
15
19
  * Sign transaction with both channel and fund relayers
16
20
  * - First sign with channel account
@@ -23,10 +27,9 @@ export declare function signWithChannelAndFund(transaction: Transaction, channel
23
27
  */
24
28
  export declare function submitWithFeeBumpAndWait(fundRelayer: Relayer, signedXdr: string, network: 'testnet' | 'mainnet', maxFee: number, api: PluginAPI, tracker?: FeeTracker, context?: SubmitContext): Promise<ChannelAccountsResponse>;
25
29
  /** Try to decode a transaction result XDR from the reason string */
26
- export declare function decodeTransactionResult(reason: string): {
27
- feeCharged: number;
28
- resultCode: string;
29
- } | null;
30
+ export declare function decodeTransactionResult(reason: string): DecodedTransactionResult | null;
31
+ export declare function buildStellarLabTransactionUrl(network: 'testnet' | 'mainnet', txHash: string): string;
30
32
  /** Strip provider wrapper text, extract last segment (e.g., "TxInsufficientBalance") */
31
33
  export declare function sanitizeReason(reason: string): string;
34
+ export {};
32
35
  //# 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,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;;;;;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,OAAO,CAAC,EAAE,UAAU,EACpB,OAAO,CAAC,EAAE,aAAa,GACtB,OAAO,CAAC,uBAAuB,CAAC,CA6ElC;AASD,oEAAoE;AACpE,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,MAAM,GAAG;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAwBzG;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,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,OAAO,CAAC,EAAE,UAAU,EACpB,OAAO,CAAC,EAAE,aAAa,GACtB,OAAO,CAAC,uBAAuB,CAAC,CAmFlC;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"}
@@ -8,6 +8,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
8
8
  exports.signWithChannelAndFund = signWithChannelAndFund;
9
9
  exports.submitWithFeeBumpAndWait = submitWithFeeBumpAndWait;
10
10
  exports.decodeTransactionResult = decodeTransactionResult;
11
+ exports.buildStellarLabTransactionUrl = buildStellarLabTransactionUrl;
11
12
  exports.sanitizeReason = sanitizeReason;
12
13
  const stellar_sdk_1 = require("@stellar/stellar-sdk");
13
14
  const relayer_sdk_1 = require("@openzeppelin/relayer-sdk");
@@ -66,10 +67,14 @@ async function submitWithFeeBumpAndWait(fundRelayer, signedXdr, network, maxFee,
66
67
  }
67
68
  const rawReason = final.status_reason || 'Transaction failed';
68
69
  const decoded = decodeTransactionResult(rawReason);
70
+ const labUrl = final.hash ? buildStellarLabTransactionUrl(network, final.hash) : null;
69
71
  const contractType = context?.isLimited ? 'limited' : 'default';
70
72
  const base = `[channels] Transaction failed: contractId=${context?.contractId ?? 'unknown'}, contractType=${contractType}, maxFee=${maxFee}`;
71
- if (decoded?.resultCode === 'txInsufficientFee') {
72
- console.error(`${base}, reason=txInsufficientFee, requiredFee=${decoded.feeCharged}, shortfall=${decoded.feeCharged - maxFee}`);
73
+ if (decoded && isTxInsufficientFeeError(decoded.resultCode)) {
74
+ const feeInfo = decoded.feeCharged != null
75
+ ? `, requiredFee=${decoded.feeCharged}, shortfall=${decoded.feeCharged - maxFee}`
76
+ : '';
77
+ console.error(`${base}, reason=txInsufficientFee${feeInfo}`);
73
78
  }
74
79
  else if (decoded) {
75
80
  console.error(`${base}, reason=${decoded.resultCode}`);
@@ -78,7 +83,8 @@ async function submitWithFeeBumpAndWait(fundRelayer, signedXdr, network, maxFee,
78
83
  console.error(`${base}, reason=${rawReason}`);
79
84
  }
80
85
  const reason = sanitizeReason(rawReason);
81
- throw (0, relayer_sdk_1.pluginError)(reason, {
86
+ const reasonWithLab = labUrl ? `${reason}. Debug in Stellar Lab (click "Load Transaction"): ${labUrl}` : reason;
87
+ throw (0, relayer_sdk_1.pluginError)(reasonWithLab, {
82
88
  code: 'ONCHAIN_FAILED',
83
89
  status: constants_1.HTTP_STATUS.BAD_REQUEST,
84
90
  details: {
@@ -86,6 +92,8 @@ async function submitWithFeeBumpAndWait(fundRelayer, signedXdr, network, maxFee,
86
92
  reason,
87
93
  id: final.id,
88
94
  hash: final.hash ?? null,
95
+ resultCode: decoded?.resultCode ?? null,
96
+ labUrl: labUrl ? `Debug this failure in Stellar Lab (click "Load Transaction"): ${labUrl}` : null,
89
97
  },
90
98
  });
91
99
  }
@@ -123,17 +131,26 @@ function isSignTransactionResponseStellar(data) {
123
131
  }
124
132
  /** Try to decode a transaction result XDR from the reason string */
125
133
  function decodeTransactionResult(reason) {
134
+ const fromReasonText = extractResultCodeFromReasonText(reason);
135
+ if (fromReasonText) {
136
+ return {
137
+ feeCharged: 0,
138
+ resultCode: fromReasonText,
139
+ };
140
+ }
126
141
  try {
127
142
  const match = reason.match(/([A-Za-z0-9+/=]{20,})$/);
128
143
  if (!match)
129
144
  return null;
130
145
  const result = stellar_sdk_1.xdr.TransactionResult.fromXDR(match[1], 'base64');
131
- let resultCode = result.result().switch().name;
146
+ const outerResultCode = String(result.result().switch().name);
147
+ let resultCode = outerResultCode;
132
148
  // Unwrap fee bump inner failure to get the actual result code
133
- if (resultCode === 'txFeeBumpInnerFailed') {
149
+ if (outerResultCode === 'txFeeBumpInnerFailed') {
134
150
  try {
135
151
  const innerResult = result.result().innerResultPair().result();
136
- resultCode = innerResult.result().switch().name;
152
+ const innerResultCode = String(innerResult.result().switch().name);
153
+ resultCode = `${outerResultCode}:${innerResultCode}`;
137
154
  }
138
155
  catch {
139
156
  // keep outer result code if unwrap fails
@@ -148,6 +165,31 @@ function decodeTransactionResult(reason) {
148
165
  return null;
149
166
  }
150
167
  }
168
+ function extractResultCodeFromReasonText(reason) {
169
+ const outerMatch = reason.match(/\bSpecific XDR reason:\s*([A-Za-z0-9_]+)\b/i);
170
+ if (!outerMatch?.[1])
171
+ return null;
172
+ const innerMatch = reason.match(/\bInner result:\s*([A-Za-z0-9_]+)\b/i);
173
+ return innerMatch?.[1] ? `${outerMatch[1]}:${innerMatch[1]}` : outerMatch[1];
174
+ }
175
+ /** Check if the result code indicates an insufficient fee error (case-insensitive). */
176
+ function isTxInsufficientFeeError(resultCode) {
177
+ return resultCode?.toLowerCase() === 'txinsufficientfee';
178
+ }
179
+ function buildStellarLabTransactionUrl(network, txHash) {
180
+ const isMainnet = network === 'mainnet';
181
+ const networkId = isMainnet ? 'mainnet' : 'testnet';
182
+ const label = isMainnet ? 'Mainnet' : 'Testnet';
183
+ const horizonUrl = isMainnet ? 'https://horizon.stellar.org' : 'https://horizon-testnet.stellar.org';
184
+ const rpcUrl = isMainnet ? 'https://mainnet.sorobanrpc.com' : 'https://soroban-testnet.stellar.org';
185
+ const passphrase = isMainnet ? 'Public Global Stellar Network ; September 2015' : 'Test SDF Network ; September 2015';
186
+ // Stellar Lab expects protocol values encoded as https://// in query params.
187
+ // txHash is intentionally left unencoded because it is a hex string.
188
+ const horizonParam = horizonUrl.replace('https://', 'https:////');
189
+ const rpcParam = rpcUrl.replace('https://', 'https:////');
190
+ const passphraseParam = passphrase.replace(/ /g, '%20').replace(/;/g, '%3B');
191
+ return `https://lab.stellar.org/transaction/dashboard?$=network$id=${networkId}&label=${label}&horizonUrl=${horizonParam}&rpcUrl=${rpcParam}&passphrase=${passphraseParam}&txDashboard$transactionHash=${txHash};;`;
192
+ }
151
193
  /** Strip provider wrapper text, extract last segment (e.g., "TxInsufficientBalance") */
152
194
  function sanitizeReason(reason) {
153
195
  const segments = reason.split(/:\s*/);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openzeppelin/relayer-plugin-channels",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "OpenZeppelin Relayer Plugin for Stellar Channel Accounts",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",