Skip to content

Commit d93b63a

Browse files
authored
Merge pull request #438 from rabbitholegg/mmackz/lens-collect
feat(lens): add support for Lens Plugin
2 parents c94fd20 + 377a3c0 commit d93b63a

File tree

14 files changed

+539
-18
lines changed

14 files changed

+539
-18
lines changed

.changeset/loud-books-prove.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@rabbitholegg/questdk-plugin-registry": minor
3+
"@rabbitholegg/questdk-plugin-lens": minor
4+
---
5+
6+
add lens plugin to questdk

packages/lens/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
## Lens
2+
3+
Lens Protocol is a decentralized social graph protocol designed to empower developers to build and innovate on top of a user-owned social network, providing seamless integration and interoperability across various applications.
4+
5+
## Collect Plugin
6+
7+
### Implementation Details
8+
9+
Lens Collect action will use the Indexer V4 along with the Lens GraphQL endpoint to determine if a certain address has collected a post.
10+
11+
Since there is no current way to query the time that a post was "collected" in Lens, so if someone has collected a post before a boost has started, they will be eligible for rewards. The only way around this would be to capture the data in on-chain events.
12+
13+
The endpoint to query for collected posts is `whoActedOnPublication` which returns a paginated response of addresses who have collected a specific postId.
14+
15+
https://lens-protocol.github.io/lens-sdk/classes/_lens_protocol_client.Core.Profile.html#whoActedOnPublication
16+
17+
There is no way to filter this response, so we will need to check each page of the response to see if the address has collected the post.
18+
19+
### Example Collect Post
20+
21+
https://hey.xyz/posts/0x015f34-0x1d4a

packages/lens/package.json

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
"name": "@rabbitholegg/questdk-plugin-lens",
3+
"private": false,
4+
"version": "1.0.0-alpha.0",
5+
"exports": {
6+
"require": "./dist/cjs/index.js",
7+
"import": "./dist/esm/index.js",
8+
"types": "./dist/types/index.d.ts"
9+
},
10+
"main": "./dist/cjs/index.js",
11+
"module": "./dist/esm/index.js",
12+
"types": "./dist/types/index.d.ts",
13+
"typings": "./dist/types/index.d.ts",
14+
"scripts": {
15+
"build": "vite build && tsc --project tsconfig.build.json --emitDeclarationOnly --declaration --declarationMap --declarationDir ./dist/types",
16+
"bench": "vitest bench",
17+
"bench:ci": "CI=true vitest bench",
18+
"clean": "rimraf dist",
19+
"format": "rome format . --write",
20+
"lint": "eslint .",
21+
"lint:fix": "eslint . --fix",
22+
"test": "vitest dev",
23+
"test:cov": "vitest dev --coverage",
24+
"test:ci": "CI=true vitest --coverage",
25+
"test:ui": "vitest dev --ui"
26+
},
27+
"keywords": [],
28+
"author": "",
29+
"license": "ISC",
30+
"dependencies": {
31+
"@apollo/client": "^3.10.4",
32+
"@rabbitholegg/questdk": "workspace:*",
33+
"@rabbitholegg/questdk-plugin-utils": "workspace:*",
34+
"graphql": "^16.8.1"
35+
}
36+
}

packages/lens/src/Lens.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { validateCollect } from './Lens'
2+
import { client } from './graphql'
3+
import { MockedFunction, beforeEach, describe, expect, test, vi } from 'vitest'
4+
5+
vi.mock('@apollo/client', async () => {
6+
const actualApollo = await vi.importActual('@apollo/client')
7+
return {
8+
...(actualApollo as object),
9+
ApolloClient: vi.fn(() => ({
10+
query: vi.fn(),
11+
InMemoryCache: vi.fn(),
12+
})),
13+
}
14+
})
15+
16+
describe('Given the lens plugin', () => {
17+
describe('When handling the collect action', () => {
18+
beforeEach(() => {
19+
vi.resetAllMocks()
20+
})
21+
test('return true if actor has collected a specific post', async () => {
22+
// use mock
23+
//eslint-disable-next-line
24+
;(
25+
client.query as MockedFunction<typeof client.query>
26+
).mockResolvedValueOnce({
27+
data: {
28+
whoActedOnPublication: {
29+
items: [
30+
{
31+
ownedBy: {
32+
address: '0xA99F898530dF1514A566f1a6562D62809e99557D',
33+
},
34+
},
35+
],
36+
},
37+
},
38+
loading: false,
39+
networkStatus: 7,
40+
})
41+
const hasCollected = await validateCollect(
42+
{ identifier: '0x5dbb-0xd5' },
43+
{ actor: '0xA99F898530dF1514A566f1a6562D62809e99557D' },
44+
)
45+
expect(hasCollected).toBe(true)
46+
})
47+
48+
test('return false if actor has not collected a specific post', async () => {
49+
// use mock
50+
// eslint-disable-next-line
51+
;(
52+
client.query as MockedFunction<typeof client.query>
53+
).mockResolvedValueOnce({
54+
data: {
55+
whoActedOnPublication: {
56+
items: [],
57+
},
58+
},
59+
loading: false,
60+
networkStatus: 7,
61+
})
62+
const hasCollected = await validateCollect(
63+
{ identifier: '0x2fb0-0x69-DA-3096022f' },
64+
{ actor: '0xA99F898530dF1514A566f1a6562D62809e99557D' },
65+
)
66+
expect(hasCollected).toBe(false)
67+
})
68+
})
69+
})

