Many thanks to Matt for great discussion on that topic and for developing this tool to parse every contract and put them into a sparse Merkle tree which simplifies prototyping by not having to do zk proves over the patricia tree. Also, thanks to Vitalik for great input.
Managing multiple accounts can be challenging for several reasons, including issues with social recovery, privacy, L2s and overall user experience. Using stealth addresses complicates it further, as each interaction needs a new account. Vitalik suggested using zk-SNARKs to separate the account for transaction logic from the one holding assets. This allows for improved privacy, user experience and one-click social recovery.
For the following, it is recommended to first have a look at Vitalik's The Three Transitions post for some background.
In short, what we try to achieve is the following:
One-stop social recovery without compromising privacy.
A straightforward but privacy-compromising implementation would look like this:
The downside is that this links the logic- and asset-holding accounts publicly, compromising privacy.
By using zk-SNARKs, a user can prove they have permission to spend without revealing the connection between the logic-holding and asset-holding accounts.
The workflow then looks like the following:
Essentially, the user says, "I have provable permission from a logic-holding account to perform this action, and I know where that logic account is within the state."
In addition, by adding another (aggregator) contract between the logic and asset-holding contract, multiple proofs to different asset-holding accounts can be provided within a single trancation, allowing to treat accounts almost like UTXOs. The aggregator would be able to take multiple zk-proofs and forwards them to the respective asset-holding accounts for verification. Of course, such an aggregator may create a link between the individual asset-holding accounts - comprising privacy.
It is important to note that it's not necessarily a binary choice between employing SNARKs, thus relying on their security, and not using them at all and consequently missing out on the nice privacy properties. Instead of requiring a SNARK proof for spending, a compromise could be to use the SNARK proof to open a time window within the logic holding contract, preceded by a short delay, after which the owner of the logic-holding contract can change the slot0 value, thus change the spending logic. The delay until the time window opens can be used by the current owner of the contract to prevent the credential update.
The zk-SNARK setup includes private elements:
A prototype of the logic-holding account may look like this:
pragma solidity >=0.7.0 <0.9.0;
contract LogicHoldingAccount is Ownable {
uint256 public slot0 = 0x1234; // hashed secret
uint256 public nonce = 0; // keep track of key changes
address public owner;
function updateOwner(uint256 newValue) public onlyOwner {
nonce += 1;
slot0 = newValue;
}
}
slot0
value and increments the nonce.This contract keeps track of the owner's current spending logic (slot0
) and allows for updates via the updateOwner
function.
pragma solidity >=0.7.0 <0.9.0;
contract AssetHoldingAccount {
uint256 public logicHoldingAccountHash = 1234...;
// Scalar field size, Base field size, Verification Key data, etc.
// ...
function verifyProof(
uint[2] calldata _pA,
uint[2][2] calldata _pB,
uint[2] calldata _pC,
uint[2] calldata _pubSignals
) public view returns (bool val) {
// Snarkjs assembly code for proof verification...
// ...
}
// _pubSignals[0] - the root of the contract-slot0||nonce Merkle tree
// _pubSignals[1] - the hased logic-holder address
function execute(
address payable to,
uint256 amount,
uint[2] calldata _pA,
uint[2][2] calldata _pB,
uint[2] calldata _pC,
uint[2] calldata _pubSignals
) public {
contractRootPrecompile.getRoot(block.number)
uint256 specifiedLogicHolder = _pubSignals[1];
require(specifiedLogicHolder == logicHoldingAccountHash, "Not allowed");
bool validProof = verifyProof(_pA, _pB, _pC, _pubSignals) == true;
if (validProof) {
(bool success,) = to.call{value:amount}("");
require(success);
}
}
receive() external payable {}
}
The asset-holding account stores assets like ETH and enables users to submit proofs for withdrawals. By verifying that specifiedLogicHolder
matches logicHoldingAccountHash
, the owner can ensure that the asset-holding contract accepts proofs only from the authorized logic-holding contract, rather than any arbitrary contract.
The secret provided as a private signal when constructing the proof ensures that only the owner of the account, which contains the spending logic, can access funds from the asset-holding account.
The following circuit was developed using circom. The complete code can be found here.
pragma circom 2.0.2;
include "./modules/merkleTree.circom";
include "./modules/commitmentHasher.circom";
template Main(levels) {
signal input root;
signal input logicHoldingAddressHash;
signal input logicHoldingAddress;
signal input secret;
signal input nonce;
signal input pathElements[levels];
signal input pathIndices[levels];
component secretHasher = SecretHasher();
secretHasher.secret <== secret;
component hasher = CommitmentHasher();
hasher.logicHoldingAddress <== logicHoldingAddress;
hasher.secret <== secretHasher.hashedSecret;
hasher.nonce <== nonce;
hasher.logicHoldingAddressHash === logicHoldingAddressHash;
component tree = MerkleTreeChecker(levels);
tree.leaf <== hasher.commitment;
tree.root <== root;
for (var i = 0; i < levels; i++) {
tree.pathElements[i] <== pathElements[i];
tree.pathIndices[i] <== pathIndices[i];
}
}
component main {public [root,logicHoldingAddressHash]} = Main(N);
The circuit has a total of 7 signals, 2 of which are public, namely the Merkle tree root and the hashed address of the logic-holding account (it must be hashed before encoding it into the asset-holding contracts to prevent observers from clustering accounts based on the same logic-holder account).
In a world where users must manage multiple accounts, the need for a one-stop social recovery feature becomes increasingly important. Zk-SNARKs can be used to implement logic/asset-separated wallets, enabling users to use the 'logic' of account A to spend from account B without creating a link between the two. As an initial step, SNARK proofs could be utilized for actions less risky than asset spending. For instance, a good starting point might be to allow users to initiate a 'withdrawal request'. This request can be finalized by the user after a certain period, provided it is not disputed by the owner of the logic-holding contract.
This way, the owner of the logic-holding contract can still intervene, albeit in a privacy-breaking manner, in case something unexpected happens.