-
Notifications
You must be signed in to change notification settings - Fork 375
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
Implement identity proxies #7363
Conversation
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.
I like this direction a lot! It's quite simple and seems to fulfill the needs for this application. left some comments on some specifics. More generally, it would be useful to have a bit more clarity on where the identity rules execution will be and how that will be implemented. How parameters for the rules will be encoded is a question I have as well. My assumption is that it would be encoded into the Hub/Factory (such that a consumer with different parameter tuning needs would deploy a new hub/factory), but would like to see more detail on that.
contract IdentityProxyHub { | ||
mapping(bytes32 => IdentityProxy) public identityProxies; | ||
|
||
function getIdentityProxy(bytes32 identifier) public returns (IdentityProxy) { |
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.
In the view that Ethereum is a database, I feel like this should be split into two functions: A view function called getIdentityProxy
that returns the address or address(0)
and a state-modifying function createIdentityProxy
that creates a new proxy of no-ops or reverts if one already exists.
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.
So the getter already exists - identityProxies
is a public mapping. I think my intuition for why getIdentityProxy
works the way it does is akin to the Singleton pattern. There's essentially supposed to be a singleton IdentityProxy per identifier, and this is the function for getting it.
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.
I agree that the naming here is misleading
imo any function prefixed with get
I would expect to only use eth_call
against
return identityProxies[identifier]; | ||
} | ||
|
||
function makeCall(bytes32 identifier, address destination, uint256 value, bytes calldata data) |
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.
Instead of having the ownership check being here, and needing an external call to the proxy contract, why not have the Identity proxy execute the logic and have the caller call it directly to save gas and a bit of contract complexity. (The caller will already need to be aware of the proxies address to use it for it's application, and it can look up that address as a view call if it doesn't have it)
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.
I was actually thinking about this as well. I don't feel particularly strongly and would be open to moving the heuristic to IdentityProxy instead.
The benefits of the current approach is less deployed bytecode in storage (at the cost of slightly higher gas spent over time) and that you only need to care about the single IdentityProxyHub address if all you're doing is making calls (you don't even need to know your proxy's address since currently makeCall
will provision it/look it up for you if you don't know it), which could make some parts of applications simpler.
That said, I do agree that long term this will burn more gas, assuming we expect users to make many makeCall
s. Guess that's not entirely true in the use case we're currently building for (account recovery), but could become true depending on what these contracts are going to be used for in the future.
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.
I'm glad you are thinking about this and based on our use case, account recovery, we can assume that we may deploy many contracts that will never be called, and those that are will be called only a few times. So I agree with your assessment that including the check here will use less storage, and its likely to use less gas as well.
At first I found this a bit more complex from a mental model standpoint, but on second reading I no longer think so. The lack of ability to use the IdentityProxy
in a standalone fashion also seems like an unnecessary feature with some more thinking. Overall, I like this way of doing it 👍
} | ||
|
||
function makeCall(address destination, uint256 value, bytes calldata data) external onlyDeployer { | ||
ExternalCall.execute(destination, value, data); |
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.
If the identity logic were executed here instead of in the deployer, it would be possible to deploy these in a trust-less standalone fashion, or via the factory. It would also remove an external call from the transaction execution, which is a nice gas savings.
|
||
import "../common/ExternalCall.sol"; | ||
|
||
contract IdentityProxy { |
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.
I am against overloading Proxy
like this
We have tooling all over the place which assumes this suffix implies OZ unstructured storage proxy pattern
Is there a synonym we could use or a different naming scheme?
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.
On a slightly different naming angle, as the current contract is written it actually has nothing to do with identity and could be used in more generic ways. So we might remove that word from its name unless the heuristic call is moved into this code.
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.
Forwarder
? Relayer
? Maybe a little more abstract, but IdentityAlias
if we keep the semantics of using it in the identity scheme in the name.
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.
I like Relay
personally but basically anything other than Proxy
is good imo
Based on how this is shaping up, we should consider using CREATE2 here to avoid deploying the Tooling support for CREATE2 is not perfect, but Solidity added support in 0.6.2 and OpenZeppelin has a guide on how to use CREATE2 before that point as well. Using CREATE2 here would be relatively simple. On each call of The address can be computed using a formula from the Solidity documentation, making function getIdentityProxy(bytes32 identifier) public view returns (address) {
return address(bytes20(keccak256(abi.encodePacked(
byte(0xff),
address(this),
identifier,
keccak256(abi.encodePacked(
type(IdentityProxy).creationCode
))
))));
} It's a bit more complicated, especially conceptually for those unfamiliar with CREATE2, but it would be a substantial gas savings over the long term. |
Thanks for all the great comments, @nategraf. I really like the CREATE2 idea - that would really minimize overall gas costs and storage requirements, especially considering the use case of phone number recovery where hopefully not many identity proxies need to be deployed or used. Small, but important, technical note to what you wrote: I think the |
A few design questions:
|
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.
Overall, looks great! I had several smaller comments
On the hasMostCompletions
using strictly greater than, I agree with the current implementation, allowing the call if the caller has at least as many completions as any other account. It's true that this allows for more than one account to be linked to a number under this heuristic at a time, but that is not entirely unexpected, especially given the application to account recovery.
On EIP-1167, I agree with your intuition that we should skip that for now.
abi.encodePacked(type(IdentityProxy).creationCode) | ||
); | ||
|
||
mapping(bytes32 => IdentityProxy) public identityProxies; |
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.
Do we need this mapping given that we can always calculate the address from the identifier in a pure manner? If we don't have this we can save storage and reduce the number of state reads and writes, which ultimately saves gas as well.
It's usage below, as a way to tell if the contract is already deployed, could be replaced by a call to EXTCODESIZE
to check if it is nonzero. (stack overflow link). (IIUC, we are not susceptible to the trick here of the code size being zero during constructor execution because we deploy the contract from this factory)
* @return True if the given address is the likely owner of the given | ||
* identifier, false otherwise. | ||
*/ | ||
function passesIdentityHeuristic(address addr, bytes32 identifier) public returns (bool) { |
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.
function passesIdentityHeuristic(address addr, bytes32 identifier) public returns (bool) { | |
function passesIdentityHeuristic(address addr, bytes32 identifier) public view returns (bool) { |
IAttestations attestations = getAttestations(); | ||
(uint32 completed, uint32 requested) = attestations.getAttestationStats(identifier, addr); | ||
|
||
bool hasEnoughCompletions = completed >= 3; |
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.
It would be nice to have the parameters of this heuristic be set as a contract field in the constructor
. (Better still if it were immutable
, but I notice we are not yet using Solidity ^0.6.5)
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.
Is it possible to set these as constants in a solidity constructor? Would be great to avoid unnecessary storage reads in the proxy functionality.
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.
So that's what immutable does IIUC. (i.e. immutable variables get writing into the deployed bytecode rather than being a storage variable)
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.
If we can't use immutable
we might still use a constant
just to make it a bit more parameterized (even though it can't be set in the constructor)
|
||
bool completedOverHalfRequests = false; | ||
if (completed > 0) { | ||
completedOverHalfRequests = requested / completed < 2; |
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.
A UX consideration for having this be strictly less than rather than less than or equal is that if a user attempts verification once, and fails all three, they again and completes all three, they will not be able to pass this rule. We should compare the relative security of half vs half+1 and compare that to this UX tradeoff.
if (otherCompleted > completed) { | ||
hasMostCompletions = false; | ||
break; | ||
} |
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.
Now for a totally unimportant suggestion
if (otherCompleted > completed) { | |
hasMostCompletions = false; | |
break; | |
} | |
hasMostCompletions = hasMostCompletions && otherCompleted <= completed |
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.
don't we want the short-circuit from the break
?
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.
In my opinion, no. We should optimize for the passing case, and I don't think we need to worry about gas usage in the failing case. The suggested change gets rid of a conditional (i.e. a JUMPI
), replacing it with an AND
, on every iteration to save a bit gas, and it makes the control flow a bit easier to reason about.
|
||
bool hasMostCompletions = true; | ||
address[] memory addresses = attestations.lookupAccountsForIdentifier(identifier); | ||
for (uint256 i = 0; i < addresses.length; i++) { |
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.
Is there a DoS vector here where an attacker fills up the account list for an identifier until this function cannot be executed?
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.
it will always just run out of gas
isn't this the power of gas limit?
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.
An attestations record isn't saved to storage until at least one attestation is completed by an address for a given identifier. So this DoS would only be possible for an attacker that can easily generate valid attestations - i.e. actually has control over the number or mass validator collusion.
* @dev This can only be called by the deployer of this contract, presumably | ||
* the IdentityProxyHub after it checks the identity heuristic. | ||
*/ | ||
function makeCall(address destination, uint256 value, bytes calldata data) external onlyDeployer { |
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.
Should this be payable
to allow passing value
through? (Same question applies for IdentityProxyHub.makeCall
)
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.
Great catch, sending value was incorrectly implemented. Improved and added a test.
const IdentityProxy: IdentityProxyContract = artifacts.require('IdentityProxy') | ||
const IdentityProxyTest: IdentityProxyTestContract = artifacts.require('IdentityProxyTest') | ||
|
||
contract('IdentityProxyHub', (accounts: string[]) => { |
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.
I notice this does not test sending calls with value
👀 Is that a feature we are not supporting here?
beforeEach(async () => { | ||
address = await identityProxyHub.getIdentityProxy(identifier) | ||
await identityProxyHub.getOrDeployIdentityProxy(identifier) | ||
}) |
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.
Having beforeEach
here means the case where the relayer contract is deployed as part of makeCall
is not tested. Testing each of the following cases with the relayer predeployed and not would be good.
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.
I don't think it's worth the mess of redoing all tests both ways. Do you think just removing the call to getOrDeploy
here would be enough? That way we're never relying on the proxy having been deployed beforehand. The function itself is tested by itself in some unit tests above.
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.
I'd be happy with that solution 👍
|
||
bool hasMostCompletions = true; | ||
address[] memory addresses = attestations.lookupAccountsForIdentifier(identifier); | ||
for (uint256 i = 0; i < addresses.length; i++) { |
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.
it will always just run out of gas
isn't this the power of gas limit?
if (otherCompleted > completed) { | ||
hasMostCompletions = false; | ||
break; | ||
} |
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.
don't we want the short-circuit from the break
?
@@ -0,0 +1,25 @@ | |||
pragma solidity ^0.5.13; | |||
|
|||
library Create2 { |
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.
this is nice!
assembly { | ||
deployedAddress := create2(0, add(initCode, 32), mload(initCode), salt) | ||
if iszero(extcodesize(deployedAddress)) { | ||
revert(0, 0) |
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.
can we revert with something more descriptive?
I'm not familiar with how the calling contract can consume this value
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.
I tried to poke around assembly a bit, but didn't find a way that would be nice.
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.
Exciting!
await attestations.request(identifier, 0, '0x0', '0x0') | ||
await attestations.request(identifier, 0, '0x0', '0x0') | ||
await attestations.request(identifier, 0, '0x0', '0x0') | ||
await attestations.request(identifier, 0, '0x0', '0x0') | ||
await attestations.request(identifier, 0, '0x0', '0x0') |
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.
we could dedupe these easily but nbd for now
Description
This PR introduces the concept of identity proxies, which are smart contract addresses from which calls can be made only by "owners" of a related identity. "Owning" an identity is determined by an on-chain heuristic using the Attestations contract.
Tested
Suite of unit tests
Related issues
Backwards compatibility
Only introduces new contracts.