packages/lens/src/Lens.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { hasAddressCollectedPost } from './graphql'
2+
import {
3+
ActionType,
4+
type CollectActionParams,
5+
type CollectValidationParams,
6+
type PluginActionValidation,
7+
type QuestCompletionPayload,
8+
} from '@rabbitholegg/questdk-plugin-utils'
9+
import { type Address } from 'viem'
10+
11+
export const validate = async (
12+
validationPayload: PluginActionValidation,
13+
): Promise<QuestCompletionPayload | null> => {
14+
const { actor, payload } = validationPayload
15+
const { actionParams, validationParams, questId, taskId } = payload
16+
switch (actionParams.type) {
17+
case ActionType.Collect: {
18+
const isCollectValid = await validateCollect(
19+
actionParams.data,
20+
validationParams.data,
21+
)
22+
if (isCollectValid) {
23+
return {
24+
address: actor,
25+
questId,
26+
taskId,
27+
}
28+
} else {
29+
return null
30+
}
31+
}
32+
default:
33+
throw new Error('Unsupported action type')
34+
}
35+
}
36+
37+
export const validateCollect = async (
38+
actionP: CollectActionParams,
39+
validateP: CollectValidationParams,
40+
): Promise<boolean> => {
41+
try {
42+
// call lens graphql endpoint to verify if actor has collected the publication
43+
const hasCollected = await hasAddressCollectedPost(
44+
actionP.identifier,
45+
validateP.actor,
46+
)
47+
return hasCollected
48+
} catch {
49+
return false
50+
}
51+
}
52+
53+
export const getSupportedTokenAddresses = async (
54+
_chainId: number,
55+
): Promise<Address[]> => {
56+
return []
57+
}
58+
59+
export const getSupportedChainIds = async (): Promise<number[]> => {
60+
return []
61+
}

packages/lens/src/graphql.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { ActionVariables, PaginatedResponse, Profile } from './types'
2+
import { ApolloClient, DocumentNode, InMemoryCache, gql } from '@apollo/client'
3+
4+
const API_URL = 'https://api-v2.lens.dev/'
5+
6+
export const client = new ApolloClient({
7+
uri: API_URL,
8+
cache: new InMemoryCache(),
9+
})
10+
11+
const WHO_ACTED_ON_PUBLICATION = gql`
12+
query WhoActedOnPublication($request: WhoActedOnPublicationRequest!) {
13+
whoActedOnPublication(request: $request) {
14+
items {
15+
ownedBy {
16+
address
17+
}
18+
}
19+
pageInfo {
20+
next
21+
}
22+
}
23+
}
24+
`
25+
26+
export async function hasAddressPerformedAction(
27+
address: string,
28+
actionQuery: DocumentNode,
29+
actionDataKey: string,
30+
actionVariables: ActionVariables,
31+
) {
32+
let cursor = null
33+
let hasNextPage = true
34+
while (hasNextPage) {
35+
const { data } = (await client.query({
36+
query: actionQuery,
37+
variables: {
38+
request: {
39+
...actionVariables,
40+
cursor: cursor,
41+
},
42+
},
43+
})) as PaginatedResponse<Profile>
44+
45+
const { items, pageInfo } = data[actionDataKey]
46+
const found = items.find(
47+
(item: Profile) =>
48+
item.ownedBy.address.toLowerCase() === address.toLowerCase(),
49+
)
50+
if (found) {
51+
return true
52+
}
53+
cursor = pageInfo.next
54+
hasNextPage = cursor !== null
55+
}
56+
return false
57+
}
58+
59+
export async function hasAddressCollectedPost(postId: string, address: string) {
60+
return hasAddressPerformedAction(
61+
address,
62+
WHO_ACTED_ON_PUBLICATION,
63+
'whoActedOnPublication',
64+
{
65+
on: postId,
66+
where: {
67+
anyOf: [{ category: 'COLLECT' }],
68+
},
69+
},
70+
)
71+
}

