diff --git a/.changeset/lazy-poets-return.md b/.changeset/lazy-poets-return.md new file mode 100644 index 000000000..4ca3f71cb --- /dev/null +++ b/.changeset/lazy-poets-return.md @@ -0,0 +1,41 @@ +--- +"@onflow/fcl-wc": patch +--- + +Adds additional options to `init` for `pairingModalOverride` and `wcRequestHook` + +```js +import * as fcl from '@onflow/fcl' +import { init } from '@onflow/fcl-wc' +// example using pairing data from wcRequestHook and providing a custom pairing modal +const { FclWcServicePlugin, client } = await init({ + projectId: PROJECT_ID, + metadata: PROJECT_METADATA, + includeBaseWC: false, + wallets: [], + wcRequestHook: (data: WcRequestData) => { + const peerMetadata = data?.pairing?.peerMetadata + setSessionRequestData(peerMetadata) + setShowRequestModal(true) + }, + pairingModalOverride: (uri: string = '', rejectPairingRequest: () => void) => { + openCustomPairingModal(uri) + // call rejectPairingRequest() to manually reject pairing request from client + } +}) + +fcl.pluginRegistry.add(FclWcServicePlugin) + +``` + +```ts + +interface WcRequestData { + type: string // 'session_request' | 'pairing_request' + session: SessionTypes.Struct | undefined // https://www.npmjs.com/package/@walletconnect/types + pairing: PairingTypes.Struct | undefined // https://www.npmjs.com/package/@walletconnect/types + method: string // "flow_authn" | "flow_authz" | "flow_user_sign" + uri: string | undefined +} + +``` diff --git a/packages/fcl-wc/src/constants.js b/packages/fcl-wc/src/constants.js new file mode 100644 index 000000000..cc39bdc99 --- /dev/null +++ b/packages/fcl-wc/src/constants.js @@ -0,0 +1,10 @@ +export const FLOW_METHODS = { + FLOW_AUTHN: "flow_authn", + FLOW_AUTHZ: "flow_authz", + FLOW_USER_SIGN: "flow_user_sign", +} + +export const REQUEST_TYPES = { + SESSION: "session_request", + PAIRING: "pairing_request", +} diff --git a/packages/fcl-wc/src/fcl-wc.js b/packages/fcl-wc/src/fcl-wc.js index 63ea8d31f..34a861ca9 100644 --- a/packages/fcl-wc/src/fcl-wc.js +++ b/packages/fcl-wc/src/fcl-wc.js @@ -1,13 +1,14 @@ +import * as fcl from "@onflow/fcl" import SignClient from "@walletconnect/sign-client" import {makeServicePlugin} from "./service" import {invariant} from "@onflow/util-invariant" import {LEVELS, log} from "@onflow/util-logger" -import * as fcl from "@onflow/fcl" export {getSdkError} from "@walletconnect/utils" import {setConfiguredNetwork} from "./utils" const DEFAULT_RELAY_URL = "wss://relay.walletconnect.com" const DEFAULT_LOGGER = "debug" +let client = null const initClient = async ({projectId, metadata}) => { invariant( @@ -15,12 +16,13 @@ const initClient = async ({projectId, metadata}) => { "FCL Wallet Connect Error: WalletConnect projectId is required" ) try { - return SignClient.init({ + client = await SignClient.init({ logger: DEFAULT_LOGGER, relayUrl: DEFAULT_RELAY_URL, projectId: projectId, metadata: metadata, }) + return client } catch (error) { log({ title: `${error.name} fcl-wc Init Client`, @@ -35,14 +37,17 @@ export const init = async ({ projectId, metadata, includeBaseWC = false, - sessionRequestHook = null, + wcRequestHook = null, + pairingModalOverride = null, wallets = [], } = {}) => { - const client = await initClient({projectId, metadata}) await setConfiguredNetwork() - const FclWcServicePlugin = await makeServicePlugin(client, { + const _client = client ?? (await initClient({projectId, metadata})) + const FclWcServicePlugin = await makeServicePlugin(_client, { + projectId, includeBaseWC, - sessionRequestHook, + wcRequestHook, + pairingModalOverride, wallets, }) fcl.discovery.authn.update() diff --git a/packages/fcl-wc/src/service.js b/packages/fcl-wc/src/service.js index 34ac7f001..81a71581f 100644 --- a/packages/fcl-wc/src/service.js +++ b/packages/fcl-wc/src/service.js @@ -2,6 +2,7 @@ import QRCodeModal from "@walletconnect/qrcode-modal" import {invariant} from "@onflow/util-invariant" import {log, LEVELS} from "@onflow/util-logger" import {fetchFlowWallets, isMobile, CONFIGURED_NETWORK} from "./utils" +import {FLOW_METHODS, REQUEST_TYPES} from "./constants" export const makeServicePlugin = async (client, opts = {}) => ({ name: "fcl-plugin-service-walletconnect", @@ -11,60 +12,55 @@ export const makeServicePlugin = async (client, opts = {}) => ({ serviceStrategy: {method: "WC/RPC", exec: makeExec(client, opts)}, }) -const makeExec = (client, {sessionRequestHook}) => { +const makeExec = (client, {wcRequestHook, pairingModalOverride}) => { return ({service, body, opts}) => { return new Promise(async (resolve, reject) => { invariant(client, "WalletConnect is not initialized") - let session - const onResponse = resp => { - try { - if (typeof resp !== "object") return - - switch (resp.status) { - case "APPROVED": - resolve(resp.data) - break - - case "DECLINED": - reject(`Declined: ${resp.reason || "No reason supplied"}`) - break - - case "REDIRECT": - resolve(resp) - break - - default: - reject(`Declined: No reason supplied`) - break - } - } catch (error) { - log({ - title: `${error.name} "WC/RPC onResponse error"`, - message: error.message, - level: LEVELS.error, - }) - throw error - } - } + let session, pairing + const appLink = service.uid + const method = service.endpoint - const onClose = () => { - reject(`Declined: Externally Halted`) + const pairings = client.pairing.getAll({active: true}) + if (pairings.length > 0) { + pairing = pairings?.find(p => p.peerMetadata?.url === service.uid) } - if (client.session.length) { + if (client.session.length > 0) { const lastKeyIndex = client.session.keys.length - 1 session = client.session.get(client.session.keys.at(lastKeyIndex)) } - if (session == null) { - const pairings = client.pairing.getAll({active: true}) - const pairing = pairings?.find(p => p.peerMetadata.url === service.uid) + if (session) { + if (isMobile()) window.location.href = appLink + log({ + title: "WalletConnect Request", + message: ` + Check your ${ + session?.peer?.metadata?.name || pairing?.peerMetadata?.name + } Mobile Wallet to Approve/Reject this request + `, + level: LEVELS.warn, + }) + if (wcRequestHook && wcRequestHook instanceof Function) { + wcRequestHook({ + type: REQUEST_TYPES.SESSION, + session, + pairing, + method, + uri: null, + }) + } + } - session = await connectWc(onClose, { - service, + if (session == null) { + session = await connectWc({ + onClose, + appLink, client, + method, pairing, - sessionRequestHook, + wcRequestHook, + pairingModalOverride, }) } @@ -74,7 +70,6 @@ const makeExec = (client, {sessionRequestHook}) => { .filter(account => account.startsWith("flow:"))[0] .split(":") - const method = service.endpoint const chainId = `${namespace}:${reference}` const addr = address const data = JSON.stringify({...body, addr}) @@ -89,67 +84,116 @@ const makeExec = (client, {sessionRequestHook}) => { }, }) onResponse(result) - } catch (e) { + } catch (error) { log({ - title: `${e.name} Error on WalletConnect client ${method} request`, - message: e.message, + title: `${error.name} Error on WalletConnect client ${method} request`, + message: error.message, level: LEVELS.error, }) reject(`Declined: Externally Halted`) } + + function onResponse(resp) { + try { + if (typeof resp !== "object") return + + switch (resp.status) { + case "APPROVED": + resolve(resp.data) + break + + case "DECLINED": + reject(`Declined: ${resp.reason || "No reason supplied"}`) + break + + case "REDIRECT": + resolve(resp) + break + + default: + reject(`Declined: No reason supplied`) + break + } + } catch (error) { + log({ + title: `${error.name} "WC/RPC onResponse error"`, + message: error.message, + level: LEVELS.error, + }) + throw error + } + } + + function onClose() { + reject(`Declined: Externally Halted`) + } }) } } -async function connectWc( +async function connectWc({ onClose, - {service, client, pairing, sessionRequestHook} -) { + appLink, + client, + method, + pairing, + wcRequestHook, + pairingModalOverride, +}) { try { const requiredNamespaces = { flow: { - methods: ["flow_authn", "flow_authz", "flow_user_sign"], + methods: [ + FLOW_METHODS.FLOW_AUTHN, + FLOW_METHODS.FLOW_AUTHZ, + FLOW_METHODS.FLOW_USER_SIGN, + ], chains: [`flow:${CONFIGURED_NETWORK}`], events: ["chainChanged", "accountsChanged"], }, } - const {uri, approval} = await client.connect({ pairingTopic: pairing?.topic, requiredNamespaces, }) - - const appLink = service.uid || pairing?.peerMetadata?.url - - if (!isMobile() && !pairing) { - QRCodeModal.open(uri, () => { - onClose() - }) - } else if (!isMobile() && pairing) { - log({ - title: "WalletConnect Session request", - message: ` - ${pairing.peerMetadata.name} - Pairing exists, Approve Session in your Mobile Wallet - `, - level: LEVELS.warn, + var _uri = uri + + if (wcRequestHook && wcRequestHook instanceof Function) { + wcRequestHook({ + type: REQUEST_TYPES.PAIRING, + session, + pairing, + method, + uri, }) - sessionRequestHook && sessionRequestHook(pairing.peerMetadata) - } else { + } + + if (isMobile()) { const queryString = new URLSearchParams({uri: uri}).toString() let url = pairing == null ? appLink + "?" + queryString : appLink - window.open(url, "blank").focus() + window.location.href = url + } else if (!pairing && uri) { + if (!pairingModalOverride) { + QRCodeModal.open(uri, () => { + onClose() + }) + } else { + pairingModalOverride(uri, onClose) + } } const session = await approval() return session - } catch (e) { + } catch (error) { log({ - title: `${e.name} "Error establishing Walletconnect session"`, - message: e.message, + title: `Error establishing Walletconnect session`, + message: `${error.message} + uri: ${_uri} + `, level: LEVELS.error, }) - throw e + onClose() + throw error } finally { QRCodeModal.close() } @@ -176,10 +220,9 @@ const baseWalletConnectService = includeBaseWC => { } } -async function makeWcServices({includeBaseWC, wallets}) { +async function makeWcServices({projectId, includeBaseWC, wallets}) { const wcBaseService = baseWalletConnectService(includeBaseWC) - const flowWcWalletServices = await fetchFlowWallets() - const injectedWalletsServices = - CONFIGURED_NETWORK === "testnet" ? wallets : [] - return [wcBaseService, ...flowWcWalletServices, ...injectedWalletsServices] + const flowWcWalletServices = (await fetchFlowWallets(projectId)) ?? [] + const injectedWalletServices = CONFIGURED_NETWORK === "testnet" ? wallets : [] + return [wcBaseService, ...flowWcWalletServices, ...injectedWalletServices] } diff --git a/packages/fcl-wc/src/utils.js b/packages/fcl-wc/src/utils.js index ac6a54a1f..842c0aaed 100644 --- a/packages/fcl-wc/src/utils.js +++ b/packages/fcl-wc/src/utils.js @@ -1,4 +1,4 @@ -import {log} from "@onflow/util-logger" +import {log, LEVELS} from "@onflow/util-logger" import {config} from "@onflow/config" import {invariant} from "@onflow/util-invariant" @@ -21,7 +21,7 @@ const makeFlowServicesFromWallets = wallets => { f_vsn: "1.0.0", type: "authn", method: "WC/RPC", - uid: wallet.mobile.universal, + uid: wallet.mobile?.universal, endpoint: "flow_authn", optIn: false, provider: { @@ -37,10 +37,10 @@ const makeFlowServicesFromWallets = wallets => { }) } -export async function fetchFlowWallets() { +export const fetchFlowWallets = async projectId => { try { const wcApiWallets = await fetch( - "https://explorer-api.walletconnect.com/v1/wallets?entries=5&page=1&search=flow" + `https://explorer-api.walletconnect.com/v3/wallets?projectId=${projectId}&chains=flow:${CONFIGURED_NETWORK}&entries=5&page=1` ).then(res => res.json()) if (wcApiWallets?.count > 0) { @@ -52,7 +52,7 @@ export async function fetchFlowWallets() { log({ title: `${error.name} Error fetching wallets from WalletConnect API`, message: error.message, - level: 1, + level: LEVELS.error, }) } }