Closing a Pendle PT leveraged Long on Euler With Euler Orderflow Router and EVC with One Transaction

Yield DevYield Dev
12 min read

Euler Finance maintains a an incredibly useful tool for aggregating and routing on chain swaps called euler-orderflow-router. The euler-orderflow-router API allows users to fetch payload data for routing on chain swaps and even offers various swap modes making it aware of the swap's context.

For example, paying down a specific amount of debt, where the swaps output is meant to deleverage an active borrow position, is achieved by requesting a payload in target debt mode which will return swap data for purchasing a specific amount of the liability asset to pay the debt on a given account down to a specified amount. Additionally, it provides the transaction data for calling the peripheral contract to repay the debt and verify the swapped amount, called the swapVerifier. By fetching this routing data off chain and structuring them in an EVC batch transaction (which defers liquidity checks) we can efficiently and easily payoff a debt using an accounts collateral.

This is a potent tool for risk management, as it allows for deleveraging a position position with no additional collateral. It also allows on chain traders to ratchet up leveraged positions in a controlled manner without looping.

For leveraged traders, it can also be used for unwinding and closing out a leveraged long position entirely by swapping the entirety of the collateral assets and then, using an EVC batch, repaying the debt and leaving us with the net equity of our long position in cash.

In this post we are going to be looking at how this is implemented. Using typescript to fetch the swap routing data from the api and then using solidity (via forge scripts) to write our evc batch execution.

Let us take, for example, our previously discussed long PT-wstkscUSD position which is leveraged against USDC.e liability vault. Since, we are short the rate and the USDC.e borrow spread is compressed perhaps we would like to have a script ready to close out the entire position in one shot and claim our USDC profit.

First, We will need to route our order through the Euler swapper contract by providing specific function call data. Euler Swap works in two parts, first the swap executes the asset swap via whichever DEX route is provided in the data, after that we must call the swap verifier, which acts to reconcile the actual result of the swap with the output amount expected according to the slippage parameter and to execute follow up actions such as claiming the resulting tokens as a vault deposit or repaying debt.

To obtain the transaction data that routes our swap efficiently and accurately and needs to be provided to these two function we call the euler-orderflow-router API, an off chain service which retrieves and formats this data, conveniently delivering it to us for use in our transaction.

So we must write a typescript script which fetches this info for us. let us create a file called: closeToLiability.ts

We will import the required libraries and set the swap api endpoint provided by Euler.

import { ethers } from "ethers";
import axios from "axios";
import dotenv from "dotenv";
import * as fs from "fs";

dotenv.config();

const SWAP_API_URL = "https://swap.euler.finance";

now we will want to also setup an interface for interacting with the EVault contract to gather some basic information.

interface EVault {
    convertToAssets(amount: bigint): Promise<bigint>;
    balanceOf(account: string): Promise<bigint>;
    previewRedeem(amount: bigint): Promise<bigint>;
}
async function getEVaultContract(address: string, provider: ethers.JsonRpcProvider): Promise<EVault> {
    return new ethers.Contract(
        address,
        [
            "function convertToAssets(uint256) view returns (uint256)",
            "function balanceOf(address) view returns (uint256)",
            "function previewRedeem(uint256) view returns (uint256)",
            "function debtOf(address) view returns (uint256)"
        ],
        provider
    ) as unknown as EVault;
}

From here we can create the function to query the data that we are going to need for our swap. This query outlines all the info we will need to provide. In swapper mode, we are using 0 exact input mode, this means that the swap is going to consume all of the amountIn that we give it and purchase as much tokenOut as possible.

Since we are cashing out our entire position, all of the output token will be delivered to the liabilityVaultAddress which we have set in the receiver field. Once the funds are in the vault we will repay the debt and keep the rest of our balance as a deposit earning interest in the vault.

Note, also, that there is an isRepay field which we have set to false. This field is useful if the resulting output tokens from the swap are meant to pay a specified portion of the debt. This allows the swapper to calculate an input amount that results in purchasing up to the total amount of the debt when paired with swapper mode 2 target debt mode. however, since we want to swap all of our collateral via exactInput mode, setting is repay to true results in triggering an error on the vault EVault__RepayTooMuch. For this reason we will manually repay the debt and deposit the remainder as an interest bearing asset.

