diff --git a/apps/extension/src/hooks/use-unmount-promise.ts b/apps/extension/src/hooks/use-unmount-promise.ts new file mode 100644 index 0000000000..0f0c83a03c --- /dev/null +++ b/apps/extension/src/hooks/use-unmount-promise.ts @@ -0,0 +1,18 @@ +import { useState } from "react"; +import { useUnmount } from "./use-unmount"; + +export function useUnmountPromise() { + const [unmountPromise] = useState(() => { + let resolver: () => void; + const promise = new Promise((resolve) => { + resolver = resolve; + }); + return { promise, resolver: resolver! }; + }); + + useUnmount(() => { + unmountPromise.resolver(); + }); + + return unmountPromise; +} diff --git a/apps/extension/src/languages/en.json b/apps/extension/src/languages/en.json index 2a569bb5fa..a6deb8535f 100644 --- a/apps/extension/src/languages/en.json +++ b/apps/extension/src/languages/en.json @@ -430,10 +430,6 @@ "page.wallet.smart-account.confirm.delegator": "Delegation Contract", "page.wallet.smart-account.confirm.network": "Network", "page.wallet.smart-account.confirm.account": "Account", - "page.wallet.smart-account.confirm.fee": "Network Fee", - "page.wallet.smart-account.confirm.fee.insufficient": "Insufficient balance for network fee.", - "page.wallet.smart-account.confirm.fee.failed": "Unable to estimate", - "page.wallet.smart-account.confirm.fee.retry": "Tap to retry", "page.wallet.change-name.previous-name-input-label": "Previous Wallet Name", "page.wallet.change-name.new-name-input-label": "New Wallet Name", @@ -547,6 +543,33 @@ "page.sign.ethereum.message": "Signing Message", "page.sign.ethereum.message.title": "Sign Message", "page.sign.ethereum.eip-712.title": "Sign Typed Data", + "page.sign.ethereum.batch.title": "Confirm Transaction", + "page.sign.ethereum.batch.section-title": "Transaction Summary", + "page.sign.ethereum.batch.upgrade-notice.title": "Smart Account upgrade is included in this transaction", + "page.sign.ethereum.batch.calls.item": "Transaction {index}", + "page.sign.ethereum.batch.calls.to": "To", + "page.sign.ethereum.batch.calls.value": "Value", + "page.sign.ethereum.batch.calls.data": "Data", + "page.sign.ethereum.batch.calls.data.expand": "View all", + "page.sign.ethereum.batch.calls.data.collapse": "Collapse", + "page.sign.ethereum.batch.calls.decoded.spender": "Spender", + "page.sign.ethereum.batch.calls.decoded.recipient": "Recipient", + "page.sign.ethereum.batch.calls.decoded.amount": "Amount", + "page.sign.ethereum.batch.calls.decoded.unlimited": "⚠ Unlimited Approval", + "page.sign.ethereum.batch.calls.copied": "Copied to clipboard", + "page.sign.ethereum.batch.calls.contract-deploy": "Contract Deploy", + "page.sign.ethereum.batch.calls.intent.transfer": "Transfer", + "page.sign.ethereum.batch.calls.intent.approve": "Approve", + "page.sign.ethereum.batch.calls.intent.swap": "Swap", + "page.sign.ethereum.batch.calls.intent.deposit": "Deposit", + "page.sign.ethereum.batch.calls.intent.withdraw": "Withdraw", + "page.sign.ethereum.batch.summary.title": "Batch Transaction", + "page.sign.ethereum.batch.summary.desc": "{count} transactions will be bundled together", + "page.sign.ethereum.batch.detail.account": "Account", + "page.sign.ethereum.batch.detail.requester": "Requester", + "page.sign.ethereum.batch.detail.network": "Network", + "page.sign.ethereum.batch.advanced.title": "Advanced Details", + "page.sign.ethereum.batch.advanced.nonce": "Nonce", "page.sign.starknet.tx.calls": "Calls", diff --git a/apps/extension/src/languages/ko.json b/apps/extension/src/languages/ko.json index 90d42ade18..995517ad53 100644 --- a/apps/extension/src/languages/ko.json +++ b/apps/extension/src/languages/ko.json @@ -424,10 +424,6 @@ "page.wallet.smart-account.confirm.delegator": "위임 컨트랙트", "page.wallet.smart-account.confirm.network": "네트워크", "page.wallet.smart-account.confirm.account": "계정", - "page.wallet.smart-account.confirm.fee": "네트워크 수수료", - "page.wallet.smart-account.confirm.fee.insufficient": "네트워크 수수료를 위한 잔액이 부족합니다.", - "page.wallet.smart-account.confirm.fee.failed": "추정 불가", - "page.wallet.smart-account.confirm.fee.retry": "탭하여 재시도", "page.wallet.change-name.previous-name-input-label": "현재 지갑 이름", "page.wallet.change-name.new-name-input-label": "새로운 지갑 이름", @@ -539,6 +535,33 @@ "page.sign.ethereum.message": "서명할 메시지", "page.sign.ethereum.message.title": "메시지 서명", "page.sign.ethereum.eip-712.title": "타입 데이터 서명", + "page.sign.ethereum.batch.title": "트랜잭션 확인", + "page.sign.ethereum.batch.section-title": "트랜잭션 요약", + "page.sign.ethereum.batch.upgrade-notice.title": "이 트랜잭션에 스마트 어카운트 업그레이드가 포함됩니다", + "page.sign.ethereum.batch.calls.item": "트랜잭션 {index}", + "page.sign.ethereum.batch.calls.to": "수신", + "page.sign.ethereum.batch.calls.value": "금액", + "page.sign.ethereum.batch.calls.data": "데이터", + "page.sign.ethereum.batch.calls.data.expand": "전체 보기", + "page.sign.ethereum.batch.calls.data.collapse": "접기", + "page.sign.ethereum.batch.calls.decoded.spender": "승인 대상", + "page.sign.ethereum.batch.calls.decoded.recipient": "수신자", + "page.sign.ethereum.batch.calls.decoded.amount": "금액", + "page.sign.ethereum.batch.calls.decoded.unlimited": "⚠ 무제한 승인", + "page.sign.ethereum.batch.calls.copied": "클립보드에 복사됨", + "page.sign.ethereum.batch.calls.contract-deploy": "컨트랙트 배포", + "page.sign.ethereum.batch.calls.intent.transfer": "전송", + "page.sign.ethereum.batch.calls.intent.approve": "승인", + "page.sign.ethereum.batch.calls.intent.swap": "스왑", + "page.sign.ethereum.batch.calls.intent.deposit": "예치", + "page.sign.ethereum.batch.calls.intent.withdraw": "인출", + "page.sign.ethereum.batch.summary.title": "배치 트랜잭션", + "page.sign.ethereum.batch.summary.desc": "{count}개의 트랜잭션이 함께 실행됩니다", + "page.sign.ethereum.batch.detail.account": "계정", + "page.sign.ethereum.batch.detail.requester": "요청자", + "page.sign.ethereum.batch.detail.network": "네트워크", + "page.sign.ethereum.batch.advanced.title": "상세 정보", + "page.sign.ethereum.batch.advanced.nonce": "논스", "page.sign.starknet.tx.calls": "실행할 호출", diff --git a/apps/extension/src/languages/zh-cn.json b/apps/extension/src/languages/zh-cn.json index 70f87078a2..2187b908c6 100644 --- a/apps/extension/src/languages/zh-cn.json +++ b/apps/extension/src/languages/zh-cn.json @@ -394,10 +394,6 @@ "page.wallet.smart-account.confirm.delegator": "委托合约", "page.wallet.smart-account.confirm.network": "网络", "page.wallet.smart-account.confirm.account": "账户", - "page.wallet.smart-account.confirm.fee": "网络费用", - "page.wallet.smart-account.confirm.fee.insufficient": "余额不足以支付网络费用。", - "page.wallet.smart-account.confirm.fee.failed": "无法估算", - "page.wallet.smart-account.confirm.fee.retry": "点击重试", "page.wallet.change-name.previous-name-input-label": "原钱包名称", "page.wallet.change-name.new-name-input-label": "新钱包名称", @@ -501,6 +497,33 @@ "page.sign.ethereum.message": "签署信息", "page.sign.ethereum.message.title": "标志信息", "page.sign.ethereum.eip-712.title": "签署键入数据", + "page.sign.ethereum.batch.title": "确认交易", + "page.sign.ethereum.batch.section-title": "交易摘要", + "page.sign.ethereum.batch.upgrade-notice.title": "此交易包含智能账户升级", + "page.sign.ethereum.batch.calls.item": "交易 {index}", + "page.sign.ethereum.batch.calls.to": "接收", + "page.sign.ethereum.batch.calls.value": "金额", + "page.sign.ethereum.batch.calls.data": "数据", + "page.sign.ethereum.batch.calls.data.expand": "查看全部", + "page.sign.ethereum.batch.calls.data.collapse": "收起", + "page.sign.ethereum.batch.calls.decoded.spender": "授权对象", + "page.sign.ethereum.batch.calls.decoded.recipient": "接收者", + "page.sign.ethereum.batch.calls.decoded.amount": "金额", + "page.sign.ethereum.batch.calls.decoded.unlimited": "⚠ 无限授权", + "page.sign.ethereum.batch.calls.copied": "已复制到剪贴板", + "page.sign.ethereum.batch.calls.contract-deploy": "合约部署", + "page.sign.ethereum.batch.calls.intent.transfer": "转账", + "page.sign.ethereum.batch.calls.intent.approve": "批准", + "page.sign.ethereum.batch.calls.intent.swap": "兑换", + "page.sign.ethereum.batch.calls.intent.deposit": "存入", + "page.sign.ethereum.batch.calls.intent.withdraw": "提取", + "page.sign.ethereum.batch.summary.title": "批量交易", + "page.sign.ethereum.batch.summary.desc": "{count}笔交易将被打包执行", + "page.sign.ethereum.batch.detail.account": "账户", + "page.sign.ethereum.batch.detail.requester": "请求者", + "page.sign.ethereum.batch.detail.network": "网络", + "page.sign.ethereum.batch.advanced.title": "高级详情", + "page.sign.ethereum.batch.advanced.nonce": "Nonce", "page.sign.bitcoin.transaction.data": "交易数据", "page.sign.bitcoin.transaction.input": "输入", diff --git a/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/advanced-details.tsx b/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/advanced-details.tsx new file mode 100644 index 0000000000..3fa01481bc --- /dev/null +++ b/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/advanced-details.tsx @@ -0,0 +1,125 @@ +import React, { FunctionComponent, useState } from "react"; +import { useIntl } from "react-intl"; +import styled from "styled-components"; +import { DSColor, DSTypography } from "@keplr-wallet/design-system"; +import { Buffer } from "buffer/"; + +export const AdvancedDetails: FunctionComponent<{ + signingDataBuff: Buffer; + nonce: number; +}> = ({ signingDataBuff, nonce }) => { + const intl = useIntl(); + const [expanded, setExpanded] = useState(false); + + const rawData = (() => { + try { + return JSON.stringify( + JSON.parse(Buffer.from(signingDataBuff).toString("utf8")), + null, + 2 + ); + } catch { + return Buffer.from(signingDataBuff).toString("utf8"); + } + })(); + + return ( + +
setExpanded((v) => !v)}> + + {intl.formatMessage({ + id: "page.sign.ethereum.batch.advanced.title", + })} + + +
+ + + + + + + {intl.formatMessage({ + id: "page.sign.ethereum.batch.advanced.nonce", + })} + + + {nonce} + + + {rawData} + + + +
+ ); +}; + +const Card = styled.div` + border-radius: 0.375rem; + background-color: ${DSColor.background.surface.elevated}; + overflow: hidden; +`; + +const Header = styled.button` + width: 100%; + padding: 0.75rem 1rem; + display: flex; + justify-content: space-between; + align-items: center; + background: none; + border: none; + cursor: pointer; +`; + +const CollapseOuter = styled.div<{ $expanded: boolean }>` + display: grid; + grid-template-rows: ${(p) => (p.$expanded ? "1fr" : "0fr")}; + transition: grid-template-rows 0.22s ease; +`; + +const CollapseInner = styled.div` + overflow: hidden; +`; + +const Body = styled.div` + padding: 0 1rem 0 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +`; + +const InfoRow = styled.div` + display: flex; + justify-content: space-between; + align-items: center; +`; + +const RawDataBlock = styled.pre` + font-size: 0.625rem; + line-height: 1.4; + color: ${DSColor.typography.tertiary}; + background-color: ${DSColor.background.surface.ground}; + border-radius: 0.375rem; + padding: 0.75rem; + margin: 0 0 0.75rem 0; + white-space: pre-wrap; + word-break: break-all; + max-height: 10rem; + overflow-y: auto; +`; + +const Chevron = styled.span<{ $open: boolean }>` + transform: ${(p) => (p.$open ? "rotate(0deg)" : "rotate(-90deg)")}; + transition: transform 0.15s ease; + font-size: 0.625rem; + color: ${DSColor.typography.secondary}; +`; diff --git a/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/batch-detail-card.tsx b/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/batch-detail-card.tsx new file mode 100644 index 0000000000..7291eef485 --- /dev/null +++ b/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/batch-detail-card.tsx @@ -0,0 +1,111 @@ +import React, { FunctionComponent } from "react"; +import { useIntl } from "react-intl"; +import styled from "styled-components"; +import { DSColor, DSTypography } from "@keplr-wallet/design-system"; +import { XAxis, YAxis } from "../../../../../components/axis"; +import { ChainImageFallback } from "../../../../../components/image"; +import type { IModularChainInfoImpl } from "@keplr-wallet/stores"; + +export const BatchDetailCard: FunctionComponent<{ + accountName: string; + hexAddress: string; + origin: string; + chainName: string; + chainInfo: IModularChainInfoImpl; +}> = ({ accountName, hexAddress, origin, chainName, chainInfo }) => { + const intl = useIntl(); + + const originDisplay = (() => { + try { + return new URL(origin).hostname; + } catch { + return origin; + } + })(); + + const truncatedAddress = hexAddress + ? `${hexAddress.slice(0, 10)}...${hexAddress.slice(-8)}` + : ""; + + return ( + + + + + + + {accountName} + + {truncatedAddress && ( + + {truncatedAddress} + + )} + + + + + + + {originDisplay} + + + + + + + + + {chainName} + + + + + + ); +}; + +const Card = styled.div` + border-radius: 0.375rem; + background-color: ${DSColor.background.surface.elevated}; + padding: 1rem; +`; + +const Label: FunctionComponent<{ children: React.ReactNode }> = ({ + children, +}) => ( + + {children} + +); + +const InfoRow = styled(XAxis)` + justify-content: space-between; + align-items: center; +`; + +const Divider = styled.div` + height: 1px; + background-color: ${DSColor.stroke.separator.primary}; +`; diff --git a/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/batch-summary-card.tsx b/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/batch-summary-card.tsx new file mode 100644 index 0000000000..02cb43796f --- /dev/null +++ b/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/batch-summary-card.tsx @@ -0,0 +1,63 @@ +import React, { FunctionComponent } from "react"; +import { useIntl } from "react-intl"; +import styled from "styled-components"; +import { + DSColor, + DSTypography, + ArrowRouteIcon, +} from "@keplr-wallet/design-system"; +import { XAxis, YAxis } from "../../../../../components/axis"; +import { Gutter } from "../../../../../components/gutter"; + +export const BatchSummaryCard: FunctionComponent<{ + callsCount: number; +}> = ({ callsCount }) => { + const intl = useIntl(); + + return ( + + + + + + + + + {intl.formatMessage({ + id: "page.sign.ethereum.batch.summary.title", + })} + + + + {intl.formatMessage( + { id: "page.sign.ethereum.batch.summary.desc" }, + { count: callsCount } + )} + + + + + ); +}; + +const Card = styled.div` + border-radius: 0.375rem; + background-color: ${DSColor.background.surface.elevated}; + padding: 1rem; +`; + +const IconCircle = styled.div` + width: 2.5rem; + min-width: 2.5rem; + height: 2.5rem; + border-radius: 50%; + background-color: ${DSColor.button.primaryTransparent}; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +`; diff --git a/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/calls-list/call-card.tsx b/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/calls-list/call-card.tsx new file mode 100644 index 0000000000..bd666d6686 --- /dev/null +++ b/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/calls-list/call-card.tsx @@ -0,0 +1,183 @@ +import React, { FunctionComponent, useState } from "react"; +import { useIntl } from "react-intl"; +import { observer } from "mobx-react-lite"; +import styled from "styled-components"; +import { DSColor, DSTypography } from "@keplr-wallet/design-system"; +import { EIP5792Call } from "@keplr-wallet/types"; +import { useNotification } from "../../../../../../hooks/notification"; +import { useDecodedCall } from "./hooks/use-decoded-call"; +import { useMethodName } from "./hooks/use-method-name"; +import { useNativeValue } from "./hooks/use-native-value"; +import { truncateAddress } from "./utils"; +import { DecodedInfo } from "./decoded-info"; +import { CallDataRow } from "./call-data-row"; + +export const CallCard: FunctionComponent<{ + call: EIP5792Call; + index: number; + chainId: string; +}> = observer(({ call, index, chainId }) => { + const intl = useIntl(); + const notification = useNotification(); + const [expanded, setExpanded] = useState(index === 0); + + const methodName = useMethodName(call.data); + const nativeValue = useNativeValue(call.value, chainId); + const { decoded, tokenInfo, formattedAmount, isUnlimited } = useDecodedCall( + call, + chainId + ); + + const handleCopy = (text: string) => { + navigator.clipboard + .writeText(text) + .then(() => { + notification.show( + "success", + intl.formatMessage({ + id: "page.sign.ethereum.batch.calls.copied", + }), + "" + ); + }) + .catch(() => undefined); + }; + + const truncatedTo = call.to + ? truncateAddress(call.to) + : intl.formatMessage({ + id: "page.sign.ethereum.batch.calls.contract-deploy", + }); + + return ( + +
setExpanded((v) => !v)}> + + {methodName + ? `${methodName} #${index + 1}` + : intl.formatMessage( + { id: "page.sign.ethereum.batch.calls.item" }, + { index: index + 1 } + )} + + +
+ + + + + {decoded && tokenInfo && ( + + )} + + + +
call.to && handleCopy(call.to)}> + {truncatedTo} +
+
+ + {nativeValue && ( + + + {nativeValue} + + )} + + {call.data && call.data !== "0x" && ( + + )} + +
+
+
+ ); +}); + +const Card = styled.div` + border-radius: 0.375rem; + background-color: ${DSColor.background.surface.elevated}; + overflow: hidden; +`; + +const Header = styled.button` + width: 100%; + padding: 0.75rem 1rem; + display: flex; + justify-content: space-between; + align-items: center; + background: none; + border: none; + cursor: pointer; +`; + +const CollapseOuter = styled.div<{ $expanded: boolean }>` + display: grid; + grid-template-rows: ${(p) => (p.$expanded ? "1fr" : "0fr")}; + transition: grid-template-rows 0.22s ease; +`; + +const CollapseInner = styled.div` + overflow: hidden; +`; + +const Body = styled.div` + padding: 0 1rem 0.75rem 1rem; + display: flex; + flex-direction: column; + gap: 0.375rem; +`; + +const Row = styled.div` + display: flex; + justify-content: space-between; + align-items: flex-start; +`; + +const Label = styled(DSTypography).attrs({ + size: "textXxs", + weight: "regular", + color: DSColor.typography.secondary, +})` + flex-shrink: 0; +`; + +const Value = styled(DSTypography).attrs({ + size: "textXxs", + weight: "regular", +})` + text-align: right; + max-width: 60%; + word-break: break-all; +`; + +const Address = styled(Value)` + cursor: pointer; + text-decoration: underline; + text-decoration-color: ${DSColor.typography.tertiary}; + text-underline-offset: 0.15rem; + font-family: monospace; + &:hover { + color: ${DSColor.typography.brand}; + } +`; + +const Chevron = styled.span<{ $open: boolean }>` + transform: ${(p) => (p.$open ? "rotate(0deg)" : "rotate(-90deg)")}; + transition: transform 0.15s ease; + font-size: 0.625rem; + color: ${DSColor.typography.secondary}; +`; diff --git a/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/calls-list/call-data-row.tsx b/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/calls-list/call-data-row.tsx new file mode 100644 index 0000000000..4413977b21 --- /dev/null +++ b/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/calls-list/call-data-row.tsx @@ -0,0 +1,94 @@ +import React, { FunctionComponent, useState } from "react"; +import { useIntl } from "react-intl"; +import styled from "styled-components"; +import { DSColor, DSTypography } from "@keplr-wallet/design-system"; + +export const CallDataRow: FunctionComponent<{ + data: string; + onCopy: (text: string) => void; +}> = ({ data, onCopy }) => { + const intl = useIntl(); + const [showFull, setShowFull] = useState(false); + + return ( + + + + {showFull ? data : data.slice(0, 10)} + + setShowFull((v) => !v)}> + {intl.formatMessage({ + id: showFull + ? "page.sign.ethereum.batch.calls.data.collapse" + : "page.sign.ethereum.batch.calls.data.expand", + })} + + onCopy(data)}>Copy + + + + ); +}; + +const Row = styled.div` + display: flex; + justify-content: space-between; + align-items: flex-start; +`; + +const Label = styled(DSTypography).attrs({ + size: "textXxs", + weight: "regular", + color: DSColor.typography.secondary, +})` + flex-shrink: 0; +`; + +const DataColumn = styled.div` + display: flex; + flex-direction: column; + align-items: flex-end; + max-width: 60%; +`; + +const DataText = styled(DSTypography).attrs({ + size: "textXxs", + weight: "regular", + color: DSColor.typography.tertiary, +})` + font-family: monospace; + text-align: right; + word-break: break-all; +`; + +const Actions = styled.div` + display: flex; + gap: 0.5rem; + margin-top: 0.25rem; +`; + +const Toggle = styled.button` + background: none; + border: none; + padding: 0; + cursor: pointer; + font-size: 0.625rem; + color: ${DSColor.typography.brand}; + &:hover { + text-decoration: underline; + } +`; + +const CopyBtn = styled.button` + background: none; + border: none; + padding: 0; + cursor: pointer; + font-size: 0.625rem; + color: ${DSColor.typography.secondary}; + &:hover { + color: ${DSColor.typography.primary}; + } +`; diff --git a/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/calls-list/calls-list.tsx b/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/calls-list/calls-list.tsx new file mode 100644 index 0000000000..4af12ea056 --- /dev/null +++ b/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/calls-list/calls-list.tsx @@ -0,0 +1,23 @@ +import React, { FunctionComponent } from "react"; +import styled from "styled-components"; +import { EIP5792Call } from "@keplr-wallet/types"; +import { CallCard } from "./call-card"; + +export const CallsList: FunctionComponent<{ + calls: EIP5792Call[]; + chainId: string; +}> = ({ calls, chainId }) => { + return ( + + {calls.map((call, i) => ( + + ))} + + ); +}; + +const Container = styled.div` + display: flex; + flex-direction: column; + gap: 0.5rem; +`; diff --git a/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/calls-list/constants.ts b/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/calls-list/constants.ts new file mode 100644 index 0000000000..fe0de12fb0 --- /dev/null +++ b/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/calls-list/constants.ts @@ -0,0 +1,46 @@ +import { AbiFunction } from "ox"; +import { + erc20TransferFunction, + erc20ApproveFunction, +} from "@keplr-wallet/stores-eth"; + +// Tier 1: Intent-based mapping for common selectors +export const SELECTOR_INTENT: Record = { + // Transfer + "0xa9059cbb": "transfer", // ERC-20 transfer + "0x23b872dd": "transfer", // ERC-20/721 transferFrom + "0x42842e0e": "transfer", // ERC-721 safeTransferFrom + // Approve + "0x095ea7b3": "approve", // ERC-20 approve + // Swap (all variants) + "0x38ed1739": "swap", // Uniswap V2 swapExactTokensForTokens + "0x7ff36ab5": "swap", // Uniswap V2 swapExactETHForTokens + "0x18cbafe5": "swap", // Uniswap V2 swapExactTokensForETH + "0x5c11d795": "swap", // Uniswap V2 fee-on-transfer + "0xfb3bdb41": "swap", // Uniswap V2 swapETHForExactTokens + "0x3593564c": "swap", // Uniswap Universal Router execute + "0x04e45aaf": "swap", // Uniswap V3 SwapRouter02 exactInputSingle + "0xb858183f": "swap", // Uniswap V3 SwapRouter02 exactInput + "0x414bf389": "swap", // Uniswap V3 SwapRouter exactInputSingle + // Deposit / Withdraw + "0xd0e30db0": "deposit", // WETH deposit + "0x2e1a7d4d": "withdraw", // WETH withdraw +}; + +export const APPROVE_SELECTOR = AbiFunction.getSelector(erc20ApproveFunction); +export const TRANSFER_SELECTOR = AbiFunction.getSelector(erc20TransferFunction); + +export const erc20TransferFromFunction = AbiFunction.from( + "function transferFrom(address from, address to, uint256 value) returns (bool)" +); +export const TRANSFER_FROM_SELECTOR = AbiFunction.getSelector( + erc20TransferFromFunction +); + +export const UNLIMITED_THRESHOLD = BigInt("10") ** BigInt("15"); + +export interface DecodedErc20 { + type: "transfer" | "approve"; + targetAddress: string; + amount: bigint; +} diff --git a/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/calls-list/decoded-info.tsx b/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/calls-list/decoded-info.tsx new file mode 100644 index 0000000000..7bd2713df0 --- /dev/null +++ b/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/calls-list/decoded-info.tsx @@ -0,0 +1,99 @@ +import React, { FunctionComponent } from "react"; +import { useIntl } from "react-intl"; +import styled from "styled-components"; +import { DSColor, DSTypography } from "@keplr-wallet/design-system"; +import { DecodedErc20 } from "./constants"; +import { truncateAddress } from "./utils"; + +export const DecodedInfo: FunctionComponent<{ + decoded: DecodedErc20; + formattedAmount: string | null; + isUnlimited: boolean; + onCopy: (text: string) => void; +}> = ({ decoded, formattedAmount, isUnlimited, onCopy }) => { + const intl = useIntl(); + + const labelKey = + decoded.type === "approve" + ? "page.sign.ethereum.batch.calls.decoded.spender" + : "page.sign.ethereum.batch.calls.decoded.recipient"; + + return ( +
+ + +
onCopy(decoded.targetAddress)}> + {truncateAddress(decoded.targetAddress)} +
+
+ + + {isUnlimited ? ( + + {intl.formatMessage({ + id: "page.sign.ethereum.batch.calls.decoded.unlimited", + })} + + ) : ( + {formattedAmount} + )} + +
+ ); +}; + +const Section = styled.div` + display: flex; + flex-direction: column; + gap: 0.375rem; + padding-bottom: 0.375rem; + border-bottom: 1px solid ${DSColor.stroke.separator.primary}; + margin-bottom: 0.25rem; +`; + +const Row = styled.div` + display: flex; + justify-content: space-between; + align-items: flex-start; +`; + +const Label = styled(DSTypography).attrs({ + size: "textXxs", + weight: "regular", + color: DSColor.typography.secondary, +})` + flex-shrink: 0; +`; + +const Value = styled(DSTypography).attrs({ + size: "textXxs", + weight: "regular", +})` + text-align: right; +`; + +const Address = styled(Value)` + cursor: pointer; + text-decoration: underline; + text-decoration-color: ${DSColor.typography.tertiary}; + text-underline-offset: 0.15rem; + font-family: monospace; + + &:hover { + color: ${DSColor.typography.brand}; + } +`; + +const Badge = styled(DSTypography).attrs({ + size: "textXxs", + weight: "medium", +})` + color: ${DSColor.typography.alert.medium}; + background-color: ${DSColor.fill.alert.medium_10}; + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; +`; diff --git a/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/calls-list/hooks/use-decoded-call.ts b/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/calls-list/hooks/use-decoded-call.ts new file mode 100644 index 0000000000..195a7a88f1 --- /dev/null +++ b/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/calls-list/hooks/use-decoded-call.ts @@ -0,0 +1,51 @@ +import { useMemo } from "react"; +import { EIP5792Call } from "@keplr-wallet/types"; +import { CoinPretty, Dec } from "@keplr-wallet/unit"; +import { useStore } from "../../../../../../../stores"; +import { UNLIMITED_THRESHOLD, DecodedErc20 } from "../constants"; +import { tryDecodeErc20 } from "../utils"; + +export function useDecodedCall(call: EIP5792Call, chainId: string) { + const { queriesStore } = useStore(); + + const decoded: DecodedErc20 | null = useMemo( + () => tryDecodeErc20(call.data), + [call.data] + ); + + const tokenContractAddress = decoded ? call.to : null; + const tokenInfoQuery = tokenContractAddress + ? queriesStore + .get(chainId) + .ethereum.queryEthereumERC20ContractInfo.getQueryContract( + tokenContractAddress + ) + : null; + const tokenInfo = tokenInfoQuery?.tokenInfo; + + const formattedAmount = useMemo(() => { + if (!decoded || !tokenInfo) return null; + try { + const currency = { + coinMinimalDenom: `erc20:${tokenContractAddress}`, + coinDenom: tokenInfo.symbol, + coinDecimals: tokenInfo.decimals, + }; + return new CoinPretty(currency, new Dec(decoded.amount.toString())) + .maxDecimals(6) + .trim(true) + .toString(); + } catch { + return decoded.amount.toString(); + } + }, [decoded, tokenInfo, tokenContractAddress]); + + const isUnlimited = useMemo(() => { + if (!decoded || decoded.type !== "approve" || !tokenInfo) return false; + const normalized = + decoded.amount / BigInt(10) ** BigInt(tokenInfo.decimals); + return normalized >= UNLIMITED_THRESHOLD; + }, [decoded, tokenInfo]); + + return { decoded, tokenInfo, formattedAmount, isUnlimited }; +} diff --git a/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/calls-list/hooks/use-method-name.ts b/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/calls-list/hooks/use-method-name.ts new file mode 100644 index 0000000000..4fde02568e --- /dev/null +++ b/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/calls-list/hooks/use-method-name.ts @@ -0,0 +1,38 @@ +import { useIntl } from "react-intl"; +import { useStore } from "../../../../../../../stores"; +import { SELECTOR_INTENT } from "../constants"; +import { getSelector, toTitleCase } from "../utils"; + +export function useMethodName(data: string | undefined) { + const intl = useIntl(); + const { queriesStore } = useStore(); + + const selector = getSelector(data); + const intentKey = selector ? SELECTOR_INTENT[selector] : null; + + const fourByteResult = + selector && !intentKey + ? queriesStore.simpleQuery.queryGet<{ + results: ( + | { id: number; created_at: string; text_signature: string } + | undefined + )[]; + }>( + "https://www.4byte.directory", + `/api/v1/signatures?hex_signature=${selector}` + ) + : null; + + const fourByteName = + fourByteResult?.response?.data.results[ + (fourByteResult.response?.data.results.length ?? 0) - 1 + ]?.text_signature?.split("(")[0]; + + return intentKey + ? intl.formatMessage({ + id: `page.sign.ethereum.batch.calls.intent.${intentKey}`, + }) + : fourByteName + ? toTitleCase(fourByteName) + : null; +} diff --git a/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/calls-list/hooks/use-native-value.ts b/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/calls-list/hooks/use-native-value.ts new file mode 100644 index 0000000000..b4bee74b12 --- /dev/null +++ b/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/calls-list/hooks/use-native-value.ts @@ -0,0 +1,30 @@ +import { useMemo } from "react"; +import { CoinPretty, Dec } from "@keplr-wallet/unit"; +import { useStore } from "../../../../../../../stores"; + +export function useNativeValue(value: string | undefined, chainId: string) { + const { chainStore } = useStore(); + + return useMemo(() => { + if (!value || value === "0x0" || value === "0x") return null; + try { + const wei = BigInt(value); + const unwrapped = chainStore.getModularChain(chainId).unwrapped; + const currency = + unwrapped.type === "evm" + ? unwrapped.evm.nativeCurrency + : unwrapped.type === "ethermint" + ? unwrapped.cosmos.stakeCurrency || unwrapped.cosmos.currencies[0] + : null; + if (currency) { + return new CoinPretty(currency, new Dec(wei.toString())) + .maxDecimals(8) + .trim(true) + .toString(); + } + return `${wei.toString()} wei`; + } catch { + return value; + } + }, [value, chainId, chainStore]); +} diff --git a/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/calls-list/utils.ts b/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/calls-list/utils.ts new file mode 100644 index 0000000000..b34c297810 --- /dev/null +++ b/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/calls-list/utils.ts @@ -0,0 +1,83 @@ +import { AbiFunction } from "ox"; +import { + erc20TransferFunction, + erc20ApproveFunction, +} from "@keplr-wallet/stores-eth"; +import { + APPROVE_SELECTOR, + TRANSFER_SELECTOR, + TRANSFER_FROM_SELECTOR, + erc20TransferFromFunction, + DecodedErc20, +} from "./constants"; + +export function getSelector(data: string | undefined): string | null { + if (!data || data === "0x" || data.length < 10) return null; + return data.slice(0, 10).toLowerCase(); +} + +export function toTitleCase(name: string): string { + return name + .replace(/([-_][a-z])/g, (group) => + group.toUpperCase().replace("-", "").replace("_", "") + ) + .replace( + /([a-z])([A-Z]+(?=[A-Z][a-z]|$))|([A-Z][a-z])/g, + (match, lower, acronym, normalWord) => { + if (acronym) return `${lower} ${acronym}`; + if (normalWord) return ` ${normalWord}`; + return match; + } + ) + .replace(/\s+/g, " ") + .trim(); +} + +export function tryDecodeErc20(data: string | undefined): DecodedErc20 | null { + if (!data || data === "0x" || data.length < 10) return null; + const selector = data.slice(0, 10).toLowerCase(); + + try { + if (selector === APPROVE_SELECTOR) { + const decoded = AbiFunction.decodeData( + erc20ApproveFunction, + data as `0x${string}` + ); + const spender = decoded[0]; + const amount = decoded[1]; + if (typeof spender === "string" && typeof amount === "bigint") { + return { type: "approve", targetAddress: spender, amount }; + } + } + + if (selector === TRANSFER_SELECTOR) { + const decoded = AbiFunction.decodeData( + erc20TransferFunction, + data as `0x${string}` + ); + const to = decoded[0]; + const amount = decoded[1]; + if (typeof to === "string" && typeof amount === "bigint") { + return { type: "transfer", targetAddress: to, amount }; + } + } + + if (selector === TRANSFER_FROM_SELECTOR) { + const decoded = AbiFunction.decodeData( + erc20TransferFromFunction, + data as `0x${string}` + ); + const to = decoded[1]; + const amount = decoded[2]; + if (typeof to === "string" && typeof amount === "bigint") { + return { type: "transfer", targetAddress: to, amount }; + } + } + } catch {} + + return null; +} + +export function truncateAddress(address: string): string { + return `${address.slice(0, 10)}...${address.slice(-8)}`; +} diff --git a/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/hooks/use-batch-transaction.ts b/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/hooks/use-batch-transaction.ts new file mode 100644 index 0000000000..68c3d4764c --- /dev/null +++ b/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/hooks/use-batch-transaction.ts @@ -0,0 +1,85 @@ +import { useMemo } from "react"; +import { + ALLOWED_DELEGATORS, + buildDummyAuthorizationList, +} from "@keplr-wallet/background"; +import { + createAtomicBatchTransaction, + buildDelegationStateOverride, +} from "@keplr-wallet/stores-eth"; +import { InternalSendCallsRequest } from "@keplr-wallet/types"; +import { IGasConfig } from "@keplr-wallet/hooks"; +import { Buffer } from "buffer/"; + +interface FeeParamsHex { + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + gasPrice?: string; +} + +export function useBatchTransaction( + request: InternalSendCallsRequest, + hexAddress: string, + evmChainId: number, + gasConfig: IGasConfig, + feeHex: FeeParamsHex +) { + const status = request.chainCapabilities.atomic.status; + + // Batch TX assembly + const { batchTx, stateOverride } = useMemo(() => { + const authList = + status === "ready" + ? buildDummyAuthorizationList(ALLOWED_DELEGATORS[0], evmChainId) + : undefined; + const tx = createAtomicBatchTransaction( + request.calls, + hexAddress, + authList + ); + const override = + status === "ready" + ? buildDelegationStateOverride(hexAddress, ALLOWED_DELEGATORS[0]) + : undefined; + return { batchTx: tx, stateOverride: override }; + }, [request.calls, hexAddress, status, evmChainId]); + + const { maxFeePerGas, maxPriorityFeePerGas, gasPrice } = feeHex; + + // Derive signing data from batch TX + fee fields (pure computation, no stale closure) + const signingDataBuff = useMemo(() => { + const tx: Record = { + ...batchTx, + nonce: request.nonce, + chainId: evmChainId, + }; + + if (gasConfig.gas > 0) { + tx["gasLimit"] = `0x${gasConfig.gas.toString(16)}`; + } + + if (maxFeePerGas) { + tx["maxFeePerGas"] = maxFeePerGas; + tx["maxPriorityFeePerGas"] = maxPriorityFeePerGas; + } else if (gasPrice) { + tx["gasPrice"] = gasPrice; + } + + return Buffer.from(JSON.stringify(tx), "utf8"); + }, [ + batchTx, + request.nonce, + evmChainId, + gasConfig.gas, + maxFeePerGas, + maxPriorityFeePerGas, + gasPrice, + ]); + + return { + batchTx, + stateOverride, + signingDataBuff, + status, + }; +} diff --git a/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/sign-batch-view.tsx b/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/sign-batch-view.tsx new file mode 100644 index 0000000000..17916f8894 --- /dev/null +++ b/apps/extension/src/pages/sign/ethereum/views/sign-batch-view/sign-batch-view.tsx @@ -0,0 +1,324 @@ +import React, { FunctionComponent, useEffect, useMemo } from "react"; +import { SignEthereumInteractionStore } from "@keplr-wallet/stores-core"; +import { observer } from "mobx-react-lite"; +import { useStore } from "../../../../../stores"; +import { BackButton } from "../../../../../layouts/header/components"; +import { HeaderLayout } from "../../../../../layouts/header"; +import { useInteractionInfo } from "../../../../../hooks"; +import { Buffer } from "buffer/"; +import { useIntl } from "react-intl"; +import { useTheme } from "styled-components"; +import { useUnmountPromise } from "../../../../../hooks/use-unmount-promise"; +import { handleExternalInteractionWithNoProceedNext } from "../../../../../utils"; +import { useNavigate } from "react-router"; +import { ApproveIcon, CancelIcon } from "../../../../../components/button"; +import { HeaderProps } from "../../../../../layouts/header/types"; +import { Box } from "../../../../../components/box"; +import { Gutter } from "../../../../../components/gutter"; +import { FeeControl } from "../../../../../components/input/fee-control"; +import { ColorPalette } from "../../../../../styles"; +import { MemoryKVStore } from "@keplr-wallet/common"; +import { + useAmountConfig, + useGasSimulator, + useZeroAllowedGasConfig, + useTxConfigsValidate, +} from "@keplr-wallet/hooks"; +import { useFeeConfig, useSenderConfig } from "@keplr-wallet/hooks-evm"; +import { + BatchSigningData, + InternalSendCallsRequest, +} from "@keplr-wallet/types"; +import { DSTypography } from "@keplr-wallet/design-system"; +import { Dec } from "@keplr-wallet/unit"; +import { UpgradeNotice } from "./upgrade-notice"; +import { CallsList } from "./calls-list/calls-list"; +import { BatchSummaryCard } from "./batch-summary-card"; +import { BatchDetailCard } from "./batch-detail-card"; +import { useBatchTransaction } from "./hooks/use-batch-transaction"; +import { AdvancedDetails } from "./advanced-details"; + +export const EthereumSignBatchView: FunctionComponent<{ + interactionData: NonNullable; +}> = observer(({ interactionData }) => { + const { + signEthereumInteractionStore, + chainStore, + accountStore, + ethereumAccountStore, + queriesStore, + } = useStore(); + const intl = useIntl(); + const theme = useTheme(); + const navigate = useNavigate(); + + const interactionInfo = useInteractionInfo({ + onUnmount: async () => { + await signEthereumInteractionStore.rejectWithProceedNext( + interactionData.id, + // eslint-disable-next-line @typescript-eslint/no-empty-function + () => {} + ); + }, + }); + + const { message, chainId, signer } = interactionData.data; + + const request: InternalSendCallsRequest = useMemo( + () => JSON.parse(Buffer.from(message).toString("utf8")), + [message] + ); + + const chainInfo = chainStore.getModularChain(chainId); + const ethereumAccount = ethereumAccountStore.getAccount(chainId); + const hexAddress = accountStore.getAccount(chainId).ethereumHexAddress; + + const evmChainId = useMemo(() => { + const u = chainInfo.unwrapped; + if (u.type === "evm") return u.evm.chainId; + if (u.type === "ethermint") return u.evm.chainId; + return 0; + }, [chainInfo]); + + // Fee hooks + const senderConfig = useSenderConfig(chainStore, chainId, signer); + const gasConfig = useZeroAllowedGasConfig(chainStore, chainId, 0); + const amountConfig = useAmountConfig( + chainStore, + queriesStore, + chainId, + senderConfig, + false + ); + const feeConfig = useFeeConfig( + chainStore, + queriesStore, + chainId, + senderConfig, + amountConfig, + gasConfig + ); + + // Fee extraction + const feeHex = (() => { + const fees = feeConfig.getEIP1559TxFees(feeConfig.type); + if (fees.maxFeePerGas && fees.maxPriorityFeePerGas) { + return { + maxFeePerGas: `0x${BigInt( + fees.maxFeePerGas.truncate().toString() + ).toString(16)}`, + maxPriorityFeePerGas: `0x${BigInt( + fees.maxPriorityFeePerGas.truncate().toString() + ).toString(16)}`, + }; + } + return { + gasPrice: `0x${BigInt(fees.gasPrice?.truncate().toString() ?? 0).toString( + 16 + )}`, + }; + })(); + + // Batch TX logic + const { batchTx, stateOverride, signingDataBuff, status } = + useBatchTransaction(request, hexAddress, evmChainId, gasConfig, feeHex); + + // Gas simulator + const gasSimulator = useGasSimulator( + new MemoryKVStore("gas-simulator.ethereum.batch"), + chainStore, + chainId, + gasConfig, + feeConfig, + "evm/batch", + () => ({ + simulate: () => + ethereumAccount.simulateGas(hexAddress, batchTx, stateOverride), + }) + ); + + // OP Stack L1 data fee + useEffect(() => { + (async () => { + if (chainInfo.hasFeature("op-stack-l1-data-fee") && hexAddress) { + const l1Fee = await ethereumAccount.simulateOpStackL1Fee({ + to: hexAddress, + value: "0x0", + data: "0x", + }); + feeConfig.setL1DataFee(new Dec(BigInt(l1Fee))); + } + })(); + }, [chainInfo, ethereumAccount, feeConfig, hexAddress]); + + // Refresh EIP-1559 fee every 12 seconds + useEffect(() => { + const intervalId = setInterval(() => { + feeConfig.refreshEIP1559TxFees(); + }, 12000); + return () => clearInterval(intervalId); + }, [feeConfig]); + + const unmountPromise = useUnmountPromise(); + + const isLoading = signEthereumInteractionStore.isObsoleteInteractionApproved( + interactionData.id + ); + + const txConfigsValidate = useTxConfigsValidate({ + senderConfig, + gasConfig, + feeConfig, + }); + const approveDisabled = txConfigsValidate.interactionBlocked; + + const bottomButtons: HeaderProps["bottomButtons"] = [ + { + textOverrideIcon: ( + + ), + size: "large", + color: "secondary", + style: { width: "3.25rem" }, + onClick: async () => { + await signEthereumInteractionStore.rejectWithProceedNext( + interactionData.id, + async (proceedNext) => { + if (!proceedNext) { + if ( + interactionInfo.interaction && + !interactionInfo.interactionInternal + ) { + handleExternalInteractionWithNoProceedNext(); + } else if ( + interactionInfo.interaction && + interactionInfo.interactionInternal + ) { + window.history.length > 1 ? navigate(-1) : navigate("/"); + } else { + navigate("/", { replace: true }); + } + } + } + ); + }, + }, + { + text: intl.formatMessage({ id: "button.approve" }), + isSpecial: true, + size: "large", + left: !isLoading && , + isLoading, + disabled: approveDisabled, + onClick: async () => { + const unsignedTx = JSON.parse( + Buffer.from(signingDataBuff).toString("utf8") + ); + const batchSigningData: BatchSigningData = { + strategy: "atomic", + batchId: request.batchId, + unsignedTxs: [unsignedTx], + }; + + await signEthereumInteractionStore.approveWithProceedNext( + interactionData.id, + Buffer.from(JSON.stringify(batchSigningData), "utf8"), + undefined, + async (proceedNext) => { + if (!proceedNext) { + if ( + interactionInfo.interaction && + !interactionInfo.interactionInternal + ) { + handleExternalInteractionWithNoProceedNext(); + } + } + if ( + interactionInfo.interaction && + interactionInfo.interactionInternal + ) { + await unmountPromise.promise; + } + } + ); + }, + }, + ]; + + return ( +