@openzeppelin/relayer-plugin-channels 0.10.0 → 0.12.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/README.md CHANGED
@@ -233,6 +233,12 @@ export CONTRACT_CAPACITY_RATIO=0.8 # Max ratio of pool for limited contra
233
233
  # Inclusion fee overrides (optional)
234
234
  export INCLUSION_FEE_DEFAULT=203 # Inclusion fee in stroops for regular contracts (default: BASE_FEE * 2 + 3 = 203)
235
235
  export INCLUSION_FEE_LIMITED=201 # Inclusion fee in stroops for limited contracts (default: BASE_FEE * 2 + 1 = 201)
236
+
237
+ # Sequence number cache (optional)
238
+ export SEQUENCE_NUMBER_CACHE_MAX_AGE_MS=120000 # Max age of cached sequence numbers in ms (default: 120000)
239
+
240
+ # Auth expiry validation (optional)
241
+ export MIN_SIGNATURE_EXPIRATION_LEDGER_BUFFER=2 # Minimum ledger margin for auth entry signatureExpirationLedger (default: 2)
236
242
  ```
237
243
 
238
244
  Your Relayer should now contain:
@@ -940,25 +946,67 @@ Plugin error example:
940
946
  - **Key**: `<network>:api-key-limit:<apiKey>`
941
947
  - **Value**: `{ limit: number }` (custom fee limit in stroops)
942
948
 
949
+ ### Sequence Number Cache
950
+
951
+ - **Key**: `<network>:channel:seq:<address>`
952
+ - **Value**: `{ sequence: string, storedAt: number }` (sequence number and cache timestamp in ms)
953
+
943
954
  ## Error Codes
944
955
 
956
+ ### Configuration
957
+
945
958
  - `CONFIG_MISSING`: Missing required environment variable
959
+ - `CONFIG_INVALID`: Invalid configuration value (e.g., invalid contract address in `LIMITED_CONTRACTS`)
946
960
  - `UNSUPPORTED_NETWORK`: Invalid network type
961
+
962
+ ### Request Validation
963
+
947
964
  - `INVALID_PARAMS`: Invalid request parameters
948
965
  - `INVALID_XDR`: Failed to parse XDR
949
966
  - `INVALID_ENVELOPE_TYPE`: Not a regular transaction envelope
967
+ - `INVALID_UNSIGNED_XDR`: Unsigned XDR must contain exactly one `invokeHostFunction` operation
950
968
  - `INVALID_TIME_BOUNDS`: TimeBounds too far in the future
969
+ - `TIMEBOUNDS_EXPIRED`: Transaction timebounds have expired
970
+ - `TIMEBOUNDS_TOO_FAR`: Transaction timebounds maxTime too far in the future
971
+ - `FEE_MISMATCH`: Fee mismatch in signed transaction
972
+ - `INVALID_OPERATION_SOURCE`: Operation source does not match transaction source
973
+
974
+ ### Pool & Channel
975
+
951
976
  - `NO_CHANNELS_CONFIGURED`: No channel accounts have been configured via management API
952
977
  - `POOL_CAPACITY`: All channel accounts in use
953
978
  - `RELAYER_UNAVAILABLE`: Relayer not found
979
+ - `FAILED_TO_GET_SEQUENCE`: Failed to fetch channel account sequence number
980
+ - `ACCOUNT_NOT_FOUND`: Channel account not found on the ledger
981
+
982
+ ### Simulation & Assembly
983
+
954
984
  - `SIMULATION_FAILED`: Transaction simulation failed
985
+ - `SIMULATION_NETWORK_ERROR`: Simulation network request failed (HTTP 502)
986
+ - `SIMULATION_RPC_FAILURE`: Simulation RPC provider error (HTTP 502)
987
+ - `SIMULATION_SIGNED_AUTH_VALIDATION_FAILED`: Signed auth entry validation failed in enforce-mode simulation
988
+ - `AUTH_EXPIRY_TOO_SHORT`: Auth entry `signatureExpirationLedger` too close to current ledger
989
+ - `ASSEMBLY_FAILED`: Transaction assembly failed
990
+
991
+ ### Submission
992
+
993
+ - `INVALID_SIGNATURE`: Invalid channel signature response
955
994
  - `ONCHAIN_FAILED`: Transaction failed on-chain
956
995
  - `WAIT_TIMEOUT`: Transaction wait timeout
996
+
997
+ ### Fee Tracking
998
+
999
+ - `API_KEY_REQUIRED`: API key header missing when `FEE_LIMIT` is configured
1000
+ - `FEE_LIMIT_EXCEEDED`: API key has exceeded its fee limit (HTTP 429)
1001
+
1002
+ ### Management
1003
+
957
1004
  - `MANAGEMENT_DISABLED`: Management API not enabled
958
1005
  - `UNAUTHORIZED`: Invalid admin secret
1006
+ - `INVALID_ACTION`: Invalid management action
1007
+ - `INVALID_PAYLOAD`: Invalid management request payload
959
1008
  - `LOCKED_CONFLICT`: Cannot remove locked channel accounts
960
- - `API_KEY_REQUIRED`: API key header missing when `FEE_LIMIT` is configured (HTTP 403)
961
- - `FEE_LIMIT_EXCEEDED`: API key has exceeded its fee limit (HTTP 429)
1009
+ - `KV_ERROR`: KV store operation failed
962
1010
 
963
1011
  ## License
964
1012
 
@@ -15,6 +15,8 @@ export interface ChannelAccountsConfig {
15
15
  contractCapacityRatio: number;
16
16
  inclusionFeeDefault: number;
17
17
  inclusionFeeLimited: number;
18
+ sequenceNumberCacheMaxAgeMs: number;
19
+ minSignatureExpirationLedgerBuffer: number;
18
20
  }
19
21
  /**
20
22
  * 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;IACpC,kCAAkC,EAAE,MAAM,CAAC;CAC5C;AA0GD;;GAEG;AACH,wBAAgB,UAAU,IAAI,qBAAqB,CAwBlD;AAED;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,SAAS,GAAG,SAAS,GAAG,MAAM,CAE3E"}
@@ -91,6 +91,20 @@ function parseInclusionFee(envVar, defaultValue) {
91
91
  return defaultValue;
92
92
  return Math.floor(n);
93
93
  }
94
+ function parseMinAuthExpiryLedgerBuffer() {
95
+ const raw = process.env.MIN_SIGNATURE_EXPIRATION_LEDGER_BUFFER;
96
+ if (!raw)
97
+ return constants_1.CONFIG.DEFAULT_MIN_SIGNATURE_EXPIRATION_LEDGER_BUFFER;
98
+ const n = Number(raw);
99
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : constants_1.CONFIG.DEFAULT_MIN_SIGNATURE_EXPIRATION_LEDGER_BUFFER;
100
+ }
101
+ function parseSequenceNumberCacheMaxAge() {
102
+ const raw = process.env.SEQUENCE_NUMBER_CACHE_MAX_AGE_MS;
103
+ if (!raw)
104
+ return constants_1.CONFIG.DEFAULT_SEQUENCE_NUMBER_CACHE_MAX_AGE_MS;
105
+ const n = Number(raw);
106
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : constants_1.CONFIG.DEFAULT_SEQUENCE_NUMBER_CACHE_MAX_AGE_MS;
107
+ }
94
108
  function parseContractCapacityRatio() {
95
109
  const raw = process.env.CONTRACT_CAPACITY_RATIO;
96
110
  if (!raw)
@@ -124,6 +138,8 @@ function loadConfig() {
124
138
  contractCapacityRatio: parseContractCapacityRatio(),
125
139
  inclusionFeeDefault: parseInclusionFee('INCLUSION_FEE_DEFAULT', DEFAULT_INCLUSION_FEE_DEFAULT),
126
140
  inclusionFeeLimited: parseInclusionFee('INCLUSION_FEE_LIMITED', DEFAULT_INCLUSION_FEE_LIMITED),
141
+ sequenceNumberCacheMaxAgeMs: parseSequenceNumberCacheMaxAge(),
142
+ minSignatureExpirationLedgerBuffer: parseMinAuthExpiryLedgerBuffer(),
127
143
  };
128
144
  }
129
145
  /**
@@ -19,6 +19,8 @@ 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;
23
+ readonly DEFAULT_MIN_SIGNATURE_EXPIRATION_LEDGER_BUFFER: 2;
22
24
  };
23
25
  export declare const POOL: {
24
26
  readonly MUTEX_TTL_SECONDS: 1;
@@ -34,6 +36,9 @@ export declare const SIMULATION: {
34
36
  readonly MIN_TIME_BOUND: 0;
35
37
  readonly MAX_TIME_BOUND_OFFSET_SECONDS: 120;
36
38
  readonly MAX_FUTURE_TIME_BOUND_SECONDS: 120;
39
+ readonly SIMULATION_AUTH_MODE: "enforce";
40
+ /** Minimum ledger margin required between latestLedger and auth signatureExpirationLedger. Must be > 1 since simulation already validates 1 ledger of validity. ~10s at ~5s/ledger. */
41
+ readonly MIN_SIGNATURE_EXPIRATION_LEDGER_BUFFER: 2;
37
42
  };
38
43
  export declare const POLLING: {
39
44
  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;;;;;;;CAOT,CAAC;AAGX,eAAO,MAAM,IAAI;;;;;CAOP,CAAC;AAGX,eAAO,MAAM,IAAI;;CAEP,CAAC;AAGX,eAAO,MAAM,UAAU;;;;;;IAMrB,uLAAuL;;CAE/K,CAAC;AAGX,eAAO,MAAM,OAAO;;;CAGV,CAAC;AAEX,eAAO,MAAM,GAAG;;CAGN,CAAC"}
@@ -24,6 +24,8 @@ 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,
28
+ DEFAULT_MIN_SIGNATURE_EXPIRATION_LEDGER_BUFFER: 2,
27
29
  };
28
30
  // Pool Constants
29
31
  exports.POOL = {
@@ -44,6 +46,9 @@ exports.SIMULATION = {
44
46
  MIN_TIME_BOUND: 0,
45
47
  MAX_TIME_BOUND_OFFSET_SECONDS: 120,
46
48
  MAX_FUTURE_TIME_BOUND_SECONDS: 120,
49
+ SIMULATION_AUTH_MODE: 'enforce',
50
+ /** Minimum ledger margin required between latestLedger and auth signatureExpirationLedger. Must be > 1 since simulation already validates 1 ledger of validity. ~10s at ~5s/ledger. */
51
+ MIN_SIGNATURE_EXPIRATION_LEDGER_BUFFER: 2,
47
52
  };
48
53
  // Polling for transactionWait
49
54
  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;AAsPD;;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.config.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, ctx.config.minSignatureExpirationLedgerBuffer);
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
+ config,
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
+ }
@@ -39,7 +39,7 @@ export declare function simulateTransaction(func: xdr.HostFunction, auth: xdr.So
39
39
  * Build and assemble a transaction using a channel account and an already-
40
40
  * obtained simulation result. No additional network calls are made.
41
41
  */
42
- export declare function buildWithChannel(func: xdr.HostFunction, auth: xdr.SorobanAuthorizationEntry[] | undefined, channel: ChannelAccount, networkPassphrase: string, simResult: rpc.Api.RawSimulateTransactionResponse): Transaction;
42
+ export declare function buildWithChannel(func: xdr.HostFunction, auth: xdr.SorobanAuthorizationEntry[] | undefined, channel: ChannelAccount, networkPassphrase: string, simResult: rpc.Api.RawSimulateTransactionResponse, minSignatureExpirationLedgerBuffer?: number): Transaction;
43
43
  /** Extract human-readable message + error type from simulation error diagnostic events */
44
44
  export declare function parseSimulationError(error: string): string;
45
45
  //# sourceMappingURL=simulation.d.ts.map
@@ -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,EACjD,kCAAkC,GAAE,MAA0D,GAC7F,WAAW,CA4Cb;AA4CD,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
@@ -101,7 +104,12 @@ async function simulateTransaction(func, auth, sourceAddress, relayer, networkPa
101
104
  * Build and assemble a transaction using a channel account and an already-
102
105
  * obtained simulation result. No additional network calls are made.
103
106
  */
104
- function buildWithChannel(func, auth, channel, networkPassphrase, simResult) {
107
+ function buildWithChannel(func, auth, channel, networkPassphrase, simResult, minSignatureExpirationLedgerBuffer = constants_1.SIMULATION.MIN_SIGNATURE_EXPIRATION_LEDGER_BUFFER) {
108
+ // Fail fast: reject auth entries whose signatureExpirationLedger is too close
109
+ // to latestLedger before doing any assembly work.
110
+ if (simResult.latestLedger) {
111
+ validateAuthExpiry(auth, simResult.latestLedger, minSignatureExpirationLedgerBuffer);
112
+ }
105
113
  const now = Math.floor(Date.now() / 1000);
106
114
  console.debug(`[channels] Building tx: channel=${channel.address}, seq=${channel.sequence}, auth_count=${auth?.length ?? 0}`);
107
115
  // Build inner transaction (source = channel)
@@ -134,6 +142,36 @@ function buildWithChannel(func, auth, channel, networkPassphrase, simResult) {
134
142
  });
135
143
  }
136
144
  }
145
+ /**
146
+ * Reject transactions where any address-credentialed auth entry has a
147
+ * signatureExpirationLedger too close to the current ledger. This catches
148
+ * tight expiries early instead of failing on-chain with "signature has expired".
149
+ */
150
+ function validateAuthExpiry(authEntries, latestLedger, minBuffer) {
151
+ if (!authEntries?.length)
152
+ return;
153
+ for (const entry of authEntries) {
154
+ const creds = entry.credentials();
155
+ if (creds.switch() !== stellar_sdk_1.xdr.SorobanCredentialsType.sorobanCredentialsAddress()) {
156
+ continue;
157
+ }
158
+ const expiry = creds.address().signatureExpirationLedger();
159
+ const margin = expiry - latestLedger;
160
+ if (margin < minBuffer) {
161
+ console.error(`[channels] Auth entry signatureExpirationLedger too close to current ledger (expires in ${margin} ledgers, minimum ${minBuffer} required)`);
162
+ throw (0, relayer_sdk_1.pluginError)(`Auth entry signatureExpirationLedger too close to current ledger (expires in ${margin} ledgers, minimum ${minBuffer} required)`, {
163
+ code: 'AUTH_EXPIRY_TOO_SHORT',
164
+ status: constants_1.HTTP_STATUS.BAD_REQUEST,
165
+ details: {
166
+ signatureExpirationLedger: expiry,
167
+ latestLedger,
168
+ margin,
169
+ minimumRequired: minBuffer,
170
+ },
171
+ });
172
+ }
173
+ }
174
+ }
137
175
  /** Extract human-readable message + error type from simulation error diagnostic events */
138
176
  function parseSimulationError(error) {
139
177
  const firstLine = error.split('\n')[0]?.trim() || 'Simulation failed';
@@ -148,3 +186,25 @@ function parseSimulationError(error) {
148
186
  }
149
187
  return firstLine;
150
188
  }
189
+ function classifySimulationFailure(rawError, parsedError) {
190
+ const isEnforcedAuthValidation = constants_1.SIMULATION.SIMULATION_AUTH_MODE === 'enforce' &&
191
+ (/\bError\(Auth,/i.test(rawError) ||
192
+ /\brequire_auth\b/i.test(rawError) ||
193
+ /\binvalid\s+signature\b/i.test(rawError) ||
194
+ /\bsignature\s+has\s+expired\b/i.test(rawError) ||
195
+ /\bsignature\s+expired\b/i.test(rawError) ||
196
+ /\bsignature\s+verification\s+failed\b/i.test(rawError) ||
197
+ /\bbad[_\s]?signature\b/i.test(rawError) ||
198
+ /\btx_bad_auth\b/i.test(rawError) ||
199
+ /\bbad[_\s]?auth\b/i.test(rawError));
200
+ if (isEnforcedAuthValidation) {
201
+ return {
202
+ code: 'SIMULATION_SIGNED_AUTH_VALIDATION_FAILED',
203
+ message: `Signed auth entry validation failed in enforce simulation: ${parsedError}`,
204
+ };
205
+ }
206
+ return {
207
+ code: 'SIMULATION_FAILED',
208
+ message: 'Simulation failed',
209
+ };
210
+ }
@@ -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.12.0",
4
4
  "description": "OpenZeppelin Relayer Plugin for Stellar Channel Accounts",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",