Skip to content
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

Single flashloan encoding #3279

Merged
merged 48 commits into from
Mar 7, 2025
Merged

Single flashloan encoding #3279

merged 48 commits into from
Mar 7, 2025

Conversation

sunce86
Copy link
Contributor

@sunce86 sunce86 commented Feb 13, 2025

Description

Tackles #3218

Implements on flavor of the "repay debt with collateral" idea an a e2e test. The trader is a gnosis safe to make it easier to do all the necessary pre-interactions. The basic flow is like this:

  1. make trader a safe
  2. deposit 50K USDC
  3. borrow 1 WETH
  4. build appdata
    • flashloan 1 WETH (flashloan hint)
    • repay 1 WETH (1st pre-hook, tx pre-signed by safe)
    • withdraw 50K USDC (2nd pre-hook, tx pre-signed by safe)
  5. place order with that appdata like:
{
    "from": "<SAFE_ADDRESS>",
    "receiver": "<SETTLEMENT_ADDRESS>",
    "sell": "50_000 USDC",
    "buy": "1 WETH + flashloanFee",
    "kind": "buy",
}
  1. assert that trade got executed, indexed and that changed balances make sense

This flavor of "repay debt with collateral" leaves trace amounts of debt in the AAVE pool. The alternative would be either over paying slightly or a bunch of very complex accounting logic using another helper contract. Both are more involved and not necessary to proof the concept.

Changes

some bug fixes / workarounds that were necessary to make it work:

  • introduce exceptions in filtering logic based on balances in autopilot and driver to prevent the order from getting filtered out
  • forked e2e test setup currently does some things that will not needed once the flashloan contracts are actually deployed:
    • deploy contracts
    • allow list them as solvers in the settlement contract
  • code to pipe the flashloan data around

Things not addressed by this PR

  • proper configuration options in the driver
  • more convenience for computing flashloan amounts that need fees
    • currently the fee gets added on top of the flashloan amount which means the user needs to be aware that their buyAmount needs to be appdata.flashloan.amount + fee. Ideally the driver would reduce the flashloan amount such that loanedAmount + fee = totalSignedAmount.
  • error handling
  • supporting multiple flashloans in the same settlement

How to test

added new forked e2e test
adjusted unit test for solvable orders filtering exception for flashloan orders

@sunce86 sunce86 self-assigned this Feb 13, 2025
serde_json::from_str(&response.text().await?)
.context("invalid app data document")?;
match appdata.full_app_data == "{}" {
true => None, // empty app data
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think to avoid fetching the underlying app data for 0x000...000 (i.e. "{}") over an over again we need to store a dummy value for that hash.

@@ -173,6 +173,51 @@ pub fn tx(
min: None,
prices: auction.prices().clone(),
};

// Add all interactions needed to move flash loaned tokens around
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably makes sense to move this and the other flashloan specific encoding logic into a separate function. Also technically all of this should happen at the very end to make sure no other interactions get added before or after the flashloan interactions.

Comment on lines 114 to 115
// Receiver is flashloan wrapper, so that borrowed funds can be returned to the lender
receiver: Some(onchain.contracts().flashloan_wrapper.address()),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not how we probably end up using this feature. However, while thinking about how to correct this code I realized that we didn't think some of the details through enough. Fine for now to show that the wrapper contract works but ultimately we should probably have an e2e test that repays a debt with collateral since that is the primary use case for this feature AFAIK.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There was a discussion we had that led to this implementation:
cowprotocol/flash-loan-wrapper-solver#2 (comment)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ended up having a discussion with Felix and Federico about the exact details for this. The decision was made that the underlying order should always have the settlement contract as the receiver. That allows the order to be oblivious of the flashloan helper contract that actually ends up getting used. The driver encoding would then have to take this into consideration and transfer the respective funds from the settlement contract to the flashloan helper contract itself.

Copy link
Contributor Author

@sunce86 sunce86 Feb 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks.
Will adjust the code and tests.

.transfer_from(
contracts.settlement().address().into(),
flashloan_wrapper.address(),
flashloan.amount.0, // or order buy amount?
Copy link
Contributor

@MartinquaXD MartinquaXD Feb 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our initial implementation assumes the values are always the same. But if they were not, using the flashloan amount would lead to better outcomes:
if flashloan.amount > order.buyAmount the solution would not simulate in the first place which encourages solvers to make sure the buy amount covers the flashloan amount. Unless the settlement contract has enough fees already buffered but then this would still count as negative slippage which is a punishment for the solver.
if flashloan.amount < order.buyAmount the difference would stay in the settlement contract instead of accumulate in the wrapper contract which is more convenient for us and would count as positive slippage the solver can claim when paying out rewards.

@MartinquaXD MartinquaXD marked this pull request as ready for review March 5, 2025 11:25
@MartinquaXD MartinquaXD requested a review from a team as a code owner March 5, 2025 11:25
}
};

