diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8fa71f4b2..1492f5ed9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -456,6 +456,9 @@ importers: '@polkadot-api/client': specifier: 0.0.1-86c37582599a5aaa7c2dde1d765f8af5ef6def2a.1.0 version: 0.0.1-86c37582599a5aaa7c2dde1d765f8af5ef6def2a.1.0(rxjs@7.8.1) + '@polkadot-api/metadata-builders': + specifier: 0.0.1-86c37582599a5aaa7c2dde1d765f8af5ef6def2a.1.0 + version: 0.0.1-86c37582599a5aaa7c2dde1d765f8af5ef6def2a.1.0 '@polkadot-api/substrate-client': specifier: 0.0.1-86c37582599a5aaa7c2dde1d765f8af5ef6def2a.1.0 version: 0.0.1-86c37582599a5aaa7c2dde1d765f8af5ef6def2a.1.0 diff --git a/projects/wallet-template/assets/wallet-popup.html b/projects/wallet-template/assets/wallet-popup.html new file mode 100644 index 000000000..6f2698bdb --- /dev/null +++ b/projects/wallet-template/assets/wallet-popup.html @@ -0,0 +1,23 @@ + + + + Substrate Connect + + + + + + + + + + diff --git a/projects/wallet-template/package.json b/projects/wallet-template/package.json index 2a7e3e5fc..72712a60c 100644 --- a/projects/wallet-template/package.json +++ b/projects/wallet-template/package.json @@ -55,6 +55,7 @@ }, "dependencies": { "@polkadot-api/client": "0.0.1-86c37582599a5aaa7c2dde1d765f8af5ef6def2a.1.0", + "@polkadot-api/metadata-builders": "0.0.1-86c37582599a5aaa7c2dde1d765f8af5ef6def2a.1.0", "@polkadot-api/tx-helper": "0.0.1-86c37582599a5aaa7c2dde1d765f8af5ef6def2a.1.0", "@polkadot-api/substrate-client": "0.0.1-86c37582599a5aaa7c2dde1d765f8af5ef6def2a.1.0", "@polkadot-api/utils": "0.0.1-86c37582599a5aaa7c2dde1d765f8af5ef6def2a.1.0", diff --git a/projects/wallet-template/src/background/createBackgroundRpc.ts b/projects/wallet-template/src/background/createBackgroundRpc.ts index fba050ceb..255919856 100644 --- a/projects/wallet-template/src/background/createBackgroundRpc.ts +++ b/projects/wallet-template/src/background/createBackgroundRpc.ts @@ -1,7 +1,9 @@ import { type RpcMethodHandlers, type RpcMessage, + type RpcMethodMiddleware, createRpc, + RpcError, } from "@substrate/light-client-extension-helpers/utils" import type { LightClientPageHelper } from "@substrate/light-client-extension-helpers/background" import { sr25519CreateDerive } from "@polkadot-labs/hdkd" @@ -14,7 +16,7 @@ import { import { toHex, fromHex } from "@polkadot-api/utils" import { UserSignedExtensions, getTxCreator } from "@polkadot-api/tx-helper" -import type { BackgroundRpcSpec } from "./types" +import type { BackgroundRpcSpec, SignRequest } from "./types" const entropy = mnemonicToEntropy(DEV_PHRASE) const miniSecret = entropyToMiniSecret(entropy) @@ -27,10 +29,21 @@ const keypairs = [ derive("//westend//2"), ] +type InternalSignRequest = { + resolve: () => void + reject: (reason?: any) => void +} & SignRequest + +let nextSignRequestId = 0 + export const createBackgroundRpc = ( sendMessage: (message: RpcMessage) => void, ) => { - type Context = { lightClientPageHelper: LightClientPageHelper } + type Context = { + lightClientPageHelper: LightClientPageHelper + signRequests: Record + port: chrome.runtime.Port + } const handlers: RpcMethodHandlers = { async getAccounts([chainId], { lightClientPageHelper }) { const chains = await lightClientPageHelper.getChains() @@ -40,7 +53,12 @@ export const createBackgroundRpc = ( address: ss58Address(publicKey, chain.ss58Format), })) }, - async createTx([chainId, from, callData], { lightClientPageHelper }) { + async createTx( + [chainId, from, callData], + { lightClientPageHelper, signRequests, port }, + ) { + const url = port.sender?.url + if (!url) throw new Error("unknown url") const chains = await lightClientPageHelper.getChains() const chain = chains.find(({ genesisHash }) => genesisHash === chainId) if (!chain) throw new Error("unknown chain") @@ -48,7 +66,38 @@ export const createBackgroundRpc = ( ({ publicKey }) => toHex(publicKey) === from, ) if (!keypair) throw new Error("unknown account") - // FIXME: trigger prompt to show the decoded transaction details + + const id = nextSignRequestId++ + const signRequest = new Promise( + (resolve, reject) => + (signRequests[id] = { + resolve, + reject, + chainId, + url, + address: ss58Address(from, chain.ss58Format), + callData, + }), + ) + + const window = await chrome.windows.create({ + focused: true, + height: 600, + left: 150, + top: 150, + type: "popup", + url: chrome.runtime.getURL( + `ui/assets/wallet-popup.html#signRequest/${id}`, + ), + width: 560, + }) + try { + await signRequest + } finally { + delete signRequests[id] + chrome.windows.remove(window.id!) + } + const txCreator = getTxCreator( chain.provider, ({ userSingedExtensionsName }, callback) => { @@ -71,6 +120,7 @@ export const createBackgroundRpc = ( callback({ userSignedExtensionsData, overrides: {}, + // FIXME: this should be inferred from the keypair signature scheme signingType: "Sr25519", signer: async (value) => keypair.sign(value), }) @@ -82,6 +132,31 @@ export const createBackgroundRpc = ( txCreator.destroy() return tx }, + async getSignRequests([], { signRequests }) { + return signRequests + }, + async approveSignRequest([id], { signRequests }) { + signRequests[id]?.resolve() + }, + async cancelSignRequest([id], { signRequests }) { + signRequests[id]?.reject() + }, + } + + type Method = keyof BackgroundRpcSpec + const ALLOWED_WEB_METHODS: Method[] = ["createTx", "getAccounts"] + const allowedMethodsMiddleware: RpcMethodMiddleware = async ( + next, + request, + context, + ) => { + const { port } = context + if ( + port.name === "substrate-wallet-template" && + !ALLOWED_WEB_METHODS.includes(request.method as Method) + ) + throw new RpcError("Method not found", -32601) + return next(request, context) } - return createRpc(sendMessage, handlers) + return createRpc(sendMessage, handlers, [allowedMethodsMiddleware]) } diff --git a/projects/wallet-template/src/background/index.ts b/projects/wallet-template/src/background/index.ts index 7ab66ec93..d2516c89a 100644 --- a/projects/wallet-template/src/background/index.ts +++ b/projects/wallet-template/src/background/index.ts @@ -24,10 +24,16 @@ const { lightClientPageHelper } = register({ ), }) +const signRequests = {} + chrome.runtime.onConnect.addListener((port) => { - if (port.name !== "substrate-wallet-template") return + if (!port.name.startsWith("substrate-wallet-template")) return const rpc = createBackgroundRpc((msg) => port.postMessage(msg)) port.onMessage.addListener((msg) => - rpc.handle(msg, { lightClientPageHelper }), + rpc.handle(msg, { + lightClientPageHelper, + signRequests, + port, + }), ) }) diff --git a/projects/wallet-template/src/background/types.ts b/projects/wallet-template/src/background/types.ts index cfa3ce648..bca90a6d6 100644 --- a/projects/wallet-template/src/background/types.ts +++ b/projects/wallet-template/src/background/types.ts @@ -2,7 +2,18 @@ export type Account = { address: string } +export type SignRequest = { + url: string + chainId: string + address: string + callData: string +} + export type BackgroundRpcSpec = { getAccounts(chainId: string): Promise createTx(chainId: string, from: string, callData: string): Promise + // private methods + getSignRequests(): Promise> + approveSignRequest(id: string): Promise + cancelSignRequest(id: string): Promise } diff --git a/projects/wallet-template/src/containers/WalletPopup.tsx b/projects/wallet-template/src/containers/WalletPopup.tsx new file mode 100644 index 000000000..a711af88f --- /dev/null +++ b/projects/wallet-template/src/containers/WalletPopup.tsx @@ -0,0 +1,82 @@ +import { createRpc } from "@substrate/light-client-extension-helpers/utils" +import { useEffect, useState } from "react" +import type { BackgroundRpcSpec, SignRequest } from "../background/types" +import { DecodedCallData } from "./WalletPopup/components" + +// FIXME: use hook +const port = chrome.runtime.connect({ + name: "substrate-wallet-template/wallet-popup", +}) +const rpc = createRpc((msg) => + port.postMessage(msg), +).withClient() +port.onMessage.addListener(rpc.handle) + +export const WalletPopup = () => { + const [signRequest, setSignRequest] = useState() + const [signRequestId, setSignRequestId] = useState() + useEffect(() => { + const init = async () => { + const signRequests = await rpc.client.getSignRequests() + // TODO: handle many signRequests + const keys = Object.keys(signRequests) + if (keys.length === 0) return + setSignRequestId(keys[0]) + setSignRequest(signRequests[keys[0]]) + } + init() + }, []) + if (!signRequestId || !signRequest) return null + return ( +
+
+

Sign Request #{signRequestId}

+
+
+
Origin
+
{signRequest.url}
+
+
+
Chain Id
+
{signRequest.chainId}
+
+
+
From
+
{signRequest.address}
+
+
+
Call data
+
{signRequest.callData}
+
+
+
Decoded Call data
+ +
+
+
Signed extensions
+
coming soon...
+
+
+
+ + +
+
+
+ ) +} diff --git a/projects/wallet-template/src/containers/WalletPopup/components/DecodedCallData.tsx b/projects/wallet-template/src/containers/WalletPopup/components/DecodedCallData.tsx new file mode 100644 index 000000000..bfa8160bd --- /dev/null +++ b/projects/wallet-template/src/containers/WalletPopup/components/DecodedCallData.tsx @@ -0,0 +1,26 @@ +import { useDecodedCallData } from "../hooks" + +type Props = { + chainId: string + callData: string +} + +const jsonStringify = (value: any) => + JSON.stringify( + value, + (_, value) => (typeof value === "bigint" ? value.toString() : value), + 2, + ) + +export const DecodedCallData = ({ chainId, callData }: Props) => { + const { decodedCallData } = useDecodedCallData(chainId, callData) + if (!decodedCallData) return
decoding...
+ return ( +
+
Pallet: {decodedCallData.pallet.value.name}
+
Call: {decodedCallData.call.value.name}
+
Args
+
{jsonStringify(decodedCallData?.args.value)}
+
+ ) +} diff --git a/projects/wallet-template/src/containers/WalletPopup/components/index.ts b/projects/wallet-template/src/containers/WalletPopup/components/index.ts new file mode 100644 index 000000000..acb187d12 --- /dev/null +++ b/projects/wallet-template/src/containers/WalletPopup/components/index.ts @@ -0,0 +1 @@ +export * from "./DecodedCallData" diff --git a/projects/wallet-template/src/containers/WalletPopup/hooks/index.ts b/projects/wallet-template/src/containers/WalletPopup/hooks/index.ts new file mode 100644 index 000000000..464cce4e0 --- /dev/null +++ b/projects/wallet-template/src/containers/WalletPopup/hooks/index.ts @@ -0,0 +1 @@ +export * from "./useDecodedCallData" diff --git a/projects/wallet-template/src/containers/WalletPopup/hooks/useDecodedCallData.ts b/projects/wallet-template/src/containers/WalletPopup/hooks/useDecodedCallData.ts new file mode 100644 index 000000000..ab54f7c45 --- /dev/null +++ b/projects/wallet-template/src/containers/WalletPopup/hooks/useDecodedCallData.ts @@ -0,0 +1,30 @@ +import { helper } from "@substrate/light-client-extension-helpers/extension-page" +import { getObservableClient } from "@polkadot-api/client" +import { createClient } from "@polkadot-api/substrate-client" +import { DecodedCall, getViewBuilder } from "@polkadot-api/metadata-builders" +import { useEffect, useState } from "react" +import { filter, firstValueFrom } from "rxjs" +import { useIsMounted } from "../../../hooks/useIsMounted" + +export const useDecodedCallData = (chainId: string, callData: string) => { + const [decodedCallData, setDecodedCallData] = useState() + const isMounted = useIsMounted() + + useEffect(() => { + if (!chainId || !callData) return + helper.getChains().then(async (chains) => { + const chain = chains.find(({ genesisHash }) => genesisHash === chainId) + if (!chain) return + const client = getObservableClient(createClient(chain.provider)) + const { metadata$, unfollow } = client.chainHead$() + const metadata = await firstValueFrom(metadata$.pipe(filter(Boolean))) + unfollow() + client.destroy() + const builder = getViewBuilder(metadata) + if (!isMounted()) return + setDecodedCallData(builder.callDecoder(callData)) + }) + }, [chainId, callData]) + + return { decodedCallData } +} diff --git a/projects/wallet-template/src/containers/index.tsx b/projects/wallet-template/src/containers/index.tsx index 71c1fd1a5..d86ca7c0c 100644 --- a/projects/wallet-template/src/containers/index.tsx +++ b/projects/wallet-template/src/containers/index.tsx @@ -1,3 +1,4 @@ export { Options } from "./Options" export { default as Popup } from "./Popup" export { default as Logo } from "../components/Logo" +export { WalletPopup } from "./WalletPopup" diff --git a/projects/wallet-template/src/wallet-popup.tsx b/projects/wallet-template/src/wallet-popup.tsx new file mode 100644 index 000000000..385be8ce4 --- /dev/null +++ b/projects/wallet-template/src/wallet-popup.tsx @@ -0,0 +1,5 @@ +import { render } from "react-dom" +import { WalletPopup } from "./containers" +import "./style.css" + +render(, document.getElementById("popup")) diff --git a/projects/wallet-template/vite.ui.config.js b/projects/wallet-template/vite.ui.config.js index 1cbd7f91c..85c0ed890 100644 --- a/projects/wallet-template/vite.ui.config.js +++ b/projects/wallet-template/vite.ui.config.js @@ -12,6 +12,7 @@ export default defineConfig(({ mode }) => ({ input: { popup: "assets/popup.html", options: "assets/options.html", + walletPopup: "assets/wallet-popup.html", }, }, },