Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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 @@ -2,6 +2,7 @@ import { connectHandler } from "@/api/background/handlers/connect";
import { getAccountHandler } from "@/api/background/handlers/getAccount";
import { signAndBroadcastTxHandler } from "@/api/background/handlers/signAndBroadcastTx";
import { signTxHandler } from "@/api/background/handlers/signTx";
import { signMessageHandler } from "@/api/background/handlers/signMessage";
import { Action, ApiRequest, ApiResponse } from "@/api/message";

export class BackgroundService {
Expand Down Expand Up @@ -48,6 +49,7 @@ export class BackgroundService {
[Action.GET_ACCOUNT]: getAccountHandler,
[Action.SIGN_AND_BROADCAST_TX]: signAndBroadcastTxHandler,
[Action.SIGN_TX]: signTxHandler,
[Action.SIGN_MESSAGE]: signMessageHandler,
};

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

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

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

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

if (!SignMessagePayload.validate(message.payload)) {
sendResponse(new ApiResponse(message.id, null, "Invalid payload"));
return;
}

// Reconstruct SignMessagePayload from serialized message data to restore methods
const payload = Object.assign(new SignMessagePayload(""), message.payload);

const url = browser.runtime.getURL(
`/popup.html?requestId=${encodeURIComponent(message.id)}&payload=${payload.toUriString()}#/sign-message`,
);

ApiUtils.openPopup(tabId, url);

const response = await ApiUtils.receiveExtensionMessage(message.id);
sendResponse(response);
};
13 changes: 13 additions & 0 deletions api/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
ApiRequest,
ApiResponse,
ConnectPayload,
SignMessagePayload,
SignTxPayload,
} from "@/api/message";
import { ScriptOption } from "@/lib/wallet/wallet-interface.ts";
Expand Down Expand Up @@ -76,6 +77,18 @@ export class KastleBrowserAPI {
return await this.receiveMessage(requestId);
}

async signMessage(message: string): Promise<string> {
const requestId = uuid();
const request = new ApiRequest(
Action.SIGN_MESSAGE,
requestId,
new SignMessagePayload(message),
);
window.postMessage(request, "*");

return await this.receiveMessage(requestId);
}

private async receiveMessage<T>(
id: string,
timeout = 60_000, // 1 minute
Expand Down
19 changes: 19 additions & 0 deletions api/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export enum Action {
GET_ACCOUNT,
SIGN_AND_BROADCAST_TX,
SIGN_TX,
SIGN_MESSAGE,
}

export class SignTxPayload {
Expand Down Expand Up @@ -36,6 +37,24 @@ export class SignTxPayload {

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

export class SignMessagePayload {
constructor(public readonly message: string) {}

static validate(data: unknown): data is SignMessagePayload {
return typeof data === "object" && !!data && "message" in data;
}

static fromUriString(uriComponent: string): SignMessagePayload {
return new SignMessagePayload(decodeURIComponent(uriComponent));
}

toUriString(): string {
return encodeURIComponent(this.message);
}
}

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

export class ConnectPayload {
constructor(
public readonly name: string,
Expand Down
49 changes: 49 additions & 0 deletions components/screens/browser-api/SignMessageConfirm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
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
? SignMessagePayload.fromUriString(encodedPayload)
: null;

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

useEffect(() => {
// Handle beforeunload event
async function beforeunload(event: BeforeUnloadEvent) {

Check warning on line 26 in components/screens/browser-api/SignMessageConfirm.tsx

View workflow job for this annotation

GitHub Actions / submit

'event' is defined but never used
const denyMessage = new ApiResponse(requestId, false, "User denied");
await ApiExtensionUtils.sendMessage(requestId, denyMessage);
}

window.addEventListener("beforeunload", beforeunload);

return () => {
window.removeEventListener("beforeunload", beforeunload);
};
}, []);

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 { ApiResponse } from "@/api/message";

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,
new ApiResponse(requestId, signed),
);
toggleIsSigning();
} catch (err) {
await ApiExtensionUtils.sendMessage(
requestId,
new ApiResponse(
requestId,
null,
"Failed to sign message: " + (err as any).toString(),
),
);
} finally {
window.close();
}
};

const cancel = async () => {
await ApiExtensionUtils.sendMessage(
requestId,
new ApiResponse(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>
);
}
Loading
Loading