Platform: Code4rena
Start Date: 23/05/2023
Pot Size: $32,600 USDC
Total HM: 5
Participants: 5
Period: 3 days
Judge: Picodes
Total Solo HM: 4
Id: 243
League: ETH
Rank: 2/5
Findings: 5
Award: $0.00
π Selected for report: 5
π Solo Findings: 4
π Selected for report: adriro
Data not available
Selector clashing can be used to trick users into calling base functions of the wallet.
Fallback handlers provide extensibility to the Ambire wallet. The main idea here is that functions not present in the wallet implementation are delegated to the fallback handler by using the fallback()
function.
Function dispatch in Solidity is done using function selectors. Selectors are represented by the first 4 bytes of the keccak hash of the function signature (name + argument types). It is possible (and not computationally difficult) to find different functions that have the same selector.
This means that a malicious actor can craft a fallback handler with a function signature carefully selected to match one of the functions present in the base AmbireAccount contract, and with an innocent looking implementation. While the fallback implementation may seem harmless, this function when called will actually trigger the function in the base AmbireAccount contract. This can be used, for example, to hide a call to setAddrPrivilege()
which could be used to grant control of the wallet to the malicious actor.
This is similar to the exploit reported on proxies in this article, which caused the proposal of the transparent proxy pattern.
As further reference, another similar issue can be found in the DebtDAO contest that could lead to unnoticed calls due to selector clashing (disclaimer: the linked report is authored by me).
It is difficult to provide a recommendation based on the current design of contracts. Any whitelisting or validation around the selector won't work as the main entrypoint of the wallet is the AmbireAccount contract itself. The solution would need to be based on something similar to what was proposed for transparent proxies, which involves segmenting the calls to avoid clashing, but this could cripple the functionality and simplicity of the wallet.
Other
#0 - Ivshti
2023-05-26T20:47:01Z
Iβm not sure if this is applicable: the use case of this is the Ambire team pushing out fallback handlers and allowing users to opt into them. While this does leaves an opportunity for us to be that malicious actor, Iβm not sure thereβs a better trade off here.
#1 - Picodes
2023-05-28T15:20:39Z
The scenario is convincing provided the attacker manages to have its malicious implementation of fallbackHandler
used by Ambire wallet users, which seems unlikely but doable. Furthermore, as there are no admin roles here, the possibility of this attack by the Ambire team is worth stating.
Overall, I think Medium severity is appropriate. I agree with the previous comments that there is no clear mitigation though aside from warning users about this.
#2 - c4-judge
2023-05-28T15:21:23Z
Picodes marked the issue as satisfactory
π Selected for report: adriro
Data not available
tryCatch
An attacker or malicious relayer can force the failure of transactions that rely on tryCatch()
by carefully choosing the gas limit.
The tryCatch()
function present in the AmbireAccount contract can be used to execute a call in the context of a wallet that is eventually allowed to fail, i.e. the operation doesn't revert if the call fails.
119: function tryCatch(address to, uint256 value, bytes calldata data) external payable { 120: require(msg.sender == address(this), 'ONLY_IDENTITY_CAN_CALL'); 121: (bool success, bytes memory returnData) = to.call{ value: value, gas: gasleft() }(data); 122: if (!success) emit LogErr(to, value, data, returnData); 123: }
EIP-150 introduces the "rule of 1/64th" in which 1/64th of the available is reserved in the calling context and the rest of it is forward to the external call. This means that, potentially, the called function can run out of gas, while the calling context may have some gas to eventually continue and finish execution successfully.
A malicious relayer, or a malicious actor that front-runs the transaction, can carefully choose the gas limit to make the call to tryCatch()
fail due out of gas, while still saving some gas in the main context to continue execution. Even if the underlying call in tryCatch()
would succeed, an attacker can force its failure, while the main call to the wallet is successfully executed.
The following test reproduces the attack. The user creates a transaction to execute a call using tryCatch()
to a function of the TestTryCatch contract, which simulates some operations that consume gas. The attacker then executes the bundle by carefully choosing the gas limit (450,000 units of gas in this case) so that the call to TestTryCatch fails due to out of gas, but the main call to execute()
in the wallet (here simplified by using executeBySender()
to avoid signatures) gets correctly executed.
Note: the snippet shows only the relevant code for the test. Full test file can be found here.
contract TestTryCatch { uint256[20] public foo; function test() external { // simulate expensive operation for (uint256 index = 0; index < 20; index++) { foo[index] = index + 1; } } } function test_AmbireAccount_ForceFailTryCatch() public { address user = makeAddr("user"); address[] memory addrs = new address[](1); addrs[0] = user; AmbireAccount account = new AmbireAccount(addrs); TestTryCatch testTryCatch = new TestTryCatch(); AmbireAccount.Transaction[] memory txns = new AmbireAccount.Transaction[](1); txns[0].to = address(account); txns[0].value = 0; txns[0].data = abi.encodeWithSelector( AmbireAccount.tryCatch.selector, address(testTryCatch), uint256(0), abi.encodeWithSelector(TestTryCatch.test.selector) ); // This should actually be a call to "execute", we simplify the case using "executeBySender" // to avoid the complexity of providing a signature. Core issue remains the same. vm.expectEmit(true, false, false, false); emit LogErr(address(testTryCatch), 0, "", ""); vm.prank(user); account.executeBySender{gas: 450_000}(txns); // assert call to TestTryCatch failed assertEq(testTryCatch.foo(0), 0); }
The context that does the call in tryCatch()
can check the remaining gas after the call to determine if the remaining amount is greater than 1/64 of the available gas before the external call.
function tryCatch(address to, uint256 value, bytes calldata data) external payable { require(msg.sender == address(this), 'ONLY_IDENTITY_CAN_CALL'); + uint256 gasBefore = gasleft(); (bool success, bytes memory returnData) = to.call{ value: value, gas: gasleft() }(data); + require(gasleft() > gasBefore/64); if (!success) emit LogErr(to, value, data, returnData); }
Other
#0 - Ivshti
2023-05-30T04:44:20Z
@Picodes we tend to disagree with the severity here - gas attacks are possible in almost all cases of using Ambire accounts through a relayer. It's also an inherent design compromise of ERC-4337 as well, and the only way to counter it is with appropriate offchain checks/reputation systems and griefing protections.
Also, the solution seems too finicky. What if the tryCatch
is called within execute (which it very likely will), which requires even more gas left to complete? Then 1) the solution won't be reliable 2) the attacker can make the attack anyway through execute
#1 - Picodes
2023-05-31T15:59:08Z
The main issue here is that the nonce is incremented despite the fact that the transaction wasn't executed as intended, which would force the user to resign the payload and would be a griefing attack against the user. I do break an important invariant which is that if the nonce is incremented, the transaction signed by the user was included as he intended.
Also, I think this can be used within tryCatchLimit
to pass a lower gasLimit
: quoting EIP150:
"If a call asks for more gas than the maximum allowed amount (i.e. the total amount of gas remaining in the parent after subtracting the gas cost of the call and memory expansion), do not return an OOG error; instead, if a call asks for more gas than all but one 64th of the maximum allowed amount, call with all but one 64th of the maximum allowed amount of gas (this is equivalent to a version of EIP-901 plus EIP-1142)."
#2 - c4-judge
2023-05-31T15:59:32Z
Picodes marked the issue as satisfactory
#3 - Ivshti
2023-06-01T08:01:27Z
The main issue here is that the nonce is incremented despite the fact that the transaction wasn't executed as intended, which would force the user to resign the payload and would be a griefing attack against the user. I do break an important invariant which is that if the nonce is incremented, the transaction signed by the user was included as he intended.
Also, I think this can be used within
tryCatchLimit
to pass a lowergasLimit
: quoting EIP150: "If a call asks for more gas than the maximum allowed amount (i.e. the total amount of gas remaining in the parent after subtracting the gas cost of the call and memory expansion), do not return an OOG error; instead, if a call asks for more gas than all but one 64th of the maximum allowed amount, call with all but one 64th of the maximum allowed amount of gas (this is equivalent to a version of EIP-901 plus EIP-1142)."
I'm not sure I undestand, the whole point of signing something that calls into tryCatch is that you don't care about the case where the nonce is incremented but the transaction is failing. What am I missing?
#4 - Picodes
2023-06-02T12:05:10Z
the whole point of signing something that calls into tryCatch is that you don't care about the case where the nonce is incremented but the transaction is failing
You don't care if the transactions fail because the sub-call is invalid, but you do if it's because the relayer manipulated the gas, right?
#5 - Ivshti
2023-06-15T12:35:48Z
the whole point of signing something that calls into tryCatch is that you don't care about the case where the nonce is incremented but the transaction is failing
You don't care if the transactions fail because the sub-call is invalid, but you do if it's because the relayer manipulated the gas, right?
Ok, I see the point here - prob repeating stuff that others said before but, trying to simplify - the relayer can rug users by taking their fee regardless of the fact that the inner transactions fail due to the relayer using a lower gasLimit. This would be possible if some of the sub-transactions use tryCatch
but the fee payment does not.
However, I'm not sure how the mitigation would work. Can't the relayer still calculate a "right" gas limit for which the tryCatch
will fail but the rest will succeed?
#6 - Picodes
2023-06-15T13:06:37Z
My understanding is that as usinggasleft() > gasBefore/64
, we know for sure than the inner call didn't failed due to an out of gas as it was called with 63*gasBefore/64
. So the relayer has to give enough gas for every subcall to execute fully, whether it is successful or not.
#7 - Ivshti
2023-06-15T13:18:50Z
I see, this sounds reasonable, i need a bit more time eto think about it and if it is, we'll apply this mitigation
Data not available
The recovery transaction can be replayed after a cancellation of the recovery procedure, reinstating the recovery mechanism.
The Ambire wallet provides a recovery mechanism in which a privilege can recover access to the wallet if they lose their keys. The process contains three parts, all of them considered in the execute()
function:
scheduledRecoveries
.Since nonces are only incremented when the bundle is executed, the call that triggers the recovery procedure can be replayed as long as the nonce stays the same.
This means that the recovery process can be re-initiated after a cancellation is issued by replaying the original call that initiated the procedure.
Note that this also works for cancellations. If the submitted recovery bundle is the same, then a cancellation can be replayed if the recovery process is initiated again while under the same nonce value.
SIGMODE_RECOVER
signature mode.SIGMODE_CANCEL
signature mode.Increment the nonce during a cancellation. This will step the nonce preventing any previous signature from being replayed.
... if (isCancellation) { delete scheduledRecoveries[hash]; + nonce = currentNonce + 1; emit LogRecoveryCancelled(hash, recoveryInfoHash, recoveryKey, block.timestamp); } else { scheduledRecoveries[hash] = block.timestamp + recoveryInfo.timelock; emit LogRecoveryScheduled(hash, recoveryInfoHash, recoveryKey, currentNonce, block.timestamp, txns); } return; ...
Other
#0 - Ivshti
2023-05-26T20:40:37Z
Excellent finding but on first thought it looks like #5
#1 - c4-judge
2023-05-28T15:30:44Z
Picodes marked the issue as primary issue
#2 - c4-judge
2023-05-28T15:30:56Z
Picodes marked the issue as satisfactory
#3 - c4-judge
2023-05-28T15:32:15Z
Picodes marked the issue as selected for report
#4 - Ivshti
2023-05-30T04:46:46Z
Solved here @Picodes @c4-judge https://github.com/AmbireTech/ambire-common/commit/1c0b06fbbbdd9aac1285d4fc4949f5b84f923238
π Selected for report: adriro
Data not available
Current settings may produce incompatible bytecode with some of the chains supported by the protocol.
The Ambire wallet supports and targets different chains, such as Ethereum, Polygon, Avalanche, BNB, Optimism, Arbitrum, etc. This information is available in their website.
All of the contracts in scope have the version pragma fixed to be compiled using Solidity 0.8.20. This new version of the compiler uses the new PUSH0 opcode introduced in the Shanghai hard fork, which is now the default EVM version in the compiler and the one being currently used to compile the project.
Here is an excerpt of the bytecode produced for the AmbireAccount contract in which we can see the presence of the PUSH0 opcode(full bytecode can be found in the file artifacts/contracts/AmbireAccount.sol/AmbireAccount.json
):
This means that the produced bytecode for the different contracts won't be compatible with the chains that don't yet support the Shanghai hard fork.
This could also become a problem if different versions of Solidity are used to compile contracts for different chains. The differences in bytecode between versions can impact the deterministic nature of contract addresses, potentially breaking counterfactuality.
Change the Solidity compiler version to 0.8.19 or define an evm version that is compatible across all of the intended chains to be supported by the protocol (see https://book.getfoundry.sh/reference/config/solidity-compiler?highlight=evm_vers#evm_version).
Other
#0 - Ivshti
2023-05-27T05:24:42Z
valid finding, do you know of any big mainstream chains that do not support PUSH0?
#1 - Picodes
2023-05-28T10:29:35Z
@Ivshti haven't checked for myself but it seems Arbitrum doesn't support PUSH0 yet for example: https://github.com/ethereum/solidity/issues/14254
#2 - Picodes
2023-05-28T10:33:03Z
Regarding the severity of the finding, I don't think the generic finding of "this contract uses 0.8.20 so won't work on some L2s" is of Medium severity as there is 0 chances that this leads to a loss of fund in production (the team will obviously see that it doesn't work and just change the compiler version).
However, in this context, I do agree with the warden that "the differences in bytecode between versions can impact the deterministic nature of contract addresses, potentially breaking counterfactuality". Therefore, Medium severity seems appropriate.
#3 - c4-judge
2023-05-28T10:33:43Z
Picodes marked the issue as satisfactory
#4 - Ivshti
2023-05-30T04:47:01Z
@Picodes @c4-judge solved here https://github.com/AmbireTech/ambire-common/commit/cf9c8b115a60df384ae8986a368bb65c56cd7e12
π Selected for report: adriro
Data not available
The AmbireAccount implementation can be destroyed, resulting in the bricking of all associated wallets.
The AmbireAccount contract has a constructor that setups privileges, these are essentially addresses that have control over the wallet.
58: constructor(address[] memory addrs) { 59: uint256 len = addrs.length; 60: for (uint256 i = 0; i < len; i++) { 61: // NOTE: privileges[] can be set to any arbitrary value, but for this we SSTORE directly through the proxy creator 62: privileges[addrs[i]] = bytes32(uint(1)); 63: emit LogPrivilegeChanged(addrs[i], bytes32(uint(1))); 64: } 65: }
Normally this constructor is not really used, as wallets are deployed using proxies. The proxy constructor is the actual piece of code that setups the privileges storage to grant initial permission to the owner of the wallet.
However these proxies need to rely on a reference implementation of the AmbireAccount contract. A single contract is deployed and its address is then injected into the proxy code.
The main issue is that privileges defined in the reference implementation have control over that instance, and could eventually force a destruction of the contract using a fallback handler with a selfdestruct
instruction (see PoC for a detailed explanation). This destruction of the implementation would render all wallets non-functional, as the proxies won't have any underlying logic code. Consequently, wallets would become inaccessible, resulting in potential loss of funds.
It is not clear the purpose of this constructor in the AmbireAccount contract. It may be present to facilitate testing. This issue can be triggered by a malicious deployer (or any of the defined privileges) or by simply setting up a wrong privilege accidentally. Nevertheless, its presence imposes a big and unneeded security risk, as the destruction of the reference implementation can render all wallets useless and inaccessible.
The following test reproduces the described issue. A deployer account deploys the implementation of the AmbireAccount contract that is later used by the user account to create a proxy (AccountProxy contract) over the implementation. The deployer then forces the destruction of the reference implementation using a fallback handler (Destroyer contract). The user's wallet is now inaccessible as there is no code behind the proxy.
The majority of the test is implemented in the setUp()
function in order to properly test the destruction of the contract (in Foundry contracts are deleted when the test is finalized).
Note: the snippet shows only the relevant code for the test. Full test file can be found here.
contract Destroyer { function destruct() external { selfdestruct(payable(address(0))); } } contract AccountProxy is ERC1967Proxy { // Simulate privileges storage mapping(address => bytes32) public privileges; constructor(address[] memory addrs, address _logic) ERC1967Proxy(_logic, "") { uint256 len = addrs.length; for (uint256 i = 0; i < len; i++) { // NOTE: privileges[] can be set to any arbitrary value, but for this we SSTORE directly through the proxy creator privileges[addrs[i]] = bytes32(uint(1)); } } } contract AuditDestructTest is Test { AmbireAccount implementation; AmbireAccount wallet; function setUp() public { // Master account implementation can be destroyed by any of the configured privileges address deployer = makeAddr("deployer"); address user = makeAddr("user"); // Lets say deployer creates reference implementation address[] memory addrsImpl = new address[](1); addrsImpl[0] = deployer; implementation = new AmbireAccount(addrsImpl); // User deploys wallet address[] memory addrsWallet = new address[](1); addrsWallet[0] = user; wallet = AmbireAccount(payable( new AccountProxy(addrsWallet, address(implementation)) )); // Test the wallet is working ok assertTrue(wallet.supportsInterface(0x4e2312e0)); // Now privilege sets fallback Destroyer destroyer = new Destroyer(); AmbireAccount.Transaction[] memory txns = new AmbireAccount.Transaction[](1); txns[0].to = address(implementation); txns[0].value = 0; txns[0].data = abi.encodeWithSelector( AmbireAccount.setAddrPrivilege.selector, address(0x6969), bytes32(uint256(uint160(address(destroyer)))) ); vm.prank(deployer); implementation.executeBySender(txns); // and destroys master implementation Destroyer(address(implementation)).destruct(); } function test_AmbireAccount_DestroyImplementation() public { // Assert implementation has been destroyed assertEq(address(implementation).code.length, 0); // Now every wallet (proxy) that points to this master implementation will be bricked wallet.supportsInterface(0x4e2312e0); } }
Remove the constructor from the AmbireAccount contract.
Other
#0 - Picodes
2023-05-28T15:47:07Z
On first reading, I'm confused because there is a constructor but no initializer, so I don't get how a wallet could be deployed behind a minimal proxy: how do you set the first privilege addresses?
So it seems that either the constructor needs to be changed to an initializer, or the intent is to deploy the whole bytecode for every wallet
#1 - Ivshti
2023-05-30T04:53:48Z
@Picodes we use a completely different mechanism in which we geenerate bytecode which directly SSTORES the relevant privileges
slots: https://github.com/AmbireTech/adex-protocol-eth/blob/master/js/IdentityProxyDeploy.js
We absolutely disagree with using an initializer, it is leaving too much room for error as it can be seen from the two Parity exploits.
That said, this finding is valid and removing the constructor is one solution, another is just ensuring we deploy the implementation with no privileges. Wyt?
#2 - Ivshti
2023-05-30T14:49:16Z
@Picodes we are in the process of fixing this by removing the constructor.
I would say this finding is excellent but I am considering whether the severity should be degraded, as once the implementation is deployed with empty privileges
, this issue doesn't exist. You can argue that this creates sort of a "trusted setup", where someone needs to watch what we're deploying, but this is a fundamental effect anyway, as someone needs to watch whether we're deploying the right code. The way we'll mitigate this in the future when we're deploying is by pre-signing deployment transactions with different gas prices and different networks and placing them on github for people to review or even broadcast themselves.
#3 - Ivshti
2023-05-30T17:45:34Z
@Picodes we decided to remove the constructor because it just makes things more obvious (that in production privileges are not set via the constructor)
but with that said, I just remembered that this vulnerability is mitigated by the fact the implementation will be deployed via CREATE2 and can be re-deployed
#4 - Picodes
2023-05-31T14:56:44Z
So:
Overall, considering that there shouldn't be any trust assumption in this repository, I think Medium severity is appropriate, under "the function of the protocol or its availability could be impacted, or leak value with a hypothetical attack path with stated assumptions, but external requirements".
#5 - c4-judge
2023-05-31T14:56:55Z
Picodes marked the issue as satisfactory
#6 - Ivshti
2023-05-31T15:25:53Z
So:
* this is in the end a trust issue and the report shows how the team could have used the constructor to grief users * the fact that the implementation is deployed via CREATE2 doesn't change the severity as the team could still be malicious. If anything it makes it even worse because it creates a scenario where the team could blackmail users to get paid for the redeployment
Overall, considering that there shouldn't be any trust assumption in this repository, I think Medium severity is appropriate, under "the function of the protocol or its availability could be impacted, or leak value with a hypothetical attack path with stated assumptions, but external requirements".
I'm not sure I agree with this: anyone can use CREATE2 on the factory to deploy the contract, so the team cannot extort anyone for anything
#7 - Picodes
2023-05-31T17:15:05Z
So:
* this is in the end a trust issue and the report shows how the team could have used the constructor to grief users * the fact that the implementation is deployed via CREATE2 doesn't change the severity as the team could still be malicious. If anything it makes it even worse because it creates a scenario where the team could blackmail users to get paid for the redeployment
Overall, considering that there shouldn't be any trust assumption in this repository, I think Medium severity is appropriate, under "the function of the protocol or its availability could be impacted, or leak value with a hypothetical attack path with stated assumptions, but external requirements".
I'm not sure I agree with this: anyone can use CREATE2 on the factory to deploy the contract, so the team cannot extort anyone for anything
Ah right I don't know why I had in mind a permissioned factory
#8 - Ivshti
2023-06-02T08:15:02Z
So:
* this is in the end a trust issue and the report shows how the team could have used the constructor to grief users * the fact that the implementation is deployed via CREATE2 doesn't change the severity as the team could still be malicious. If anything it makes it even worse because it creates a scenario where the team could blackmail users to get paid for the redeployment
Overall, considering that there shouldn't be any trust assumption in this repository, I think Medium severity is appropriate, under "the function of the protocol or its availability could be impacted, or leak value with a hypothetical attack path with stated assumptions, but external requirements".
I'm not sure I agree with this: anyone can use CREATE2 on the factory to deploy the contract, so the team cannot extort anyone for anything
Ah right I don't know why I had in mind a permissioned factory
In that light, I think this can be downgraded. But we took the recommendation and removed the constructor in the v2 branch, also to avoid confusion
#9 - romeroadrian
2023-06-02T11:32:47Z
So:
* this is in the end a trust issue and the report shows how the team could have used the constructor to grief users * the fact that the implementation is deployed via CREATE2 doesn't change the severity as the team could still be malicious. If anything it makes it even worse because it creates a scenario where the team could blackmail users to get paid for the redeployment
Overall, considering that there shouldn't be any trust assumption in this repository, I think Medium severity is appropriate, under "the function of the protocol or its availability could be impacted, or leak value with a hypothetical attack path with stated assumptions, but external requirements".
I'm not sure I agree with this: anyone can use CREATE2 on the factory to deploy the contract, so the team cannot extort anyone for anything
Ah right I don't know why I had in mind a permissioned factory
In that light, I think this can be downgraded. But we took the recommendation and removed the constructor in the v2 branch, also to avoid confusion
But in that case the issue would be present again, and the same could happen if you redeploy it. I think the severity is well justified given the potential consequences.
#10 - Picodes
2023-06-04T22:00:57Z
Keeping Medium severity for this one for the arguments above (even if the team intended to deploy through the factory they could do differently) and because of the security model of the wallet which is very strict as it assumes no privileged role
π Selected for report: d3e4
Also found by: adriro, bin2chen, carlitox477, rbserver
Data not available
Issue | |
---|---|
L-1 | AmbireAccountFactory can deploy backdoored code |
L-2 | splitSignature() does not validate length of signature |
The AmbireAccountFactory deploys new wallets by creating a contract using an arbitrary code given as a parameter. This can be used in a MITM attack to backdoor the wallet and install another privilege that would give the attacker control over the wallet.
Although this action may go unnoticed it is important to note that backdooring the code would change the address and could be early detected, hence the low severity.
splitSignature()
does not validate length of signatureThe splitSignature()
does not validate that the length of the signature is greater than 0 and may end up causing an underflow in the unchecked block.
Issue | Instances | |
---|---|---|
NC-1 | Import declarations should import specific symbols | 3 |
NC-2 | Use named parameters for mapping type declarations | 2 |
NC-3 | Use uint256 instead of the uint alias | 1 |
NC-4 | Unneeded explicit return | 1 |
Prefer import declarations that specify the symbol(s) using the form import {SYMBOL} from "SomeContract.sol"
rather than importing the whole file
Instances (3):
File: src/AmbireAccount.sol 4: import './libs/SignatureValidator.sol';
File: src/AmbireAccountFactory.sol 4: import './AmbireAccount.sol';
File: src/libs/SignatureValidator.sol 4: import './Bytes.sol';
Consider using named parameters in mappings (e.g. mapping(address account => uint256 balance)
) to improve readability. This feature is present since Solidity 0.8.18
Instances (2):
File: src/AmbireAccount.sol 13: mapping(address => bytes32) public privileges; 15: mapping(bytes32 => uint) public scheduledRecoveries;
uint256
instead of the uint
aliasPrefer using the uint256
type definition over its uint
alias.
Instances (1):
File: src/AmbireAccount.sol 15: mapping(bytes32 => uint) public scheduledRecoveries;
The explicit return can be omitted as the function is using named return data.
#0 - c4-judge
2023-05-28T16:06:49Z
Picodes marked the issue as grade-b