Getting creative with Account Abstraction on Starknet

FreshPizza
6 min readFeb 8, 2023

If you’ve been building on Starknet or some other validity rollup, then you have likely been exposed to a lot of talk around the concept of account abstraction (AA). Unfortunately, a lot of the work surrounding this concept is being done solely by wallets developers with application devs leaning back and expecting the wallets to provide all the solutions.

However, there is a lot of potential for application specific features which won’t be covered by the wallets. In this post I’ll be providing an example of how AA could be used to improve your application/protocol.

One user experience which AA has the potential to wildly improve is the act of protocols paying for its users transactions, which until now would have to be handled via retroactive refunds.

Most applications that build on L1s and L2s should not be interested in covering it’s users transaction fees. For that to be viable the individual transaction value for the protocol would have to be greater then the gas fee charged for executing the transaction. This is seldomly the case.
Transactions where the protocol charges an additional fee lend itself the most to a fee refund. Token swaps on AMMs come to mind.
However, there are also transactions where low or no gas fees for the user have additional positive effects on the protocol.

Liquidations on platforms like Aave or Compound not only bring revenue to the protocol (if the liquidation rewards are shared) but are essential to secure their solvency. High gas fees result in liquidations being executed later, increasing the risk of the protocol producing bad debt during volatile market conditions. As a result there is an interest in removing this initial cost barrier to ensure liquidations are executed asap.

The combination of AA as implemented on Starknet plus new language features introduced with cairo 1.0 uniquely allows lending protocols to cover the fees of valid liquidations.

Account Abstraction on Starknet — A Primer

As a prerequisite we need to revisit how “accounts” work on Starknet. Two major components of a Starknet account are the __validate__ and__execute__ functions. The __validate__ logic is run by the sequencer first and if it doesn’t revert, the __execute__ logic is run.
The ETH held by the contract implementing the __validate__ and __execute__ functions is used to pay for the gas fees. No gas fees are charged for the logic run in the __validate__ method, which usually just includes a verification that the caller actually owns the private key that matches the accounts public key. The logic executed by the __execute__ contains the actual state changing actions which the caller plans to make (be it a token transfer, swap, mint, etc…). Gas fees are charged for this logic execution, regardless of whether the transaction reverts or not.

The magic comes into play once we realize that we can use the __validate__ function for more then just verifying the callers signature. We can run any Cairo code we want, with two restrictions:

  • We cannot access storage outside of the account contract.
  • The number of available execution steps is a lot lower then for the __execute__ function.

Practical implementation

I will demonstrate two ways to approach the implementation.

The most straight forward and safest implementation is one where we can make the assumption that a liquidation is performed using only funds available inside of lending protocol. This has become a popular implementation in recent lending protocol iterations as you avoid the classic causes of high liquidation thresholds. These being the multiple storage writes and protocol fees involved when performing swaps and flash loans.

This architecture is great for us as in theory this would allow for the creation of a lending protocol where all required storage entries are accessible within one contract.

As Cairo.1.0. is still in development I’ll be using some pseudo code at places to simplify the example implementation.

In the first step we add the two previously mentioned functions to our protocol.

 
fn __validate__(calldata_len: felt, calldata: felt*) {

}

fn __excute__(calldata_len: felt, calldata: felt*) {

}

This essentially transforms the protocol into an account.

In the second step we add the functions that would either be used to check if a give liquidation can be performed as well as a function to actually execute the liquidation.

fn __validate__(calldata_len: felt, calldata: felt*) {

// There is no need for signature validation.
// Anyone should be able to call this method in order to perfrom a liquidation.
// Using the calldata the caller of this method can specify to which
// account the liquidation reward should be credited.

// We call an internal liquidation function that checks whether the provided
// account can be liquidated using internal funds.
// if not, it reverts.
_can_liquidate(account=calldata[0],asset=calldata[1],repay_amount=calldata[2], reward_address=calldata[3])

// if the _liquidate function didn't revert, we move on to the __execute__ function.
}

fn __excute__(calldata_len: felt, calldata: felt*) {
// Actualy execute the liquidation using the same calldata parameters as the
// __validate__ function
_liquidate(account=calldata[0],asset=calldata[1],repay_amount=calldata[2], reward_address=calldata[3])
// After __execute__ is executed, the gas fees for the execution are extracted from this contract.
}

