diff --git a/packages/gated-content/package.json b/packages/gated-content/package.json index a4dea02c07..51101d4e74 100644 --- a/packages/gated-content/package.json +++ b/packages/gated-content/package.json @@ -45,6 +45,9 @@ }, "license": "MIT", "dependencies": { + "@ethersproject/abi": "^5.7.0", + "@ethersproject/address": "^5.7.0", + "@ethersproject/bignumber": "^5.7.0", "@lens-protocol/shared-kernel": "workspace:*", "@lens-protocol/storage": "workspace:*", "@lit-protocol/constants": "2.1.62", @@ -60,8 +63,6 @@ "@babel/core": "^7.20.12", "@babel/preset-env": "^7.20.2", "@babel/preset-typescript": "^7.18.6", - "@ethersproject/address": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", "@ethersproject/contracts": "^5.7.0", "@ethersproject/hash": "^5.7.0", "@ethersproject/providers": "^5.7.2", @@ -71,7 +72,7 @@ "@graphql-codegen/typescript": "3.0.1", "@jest/globals": "^29.4.3", "@lens-protocol/eslint-config": "workspace:*", - "@lens-protocol/metadata": "0.1.0-alpha.29", + "@lens-protocol/metadata": "^0.1.0-alpha.30", "@lens-protocol/prettier-config": "workspace:*", "@lens-protocol/tsconfig": "workspace:*", "@types/jest": "29.5.3", @@ -91,13 +92,14 @@ "zod": "^3.22.0" }, "peerDependencies": { + "@ethersproject/abi": "^5.7.0", "@ethersproject/address": "^5.7.0", "@ethersproject/bignumber": "^5.7.0", "@ethersproject/contracts": "^5.7.0", "@ethersproject/hash": "^5.7.0", "@ethersproject/providers": "^5.7.2", "@ethersproject/wallet": "^5.7.0", - "@lens-protocol/metadata": "0.1.0-alpha.29", + "@lens-protocol/metadata": "0.1.0-alpha.30", "zod": "^3.22.0" }, "prettier": "@lens-protocol/prettier-config", diff --git a/packages/gated-content/src/__helpers__/mocks.ts b/packages/gated-content/src/__helpers__/mocks.ts index 58c1d09223..c4ce885b79 100644 --- a/packages/gated-content/src/__helpers__/mocks.ts +++ b/packages/gated-content/src/__helpers__/mocks.ts @@ -1,5 +1,6 @@ import { faker } from '@faker-js/faker'; import { + AdvancedContractCondition, Amount, Asset, CollectCondition, @@ -133,3 +134,18 @@ export function mockCollectCondition(overrides?: Partial): Col ...overrides, }; } + +export function mockAdvancedContractCondition( + overrides?: Partial, +): AdvancedContractCondition { + return { + type: ConditionType.ADVANCED_CONTRACT, + contract: mockNetworkAddress(), + abi: 'function balanceOf(address) external view returns (uint256)', + functionName: 'balanceOf', + params: [':userAddress'], + comparison: ConditionComparisonOperator.EQUAL, + value: '1', + ...overrides, + }; +} diff --git a/packages/gated-content/src/conditions/__tests__/advanced-contract-condition.spec.ts b/packages/gated-content/src/conditions/__tests__/advanced-contract-condition.spec.ts new file mode 100644 index 0000000000..d641bab9ec --- /dev/null +++ b/packages/gated-content/src/conditions/__tests__/advanced-contract-condition.spec.ts @@ -0,0 +1,86 @@ +import { Interface } from '@ethersproject/abi'; +import { toChainId, ConditionComparisonOperator, toEvmAddress } from '@lens-protocol/metadata'; + +import { mockNetworkAddress, mockAdvancedContractCondition } from '../../__helpers__/mocks'; +import { transformAdvancedContractCondition } from '../advanced-contract-condition'; +import { LitConditionType, SupportedChains } from '../types'; +import { resolveScalarOperatorSymbol } from '../utils'; +import { InvalidAccessCriteriaError } from '../validators'; + +describe(`Given the "${transformAdvancedContractCondition.name}" function`, () => { + describe('when called with an Advanced Contract condition', () => { + const operatorPairs = Object.values(ConditionComparisonOperator).map((operator) => ({ + operator, + expectedLitOperator: resolveScalarOperatorSymbol(operator), + })); + + it.each(operatorPairs)( + 'should support $operator comparisons', + ({ operator, expectedLitOperator }) => { + const condition = mockAdvancedContractCondition({ + comparison: operator, + }); + + const actual = transformAdvancedContractCondition(condition); + + const expectedLitAccessConditions = [ + { + conditionType: LitConditionType.EVM_CONTRACT, + chain: SupportedChains.ETHEREUM, + contractAddress: condition.contract.address.toLowerCase(), + functionAbi: new Interface([ + 'function balanceOf(address) view returns (uint256)', + ]).getFunction(condition.functionName), + functionName: 'balanceOf', + functionParams: [':userAddress'], + returnValueTest: { + comparator: expectedLitOperator, + value: '1', + key: '', + }, + }, + ]; + expect(actual).toEqual(expectedLitAccessConditions); + }, + ); + + it.each([ + { + description: 'if with invalid contract address', + condition: mockAdvancedContractCondition({ + contract: mockNetworkAddress({ + address: toEvmAddress('0x000000000000000000000000000000000000000000000000'), + }), + }), + }, + + { + description: 'if with invalid chain ID', + condition: mockAdvancedContractCondition({ + contract: mockNetworkAddress({ + chainId: toChainId(2), + }), + }), + }, + + { + description: 'if with invalid comparison value', + condition: mockAdvancedContractCondition({ + comparison: ConditionComparisonOperator.GREATER_THAN, + value: 'a', + }), + }, + + { + description: 'if with invalid comparison operator', + condition: mockAdvancedContractCondition({ + comparison: 'a' as ConditionComparisonOperator, + }), + }, + ])(`should throw an ${InvalidAccessCriteriaError.name} $description`, ({ condition }) => { + expect(() => transformAdvancedContractCondition(condition)).toThrow( + InvalidAccessCriteriaError, + ); + }); + }); +}); diff --git a/packages/gated-content/src/conditions/__tests__/erc20-condition.spec.ts b/packages/gated-content/src/conditions/__tests__/erc20-condition.spec.ts index 60df6f2dd4..d6d0fb6025 100644 --- a/packages/gated-content/src/conditions/__tests__/erc20-condition.spec.ts +++ b/packages/gated-content/src/conditions/__tests__/erc20-condition.spec.ts @@ -6,8 +6,9 @@ import { mockErc20OwnershipCondition, mockNetworkAddress, } from '../../__helpers__/mocks'; -import { resolveScalarOperatorSymbol, transformErc20Condition } from '../erc20-condition'; +import { transformErc20Condition } from '../erc20-condition'; import { LitConditionType, LitContractType, SupportedChains } from '../types'; +import { resolveScalarOperatorSymbol } from '../utils'; import { InvalidAccessCriteriaError } from '../validators'; describe(`Given the "${transformErc20Condition.name}" function`, () => { diff --git a/packages/gated-content/src/conditions/advanced-contract-condition.ts b/packages/gated-content/src/conditions/advanced-contract-condition.ts new file mode 100644 index 0000000000..e3dce812fb --- /dev/null +++ b/packages/gated-content/src/conditions/advanced-contract-condition.ts @@ -0,0 +1,150 @@ +import { Interface } from '@ethersproject/abi'; +import { BigNumber } from '@ethersproject/bignumber'; +import { AdvancedContractCondition, ConditionComparisonOperator } from '@lens-protocol/metadata'; +import { invariant, assertError } from '@lens-protocol/shared-kernel'; + +import { LitConditionType, LitEvmAccessCondition } from './types'; +import { toLitSupportedChainName, resolveScalarOperatorSymbol } from './utils'; +import { + assertValidAddress, + assertSupportedChainId, + InvalidAccessCriteriaError, +} from './validators'; + +export const transformAdvancedContractCondition = ( + condition: AdvancedContractCondition, +): Array => { + assertValidAddress(condition.contract.address); + assertSupportedChainId(condition.contract.chainId); + assertValidAbi(condition.abi, condition.functionName); + assertValidFunctionParams(condition); + assertValidComparison(condition); + + return [ + { + conditionType: LitConditionType.EVM_CONTRACT, + contractAddress: condition.contract.address.toLowerCase(), + chain: toLitSupportedChainName(condition.contract.chainId), + functionName: condition.functionName, + functionParams: condition.params || [], + functionAbi: new Interface([condition.abi]).getFunction(condition.functionName), + returnValueTest: { + key: '', + comparator: resolveScalarOperatorSymbol(condition.comparison), + value: condition.value, + }, + }, + ]; +}; + +function assertValidAbi(humanReadableAbi: string, functionName: string): void { + try { + const fn = new Interface([humanReadableAbi]).getFunction(functionName); + + // assert view function + invariant(fn.stateMutability === 'view', 'unsupported'); + + // assert single output + invariant(Array.isArray(fn.outputs) && fn.outputs.length === 1, 'unsupported'); + + // assert output is boolean or uint + invariant( + fn.outputs[0] && (fn.outputs[0].type === 'bool' || fn.outputs[0].type === 'uint256'), + 'unsupported', + ); + } catch (e: any) { + throw new InvalidAccessCriteriaError( + `Invalid abi: ${humanReadableAbi} or function: ${functionName}. Only view functions returning a single boolean or uint output are supported`, + ); + } +} + +/** + * verifies arguments are valid, as well as the prefixed `:userAddress` param exists + * @param condition the user provided condition object + */ +function assertValidFunctionParams(condition: AdvancedContractCondition): void { + try { + const fn = new Interface([condition.abi]).getFunction(condition.functionName); + let userAddressParamFound = false; + + invariant(fn.inputs.length === condition.params.length, 'wrong number of params'); + + fn.inputs.forEach((input, index) => { + const param = condition.params[index]; + + invariant(param, `param ${input.name || input.type} is missing`); + + if (input.baseType === 'array' || input.baseType === 'tuple') { + invariant( + Array.isArray(param) && param.length > 0, + `param ${input.name} expects an array argument`, + ); + + if (param.includes(':userAddress')) { + userAddressParamFound = true; + } + } + + if (input.baseType === 'address') { + if (param === ':userAddress') { + userAddressParamFound = true; + } else { + assertValidAddress(param); + } + } else if (input.baseType.includes('int')) { + BigNumber.from(param); + } else if (input.baseType === 'bool') { + invariant( + param === 'true' || param === 'false', + `param ${input.name} is invalid, must be a boolean)`, + ); + } else if (input.baseType === 'bytes') { + invariant(param.startsWith('0x'), `param ${input.name} is invalid, must be a hex string)`); + } + }); + + invariant(userAddressParamFound, `param :userAddress is missing`); + } catch (e: any) { + assertError(e); + throw new InvalidAccessCriteriaError(e.message); + } +} + +/** + * verifies the comparison is valid based on the function output type + * @param condition the user provided condition object + */ +function assertValidComparison(condition: AdvancedContractCondition): void { + try { + invariant( + Object.values(ConditionComparisonOperator).includes(condition.comparison), + `comparison operator ${condition.comparison} is unsupported`, + ); + + // get function return type + const fn = new Interface([condition.abi]).getFunction(condition.functionName); + + invariant( + Array.isArray(fn.outputs) && fn.outputs.length === 1 && fn.outputs[0], + 'function should have a single output', + ); + + // for bool, array, tuple results we only allow equal/not equal + if (['bool', 'string', 'bytes', 'address', 'array', 'tuple'].includes(fn.outputs[0].baseType)) { + invariant( + condition.comparison === ConditionComparisonOperator.EQUAL || + condition.comparison === ConditionComparisonOperator.NOT_EQUAL, + `comparison ${condition.comparison} is invalid for function return type ${fn.outputs[0].type}`, + ); + } + + // for uint results we allow all comparisons but we check the provided value + if (fn.outputs[0].baseType.includes('int')) { + BigNumber.from(condition.value); + } + } catch (e: any) { + assertError(e); + throw new InvalidAccessCriteriaError(e.message); + } +} diff --git a/packages/gated-content/src/conditions/erc20-condition.ts b/packages/gated-content/src/conditions/erc20-condition.ts index f0b1f080a6..275c2d2c85 100644 --- a/packages/gated-content/src/conditions/erc20-condition.ts +++ b/packages/gated-content/src/conditions/erc20-condition.ts @@ -1,5 +1,5 @@ import { parseFixed } from '@ethersproject/bignumber'; -import { Erc20OwnershipCondition, ConditionComparisonOperator } from '@lens-protocol/metadata'; +import { Erc20OwnershipCondition } from '@lens-protocol/metadata'; import { LitAccessCondition, @@ -7,23 +7,14 @@ import { LitContractType, LitKnownMethods, LitKnownParams, - LitScalarOperator, } from './types'; -import { toLitSupportedChainName } from './utils'; +import { toLitSupportedChainName, resolveScalarOperatorSymbol } from './utils'; import { assertValidAddress, assertSupportedChainId, InvalidAccessCriteriaError, } from './validators'; -export const resolveScalarOperatorSymbol = ( - operator: ConditionComparisonOperator, -): LitScalarOperator => { - if (operator in LitScalarOperator) return LitScalarOperator[operator]; - - throw new InvalidAccessCriteriaError(`Invalid operator: ${String(operator)}`); -}; - function parseConditionAmount(condition: Erc20OwnershipCondition): string { try { return parseFixed(condition.amount.value, condition.amount.asset.decimals).toString(); diff --git a/packages/gated-content/src/conditions/index.ts b/packages/gated-content/src/conditions/index.ts index 515ad6f44e..56576a0afc 100644 --- a/packages/gated-content/src/conditions/index.ts +++ b/packages/gated-content/src/conditions/index.ts @@ -4,6 +4,7 @@ import type { UnifiedAccessControlConditions } from '@lit-protocol/types'; import { EnvironmentConfig } from '../environments'; import * as gql from '../graphql'; +import { transformAdvancedContractCondition } from './advanced-contract-condition'; import { transformCollectCondition } from './collect-condition'; import { transformEoaCondition } from './eoa-condition'; import { transformErc20Condition } from './erc20-condition'; @@ -59,6 +60,8 @@ function transformSimpleCondition( return transformCollectCondition(condition, env, context); case raw.ConditionType.FOLLOW: return transformFollowCondition(condition, env); + case raw.ConditionType.ADVANCED_CONTRACT: + return transformAdvancedContractCondition(condition); default: throw new InvariantError( `Unknown access criteria type: \n${JSON.stringify(condition, null, 2)}`, @@ -155,11 +158,10 @@ function toRawSimpleCondition(gqlCondition: gql.ThirdTierCondition): raw.SimpleC case 'Erc20OwnershipCondition': return raw.erc20OwnershipCondition({ - chainId: gqlCondition.amount.asset.contract.chainId, condition: raw.ConditionComparisonOperator[gqlCondition.condition] ?? never(`Not supported condition: ${gqlCondition.condition}`), - contract: gqlCondition.amount.asset.contract.address, + contract: toRawNetworkAddress(gqlCondition.amount.asset.contract), decimals: gqlCondition.amount.asset.decimals, value: gqlCondition.amount.value, }); @@ -194,6 +196,19 @@ function toRawSimpleCondition(gqlCondition: gql.ThirdTierCondition): raw.SimpleC follow: raw.toProfileId(gqlCondition.follow), }); + case 'AdvancedContractCondition': + return { + type: raw.ConditionType.ADVANCED_CONTRACT, + contract: toRawNetworkAddress(gqlCondition.contract), + abi: gqlCondition.abi, + functionName: gqlCondition.functionName, + params: gqlCondition.params as string[], + comparison: + raw.ConditionComparisonOperator[gqlCondition.comparison] ?? + never(`Not supported condition: ${gqlCondition.comparison}`), + value: gqlCondition.value, + }; + default: assertNever(gqlCondition, 'Unknown access condition type'); } diff --git a/packages/gated-content/src/conditions/utils.ts b/packages/gated-content/src/conditions/utils.ts index 984c1106a4..da407c982d 100644 --- a/packages/gated-content/src/conditions/utils.ts +++ b/packages/gated-content/src/conditions/utils.ts @@ -1,6 +1,8 @@ import { assertNever, never } from '@lens-protocol/shared-kernel'; -import { LitOperator, SupportedChainId, SupportedChains } from './types'; +import { ConditionComparisonOperator } from '../../../../../lens-metadata'; +import { LitOperator, SupportedChainId, SupportedChains, LitScalarOperator } from './types'; +import { InvalidAccessCriteriaError } from './validators'; export const insertObjectInBetweenArrayElements = ( array: Array, @@ -30,3 +32,11 @@ export const toLitSupportedChainName = (chainId: SupportedChainId): SupportedCha assertNever(chainId, 'Unsupported chain id'); } }; + +export const resolveScalarOperatorSymbol = ( + operator: ConditionComparisonOperator, +): LitScalarOperator => { + if (operator in LitScalarOperator) return LitScalarOperator[operator]; + + throw new InvalidAccessCriteriaError(`Invalid operator: ${String(operator)}`); +}; diff --git a/packages/gated-content/src/graphql/generated.ts b/packages/gated-content/src/graphql/generated.ts index 63b59dfcbe..abac37b112 100644 --- a/packages/gated-content/src/graphql/generated.ts +++ b/packages/gated-content/src/graphql/generated.ts @@ -84,6 +84,23 @@ export type ActedNotification = { readonly publication: AnyPublication; }; +/** Condition that checks if the given on-chain contract function returns true. It only supports view functions */ +export type AdvancedContractCondition = { + readonly __typename: 'AdvancedContractCondition'; + /** The contract ABI. Has to be in human readable single string format containing the signature of the function you want to call. See https://docs.ethers.org/v5/api/utils/abi/fragments/#human-readable-abi for more info */ + readonly abi: Scalars['String']; + /** The check to perform on the result of the function. In case of boolean outputs, "EQUALS" and "NOT_EQUALS" are supported. For BigNumber outputs, you can use every comparison option */ + readonly comparison: ComparisonOperatorConditionType; + /** The address and chain ID of the contract to call */ + readonly contract: NetworkAddress; + /** The name of the function to call. Must be included in the provided abi */ + readonly functionName: Scalars['String']; + /** ABI encoded function parameters. In order to represent the address of the person trying to decrypt, you *have* to use the string ":userAddress" as this param represents the decrypting user address. If a param is an array or tuple, it will be in stringified format. */ + readonly params: ReadonlyArray; + /** The value to compare the result of the function against. Can be "true", "false" or a BigNumber */ + readonly value: Scalars['String']; +}; + export type AlreadyInvitedCheckRequest = { readonly for: Scalars['EvmAddress']; }; @@ -4527,6 +4544,7 @@ export type ThirdTierCondition = | Erc20OwnershipCondition | FollowCondition | NftOwnershipCondition + | AdvancedContractCondition | ProfileOwnershipCondition; export type ThreeDMetadataV3 = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e1b54b647..15653e768c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -779,6 +779,15 @@ importers: packages/gated-content: dependencies: + '@ethersproject/abi': + specifier: ^5.7.0 + version: 5.7.0 + '@ethersproject/address': + specifier: ^5.7.0 + version: 5.7.0 + '@ethersproject/bignumber': + specifier: ^5.7.0 + version: 5.7.0 '@lens-protocol/shared-kernel': specifier: workspace:* version: link:../shared-kernel @@ -819,12 +828,6 @@ importers: '@babel/preset-typescript': specifier: ^7.18.6 version: 7.18.6(@babel/core@7.20.12) - '@ethersproject/address': - specifier: ^5.7.0 - version: 5.7.0 - '@ethersproject/bignumber': - specifier: ^5.7.0 - version: 5.7.0 '@ethersproject/contracts': specifier: ^5.7.0 version: 5.7.0 @@ -853,8 +856,8 @@ importers: specifier: workspace:* version: link:../eslint-config '@lens-protocol/metadata': - specifier: 0.1.0-alpha.29 - version: 0.1.0-alpha.29(zod@3.22.0) + specifier: ^0.1.0-alpha.30 + version: 0.1.0-alpha.30(zod@3.22.0) '@lens-protocol/prettier-config': specifier: workspace:* version: link:../prettier-config @@ -7565,6 +7568,19 @@ packages: - supports-color dev: true + /@lens-protocol/metadata@0.1.0-alpha.30(zod@3.22.0): + resolution: {integrity: sha512-zYdd/H9Hnl5qdIBLE8rDELhWL7HpTs/g9fAjk3gwxFReJQiyfQJHjCVB1s7yEwlzSYdxhQNvB2jhY3JUO9+DaQ==} + engines: {node: ^v18.12.1} + peerDependencies: + zod: ^3.22.0 + peerDependenciesMeta: + zod: + optional: true + dependencies: + uuid: 9.0.1 + zod: 3.22.0 + dev: true + /@jest/schemas@29.4.3: resolution: {integrity: sha512-VLYKXQmtmuEz6IxJsrZwzG9NvtkQsWNnWMsKxqWNu3+CnfzJQhp0WDDKWLVV9hLKr0l3SLLFRqcYHjhtyuDVxg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}