async function getSwapPayload(
    account: string,
    collateralVaultAddress: string,
    liabilityVaultAddress: string,
    collateralToken: string,
    liabilityToken: string,
    amountIn: string,
) {

    const queryParams = {
        chainId: "146", // sonic chain
        tokenIn: collateralToken,
        tokenOut: liabilityToken,
        amount: amountIn, // the amount to swap 
        targetDebt: "0", // irrelevant in this exactIn flow
        currentDebt: amountIn, // irrelevant in this exactIn flow
        receiver: liabilityVaultAddress,
        vaultIn: collateralVaultAddress, // left over tokenIn goes here, irrelevent for exactIn flow
        origin: account, // account executing the tx
        accountIn: account, // account holding the collateral
        accountOut: account, // account to swap to, the account that skim will deliver to 
        slippage: "0.15", // 0.15% slippage
        deadline: String(Math.floor(Date.now() / 1000) + 10 * 60), // 10 minutes from now
        swapperMode: "0", // exact input mode = 0 
        isRepay: "false", // we will manually add a call to repay the debt
    };

    const { data: response } = await axios.get(
        `${SWAP_API_URL}/swap`,
        {
            params: queryParams
        }
    );

    return response.data
}

Now we can set up all the info we need to retrieve our payload in the main function. Here we will need to set address to the actual Euler subaccount which holds our position. we will also fetch the total collateral balance to use as the amountIn in our request.

Once we have received the payload we will write the json data to two separate files, one for each of the required transactions. The first is the swapper data for actually routing the swap, the second is the verifier data which will make sure we got equal to or more output tokens than the minimum amount expected according to the slippage parameter. The verifier will also be responsible for executing the skim function on the liability vault which claims the tokens as a deposit for our account since they will be sent directly to vault after the swap.

async function main() {
    const provider = new ethers.JsonRpcProvider(process.env.SONIC_RPC_URL);
    const address = $EULER_SUBACCOUNT_ADDRESS; // the address of the euler subaccount holding the position
    const COLLATERAL_VAULT = "0xF6E2ddf7a149C171E591C8d58449e371E6dc7570"; // PTUSDC Vault
    const LIABILITY_VAULT = "0x196F3C7443E940911EE2Bb88e019Fd71400349D9" // USDC Vault

    const COLLATERAL_TOKEN = "0xBe27993204Ec64238F71A527B4c4D5F4949034C3"; // PTUSDC
    const LIABILITY_TOKEN = "0x29219dd400f2Bf60E5a23d13Be72B486D4038894"; // USDC

    const evaultContract = await getEVaultContract(COLLATERAL_VAULT, provider);
    const collateralBalance = await evaultContract.balanceOf(address);
    const withdrawAmount = await evaultContract.previewRedeem(collateralBalance);

    const swapPayload = await getSwapPayload(
        address,
        COLLATERAL_VAULT,
        LIABILITY_VAULT,
        COLLATERAL_TOKEN,
        LIABILITY_TOKEN,
        withdrawAmount.toString()
    );

    await writeToJsonFile(swapPayload.swap, "./script/closeSwapPayload.json");
    await writeToJsonFile(swapPayload.verify, "./script/closeSwapVerify.json");
    console.log("Payloads written to files")
}
main().catch((error) => {
    console.error(error);
    process.exitCode = 1;
});

Now that we have the payload written to a json file, we can write a solidity script to execute on that data.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.28;

import {Script, console} from "forge-std/Script.sol";
import {SonicLib} from "../test/common/SonicLib.sol";
import "evc/interfaces/IEthereumVaultConnector.sol";
import { EVault } from "evk/EVault/EVault.sol";

Setup our script contract

contract ClosePositionScript is Script {
    address _e_account1;
}

With two utility functions, One to get the json file data, the other to derive our Euler subaccount.

    function getSubaccount(address _account, uint256 _index) public returns (address) {
        return address(uint160(uint160(_account)^_index));
    }
    function getJsonFile(string memory _filePath) internal view returns (string memory) {
        return vm.readFile(_filePath);
    }

