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 @@
+
+
+
+
+
+ Back
+
+ Farcaster Actions
+
+
+
+
+
+ Selected address needs to own a FID in order to work, this address is also
+ known as custody address.
+
+
+
+ These are experimental features RE from Warpcast might not work in all cases
+ and might break if WC makes changes.
+
+
+
+ Selected Account: {{ selectedAccount?.name }}
+ {
+ accountsModal = true;
+ toastState = false;
+ }
+ "
+ >Select
+
+
+ {{ selectedAccount?.address }}
+
+
+
+ Used for sign in with farcaster/warpcast QR you'll need to paste the deep link
+ in next screen
+
+ Sign in with farcaster
+
+
+ Used to login on warpcast.com without needing a mobile device
+
+
+ Login on Warpcast.com
+
+
+
+
+
+
+
+
+ Close
+
+ Select
+
+
+
+
+
+
+ Accounts
+
+
+
+
+ {{ account.name }}
+
+
+ {{
+ account.address
+ }}
+
+
+
+
+
+
+
+
+
+
+
+ Close
+
+ Paste Link To Authorize
+
+
+
+
+ Enter deep-link from Sign in with farcaster QR EX:
+
+ https://warpcast.com/~/sign-in-with-farcaster?channelToken=4a8d3f27-....
+
+
+
+
+
+
+ Cancel
+ Authorize
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 {