Skip to content

Commit

Permalink
feat: add wallet-popup (#1904)
Browse files Browse the repository at this point in the history
* feat: add wallet-popup

* feat: add DecodedCallData component
  • Loading branch information
kratico committed Feb 29, 2024
1 parent b649e9b commit 3bf56b4
Show file tree
Hide file tree
Showing 14 changed files with 273 additions and 7 deletions.
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 23 additions & 0 deletions projects/wallet-template/assets/wallet-popup.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<!doctype html>
<html>
<head>
<title>Substrate Connect</title>
<meta charset="utf-8" />
<link
href="https://fonts.googleapis.com/css2?family=Poppins:ital@0;1&display=swap"
rel="stylesheet"
/>
<link
href="http://fonts.googleapis.com/css?family=Roboto:400,100,100italic,300, 500,700,900"
rel="stylesheet"
/>
<link
href="http://fonts.googleapis.com/css?family=Inter:400,100,100italic,300, 500,700,900"
rel="stylesheet"
/>
</head>
<body>
<div id="popup"></div>
<script type="module" src="../src/wallet-popup.tsx"></script>
</body>
</html>
1 change: 1 addition & 0 deletions projects/wallet-template/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
85 changes: 80 additions & 5 deletions projects/wallet-template/src/background/createBackgroundRpc.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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)
Expand All @@ -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<string, InternalSignRequest>
port: chrome.runtime.Port
}
const handlers: RpcMethodHandlers<BackgroundRpcSpec, Context> = {
async getAccounts([chainId], { lightClientPageHelper }) {
const chains = await lightClientPageHelper.getChains()
Expand All @@ -40,15 +53,51 @@ 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")
const keypair = keypairs.find(
({ 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<void>(
(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) => {
Expand All @@ -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),
})
Expand All @@ -82,6 +132,31 @@ export const createBackgroundRpc = (
txCreator.destroy()
return tx
},
async getSignRequests([], { signRequests }) {

Check warning on line 135 in projects/wallet-template/src/background/createBackgroundRpc.ts

View workflow job for this annotation

GitHub Actions / build (18.x)

Unexpected empty array pattern

Check warning on line 135 in projects/wallet-template/src/background/createBackgroundRpc.ts

View workflow job for this annotation

GitHub Actions / build (20.x)

Unexpected empty array pattern

Check warning on line 135 in projects/wallet-template/src/background/createBackgroundRpc.ts

View workflow job for this annotation

GitHub Actions / playwright-test-wallet-template

Unexpected empty array pattern

Check warning on line 135 in projects/wallet-template/src/background/createBackgroundRpc.ts

View workflow job for this annotation

GitHub Actions / playwright-test-examples

Unexpected empty array pattern

Check warning on line 135 in projects/wallet-template/src/background/createBackgroundRpc.ts

View workflow job for this annotation

GitHub Actions / connect-flaky-tests

Unexpected empty array pattern

Check warning on line 135 in projects/wallet-template/src/background/createBackgroundRpc.ts

View workflow job for this annotation

GitHub Actions / npm-publish

Unexpected empty array pattern

Check warning on line 135 in projects/wallet-template/src/background/createBackgroundRpc.ts

View workflow job for this annotation

GitHub Actions / playwright-test-extension

Unexpected empty array pattern

Check warning on line 135 in projects/wallet-template/src/background/createBackgroundRpc.ts

View workflow job for this annotation

GitHub Actions / upload-extension-artifacts

Unexpected empty array pattern

Check warning on line 135 in projects/wallet-template/src/background/createBackgroundRpc.ts

View workflow job for this annotation

GitHub Actions / zombienet-tests

Unexpected empty array pattern
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<Context> = 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])
}
10 changes: 8 additions & 2 deletions projects/wallet-template/src/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
)
})
11 changes: 11 additions & 0 deletions projects/wallet-template/src/background/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Account[]>
createTx(chainId: string, from: string, callData: string): Promise<string>
// private methods
getSignRequests(): Promise<Record<string, SignRequest>>
approveSignRequest(id: string): Promise<void>
cancelSignRequest(id: string): Promise<void>
}
82 changes: 82 additions & 0 deletions projects/wallet-template/src/containers/WalletPopup.tsx
Original file line number Diff line number Diff line change
@@ -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<BackgroundRpcSpec>()
port.onMessage.addListener(rpc.handle)

