diff --git a/packages/functions/global-api-v2.ts b/packages/functions/global-api-v2.ts new file mode 100644 index 0000000..0b5093e --- /dev/null +++ b/packages/functions/global-api-v2.ts @@ -0,0 +1,275 @@ +import { + Activation, + Proposal, + ProposalStatus, +} from '@consensys/linea-voyager/src/types'; + +const headers = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + 'Content-Type': 'application/json', +}; + +export const LXP_CONTRACT_ADDRESS = + '0xd83af4fbD77f3AB65C3B1Dc4B38D7e67AEcf599A'; +export const LXP_L_CONTRACT_ADDRESS = + '0x96B3a15257c4983A6fE9073D8C91763433124B82'; + +const { CONTENTFUL_API_KEY, LINEASCAN_API_KEY, TALLY_API_KEY } = process.env; + +/** + * This function is called on every network call. + * @param event - The event object. + * @param event.httpMethod - The HTTP method used by the caller. + * @param event.body - The HTTP request body. + * @returns The response object. + */ +export async function handler(event: { + queryStringParameters: { address: string; isLineascan: boolean }; + body: string; + httpMethod: string; +}) { + if (event.httpMethod === 'OPTIONS') { + return { + statusCode: 204, + headers, + body: '', + }; + } + + if (event.httpMethod === 'GET') { + if (!LINEASCAN_API_KEY) { + return { + statusCode: 500, + body: JSON.stringify({ + message: 'Lineascan API key not set', + }), + }; + } + + if (!TALLY_API_KEY) { + return { + statusCode: 500, + body: JSON.stringify({ + message: 'Tally API key not set', + }), + }; + } + + if (!CONTENTFUL_API_KEY) { + return { + statusCode: 500, + body: JSON.stringify({ + message: 'Contentful API key not set', + }), + }; + } + + const { address, isLineascan } = event.queryStringParameters; + + if (!address || address == '') { + return { + statusCode: 400, + body: JSON.stringify({ + message: 'Missing address parameter', + }), + }; + } + + const [ + activations, + pohStatus, + openBlockScore, + lxpBalance, + lxpLBalance, + name, + proposals, + ] = await Promise.all([ + getActivations(CONTENTFUL_API_KEY), + fetchPohStatus(address), + getOpenBlockScore(address.toLowerCase()), + isLineascan + ? fetchBalanceFromLineascan(LXP_CONTRACT_ADDRESS, address) + : Promise.resolve('0'), + isLineascan + ? fetchBalanceFromLineascan(LXP_L_CONTRACT_ADDRESS, address) + : Promise.resolve('0'), + isLineascan ? fetchLineaEns(address.toLowerCase()) : undefined, + fetchActiveProposals(TALLY_API_KEY), + ]); + + return { + statusCode: 200, + headers, + body: JSON.stringify({ + activations, + pohStatus, + openBlockScore, + lxpBalance, + lxpLBalance, + name, + proposals, + }), + }; + } + + return { + statusCode: 405, + headers, + body: JSON.stringify({ + message: 'Method not allowed', + }), + }; +} + +async function getData( + url: string, + additionalHeaders?: Record, +) { + const response = await fetch(url, { + method: 'GET', + headers: { + ...additionalHeaders, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + console.error(`Call to ${url} failed with status ${response.status}`); + throw new Error(`HTTP error! Status: ${response.status}`); + } + + return response.json(); +} + +async function postData( + url: string, + data: Record, + additionalHeaders?: Record, +) { + const response = await fetch(url, { + method: 'POST', + headers: { + ...additionalHeaders, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + console.error(`Call to ${url} failed with status ${response.status}`); + throw new Error(`HTTP error! Status: ${response.status}`); + } + + return response.json(); +} + +/** + * Get current active activations from Contentful. + * @returns The activations object. + */ +async function getActivations(contentfulApiKey: string) { + const GET_XP_TAG = '4WJBpV24ju4wlbr6Kvi2pt'; + + try { + const res = await getData( + 'https://api.contentful.com/spaces/64upluvbiuck/environments/master/entries/?content_type=activationsCard', + { + Authorization: contentfulApiKey, + }, + ); + + const allActivations = res?.items ?? []; + return allActivations.filter((activation: Activation) => { + const isCurrent = + new Date(activation?.fields?.endDate?.['en-US']) > new Date(); + const hasXpTag = activation?.fields?.tags?.['en-US']?.find( + (tag) => tag?.sys?.id === GET_XP_TAG, + ); + return isCurrent && hasXpTag; + }); + } catch (error) { + return []; + } +} + +/** + * Get the current OpenBlock XP score for an address. + * @param address - The address to get the OpenBlock XP score for. + * @returns The OpenBlock XP score for the address. + */ +async function getOpenBlockScore(address: string) { + try { + const res = await getData( + `https://kx58j6x5me.execute-api.us-east-1.amazonaws.com/linea/userPointsSearchMetaMask?user=${address}`, + { + Origin: 'snap://linea-voyager', + }, + ); + + return res[0].xp; + } catch (error) { + return 0; + } +} + +async function fetchBalanceFromLineascan( + tokenBalance: string, + address: string, +) { + try { + const res = await getData( + `https://api.lineascan.build/api?module=account&action=tokenbalance&contractaddress=${tokenBalance}&address=${address}&tag=latest&apiKey=${LINEASCAN_API_KEY}`, + ); + + return res.result as string; + } catch (e) { + return '0'; + } +} + +async function fetchPohStatus(address: string) { + try { + const pohPayload = await getData( + `https://linea-xp-poh-api.linea.build/poh/${address}`, + ); + return pohPayload.poh as boolean; + } catch (e) { + return false; + } +} + +async function fetchLineaEns(address: string) { + try { + const res = await postData( + `https://api.studio.thegraph.com/query/69290/ens-linea-mainnet/version/latest`, + { + query: `query getNamesForAddress {domains(first: 1, where: {and: [{or: [{owner: \"${address}\"}, {registrant: \"${address}\"}, {wrappedOwner: \"${address}\"}]}, {parent_not: \"0x91d1777781884d03a6757a803996e38de2a42967fb37eeaca72729271025a9e2\"}, {or: [{expiryDate_gt: \"1721033912\"}, {expiryDate: null}]}, {or: [{owner_not: \"0x0000000000000000000000000000000000000000\"}, {resolver_not: null}, {and: [{registrant_not: \"0x0000000000000000000000000000000000000000\"}, {registrant_not: null}]}]}]}) {...DomainDetailsWithoutParent}} fragment DomainDetailsWithoutParent on Domain {name}`, + }, + ); + return res.data.domains[0].name as string; + } catch (e) { + return undefined; + } +} + +async function fetchActiveProposals(tallyApiKey: string) { + try { + const res = await postData( + `https://api.tally.xyz/query`, + { + query: `query Proposals { proposals(input: { filters: { governorId: "eip155:1:0x5d2C31ce16924C2a71D317e5BbFd5ce387854039\", includeArchived: false, isDraft: false } }) { nodes { ... on Proposal { id onchainId chainId votableChains createdAt l1ChainId originalId quorum status metadata { title description eta ipfsHash previousEnd timelockId txHash discourseURL snapshotURL } end { ... on Block { id timestamp } ... on BlocklessTimestamp { timestamp } } start { ... on Block { id timestamp } ... on BlocklessTimestamp { timestamp } } } } } }`, + }, + { + 'Api-Key': tallyApiKey, + }, + ); + const allProposals = res.data.proposals.nodes as Proposal[]; + return allProposals.filter( + (proposal) => proposal.status === ProposalStatus.Active, + ); + } catch (e) { + return undefined; + } +} diff --git a/packages/snap/snap.manifest.json b/packages/snap/snap.manifest.json index b15264e..1d3edc6 100644 --- a/packages/snap/snap.manifest.json +++ b/packages/snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/Consensys/linea-voyager-snap" }, "source": { - "shasum": "oIZBQ1pqMiZJJvAIDJuYAHRvPR3AIB2Hlt99G27DP+E=", + "shasum": "1Imd2ziyA7p1i8orH+dC66gdJAHM7w34Bn1r03QRlm4=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/snap/src/api.ts b/packages/snap/src/api.ts index b5e549c..99a9ac1 100644 --- a/packages/snap/src/api.ts +++ b/packages/snap/src/api.ts @@ -5,7 +5,7 @@ export const callGlobalApi = async ( isLineascan: boolean, ): Promise => { const response = await fetch( - `https://lxp-snap-api.netlify.app/.netlify/functions/global-api?address=${address}&isLineascan=${isLineascan}`, + `https://lxp-snap-api.netlify.app/.netlify/functions/global-api-v2?address=${address}&isLineascan=${isLineascan}`, { method: 'GET', }, diff --git a/packages/snap/src/index.ts b/packages/snap/src/index.ts index acf56fb..363f682 100644 --- a/packages/snap/src/index.ts +++ b/packages/snap/src/index.ts @@ -42,6 +42,7 @@ export const onHomePage: OnHomePageHandler = async () => { pohStatus, activations, name, + proposals, } = await getDataForUser(myAccount, chainId); await setState({ @@ -51,6 +52,7 @@ export const onHomePage: OnHomePageHandler = async () => { myPohStatus: pohStatus, activations, myLineaEns: name, + proposals, }); return renderMainUi(myAccount); diff --git a/packages/snap/src/service.ts b/packages/snap/src/service.ts index df32d89..b4a0135 100644 --- a/packages/snap/src/service.ts +++ b/packages/snap/src/service.ts @@ -2,7 +2,7 @@ import { decode } from '@metamask/abi-utils'; import type { Hex } from '@metamask/utils'; import { callGlobalApi } from './api'; -import type { UserData } from './types'; +import type { Proposal, UserData } from './types'; import { convertBalanceToDisplay, LXP_CONTRACT_ADDRESS, @@ -40,6 +40,16 @@ export async function getDataForUser( ? convertBalanceToDisplay(userData.lxpLBalance.toString()) : lxpLBalanceRaw; userData.name = isLineascan ? userData.name : name; + userData.proposals = userData.proposals.map((proposal: Proposal) => { + return { + ...proposal, + metadata: { + ...proposal.metadata, + title: proposal.metadata.title.replace(/^#\s*/u, ''), + description: proposal.metadata.description.replace(/^#\s*/u, ''), + }, + }; + }); return userData; } catch (error) { @@ -50,6 +60,7 @@ export async function getDataForUser( pohStatus: false, activations: [], name: '', + proposals: [], }; } } diff --git a/packages/snap/src/types/index.ts b/packages/snap/src/types/index.ts index e42a039..aeace3a 100644 --- a/packages/snap/src/types/index.ts +++ b/packages/snap/src/types/index.ts @@ -72,6 +72,7 @@ export type UserData = { lxpBalance: number; lxpLBalance: number; name: string; + proposals: Proposal[]; }; export type SnapState = { @@ -83,4 +84,29 @@ export type SnapState = { myPohStatus?: boolean; activations?: Activation[]; myLineaEns?: string; + proposals?: Proposal[]; +}; + +export type Proposal = { + id: string; + onchainId: string; + status: ProposalStatus; + metadata: { + title: string; + description: string; + }; + start: BlockTime; + end: BlockTime; +}; + +export enum ProposalStatus { + Active = 'active', + Canceled = 'canceled', + Executed = 'executed', + Queued = 'queued', +} + +export type BlockTime = { + id: string; + timestamp: Date; }; diff --git a/packages/snap/src/ui.ts b/packages/snap/src/ui.ts index 4ee9a49..90071b5 100644 --- a/packages/snap/src/ui.ts +++ b/packages/snap/src/ui.ts @@ -29,6 +29,7 @@ export async function renderMainUi(myAccount: string) { const openBlockScore = snapState?.myOpenBlockScore ?? 0; const activations = snapState?.activations ?? []; const name = snapState?.myLineaEns ?? ''; + const proposals = snapState?.proposals ?? []; const captions = snapState?.captions; @@ -94,6 +95,29 @@ export async function renderMainUi(myAccount: string) { myData.push(row(labelLineaEns, text(name))); } + const governanceData = []; + + if (proposals.length > 0) { + governanceData.push(divider()); + governanceData.push(text(`**Active Governance Proposals:**`)); + for (const proposal of proposals) { + governanceData.push( + text(`${truncateString(proposal.metadata.title, 30)}`), + ); + governanceData.push( + text(`Start: ${new Date(proposal.start.timestamp).toLocaleString()}`), + ); + governanceData.push( + text(`End: ${new Date(proposal.end.timestamp).toLocaleString()}`), + ); + governanceData.push( + text( + `[See details](https://www.tally.xyz/gov/lil-nouns/proposal/${proposal.onchainId})`, + ), + ); + } + } + const help = captions?.help as string; const viewBalance = captions?.viewBalance as string; const viewLxpLBalance = captions?.viewLxpLBalance as string; @@ -137,6 +161,7 @@ export async function renderMainUi(myAccount: string) { image(banner), ...myData, ...activationsList, + ...governanceData, divider(), text(`_${help}_`), ...extraLinks,