Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions components/DelegatingView/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { AccountQueryResult, OrchestratorsSortedQueryResult } from "apollo";
import {
useAccountAddress,
useEnsData,
useIsSafe,
usePendingFeesAndStakeData,
} from "hooks";
import { useBondingManagerAddress } from "hooks/useContracts";
Expand Down Expand Up @@ -63,6 +64,23 @@ const Index = ({ delegator, transcoders, protocol, currentRound }: Props) => {
});
const { data, isPending, writeContract, isSuccess, error } =
useWriteContract();
// Simulation reverts for Safes (2300-gas transfer into a cold proxy) but
// execTransaction succeeds; Safe runs its own simulation, so skip the gate.
const isSafe = useIsSafe();
const canWithdraw = Boolean(
config || (isSafe && bondingManagerAddress && recipient)
);
const withdrawFees = () => {
if (config) return writeContract(config.request);
if (bondingManagerAddress && recipient) {
writeContract({
address: bondingManagerAddress,
abi: bondingManager,
functionName: "withdrawFees",
args: [recipient, BigInt(amount)],
});
}
};

useHandleTransaction("withdrawFees", data, error, isPending, isSuccess, {
recipient,
Expand Down Expand Up @@ -364,8 +382,8 @@ const Index = ({ delegator, transcoders, protocol, currentRound }: Props) => {
marginTop: "$3",
width: "100%",
}}
disabled={!config}
onClick={() => config && writeContract(config.request)}
disabled={!canWithdraw}
onClick={withdrawFees}
size="4"
variant="primary"
>
Expand Down
128 changes: 116 additions & 12 deletions components/TxConfirmedDialog/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,29 @@ import {
Heading,
Link as A,
} from "@livepeer/design-system";
import { CheckIcon } from "@radix-ui/react-icons";
import { CheckIcon, ExternalLinkIcon } from "@radix-ui/react-icons";
import { formatAddress, fromWei } from "@utils/web3";
import { TransactionStatus, useExplorerStore } from "hooks";
import {
TransactionStatus,
useAccountAddress,
useActiveChain,
useExplorerStore,
useIsSafe,
} from "hooks";
import { useBondingManagerAddress } from "hooks/useContracts";
import { CHAIN_INFO, DEFAULT_CHAIN_ID } from "lib/chains";
import { useRouter } from "next/router";
import { useCallback } from "react";
import { MdReceipt } from "react-icons/md";
import { Address } from "viem";
import { useReadContract } from "wagmi";
import { useAccount, useReadContract } from "wagmi";

import { txMessages } from "../../lib/utils";

const Index = () => {
const router = useRouter();
const { latestTransaction, clearLatestTransaction } = useExplorerStore();
const isSafe = useIsSafe();

const onDismiss = useCallback(() => {
clearLatestTransaction();
Expand Down Expand Up @@ -82,26 +89,99 @@ const Index = () => {
}}
>
<CheckIcon width={18} height={18} />
<Box css={{ paddingLeft: "$1", paddingRight: "$1" }}>Success</Box>
<Box css={{ paddingLeft: "$1", paddingRight: "$1" }}>
{isSafe === undefined
? "Submitted"
: isSafe
? "Sent to Safe"
: "Success"}
</Box>
</Badge>
</Heading>
</DialogTitle>
<TransactionContent tx={latestTransaction} onDismiss={onDismiss} />
{latestTransaction.inputData && <SafeAppHint />}
</DialogContent>
</Dialog>
);
};

export default Index;

// Suggest the Safe App to Safes connected from outside Safe{Wallet}.
function SafeAppHint() {
const account = useAccountAddress();
const activeChain = useActiveChain();
const isSafe = useIsSafe();
const { connector } = useAccount();

if (!account || !isSafe || connector?.id === "safe") return null;

const chainInfo =
CHAIN_INFO[activeChain?.id as keyof typeof CHAIN_INFO] ??
CHAIN_INFO[DEFAULT_CHAIN_ID];
const safeId = `${chainInfo.safePrefix}:${account}`;
const appUrl = encodeURIComponent(window.location.origin);

return (
<Box
css={{
mt: "$3",
textAlign: "center",
fontSize: "$2",
color: "$neutral11",
}}
>
Next time, use the explorer inside your Safe.{" "}
<A
variant="primary"
target="_blank"
rel="noopener noreferrer"
href={`https://app.safe.global/apps/open?safe=${safeId}&appUrl=${appUrl}`}
css={{ display: "inline-flex", alignItems: "center", gap: "$1" }}
>
Open as Safe App
<ExternalLinkIcon aria-hidden />
</A>
</Box>
);
}

const TransactionContent = ({
tx,
onDismiss,
}: {
tx: TransactionStatus;
onDismiss: () => void;
}) => {
const isSafe = useIsSafe();

if (!tx.inputData) return;
if (isSafe === undefined) {
return <Box css={{ textAlign: "center" }}>Checking wallet status...</Box>;
}
if (isSafe) {
return (
<Box>
<Table css={{ mb: "$4" }}>
<Header tx={tx} />
<Box css={{ padding: "$3" }}>
Your transaction was sent to Safe. Check its status there; it takes
effect only after on-chain execution.
</Box>
</Table>
<Button
onClick={onDismiss}
size="4"
variant="primary"
css={{ width: "100%" }}
>
Close
</Button>
</Box>
);
}

switch (tx.name) {
case "bond":
return (
Expand Down Expand Up @@ -542,6 +622,26 @@ function Table({ css = {}, children, ...props }) {
}

function Header({ tx }: { tx: TransactionStatus }) {
const account = useAccountAddress();
const activeChain = useActiveChain();
const isSafe = useIsSafe();
const chainInfo =
CHAIN_INFO[activeChain?.id as keyof typeof CHAIN_INFO] ??
CHAIN_INFO[DEFAULT_CHAIN_ID];

// A Safe returns a Safe tx hash, so link to its queue instead of the explorer.
const link = isSafe
? {
label: "View in Safe",
href: `https://app.safe.global/transactions/queue?safe=${chainInfo.safePrefix}:${account}`,
icon: <ExternalLinkIcon />,
}
: {
label: "Transfer Receipt",
href: `${chainInfo.explorer}tx/${tx?.hash}`,
icon: <MdReceipt />,
};

return (
<Flex
css={{
Expand All @@ -553,23 +653,27 @@ function Header({ tx }: { tx: TransactionStatus }) {
}}
>
<Flex css={{ fontWeight: 700, alignItems: "center" }}>
<Box css={{ marginRight: "10px" }}>🎉</Box>
{txMessages[tx?.name ?? ""]?.confirmed}
{isSafe ? (
"Sign and execute it in your Safe"
) : (
<>
<Box css={{ marginRight: "10px" }}>🎉</Box>
{txMessages[tx?.name ?? ""]?.confirmed}
</>
)}
</Flex>
<A
variant="primary"
css={{ display: "flex", alignItems: "center", flexShrink: 0 }}
target="_blank"
rel="noopener noreferrer"
href={`${CHAIN_INFO[DEFAULT_CHAIN_ID].explorer}tx/${tx?.hash}`}
aria-label="Transfer Receipt"
href={link.href}
aria-label={link.label}
>
<Box css={{ display: "none", "@bp1": { display: "inline" } }}>
Transfer Receipt
</Box>
<Box css={{ marginLeft: "6px", color: "$primary10" }}>
<MdReceipt />
{link.label}
</Box>
<Box css={{ marginLeft: "6px", color: "$primary10" }}>{link.icon}</Box>
</A>
</Flex>
);
Expand Down
19 changes: 18 additions & 1 deletion components/Web3Providers/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import {
getDefaultConfig,
type Locale,
RainbowKitProvider,
type Wallet,
} from "@rainbow-me/rainbowkit";
import {
baseAccount,
braveWallet,
metaMaskWallet,
rabbyWallet,
rainbowWallet,
safeWallet,
trustWallet,
walletConnectWallet,
} from "@rainbow-me/rainbowkit/wallets";
Expand All @@ -21,7 +23,19 @@ import {
} from "lib/chains";
import { useMemo } from "react";
import { fallback, http } from "viem";
import { WagmiProvider } from "wagmi";
import { createConnector, WagmiProvider } from "wagmi";
import { safe } from "wagmi/connectors";

// RainbowKit's safeWallet, but only trusting Safe{Wallet} as the parent page;
// otherwise any site embedding the explorer can fake the Safe handshake.
const safeAppWallet = (): Wallet => ({
...safeWallet(),
createConnector: (walletDetails) =>
createConnector((config) => ({
...safe({ allowedDomains: [/^https:\/\/app\.safe\.global$/] })(config),
...walletDetails,
})),
});

const Index = ({
children,
Expand All @@ -35,6 +49,8 @@ const Index = ({
DEFAULT_CHAIN.id === L1_CHAIN.id
? ([DEFAULT_CHAIN] as const)
: ([DEFAULT_CHAIN, L1_CHAIN] as const);
// Safe Apps run in Safe{Wallet}'s iframe; list Safe first so reconnect picks it.
const isSafeApp = typeof window !== "undefined" && window.parent !== window;

return getDefaultConfig({
appName: "Livepeer Explorer",
Expand All @@ -51,6 +67,7 @@ const Index = ({
{
groupName: "Popular",
wallets: [
...(isSafeApp ? [safeAppWallet] : []),
metaMaskWallet,
braveWallet,
rainbowWallet,
Expand Down
67 changes: 67 additions & 0 deletions hooks/useHandleTransaction.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/** @jest-environment jsdom */

import { useAddRecentTransaction } from "@rainbow-me/rainbowkit";
import { renderHook } from "@testing-library/react";

import { useExplorerStore } from "./useExplorerStore";
import { useHandleTransaction } from "./useHandleTransaction";
import { useIsSafe } from "./wallet";

jest.mock("@rainbow-me/rainbowkit", () => ({
useAddRecentTransaction: jest.fn(),
}));
jest.mock("./useExplorerStore", () => ({
useExplorerStore: jest.fn(),
}));
jest.mock("./wallet", () => ({
useIsSafe: jest.fn(),
}));
jest.mock("viem", () => ({
isHash: (value: string) => /^0x[0-9a-f]{64}$/i.test(value),
}));

const hash = `0x${"1".repeat(64)}` as `0x${string}`;
const addRecentTransaction = jest.fn();

beforeEach(() => {
(useAddRecentTransaction as jest.Mock).mockReturnValue(addRecentTransaction);
(useExplorerStore as unknown as jest.Mock).mockReturnValue({
setLatestTransactionError: jest.fn(),
setLatestTransactionSummary: jest.fn(),
setLatestTransactionConfirmed: jest.fn(),
setLatestTransactionDetails: jest.fn(),
});
});

it("never tracks a Safe proposal that arrives before Safe detection finishes", () => {
(useIsSafe as jest.Mock).mockReturnValue(undefined);
const { rerender } = renderHook(() =>
useHandleTransaction("vote", hash, null, false, true, {})
);

expect(addRecentTransaction).not.toHaveBeenCalled();

(useIsSafe as jest.Mock).mockReturnValue(true);
rerender();

expect(addRecentTransaction).not.toHaveBeenCalled();
});

it("tracks an EOA transaction once after detection finishes", () => {
(useIsSafe as jest.Mock).mockReturnValue(undefined);
const { rerender } = renderHook(() =>
useHandleTransaction("vote", hash, null, false, true, {})
);

expect(addRecentTransaction).not.toHaveBeenCalled();

(useIsSafe as jest.Mock).mockReturnValue(false);
rerender();
rerender();

expect(addRecentTransaction).toHaveBeenCalledTimes(1);
expect(addRecentTransaction).toHaveBeenCalledWith({
hash,
description: "Vote",
});
});
Loading
Loading