Skip to content

Commit

Permalink
feat: advanced contract condition
Browse files Browse the repository at this point in the history
  • Loading branch information
zannis committed Oct 6, 2023
1 parent feec00a commit a9b46b5
Show file tree
Hide file tree
Showing 10 changed files with 332 additions and 27 deletions.
10 changes: 6 additions & 4 deletions packages/gated-content/package.json
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",
"@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,
};
}
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

0 comments on commit a9b46b5

Please sign in to comment.