diff --git a/src/extension/manifest.json b/src/extension/manifest.json index 3130607..345e6dd 100644 --- a/src/extension/manifest.json +++ b/src/extension/manifest.json @@ -3,8 +3,8 @@ "name": "__MSG_appName__", "description": "__MSG_appDesc__", "default_locale": "en", - "version": "1.4.0", - "version_name": "1.4.0", + "version": "1.4.1", + "version_name": "1.4.1", "icons": { "16": "assets/extension-icon/wallet_16.png", "32": "assets/extension-icon/wallet_32.png", diff --git a/src/router/index.ts b/src/router/index.ts index 2fe0321..62bce73 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -32,6 +32,10 @@ const routes: Array = [ path: '/request-network/:rid/:param', component: () => import('@/views/RequestNetwork.vue'), }, + { + path: '/farcaster-actions', + component: () => import('@/views/FarcasterActions.vue'), + }, { path: '/tabs/', component: AppTabs, diff --git a/src/utils/abis.ts b/src/utils/abis.ts new file mode 100644 index 0000000..90c20b7 --- /dev/null +++ b/src/utils/abis.ts @@ -0,0 +1,21 @@ +export const FARCASTER_PARTIAL_KEY_ABI = [ + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "idOf", + "outputs": [ + { + "internalType": "uint256", + "name": "fid", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + } + ] \ No newline at end of file diff --git a/src/utils/farcaster.ts b/src/utils/farcaster.ts new file mode 100644 index 0000000..8c8af68 --- /dev/null +++ b/src/utils/farcaster.ts @@ -0,0 +1,159 @@ +import { signMsg, getSelectedAddress, getOptimismProvider } from './wallet' +import { FARCASTER_PARTIAL_KEY_ABI } from './abis' +import { ethers } from 'ethers' +import { getUrl } from './platform' +import { generateApiToken } from './warpcast-auth' + +const WARPCAST_BASE = 'https://client.warpcast.com/v2/' +const EP_SIGNIN = `${WARPCAST_BASE}sign-in-with-farcaster` +const FC_ID_REGISTRY_CONTRACT = '0x00000000fc6c5f01fc30151999387bb99a9f489b' + +export const extractLinkData = (link: string) => { + const url = new URL(link); + const channelToken = url.searchParams.get('channelToken'); + const nonce = url.searchParams.get('nonce'); + const siweUri = url.searchParams.get('siweUri'); + const domain = url.searchParams.get('domain'); + const notBefore = url.searchParams.get('notBefore'); + const expirationTime = url.searchParams.get('expirationTime'); + + return { + channelToken, + nonce, + siweUri, + domain, + notBefore, + expirationTime, + } as { + channelToken: string, + nonce: string, + siweUri: string, + domain: string, + notBefore: string, + expirationTime: string, + } +} + +export const validateLinkData = (link: string) => { + const { channelToken, nonce, siweUri, domain, notBefore, expirationTime } = extractLinkData(link); + if (!channelToken || !nonce || !siweUri || !domain || !notBefore || !expirationTime) { + return false; + } + return true; +} + + +export const constructWarpcastSWIEMsg = ({ + siweUri, + domain, + nonce, + notBefore, + expirationTime, + fid, + custodyAddress +}: { + siweUri: string, + domain: string, + nonce: string, + notBefore: string, + expirationTime: string, + fid: number, + custodyAddress: string +}) => { + return `${domain} wants you to sign in with your Ethereum account:\n${custodyAddress}\n\nFarcaster Auth\n\nURI: ${siweUri}\nVersion: 1\nChain ID: 10\nNonce: ${nonce}\nIssued At: ${notBefore}\nExpiration Time: ${expirationTime}\nNot Before: ${notBefore}\nResources:\n- farcaster://fid/${fid}` +} + + +export const signInWithFarcaster = async ({ + channelToken, + message, + signature, + authToken +} : { + channelToken: string, + message: string, + signature: string, + authToken: string +}) => { + const response = await fetch(`${EP_SIGNIN}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authToken}` + }, + body: JSON.stringify({ + channelToken, + message, + signature, + }) + }); + + return response.json(); +} + +const noFidNotification = () => { + const messageId = Math.floor(Math.random() * 1000000); + + chrome.notifications.create('no-fid', { + type: 'basic', + iconUrl: getUrl('assets/extension-icon/wallet_128.png'), + title: 'Error', + message: 'This addres does not own any FID please select custody address that owns your FID.\nMessage ID: ' + messageId + }); +} + +export const getFidFromAddress = async (address: string) : Promise => { + const provider = await getOptimismProvider(); + const contract = new ethers.Contract(FC_ID_REGISTRY_CONTRACT, FARCASTER_PARTIAL_KEY_ABI, provider); + const FID = await contract.idOf(address); + if (FID > 0) { + return FID; + } + noFidNotification(); + return 0; +} + +export const doSignInWithFarcaster = async ({ + link +}: { + link: string +}) => { + const { channelToken, nonce, siweUri, domain, notBefore, expirationTime } = extractLinkData(link); + const custodyAddress = (await getSelectedAddress())?.[0] || ''; + const fid = custodyAddress && await getFidFromAddress(custodyAddress); + if (!fid) { + return -1; + } + const message = constructWarpcastSWIEMsg({ + siweUri, + domain, + nonce, + notBefore, + expirationTime, + fid, + custodyAddress + }); + + const genToken = await generateApiToken(); + let authToken = ''; + if(genToken.success) { + authToken = genToken.data; + } + + console.log('authToken', authToken); + + if (!authToken) { + return -2; + } + + const signature = await signMsg(message); + await signInWithFarcaster({ + channelToken, + message, + signature, + authToken + }); + + return 1 +} + diff --git a/src/utils/platform.ts b/src/utils/platform.ts index 411ea29..245dd8e 100644 --- a/src/utils/platform.ts +++ b/src/utils/platform.ts @@ -315,4 +315,4 @@ export const openTab = (url: string) => { }); } -export const getVersion = () => chrome?.runtime?.getManifest()?.version ?? '' +export const getVersion = () => chrome?.runtime?.getManifest()?.version ?? '' \ No newline at end of file diff --git a/src/utils/wallet.ts b/src/utils/wallet.ts index 3e583d2..c28cfa1 100644 --- a/src/utils/wallet.ts +++ b/src/utils/wallet.ts @@ -1,5 +1,6 @@ import { getSelectedAccount, getSelectedNetwork, numToHexStr } from '@/utils/platform'; import { ethers } from "ethers" +import { mainNets } from '@/utils/networks'; let provider: ethers.JsonRpcProvider | null = null @@ -16,6 +17,11 @@ export const getCurrentProvider = async () => { return {provider, network} } +export const getOptimismProvider = async () => { + const network = mainNets[10] + return new ethers.JsonRpcProvider(network.rpc, ethers.Network.from(network.chainId), { staticNetwork: true, batchMaxCount: 6, polling: false }) +} + const convertReceipt = (receipt: ethers.TransactionReceipt | null) => { if(!receipt) return null const newReceipt = {...receipt} as any diff --git a/src/utils/warpcast-auth.ts b/src/utils/warpcast-auth.ts new file mode 100644 index 0000000..818c1b7 --- /dev/null +++ b/src/utils/warpcast-auth.ts @@ -0,0 +1,161 @@ +import { signMsg } from './wallet' +import { getBytes } from 'ethers'; +import bufferLib from 'buffer'; + +const EIP_191_PREFIX = "eip191:"; +const WARPCAST_API = 'https://client.warpcast.com/v2' + +const NO_WALLET = 'NO_WALLET' +const SIG_DENIED = 'SIG_DENIED' +const NO_AUTH_TOKEN = 'NO_AUTH_TOKEN' +const AUTH_SUCCESS = 'AUTH_SUCCESS' + +type T_RESULT_GEN_AUTH_TOKEN = { + success: boolean; + data: typeof SIG_DENIED | typeof NO_AUTH_TOKEN | typeof AUTH_SUCCESS | typeof NO_WALLET | string; +} + +type T_IDDB_VALUE = { + secret: string; + expiresAt: number; +} + +function serialize (object: any) { + if (typeof object === 'number' && isNaN(object)) { + throw new Error('NaN is not allowed'); + } + + if (typeof object === 'number' && !isFinite(object)) { + throw new Error('Infinity is not allowed'); + } + + if (object === null || typeof object !== 'object') { + return JSON.stringify(object); + } + + if (object.toJSON instanceof Function) { + return serialize(object.toJSON()); + } + + if (Array.isArray(object)) { + const values: any = object.reduce((t, cv, ci) => { + const comma = ci === 0 ? '' : ','; + const value = cv === undefined || typeof cv === 'symbol' ? null : cv; + return `${t}${comma}${serialize(value)}`; + }, ''); + return `[${values}]`; + } + + const values: any = Object.keys(object).sort().reduce((t, cv) => { + if (object[cv] === undefined || + typeof object[cv] === 'symbol') { + return t; + } + const comma = t.length === 0 ? '' : ','; + return `${t}${comma}${serialize(cv)}:${serialize(object[cv])}`; + }, ''); + return `{${values}}`; + }; + +function createWarpMessage (data: any) { + return { message: serialize(data) } +} + + +export const generateApiToken = async (): Promise => { + try { + + const timestamp = Date.now(); + const payload = { + method: "generateToken", + params: { + timestamp, + expiresAt: 1777046287381 + }, + }; + const msgToSign = createWarpMessage(payload); + + + const sig = await signMsg(msgToSign.message); + + const Buffer = bufferLib.Buffer; + + const sigBase64 = Buffer.from(getBytes(sig)).toString('base64'); + const cusAuth = EIP_191_PREFIX + sigBase64 + + const req = await fetch(`${WARPCAST_API}/auth`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${cusAuth}`, + }, + body: JSON.stringify(payload), + }); + + + if (req.ok) { + const data = await req.json(); + const token = data?.result?.token?.secret; + if (token) { + return { success: true, data: token } + } + return { success: false, data: NO_AUTH_TOKEN } + } + return { success: false, data: NO_AUTH_TOKEN } + } catch (error) { + console.error('Failed to generate api token', error) + return { success: false, data: NO_AUTH_TOKEN} + } +} + +export const addWarpAuthToken = async (value: T_IDDB_VALUE): Promise => { + const dbName = 'localforage' + const storeName = 'keyvaluepairs' + const key = 'auth-token' + const version = 2 + let resolve = (a = false) => {} + const result = new Promise((res) => { + resolve = res + }) + + try { + const dbRequest = indexedDB.open(dbName, version); + + dbRequest.onupgradeneeded = (event: any) => { + const db = event.target.result; + db.createObjectStore(storeName); + }; + + dbRequest.onsuccess = (event: any) => { + + const db = dbRequest.result + const transaction = db.transaction(storeName, "readwrite"); + const store = transaction.objectStore(storeName); + + const request = store.put(value, key); + + request.onsuccess = (event: any) => { + console.log("Successfully added data:", event.target.result); + window?.location?.reload() + resolve?.() + } + + request.onerror = (event: any) => { + console.error("Error adding data:", event.target.error); + resolve?.() + } + + }; + + dbRequest.onerror = (event: any) => { + console.error("Error adding data:", event.target.error); + resolve?.() + }; + + + } catch (error) { + console.error("Error accessing IndexedDB:", error); + resolve?.() + } + return result + } \ No newline at end of file diff --git a/src/views/FarcasterActions.vue b/src/views/FarcasterActions.vue new file mode 100644 index 0000000..7acc04b --- /dev/null +++ b/src/views/FarcasterActions.vue @@ -0,0 +1,468 @@ + + + diff --git a/src/views/HomeTab.vue b/src/views/HomeTab.vue index b41f8c5..03b3a7b 100644 --- a/src/views/HomeTab.vue +++ b/src/views/HomeTab.vue @@ -137,6 +137,11 @@

+ + Experimental Farcaster Wallet Actions + { + router.push("/farcaster-actions"); + }; + const changeSelectedAccount = async (address: string) => { loading.value = true; const findIndex = accounts.value.findIndex((a) => a.address == address); @@ -421,6 +430,7 @@ export default defineComponent({ openTab, settings, version, + goToFarcasterActions, }; }, }); diff --git a/src/views/SignMessage.vue b/src/views/SignMessage.vue index 83501bd..e9281d4 100644 --- a/src/views/SignMessage.vue +++ b/src/views/SignMessage.vue @@ -143,6 +143,9 @@ export default defineComponent({ const onSign = async () => { loading.value = true; + if (interval) { + clearInterval(interval); + } const selectedAccount = await getSelectedAccount(); loading.value = false; if ((selectedAccount.pk ?? "").length !== 66) { diff --git a/src/views/SignTx.vue b/src/views/SignTx.vue index e09f676..65c728b 100644 --- a/src/views/SignTx.vue +++ b/src/views/SignTx.vue @@ -270,6 +270,29 @@ export default defineComponent({ signTxData.value = JSON.stringify(paramsWithoutZeros, null, 2); } + const setItervalFn = async () => { + if (timerReject.value <= 0) { + onCancel(); + return; + } + if (gasPriceReFetch.value) { + timerFee.value -= 1; + if (timerFee.value <= 0) { + timerFee.value = 20; + loading.value = true; + const { feed, price } = await getGasPrice(); + gasFeed = feed; + gasPrice.value = parseFloat(price.toString() ?? 0.1); + await newGasData(); + loading.value = false; + } + } + + timerReject.value -= 1; + bars.value++; + walletPing(); + }; + const openModal = async () => { const modal = await modalController.create({ component: UnlockModal, @@ -285,6 +308,9 @@ export default defineComponent({ const onSign = async () => { loading.value = true; + if (interval) { + clearInterval(interval); + } const selectedAccount = await getSelectedAccount(); loading.value = false; if ((selectedAccount.pk ?? "").length !== 66) { @@ -370,28 +396,7 @@ export default defineComponent({ await newGasData(); loading.value = false; - interval = setInterval(async () => { - if (timerReject.value <= 0) { - onCancel(); - return; - } - if (gasPriceReFetch.value) { - timerFee.value -= 1; - if (timerFee.value <= 0) { - timerFee.value = 20; - loading.value = true; - const { feed, price } = await getGasPrice(); - gasFeed = feed; - gasPrice.value = parseFloat(price.toString() ?? 0.1); - await newGasData(); - loading.value = false; - } - } - - timerReject.value -= 1; - bars.value++; - walletPing(); - }, 1000) as any; + interval = setInterval(setItervalFn, 1000) as any; }); const setGasLimit = () => { diff --git a/src/views/UnlockModal.vue b/src/views/UnlockModal.vue index c805567..4602c0d 100644 --- a/src/views/UnlockModal.vue +++ b/src/views/UnlockModal.vue @@ -154,14 +154,29 @@ export default defineComponent({ } }; - onMounted(() => { - requestAnimationFrame(async () => { - if (passInput.value) { - await new Promise((resolve) => setTimeout(resolve, 50)); - console.log("passInput.value", passInput.value); + onMounted(async () => { + console.log("rendered"); + if (passInput.value) { + await new Promise((resolve) => setTimeout(resolve, 50)); + passInput.value.$el.setFocus(); + passInput.value.$el.addEventListener("keyup", (e: any) => { + if (e.key === "Enter") { + unlock(); + } + }); + passInput.value.$el.addEventListener("blur", () => { passInput.value.$el.setFocus(); - } - }); + passInput.value.$el.selectionStart = passInput.value?.$el.value.length; + }); + } + + // requestAnimationFrame(async () => { + // if (passInput.value) { + // await new Promise((resolve) => setTimeout(resolve, 50)); + // console.log("passInput.value", passInput.value); + // passInput.value.$el.setFocus(); + // } + // }); }); return {