This document assumes that you have read the Sphinx Merkle Tree specification. Please read it before continuing.
The SphinxModuleProxy
submits transactions in a Gnosis Safe and verifies that the Gnosis Safe owners have approved the transactions. Each SphinxModuleProxy
belongs to a single Gnosis Safe.
A SphinxModuleProxy
is a minimal, non-upgradeable
EIP-1167 proxy that delegates calls to a SphinxModule
implementation contract. In production, users will interact with a SphinxModuleProxy
instead of
the SphinxModule
implementation contract, which is why this specification describes the expected
behavior of the proxy. The implementation contract will be locked so nobody can deploy directly
through it.
Vocabulary notes:
- An executor is an address with sole permission to execute a deployment in a Gnosis Safe. Normally, deployments will be executed by Sphinx's backend, but the Gnosis Safe owners can specify any executor they'd like.
- A user is a set of Gnosis Safe owners. We use these terms interchangeably throughout this document.
- Relevant Files
- Overview
- Deployment Process
- High-Level Invariants
- Function-Level Invariants
- Assumptions
- Footnotes
- The interface:
ISphinxModule.sol
- The implementation contract:
SphinxModule.sol
- Unit tests:
SphinxModuleProxy.t.sol
- Key data structures:
SphinxDataTypes.sol
Note: There is no source file for the SphinxModuleProxy
because we use OpenZeppelin's Clones.sol
for deploying EIP-1167 proxies.
Here are the steps of a standard deployment:
- The Gnosis Safe owners sign a Merkle root off-chain with a meta transaction.
- The executor initiates the deployment by calling the
approve
function on the Gnosis Safe'sSphinxModuleProxy
. This verifies that a sufficient number of Gnosis Safe owners have signed the Merkle root, then sets the Merkle root as "active". - The executor submits the user's transactions by calling the
execute
function on the Gnosis Safe'sSphinxModuleProxy
, which forwards the calls to the Gnosis Safe. This step may involve multiple transactions for larger deployments.
Since a Merkle root can contain deployments across an arbitrary number of chains, this process will occur on every chain the owners approved in the first step.
It's impossible for the executor to submit anything that the Gnosis Safe owners have not explicitly approved.
There can only be one active Merkle root in a SphinxModuleProxy
contract at a time. A Merkle root will always exist in one of the five following states:
- Empty: The Merkle root has not been used in the
SphinxModuleProxy
before. - Approved: The Merkle root has been signed by the Gnosis Safe owners, and the
approve
function has been called on theSphinxModuleProxy
. This Merkle root is now "active". - Canceled: A Merkle root that was previously active but has been canceled by the Gnosis Safe owners. Canceled Merkle roots can never be re-approved or executed.
- Failed: A Merkle root will fail if one of the transactions reverts in the Gnosis Safe and the transaction must succeed (i.e.
requireSuccess == true
). Failed Merkle roots can never be re-approved or executed. - Completed: A Merkle root is considered complete after all the Merkle leaves have been executed on the target chain. Completed Merkle roots can never be re-approved or executed.
We've included a flow chart that highlights the deployment process:
graph TD
style C fill:#cccc66,stroke:#cccc00,stroke-width:2px,color:black
style B fill:#66cc66,stroke:#00cc00,stroke-width:2px,color:black
style F fill:#cc6666,stroke:#cc0000,stroke-width:2px,color:black
style K fill:#6699cc,stroke:#336699,stroke-width:2px,color:black
Z["approve(...)"] --> A[Is there one leaf in the new Merkle tree for the current chain?]
A -->|Yes| B[Completed]
A -->|No| C[Approved]
C --> D["execute(...)"]
D --> L[Was the Merkle root canceled?]
L -->|Yes| M[Revert]
L -->|No| N[Execute transaction in the Gnosis Safe]
N --> E[Did the transaction fail?]
E -->|Yes| F[Failed]
E -->|No| G[Are there any more leaves to execute for the current chain in this deployment?]
G -->|Yes| D
G -->|No| B
H["cancel(...)"] --> I[Is there an active Merkle root?]
I -->|No| J[Revert]
I -->|Yes| K[Canceled]
In this flow chart, you'll notice that it's possible to approve a Merkle root that contains a single leaf, in which case the Merkle root is marked as COMPLETED
immediately. This allows the Gnosis Safe owners to cancel a Merkle root that has been signed off-chain but is not yet active in the SphinxModuleProxy
.1
- Each
SphinxModuleProxy
must only be able to execute transactions on one Gnosis Safe.- Rationale: This ensures that a
SphinxModuleProxy
will not execute a deployment in one Gnosis Safe when it was actually meant for another Gnosis Safe.
- Rationale: This ensures that a
- There must be at most one active Merkle root in a
SphinxModuleProxy
at a time.- Rationale: This reduces complexity in the
SphinxModuleProxy
. We don't expect that Gnosis Safe owners will need to execute multiple deployments in parallel.
- Rationale: This reduces complexity in the
- Each leaf in a Merkle tree must be submitted exactly once on its target chain in order for the Merkle root to be considered "complete" on that chain.
- Rationale: Allowing a transaction to be executed multiple times or skipping a transaction can create a security risk for the user's deployment.
- The leaves in a Merkle tree must be submitted in ascending order on each chain according to the leaf's index.
- Rationale: Transactions in a deployment often need to follow a specific order, such as deploying a contract before initializing it. Reversing this sequence can result in security vulnerabilities.
- On a given network, a Merkle root can either contain a single
CANCEL
leaf or a singleAPPROVE
leaf optionally followed byEXECUTE
leaves.- Rationale:
- While a user might reasonably wish to cancel an old deployment and approve a new one using a single Merkle root, keeping these operations separate reduces complexity in the
SphinxModule
. - An
APPROVE
leaf is optionally followed byEXECUTE
leaves because users may omit theEXECUTE
leaves if they are canceling a Merkle root that has been signed off-chain, but is not yet active in theSphinxModuleProxy
.1
- While a user might reasonably wish to cancel an old deployment and approve a new one using a single Merkle root, keeping these operations separate reduces complexity in the
- Rationale:
- The Gnosis Safe owners must be able to cancel a Merkle root that has been signed off-chain, but is not yet active in the
SphinxModuleProxy
.1 - The Merkle proof verification logic must hash the Merkle leaf using the internal
_getLeafHash
function.- Rationale: This function double hashes the Merkle leaf to prevent second preimage attacks.
- It must be impossible to reuse a signed Merkle root in a different
SphinxModuleProxy
.2- Rationale: If a Gnosis Safe enables a new
SphinxModuleProxy
after executing deployments with a differentSphinxModuleProxy
, it must be impossible to re-execute all previous deployments through the newly enabledSphinxModuleProxy
, since this would be a security hazard.
- Rationale: If a Gnosis Safe enables a new
- It must be impossible to reuse a signed Merkle root in a different Gnosis Safe.3
- Rationale: If a set of owners sign a Merkle root using a meta transaction, the signature will be valid in all Gnosis Safe contracts that they own. It would be a security hazard if a Merkle root intended for one Gnosis Safe is executed in a different Gnosis Safe.
- It must be impossible to initialize the
SphinxModule
implementation contract directly (i.e., it must only be initializable through a proxy).- Rationale: This prevents the possibility that an attacker could take over an uninitialized
SphinxModule
implementation contract.
- Rationale: This prevents the possibility that an attacker could take over an uninitialized
- The
SphinxModuleProxy
must be initialized with a Gnosis Safe singleton that has a version compatible with Sphinx.4- Rationale: This prevents the user from mistakenly adding a
SphinxModuleProxy
to an incompatible Gnosis Safe, which could potentially lead to vulnerabilities in the Gnosis Safe.
- Rationale: This prevents the user from mistakenly adding a
- All of the behavior described in this specification must apply to all Gnosis Safe contracts supported by Sphinx.
- Must revert if this function has already been successfully called.
- Must revert if the input Gnosis Safe proxy is the zero address.
- Must revert if the input Gnosis Safe proxy's singleton has a
VERSION()
function that does not equal the version of a Gnosis Safe contract supported by Sphinx. - A successful call must set the Gnosis Safe proxy address in the
SphinxModuleProxy
.
function approve(bytes32 _root, SphinxLeafWithProof memory _leafWithProof, bytes memory _signatures) public
- Must revert if the
SphinxModuleProxy
calls this function directly or indirectly (i.e. re-entrancy is not allowed). - Must revert if there is an active Merkle root in the
SphinxModuleProxy
. - Must revert if the input Merkle root is
bytes32(0)
. - Must revert if the input Merkle root has been used before (i.e. its
status
must beEMPTY
). - Must revert if the input Merkle leaf does not yield the input Merkle root, given the input Merkle proof.
- Must revert if the Merkle leaf's type does not equal
APPROVE
. - Must revert if the Merkle leaf's index does not equal
0
. - The following conditions apply to the ABI-decoded Merkle leaf data:
- Must revert if the leaf data contains a Gnosis Safe address that does not equal the Gnosis Safe address in the
SphinxModuleProxy
. - Must revert if the leaf data contains a
SphinxModuleProxy
address that does not equal the current contract's address (i.e.address(this)
). - Must revert if the leaf data contains a Merkle root nonce that does not equal the current Merkle root nonce in the
SphinxModuleProxy
. - Must revert if the leaf data contains a
numLeaves
field that equals0
. - Must revert if the leaf data contains an
executor
field that does not equal the caller's address. - Must revert if the Merkle root cannot be executed on an arbitrary chain (as indicated by the
arbitraryChain
field) and the leaf data contains achainId
field that does not match the current chain ID. - Must revert if the Merkle root can be executed on an arbitrary chain (as indicated by the
arbitraryChain
field) and the leaf's chain ID field is not0
.- Rationale: This is just a convention. When
arbitraryChain
istrue
, the leaf's chain ID must be0
.
- Rationale: This is just a convention. When
- Must revert if the leaf data contains a Gnosis Safe address that does not equal the Gnosis Safe address in the
- Must revert if an insufficient number of Gnosis Safe owners have signed the EIP-712 data that contains the input Merkle root.
- A successful call must:
- Emit a
SphinxMerkleRootApproved
event in theSphinxModuleProxy
. - Set all of the fields in the
MerkleRootState
struct. - Increment the Merkle root nonce in the
SphinxModuleProxy
.
- Emit a
- If there is a single leaf in the Merkle tree for the current chain, a successful call must also:
- Emit a
SphinxMerkleRootCompleted
event in theSphinxModuleProxy
using the input Merkle root. - Set the
MerkleRootStatus
of the input Merkle root to beCOMPLETED
. - Remove the active Merkle root, preventing it from being approved in the future.
- Emit a
- If there is more than one leaf in the Merkle tree for the current chain, a successful call must also:
- Set the
MerkleRootStatus
of the input Merkle root to beAPPROVED
. - Set the active Merkle root equal to the input Merkle root.
- Set the
function cancel(bytes32 _root, SphinxLeafWithProof memory _leafWithProof, bytes memory _signatures) external
- Must revert if the
SphinxModuleProxy
calls this function directly or indirectly (i.e. re-entrancy is not allowed). - Must revert if there is no active Merkle root in the
SphinxModuleProxy
. - Must revert if the input Merkle root is
bytes32(0)
. - Must revert if the input Merkle root has been used before (i.e. its
status
must beEMPTY
). - Must revert if the input Merkle leaf does not yield the input Merkle root, given the input Merkle proof.
- Must revert if the input Merkle leaf's type does not equal
CANCEL
. - Must revert if the input Merkle leaf's index does not equal
0
. - The following conditions apply to the ABI-decoded Merkle leaf data:
- Must revert if the leaf data contains a Gnosis Safe address that does not equal the Gnosis Safe address in the
SphinxModuleProxy
. - Must revert if the leaf data contains a
SphinxModuleProxy
address that does not equal the current contract's address (i.e.address(this)
). - Must revert if the leaf data contains a Merkle root nonce that does not equal the current Merkle root nonce in the
SphinxModuleProxy
. - Must revert if the Merkle root to cancel does not equal the active Merkle root in the
SphinxModuleProxy
. - Must revert if the leaf data contains an
executor
field that does not equal the caller's address. - Must revert if the leaf data contains a
chainId
field that does not match the current chain ID.
- Must revert if the leaf data contains a Gnosis Safe address that does not equal the Gnosis Safe address in the
- Must revert if an insufficient number of Gnosis Safe owners have signed the EIP-712 data that contains the input Merkle root.
- A successful call must:
- Emit a
SphinxMerkleRootCanceled
event in theSphinxModuleProxy
. - Set the active Merkle root's status to
CANCELED
. - Set the active Merkle root to
bytes32(0)
. - Emit a
SphinxMerkleRootCompleted
event in theSphinxModuleProxy
. - Set all of the fields in the
MerkleRootState
struct for the input Merkle root. - Increment the Merkle root nonce in the
SphinxModuleProxy
.
- Emit a
- Must revert if the
SphinxModuleProxy
calls this function directly or indirectly (i.e. re-entrancy is not allowed). - Must revert if the input
_leavesWithProofs
array does not contain any elements. - Must revert if there is no active Merkle root.
- Must revert if the caller is not the executor specified in the
approve
function. - Must revert if the number of leaves executed for the current Merkle root is greater than the
numLeaves
specified in theapprove
function. - For each element of the
_leavesWithProofs
array:- Must revert if the current Merkle leaf does not yield the active Merkle root, given the current Merkle proof.
- Must revert if the current Merkle leaf's type does not equal
EXECUTE
. - Must revert if the Merkle root cannot be executed on an arbitrary chain (as indicated by the
arbitraryChain
field) and the leaf data contains achainId
field that does not match the current chain ID. - Must revert if the Merkle root can be executed on an arbitrary chain (as indicated by the
arbitraryChain
field) and the leaf's chain ID field is not0
.- Rationale: This is just a convention. When
arbitraryChain
istrue
, the leaf's chain ID must be0
.
- Rationale: This is just a convention. When
- Must revert if the current Merkle leaf is executed in the incorrect order (i.e. its index isn't correct).
- Must revert if the transaction has an insufficient amount of gas.
- A successful iteration must:
- Increment the number of leaves executed for the active Merkle root by
1
. - Attempt to execute a transaction in the user's Gnosis Safe using the data in the current Merkle leaf.
- The call to the user's Gnosis Safe must never revert.
- Rationale: This would cause the Merkle root to be active indefinitely until they manually cancel it.
- Assumptions:
- The user-supplied
gas
amount is low enough to execute on the current network (e.g. it's not greater than the current block gas limit). - The account at the Gnosis Safe's address is one of the Gnosis Safe contracts supported by Sphinx.
- The user-supplied
- If the call to the Gnosis Safe is successful:
- Must emit a
SphinxActionSucceeded
event in theSphinxModuleProxy
.
- Must emit a
- If the call to the Gnosis Safe is unsuccessful for any reason:
- Must emit a
SphinxActionFailed
event in theSphinxModuleProxy
.
- Must emit a
- If the call to the Gnosis Safe is unsuccessful for any reason and the current leaf requires a success:
- Must emit a
SphinxMerkleRootFailed
event in theSphinxModuleProxy
. - Must set the active Merkle root's
MerkleRootStatus
equal toFAILED
. - Must remove the active Merkle root, preventing it from being approved in the future.
- Must exit the
execute
function immediately.
- Must emit a
- Increment the number of leaves executed for the active Merkle root by
- If there are no more leaves to execute for the active Merkle root:
- Must emit a
SphinxMerkleRootCompleted
event in theSphinxModuleProxy
using the active Merkle root. - Must set the active Merkle root's
MerkleRootStatus
equal toCOMPLETED
. - Must remove the active Merkle root, preventing it from being approved in the future.
- Must emit a
- Must double-hash the ABI-encoded Merkle leaf.
- Rationale: We double-hash to prevent second preimage attacks, as recommended by OpenZeppelin's Merkle Tree library.
A buggy executor can:
- Wait an arbitrary amount of time to approve or execute a Merkle root that the Gnosis Safe owners have already signed.
- Remedy: The Gnosis Safe owners can cancel the deployment anytime.
- Partially execute a deployment.
Note: It's impossible for the executor to submit anything that the Gnosis Safe owners have not explicitly approved.
A malicious executor has the same two limitations as a buggy executor. In addition, a malicious executor can take advantage of its privilege as the sole executor of a deployment. There are a variety of ways that it can do this.
Some examples:
- If a deployment relies on the state of an existing smart contract, and if the executor can manipulate the state of that smart contract, then it could be possible for the executor to execute the deployment in a manner that is detrimental to the user. For example, say a deployment relies on
existingContract.myBoolean() == true
; otherwise it fails. If the executor can setexistingContract.myBoolean() == false
, then the deployment will fail. - The executor can interact with a contract in the same transaction it's deployed, which can be an "unfair advantage" for the executor. For example, suppose a deployed contract has an open token airdrop. In that case, the executor can deploy the contract and claim the airdropped tokens in the same transaction before any other account has a chance to claim them.
It's worth reiterating that the Gnosis Safe owners can choose anybody to be an executor, including themselves.
- A user could attempt to grief the executor by specifying an arbitrarily large gas amount for a transaction,
which would prevent the deployment from being executable.
- Remedy: The executor should resolve this off-chain by determining if the gas amount for a transaction is too high. This is not a concern of the protocol.
- A malicious user could pay less than the cost of the deployment.
- Remedy: Billing should be handled off-chain, so it is not a concern of the protocol.
The SphinxModuleProxy
makes several calls to OpenZeppelin's Contracts library and Gnosis Safe's contracts. We test that the interactions with these contracts work properly in the unit tests for the SphinxModuleProxy
, but we don't thoroughly test the internals of these external contracts. Instead, we assume that they're secure and have been thoroughly tested by their authors. These contracts are:
- OpenZeppelin v4.9.3:
Initializable
: Initializes theSphinxModuleProxy
and prevents theSphinxModule
implementation from being initialized directly.ReentrancyGuard
: Prevents re-entrancy attacks in theapprove
andexecute
functions in theSphinxModuleProxy
.MerkleProof
: Verifies that a Merkle leaf belongs to a Merkle root, given a Merkle proof.
- Gnosis Safe:
Enum
(v1.3.0, v1.4.1): Contains the types of operations that can occur in a Gnosis Safe (i.e.Call
andDelegateCall
).GnosisSafe
: Contains the logic for verifying Gnosis Safe owner signature and executing the user's transactions.
The SphinxModuleProxy
's initializer
function checks that the user's Gnosis Safe proxy has a singleton with a compatible version by checking its string VERSION()
function. This is sufficient to prevent users from accidentally enabling a SphinxModuleProxy
in a Gnosis Safe with an incompatible version. However, we assume that the Gnosis Safe singleton isn't malicious. If a Gnosis Safe singleton has a valid VERSION()
function and arbitrary malicious logic, the SphinxModuleProxy
's initializer
function would still consider the singleton to be valid. We will use the same strategy as Gnosis Safe to mitigate this threat: maintain a list of Gnosis Safe singleton addresses off-chain.
Footnotes
-
The Gnosis Safe owners can cancel a Merkle root that hasn't been approved on-chain by signing a new Merkle root with the same Merkle root nonce and approving it on-chain. This prevents the old Merkle root from ever being approved. If the Gnosis Safe owners want to cancel a Merkle root without approving a new deployment, they can simply approve a Merkle root that contains a single
APPROVE
leaf. This will cause the new Merkle root to be marked asCOMPLETED
immediately. ↩ ↩2 ↩3 -
It's not possible to reuse a signed Merkle root in a different
SphinxModuleProxy
because we include the address of theSphinxModuleProxy
in theAPPROVE
Merkle leaf, and we check that this field matchesaddress(this)
in theSphinxModuleProxy
'sapprove
function. ↩ -
It's not possible to reuse a signed Merkle root in a different Gnosis Safe because the Merkle root can only be executed in one
SphinxModuleProxy
, and eachSphinxModuleProxy
can only execute transactions on one Gnosis Safe. ↩ -
We check that a Gnosis Safe singleton has a compatible version by checking its string
VERSION()
function. We considered checking thecodehash
of the Gnosis Safe proxy and singleton instead, since this provides additional protection against using an invalid Gnosis Safe. However, we decided not to use thecodehash
because one chain that we intend to support (Polygon zkEVM) appears to use a different calculation for thecodehash
. This is a difference that appears to be undocumented. An alternative solution is to pass an array of valid code hashes into the constructor of theSphinxModule
, which would allow us to support chains with non-standard code hashes like Polygon zkEVM. However, we decided not to do this because it would change the address of theSphinxModule
on these chains, which would change the address of the user's Gnosis Safe when deploying with Sphinx's standard Gnosis Safe deployment method. ↩