-
Notifications
You must be signed in to change notification settings - Fork 81
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -45,6 +45,9 @@ | |
}, | ||
"license": "MIT", | ||
"dependencies": { | ||
"@ethersproject/abi": "^5.7.0", | ||
"@ethersproject/address": "^5.7.0", | ||
"@ethersproject/bignumber": "^5.7.0", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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", | ||
|
@@ -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", | ||
|
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, | ||
|
@@ -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, | ||
}; | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
---|---|---|
@@ -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); | ||
} | ||
} |
There was a problem hiding this comment.
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.