Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
2 changes: 2 additions & 0 deletions api/background/background-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
ApiResponseSchema,
} from "@/api/message";
import { ethereumRequestHandler } from "@/api/background/handlers/ethereum/request";
import { signMessageHandler } from "@/api/background/handlers/signMessage";

export class BackgroundService {
public listen(): void {
Expand Down Expand Up @@ -57,6 +58,7 @@ export class BackgroundService {
[Action.SIGN_AND_BROADCAST_TX]: signAndBroadcastTxHandler,
[Action.SIGN_TX]: signTxHandler,
[Action.ETHEREUM_REQUEST]: ethereumRequestHandler,
[Action.SIGN_MESSAGE]: signMessageHandler,
};

return handlers[action];
Expand Down
59 changes: 59 additions & 0 deletions api/background/handlers/signMessage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Handler } from "@/api/background/utils";
import {
ApiRequestWithHost,
ApiResponse,

Check warning on line 4 in api/background/handlers/signMessage.ts

View workflow job for this annotation

GitHub Actions / submit

'ApiResponse' is defined but never used
SignMessagePayloadSchema,
} from "@/api/message";
import { ApiUtils } from "@/api/background/utils";

/** signMessageHandler to serve BrowserMessageType.SIGN_MESSAGE message */
export const signMessageHandler: Handler = async (
tabId: number,
message: ApiRequestWithHost,
sendResponse: any,
) => {
if (!message.host) {
sendResponse(
ApiUtils.createApiResponse(message.id, null, "Host is required"),
);
return;
}

// Check if extension is initialized
if (!(await ApiUtils.isInitialized())) {
sendResponse(
ApiUtils.createApiResponse(
message.id,
null,
"Extension is not initialized",
),
);
return;
}

// Check if host is connected, if not, return error
if (!(await ApiUtils.isHostConnected(message.host))) {
sendResponse(
ApiUtils.createApiResponse(message.id, null, "Host not connected"),
);
return;
}

const result = SignMessagePayloadSchema.safeParse(message.payload);
if (!result.success) {
sendResponse(
ApiUtils.createApiResponse(message.id, null, "Invalid transaction data"),
);
return;
}

const url = new URL(browser.runtime.getURL("/popup.html"));
url.hash = "/sign-message";
url.searchParams.set("requestId", message.id);
url.searchParams.set("payload", JSON.stringify(result.data));

ApiUtils.openPopup(tabId, url.toString());

const response = await ApiUtils.receiveExtensionMessage(message.id);
sendResponse(response);
};
15 changes: 15 additions & 0 deletions api/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
ApiResponseSchema,
ConnectPayloadSchema,
SignTxPayloadSchema,
SignMessagePayloadSchema,
} from "@/api/message";
import { ScriptOption } from "@/lib/wallet/wallet-interface.ts";
import { EthereumBrowserAPI } from "./ethereum";
Expand Down Expand Up @@ -103,6 +104,20 @@ export class KastleBrowserAPI {
return await this.receiveMessageWithTimeout(requestId);
}

async signMessage(message: string): Promise<string> {
const requestId = uuid();
const request = createApiRequest(
Action.SIGN_MESSAGE,
requestId,
SignMessagePayloadSchema.parse({
message,
}),
);
window.postMessage(request, "*");

return await this.receiveMessageWithTimeout(requestId);
}

