diff --git a/.changeset/large-maps-decide.md b/.changeset/large-maps-decide.md new file mode 100644 index 000000000..073cb973e --- /dev/null +++ b/.changeset/large-maps-decide.md @@ -0,0 +1,6 @@ +--- +"@rabbitholegg/questdk-plugin-utils": minor +"@rabbitholegg/questdk-plugin-lens": minor +--- + +update validation method for lens diff --git a/packages/lens/package.json b/packages/lens/package.json index 9d5d8df50..4545cafde 100644 --- a/packages/lens/package.json +++ b/packages/lens/package.json @@ -28,8 +28,9 @@ "author": "", "license": "ISC", "dependencies": { + "@lens-protocol/client": "2.3.0", "@rabbitholegg/questdk": "workspace:*", "@rabbitholegg/questdk-plugin-utils": "workspace:*", - "@lens-protocol/client": "2.3.0" + "alchemy-sdk": "3.3.1" } } diff --git a/packages/lens/src/Lens.test.ts b/packages/lens/src/Lens.test.ts index 9aaefb101..eb3df34c0 100644 --- a/packages/lens/src/Lens.test.ts +++ b/packages/lens/src/Lens.test.ts @@ -1,5 +1,5 @@ import { validateCollect } from './Lens' -import { beforeEach, describe, expect, test, vi, MockedFunction } from 'vitest' +import { MockedFunction, beforeEach, describe, expect, test, vi } from 'vitest' vi.mock('./Lens', () => ({ validateCollect: vi.fn(), diff --git a/packages/lens/src/Lens.ts b/packages/lens/src/Lens.ts index ec4925b3a..1ce9e6660 100644 --- a/packages/lens/src/Lens.ts +++ b/packages/lens/src/Lens.ts @@ -5,6 +5,7 @@ import { type CollectValidationParams, type PluginActionValidation, type QuestCompletionPayload, + ValidationNotValid, } from '@rabbitholegg/questdk-plugin-utils' import { type Address } from 'viem' @@ -45,14 +46,8 @@ export const validateCollect = async ( ) return hasCollected } catch (err) { - if (err instanceof Error) { - const error = new Error(err.message) - error.name = 'ValidationNotValid' - throw error - } else { - console.error(err) - throw new Error('Unknown error') - } + console.error('[lens-plugin] Error while validating collect action') + throw new ValidationNotValid(err instanceof Error ? err : String(err)) } } diff --git a/packages/lens/src/alchemy.ts b/packages/lens/src/alchemy.ts new file mode 100644 index 000000000..d9378154a --- /dev/null +++ b/packages/lens/src/alchemy.ts @@ -0,0 +1,9 @@ +import { Alchemy, Network } from 'alchemy-sdk' + +const settings = { + apiKey: process.env.ALCHEMY_API_KEY, + network: Network.MATIC_MAINNET, + requestTimeout: 2000, +} + +export const alchemy = new Alchemy(settings) diff --git a/packages/lens/src/client.ts b/packages/lens/src/client.ts deleted file mode 100644 index f462b1ce4..000000000 --- a/packages/lens/src/client.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { chainIdToViemChain } from '@rabbitholegg/questdk-plugin-utils' -import { type PublicClient, createPublicClient, http } from 'viem' - -export function getClient(chainId: number) { - const client = createPublicClient({ - chain: chainIdToViemChain(chainId), - transport: http(), - }) as PublicClient - - return client -} diff --git a/packages/lens/src/validate.ts b/packages/lens/src/validate.ts index 4edd75df9..151bbbc36 100644 --- a/packages/lens/src/validate.ts +++ b/packages/lens/src/validate.ts @@ -1,12 +1,13 @@ +import { alchemy } from './alchemy' import { LensClient, - production, - type SimpleCollectOpenActionSettingsFragment, type MultirecipientFeeCollectOpenActionSettingsFragment, + type SimpleCollectOpenActionSettingsFragment, + production, } from '@lens-protocol/client' -import { getClient } from './client' -import { Chains } from '@rabbitholegg/questdk-plugin-utils' -import { Address } from 'viem' +import { GetTransfersForOwnerTransferType } from 'alchemy-sdk' +import axios from 'axios' +import { Address, zeroAddress } from 'viem' const lensClient = new LensClient({ environment: production, @@ -20,53 +21,96 @@ export async function hasAddressCollectedPost( if (collectAddress === null) { return false } - const amountOwned = await checkAddressOwnsCollect(address, collectAddress) - return amountOwned > 0n + return await checkAddressMintedCollect(address, collectAddress) } export async function getCollectAddress(postId: string) { - const result = await lensClient.publication.fetch({ - forId: postId, - }) - if (!result || result?.__typename === 'Mirror') { + try { + const result = await lensClient.publication.fetch({ + forId: postId, + }) + if (!result || result?.__typename === 'Mirror') { + return null + } + const openActions = result.openActionModules + if (!openActions || openActions.length === 0) { + return null + } + const collectActions = openActions.filter( + (action) => + action.__typename === 'SimpleCollectOpenActionSettings' || + action.__typename === 'MultirecipientFeeCollectOpenActionSettings', + ) as Array< + | MultirecipientFeeCollectOpenActionSettingsFragment + | SimpleCollectOpenActionSettingsFragment + > + + const collectNft = collectActions.find((action) => action.collectNft) + const collectAddress = collectNft?.collectNft?.toLowerCase() + return (collectAddress as Address) ?? null + } catch (error) { + console.error('Error fetching collect contract address', error) return null } - const openActions = result.openActionModules - if (!openActions || openActions.length === 0) { - return null +} + +export async function checkAddressMintedCollect( + actor: Address, + collectAddress: Address, +) { + try { + return await checkMintedUsingAlchemy(actor, collectAddress) + } catch (err) { + console.error('Error while using alchemy api', err) + return await checkMintedUsingReservoir(actor, collectAddress) } - const collectActions = openActions.filter( - (action) => - action.__typename === 'SimpleCollectOpenActionSettings' || - action.__typename === 'MultirecipientFeeCollectOpenActionSettings', - ) as Array< - | MultirecipientFeeCollectOpenActionSettingsFragment - | SimpleCollectOpenActionSettingsFragment - > +} - const collectNft = collectActions.find((action) => action.collectNft) - const collectAddress = collectNft?.collectNft?.toLowerCase() - return (collectAddress as Address) ?? null +async function checkMintedUsingAlchemy( + actor: Address, + collectAddress: Address, +) { + // alchemy will work without an api key, but we risk being rate-limited + if (!process.env.ALCHEMY_API_KEY) { + console.error('Alchemy API key not found') + } + + const options = { + contractAddresses: [collectAddress], + } + + const transfers = await alchemy.nft.getTransfersForOwner( + actor, + GetTransfersForOwnerTransferType.TO, + options, + ) + + // only count nfts that originate from zeroAddress + const mintedNfts = transfers.nfts.filter((nft) => nft.from === zeroAddress) + + return mintedNfts.length > 0 } -export async function checkAddressOwnsCollect( +async function checkMintedUsingReservoir( actor: Address, collectAddress: Address, ) { - const client = getClient(Chains.POLYGON_POS) - const result = await client.readContract({ - address: collectAddress as Address, - abi: [ - { - inputs: [{ internalType: 'address', name: 'owner', type: 'address' }], - name: 'balanceOf', - outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], - stateMutability: 'view', - type: 'function', - }, - ], - functionName: 'balanceOf', - args: [actor], + const baseUrl = 'https://api-polygon.reservoir.tools/users/activity/v6' + const params = new URLSearchParams({ + users: actor, + collection: collectAddress, + limit: '1', + types: 'mint', }) - return result + + const options = { + method: 'GET', + url: `${baseUrl}?${params.toString()}`, + headers: { + accept: '*/*', + }, + } + + const response = await axios.request(options) + return response.data?.activities?.length > 0 } diff --git a/packages/lens/vite.config.js b/packages/lens/vite.config.js index 57949049c..58a943ae5 100644 --- a/packages/lens/vite.config.js +++ b/packages/lens/vite.config.js @@ -2,7 +2,7 @@ export default { build: { rollupOptions: { - external: [/@rabbitholegg/, /@lens-protocol/], + external: [/@rabbitholegg/, /@lens-protocol/, 'alchemy-sdk'], }, lib: { entry: 'src/index.ts', diff --git a/packages/utils/src/errors/index.ts b/packages/utils/src/errors/index.ts index 7cb9d0d54..04d0db8f5 100644 --- a/packages/utils/src/errors/index.ts +++ b/packages/utils/src/errors/index.ts @@ -1 +1,4 @@ -export { PluginActionNotImplementedError } from './plugin' +export { + PluginActionNotImplementedError, + ValidationNotValid, +} from './plugin' diff --git a/packages/utils/src/errors/plugin.ts b/packages/utils/src/errors/plugin.ts index 4dfd60d4f..dcafaf00a 100644 --- a/packages/utils/src/errors/plugin.ts +++ b/packages/utils/src/errors/plugin.ts @@ -4,3 +4,16 @@ export class PluginActionNotImplementedError extends Error { this.name = 'PluginActionNotImplementedError' } } + +export class ValidationNotValid extends Error { + constructor(input: string | Error) { + if (input instanceof Error) { + super(input.message) + Object.assign(this, input) + + this.name = 'ValidationNotValid' + } else { + super(input) + } + } +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 07f390953..b2929af68 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -141,4 +141,7 @@ export { PremintActionFormSchema, } from './types' -export { PluginActionNotImplementedError } from './errors' +export { + PluginActionNotImplementedError, + ValidationNotValid, +} from './errors' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1787c90ac..5e1ce8b6a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -350,6 +350,9 @@ importers: '@rabbitholegg/questdk-plugin-utils': specifier: workspace:* version: link:../utils + alchemy-sdk: + specifier: 3.3.1 + version: 3.3.1 packages/llama: dependencies: @@ -5798,7 +5801,7 @@ packages: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} dependencies: - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.5 transitivePeerDependencies: - supports-color @@ -5854,6 +5857,30 @@ packages: uri-js: 4.4.1 dev: false + /alchemy-sdk@3.3.1: + resolution: {integrity: sha512-iH/wIhBsHr18NTV9G9WrNsk/ofBOrhKaxH1vG9IZN3t+sTrB5uKAMMgmKvvJHDnOJ2Fo/bTnYPgUWNqhQxEfCQ==} + dependencies: + '@ethersproject/abi': 5.7.0 + '@ethersproject/abstract-provider': 5.7.0 + '@ethersproject/bignumber': 5.7.0 + '@ethersproject/bytes': 5.7.0 + '@ethersproject/contracts': 5.7.0 + '@ethersproject/hash': 5.7.0 + '@ethersproject/networks': 5.7.1 + '@ethersproject/providers': 5.7.2 + '@ethersproject/units': 5.7.0 + '@ethersproject/wallet': 5.7.0 + '@ethersproject/web': 5.7.1 + axios: 1.7.2 + sturdy-websocket: 0.2.1 + websocket: 1.0.35 + transitivePeerDependencies: + - bufferutil + - debug + - supports-color + - utf-8-validate + dev: false + /ansi-align@3.0.1: resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} dependencies: @@ -8461,7 +8488,7 @@ packages: engines: {node: '>= 10.17.0'} hasBin: true dependencies: - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.5 get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -9406,7 +9433,7 @@ packages: engines: {node: '>= 6'} dependencies: agent-base: 6.0.2 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.5 transitivePeerDependencies: - supports-color @@ -13166,6 +13193,10 @@ packages: dependencies: acorn: 8.12.0 + /sturdy-websocket@0.2.1: + resolution: {integrity: sha512-NnzSOEKyv4I83qbuKw9ROtJrrT6Z/Xt7I0HiP/e6H6GnpeTDvzwGIGeJ8slai+VwODSHQDooW2CAilJwT9SpRg==} + dev: false + /sucrase@3.35.0: resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==} engines: {node: '>=16 || 14 >=14.17'}