@keplr-wallet/background 0.12.313 → 0.13.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.
Files changed (83) hide show
  1. package/build/index.d.ts +1 -0
  2. package/build/index.js +7 -1
  3. package/build/index.js.map +1 -1
  4. package/build/keyring-cosmos/service.d.ts +10 -0
  5. package/build/keyring-cosmos/service.js +100 -0
  6. package/build/keyring-cosmos/service.js.map +1 -1
  7. package/build/keyring-ethereum/service.d.ts +5 -0
  8. package/build/keyring-ethereum/service.js +66 -0
  9. package/build/keyring-ethereum/service.js.map +1 -1
  10. package/build/recent-send-history/api.d.ts +31 -0
  11. package/build/recent-send-history/api.js +97 -0
  12. package/build/recent-send-history/api.js.map +1 -0
  13. package/build/recent-send-history/handler.js +36 -0
  14. package/build/recent-send-history/handler.js.map +1 -1
  15. package/build/recent-send-history/init.js +5 -0
  16. package/build/recent-send-history/init.js.map +1 -1
  17. package/build/recent-send-history/messages.d.ts +76 -1
  18. package/build/recent-send-history/messages.js +121 -1
  19. package/build/recent-send-history/messages.js.map +1 -1
  20. package/build/recent-send-history/service.d.ts +262 -9
  21. package/build/recent-send-history/service.js +2103 -812
  22. package/build/recent-send-history/service.js.map +1 -1
  23. package/build/recent-send-history/types.d.ts +214 -22
  24. package/build/recent-send-history/types.js +21 -0
  25. package/build/recent-send-history/types.js.map +1 -1
  26. package/build/tx/service.d.ts +2 -0
  27. package/build/tx/service.js +35 -0
  28. package/build/tx/service.js.map +1 -1
  29. package/build/tx-ethereum/service.d.ts +2 -0
  30. package/build/tx-ethereum/service.js +42 -0
  31. package/build/tx-ethereum/service.js.map +1 -1
  32. package/build/tx-executor/constants.d.ts +1 -0
  33. package/build/tx-executor/constants.js +5 -0
  34. package/build/tx-executor/constants.js.map +1 -0
  35. package/build/tx-executor/handler.d.ts +3 -0
  36. package/build/tx-executor/handler.js +45 -0
  37. package/build/tx-executor/handler.js.map +1 -0
  38. package/build/tx-executor/index.d.ts +3 -0
  39. package/build/tx-executor/index.js +20 -0
  40. package/build/tx-executor/index.js.map +1 -0
  41. package/build/tx-executor/init.d.ts +3 -0
  42. package/build/tx-executor/init.js +14 -0
  43. package/build/tx-executor/init.js.map +1 -0
  44. package/build/tx-executor/internal.d.ts +4 -0
  45. package/build/tx-executor/internal.js +24 -0
  46. package/build/tx-executor/internal.js.map +1 -0
  47. package/build/tx-executor/messages.d.ts +53 -0
  48. package/build/tx-executor/messages.js +116 -0
  49. package/build/tx-executor/messages.js.map +1 -0
  50. package/build/tx-executor/service.d.ts +67 -0
  51. package/build/tx-executor/service.js +715 -0
  52. package/build/tx-executor/service.js.map +1 -0
  53. package/build/tx-executor/types.d.ts +105 -0
  54. package/build/tx-executor/types.js +33 -0
  55. package/build/tx-executor/types.js.map +1 -0
  56. package/build/tx-executor/utils/cosmos.d.ts +59 -0
  57. package/build/tx-executor/utils/cosmos.js +526 -0
  58. package/build/tx-executor/utils/cosmos.js.map +1 -0
  59. package/build/tx-executor/utils/evm.d.ts +4 -0
  60. package/build/tx-executor/utils/evm.js +236 -0
  61. package/build/tx-executor/utils/evm.js.map +1 -0
  62. package/package.json +13 -13
  63. package/src/index.ts +24 -1
  64. package/src/keyring-cosmos/service.ts +151 -0
  65. package/src/keyring-ethereum/service.ts +103 -6
  66. package/src/recent-send-history/api.ts +119 -0
  67. package/src/recent-send-history/handler.ts +84 -0
  68. package/src/recent-send-history/init.ts +10 -0
  69. package/src/recent-send-history/messages.ts +163 -1
  70. package/src/recent-send-history/service.ts +3042 -1153
  71. package/src/recent-send-history/types.ts +268 -31
  72. package/src/tx/service.ts +41 -0
  73. package/src/tx-ethereum/service.ts +57 -0
  74. package/src/tx-executor/constants.ts +1 -0
  75. package/src/tx-executor/handler.ts +71 -0
  76. package/src/tx-executor/index.ts +3 -0
  77. package/src/tx-executor/init.ts +20 -0
  78. package/src/tx-executor/internal.ts +9 -0
  79. package/src/tx-executor/messages.ts +157 -0
  80. package/src/tx-executor/service.ts +1025 -0
  81. package/src/tx-executor/types.ts +161 -0
  82. package/src/tx-executor/utils/cosmos.ts +771 -0
  83. package/src/tx-executor/utils/evm.ts +310 -0
