From 61b815568a358907a98fb25ea2a9556cfaa534b0 Mon Sep 17 00:00:00 2001 From: rowan Date: Tue, 25 Nov 2025 21:15:59 +0900 Subject: [PATCH 01/29] init stub direct tx executor service --- .../src/direct-tx-executor/constants.ts | 1 + .../src/direct-tx-executor/handler.ts | 33 +++++++++++++++++++ .../src/direct-tx-executor/index.ts | 3 ++ .../background/src/direct-tx-executor/init.ts | 11 +++++++ .../src/direct-tx-executor/internal.ts | 2 ++ .../src/direct-tx-executor/messages.ts | 29 ++++++++++++++++ .../src/direct-tx-executor/service.ts | 26 +++++++++++++++ .../src/direct-tx-executor/types.ts | 4 +++ packages/background/src/index.ts | 16 +++++++++ 9 files changed, 125 insertions(+) create mode 100644 packages/background/src/direct-tx-executor/constants.ts create mode 100644 packages/background/src/direct-tx-executor/handler.ts create mode 100644 packages/background/src/direct-tx-executor/index.ts create mode 100644 packages/background/src/direct-tx-executor/init.ts create mode 100644 packages/background/src/direct-tx-executor/internal.ts create mode 100644 packages/background/src/direct-tx-executor/messages.ts create mode 100644 packages/background/src/direct-tx-executor/service.ts create mode 100644 packages/background/src/direct-tx-executor/types.ts diff --git a/packages/background/src/direct-tx-executor/constants.ts b/packages/background/src/direct-tx-executor/constants.ts new file mode 100644 index 0000000000..eabc5d8936 --- /dev/null +++ b/packages/background/src/direct-tx-executor/constants.ts @@ -0,0 +1 @@ +export const ROUTE = "direct-tx-executor"; diff --git a/packages/background/src/direct-tx-executor/handler.ts b/packages/background/src/direct-tx-executor/handler.ts new file mode 100644 index 0000000000..a4a7e38fa8 --- /dev/null +++ b/packages/background/src/direct-tx-executor/handler.ts @@ -0,0 +1,33 @@ +import { + Env, + Handler, + InternalHandler, + KeplrError, + Message, +} from "@keplr-wallet/router"; +import { DirectTxExecutorService } from "./service"; +import { GetDirectTxExecutorDataMsg } from "./messages"; + +export const getHandler: (service: DirectTxExecutorService) => Handler = ( + service: DirectTxExecutorService +) => { + return (env: Env, msg: Message) => { + switch (msg.constructor) { + case GetDirectTxExecutorDataMsg: + return handleGetDirectTxExecutorDataMsg(service)( + env, + msg as GetDirectTxExecutorDataMsg + ); + default: + throw new KeplrError("direct-tx-executor", 100, "Unknown msg type"); + } + }; +}; + +const handleGetDirectTxExecutorDataMsg: ( + service: DirectTxExecutorService +) => InternalHandler = (_service) => { + return async (_env, _msg) => { + throw new KeplrError("direct-tx-executor", 100, "Not implemented"); + }; +}; diff --git a/packages/background/src/direct-tx-executor/index.ts b/packages/background/src/direct-tx-executor/index.ts new file mode 100644 index 0000000000..cbc46ebd42 --- /dev/null +++ b/packages/background/src/direct-tx-executor/index.ts @@ -0,0 +1,3 @@ +export * from "./service"; +export * from "./messages"; +export * from "./types"; diff --git a/packages/background/src/direct-tx-executor/init.ts b/packages/background/src/direct-tx-executor/init.ts new file mode 100644 index 0000000000..4907186ad7 --- /dev/null +++ b/packages/background/src/direct-tx-executor/init.ts @@ -0,0 +1,11 @@ +import { Router } from "@keplr-wallet/router"; +import { ROUTE } from "./constants"; +import { getHandler } from "./handler"; +import { DirectTxExecutorService } from "./service"; +import { GetDirectTxExecutorDataMsg } from "./messages"; + +export function init(router: Router, service: DirectTxExecutorService): void { + router.registerMessage(GetDirectTxExecutorDataMsg); + + router.addHandler(ROUTE, getHandler(service)); +} diff --git a/packages/background/src/direct-tx-executor/internal.ts b/packages/background/src/direct-tx-executor/internal.ts new file mode 100644 index 0000000000..85b858331f --- /dev/null +++ b/packages/background/src/direct-tx-executor/internal.ts @@ -0,0 +1,2 @@ +export * from "./service"; +export * from "./init"; diff --git a/packages/background/src/direct-tx-executor/messages.ts b/packages/background/src/direct-tx-executor/messages.ts new file mode 100644 index 0000000000..666fc9dc10 --- /dev/null +++ b/packages/background/src/direct-tx-executor/messages.ts @@ -0,0 +1,29 @@ +import { Message } from "@keplr-wallet/router"; +import { ROUTE } from "./constants"; +import { DirectTxExecutorData } from "./types"; + +export class GetDirectTxExecutorDataMsg extends Message { + public static type() { + return "get-direct-tx-executor-data"; + } + + constructor(public readonly id: string) { + super(); + } + + validateBasic(): void { + // Add validation + } + + override approveExternal(): boolean { + return true; + } + + route(): string { + return ROUTE; + } + + type(): string { + return GetDirectTxExecutorDataMsg.type(); + } +} diff --git a/packages/background/src/direct-tx-executor/service.ts b/packages/background/src/direct-tx-executor/service.ts new file mode 100644 index 0000000000..61708a7a14 --- /dev/null +++ b/packages/background/src/direct-tx-executor/service.ts @@ -0,0 +1,26 @@ +import { KVStore } from "@keplr-wallet/common"; +import { ChainsService } from "../chains"; +import { KeyRingCosmosService } from "../keyring-cosmos"; +import { KeyRingEthereumService } from "../keyring-ethereum"; +import { AnalyticsService } from "../analytics"; +import { RecentSendHistoryService } from "../recent-send-history"; +import { BackgroundTxService } from "../tx"; +import { BackgroundTxEthereumService } from "../tx-ethereum"; + +// TODO: implement this service +export class DirectTxExecutorService { + constructor( + protected readonly kvStore: KVStore, + protected readonly chainsService: ChainsService, + protected readonly keyRingCosmosService: KeyRingCosmosService, + protected readonly keyRingEthereumService: KeyRingEthereumService, + protected readonly backgroundTxService: BackgroundTxService, + protected readonly backgroundTxEthereumService: BackgroundTxEthereumService, + protected readonly analyticsService: AnalyticsService, + protected readonly recentSendHistoryService: RecentSendHistoryService + ) {} + + async init(): Promise { + // noop + } +} diff --git a/packages/background/src/direct-tx-executor/types.ts b/packages/background/src/direct-tx-executor/types.ts new file mode 100644 index 0000000000..f61208eca6 --- /dev/null +++ b/packages/background/src/direct-tx-executor/types.ts @@ -0,0 +1,4 @@ +// TODO: define data type for direct tx executor +export interface DirectTxExecutorData { + readonly id: string; +} diff --git a/packages/background/src/index.ts b/packages/background/src/index.ts index 25fc4dbb02..d01d574ca7 100644 --- a/packages/background/src/index.ts +++ b/packages/background/src/index.ts @@ -31,6 +31,7 @@ import * as RecentSendHistory from "./recent-send-history/internal"; import * as SidePanel from "./side-panel/internal"; import * as Settings from "./settings/internal"; import * as ManageViewAssetToken from "./manage-view-asset-token/internal"; +import * as DirectTxExecutor from "./direct-tx-executor/internal"; export * from "./chains"; export * from "./chains-ui"; @@ -58,6 +59,7 @@ export * from "./side-panel"; export * from "./settings"; export * from "./manage-view-asset-token"; export * from "./tx-ethereum"; +export * from "./direct-tx-executor"; import { KVStore } from "@keplr-wallet/common"; import { ChainInfo, ModularChainInfo } from "@keplr-wallet/types"; @@ -312,6 +314,17 @@ export function init( chainsService ); + const directTxExecutorService = new DirectTxExecutor.DirectTxExecutorService( + storeCreator("direct-tx-executor"), + chainsService, + keyRingCosmosService, + keyRingEthereumService, + backgroundTxService, + backgroundTxEthereumService, + analyticsService, + recentSendHistoryService + ); + Interaction.init(router, interactionService); Permission.init(router, permissionService); Chains.init( @@ -367,6 +380,7 @@ export function init( SidePanel.init(router, sidePanelService); Settings.init(router, settingsService); ManageViewAssetToken.init(router, manageViewAssetTokenService); + DirectTxExecutor.init(router, directTxExecutorService); return { initFn: async () => { @@ -406,6 +420,8 @@ export function init( await chainsService.afterInit(); await manageViewAssetTokenService.init(); + + await directTxExecutorService.init(); }, keyRingService: keyRingV2Service, analyticsService: analyticsService, From 9fc5ee13c49a8f7314374c73727708b33b65ba38 Mon Sep 17 00:00:00 2001 From: rowan Date: Wed, 26 Nov 2025 15:00:53 +0900 Subject: [PATCH 02/29] define types for direct tx execution --- .../src/direct-tx-executor/types.ts | 88 ++++++++++++++++++- 1 file changed, 86 insertions(+), 2 deletions(-) diff --git a/packages/background/src/direct-tx-executor/types.ts b/packages/background/src/direct-tx-executor/types.ts index f61208eca6..e2af398fda 100644 --- a/packages/background/src/direct-tx-executor/types.ts +++ b/packages/background/src/direct-tx-executor/types.ts @@ -1,4 +1,88 @@ -// TODO: define data type for direct tx executor -export interface DirectTxExecutorData { +import { StdSignDoc, StdSignature } from "@keplr-wallet/types"; + +// Transaction status +export enum DirectTxStatus { + PENDING = "pending", + SIGNING = "signing", + BROADCASTING = "broadcasting", + WAITING = "waiting", + COMPLETED = "completed", + FAILED = "failed", + CANCELLED = "cancelled", +} + +// Transaction type +export enum DirectTxType { + EVM = "evm", + COSMOS = "cosmos", +} + +// TODO: 뭐가 필요할까... +export interface CosmosTxData { + readonly signDoc: StdSignDoc; + readonly signature?: StdSignature; +} + +export interface EvmTxData { + readonly tx: Uint8Array; + readonly signature?: Uint8Array; +} + +// Transaction data union type +export type DirectTxData = + | EvmTxData // EVM transaction data + | CosmosTxData; // Cosmos transaction construction data + +// Single transaction data +export interface DirectTx { + readonly type: DirectTxType; + status: DirectTxStatus; // mutable while executing + readonly chainId: string; + readonly txData: DirectTxData; + + // Transaction hash for completed tx + txHash?: string; + + // Error message if failed + error?: string; +} + +export enum DirectTxsExecutionStatus { + PENDING = "pending", + PROCESSING = "processing", + COMPLETED = "completed", + FAILED = "failed", + CANCELLED = "cancelled", +} + +export interface DirectTxsExecutionData { + readonly id: string; + status: DirectTxsExecutionStatus; + + // keyring vault id + readonly vaultId: string; + + // transactions + readonly txs: DirectTx[]; + currentTxIndex: number; // Current transaction being processed + + // swap history id after record swap history + swapHistoryId?: string; + // TODO: add more required fields for swap history data + readonly swapHistoryData?: { + readonly chainId: string; + }; + + readonly timestamp: number; // Timestamp when execution started +} + +// Execution result +export interface DirectTxsExecutionResult { readonly id: string; + readonly txs: { + chainId: string; + txHash?: string; + }[]; + readonly swapHistoryId?: string; + readonly error?: string; } From 3baaa52c5ab0f2b0b2fdbf52e44894310c84d61a Mon Sep 17 00:00:00 2001 From: rowan Date: Wed, 26 Nov 2025 15:50:39 +0900 Subject: [PATCH 03/29] define messages for direct tx execution --- .../src/direct-tx-executor/handler.ts | 74 +++++++- .../background/src/direct-tx-executor/init.ts | 14 +- .../src/direct-tx-executor/messages.ts | 164 +++++++++++++++++- .../src/direct-tx-executor/service.ts | 64 ++++++- .../src/direct-tx-executor/types.ts | 2 +- 5 files changed, 300 insertions(+), 18 deletions(-) diff --git a/packages/background/src/direct-tx-executor/handler.ts b/packages/background/src/direct-tx-executor/handler.ts index a4a7e38fa8..e2edfe49f9 100644 --- a/packages/background/src/direct-tx-executor/handler.ts +++ b/packages/background/src/direct-tx-executor/handler.ts @@ -6,17 +6,43 @@ import { Message, } from "@keplr-wallet/router"; import { DirectTxExecutorService } from "./service"; -import { GetDirectTxExecutorDataMsg } from "./messages"; +import { + RecordAndExecuteDirectTxsMsg, + GetDirectTxsExecutionDataMsg, + ExecuteDirectTxMsg, + GetDirectTxsExecutionResultMsg, + CancelDirectTxsExecutionMsg, +} from "./messages"; export const getHandler: (service: DirectTxExecutorService) => Handler = ( service: DirectTxExecutorService ) => { return (env: Env, msg: Message) => { switch (msg.constructor) { - case GetDirectTxExecutorDataMsg: - return handleGetDirectTxExecutorDataMsg(service)( + case RecordAndExecuteDirectTxsMsg: + return handleRecordAndExecuteDirectTxsMsg(service)( + env, + msg as RecordAndExecuteDirectTxsMsg + ); + case GetDirectTxsExecutionDataMsg: + return handleGetDirectTxsExecutionDataMsg(service)( + env, + msg as GetDirectTxsExecutionDataMsg + ); + case ExecuteDirectTxMsg: + return handleExecuteDirectTxMsg(service)( + env, + msg as ExecuteDirectTxMsg + ); + case GetDirectTxsExecutionResultMsg: + return handleGetDirectTxsExecutionResultMsg(service)( env, - msg as GetDirectTxExecutorDataMsg + msg as GetDirectTxsExecutionResultMsg + ); + case CancelDirectTxsExecutionMsg: + return handleCancelDirectTxsExecutionMsg(service)( + env, + msg as CancelDirectTxsExecutionMsg ); default: throw new KeplrError("direct-tx-executor", 100, "Unknown msg type"); @@ -24,10 +50,42 @@ export const getHandler: (service: DirectTxExecutorService) => Handler = ( }; }; -const handleGetDirectTxExecutorDataMsg: ( +const handleRecordAndExecuteDirectTxsMsg: ( + service: DirectTxExecutorService +) => InternalHandler = (service) => { + return async (env, msg) => { + return await service.recordAndExecuteDirectTxs(env, msg.vaultId, msg.txs); + }; +}; + +const handleExecuteDirectTxMsg: ( + service: DirectTxExecutorService +) => InternalHandler = (service) => { + return async (env, msg) => { + return await service.executeDirectTx(env, msg.id, msg.vaultId, msg.txIndex); + }; +}; + +const handleGetDirectTxsExecutionDataMsg: ( + service: DirectTxExecutorService +) => InternalHandler = (service) => { + return async (_env, msg) => { + return await service.getDirectTxsExecutionData(msg.id); + }; +}; + +const handleGetDirectTxsExecutionResultMsg: ( + service: DirectTxExecutorService +) => InternalHandler = (service) => { + return async (_env, msg) => { + return await service.getDirectTxsExecutionResult(msg.id); + }; +}; + +const handleCancelDirectTxsExecutionMsg: ( service: DirectTxExecutorService -) => InternalHandler = (_service) => { - return async (_env, _msg) => { - throw new KeplrError("direct-tx-executor", 100, "Not implemented"); +) => InternalHandler = (service) => { + return async (_env, msg) => { + await service.cancelDirectTxsExecution(msg.id); }; }; diff --git a/packages/background/src/direct-tx-executor/init.ts b/packages/background/src/direct-tx-executor/init.ts index 4907186ad7..bf734081b4 100644 --- a/packages/background/src/direct-tx-executor/init.ts +++ b/packages/background/src/direct-tx-executor/init.ts @@ -2,10 +2,20 @@ import { Router } from "@keplr-wallet/router"; import { ROUTE } from "./constants"; import { getHandler } from "./handler"; import { DirectTxExecutorService } from "./service"; -import { GetDirectTxExecutorDataMsg } from "./messages"; +import { + RecordAndExecuteDirectTxsMsg, + GetDirectTxsExecutionDataMsg, + ExecuteDirectTxMsg, + CancelDirectTxsExecutionMsg, + GetDirectTxsExecutionResultMsg, +} from "./messages"; export function init(router: Router, service: DirectTxExecutorService): void { - router.registerMessage(GetDirectTxExecutorDataMsg); + router.registerMessage(RecordAndExecuteDirectTxsMsg); + router.registerMessage(ExecuteDirectTxMsg); + router.registerMessage(GetDirectTxsExecutionDataMsg); + router.registerMessage(GetDirectTxsExecutionResultMsg); + router.registerMessage(CancelDirectTxsExecutionMsg); router.addHandler(ROUTE, getHandler(service)); } diff --git a/packages/background/src/direct-tx-executor/messages.ts b/packages/background/src/direct-tx-executor/messages.ts index 666fc9dc10..0711d6be49 100644 --- a/packages/background/src/direct-tx-executor/messages.ts +++ b/packages/background/src/direct-tx-executor/messages.ts @@ -1,10 +1,160 @@ -import { Message } from "@keplr-wallet/router"; +import { KeplrError, Message } from "@keplr-wallet/router"; import { ROUTE } from "./constants"; -import { DirectTxExecutorData } from "./types"; +import { + DirectTxsExecutionData, + DirectTxsExecutionResult, + DirectTx, +} from "./types"; -export class GetDirectTxExecutorDataMsg extends Message { +/** + * Record and execute multiple transactions + * execution id is returned if the transactions are recorded successfully + * and the execution will be started automatically after the transactions are recorded. + */ +export class RecordAndExecuteDirectTxsMsg extends Message { public static type() { - return "get-direct-tx-executor-data"; + return "record-and-execute-direct-txs"; + } + + //TODO: add history data... + constructor( + public readonly vaultId: string, + public readonly txs: DirectTx[] + ) { + super(); + } + + validateBasic(): void { + if (!this.vaultId) { + throw new KeplrError("direct-tx-executor", 101, "vaultId is empty"); + } + if (!this.txs || this.txs.length === 0) { + throw new KeplrError("direct-tx-executor", 102, "txs is empty"); + } + } + + override approveExternal(): boolean { + return false; + } + + route(): string { + return ROUTE; + } + + type(): string { + return RecordAndExecuteDirectTxsMsg.type(); + } +} + +/** + * Execute existing direct transaction by execution id and transaction index + * Tx hash is returned if the transaction is executed successfully + */ +export class ExecuteDirectTxMsg extends Message { + public static type() { + return "execute-existing-direct-tx"; + } + + constructor( + public readonly id: string, + public readonly vaultId: string, + public readonly txIndex: number + ) { + super(); + } + + validateBasic(): void { + if (!this.id) { + throw new KeplrError("direct-tx-executor", 101, "id is empty"); + } + if (!this.vaultId) { + throw new KeplrError("direct-tx-executor", 102, "vaultId is empty"); + } + if (!this.txIndex) { + throw new KeplrError("direct-tx-executor", 103, "txIndex is empty"); + } + } + + override approveExternal(): boolean { + return false; + } + + route(): string { + return ROUTE; + } + + type(): string { + return ExecuteDirectTxMsg.type(); + } +} + +/** + * Get execution data by execution id + */ +export class GetDirectTxsExecutionDataMsg extends Message { + public static type() { + return "get-direct-txs-execution-data"; + } + + constructor(public readonly id: string) { + super(); + } + + validateBasic(): void { + if (!this.id) { + throw new KeplrError("direct-tx-executor", 101, "id is empty"); + } + } + + override approveExternal(): boolean { + return false; + } + + route(): string { + return ROUTE; + } + + type(): string { + return GetDirectTxsExecutionDataMsg.type(); + } +} + +/** + * Get execution result by execution id + */ +export class GetDirectTxsExecutionResultMsg extends Message { + public static type() { + return "get-direct-txs-execution-result"; + } + constructor(public readonly id: string) { + super(); + } + + validateBasic(): void { + if (!this.id) { + throw new KeplrError("direct-tx-executor", 101, "id is empty"); + } + } + + override approveExternal(): boolean { + return false; + } + + route(): string { + return ROUTE; + } + + type(): string { + return GetDirectTxsExecutionResultMsg.type(); + } +} + +/** + * Cancel execution by execution id + */ +export class CancelDirectTxsExecutionMsg extends Message { + public static type() { + return "cancel-direct-txs-execution"; } constructor(public readonly id: string) { @@ -12,7 +162,9 @@ export class GetDirectTxExecutorDataMsg extends Message { } validateBasic(): void { - // Add validation + if (!this.id) { + throw new KeplrError("direct-tx-executor", 101, "id is empty"); + } } override approveExternal(): boolean { @@ -24,6 +176,6 @@ export class GetDirectTxExecutorDataMsg extends Message { } type(): string { - return GetDirectTxExecutorDataMsg.type(); + return CancelDirectTxsExecutionMsg.type(); } } diff --git a/packages/background/src/direct-tx-executor/service.ts b/packages/background/src/direct-tx-executor/service.ts index 61708a7a14..cc585df75f 100644 --- a/packages/background/src/direct-tx-executor/service.ts +++ b/packages/background/src/direct-tx-executor/service.ts @@ -6,6 +6,12 @@ import { AnalyticsService } from "../analytics"; import { RecentSendHistoryService } from "../recent-send-history"; import { BackgroundTxService } from "../tx"; import { BackgroundTxEthereumService } from "../tx-ethereum"; +import { Env } from "@keplr-wallet/router"; +import { + DirectTxsExecutionData, + DirectTxsExecutionResult, + DirectTx, +} from "./types"; // TODO: implement this service export class DirectTxExecutorService { @@ -21,6 +27,62 @@ export class DirectTxExecutorService { ) {} async init(): Promise { - // noop + // TODO: Load pending executions and resume if needed + } + + /** + * Get execution data by ID + */ + async getDirectTxsExecutionData( + _id: string + ): Promise { + // TODO: implement + throw new Error("Not implemented"); + } + + /** + * Execute single transaction + * Tx hash is returned if the transaction is executed successfully + */ + async executeDirectTx( + _env: Env, + _id: string, + _vaultId: string, + _txIndex: number + ): Promise { + // TODO: implement + throw new Error("Not implemented"); + } + + /** + * Execute multiple transactions sequentially + * Execution id is returned if the execution is started successfully + * and the execution will be started automatically after the transactions are recorded. + */ + async recordAndExecuteDirectTxs( + _env: Env, + _vaultId: string, + _txs: DirectTx[] + ): Promise { + // TODO: implement + throw new Error("Not implemented"); + } + + /** + * Get execution result by execution id + */ + async getDirectTxsExecutionResult( + _id: string + ): Promise { + // TODO: implement + throw new Error("Not implemented"); + } + + /** + * Cancel execution by execution id + */ + async cancelDirectTxsExecution(_id: string): Promise { + // TODO: implement + throw new Error("Not implemented"); } } diff --git a/packages/background/src/direct-tx-executor/types.ts b/packages/background/src/direct-tx-executor/types.ts index e2af398fda..b79369fb3c 100644 --- a/packages/background/src/direct-tx-executor/types.ts +++ b/packages/background/src/direct-tx-executor/types.ts @@ -76,7 +76,7 @@ export interface DirectTxsExecutionData { readonly timestamp: number; // Timestamp when execution started } -// Execution result +// Execution result (summary of the execution data) export interface DirectTxsExecutionResult { readonly id: string; readonly txs: { From 0bdbf569cb2e60f0f0b140890f36dd6f5deec53c Mon Sep 17 00:00:00 2001 From: rowan Date: Wed, 26 Nov 2025 21:52:56 +0900 Subject: [PATCH 04/29] rename to background tx executor service --- apps/extension/src/keplr-wallet-private | 1 - .../src/direct-tx-executor/handler.ts | 87 ++-- .../background/src/direct-tx-executor/init.ts | 23 +- .../src/direct-tx-executor/messages.ts | 41 +- .../src/direct-tx-executor/service.ts | 382 ++++++++++++++++-- .../src/direct-tx-executor/types.ts | 20 +- 6 files changed, 439 insertions(+), 115 deletions(-) delete mode 160000 apps/extension/src/keplr-wallet-private diff --git a/apps/extension/src/keplr-wallet-private b/apps/extension/src/keplr-wallet-private deleted file mode 160000 index cfc88b8733..0000000000 --- a/apps/extension/src/keplr-wallet-private +++ /dev/null @@ -1 +0,0 @@ -Subproject commit cfc88b8733abae847deb57f95fee4a1811d17af8 diff --git a/packages/background/src/direct-tx-executor/handler.ts b/packages/background/src/direct-tx-executor/handler.ts index e2edfe49f9..7d9122e2f7 100644 --- a/packages/background/src/direct-tx-executor/handler.ts +++ b/packages/background/src/direct-tx-executor/handler.ts @@ -5,17 +5,17 @@ import { KeplrError, Message, } from "@keplr-wallet/router"; -import { DirectTxExecutorService } from "./service"; +import { BackgroundTxExecutorService } from "./service"; import { RecordAndExecuteDirectTxsMsg, - GetDirectTxsExecutionDataMsg, - ExecuteDirectTxMsg, - GetDirectTxsExecutionResultMsg, - CancelDirectTxsExecutionMsg, + ResumeDirectTxsMsg, + CancelDirectTxsMsg, + GetDirectTxsBatchMsg, + GetDirectTxsBatchResultMsg, } from "./messages"; -export const getHandler: (service: DirectTxExecutorService) => Handler = ( - service: DirectTxExecutorService +export const getHandler: (service: BackgroundTxExecutorService) => Handler = ( + service: BackgroundTxExecutorService ) => { return (env: Env, msg: Message) => { switch (msg.constructor) { @@ -24,25 +24,25 @@ export const getHandler: (service: DirectTxExecutorService) => Handler = ( env, msg as RecordAndExecuteDirectTxsMsg ); - case GetDirectTxsExecutionDataMsg: - return handleGetDirectTxsExecutionDataMsg(service)( + case GetDirectTxsBatchMsg: + return handleGetDirectTxsBatchMsg(service)( env, - msg as GetDirectTxsExecutionDataMsg + msg as GetDirectTxsBatchMsg ); - case ExecuteDirectTxMsg: - return handleExecuteDirectTxMsg(service)( + case ResumeDirectTxsMsg: + return handleResumeDirectTxsMsg(service)( env, - msg as ExecuteDirectTxMsg + msg as ResumeDirectTxsMsg ); - case GetDirectTxsExecutionResultMsg: - return handleGetDirectTxsExecutionResultMsg(service)( + case GetDirectTxsBatchResultMsg: + return handleGetDirectTxsBatchResultMsg(service)( env, - msg as GetDirectTxsExecutionResultMsg + msg as GetDirectTxsBatchResultMsg ); - case CancelDirectTxsExecutionMsg: - return handleCancelDirectTxsExecutionMsg(service)( + case CancelDirectTxsMsg: + return handleCancelDirectTxsMsg(service)( env, - msg as CancelDirectTxsExecutionMsg + msg as CancelDirectTxsMsg ); default: throw new KeplrError("direct-tx-executor", 100, "Unknown msg type"); @@ -51,41 +51,48 @@ export const getHandler: (service: DirectTxExecutorService) => Handler = ( }; const handleRecordAndExecuteDirectTxsMsg: ( - service: DirectTxExecutorService + service: BackgroundTxExecutorService ) => InternalHandler = (service) => { - return async (env, msg) => { - return await service.recordAndExecuteDirectTxs(env, msg.vaultId, msg.txs); + return (env, msg) => { + return service.recordAndExecuteDirectTxs(env, msg.vaultId, msg.txs); }; }; -const handleExecuteDirectTxMsg: ( - service: DirectTxExecutorService -) => InternalHandler = (service) => { +const handleResumeDirectTxsMsg: ( + service: BackgroundTxExecutorService +) => InternalHandler = (service) => { return async (env, msg) => { - return await service.executeDirectTx(env, msg.id, msg.vaultId, msg.txIndex); + return await service.resumeDirectTxs( + env, + msg.id, + msg.vaultId, + msg.txIndex, + msg.signedTx, + msg.signature + ); }; }; -const handleGetDirectTxsExecutionDataMsg: ( - service: DirectTxExecutorService -) => InternalHandler = (service) => { - return async (_env, msg) => { - return await service.getDirectTxsExecutionData(msg.id); +const handleGetDirectTxsBatchMsg: ( + service: BackgroundTxExecutorService +) => InternalHandler = (service) => { + return (_env, msg) => { + return service.getDirectTxsBatch(msg.id); }; }; -const handleGetDirectTxsExecutionResultMsg: ( - service: DirectTxExecutorService -) => InternalHandler = (service) => { - return async (_env, msg) => { - return await service.getDirectTxsExecutionResult(msg.id); +const handleGetDirectTxsBatchResultMsg: ( + service: BackgroundTxExecutorService +) => InternalHandler = (service) => { + return (_env, msg) => { + return service.getDirectTxsBatchResult(msg.id); }; }; -const handleCancelDirectTxsExecutionMsg: ( - service: DirectTxExecutorService -) => InternalHandler = (service) => { +const handleCancelDirectTxsMsg: ( + service: BackgroundTxExecutorService +) => InternalHandler = (service) => { return async (_env, msg) => { - await service.cancelDirectTxsExecution(msg.id); + await service.cancelDirectTxs(msg.id); }; }; diff --git a/packages/background/src/direct-tx-executor/init.ts b/packages/background/src/direct-tx-executor/init.ts index bf734081b4..5cd09e51da 100644 --- a/packages/background/src/direct-tx-executor/init.ts +++ b/packages/background/src/direct-tx-executor/init.ts @@ -1,21 +1,24 @@ import { Router } from "@keplr-wallet/router"; import { ROUTE } from "./constants"; import { getHandler } from "./handler"; -import { DirectTxExecutorService } from "./service"; +import { BackgroundTxExecutorService } from "./service"; import { RecordAndExecuteDirectTxsMsg, - GetDirectTxsExecutionDataMsg, - ExecuteDirectTxMsg, - CancelDirectTxsExecutionMsg, - GetDirectTxsExecutionResultMsg, + ResumeDirectTxsMsg, + CancelDirectTxsMsg, + GetDirectTxsBatchMsg, + GetDirectTxsBatchResultMsg, } from "./messages"; -export function init(router: Router, service: DirectTxExecutorService): void { +export function init( + router: Router, + service: BackgroundTxExecutorService +): void { router.registerMessage(RecordAndExecuteDirectTxsMsg); - router.registerMessage(ExecuteDirectTxMsg); - router.registerMessage(GetDirectTxsExecutionDataMsg); - router.registerMessage(GetDirectTxsExecutionResultMsg); - router.registerMessage(CancelDirectTxsExecutionMsg); + router.registerMessage(ResumeDirectTxsMsg); + router.registerMessage(GetDirectTxsBatchMsg); + router.registerMessage(GetDirectTxsBatchResultMsg); + router.registerMessage(CancelDirectTxsMsg); router.addHandler(ROUTE, getHandler(service)); } diff --git a/packages/background/src/direct-tx-executor/messages.ts b/packages/background/src/direct-tx-executor/messages.ts index 0711d6be49..04aec053d1 100644 --- a/packages/background/src/direct-tx-executor/messages.ts +++ b/packages/background/src/direct-tx-executor/messages.ts @@ -1,10 +1,6 @@ import { KeplrError, Message } from "@keplr-wallet/router"; import { ROUTE } from "./constants"; -import { - DirectTxsExecutionData, - DirectTxsExecutionResult, - DirectTx, -} from "./types"; +import { DirectTxsBatch, DirectTxsBatchResult, DirectTx } from "./types"; /** * Record and execute multiple transactions @@ -47,18 +43,21 @@ export class RecordAndExecuteDirectTxsMsg extends Message { } /** - * Execute existing direct transaction by execution id and transaction index - * Tx hash is returned if the transaction is executed successfully + * Resume existing direct transactions by execution id and transaction index + * This message is used to resume the execution of direct transactions that were paused by waiting for the asset to be bridged or other reasons. */ -export class ExecuteDirectTxMsg extends Message { +export class ResumeDirectTxsMsg extends Message { public static type() { - return "execute-existing-direct-tx"; + return "resume-direct-txs"; } constructor( public readonly id: string, public readonly vaultId: string, - public readonly txIndex: number + public readonly txIndex: number, + // NOTE: these fields are optional for hardware wallet cases + public readonly signedTx?: Uint8Array, + public readonly signature?: Uint8Array ) { super(); } @@ -84,16 +83,16 @@ export class ExecuteDirectTxMsg extends Message { } type(): string { - return ExecuteDirectTxMsg.type(); + return ResumeDirectTxsMsg.type(); } } /** * Get execution data by execution id */ -export class GetDirectTxsExecutionDataMsg extends Message { +export class GetDirectTxsBatchMsg extends Message { public static type() { - return "get-direct-txs-execution-data"; + return "get-direct-txs-batch"; } constructor(public readonly id: string) { @@ -115,16 +114,18 @@ export class GetDirectTxsExecutionDataMsg extends Message { +export class GetDirectTxsBatchResultMsg extends Message< + DirectTxsBatchResult | undefined +> { public static type() { - return "get-direct-txs-execution-result"; + return "get-direct-txs-batch-result"; } constructor(public readonly id: string) { super(); @@ -145,16 +146,16 @@ export class GetDirectTxsExecutionResultMsg extends Message { +export class CancelDirectTxsMsg extends Message { public static type() { - return "cancel-direct-txs-execution"; + return "cancel-direct-txs"; } constructor(public readonly id: string) { @@ -176,6 +177,6 @@ export class CancelDirectTxsExecutionMsg extends Message { } type(): string { - return CancelDirectTxsExecutionMsg.type(); + return CancelDirectTxsMsg.type(); } } diff --git a/packages/background/src/direct-tx-executor/service.ts b/packages/background/src/direct-tx-executor/service.ts index cc585df75f..b59685d716 100644 --- a/packages/background/src/direct-tx-executor/service.ts +++ b/packages/background/src/direct-tx-executor/service.ts @@ -6,15 +6,32 @@ import { AnalyticsService } from "../analytics"; import { RecentSendHistoryService } from "../recent-send-history"; import { BackgroundTxService } from "../tx"; import { BackgroundTxEthereumService } from "../tx-ethereum"; -import { Env } from "@keplr-wallet/router"; +import { Env, KeplrError } from "@keplr-wallet/router"; import { - DirectTxsExecutionData, - DirectTxsExecutionResult, + DirectTxsBatch, + DirectTxsBatchResult, DirectTx, + DirectTxsBatchStatus, + DirectTxStatus, } from "./types"; +import { + action, + autorun, + makeObservable, + observable, + runInAction, + toJS, +} from "mobx"; // TODO: implement this service -export class DirectTxExecutorService { +export class BackgroundTxExecutorService { + @observable + protected recentDirectTxsBatchesSeq: number = 0; + // Key: id (sequence, it should be increased by 1 for each) + @observable + protected readonly recentDirectTxsBatchesMap: Map = + new Map(); + constructor( protected readonly kvStore: KVStore, protected readonly chainsService: ChainsService, @@ -24,65 +41,360 @@ export class DirectTxExecutorService { protected readonly backgroundTxEthereumService: BackgroundTxEthereumService, protected readonly analyticsService: AnalyticsService, protected readonly recentSendHistoryService: RecentSendHistoryService - ) {} + ) { + makeObservable(this); + } async init(): Promise { - // TODO: Load pending executions and resume if needed + const recentDirectTxsBatchesSeqSaved = await this.kvStore.get( + "recentDirectTxsBatchesSeq" + ); + if (recentDirectTxsBatchesSeqSaved) { + runInAction(() => { + this.recentDirectTxsBatchesSeq = recentDirectTxsBatchesSeqSaved; + }); + } + autorun(() => { + const js = toJS(this.recentDirectTxsBatchesSeq); + this.kvStore.set("recentDirectTxsBatchesSeq", js); + }); + + const recentDirectTxsBatchesMapSaved = await this.kvStore.get< + Record + >("recentDirectTxsBatchesMap"); + if (recentDirectTxsBatchesMapSaved) { + runInAction(() => { + let entries = Object.entries(recentDirectTxsBatchesMapSaved); + entries = entries.sort(([, a], [, b]) => { + return parseInt(a.id) - parseInt(b.id); + }); + for (const [key, value] of entries) { + this.recentDirectTxsBatchesMap.set(key, value); + } + }); + } + autorun(() => { + const js = toJS(this.recentDirectTxsBatchesMap); + const obj = Object.fromEntries(js); + this.kvStore.set>( + "recentDirectTxsBatchesMap", + obj + ); + }); + + // TODO: 간단한 메시지 큐를 구현해서 recent send history service에서 multi tx를 처리할 조건이 만족되었을 때 + // 이 서비스로 메시지를 보내 트랜잭션을 자동으로 실행할 수 있도록 한다. 굳 + + // CHECK: 현재 활성화되어 있는 vault에서만 실행할 수 있으면 좋을 듯, how? vaultId 변경 감지? how? + // CHECK: 굳이 이걸 백그라운드에서 자동으로 실행할 필요가 있을까? + // 불러왔는데 pending 상태거나 오래된 실행이면 사실상 이 작업을 이어가는 것이 의미가 있는지 의문이 든다. + // for (const execution of this.getRecentDirectTxsExecutions()) { + + // this.executeDirectTxs(execution.id); + // } } /** - * Get execution data by ID + * Execute multiple transactions sequentially + * Execution id is returned if the execution is started successfully + * and the execution will be started automatically after the transactions are recorded. */ - async getDirectTxsExecutionData( - _id: string - ): Promise { - // TODO: implement - throw new Error("Not implemented"); + @action + recordAndExecuteDirectTxs( + env: Env, + vaultId: string, + txs: DirectTx[] + ): string { + if (!env.isInternalMsg) { + throw new KeplrError("direct-tx-executor", 101, "Not internal message"); + } + + const id = (this.recentDirectTxsBatchesSeq++).toString(); + + const batch: DirectTxsBatch = { + id, + status: DirectTxsBatchStatus.PENDING, + vaultId: vaultId, + txs: txs, + txIndex: -1, + timestamp: Date.now(), + // TODO: add swap history data... + }; + + this.recentDirectTxsBatchesMap.set(id, batch); + this.executeDirectTxs(id); + + return id; } /** - * Execute single transaction + * Execute single specific transaction by execution id and transaction index * Tx hash is returned if the transaction is executed successfully */ - async executeDirectTx( - _env: Env, + async resumeDirectTxs( + env: Env, _id: string, _vaultId: string, - _txIndex: number - ): Promise { + _txIndex: number, + _signedTx?: Uint8Array, + _signature?: Uint8Array + ): Promise { + if (!env.isInternalMsg) { + // TODO: 에러 코드 신경쓰기 + throw new KeplrError("direct-tx-executor", 101, "Not internal message"); + } + // TODO: implement throw new Error("Not implemented"); } + protected async executeDirectTxs(id: string): Promise { + const batch = this.getDirectTxsBatch(id); + if (!batch) { + return; + } + + // Only pending or processing executions can be executed + const needContinue = + batch.status === DirectTxsBatchStatus.PENDING || + batch.status === DirectTxsBatchStatus.PROCESSING; + + if (!needContinue) { + return; + } + + // check if the vault is still valid + const keyInfo = this.keyRingCosmosService.keyRingService.getKeyInfo( + batch.vaultId + ); + if (!keyInfo) { + throw new KeplrError("direct-tx-executor", 102, "Key info not found"); + } + + const txIndex = Math.min( + batch.txIndex < 0 ? 0 : batch.txIndex, + batch.txs.length - 1 + ); + let nextTxIndex = txIndex; + + const currentTx = batch.txs[txIndex]; + if (!currentTx) { + throw new KeplrError("direct-tx-executor", 103, "Tx not found"); + } + + if (currentTx.status === DirectTxStatus.CANCELLED) { + batch.status = DirectTxsBatchStatus.CANCELLED; + return; + } + + // if the current transaction is in failed/reverted status, + // the execution should be failed and the execution should be stopped + if ( + currentTx.status === DirectTxStatus.FAILED || + currentTx.status === DirectTxStatus.REVERTED + ) { + batch.status = DirectTxsBatchStatus.FAILED; + return; + } + + // if the current transaction is already confirmed, + // should start the next transaction execution + if (currentTx.status === DirectTxStatus.CONFIRMED) { + nextTxIndex = txIndex + 1; + } + + // if tx index is out of range, the execution should be completed + if (nextTxIndex >= batch.txs.length) { + batch.status = DirectTxsBatchStatus.COMPLETED; + // TODO: record swap history if needed + return; + } + + if (batch.status === DirectTxsBatchStatus.PENDING) { + batch.status = DirectTxsBatchStatus.PROCESSING; + } + + for (let i = txIndex; i < batch.txs.length; i++) { + // CHECK: multi tx 케이스인 경우, 연속해서 실행할 수 없는 상황이 발생할 수 있음... + await this.executePendingDirectTx(id, i); + } + } + + protected async executePendingDirectTx( + id: string, + index: number + ): Promise { + const batch = this.getDirectTxsBatch(id); + if (!batch) { + throw new KeplrError("direct-tx-executor", 105, "Execution not found"); + } + + const currentTx = batch.txs[index]; + if (!currentTx) { + throw new KeplrError("direct-tx-executor", 106, "Tx not found"); + } + + if (currentTx.status === DirectTxStatus.CONFIRMED) { + return; + } + + // these statuses are not expected to be reached for pending transactions + if ( + currentTx.status === DirectTxStatus.FAILED || + currentTx.status === DirectTxStatus.REVERTED || + currentTx.status === DirectTxStatus.CANCELLED + ) { + throw new KeplrError( + "direct-tx-executor", + 107, + `Unexpected tx status when executing pending transaction: ${currentTx.status}` + ); + } + + // update the tx index to the current tx index + batch.txIndex = index; + + // 순서대로 signing -> broadcasting -> checking receipt 순으로 진행된다. + + // 1. signing + if ( + currentTx.status === DirectTxStatus.PENDING || + currentTx.status === DirectTxStatus.SIGNING + ) { + if (currentTx.status === DirectTxStatus.SIGNING) { + // check if the transaction is signed + // if not, try sign again... + } else { + // set the transaction status to signing + currentTx.status = DirectTxStatus.SIGNING; + } + + try { + // sign the transaction + + // if success, set the transaction status to signed + currentTx.status = DirectTxStatus.SIGNED; + } catch (error) { + currentTx.status = DirectTxStatus.FAILED; + currentTx.error = error.message; + } + } + + // 2. broadcasting + if ( + currentTx.status === DirectTxStatus.SIGNED || + currentTx.status === DirectTxStatus.BROADCASTING + ) { + if (currentTx.status === DirectTxStatus.BROADCASTING) { + // check if the transaction is broadcasted + // if not, try broadcast again... + } else { + // set the transaction status to broadcasting + currentTx.status = DirectTxStatus.BROADCASTING; + } + + try { + // broadcast the transaction + + // if success, set the transaction status to broadcasted + currentTx.status = DirectTxStatus.BROADCASTED; + } catch (error) { + currentTx.status = DirectTxStatus.FAILED; + currentTx.error = error.message; + } + } + + // 3. checking receipt + if (currentTx.status === DirectTxStatus.BROADCASTED) { + // check if the transaction is confirmed + + try { + const confirmed = true; + + if (confirmed) { + currentTx.status = DirectTxStatus.CONFIRMED; + } else { + currentTx.status = DirectTxStatus.FAILED; + currentTx.error = "Transaction failed"; + } + } catch (error) { + currentTx.status = DirectTxStatus.FAILED; + currentTx.error = error.message; + } + } + + // TODO: record swap history if needed + } + /** - * Execute multiple transactions sequentially - * Execution id is returned if the execution is started successfully - * and the execution will be started automatically after the transactions are recorded. + * Get all recent direct transactions executions */ - async recordAndExecuteDirectTxs( - _env: Env, - _vaultId: string, - _txs: DirectTx[] - ): Promise { - // TODO: implement - throw new Error("Not implemented"); + getRecentDirectTxsBatches(): DirectTxsBatch[] { + return Array.from(this.recentDirectTxsBatchesMap.values()); + } + + /** + * Get execution data by ID + */ + getDirectTxsBatch(id: string): DirectTxsBatch | undefined { + const batch = this.recentDirectTxsBatchesMap.get(id); + if (!batch) { + return undefined; + } + + return batch; } /** * Get execution result by execution id */ - async getDirectTxsExecutionResult( - _id: string - ): Promise { - // TODO: implement - throw new Error("Not implemented"); + getDirectTxsBatchResult(id: string): DirectTxsBatchResult | undefined { + const batch = this.recentDirectTxsBatchesMap.get(id); + if (!batch) { + return undefined; + } + + return { + id: batch.id, + txs: batch.txs.map((tx) => ({ + chainId: tx.chainId, + txHash: tx.txHash, + error: tx.error, + })), + swapHistoryId: batch.swapHistoryId, + }; } /** * Cancel execution by execution id */ - async cancelDirectTxsExecution(_id: string): Promise { - // TODO: implement - throw new Error("Not implemented"); + @action + async cancelDirectTxs(id: string): Promise { + const batch = this.recentDirectTxsBatchesMap.get(id); + if (!batch) { + return; + } + + const currentStatus = batch.status; + + // Only pending or processing executions can be cancelled + if ( + currentStatus !== DirectTxsBatchStatus.PENDING && + currentStatus !== DirectTxsBatchStatus.PROCESSING + ) { + return; + } + + // CHECK: cancellation is really needed? + batch.status = DirectTxsBatchStatus.CANCELLED; + + if (currentStatus === DirectTxsBatchStatus.PROCESSING) { + // TODO: cancel the current transaction execution... + } + } + + @action + protected removeDirectTxsBatch(id: string): void { + this.recentDirectTxsBatchesMap.delete(id); } } diff --git a/packages/background/src/direct-tx-executor/types.ts b/packages/background/src/direct-tx-executor/types.ts index b79369fb3c..660d5cbba3 100644 --- a/packages/background/src/direct-tx-executor/types.ts +++ b/packages/background/src/direct-tx-executor/types.ts @@ -4,11 +4,13 @@ import { StdSignDoc, StdSignature } from "@keplr-wallet/types"; export enum DirectTxStatus { PENDING = "pending", SIGNING = "signing", + SIGNED = "signed", BROADCASTING = "broadcasting", - WAITING = "waiting", - COMPLETED = "completed", - FAILED = "failed", + BROADCASTED = "broadcasted", + CONFIRMED = "confirmed", + REVERTED = "reverted", CANCELLED = "cancelled", + FAILED = "failed", } // Transaction type @@ -47,7 +49,7 @@ export interface DirectTx { error?: string; } -export enum DirectTxsExecutionStatus { +export enum DirectTxsBatchStatus { PENDING = "pending", PROCESSING = "processing", COMPLETED = "completed", @@ -55,16 +57,16 @@ export enum DirectTxsExecutionStatus { CANCELLED = "cancelled", } -export interface DirectTxsExecutionData { +export interface DirectTxsBatch { readonly id: string; - status: DirectTxsExecutionStatus; + status: DirectTxsBatchStatus; // keyring vault id readonly vaultId: string; // transactions readonly txs: DirectTx[]; - currentTxIndex: number; // Current transaction being processed + txIndex: number; // Current transaction being processed // swap history id after record swap history swapHistoryId?: string; @@ -77,12 +79,12 @@ export interface DirectTxsExecutionData { } // Execution result (summary of the execution data) -export interface DirectTxsExecutionResult { +export interface DirectTxsBatchResult { readonly id: string; readonly txs: { chainId: string; txHash?: string; + error?: string; }[]; readonly swapHistoryId?: string; - readonly error?: string; } From 9bf3f1bef2eb5fb54eb0ae6ac0d9b375d3d68f81 Mon Sep 17 00:00:00 2001 From: rowan Date: Wed, 26 Nov 2025 21:54:10 +0900 Subject: [PATCH 05/29] rename directory --- packages/background/src/index.ts | 29 ++++++++++--------- .../constants.ts | 0 .../handler.ts | 0 .../index.ts | 0 .../init.ts | 0 .../internal.ts | 0 .../messages.ts | 0 .../service.ts | 0 .../types.ts | 0 9 files changed, 15 insertions(+), 14 deletions(-) rename packages/background/src/{direct-tx-executor => tx-executor}/constants.ts (100%) rename packages/background/src/{direct-tx-executor => tx-executor}/handler.ts (100%) rename packages/background/src/{direct-tx-executor => tx-executor}/index.ts (100%) rename packages/background/src/{direct-tx-executor => tx-executor}/init.ts (100%) rename packages/background/src/{direct-tx-executor => tx-executor}/internal.ts (100%) rename packages/background/src/{direct-tx-executor => tx-executor}/messages.ts (100%) rename packages/background/src/{direct-tx-executor => tx-executor}/service.ts (100%) rename packages/background/src/{direct-tx-executor => tx-executor}/types.ts (100%) diff --git a/packages/background/src/index.ts b/packages/background/src/index.ts index d01d574ca7..7ebc6722a1 100644 --- a/packages/background/src/index.ts +++ b/packages/background/src/index.ts @@ -31,7 +31,7 @@ import * as RecentSendHistory from "./recent-send-history/internal"; import * as SidePanel from "./side-panel/internal"; import * as Settings from "./settings/internal"; import * as ManageViewAssetToken from "./manage-view-asset-token/internal"; -import * as DirectTxExecutor from "./direct-tx-executor/internal"; +import * as BackgroundTxExecutor from "./tx-executor/internal"; export * from "./chains"; export * from "./chains-ui"; @@ -59,7 +59,7 @@ export * from "./side-panel"; export * from "./settings"; export * from "./manage-view-asset-token"; export * from "./tx-ethereum"; -export * from "./direct-tx-executor"; +export * from "./tx-executor"; import { KVStore } from "@keplr-wallet/common"; import { ChainInfo, ModularChainInfo } from "@keplr-wallet/types"; @@ -314,16 +314,17 @@ export function init( chainsService ); - const directTxExecutorService = new DirectTxExecutor.DirectTxExecutorService( - storeCreator("direct-tx-executor"), - chainsService, - keyRingCosmosService, - keyRingEthereumService, - backgroundTxService, - backgroundTxEthereumService, - analyticsService, - recentSendHistoryService - ); + const backgroundTxExecutorService = + new BackgroundTxExecutor.BackgroundTxExecutorService( + storeCreator("background-tx-executor"), + chainsService, + keyRingCosmosService, + keyRingEthereumService, + backgroundTxService, + backgroundTxEthereumService, + analyticsService, + recentSendHistoryService + ); Interaction.init(router, interactionService); Permission.init(router, permissionService); @@ -380,7 +381,7 @@ export function init( SidePanel.init(router, sidePanelService); Settings.init(router, settingsService); ManageViewAssetToken.init(router, manageViewAssetTokenService); - DirectTxExecutor.init(router, directTxExecutorService); + BackgroundTxExecutor.init(router, backgroundTxExecutorService); return { initFn: async () => { @@ -421,7 +422,7 @@ export function init( await manageViewAssetTokenService.init(); - await directTxExecutorService.init(); + await backgroundTxExecutorService.init(); }, keyRingService: keyRingV2Service, analyticsService: analyticsService, diff --git a/packages/background/src/direct-tx-executor/constants.ts b/packages/background/src/tx-executor/constants.ts similarity index 100% rename from packages/background/src/direct-tx-executor/constants.ts rename to packages/background/src/tx-executor/constants.ts diff --git a/packages/background/src/direct-tx-executor/handler.ts b/packages/background/src/tx-executor/handler.ts similarity index 100% rename from packages/background/src/direct-tx-executor/handler.ts rename to packages/background/src/tx-executor/handler.ts diff --git a/packages/background/src/direct-tx-executor/index.ts b/packages/background/src/tx-executor/index.ts similarity index 100% rename from packages/background/src/direct-tx-executor/index.ts rename to packages/background/src/tx-executor/index.ts diff --git a/packages/background/src/direct-tx-executor/init.ts b/packages/background/src/tx-executor/init.ts similarity index 100% rename from packages/background/src/direct-tx-executor/init.ts rename to packages/background/src/tx-executor/init.ts diff --git a/packages/background/src/direct-tx-executor/internal.ts b/packages/background/src/tx-executor/internal.ts similarity index 100% rename from packages/background/src/direct-tx-executor/internal.ts rename to packages/background/src/tx-executor/internal.ts diff --git a/packages/background/src/direct-tx-executor/messages.ts b/packages/background/src/tx-executor/messages.ts similarity index 100% rename from packages/background/src/direct-tx-executor/messages.ts rename to packages/background/src/tx-executor/messages.ts diff --git a/packages/background/src/direct-tx-executor/service.ts b/packages/background/src/tx-executor/service.ts similarity index 100% rename from packages/background/src/direct-tx-executor/service.ts rename to packages/background/src/tx-executor/service.ts diff --git a/packages/background/src/direct-tx-executor/types.ts b/packages/background/src/tx-executor/types.ts similarity index 100% rename from packages/background/src/direct-tx-executor/types.ts rename to packages/background/src/tx-executor/types.ts From 99638f03b370469ca881208a74050967f573076b Mon Sep 17 00:00:00 2001 From: rowan Date: Wed, 26 Nov 2025 22:03:58 +0900 Subject: [PATCH 06/29] add options to execute presigned tx on background tx executor --- .../background/src/tx-executor/handler.ts | 1 - .../background/src/tx-executor/messages.ts | 13 +++-- .../background/src/tx-executor/service.ts | 53 +++++++++++++------ 3 files changed, 46 insertions(+), 21 deletions(-) diff --git a/packages/background/src/tx-executor/handler.ts b/packages/background/src/tx-executor/handler.ts index 7d9122e2f7..cb6edd60d3 100644 --- a/packages/background/src/tx-executor/handler.ts +++ b/packages/background/src/tx-executor/handler.ts @@ -65,7 +65,6 @@ const handleResumeDirectTxsMsg: ( return await service.resumeDirectTxs( env, msg.id, - msg.vaultId, msg.txIndex, msg.signedTx, msg.signature diff --git a/packages/background/src/tx-executor/messages.ts b/packages/background/src/tx-executor/messages.ts index 04aec053d1..9f06ccb43c 100644 --- a/packages/background/src/tx-executor/messages.ts +++ b/packages/background/src/tx-executor/messages.ts @@ -53,7 +53,6 @@ export class ResumeDirectTxsMsg extends Message { constructor( public readonly id: string, - public readonly vaultId: string, public readonly txIndex: number, // NOTE: these fields are optional for hardware wallet cases public readonly signedTx?: Uint8Array, @@ -66,12 +65,18 @@ export class ResumeDirectTxsMsg extends Message { if (!this.id) { throw new KeplrError("direct-tx-executor", 101, "id is empty"); } - if (!this.vaultId) { - throw new KeplrError("direct-tx-executor", 102, "vaultId is empty"); - } if (!this.txIndex) { throw new KeplrError("direct-tx-executor", 103, "txIndex is empty"); } + + // signedTx and signature should be provided together + if (this.signedTx && !this.signature) { + throw new KeplrError("direct-tx-executor", 104, "signature is empty"); + } + + if (!this.signedTx && this.signature) { + throw new KeplrError("direct-tx-executor", 105, "signedTx is empty"); + } } override approveExternal(): boolean { diff --git a/packages/background/src/tx-executor/service.ts b/packages/background/src/tx-executor/service.ts index b59685d716..2d27730de6 100644 --- a/packages/background/src/tx-executor/service.ts +++ b/packages/background/src/tx-executor/service.ts @@ -131,24 +131,34 @@ export class BackgroundTxExecutorService { * Execute single specific transaction by execution id and transaction index * Tx hash is returned if the transaction is executed successfully */ + @action async resumeDirectTxs( env: Env, - _id: string, - _vaultId: string, - _txIndex: number, - _signedTx?: Uint8Array, - _signature?: Uint8Array + id: string, + txIndex: number, + signedTx?: Uint8Array, + signature?: Uint8Array ): Promise { if (!env.isInternalMsg) { // TODO: 에러 코드 신경쓰기 throw new KeplrError("direct-tx-executor", 101, "Not internal message"); } - // TODO: implement - throw new Error("Not implemented"); + return await this.executeDirectTxs(id, { + txIndex, + signedTx, + signature, + }); } - protected async executeDirectTxs(id: string): Promise { + protected async executeDirectTxs( + id: string, + options?: { + txIndex?: number; + signedTx?: Uint8Array; + signature?: Uint8Array; + } + ): Promise { const batch = this.getDirectTxsBatch(id); if (!batch) { return; @@ -171,13 +181,13 @@ export class BackgroundTxExecutorService { throw new KeplrError("direct-tx-executor", 102, "Key info not found"); } - const txIndex = Math.min( - batch.txIndex < 0 ? 0 : batch.txIndex, + const currentTxIndex = Math.min( + options?.txIndex ?? batch.txIndex < 0 ? 0 : batch.txIndex, batch.txs.length - 1 ); - let nextTxIndex = txIndex; + let nextTxIndex = currentTxIndex; - const currentTx = batch.txs[txIndex]; + const currentTx = batch.txs[currentTxIndex]; if (!currentTx) { throw new KeplrError("direct-tx-executor", 103, "Tx not found"); } @@ -200,7 +210,7 @@ export class BackgroundTxExecutorService { // if the current transaction is already confirmed, // should start the next transaction execution if (currentTx.status === DirectTxStatus.CONFIRMED) { - nextTxIndex = txIndex + 1; + nextTxIndex = currentTxIndex + 1; } // if tx index is out of range, the execution should be completed @@ -214,15 +224,26 @@ export class BackgroundTxExecutorService { batch.status = DirectTxsBatchStatus.PROCESSING; } - for (let i = txIndex; i < batch.txs.length; i++) { + for (let i = nextTxIndex; i < batch.txs.length; i++) { // CHECK: multi tx 케이스인 경우, 연속해서 실행할 수 없는 상황이 발생할 수 있음... - await this.executePendingDirectTx(id, i); + if (options?.txIndex != null && i === options.txIndex) { + await this.executePendingDirectTx(id, i, { + signedTx: options.signedTx, + signature: options.signature, + }); + } else { + await this.executePendingDirectTx(id, i); + } } } protected async executePendingDirectTx( id: string, - index: number + index: number, + _options?: { + signedTx?: Uint8Array; + signature?: Uint8Array; + } ): Promise { const batch = this.getDirectTxsBatch(id); if (!batch) { From 22c5febefc230d5c4e527854b79e13481e35bc08 Mon Sep 17 00:00:00 2001 From: rowan Date: Thu, 27 Nov 2025 11:04:07 +0900 Subject: [PATCH 07/29] stub flow definition for background tx executor --- .../background/src/tx-executor/handler.ts | 35 +- packages/background/src/tx-executor/init.ts | 6 +- .../background/src/tx-executor/messages.ts | 46 +-- .../background/src/tx-executor/service.ts | 309 +++++++++++------- packages/background/src/tx-executor/types.ts | 75 +++-- 5 files changed, 268 insertions(+), 203 deletions(-) diff --git a/packages/background/src/tx-executor/handler.ts b/packages/background/src/tx-executor/handler.ts index cb6edd60d3..b50e905526 100644 --- a/packages/background/src/tx-executor/handler.ts +++ b/packages/background/src/tx-executor/handler.ts @@ -10,8 +10,7 @@ import { RecordAndExecuteDirectTxsMsg, ResumeDirectTxsMsg, CancelDirectTxsMsg, - GetDirectTxsBatchMsg, - GetDirectTxsBatchResultMsg, + GetDirectTxBatchMsg, } from "./messages"; export const getHandler: (service: BackgroundTxExecutorService) => Handler = ( @@ -24,21 +23,16 @@ export const getHandler: (service: BackgroundTxExecutorService) => Handler = ( env, msg as RecordAndExecuteDirectTxsMsg ); - case GetDirectTxsBatchMsg: - return handleGetDirectTxsBatchMsg(service)( + case GetDirectTxBatchMsg: + return handleGetDirectTxBatchMsg(service)( env, - msg as GetDirectTxsBatchMsg + msg as GetDirectTxBatchMsg ); case ResumeDirectTxsMsg: return handleResumeDirectTxsMsg(service)( env, msg as ResumeDirectTxsMsg ); - case GetDirectTxsBatchResultMsg: - return handleGetDirectTxsBatchResultMsg(service)( - env, - msg as GetDirectTxsBatchResultMsg - ); case CancelDirectTxsMsg: return handleCancelDirectTxsMsg(service)( env, @@ -54,7 +48,12 @@ const handleRecordAndExecuteDirectTxsMsg: ( service: BackgroundTxExecutorService ) => InternalHandler = (service) => { return (env, msg) => { - return service.recordAndExecuteDirectTxs(env, msg.vaultId, msg.txs); + return service.recordAndExecuteDirectTxs( + env, + msg.vaultId, + msg.batchType, + msg.txs + ); }; }; @@ -72,19 +71,11 @@ const handleResumeDirectTxsMsg: ( }; }; -const handleGetDirectTxsBatchMsg: ( - service: BackgroundTxExecutorService -) => InternalHandler = (service) => { - return (_env, msg) => { - return service.getDirectTxsBatch(msg.id); - }; -}; - -const handleGetDirectTxsBatchResultMsg: ( +const handleGetDirectTxBatchMsg: ( service: BackgroundTxExecutorService -) => InternalHandler = (service) => { +) => InternalHandler = (service) => { return (_env, msg) => { - return service.getDirectTxsBatchResult(msg.id); + return service.getDirectTxBatch(msg.id); }; }; diff --git a/packages/background/src/tx-executor/init.ts b/packages/background/src/tx-executor/init.ts index 5cd09e51da..e857358c82 100644 --- a/packages/background/src/tx-executor/init.ts +++ b/packages/background/src/tx-executor/init.ts @@ -6,8 +6,7 @@ import { RecordAndExecuteDirectTxsMsg, ResumeDirectTxsMsg, CancelDirectTxsMsg, - GetDirectTxsBatchMsg, - GetDirectTxsBatchResultMsg, + GetDirectTxBatchMsg, } from "./messages"; export function init( @@ -16,8 +15,7 @@ export function init( ): void { router.registerMessage(RecordAndExecuteDirectTxsMsg); router.registerMessage(ResumeDirectTxsMsg); - router.registerMessage(GetDirectTxsBatchMsg); - router.registerMessage(GetDirectTxsBatchResultMsg); + router.registerMessage(GetDirectTxBatchMsg); router.registerMessage(CancelDirectTxsMsg); router.addHandler(ROUTE, getHandler(service)); diff --git a/packages/background/src/tx-executor/messages.ts b/packages/background/src/tx-executor/messages.ts index 9f06ccb43c..9f038fa1dc 100644 --- a/packages/background/src/tx-executor/messages.ts +++ b/packages/background/src/tx-executor/messages.ts @@ -1,6 +1,6 @@ import { KeplrError, Message } from "@keplr-wallet/router"; import { ROUTE } from "./constants"; -import { DirectTxsBatch, DirectTxsBatchResult, DirectTx } from "./types"; +import { DirectTxBatch, DirectTx, DirectTxBatchType } from "./types"; /** * Record and execute multiple transactions @@ -15,6 +15,7 @@ export class RecordAndExecuteDirectTxsMsg extends Message { //TODO: add history data... constructor( public readonly vaultId: string, + public readonly batchType: DirectTxBatchType, public readonly txs: DirectTx[] ) { super(); @@ -24,6 +25,11 @@ export class RecordAndExecuteDirectTxsMsg extends Message { if (!this.vaultId) { throw new KeplrError("direct-tx-executor", 101, "vaultId is empty"); } + + if (!this.batchType) { + throw new KeplrError("direct-tx-executor", 102, "batchType is empty"); + } + if (!this.txs || this.txs.length === 0) { throw new KeplrError("direct-tx-executor", 102, "txs is empty"); } @@ -95,43 +101,11 @@ export class ResumeDirectTxsMsg extends Message { /** * Get execution data by execution id */ -export class GetDirectTxsBatchMsg extends Message { +export class GetDirectTxBatchMsg extends Message { public static type() { - return "get-direct-txs-batch"; - } - - constructor(public readonly id: string) { - super(); + return "get-direct-tx-batch"; } - validateBasic(): void { - if (!this.id) { - throw new KeplrError("direct-tx-executor", 101, "id is empty"); - } - } - - override approveExternal(): boolean { - return false; - } - - route(): string { - return ROUTE; - } - - type(): string { - return GetDirectTxsBatchMsg.type(); - } -} - -/** - * Get execution result by execution id - */ -export class GetDirectTxsBatchResultMsg extends Message< - DirectTxsBatchResult | undefined -> { - public static type() { - return "get-direct-txs-batch-result"; - } constructor(public readonly id: string) { super(); } @@ -151,7 +125,7 @@ export class GetDirectTxsBatchResultMsg extends Message< } type(): string { - return GetDirectTxsBatchResultMsg.type(); + return GetDirectTxBatchMsg.type(); } } diff --git a/packages/background/src/tx-executor/service.ts b/packages/background/src/tx-executor/service.ts index 2d27730de6..2ae00955cf 100644 --- a/packages/background/src/tx-executor/service.ts +++ b/packages/background/src/tx-executor/service.ts @@ -8,11 +8,12 @@ import { BackgroundTxService } from "../tx"; import { BackgroundTxEthereumService } from "../tx-ethereum"; import { Env, KeplrError } from "@keplr-wallet/router"; import { - DirectTxsBatch, - DirectTxsBatchResult, + DirectTxBatch, DirectTx, - DirectTxsBatchStatus, + DirectTxBatchStatus, DirectTxStatus, + DirectTxBatchType, + DirectTxBatchBase, } from "./types"; import { action, @@ -23,13 +24,12 @@ import { toJS, } from "mobx"; -// TODO: implement this service export class BackgroundTxExecutorService { @observable - protected recentDirectTxsBatchesSeq: number = 0; + protected recentDirectTxBatchSeq: number = 0; // Key: id (sequence, it should be increased by 1 for each) @observable - protected readonly recentDirectTxsBatchesMap: Map = + protected readonly recentDirectTxBatchMap: Map = new Map(); constructor( @@ -46,38 +46,38 @@ export class BackgroundTxExecutorService { } async init(): Promise { - const recentDirectTxsBatchesSeqSaved = await this.kvStore.get( - "recentDirectTxsBatchesSeq" + const recentDirectTxBatchSeqSaved = await this.kvStore.get( + "recentDirectTxBatchSeq" ); - if (recentDirectTxsBatchesSeqSaved) { + if (recentDirectTxBatchSeqSaved) { runInAction(() => { - this.recentDirectTxsBatchesSeq = recentDirectTxsBatchesSeqSaved; + this.recentDirectTxBatchSeq = recentDirectTxBatchSeqSaved; }); } autorun(() => { - const js = toJS(this.recentDirectTxsBatchesSeq); - this.kvStore.set("recentDirectTxsBatchesSeq", js); + const js = toJS(this.recentDirectTxBatchSeq); + this.kvStore.set("recentDirectTxBatchSeq", js); }); - const recentDirectTxsBatchesMapSaved = await this.kvStore.get< - Record - >("recentDirectTxsBatchesMap"); - if (recentDirectTxsBatchesMapSaved) { + const recentDirectTxBatchMapSaved = await this.kvStore.get< + Record + >("recentDirectTxBatchMap"); + if (recentDirectTxBatchMapSaved) { runInAction(() => { - let entries = Object.entries(recentDirectTxsBatchesMapSaved); + let entries = Object.entries(recentDirectTxBatchMapSaved); entries = entries.sort(([, a], [, b]) => { return parseInt(a.id) - parseInt(b.id); }); for (const [key, value] of entries) { - this.recentDirectTxsBatchesMap.set(key, value); + this.recentDirectTxBatchMap.set(key, value); } }); } autorun(() => { - const js = toJS(this.recentDirectTxsBatchesMap); + const js = toJS(this.recentDirectTxBatchMap); const obj = Object.fromEntries(js); - this.kvStore.set>( - "recentDirectTxsBatchesMap", + this.kvStore.set>( + "recentDirectTxBatchMap", obj ); }); @@ -103,17 +103,18 @@ export class BackgroundTxExecutorService { recordAndExecuteDirectTxs( env: Env, vaultId: string, + type: DirectTxBatchType, txs: DirectTx[] ): string { if (!env.isInternalMsg) { throw new KeplrError("direct-tx-executor", 101, "Not internal message"); } - const id = (this.recentDirectTxsBatchesSeq++).toString(); + const id = (this.recentDirectTxBatchSeq++).toString(); - const batch: DirectTxsBatch = { + const batchBase: DirectTxBatchBase = { id, - status: DirectTxsBatchStatus.PENDING, + status: DirectTxBatchStatus.PENDING, vaultId: vaultId, txs: txs, txIndex: -1, @@ -121,14 +122,40 @@ export class BackgroundTxExecutorService { // TODO: add swap history data... }; - this.recentDirectTxsBatchesMap.set(id, batch); + let batch: DirectTxBatch; + if (type === DirectTxBatchType.SWAP_V2) { + batch = { + ...batchBase, + type: DirectTxBatchType.SWAP_V2, + // TODO: add swap history data... + swapHistoryData: { + chainId: txs[0].chainId, + }, + }; + } else if (type === DirectTxBatchType.IBC_TRANSFER) { + batch = { + ...batchBase, + type: DirectTxBatchType.IBC_TRANSFER, + // TODO: add ibc history data... + ibcHistoryData: { + chainId: txs[0].chainId, + }, + }; + } else { + batch = { + ...batchBase, + type: DirectTxBatchType.UNDEFINED, + }; + } + + this.recentDirectTxBatchMap.set(id, batch); this.executeDirectTxs(id); return id; } /** - * Execute single specific transaction by execution id and transaction index + * Execute paused direct transactions by execution id and transaction index * Tx hash is returned if the transaction is executed successfully */ @action @@ -159,17 +186,17 @@ export class BackgroundTxExecutorService { signature?: Uint8Array; } ): Promise { - const batch = this.getDirectTxsBatch(id); + const batch = this.getDirectTxBatch(id); if (!batch) { return; } - // Only pending or processing executions can be executed - const needContinue = - batch.status === DirectTxsBatchStatus.PENDING || - batch.status === DirectTxsBatchStatus.PROCESSING; - - if (!needContinue) { + // Only pending/processing/blocked executions can be executed + const needResume = + batch.status === DirectTxBatchStatus.PENDING || + batch.status === DirectTxBatchStatus.PROCESSING || + batch.status === DirectTxBatchStatus.BLOCKED; + if (!needResume) { return; } @@ -193,17 +220,14 @@ export class BackgroundTxExecutorService { } if (currentTx.status === DirectTxStatus.CANCELLED) { - batch.status = DirectTxsBatchStatus.CANCELLED; + batch.status = DirectTxBatchStatus.CANCELLED; return; } - // if the current transaction is in failed/reverted status, + // if the current transaction is in failed status, // the execution should be failed and the execution should be stopped - if ( - currentTx.status === DirectTxStatus.FAILED || - currentTx.status === DirectTxStatus.REVERTED - ) { - batch.status = DirectTxsBatchStatus.FAILED; + if (currentTx.status === DirectTxStatus.FAILED) { + batch.status = DirectTxBatchStatus.FAILED; return; } @@ -215,24 +239,46 @@ export class BackgroundTxExecutorService { // if tx index is out of range, the execution should be completed if (nextTxIndex >= batch.txs.length) { - batch.status = DirectTxsBatchStatus.COMPLETED; - // TODO: record swap history if needed + batch.status = DirectTxBatchStatus.COMPLETED; + this.recordHistoryIfNeeded(batch); return; } - if (batch.status === DirectTxsBatchStatus.PENDING) { - batch.status = DirectTxsBatchStatus.PROCESSING; + if ( + batch.status === DirectTxBatchStatus.PENDING || + batch.status === DirectTxBatchStatus.BLOCKED + ) { + batch.status = DirectTxBatchStatus.PROCESSING; } for (let i = nextTxIndex; i < batch.txs.length; i++) { - // CHECK: multi tx 케이스인 경우, 연속해서 실행할 수 없는 상황이 발생할 수 있음... + let txStatus: DirectTxStatus; + if (options?.txIndex != null && i === options.txIndex) { - await this.executePendingDirectTx(id, i, { + txStatus = await this.executePendingDirectTx(id, i, { signedTx: options.signedTx, signature: options.signature, }); } else { - await this.executePendingDirectTx(id, i); + txStatus = await this.executePendingDirectTx(id, i); + } + + // if the tx is blocked, the execution should be stopped + // and the execution should be resumed later when the condition is met + if (txStatus === DirectTxStatus.BLOCKED) { + batch.status = DirectTxBatchStatus.BLOCKED; + return; + } + + // if the tx is failed, the execution should be stopped + if (txStatus === DirectTxStatus.FAILED) { + batch.status = DirectTxBatchStatus.FAILED; + return; + } + + // something went wrong... + if (txStatus !== DirectTxStatus.CONFIRMED) { + throw new KeplrError("direct-tx-executor", 107, "Unexpected tx status"); } } } @@ -240,12 +286,12 @@ export class BackgroundTxExecutorService { protected async executePendingDirectTx( id: string, index: number, - _options?: { + options?: { signedTx?: Uint8Array; signature?: Uint8Array; } - ): Promise { - const batch = this.getDirectTxsBatch(id); + ): Promise { + const batch = this.getDirectTxBatch(id); if (!batch) { throw new KeplrError("direct-tx-executor", 105, "Execution not found"); } @@ -255,69 +301,79 @@ export class BackgroundTxExecutorService { throw new KeplrError("direct-tx-executor", 106, "Tx not found"); } - if (currentTx.status === DirectTxStatus.CONFIRMED) { - return; - } - // these statuses are not expected to be reached for pending transactions if ( + currentTx.status === DirectTxStatus.CONFIRMED || currentTx.status === DirectTxStatus.FAILED || - currentTx.status === DirectTxStatus.REVERTED || currentTx.status === DirectTxStatus.CANCELLED ) { - throw new KeplrError( - "direct-tx-executor", - 107, - `Unexpected tx status when executing pending transaction: ${currentTx.status}` - ); + return currentTx.status; } // update the tx index to the current tx index batch.txIndex = index; - // 순서대로 signing -> broadcasting -> checking receipt 순으로 진행된다. - - // 1. signing if ( - currentTx.status === DirectTxStatus.PENDING || - currentTx.status === DirectTxStatus.SIGNING + currentTx.status === DirectTxStatus.BLOCKED || + currentTx.status === DirectTxStatus.PENDING ) { - if (currentTx.status === DirectTxStatus.SIGNING) { - // check if the transaction is signed - // if not, try sign again... + // TODO: check if the condition is met to resume the execution + // this will be handled with recent send history tracking to check if the condition is met to resume the execution + // check if the current transaction's chainId is included in the chainIds of the recent send history (might enough with this) + if (this.checkIfTxIsBlocked(batch, currentTx.chainId)) { + currentTx.status = DirectTxStatus.BLOCKED; + return currentTx.status; } else { - // set the transaction status to signing currentTx.status = DirectTxStatus.SIGNING; } + } - try { - // sign the transaction + if (currentTx.status === DirectTxStatus.SIGNING) { + // if options are provided, temporary set the options to the current transaction + if (options?.signedTx && options.signature) { + currentTx.signedTx = options.signedTx; + currentTx.signature = options.signature; + } - // if success, set the transaction status to signed + // check if the transaction is signed + if (this.checkIfTxIsSigned(currentTx)) { + // if already signed, signing -> signed currentTx.status = DirectTxStatus.SIGNED; - } catch (error) { - currentTx.status = DirectTxStatus.FAILED; - currentTx.error = error.message; + } + + // if not signed, try sign + if (currentTx.status === DirectTxStatus.SIGNING) { + try { + const { signedTx, signature } = await this.signTx(currentTx); + + currentTx.signedTx = signedTx; + currentTx.signature = signature; + currentTx.status = DirectTxStatus.SIGNED; + } catch (error) { + currentTx.status = DirectTxStatus.FAILED; + currentTx.error = error.message; + } } } - // 2. broadcasting if ( currentTx.status === DirectTxStatus.SIGNED || currentTx.status === DirectTxStatus.BROADCASTING ) { if (currentTx.status === DirectTxStatus.BROADCASTING) { // check if the transaction is broadcasted - // if not, try broadcast again... + if (this.checkIfTxIsBroadcasted(currentTx)) { + currentTx.status = DirectTxStatus.BROADCASTED; + } } else { // set the transaction status to broadcasting currentTx.status = DirectTxStatus.BROADCASTING; } try { - // broadcast the transaction + const { txHash } = await this.broadcastTx(currentTx); - // if success, set the transaction status to broadcasted + currentTx.txHash = txHash; currentTx.status = DirectTxStatus.BROADCASTED; } catch (error) { currentTx.status = DirectTxStatus.FAILED; @@ -325,13 +381,11 @@ export class BackgroundTxExecutorService { } } - // 3. checking receipt if (currentTx.status === DirectTxStatus.BROADCASTED) { - // check if the transaction is confirmed + // broadcasted -> confirmed try { - const confirmed = true; - + const confirmed = await this.checkIfTxIsConfirmed(currentTx); if (confirmed) { currentTx.status = DirectTxStatus.CONFIRMED; } else { @@ -344,21 +398,66 @@ export class BackgroundTxExecutorService { } } - // TODO: record swap history if needed + if (currentTx.status === DirectTxStatus.CONFIRMED) { + this.recordHistoryIfNeeded(batch); + } + + return currentTx.status; + } + + protected checkIfTxIsBlocked( + _batch: DirectTxBatch, + _chainId: string + ): boolean { + return false; + } + + protected checkIfTxIsSigned(_tx: DirectTx): boolean { + return true; + } + + protected checkIfTxIsBroadcasted(_tx: DirectTx): boolean { + return true; + } + + protected async signTx(_tx: DirectTx): Promise<{ + signedTx: Uint8Array; + signature: Uint8Array; + }> { + return { + signedTx: new Uint8Array(), + signature: new Uint8Array(), + }; + } + + protected async broadcastTx(_tx: DirectTx): Promise<{ + txHash: string; + }> { + return { + txHash: "", + }; + } + + protected async checkIfTxIsConfirmed(_tx: DirectTx): Promise { + return true; + } + + protected recordHistoryIfNeeded(_batch: DirectTxBatch): void { + throw new Error("Not implemented"); } /** * Get all recent direct transactions executions */ - getRecentDirectTxsBatches(): DirectTxsBatch[] { - return Array.from(this.recentDirectTxsBatchesMap.values()); + getRecentDirectTxBatches(): DirectTxBatch[] { + return Array.from(this.recentDirectTxBatchMap.values()); } /** * Get execution data by ID */ - getDirectTxsBatch(id: string): DirectTxsBatch | undefined { - const batch = this.recentDirectTxsBatchesMap.get(id); + getDirectTxBatch(id: string): DirectTxBatch | undefined { + const batch = this.recentDirectTxBatchMap.get(id); if (!batch) { return undefined; } @@ -366,32 +465,12 @@ export class BackgroundTxExecutorService { return batch; } - /** - * Get execution result by execution id - */ - getDirectTxsBatchResult(id: string): DirectTxsBatchResult | undefined { - const batch = this.recentDirectTxsBatchesMap.get(id); - if (!batch) { - return undefined; - } - - return { - id: batch.id, - txs: batch.txs.map((tx) => ({ - chainId: tx.chainId, - txHash: tx.txHash, - error: tx.error, - })), - swapHistoryId: batch.swapHistoryId, - }; - } - /** * Cancel execution by execution id */ @action async cancelDirectTxs(id: string): Promise { - const batch = this.recentDirectTxsBatchesMap.get(id); + const batch = this.recentDirectTxBatchMap.get(id); if (!batch) { return; } @@ -400,22 +479,22 @@ export class BackgroundTxExecutorService { // Only pending or processing executions can be cancelled if ( - currentStatus !== DirectTxsBatchStatus.PENDING && - currentStatus !== DirectTxsBatchStatus.PROCESSING + currentStatus !== DirectTxBatchStatus.PENDING && + currentStatus !== DirectTxBatchStatus.PROCESSING ) { return; } // CHECK: cancellation is really needed? - batch.status = DirectTxsBatchStatus.CANCELLED; + batch.status = DirectTxBatchStatus.CANCELLED; - if (currentStatus === DirectTxsBatchStatus.PROCESSING) { + if (currentStatus === DirectTxBatchStatus.PROCESSING) { // TODO: cancel the current transaction execution... } } @action - protected removeDirectTxsBatch(id: string): void { - this.recentDirectTxsBatchesMap.delete(id); + protected removeDirectTxBatch(id: string): void { + this.recentDirectTxBatchMap.delete(id); } } diff --git a/packages/background/src/tx-executor/types.ts b/packages/background/src/tx-executor/types.ts index 660d5cbba3..746ebba5bd 100644 --- a/packages/background/src/tx-executor/types.ts +++ b/packages/background/src/tx-executor/types.ts @@ -8,9 +8,9 @@ export enum DirectTxStatus { BROADCASTING = "broadcasting", BROADCASTED = "broadcasted", CONFIRMED = "confirmed", - REVERTED = "reverted", - CANCELLED = "cancelled", FAILED = "failed", + CANCELLED = "cancelled", + BLOCKED = "blocked", } // Transaction type @@ -35,12 +35,14 @@ export type DirectTxData = | EvmTxData // EVM transaction data | CosmosTxData; // Cosmos transaction construction data -// Single transaction data -export interface DirectTx { - readonly type: DirectTxType; +// Base transaction interface +interface DirectTxBase { status: DirectTxStatus; // mutable while executing readonly chainId: string; - readonly txData: DirectTxData; + + // signed transaction data + signedTx?: Uint8Array; + signature?: Uint8Array; // Transaction hash for completed tx txHash?: string; @@ -49,17 +51,35 @@ export interface DirectTx { error?: string; } -export enum DirectTxsBatchStatus { +// Single transaction data with discriminated union based on type +export type DirectTx = + | (DirectTxBase & { + readonly type: DirectTxType.EVM; + readonly txData: EvmTxData; + }) + | (DirectTxBase & { + readonly type: DirectTxType.COSMOS; + readonly txData: CosmosTxData; + }); + +export enum DirectTxBatchStatus { PENDING = "pending", PROCESSING = "processing", + BLOCKED = "blocked", COMPLETED = "completed", FAILED = "failed", CANCELLED = "cancelled", } -export interface DirectTxsBatch { +export enum DirectTxBatchType { + UNDEFINED = "undefined", + IBC_TRANSFER = "ibc-transfer", + SWAP_V2 = "swap-v2", +} + +export interface DirectTxBatchBase { readonly id: string; - status: DirectTxsBatchStatus; + status: DirectTxBatchStatus; // keyring vault id readonly vaultId: string; @@ -68,23 +88,26 @@ export interface DirectTxsBatch { readonly txs: DirectTx[]; txIndex: number; // Current transaction being processed - // swap history id after record swap history - swapHistoryId?: string; - // TODO: add more required fields for swap history data - readonly swapHistoryData?: { - readonly chainId: string; - }; - readonly timestamp: number; // Timestamp when execution started } -// Execution result (summary of the execution data) -export interface DirectTxsBatchResult { - readonly id: string; - readonly txs: { - chainId: string; - txHash?: string; - error?: string; - }[]; - readonly swapHistoryId?: string; -} +export type DirectTxBatch = + | (DirectTxBatchBase & { + readonly type: DirectTxBatchType.UNDEFINED; + }) + | (DirectTxBatchBase & { + readonly type: DirectTxBatchType.SWAP_V2; + swapHistoryId?: string; + // TODO: add more required fields for swap history data + readonly swapHistoryData: { + readonly chainId: string; + }; + }) + | (DirectTxBatchBase & { + readonly type: DirectTxBatchType.IBC_TRANSFER; + readonly ibcHistoryId?: string; + // TODO: add more required fields for ibc history data + readonly ibcHistoryData: { + readonly chainId: string; + }; + }); From 572279445e63a923a76acd3dc3f26c921b438ec7 Mon Sep 17 00:00:00 2001 From: rowan Date: Thu, 27 Nov 2025 15:16:31 +0900 Subject: [PATCH 08/29] implement tx broadcasting and tracing logic on bg tx executor --- .../background/src/tx-ethereum/service.ts | 57 ++++ .../background/src/tx-executor/handler.ts | 3 +- .../background/src/tx-executor/messages.ts | 16 +- .../background/src/tx-executor/service.ts | 295 ++++++++++++++---- packages/background/src/tx-executor/types.ts | 22 +- packages/background/src/tx/service.ts | 41 +++ 6 files changed, 357 insertions(+), 77 deletions(-) diff --git a/packages/background/src/tx-ethereum/service.ts b/packages/background/src/tx-ethereum/service.ts index d281816b4d..943427f521 100644 --- a/packages/background/src/tx-ethereum/service.ts +++ b/packages/background/src/tx-ethereum/service.ts @@ -20,6 +20,7 @@ export class BackgroundTxEthereumService { tx: Uint8Array, options: { silent?: boolean; + skipTracingTxResult?: boolean; onFulfill?: (txReceipt: EthTxReceipt) => void; } ): Promise { @@ -64,6 +65,10 @@ export class BackgroundTxEthereumService { ); } + if (options.skipTracingTxResult) { + return txHash; + } + retry( () => { return new Promise(async (resolve, reject) => { @@ -121,6 +126,58 @@ export class BackgroundTxEthereumService { } } + async getEthereumTxReceipt( + origin: string, + chainId: string, + txHash: string + ): Promise { + const chainInfo = this.chainsService.getChainInfoOrThrow(chainId); + const evmInfo = ChainsService.getEVMInfo(chainInfo); + if (!evmInfo) { + return null; + } + + return await retry( + () => { + return new Promise(async (resolve, reject) => { + const txReceiptResponse = await simpleFetch<{ + result: EthTxReceipt | null; + error?: Error; + }>(evmInfo.rpc, { + method: "POST", + headers: { + "content-type": "application/json", + "request-source": origin, + }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "eth_getTransactionReceipt", + params: [txHash], + id: 1, + }), + }); + + if (txReceiptResponse.data.error) { + console.error(txReceiptResponse.data.error); + resolve(null); + } + + const txReceipt = txReceiptResponse.data.result; + if (txReceipt) { + resolve(txReceipt); + } + + reject(new Error("No tx receipt responded")); + }); + }, + { + maxRetries: 50, + waitMsAfterError: 500, + maxWaitMsAfterError: 15000, + } + ); + } + private static processTxResultNotification(notification: Notification): void { try { notification.create({ diff --git a/packages/background/src/tx-executor/handler.ts b/packages/background/src/tx-executor/handler.ts index b50e905526..5cf8d3aa95 100644 --- a/packages/background/src/tx-executor/handler.ts +++ b/packages/background/src/tx-executor/handler.ts @@ -52,7 +52,8 @@ const handleRecordAndExecuteDirectTxsMsg: ( env, msg.vaultId, msg.batchType, - msg.txs + msg.txs, + msg.executableChainIds ); }; }; diff --git a/packages/background/src/tx-executor/messages.ts b/packages/background/src/tx-executor/messages.ts index 9f038fa1dc..00191d3d08 100644 --- a/packages/background/src/tx-executor/messages.ts +++ b/packages/background/src/tx-executor/messages.ts @@ -16,7 +16,8 @@ export class RecordAndExecuteDirectTxsMsg extends Message { constructor( public readonly vaultId: string, public readonly batchType: DirectTxBatchType, - public readonly txs: DirectTx[] + public readonly txs: DirectTx[], + public readonly executableChainIds: string[] ) { super(); } @@ -33,6 +34,14 @@ export class RecordAndExecuteDirectTxsMsg extends Message { if (!this.txs || this.txs.length === 0) { throw new KeplrError("direct-tx-executor", 102, "txs is empty"); } + + if (!this.executableChainIds || this.executableChainIds.length === 0) { + throw new KeplrError( + "direct-tx-executor", + 103, + "executableChainIds is empty" + ); + } } override approveExternal(): boolean { @@ -71,8 +80,9 @@ export class ResumeDirectTxsMsg extends Message { if (!this.id) { throw new KeplrError("direct-tx-executor", 101, "id is empty"); } - if (!this.txIndex) { - throw new KeplrError("direct-tx-executor", 103, "txIndex is empty"); + + if (this.txIndex == null || this.txIndex < 0) { + throw new KeplrError("direct-tx-executor", 103, "txIndex is invalid"); } // signedTx and signature should be provided together diff --git a/packages/background/src/tx-executor/service.ts b/packages/background/src/tx-executor/service.ts index 2ae00955cf..b1c905b14b 100644 --- a/packages/background/src/tx-executor/service.ts +++ b/packages/background/src/tx-executor/service.ts @@ -14,6 +14,9 @@ import { DirectTxStatus, DirectTxBatchType, DirectTxBatchBase, + DirectTxType, + EVMDirectTx, + CosmosDirectTx, } from "./types"; import { action, @@ -23,6 +26,7 @@ import { runInAction, toJS, } from "mobx"; +import { EthTxStatus } from "@keplr-wallet/types"; export class BackgroundTxExecutorService { @observable @@ -104,7 +108,8 @@ export class BackgroundTxExecutorService { env: Env, vaultId: string, type: DirectTxBatchType, - txs: DirectTx[] + txs: DirectTx[], + executableChainIds: string[] ): string { if (!env.isInternalMsg) { throw new KeplrError("direct-tx-executor", 101, "Not internal message"); @@ -118,8 +123,8 @@ export class BackgroundTxExecutorService { vaultId: vaultId, txs: txs, txIndex: -1, + executableChainIds: executableChainIds, timestamp: Date.now(), - // TODO: add swap history data... }; let batch: DirectTxBatch; @@ -155,8 +160,7 @@ export class BackgroundTxExecutorService { } /** - * Execute paused direct transactions by execution id and transaction index - * Tx hash is returned if the transaction is executed successfully + * Execute blocked transactions by execution id and transaction index */ @action async resumeDirectTxs( @@ -172,6 +176,7 @@ export class BackgroundTxExecutorService { } return await this.executeDirectTxs(id, { + env, txIndex, signedTx, signature, @@ -181,6 +186,7 @@ export class BackgroundTxExecutorService { protected async executeDirectTxs( id: string, options?: { + env?: Env; txIndex?: number; signedTx?: Uint8Array; signature?: Uint8Array; @@ -200,7 +206,7 @@ export class BackgroundTxExecutorService { return; } - // check if the vault is still valid + // check if the key is valid const keyInfo = this.keyRingCosmosService.keyRingService.getKeyInfo( batch.vaultId ); @@ -208,41 +214,10 @@ export class BackgroundTxExecutorService { throw new KeplrError("direct-tx-executor", 102, "Key info not found"); } - const currentTxIndex = Math.min( + const executionStartIndex = Math.min( options?.txIndex ?? batch.txIndex < 0 ? 0 : batch.txIndex, batch.txs.length - 1 ); - let nextTxIndex = currentTxIndex; - - const currentTx = batch.txs[currentTxIndex]; - if (!currentTx) { - throw new KeplrError("direct-tx-executor", 103, "Tx not found"); - } - - if (currentTx.status === DirectTxStatus.CANCELLED) { - batch.status = DirectTxBatchStatus.CANCELLED; - return; - } - - // if the current transaction is in failed status, - // the execution should be failed and the execution should be stopped - if (currentTx.status === DirectTxStatus.FAILED) { - batch.status = DirectTxBatchStatus.FAILED; - return; - } - - // if the current transaction is already confirmed, - // should start the next transaction execution - if (currentTx.status === DirectTxStatus.CONFIRMED) { - nextTxIndex = currentTxIndex + 1; - } - - // if tx index is out of range, the execution should be completed - if (nextTxIndex >= batch.txs.length) { - batch.status = DirectTxBatchStatus.COMPLETED; - this.recordHistoryIfNeeded(batch); - return; - } if ( batch.status === DirectTxBatchStatus.PENDING || @@ -251,22 +226,31 @@ export class BackgroundTxExecutorService { batch.status = DirectTxBatchStatus.PROCESSING; } - for (let i = nextTxIndex; i < batch.txs.length; i++) { + for (let i = executionStartIndex; i < batch.txs.length; i++) { let txStatus: DirectTxStatus; if (options?.txIndex != null && i === options.txIndex) { txStatus = await this.executePendingDirectTx(id, i, { + env: options?.env, signedTx: options.signedTx, signature: options.signature, }); } else { - txStatus = await this.executePendingDirectTx(id, i); + txStatus = await this.executePendingDirectTx(id, i, { + env: options?.env, + }); + } + + if (txStatus === DirectTxStatus.CONFIRMED) { + continue; } - // if the tx is blocked, the execution should be stopped + // if the tx is blocked, it means multiple transactions are required to be executed on different chains + // the execution should be stopped and record the history if needed // and the execution should be resumed later when the condition is met if (txStatus === DirectTxStatus.BLOCKED) { batch.status = DirectTxBatchStatus.BLOCKED; + this.recordHistoryIfNeeded(batch); return; } @@ -276,17 +260,24 @@ export class BackgroundTxExecutorService { return; } - // something went wrong... - if (txStatus !== DirectTxStatus.CONFIRMED) { - throw new KeplrError("direct-tx-executor", 107, "Unexpected tx status"); - } + // something went wrong, should not happen + throw new KeplrError( + "direct-tx-executor", + 107, + "Unexpected tx status: " + txStatus + ); } + + // if the execution is completed successfully, update the batch status + batch.status = DirectTxBatchStatus.COMPLETED; + this.recordHistoryIfNeeded(batch); } protected async executePendingDirectTx( id: string, index: number, options?: { + env?: Env; signedTx?: Uint8Array; signature?: Uint8Array; } @@ -320,7 +311,9 @@ export class BackgroundTxExecutorService { // TODO: check if the condition is met to resume the execution // this will be handled with recent send history tracking to check if the condition is met to resume the execution // check if the current transaction's chainId is included in the chainIds of the recent send history (might enough with this) - if (this.checkIfTxIsBlocked(batch, currentTx.chainId)) { + if ( + this.checkIfTxIsBlocked(batch.executableChainIds, currentTx.chainId) + ) { currentTx.status = DirectTxStatus.BLOCKED; return currentTx.status; } else { @@ -344,14 +337,19 @@ export class BackgroundTxExecutorService { // if not signed, try sign if (currentTx.status === DirectTxStatus.SIGNING) { try { - const { signedTx, signature } = await this.signTx(currentTx); + const { signedTx, signature } = await this.signTx( + batch.vaultId, + currentTx.chainId, + currentTx, + options?.env + ); currentTx.signedTx = signedTx; currentTx.signature = signature; currentTx.status = DirectTxStatus.SIGNED; } catch (error) { currentTx.status = DirectTxStatus.FAILED; - currentTx.error = error.message; + currentTx.error = error.message ?? "Transaction signing failed"; } } } @@ -377,13 +375,12 @@ export class BackgroundTxExecutorService { currentTx.status = DirectTxStatus.BROADCASTED; } catch (error) { currentTx.status = DirectTxStatus.FAILED; - currentTx.error = error.message; + currentTx.error = error.message ?? "Transaction broadcasting failed"; } } if (currentTx.status === DirectTxStatus.BROADCASTED) { // broadcasted -> confirmed - try { const confirmed = await this.checkIfTxIsConfirmed(currentTx); if (confirmed) { @@ -394,52 +391,222 @@ export class BackgroundTxExecutorService { } } catch (error) { currentTx.status = DirectTxStatus.FAILED; - currentTx.error = error.message; + currentTx.error = error.message ?? "Transaction confirmation failed"; } } - if (currentTx.status === DirectTxStatus.CONFIRMED) { - this.recordHistoryIfNeeded(batch); - } - return currentTx.status; } protected checkIfTxIsBlocked( - _batch: DirectTxBatch, - _chainId: string + executableChainIds: string[], + chainId: string ): boolean { - return false; + return !executableChainIds.includes(chainId); } - protected checkIfTxIsSigned(_tx: DirectTx): boolean { + protected checkIfTxIsSigned(tx: DirectTx): boolean { + const isSigned = tx.signedTx != null && tx.signature != null; + if (!isSigned) { + return false; + } + + // TODO: check if the signature is valid + return true; } - protected checkIfTxIsBroadcasted(_tx: DirectTx): boolean { + protected checkIfTxIsBroadcasted(tx: DirectTx): boolean { + const isBroadcasted = tx.txHash != null; + if (!isBroadcasted) { + return false; + } + + // optimistic assumption here: + // if the tx hash is set, the transaction is broadcasted successfully + // do not need to check broadcasted status here return true; } - protected async signTx(_tx: DirectTx): Promise<{ + protected async signTx( + vaultId: string, + chainId: string, + tx: DirectTx, + env?: Env + ): Promise<{ signedTx: Uint8Array; signature: Uint8Array; }> { + if (tx.type === DirectTxType.EVM) { + return this.signEvmTx(vaultId, chainId, tx, env); + } + + return this.signCosmosTx(vaultId, chainId, tx, env); + } + + protected async signEvmTx( + _vaultId: string, + _chainId: string, + _tx: EVMDirectTx, + _env?: Env + ): Promise<{ + signedTx: Uint8Array; + signature: Uint8Array; + }> { + // check key + + // check chain + + // if ledger + // - check if env is provided + // - sign page로 이동해서 서명 요청 + + // else + // - sign directly with stored key + return { signedTx: new Uint8Array(), signature: new Uint8Array(), }; } - protected async broadcastTx(_tx: DirectTx): Promise<{ + protected async signCosmosTx( + _vaultId: string, + _chainId: string, + _tx: CosmosDirectTx, + _env?: Env + ): Promise<{ + signedTx: Uint8Array; + signature: Uint8Array; + }> { + // check key + + // check chain + + // if ledger + // - check if env is provided + // - sign page로 이동해서 서명 요청 + + // else + // - sign directly with stored key + + return { + signedTx: new Uint8Array(), + signature: new Uint8Array(), + }; + } + + protected async broadcastTx(tx: DirectTx): Promise<{ txHash: string; }> { + if (tx.type === DirectTxType.EVM) { + return this.broadcastEvmTx(tx); + } + + return this.broadcastCosmosTx(tx); + } + + protected async broadcastEvmTx(tx: EVMDirectTx): Promise<{ + txHash: string; + }> { + // check signed tx and signature + // do not validate the signed tx and signature here just assume it is valid + + // 이렇게 단순하게 처리할 수는 없을 것 같고, + // serialized tx를 decode해서 signature를 넣고 다시 serialize해서 broadcast하는 방식으로 처리해야 할 듯 + if (!tx.signedTx) { + throw new KeplrError("direct-tx-executor", 108, "Signed tx not found"); + } + + const origin = + typeof browser !== "undefined" + ? new URL(browser.runtime.getURL("/")).origin + : "extension"; + + const txHash = await this.backgroundTxEthereumService.sendEthereumTx( + origin, + tx.chainId, + tx.signedTx, + { + silent: true, + skipTracingTxResult: true, + } + ); + return { - txHash: "", + txHash, }; } - protected async checkIfTxIsConfirmed(_tx: DirectTx): Promise { - return true; + protected async broadcastCosmosTx(tx: CosmosDirectTx): Promise<{ + txHash: string; + }> { + // check signed tx and signature + // do not validate the signed tx and signature here just assume it is valid + + // broadcast the tx + const txHash = await this.backgroundTxService.sendTx( + tx.chainId, + tx.signedTx, + "sync", + { + silent: true, + skipTracingTxResult: true, + } + ); + + return { + txHash: Buffer.from(txHash).toString("hex"), + }; + } + + protected async checkIfTxIsConfirmed(tx: DirectTx): Promise { + if (tx.type === DirectTxType.EVM) { + return this.checkIfEvmTxIsConfirmed(tx); + } + + return this.checkIfCosmosTxIsConfirmed(tx); + } + + protected async checkIfEvmTxIsConfirmed(tx: EVMDirectTx): Promise { + if (!tx.txHash) { + return false; + } + + const origin = + typeof browser !== "undefined" + ? new URL(browser.runtime.getURL("/")).origin + : "extension"; + + const txReceipt = + await this.backgroundTxEthereumService.getEthereumTxReceipt( + origin, + tx.chainId, + tx.txHash + ); + if (!txReceipt) { + return false; + } + + return txReceipt.status === EthTxStatus.Success; + } + + protected async checkIfCosmosTxIsConfirmed( + tx: CosmosDirectTx + ): Promise { + if (!tx.txHash) { + return false; + } + + const txResult = await this.backgroundTxService.traceTx( + tx.chainId, + tx.txHash + ); + if (!txResult || txResult.code == null) { + return false; + } + + return txResult.code === 0; } protected recordHistoryIfNeeded(_batch: DirectTxBatch): void { diff --git a/packages/background/src/tx-executor/types.ts b/packages/background/src/tx-executor/types.ts index 746ebba5bd..12395239a7 100644 --- a/packages/background/src/tx-executor/types.ts +++ b/packages/background/src/tx-executor/types.ts @@ -51,16 +51,18 @@ interface DirectTxBase { error?: string; } +export interface EVMDirectTx extends DirectTxBase { + readonly type: DirectTxType.EVM; + readonly txData: EvmTxData; +} + +export interface CosmosDirectTx extends DirectTxBase { + readonly type: DirectTxType.COSMOS; + readonly txData: CosmosTxData; +} + // Single transaction data with discriminated union based on type -export type DirectTx = - | (DirectTxBase & { - readonly type: DirectTxType.EVM; - readonly txData: EvmTxData; - }) - | (DirectTxBase & { - readonly type: DirectTxType.COSMOS; - readonly txData: CosmosTxData; - }); +export type DirectTx = EVMDirectTx | CosmosDirectTx; export enum DirectTxBatchStatus { PENDING = "pending", @@ -88,6 +90,8 @@ export interface DirectTxBatchBase { readonly txs: DirectTx[]; txIndex: number; // Current transaction being processed + executableChainIds: string[]; // executable chain ids + readonly timestamp: number; // Timestamp when execution started } diff --git a/packages/background/src/tx/service.ts b/packages/background/src/tx/service.ts index ca45bc3366..27922f12ee 100644 --- a/packages/background/src/tx/service.ts +++ b/packages/background/src/tx/service.ts @@ -35,6 +35,7 @@ export class BackgroundTxService { mode: "async" | "sync" | "block", options: { silent?: boolean; + skipTracingTxResult?: boolean; onFulfill?: (tx: any) => void; } ): Promise { @@ -92,6 +93,10 @@ export class BackgroundTxService { const txHash = Buffer.from(txResponse.txhash, "hex"); + if (options.skipTracingTxResult) { + return txHash; + } + // 이 기능은 tx commit일때 notification을 띄울 뿐이다. // 실제 로직 처리와는 관계가 없어야하기 때문에 여기서 await을 하면 안된다!! retry( @@ -152,6 +157,42 @@ export class BackgroundTxService { } } + async traceTx(chainId: string, txHash: string): Promise { + const chainInfo = this.chainsService.getChainInfoOrThrow(chainId); + const txHashBuffer = Buffer.from(txHash, "hex"); + + return await retry( + () => { + return new Promise((resolve, reject) => { + const txTracer = new TendermintTxTracer(chainInfo.rpc, "/websocket"); + txTracer.addEventListener("close", () => { + // reject if ws closed before fulfilled + // 하지만 로직상 fulfill 되기 전에 ws가 닫히는게 되기 때문에 + // delay를 좀 준다. + // trace 이후 로직은 동기적인 로직밖에 없기 때문에 문제될 게 없다. + // 문제될게 없다. + setTimeout(() => { + reject(); + }, 500); + }); + txTracer.addEventListener("error", () => { + reject(); + }); + txTracer.traceTx(txHashBuffer).then((tx) => { + txTracer.close(); + + resolve(tx); + }); + }); + }, + { + maxRetries: 10, + waitMsAfterError: 10 * 1000, // 10sec + maxWaitMsAfterError: 5 * 60 * 1000, // 5min + } + ); + } + private static processTxResultNotification( notification: Notification, result: any From 50f25af6cb2007031f40b50f993131c947c7c6ca Mon Sep 17 00:00:00 2001 From: rowan Date: Thu, 27 Nov 2025 17:05:38 +0900 Subject: [PATCH 09/29] implement evm tx direct signing logic --- .../src/keyring-ethereum/service.ts | 289 ++++++++++++++++++ .../background/src/tx-executor/service.ts | 85 ++++-- packages/background/src/tx-executor/types.ts | 23 +- 3 files changed, 357 insertions(+), 40 deletions(-) diff --git a/packages/background/src/keyring-ethereum/service.ts b/packages/background/src/keyring-ethereum/service.ts index 7c56586be7..56b40c4404 100644 --- a/packages/background/src/keyring-ethereum/service.ts +++ b/packages/background/src/keyring-ethereum/service.ts @@ -28,6 +28,7 @@ import { import { simpleFetch } from "@keplr-wallet/simple-fetch"; import { getBasicAccessPermissionType, PermissionService } from "../permission"; import { BackgroundTxEthereumService } from "../tx-ethereum"; +import { Dec } from "@keplr-wallet/unit"; import { TokenERC20Service } from "../token-erc20"; import { validateEVMChainId } from "./helper"; import { runInAction } from "mobx"; @@ -318,6 +319,95 @@ export class KeyRingEthereumService { ); } + async signEthereumDirect( + origin: string, + vaultId: string, + chainId: string, + signer: string, + message: Uint8Array, + signType: EthSignType + ): Promise { + const chainInfo = this.chainsService.getChainInfoOrThrow(chainId); + // if (chainInfo.hideInUI) { + // throw new Error("Can't sign for hidden chain"); + // } + const isEthermintLike = KeyRingService.isEthermintLike(chainInfo); + const evmInfo = ChainsService.getEVMInfo(chainInfo); + + if (!isEthermintLike && !evmInfo) { + throw new Error("Not ethermint like and EVM chain"); + } + + const keyInfo = this.keyRingService.getKeyInfo(vaultId); + if (!keyInfo) { + throw new Error("Null key info"); + } + + if (keyInfo.type === "ledger" || keyInfo.type === "keystone") { + throw new Error("Direct signing is not supported for hardware wallets"); + } + + if (signType === EthSignType.TRANSACTION) { + const unsignedTx = JSON.parse(Buffer.from(message).toString()); + if (unsignedTx.authorizationList) { + throw new Error("EIP-7702 transactions are not supported."); + } + } + + try { + Bech32Address.validate(signer); + } catch { + // Ignore mixed-case checksum + signer = ( + signer.substring(0, 2) === "0x" ? signer : "0x" + signer + ).toLowerCase(); + } + + const key = await this.keyRingCosmosService.getKey(vaultId, chainId); + if ( + signer !== key.bech32Address && + signer !== key.ethereumHexAddress.toLowerCase() + ) { + throw new Error("Signer mismatched"); + } + + if (signType !== EthSignType.TRANSACTION) { + throw new Error( + "Direct signing is only supported for transaction for now" + ); + } + + const unsignedTx = await this.fillUnsignedTx( + origin, + chainId, + signer, + JSON.parse(Buffer.from(message).toString()) + ); + + const isEIP1559 = + !!unsignedTx.maxFeePerGas || !!unsignedTx.maxPriorityFeePerGas; + if (isEIP1559) { + unsignedTx.type = TransactionTypes.eip1559; + } + + const signature = await this.keyRingService.sign( + chainId, + vaultId, + Buffer.from(serialize(unsignedTx).replace("0x", ""), "hex"), + "keccak256" + ); + + return { + signingData: Buffer.from(JSON.stringify(unsignedTx), "utf8"), + signature: Buffer.concat([ + signature.r, + signature.s, + // The metamask doesn't seem to consider the chain id in this case... (maybe bug on metamask?) + signature.v ? Buffer.from("1c", "hex") : Buffer.from("1b", "hex"), + ]), + }; + } + async request( env: Env, origin: string, @@ -1219,6 +1309,205 @@ export class KeyRingEthereumService { return result; } + protected async fillUnsignedTx( + origin: string, + chainId: string, + signer: string, + tx: UnsignedTransaction + ): Promise { + // get chain info + const chainInfo = this.chainsService.getChainInfoOrThrow(chainId); + const evmInfo = ChainsService.getEVMInfo(chainInfo); + if (!evmInfo) { + throw new Error("Not EVM chain"); + } + + const getTransactionCountRequest = { + jsonrpc: "2.0", + method: "eth_getTransactionCount", + params: [signer, "pending"], + id: 1, + }; + + const getBlockRequest = { + jsonrpc: "2.0", + method: "eth_getBlockByNumber", + params: ["latest", false], + id: 2, + }; + + const getFeeHistoryRequest = { + jsonrpc: "2.0", + method: "eth_feeHistory", + params: [20, "latest", [50]], + id: 3, + }; + + const estimateGasRequest = { + jsonrpc: "2.0", + method: "eth_estimateGas", + params: [ + { + from: signer, + to: tx.to, + value: tx.value, + data: tx.data, + }, + ], + id: 4, + }; + + const getMaxPriorityFeePerGasRequest = { + jsonrpc: "2.0", + method: "eth_maxPriorityFeePerGas", + params: [], + id: 5, + }; + + // rpc request in batch (as 2.0 jsonrpc supports batch requests) + const batchRequest = [ + getTransactionCountRequest, + getBlockRequest, + getFeeHistoryRequest, + estimateGasRequest, + getMaxPriorityFeePerGasRequest, + ]; + + const { data: rpcResponses } = await simpleFetch< + Array<{ + jsonrpc: "2.0"; + id: number; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; + }> + >(evmInfo.rpc, { + method: "POST", + headers: { + "content-type": "application/json", + "request-source": origin, + }, + body: JSON.stringify(batchRequest), + }); + + if ( + !Array.isArray(rpcResponses) || + rpcResponses.length !== batchRequest.length + ) { + throw new Error("Invalid batch response format"); + } + + const getResult = (id: number): T => { + const res = rpcResponses.find((r) => r.id === id); + if (!res) { + throw new Error(`No response for id=${id}`); + } + if (res.error) { + throw new Error( + `RPC error (id=${id}): ${res.error.code} ${res.error.message}` + ); + } + return res.result as T; + }; + + // find responses by id + const nonceHex = getResult(1); + const latestBlock = getResult<{ baseFeePerGas?: string }>(2); + const feeHistory = getResult<{ + baseFeePerGas?: string[]; + gasUsedRatio: number[]; + oldestBlock: string; + reward?: string[][]; + }>(3); + const gasLimitHex = getResult(4); + const networkMaxPriorityFeePerGasHex = getResult(5); + + let maxPriorityFeePerGasDec: Dec | undefined; + if (feeHistory.reward && feeHistory.reward.length > 0) { + // get 50th percentile rewards (index 0 since we requested [50] percentile) + const percentileIndex = 0; + const rewards = feeHistory.reward + .map((block) => block[percentileIndex]) + .filter((v) => v != null) + .map((v) => BigInt(v)); + + if (rewards.length > 0) { + const sum = rewards.reduce((acc, x) => acc + x, BigInt(0)); + const mean = sum / BigInt(rewards.length); + + const sortedRewards = [...rewards].sort((a, b) => + a < b ? -1 : a > b ? 1 : 0 + ); + const median = sortedRewards[Math.floor(sortedRewards.length / 2)]; + + // use 1 Gwei deviation threshold to decide between mean and median + const deviationThreshold = BigInt(1 * 10 ** 9); // 1 Gwei + const deviation = mean > median ? mean - median : median - mean; + const pick = + deviation > deviationThreshold + ? mean > median + ? mean + : median + : mean; + + maxPriorityFeePerGasDec = new Dec(pick); + } + } + + if (networkMaxPriorityFeePerGasHex) { + const networkMaxPriorityFeePerGasDec = new Dec( + BigInt(networkMaxPriorityFeePerGasHex) + ); + + if ( + !maxPriorityFeePerGasDec || + (maxPriorityFeePerGasDec && + networkMaxPriorityFeePerGasDec.gt(maxPriorityFeePerGasDec)) + ) { + maxPriorityFeePerGasDec = networkMaxPriorityFeePerGasDec; + } + } + + if (!maxPriorityFeePerGasDec) { + throw new Error( + "Failed to calculate maxPriorityFeePerGas to fill unsigned transaction" + ); + } + + if (!latestBlock.baseFeePerGas) { + throw new Error( + "Failed to get baseFeePerGas to fill unsigned transaction" + ); + } + + const multiplier = new Dec(1.25); + + // Calculate maxFeePerGas = baseFeePerGas + maxPriorityFeePerGas + const baseFeePerGasDec = new Dec(BigInt(latestBlock.baseFeePerGas)); + const maxFeePerGasDec = baseFeePerGasDec + .add(maxPriorityFeePerGasDec) + .mul(multiplier); + const maxFeePerGasHex = `0x${maxFeePerGasDec + .truncate() + .toBigNumber() + .toString(16)}`; + + maxPriorityFeePerGasDec = maxPriorityFeePerGasDec.mul(multiplier); + const maxPriorityFeePerGasHex = `0x${maxPriorityFeePerGasDec + .truncate() + .toBigNumber() + .toString(16)}`; + + const newUnsignedTx: UnsignedTransaction = { + ...tx, + nonce: parseInt(nonceHex, 16), + maxFeePerGas: maxFeePerGasHex, + maxPriorityFeePerGas: maxPriorityFeePerGasHex, + gasLimit: gasLimitHex, + }; + + return newUnsignedTx; + } + getNewCurrentChainIdFromRequest( method: string, params?: unknown[] | Record diff --git a/packages/background/src/tx-executor/service.ts b/packages/background/src/tx-executor/service.ts index b1c905b14b..9ff94f2eb0 100644 --- a/packages/background/src/tx-executor/service.ts +++ b/packages/background/src/tx-executor/service.ts @@ -26,7 +26,12 @@ import { runInAction, toJS, } from "mobx"; -import { EthTxStatus } from "@keplr-wallet/types"; +import { + EthSignType, + EthTxStatus, + EthereumSignResponse, +} from "@keplr-wallet/types"; +import { TransactionTypes, serialize } from "@ethersproject/transactions"; export class BackgroundTxExecutorService { @observable @@ -406,14 +411,7 @@ export class BackgroundTxExecutorService { } protected checkIfTxIsSigned(tx: DirectTx): boolean { - const isSigned = tx.signedTx != null && tx.signature != null; - if (!isSigned) { - return false; - } - - // TODO: check if the signature is valid - - return true; + return tx.signedTx != null && tx.signature != null; } protected checkIfTxIsBroadcasted(tx: DirectTx): boolean { @@ -445,28 +443,73 @@ export class BackgroundTxExecutorService { } protected async signEvmTx( - _vaultId: string, - _chainId: string, - _tx: EVMDirectTx, - _env?: Env + vaultId: string, + chainId: string, + tx: EVMDirectTx, + env?: Env ): Promise<{ signedTx: Uint8Array; signature: Uint8Array; }> { // check key + const keyInfo = await this.keyRingCosmosService.getKey(vaultId, chainId); + const isHardware = keyInfo.isNanoLedger || keyInfo.isKeystone; + const signer = keyInfo.ethereumHexAddress; + const origin = + typeof browser !== "undefined" + ? new URL(browser.runtime.getURL("/")).origin + : "extension"; - // check chain + let result: EthereumSignResponse; - // if ledger - // - check if env is provided - // - sign page로 이동해서 서명 요청 + if (isHardware) { + if (!env) { + throw new KeplrError( + "direct-tx-executor", + 109, + "Hardware wallet signing should be triggered from user interaction" + ); + } - // else - // - sign directly with stored key + result = await this.keyRingEthereumService.signEthereum( + env, + origin, + vaultId, + chainId, + signer, + Buffer.from(JSON.stringify(tx.txData)), + EthSignType.TRANSACTION + ); + } else { + result = await this.keyRingEthereumService.signEthereumDirect( + origin, + vaultId, + chainId, + signer, + Buffer.from(JSON.stringify(tx.txData)), + EthSignType.TRANSACTION + ); + } + + // CHECK: does balance check need to be done here? + + const unsignedTx = JSON.parse(Buffer.from(result.signingData).toString()); + const isEIP1559 = + !!unsignedTx.maxFeePerGas || !!unsignedTx.maxPriorityFeePerGas; + if (isEIP1559) { + unsignedTx.type = TransactionTypes.eip1559; + } + + delete unsignedTx.from; + + const signedTx = Buffer.from( + serialize(unsignedTx, result.signature).replace("0x", ""), + "hex" + ); return { - signedTx: new Uint8Array(), - signature: new Uint8Array(), + signedTx: signedTx, + signature: result.signature, }; } diff --git a/packages/background/src/tx-executor/types.ts b/packages/background/src/tx-executor/types.ts index 12395239a7..04a845e651 100644 --- a/packages/background/src/tx-executor/types.ts +++ b/packages/background/src/tx-executor/types.ts @@ -1,4 +1,5 @@ -import { StdSignDoc, StdSignature } from "@keplr-wallet/types"; +import { UnsignedTransaction } from "@ethersproject/transactions"; +import { StdSignDoc } from "@keplr-wallet/types"; // Transaction status export enum DirectTxStatus { @@ -19,22 +20,6 @@ export enum DirectTxType { COSMOS = "cosmos", } -// TODO: 뭐가 필요할까... -export interface CosmosTxData { - readonly signDoc: StdSignDoc; - readonly signature?: StdSignature; -} - -export interface EvmTxData { - readonly tx: Uint8Array; - readonly signature?: Uint8Array; -} - -// Transaction data union type -export type DirectTxData = - | EvmTxData // EVM transaction data - | CosmosTxData; // Cosmos transaction construction data - // Base transaction interface interface DirectTxBase { status: DirectTxStatus; // mutable while executing @@ -53,12 +38,12 @@ interface DirectTxBase { export interface EVMDirectTx extends DirectTxBase { readonly type: DirectTxType.EVM; - readonly txData: EvmTxData; + readonly txData: UnsignedTransaction; } export interface CosmosDirectTx extends DirectTxBase { readonly type: DirectTxType.COSMOS; - readonly txData: CosmosTxData; + readonly txData: StdSignDoc; } // Single transaction data with discriminated union based on type From e0ff47824b6bd34c79314984c660381c7c1ef9df Mon Sep 17 00:00:00 2001 From: rowan Date: Thu, 27 Nov 2025 19:57:42 +0900 Subject: [PATCH 10/29] return tx batch result status for subsequent operations --- .../background/src/tx-executor/messages.ts | 15 +++++--- .../background/src/tx-executor/service.ts | 34 +++++++++---------- 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/packages/background/src/tx-executor/messages.ts b/packages/background/src/tx-executor/messages.ts index 00191d3d08..cbbcea75fd 100644 --- a/packages/background/src/tx-executor/messages.ts +++ b/packages/background/src/tx-executor/messages.ts @@ -1,13 +1,18 @@ import { KeplrError, Message } from "@keplr-wallet/router"; import { ROUTE } from "./constants"; -import { DirectTxBatch, DirectTx, DirectTxBatchType } from "./types"; +import { + DirectTxBatch, + DirectTx, + DirectTxBatchType, + DirectTxBatchStatus, +} from "./types"; /** * Record and execute multiple transactions * execution id is returned if the transactions are recorded successfully * and the execution will be started automatically after the transactions are recorded. */ -export class RecordAndExecuteDirectTxsMsg extends Message { +export class RecordAndExecuteDirectTxsMsg extends Message { public static type() { return "record-and-execute-direct-txs"; } @@ -61,14 +66,14 @@ export class RecordAndExecuteDirectTxsMsg extends Message { * Resume existing direct transactions by execution id and transaction index * This message is used to resume the execution of direct transactions that were paused by waiting for the asset to be bridged or other reasons. */ -export class ResumeDirectTxsMsg extends Message { +export class ResumeDirectTxsMsg extends Message { public static type() { return "resume-direct-txs"; } constructor( public readonly id: string, - public readonly txIndex: number, + public readonly txIndex?: number, // NOTE: these fields are optional for hardware wallet cases public readonly signedTx?: Uint8Array, public readonly signature?: Uint8Array @@ -81,7 +86,7 @@ export class ResumeDirectTxsMsg extends Message { throw new KeplrError("direct-tx-executor", 101, "id is empty"); } - if (this.txIndex == null || this.txIndex < 0) { + if (this.txIndex != null && this.txIndex < 0) { throw new KeplrError("direct-tx-executor", 103, "txIndex is invalid"); } diff --git a/packages/background/src/tx-executor/service.ts b/packages/background/src/tx-executor/service.ts index 9ff94f2eb0..7c0fd3d3ee 100644 --- a/packages/background/src/tx-executor/service.ts +++ b/packages/background/src/tx-executor/service.ts @@ -109,17 +109,21 @@ export class BackgroundTxExecutorService { * and the execution will be started automatically after the transactions are recorded. */ @action - recordAndExecuteDirectTxs( + async recordAndExecuteDirectTxs( env: Env, vaultId: string, type: DirectTxBatchType, txs: DirectTx[], executableChainIds: string[] - ): string { + ): Promise { if (!env.isInternalMsg) { throw new KeplrError("direct-tx-executor", 101, "Not internal message"); } + // CHECK: 다중 체인 트랜잭션이 아니라면 굳이 이걸 기록할 필요가 있을까? + // 다중 체인 트랜잭션을 기록하는 이유는 자산 브릿징 등 상당한 시간이 걸리는 경우 이 작업을 백그라운드에서 한없이 기다리는 대신 + // 실행 조건이 만족되었을 때 이어서 실행하기 위함인데, 한 번에 처리가 가능하다면 굳이 이걸 기록할 필요는 없을지도 모른다. + // 특히나 ui에서 진행상황을 체크하는 것이 아닌 이상 notification을 통해 진행상황을 알리는 것으로 충분할 수 있다. const id = (this.recentDirectTxBatchSeq++).toString(); const batchBase: DirectTxBatchBase = { @@ -159,9 +163,7 @@ export class BackgroundTxExecutorService { } this.recentDirectTxBatchMap.set(id, batch); - this.executeDirectTxs(id); - - return id; + return await this.executeDirectTxs(id); } /** @@ -171,10 +173,10 @@ export class BackgroundTxExecutorService { async resumeDirectTxs( env: Env, id: string, - txIndex: number, + txIndex?: number, signedTx?: Uint8Array, signature?: Uint8Array - ): Promise { + ): Promise { if (!env.isInternalMsg) { // TODO: 에러 코드 신경쓰기 throw new KeplrError("direct-tx-executor", 101, "Not internal message"); @@ -196,10 +198,10 @@ export class BackgroundTxExecutorService { signedTx?: Uint8Array; signature?: Uint8Array; } - ): Promise { + ): Promise { const batch = this.getDirectTxBatch(id); if (!batch) { - return; + throw new KeplrError("direct-tx-executor", 105, "Execution not found"); } // Only pending/processing/blocked executions can be executed @@ -208,7 +210,7 @@ export class BackgroundTxExecutorService { batch.status === DirectTxBatchStatus.PROCESSING || batch.status === DirectTxBatchStatus.BLOCKED; if (!needResume) { - return; + return batch.status; } // check if the key is valid @@ -224,12 +226,7 @@ export class BackgroundTxExecutorService { batch.txs.length - 1 ); - if ( - batch.status === DirectTxBatchStatus.PENDING || - batch.status === DirectTxBatchStatus.BLOCKED - ) { - batch.status = DirectTxBatchStatus.PROCESSING; - } + batch.status = DirectTxBatchStatus.PROCESSING; for (let i = executionStartIndex; i < batch.txs.length; i++) { let txStatus: DirectTxStatus; @@ -256,13 +253,13 @@ export class BackgroundTxExecutorService { if (txStatus === DirectTxStatus.BLOCKED) { batch.status = DirectTxBatchStatus.BLOCKED; this.recordHistoryIfNeeded(batch); - return; + return batch.status; } // if the tx is failed, the execution should be stopped if (txStatus === DirectTxStatus.FAILED) { batch.status = DirectTxBatchStatus.FAILED; - return; + return batch.status; } // something went wrong, should not happen @@ -276,6 +273,7 @@ export class BackgroundTxExecutorService { // if the execution is completed successfully, update the batch status batch.status = DirectTxBatchStatus.COMPLETED; this.recordHistoryIfNeeded(batch); + return batch.status; } protected async executePendingDirectTx( From bcbc32fd179e5e6fdc00025aadaafe8b5fcbfb8f Mon Sep 17 00:00:00 2001 From: rowan Date: Thu, 27 Nov 2025 21:03:40 +0900 Subject: [PATCH 11/29] minor refactor --- .../background/src/tx-executor/constants.ts | 2 +- .../background/src/tx-executor/handler.ts | 3 +- .../background/src/tx-executor/service.ts | 187 +++++++++--------- packages/background/src/tx-executor/types.ts | 1 - 4 files changed, 93 insertions(+), 100 deletions(-) diff --git a/packages/background/src/tx-executor/constants.ts b/packages/background/src/tx-executor/constants.ts index eabc5d8936..4a8a92cc49 100644 --- a/packages/background/src/tx-executor/constants.ts +++ b/packages/background/src/tx-executor/constants.ts @@ -1 +1 @@ -export const ROUTE = "direct-tx-executor"; +export const ROUTE = "background-tx-executor"; diff --git a/packages/background/src/tx-executor/handler.ts b/packages/background/src/tx-executor/handler.ts index 5cf8d3aa95..c869039e9b 100644 --- a/packages/background/src/tx-executor/handler.ts +++ b/packages/background/src/tx-executor/handler.ts @@ -66,8 +66,7 @@ const handleResumeDirectTxsMsg: ( env, msg.id, msg.txIndex, - msg.signedTx, - msg.signature + msg.signedTx ); }; }; diff --git a/packages/background/src/tx-executor/service.ts b/packages/background/src/tx-executor/service.ts index 7c0fd3d3ee..1bc14783fc 100644 --- a/packages/background/src/tx-executor/service.ts +++ b/packages/background/src/tx-executor/service.ts @@ -174,8 +174,7 @@ export class BackgroundTxExecutorService { env: Env, id: string, txIndex?: number, - signedTx?: Uint8Array, - signature?: Uint8Array + signedTx?: Uint8Array ): Promise { if (!env.isInternalMsg) { // TODO: 에러 코드 신경쓰기 @@ -186,7 +185,6 @@ export class BackgroundTxExecutorService { env, txIndex, signedTx, - signature, }); } @@ -196,7 +194,6 @@ export class BackgroundTxExecutorService { env?: Env; txIndex?: number; signedTx?: Uint8Array; - signature?: Uint8Array; } ): Promise { const batch = this.getDirectTxBatch(id); @@ -235,7 +232,6 @@ export class BackgroundTxExecutorService { txStatus = await this.executePendingDirectTx(id, i, { env: options?.env, signedTx: options.signedTx, - signature: options.signature, }); } else { txStatus = await this.executePendingDirectTx(id, i, { @@ -282,7 +278,6 @@ export class BackgroundTxExecutorService { options?: { env?: Env; signedTx?: Uint8Array; - signature?: Uint8Array; } ): Promise { const batch = this.getDirectTxBatch(id); @@ -314,9 +309,8 @@ export class BackgroundTxExecutorService { // TODO: check if the condition is met to resume the execution // this will be handled with recent send history tracking to check if the condition is met to resume the execution // check if the current transaction's chainId is included in the chainIds of the recent send history (might enough with this) - if ( - this.checkIfTxIsBlocked(batch.executableChainIds, currentTx.chainId) - ) { + const isBlocked = !batch.executableChainIds.includes(currentTx.chainId); + if (isBlocked) { currentTx.status = DirectTxStatus.BLOCKED; return currentTx.status; } else { @@ -326,34 +320,23 @@ export class BackgroundTxExecutorService { if (currentTx.status === DirectTxStatus.SIGNING) { // if options are provided, temporary set the options to the current transaction - if (options?.signedTx && options.signature) { + if (options?.signedTx) { currentTx.signedTx = options.signedTx; - currentTx.signature = options.signature; } - // check if the transaction is signed - if (this.checkIfTxIsSigned(currentTx)) { - // if already signed, signing -> signed - currentTx.status = DirectTxStatus.SIGNED; - } + try { + const { signedTx } = await this.signTx( + batch.vaultId, + currentTx.chainId, + currentTx, + options?.env + ); - // if not signed, try sign - if (currentTx.status === DirectTxStatus.SIGNING) { - try { - const { signedTx, signature } = await this.signTx( - batch.vaultId, - currentTx.chainId, - currentTx, - options?.env - ); - - currentTx.signedTx = signedTx; - currentTx.signature = signature; - currentTx.status = DirectTxStatus.SIGNED; - } catch (error) { - currentTx.status = DirectTxStatus.FAILED; - currentTx.error = error.message ?? "Transaction signing failed"; - } + currentTx.signedTx = signedTx; + currentTx.status = DirectTxStatus.SIGNED; + } catch (error) { + currentTx.status = DirectTxStatus.FAILED; + currentTx.error = error.message ?? "Transaction signing failed"; } } @@ -361,16 +344,6 @@ export class BackgroundTxExecutorService { currentTx.status === DirectTxStatus.SIGNED || currentTx.status === DirectTxStatus.BROADCASTING ) { - if (currentTx.status === DirectTxStatus.BROADCASTING) { - // check if the transaction is broadcasted - if (this.checkIfTxIsBroadcasted(currentTx)) { - currentTx.status = DirectTxStatus.BROADCASTED; - } - } else { - // set the transaction status to broadcasting - currentTx.status = DirectTxStatus.BROADCASTING; - } - try { const { txHash } = await this.broadcastTx(currentTx); @@ -385,7 +358,7 @@ export class BackgroundTxExecutorService { if (currentTx.status === DirectTxStatus.BROADCASTED) { // broadcasted -> confirmed try { - const confirmed = await this.checkIfTxIsConfirmed(currentTx); + const confirmed = await this.traceTx(currentTx); if (confirmed) { currentTx.status = DirectTxStatus.CONFIRMED; } else { @@ -401,29 +374,6 @@ export class BackgroundTxExecutorService { return currentTx.status; } - protected checkIfTxIsBlocked( - executableChainIds: string[], - chainId: string - ): boolean { - return !executableChainIds.includes(chainId); - } - - protected checkIfTxIsSigned(tx: DirectTx): boolean { - return tx.signedTx != null && tx.signature != null; - } - - protected checkIfTxIsBroadcasted(tx: DirectTx): boolean { - const isBroadcasted = tx.txHash != null; - if (!isBroadcasted) { - return false; - } - - // optimistic assumption here: - // if the tx hash is set, the transaction is broadcasted successfully - // do not need to check broadcasted status here - return true; - } - protected async signTx( vaultId: string, chainId: string, @@ -431,8 +381,13 @@ export class BackgroundTxExecutorService { env?: Env ): Promise<{ signedTx: Uint8Array; - signature: Uint8Array; }> { + if (tx.signedTx != null) { + return { + signedTx: tx.signedTx, + }; + } + if (tx.type === DirectTxType.EVM) { return this.signEvmTx(vaultId, chainId, tx, env); } @@ -440,16 +395,14 @@ export class BackgroundTxExecutorService { return this.signCosmosTx(vaultId, chainId, tx, env); } - protected async signEvmTx( + private async signEvmTx( vaultId: string, chainId: string, tx: EVMDirectTx, env?: Env ): Promise<{ signedTx: Uint8Array; - signature: Uint8Array; }> { - // check key const keyInfo = await this.keyRingCosmosService.getKey(vaultId, chainId); const isHardware = keyInfo.isNanoLedger || keyInfo.isKeystone; const signer = keyInfo.ethereumHexAddress; @@ -507,29 +460,63 @@ export class BackgroundTxExecutorService { return { signedTx: signedTx, - signature: result.signature, }; } - protected async signCosmosTx( - _vaultId: string, - _chainId: string, - _tx: CosmosDirectTx, - _env?: Env + private async signCosmosTx( + vaultId: string, + chainId: string, + tx: CosmosDirectTx, + env?: Env ): Promise<{ signedTx: Uint8Array; signature: Uint8Array; }> { // check key + const keyInfo = await this.keyRingCosmosService.getKey(vaultId, chainId); + const isHardware = keyInfo.isNanoLedger || keyInfo.isKeystone; + const signer = keyInfo.bech32Address; + const origin = + typeof browser !== "undefined" + ? new URL(browser.runtime.getURL("/")).origin + : "extension"; - // check chain + // let result: AminoSignResponse; + if (isHardware) { + if (!env) { + throw new KeplrError( + "direct-tx-executor", + 109, + "Hardware wallet signing should be triggered from user interaction" + ); + } - // if ledger - // - check if env is provided - // - sign page로 이동해서 서명 요청 + await this.keyRingCosmosService.signAmino( + env, + origin, + vaultId, + chainId, + signer, + tx.txData, + {} + ); - // else - // - sign directly with stored key + // experimentalSignEIP712CosmosTx_v0 if eip712Signing + } else { + throw new KeplrError( + "direct-tx-executor", + 110, + "Software wallet signing is not supported" + ); + // result = await this.keyRingCosmosService.signAminoDirect( + // origin, + // vaultId, + // chainId, + // signer, + // tx.txData, + // {} + // ); + } return { signedTx: new Uint8Array(), @@ -540,6 +527,15 @@ export class BackgroundTxExecutorService { protected async broadcastTx(tx: DirectTx): Promise<{ txHash: string; }> { + if (tx.txHash != null) { + // optimistic assumption here: + // if the tx hash is set, the transaction is broadcasted successfully + // do not need to check broadcasted status here + return { + txHash: tx.txHash, + }; + } + if (tx.type === DirectTxType.EVM) { return this.broadcastEvmTx(tx); } @@ -547,7 +543,7 @@ export class BackgroundTxExecutorService { return this.broadcastCosmosTx(tx); } - protected async broadcastEvmTx(tx: EVMDirectTx): Promise<{ + private async broadcastEvmTx(tx: EVMDirectTx): Promise<{ txHash: string; }> { // check signed tx and signature @@ -579,11 +575,12 @@ export class BackgroundTxExecutorService { }; } - protected async broadcastCosmosTx(tx: CosmosDirectTx): Promise<{ + private async broadcastCosmosTx(tx: CosmosDirectTx): Promise<{ txHash: string; }> { - // check signed tx and signature - // do not validate the signed tx and signature here just assume it is valid + if (!tx.signedTx) { + throw new KeplrError("direct-tx-executor", 108, "Signed tx not found"); + } // broadcast the tx const txHash = await this.backgroundTxService.sendTx( @@ -601,17 +598,17 @@ export class BackgroundTxExecutorService { }; } - protected async checkIfTxIsConfirmed(tx: DirectTx): Promise { + protected async traceTx(tx: DirectTx): Promise { if (tx.type === DirectTxType.EVM) { - return this.checkIfEvmTxIsConfirmed(tx); + return this.traceEvmTx(tx); } - return this.checkIfCosmosTxIsConfirmed(tx); + return this.traceCosmosTx(tx); } - protected async checkIfEvmTxIsConfirmed(tx: EVMDirectTx): Promise { + private async traceEvmTx(tx: EVMDirectTx): Promise { if (!tx.txHash) { - return false; + throw new KeplrError("direct-tx-executor", 108, "Tx hash not found"); } const origin = @@ -632,11 +629,9 @@ export class BackgroundTxExecutorService { return txReceipt.status === EthTxStatus.Success; } - protected async checkIfCosmosTxIsConfirmed( - tx: CosmosDirectTx - ): Promise { + private async traceCosmosTx(tx: CosmosDirectTx): Promise { if (!tx.txHash) { - return false; + throw new KeplrError("direct-tx-executor", 108, "Tx hash not found"); } const txResult = await this.backgroundTxService.traceTx( diff --git a/packages/background/src/tx-executor/types.ts b/packages/background/src/tx-executor/types.ts index 04a845e651..55b3569c8a 100644 --- a/packages/background/src/tx-executor/types.ts +++ b/packages/background/src/tx-executor/types.ts @@ -27,7 +27,6 @@ interface DirectTxBase { // signed transaction data signedTx?: Uint8Array; - signature?: Uint8Array; // Transaction hash for completed tx txHash?: string; From 55fda85f2123d424cbc4df18a3a608c089a5c720 Mon Sep 17 00:00:00 2001 From: rowan Date: Fri, 28 Nov 2025 10:00:44 +0900 Subject: [PATCH 12/29] rename background tx executor types --- .../background/src/tx-executor/handler.ts | 65 ++--- packages/background/src/tx-executor/init.ts | 16 +- .../background/src/tx-executor/messages.ts | 40 +-- .../background/src/tx-executor/service.ts | 267 +++++++++--------- packages/background/src/tx-executor/types.ts | 74 ++--- packages/stores/src/account/cosmos.ts | 1 + 6 files changed, 230 insertions(+), 233 deletions(-) diff --git a/packages/background/src/tx-executor/handler.ts b/packages/background/src/tx-executor/handler.ts index c869039e9b..833302a423 100644 --- a/packages/background/src/tx-executor/handler.ts +++ b/packages/background/src/tx-executor/handler.ts @@ -7,10 +7,10 @@ import { } from "@keplr-wallet/router"; import { BackgroundTxExecutorService } from "./service"; import { - RecordAndExecuteDirectTxsMsg, - ResumeDirectTxsMsg, - CancelDirectTxsMsg, - GetDirectTxBatchMsg, + RecordAndExecuteTxsMsg, + ResumeTxMsg, + CancelTxExecutionMsg, + GetTxExecutionMsg, } from "./messages"; export const getHandler: (service: BackgroundTxExecutorService) => Handler = ( @@ -18,25 +18,19 @@ export const getHandler: (service: BackgroundTxExecutorService) => Handler = ( ) => { return (env: Env, msg: Message) => { switch (msg.constructor) { - case RecordAndExecuteDirectTxsMsg: - return handleRecordAndExecuteDirectTxsMsg(service)( + case RecordAndExecuteTxsMsg: + return handleRecordAndExecuteTxsMsg(service)( env, - msg as RecordAndExecuteDirectTxsMsg + msg as RecordAndExecuteTxsMsg ); - case GetDirectTxBatchMsg: - return handleGetDirectTxBatchMsg(service)( + case GetTxExecutionMsg: + return handleGetTxExecutionMsg(service)(env, msg as GetTxExecutionMsg); + case ResumeTxMsg: + return handleResumeTxMsg(service)(env, msg as ResumeTxMsg); + case CancelTxExecutionMsg: + return handleCancelTxExecutionMsg(service)( env, - msg as GetDirectTxBatchMsg - ); - case ResumeDirectTxsMsg: - return handleResumeDirectTxsMsg(service)( - env, - msg as ResumeDirectTxsMsg - ); - case CancelDirectTxsMsg: - return handleCancelDirectTxsMsg(service)( - env, - msg as CancelDirectTxsMsg + msg as CancelTxExecutionMsg ); default: throw new KeplrError("direct-tx-executor", 100, "Unknown msg type"); @@ -44,45 +38,40 @@ export const getHandler: (service: BackgroundTxExecutorService) => Handler = ( }; }; -const handleRecordAndExecuteDirectTxsMsg: ( +const handleRecordAndExecuteTxsMsg: ( service: BackgroundTxExecutorService -) => InternalHandler = (service) => { +) => InternalHandler = (service) => { return (env, msg) => { - return service.recordAndExecuteDirectTxs( + return service.recordAndExecuteTxs( env, msg.vaultId, - msg.batchType, + msg.executionType, msg.txs, msg.executableChainIds ); }; }; -const handleResumeDirectTxsMsg: ( +const handleResumeTxMsg: ( service: BackgroundTxExecutorService -) => InternalHandler = (service) => { +) => InternalHandler = (service) => { return async (env, msg) => { - return await service.resumeDirectTxs( - env, - msg.id, - msg.txIndex, - msg.signedTx - ); + return await service.resumeTx(env, msg.id, msg.txIndex, msg.signedTx); }; }; -const handleGetDirectTxBatchMsg: ( +const handleGetTxExecutionMsg: ( service: BackgroundTxExecutorService -) => InternalHandler = (service) => { +) => InternalHandler = (service) => { return (_env, msg) => { - return service.getDirectTxBatch(msg.id); + return service.getTxExecution(msg.id); }; }; -const handleCancelDirectTxsMsg: ( +const handleCancelTxExecutionMsg: ( service: BackgroundTxExecutorService -) => InternalHandler = (service) => { +) => InternalHandler = (service) => { return async (_env, msg) => { - await service.cancelDirectTxs(msg.id); + await service.cancelTxExecution(msg.id); }; }; diff --git a/packages/background/src/tx-executor/init.ts b/packages/background/src/tx-executor/init.ts index e857358c82..0b86f5cf51 100644 --- a/packages/background/src/tx-executor/init.ts +++ b/packages/background/src/tx-executor/init.ts @@ -3,20 +3,20 @@ import { ROUTE } from "./constants"; import { getHandler } from "./handler"; import { BackgroundTxExecutorService } from "./service"; import { - RecordAndExecuteDirectTxsMsg, - ResumeDirectTxsMsg, - CancelDirectTxsMsg, - GetDirectTxBatchMsg, + RecordAndExecuteTxsMsg, + ResumeTxMsg, + CancelTxExecutionMsg, + GetTxExecutionMsg, } from "./messages"; export function init( router: Router, service: BackgroundTxExecutorService ): void { - router.registerMessage(RecordAndExecuteDirectTxsMsg); - router.registerMessage(ResumeDirectTxsMsg); - router.registerMessage(GetDirectTxBatchMsg); - router.registerMessage(CancelDirectTxsMsg); + router.registerMessage(RecordAndExecuteTxsMsg); + router.registerMessage(ResumeTxMsg); + router.registerMessage(GetTxExecutionMsg); + router.registerMessage(CancelTxExecutionMsg); router.addHandler(ROUTE, getHandler(service)); } diff --git a/packages/background/src/tx-executor/messages.ts b/packages/background/src/tx-executor/messages.ts index cbbcea75fd..8a88630afd 100644 --- a/packages/background/src/tx-executor/messages.ts +++ b/packages/background/src/tx-executor/messages.ts @@ -1,10 +1,10 @@ import { KeplrError, Message } from "@keplr-wallet/router"; import { ROUTE } from "./constants"; import { - DirectTxBatch, - DirectTx, - DirectTxBatchType, - DirectTxBatchStatus, + BackgroundTx, + TxExecutionType, + TxExecutionStatus, + TxExecution, } from "./types"; /** @@ -12,16 +12,16 @@ import { * execution id is returned if the transactions are recorded successfully * and the execution will be started automatically after the transactions are recorded. */ -export class RecordAndExecuteDirectTxsMsg extends Message { +export class RecordAndExecuteTxsMsg extends Message { public static type() { - return "record-and-execute-direct-txs"; + return "record-and-execute-txs"; } //TODO: add history data... constructor( public readonly vaultId: string, - public readonly batchType: DirectTxBatchType, - public readonly txs: DirectTx[], + public readonly executionType: TxExecutionType, + public readonly txs: BackgroundTx[], public readonly executableChainIds: string[] ) { super(); @@ -32,8 +32,8 @@ export class RecordAndExecuteDirectTxsMsg extends Message { throw new KeplrError("direct-tx-executor", 101, "vaultId is empty"); } - if (!this.batchType) { - throw new KeplrError("direct-tx-executor", 102, "batchType is empty"); + if (!this.executionType) { + throw new KeplrError("direct-tx-executor", 102, "executionType is empty"); } if (!this.txs || this.txs.length === 0) { @@ -58,7 +58,7 @@ export class RecordAndExecuteDirectTxsMsg extends Message { } type(): string { - return RecordAndExecuteDirectTxsMsg.type(); + return RecordAndExecuteTxsMsg.type(); } } @@ -66,9 +66,9 @@ export class RecordAndExecuteDirectTxsMsg extends Message { * Resume existing direct transactions by execution id and transaction index * This message is used to resume the execution of direct transactions that were paused by waiting for the asset to be bridged or other reasons. */ -export class ResumeDirectTxsMsg extends Message { +export class ResumeTxMsg extends Message { public static type() { - return "resume-direct-txs"; + return "resume-tx"; } constructor( @@ -109,16 +109,16 @@ export class ResumeDirectTxsMsg extends Message { } type(): string { - return ResumeDirectTxsMsg.type(); + return ResumeTxMsg.type(); } } /** * Get execution data by execution id */ -export class GetDirectTxBatchMsg extends Message { +export class GetTxExecutionMsg extends Message { public static type() { - return "get-direct-tx-batch"; + return "get-tx-execution"; } constructor(public readonly id: string) { @@ -140,16 +140,16 @@ export class GetDirectTxBatchMsg extends Message { } type(): string { - return GetDirectTxBatchMsg.type(); + return GetTxExecutionMsg.type(); } } /** * Cancel execution by execution id */ -export class CancelDirectTxsMsg extends Message { +export class CancelTxExecutionMsg extends Message { public static type() { - return "cancel-direct-txs"; + return "cancel-tx-execution"; } constructor(public readonly id: string) { @@ -171,6 +171,6 @@ export class CancelDirectTxsMsg extends Message { } type(): string { - return CancelDirectTxsMsg.type(); + return CancelTxExecutionMsg.type(); } } diff --git a/packages/background/src/tx-executor/service.ts b/packages/background/src/tx-executor/service.ts index 1bc14783fc..2123998540 100644 --- a/packages/background/src/tx-executor/service.ts +++ b/packages/background/src/tx-executor/service.ts @@ -8,15 +8,15 @@ import { BackgroundTxService } from "../tx"; import { BackgroundTxEthereumService } from "../tx-ethereum"; import { Env, KeplrError } from "@keplr-wallet/router"; import { - DirectTxBatch, - DirectTx, - DirectTxBatchStatus, - DirectTxStatus, - DirectTxBatchType, - DirectTxBatchBase, - DirectTxType, - EVMDirectTx, - CosmosDirectTx, + TxExecution, + TxExecutionStatus, + TxExecutionType, + TxExecutionBase, + BackgroundTx, + BackgroundTxStatus, + BackgroundTxType, + EVMBackgroundTx, + CosmosBackgroundTx, } from "./types"; import { action, @@ -35,11 +35,10 @@ import { TransactionTypes, serialize } from "@ethersproject/transactions"; export class BackgroundTxExecutorService { @observable - protected recentDirectTxBatchSeq: number = 0; + protected recentTxExecutionSeq: number = 0; // Key: id (sequence, it should be increased by 1 for each) @observable - protected readonly recentDirectTxBatchMap: Map = - new Map(); + protected readonly recentTxExecutionMap: Map = new Map(); constructor( protected readonly kvStore: KVStore, @@ -55,38 +54,38 @@ export class BackgroundTxExecutorService { } async init(): Promise { - const recentDirectTxBatchSeqSaved = await this.kvStore.get( - "recentDirectTxBatchSeq" + const recentTxExecutionSeqSaved = await this.kvStore.get( + "recentTxExecutionSeq" ); - if (recentDirectTxBatchSeqSaved) { + if (recentTxExecutionSeqSaved) { runInAction(() => { - this.recentDirectTxBatchSeq = recentDirectTxBatchSeqSaved; + this.recentTxExecutionSeq = recentTxExecutionSeqSaved; }); } autorun(() => { - const js = toJS(this.recentDirectTxBatchSeq); - this.kvStore.set("recentDirectTxBatchSeq", js); + const js = toJS(this.recentTxExecutionSeq); + this.kvStore.set("recentTxExecutionSeq", js); }); - const recentDirectTxBatchMapSaved = await this.kvStore.get< - Record - >("recentDirectTxBatchMap"); - if (recentDirectTxBatchMapSaved) { + const recentTxExecutionMapSaved = await this.kvStore.get< + Record + >("recentTxExecutionMap"); + if (recentTxExecutionMapSaved) { runInAction(() => { - let entries = Object.entries(recentDirectTxBatchMapSaved); + let entries = Object.entries(recentTxExecutionMapSaved); entries = entries.sort(([, a], [, b]) => { return parseInt(a.id) - parseInt(b.id); }); for (const [key, value] of entries) { - this.recentDirectTxBatchMap.set(key, value); + this.recentTxExecutionMap.set(key, value); } }); } autorun(() => { - const js = toJS(this.recentDirectTxBatchMap); + const js = toJS(this.recentTxExecutionMap); const obj = Object.fromEntries(js); - this.kvStore.set>( - "recentDirectTxBatchMap", + this.kvStore.set>( + "recentTxExecutionMap", obj ); }); @@ -109,13 +108,13 @@ export class BackgroundTxExecutorService { * and the execution will be started automatically after the transactions are recorded. */ @action - async recordAndExecuteDirectTxs( + async recordAndExecuteTxs( env: Env, vaultId: string, - type: DirectTxBatchType, - txs: DirectTx[], + type: TxExecutionType, + txs: BackgroundTx[], executableChainIds: string[] - ): Promise { + ): Promise { if (!env.isInternalMsg) { throw new KeplrError("direct-tx-executor", 101, "Not internal message"); } @@ -124,11 +123,11 @@ export class BackgroundTxExecutorService { // 다중 체인 트랜잭션을 기록하는 이유는 자산 브릿징 등 상당한 시간이 걸리는 경우 이 작업을 백그라운드에서 한없이 기다리는 대신 // 실행 조건이 만족되었을 때 이어서 실행하기 위함인데, 한 번에 처리가 가능하다면 굳이 이걸 기록할 필요는 없을지도 모른다. // 특히나 ui에서 진행상황을 체크하는 것이 아닌 이상 notification을 통해 진행상황을 알리는 것으로 충분할 수 있다. - const id = (this.recentDirectTxBatchSeq++).toString(); + const id = (this.recentTxExecutionSeq++).toString(); - const batchBase: DirectTxBatchBase = { + const executionBase: TxExecutionBase = { id, - status: DirectTxBatchStatus.PENDING, + status: TxExecutionStatus.PENDING, vaultId: vaultId, txs: txs, txIndex: -1, @@ -136,126 +135,126 @@ export class BackgroundTxExecutorService { timestamp: Date.now(), }; - let batch: DirectTxBatch; - if (type === DirectTxBatchType.SWAP_V2) { - batch = { - ...batchBase, - type: DirectTxBatchType.SWAP_V2, + let execution: TxExecution; + if (type === TxExecutionType.SWAP_V2) { + execution = { + ...executionBase, + type: TxExecutionType.SWAP_V2, // TODO: add swap history data... swapHistoryData: { chainId: txs[0].chainId, }, }; - } else if (type === DirectTxBatchType.IBC_TRANSFER) { - batch = { - ...batchBase, - type: DirectTxBatchType.IBC_TRANSFER, + } else if (type === TxExecutionType.IBC_TRANSFER) { + execution = { + ...executionBase, + type: TxExecutionType.IBC_TRANSFER, // TODO: add ibc history data... ibcHistoryData: { chainId: txs[0].chainId, }, }; } else { - batch = { - ...batchBase, - type: DirectTxBatchType.UNDEFINED, + execution = { + ...executionBase, + type: TxExecutionType.UNDEFINED, }; } - this.recentDirectTxBatchMap.set(id, batch); - return await this.executeDirectTxs(id); + this.recentTxExecutionMap.set(id, execution); + return await this.executeTxs(id); } /** * Execute blocked transactions by execution id and transaction index */ @action - async resumeDirectTxs( + async resumeTx( env: Env, id: string, txIndex?: number, signedTx?: Uint8Array - ): Promise { + ): Promise { if (!env.isInternalMsg) { // TODO: 에러 코드 신경쓰기 throw new KeplrError("direct-tx-executor", 101, "Not internal message"); } - return await this.executeDirectTxs(id, { + return await this.executeTxs(id, { env, txIndex, signedTx, }); } - protected async executeDirectTxs( + protected async executeTxs( id: string, options?: { env?: Env; txIndex?: number; signedTx?: Uint8Array; } - ): Promise { - const batch = this.getDirectTxBatch(id); - if (!batch) { + ): Promise { + const execution = this.getTxExecution(id); + if (!execution) { throw new KeplrError("direct-tx-executor", 105, "Execution not found"); } // Only pending/processing/blocked executions can be executed const needResume = - batch.status === DirectTxBatchStatus.PENDING || - batch.status === DirectTxBatchStatus.PROCESSING || - batch.status === DirectTxBatchStatus.BLOCKED; + execution.status === TxExecutionStatus.PENDING || + execution.status === TxExecutionStatus.PROCESSING || + execution.status === TxExecutionStatus.BLOCKED; if (!needResume) { - return batch.status; + return execution.status; } // check if the key is valid const keyInfo = this.keyRingCosmosService.keyRingService.getKeyInfo( - batch.vaultId + execution.vaultId ); if (!keyInfo) { throw new KeplrError("direct-tx-executor", 102, "Key info not found"); } const executionStartIndex = Math.min( - options?.txIndex ?? batch.txIndex < 0 ? 0 : batch.txIndex, - batch.txs.length - 1 + options?.txIndex ?? execution.txIndex < 0 ? 0 : execution.txIndex, + execution.txs.length - 1 ); - batch.status = DirectTxBatchStatus.PROCESSING; + execution.status = TxExecutionStatus.PROCESSING; - for (let i = executionStartIndex; i < batch.txs.length; i++) { - let txStatus: DirectTxStatus; + for (let i = executionStartIndex; i < execution.txs.length; i++) { + let txStatus: BackgroundTxStatus; if (options?.txIndex != null && i === options.txIndex) { - txStatus = await this.executePendingDirectTx(id, i, { + txStatus = await this.executePendingTx(id, i, { env: options?.env, signedTx: options.signedTx, }); } else { - txStatus = await this.executePendingDirectTx(id, i, { + txStatus = await this.executePendingTx(id, i, { env: options?.env, }); } - if (txStatus === DirectTxStatus.CONFIRMED) { + if (txStatus === BackgroundTxStatus.CONFIRMED) { continue; } // if the tx is blocked, it means multiple transactions are required to be executed on different chains // the execution should be stopped and record the history if needed // and the execution should be resumed later when the condition is met - if (txStatus === DirectTxStatus.BLOCKED) { - batch.status = DirectTxBatchStatus.BLOCKED; - this.recordHistoryIfNeeded(batch); - return batch.status; + if (txStatus === BackgroundTxStatus.BLOCKED) { + execution.status = TxExecutionStatus.BLOCKED; + this.recordHistoryIfNeeded(execution); + return execution.status; } // if the tx is failed, the execution should be stopped - if (txStatus === DirectTxStatus.FAILED) { - batch.status = DirectTxBatchStatus.FAILED; - return batch.status; + if (txStatus === BackgroundTxStatus.FAILED) { + execution.status = TxExecutionStatus.FAILED; + return execution.status; } // something went wrong, should not happen @@ -267,58 +266,60 @@ export class BackgroundTxExecutorService { } // if the execution is completed successfully, update the batch status - batch.status = DirectTxBatchStatus.COMPLETED; - this.recordHistoryIfNeeded(batch); - return batch.status; + execution.status = TxExecutionStatus.COMPLETED; + this.recordHistoryIfNeeded(execution); + return execution.status; } - protected async executePendingDirectTx( + protected async executePendingTx( id: string, index: number, options?: { env?: Env; signedTx?: Uint8Array; } - ): Promise { - const batch = this.getDirectTxBatch(id); - if (!batch) { + ): Promise { + const execution = this.getTxExecution(id); + if (!execution) { throw new KeplrError("direct-tx-executor", 105, "Execution not found"); } - const currentTx = batch.txs[index]; + const currentTx = execution.txs[index]; if (!currentTx) { throw new KeplrError("direct-tx-executor", 106, "Tx not found"); } // these statuses are not expected to be reached for pending transactions if ( - currentTx.status === DirectTxStatus.CONFIRMED || - currentTx.status === DirectTxStatus.FAILED || - currentTx.status === DirectTxStatus.CANCELLED + currentTx.status === BackgroundTxStatus.CONFIRMED || + currentTx.status === BackgroundTxStatus.FAILED || + currentTx.status === BackgroundTxStatus.CANCELLED ) { return currentTx.status; } // update the tx index to the current tx index - batch.txIndex = index; + execution.txIndex = index; if ( - currentTx.status === DirectTxStatus.BLOCKED || - currentTx.status === DirectTxStatus.PENDING + currentTx.status === BackgroundTxStatus.BLOCKED || + currentTx.status === BackgroundTxStatus.PENDING ) { // TODO: check if the condition is met to resume the execution // this will be handled with recent send history tracking to check if the condition is met to resume the execution // check if the current transaction's chainId is included in the chainIds of the recent send history (might enough with this) - const isBlocked = !batch.executableChainIds.includes(currentTx.chainId); + const isBlocked = !execution.executableChainIds.includes( + currentTx.chainId + ); if (isBlocked) { - currentTx.status = DirectTxStatus.BLOCKED; + currentTx.status = BackgroundTxStatus.BLOCKED; return currentTx.status; } else { - currentTx.status = DirectTxStatus.SIGNING; + currentTx.status = BackgroundTxStatus.SIGNING; } } - if (currentTx.status === DirectTxStatus.SIGNING) { + if (currentTx.status === BackgroundTxStatus.SIGNING) { // if options are provided, temporary set the options to the current transaction if (options?.signedTx) { currentTx.signedTx = options.signedTx; @@ -326,47 +327,47 @@ export class BackgroundTxExecutorService { try { const { signedTx } = await this.signTx( - batch.vaultId, + execution.vaultId, currentTx.chainId, currentTx, options?.env ); currentTx.signedTx = signedTx; - currentTx.status = DirectTxStatus.SIGNED; + currentTx.status = BackgroundTxStatus.SIGNED; } catch (error) { - currentTx.status = DirectTxStatus.FAILED; + currentTx.status = BackgroundTxStatus.FAILED; currentTx.error = error.message ?? "Transaction signing failed"; } } if ( - currentTx.status === DirectTxStatus.SIGNED || - currentTx.status === DirectTxStatus.BROADCASTING + currentTx.status === BackgroundTxStatus.SIGNED || + currentTx.status === BackgroundTxStatus.BROADCASTING ) { try { const { txHash } = await this.broadcastTx(currentTx); currentTx.txHash = txHash; - currentTx.status = DirectTxStatus.BROADCASTED; + currentTx.status = BackgroundTxStatus.BROADCASTED; } catch (error) { - currentTx.status = DirectTxStatus.FAILED; + currentTx.status = BackgroundTxStatus.FAILED; currentTx.error = error.message ?? "Transaction broadcasting failed"; } } - if (currentTx.status === DirectTxStatus.BROADCASTED) { + if (currentTx.status === BackgroundTxStatus.BROADCASTED) { // broadcasted -> confirmed try { const confirmed = await this.traceTx(currentTx); if (confirmed) { - currentTx.status = DirectTxStatus.CONFIRMED; + currentTx.status = BackgroundTxStatus.CONFIRMED; } else { - currentTx.status = DirectTxStatus.FAILED; + currentTx.status = BackgroundTxStatus.FAILED; currentTx.error = "Transaction failed"; } } catch (error) { - currentTx.status = DirectTxStatus.FAILED; + currentTx.status = BackgroundTxStatus.FAILED; currentTx.error = error.message ?? "Transaction confirmation failed"; } } @@ -377,7 +378,7 @@ export class BackgroundTxExecutorService { protected async signTx( vaultId: string, chainId: string, - tx: DirectTx, + tx: BackgroundTx, env?: Env ): Promise<{ signedTx: Uint8Array; @@ -388,7 +389,7 @@ export class BackgroundTxExecutorService { }; } - if (tx.type === DirectTxType.EVM) { + if (tx.type === BackgroundTxType.EVM) { return this.signEvmTx(vaultId, chainId, tx, env); } @@ -398,7 +399,7 @@ export class BackgroundTxExecutorService { private async signEvmTx( vaultId: string, chainId: string, - tx: EVMDirectTx, + tx: EVMBackgroundTx, env?: Env ): Promise<{ signedTx: Uint8Array; @@ -466,7 +467,7 @@ export class BackgroundTxExecutorService { private async signCosmosTx( vaultId: string, chainId: string, - tx: CosmosDirectTx, + tx: CosmosBackgroundTx, env?: Env ): Promise<{ signedTx: Uint8Array; @@ -524,7 +525,7 @@ export class BackgroundTxExecutorService { }; } - protected async broadcastTx(tx: DirectTx): Promise<{ + protected async broadcastTx(tx: BackgroundTx): Promise<{ txHash: string; }> { if (tx.txHash != null) { @@ -536,14 +537,14 @@ export class BackgroundTxExecutorService { }; } - if (tx.type === DirectTxType.EVM) { + if (tx.type === BackgroundTxType.EVM) { return this.broadcastEvmTx(tx); } return this.broadcastCosmosTx(tx); } - private async broadcastEvmTx(tx: EVMDirectTx): Promise<{ + private async broadcastEvmTx(tx: EVMBackgroundTx): Promise<{ txHash: string; }> { // check signed tx and signature @@ -575,7 +576,7 @@ export class BackgroundTxExecutorService { }; } - private async broadcastCosmosTx(tx: CosmosDirectTx): Promise<{ + private async broadcastCosmosTx(tx: CosmosBackgroundTx): Promise<{ txHash: string; }> { if (!tx.signedTx) { @@ -598,15 +599,15 @@ export class BackgroundTxExecutorService { }; } - protected async traceTx(tx: DirectTx): Promise { - if (tx.type === DirectTxType.EVM) { + protected async traceTx(tx: BackgroundTx): Promise { + if (tx.type === BackgroundTxType.EVM) { return this.traceEvmTx(tx); } return this.traceCosmosTx(tx); } - private async traceEvmTx(tx: EVMDirectTx): Promise { + private async traceEvmTx(tx: EVMBackgroundTx): Promise { if (!tx.txHash) { throw new KeplrError("direct-tx-executor", 108, "Tx hash not found"); } @@ -629,7 +630,7 @@ export class BackgroundTxExecutorService { return txReceipt.status === EthTxStatus.Success; } - private async traceCosmosTx(tx: CosmosDirectTx): Promise { + private async traceCosmosTx(tx: CosmosBackgroundTx): Promise { if (!tx.txHash) { throw new KeplrError("direct-tx-executor", 108, "Tx hash not found"); } @@ -645,59 +646,59 @@ export class BackgroundTxExecutorService { return txResult.code === 0; } - protected recordHistoryIfNeeded(_batch: DirectTxBatch): void { + protected recordHistoryIfNeeded(_execution: TxExecution): void { throw new Error("Not implemented"); } /** * Get all recent direct transactions executions */ - getRecentDirectTxBatches(): DirectTxBatch[] { - return Array.from(this.recentDirectTxBatchMap.values()); + getRecentTxExecutions(): TxExecution[] { + return Array.from(this.recentTxExecutionMap.values()); } /** * Get execution data by ID */ - getDirectTxBatch(id: string): DirectTxBatch | undefined { - const batch = this.recentDirectTxBatchMap.get(id); - if (!batch) { + getTxExecution(id: string): TxExecution | undefined { + const execution = this.recentTxExecutionMap.get(id); + if (!execution) { return undefined; } - return batch; + return execution; } /** * Cancel execution by execution id */ @action - async cancelDirectTxs(id: string): Promise { - const batch = this.recentDirectTxBatchMap.get(id); - if (!batch) { + async cancelTxExecution(id: string): Promise { + const execution = this.recentTxExecutionMap.get(id); + if (!execution) { return; } - const currentStatus = batch.status; + const currentStatus = execution.status; // Only pending or processing executions can be cancelled if ( - currentStatus !== DirectTxBatchStatus.PENDING && - currentStatus !== DirectTxBatchStatus.PROCESSING + currentStatus !== TxExecutionStatus.PENDING && + currentStatus !== TxExecutionStatus.PROCESSING ) { return; } // CHECK: cancellation is really needed? - batch.status = DirectTxBatchStatus.CANCELLED; + execution.status = TxExecutionStatus.CANCELLED; - if (currentStatus === DirectTxBatchStatus.PROCESSING) { + if (currentStatus === TxExecutionStatus.PROCESSING) { // TODO: cancel the current transaction execution... } } @action - protected removeDirectTxBatch(id: string): void { - this.recentDirectTxBatchMap.delete(id); + protected removeTxExecution(id: string): void { + this.recentTxExecutionMap.delete(id); } } diff --git a/packages/background/src/tx-executor/types.ts b/packages/background/src/tx-executor/types.ts index 55b3569c8a..45699b7378 100644 --- a/packages/background/src/tx-executor/types.ts +++ b/packages/background/src/tx-executor/types.ts @@ -2,7 +2,7 @@ import { UnsignedTransaction } from "@ethersproject/transactions"; import { StdSignDoc } from "@keplr-wallet/types"; // Transaction status -export enum DirectTxStatus { +export enum BackgroundTxStatus { PENDING = "pending", SIGNING = "signing", SIGNED = "signed", @@ -15,14 +15,14 @@ export enum DirectTxStatus { } // Transaction type -export enum DirectTxType { +export enum BackgroundTxType { EVM = "evm", COSMOS = "cosmos", } // Base transaction interface -interface DirectTxBase { - status: DirectTxStatus; // mutable while executing +interface BackgroundTxBase { + status: BackgroundTxStatus; // mutable while executing readonly chainId: string; // signed transaction data @@ -35,20 +35,20 @@ interface DirectTxBase { error?: string; } -export interface EVMDirectTx extends DirectTxBase { - readonly type: DirectTxType.EVM; +export interface EVMBackgroundTx extends BackgroundTxBase { + readonly type: BackgroundTxType.EVM; readonly txData: UnsignedTransaction; } -export interface CosmosDirectTx extends DirectTxBase { - readonly type: DirectTxType.COSMOS; +export interface CosmosBackgroundTx extends BackgroundTxBase { + readonly type: BackgroundTxType.COSMOS; readonly txData: StdSignDoc; } // Single transaction data with discriminated union based on type -export type DirectTx = EVMDirectTx | CosmosDirectTx; +export type BackgroundTx = EVMBackgroundTx | CosmosBackgroundTx; -export enum DirectTxBatchStatus { +export enum TxExecutionStatus { PENDING = "pending", PROCESSING = "processing", BLOCKED = "blocked", @@ -57,21 +57,21 @@ export enum DirectTxBatchStatus { CANCELLED = "cancelled", } -export enum DirectTxBatchType { +export enum TxExecutionType { UNDEFINED = "undefined", IBC_TRANSFER = "ibc-transfer", SWAP_V2 = "swap-v2", } -export interface DirectTxBatchBase { +export interface TxExecutionBase { readonly id: string; - status: DirectTxBatchStatus; + status: TxExecutionStatus; // keyring vault id readonly vaultId: string; // transactions - readonly txs: DirectTx[]; + readonly txs: BackgroundTx[]; txIndex: number; // Current transaction being processed executableChainIds: string[]; // executable chain ids @@ -79,23 +79,29 @@ export interface DirectTxBatchBase { readonly timestamp: number; // Timestamp when execution started } -export type DirectTxBatch = - | (DirectTxBatchBase & { - readonly type: DirectTxBatchType.UNDEFINED; - }) - | (DirectTxBatchBase & { - readonly type: DirectTxBatchType.SWAP_V2; - swapHistoryId?: string; - // TODO: add more required fields for swap history data - readonly swapHistoryData: { - readonly chainId: string; - }; - }) - | (DirectTxBatchBase & { - readonly type: DirectTxBatchType.IBC_TRANSFER; - readonly ibcHistoryId?: string; - // TODO: add more required fields for ibc history data - readonly ibcHistoryData: { - readonly chainId: string; - }; - }); +export interface UndefinedTxExecution extends TxExecutionBase { + readonly type: TxExecutionType.UNDEFINED; +} + +export interface IBCTransferTxExecution extends TxExecutionBase { + readonly type: TxExecutionType.IBC_TRANSFER; + readonly ibcHistoryId?: string; + // TODO: add more required fields for ibc history data + readonly ibcHistoryData: { + readonly chainId: string; + }; +} + +export interface SwapV2TxExecution extends TxExecutionBase { + readonly type: TxExecutionType.SWAP_V2; + readonly swapHistoryId?: string; + // TODO: add more required fields for swap history data + readonly swapHistoryData: { + readonly chainId: string; + }; +} + +export type TxExecution = + | UndefinedTxExecution + | SwapV2TxExecution + | IBCTransferTxExecution; diff --git a/packages/stores/src/account/cosmos.ts b/packages/stores/src/account/cosmos.ts index 7bfca015c6..7c3ee237a6 100644 --- a/packages/stores/src/account/cosmos.ts +++ b/packages/stores/src/account/cosmos.ts @@ -449,6 +449,7 @@ export class CosmosAccountImpl { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const keplr = (await this.base.getKeplr())!; + // NOTE: CHECK THIS LOGIC FOR BACKGROUND TX EXECUTOR const signedTx = await (async () => { if (isDirectSign) { return await this.createSignedTxWithDirectSign( From a6dc2dfb2b1449de4d821fcbe93311f6d4097f5c Mon Sep 17 00:00:00 2001 From: rowan Date: Fri, 28 Nov 2025 11:35:12 +0900 Subject: [PATCH 13/29] handle history record in background tx executor --- .../src/recent-send-history/service.ts | 12 +- .../src/recent-send-history/types.ts | 4 + .../background/src/tx-executor/handler.ts | 3 +- .../background/src/tx-executor/messages.ts | 4 +- .../background/src/tx-executor/service.ts | 205 +++++++++++++++--- packages/background/src/tx-executor/types.ts | 138 +++++++++++- 6 files changed, 323 insertions(+), 43 deletions(-) diff --git a/packages/background/src/recent-send-history/service.ts b/packages/background/src/recent-send-history/service.ts index f53b9fdc0c..c381368164 100644 --- a/packages/background/src/recent-send-history/service.ts +++ b/packages/background/src/recent-send-history/service.ts @@ -1062,7 +1062,8 @@ export class RecentSendHistoryService { notificationInfo: { currencies: AppCurrency[]; }, - txHash: Uint8Array + txHash: Uint8Array, + backgroundExecutionId?: string ): string { const id = (this.recentIBCHistorySeq++).toString(); @@ -1087,6 +1088,7 @@ export class RecentSendHistoryService { }), notificationInfo, txHash: Buffer.from(txHash).toString("hex"), + backgroundExecutionId: backgroundExecutionId, }; this.recentIBCHistoryMap.set(id, history); @@ -1120,7 +1122,8 @@ export class RecentSendHistoryService { notificationInfo: { currencies: AppCurrency[]; }, - txHash: Uint8Array + txHash: Uint8Array, + backgroundExecutionId?: string ): string { const id = (this.recentIBCHistorySeq++).toString(); @@ -1149,6 +1152,7 @@ export class RecentSendHistoryService { resAmount: [], notificationInfo, txHash: Buffer.from(txHash).toString("hex"), + backgroundExecutionId: backgroundExecutionId, }; this.recentIBCHistoryMap.set(id, history); @@ -2136,7 +2140,8 @@ export class RecentSendHistoryService { }, routeDurationSeconds: number = 0, txHash: string, - isOnlyUseBridge?: boolean + isOnlyUseBridge?: boolean, + backgroundExecutionId?: string ): string { const id = (this.recentSwapV2HistorySeq++).toString(); @@ -2160,6 +2165,7 @@ export class RecentSendHistoryService { resAmount: [], swapRefundInfo: undefined, notified: undefined, + backgroundExecutionId: backgroundExecutionId, }; this.recentSwapV2HistoryMap.set(id, history); diff --git a/packages/background/src/recent-send-history/types.ts b/packages/background/src/recent-send-history/types.ts index 1ad7072f76..a123acad98 100644 --- a/packages/background/src/recent-send-history/types.ts +++ b/packages/background/src/recent-send-history/types.ts @@ -34,6 +34,8 @@ export type IBCHistory = { txHash: string; + backgroundExecutionId?: string; + txFulfilled?: boolean; txError?: string; packetTimeout?: boolean; @@ -564,6 +566,8 @@ export interface SwapV2History { }[]; }; + backgroundExecutionId?: string; + trackDone?: boolean; // status tracking이 완료되었는지 여부 trackError?: string; // status tracking 중 에러가 발생했는지 여부 diff --git a/packages/background/src/tx-executor/handler.ts b/packages/background/src/tx-executor/handler.ts index 833302a423..cee5802534 100644 --- a/packages/background/src/tx-executor/handler.ts +++ b/packages/background/src/tx-executor/handler.ts @@ -47,7 +47,8 @@ const handleRecordAndExecuteTxsMsg: ( msg.vaultId, msg.executionType, msg.txs, - msg.executableChainIds + msg.executableChainIds, + msg.historyData ); }; }; diff --git a/packages/background/src/tx-executor/messages.ts b/packages/background/src/tx-executor/messages.ts index 8a88630afd..459ced9768 100644 --- a/packages/background/src/tx-executor/messages.ts +++ b/packages/background/src/tx-executor/messages.ts @@ -5,6 +5,7 @@ import { TxExecutionType, TxExecutionStatus, TxExecution, + HistoryData, } from "./types"; /** @@ -22,7 +23,8 @@ export class RecordAndExecuteTxsMsg extends Message { public readonly vaultId: string, public readonly executionType: TxExecutionType, public readonly txs: BackgroundTx[], - public readonly executableChainIds: string[] + public readonly executableChainIds: string[], + public readonly historyData?: HistoryData ) { super(); } diff --git a/packages/background/src/tx-executor/service.ts b/packages/background/src/tx-executor/service.ts index 2123998540..2fc2632193 100644 --- a/packages/background/src/tx-executor/service.ts +++ b/packages/background/src/tx-executor/service.ts @@ -17,6 +17,11 @@ import { BackgroundTxType, EVMBackgroundTx, CosmosBackgroundTx, + HistoryData, + SwapV2HistoryData, + IBCTransferHistoryData, + IBCSwapHistoryData, + RecentSendHistoryData, } from "./types"; import { action, @@ -113,7 +118,8 @@ export class BackgroundTxExecutorService { vaultId: string, type: TxExecutionType, txs: BackgroundTx[], - executableChainIds: string[] + executableChainIds: string[], + historyData?: HistoryData ): Promise { if (!env.isInternalMsg) { throw new KeplrError("direct-tx-executor", 101, "Not internal message"); @@ -136,29 +142,47 @@ export class BackgroundTxExecutorService { }; let execution: TxExecution; - if (type === TxExecutionType.SWAP_V2) { - execution = { - ...executionBase, - type: TxExecutionType.SWAP_V2, - // TODO: add swap history data... - swapHistoryData: { - chainId: txs[0].chainId, - }, - }; - } else if (type === TxExecutionType.IBC_TRANSFER) { - execution = { - ...executionBase, - type: TxExecutionType.IBC_TRANSFER, - // TODO: add ibc history data... - ibcHistoryData: { - chainId: txs[0].chainId, - }, - }; - } else { - execution = { - ...executionBase, - type: TxExecutionType.UNDEFINED, - }; + + switch (type) { + case TxExecutionType.SWAP_V2: { + execution = { + ...executionBase, + type: TxExecutionType.SWAP_V2, + swapHistoryData: historyData as SwapV2HistoryData, + }; + break; + } + case TxExecutionType.IBC_TRANSFER: { + execution = { + ...executionBase, + type: TxExecutionType.IBC_TRANSFER, + ibcHistoryData: historyData as IBCTransferHistoryData, + }; + break; + } + case TxExecutionType.IBC_SWAP: { + execution = { + ...executionBase, + type: TxExecutionType.IBC_SWAP, + ibcHistoryData: historyData as IBCSwapHistoryData, + }; + break; + } + case TxExecutionType.SEND: { + execution = { + ...executionBase, + type: TxExecutionType.SEND, + sendHistoryData: historyData as RecentSendHistoryData, + }; + break; + } + default: { + execution = { + ...executionBase, + type: TxExecutionType.UNDEFINED, + }; + break; + } } this.recentTxExecutionMap.set(id, execution); @@ -646,8 +670,137 @@ export class BackgroundTxExecutorService { return txResult.code === 0; } - protected recordHistoryIfNeeded(_execution: TxExecution): void { - throw new Error("Not implemented"); + protected recordHistoryIfNeeded(execution: TxExecution): void { + if (execution.type === TxExecutionType.UNDEFINED) { + return; + } + + if (execution.type === TxExecutionType.SEND) { + if (execution.hasRecordedHistory || !execution.sendHistoryData) { + return; + } + + const sendHistoryData = execution.sendHistoryData; + + this.recentSendHistoryService.addRecentSendHistory( + sendHistoryData.chainId, + sendHistoryData.historyType, + { + sender: sendHistoryData.sender, + recipient: sendHistoryData.recipient, + amount: sendHistoryData.amount, + memo: sendHistoryData.memo, + ibcChannels: undefined, + } + ); + + execution.hasRecordedHistory = true; + return; + } + + if (execution.type === TxExecutionType.IBC_TRANSFER) { + if (execution.ibcHistoryId != null || !execution.ibcHistoryData) { + return; + } + + // first tx should be a cosmos tx and it should have a tx hash + const tx = execution.txs[0]; + if (!tx || tx.type !== BackgroundTxType.COSMOS) { + return; + } + + if (tx.txHash == null) { + return; + } + + const ibcHistoryData = execution.ibcHistoryData; + + // TODO: 기록할 때 execution id를 넘겨줘야 함 + const id = this.recentSendHistoryService.addRecentIBCTransferHistory( + ibcHistoryData.sourceChainId, + ibcHistoryData.destinationChainId, + ibcHistoryData.sender, + ibcHistoryData.recipient, + ibcHistoryData.amount, + ibcHistoryData.memo, + ibcHistoryData.channels, + ibcHistoryData.notificationInfo, + Buffer.from(tx.txHash, "hex"), + execution.id + ); + + execution.ibcHistoryId = id; + return; + } + + if (execution.type === TxExecutionType.IBC_SWAP) { + if (execution.ibcHistoryId != null || !execution.ibcHistoryData) { + return; + } + + // first tx should be a cosmos tx and it should have a tx hash + const tx = execution.txs[0]; + if (!tx || tx.type !== BackgroundTxType.COSMOS) { + return; + } + + if (tx.txHash == null) { + return; + } + + const ibcHistoryData = execution.ibcHistoryData; + + const id = this.recentSendHistoryService.addRecentIBCSwapHistory( + ibcHistoryData.swapType, + ibcHistoryData.chainId, + ibcHistoryData.destinationChainId, + ibcHistoryData.sender, + ibcHistoryData.amount, + ibcHistoryData.memo, + ibcHistoryData.ibcChannels, + ibcHistoryData.destinationAsset, + ibcHistoryData.swapChannelIndex, + ibcHistoryData.swapReceiver, + ibcHistoryData.notificationInfo, + Buffer.from(tx.txHash, "hex"), + execution.id + ); + + execution.ibcHistoryId = id; + return; + } + + if (execution.type === TxExecutionType.SWAP_V2) { + if (execution.swapHistoryId != null || !execution.swapHistoryData) { + return; + } + + // first tx should exist and it should have a tx hash + const tx = execution.txs[0]; + if (!tx || tx.txHash == null) { + return; + } + + const swapHistoryData = execution.swapHistoryData; + + const id = this.recentSendHistoryService.recordTxWithSwapV2( + swapHistoryData.fromChainId, + swapHistoryData.toChainId, + swapHistoryData.provider, + swapHistoryData.destinationAsset, + swapHistoryData.simpleRoute, + swapHistoryData.sender, + swapHistoryData.recipient, + swapHistoryData.amount, + swapHistoryData.notificationInfo, + swapHistoryData.routeDurationSeconds, + tx.txHash, + swapHistoryData.isOnlyUseBridge, + execution.id + ); + + execution.swapHistoryId = id; + } } /** diff --git a/packages/background/src/tx-executor/types.ts b/packages/background/src/tx-executor/types.ts index 45699b7378..fbb9b383d0 100644 --- a/packages/background/src/tx-executor/types.ts +++ b/packages/background/src/tx-executor/types.ts @@ -1,5 +1,6 @@ import { UnsignedTransaction } from "@ethersproject/transactions"; -import { StdSignDoc } from "@keplr-wallet/types"; +import { AppCurrency, StdSignDoc } from "@keplr-wallet/types"; +import { SwapProvider } from "../recent-send-history"; // Transaction status export enum BackgroundTxStatus { @@ -59,7 +60,9 @@ export enum TxExecutionStatus { export enum TxExecutionType { UNDEFINED = "undefined", + SEND = "send", IBC_TRANSFER = "ibc-transfer", + IBC_SWAP = "ibc-swap", SWAP_V2 = "swap-v2", } @@ -83,25 +86,136 @@ export interface UndefinedTxExecution extends TxExecutionBase { readonly type: TxExecutionType.UNDEFINED; } +export interface RecentSendHistoryData { + readonly chainId: string; + readonly historyType: string; + readonly sender: string; + readonly recipient: string; + readonly amount: { + readonly amount: string; + readonly denom: string; + }[]; + readonly memo: string; + ibcChannels: + | { + portId: string; + channelId: string; + counterpartyChainId: string; + }[] + | undefined; +} + +export interface SendTxExecution extends TxExecutionBase { + readonly type: TxExecutionType.SEND; + + hasRecordedHistory?: boolean; + readonly sendHistoryData?: RecentSendHistoryData; +} + +export interface IBCTransferHistoryData { + readonly historyType: string; + readonly sourceChainId: string; + readonly destinationChainId: string; + readonly channels: { + portId: string; + channelId: string; + counterpartyChainId: string; + }[]; + readonly sender: string; + readonly recipient: string; + readonly amount: { + readonly amount: string; + readonly denom: string; + }[]; + readonly memo: string; + readonly notificationInfo: { + readonly currencies: AppCurrency[]; + }; +} + export interface IBCTransferTxExecution extends TxExecutionBase { readonly type: TxExecutionType.IBC_TRANSFER; - readonly ibcHistoryId?: string; - // TODO: add more required fields for ibc history data - readonly ibcHistoryData: { - readonly chainId: string; + + ibcHistoryId?: string; + readonly ibcHistoryData?: IBCTransferHistoryData; +} + +export interface IBCSwapHistoryData { + readonly swapType: "amount-in" | "amount-out"; + readonly chainId: string; + readonly destinationChainId: string; + readonly sender: string; + readonly amount: { + amount: string; + denom: string; + }[]; + readonly memo: string; + readonly ibcChannels: + | { + portId: string; + channelId: string; + counterpartyChainId: string; + }[]; + readonly destinationAsset: { + chainId: string; + denom: string; + }; + readonly swapChannelIndex: number; + readonly swapReceiver: string[]; + readonly notificationInfo: { + currencies: AppCurrency[]; }; } +export interface IBCSwapTxExecution extends TxExecutionBase { + readonly type: TxExecutionType.IBC_SWAP; + ibcHistoryId?: string; + readonly ibcHistoryData?: IBCSwapHistoryData; +} + +export interface SwapV2HistoryData { + readonly fromChainId: string; + readonly toChainId: string; + readonly provider: SwapProvider; + readonly destinationAsset: { + chainId: string; + denom: string; + expectedAmount: string; + }; + readonly simpleRoute: { + isOnlyEvm: boolean; + chainId: string; + receiver: string; + }[]; + readonly sender: string; + readonly recipient: string; + readonly amount: { + readonly amount: string; + readonly denom: string; + }[]; + readonly notificationInfo: { + currencies: AppCurrency[]; + }; + readonly routeDurationSeconds: number; + readonly isOnlyUseBridge?: boolean; +} + export interface SwapV2TxExecution extends TxExecutionBase { readonly type: TxExecutionType.SWAP_V2; - readonly swapHistoryId?: string; - // TODO: add more required fields for swap history data - readonly swapHistoryData: { - readonly chainId: string; - }; + + swapHistoryId?: string; + readonly swapHistoryData?: SwapV2HistoryData; } +export type HistoryData = + | RecentSendHistoryData + | IBCTransferHistoryData + | IBCSwapHistoryData + | SwapV2HistoryData; + export type TxExecution = | UndefinedTxExecution - | SwapV2TxExecution - | IBCTransferTxExecution; + | SendTxExecution + | IBCTransferTxExecution + | IBCSwapTxExecution + | SwapV2TxExecution; From a056ff76fbf890bdf27dd70ad0bef7a55de786ff Mon Sep 17 00:00:00 2001 From: rowan Date: Fri, 28 Nov 2025 14:27:32 +0900 Subject: [PATCH 14/29] minor refactor --- .../background/src/tx-executor/messages.ts | 13 +- .../background/src/tx-executor/service.ts | 190 ++++++++---------- packages/background/src/tx-executor/types.ts | 25 ++- 3 files changed, 114 insertions(+), 114 deletions(-) diff --git a/packages/background/src/tx-executor/messages.ts b/packages/background/src/tx-executor/messages.ts index 459ced9768..95d8e5764f 100644 --- a/packages/background/src/tx-executor/messages.ts +++ b/packages/background/src/tx-executor/messages.ts @@ -5,7 +5,7 @@ import { TxExecutionType, TxExecutionStatus, TxExecution, - HistoryData, + ExecutionTypeToHistoryData, } from "./types"; /** @@ -13,18 +13,21 @@ import { * execution id is returned if the transactions are recorded successfully * and the execution will be started automatically after the transactions are recorded. */ -export class RecordAndExecuteTxsMsg extends Message { +export class RecordAndExecuteTxsMsg< + T extends TxExecutionType = TxExecutionType +> extends Message { public static type() { return "record-and-execute-txs"; } - //TODO: add history data... constructor( public readonly vaultId: string, - public readonly executionType: TxExecutionType, + public readonly executionType: T, public readonly txs: BackgroundTx[], public readonly executableChainIds: string[], - public readonly historyData?: HistoryData + public readonly historyData?: T extends TxExecutionType.UNDEFINED + ? undefined + : ExecutionTypeToHistoryData[T] ) { super(); } diff --git a/packages/background/src/tx-executor/service.ts b/packages/background/src/tx-executor/service.ts index 2fc2632193..487c925b1a 100644 --- a/packages/background/src/tx-executor/service.ts +++ b/packages/background/src/tx-executor/service.ts @@ -11,17 +11,12 @@ import { TxExecution, TxExecutionStatus, TxExecutionType, - TxExecutionBase, BackgroundTx, BackgroundTxStatus, BackgroundTxType, EVMBackgroundTx, CosmosBackgroundTx, - HistoryData, - SwapV2HistoryData, - IBCTransferHistoryData, - IBCSwapHistoryData, - RecentSendHistoryData, + ExecutionTypeToHistoryData, } from "./types"; import { action, @@ -113,25 +108,58 @@ export class BackgroundTxExecutorService { * and the execution will be started automatically after the transactions are recorded. */ @action - async recordAndExecuteTxs( + async recordAndExecuteTxs( env: Env, vaultId: string, - type: TxExecutionType, + type: T, txs: BackgroundTx[], executableChainIds: string[], - historyData?: HistoryData + historyData?: T extends TxExecutionType.UNDEFINED + ? undefined + : ExecutionTypeToHistoryData[T] ): Promise { if (!env.isInternalMsg) { throw new KeplrError("direct-tx-executor", 101, "Not internal message"); } + const keyInfo = + this.keyRingCosmosService.keyRingService.getKeyInfo(vaultId); + if (!keyInfo) { + throw new KeplrError("direct-tx-executor", 102, "Key info not found"); + } + + // validation for hardware wallets + if (keyInfo.type === "ledger" || keyInfo.type === "keystone") { + // at least first tx should be signed with hardware wallet + // as signing on background is not supported for hardware wallets + const firstTx = txs[0]; + if (!firstTx) { + throw new KeplrError( + "direct-tx-executor", + 103, + "First tx is not found" + ); + } + + if ( + firstTx.signedTx == null || + !executableChainIds.includes(firstTx.chainId) + ) { + throw new KeplrError( + "direct-tx-executor", + 104, + "First tx should be signed and executable" + ); + } + } + // CHECK: 다중 체인 트랜잭션이 아니라면 굳이 이걸 기록할 필요가 있을까? // 다중 체인 트랜잭션을 기록하는 이유는 자산 브릿징 등 상당한 시간이 걸리는 경우 이 작업을 백그라운드에서 한없이 기다리는 대신 // 실행 조건이 만족되었을 때 이어서 실행하기 위함인데, 한 번에 처리가 가능하다면 굳이 이걸 기록할 필요는 없을지도 모른다. // 특히나 ui에서 진행상황을 체크하는 것이 아닌 이상 notification을 통해 진행상황을 알리는 것으로 충분할 수 있다. const id = (this.recentTxExecutionSeq++).toString(); - const executionBase: TxExecutionBase = { + const execution = { id, status: TxExecutionStatus.PENDING, vaultId: vaultId, @@ -139,51 +167,9 @@ export class BackgroundTxExecutorService { txIndex: -1, executableChainIds: executableChainIds, timestamp: Date.now(), - }; - - let execution: TxExecution; - - switch (type) { - case TxExecutionType.SWAP_V2: { - execution = { - ...executionBase, - type: TxExecutionType.SWAP_V2, - swapHistoryData: historyData as SwapV2HistoryData, - }; - break; - } - case TxExecutionType.IBC_TRANSFER: { - execution = { - ...executionBase, - type: TxExecutionType.IBC_TRANSFER, - ibcHistoryData: historyData as IBCTransferHistoryData, - }; - break; - } - case TxExecutionType.IBC_SWAP: { - execution = { - ...executionBase, - type: TxExecutionType.IBC_SWAP, - ibcHistoryData: historyData as IBCSwapHistoryData, - }; - break; - } - case TxExecutionType.SEND: { - execution = { - ...executionBase, - type: TxExecutionType.SEND, - sendHistoryData: historyData as RecentSendHistoryData, - }; - break; - } - default: { - execution = { - ...executionBase, - type: TxExecutionType.UNDEFINED, - }; - break; - } - } + type, + ...(type !== TxExecutionType.UNDEFINED ? { historyData } : {}), + } as TxExecution; this.recentTxExecutionMap.set(id, execution); return await this.executeTxs(id); @@ -676,20 +662,20 @@ export class BackgroundTxExecutorService { } if (execution.type === TxExecutionType.SEND) { - if (execution.hasRecordedHistory || !execution.sendHistoryData) { + if (execution.hasRecordedHistory || !execution.historyData) { return; } - const sendHistoryData = execution.sendHistoryData; + const historyData = execution.historyData; this.recentSendHistoryService.addRecentSendHistory( - sendHistoryData.chainId, - sendHistoryData.historyType, + historyData.chainId, + historyData.historyType, { - sender: sendHistoryData.sender, - recipient: sendHistoryData.recipient, - amount: sendHistoryData.amount, - memo: sendHistoryData.memo, + sender: historyData.sender, + recipient: historyData.recipient, + amount: historyData.amount, + memo: historyData.memo, ibcChannels: undefined, } ); @@ -699,7 +685,7 @@ export class BackgroundTxExecutorService { } if (execution.type === TxExecutionType.IBC_TRANSFER) { - if (execution.ibcHistoryId != null || !execution.ibcHistoryData) { + if (execution.historyId != null || !execution.historyData) { return; } @@ -713,28 +699,28 @@ export class BackgroundTxExecutorService { return; } - const ibcHistoryData = execution.ibcHistoryData; + const historyData = execution.historyData; // TODO: 기록할 때 execution id를 넘겨줘야 함 const id = this.recentSendHistoryService.addRecentIBCTransferHistory( - ibcHistoryData.sourceChainId, - ibcHistoryData.destinationChainId, - ibcHistoryData.sender, - ibcHistoryData.recipient, - ibcHistoryData.amount, - ibcHistoryData.memo, - ibcHistoryData.channels, - ibcHistoryData.notificationInfo, + historyData.sourceChainId, + historyData.destinationChainId, + historyData.sender, + historyData.recipient, + historyData.amount, + historyData.memo, + historyData.channels, + historyData.notificationInfo, Buffer.from(tx.txHash, "hex"), execution.id ); - execution.ibcHistoryId = id; + execution.historyId = id; return; } if (execution.type === TxExecutionType.IBC_SWAP) { - if (execution.ibcHistoryId != null || !execution.ibcHistoryData) { + if (execution.historyId != null || !execution.historyData) { return; } @@ -748,30 +734,30 @@ export class BackgroundTxExecutorService { return; } - const ibcHistoryData = execution.ibcHistoryData; + const historyData = execution.historyData; const id = this.recentSendHistoryService.addRecentIBCSwapHistory( - ibcHistoryData.swapType, - ibcHistoryData.chainId, - ibcHistoryData.destinationChainId, - ibcHistoryData.sender, - ibcHistoryData.amount, - ibcHistoryData.memo, - ibcHistoryData.ibcChannels, - ibcHistoryData.destinationAsset, - ibcHistoryData.swapChannelIndex, - ibcHistoryData.swapReceiver, - ibcHistoryData.notificationInfo, + historyData.swapType, + historyData.chainId, + historyData.destinationChainId, + historyData.sender, + historyData.amount, + historyData.memo, + historyData.ibcChannels, + historyData.destinationAsset, + historyData.swapChannelIndex, + historyData.swapReceiver, + historyData.notificationInfo, Buffer.from(tx.txHash, "hex"), execution.id ); - execution.ibcHistoryId = id; + execution.historyId = id; return; } if (execution.type === TxExecutionType.SWAP_V2) { - if (execution.swapHistoryId != null || !execution.swapHistoryData) { + if (execution.historyId != null || !execution.historyData) { return; } @@ -781,25 +767,25 @@ export class BackgroundTxExecutorService { return; } - const swapHistoryData = execution.swapHistoryData; + const historyData = execution.historyData; const id = this.recentSendHistoryService.recordTxWithSwapV2( - swapHistoryData.fromChainId, - swapHistoryData.toChainId, - swapHistoryData.provider, - swapHistoryData.destinationAsset, - swapHistoryData.simpleRoute, - swapHistoryData.sender, - swapHistoryData.recipient, - swapHistoryData.amount, - swapHistoryData.notificationInfo, - swapHistoryData.routeDurationSeconds, + historyData.fromChainId, + historyData.toChainId, + historyData.provider, + historyData.destinationAsset, + historyData.simpleRoute, + historyData.sender, + historyData.recipient, + historyData.amount, + historyData.notificationInfo, + historyData.routeDurationSeconds, tx.txHash, - swapHistoryData.isOnlyUseBridge, + historyData.isOnlyUseBridge, execution.id ); - execution.swapHistoryId = id; + execution.historyId = id; } } diff --git a/packages/background/src/tx-executor/types.ts b/packages/background/src/tx-executor/types.ts index fbb9b383d0..bb2ad0f67c 100644 --- a/packages/background/src/tx-executor/types.ts +++ b/packages/background/src/tx-executor/types.ts @@ -84,6 +84,7 @@ export interface TxExecutionBase { export interface UndefinedTxExecution extends TxExecutionBase { readonly type: TxExecutionType.UNDEFINED; + readonly historyData?: never; } export interface RecentSendHistoryData { @@ -107,9 +108,9 @@ export interface RecentSendHistoryData { export interface SendTxExecution extends TxExecutionBase { readonly type: TxExecutionType.SEND; + readonly historyData?: RecentSendHistoryData; hasRecordedHistory?: boolean; - readonly sendHistoryData?: RecentSendHistoryData; } export interface IBCTransferHistoryData { @@ -135,9 +136,9 @@ export interface IBCTransferHistoryData { export interface IBCTransferTxExecution extends TxExecutionBase { readonly type: TxExecutionType.IBC_TRANSFER; + readonly historyData?: IBCTransferHistoryData; - ibcHistoryId?: string; - readonly ibcHistoryData?: IBCTransferHistoryData; + historyId?: string; } export interface IBCSwapHistoryData { @@ -169,8 +170,9 @@ export interface IBCSwapHistoryData { export interface IBCSwapTxExecution extends TxExecutionBase { readonly type: TxExecutionType.IBC_SWAP; - ibcHistoryId?: string; - readonly ibcHistoryData?: IBCSwapHistoryData; + readonly historyData?: IBCSwapHistoryData; + + historyId?: string; } export interface SwapV2HistoryData { @@ -203,8 +205,9 @@ export interface SwapV2HistoryData { export interface SwapV2TxExecution extends TxExecutionBase { readonly type: TxExecutionType.SWAP_V2; - swapHistoryId?: string; - readonly swapHistoryData?: SwapV2HistoryData; + readonly historyData?: SwapV2HistoryData; + + historyId?: string; } export type HistoryData = @@ -213,6 +216,14 @@ export type HistoryData = | IBCSwapHistoryData | SwapV2HistoryData; +export type ExecutionTypeToHistoryData = { + [TxExecutionType.SWAP_V2]: SwapV2HistoryData; + [TxExecutionType.IBC_TRANSFER]: IBCTransferHistoryData; + [TxExecutionType.IBC_SWAP]: IBCSwapHistoryData; + [TxExecutionType.SEND]: RecentSendHistoryData; + [TxExecutionType.UNDEFINED]: undefined; +}; + export type TxExecution = | UndefinedTxExecution | SendTxExecution From 104f4d1f1f87799f705c68254400520e91bbc40e Mon Sep 17 00:00:00 2001 From: rowan Date: Fri, 28 Nov 2025 17:25:56 +0900 Subject: [PATCH 15/29] implement stub cosmos tx signing logic on tx executor --- .../background/src/keyring-cosmos/service.ts | 82 ++++ .../src/keyring-ethereum/service.ts | 8 +- .../background/src/tx-executor/service.ts | 218 ++++++++-- packages/background/src/tx-executor/types.ts | 22 +- packages/background/src/tx-executor/utils.ts | 378 ++++++++++++++++++ 5 files changed, 665 insertions(+), 43 deletions(-) create mode 100644 packages/background/src/tx-executor/utils.ts diff --git a/packages/background/src/keyring-cosmos/service.ts b/packages/background/src/keyring-cosmos/service.ts index 36b5807d23..73a61a3e94 100644 --- a/packages/background/src/keyring-cosmos/service.ts +++ b/packages/background/src/keyring-cosmos/service.ts @@ -365,6 +365,88 @@ export class KeyRingCosmosService { ); } + /** + * Sign a amino-encoded transaction with pre-authorization + * @dev only sign the transaction, not simulate or broadcast + */ + async signAminoPreAuthorized( + origin: string, + vaultId: string, + chainId: string, + signer: string, + signDoc: StdSignDoc + ): Promise { + const chainInfo = this.chainsService.getChainInfoOrThrow(chainId); + // if (chainInfo.hideInUI) { + // throw new Error("Can't sign for hidden chain"); + // } + const isEthermintLike = KeyRingService.isEthermintLike(chainInfo); + + const keyInfo = this.keyRingService.getKeyInfo(vaultId); + if (!keyInfo) { + throw new Error("Null key info"); + } + + if (keyInfo.type === "ledger" || keyInfo.type === "keystone") { + throw new Error( + "Pre-authorized signing is not supported for hardware wallets" + ); + } + + signDoc = { + ...signDoc, + memo: escapeHTML(signDoc.memo), + }; + + signDoc = trimAminoSignDoc(signDoc); + signDoc = sortObjectByKey(signDoc); + + const key = await this.getKey(vaultId, chainId); + const bech32Prefix = + this.chainsService.getChainInfoOrThrow(chainId).bech32Config + ?.bech32PrefixAccAddr ?? ""; + const bech32Address = new Bech32Address(key.address).toBech32(bech32Prefix); + if (signer !== bech32Address) { + throw new Error("Signer mismatched"); + } + + const isADR36SignDoc = checkAndValidateADR36AminoSignDoc( + signDoc, + bech32Prefix + ); + if (isADR36SignDoc) { + throw new Error("Only transaction signing is supported for now"); + } + + const signResponse = await this.keyRingService.sign( + chainId, + vaultId, + serializeSignDoc(signDoc), + isEthermintLike ? "keccak256" : "sha256" + ); + const signature = new Uint8Array([...signResponse.r, ...signResponse.s]); + const msgTypes = signDoc.msgs + .filter((msg) => msg.type) + .map((msg) => msg.type); + + // CHECK: 필요함? + try { + this.trackError(chainInfo, signer, signDoc.sequence, { + isInternal: true, + origin, + signMode: "amino", + msgTypes, + }); + } catch (e) { + console.log(e); + } + + return { + signed: signDoc, + signature: encodeSecp256k1Signature(key.pubKey, signature), + }; + } + async privilegeSignAminoWithdrawRewards( env: Env, origin: string, diff --git a/packages/background/src/keyring-ethereum/service.ts b/packages/background/src/keyring-ethereum/service.ts index 56b40c4404..e67f8eec07 100644 --- a/packages/background/src/keyring-ethereum/service.ts +++ b/packages/background/src/keyring-ethereum/service.ts @@ -319,7 +319,7 @@ export class KeyRingEthereumService { ); } - async signEthereumDirect( + async signEthereumPreAuthorized( origin: string, vaultId: string, chainId: string, @@ -344,7 +344,9 @@ export class KeyRingEthereumService { } if (keyInfo.type === "ledger" || keyInfo.type === "keystone") { - throw new Error("Direct signing is not supported for hardware wallets"); + throw new Error( + "Pre-authorized signing is not supported for hardware wallets" + ); } if (signType === EthSignType.TRANSACTION) { @@ -373,7 +375,7 @@ export class KeyRingEthereumService { if (signType !== EthSignType.TRANSACTION) { throw new Error( - "Direct signing is only supported for transaction for now" + "Pre-authorized signing is only supported for transaction for now" ); } diff --git a/packages/background/src/tx-executor/service.ts b/packages/background/src/tx-executor/service.ts index 487c925b1a..139f82c5de 100644 --- a/packages/background/src/tx-executor/service.ts +++ b/packages/background/src/tx-executor/service.ts @@ -27,11 +27,21 @@ import { toJS, } from "mobx"; import { + AminoSignResponse, EthSignType, EthTxStatus, EthereumSignResponse, } from "@keplr-wallet/types"; import { TransactionTypes, serialize } from "@ethersproject/transactions"; +import { BaseAccount } from "@keplr-wallet/cosmos"; +import { Any } from "@keplr-wallet/proto-types/google/protobuf/any"; +import { Msg } from "@keplr-wallet/types"; +import { + getEip712TypedDataBasedOnChainInfo, + buildSignedTxFromAminoSignResponse, + prepareSignDocForAminoSigning, + simulateCosmosTx, +} from "./utils"; export class BackgroundTxExecutorService { @observable @@ -338,7 +348,6 @@ export class BackgroundTxExecutorService { try { const { signedTx } = await this.signTx( execution.vaultId, - currentTx.chainId, currentTx, options?.env ); @@ -387,7 +396,6 @@ export class BackgroundTxExecutorService { protected async signTx( vaultId: string, - chainId: string, tx: BackgroundTx, env?: Env ): Promise<{ @@ -400,21 +408,20 @@ export class BackgroundTxExecutorService { } if (tx.type === BackgroundTxType.EVM) { - return this.signEvmTx(vaultId, chainId, tx, env); + return this.signEvmTx(vaultId, tx, env); } - return this.signCosmosTx(vaultId, chainId, tx, env); + return this.signCosmosTx(vaultId, tx, env); } private async signEvmTx( vaultId: string, - chainId: string, tx: EVMBackgroundTx, env?: Env ): Promise<{ signedTx: Uint8Array; }> { - const keyInfo = await this.keyRingCosmosService.getKey(vaultId, chainId); + const keyInfo = await this.keyRingCosmosService.getKey(vaultId, tx.chainId); const isHardware = keyInfo.isNanoLedger || keyInfo.isKeystone; const signer = keyInfo.ethereumHexAddress; const origin = @@ -437,16 +444,16 @@ export class BackgroundTxExecutorService { env, origin, vaultId, - chainId, + tx.chainId, signer, Buffer.from(JSON.stringify(tx.txData)), EthSignType.TRANSACTION ); } else { - result = await this.keyRingEthereumService.signEthereumDirect( + result = await this.keyRingEthereumService.signEthereumPreAuthorized( origin, vaultId, - chainId, + tx.chainId, signer, Buffer.from(JSON.stringify(tx.txData)), EthSignType.TRANSACTION @@ -476,24 +483,56 @@ export class BackgroundTxExecutorService { private async signCosmosTx( vaultId: string, - chainId: string, tx: CosmosBackgroundTx, env?: Env ): Promise<{ signedTx: Uint8Array; - signature: Uint8Array; }> { // check key - const keyInfo = await this.keyRingCosmosService.getKey(vaultId, chainId); + const keyInfo = await this.keyRingCosmosService.getKey(vaultId, tx.chainId); const isHardware = keyInfo.isNanoLedger || keyInfo.isKeystone; const signer = keyInfo.bech32Address; + const chainInfo = this.chainsService.getChainInfoOrThrow(tx.chainId); + const origin = typeof browser !== "undefined" ? new URL(browser.runtime.getURL("/")).origin : "extension"; - // let result: AminoSignResponse; + const aminoMsgs: Msg[] = tx.txData.aminoMsgs ?? []; + const protoMsgs: Any[] = tx.txData.protoMsgs; + const feeCurrency = chainInfo.currencies[0]; + const pseudoFee = { + amount: [ + { + denom: feeCurrency.coinMinimalDenom, + amount: "1", + }, + ], + gas: "100000", + }; + + // NOTE: 백그라운드에서 자동으로 실행하는 것이므로 편의상 amino로 일관되게 처리한다 + const isDirectSign = aminoMsgs.length === 0; + if (isDirectSign) { + throw new KeplrError( + "direct-tx-executor", + 110, + "Direct signing is not supported for now" + ); + } + + if (protoMsgs.length === 0) { + throw new Error("There is no msg to send"); + } + + if (!isDirectSign && aminoMsgs.length !== protoMsgs.length) { + throw new Error("The length of aminoMsgs and protoMsgs are different"); + } + if (isHardware) { + // hardware wallet signing should be triggered from user interaction + // so only currently activated key should be used for signing if (!env) { throw new KeplrError( "direct-tx-executor", @@ -502,37 +541,140 @@ export class BackgroundTxExecutorService { ); } - await this.keyRingCosmosService.signAmino( - env, - origin, - vaultId, - chainId, + const useEthereumSign = + chainInfo.features?.includes("eth-key-sign") === true; + const eip712Signing = useEthereumSign && keyInfo.isNanoLedger; + if (eip712Signing && !tx.txData.rlpTypes) { + throw new KeplrError( + "direct-tx-executor", + 111, + "RLP types information is needed for signing tx for ethermint chain with ledger" + ); + } + + if (eip712Signing && isDirectSign) { + throw new KeplrError( + "direct-tx-executor", + 112, + "EIP712 signing is not supported for proto signing" + ); + } + + // CHECK: what about keystone? + + const account = await BaseAccount.fetchFromRest( + chainInfo.rest, signer, - tx.txData, - {} + true ); - // experimentalSignEIP712CosmosTx_v0 if eip712Signing + const signDoc = prepareSignDocForAminoSigning({ + chainInfo, + accountNumber: account.getAccountNumber().toString(), + sequence: account.getSequence().toString(), + aminoMsgs: tx.txData.aminoMsgs ?? [], + fee: pseudoFee, + memo: tx.txData.memo ?? "", + eip712Signing, + signer, + }); + + const signResponse: AminoSignResponse = await (async () => { + if (!eip712Signing) { + return await this.keyRingCosmosService.signAmino( + env, + origin, + vaultId, + tx.chainId, + signer, + signDoc, + {} + ); + } + + return await this.keyRingCosmosService.requestSignEIP712CosmosTx_v0( + env, + vaultId, + origin, + tx.chainId, + signer, + getEip712TypedDataBasedOnChainInfo(chainInfo, { + aminoMsgs: tx.txData.aminoMsgs ?? [], + protoMsgs: tx.txData.protoMsgs, + rlpTypes: tx.txData.rlpTypes, + }), + signDoc, + {} + ); + })(); + + const signedTx = buildSignedTxFromAminoSignResponse({ + protoMsgs, + signResponse, + chainInfo, + eip712Signing, + useEthereumSign, + }); + + return { + signedTx: signedTx.tx, + }; } else { - throw new KeplrError( - "direct-tx-executor", - 110, - "Software wallet signing is not supported" + const account = await BaseAccount.fetchFromRest( + chainInfo.rest, + signer, + true ); - // result = await this.keyRingCosmosService.signAminoDirect( - // origin, - // vaultId, - // chainId, - // signer, - // tx.txData, - // {} - // ); - } - return { - signedTx: new Uint8Array(), - signature: new Uint8Array(), - }; + const { gasUsed } = await simulateCosmosTx( + signer, + chainInfo, + protoMsgs, + pseudoFee, + tx.txData.memo ?? "" + ); + + const signDoc = prepareSignDocForAminoSigning({ + chainInfo, + accountNumber: account.getAccountNumber().toString(), + sequence: account.getSequence().toString(), + aminoMsgs: tx.txData.aminoMsgs ?? [], + fee: { + amount: [ + { + // TODO: fee token 설정이 필요함... + denom: feeCurrency.coinMinimalDenom, + amount: "100000", // TODO: get gas price + }, + ], + gas: Math.floor(gasUsed * 1.4).toString(), + }, + memo: tx.txData.memo ?? "", + eip712Signing: false, + signer, + }); + + const signResponse: AminoSignResponse = + await this.keyRingCosmosService.signAminoPreAuthorized( + origin, + vaultId, + tx.chainId, + signer, + signDoc + ); + + const signedTx = buildSignedTxFromAminoSignResponse({ + protoMsgs, + signResponse, + chainInfo, + eip712Signing: false, + useEthereumSign: false, + }); + + return { + signedTx: signedTx.tx, + }; + } } protected async broadcastTx(tx: BackgroundTx): Promise<{ diff --git a/packages/background/src/tx-executor/types.ts b/packages/background/src/tx-executor/types.ts index bb2ad0f67c..1e486b71ce 100644 --- a/packages/background/src/tx-executor/types.ts +++ b/packages/background/src/tx-executor/types.ts @@ -1,5 +1,8 @@ import { UnsignedTransaction } from "@ethersproject/transactions"; -import { AppCurrency, StdSignDoc } from "@keplr-wallet/types"; +import { AppCurrency } from "@keplr-wallet/types"; +import { Any } from "@keplr-wallet/proto-types/google/protobuf/any"; +import { Msg } from "@keplr-wallet/types"; + import { SwapProvider } from "../recent-send-history"; // Transaction status @@ -43,7 +46,22 @@ export interface EVMBackgroundTx extends BackgroundTxBase { export interface CosmosBackgroundTx extends BackgroundTxBase { readonly type: BackgroundTxType.COSMOS; - readonly txData: StdSignDoc; + readonly txData: { + aminoMsgs?: Msg[]; + protoMsgs: Any[]; + + // Add rlp types data if you need to support ethermint with ledger. + // Must include `MsgValue`. + rlpTypes?: Record< + string, + Array<{ + name: string; + type: string; + }> + >; + + memo?: string; + }; } // Single transaction data with discriminated union based on type diff --git a/packages/background/src/tx-executor/utils.ts b/packages/background/src/tx-executor/utils.ts new file mode 100644 index 0000000000..48f6f914b4 --- /dev/null +++ b/packages/background/src/tx-executor/utils.ts @@ -0,0 +1,378 @@ +import { BaseAccount, EthermintChainIdHelper } from "@keplr-wallet/cosmos"; +import { Any } from "@keplr-wallet/proto-types/google/protobuf/any"; +import { + ChainInfo, + Msg, + AminoSignResponse, + StdSignDoc, + Coin, + StdFee, +} from "@keplr-wallet/types"; +import { Buffer } from "buffer/"; +import { escapeHTML, sortObjectByKey } from "@keplr-wallet/common"; +import { Mutable } from "utility-types"; +import { + AuthInfo, + Fee, + SignerInfo, + TxBody, + TxRaw, +} from "@keplr-wallet/proto-types/cosmos/tx/v1beta1/tx"; +import { ExtensionOptionsWeb3Tx } from "@keplr-wallet/proto-types/ethermint/types/v1/web3"; +import { PubKey } from "@keplr-wallet/proto-types/cosmos/crypto/secp256k1/keys"; +import { SignMode } from "@keplr-wallet/proto-types/cosmos/tx/signing/v1beta1/signing"; +import { simpleFetch } from "@keplr-wallet/simple-fetch"; + +// NOTE: duplicated with packages/stores/src/account/utils.ts +export const getEip712TypedDataBasedOnChainInfo = ( + chainInfo: ChainInfo, + msgs: { + aminoMsgs?: Msg[]; + protoMsgs: Any[]; + rlpTypes?: Record>; + } +): { + types: Record; + domain: Record; + primaryType: string; +} => { + const chainId = chainInfo.chainId; + const chainIsInjective = chainId.startsWith("injective"); + const signPlainJSON = + chainInfo.features && + chainInfo.features.includes("evm-ledger-sign-plain-json"); + + const types = { + types: { + EIP712Domain: [ + { name: "name", type: "string" }, + { name: "version", type: "string" }, + { name: "chainId", type: "uint256" }, + // XXX: Maybe, non-standard format? + { name: "verifyingContract", type: "string" }, + // XXX: Maybe, non-standard format? + { name: "salt", type: "string" }, + ], + Tx: [ + { name: "account_number", type: "string" }, + { name: "chain_id", type: "string" }, + { name: "fee", type: "Fee" }, + { name: "memo", type: "string" }, + { name: "msgs", type: "Msg[]" }, + { name: "sequence", type: "string" }, + ], + Fee: [ + ...(signPlainJSON ? [] : [{ name: "feePayer", type: "string" }]), + { name: "amount", type: "Coin[]" }, + { name: "gas", type: "string" }, + ], + Coin: [ + { name: "denom", type: "string" }, + { name: "amount", type: "string" }, + ], + Msg: [ + { name: "type", type: "string" }, + { name: "value", type: "MsgValue" }, + ], + ...msgs.rlpTypes, + }, + domain: { + name: "Cosmos Web3", + version: "1.0.0", + // signPlainJSON일때 밑의 값은 사실 사용되지 않으므로 대강 처리 + chainId: signPlainJSON + ? 9999 + : EthermintChainIdHelper.parse(chainId).ethChainId.toString(), + verifyingContract: "cosmos", + salt: "0", + }, + primaryType: "Tx", + }; + + // Injective doesn't need feePayer to be included but requires + // timeout_height in the types + if (chainIsInjective) { + types.types.Tx = [ + ...types.types.Tx, + { name: "timeout_height", type: "string" }, + ]; + types.domain.name = "Injective Web3"; + types.domain.chainId = + "0x" + EthermintChainIdHelper.parse(chainId).ethChainId.toString(16); + types.types.Fee = [ + { name: "amount", type: "Coin[]" }, + { name: "gas", type: "string" }, + ]; + + return types; + } + + // Return default types for Evmos + return types; +}; + +/** + * Build a signed transaction from an AminoSignResponse + */ +export function buildSignedTxFromAminoSignResponse(params: { + protoMsgs: Any[]; + signResponse: AminoSignResponse; + chainInfo: ChainInfo; + eip712Signing: boolean; + useEthereumSign: boolean; +}): { + tx: Uint8Array; + signDoc: StdSignDoc; +} { + const { protoMsgs, signResponse, chainInfo, eip712Signing, useEthereumSign } = + params; + + const chainIsInjective = chainInfo.chainId.startsWith("injective"); + const chainIsStratos = chainInfo.chainId.startsWith("stratos"); + const ethSignPlainJson: boolean = + chainInfo.features?.includes("evm-ledger-sign-plain-json") === true; + + return { + tx: TxRaw.encode({ + bodyBytes: TxBody.encode( + TxBody.fromPartial({ + messages: protoMsgs, + timeoutHeight: signResponse.signed.timeout_height, + memo: signResponse.signed.memo, + extensionOptions: + eip712Signing && !ethSignPlainJson + ? [ + { + typeUrl: (() => { + if ( + chainInfo.features?.includes( + "/cosmos.evm.types.v1.ExtensionOptionsWeb3Tx" + ) + ) { + return "/cosmos.evm.types.v1.ExtensionOptionsWeb3Tx"; + } + + if (chainIsInjective) { + return "/injective.types.v1beta1.ExtensionOptionsWeb3Tx"; + } + + return "/ethermint.types.v1.ExtensionOptionsWeb3Tx"; + })(), + value: ExtensionOptionsWeb3Tx.encode( + ExtensionOptionsWeb3Tx.fromPartial({ + typedDataChainId: EthermintChainIdHelper.parse( + chainInfo.chainId + ).ethChainId.toString(), + feePayer: !chainIsInjective + ? signResponse.signed.fee.feePayer + : undefined, + feePayerSig: !chainIsInjective + ? Buffer.from( + signResponse.signature.signature, + "base64" + ) + : undefined, + }) + ).finish(), + }, + ] + : undefined, + }) + ).finish(), + authInfoBytes: AuthInfo.encode({ + signerInfos: [ + { + publicKey: { + typeUrl: (() => { + if (!useEthereumSign) { + return "/cosmos.crypto.secp256k1.PubKey"; + } + + if (chainIsInjective) { + return "/injective.crypto.v1beta1.ethsecp256k1.PubKey"; + } + + if (chainIsStratos) { + return "/stratos.crypto.v1.ethsecp256k1.PubKey"; + } + + if (chainInfo.features?.includes("eth-secp256k1-cosmos")) { + return "/cosmos.evm.crypto.v1.ethsecp256k1.PubKey"; + } + + if (chainInfo.features?.includes("eth-secp256k1-initia")) { + return "/initia.crypto.v1beta1.ethsecp256k1.PubKey"; + } + + return "/ethermint.crypto.v1.ethsecp256k1.PubKey"; + })(), + value: PubKey.encode({ + key: Buffer.from( + signResponse.signature.pub_key.value, + "base64" + ), + }).finish(), + }, + modeInfo: { + single: { + mode: + eip712Signing && ethSignPlainJson + ? SignMode.SIGN_MODE_EIP_191 + : SignMode.SIGN_MODE_LEGACY_AMINO_JSON, + }, + multi: undefined, + }, + sequence: signResponse.signed.sequence, + }, + ], + fee: Fee.fromPartial({ + amount: signResponse.signed.fee.amount as Coin[], + gasLimit: signResponse.signed.fee.gas, + payer: + eip712Signing && !chainIsInjective && !ethSignPlainJson + ? // Fee delegation feature not yet supported. But, for eip712 ethermint signing, we must set fee payer. + signResponse.signed.fee.feePayer + : undefined, + }), + }).finish(), + signatures: + // Injective needs the signature in the signatures list even if eip712 + !eip712Signing || chainIsInjective || ethSignPlainJson + ? [Buffer.from(signResponse.signature.signature, "base64")] + : [new Uint8Array(0)], + }).finish(), + signDoc: signResponse.signed, + }; +} + +/** + * Prepare sign document for Cosmos transaction signing + */ +export function prepareSignDocForAminoSigning(params: { + chainInfo: ChainInfo; + accountNumber: string; + sequence: string; + aminoMsgs: Msg[]; + fee: StdFee; + memo: string; + eip712Signing: boolean; + signer: string; +}): StdSignDoc { + const { + chainInfo, + accountNumber, + sequence, + aminoMsgs, + memo, + eip712Signing, + signer, + fee, + } = params; + + const chainIsInjective = chainInfo.chainId.startsWith("injective"); + const ethSignPlainJson: boolean = + chainInfo.features?.includes("evm-ledger-sign-plain-json") === true; + + const signDocRaw: StdSignDoc = { + chain_id: chainInfo.chainId, + account_number: accountNumber, + sequence, + fee, + msgs: aminoMsgs, + memo: escapeHTML(memo ?? ""), + }; + + if (eip712Signing) { + if (chainIsInjective) { + // Due to injective's problem, it should exist if injective with ledger. + // There is currently no effective way to handle this in keplr. Just set a very large number. + (signDocRaw as Mutable).timeout_height = + Number.MAX_SAFE_INTEGER.toString(); + } else { + // If not injective (evmos), they require fee payer. + // XXX: "feePayer" should be "payer". But, it maybe from ethermint team's mistake. + // That means this part is not standard. + (signDocRaw as Mutable).fee = { + ...signDocRaw.fee, + ...(() => { + if (ethSignPlainJson) { + return {}; + } + return { + feePayer: signer, + }; + })(), + }; + } + } + + return sortObjectByKey(signDocRaw); +} + +export async function simulateCosmosTx( + signer: string, + chainInfo: ChainInfo, + msgs: Any[], + fee: Omit, + memo: string = "" +): Promise<{ + gasUsed: number; +}> { + const account = await BaseAccount.fetchFromRest(chainInfo.rest, signer, true); + + const unsignedTx = TxRaw.encode({ + bodyBytes: TxBody.encode( + TxBody.fromPartial({ + messages: msgs, + memo: memo, + }) + ).finish(), + authInfoBytes: AuthInfo.encode({ + signerInfos: [ + SignerInfo.fromPartial({ + // Pub key is ignored. + // It is fine to ignore the pub key when simulating tx. + // However, the estimated gas would be slightly smaller because tx size doesn't include pub key. + modeInfo: { + single: { + mode: SignMode.SIGN_MODE_LEGACY_AMINO_JSON, + }, + multi: undefined, + }, + sequence: account.getSequence().toString(), + }), + ], + fee: Fee.fromPartial({ + amount: fee.amount.map((amount) => { + return { amount: amount.amount, denom: amount.denom }; + }), + }), + }).finish(), + // Because of the validation of tx itself, the signature must exist. + // However, since they do not actually verify the signature, it is okay to use any value. + signatures: [new Uint8Array(64)], + }).finish(); + + const result = await simpleFetch<{ + gas_info: { + gas_used: string; + }; + }>(chainInfo.rest, "/cosmos/tx/v1beta1/simulate", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + tx_bytes: Buffer.from(unsignedTx).toString("base64"), + }), + }); + + const gasUsed = parseInt(result.data.gas_info.gas_used); + if (Number.isNaN(gasUsed)) { + throw new Error(`Invalid integer gas: ${result.data.gas_info.gas_used}`); + } + + return { + gasUsed: gasUsed, + }; +} From 6d00f06e4fbec44b537035c4982da9bed59b1c13 Mon Sep 17 00:00:00 2001 From: rowan Date: Fri, 28 Nov 2025 17:36:04 +0900 Subject: [PATCH 16/29] refactor `sign**PreAuthorized` method should only sign the message --- .../src/keyring-ethereum/service.ts | 213 +----------------- .../background/src/tx-executor/service.ts | 17 +- packages/background/src/tx-executor/utils.ts | 189 ++++++++++++++++ 3 files changed, 210 insertions(+), 209 deletions(-) diff --git a/packages/background/src/keyring-ethereum/service.ts b/packages/background/src/keyring-ethereum/service.ts index e67f8eec07..10d0241739 100644 --- a/packages/background/src/keyring-ethereum/service.ts +++ b/packages/background/src/keyring-ethereum/service.ts @@ -28,7 +28,6 @@ import { import { simpleFetch } from "@keplr-wallet/simple-fetch"; import { getBasicAccessPermissionType, PermissionService } from "../permission"; import { BackgroundTxEthereumService } from "../tx-ethereum"; -import { Dec } from "@keplr-wallet/unit"; import { TokenERC20Service } from "../token-erc20"; import { validateEVMChainId } from "./helper"; import { runInAction } from "mobx"; @@ -319,8 +318,11 @@ export class KeyRingEthereumService { ); } + /** + * Sign an Ethereum transaction with pre-authorization + * @dev only sign the transaction, not simulate or broadcast + */ async signEthereumPreAuthorized( - origin: string, vaultId: string, chainId: string, signer: string, @@ -379,13 +381,9 @@ export class KeyRingEthereumService { ); } - const unsignedTx = await this.fillUnsignedTx( - origin, - chainId, - signer, - JSON.parse(Buffer.from(message).toString()) + const unsignedTx: UnsignedTransaction = JSON.parse( + Buffer.from(message).toString() ); - const isEIP1559 = !!unsignedTx.maxFeePerGas || !!unsignedTx.maxPriorityFeePerGas; if (isEIP1559) { @@ -1311,205 +1309,6 @@ export class KeyRingEthereumService { return result; } - protected async fillUnsignedTx( - origin: string, - chainId: string, - signer: string, - tx: UnsignedTransaction - ): Promise { - // get chain info - const chainInfo = this.chainsService.getChainInfoOrThrow(chainId); - const evmInfo = ChainsService.getEVMInfo(chainInfo); - if (!evmInfo) { - throw new Error("Not EVM chain"); - } - - const getTransactionCountRequest = { - jsonrpc: "2.0", - method: "eth_getTransactionCount", - params: [signer, "pending"], - id: 1, - }; - - const getBlockRequest = { - jsonrpc: "2.0", - method: "eth_getBlockByNumber", - params: ["latest", false], - id: 2, - }; - - const getFeeHistoryRequest = { - jsonrpc: "2.0", - method: "eth_feeHistory", - params: [20, "latest", [50]], - id: 3, - }; - - const estimateGasRequest = { - jsonrpc: "2.0", - method: "eth_estimateGas", - params: [ - { - from: signer, - to: tx.to, - value: tx.value, - data: tx.data, - }, - ], - id: 4, - }; - - const getMaxPriorityFeePerGasRequest = { - jsonrpc: "2.0", - method: "eth_maxPriorityFeePerGas", - params: [], - id: 5, - }; - - // rpc request in batch (as 2.0 jsonrpc supports batch requests) - const batchRequest = [ - getTransactionCountRequest, - getBlockRequest, - getFeeHistoryRequest, - estimateGasRequest, - getMaxPriorityFeePerGasRequest, - ]; - - const { data: rpcResponses } = await simpleFetch< - Array<{ - jsonrpc: "2.0"; - id: number; - result?: unknown; - error?: { code: number; message: string; data?: unknown }; - }> - >(evmInfo.rpc, { - method: "POST", - headers: { - "content-type": "application/json", - "request-source": origin, - }, - body: JSON.stringify(batchRequest), - }); - - if ( - !Array.isArray(rpcResponses) || - rpcResponses.length !== batchRequest.length - ) { - throw new Error("Invalid batch response format"); - } - - const getResult = (id: number): T => { - const res = rpcResponses.find((r) => r.id === id); - if (!res) { - throw new Error(`No response for id=${id}`); - } - if (res.error) { - throw new Error( - `RPC error (id=${id}): ${res.error.code} ${res.error.message}` - ); - } - return res.result as T; - }; - - // find responses by id - const nonceHex = getResult(1); - const latestBlock = getResult<{ baseFeePerGas?: string }>(2); - const feeHistory = getResult<{ - baseFeePerGas?: string[]; - gasUsedRatio: number[]; - oldestBlock: string; - reward?: string[][]; - }>(3); - const gasLimitHex = getResult(4); - const networkMaxPriorityFeePerGasHex = getResult(5); - - let maxPriorityFeePerGasDec: Dec | undefined; - if (feeHistory.reward && feeHistory.reward.length > 0) { - // get 50th percentile rewards (index 0 since we requested [50] percentile) - const percentileIndex = 0; - const rewards = feeHistory.reward - .map((block) => block[percentileIndex]) - .filter((v) => v != null) - .map((v) => BigInt(v)); - - if (rewards.length > 0) { - const sum = rewards.reduce((acc, x) => acc + x, BigInt(0)); - const mean = sum / BigInt(rewards.length); - - const sortedRewards = [...rewards].sort((a, b) => - a < b ? -1 : a > b ? 1 : 0 - ); - const median = sortedRewards[Math.floor(sortedRewards.length / 2)]; - - // use 1 Gwei deviation threshold to decide between mean and median - const deviationThreshold = BigInt(1 * 10 ** 9); // 1 Gwei - const deviation = mean > median ? mean - median : median - mean; - const pick = - deviation > deviationThreshold - ? mean > median - ? mean - : median - : mean; - - maxPriorityFeePerGasDec = new Dec(pick); - } - } - - if (networkMaxPriorityFeePerGasHex) { - const networkMaxPriorityFeePerGasDec = new Dec( - BigInt(networkMaxPriorityFeePerGasHex) - ); - - if ( - !maxPriorityFeePerGasDec || - (maxPriorityFeePerGasDec && - networkMaxPriorityFeePerGasDec.gt(maxPriorityFeePerGasDec)) - ) { - maxPriorityFeePerGasDec = networkMaxPriorityFeePerGasDec; - } - } - - if (!maxPriorityFeePerGasDec) { - throw new Error( - "Failed to calculate maxPriorityFeePerGas to fill unsigned transaction" - ); - } - - if (!latestBlock.baseFeePerGas) { - throw new Error( - "Failed to get baseFeePerGas to fill unsigned transaction" - ); - } - - const multiplier = new Dec(1.25); - - // Calculate maxFeePerGas = baseFeePerGas + maxPriorityFeePerGas - const baseFeePerGasDec = new Dec(BigInt(latestBlock.baseFeePerGas)); - const maxFeePerGasDec = baseFeePerGasDec - .add(maxPriorityFeePerGasDec) - .mul(multiplier); - const maxFeePerGasHex = `0x${maxFeePerGasDec - .truncate() - .toBigNumber() - .toString(16)}`; - - maxPriorityFeePerGasDec = maxPriorityFeePerGasDec.mul(multiplier); - const maxPriorityFeePerGasHex = `0x${maxPriorityFeePerGasDec - .truncate() - .toBigNumber() - .toString(16)}`; - - const newUnsignedTx: UnsignedTransaction = { - ...tx, - nonce: parseInt(nonceHex, 16), - maxFeePerGas: maxFeePerGasHex, - maxPriorityFeePerGas: maxPriorityFeePerGasHex, - gasLimit: gasLimitHex, - }; - - return newUnsignedTx; - } - getNewCurrentChainIdFromRequest( method: string, params?: unknown[] | Record diff --git a/packages/background/src/tx-executor/service.ts b/packages/background/src/tx-executor/service.ts index 139f82c5de..276961a61b 100644 --- a/packages/background/src/tx-executor/service.ts +++ b/packages/background/src/tx-executor/service.ts @@ -41,6 +41,7 @@ import { buildSignedTxFromAminoSignResponse, prepareSignDocForAminoSigning, simulateCosmosTx, + fillUnsignedEVMTx, } from "./utils"; export class BackgroundTxExecutorService { @@ -450,12 +451,24 @@ export class BackgroundTxExecutorService { EthSignType.TRANSACTION ); } else { - result = await this.keyRingEthereumService.signEthereumPreAuthorized( + const chainInfo = this.chainsService.getChainInfoOrThrow(tx.chainId); + const evmInfo = ChainsService.getEVMInfo(chainInfo); + if (!evmInfo) { + throw new KeplrError("direct-tx-executor", 113, "Not EVM chain"); + } + + const unsignedTx = await fillUnsignedEVMTx( origin, + evmInfo, + signer, + tx.txData + ); + + result = await this.keyRingEthereumService.signEthereumPreAuthorized( vaultId, tx.chainId, signer, - Buffer.from(JSON.stringify(tx.txData)), + Buffer.from(JSON.stringify(unsignedTx)), EthSignType.TRANSACTION ); } diff --git a/packages/background/src/tx-executor/utils.ts b/packages/background/src/tx-executor/utils.ts index 48f6f914b4..6fc2a84983 100644 --- a/packages/background/src/tx-executor/utils.ts +++ b/packages/background/src/tx-executor/utils.ts @@ -7,6 +7,7 @@ import { StdSignDoc, Coin, StdFee, + EVMInfo, } from "@keplr-wallet/types"; import { Buffer } from "buffer/"; import { escapeHTML, sortObjectByKey } from "@keplr-wallet/common"; @@ -22,6 +23,8 @@ import { ExtensionOptionsWeb3Tx } from "@keplr-wallet/proto-types/ethermint/type import { PubKey } from "@keplr-wallet/proto-types/cosmos/crypto/secp256k1/keys"; import { SignMode } from "@keplr-wallet/proto-types/cosmos/tx/signing/v1beta1/signing"; import { simpleFetch } from "@keplr-wallet/simple-fetch"; +import { UnsignedTransaction } from "@ethersproject/transactions"; +import { Dec } from "@keplr-wallet/unit"; // NOTE: duplicated with packages/stores/src/account/utils.ts export const getEip712TypedDataBasedOnChainInfo = ( @@ -376,3 +379,189 @@ export async function simulateCosmosTx( gasUsed: gasUsed, }; } + +export async function fillUnsignedEVMTx( + origin: string, + evmInfo: EVMInfo, + signer: string, + tx: UnsignedTransaction +): Promise { + const getTransactionCountRequest = { + jsonrpc: "2.0", + method: "eth_getTransactionCount", + params: [signer, "pending"], + id: 1, + }; + + const getBlockRequest = { + jsonrpc: "2.0", + method: "eth_getBlockByNumber", + params: ["latest", false], + id: 2, + }; + + const getFeeHistoryRequest = { + jsonrpc: "2.0", + method: "eth_feeHistory", + params: [20, "latest", [50]], + id: 3, + }; + + const estimateGasRequest = { + jsonrpc: "2.0", + method: "eth_estimateGas", + params: [ + { + from: signer, + to: tx.to, + value: tx.value, + data: tx.data, + }, + ], + id: 4, + }; + + const getMaxPriorityFeePerGasRequest = { + jsonrpc: "2.0", + method: "eth_maxPriorityFeePerGas", + params: [], + id: 5, + }; + + // rpc request in batch (as 2.0 jsonrpc supports batch requests) + const batchRequest = [ + getTransactionCountRequest, + getBlockRequest, + getFeeHistoryRequest, + estimateGasRequest, + getMaxPriorityFeePerGasRequest, + ]; + + const { data: rpcResponses } = await simpleFetch< + Array<{ + jsonrpc: "2.0"; + id: number; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; + }> + >(evmInfo.rpc, { + method: "POST", + headers: { + "content-type": "application/json", + "request-source": origin, + }, + body: JSON.stringify(batchRequest), + }); + + if ( + !Array.isArray(rpcResponses) || + rpcResponses.length !== batchRequest.length + ) { + throw new Error("Invalid batch response format"); + } + + const getResult = (id: number): T => { + const res = rpcResponses.find((r) => r.id === id); + if (!res) { + throw new Error(`No response for id=${id}`); + } + if (res.error) { + throw new Error( + `RPC error (id=${id}): ${res.error.code} ${res.error.message}` + ); + } + return res.result as T; + }; + + // find responses by id + const nonceHex = getResult(1); + const latestBlock = getResult<{ baseFeePerGas?: string }>(2); + const feeHistory = getResult<{ + baseFeePerGas?: string[]; + gasUsedRatio: number[]; + oldestBlock: string; + reward?: string[][]; + }>(3); + const gasLimitHex = getResult(4); + const networkMaxPriorityFeePerGasHex = getResult(5); + + let maxPriorityFeePerGasDec: Dec | undefined; + if (feeHistory.reward && feeHistory.reward.length > 0) { + // get 50th percentile rewards (index 0 since we requested [50] percentile) + const percentileIndex = 0; + const rewards = feeHistory.reward + .map((block) => block[percentileIndex]) + .filter((v) => v != null) + .map((v) => BigInt(v)); + + if (rewards.length > 0) { + const sum = rewards.reduce((acc, x) => acc + x, BigInt(0)); + const mean = sum / BigInt(rewards.length); + + const sortedRewards = [...rewards].sort((a, b) => + a < b ? -1 : a > b ? 1 : 0 + ); + const median = sortedRewards[Math.floor(sortedRewards.length / 2)]; + + // use 1 Gwei deviation threshold to decide between mean and median + const deviationThreshold = BigInt(1 * 10 ** 9); // 1 Gwei + const deviation = mean > median ? mean - median : median - mean; + const pick = + deviation > deviationThreshold ? (mean > median ? mean : median) : mean; + + maxPriorityFeePerGasDec = new Dec(pick); + } + } + + if (networkMaxPriorityFeePerGasHex) { + const networkMaxPriorityFeePerGasDec = new Dec( + BigInt(networkMaxPriorityFeePerGasHex) + ); + + if ( + !maxPriorityFeePerGasDec || + (maxPriorityFeePerGasDec && + networkMaxPriorityFeePerGasDec.gt(maxPriorityFeePerGasDec)) + ) { + maxPriorityFeePerGasDec = networkMaxPriorityFeePerGasDec; + } + } + + if (!maxPriorityFeePerGasDec) { + throw new Error( + "Failed to calculate maxPriorityFeePerGas to fill unsigned transaction" + ); + } + + if (!latestBlock.baseFeePerGas) { + throw new Error("Failed to get baseFeePerGas to fill unsigned transaction"); + } + + const multiplier = new Dec(1.25); + + // Calculate maxFeePerGas = baseFeePerGas + maxPriorityFeePerGas + const baseFeePerGasDec = new Dec(BigInt(latestBlock.baseFeePerGas)); + const maxFeePerGasDec = baseFeePerGasDec + .add(maxPriorityFeePerGasDec) + .mul(multiplier); + const maxFeePerGasHex = `0x${maxFeePerGasDec + .truncate() + .toBigNumber() + .toString(16)}`; + + maxPriorityFeePerGasDec = maxPriorityFeePerGasDec.mul(multiplier); + const maxPriorityFeePerGasHex = `0x${maxPriorityFeePerGasDec + .truncate() + .toBigNumber() + .toString(16)}`; + + const newUnsignedTx: UnsignedTransaction = { + ...tx, + nonce: parseInt(nonceHex, 16), + maxFeePerGas: maxFeePerGasHex, + maxPriorityFeePerGas: maxPriorityFeePerGasHex, + gasLimit: gasLimitHex, + }; + + return newUnsignedTx; +} From 9feef38c257694e504ee81653c577b276a83db4c Mon Sep 17 00:00:00 2001 From: rowan Date: Fri, 28 Nov 2025 20:09:25 +0900 Subject: [PATCH 17/29] calculate cosmos tx fee on background tx executor --- .../background/src/tx-executor/service.ts | 30 +- packages/background/src/tx-executor/types.ts | 2 + .../tx-executor/{utils.ts => utils/cosmos.ts} | 539 ++++++++++++------ .../background/src/tx-executor/utils/evm.ts | 192 +++++++ 4 files changed, 588 insertions(+), 175 deletions(-) rename packages/background/src/tx-executor/{utils.ts => utils/cosmos.ts} (52%) create mode 100644 packages/background/src/tx-executor/utils/evm.ts diff --git a/packages/background/src/tx-executor/service.ts b/packages/background/src/tx-executor/service.ts index 276961a61b..6ec835d32e 100644 --- a/packages/background/src/tx-executor/service.ts +++ b/packages/background/src/tx-executor/service.ts @@ -41,9 +41,10 @@ import { buildSignedTxFromAminoSignResponse, prepareSignDocForAminoSigning, simulateCosmosTx, - fillUnsignedEVMTx, -} from "./utils"; - + getCosmosGasPrice, + calculateCosmosStdFee, +} from "./utils/cosmos"; +import { fillUnsignedEVMTx } from "./utils/evm"; export class BackgroundTxExecutorService { @observable protected recentTxExecutionSeq: number = 0; @@ -514,11 +515,10 @@ export class BackgroundTxExecutorService { const aminoMsgs: Msg[] = tx.txData.aminoMsgs ?? []; const protoMsgs: Any[] = tx.txData.protoMsgs; - const feeCurrency = chainInfo.currencies[0]; const pseudoFee = { amount: [ { - denom: feeCurrency.coinMinimalDenom, + denom: chainInfo.currencies[0].coinMinimalDenom, amount: "1", }, ], @@ -647,21 +647,21 @@ export class BackgroundTxExecutorService { tx.txData.memo ?? "" ); + // TODO: fee token을 사용자가 설정한 것을 사용해야 함 + const { gasPrice } = await getCosmosGasPrice(chainInfo); + const fee = calculateCosmosStdFee( + chainInfo.currencies[0], + gasUsed, + gasPrice, + chainInfo.features + ); + const signDoc = prepareSignDocForAminoSigning({ chainInfo, accountNumber: account.getAccountNumber().toString(), sequence: account.getSequence().toString(), aminoMsgs: tx.txData.aminoMsgs ?? [], - fee: { - amount: [ - { - // TODO: fee token 설정이 필요함... - denom: feeCurrency.coinMinimalDenom, - amount: "100000", // TODO: get gas price - }, - ], - gas: Math.floor(gasUsed * 1.4).toString(), - }, + fee, memo: tx.txData.memo ?? "", eip712Signing: false, signer, diff --git a/packages/background/src/tx-executor/types.ts b/packages/background/src/tx-executor/types.ts index 1e486b71ce..a6ff11dfce 100644 --- a/packages/background/src/tx-executor/types.ts +++ b/packages/background/src/tx-executor/types.ts @@ -5,6 +5,8 @@ import { Msg } from "@keplr-wallet/types"; import { SwapProvider } from "../recent-send-history"; +export type FeeType = "low" | "average" | "high"; + // Transaction status export enum BackgroundTxStatus { PENDING = "pending", diff --git a/packages/background/src/tx-executor/utils.ts b/packages/background/src/tx-executor/utils/cosmos.ts similarity index 52% rename from packages/background/src/tx-executor/utils.ts rename to packages/background/src/tx-executor/utils/cosmos.ts index 6fc2a84983..72b124e80f 100644 --- a/packages/background/src/tx-executor/utils.ts +++ b/packages/background/src/tx-executor/utils/cosmos.ts @@ -7,7 +7,7 @@ import { StdSignDoc, Coin, StdFee, - EVMInfo, + FeeCurrency, } from "@keplr-wallet/types"; import { Buffer } from "buffer/"; import { escapeHTML, sortObjectByKey } from "@keplr-wallet/common"; @@ -23,8 +23,8 @@ import { ExtensionOptionsWeb3Tx } from "@keplr-wallet/proto-types/ethermint/type import { PubKey } from "@keplr-wallet/proto-types/cosmos/crypto/secp256k1/keys"; import { SignMode } from "@keplr-wallet/proto-types/cosmos/tx/signing/v1beta1/signing"; import { simpleFetch } from "@keplr-wallet/simple-fetch"; -import { UnsignedTransaction } from "@ethersproject/transactions"; import { Dec } from "@keplr-wallet/unit"; +import { FeeType } from "../types"; // NOTE: duplicated with packages/stores/src/account/utils.ts export const getEip712TypedDataBasedOnChainInfo = ( @@ -380,188 +380,407 @@ export async function simulateCosmosTx( }; } -export async function fillUnsignedEVMTx( - origin: string, - evmInfo: EVMInfo, - signer: string, - tx: UnsignedTransaction -): Promise { - const getTransactionCountRequest = { - jsonrpc: "2.0", - method: "eth_getTransactionCount", - params: [signer, "pending"], - id: 1, - }; - - const getBlockRequest = { - jsonrpc: "2.0", - method: "eth_getBlockByNumber", - params: ["latest", false], - id: 2, - }; +export async function fetchCosmosSpendableBalances( + baseURL: string, + bech32Address: string, + limit = 1000 +): Promise<{ balances: Coin[] }> { + const { data } = await simpleFetch<{ balances: Coin[] }>( + baseURL, + `/cosmos/bank/v1beta1/spendable_balances/${bech32Address}?pagination.limit=${limit}` + ); + return data; +} - const getFeeHistoryRequest = { - jsonrpc: "2.0", - method: "eth_feeHistory", - params: [20, "latest", [50]], - id: 3, - }; +// Default gas price steps +const DefaultGasPriceStep = { + low: 0.01, + average: 0.025, + high: 0.04, +}; - const estimateGasRequest = { - jsonrpc: "2.0", - method: "eth_estimateGas", - params: [ - { - from: signer, - to: tx.to, - value: tx.value, - data: tx.data, - }, - ], - id: 4, - }; +// Default multiplication factors for fee market +const DefaultMultiplication = { + low: 1.1, + average: 1.2, + high: 1.3, +}; - const getMaxPriorityFeePerGasRequest = { - jsonrpc: "2.0", - method: "eth_maxPriorityFeePerGas", - params: [], - id: 5, - }; +export async function getCosmosGasPrice( + chainInfo: ChainInfo, + feeType: FeeType = "average", + feeCurrency?: FeeCurrency +): Promise<{ + gasPrice: Dec; + source: + | "osmosis-base-fee" + | "osmosis-txfees" + | "feemarket" + | "initia-dynamic" + | "eip1559" + | "default"; +}> { + // Use first currency from chainInfo if feeCurrency is not provided + const currency = feeCurrency || chainInfo.feeCurrencies[0]; + if (!currency) { + throw new Error("No fee currency is provided and not found for chain"); + } - // rpc request in batch (as 2.0 jsonrpc supports batch requests) - const batchRequest = [ - getTransactionCountRequest, - getBlockRequest, - getFeeHistoryRequest, - estimateGasRequest, - getMaxPriorityFeePerGasRequest, - ]; - - const { data: rpcResponses } = await simpleFetch< - Array<{ - jsonrpc: "2.0"; - id: number; - result?: unknown; - error?: { code: number; message: string; data?: unknown }; - }> - >(evmInfo.rpc, { - method: "POST", - headers: { - "content-type": "application/json", - "request-source": origin, - }, - body: JSON.stringify(batchRequest), - }); + // 1. Try Osmosis base fee (for Osmosis with base-fee-beta feature) + if (chainInfo.features?.includes("osmosis-base-fee-beta")) { + try { + const osmosisResult = await getOsmosisBaseFeeCurrency( + chainInfo, + currency, + feeType + ); + if (!osmosisResult) { + throw new Error("Failed to fetch Osmosis base fee currency"); + } + + if (chainInfo.features?.includes("osmosis-txfees")) { + const osmosisTxFeesResult = await getOsmosisTxFeesGasPrice( + chainInfo, + currency, + feeType + ); + if (osmosisTxFeesResult) { + return { + gasPrice: osmosisTxFeesResult, + source: "osmosis-txfees", + }; + } + } - if ( - !Array.isArray(rpcResponses) || - rpcResponses.length !== batchRequest.length - ) { - throw new Error("Invalid batch response format"); + // if osmosis-txfees is not enabled, use the base fee currency + return { + gasPrice: new Dec(osmosisResult.gasPriceStep![feeType]), + source: "osmosis-base-fee", + }; + } catch (error) { + console.error("Failed to fetch Osmosis base fee:", error); + } } - const getResult = (id: number): T => { - const res = rpcResponses.find((r) => r.id === id); - if (!res) { - throw new Error(`No response for id=${id}`); + // 2. Try Initia Dynamic Fee + if (chainInfo.features?.includes("initia-dynamicfee")) { + try { + const initiaResult = await getInitiaDynamicFeeGasPrice( + chainInfo, + feeType + ); + if (initiaResult) { + return { gasPrice: initiaResult, source: "initia-dynamic" }; + } + } catch (error) { + console.error("Failed to fetch Initia dynamic fee:", error); } - if (res.error) { - throw new Error( - `RPC error (id=${id}): ${res.error.code} ${res.error.message}` + } + + // 3. Try Fee Market (for chains with feemarket feature) + if (chainInfo.features?.includes("feemarket")) { + try { + const feeMarketResult = await getFeeMarketGasPrice( + chainInfo, + currency, + feeType ); + if (feeMarketResult) { + return { gasPrice: feeMarketResult, source: "feemarket" }; + } + } catch (error) { + console.error("Failed to fetch fee market gas price:", error); } - return res.result as T; + } + + // 5. Try EIP-1559 (for EVM chains) + if (chainInfo.evm) { + try { + const eip1559Result = await getEIP1559GasPrice(chainInfo, feeType); + if (eip1559Result) { + return { gasPrice: eip1559Result, source: "eip1559" }; + } + } catch (error) { + console.error("Failed to fetch EIP-1559 gas price:", error); + } + } + + // 6. Fallback to default gas price step + const gasPrice = getDefaultGasPrice(currency, feeType); + return { gasPrice, source: "default" }; +} + +async function getOsmosisBaseFeeCurrency( + chainInfo: ChainInfo, + feeCurrency: FeeCurrency, + feeType: FeeType +): Promise { + // Fetch base fee from Osmosis + const baseDenom = "uosmo"; + + if (feeCurrency.coinMinimalDenom !== baseDenom) { + return null; + } + + // Fetch multiplication factors from remote config + const remoteConfig = await simpleFetch<{ + low?: number; + average?: number; + high?: number; + }>( + "https://gjsttg7mkgtqhjpt3mv5aeuszi0zblbb.lambda-url.us-west-2.on.aws/osmosis/osmosis-base-fee-beta.json" + ).catch(() => ({ data: {} as Record })); + + const { data: baseFeeResponse } = await simpleFetch<{ base_fee: string }>( + chainInfo.rest, + "/osmosis/txfees/v1beta1/cur_eip_base_fee" + ); + + const multiplier = + remoteConfig.data[feeType] || DefaultMultiplication[feeType]; + return { + ...feeCurrency, + gasPriceStep: { + low: parseFloat(baseFeeResponse.base_fee) * multiplier, + average: parseFloat(baseFeeResponse.base_fee) * multiplier, + high: parseFloat(baseFeeResponse.base_fee) * multiplier, + }, }; +} - // find responses by id - const nonceHex = getResult(1); - const latestBlock = getResult<{ baseFeePerGas?: string }>(2); - const feeHistory = getResult<{ - baseFeePerGas?: string[]; - gasUsedRatio: number[]; - oldestBlock: string; - reward?: string[][]; - }>(3); - const gasLimitHex = getResult(4); - const networkMaxPriorityFeePerGasHex = getResult(5); - - let maxPriorityFeePerGasDec: Dec | undefined; - if (feeHistory.reward && feeHistory.reward.length > 0) { - // get 50th percentile rewards (index 0 since we requested [50] percentile) - const percentileIndex = 0; - const rewards = feeHistory.reward - .map((block) => block[percentileIndex]) - .filter((v) => v != null) - .map((v) => BigInt(v)); - - if (rewards.length > 0) { - const sum = rewards.reduce((acc, x) => acc + x, BigInt(0)); - const mean = sum / BigInt(rewards.length); - - const sortedRewards = [...rewards].sort((a, b) => - a < b ? -1 : a > b ? 1 : 0 - ); - const median = sortedRewards[Math.floor(sortedRewards.length / 2)]; +async function getOsmosisTxFeesGasPrice( + chainInfo: ChainInfo, + feeCurrency: FeeCurrency, + feeType: FeeType +): Promise { + // Check if it's a fee token + const { data: feeTokensResponse } = await simpleFetch<{ + fee_tokens: Array<{ denom: string; poolID: string }>; + }>(chainInfo.rest, "/osmosis/txfees/v1beta1/fee_tokens"); + + const isFeeToken = feeTokensResponse.fee_tokens.some( + (token) => token.denom === feeCurrency.coinMinimalDenom + ); + + if (!isFeeToken) { + return null; + } - // use 1 Gwei deviation threshold to decide between mean and median - const deviationThreshold = BigInt(1 * 10 ** 9); // 1 Gwei - const deviation = mean > median ? mean - median : median - mean; - const pick = - deviation > deviationThreshold ? (mean > median ? mean : median) : mean; + // Get spot price + const { data: spotPriceResponse } = await simpleFetch<{ spot_price: string }>( + chainInfo.rest, + `/osmosis/txfees/v1beta1/spot_price_by_denom?denom=${feeCurrency.coinMinimalDenom}` + ); - maxPriorityFeePerGasDec = new Dec(pick); - } + const spotPrice = new Dec(spotPriceResponse.spot_price); + if (spotPrice.lte(new Dec(0))) { + return null; } - if (networkMaxPriorityFeePerGasHex) { - const networkMaxPriorityFeePerGasDec = new Dec( - BigInt(networkMaxPriorityFeePerGasHex) + const baseGasPrice = getDefaultGasPrice(feeCurrency, feeType); + // Add 1% slippage protection + return baseGasPrice.quo(spotPrice).mul(new Dec(1.01)); +} + +async function getFeeMarketGasPrice( + chainInfo: ChainInfo, + feeCurrency: FeeCurrency, + feeType: FeeType +): Promise { + try { + const gasPricesResponse = await simpleFetch<{ + prices: Array<{ denom: string; amount: string }>; + }>(chainInfo.rest, "/feemarket/v1/gas_prices"); + + const gasPrice = gasPricesResponse.data.prices.find( + (price) => price.denom === feeCurrency.coinMinimalDenom ); - if ( - !maxPriorityFeePerGasDec || - (maxPriorityFeePerGasDec && - networkMaxPriorityFeePerGasDec.gt(maxPriorityFeePerGasDec)) - ) { - maxPriorityFeePerGasDec = networkMaxPriorityFeePerGasDec; + if (!gasPrice) { + return null; + } + + // Fetch multiplication config + const multiplicationConfig = await simpleFetch<{ + [chainId: string]: { + low: number; + average: number; + high: number; + }; + }>( + "https://gjsttg7mkgtqhjpt3mv5aeuszi0zblbb.lambda-url.us-west-2.on.aws", + "/feemarket/info.json" + ).catch(() => ({ + data: {} as Record< + string, + { low: number; average: number; high: number } + >, + })); + + let multiplication = DefaultMultiplication; + + // Apply default multiplication + const defaultConfig = multiplicationConfig.data["__default__"]; + if (defaultConfig) { + multiplication = { + low: defaultConfig.low || multiplication.low, + average: defaultConfig.average || multiplication.average, + high: defaultConfig.high || multiplication.high, + }; + } + + // Apply chain-specific multiplication + const chainConfig = multiplicationConfig.data[chainInfo.chainId]; + if (chainConfig) { + multiplication = { + low: chainConfig.low || multiplication.low, + average: chainConfig.average || multiplication.average, + high: chainConfig.high || multiplication.high, + }; } + + const baseGasPrice = new Dec(gasPrice.amount); + return baseGasPrice.mul(new Dec(multiplication[feeType])); + } catch (error) { + return null; } +} - if (!maxPriorityFeePerGasDec) { - throw new Error( - "Failed to calculate maxPriorityFeePerGas to fill unsigned transaction" - ); +async function getInitiaDynamicFeeGasPrice( + chainInfo: ChainInfo, + feeType: FeeType +): Promise { + try { + const dynamicFeeResponse = await simpleFetch<{ + params: { + base_gas_price: string; + }; + }>(chainInfo.rest, "/initia/dynamicfee/v1/params"); + + if (!dynamicFeeResponse.data.params.base_gas_price) { + return null; + } + + const baseGasPrice = new Dec(dynamicFeeResponse.data.params.base_gas_price); + + // Fetch multiplication config + const multiplicationConfig = await simpleFetch<{ + [str: string]: { + low: number; + average: number; + high: number; + }; + }>( + "https://gjsttg7mkgtqhjpt3mv5aeuszi0zblbb.lambda-url.us-west-2.on.aws", + "/feemarket/info.json" + ).catch(() => ({ + data: {} as Record< + string, + { low: number; average: number; high: number } + >, + })); + + let multiplication = DefaultMultiplication; + + // Apply default multiplication + const defaultConfig = multiplicationConfig.data["__default__"]; + if (defaultConfig) { + multiplication = { + low: defaultConfig.low || multiplication.low, + average: defaultConfig.average || multiplication.average, + high: defaultConfig.high || multiplication.high, + }; + } + + // Apply chain-specific multiplication + const chainConfig = multiplicationConfig.data[chainInfo.chainId]; + if (chainConfig) { + multiplication = { + low: chainConfig.low || multiplication.low, + average: chainConfig.average || multiplication.average, + high: chainConfig.high || multiplication.high, + }; + } + + return baseGasPrice.mul(new Dec(multiplication[feeType])); + } catch (error) { + return null; } +} + +// TODO: enhance the logic if required... +async function getEIP1559GasPrice( + chainInfo: ChainInfo, + feeType: FeeType +): Promise { + try { + // Get latest block for base fee + const blockResponse = await simpleFetch<{ + result: { + baseFeePerGas: string; + }; + }>(chainInfo.rpc, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "eth_getBlockByNumber", + params: ["latest", false], + id: 1, + }), + }); + + const baseFeePerGasHex = blockResponse.data.result.baseFeePerGas; + if (!baseFeePerGasHex) { + return null; + } + + const baseFeePerGas = new Dec(parseInt(baseFeePerGasHex, 16)); + + // Calculate priority fee (simplified version) + const priorityFeeMultipliers = { + low: 1.1, + average: 1.25, + high: 1.5, + }; + + const maxPriorityFeePerGas = baseFeePerGas.mul( + new Dec(priorityFeeMultipliers[feeType] - 1) + ); - if (!latestBlock.baseFeePerGas) { - throw new Error("Failed to get baseFeePerGas to fill unsigned transaction"); + return baseFeePerGas.add(maxPriorityFeePerGas); + } catch (error) { + return null; } +} - const multiplier = new Dec(1.25); - - // Calculate maxFeePerGas = baseFeePerGas + maxPriorityFeePerGas - const baseFeePerGasDec = new Dec(BigInt(latestBlock.baseFeePerGas)); - const maxFeePerGasDec = baseFeePerGasDec - .add(maxPriorityFeePerGasDec) - .mul(multiplier); - const maxFeePerGasHex = `0x${maxFeePerGasDec - .truncate() - .toBigNumber() - .toString(16)}`; - - maxPriorityFeePerGasDec = maxPriorityFeePerGasDec.mul(multiplier); - const maxPriorityFeePerGasHex = `0x${maxPriorityFeePerGasDec - .truncate() - .toBigNumber() - .toString(16)}`; - - const newUnsignedTx: UnsignedTransaction = { - ...tx, - nonce: parseInt(nonceHex, 16), - maxFeePerGas: maxFeePerGasHex, - maxPriorityFeePerGas: maxPriorityFeePerGasHex, - gasLimit: gasLimitHex, - }; +export function getDefaultGasPrice( + feeCurrency: FeeCurrency, + feeType: FeeType +): Dec { + const gasPriceStep = feeCurrency.gasPriceStep || DefaultGasPriceStep; + return new Dec(gasPriceStep[feeType]); +} + +export function calculateCosmosStdFee( + feeCurrency: FeeCurrency, + gasUsed: number, + gasPrice: Dec, + features: string[] | undefined +): StdFee { + const gasAdjustment = features?.includes("feemarket") ? 1.6 : 1.4; + + const adjustedGas = Math.floor(gasUsed * gasAdjustment); - return newUnsignedTx; + const feeAmount = gasPrice.mul(new Dec(adjustedGas)).roundUp(); + + return { + amount: [ + { + denom: feeCurrency.coinMinimalDenom, + amount: feeAmount.toString(), + }, + ], + gas: adjustedGas.toString(), + }; } diff --git a/packages/background/src/tx-executor/utils/evm.ts b/packages/background/src/tx-executor/utils/evm.ts new file mode 100644 index 0000000000..766569e4fd --- /dev/null +++ b/packages/background/src/tx-executor/utils/evm.ts @@ -0,0 +1,192 @@ +import { EVMInfo } from "@keplr-wallet/types"; +import { simpleFetch } from "@keplr-wallet/simple-fetch"; +import { UnsignedTransaction } from "@ethersproject/transactions"; +import { Dec } from "@keplr-wallet/unit"; + +const DEFAULT_MULTIPLIER = 1.25; + +export async function fillUnsignedEVMTx( + origin: string, + evmInfo: EVMInfo, + signer: string, + tx: UnsignedTransaction +): Promise { + const getTransactionCountRequest = { + jsonrpc: "2.0", + method: "eth_getTransactionCount", + params: [signer, "pending"], + id: 1, + }; + + const getBlockRequest = { + jsonrpc: "2.0", + method: "eth_getBlockByNumber", + params: ["latest", false], + id: 2, + }; + + const getFeeHistoryRequest = { + jsonrpc: "2.0", + method: "eth_feeHistory", + params: [20, "latest", [50]], + id: 3, + }; + + const estimateGasRequest = { + jsonrpc: "2.0", + method: "eth_estimateGas", + params: [ + { + from: signer, + to: tx.to, + value: tx.value, + data: tx.data, + }, + ], + id: 4, + }; + + const getMaxPriorityFeePerGasRequest = { + jsonrpc: "2.0", + method: "eth_maxPriorityFeePerGas", + params: [], + id: 5, + }; + + // rpc request in batch (as 2.0 jsonrpc supports batch requests) + const batchRequest = [ + getTransactionCountRequest, + getBlockRequest, + getFeeHistoryRequest, + estimateGasRequest, + getMaxPriorityFeePerGasRequest, + ]; + + const { data: rpcResponses } = await simpleFetch< + Array<{ + jsonrpc: "2.0"; + id: number; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; + }> + >(evmInfo.rpc, { + method: "POST", + headers: { + "content-type": "application/json", + "request-source": origin, + }, + body: JSON.stringify(batchRequest), + }); + + if ( + !Array.isArray(rpcResponses) || + rpcResponses.length !== batchRequest.length + ) { + throw new Error("Invalid batch response format"); + } + + const getResult = (id: number): T => { + const res = rpcResponses.find((r) => r.id === id); + if (!res) { + throw new Error(`No response for id=${id}`); + } + if (res.error) { + throw new Error( + `RPC error (id=${id}): ${res.error.code} ${res.error.message}` + ); + } + return res.result as T; + }; + + // find responses by id + const nonceHex = getResult(1); + const latestBlock = getResult<{ baseFeePerGas?: string }>(2); + const feeHistory = getResult<{ + baseFeePerGas?: string[]; + gasUsedRatio: number[]; + oldestBlock: string; + reward?: string[][]; + }>(3); + const gasLimitHex = getResult(4); + const networkMaxPriorityFeePerGasHex = getResult(5); + + let maxPriorityFeePerGasDec: Dec | undefined; + if (feeHistory.reward && feeHistory.reward.length > 0) { + // get 50th percentile rewards (index 0 since we requested [50] percentile) + const percentileIndex = 0; + const rewards = feeHistory.reward + .map((block) => block[percentileIndex]) + .filter((v) => v != null) + .map((v) => BigInt(v)); + + if (rewards.length > 0) { + const sum = rewards.reduce((acc, x) => acc + x, BigInt(0)); + const mean = sum / BigInt(rewards.length); + + const sortedRewards = [...rewards].sort((a, b) => + a < b ? -1 : a > b ? 1 : 0 + ); + const median = sortedRewards[Math.floor(sortedRewards.length / 2)]; + + // use 1 Gwei deviation threshold to decide between mean and median + const deviationThreshold = BigInt(1 * 10 ** 9); // 1 Gwei + const deviation = mean > median ? mean - median : median - mean; + const pick = + deviation > deviationThreshold ? (mean > median ? mean : median) : mean; + + maxPriorityFeePerGasDec = new Dec(pick); + } + } + + if (networkMaxPriorityFeePerGasHex) { + const networkMaxPriorityFeePerGasDec = new Dec( + BigInt(networkMaxPriorityFeePerGasHex) + ); + + if ( + !maxPriorityFeePerGasDec || + (maxPriorityFeePerGasDec && + networkMaxPriorityFeePerGasDec.gt(maxPriorityFeePerGasDec)) + ) { + maxPriorityFeePerGasDec = networkMaxPriorityFeePerGasDec; + } + } + + if (!maxPriorityFeePerGasDec) { + throw new Error( + "Failed to calculate maxPriorityFeePerGas to fill unsigned transaction" + ); + } + + if (!latestBlock.baseFeePerGas) { + throw new Error("Failed to get baseFeePerGas to fill unsigned transaction"); + } + + const multiplier = new Dec(DEFAULT_MULTIPLIER); + + // Calculate maxFeePerGas = baseFeePerGas + maxPriorityFeePerGas + const baseFeePerGasDec = new Dec(BigInt(latestBlock.baseFeePerGas)); + const maxFeePerGasDec = baseFeePerGasDec + .add(maxPriorityFeePerGasDec) + .mul(multiplier); + const maxFeePerGasHex = `0x${maxFeePerGasDec + .truncate() + .toBigNumber() + .toString(16)}`; + + maxPriorityFeePerGasDec = maxPriorityFeePerGasDec.mul(multiplier); + const maxPriorityFeePerGasHex = `0x${maxPriorityFeePerGasDec + .truncate() + .toBigNumber() + .toString(16)}`; + + const newUnsignedTx: UnsignedTransaction = { + ...tx, + nonce: parseInt(nonceHex, 16), + maxFeePerGas: maxFeePerGasHex, + maxPriorityFeePerGas: maxPriorityFeePerGasHex, + gasLimit: gasLimitHex, + }; + + return newUnsignedTx; +} From e0b4c1dc480205c8a15c47316f949dc2e2367994 Mon Sep 17 00:00:00 2001 From: rowan Date: Fri, 28 Nov 2025 20:14:51 +0900 Subject: [PATCH 18/29] record fee type --- .../background/src/tx-executor/handler.ts | 1 + .../background/src/tx-executor/messages.ts | 2 ++ .../background/src/tx-executor/service.ts | 3 +++ packages/background/src/tx-executor/types.ts | 6 ++++-- .../src/tx-executor/utils/cosmos.ts | 20 +++++++++---------- 5 files changed, 20 insertions(+), 12 deletions(-) diff --git a/packages/background/src/tx-executor/handler.ts b/packages/background/src/tx-executor/handler.ts index cee5802534..54d2c09c64 100644 --- a/packages/background/src/tx-executor/handler.ts +++ b/packages/background/src/tx-executor/handler.ts @@ -48,6 +48,7 @@ const handleRecordAndExecuteTxsMsg: ( msg.executionType, msg.txs, msg.executableChainIds, + msg.feeType, msg.historyData ); }; diff --git a/packages/background/src/tx-executor/messages.ts b/packages/background/src/tx-executor/messages.ts index 95d8e5764f..d9b2ccefe3 100644 --- a/packages/background/src/tx-executor/messages.ts +++ b/packages/background/src/tx-executor/messages.ts @@ -6,6 +6,7 @@ import { TxExecutionStatus, TxExecution, ExecutionTypeToHistoryData, + ExecutionFeeType, } from "./types"; /** @@ -25,6 +26,7 @@ export class RecordAndExecuteTxsMsg< public readonly executionType: T, public readonly txs: BackgroundTx[], public readonly executableChainIds: string[], + public readonly feeType?: ExecutionFeeType, public readonly historyData?: T extends TxExecutionType.UNDEFINED ? undefined : ExecutionTypeToHistoryData[T] diff --git a/packages/background/src/tx-executor/service.ts b/packages/background/src/tx-executor/service.ts index 6ec835d32e..acb43f22bc 100644 --- a/packages/background/src/tx-executor/service.ts +++ b/packages/background/src/tx-executor/service.ts @@ -17,6 +17,7 @@ import { EVMBackgroundTx, CosmosBackgroundTx, ExecutionTypeToHistoryData, + ExecutionFeeType, } from "./types"; import { action, @@ -126,6 +127,7 @@ export class BackgroundTxExecutorService { type: T, txs: BackgroundTx[], executableChainIds: string[], + feeType: ExecutionFeeType = "average", historyData?: T extends TxExecutionType.UNDEFINED ? undefined : ExecutionTypeToHistoryData[T] @@ -180,6 +182,7 @@ export class BackgroundTxExecutorService { executableChainIds: executableChainIds, timestamp: Date.now(), type, + feeType, ...(type !== TxExecutionType.UNDEFINED ? { historyData } : {}), } as TxExecution; diff --git a/packages/background/src/tx-executor/types.ts b/packages/background/src/tx-executor/types.ts index a6ff11dfce..250a4de789 100644 --- a/packages/background/src/tx-executor/types.ts +++ b/packages/background/src/tx-executor/types.ts @@ -5,8 +5,6 @@ import { Msg } from "@keplr-wallet/types"; import { SwapProvider } from "../recent-send-history"; -export type FeeType = "low" | "average" | "high"; - // Transaction status export enum BackgroundTxStatus { PENDING = "pending", @@ -86,6 +84,8 @@ export enum TxExecutionType { SWAP_V2 = "swap-v2", } +export type ExecutionFeeType = "low" | "average" | "high"; + export interface TxExecutionBase { readonly id: string; status: TxExecutionStatus; @@ -100,6 +100,8 @@ export interface TxExecutionBase { executableChainIds: string[]; // executable chain ids readonly timestamp: number; // Timestamp when execution started + + readonly feeType: ExecutionFeeType; } export interface UndefinedTxExecution extends TxExecutionBase { diff --git a/packages/background/src/tx-executor/utils/cosmos.ts b/packages/background/src/tx-executor/utils/cosmos.ts index 72b124e80f..df6549b892 100644 --- a/packages/background/src/tx-executor/utils/cosmos.ts +++ b/packages/background/src/tx-executor/utils/cosmos.ts @@ -24,7 +24,7 @@ import { PubKey } from "@keplr-wallet/proto-types/cosmos/crypto/secp256k1/keys"; import { SignMode } from "@keplr-wallet/proto-types/cosmos/tx/signing/v1beta1/signing"; import { simpleFetch } from "@keplr-wallet/simple-fetch"; import { Dec } from "@keplr-wallet/unit"; -import { FeeType } from "../types"; +import { ExecutionFeeType } from "../types"; // NOTE: duplicated with packages/stores/src/account/utils.ts export const getEip712TypedDataBasedOnChainInfo = ( @@ -408,7 +408,7 @@ const DefaultMultiplication = { export async function getCosmosGasPrice( chainInfo: ChainInfo, - feeType: FeeType = "average", + feeType: ExecutionFeeType = "average", feeCurrency?: FeeCurrency ): Promise<{ gasPrice: Dec; @@ -513,7 +513,7 @@ export async function getCosmosGasPrice( async function getOsmosisBaseFeeCurrency( chainInfo: ChainInfo, feeCurrency: FeeCurrency, - feeType: FeeType + feeType: ExecutionFeeType ): Promise { // Fetch base fee from Osmosis const baseDenom = "uosmo"; @@ -529,7 +529,7 @@ async function getOsmosisBaseFeeCurrency( high?: number; }>( "https://gjsttg7mkgtqhjpt3mv5aeuszi0zblbb.lambda-url.us-west-2.on.aws/osmosis/osmosis-base-fee-beta.json" - ).catch(() => ({ data: {} as Record })); + ).catch(() => ({ data: {} as Record })); const { data: baseFeeResponse } = await simpleFetch<{ base_fee: string }>( chainInfo.rest, @@ -551,7 +551,7 @@ async function getOsmosisBaseFeeCurrency( async function getOsmosisTxFeesGasPrice( chainInfo: ChainInfo, feeCurrency: FeeCurrency, - feeType: FeeType + feeType: ExecutionFeeType ): Promise { // Check if it's a fee token const { data: feeTokensResponse } = await simpleFetch<{ @@ -585,7 +585,7 @@ async function getOsmosisTxFeesGasPrice( async function getFeeMarketGasPrice( chainInfo: ChainInfo, feeCurrency: FeeCurrency, - feeType: FeeType + feeType: ExecutionFeeType ): Promise { try { const gasPricesResponse = await simpleFetch<{ @@ -648,7 +648,7 @@ async function getFeeMarketGasPrice( async function getInitiaDynamicFeeGasPrice( chainInfo: ChainInfo, - feeType: FeeType + feeType: ExecutionFeeType ): Promise { try { const dynamicFeeResponse = await simpleFetch<{ @@ -711,7 +711,7 @@ async function getInitiaDynamicFeeGasPrice( // TODO: enhance the logic if required... async function getEIP1559GasPrice( chainInfo: ChainInfo, - feeType: FeeType + feeType: ExecutionFeeType ): Promise { try { // Get latest block for base fee @@ -738,7 +738,7 @@ async function getEIP1559GasPrice( const baseFeePerGas = new Dec(parseInt(baseFeePerGasHex, 16)); // Calculate priority fee (simplified version) - const priorityFeeMultipliers = { + const priorityFeeMultipliers: Record = { low: 1.1, average: 1.25, high: 1.5, @@ -756,7 +756,7 @@ async function getEIP1559GasPrice( export function getDefaultGasPrice( feeCurrency: FeeCurrency, - feeType: FeeType + feeType: ExecutionFeeType ): Dec { const gasPriceStep = feeCurrency.gasPriceStep || DefaultGasPriceStep; return new Dec(gasPriceStep[feeType]); From 1a956a50a83889ae9ee5d92b2a3720ffb2776b63 Mon Sep 17 00:00:00 2001 From: rowan Date: Fri, 28 Nov 2025 20:51:27 +0900 Subject: [PATCH 19/29] add simple message queue --- packages/background/package.json | 1 + packages/background/src/index.ts | 9 ++- .../src/recent-send-history/service.ts | 4 +- .../background/src/tx-executor/internal.ts | 1 + .../src/tx-executor/message-queue.ts | 78 +++++++++++++++++++ .../background/src/tx-executor/service.ts | 44 ++++++++--- yarn.lock | 1 + 7 files changed, 125 insertions(+), 13 deletions(-) create mode 100644 packages/background/src/tx-executor/message-queue.ts diff --git a/packages/background/package.json b/packages/background/package.json index f10cfa4ac8..2c8ad28ea6 100644 --- a/packages/background/package.json +++ b/packages/background/package.json @@ -56,6 +56,7 @@ "ledger-bitcoin": "^0.2.3", "long": "^4.0.0", "miscreant": "0.3.2", + "p-queue": "^6.6.2", "pbkdf2": "^3.1.2", "utility-types": "^3.10.0" }, diff --git a/packages/background/src/index.ts b/packages/background/src/index.ts index 7ebc6722a1..5911bb70f5 100644 --- a/packages/background/src/index.ts +++ b/packages/background/src/index.ts @@ -293,12 +293,16 @@ export function init( keyRingBitcoinService ); + const txExecutableMQ = + BackgroundTxExecutor.createMessageQueue(); + const recentSendHistoryService = new RecentSendHistory.RecentSendHistoryService( storeCreator("recent-send-history"), chainsService, backgroundTxService, - notification + notification, + txExecutableMQ.publisher ); const settingsService = new Settings.SettingsService( @@ -323,7 +327,8 @@ export function init( backgroundTxService, backgroundTxEthereumService, analyticsService, - recentSendHistoryService + recentSendHistoryService, + txExecutableMQ.subscriber ); Interaction.init(router, interactionService); diff --git a/packages/background/src/recent-send-history/service.ts b/packages/background/src/recent-send-history/service.ts index c381368164..ebf919c2bc 100644 --- a/packages/background/src/recent-send-history/service.ts +++ b/packages/background/src/recent-send-history/service.ts @@ -38,6 +38,7 @@ import { import { CoinPretty } from "@keplr-wallet/unit"; import { simpleFetch } from "@keplr-wallet/simple-fetch"; import { id } from "@ethersproject/hash"; +import { Publisher, TxExecutableEvent } from "../tx-executor/internal"; const SWAP_API_ENDPOINT = process.env["KEPLR_API_ENDPOINT"] ?? ""; @@ -70,7 +71,8 @@ export class RecentSendHistoryService { protected readonly kvStore: KVStore, protected readonly chainsService: ChainsService, protected readonly txService: BackgroundTxService, - protected readonly notification: Notification + protected readonly notification: Notification, + protected readonly publisher: Publisher // TODO: publish tx executable event when 트래킹 인덱스가 증가되었을 때 ) { makeObservable(this); } diff --git a/packages/background/src/tx-executor/internal.ts b/packages/background/src/tx-executor/internal.ts index 85b858331f..6c382a1946 100644 --- a/packages/background/src/tx-executor/internal.ts +++ b/packages/background/src/tx-executor/internal.ts @@ -1,2 +1,3 @@ export * from "./service"; export * from "./init"; +export * from "./message-queue"; diff --git a/packages/background/src/tx-executor/message-queue.ts b/packages/background/src/tx-executor/message-queue.ts new file mode 100644 index 0000000000..790aa0362f --- /dev/null +++ b/packages/background/src/tx-executor/message-queue.ts @@ -0,0 +1,78 @@ +import PQueue from "p-queue"; + +class MessageQueueCore { + public subscriber: ((msg: T) => Promise | void) | null = null; + public buffer: T[] = []; + public queue: PQueue; + private isFlushing = false; + + constructor(concurrency = 1) { + this.queue = new PQueue({ concurrency }); + } + + enqueue(message: T) { + this.buffer.push(message); + this.flush(); + } + + /** + * subscriber 설정 + */ + setSubscriber(handler: (msg: T) => Promise | void) { + this.subscriber = handler; + this.flush(); + } + + private flush() { + if (this.isFlushing) return; + if (!this.subscriber) return; + if (this.buffer.length === 0) return; + + this.isFlushing = true; + + try { + console.log("[MessageQueueCore] flush start", this.buffer.length); + + while (this.buffer.length > 0) { + const msg = this.buffer.shift()!; + this.queue.add(async () => { + try { + await this.subscriber!(msg); + } catch (e) { + console.error("[MessageQueueCore] handler error:", e); + } + }); + } + } finally { + this.isFlushing = false; + } + } +} +export class Publisher { + constructor(private core: MessageQueueCore) {} + + publish(message: T) { + this.core.enqueue(message); + } +} + +export class Subscriber { + constructor(private core: MessageQueueCore) {} + + subscribe(handler: (msg: T) => Promise | void) { + this.core.setSubscriber(handler); + } +} + +export function createMessageQueue(concurrency = 1) { + const core = new MessageQueueCore(concurrency); + return { + publisher: new Publisher(core), + subscriber: new Subscriber(core), + }; +} + +export interface TxExecutableEvent { + readonly executionId: string; + readonly executableChainIds: string[]; +} diff --git a/packages/background/src/tx-executor/service.ts b/packages/background/src/tx-executor/service.ts index acb43f22bc..040ce368b1 100644 --- a/packages/background/src/tx-executor/service.ts +++ b/packages/background/src/tx-executor/service.ts @@ -46,6 +46,7 @@ import { calculateCosmosStdFee, } from "./utils/cosmos"; import { fillUnsignedEVMTx } from "./utils/evm"; +import { Subscriber, TxExecutableEvent } from "./internal"; export class BackgroundTxExecutorService { @observable protected recentTxExecutionSeq: number = 0; @@ -61,7 +62,8 @@ export class BackgroundTxExecutorService { protected readonly backgroundTxService: BackgroundTxService, protected readonly backgroundTxEthereumService: BackgroundTxEthereumService, protected readonly analyticsService: AnalyticsService, - protected readonly recentSendHistoryService: RecentSendHistoryService + protected readonly recentSendHistoryService: RecentSendHistoryService, + protected readonly subscriber: Subscriber ) { makeObservable(this); } @@ -103,16 +105,31 @@ export class BackgroundTxExecutorService { ); }); - // TODO: 간단한 메시지 큐를 구현해서 recent send history service에서 multi tx를 처리할 조건이 만족되었을 때 - // 이 서비스로 메시지를 보내 트랜잭션을 자동으로 실행할 수 있도록 한다. 굳 + this.subscriber.subscribe(async ({ executionId, executableChainIds }) => { + const execution = this.getTxExecution(executionId); + if (!execution) { + return; + } + + const newExecutableChainIds = executableChainIds.filter((chainId) => + execution.executableChainIds.includes(chainId) + ); - // CHECK: 현재 활성화되어 있는 vault에서만 실행할 수 있으면 좋을 듯, how? vaultId 변경 감지? how? - // CHECK: 굳이 이걸 백그라운드에서 자동으로 실행할 필요가 있을까? - // 불러왔는데 pending 상태거나 오래된 실행이면 사실상 이 작업을 이어가는 것이 의미가 있는지 의문이 든다. - // for (const execution of this.getRecentDirectTxsExecutions()) { + if (newExecutableChainIds.length === 0) { + return; + } + + // update the executable chain ids + for (const chainId of newExecutableChainIds) { + execution.executableChainIds.push(chainId); + } - // this.executeDirectTxs(execution.id); - // } + // cause new executable chain ids are available, resume the execution + + // CHECK: 현재 활성화되어 있는 vault에서만 실행할 수 있으면 좋을 듯, how? vaultId 변경 감지? how? + // 불러왔는데 pending 상태거나 오래된 실행이면 사실상 이 작업을 이어가는 것이 의미가 있는지 의문이 든다. + await this.executeTxs(executionId); + }); } /** @@ -225,10 +242,17 @@ export class BackgroundTxExecutorService { throw new KeplrError("direct-tx-executor", 105, "Execution not found"); } + if (execution.status === TxExecutionStatus.PROCESSING) { + throw new KeplrError( + "direct-tx-executor", + 108, + "Execution is already processing" + ); + } + // Only pending/processing/blocked executions can be executed const needResume = execution.status === TxExecutionStatus.PENDING || - execution.status === TxExecutionStatus.PROCESSING || execution.status === TxExecutionStatus.BLOCKED; if (!needResume) { return execution.status; diff --git a/yarn.lock b/yarn.lock index bf01624f32..8fc4afc30f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5857,6 +5857,7 @@ __metadata: ledger-bitcoin: ^0.2.3 long: ^4.0.0 miscreant: 0.3.2 + p-queue: ^6.6.2 pbkdf2: ^3.1.2 utility-types: ^3.10.0 peerDependencies: From f91b3034ac86fb61a0dd9fd8f70d6810704b1e43 Mon Sep 17 00:00:00 2001 From: rowan Date: Sun, 30 Nov 2025 17:19:54 +0900 Subject: [PATCH 20/29] handle sophisticated state update in tx executor --- packages/background/src/index.ts | 2 +- .../src/tx-executor/message-queue.ts | 27 +-- .../background/src/tx-executor/messages.ts | 12 +- .../background/src/tx-executor/service.ts | 175 +++++++++++++----- packages/background/src/tx-executor/types.ts | 4 +- 5 files changed, 146 insertions(+), 74 deletions(-) diff --git a/packages/background/src/index.ts b/packages/background/src/index.ts index 5911bb70f5..25ee259a5d 100644 --- a/packages/background/src/index.ts +++ b/packages/background/src/index.ts @@ -294,7 +294,7 @@ export function init( ); const txExecutableMQ = - BackgroundTxExecutor.createMessageQueue(); + BackgroundTxExecutor.createSingleChannelMessageQueue(); const recentSendHistoryService = new RecentSendHistory.RecentSendHistoryService( diff --git a/packages/background/src/tx-executor/message-queue.ts b/packages/background/src/tx-executor/message-queue.ts index 790aa0362f..0d6ae43311 100644 --- a/packages/background/src/tx-executor/message-queue.ts +++ b/packages/background/src/tx-executor/message-queue.ts @@ -1,6 +1,14 @@ import PQueue from "p-queue"; -class MessageQueueCore { +interface MessageQueueCore { + enqueue(message: T): void; + subscribe(handler: (msg: T) => Promise | void): void; + flush(): void; +} + +class SingleChannelMessageQueueCore + implements MessageQueueCore +{ public subscriber: ((msg: T) => Promise | void) | null = null; public buffer: T[] = []; public queue: PQueue; @@ -15,15 +23,12 @@ class MessageQueueCore { this.flush(); } - /** - * subscriber 설정 - */ - setSubscriber(handler: (msg: T) => Promise | void) { + subscribe(handler: (msg: T) => Promise | void) { this.subscriber = handler; this.flush(); } - private flush() { + flush() { if (this.isFlushing) return; if (!this.subscriber) return; if (this.buffer.length === 0) return; @@ -31,15 +36,13 @@ class MessageQueueCore { this.isFlushing = true; try { - console.log("[MessageQueueCore] flush start", this.buffer.length); - while (this.buffer.length > 0) { const msg = this.buffer.shift()!; this.queue.add(async () => { try { await this.subscriber!(msg); } catch (e) { - console.error("[MessageQueueCore] handler error:", e); + console.error("error:", e); } }); } @@ -60,12 +63,12 @@ export class Subscriber { constructor(private core: MessageQueueCore) {} subscribe(handler: (msg: T) => Promise | void) { - this.core.setSubscriber(handler); + this.core.subscribe(handler); } } -export function createMessageQueue(concurrency = 1) { - const core = new MessageQueueCore(concurrency); +export function createSingleChannelMessageQueue(concurrency = 1) { + const core = new SingleChannelMessageQueueCore(concurrency); return { publisher: new Publisher(core), subscriber: new Subscriber(core), diff --git a/packages/background/src/tx-executor/messages.ts b/packages/background/src/tx-executor/messages.ts index d9b2ccefe3..cad4e1f01e 100644 --- a/packages/background/src/tx-executor/messages.ts +++ b/packages/background/src/tx-executor/messages.ts @@ -82,8 +82,7 @@ export class ResumeTxMsg extends Message { public readonly id: string, public readonly txIndex?: number, // NOTE: these fields are optional for hardware wallet cases - public readonly signedTx?: Uint8Array, - public readonly signature?: Uint8Array + public readonly signedTx?: string ) { super(); } @@ -97,13 +96,8 @@ export class ResumeTxMsg extends Message { throw new KeplrError("direct-tx-executor", 103, "txIndex is invalid"); } - // signedTx and signature should be provided together - if (this.signedTx && !this.signature) { - throw new KeplrError("direct-tx-executor", 104, "signature is empty"); - } - - if (!this.signedTx && this.signature) { - throw new KeplrError("direct-tx-executor", 105, "signedTx is empty"); + if (this.signedTx != null && this.signedTx.length === 0) { + throw new KeplrError("direct-tx-executor", 104, "signedTx is empty"); } } diff --git a/packages/background/src/tx-executor/service.ts b/packages/background/src/tx-executor/service.ts index 040ce368b1..cbd35d0d0b 100644 --- a/packages/background/src/tx-executor/service.ts +++ b/packages/background/src/tx-executor/service.ts @@ -94,6 +94,8 @@ export class BackgroundTxExecutorService { for (const [key, value] of entries) { this.recentTxExecutionMap.set(key, value); } + + this.cleanupOldExecutions(); }); } autorun(() => { @@ -111,21 +113,31 @@ export class BackgroundTxExecutorService { return; } - const newExecutableChainIds = executableChainIds.filter((chainId) => - execution.executableChainIds.includes(chainId) + const newExecutableChainIds = executableChainIds.filter( + (chainId) => !execution.executableChainIds.includes(chainId) ); if (newExecutableChainIds.length === 0) { return; } - // update the executable chain ids - for (const chainId of newExecutableChainIds) { - execution.executableChainIds.push(chainId); + runInAction(() => { + // update the executable chain ids + for (const chainId of newExecutableChainIds) { + execution.executableChainIds.push(chainId); + } + }); + + // if the key is hardware wallet, do not resume the execution automatically + // user should sign the transaction manually + const keyInfo = this.keyRingCosmosService.keyRingService.getKeyInfo( + execution.vaultId + ); + if (keyInfo?.type === "ledger" || keyInfo?.type === "keystone") { + return; } // cause new executable chain ids are available, resume the execution - // CHECK: 현재 활성화되어 있는 vault에서만 실행할 수 있으면 좋을 듯, how? vaultId 변경 감지? how? // 불러왔는데 pending 상태거나 오래된 실행이면 사실상 이 작업을 이어가는 것이 의미가 있는지 의문이 든다. await this.executeTxs(executionId); @@ -210,12 +222,11 @@ export class BackgroundTxExecutorService { /** * Execute blocked transactions by execution id and transaction index */ - @action async resumeTx( env: Env, id: string, txIndex?: number, - signedTx?: Uint8Array + signedTx?: string ): Promise { if (!env.isInternalMsg) { // TODO: 에러 코드 신경쓰기 @@ -234,7 +245,7 @@ export class BackgroundTxExecutorService { options?: { env?: Env; txIndex?: number; - signedTx?: Uint8Array; + signedTx?: string; } ): Promise { const execution = this.getTxExecution(id); @@ -267,11 +278,13 @@ export class BackgroundTxExecutorService { } const executionStartIndex = Math.min( - options?.txIndex ?? execution.txIndex < 0 ? 0 : execution.txIndex, + options?.txIndex ?? (execution.txIndex < 0 ? 0 : execution.txIndex), execution.txs.length - 1 ); - execution.status = TxExecutionStatus.PROCESSING; + runInAction(() => { + execution.status = TxExecutionStatus.PROCESSING; + }); for (let i = executionStartIndex; i < execution.txs.length; i++) { let txStatus: BackgroundTxStatus; @@ -295,14 +308,18 @@ export class BackgroundTxExecutorService { // the execution should be stopped and record the history if needed // and the execution should be resumed later when the condition is met if (txStatus === BackgroundTxStatus.BLOCKED) { - execution.status = TxExecutionStatus.BLOCKED; + runInAction(() => { + execution.status = TxExecutionStatus.BLOCKED; + }); this.recordHistoryIfNeeded(execution); return execution.status; } // if the tx is failed, the execution should be stopped if (txStatus === BackgroundTxStatus.FAILED) { - execution.status = TxExecutionStatus.FAILED; + runInAction(() => { + execution.status = TxExecutionStatus.FAILED; + }); return execution.status; } @@ -315,7 +332,9 @@ export class BackgroundTxExecutorService { } // if the execution is completed successfully, update the batch status - execution.status = TxExecutionStatus.COMPLETED; + runInAction(() => { + execution.status = TxExecutionStatus.COMPLETED; + }); this.recordHistoryIfNeeded(execution); return execution.status; } @@ -325,7 +344,7 @@ export class BackgroundTxExecutorService { index: number, options?: { env?: Env; - signedTx?: Uint8Array; + signedTx?: string; } ): Promise { const execution = this.getTxExecution(id); @@ -348,7 +367,9 @@ export class BackgroundTxExecutorService { } // update the tx index to the current tx index - execution.txIndex = index; + runInAction(() => { + execution.txIndex = index; + }); if ( currentTx.status === BackgroundTxStatus.BLOCKED || @@ -361,17 +382,23 @@ export class BackgroundTxExecutorService { currentTx.chainId ); if (isBlocked) { - currentTx.status = BackgroundTxStatus.BLOCKED; + runInAction(() => { + currentTx.status = BackgroundTxStatus.BLOCKED; + }); return currentTx.status; } else { - currentTx.status = BackgroundTxStatus.SIGNING; + runInAction(() => { + currentTx.status = BackgroundTxStatus.SIGNING; + }); } } if (currentTx.status === BackgroundTxStatus.SIGNING) { // if options are provided, temporary set the options to the current transaction if (options?.signedTx) { - currentTx.signedTx = options.signedTx; + runInAction(() => { + currentTx.signedTx = options.signedTx; + }); } try { @@ -381,11 +408,15 @@ export class BackgroundTxExecutorService { options?.env ); - currentTx.signedTx = signedTx; - currentTx.status = BackgroundTxStatus.SIGNED; + runInAction(() => { + currentTx.signedTx = signedTx; + currentTx.status = BackgroundTxStatus.SIGNED; + }); } catch (error) { - currentTx.status = BackgroundTxStatus.FAILED; - currentTx.error = error.message ?? "Transaction signing failed"; + runInAction(() => { + currentTx.status = BackgroundTxStatus.FAILED; + currentTx.error = error.message ?? "Transaction signing failed"; + }); } } @@ -396,11 +427,15 @@ export class BackgroundTxExecutorService { try { const { txHash } = await this.broadcastTx(currentTx); - currentTx.txHash = txHash; - currentTx.status = BackgroundTxStatus.BROADCASTED; + runInAction(() => { + currentTx.txHash = txHash; + currentTx.status = BackgroundTxStatus.BROADCASTED; + }); } catch (error) { - currentTx.status = BackgroundTxStatus.FAILED; - currentTx.error = error.message ?? "Transaction broadcasting failed"; + runInAction(() => { + currentTx.status = BackgroundTxStatus.FAILED; + currentTx.error = error.message ?? "Transaction broadcasting failed"; + }); } } @@ -408,15 +443,19 @@ export class BackgroundTxExecutorService { // broadcasted -> confirmed try { const confirmed = await this.traceTx(currentTx); - if (confirmed) { - currentTx.status = BackgroundTxStatus.CONFIRMED; - } else { - currentTx.status = BackgroundTxStatus.FAILED; - currentTx.error = "Transaction failed"; - } + runInAction(() => { + if (confirmed) { + currentTx.status = BackgroundTxStatus.CONFIRMED; + } else { + currentTx.status = BackgroundTxStatus.FAILED; + currentTx.error = "Transaction failed"; + } + }); } catch (error) { - currentTx.status = BackgroundTxStatus.FAILED; - currentTx.error = error.message ?? "Transaction confirmation failed"; + runInAction(() => { + currentTx.status = BackgroundTxStatus.FAILED; + currentTx.error = error.message ?? "Transaction confirmation failed"; + }); } } @@ -428,7 +467,7 @@ export class BackgroundTxExecutorService { tx: BackgroundTx, env?: Env ): Promise<{ - signedTx: Uint8Array; + signedTx: string; }> { if (tx.signedTx != null) { return { @@ -448,7 +487,7 @@ export class BackgroundTxExecutorService { tx: EVMBackgroundTx, env?: Env ): Promise<{ - signedTx: Uint8Array; + signedTx: string; }> { const keyInfo = await this.keyRingCosmosService.getKey(vaultId, tx.chainId); const isHardware = keyInfo.isNanoLedger || keyInfo.isKeystone; @@ -512,10 +551,7 @@ export class BackgroundTxExecutorService { delete unsignedTx.from; - const signedTx = Buffer.from( - serialize(unsignedTx, result.signature).replace("0x", ""), - "hex" - ); + const signedTx = serialize(unsignedTx, result.signature); return { signedTx: signedTx, @@ -527,7 +563,7 @@ export class BackgroundTxExecutorService { tx: CosmosBackgroundTx, env?: Env ): Promise<{ - signedTx: Uint8Array; + signedTx: string; }> { // check key const keyInfo = await this.keyRingCosmosService.getKey(vaultId, tx.chainId); @@ -657,7 +693,7 @@ export class BackgroundTxExecutorService { }); return { - signedTx: signedTx.tx, + signedTx: Buffer.from(signedTx.tx).toString("base64"), }; } else { const account = await BaseAccount.fetchFromRest( @@ -711,9 +747,7 @@ export class BackgroundTxExecutorService { useEthereumSign: false, }); - return { - signedTx: signedTx.tx, - }; + return { signedTx: Buffer.from(signedTx.tx).toString("base64") }; } } @@ -753,10 +787,12 @@ export class BackgroundTxExecutorService { ? new URL(browser.runtime.getURL("/")).origin : "extension"; + const signedTxBytes = Buffer.from(tx.signedTx.replace("0x", ""), "hex"); + const txHash = await this.backgroundTxEthereumService.sendEthereumTx( origin, tx.chainId, - tx.signedTx, + signedTxBytes, { silent: true, skipTracingTxResult: true, @@ -775,10 +811,12 @@ export class BackgroundTxExecutorService { throw new KeplrError("direct-tx-executor", 108, "Signed tx not found"); } + const signedTxBytes = Buffer.from(tx.signedTx, "base64"); + // broadcast the tx const txHash = await this.backgroundTxService.sendTx( tx.chainId, - tx.signedTx, + signedTxBytes, "sync", { silent: true, @@ -838,6 +876,7 @@ export class BackgroundTxExecutorService { return txResult.code === 0; } + @action protected recordHistoryIfNeeded(execution: TxExecution): void { if (execution.type === TxExecutionType.UNDEFINED) { return; @@ -1002,10 +1041,11 @@ export class BackgroundTxExecutorService { const currentStatus = execution.status; - // Only pending or processing executions can be cancelled + // Only pending/processing/blocked executions can be cancelled if ( currentStatus !== TxExecutionStatus.PENDING && - currentStatus !== TxExecutionStatus.PROCESSING + currentStatus !== TxExecutionStatus.PROCESSING && + currentStatus !== TxExecutionStatus.BLOCKED ) { return; } @@ -1022,4 +1062,39 @@ export class BackgroundTxExecutorService { protected removeTxExecution(id: string): void { this.recentTxExecutionMap.delete(id); } + + @action + protected cleanupOldExecutions(): void { + const maxAge = 7 * 24 * 60 * 60 * 1000; // 7일 + const now = Date.now(); + + const completedStatuses = [ + TxExecutionStatus.COMPLETED, + TxExecutionStatus.FAILED, + TxExecutionStatus.CANCELLED, + ]; + + for (const [id, execution] of this.recentTxExecutionMap) { + // 비정상 종료된 PROCESSING 상태 → FAILED 처리 + // (브라우저 종료, 시스템 재부팅, 익스텐션 업데이트 등) + if (execution.status === TxExecutionStatus.PROCESSING) { + execution.status = TxExecutionStatus.FAILED; + } + + const isOld = now - execution.timestamp > maxAge; + const isDone = completedStatuses.includes(execution.status); + + if (isOld && isDone) { + this.recentTxExecutionMap.delete(id); + } + } + + const entries = Array.from(this.recentTxExecutionMap.entries()) + .filter(([, e]) => completedStatuses.includes(e.status)) + .sort(([, a], [, b]) => a.timestamp - b.timestamp); + + for (let i = 0; i < entries.length; i++) { + this.recentTxExecutionMap.delete(entries[i][0]); + } + } } diff --git a/packages/background/src/tx-executor/types.ts b/packages/background/src/tx-executor/types.ts index 250a4de789..50d81864e9 100644 --- a/packages/background/src/tx-executor/types.ts +++ b/packages/background/src/tx-executor/types.ts @@ -29,8 +29,8 @@ interface BackgroundTxBase { status: BackgroundTxStatus; // mutable while executing readonly chainId: string; - // signed transaction data - signedTx?: Uint8Array; + // Cosmos: base64 encoded, EVM: hex encoded (0x prefix) + signedTx?: string; // Transaction hash for completed tx txHash?: string; From b92d98e867614c0c72da635540a2904499b81e51 Mon Sep 17 00:00:00 2001 From: rowan Date: Mon, 1 Dec 2025 07:20:28 +0900 Subject: [PATCH 21/29] publish executable chain ids --- .../src/recent-send-history/service.ts | 417 +++++++----------- 1 file changed, 165 insertions(+), 252 deletions(-) diff --git a/packages/background/src/recent-send-history/service.ts b/packages/background/src/recent-send-history/service.ts index ebf919c2bc..b36ac9e98a 100644 --- a/packages/background/src/recent-send-history/service.ts +++ b/packages/background/src/recent-send-history/service.ts @@ -872,11 +872,29 @@ export class RecentSendHistoryService { nextChannel.channelId, index ); + + // publish executable chains + if (history.backgroundExecutionId) { + this.publisher.publish({ + executionId: history.backgroundExecutionId, + executableChainIds: + this.getExecutableChainIdsFromIBCHistory(history), + }); + } + onFulfillOnce(); this.trackIBCPacketForwardingRecursive(id); break; } else { // Packet received to destination chain. + if (history.backgroundExecutionId) { + this.publisher.publish({ + executionId: history.backgroundExecutionId, + executableChainIds: + this.getExecutableChainIdsFromIBCHistory(history), + }); + } + if (history.notificationInfo && !history.notified) { runInAction(() => { history.notified = true; @@ -1329,6 +1347,7 @@ export class RecentSendHistoryService { ); } + // TODO: refactor protected checkAndTrackSwapTxFulfilledRecursive = ( type: "skip" | "swap-v2", historyId: string, @@ -2238,40 +2257,14 @@ export class RecentSendHistoryService { return; } - const { - txHash, - fromChainId, - toChainId, - provider, - status, - trackDone, - routeIndex, - simpleRoute, - } = history; - - let needRun = true; - - // if the status is partial success or success, and the route index is the last one, then don't need to run - if ( - status === SwapV2TxStatus.PARTIAL_SUCCESS || - status === SwapV2TxStatus.SUCCESS - ) { - if (routeIndex === simpleRoute.length - 1) { - needRun = false; - } - } - - // if the track done is not true, then need to run - if (!trackDone) { - needRun = true; - } - - // quit if not need to run - if (!needRun) { + // if already tracked, fulfill + if (history.trackDone) { onFulfill(); return; } + const { txHash, fromChainId, toChainId, provider } = history; + const normalizeChainId = (chainId: string): string => { return chainId.replace("eip155:", ""); }; @@ -2295,240 +2288,127 @@ export class RecentSendHistoryService { } ) .then((res) => { - const { status, steps, asset_location } = res.data; - - // Update the overall status - runInAction(() => { - history.status = status; - }); + this.processSwapV2StatusResponse(id, res.data, onFulfill, onError); + }) + .catch((e) => { + console.error("SwapV2 status tracking error:", e); + onError(); + }); + } - // Handle empty steps array - if (!steps || steps.length === 0) { - if (status !== SwapV2TxStatus.IN_PROGRESS) { - runInAction(() => { - history.trackDone = true; - }); - onFulfill(); - } else { - onError(); - } - return; - } + @action + protected processSwapV2StatusResponse( + id: string, + response: SwapV2TxStatusResponse, + onFulfill: () => void, + onError: () => void + ): void { + const history = this.getRecentSwapV2History(id); + if (!history) { + onFulfill(); + return; + } - // steps 배열에는 현재 진행중이거나 완료/실패된 단계만 포함되므로 - // simpleRoute와 길이가 다를 수 있음 - // 따라서 steps의 인덱스와 simpleRoute의 인덱스를 구분해야 함 + const { status, steps, asset_location } = response; + const { simpleRoute } = history; + const prevRouteIndex = history.routeIndex; - // find the next blocking step (in progress or failed) - const nextBlockingStepIndex = steps.findIndex((step) => { - return ( - step.status === SwapV2RouteStepStatus.IN_PROGRESS || - step.status === SwapV2RouteStepStatus.FAILED - ); - }); + history.status = status; + history.trackError = undefined; - // Find the current step to process - // If there's a blocking step, use it; otherwise use the last step - const currentStepIndex = - nextBlockingStepIndex >= 0 ? nextBlockingStepIndex : steps.length - 1; - const currentStep = steps[currentStepIndex]; + if (!steps || steps.length === 0) { + if (status === SwapV2TxStatus.IN_PROGRESS) { + onError(); + } else { + history.trackDone = true; + onFulfill(); + } + return; + } - if (!currentStep) { - // No step found, mark as done if status is final - if (status !== SwapV2TxStatus.IN_PROGRESS) { - runInAction(() => { - history.trackDone = true; - }); + // find current step (in_progress/failed first, otherwise last step) + const currentStep = + steps.find( + (s) => + s.status === SwapV2RouteStepStatus.IN_PROGRESS || + s.status === SwapV2RouteStepStatus.FAILED + ) ?? steps[steps.length - 1]; + + // calculate routeIndex by matching step.chain_id with simpleRoute + let updatedRouteIndex = Math.max(0, history.routeIndex); + if (currentStep.chain_id) { + const normalizedStepChainId = currentStep.chain_id.toLowerCase(); + for (let i = 0; i < simpleRoute.length; i++) { + const routeChainId = simpleRoute[i].chainId + .replace("eip155:", "") + .toLowerCase(); + if (routeChainId === normalizedStepChainId) { + updatedRouteIndex = i; + break; + } + } + } + history.routeIndex = updatedRouteIndex; + + // publish executable chains if routeIndex increased + if (updatedRouteIndex > prevRouteIndex && history.backgroundExecutionId) { + this.publisher.publish({ + executionId: history.backgroundExecutionId, + executableChainIds: + this.getExecutableChainIdsFromSwapV2History(history), + }); + } - // Handle asset location for failed status - if ( - status === SwapV2TxStatus.FAILED && - asset_location && - asset_location.length > 0 - ) { - const location = asset_location[asset_location.length - 1]; - const chainId = location.chain_id; - const evmLikeChainId = Number(chainId); - const isEVMChainId = - !Number.isNaN(evmLikeChainId) && evmLikeChainId > 0; - - history.swapRefundInfo = { - chainId: isEVMChainId ? `eip155:${chainId}` : chainId, - amount: [ - { - amount: location.amount, - denom: location.denom, - }, - ], - }; - } else if ( - status === SwapV2TxStatus.SUCCESS || - status === SwapV2TxStatus.PARTIAL_SUCCESS - ) { - // Track destination asset amount using the last step - const lastStep = steps[steps.length - 1]; - if (lastStep && lastStep.tx_hash) { - this.trackSwapV2DestinationAssetAmount( - id, - lastStep.tx_hash, - onFulfill - ); - } else { - onFulfill(); - } - } else { - onFulfill(); - } + switch (currentStep.status) { + case SwapV2RouteStepStatus.IN_PROGRESS: + onError(); + break; + + case SwapV2RouteStepStatus.SUCCESS: { + const isLastRoute = updatedRouteIndex >= simpleRoute.length - 1; + const isFinalStatus = + status === SwapV2TxStatus.SUCCESS || + status === SwapV2TxStatus.PARTIAL_SUCCESS; + + if (isLastRoute && isFinalStatus) { + if (currentStep.tx_hash) { + this.trackSwapV2DestinationAssetAmount( + id, + currentStep.tx_hash, + onFulfill + ); } else { - // if the status is not final, then we need to continue tracking - onError(); + history.trackDone = true; + onFulfill(); } - return; + } else { + onError(); } + break; + } - // Update routeIndex based on current step's chain_id - // Match the step's chain_id with simpleRoute to find the correct routeIndex - // steps의 인덱스와 simpleRoute의 인덱스는 다를 수 있으므로 chain_id로 매칭해야 함 - let updatedRouteIndex = routeIndex < 0 ? 0 : routeIndex; - if (currentStep.chain_id) { - // Find the routeIndex in simpleRoute that matches the current step's chain_id - for (let i = 0; i < simpleRoute.length; i++) { - const routeChainId = simpleRoute[i].chainId.replace("eip155:", ""); - if ( - routeChainId.toLowerCase() === currentStep.chain_id.toLowerCase() - ) { - updatedRouteIndex = i; - break; - } - } - // If not found, try to find the next route after the current routeIndex - if (updatedRouteIndex === (routeIndex < 0 ? 0 : routeIndex)) { - // If we couldn't find a match, check if we should advance - // Look for routes after the current routeIndex - for ( - let i = (routeIndex < 0 ? 0 : routeIndex) + 1; - i < simpleRoute.length; - i++ - ) { - const routeChainId = simpleRoute[i].chainId.replace( - "eip155:", - "" - ); - if ( - routeChainId.toLowerCase() === - currentStep.chain_id.toLowerCase() - ) { - updatedRouteIndex = i; - break; - } - } + case SwapV2RouteStepStatus.FAILED: + if (status === SwapV2TxStatus.IN_PROGRESS) { + onError(); + } else { + history.trackDone = true; + history.status = SwapV2TxStatus.FAILED; + // set refund info from asset_location + if (asset_location && asset_location.length > 0) { + const location = asset_location[asset_location.length - 1]; + const chainId = location.chain_id; + const evmLikeChainId = Number(chainId); + const isEVMChainId = + !Number.isNaN(evmLikeChainId) && evmLikeChainId > 0; + history.swapRefundInfo = { + chainId: isEVMChainId ? `eip155:${chainId}` : chainId, + amount: [{ amount: location.amount, denom: location.denom }], + }; } + onFulfill(); } - - runInAction(() => { - history.routeIndex = updatedRouteIndex; - history.trackError = undefined; - }); - - switch (currentStep.status) { - case SwapV2RouteStepStatus.IN_PROGRESS: - // Still in progress, continue tracking - onError(); - break; - - case SwapV2RouteStepStatus.SUCCESS: - // Current step succeeded - if ( - status === SwapV2TxStatus.SUCCESS || - status === SwapV2TxStatus.PARTIAL_SUCCESS - ) { - // Check if this is the last route in simpleRoute - // steps 배열은 부분 정보이므로 simpleRoute의 마지막 인덱스와 비교해야 함 - const isLastRoute = updatedRouteIndex >= simpleRoute.length - 1; - - if (isLastRoute) { - // This is the final route, track destination asset amount - if (currentStep.tx_hash) { - this.trackSwapV2DestinationAssetAmount( - id, - currentStep.tx_hash, - onFulfill - ); - } else { - runInAction(() => { - history.trackDone = true; - }); - onFulfill(); - } - } else { - // Not the last route, continue tracking - // Update routeIndex to the next route - runInAction(() => { - history.routeIndex = updatedRouteIndex; - }); - onError(); // Continue tracking - } - } else { - // Status is still IN_PROGRESS, continue tracking - runInAction(() => { - history.routeIndex = updatedRouteIndex; - }); - onError(); - } - break; - - case SwapV2RouteStepStatus.FAILED: - // Step failed - // If overall status is still IN_PROGRESS, this might be a temporary failure - // Continue tracking in case it recovers - if (status === SwapV2TxStatus.IN_PROGRESS) { - // Overall status is still in progress, continue tracking - // This might be a temporary failure that could recover - runInAction(() => { - history.routeIndex = updatedRouteIndex; - // Don't set trackError yet, as it might recover - }); - onError(); // Continue tracking - break; - } - - // Overall status is FAILED or final, mark as done with error - // Handle asset location if available - if (asset_location && asset_location.length > 0) { - const location = asset_location[asset_location.length - 1]; - const chainId = location.chain_id; - const evmLikeChainId = Number(chainId); - const isEVMChainId = - !Number.isNaN(evmLikeChainId) && evmLikeChainId > 0; - - runInAction(() => { - history.trackDone = true; - history.status = SwapV2TxStatus.FAILED; - history.swapRefundInfo = { - chainId: isEVMChainId ? `eip155:${chainId}` : chainId, - amount: [ - { - amount: location.amount, - denom: location.denom, - }, - ], - }; - }); - } else { - runInAction(() => { - history.trackDone = true; - history.status = SwapV2TxStatus.FAILED; - }); - } - onFulfill(); - break; - } - }) - .catch((e) => { - console.error(e); - - onError(); - }); + break; + } } protected trackSwapV2DestinationAssetAmount( @@ -3485,4 +3365,37 @@ export class RecentSendHistoryService { } }); }; + + protected getExecutableChainIdsFromIBCHistory(history: IBCHistory): string[] { + const chainIds: string[] = [history.chainId]; + + for (const channel of history.ibcHistory) { + if (channel.completed) { + chainIds.push(channel.counterpartyChainId); + if (channel.dstChannelId) { + chainIds.push(channel.dstChannelId); + } + } else { + break; + } + } + + return chainIds; + } + + protected getExecutableChainIdsFromSwapV2History( + history: SwapV2History + ): string[] { + const chainIds: string[] = []; + + for ( + let i = 0; + i <= history.routeIndex && i < history.simpleRoute.length; + i++ + ) { + chainIds.push(history.simpleRoute[i].chainId); + } + + return chainIds; + } } From 0b8165a9d3c59e48d486d146b9fc946af38072fc Mon Sep 17 00:00:00 2001 From: rowan Date: Mon, 1 Dec 2025 08:34:35 +0900 Subject: [PATCH 22/29] use fee type --- .../background/src/tx-executor/service.ts | 21 ++++++++----------- .../background/src/tx-executor/utils/evm.ts | 12 ++++++++--- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/packages/background/src/tx-executor/service.ts b/packages/background/src/tx-executor/service.ts index cbd35d0d0b..d71b688909 100644 --- a/packages/background/src/tx-executor/service.ts +++ b/packages/background/src/tx-executor/service.ts @@ -405,6 +405,7 @@ export class BackgroundTxExecutorService { const { signedTx } = await this.signTx( execution.vaultId, currentTx, + execution.feeType, options?.env ); @@ -465,6 +466,7 @@ export class BackgroundTxExecutorService { protected async signTx( vaultId: string, tx: BackgroundTx, + feeType: ExecutionFeeType, env?: Env ): Promise<{ signedTx: string; @@ -476,15 +478,16 @@ export class BackgroundTxExecutorService { } if (tx.type === BackgroundTxType.EVM) { - return this.signEvmTx(vaultId, tx, env); + return this.signEvmTx(vaultId, tx, feeType, env); } - return this.signCosmosTx(vaultId, tx, env); + return this.signCosmosTx(vaultId, tx, feeType, env); } private async signEvmTx( vaultId: string, tx: EVMBackgroundTx, + feeType: ExecutionFeeType, env?: Env ): Promise<{ signedTx: string; @@ -528,7 +531,8 @@ export class BackgroundTxExecutorService { origin, evmInfo, signer, - tx.txData + tx.txData, + feeType ); result = await this.keyRingEthereumService.signEthereumPreAuthorized( @@ -561,6 +565,7 @@ export class BackgroundTxExecutorService { private async signCosmosTx( vaultId: string, tx: CosmosBackgroundTx, + feeType: ExecutionFeeType, env?: Env ): Promise<{ signedTx: string; @@ -711,7 +716,7 @@ export class BackgroundTxExecutorService { ); // TODO: fee token을 사용자가 설정한 것을 사용해야 함 - const { gasPrice } = await getCosmosGasPrice(chainInfo); + const { gasPrice } = await getCosmosGasPrice(chainInfo, feeType); const fee = calculateCosmosStdFee( chainInfo.currencies[0], gasUsed, @@ -1088,13 +1093,5 @@ export class BackgroundTxExecutorService { this.recentTxExecutionMap.delete(id); } } - - const entries = Array.from(this.recentTxExecutionMap.entries()) - .filter(([, e]) => completedStatuses.includes(e.status)) - .sort(([, a], [, b]) => a.timestamp - b.timestamp); - - for (let i = 0; i < entries.length; i++) { - this.recentTxExecutionMap.delete(entries[i][0]); - } } } diff --git a/packages/background/src/tx-executor/utils/evm.ts b/packages/background/src/tx-executor/utils/evm.ts index 766569e4fd..8d60858645 100644 --- a/packages/background/src/tx-executor/utils/evm.ts +++ b/packages/background/src/tx-executor/utils/evm.ts @@ -2,14 +2,20 @@ import { EVMInfo } from "@keplr-wallet/types"; import { simpleFetch } from "@keplr-wallet/simple-fetch"; import { UnsignedTransaction } from "@ethersproject/transactions"; import { Dec } from "@keplr-wallet/unit"; +import { ExecutionFeeType } from "../types"; -const DEFAULT_MULTIPLIER = 1.25; +const FEE_MULTIPLIERS: Record = { + low: 1.1, + average: 1.25, + high: 1.5, +}; export async function fillUnsignedEVMTx( origin: string, evmInfo: EVMInfo, signer: string, - tx: UnsignedTransaction + tx: UnsignedTransaction, + feeType: ExecutionFeeType = "average" ): Promise { const getTransactionCountRequest = { jsonrpc: "2.0", @@ -162,7 +168,7 @@ export async function fillUnsignedEVMTx( throw new Error("Failed to get baseFeePerGas to fill unsigned transaction"); } - const multiplier = new Dec(DEFAULT_MULTIPLIER); + const multiplier = new Dec(FEE_MULTIPLIERS[feeType]); // Calculate maxFeePerGas = baseFeePerGas + maxPriorityFeePerGas const baseFeePerGasDec = new Dec(BigInt(latestBlock.baseFeePerGas)); From 22231e25af6bac3fd77e54589e4b276a264acf79 Mon Sep 17 00:00:00 2001 From: rowan Date: Mon, 1 Dec 2025 17:51:10 +0900 Subject: [PATCH 23/29] add hold button --- .../hold-button/circular-progress.tsx | 59 +++++++ .../components/hold-button/hold-button.tsx | 165 ++++++++++++++++++ .../src/components/hold-button/index.ts | 3 + .../src/components/hold-button/types.ts | 13 ++ apps/extension/src/languages/en.json | 2 + apps/extension/src/languages/ko.json | 2 + apps/extension/src/languages/zh-cn.json | 2 + apps/extension/src/pages/ibc-swap/index.tsx | 10 +- 8 files changed, 253 insertions(+), 3 deletions(-) create mode 100644 apps/extension/src/components/hold-button/circular-progress.tsx create mode 100644 apps/extension/src/components/hold-button/hold-button.tsx create mode 100644 apps/extension/src/components/hold-button/index.ts create mode 100644 apps/extension/src/components/hold-button/types.ts diff --git a/apps/extension/src/components/hold-button/circular-progress.tsx b/apps/extension/src/components/hold-button/circular-progress.tsx new file mode 100644 index 0000000000..aeafbc5223 --- /dev/null +++ b/apps/extension/src/components/hold-button/circular-progress.tsx @@ -0,0 +1,59 @@ +import React, { CSSProperties, FunctionComponent } from "react"; + +export interface CircularProgressProps { + progress: number; // 0 ~ 1 + size?: string; + color?: string; + style?: CSSProperties; +} + +export const CircularProgress: FunctionComponent = ({ + progress, + size = "1.25rem", + color = "white", + style, +}) => { + const radius = 8.5; + const strokeWidth = 3; + const circumference = 2 * Math.PI * radius; + const strokeDashoffset = circumference * (1 - progress); + + return ( + + {/* Background donut - 30% opacity */} + + {/* Progress circle */} + + + ); +}; diff --git a/apps/extension/src/components/hold-button/hold-button.tsx b/apps/extension/src/components/hold-button/hold-button.tsx new file mode 100644 index 0000000000..4098742efd --- /dev/null +++ b/apps/extension/src/components/hold-button/hold-button.tsx @@ -0,0 +1,165 @@ +import React, { + FunctionComponent, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { HoldButtonProps } from "./types"; +import { Styles } from "../button/styles"; +import { LoadingIcon } from "../icon"; +import { Box } from "../box"; +import { useTheme } from "styled-components"; +import { CircularProgress } from "./circular-progress"; + +const UPDATE_INTERVAL_MS = 16; +const MIN_HOLD_DURATION_MS = 100; + +export const HoldButton: FunctionComponent = ({ + holdDurationMs, + onConfirm, + onHoldStart, + onHoldEnd, + onProgressChange, + type = "button", + progressSize = "1.25rem", + style, + className, + text, + holdingText, + left, + right, + isLoading, + disabled, + ...otherProps +}) => { + const theme = useTheme(); + + const [isHolding, setIsHolding] = useState(false); + const [progress, setProgress] = useState(0); + + const buttonRef = useRef(null); + const holdStartTimeRef = useRef(null); + const intervalRef = useRef(null); + const confirmedRef = useRef(false); + + const clearHoldInterval = useCallback(() => { + if (intervalRef.current !== null) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, []); + + const startHold = useCallback(() => { + if (disabled || isLoading) return; + + confirmedRef.current = false; + setIsHolding(true); + setProgress(0); + holdStartTimeRef.current = Date.now(); + onHoldStart?.(); + + intervalRef.current = window.setInterval(() => { + if (holdStartTimeRef.current === null) return; + + const elapsed = Date.now() - holdStartTimeRef.current; + const newProgress = Math.min( + elapsed / Math.max(holdDurationMs, MIN_HOLD_DURATION_MS), + 1 + ); + + setProgress(newProgress); + onProgressChange?.(newProgress); + + if (newProgress >= 1 && !confirmedRef.current) { + confirmedRef.current = true; + clearHoldInterval(); + setIsHolding(false); + setProgress(0); + holdStartTimeRef.current = null; + + onConfirm?.(); + + if (type === "submit" && buttonRef.current?.form) { + buttonRef.current.form.requestSubmit(); + } + } + }, UPDATE_INTERVAL_MS); + }, [ + disabled, + isLoading, + holdDurationMs, + onHoldStart, + onProgressChange, + onConfirm, + type, + clearHoldInterval, + ]); + + const endHold = useCallback(() => { + if (!isHolding) return; + + clearHoldInterval(); + setIsHolding(false); + setProgress(0); + holdStartTimeRef.current = null; + onHoldEnd?.(); + }, [isHolding, clearHoldInterval, onHoldEnd]); + + useEffect(() => { + return () => { + clearHoldInterval(); + }; + }, [clearHoldInterval]); + + const displayText = isHolding && holdingText ? holdingText : text; + + // CircularProgress is default left content + const leftContent = + left !== undefined ? ( + left + ) : ( + + ); + + return ( + + + {leftContent ? {leftContent} : null} + + {isLoading ? ( + + + + ) : null} + + {displayText} + + {right ? {right} : null} + + + ); +}; diff --git a/apps/extension/src/components/hold-button/index.ts b/apps/extension/src/components/hold-button/index.ts new file mode 100644 index 0000000000..2eefc13a68 --- /dev/null +++ b/apps/extension/src/components/hold-button/index.ts @@ -0,0 +1,3 @@ +export * from "./hold-button"; +export * from "./circular-progress"; +export * from "./types"; diff --git a/apps/extension/src/components/hold-button/types.ts b/apps/extension/src/components/hold-button/types.ts new file mode 100644 index 0000000000..8c83265f82 --- /dev/null +++ b/apps/extension/src/components/hold-button/types.ts @@ -0,0 +1,13 @@ +import { ReactNode } from "react"; +import { ButtonProps } from "../button/types"; + +export interface HoldButtonProps extends ButtonProps { + holdDurationMs: number; + onConfirm?: () => void; + onHoldStart?: () => void; + onHoldEnd?: () => void; + onProgressChange?: (progress: number) => void; + + progressSize?: string; + holdingText?: string | ReactNode; +} diff --git a/apps/extension/src/languages/en.json b/apps/extension/src/languages/en.json index f91ab84776..ad912d1bc3 100644 --- a/apps/extension/src/languages/en.json +++ b/apps/extension/src/languages/en.json @@ -439,6 +439,8 @@ "page.ibc-swap.title.swap": "Swap", "page.ibc-swap.button.next": "Swap", + "page.ibc-swap.button.hold-to-approve": "Hold to Approve & Swap", + "page.ibc-swap.button.keep-holding": "Keep holding...", "page.ibc-swap.error.no-route-found": "No routes found for this swap", "page.ibc-swap.warning.unable-to-populate-price": "Unable to retrieve {assets} price from Coingecko. Check the fiat values of input and output from other sources before clicking \"Next\".", "page.ibc-swap.warning.high-price-impact-title": "{inPrice} on {srcChain} → {outPrice} on {dstChain}", diff --git a/apps/extension/src/languages/ko.json b/apps/extension/src/languages/ko.json index b5057bb23f..07cf39e74e 100644 --- a/apps/extension/src/languages/ko.json +++ b/apps/extension/src/languages/ko.json @@ -433,6 +433,8 @@ "page.ibc-swap.title.swap": "토큰 교환", "page.ibc-swap.button.next": "스왑", + "page.ibc-swap.button.hold-to-approve": "꾹 눌러서 승인 & 스왑", + "page.ibc-swap.button.keep-holding": "계속 누르세요...", "page.ibc-swap.error.no-route-found": "이 교환에 맞는 경로가 검색되지 않습니다.", "page.ibc-swap.warning.unable-to-populate-price": "{assets}의 가격을 알 수 없습니다. \"다음\"을 누르기 전에 각각 통화 가치를 확인하십시오.", "page.ibc-swap.warning.high-price-impact-title": "{inPrice} ({srcChain}) → {outPrice} ({dstChain})", diff --git a/apps/extension/src/languages/zh-cn.json b/apps/extension/src/languages/zh-cn.json index 95e8dac646..dfd0a1d776 100644 --- a/apps/extension/src/languages/zh-cn.json +++ b/apps/extension/src/languages/zh-cn.json @@ -397,6 +397,8 @@ "page.ibc-swap.title.swap": "兑换", "page.ibc-swap.button.next": "交换", + "page.ibc-swap.button.hold-to-approve": "按住以批准并交换", + "page.ibc-swap.button.keep-holding": "请继续按住...", "page.ibc-swap.error.no-route-found": "找不到此兑换的路线", "page.ibc-swap.warning.unable-to-populate-price": "无法从Coingecko检索到{assets}的价格。在点击“下一步”之前从其他来源检查输入和输出的法定价值。", "page.ibc-swap.warning.high-price-impact-title": "{inPrice} ({srcChain}) → {outPrice} ({dstChain})", diff --git a/apps/extension/src/pages/ibc-swap/index.tsx b/apps/extension/src/pages/ibc-swap/index.tsx index 4f254a65a6..d93ae95940 100644 --- a/apps/extension/src/pages/ibc-swap/index.tsx +++ b/apps/extension/src/pages/ibc-swap/index.tsx @@ -50,7 +50,7 @@ import { BACKGROUND_PORT, Message } from "@keplr-wallet/router"; import { ChainIdHelper } from "@keplr-wallet/cosmos"; import { useEffectOnce } from "../../hooks/use-effect-once"; import { amountToAmbiguousAverage, amountToAmbiguousString } from "../../utils"; -import { Button } from "../../components/button"; +import { HoldButton } from "../../components/hold-button"; import { TextButtonProps } from "../../components/button-text"; import { UnsignedEVMTransaction, @@ -1861,8 +1861,9 @@ export const IBCSwapPage: FunctionComponent = observer(() => { -