The purpose of placing the initial check in the validate function is so that when someone provides calldata that would result in a reverted transaction, it fails within the __validate__ method which won’t cause the protocol to have to pay a gas fee. A gas fee will only be charged by the Starknet sequencer on a successful liquidation.

The challenge with a lending protocol that requires access to external storage slots for liquidations (which is basically all of them) is that we cannot simulate the entire liquidation in the __validate__ function. Therefore it is possible for people to provide calldata that will cause a revert and thus the protocol can be robed of all it’s ETH holdings through malicious actors spamming failing transactions at no cost of their own.

In essence the second approach requires that that anyone interested in performing liquidations first deposits some ETH inside of the protocol. When the liquidation logic is being executed we catch any occurring reverts (a feature enabled by Cairo.1.0) and subtract the created gas costs from the liquidators deposited ETH amount. As a result the lending protocol only pays the gas fees for valid liquidations and the transaction initiator has to pay the gas fee in case of an invalid liquidation.

const MIN_SLASH_AMOUNT = 1 * 10 ** 16;
const DEX_ADDRESS = 0x05dcd266a80b8a5f29f04d779c6b166b80150c24f2180a75e82427242dab20a9;

fn __validate__(calldata_len: felt, calldata: felt*) {
// We first get the maximum gas fee that can be charged for this transaction
let tx_info = get_tx_info();
let maxGasFee = tx_info[2];

// We verify that the liquidator has some ETH deposited in the protocol
liquidator_address = calldata[0];
let public_key = IAccount.get_public_key(liquidator_address);
let liquidator_eth_balance = _get_liquidator_deposit(liquidator);
verify_ecdsa_signature(
message=calldata[1],
public_key=public_key,
signature_r=calldata[2],
signature_s=calldata[3]
);
// We ensure that the transaction gas fee is in a reasonable range
// and that we are able to slash the users funds in case of a revert
assert maxGasFee >= MIN_SLASH_AMOUNT;
assert maxGasFee <= liquidator_eth_balance;
}

fn __excute__(calldata_len: felt, calldata: felt*) {
// Perform liquidations.
// Arbitrary calls are not allowed in order to prevent malicious behavior and
// to ensure that the transaction doesn't fail due to exeeding the maxGasFee.
match _perform_limited_calls(calldata[1]){
// On revert, we get the subtract gas fees from the lquidator
0 => _slash(calldata[0]);
// If the liquidation occured successfully,
// we perform some additional checks and computation
_ => _extra_checks();
}
}

fn _perform_limited_calls(flash_loan_data: felt*, swaps_len: felt, swaps: felt*, liquidation_data: felt*) {
let offset = 0;
// We assume that this lending protocol also offers a flashloan service
flashloan(flash_loan_data[0], flash_loan_data[1]);
while i < actions_len {
// We hard code the DEX address to ensure that no unknown code is being accessed
// The tokens to be swapped should also be limited to known ones like DAI, USDC, ETH etc...
swap(DEX_ADDRESS,calldata[i], calldata[i+1], calldata[i+2], calldata[i+3]);
i += 3;
}
liquidate(liquidation_data[i], liquidation_data[1]);
}

fn _extra_checks() {
// Extra requirements or checks that ensure nothing malicious has occured.
// E.g:
// liquidation_amount > threshold
// new_asset balances >= old balances
// etc...

// We can also add logic here that we know is going to succeed.
// Such as distributing the liquidation rewards among the protocol and the liquidator.
}

fn _slash(liquidator_address: felt) {
// we get the maximum gasFee that and deduct that amount from the liquidators deposited ETH
let tx_info = get_tx_info();
let maxGasFee = tx_info[2];
_reduce_eth_balance(liquidator_address,maxGasFee) ;
}

Imo the most apparent issue with this approach is the plethora of attack vectors that are enabled once we allow the contract to make calls to other contracts outside of our protocol. The viability of this approach heavily depends on the extend to which lending protocols depend on other protocols for the execution of liquidations.

Even if this specific approach turns out not to be safely applicable in practice, at the very least I hope this might inspire the one or the other dev to think of how they might use AA to their advantage and don’t simply rely on the wallet developers to come up with all the features surrounding AA.

Shout out to Louis for pushing me to exploring this misuse of the validate function. And Sylve for being an excellent rubber duck.

--

--