Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: WalletConnect integration, part 6, request #2029

Merged
merged 2 commits into from
Dec 18, 2024
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { type TezosToolkit } from "@taquito/taquito";
import { useDynamicModalContext } from "@umami/components";
import { executeOperations, totalFee } from "@umami/core";
import { useAsyncActionHandler, walletKit } from "@umami/state";
import { formatJsonRpcResult } from "@walletconnect/jsonrpc-utils";
import { useForm } from "react-hook-form";

import { SuccessStep } from "../SuccessStep";
import { type CalculatedSignProps, type SdkSignPageProps } from "../utils";

export const useSignWithWalletConnect = ({
operation,
headerProps,
requestId,
}: SdkSignPageProps): CalculatedSignProps => {
const { isLoading: isSigning, handleAsyncAction } = useAsyncActionHandler();
const { openWith } = useDynamicModalContext();

const form = useForm({ defaultValues: { executeParams: operation.estimates } });

if (requestId.sdkType !== "walletconnect") {
return {
fee: 0,
isSigning: false,
onSign: async () => {},
network: null,
};
}

const onSign = async (tezosToolkit: TezosToolkit) =>
handleAsyncAction(
async () => {
const { opHash } = await executeOperations(
{ ...operation, estimates: form.watch("executeParams") },
tezosToolkit
);

const response = formatJsonRpcResult(requestId.id, { hash: opHash });
await walletKit.respondSessionRequest({ topic: requestId.topic, response });
return openWith(<SuccessStep hash={opHash} />);
},
error => ({
description: `Failed to confirm WalletConnect operation: ${error.message}`,
})
);

return {
fee: totalFee(form.watch("executeParams")),
isSigning,
onSign,
network: headerProps.network,
};
};
5 changes: 4 additions & 1 deletion apps/web/src/components/SendFlow/common/BatchSignPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { useSignWithBeacon } from "../Beacon/useSignWithBeacon";
import { SignButton } from "../SignButton";
import { SignPageFee } from "../SignPageFee";
import { type SdkSignPageProps } from "../utils";
import { useSignWithWalletConnect } from "../WalletConnect/useSignWithWalletConnect";