packages/lens/src/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { type IActionPlugin } from '@rabbitholegg/questdk'
2+
3+
import {
4+
getSupportedChainIds,
5+
getSupportedTokenAddresses,
6+
validate,
7+
validateCollect,
8+
} from './Lens'
9+
10+
export const Lens: IActionPlugin = {
11+
pluginId: 'lens',
12+
getSupportedTokenAddresses,
13+
getSupportedChainIds,
14+
validate,
15+
validateCollect,
16+
}

packages/lens/src/types.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Address } from 'viem'
2+
3+
export type Profile = {
4+
ownedBy: {
5+
address: Address
6+
}
7+
}
8+
9+
export interface PaginatedResponse<T> {
10+
data: {
11+
[key: string]: {
12+
items: T[]
13+
pageInfo: {
14+
next: string | null
15+
}
16+
}
17+
}
18+
}
19+
20+
export interface ActionVariables {
21+
on?: string
22+
where: {
23+
anyOf?: Array<{ category: string }>
24+
whoMirroredPublication?: string
25+
}
26+
cursor?: string | null
27+
}

packages/lens/tsconfig.build.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"extends": "../../tsconfig.json",
3+
"include": ["src"],
4+
"exclude": [
5+
"src/**/*.test.ts",
6+
"src/**/*.test-d.ts",
7+
"src/**/*.bench.ts",
8+
"src/_test",
9+
"scripts/**/*"
10+
],
11+
"compilerOptions": {
12+
"sourceMap": true,
13+
"rootDir": "./src"
14+
}
15+
}

packages/lens/tsconfig.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"extends": "../../tsconfig.json",
3+
"include": ["src/**/*", "src/chain-data.ts"],
4+
"exclude": ["dist", "build", "node_modules"]
5+
}

packages/lens/vite.config.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/** @type {import('vite').UserConfig} */
2+
export default {
3+
build: {
4+
rollupOptions: {
5+
external: [/@rabbitholegg/],
6+
},
7+
lib: {
8+
entry: 'src/index.ts',
9+
emptyOutDir: false,
10+
name: 'QuestdkPluginLens',
11+
fileName: (module, name) => {
12+
const outPath = `${module === 'es' ? 'esm' : 'cjs'}/${
13+
name.startsWith('index') ? 'index.js' : name + '/index.js'
14+
}`
15+
return outPath
16+
},
17+
},
18+
},
19+
}

packages/registry/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@
7777
"@rabbitholegg/questdk-plugin-neynar": "workspace:*",
7878
"@rabbitholegg/questdk-plugin-titles": "workspace:*",
7979
"@rabbitholegg/questdk-plugin-foundation": "workspace:*",
80-
"@rabbitholegg/questdk-plugin-thirdweb": "workspace:*"
80+
"@rabbitholegg/questdk-plugin-thirdweb": "workspace:*",
81+
"@rabbitholegg/questdk-plugin-lens": "workspace:*"
8182
}
8283
}

packages/registry/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import { WooFi } from '@rabbitholegg/questdk-plugin-woofi'
4646
import { Zora } from '@rabbitholegg/questdk-plugin-zora'
4747
import { Foundation } from '@rabbitholegg/questdk-plugin-foundation'
4848
import { ThirdWeb } from '@rabbitholegg/questdk-plugin-thirdweb'
49+
import { Lens } from '@rabbitholegg/questdk-plugin-lens'
4950
// ^^^ New Imports Go Here ^^^
5051
import {
5152
ActionType,
@@ -117,6 +118,7 @@ export const plugins: Record<string, IActionPlugin> = {
117118
[Titles.pluginId]: Titles,
118119
[Foundation.pluginId]: Foundation,
119120
[ThirdWeb.pluginId]: ThirdWeb,
121+
[Lens.pluginId]: Lens,
120122
}
121123

122124
export const getPlugin = (pluginId: string) => {

0 commit comments

Comments
 (0)