private createReceiveCallback<T>(id: string) {
return (event: MessageEvent<unknown>) => {
if (event.origin !== window.location.origin) {
Expand Down
13 changes: 13 additions & 0 deletions api/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,19 @@ export enum Action {
SIGN_AND_BROADCAST_TX,
SIGN_TX,
ETHEREUM_REQUEST,
SIGN_MESSAGE,
}

// ================================================================================================

export const SignMessagePayloadSchema = z.object({
Copy link

Choose a reason for hiding this comment

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

This file is growing with schema. Maybe we can put them in the handlers directly.

You can also have schema directly inside a function (scope).

message: z.string(),
});

export type SignMessagePayload = z.infer<typeof SignMessagePayloadSchema>;

// ================================================================================================

export const SignTxPayloadSchema = z.object({
networkId: z.string(),
txJson: z.string(),
Expand All @@ -25,6 +36,8 @@ export const RpcRequestSchema = z.object({
params: z.array(z.unknown()).optional(),
});

// ================================================================================================

export type RpcRequest = z.infer<typeof RpcRequestSchema>;

export const RpcErrorSchema = z.object({
Expand Down
35 changes: 35 additions & 0 deletions components/screens/browser-api/SignMessageConfirm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { SignMessagePayload } from "@/api/message";
import HotWalletSignMessage from "@/components/screens/browser-api/sign-message/HotWalletSignMessage";
import LedgerSignMessage from "@/components/screens/browser-api/sign-message/LedgerSignMessage";
import useWalletManager from "@/hooks/useWalletManager.ts";
import { useEffect } from "react";
import { ApiExtensionUtils } from "@/api/extension";
import { ApiResponse } from "@/api/message";
import Splash from "@/components/screens/Splash";

export default function SignMessageConfirm() {
const { wallet } = useWalletManager();
const requestId =
new URLSearchParams(window.location.search).get("requestId") ?? "";
const encodedPayload = new URLSearchParams(window.location.search).get(
"payload",
);

const payload = encodedPayload
? JSON.parse(decodeURIComponent(encodedPayload))
: null;

const loading = !wallet || !requestId || !payload;

return (
<div className="h-screen p-4">
{loading && <Splash />}
{!loading && wallet.type !== "ledger" && (
<HotWalletSignMessage requestId={requestId} payload={payload} />
)}
{!loading && wallet.type === "ledger" && (
<LedgerSignMessage requestId={requestId} payload={payload} />
)}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { SignMessagePayload } from "@/api/message";
import SignMessage from "@/components/screens/browser-api/sign-message/SignMessage";
import { IWallet } from "@/lib/wallet/wallet-interface.ts";
import { AccountFactory } from "@/lib/wallet/wallet-factory";
import useWalletManager from "@/hooks/useWalletManager.ts";
import useRpcClientStateful from "@/hooks/useRpcClientStateful";
import Splash from "@/components/screens/Splash.tsx";

type HotWalletSignMessageProps = {
requestId: string;
payload: SignMessagePayload;
};

export default function HotWalletSignMessage({
requestId,
payload,
}: HotWalletSignMessageProps) {
const { getWalletSecret } = useKeyring();
const { wallet: walletInfo, account } = useWalletManager();
const { rpcClient, networkId: rpcNetworkId } = useRpcClientStateful();
const [wallet, setWallet] = useState<IWallet>();
const loading =
!rpcClient || !wallet || !walletInfo || !account || !rpcNetworkId;

useEffect(() => {
if (!rpcClient || !walletInfo || !rpcNetworkId || !account) return;
Copy link

Choose a reason for hiding this comment

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

Why is account missing here? can do !loading?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think you are mentioning wallet.
It is for setting IWallet, so unluckily, so we can't check wallet here, or the wallet would never be initialized.

if (walletInfo.type !== "mnemonic" && walletInfo.type !== "privateKey") {
throw new Error("Unsupported wallet type");
}

getWalletSecret({ walletId: walletInfo.id }).then(({ walletSecret }) => {
const factory = new AccountFactory(rpcClient, rpcNetworkId);
Copy link

Choose a reason for hiding this comment

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

I really think we should try to bubble up this wallet instantiation in the WalletManagerContext. Despite the complexity, it will remove code outside and inside the context.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Due to compatibility issues between Ledger and EVM-compatible features, we've decided to postpone this improvement for now.


switch (walletInfo.type) {
case "mnemonic":
setWallet(
factory.createFromMnemonic(walletSecret.value, account.index),
);
break;
case "privateKey":
setWallet(factory.createFromPrivateKey(walletSecret.value));
break;
}
});
}, [rpcClient, walletInfo, account]);

return (
<>
{loading && <Splash />}
{!loading && (
<SignMessage
walletSigner={wallet}
requestId={requestId}
message={payload.message}
/>
)}
</>
);
}
44 changes: 44 additions & 0 deletions components/screens/browser-api/sign-message/LedgerSignMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { SignMessagePayload } from "@/api/message";
import { AccountFactory } from "@/lib/wallet/wallet-factory";
import useRpcClientStateful from "@/hooks/useRpcClientStateful";
import { NetworkType } from "@/contexts/SettingsContext";
import Splash from "@/components/screens/Splash";
import LedgerConnectForSign from "@/components/screens/ledger-connect/LedgerConnectForSign";
import SignMessage from "@/components/screens/browser-api/sign-message/SignMessage";

type LedgerSignTxProps = {
requestId: string;
payload: SignMessagePayload;
};

export default function LedgerSignMessage({
requestId,
payload,
}: LedgerSignTxProps) {
const { transport, isAppOpen } = useLedgerTransport();
const { rpcClient, networkId } = useRpcClientStateful();

const wallet =
rpcClient && transport
? new AccountFactory(
rpcClient,
networkId ?? ("mainnet" as NetworkType),
).createFromLedger(transport)
: null;

return (
<>
{(!transport || !isAppOpen) && (
<LedgerConnectForSign showClose={false} showPrevious={false} />
)}
{transport && isAppOpen && !wallet && <Splash />}
{wallet && isAppOpen && (
<SignMessage
walletSigner={wallet}
requestId={requestId}
message={payload.message}
/>
)}
</>
);
}
112 changes: 112 additions & 0 deletions components/screens/browser-api/sign-message/SignMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import useWalletManager from "@/hooks/useWalletManager";
import ledgerSignImage from "@/assets/images/ledger-on-sign.svg";
import signImage from "@/assets/images/sign.png";
import Header from "@/components/GeneralHeader";
import { useBoolean } from "usehooks-ts";
import { IWallet } from "@/lib/wallet/wallet-interface";
import { ApiExtensionUtils } from "@/api/extension";
import { ApiUtils } from "@/api/background/utils";

type SignMessageProps = {
requestId: string;
walletSigner: IWallet;
message: string;
};

export default function SignMessage({
requestId,
walletSigner,
message,
}: SignMessageProps) {
const { wallet } = useWalletManager();
const { value: isSigning, toggle: toggleIsSigning } = useBoolean(false);

const onConfirm = async () => {
if (isSigning) {
return;
}

toggleIsSigning();
try {
// Sign the message
const signed = await walletSigner.signMessage(message);
await ApiExtensionUtils.sendMessage(
requestId,
ApiUtils.createApiResponse(requestId, signed),
);
toggleIsSigning();
} catch (err) {
await ApiExtensionUtils.sendMessage(
requestId,
ApiUtils.createApiResponse(
requestId,
null,
"Failed to sign message: " + (err as any).toString(),
),
);
} finally {
window.close();
}
};

const cancel = async () => {
await ApiExtensionUtils.sendMessage(
requestId,
ApiUtils.createApiResponse(requestId, null, "User cancelled"),
);
window.close();
};

return (
<div className="flex h-full flex-col justify-between">
<div>
<Header showPrevious={false} showClose={false} title="Confirm" />
<div className="relative">
{wallet?.type !== "ledger" && (
<img src={signImage} alt="Sign" className="mx-auto" />
)}
{wallet?.type === "ledger" && (
<img src={ledgerSignImage} alt="Sign" className="mx-auto" />
)}
</div>

{/* Confirm Content */}
<div className="text-center">
<h2 className="mt-4 text-2xl font-semibold">Sign Message</h2>
<p className="mt-2 text-base text-daintree-400">
Please confirm the message you are signing
</p>
<div className="mt-4 break-words rounded-md bg-daintree-700 p-4">
<p className="text-start text-sm">{message}</p>
</div>
</div>
</div>

{/* Buttons */}
<div className="flex gap-2 text-base font-semibold">
<button className="rounded-full p-5 text-[#7B9AAA]" onClick={cancel}>
Cancel
</button>
<button
className="flex flex-auto items-center justify-center rounded-full bg-icy-blue-400 py-5 font-semibold hover:bg-icy-blue-600"
onClick={onConfirm}
>
{isSigning ? (
<div className="flex gap-2">
<div
className="inline-block size-5 animate-spin self-center rounded-full border-[3px] border-current border-t-[#A2F5FF] text-icy-blue-600"
role="status"
aria-label="loading"
/>
{wallet?.type === "ledger" && (
<span className="text-sm">Please approve on Ledger</span>
)}
</div>
) : (
`Confirm`
)}
</button>
</div>
</div>
);
}
3 changes: 1 addition & 2 deletions components/screens/browser-api/sign-tx/SignTx.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,7 @@ export default function SignTx({ wallet, requestId, payload }: SignTxProps) {
ApiUtils.createApiResponse(
requestId,
null,
"Failed to sign and broadcast transaction: " +
(err as any).toString(),
"Failed to sign transaction: " + (err as any).toString(),
),
);
} finally {
Expand Down
Loading
Loading