diff --git a/apps/extension/src/pages/wallet/smart-account/confirm/confirm.tsx b/apps/extension/src/pages/wallet/smart-account/confirm/confirm.tsx index 3d406547c4..224c6aa18d 100644 --- a/apps/extension/src/pages/wallet/smart-account/confirm/confirm.tsx +++ b/apps/extension/src/pages/wallet/smart-account/confirm/confirm.tsx @@ -69,7 +69,7 @@ export const SmartAccountConfirmPage: FunctionComponent = observer(() => { insufficientBalance, balanceLoaded, retry, - } = useSmartAccountFee(chainId, vaultId, hexAddress, isValid); + } = useSmartAccountFee(chainId, hexAddress, isValid); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -109,7 +109,8 @@ export const SmartAccountConfirmPage: FunctionComponent = observer(() => { insufficientBalance || isFailed || !balanceLoaded || - isEstimating; + isEstimating || + !feeDisplay; return ( { try { @@ -30,46 +32,102 @@ export function useSmartAccountFee( } }, [chainStore, chainId]); + const evmChainId = useMemo(() => { + const unwrapped = chainStore.getModularChain(chainId).unwrapped; + if (unwrapped.type === "evm") return unwrapped.evm.chainId; + if (unwrapped.type === "ethermint") return unwrapped.evm.chainId; + return 0; + }, [chainStore, chainId]); + const nativeSymbol = nativeCurrency?.coinDenom ?? "ETH"; const [feeState, setFeeState] = useState({ status: "loading" }); const [retryCount, setRetryCount] = useState(0); useEffect(() => { - if (!isValid) return; + if (!isValid || !hexAddress || !evmChainId) return; let cancelled = false; setFeeState({ status: "loading" }); - new InExtensionMessageRequester() - .sendMessage( - BACKGROUND_PORT, - new EstimateSmartAccountFeeMsg(chainId, vaultId) - ) - .then((result) => { - if (!cancelled) + + const ethereumAccount = ethereumAccountStore.getAccount(chainId); + + const modularChainInfo = chainStore.getModularChain(chainId); + const hasOpStackFee = modularChainInfo.hasFeature("op-stack-l1-data-fee"); + + ethereumAccount + .simulateGas(hexAddress, { + to: hexAddress, + value: "0x0", + data: "0x", + authorizationList: buildDummyAuthorizationList( + ALLOWED_DELEGATORS[0], + evmChainId + ), + }) + .then(async (result) => { + if (cancelled) return; + + let l1DataFee: Dec | undefined; + if (hasOpStackFee) { + const gasLimit = Math.ceil(result.gasUsed * 1.3); + const l1Fee = await ethereumAccount.simulateOpStackL1Fee({ + to: hexAddress, + value: "0x0", + data: "0x", + gasLimit, + }); + l1DataFee = new Dec(BigInt(l1Fee)); + } + + if (!cancelled) { setFeeState({ status: "success", - estimatedFeeWei: result.estimatedFeeWei, + gasUsed: result.gasUsed, + l1DataFee, }); + } }) .catch(() => { if (!cancelled) setFeeState({ status: "error" }); }); + return () => { cancelled = true; }; - }, [chainId, vaultId, isValid, retryCount]); + }, [ + chainId, + chainStore, + hexAddress, + evmChainId, + isValid, + retryCount, + ethereumAccountStore, + ]); + + // Fee from reactive queries (feeHistory percentile + baseFee margin, fillUnsignedEVMTx와 동일 로직) + const ethereumQueries = queriesStore.get(chainId).ethereum; + const txFees = ethereumQueries + ? computeEIP1559TxFees(ethereumQueries, "average", chainId) + : undefined; + + const estimatedFeeWei = useMemo(() => { + if (feeState.status !== "success") return null; + const feePerGas = txFees?.maxFeePerGas ?? txFees?.gasPrice; + if (!feePerGas || feePerGas.isZero()) return null; + const gasLimit = Math.ceil(feeState.gasUsed * 1.3); + let fee = feePerGas.mul(new Dec(gasLimit)).truncate(); + if (feeState.l1DataFee) { + fee = fee.add(feeState.l1DataFee.truncate()); + } + return "0x" + BigInt(fee.toString()).toString(16); + }, [feeState, txFees]); - const estimatedFeeWei = - feeState.status === "success" ? feeState.estimatedFeeWei : null; const isEstimating = feeState.status === "loading"; const isFailed = feeState.status === "error"; const feeDisplay = useMemo( - () => - feeState.status === "success" - ? formatFee(feeState.estimatedFeeWei, nativeSymbol) - : null, - [feeState, nativeSymbol] + () => (estimatedFeeWei ? formatFee(estimatedFeeWei, nativeSymbol) : null), + [estimatedFeeWei, nativeSymbol] ); const feeUsd = useMemo(() => { diff --git a/packages/background/src/keyring-ethereum/handler.ts b/packages/background/src/keyring-ethereum/handler.ts index e0dd0c791f..37e1fd80b8 100644 --- a/packages/background/src/keyring-ethereum/handler.ts +++ b/packages/background/src/keyring-ethereum/handler.ts @@ -12,7 +12,6 @@ import { CheckNeedEnableAccessForEVMMsg, UpgradeSmartAccountMsg, DowngradeSmartAccountMsg, - EstimateSmartAccountFeeMsg, GetEthereumAddressForVaultMsg, } from "./messages"; import { KeyRingEthereumService } from "./service"; @@ -59,12 +58,6 @@ export const getHandler: ( (msg as DowngradeSmartAccountMsg).chainId, (msg as DowngradeSmartAccountMsg).vaultId ); - case EstimateSmartAccountFeeMsg: - return service.estimateSmartAccountFee( - env, - (msg as EstimateSmartAccountFeeMsg).chainId, - (msg as EstimateSmartAccountFeeMsg).vaultId - ); case GetEthereumAddressForVaultMsg: return service.getEthAddressForVault( (msg as GetEthereumAddressForVaultMsg).chainId, diff --git a/packages/background/src/keyring-ethereum/index.ts b/packages/background/src/keyring-ethereum/index.ts index c0e3380a87..78d99aea49 100644 --- a/packages/background/src/keyring-ethereum/index.ts +++ b/packages/background/src/keyring-ethereum/index.ts @@ -1,3 +1,4 @@ export * from "./messages"; export * from "./service"; export { ALLOWED_DELEGATORS } from "./constants"; +export { buildDummyAuthorizationList } from "./helper"; diff --git a/packages/background/src/keyring-ethereum/init.ts b/packages/background/src/keyring-ethereum/init.ts index 927ee3b3ad..adbbd44ab0 100644 --- a/packages/background/src/keyring-ethereum/init.ts +++ b/packages/background/src/keyring-ethereum/init.ts @@ -7,7 +7,6 @@ import { CheckNeedEnableAccessForEVMMsg, UpgradeSmartAccountMsg, DowngradeSmartAccountMsg, - EstimateSmartAccountFeeMsg, GetEthereumAddressForVaultMsg, } from "./messages"; import { ROUTE } from "./constants"; @@ -25,7 +24,6 @@ export function init( router.registerMessage(CheckNeedEnableAccessForEVMMsg); router.registerMessage(UpgradeSmartAccountMsg); router.registerMessage(DowngradeSmartAccountMsg); - router.registerMessage(EstimateSmartAccountFeeMsg); router.registerMessage(GetEthereumAddressForVaultMsg); router.addHandler(ROUTE, getHandler(service, permissionInteractionService)); diff --git a/packages/background/src/keyring-ethereum/messages.ts b/packages/background/src/keyring-ethereum/messages.ts index 92d73b3ac8..f1b0d73d3a 100644 --- a/packages/background/src/keyring-ethereum/messages.ts +++ b/packages/background/src/keyring-ethereum/messages.ts @@ -179,45 +179,6 @@ export class DowngradeSmartAccountMsg extends Message { } } -export class EstimateSmartAccountFeeMsg extends Message<{ - gasLimit: string; - maxFeePerGas: string; - maxPriorityFeePerGas: string; - estimatedFeeWei: string; -}> { - public static type() { - return "estimate-smart-account-fee"; - } - - constructor( - public readonly chainId: string, - public readonly vaultId: string - ) { - super(); - } - - validateBasic(): void { - if (!this.chainId) { - throw new Error("chain id not set"); - } - if (!this.vaultId) { - throw new Error("vault id not set"); - } - } - - override approveExternal(): boolean { - return false; - } - - route(): string { - return ROUTE; - } - - type(): string { - return EstimateSmartAccountFeeMsg.type(); - } -} - export class GetEthereumAddressForVaultMsg extends Message { public static type() { return "get-ethereum-address-for-vault"; diff --git a/packages/background/src/keyring-ethereum/service.ts b/packages/background/src/keyring-ethereum/service.ts index 42a9548e99..29e4396c4a 100644 --- a/packages/background/src/keyring-ethereum/service.ts +++ b/packages/background/src/keyring-ethereum/service.ts @@ -5,6 +5,7 @@ import { AnalyticsService } from "../analytics"; import { Env, EthereumProviderRpcError } from "@keplr-wallet/router"; import { BatchSigningData, + BatchStrategy, DelegationStatus, EthereumBatchSignResponse, EthereumSignResponse, @@ -48,8 +49,8 @@ import { } from "./helper"; import { PermissionInteractiveService } from "../permission-interactive"; import { - DEFAULT_EVM_CHAIN_ID, ALLOWED_DELEGATORS, + DEFAULT_EVM_CHAIN_ID, EIP5792_ERROR_ATOMICITY_NOT_SUPPORTED, EIP5792_ERROR_UNKNOWN_BUNDLE, ERC20_TRANSFER_SELECTOR, @@ -66,10 +67,16 @@ export class KeyRingEthereumService { private readonly wsManager: EvmWebSocketManager; private readonly chainHandler: EvmChainHandler; - // EIP-5792: batchId → txHash mapping (in-memory, no persistence needed) + // EIP-5792: batchId → tx metadata mapping (in-memory, no persistence needed) private readonly batchCallsMap = new Map< string, - { txHash: string; chainId: string } + { + txHash: string; + chainId: string; + origin: string; + strategy: BatchStrategy; + apiVersion: string; + } >(); constructor( @@ -208,41 +215,6 @@ export class KeyRingEthereumService { ); } - async estimateSmartAccountFee( - _env: Env, - chainId: string, - vaultId: string - ): Promise<{ - gasLimit: string; - maxFeePerGas: string; - maxPriorityFeePerGas: string; - estimatedFeeWei: string; - }> { - const evmInfo = this.chainsService.getEVMInfoOrThrow(chainId); - const address = await this.getEthAddressForVault(chainId, vaultId); - - const filled = await fillUnsignedEVMTx("", evmInfo, address, { - to: address, - value: "0x0", - data: "0x", - authorizationList: buildDummyAuthorizationList( - ALLOWED_DELEGATORS[0], - evmInfo.chainId - ), - }); - - const gasLimit = `0x${BigInt(filled.gasLimit ?? 0).toString(16)}`; - const maxFeePerGas = `0x${BigInt(filled.maxFeePerGas ?? 0).toString(16)}`; - const maxPriorityFeePerGas = `0x${BigInt( - filled.maxPriorityFeePerGas ?? 0 - ).toString(16)}`; - - const estimatedFeeWei = - "0x" + (BigInt(gasLimit) * BigInt(maxFeePerGas)).toString(16); - - return { gasLimit, maxFeePerGas, maxPriorityFeePerGas, estimatedFeeWei }; - } - private async sendEip7702Tx( env: Env, chainId: string, @@ -1464,7 +1436,13 @@ export class KeyRingEthereumService { {} ); - this.batchCallsMap.set(batchId, { txHash, chainId: currentChainId }); + this.batchCallsMap.set(batchId, { + txHash, + chainId: currentChainId, + origin, + strategy: batchResponse.strategy, + apiVersion: request.version || "1.0", + }); } catch (error) { console.error(`Batch broadcast failed for ${batchId}:`, error); rethrowAsProviderError(error); @@ -1491,7 +1469,7 @@ export class KeyRingEthereumService { } const entry = this.batchCallsMap.get(batchId); - if (!entry) { + if (!entry || entry.origin !== origin) { throw new EthereumProviderRpcError( EIP5792_ERROR_UNKNOWN_BUNDLE, "Unknown bundle ID" @@ -1523,11 +1501,11 @@ export class KeyRingEthereumService { } return { - version: "1.0", + version: entry.apiVersion, chainId: `0x${evmInfo.chainId.toString(16)}`, id: batchId, status, - atomic: true, + atomic: entry.strategy === "atomic", receipts, }; } diff --git a/packages/stores-eth/src/account/base.ts b/packages/stores-eth/src/account/base.ts index 93ba571a66..127696194d 100644 --- a/packages/stores-eth/src/account/base.ts +++ b/packages/stores-eth/src/account/base.ts @@ -140,7 +140,7 @@ export class EthereumAccountBase { throw new Error("No EVM chain info provided"); } - const { to, value, data } = unsignedTx; + const { to, value, data, authorizationList } = unsignedTx; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const keplr = (await this.getKeplr())!; @@ -151,6 +151,7 @@ export class EthereumAccountBase { to, value, data, + ...(authorizationList ? { authorizationList } : {}), }, ]; diff --git a/packages/stores-eth/src/batch.ts b/packages/stores-eth/src/batch.ts new file mode 100644 index 0000000000..55bcf5f835 --- /dev/null +++ b/packages/stores-eth/src/batch.ts @@ -0,0 +1,51 @@ +import { Execute } from "ox/erc7821"; +import type { UnsignedEVMTransaction } from "./types"; + +const EIP7702_DELEGATION_PREFIX = "0xef0100"; + +/** + * EIP-5792 calls 배열을 StatelessDeleGator의 execute(bytes32,bytes) calldata로 변환. + * ox/erc7821 Execute.encodeData를 사용하여 ERC-7579 표준 준수를 보장. + */ +export function createAtomicBatchTransaction( + calls: { to?: string; data?: string; value?: string }[], + selectedAddress: string, + authorizationList?: UnsignedEVMTransaction["authorizationList"] +): UnsignedEVMTransaction { + const data = Execute.encodeData( + calls.map((call, i) => { + if (!call.to) { + throw new Error( + `Call at index ${i}: contract deployment is not supported in batch execution` + ); + } + return { + to: call.to as `0x${string}`, + value: BigInt(call.value ?? "0x0"), + data: (call.data ?? "0x") as `0x${string}`, + }; + }) + ); + + return { + to: selectedAddress, + data, + value: "0x0", + ...(authorizationList ? { authorizationList } : {}), + }; +} + +/** + * 미업그레이드 계정의 가스 추정을 위한 stateOverride 생성. + * eth_estimateGas 3번째 파라미터로 전달하여 delegation 코드를 주입. + */ +export function buildDelegationStateOverride( + address: string, + delegatorAddress: string +): Record { + return { + [address]: { + code: EIP7702_DELEGATION_PREFIX + delegatorAddress.toLowerCase().slice(2), + }, + }; +} diff --git a/packages/stores-eth/src/index.ts b/packages/stores-eth/src/index.ts index 9f41b56298..553512ecb0 100644 --- a/packages/stores-eth/src/index.ts +++ b/packages/stores-eth/src/index.ts @@ -4,3 +4,4 @@ export * from "./constants"; export * from "./currency-registrar"; export * from "./types"; export * from "./serialize"; +export * from "./batch";