export const BatchSignPage = (
signProps: SdkSignPageProps,
Expand All @@ -31,7 +32,9 @@ export const BatchSignPage = (
const color = useColor();

const beaconCalculatedProps = useSignWithBeacon({ ...signProps });
const calculatedProps = beaconCalculatedProps;
const walletConnectCalculatedProps = useSignWithWalletConnect({ ...signProps });
const calculatedProps =
signProps.requestId.sdkType === "beacon" ? beaconCalculatedProps : walletConnectCalculatedProps;

const { isSigning, onSign, network, fee } = calculatedProps;
const { signer, operations } = signProps.operation;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ export const OriginationOperationSignPage = ({
}: SdkSignPageProps & CalculatedSignProps) => {
const color = useColor();
const { code, storage } = operation.operations[0] as ContractOrigination;

const form = useForm({ defaultValues: { executeParams: operation.estimates } });

return (
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/components/SendFlow/common/SingleSignPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@ import { TezSignPage } from "./TezSignPage";
import { UndelegationSignPage } from "./UndelegationSignPage";
import { UnstakeSignPage } from "./UnstakeSignPage";
import { useSignWithBeacon } from "../Beacon/useSignWithBeacon";
import { useSignWithWalletConnect } from "../WalletConnect/useSignWithWalletConnect";

export const SingleSignPage = (signProps: SdkSignPageProps) => {
const operationType = signProps.operation.operations[0].type;

const beaconCalculatedProps = useSignWithBeacon({ ...signProps });
const calculatedProps = beaconCalculatedProps;
const walletConnectCalculatedProps = useSignWithWalletConnect({ ...signProps });
const calculatedProps =
signProps.requestId.sdkType === "beacon" ? beaconCalculatedProps : walletConnectCalculatedProps;

switch (operationType) {
case "tez": {
Expand Down
47 changes: 22 additions & 25 deletions apps/web/src/components/WalletConnect/WalletConnectProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,14 @@ import {
walletKit,
} from "@umami/state";
import { type Network } from "@umami/tezos";
import { CustomError } from "@umami/utils";
import { CustomError, WalletConnectError } from "@umami/utils";
import { formatJsonRpcError } from "@walletconnect/jsonrpc-utils";
import { type SessionTypes } from "@walletconnect/types";
import { getSdkError } from "@walletconnect/utils";
import { type SdkErrorKey, getSdkError } from "@walletconnect/utils";
import { type PropsWithChildren, useCallback, useEffect, useRef } from "react";

import { SessionProposalModal } from "./SessionProposalModal";
import { useHandleWcRequest } from "./useHandleWcRequest";

enum WalletKitState {
NOT_INITIALIZED,
Expand All @@ -36,6 +37,8 @@ export const WalletConnectProvider = ({ children }: PropsWithChildren) => {

const availableNetworks: Network[] = useAvailableNetworks();

const handleWcRequest = useHandleWcRequest();

const onSessionProposal = useCallback(
(proposal: WalletKitTypes.SessionProposal) =>
handleAsyncActionUnsafe(async () => {
Expand Down Expand Up @@ -87,43 +90,37 @@ export const WalletConnectProvider = ({ children }: PropsWithChildren) => {
);

const onSessionRequest = useCallback(
async (event: WalletKitTypes.SessionRequest) => {
try {
async (event: WalletKitTypes.SessionRequest) =>
handleAsyncActionUnsafe(async () => {
const activeSessions: Record<string, SessionTypes.Struct> = walletKit.getActiveSessions();
if (!(event.topic in activeSessions)) {
console.error("WalletConnect session request failed. Session not found", event);
throw new CustomError("WalletConnect session request failed. Session not found");
throw new WalletConnectError("Session not found", "INVALID_EVENT", null);
}

const session = activeSessions[event.topic];

toast({
description: `Session request from dApp ${session.peer.metadata.name}`,
status: "info",
});
throw new CustomError("Not implemented");
} catch (error) {
await handleWcRequest(event, session);
}).catch(async error => {
const { id, topic } = event;
const activeSessions: Record<string, SessionTypes.Struct> = walletKit.getActiveSessions();
console.error("WalletConnect session request failed", event, error);
if (event.topic in activeSessions) {
const session = activeSessions[event.topic];
toast({
description: `Session request for dApp ${session.peer.metadata.name} failed. It was rejected.`,
status: "error",
});
let sdkErrorKey: SdkErrorKey =
error instanceof WalletConnectError ? error.sdkError : "SESSION_SETTLEMENT_FAILED";
if (sdkErrorKey === "USER_REJECTED") {
console.info("WC request rejected", sdkErrorKey, event, error);
} else {
toast({
description: `Session request for dApp ${topic} failed. It was rejected. Peer not found by topic.`,
status: "error",
});
if (error.message.includes("delegate.unchanged")) {
sdkErrorKey = "INVALID_EVENT";
}
console.warn("WC request failed", sdkErrorKey, event, error);
}
// dApp is waiting so we need to notify it
const response = formatJsonRpcError(id, getSdkError("INVALID_METHOD").message);
const sdkErrorMessage = getSdkError(sdkErrorKey).message;
const response = formatJsonRpcError(id, sdkErrorMessage);
await walletKit.respondSessionRequest({ topic, response });
}
},
[toast]
}),
[handleAsyncActionUnsafe, handleWcRequest, toast]
);

useEffect(() => {
Expand Down
116 changes: 116 additions & 0 deletions apps/web/src/components/WalletConnect/useHandleWcRequest.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { useDynamicModalContext } from "@umami/components";
import { type ImplicitAccount, estimate, toAccountOperations } from "@umami/core";
import { useAsyncActionHandler, useFindNetwork, useGetOwnedAccountSafe } from "@umami/state";
import { WalletConnectError } from "@umami/utils";
import { type SessionTypes, type SignClientTypes, type Verify } from "@walletconnect/types";

import { BatchSignPage } from "../SendFlow/common/BatchSignPage";
import { SingleSignPage } from "../SendFlow/common/SingleSignPage";
import { type SdkSignPageProps, type SignHeaderProps } from "../SendFlow/utils";

/**
* @returns a function that handles a beacon message and opens a modal with the appropriate content
*
* For operation requests it will also try to convert the operation(s) to our {@link Operation} format,
* estimate the fee and open the BeaconSignPage only if it succeeds
*/
export const useHandleWcRequest = () => {
const { openWith } = useDynamicModalContext();
const { handleAsyncActionUnsafe } = useAsyncActionHandler();
const getAccount = useGetOwnedAccountSafe();
const findNetwork = useFindNetwork();

return async (
event: {
verifyContext: Verify.Context;
} & SignClientTypes.BaseEventArgs<{
request: {
method: string;
params: any;
expiryTimestamp?: number;
};
chainId: string;
}>,
session: SessionTypes.Struct
) => {
await handleAsyncActionUnsafe(async () => {
const { id, topic, params } = event;
const { request, chainId } = params;

let modal;
let onClose;

switch (request.method) {
case "tezos_getAccounts": {
throw new WalletConnectError(
"Getting accounts is not supported yet",
"WC_METHOD_UNSUPPORTED",
session
);
}

case "tezos_sign": {
throw new WalletConnectError(
"Sign is not supported yet",
"WC_METHOD_UNSUPPORTED",
session
);
}

case "tezos_send": {
if (!request.params.account) {
throw new WalletConnectError("Missing account in request", "INVALID_EVENT", session);
}
const signer = getAccount(request.params.account);
if (!signer) {
throw new WalletConnectError(
`Unknown account, no signer: ${request.params.account}`,
"UNAUTHORIZED_EVENT",
session
);
}
const operation = toAccountOperations(
request.params.operations,
signer as ImplicitAccount
);
const network = findNetwork(chainId.split(":")[1]);
if (!network) {
throw new WalletConnectError(
`Unsupported network ${chainId}`,
"UNSUPPORTED_CHAINS",
session
);
}
const estimatedOperations = await estimate(operation, network);
const headerProps: SignHeaderProps = {
network,
appName: session.peer.metadata.name,
appIcon: session.peer.metadata.icons[0],
};
const signProps: SdkSignPageProps = {
headerProps: headerProps,
operation: estimatedOperations,
requestId: { sdkType: "walletconnect", id: id, topic },
};

if (operation.operations.length === 1) {
modal = <SingleSignPage {...signProps} />;
} else {
modal = <BatchSignPage {...signProps} {...event.params.request.params} />;
}
onClose = () => {
throw new WalletConnectError("Rejected by user", "USER_REJECTED", session);
};

return openWith(modal, { onClose });
}
default:
throw new WalletConnectError(
`Unsupported method ${request.method}`,
"WC_METHOD_UNSUPPORTED",
session
);
}
});
};
};
17 changes: 16 additions & 1 deletion packages/utils/src/ErrorContext.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CustomError, getErrorContext, handleTezError } from "./ErrorContext";
import { CustomError, WalletConnectError, getErrorContext, handleTezError } from "./ErrorContext";

describe("getErrorContext", () => {
it("should handle error object with message and stack", () => {
Expand Down Expand Up @@ -53,6 +53,16 @@ describe("getErrorContext", () => {
expect(context.stacktrace).toBeDefined();
expect(context.timestamp).toBeDefined();
});
it("should handle WalletConnectError instances", () => {
const error = new WalletConnectError("Custom WC error message", "UNSUPPORTED_EVENTS", null);

const context = getErrorContext(error);

expect(context.technicalDetails).toBe("");
expect(context.description).toBe("Custom WC error message");
expect(context.stacktrace).toBeDefined();
expect(context.timestamp).toBeDefined();
});
});

describe("handleTezError", () => {
Expand All @@ -78,6 +88,11 @@ describe("handleTezError", () => {
);
});

it("catches delegate.unchanged", () => {
const res = handleTezError(new Error("delegate.unchanged"));
expect(res).toBe("The delegate is unchanged. Delegation to this address is already done.");
});

it("returns undefined for unknown errors", () => {
const err = new Error("unknown error");
expect(handleTezError(err)).toBeUndefined();
Expand Down
16 changes: 15 additions & 1 deletion packages/utils/src/ErrorContext.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { type SessionTypes } from "@walletconnect/types";
import { type SdkErrorKey } from "@walletconnect/utils";
export type ErrorContext = {
timestamp: string;
description: string;
Expand All @@ -12,6 +14,16 @@ export class CustomError extends Error {
}
}

export class WalletConnectError extends CustomError {
sdkError: SdkErrorKey;
constructor(message: string, sdkError: SdkErrorKey, session: SessionTypes.Struct | null) {
const dappName = session?.peer.metadata.name ?? "unknown";
super(session ? `Request from ${dappName} is rejected. ${message}` : message);
this.name = "WalletConnectError";
this.sdkError = sdkError;
}
}

// Converts a known L1 error message to a more user-friendly one
export const handleTezError = (err: Error): string | undefined => {
if (err.message.includes("subtraction_underflow")) {
Expand All @@ -22,6 +34,8 @@ export const handleTezError = (err: Error): string | undefined => {
return "The baker you are trying to stake to does not accept external staking.";
} else if (err.message.includes("empty_implicit_delegated_contract")) {
return "Emptying an implicit delegated account is not allowed. End delegation before trying again.";
} else if (err.message.includes("delegate.unchanged")) {
return "The delegate is unchanged. Delegation to this address is already done.";
}
};

Expand All @@ -41,7 +55,7 @@ export const getErrorContext = (error: any): ErrorContext => {
technicalDetails = error;
}

if (error.name === "CustomError") {
if (error instanceof CustomError) {
description = error.message;
technicalDetails = "";
} else if (error instanceof Error) {
Expand Down
Loading