Next our run function holds the main logic

    function run() public {
        borrower = msg.sender;
        _e_account1 = getSubaccount(borrower, 1);
}

Now we need to setup our vault addresses and gather the payload info:

    function run() public {
        borrower = msg.sender;
        _e_account1 = getSubaccount(borrower, 1);

        address PT_USDC_VAULT = SonicLib.EULER_PT_USDC_VAULT;
        address USDC_VAULT = SonicLib.EULER_USDC_VAULT;

        address PT_USDC = SonicLib.PT_USDC;
        address USDC = SonicLib.USDC;

        address evc = SonicLib.Euler_Vault_Connector;

        string memory swapJson = getJsonFile("./script/closeSwapPayload.json");
        string memory verifyJson = getJsonFile("./script/closeSwapVerify.json");
    }

Note that, in order to open a json file via vm.readFile we need to add permissions in our foundry.toml configuration. To do this, simply add the following line:

# foundry.toml
fs_permissions = [{ access = "read", path = "./script"}]

Now we fetch the balance of our collateral shares, which we will be redeeming in order to swap and then construct the EVC Batch transaction, consisting of 4 transaction items. Let's go over each item in the batch to illustrate the process.

First, we construct a BatchItem which executes the redemption of our total collateral shares balance from our subaccount, in order to execute the swap; we set the receiver of the assets from this redemption to the swapperAddress in our swapJson file.

Next, our second BatchItem will call the swapper contract at swapperAddress with the routing data we have saved in our swapJson file. This execution will swap our collateral assets for the liability asset.

Third, we construct a call to the verifierAddress this contract holds logic that verifies our swap output was executed with acceptable slippage. Further, since it is aware of the context of our swap, it also executes the logic to call skim() on the vault address we have set as the receiver, in this case the USDC.e vault. The skim() call converts the assets which were swapped into the vault into deposits on behalf of our accountOut which we set to our Euler subaccount when requesting the payload. After this is called we will have shares representing our deposit.

Finally, we call the liability vault (USDC.e vault) to repay our outstanding debt with the newly minted shares we received from our asset deposit. Here we pass in type(uint256).max to use our entire balance to payoff our owed amount in the vault. Any remaining shares after the debt is paid off will be our remaining equity in the position in cash in the vault.

        uint256 shares = EVault(PT_USDC_VAULT).balanceOf(_e_account1);

        IEVC.BatchItem[] memory items = new IEVC.BatchItem[](4);
        items[0] = IEVC.BatchItem({
            onBehalfOfAccount: _e_account1,
            targetContract: PT_USDC_VAULT,
            value: 0,
            data: abi.encodeWithSelector(
                EVault.redeem.selector,
                shares,
                vm.parseJsonAddress(swapJson, ".swapperAddress"),
                _e_account1
            )
        });
        items[1] = IEVC.BatchItem({
            onBehalfOfAccount: _e_account1,
            targetContract: vm.parseJsonAddress(swapJson, ".swapperAddress"),
            value: 0,
            data: vm.parseJsonBytes(swapJson, ".swapperData")
        });
        items[2] = IEVC.BatchItem({
            onBehalfOfAccount: _e_account1,
            targetContract: vm.parseJsonAddress(verifyJson, ".verifierAddress"),
            value: 0,
            data: vm.parseJsonBytes(verifyJson, ".verifierData")
        });
        items[3] = IEVC.BatchItem({
            onBehalfOfAccount: _e_account1,
            targetContract: USDC_VAULT,
            value: 0,
            data: abi.encodeWithSelector(EVault.repayWithShares.selector, type(uint256).max, _e_account1)
        });

        vm.startBroadcast();
        IEVC(evc).batch(items);
        vm.stopBroadcast();

        console.log("PT BALANCE: ", EVault(PT_USDC_VAULT).balanceOf(_e_account1));
        console.log("USDC DEBT: ", EVault(USDC_VAULT).debtOf(_e_account1));
        console.log("USDC BALANCE: ", EVault(USDC_VAULT).convertToAssets(EVault(USDC_VAULT).balanceOf(_e_account1)));

