Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null);
Expand Down Expand Up @@ -109,7 +109,8 @@ export const SmartAccountConfirmPage: FunctionComponent = observer(() => {
insufficientBalance ||
isFailed ||
!balanceLoaded ||
isEstimating;
isEstimating ||
!feeDisplay;

return (
<HeaderLayout
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { EstimateSmartAccountFeeMsg } from "@keplr-wallet/background";
import { InExtensionMessageRequester } from "@keplr-wallet/router-extension";
import { BACKGROUND_PORT } from "@keplr-wallet/router";
import { CoinPretty, Int } from "@keplr-wallet/unit";
import {
ALLOWED_DELEGATORS,
buildDummyAuthorizationList,
} from "@keplr-wallet/background";
import { computeEIP1559TxFees } from "@keplr-wallet/hooks-evm";
import { CoinPretty, Dec, Int } from "@keplr-wallet/unit";
import { useStore } from "../../../../../stores";
import { formatFee } from "../../utils";

type FeeState =
| { status: "loading" }
| { status: "success"; estimatedFeeWei: string }
| { status: "success"; gasUsed: number; l1DataFee?: Dec }
| { status: "error" };

export function useSmartAccountFee(
chainId: string,
vaultId: string,
hexAddress: string,
isValid: boolean
) {
const { chainStore, queriesStore, priceStore } = useStore();
const { chainStore, queriesStore, priceStore, ethereumAccountStore } =
useStore();

const nativeCurrency = useMemo(() => {
try {
Expand All @@ -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<FeeState>({ 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,
});
}
Comment thread
piatoss3612 marked this conversation as resolved.
})
.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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Guard fee-query setup behind chain validation

This new unconditional lookup queriesStore.get(chainId).ethereum runs even when the confirm route params are invalid, but useConfirmRoute only checks for non-empty strings and redirects asynchronously in an effect. If chainId is missing/stale (or otherwise unknown), constructing these Ethereum queries calls chainStore.getModularChain(chainId) and throws, so the confirm page crashes before redirect instead of failing gracefully. Please gate this lookup with the same defensive checks used above (or wrap it in try/catch) so invalid URLs don't hard-crash the page.

Useful? React with ๐Ÿ‘ย / ๐Ÿ‘Ž.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

queriesStore.get(chainId)๋Š” ์ž˜๋ชป๋œ chainId์—๋„ throwํ•˜์ง€ ์•Š๊ณ  lazyํ•˜๊ฒŒ ์ธ์Šคํ„ด์Šค๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. ๋ฐ”๋กœ ๋‹ค์Œ ์ค„์—์„œ ethereumQueries ? ... : undefined๋กœ ๊ฐ€๋“œ๋˜์–ด ์žˆ๊ณ , isValid false์ผ ๋•Œ useRedirectIfInvalid + useEffect early return์œผ๋กœ ์ด๋ฏธ ์ฒ˜๋ฆฌ๋ฉ๋‹ˆ๋‹ค.

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);
Comment on lines +115 to +117
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Mark missing gas-price data as fee-estimation failure

When gas simulation succeeds but fee queries are still empty or errored, feePerGas becomes zero/undefined and this path returns null instead of transitioning to an error state. In that case isFailed stays false, so the confirm page can enable Approve while fee text shows failure and insufficientBalance is skipped (because estimatedFeeWei is null), which can lead to avoidable upgrade/downgrade tx failures from underfunded accounts.

Useful? React with ๐Ÿ‘ย / ๐Ÿ‘Ž.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

์ˆ˜์ •ํ–ˆ์Šต๋‹ˆ๋‹ค. !feeDisplay๋ฅผ approveDisabled ์กฐ๊ฑด์— ์ถ”๊ฐ€ํ•˜์—ฌ, fee ์ฟผ๋ฆฌ๊ฐ€ ๋กœ๋“œ๋˜์ง€ ์•Š์€ ์ƒํƒœ์—์„œ ์Šน์ธ ๋ฒ„ํŠผ์ด ํ™œ์„ฑํ™”๋˜์ง€ ์•Š๋„๋ก ํ–ˆ์Šต๋‹ˆ๋‹ค.

cb42016ad

Comment on lines +115 to +117
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Mark missing fee-per-gas data as estimation failure

When gas simulation succeeds but fee queries are still empty, this branch returns null and keeps feeState in success. In this commit confirm.tsx also disables approval on !feeDisplay, so users can end up with a permanently disabled Approve button and no retry path (isFailed stays false) whenever eth_feeHistory/eth_gasPrice data is temporarily unavailable, even though the request itself is valid.

Useful? React with ๐Ÿ‘ย / ๐Ÿ‘Ž.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

txFees๋Š” MobX reactive query ๊ธฐ๋ฐ˜์ด๋ผ RPC ์‘๋‹ต ์˜ค๋ฉด ์ž๋™ ๊ฐฑ์‹ ๋ฉ๋‹ˆ๋‹ค. ์˜๊ตฌ ๊ต์ฐฉ์€ ๋ฐœ์ƒํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

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(() => {
Expand Down
7 changes: 0 additions & 7 deletions packages/background/src/keyring-ethereum/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {
CheckNeedEnableAccessForEVMMsg,
UpgradeSmartAccountMsg,
DowngradeSmartAccountMsg,
EstimateSmartAccountFeeMsg,
GetEthereumAddressForVaultMsg,
} from "./messages";
import { KeyRingEthereumService } from "./service";
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/background/src/keyring-ethereum/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./messages";
export * from "./service";
export { ALLOWED_DELEGATORS } from "./constants";
export { buildDummyAuthorizationList } from "./helper";
2 changes: 0 additions & 2 deletions packages/background/src/keyring-ethereum/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
CheckNeedEnableAccessForEVMMsg,
UpgradeSmartAccountMsg,
DowngradeSmartAccountMsg,
EstimateSmartAccountFeeMsg,
GetEthereumAddressForVaultMsg,
} from "./messages";
import { ROUTE } from "./constants";
Expand All @@ -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));
Expand Down
39 changes: 0 additions & 39 deletions packages/background/src/keyring-ethereum/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,45 +179,6 @@ export class DowngradeSmartAccountMsg extends Message<string> {
}
}

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<string> {
public static type() {
return "get-ethereum-address-for-vault";
Expand Down
62 changes: 20 additions & 42 deletions packages/background/src/keyring-ethereum/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AnalyticsService } from "../analytics";
import { Env, EthereumProviderRpcError } from "@keplr-wallet/router";
import {
BatchSigningData,
BatchStrategy,
DelegationStatus,
EthereumBatchSignResponse,
EthereumSignResponse,
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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"
Expand Down Expand Up @@ -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,
};
}
Expand Down
3 changes: 2 additions & 1 deletion packages/stores-eth/src/account/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())!;
Expand All @@ -151,6 +151,7 @@ export class EthereumAccountBase {
to,
value,
data,
...(authorizationList ? { authorizationList } : {}),
},
];

Expand Down
Loading
Loading