// pay 9 bps of flashloan fee to the AAVE pool
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

where does this number come from?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I googled it. After double checking it turned out I got the wrong number. Updated it to be 5 bps. Still didn't link a reference in the source code in the driver encoding since this hopefully going to change before the release anyway.

Comment on lines 211 to 212
// Receiver is always the settlement contract, so driver will have to manually send funds to
// solver wrapper (flashloan borrower)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this happen in this test? is baseline doing this? or is it a TODO or not needed?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reworded the comment

&order.data().hash_struct(),
)));

{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please can you add comments to this section? 🙏

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of a comment I have a log explaining what is happening. Can move it above the block though to avoid confusion.

let current_safe_nonce = trader.nonce().await;

// Build appdata that does:
// 1. take out a 1 WETH flashloan for the trader
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. is not actually part of the appdata right, it's already done above?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you confuse flashloan with borrowing the WETH (which indeed happens above)?

The flashloan is not explicitly encoded in an interaction but it's still part of the appdata as the flashloan hint. Updated the comment to make this clearer.

#[derive(Debug, Clone)]
pub struct Flashloan {
pub lender: ContractAddress,
pub borrower: ContractAddress,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The borrower shouldn't be an ordinary Address? Is there a requirement that it is a contract?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Indeed any Address could be the receiver of a flashloan.

solution
.flashloans
.into_iter()
.map(|flashloan| eth::Flashloan {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this conversion be moved into From trait implementation in the dto Flashloan?
This function is quite long with lots of indentation, and moving all those conversions to implementation blocks will improve the code readability a bit.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From implementations turned out to be a double edged sword with the domain driven design that requires a ton of conversions because if you use an IDE to jump to the definition of the .into() call it will jump to the blanket implementation on the From trait which is not really helpful.

.unwrap();

let trader_usdc = balance(&web3, trader.address(), usdc.address()).await;
assert!(trader_usdc > to_wei_with_exp(47_000, 6));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the trader is not getting back the whole collateral value?

Copy link
Contributor

@squadgazzz squadgazzz Mar 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Double that. From the logic above, I couldn't understand where 3000 USDC remain. A comment would be helpful. Ah, it is related to 1 weth debt. Got it. Still, a comment would be helpful.

assert!(settlement_weth < 100_000_000u128.into());
tracing::info!("settlement contract only has dust amounts of WETH");

assert!(balance(&web3, trader.address(), ausdc).await < 10_000.into());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the assertion above checks that the trader spent less than 3000 USDC on the loan, why do we have 10k aUSDC here? Should it be 3000 also?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aUSDC only tracks how much USDC you have currently deposited in AAVE (+ interest). Since the trader actually had to sell some USDC they wipe out their entire debt (close to 0 aUSDC) but don't get their entire USDC back (~3000 sold for 1 WETH).

Copy link
Contributor

@squadgazzz squadgazzz Mar 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, but still not sure why 10k is used here. Should it be 3k due to the assertion above in the code?

assert!(trader_usdc > to_wei_with_exp(47_000, 6));

If this passes, and initially the trader had only 50k, then <3k of aUSDC remains, right? Or did I miss something?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe you overlooked the different orders of magnitude. The user has ~3000 actual USDC less (i.e. 3_000_000_000 atoms). The remaining debt is only 10_000 atoms which is not even 1ct of debt.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aaah, i knew I missed something, thank you!

@MartinquaXD MartinquaXD changed the title [DRAFT] Single flashloan encoding Single flashloan encoding Mar 7, 2025
@MartinquaXD MartinquaXD merged commit 169cb46 into main Mar 7, 2025
11 checks passed
@MartinquaXD MartinquaXD deleted the flashloan-encoding branch March 7, 2025 12:31
@github-actions github-actions bot locked and limited conversation to collaborators Mar 7, 2025
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants