Skip to content

Commit

Permalink
feat: WalletConnect, first version
Browse files Browse the repository at this point in the history
Working WalletConnect integration with Umami:
 - listing dApps connected with WalletConnect
 - tezos_getAccounts returns the current account
 - tezos_sign signs payload with the current account
 - tezos_send supports all perations:
   - transaction signing
   - delegate / undelegate
   - origination, calling smart contract
   - stake, unstake, finalize
 - approve and reject by user
 - success and error from Tezos node

Limitations:
 - the operation result is not shown to the user
 - pairings list doesn't work on remote disconnect
 - no tests
 - no documentation
 - several lint errors
  • Loading branch information
dianasavvatina committed Sep 25, 2024
1 parent 2273e92 commit 75b3263
Show file tree
Hide file tree
Showing 41 changed files with 16,806 additions and 7,139 deletions.
5 changes: 5 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
"@chakra-ui/theme-tools": "^2.1.2",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@json-rpc-tools/utils": "^1.7.6",
"@nextui-org/react": "^2.4.6",
"@reduxjs/toolkit": "^2.2.7",
"@tanstack/react-query": "^5.55.0",
"@taquito/beacon-wallet": "^20.0.1",
Expand All @@ -49,6 +51,8 @@
"@umami/state": "workspace:^",
"@umami/tezos": "workspace:^",
"@umami/tzkt": "workspace:^",
"@walletconnect/types": "^2.16.1",
"@walletconnect/utils": "^2.16.1",
"bignumber.js": "^9.1.2",
"bip39": "^3.1.0",
"cross-env": "^7.0.3",
Expand Down Expand Up @@ -76,6 +80,7 @@
"react-test-renderer": "^18.3.1",
"redux": "^5.0.1",
"redux-persist": "^6.0.0",
"valtio": "^2.0.0",
"zod": "^3.23.8"
},
"devDependencies": {
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/components/App/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,18 @@ import { useCurrentAccount } from "@umami/state";
import { Layout } from "../../Layout";
import { Welcome } from "../../views/Welcome";
import { BeaconProvider } from "../beacon";
import { WalletConnectProvider } from "../WalletConnect";
import Modal from "../WalletConnect/Modal";

export const App = () => {
const currentAccount = useCurrentAccount();

return currentAccount ? (
<BeaconProvider>
<Layout />
<WalletConnectProvider>
<Layout />
<Modal />
</WalletConnectProvider>
</BeaconProvider>
) : (
<Welcome />
Expand Down
13 changes: 10 additions & 3 deletions apps/web/src/components/Menu/AppsMenu/AppsMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Button, Divider, Text } from "@chakra-ui/react";
import { useAddPeer } from "@umami/state";
import { onConnect, useAddPeer } from "@umami/state";

import { BeaconPeers } from "../../beacon";
import PairingsPage from "../../SendFlow/WalletConnect/pairings";
import { DrawerContentWrapper } from "../DrawerContentWrapper";

export const AppsMenu = () => {
Expand All @@ -10,19 +11,25 @@ export const AppsMenu = () => {
return (
<DrawerContentWrapper title="Apps">
<Text marginTop="12px" size="lg">
Connect with Pairing Request
Connect with Pairing Request for Beacon or WalletConnect
</Text>
<Button
width="fit-content"
marginTop="18px"
padding="0 24px"
onClick={() => navigator.clipboard.readText().then(addPeer)}
onClick={() =>
navigator.clipboard.readText().then(
// if payload starts with wc, call OnConnect else call addPeer
payload => (payload.startsWith("wc:") ? onConnect(payload) : addPeer(payload))
)
}
variant="secondary"
>
Connect
</Button>
<Divider marginTop={{ base: "36px", lg: "40px" }} />
<BeaconPeers />
<PairingsPage />
</DrawerContentWrapper>
);
};
43 changes: 43 additions & 0 deletions apps/web/src/components/SendFlow/WalletConnect/PairingCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@

import { Button, Card, CardBody, Link, Text, Tooltip } from "@chakra-ui/react"
import { truncate } from "@umami/tezos"

import { CrossedCircleIcon } from "../../../assets/icons"

/**
* Types
*/
interface IProps {
name?: string
url?: string
topic?: string
onDelete: () => Promise<void>
}

/**
* Component
*/
export default function PairingCard({ name, url, topic, onDelete }: IProps) {
return (
<Card className="relative mb-6 min-h-[70px] border border-light">
<CardBody className="flex flex-row items-center justify-between overflow-hidden p-4">
<div className="flex-1">
<Text className="ml-9" data-testid={"pairing-text-" + topic}>
{name}
</Text>
<Link className="ml-9" data-testid={"pairing-text-" + topic} href={url}>
{truncate(url?.split("https://")[1] ?? "Unknown", 23)}
</Link>
</div>
<Tooltip content="Delete" placement="left">
<Button className="min-w-auto text-error border-0 p-1 hover:bg-red-100 transition-all"
data-testid={"pairing-delete-" + topic}
onClick={onDelete}
>
<CrossedCircleIcon alt="delete icon" />
</Button>
</Tooltip>
</CardBody>
</Card>
)
}
49 changes: 49 additions & 0 deletions apps/web/src/components/SendFlow/WalletConnect/pairings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@

import { Text } from "@chakra-ui/react"
import { SettingsStore, web3wallet } from "@umami/state"
import { type PairingTypes } from "@walletconnect/types"
import { getSdkError } from "@walletconnect/utils"
import { Fragment, useEffect } from "react"
import { useSnapshot } from "valtio"

import PairingCard from "./PairingCard"

export default function PairingsPage() {
const { pairings } = useSnapshot(SettingsStore.state)
// const [walletPairings ] = useState(web3wallet.core.pairing.getPairings())

async function onDelete(topic: string) {
await web3wallet.disconnectSession({ topic, reason: getSdkError("USER_DISCONNECTED") })
const newPairings = pairings.filter(pairing => pairing.topic !== topic)
SettingsStore.setPairings(newPairings as PairingTypes.Struct[])
}

useEffect(() => {
SettingsStore.setPairings(web3wallet.core.pairing.getPairings())
}, [])

// console.log("pairings", walletPairings)
return (
<Fragment>
{pairings.length ? (
pairings.map(pairing => {
const { peerMetadata } = pairing

return (
<PairingCard
key={pairing.topic}
data-testid={"pairing-" + pairing.topic}
logo={peerMetadata?.icons[0]}
name={peerMetadata?.name}
onDelete={() => onDelete(pairing.topic)}
topic={pairing.topic}
url={peerMetadata?.url}
/>
)
})
) : (
<Text css={{ opacity: "0.5", textAlign: "center", marginTop: "$20" }}>No pairings</Text>
)}
</Fragment>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { formatJsonRpcError, formatJsonRpcResult } from "@json-rpc-tools/utils";
import { type TezosToolkit } from "@taquito/taquito";
import {
type Account,
type ImplicitAccount,
type SecretKeyAccount,
estimate,
executeOperations,
toAccountOperations,
} from "@umami/core";
import {
TEZOS_SIGNING_METHODS,
} from "@umami/state";
import { type Network } from "@umami/tezos";
import { type SignClientTypes } from "@walletconnect/types";
import { getSdkError } from "@walletconnect/utils";

export async function approveTezosRequest(
requestEvent: SignClientTypes.EventArguments["session_request"],
tezosToolkit: TezosToolkit,
signer: Account,
network: Network
) {
const { params, id } = requestEvent;
const { request } = params;

console.log("approveTezosRequest", request);

switch (request.method) {
case TEZOS_SIGNING_METHODS.TEZOS_GET_ACCOUNTS: {
console.log("TEZOS_GET_ACCOUNTS");
return formatJsonRpcResult(id, [{
algo: (signer as SecretKeyAccount).curve,
address: signer.address.pkh,
pubkey: (signer as SecretKeyAccount).pk,
}]);
}

case TEZOS_SIGNING_METHODS.TEZOS_SEND: {
console.log("TEZOS_SEND");
try {
const operation = toAccountOperations(request.params.operations, signer as ImplicitAccount);
const estimatedOperations = await estimate(operation, network);
console.log("TEZOS_SEND: executing operation", estimatedOperations);
const { opHash } = await executeOperations(estimatedOperations, tezosToolkit);
console.log("TEZOS_SEND: executed operation", request.params.method, operation, opHash);
return formatJsonRpcResult(id, { hash: opHash });
} catch (error) {
if (error instanceof Error) {
console.error("Tezos_send operation failed with error: ", error.message);
return formatJsonRpcError(id, error.message);
} else {
console.error("Tezos_send operation failed with unknown error: ", error);
return formatJsonRpcError(id, "TEZOS_SEND failed with unknown error.");
}
}
}

case TEZOS_SIGNING_METHODS.TEZOS_SIGN: {
const result = await tezosToolkit.signer.sign(request.params.payload);
console.log("TEZOS_SIGN", result.prefixSig);
return formatJsonRpcResult(id, { signature: result.prefixSig });
}

default:
throw new Error(getSdkError("INVALID_METHOD").message);
}
}

export function rejectTezosRequest(request: SignClientTypes.EventArguments["session_request"]) {
const { id } = request;

return formatJsonRpcError(id, getSdkError("USER_REJECTED_METHODS").message);
}
16 changes: 16 additions & 0 deletions apps/web/src/components/WalletConnect/ChainAddressMini.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
interface Props {
address?: string;
}

export default function ChainAddressMini({ address }: Props) {
if (!address || address === "N/A") {return <></>;}
return (
<>
<div>
<span style={{ marginLeft: "5px" }}>
{address.substring(0, 6)}...{address.substring(address.length - 6)}
</span>
</div>
</>
);
}
33 changes: 33 additions & 0 deletions apps/web/src/components/WalletConnect/ChainCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Card, CardBody } from "@chakra-ui/react";
import { type ReactNode } from "react";

interface Props {
children: ReactNode | ReactNode[];
rgb: string;
flexDirection: "row" | "col";
alignItems: "center" | "flex-start";
flexWrap?: "wrap" | "nowrap";
}

export default function ChainCard({ rgb, children, flexDirection, alignItems, flexWrap }: Props) {
return (
<Card
className="mb-6 min-h-[70px] shadow-md rounded-lg border"
style={{
borderColor: `rgba(${rgb}, 0.4)`,
boxShadow: `0 0 10px 0 rgba(${rgb}, 0.15)`,
backgroundColor: `rgba(${rgb}, 0.25)`,
}}
>
<CardBody
className={`flex justify-between overflow-hidden
${flexWrap === "wrap" ? "flex-wrap" : "flex-nowrap"}
${flexDirection === "row" ? "flex-row" : "flex-col"}
${alignItems === "center" ? "items-center" : "items-start"}
`}
>
{children}
</CardBody>
</Card>
);
}
22 changes: 22 additions & 0 deletions apps/web/src/components/WalletConnect/ChainDataMini.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { getChainData } from "@umami/state";
import { useMemo } from "react";

import { TezosLogoIcon } from "../../assets/icons";

interface Props {
chainId?: string; // namespace + ":" + reference
}

export default function ChainDataMini({ chainId }: Props) {
const chainData = useMemo(() => getChainData(chainId), [chainId]);

if (!chainData) {return <></>;}
return (
<>
<div>
<TezosLogoIcon size="sm" />
<span style={{ marginLeft: "5px" }}>{chainData.name}</span>
</div>
</>
);
}
23 changes: 23 additions & 0 deletions apps/web/src/components/WalletConnect/ChainSmartAddressMini.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Card } from "@chakra-ui/react";

import ChainAddressMini from "./ChainAddressMini";

type SmartAccount = {
address: string;
type: string;
};

interface Props {
account: SmartAccount;
}

export default function ChainSmartAddressMini({ account }: Props) {
return (
<div>
<div>
<Card>({account.type})</Card>
<ChainAddressMini address={account.address} />
</div>
</div>
);
}
29 changes: 29 additions & 0 deletions apps/web/src/components/WalletConnect/LoadingModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Card, Divider } from "@chakra-ui/react";
import { ModalStore } from "@umami/state";
import { useSnapshot } from "valtio";

import RequestModalContainer from "./RequestModalContainer";


export default function LoadingModal() {
const state = useSnapshot(ModalStore.state);
const message = state.data?.loadingMessage;

return (
<RequestModalContainer title="">
<div style={{ textAlign: "center", padding: "20px" }}>
<div>
<div>
<h3>Loading your request...</h3>
</div>
</div>
{message ? (
<div style={{ textAlign: "center" }}>
<Divider />
<Card>{message}</Card>
</div>
) : null}
</div>
</RequestModalContainer>
);
}
Loading

0 comments on commit 75b3263

Please sign in to comment.