Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add advanced contract condition for gated-content #555

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions packages/gated-content/package.json
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are all these changes intentional?

The peer dependencies are meant to be satisfied by the @lens-protocol/client (and later by the @lens-protocol/react) packages as this package is not a standalone public package.

Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@
},
"license": "MIT",
"dependencies": {
"@ethersproject/abi": "^5.7.0",
"@ethersproject/address": "^5.7.0",
"@ethersproject/bignumber": "^5.7.0",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unless something is changed these should stay as peer dependency only. Maybe there is an issue somewhere else, can you please clarify what made you make this change?

"@lens-protocol/shared-kernel": "workspace:*",
"@lens-protocol/storage": "workspace:*",
"@lit-protocol/constants": "2.1.62",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions packages/gated-content/src/__helpers__/mocks.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { faker } from '@faker-js/faker';
import {
AdvancedContractCondition,
Amount,
Asset,
CollectCondition,
Expand Down Expand Up @@ -133,3 +134,18 @@ export function mockCollectCondition(overrides?: Partial<CollectCondition>): Col
...overrides,
};
}

export function mockAdvancedContractCondition(
overrides?: Partial<AdvancedContractCondition>,
): 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,
};
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see this helper but not use of it, was a test suite meant to be checked-in?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah tests are still WIP, will push them soon

Original file line number Diff line number Diff line change
@@ -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,
);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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`, () => {
Expand Down
150 changes: 150 additions & 0 deletions packages/gated-content/src/conditions/advanced-contract-condition.ts
Original file line number Diff line number Diff line change
@@ -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<LitEvmAccessCondition> => {
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);
}
}
13 changes: 2 additions & 11 deletions packages/gated-content/src/conditions/erc20-condition.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,20 @@
import { parseFixed } from '@ethersproject/bignumber';
import { Erc20OwnershipCondition, ConditionComparisonOperator } from '@lens-protocol/metadata';
import { Erc20OwnershipCondition } from '@lens-protocol/metadata';

import {
LitAccessCondition,
LitConditionType,
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();
Expand Down
Loading
Loading