We also add a few log statements to print out our position info ensuring that our collateral balance and debt are both zero. Additionally, we print our USDC.e vault asset balance after the entire transaction is complete to see our remaining assets after the position had been closed.

we can run this script in a fork to see how it all shakes out:

forge script --fork-url https://rpc.soniclabs.com script/closePosition.s.sol --sender $SENDER

Our logs should match expectations.

  PT BALANCE:  0
  ETH DEBT:  0
  USDC BALANCE:  10666666666

With this example we illustrate the utility of the euler-orderflow-router tool for managing highly leveraged on chain position. By using EVC batch we can defer all account solvency checks until the end of position closing process, allowing us to swap our entire position, close out our margin and end up with our positions equity in cash. All of this can be done in a single transaction with no additional capital or liquidity.

Knowledge of EVC and its peripheral tools provide a massive advantage to on chain traders and yield farmers, allowing them to construct flexible, use case specific scripts for quickly managing and maintaining high yield positions while also taking advantage of capital efficiency inherent in the protocol.

At YieldDev Studio we specialize in developing cutting edge, bespoke Defi applications and tools.

Feel free to contact me on X -> @yielddev

Full Code:

import { ethers } from "ethers";
import axios from "axios";
import dotenv from "dotenv";

import * as fs from "fs";

dotenv.config();

const SWAP_API_URL = "https://swap.euler.finance";
interface EVault {
    convertToAssets(amount: bigint): Promise<bigint>;
    balanceOf(account: string): Promise<bigint>;
    previewRedeem(amount: bigint): Promise<bigint>;
}
async function getEVaultContract(address: string, provider: ethers.JsonRpcProvider): Promise<EVault> {
    return new ethers.Contract(
        address,
        [
            "function convertToAssets(uint256) view returns (uint256)",
            "function balanceOf(address) view returns (uint256)",
            "function previewRedeem(uint256) view returns (uint256)",
            "function debtOf(address) view returns (uint256)"
        ],
        provider
    ) as unknown as EVault;
}

async function getSwapPayload(
    account: string,
    collateralVaultAddress: string,
    liabilityVaultAddress: string,
    collateralToken: string,
    liabilityToken: string,
    amountIn: string,
) {

    const queryParams = {
        chainId: "146",
        tokenIn: collateralToken,
        tokenOut: liabilityToken,
        amount: amountIn, // the amount to swap 
        targetDebt: "0", // irrelevant in this exactIn flow
        currentDebt: amountIn, // irrelevant in this exactIn flow
        receiver: liabilityVaultAddress,
        vaultIn: collateralVaultAddress, // left over tokenIn goes here, irrelevent for exactIn flow
        origin: account, // account executing the tx
        accountIn: account, // account holding the collateral
        accountOut: account, // account to swap to, the account that skim will deliver to 
        slippage: "0.15", // 0.15% slippage
        deadline: String(Math.floor(Date.now() / 1000) + 10 * 60), // 10 minutes from now
        swapperMode: "0", // exact input mode = 0 
        isRepay: "false", // we will manually add a call to repay the debt
    };

    const { data: response } = await axios.get(
        `${SWAP_API_URL}/swap`,
        {
            params: queryParams
        }
    );

    return response.data
}

async function writeToJsonFile(data: any, filename: string) {
    fs.writeFileSync(filename, JSON.stringify(data, null, 2));
}