export const WalletPopup = () => {
const [signRequest, setSignRequest] = useState<SignRequest>()
const [signRequestId, setSignRequestId] = useState<string>()
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 (
<main className="w-[32rem] mx-auto px-6 py-8">
<div>
<h1 className="text-3xl font-bold">Sign Request #{signRequestId}</h1>
<div className="my-4">
<div className="my-2">
<div className="text-sm font-semibold">Origin</div>
<div>{signRequest.url}</div>
</div>
<div className="my-2 overflow-hidden">
<div className="text-sm font-semibold">Chain Id</div>
<pre className="overflow-auto">{signRequest.chainId}</pre>
</div>
<div className="my-2">
<div className="text-sm font-semibold">From</div>
<pre>{signRequest.address}</pre>
</div>
<div className="my-2 overflow-hidden">
<div className="text-sm font-semibold">Call data</div>
<pre className="overflow-auto">{signRequest.callData}</pre>
</div>
<div className="my-2">
<div className="text-sm font-semibold">Decoded Call data</div>
<DecodedCallData
chainId={signRequest.chainId}
callData={signRequest.callData}
/>
</div>
<div className="my-2">
<div className="text-sm font-semibold">Signed extensions</div>
<div>coming soon...</div>
</div>
</div>
<div className="my-4 text-center">
<button
onClick={() => rpc.client.approveSignRequest(signRequestId)}
className="py-1.5 px-8 mr-4 text-sm rounded border border-[#24cc85] text-[#24cc85] hover:text-white
hover:bg-[#24cc85]"
>
Approve
</button>
<button
onClick={() => rpc.client.cancelSignRequest(signRequestId)}
className="py-1.5 px-8 text-sm rounded border border-[#24cc85] text-[#24cc85] hover:text-white
hover:bg-[#24cc85]"
>
Cancel
</button>
</div>
</div>
</main>
)
}
Original file line number Diff line number Diff line change
@@ -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 <div>decoding...</div>
return (
<div>
<div className="text-sm">Pallet: {decodedCallData.pallet.value.name}</div>
<div className="text-sm">Call: {decodedCallData.call.value.name}</div>
<div className="text-sm">Args</div>
<pre>{jsonStringify(decodedCallData?.args.value)}</pre>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./DecodedCallData"
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./useDecodedCallData"
Original file line number Diff line number Diff line change
@@ -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<DecodedCall>()
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])

Check warning on line 27 in projects/wallet-template/src/containers/WalletPopup/hooks/useDecodedCallData.ts

View workflow job for this annotation

GitHub Actions / build (18.x)

React Hook useEffect has a missing dependency: 'isMounted'. Either include it or remove the dependency array

Check warning on line 27 in projects/wallet-template/src/containers/WalletPopup/hooks/useDecodedCallData.ts

View workflow job for this annotation

GitHub Actions / build (20.x)

React Hook useEffect has a missing dependency: 'isMounted'. Either include it or remove the dependency array

Check warning on line 27 in projects/wallet-template/src/containers/WalletPopup/hooks/useDecodedCallData.ts

View workflow job for this annotation

GitHub Actions / playwright-test-wallet-template

React Hook useEffect has a missing dependency: 'isMounted'. Either include it or remove the dependency array

Check warning on line 27 in projects/wallet-template/src/containers/WalletPopup/hooks/useDecodedCallData.ts

View workflow job for this annotation

GitHub Actions / playwright-test-examples

React Hook useEffect has a missing dependency: 'isMounted'. Either include it or remove the dependency array

Check warning on line 27 in projects/wallet-template/src/containers/WalletPopup/hooks/useDecodedCallData.ts

View workflow job for this annotation

GitHub Actions / connect-flaky-tests

React Hook useEffect has a missing dependency: 'isMounted'. Either include it or remove the dependency array

Check warning on line 27 in projects/wallet-template/src/containers/WalletPopup/hooks/useDecodedCallData.ts

View workflow job for this annotation

GitHub Actions / npm-publish

React Hook useEffect has a missing dependency: 'isMounted'. Either include it or remove the dependency array

Check warning on line 27 in projects/wallet-template/src/containers/WalletPopup/hooks/useDecodedCallData.ts

View workflow job for this annotation

GitHub Actions / playwright-test-extension

React Hook useEffect has a missing dependency: 'isMounted'. Either include it or remove the dependency array

Check warning on line 27 in projects/wallet-template/src/containers/WalletPopup/hooks/useDecodedCallData.ts

View workflow job for this annotation

GitHub Actions / upload-extension-artifacts

React Hook useEffect has a missing dependency: 'isMounted'. Either include it or remove the dependency array

Check warning on line 27 in projects/wallet-template/src/containers/WalletPopup/hooks/useDecodedCallData.ts

View workflow job for this annotation

GitHub Actions / zombienet-tests

React Hook useEffect has a missing dependency: 'isMounted'. Either include it or remove the dependency array

return { decodedCallData }
}
1 change: 1 addition & 0 deletions projects/wallet-template/src/containers/index.tsx
Original file line number Diff line number Diff line change
@@ -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"
5 changes: 5 additions & 0 deletions projects/wallet-template/src/wallet-popup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { render } from "react-dom"
import { WalletPopup } from "./containers"
import "./style.css"

render(<WalletPopup />, document.getElementById("popup"))
1 change: 1 addition & 0 deletions projects/wallet-template/vite.ui.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export default defineConfig(({ mode }) => ({
input: {
popup: "assets/popup.html",
options: "assets/options.html",
walletPopup: "assets/wallet-popup.html",
},
},
},
Expand Down

0 comments on commit 3bf56b4

Please sign in to comment.