@@ -0,0 +1,1025 @@
1
+ import { KVStore } from "@keplr-wallet/common";
2
+ import { ChainsService } from "../chains";
3
+ import { KeyRingCosmosService } from "../keyring-cosmos";
4
+ import { KeyRingEthereumService } from "../keyring-ethereum";
5
+ import { AnalyticsService } from "../analytics";
6
+ import { RecentSendHistoryService } from "../recent-send-history";
7
+ import { BackgroundTxService } from "../tx";
8
+ import { BackgroundTxEthereumService } from "../tx-ethereum";
9
+ import { Env, KeplrError } from "@keplr-wallet/router";
10
+ import {
11
+ TxExecution,
12
+ TxExecutionStatus,
13
+ TxExecutionType,
14
+ BackgroundTx,
15
+ BackgroundTxStatus,
16
+ BackgroundTxType,
17
+ EVMBackgroundTx,
18
+ CosmosBackgroundTx,
19
+ ExecutionTypeToHistoryData,
20
+ TxExecutionResult,
21
+ PendingTxExecutionResult,
22
+ IBCSwapMinimalTrackingData,
23
+ } from "./types";
24
+ import {
25
+ action,
26
+ autorun,
27
+ makeObservable,
28
+ observable,
29
+ runInAction,
30
+ toJS,
31
+ } from "mobx";
32
+ import {
33
+ AminoSignResponse,
34
+ EthSignType,
35
+ EthTxStatus,
36
+ } from "@keplr-wallet/types";
37
+ import { TransactionTypes, serialize } from "@ethersproject/transactions";
38
+ import { BaseAccount } from "@keplr-wallet/cosmos";
39
+ import { Any } from "@keplr-wallet/proto-types/google/protobuf/any";
40
+ import { TxRaw } from "@keplr-wallet/proto-types/cosmos/tx/v1beta1/tx";
41
+ import { Msg } from "@keplr-wallet/types";
42
+ import {
43
+ buildSignedTxFromAminoSignResponse,
44
+ prepareSignDocForAminoSigning,
45
+ simulateCosmosTx,
46
+ getCosmosGasPrice,
47
+ calculateCosmosStdFee,
48
+ prepareSignDocForDirectSigning,
49
+ } from "./utils/cosmos";
50
+ import { fillUnsignedEVMTx } from "./utils/evm";
51
+ import { EventBusSubscriber } from "@keplr-wallet/common";
52
+ import { TxExecutionEvent } from "./types";
53
+
54
+ export class BackgroundTxExecutorService {
55
+ @observable
56
+ protected recentTxExecutionSeq: number = 0;
57
+ // Key: id (sequence, it should be increased by 1 for each)
58
+ @observable
59
+ protected readonly recentTxExecutionMap: Map<string, TxExecution> = new Map();
60
+
61
+ constructor(
62
+ protected readonly kvStore: KVStore,
63
+ protected readonly chainsService: ChainsService,
64
+ protected readonly keyRingCosmosService: KeyRingCosmosService,
65
+ protected readonly keyRingEthereumService: KeyRingEthereumService,
66
+ protected readonly backgroundTxService: BackgroundTxService,
67
+ protected readonly backgroundTxEthereumService: BackgroundTxEthereumService,
68
+ protected readonly analyticsService: AnalyticsService,
69
+ protected readonly recentSendHistoryService: RecentSendHistoryService,
70
+ protected readonly subscriber: EventBusSubscriber<TxExecutionEvent>
71
+ ) {
72
+ makeObservable(this);
73
+ }
74
+
75
+ async init(): Promise<void> {
76
+ const recentTxExecutionSeqSaved = await this.kvStore.get<number>(
77
+ "recentTxExecutionSeq"
78
+ );
79
+ if (recentTxExecutionSeqSaved) {
80
+ runInAction(() => {
81
+ this.recentTxExecutionSeq = recentTxExecutionSeqSaved;
82
+ });
83
+ }
84
+ autorun(() => {
85
+ const js = toJS(this.recentTxExecutionSeq);
86
+ this.kvStore.set<number>("recentTxExecutionSeq", js);
87
+ });
88
+
89
+ const recentTxExecutionMapSaved = await this.kvStore.get<
90
+ Record<string, string>
91
+ >("recentSerializedTxExecutionMap");
92
+ if (recentTxExecutionMapSaved) {
93
+ runInAction(() => {
94
+ const entries = Object.entries(recentTxExecutionMapSaved);
95
+ const sorted = entries
96
+ .map(([key, value]) => [key, JSON.parse(value)])
97
+ .sort(([, a], [, b]) => parseInt(a.id) - parseInt(b.id));
98
+
99
+ for (const [key, execution] of sorted) {
100
+ this.recentTxExecutionMap.set(key, execution);
101
+ }
102
+
103
+ this.cleanupOldExecutions();
104
+ });
105
+ }
106
+ autorun(() => {
107
+ const js = toJS(this.recentTxExecutionMap);
108
+ const serialized: Record<string, string> = {};
109
+ for (const [key, value] of js) {
110
+ // only persist executions that are BLOCKED
111
+ if (value.status === TxExecutionStatus.BLOCKED) {
112
+ serialized[key] = JSON.stringify(value);
113
+ }
114
+ }
115
+
116
+ this.kvStore
117
+ .set<Record<string, string>>(
118
+ "recentSerializedTxExecutionMap",
119
+ serialized
120
+ )
121
+ .catch((e) => {
122
+ console.error("[TxExecutor] kvStore save failed:", e);
123
+ });
124
+ });
125
+
126
+ this.subscriber.subscribe((event) => this.handleTxExecutionEvent(event));
127
+ }
128
+
129
+ @action
130
+ protected handleTxExecutionEvent(event: TxExecutionEvent): void {
131
+ if (event.type === "remove") {
132
+ this.removeTxExecution(event.executionId);
133
+ return;
134
+ }
135
+
136
+ const { executionId, executableChainIds } = event;
137
+
138
+ const execution = this.getTxExecution(executionId);
139
+ if (!execution) {
140
+ return;
141
+ }
142
+
143
+ const newExecutableChainIds = executableChainIds.filter(
144
+ (chainId) => !execution.executableChainIds.includes(chainId)
145
+ );
146
+
147
+ if (newExecutableChainIds.length === 0) {
148
+ return;
149
+ }
150
+
151
+ // update the executable chain ids
152
+ execution.executableChainIds = Array.from(
153
+ new Set([...execution.executableChainIds, ...newExecutableChainIds])
154
+ );
155
+
156
+ // if there is a pending tx that is executable, force display the swap v2 history
157
+ if (
158
+ execution.type === TxExecutionType.SWAP_V2 &&
159
+ execution.historyId != null
160
+ ) {
161
+ const hasExecutableTx = execution.txs.some(
162
+ (tx) =>
163
+ (tx.status === BackgroundTxStatus.PENDING ||
164
+ tx.status === BackgroundTxStatus.BLOCKED) &&
165
+ execution.executableChainIds.includes(tx.chainId)
166
+ );
167
+ if (hasExecutableTx) {
168
+ this.recentSendHistoryService.showSwapV2History(execution.historyId);
169
+ }
170
+ }
171
+ }
172
+
173
+ async recordAndExecuteTxs<T extends TxExecutionType>(
174
+ env: Env,
175
+ vaultId: string,
176
+ type: T,
177
+ txs: (BackgroundTx & {
178
+ status: BackgroundTxStatus.PENDING | BackgroundTxStatus.CONFIRMED;
179
+ })[],
180
+ executableChainIds: string[],
181
+ historyData?: T extends TxExecutionType.UNDEFINED
182
+ ? undefined
183
+ : ExecutionTypeToHistoryData[T],
184
+ historyTxIndex?: number
185
+ ): Promise<TxExecutionResult> {
186
+ if (!env.isInternalMsg) {
187
+ throw new KeplrError("direct-tx-executor", 101, "Not internal message");
188
+ }
189
+
190
+ const keyInfo =
191
+ this.keyRingCosmosService.keyRingService.getKeyInfo(vaultId);
192
+ if (!keyInfo) {
193
+ throw new KeplrError("direct-tx-executor", 120, "Key info not found");
194
+ }
195
+
196
+ // If any of the transactions are not executable or the key is hardware wallet,
197
+ // auto sign is disabled.
198
+ const preventAutoSign =
199
+ txs.some((tx) => !executableChainIds.includes(tx.chainId)) ||
200
+ keyInfo.type === "ledger" ||
201
+ keyInfo.type === "keystone";
202
+
203
+ /**
204
+ * If preventAutoSign is true, at least one executable transaction must already be signed.
205
+ * For example, in an EVM bundle (like ERC20 approve + swap) where simulation is not possible,
206
+ * the UI might execute 'approve' (tx[0]) first and set its txHash, then sign the swap (tx[1]).
207
+ * Both tx[0] and tx[1] are executable, but tx[0] has already been executed and doesn't need to be signed again.
208
+ * So, ensure that at least one executable tx is already signed before proceeding.
209
+ */
210
+ if (preventAutoSign) {
211
+ const executableTxs = txs.filter((tx) =>
212
+ executableChainIds.includes(tx.chainId)
213
+ );
214
+
215
+ if (executableTxs.length === 0) {
216
+ throw new KeplrError("direct-tx-executor", 122, "No executable txs");
217
+ }
218
+
219
+ if (executableTxs.every((tx) => tx.signedTx == null)) {
220
+ throw new KeplrError(
221
+ "direct-tx-executor",
222
+ 123,
223
+ "No signed txs found with preventAutoSign"
224
+ );
225
+ }
226
+ }
227
+
228
+ const id = runInAction(() => {
229
+ return (this.recentTxExecutionSeq++).toString();
230
+ });
231
+
232
+ const execution = {
233
+ id,
234
+ status: TxExecutionStatus.PENDING,
235
+ vaultId: vaultId,
236
+ txs,
237
+ txIndex: -1,
238
+ executableChainIds: executableChainIds,
239
+ timestamp: Date.now(),
240
+ type,
241
+ preventAutoSign,
242
+ historyTxIndex,
243
+ ...(type !== TxExecutionType.UNDEFINED ? { historyData } : {}),
244
+ } as TxExecution;
245
+
246
+ runInAction(() => {
247
+ this.recentTxExecutionMap.set(id, execution);
248
+ });
249
+
250
+ return await this.executeTxs(id);
251
+ }
252
+
253
+ /**
254
+ * Execute blocked transactions by execution id and transaction index
255
+ */
256
+ async resumeTx(
257
+ env: Env,
258
+ id: string,
259
+ txIndex: number,
260
+ signedTx: string,
261
+ ibcSwapData?: IBCSwapMinimalTrackingData
262
+ ): Promise<TxExecutionResult> {
263
+ if (!env.isInternalMsg) {
264
+ throw new KeplrError("direct-tx-executor", 101, "Not internal message");
265
+ }
266
+
267
+ return await this.executeTxs(id, {
268
+ txIndex,
269
+ signedTx,
270
+ ibcSwapData,
271
+ });
272
+ }
273
+
274
+ protected async executeTxs(
275
+ id: string,
276
+ options?: {
277
+ txIndex?: number;
278
+ signedTx?: string;
279
+ ibcSwapData?: IBCSwapMinimalTrackingData;
280
+ }
281
+ ): Promise<TxExecutionResult> {
282
+ const execution = this.getTxExecution(id);
283
+ if (!execution) {
284
+ throw new KeplrError("direct-tx-executor", 121, "Execution not found");
285
+ }
286
+
287
+ if (execution.status === TxExecutionStatus.PROCESSING) {
288
+ throw new KeplrError(
289
+ "direct-tx-executor",
290
+ 130,
291
+ "Execution is already processing"
292
+ );
293
+ }
294
+
295
+ // Only pending or blocked executions can be executed
296
+ const needExecute =
297
+ execution.status === TxExecutionStatus.PENDING ||
298
+ execution.status === TxExecutionStatus.BLOCKED;
299
+ if (!needExecute) {
300
+ return {
301
+ status: execution.status,
302
+ };
303
+ }
304
+
305
+ const keyInfo = this.keyRingCosmosService.keyRingService.getKeyInfo(
306
+ execution.vaultId
307
+ );
308
+ if (!keyInfo) {
309
+ throw new KeplrError("direct-tx-executor", 120, "Key info not found");
310
+ }
311
+
312
+ const executionStartIndex = Math.min(
313
+ options?.txIndex ?? (execution.txIndex < 0 ? 0 : execution.txIndex),
314
+ execution.txs.length - 1
315
+ );
316
+
317
+ runInAction(() => {
318
+ execution.status = TxExecutionStatus.PROCESSING;
319
+ });
320
+
321
+ for (let i = executionStartIndex; i < execution.txs.length; i++) {
322
+ const currentTx = execution.txs[i];
323
+
324
+ const providedSignedTx =
325
+ options?.txIndex != null && i === options.txIndex
326
+ ? options.signedTx
327
+ : undefined;
328
+
329
+ const result = await this.executePendingTx(
330
+ execution.vaultId,
331
+ currentTx,
332
+ execution.executableChainIds,
333
+ execution.preventAutoSign ?? false,
334
+ providedSignedTx
335
+ );
336
+
337
+ // update the tx status and related fields
338
+ runInAction(() => {
339
+ execution.txIndex = i;
340
+ currentTx.status = result.status;
341
+ if (result.txHash != null) {
342
+ currentTx.txHash = result.txHash;
343
+ }
344
+ if (result.error != null) {
345
+ currentTx.error = result.error;
346
+ }
347
+ currentTx.signedTx = undefined;
348
+ });
349
+
350
+ switch (result.status) {
351
+ case BackgroundTxStatus.CONFIRMED: {
352
+ if (providedSignedTx) {
353
+ // 외부에서 제공된 signed tx로 실행한 경우 (= multi tx 재개 케이스)
354
+ //
355
+ // 이번에 처리된 트랜잭션이 multi tx swap의 마지막 트랜잭션이라고 optimistically 가정하고,
356
+ // 추가적인 히스토리 데이터를 기록해야 한다.
357
+ //
358
+ // [배경]
359
+ // Skip에서 기본적으로 smart relay 기능을 활성화해 놓았으므로,
360
+ // multi tx swap이 필요한 경우는 다음과 같다:
361
+ // - A 체인에서 브릿지, 메시징 프로토콜, 또는 IBC Eureka를 통해 B 체인으로 자산을 전송
362
+ // - B 체인에서 사용자 주소로 릴리즈되는 자산이 wrapped asset이거나 IBC swap이 필요한 asset
363
+ //
364
+ // [마지막 트랜잭션의 유형]
365
+ // 따라서 multi tx swap의 마지막 트랜잭션은 아래 두 가지 중 하나라고 가정할 수 있다:
366
+ // 1. Wrapped asset → Native asset 변환 트랜잭션 (EVM)
367
+ // 2. IBC swap이 필요한 asset의 IBC swap 트랜잭션 (Cosmos)
368
+ //
369
+ // [트랜잭션 타입별 처리]
370
+ // 1. EVM: txHash를 additionalTrackingData에 저장 → debug_traceTransaction으로 추적
371
+ // 2. Cosmos: 외부에서 IBC swap data를 받아 additionalTrackingData에 저장 → IBC swap tracking
372
+ if (
373
+ execution.type === TxExecutionType.SWAP_V2 &&
374
+ execution.historyId != null
375
+ ) {
376
+ const currentTx = execution.txs[i];
377
+ switch (currentTx.type) {
378
+ case BackgroundTxType.EVM: {
379
+ if (result.txHash != null) {
380
+ this.recentSendHistoryService.setSwapV2AdditionalTrackingData(
381
+ execution.historyId,
382
+ {
383
+ type: "evm",
384
+ chainId: currentTx.chainId,
385
+ txHash: result.txHash,
386
+ }
387
+ );
388
+ }
389
+ break;
390
+ }
391
+ case BackgroundTxType.COSMOS: {
392
+ const ibcSwapData = options?.ibcSwapData;
393
+ if (ibcSwapData != null && result.txHash != null) {
394
+ this.recentSendHistoryService.setSwapV2AdditionalTrackingData(
395
+ execution.historyId,
396
+ { type: "cosmos-ibc", ibcSwapData, txHash: result.txHash }
397
+ );
398
+ }
399
+ break;
400
+ }
401
+ default: {
402
+ // noop
403
+ break;
404
+ }
405
+ }
406
+ }
407
+ }
408
+ continue;
409
+ }
410
+ case BackgroundTxStatus.FAILED: {
411
+ this.recordSwapV2HistoryErrorIfNeeded(
412
+ execution,
413
+ result.error ?? `${i + 1}th transaction failed`
414
+ );
415
+ this.removeTxExecution(id);
416
+
417
+ return {
418
+ status: TxExecutionStatus.FAILED,
419
+ error: result.error,
420
+ };
421
+ }
422
+ case BackgroundTxStatus.BLOCKED: {
423
+ /**
424
+ * If the tx is BLOCKED, it means multiple transactions are required
425
+ * to be executed on different chains.
426
+ *
427
+ * - The execution should be stopped here,
428
+ * - Record the history if needed,
429
+ * - The execution should be resumed later when the condition is met.
430
+ */
431
+ runInAction(() => {
432
+ execution.status = TxExecutionStatus.BLOCKED;
433
+ this.recordHistoryIfNeeded(execution);
434
+
435
+ // no need to keep the history data anymore
436
+ delete execution.historyData;
437
+ });
438
+
439
+ return {
440
+ status: TxExecutionStatus.BLOCKED,
441
+ };
442
+ }
443
+ default: {
444
+ throw new KeplrError(
445
+ "direct-tx-executor",
446
+ 131,
447
+ "Unexpected tx status: " + result.status
448
+ );
449
+ }
450
+ }
451
+ }
452
+
453
+ this.recordHistoryIfNeeded(execution);
454
+ this.clearSwapV2HistoryBackgroundExecutionIdIfNeeded(execution);
455
+ this.removeTxExecution(id);
456
+
457
+ return {
458
+ status: TxExecutionStatus.COMPLETED,
459
+ };
460
+ }
461
+
462
+ /**
463
+ * Execute a pending transaction without modifying observable state.
464
+ * Returns the result which should be applied by the caller using runInAction.
465
+ * This reduces autorun trigger count by batching state updates.
466
+ */
467
+ protected async executePendingTx(
468
+ vaultId: string,
469
+ tx: BackgroundTx,
470
+ executableChainIds: string[],
471
+ preventAutoSign: boolean,
472
+ providedSignedTx?: string
473
+ ): Promise<PendingTxExecutionResult> {
474
+ const status = tx.status;
475
+ let signedTx = tx.signedTx ?? providedSignedTx;
476
+ let txHash = tx.txHash;
477
+ let error: string | undefined;
478
+
479
+ // Already in final state
480
+ if (
481
+ status === BackgroundTxStatus.CONFIRMED ||
482
+ status === BackgroundTxStatus.FAILED
483
+ ) {
484
+ return { status, txHash, error };
485
+ }
486
+
487
+ // Check if blocked
488
+ const isBlocked = !executableChainIds.includes(tx.chainId);
489
+ if (isBlocked) {
490
+ return { status: BackgroundTxStatus.BLOCKED, txHash, error };
491
+ }
492
+
493
+ // If preventAutoSign and not signed, block
494
+ if (preventAutoSign && signedTx == null) {
495
+ return { status: BackgroundTxStatus.BLOCKED, txHash, error };
496
+ }
497
+
498
+ // if not signed, sign the tx
499
+ if (signedTx == null) {
500
+ try {
501
+ const signResult = await this.signTx(vaultId, tx);
502
+ signedTx = signResult;
503
+ } catch (e) {
504
+ console.error(`[TxExecutor] tx signing failed:`, e);
505
+ return {
506
+ status: BackgroundTxStatus.FAILED,
507
+ txHash,
508
+ error: e.message ?? "Transaction signing failed",
509
+ };
510
+ }
511
+ }
512
+
513
+ // if tx hash is not set, broadcast the tx
514
+ if (txHash == null) {
515
+ try {
516
+ const txWithSignedTx = { ...tx, signedTx };
517
+ const broadcastResult = await this.broadcastTx(txWithSignedTx);
518
+ txHash = broadcastResult;
519
+ } catch (e) {
520
+ console.error(`[TxExecutor] tx broadcast failed:`, e);
521
+
522
+ return {
523
+ status: BackgroundTxStatus.FAILED,
524
+ txHash,
525
+ error: e.message ?? "Transaction broadcasting failed",
526
+ };
527
+ }
528
+ }
529
+
530
+ // trace the tx
531
+ try {
532
+ const txWithHash = { ...tx, txHash };
533
+ const confirmed = await this.traceTx(txWithHash);
534
+
535
+ if (confirmed) {
536
+ return { status: BackgroundTxStatus.CONFIRMED, txHash };
537
+ }
538
+
539
+ return {
540
+ status: BackgroundTxStatus.FAILED,
541
+ txHash,
542
+ error: "Transaction confirmation failed",
543
+ };
544
+ } catch (e) {
545
+ console.error(`[TxExecutor] tx trace failed:`, e);
546
+ return {
547
+ status: BackgroundTxStatus.FAILED,
548
+ txHash,
549
+ error: e.message ?? "Transaction confirmation failed",
550
+ };
551
+ }
552
+ }
553
+
554
+ protected async signTx(vaultId: string, tx: BackgroundTx): Promise<string> {
555
+ switch (tx.type) {
556
+ case BackgroundTxType.EVM: {
557
+ return this.signEvmTx(vaultId, tx);
558
+ }
559
+ case BackgroundTxType.COSMOS: {
560
+ return this.signCosmosTx(vaultId, tx);
561
+ }
562
+ default: {
563
+ throw new KeplrError("direct-tx-executor", 143, "Unknown tx type");
564
+ }
565
+ }
566
+ }
567
+
568
+ private async signEvmTx(
569
+ vaultId: string,
570
+ tx: EVMBackgroundTx
571
+ ): Promise<string> {
572
+ const keyInfo = await this.keyRingCosmosService.getKey(vaultId, tx.chainId);
573
+ const isHardware = keyInfo.isNanoLedger || keyInfo.isKeystone;
574
+ const signer = keyInfo.ethereumHexAddress;
575
+
576
+ // For hardware wallets, the signedTx must be provided externally when calling resumeTx or recordAndExecuteTxs.
577
+ if (isHardware) {
578
+ throw new KeplrError(
579
+ "direct-tx-executor",
580
+ 140,
581
+ "Hardware wallet signing should be triggered from user interaction"
582
+ );
583
+ }
584
+
585
+ const origin =
586
+ typeof browser !== "undefined"
587
+ ? new URL(browser.runtime.getURL("/")).origin
588
+ : "extension";
589
+ const chainInfo = this.chainsService.getChainInfoOrThrow(tx.chainId);
590
+ const evmInfo = ChainsService.getEVMInfo(chainInfo);
591
+ if (!evmInfo) {
592
+ throw new KeplrError("direct-tx-executor", 142, "Not EVM chain");
593
+ }
594
+
595
+ const unsignedTx = await fillUnsignedEVMTx(
596
+ origin,
597
+ evmInfo,
598
+ signer,
599
+ tx.txData,
600
+ tx.feeType ?? "average"
601
+ );
602
+
603
+ const result = await this.keyRingEthereumService.signEthereumPreAuthorized(
604
+ vaultId,
605
+ tx.chainId,
606
+ signer,
607
+ Buffer.from(JSON.stringify(unsignedTx)),
608
+ EthSignType.TRANSACTION
609
+ );
610
+
611
+ const signedTxData = JSON.parse(Buffer.from(result.signingData).toString());
612
+ const isEIP1559 =
613
+ !!signedTxData.maxFeePerGas || !!signedTxData.maxPriorityFeePerGas;
614
+ if (isEIP1559) {
615
+ signedTxData.type = TransactionTypes.eip1559;
616
+ }
617
+
618
+ delete signedTxData.from;
619
+
620
+ return serialize(signedTxData, result.signature);
621
+ }
622
+
623
+ private async signCosmosTx(
624
+ vaultId: string,
625
+ tx: CosmosBackgroundTx
626
+ ): Promise<string> {
627
+ const keyInfo = await this.keyRingCosmosService.getKey(vaultId, tx.chainId);
628
+ const isHardware = keyInfo.isNanoLedger || keyInfo.isKeystone;
629
+ const signer = keyInfo.bech32Address;
630
+
631
+ // For hardware wallets, the signedTx must be provided externally when calling resumeTx or recordAndExecuteTxs.
632
+ if (isHardware) {
633
+ throw new KeplrError(
634
+ "direct-tx-executor",
635
+ 140,
636
+ "Hardware wallet signing should be triggered from user interaction"
637
+ );
638
+ }
639
+
640
+ const origin =
641
+ typeof browser !== "undefined"
642
+ ? new URL(browser.runtime.getURL("/")).origin
643
+ : "extension";
644
+ const chainInfo = this.chainsService.getChainInfoOrThrow(tx.chainId);
645
+
646
+ const aminoMsgs: Msg[] = tx.txData.aminoMsgs ?? [];
647
+ const protoMsgs: Any[] = tx.txData.protoMsgs;
648
+ const pseudoFee = {
649
+ amount: [
650
+ {
651
+ denom: chainInfo.currencies[0].coinMinimalDenom,
652
+ amount: "1",
653
+ },
654
+ ],
655
+ gas: "100000",
656
+ };
657
+ const memo = tx.txData.memo ?? "";
658
+
659
+ const isDirectSign = aminoMsgs.length === 0;
660
+
661
+ if (protoMsgs.length === 0) {
662
+ throw new Error("There is no msg to send");
663
+ }
664
+
665
+ if (!isDirectSign && aminoMsgs.length !== protoMsgs.length) {
666
+ throw new Error("The length of aminoMsgs and protoMsgs are different");
667
+ }
668
+
669
+ const account = await BaseAccount.fetchFromRest(
670
+ chainInfo.rest,
671
+ signer,
672
+ true
673
+ );
674
+
675
+ let fee = tx.txData.fee; // use provided fee if exists
676
+ if (fee == null) {
677
+ const { gasUsed } = await simulateCosmosTx(
678
+ signer,
679
+ chainInfo,
680
+ protoMsgs,
681
+ pseudoFee,
682
+ memo
683
+ );
684
+
685
+ const feeCurrency =
686
+ chainInfo.feeCurrencies.find(
687
+ (currency) => currency.coinMinimalDenom === tx.feeCurrencyDenom
688
+ ) ?? chainInfo.currencies[0];
689
+
690
+ const { gasPrice } = await getCosmosGasPrice(
691
+ chainInfo,
692
+ tx.feeType ?? "average",
693
+ feeCurrency
694
+ );
695
+ fee = calculateCosmosStdFee(
696
+ feeCurrency,
697
+ gasUsed,
698
+ gasPrice,
699
+ chainInfo.features
700
+ );
701
+ }
702
+
703
+ if (isDirectSign) {
704
+ const { signDoc, bodyBytes, authInfoBytes } =
705
+ prepareSignDocForDirectSigning({
706
+ chainInfo,
707
+ accountNumber: account.getAccountNumber().toString(),
708
+ sequence: account.getSequence().toString(),
709
+ protoMsgs,
710
+ fee,
711
+ memo,
712
+ pubKey: keyInfo.pubKey,
713
+ });
714
+
715
+ const { signature } =
716
+ await this.keyRingCosmosService.signDirectPreAuthorized(
717
+ origin,
718
+ vaultId,
719
+ tx.chainId,
720
+ signer,
721
+ signDoc
722
+ );
723
+
724
+ const signedTx = TxRaw.encode({
725
+ bodyBytes,
726
+ authInfoBytes,
727
+ signatures: [Buffer.from(signature.signature, "base64")],
728
+ }).finish();
729
+
730
+ return Buffer.from(signedTx).toString("base64");
731
+ } else {
732
+ const signDoc = prepareSignDocForAminoSigning({
733
+ chainInfo,
734
+ accountNumber: account.getAccountNumber().toString(),
735
+ sequence: account.getSequence().toString(),
736
+ aminoMsgs: tx.txData.aminoMsgs ?? [],
737
+ fee,
738
+ memo,
739
+ eip712Signing: false,
740
+ signer,
741
+ });
742
+
743
+ const signResponse: AminoSignResponse =
744
+ await this.keyRingCosmosService.signAminoPreAuthorized(
745
+ origin,
746
+ vaultId,
747
+ tx.chainId,
748
+ signer,
749
+ signDoc
750
+ );
751
+
752
+ const signedTx = buildSignedTxFromAminoSignResponse({
753
+ protoMsgs,
754
+ signResponse,
755
+ chainInfo,
756
+ eip712Signing: false,
757
+ });
758
+
759
+ return Buffer.from(signedTx.tx).toString("base64");
760
+ }
761
+ }
762
+
763
+ protected async broadcastTx(tx: BackgroundTx): Promise<string> {
764
+ switch (tx.type) {
765
+ case BackgroundTxType.EVM: {
766
+ return this.broadcastEvmTx(tx);
767
+ }
768
+ case BackgroundTxType.COSMOS: {
769
+ return this.broadcastCosmosTx(tx);
770
+ }
771
+ default: {
772
+ throw new KeplrError("direct-tx-executor", 143, "Unknown tx type");
773
+ }
774
+ }
775
+ }
776
+
777
+ private async broadcastEvmTx(tx: EVMBackgroundTx): Promise<string> {
778
+ // assume the signed tx is valid if exists
779
+ if (!tx.signedTx) {
780
+ throw new KeplrError("direct-tx-executor", 132, "Signed tx not found");
781
+ }
782
+
783
+ const origin =
784
+ typeof browser !== "undefined"
785
+ ? new URL(browser.runtime.getURL("/")).origin
786
+ : "extension";
787
+
788
+ const signedTxBytes = Buffer.from(tx.signedTx.replace("0x", ""), "hex");
789
+
790
+ const txHash = await this.backgroundTxEthereumService.sendEthereumTx(
791
+ origin,
792
+ tx.chainId,
793
+ signedTxBytes,
794
+ {
795
+ silent: true,
796
+ skipTracingTxResult: true,
797
+ }
798
+ );
799
+
800
+ return txHash;
801
+ }
802
+
803
+ private async broadcastCosmosTx(tx: CosmosBackgroundTx): Promise<string> {
804
+ if (!tx.signedTx) {
805
+ throw new KeplrError("direct-tx-executor", 132, "Signed tx not found");
806
+ }
807
+
808
+ const signedTxBytes = Buffer.from(tx.signedTx, "base64");
809
+
810
+ // broadcast the tx
811
+ const txHash = await this.backgroundTxService.sendTx(
812
+ tx.chainId,
813
+ signedTxBytes,
814
+ "sync",
815
+ {
816
+ silent: true,
817
+ skipTracingTxResult: true,
818
+ }
819
+ );
820
+
821
+ return Buffer.from(txHash).toString("hex");
822
+ }
823
+
824
+ protected async traceTx(tx: BackgroundTx): Promise<boolean> {
825
+ switch (tx.type) {
826
+ case BackgroundTxType.EVM: {
827
+ return this.traceEvmTx(tx);
828
+ }
829
+ case BackgroundTxType.COSMOS: {
830
+ return this.traceCosmosTx(tx);
831
+ }
832
+ default: {
833
+ throw new KeplrError("direct-tx-executor", 143, "Unknown tx type");
834
+ }
835
+ }
836
+ }
837
+
838
+ private async traceEvmTx(tx: EVMBackgroundTx): Promise<boolean> {
839
+ if (!tx.txHash) {
840
+ throw new KeplrError("direct-tx-executor", 133, "Tx hash not found");
841
+ }
842
+
843
+ const origin =
844
+ typeof browser !== "undefined"
845
+ ? new URL(browser.runtime.getURL("/")).origin
846
+ : "extension";
847
+
848
+ const txReceipt =
849
+ await this.backgroundTxEthereumService.getEthereumTxReceipt(
850
+ origin,
851
+ tx.chainId,
852
+ tx.txHash
853
+ );
854
+ if (!txReceipt) {
855
+ return false;
856
+ }
857
+
858
+ return txReceipt.status === EthTxStatus.Success;
859
+ }
860
+
861
+ private async traceCosmosTx(tx: CosmosBackgroundTx): Promise<boolean> {
862
+ if (!tx.txHash) {
863
+ throw new KeplrError("direct-tx-executor", 133, "Tx hash not found");
864
+ }
865
+
866
+ const txResult = await this.backgroundTxService.traceTx(
867
+ tx.chainId,
868
+ tx.txHash
869
+ );
870
+ if (!txResult) {
871
+ return false;
872
+ }
873
+
874
+ // Tendermint/CometBFT omits the code field when tx is successful (code=0)
875
+ // If code is present and non-zero, it's a failure
876
+ if (txResult.code != null && txResult.code !== 0) {
877
+ return false;
878
+ }
879
+
880
+ return true;
881
+ }
882
+
883
+ /**
884
+ * Find the index of the most recent confirmed transaction with executable chain ids.
885
+ * Returns -1 if not found.
886
+ */
887
+ private findHistoryTxIndex(execution: TxExecution): number {
888
+ if (execution.historyTxIndex != null) {
889
+ return execution.historyTxIndex;
890
+ }
891
+ for (let i = execution.txs.length - 1; i >= 0; i--) {
892
+ const tx = execution.txs[i];
893
+ if (
894
+ execution.executableChainIds.includes(tx.chainId) &&
895
+ tx.status === BackgroundTxStatus.CONFIRMED
896
+ ) {
897
+ return i;
898
+ }
899
+ }
900
+ return -1;
901
+ }
902
+
903
+ @action
904
+ protected recordHistoryIfNeeded(execution: TxExecution): void {
905
+ switch (execution.type) {
906
+ case TxExecutionType.SWAP_V2: {
907
+ if (execution.historyId != null || execution.historyData == null) {
908
+ return;
909
+ }
910
+
911
+ const historyTxIndex = this.findHistoryTxIndex(execution);
912
+ if (historyTxIndex < 0) {
913
+ return;
914
+ }
915
+
916
+ const tx = execution.txs[historyTxIndex];
917
+ if (!tx || tx.txHash == null) {
918
+ return;
919
+ }
920
+
921
+ const historyData = execution.historyData;
922
+
923
+ const backgroundExecutionId = execution.txs.some(
924
+ (tx) => tx.status === BackgroundTxStatus.BLOCKED
925
+ )
926
+ ? execution.id
927
+ : undefined;
928
+
929
+ const id = this.recentSendHistoryService.recordTxWithSwapV2(
930
+ historyData.fromChainId,
931
+ historyData.toChainId,
932
+ historyData.provider,
933
+ historyData.destinationAsset,
934
+ historyData.simpleRoute,
935
+ historyData.sender,
936
+ historyData.recipient,
937
+ historyData.amount,
938
+ historyData.notificationInfo,
939
+ historyData.routeDurationSeconds,
940
+ tx.txHash,
941
+ historyData.isOnlyUseBridge,
942
+ backgroundExecutionId
943
+ );
944
+
945
+ execution.historyId = id;
946
+ break;
947
+ }
948
+ default: {
949
+ return;
950
+ }
951
+ }
952
+ }
953
+
954
+ getRecentTxExecutions(): TxExecution[] {
955
+ return Array.from(this.recentTxExecutionMap.values());
956
+ }
957
+
958
+ getTxExecution(id: string): TxExecution | undefined {
959
+ const execution = this.recentTxExecutionMap.get(id);
960
+ if (!execution) {
961
+ return undefined;
962
+ }
963
+
964
+ return execution;
965
+ }
966
+
967
+ @action
968
+ protected removeTxExecution(id: string): void {
969
+ this.recentTxExecutionMap.delete(id);
970
+ }
971
+
972
+ @action
973
+ protected cleanupOldExecutions(): void {
974
+ const completedStatuses = [
975
+ TxExecutionStatus.COMPLETED,
976
+ TxExecutionStatus.FAILED,
977
+ ];
978
+
979
+ const idsToDelete: string[] = [];
980
+
981
+ for (const [id, execution] of this.recentTxExecutionMap) {
982
+ // 비정상 종료된 PROCESSING 상태 → FAILED 처리
983
+ // (브라우저 종료, 시스템 재부팅, 익스텐션 업데이트 등)
984
+ if (execution.status === TxExecutionStatus.PROCESSING) {
985
+ execution.status = TxExecutionStatus.FAILED;
986
+ }
987
+
988
+ if (completedStatuses.includes(execution.status)) {
989
+ idsToDelete.push(id);
990
+ }
991
+ }
992
+
993
+ for (const id of idsToDelete) {
994
+ this.recentTxExecutionMap.delete(id);
995
+ }
996
+ }
997
+
998
+ private recordSwapV2HistoryErrorIfNeeded(
999
+ execution: TxExecution,
1000
+ error: string
1001
+ ): void {
1002
+ if (
1003
+ execution.type === TxExecutionType.SWAP_V2 &&
1004
+ execution.historyId != null
1005
+ ) {
1006
+ this.recentSendHistoryService.setSwapV2HistoryError(
1007
+ execution.historyId,
1008
+ error
1009
+ );
1010
+ }
1011
+ }
1012
+
1013
+ private clearSwapV2HistoryBackgroundExecutionIdIfNeeded(
1014
+ execution: TxExecution
1015
+ ): void {
1016
+ if (
1017
+ execution.type === TxExecutionType.SWAP_V2 &&
1018
+ execution.historyId != null
1019
+ ) {
1020
+ this.recentSendHistoryService.clearSwapV2HistoryBackgroundExecutionId(
1021
+ execution.historyId
1022
+ );
1023
+ }
1024
+ }
1025
+ }