async function main() {
    const provider = new ethers.JsonRpcProvider(process.env.SONIC_RPC_URL);
    const address = ""; // acc1
    const COLLATERAL_VAULT = "0xF6E2ddf7a149C171E591C8d58449e371E6dc7570"; // PTUSDC Vault
    const LIABILITY_VAULT = "0x196F3C7443E940911EE2Bb88e019Fd71400349D9" // USDC Vault

    const COLLATERAL_TOKEN = "0xBe27993204Ec64238F71A527B4c4D5F4949034C3"; // PTUSDC
    const LIABILITY_TOKEN = "0x29219dd400f2Bf60E5a23d13Be72B486D4038894"; // USDC

    const evaultContract = await getEVaultContract(COLLATERAL_VAULT, provider);
    const collateralBalance = await evaultContract.balanceOf(address);
    console.log("COLLATERAL BALANCE: ", collateralBalance)
    const withdrawAmount = await evaultContract.previewRedeem(collateralBalance);

    const swapPayload = await getSwapPayload(
        address,
        COLLATERAL_VAULT,
        LIABILITY_VAULT,
        COLLATERAL_TOKEN,
        LIABILITY_TOKEN,
        withdrawAmount.toString()
    );

    await writeToJsonFile(swapPayload.swap, "./script/closeSwapPayload.json");
    await writeToJsonFile(swapPayload.verify, "./script/closeSwapVerify.json");
    console.log("Payloads written to files")
}
main().catch((error) => {
    console.error(error);
    process.exitCode = 1;
});
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.28;

import {Script, console} from "forge-std/Script.sol";
import {SonicLib} from "../test/common/SonicLib.sol";
import "evc/interfaces/IEthereumVaultConnector.sol";
import { EVault } from "evk/EVault/EVault.sol";

contract ClosePositionScript is Script {
    address borrower;
    address _e_account1;
    address _e_account2;
    function getSubaccount(address _account, uint256 _index) public returns (address) {
        return address(uint160(uint160(_account)^_index));
    }
    function getJsonFile(string memory _filePath) internal view returns (string memory) {
        return vm.readFile(_filePath);
    } 

    function run() public {
        borrower = msg.sender;
        _e_account1 = getSubaccount(borrower, 1);

        address PT_USDC_VAULT = SonicLib.EULER_PT_USDC_VAULT;
        address USDC_VAULT = SonicLib.EULER_USDC_VAULT;

        address PT_USDC = SonicLib.PT_USDC;
        address USDC = SonicLib.USDC;

        address evc = SonicLib.Euler_Vault_Connector;

        string memory swapJson = getJsonFile("./script/closeSwapPayload.json");
        string memory verifyJson = getJsonFile("./script/closeSwapVerify.json");

        uint256 shares = EVault(PT_USDC_VAULT).balanceOf(_e_account1);

        IEVC.BatchItem[] memory items = new IEVC.BatchItem[](4);
        items[0] = IEVC.BatchItem({
            onBehalfOfAccount: _e_account1,
            targetContract: PT_USDC_VAULT,
            value: 0,
            data: abi.encodeWithSelector(
                EVault.redeem.selector,
                shares,
                vm.parseJsonAddress(swapJson, ".swapperAddress"),
                _e_account1
            )
        });
        items[1] = IEVC.BatchItem({
            onBehalfOfAccount: _e_account1,
            targetContract: vm.parseJsonAddress(swapJson, ".swapperAddress"),
            value: 0,
            data: vm.parseJsonBytes(swapJson, ".swapperData")
        });
        items[2] = IEVC.BatchItem({
            onBehalfOfAccount: _e_account1,
            targetContract: vm.parseJsonAddress(verifyJson, ".verifierAddress"),
            value: 0,
            data: vm.parseJsonBytes(verifyJson, ".verifierData")
        });
        items[3] = IEVC.BatchItem({
            onBehalfOfAccount: _e_account1,
            targetContract: USDC_VAULT,
            value: 0,
            data: abi.encodeWithSelector(EVault.repayWithShares.selector, type(uint256).max, _e_account1)
        });

        vm.startBroadcast();
        IEVC(evc).batch(items);
        vm.stopBroadcast();

        console.log("PT BALANCE: ", EVault(PT_USDC_VAULT).balanceOf(_e_account1));
        console.log("USDC DEBT: ", EVault(USDC_VAULT).debtOf(_e_account1));
        console.log("USDC BALANCE: ", EVault(USDC_VAULT).convertToAssets(EVault(USDC_VAULT).balanceOf(_e_account1)));
        // forge script --fork-url https://rpc.soniclabs.com script/closePosition.s.sol --sender $SENDER

    }
}
0
Subscribe to my newsletter

Read articles from Yield Dev directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Yield Dev
Yield Dev