Ver código fonte

feat(express_relay): Sample protocol and monitor (#1374)

Amin Moghaddam 1 ano atrás
pai
commit
0735cdb975

+ 6 - 0
express_relay/examples/easy_lend/.eslintrc.js

@@ -0,0 +1,6 @@
+module.exports = {
+  root: true,
+  parser: "@typescript-eslint/parser",
+  plugins: ["@typescript-eslint"],
+  extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
+};

+ 5 - 0
express_relay/examples/easy_lend/.gitignore

@@ -0,0 +1,5 @@
+lib/*
+out
+cache
+tslib
+!lib/README.md

+ 20 - 0
express_relay/examples/easy_lend/README.md

@@ -0,0 +1,20 @@
+# EasyLend Protocol
+
+EasyLend is a simplified lending protocol that uses Express Relay for avoiding value leakage on liquidations.
+It uses Pyth price feeds to calculate the asset values and the liquidation thresholds.
+
+This project illustrates how to use the Express Relay SDK for contract integration and publishing opportunities.
+
+## Contracts
+
+The contracts are located in the `contracts` directory. The `EasyLend.sol` file contains the main contract logic.
+The protocol can allow creation of undercollateralized vaults that are liquidatable upon creation. This is solely
+for ease of testing and demonstration purposes.
+
+## Monitoring script
+
+The script in `src/monitor.ts` is used to monitor the vaults health and publish the liquidation opportunities:
+
+- It subscribes to Pyth price feeds to get the latest prices for the assets used in the protocol.
+- It periodically checks for new vaults using the chain rpc.
+- Upon finding a vault that is below the liquidation threshold, it publishes a liquidation opportunity using the Express Relay SDK.

+ 358 - 0
express_relay/examples/easy_lend/contracts/EasyLend.sol

@@ -0,0 +1,358 @@
+// Copyright (C) 2024 Lavra Holdings Limited - All Rights Reserved
+pragma solidity ^0.8.13;
+
+import "./EasyLendStructs.sol";
+import "./EasyLendErrors.sol";
+import "forge-std/StdMath.sol";
+
+import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
+import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
+import "@openzeppelin/contracts/utils/Strings.sol";
+
+import "@pythnetwork/pyth-sdk-solidity/PythStructs.sol";
+import "@pythnetwork/pyth-sdk-solidity/IPyth.sol";
+import "@pythnetwork/express-relay-sdk-solidity/IExpressRelayFeeReceiver.sol";
+import "@pythnetwork/express-relay-sdk-solidity/IExpressRelay.sol";
+
+contract EasyLend is IExpressRelayFeeReceiver {
+    using SafeERC20 for IERC20;
+
+    event VaultReceivedETH(address sender, uint256 amount, bytes permissionKey);
+
+    uint256 _nVaults;
+    address public immutable expressRelay;
+    mapping(uint256 => Vault) _vaults;
+    address _oracle;
+    bool _allowUndercollateralized;
+
+    /**
+     * @notice EasyLend constructor - Initializes a new token vault contract with given parameters
+     *
+     * @param expressRelayAddress: address of the express relay
+     * @param oracleAddress: address of the oracle contract
+     * @param allowUndercollateralized: boolean to allow undercollateralized vaults to be created and updated. Can be set to true for testing.
+     */
+    constructor(
+        address expressRelayAddress,
+        address oracleAddress,
+        bool allowUndercollateralized
+    ) {
+        _nVaults = 0;
+        expressRelay = expressRelayAddress;
+        _oracle = oracleAddress;
+        _allowUndercollateralized = allowUndercollateralized;
+    }
+
+    /**
+     * @notice getLastVaultId function - getter function to get the id of the next vault to be created
+     * Ids are sequential and start from 0
+     */
+    function getLastVaultId() public view returns (uint256) {
+        return _nVaults;
+    }
+
+    /**
+     * @notice convertToUint function - converts a Pyth price struct to a uint256 representing the price of an asset
+     *
+     * @param price: Pyth price struct to be converted
+     * @param targetDecimals: target number of decimals for the output
+     */
+    function convertToUint(
+        PythStructs.Price memory price,
+        uint8 targetDecimals
+    ) private pure returns (uint256) {
+        if (price.price < 0 || price.expo > 0 || price.expo < -255) {
+            revert InvalidPriceExponent();
+        }
+
+        uint8 priceDecimals = uint8(uint32(-1 * price.expo));
+
+        if (targetDecimals >= priceDecimals) {
+            return
+                uint(uint64(price.price)) *
+                10 ** uint32(targetDecimals - priceDecimals);
+        } else {
+            return
+                uint(uint64(price.price)) /
+                10 ** uint32(priceDecimals - targetDecimals);
+        }
+    }
+
+    /**
+     * @notice getPrice function - retrieves price of a given token from the oracle
+     *
+     * @param id: price feed Id of the token
+     */
+    function _getPrice(bytes32 id) internal view returns (uint256) {
+        IPyth oracle = IPyth(payable(_oracle));
+        return convertToUint(oracle.getPrice(id), 18);
+    }
+
+    function getAllowUndercollateralized() public view returns (bool) {
+        return _allowUndercollateralized;
+    }
+
+    function getOracle() public view returns (address) {
+        return _oracle;
+    }
+
+    /**
+     * @notice getVaultHealth function - calculates vault collateral/debt ratio
+     *
+     * @param vaultId: Id of the vault for which to calculate health
+     */
+    function getVaultHealth(uint256 vaultId) public view returns (uint256) {
+        Vault memory vault = _vaults[vaultId];
+        return _getVaultHealth(vault);
+    }
+
+    /**
+     * @notice _getVaultHealth function - calculates vault collateral/debt ratio using the on-chain price feeds.
+     * In a real world scenario, caller should ensure that the price feeds are up to date before calling this function.
+     *
+     * @param vault: vault struct containing vault parameters
+     */
+    function _getVaultHealth(
+        Vault memory vault
+    ) internal view returns (uint256) {
+        uint256 priceCollateral = _getPrice(vault.tokenIdCollateral);
+        uint256 priceDebt = _getPrice(vault.tokenIdDebt);
+
+        if (priceCollateral < 0) {
+            revert NegativePrice();
+        }
+        if (priceDebt < 0) {
+            revert NegativePrice();
+        }
+
+        uint256 valueCollateral = priceCollateral * vault.amountCollateral;
+        uint256 valueDebt = priceDebt * vault.amountDebt;
+
+        return (valueCollateral * 1_000_000_000_000_000_000) / valueDebt;
+    }
+
+    /**
+     * @notice createVault function - creates a vault
+     *
+     * @param tokenCollateral: address of the collateral token of the vault
+     * @param tokenDebt: address of the debt token of the vault
+     * @param amountCollateral: amount of collateral tokens in the vault
+     * @param amountDebt: amount of debt tokens in the vault
+     * @param minHealthRatio: minimum health ratio of the vault, 10**18 is 100%
+     * @param minPermissionlessHealthRatio: minimum health ratio of the vault before permissionless liquidations are allowed. This should be less than minHealthRatio
+     * @param tokenIdCollateral: price feed Id of the collateral token
+     * @param tokenIdDebt: price feed Id of the debt token
+     * @param updateData: data to update price feeds with
+     */
+    function createVault(
+        address tokenCollateral,
+        address tokenDebt,
+        uint256 amountCollateral,
+        uint256 amountDebt,
+        uint256 minHealthRatio,
+        uint256 minPermissionlessHealthRatio,
+        bytes32 tokenIdCollateral,
+        bytes32 tokenIdDebt,
+        bytes[] calldata updateData
+    ) public payable returns (uint256) {
+        _updatePriceFeeds(updateData);
+        Vault memory vault = Vault(
+            tokenCollateral,
+            tokenDebt,
+            amountCollateral,
+            amountDebt,
+            minHealthRatio,
+            minPermissionlessHealthRatio,
+            tokenIdCollateral,
+            tokenIdDebt
+        );
+        if (minPermissionlessHealthRatio > minHealthRatio) {
+            revert InvalidHealthRatios();
+        }
+        if (
+            !_allowUndercollateralized &&
+            _getVaultHealth(vault) < vault.minHealthRatio
+        ) {
+            revert UncollateralizedVaultCreation();
+        }
+
+        IERC20(vault.tokenCollateral).safeTransferFrom(
+            msg.sender,
+            address(this),
+            vault.amountCollateral
+        );
+        IERC20(vault.tokenDebt).safeTransfer(msg.sender, vault.amountDebt);
+
+        _vaults[_nVaults] = vault;
+        _nVaults += 1;
+
+        return _nVaults;
+    }
+
+    /**
+     * @notice updateVault function - updates a vault's collateral and debt amounts
+     *
+     * @param vaultId: Id of the vault to be updated
+     * @param deltaCollateral: delta change to collateral amount (+ means adding collateral tokens, - means removing collateral tokens)
+     * @param deltaDebt: delta change to debt amount (+ means withdrawing debt tokens from protocol, - means resending debt tokens to protocol)
+     */
+    function updateVault(
+        uint256 vaultId,
+        int256 deltaCollateral,
+        int256 deltaDebt
+    ) public {
+        Vault memory vault = _vaults[vaultId];
+
+        uint256 qCollateral = stdMath.abs(deltaCollateral);
+        uint256 qDebt = stdMath.abs(deltaDebt);
+
+        bool withdrawExcessiveCollateral = (deltaCollateral < 0) &&
+            (qCollateral > vault.amountCollateral);
+
+        if (withdrawExcessiveCollateral) {
+            revert InvalidVaultUpdate();
+        }
+
+        uint256 futureCollateral = (deltaCollateral >= 0)
+            ? (vault.amountCollateral + qCollateral)
+            : (vault.amountCollateral - qCollateral);
+        uint256 futureDebt = (deltaDebt >= 0)
+            ? (vault.amountDebt + qDebt)
+            : (vault.amountDebt - qDebt);
+
+        vault.amountCollateral = futureCollateral;
+        vault.amountDebt = futureDebt;
+
+        if (
+            !_allowUndercollateralized &&
+            _getVaultHealth(vault) < vault.minHealthRatio
+        ) {
+            revert InvalidVaultUpdate();
+        }
+
+        // update collateral position
+        if (deltaCollateral >= 0) {
+            // sender adds more collateral to their vault
+            IERC20(vault.tokenCollateral).safeTransferFrom(
+                msg.sender,
+                address(this),
+                qCollateral
+            );
+            _vaults[vaultId].amountCollateral += qCollateral;
+        } else {
+            // sender takes back collateral from their vault
+            IERC20(vault.tokenCollateral).safeTransfer(msg.sender, qCollateral);
+            _vaults[vaultId].amountCollateral -= qCollateral;
+        }
+
+        // update debt position
+        if (deltaDebt >= 0) {
+            // sender takes out more debt position
+            IERC20(vault.tokenDebt).safeTransfer(msg.sender, qDebt);
+            _vaults[vaultId].amountDebt += qDebt;
+        } else {
+            // sender sends back debt tokens
+            IERC20(vault.tokenDebt).safeTransferFrom(
+                msg.sender,
+                address(this),
+                qDebt
+            );
+            _vaults[vaultId].amountDebt -= qDebt;
+        }
+    }
+
+    /**
+     * @notice getVault function - getter function to get a vault's parameters
+     *
+     * @param vaultId: Id of the vault
+     */
+    function getVault(uint256 vaultId) public view returns (Vault memory) {
+        return _vaults[vaultId];
+    }
+
+    /**
+     * @notice _updatePriceFeeds function - updates the specified price feeds with given data
+     *
+     * @param updateData: data to update price feeds with
+     */
+    function _updatePriceFeeds(bytes[] calldata updateData) internal {
+        if (updateData.length == 0) {
+            return;
+        }
+        IPyth oracle = IPyth(payable(_oracle));
+        oracle.updatePriceFeeds{value: msg.value}(updateData);
+    }
+
+    /**
+     * @notice liquidate function - liquidates a vault
+     * This function calculates the health of the vault and based on the vault parameters one of the following actions is taken:
+     * 1. If health >= minHealthRatio, don't liquidate
+     * 2. If minHealthRatio > health >= minPermissionlessHealthRatio, only liquidate if the vault is permissioned via express relay
+     * 3. If minPermissionlessHealthRatio > health, liquidate no matter what
+     *
+     * @param vaultId: Id of the vault to be liquidated
+     */
+    function liquidate(uint256 vaultId) public {
+        Vault memory vault = _vaults[vaultId];
+        uint256 vaultHealth = _getVaultHealth(vault);
+
+        // if vault health is above the minimum health ratio, don't liquidate
+        if (vaultHealth >= vault.minHealthRatio) {
+            revert InvalidLiquidation();
+        }
+
+        if (vaultHealth >= vault.minPermissionlessHealthRatio) {
+            // if vault health is below the minimum health ratio but above the minimum permissionless health ratio,
+            // only liquidate if permissioned
+            if (
+                !IExpressRelay(expressRelay).isPermissioned(
+                    address(this), // protocol fee receiver
+                    abi.encode(vaultId) // vault id uniquely represents the opportunity and can be used as permission id
+                )
+            ) {
+                revert InvalidLiquidation();
+            }
+        }
+
+        IERC20(vault.tokenDebt).transferFrom(
+            msg.sender,
+            address(this),
+            vault.amountDebt
+        );
+        IERC20(vault.tokenCollateral).transfer(
+            msg.sender,
+            vault.amountCollateral
+        );
+
+        _vaults[vaultId].amountCollateral = 0;
+        _vaults[vaultId].amountDebt = 0;
+    }
+
+    /**
+     * @notice liquidateWithPriceUpdate function - liquidates a vault after updating the specified price feeds with given data
+     *
+     * @param vaultId: Id of the vault to be liquidated
+     * @param updateData: data to update price feeds with
+     */
+    function liquidateWithPriceUpdate(
+        uint256 vaultId,
+        bytes[] calldata updateData
+    ) external payable {
+        _updatePriceFeeds(updateData);
+        liquidate(vaultId);
+    }
+
+    /**
+     * @notice receiveAuctionProceedings function - receives native token from the express relay
+     * You can use permission key to distribute the received funds to users who got liquidated, LPs, etc...
+     *
+     * @param permissionKey: permission key that was used for the auction
+     */
+    function receiveAuctionProceedings(
+        bytes calldata permissionKey
+    ) external payable {
+        emit VaultReceivedETH(msg.sender, msg.value, permissionKey);
+    }
+
+    receive() external payable {}
+}

+ 20 - 0
express_relay/examples/easy_lend/contracts/EasyLendErrors.sol

@@ -0,0 +1,20 @@
+// Copyright (C) 2024 Lavra Holdings Limited - All Rights Reserved
+pragma solidity ^0.8.13;
+
+// Signature: 0xe922edfd
+error UncollateralizedVaultCreation();
+
+// Signature: 0xdcb430ee
+error InvalidVaultUpdate();
+
+// Signature: 0x9cd7b1c6
+error InvalidPriceExponent();
+
+// Signature: 0x85914873
+error InvalidLiquidation();
+
+// Signature: 0x61ca76d2
+error NegativePrice();
+
+// Signature: 0x4a7a3163
+error InvalidHealthRatios();

+ 13 - 0
express_relay/examples/easy_lend/contracts/EasyLendStructs.sol

@@ -0,0 +1,13 @@
+// Copyright (C) 2024 Lavra Holdings Limited - All Rights Reserved
+pragma solidity ^0.8.13;
+
+struct Vault {
+    address tokenCollateral;
+    address tokenDebt;
+    uint256 amountCollateral;
+    uint256 amountDebt;
+    uint256 minHealthRatio; // 10**18 is 100%
+    uint256 minPermissionlessHealthRatio;
+    bytes32 tokenIdCollateral;
+    bytes32 tokenIdDebt;
+}

+ 10 - 0
express_relay/examples/easy_lend/foundry.toml

@@ -0,0 +1,10 @@
+[profile.default]
+src = "contracts"
+out = "out"
+libs = [
+    'lib',
+    '../../../node_modules',
+    '../../../target_chains/ethereum/sdk/solidity'
+]
+
+# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options

+ 1 - 0
express_relay/examples/easy_lend/lib/README.md

@@ -0,0 +1 @@
+Forge installs the dependencies in this folder. They are .gitignored

+ 40 - 0
express_relay/examples/easy_lend/package.json

@@ -0,0 +1,40 @@
+{
+  "name": "easylend",
+  "version": "0.1.0",
+  "description": "Example lending protocol with express relay integration",
+  "private": true,
+  "files": [
+    "tslib/**/*"
+  ],
+  "scripts": {
+    "build": "tsc",
+    "lint": "eslint src/",
+    "format": "prettier --write \"src/**/*.ts\"",
+    "monitor": "npm run build && node tslib/monitor.js",
+    "install-forge-deps": "forge install foundry-rs/forge-std@v1.7.6 --no-git --no-commit"
+  },
+  "author": "",
+  "license": "Apache-2.0",
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/pyth-network/pyth-crosschain.git"
+  },
+  "dependencies": {
+    "@openzeppelin/contracts": "^4.5.0",
+    "@pythnetwork/express-relay-evm-js": "*",
+    "@pythnetwork/express-relay-sdk-solidity": "*",
+    "@pythnetwork/pyth-evm-js": "*",
+    "@pythnetwork/pyth-sdk-solidity": "*",
+    "ts-node": "^10.9.1",
+    "typescript": "^5.3.3",
+    "viem": "^2.7.6"
+  },
+  "devDependencies": {
+    "@types/yargs": "^17.0.10",
+    "eslint": "^8.56.0",
+    "prettier": "^2.6.2",
+    "typedoc": "^0.25.7",
+    "typescript": "^5.1",
+    "yargs": "^17.4.1"
+  }
+}

+ 3 - 0
express_relay/examples/easy_lend/remappings.txt

@@ -0,0 +1,3 @@
+forge-std/=lib/forge-std/src/
+@openzeppelin/=../../../node_modules/@openzeppelin/
+@pythnetwork/=../../../node_modules/@pythnetwork/

+ 163 - 0
express_relay/examples/easy_lend/src/abi.ts

@@ -0,0 +1,163 @@
+// This is only a subset of the generated abi necessary for the monitor script
+export const abi = [
+  {
+    type: "function",
+    name: "getLastVaultId",
+    inputs: [],
+    outputs: [
+      {
+        name: "",
+        type: "uint256",
+        internalType: "uint256",
+      },
+    ],
+    stateMutability: "view",
+  },
+  {
+    type: "function",
+    name: "getVault",
+    inputs: [
+      {
+        name: "vaultId",
+        type: "uint256",
+        internalType: "uint256",
+      },
+    ],
+    outputs: [
+      {
+        name: "",
+        type: "tuple",
+        internalType: "struct Vault",
+        components: [
+          {
+            name: "tokenCollateral",
+            type: "address",
+            internalType: "address",
+          },
+          {
+            name: "tokenDebt",
+            type: "address",
+            internalType: "address",
+          },
+          {
+            name: "amountCollateral",
+            type: "uint256",
+            internalType: "uint256",
+          },
+          {
+            name: "amountDebt",
+            type: "uint256",
+            internalType: "uint256",
+          },
+          {
+            name: "minHealthRatio",
+            type: "uint256",
+            internalType: "uint256",
+          },
+          {
+            name: "minPermissionLessHealthRatio",
+            type: "uint256",
+            internalType: "uint256",
+          },
+          {
+            name: "tokenIdCollateral",
+            type: "bytes32",
+            internalType: "bytes32",
+          },
+          {
+            name: "tokenIdDebt",
+            type: "bytes32",
+            internalType: "bytes32",
+          },
+        ],
+      },
+    ],
+    stateMutability: "view",
+  },
+  {
+    type: "function",
+    name: "liquidate",
+    inputs: [
+      {
+        name: "vaultId",
+        type: "uint256",
+        internalType: "uint256",
+      },
+    ],
+    outputs: [],
+    stateMutability: "nonpayable",
+  },
+  {
+    type: "function",
+    name: "liquidateWithPriceUpdate",
+    inputs: [
+      {
+        name: "vaultId",
+        type: "uint256",
+        internalType: "uint256",
+      },
+      {
+        name: "updateData",
+        type: "bytes[]",
+        internalType: "bytes[]",
+      },
+    ],
+    outputs: [],
+    stateMutability: "payable",
+  },
+  {
+    type: "event",
+    name: "VaultReceivedETH",
+    inputs: [
+      {
+        name: "sender",
+        type: "address",
+        indexed: false,
+        internalType: "address",
+      },
+      {
+        name: "amount",
+        type: "uint256",
+        indexed: false,
+        internalType: "uint256",
+      },
+      {
+        name: "permissionKey",
+        type: "bytes",
+        indexed: false,
+        internalType: "bytes",
+      },
+    ],
+    anonymous: false,
+  },
+  {
+    type: "error",
+    name: "InvalidHealthRatios",
+    inputs: [],
+  },
+  {
+    type: "error",
+    name: "InvalidLiquidation",
+    inputs: [],
+  },
+  {
+    type: "error",
+    name: "InvalidPriceExponent",
+    inputs: [],
+  },
+  {
+    type: "error",
+    name: "InvalidVaultUpdate",
+    inputs: [],
+  },
+  {
+    type: "error",
+    name: "NegativePrice",
+    inputs: [],
+  },
+  {
+    type: "error",
+    name: "UncollateralizedVaultCreation",
+    inputs: [],
+  },
+] as const;

+ 239 - 0
express_relay/examples/easy_lend/src/monitor.ts

@@ -0,0 +1,239 @@
+import yargs from "yargs";
+import { hideBin } from "yargs/helpers";
+import {
+  checkAddress,
+  Client,
+  OpportunityParams,
+} from "@pythnetwork/express-relay-evm-js";
+import { privateKeyToAccount } from "viem/accounts";
+import type { ContractFunctionReturnType } from "viem";
+import {
+  Address,
+  createPublicClient,
+  encodeAbiParameters,
+  encodeFunctionData,
+  getContract,
+  Hex,
+  http,
+  isHex,
+} from "viem";
+import { optimismSepolia } from "viem/chains";
+import { abi } from "./abi";
+import {
+  PriceFeed,
+  PriceServiceConnection,
+} from "@pythnetwork/price-service-client";
+
+type VaultWithId = ContractFunctionReturnType<
+  typeof abi,
+  "view",
+  "getVault"
+> & { id: bigint };
+class ProtocolMonitor {
+  private client: Client;
+  private subscribedIds: Set<string> = new Set();
+  private prices: Record<Hex, PriceFeed> = {};
+  private priceConnection: PriceServiceConnection;
+
+  constructor(
+    expressRelayEndpoint: string,
+    pythEndpoint: string,
+    private chainId: string,
+    private wethContract: Address,
+    private vaultContract: Address,
+    private onlyRecent: number | undefined
+  ) {
+    this.client = new Client({ baseUrl: expressRelayEndpoint });
+    this.priceConnection = new PriceServiceConnection(pythEndpoint, {
+      priceFeedRequestConfig: { binary: true },
+    });
+  }
+
+  updatePrice(feed: PriceFeed) {
+    this.prices[`0x${feed.id}`] = feed;
+  }
+
+  async subscribeToPriceFeed(tokenId: string) {
+    if (!this.subscribedIds.has(tokenId)) {
+      await this.priceConnection.subscribePriceFeedUpdates(
+        [tokenId],
+        this.updatePrice.bind(this)
+      );
+      this.subscribedIds.add(tokenId);
+    }
+  }
+
+  async checkVaults() {
+    const rpcClient = createPublicClient({
+      chain: optimismSepolia,
+      transport: http(),
+    });
+    const contract = getContract({
+      address: this.vaultContract,
+      abi,
+      client: rpcClient,
+    });
+    const lastVaultId = await contract.read.getLastVaultId();
+    const vaults: VaultWithId[] = [];
+    let startVaultId = 0n;
+    if (this.onlyRecent && lastVaultId > BigInt(this.onlyRecent)) {
+      startVaultId = lastVaultId - BigInt(this.onlyRecent);
+    }
+    for (let vaultId = startVaultId; vaultId < lastVaultId; vaultId++) {
+      const vault = await contract.read.getVault([vaultId]);
+      // Already liquidated vault
+      if (vault.amountCollateral == 0n && vault.amountDebt == 0n) {
+        continue;
+      }
+      vaults.push({ id: vaultId, ...vault });
+      await this.subscribeToPriceFeed(vault.tokenIdCollateral);
+      await this.subscribeToPriceFeed(vault.tokenIdDebt);
+    }
+
+    for (const vault of vaults) {
+      if (this.isLiquidatable(vault)) {
+        const opportunity = this.createOpportunity(vault);
+        await this.client.submitOpportunity(opportunity);
+      }
+    }
+  }
+
+  async start() {
+    // eslint-disable-next-line no-constant-condition
+    while (true) {
+      await this.checkVaults();
+      await new Promise((resolve) => setTimeout(resolve, 10000));
+    }
+  }
+
+  private createOpportunity(vault: VaultWithId) {
+    const priceUpdates = [
+      this.prices[vault.tokenIdCollateral].getVAA()!,
+      this.prices[vault.tokenIdDebt].getVAA()!,
+    ];
+    const vaas: Hex[] = priceUpdates.map(
+      (vaa): Hex => `0x${Buffer.from(vaa, "base64").toString("hex")}`
+    );
+    const calldata = encodeFunctionData({
+      abi,
+      functionName: "liquidateWithPriceUpdate",
+      args: [vault.id, vaas],
+    });
+    const permission = this.createPermission(vault.id);
+    const targetCallValue = BigInt(priceUpdates.length);
+    let sellTokens;
+    if (targetCallValue > 0 && vault.tokenDebt == this.wethContract) {
+      sellTokens = [
+        {
+          token: this.wethContract,
+          amount: targetCallValue + vault.amountDebt,
+        },
+      ];
+    } else {
+      sellTokens = [
+        { token: vault.tokenDebt, amount: vault.amountDebt },
+        { token: this.wethContract, amount: targetCallValue },
+      ];
+    }
+    const opportunity: OpportunityParams = {
+      chainId: this.chainId,
+      targetContract: this.vaultContract,
+      targetCalldata: calldata,
+      permissionKey: permission,
+      targetCallValue: targetCallValue,
+      buyTokens: [
+        { token: vault.tokenCollateral, amount: vault.amountCollateral },
+      ],
+      sellTokens: sellTokens,
+    };
+    return opportunity;
+  }
+
+  private isLiquidatable(vault: VaultWithId): boolean {
+    if (
+      !this.prices[vault.tokenIdCollateral] ||
+      !this.prices[vault.tokenIdDebt]
+    ) {
+      return false;
+    }
+    const priceCollateral = BigInt(
+      this.prices[vault.tokenIdCollateral].getPriceUnchecked().price
+    );
+    const priceDebt = BigInt(
+      this.prices[vault.tokenIdDebt].getPriceUnchecked().price
+    );
+    const valueCollateral = priceCollateral * vault.amountCollateral;
+    const valueDebt = priceDebt * vault.amountDebt;
+    if (valueDebt * vault.minHealthRatio > valueCollateral * 10n ** 18n) {
+      const health = Number(valueCollateral) / Number(valueDebt);
+      console.log(`Vault ${vault.id} is undercollateralized health: ${health}`);
+      return true;
+    }
+    return false;
+  }
+
+  private createPermission(vaultId: bigint) {
+    const permissionPayload = encodeAbiParameters(
+      [{ type: "uint256", name: "vaultId" }],
+      [vaultId]
+    );
+    const permission = encodeAbiParameters(
+      [
+        { type: "address", name: "contract" },
+        { type: "bytes", name: "vaultId" },
+      ],
+      [this.vaultContract, permissionPayload]
+    );
+    return permission;
+  }
+}
+
+const argv = yargs(hideBin(process.argv))
+  .option("express-relay-endpoint", {
+    description:
+      "Express relay endpoint. e.g: https://per-staging.dourolabs.app/",
+    type: "string",
+    default: "https://per-staging.dourolabs.app/",
+  })
+  .option("pyth-endpoint", {
+    description: "Pyth endpoint to use for fetching prices",
+    type: "string",
+    default: "https://hermes.pyth.network",
+  })
+  .option("chain-id", {
+    description: "Chain id to send opportunities for. e.g: sepolia",
+    type: "string",
+    demandOption: true,
+  })
+  .option("weth-contract", {
+    description: "wrapped eth contract address",
+    type: "string",
+    demandOption: true,
+  })
+  .option("vault-contract", {
+    description: "Dummy token vault contract address",
+    type: "string",
+    demandOption: true,
+  })
+  .option("only-recent", {
+    description:
+      "Instead of checking all vaults, only check recent ones. Specify the number of recent vaults to check",
+    type: "number",
+  })
+  .help()
+  .alias("help", "h")
+  .parseSync();
+
+async function run() {
+  const monitor = new ProtocolMonitor(
+    argv.expressRelayEndpoint,
+    argv.pythEndpoint,
+    argv.chainId,
+    checkAddress(argv.wethContract),
+    checkAddress(argv.vaultContract),
+    argv.onlyRecent
+  );
+  await monitor.start();
+}
+
+run();

+ 15 - 0
express_relay/examples/easy_lend/tsconfig.json

@@ -0,0 +1,15 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "target": "esnext",
+    "module": "commonjs",
+    "declaration": true,
+    "rootDir": "src/",
+    "outDir": "./tslib",
+    "strict": true,
+    "esModuleInterop": true,
+    "resolveJsonModule": true
+  },
+  "include": ["src"],
+  "exclude": ["node_modules", "**/__tests__/*"]
+}

+ 1 - 0
express_relay/sdk/js/package.json

@@ -45,6 +45,7 @@
     "@types/yargs": "^17.0.10",
     "@typescript-eslint/eslint-plugin": "^5.21.0",
     "@typescript-eslint/parser": "^5.21.0",
+    "@pythnetwork/pyth-evm-js": "*",
     "eslint": "^8.56.0",
     "jest": "^27.5.1",
     "prettier": "^2.6.2",

+ 621 - 0
package-lock.json

@@ -8,6 +8,7 @@
       "name": "root",
       "version": "0.0.1",
       "workspaces": [
+        "express_relay/examples/easy_lend",
         "express_relay/sdk/js",
         "express_relay/sdk/solidity",
         "governance/xc_admin/packages/*",
@@ -650,6 +651,371 @@
         }
       }
     },
+    "express_relay/examples/easy_lend": {
+      "name": "easylend",
+      "version": "0.1.0",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@openzeppelin/contracts": "^4.5.0",
+        "@pythnetwork/express-relay-evm-js": "*",
+        "@pythnetwork/express-relay-sdk-solidity": "*",
+        "@pythnetwork/pyth-evm-js": "*",
+        "@pythnetwork/pyth-sdk-solidity": "*",
+        "ts-node": "^10.9.1",
+        "typescript": "^5.3.3",
+        "viem": "^2.7.6"
+      },
+      "devDependencies": {
+        "@types/yargs": "^17.0.10",
+        "eslint": "^8.56.0",
+        "prettier": "^2.6.2",
+        "typedoc": "^0.25.7",
+        "typescript": "^5.1",
+        "yargs": "^17.4.1"
+      }
+    },
+    "express_relay/examples/easy_lend/node_modules/@eslint/eslintrc": {
+      "version": "2.1.4",
+      "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
+      "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
+      "dev": true,
+      "dependencies": {
+        "ajv": "^6.12.4",
+        "debug": "^4.3.2",
+        "espree": "^9.6.0",
+        "globals": "^13.19.0",
+        "ignore": "^5.2.0",
+        "import-fresh": "^3.2.1",
+        "js-yaml": "^4.1.0",
+        "minimatch": "^3.1.2",
+        "strip-json-comments": "^3.1.1"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "express_relay/examples/easy_lend/node_modules/@eslint/js": {
+      "version": "8.57.0",
+      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz",
+      "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==",
+      "dev": true,
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      }
+    },
+    "express_relay/examples/easy_lend/node_modules/@humanwhocodes/config-array": {
+      "version": "0.11.14",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",
+      "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==",
+      "dev": true,
+      "dependencies": {
+        "@humanwhocodes/object-schema": "^2.0.2",
+        "debug": "^4.3.1",
+        "minimatch": "^3.0.5"
+      },
+      "engines": {
+        "node": ">=10.10.0"
+      }
+    },
+    "express_relay/examples/easy_lend/node_modules/@humanwhocodes/object-schema": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz",
+      "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==",
+      "dev": true
+    },
+    "express_relay/examples/easy_lend/node_modules/@noble/curves": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
+      "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
+      "dependencies": {
+        "@noble/hashes": "1.3.2"
+      },
+      "funding": {
+        "url": "https://paulmillr.com/funding/"
+      }
+    },
+    "express_relay/examples/easy_lend/node_modules/@noble/hashes": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz",
+      "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==",
+      "engines": {
+        "node": ">= 16"
+      },
+      "funding": {
+        "url": "https://paulmillr.com/funding/"
+      }
+    },
+    "express_relay/examples/easy_lend/node_modules/@scure/bip32": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.2.tgz",
+      "integrity": "sha512-N1ZhksgwD3OBlwTv3R6KFEcPojl/W4ElJOeCZdi+vuI5QmTFwLq3OFf2zd2ROpKvxFdgZ6hUpb0dx9bVNEwYCA==",
+      "dependencies": {
+        "@noble/curves": "~1.2.0",
+        "@noble/hashes": "~1.3.2",
+        "@scure/base": "~1.1.2"
+      },
+      "funding": {
+        "url": "https://paulmillr.com/funding/"
+      }
+    },
+    "express_relay/examples/easy_lend/node_modules/@scure/bip39": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz",
+      "integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==",
+      "dependencies": {
+        "@noble/hashes": "~1.3.0",
+        "@scure/base": "~1.1.0"
+      },
+      "funding": {
+        "url": "https://paulmillr.com/funding/"
+      }
+    },
+    "express_relay/examples/easy_lend/node_modules/abitype": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.0.0.tgz",
+      "integrity": "sha512-NMeMah//6bJ56H5XRj8QCV4AwuW6hB6zqz2LnhhLdcWVQOsXki6/Pn3APeqxCma62nXIcmZWdu1DlHWS74umVQ==",
+      "funding": {
+        "url": "https://github.com/sponsors/wevm"
+      },
+      "peerDependencies": {
+        "typescript": ">=5.0.4",
+        "zod": "^3 >=3.22.0"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        },
+        "zod": {
+          "optional": true
+        }
+      }
+    },
+    "express_relay/examples/easy_lend/node_modules/cliui": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+      "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+      "dev": true,
+      "dependencies": {
+        "string-width": "^4.2.0",
+        "strip-ansi": "^6.0.1",
+        "wrap-ansi": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "express_relay/examples/easy_lend/node_modules/escape-string-regexp": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+      "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "express_relay/examples/easy_lend/node_modules/eslint": {
+      "version": "8.57.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz",
+      "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==",
+      "dev": true,
+      "dependencies": {
+        "@eslint-community/eslint-utils": "^4.2.0",
+        "@eslint-community/regexpp": "^4.6.1",
+        "@eslint/eslintrc": "^2.1.4",
+        "@eslint/js": "8.57.0",
+        "@humanwhocodes/config-array": "^0.11.14",
+        "@humanwhocodes/module-importer": "^1.0.1",
+        "@nodelib/fs.walk": "^1.2.8",
+        "@ungap/structured-clone": "^1.2.0",
+        "ajv": "^6.12.4",
+        "chalk": "^4.0.0",
+        "cross-spawn": "^7.0.2",
+        "debug": "^4.3.2",
+        "doctrine": "^3.0.0",
+        "escape-string-regexp": "^4.0.0",
+        "eslint-scope": "^7.2.2",
+        "eslint-visitor-keys": "^3.4.3",
+        "espree": "^9.6.1",
+        "esquery": "^1.4.2",
+        "esutils": "^2.0.2",
+        "fast-deep-equal": "^3.1.3",
+        "file-entry-cache": "^6.0.1",
+        "find-up": "^5.0.0",
+        "glob-parent": "^6.0.2",
+        "globals": "^13.19.0",
+        "graphemer": "^1.4.0",
+        "ignore": "^5.2.0",
+        "imurmurhash": "^0.1.4",
+        "is-glob": "^4.0.0",
+        "is-path-inside": "^3.0.3",
+        "js-yaml": "^4.1.0",
+        "json-stable-stringify-without-jsonify": "^1.0.1",
+        "levn": "^0.4.1",
+        "lodash.merge": "^4.6.2",
+        "minimatch": "^3.1.2",
+        "natural-compare": "^1.4.0",
+        "optionator": "^0.9.3",
+        "strip-ansi": "^6.0.1",
+        "text-table": "^0.2.0"
+      },
+      "bin": {
+        "eslint": "bin/eslint.js"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "express_relay/examples/easy_lend/node_modules/find-up": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+      "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+      "dev": true,
+      "dependencies": {
+        "locate-path": "^6.0.0",
+        "path-exists": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "express_relay/examples/easy_lend/node_modules/glob-parent": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+      "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+      "dev": true,
+      "dependencies": {
+        "is-glob": "^4.0.3"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "express_relay/examples/easy_lend/node_modules/locate-path": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+      "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+      "dev": true,
+      "dependencies": {
+        "p-locate": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "express_relay/examples/easy_lend/node_modules/p-limit": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+      "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+      "dev": true,
+      "dependencies": {
+        "yocto-queue": "^0.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "express_relay/examples/easy_lend/node_modules/p-locate": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+      "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+      "dev": true,
+      "dependencies": {
+        "p-limit": "^3.0.2"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "express_relay/examples/easy_lend/node_modules/typescript": {
+      "version": "5.4.2",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz",
+      "integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==",
+      "devOptional": true,
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "express_relay/examples/easy_lend/node_modules/viem": {
+      "version": "2.8.13",
+      "resolved": "https://registry.npmjs.org/viem/-/viem-2.8.13.tgz",
+      "integrity": "sha512-jEbRUjsiBwmoDr3fnKL1Bh1GhK5ERhmZcPLeARtEaQoBTPB6bcO2siKhNPVOF8qrYRnGHGQrZHncBWMQhTjGYg==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/wevm"
+        }
+      ],
+      "dependencies": {
+        "@adraffy/ens-normalize": "1.10.0",
+        "@noble/curves": "1.2.0",
+        "@noble/hashes": "1.3.2",
+        "@scure/bip32": "1.3.2",
+        "@scure/bip39": "1.2.1",
+        "abitype": "1.0.0",
+        "isows": "1.0.3",
+        "ws": "8.13.0"
+      },
+      "peerDependencies": {
+        "typescript": ">=5.0.4"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "express_relay/examples/easy_lend/node_modules/yargs": {
+      "version": "17.7.2",
+      "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+      "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+      "dev": true,
+      "dependencies": {
+        "cliui": "^8.0.1",
+        "escalade": "^3.1.1",
+        "get-caller-file": "^2.0.5",
+        "require-directory": "^2.1.1",
+        "string-width": "^4.2.3",
+        "y18n": "^5.0.5",
+        "yargs-parser": "^21.1.1"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "express_relay/examples/easy_lend/node_modules/yargs-parser": {
+      "version": "21.1.1",
+      "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+      "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+      "dev": true,
+      "engines": {
+        "node": ">=12"
+      }
+    },
     "express_relay/sdk/js": {
       "name": "@pythnetwork/express-relay-evm-js",
       "version": "0.2.0",
@@ -663,6 +1029,7 @@
         "ws": "^8.16.0"
       },
       "devDependencies": {
+        "@pythnetwork/pyth-evm-js": "*",
         "@types/yargs": "^17.0.10",
         "@typescript-eslint/eslint-plugin": "^5.21.0",
         "@typescript-eslint/parser": "^5.21.0",
@@ -27840,6 +28207,10 @@
       "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
       "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="
     },
+    "node_modules/easylend": {
+      "resolved": "express_relay/examples/easy_lend",
+      "link": true
+    },
     "node_modules/ecc-jsbn": {
       "version": "0.1.2",
       "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz",
@@ -67903,6 +68274,7 @@
     "@pythnetwork/express-relay-evm-js": {
       "version": "file:express_relay/sdk/js",
       "requires": {
+        "@pythnetwork/pyth-evm-js": "*",
         "@types/yargs": "^17.0.10",
         "@typescript-eslint/eslint-plugin": "^5.21.0",
         "@typescript-eslint/parser": "^5.21.0",
@@ -83307,6 +83679,255 @@
       "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
       "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="
     },
+    "easylend": {
+      "version": "file:express_relay/examples/easy_lend",
+      "requires": {
+        "@openzeppelin/contracts": "^4.5.0",
+        "@pythnetwork/express-relay-evm-js": "*",
+        "@pythnetwork/express-relay-sdk-solidity": "*",
+        "@pythnetwork/pyth-evm-js": "*",
+        "@pythnetwork/pyth-sdk-solidity": "*",
+        "@types/yargs": "^17.0.10",
+        "eslint": "^8.56.0",
+        "prettier": "^2.6.2",
+        "ts-node": "^10.9.1",
+        "typedoc": "^0.25.7",
+        "typescript": "^5.1",
+        "viem": "^2.7.6",
+        "yargs": "^17.4.1"
+      },
+      "dependencies": {
+        "@eslint/eslintrc": {
+          "version": "2.1.4",
+          "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
+          "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
+          "dev": true,
+          "requires": {
+            "ajv": "^6.12.4",
+            "debug": "^4.3.2",
+            "espree": "^9.6.0",
+            "globals": "^13.19.0",
+            "ignore": "^5.2.0",
+            "import-fresh": "^3.2.1",
+            "js-yaml": "^4.1.0",
+            "minimatch": "^3.1.2",
+            "strip-json-comments": "^3.1.1"
+          }
+        },
+        "@eslint/js": {
+          "version": "8.57.0",
+          "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz",
+          "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==",
+          "dev": true
+        },
+        "@humanwhocodes/config-array": {
+          "version": "0.11.14",
+          "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",
+          "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==",
+          "dev": true,
+          "requires": {
+            "@humanwhocodes/object-schema": "^2.0.2",
+            "debug": "^4.3.1",
+            "minimatch": "^3.0.5"
+          }
+        },
+        "@humanwhocodes/object-schema": {
+          "version": "2.0.2",
+          "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz",
+          "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==",
+          "dev": true
+        },
+        "@noble/curves": {
+          "version": "1.2.0",
+          "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
+          "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
+          "requires": {
+            "@noble/hashes": "1.3.2"
+          }
+        },
+        "@noble/hashes": {
+          "version": "1.3.2",
+          "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz",
+          "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="
+        },
+        "@scure/bip32": {
+          "version": "1.3.2",
+          "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.2.tgz",
+          "integrity": "sha512-N1ZhksgwD3OBlwTv3R6KFEcPojl/W4ElJOeCZdi+vuI5QmTFwLq3OFf2zd2ROpKvxFdgZ6hUpb0dx9bVNEwYCA==",
+          "requires": {
+            "@noble/curves": "~1.2.0",
+            "@noble/hashes": "~1.3.2",
+            "@scure/base": "~1.1.2"
+          }
+        },
+        "@scure/bip39": {
+          "version": "1.2.1",
+          "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz",
+          "integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==",
+          "requires": {
+            "@noble/hashes": "~1.3.0",
+            "@scure/base": "~1.1.0"
+          }
+        },
+        "abitype": {
+          "version": "1.0.0",
+          "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.0.0.tgz",
+          "integrity": "sha512-NMeMah//6bJ56H5XRj8QCV4AwuW6hB6zqz2LnhhLdcWVQOsXki6/Pn3APeqxCma62nXIcmZWdu1DlHWS74umVQ==",
+          "requires": {}
+        },
+        "cliui": {
+          "version": "8.0.1",
+          "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+          "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+          "dev": true,
+          "requires": {
+            "string-width": "^4.2.0",
+            "strip-ansi": "^6.0.1",
+            "wrap-ansi": "^7.0.0"
+          }
+        },
+        "escape-string-regexp": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+          "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+          "dev": true
+        },
+        "eslint": {
+          "version": "8.57.0",
+          "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz",
+          "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==",
+          "dev": true,
+          "requires": {
+            "@eslint-community/eslint-utils": "^4.2.0",
+            "@eslint-community/regexpp": "^4.6.1",
+            "@eslint/eslintrc": "^2.1.4",
+            "@eslint/js": "8.57.0",
+            "@humanwhocodes/config-array": "^0.11.14",
+            "@humanwhocodes/module-importer": "^1.0.1",
+            "@nodelib/fs.walk": "^1.2.8",
+            "@ungap/structured-clone": "^1.2.0",
+            "ajv": "^6.12.4",
+            "chalk": "^4.0.0",
+            "cross-spawn": "^7.0.2",
+            "debug": "^4.3.2",
+            "doctrine": "^3.0.0",
+            "escape-string-regexp": "^4.0.0",
+            "eslint-scope": "^7.2.2",
+            "eslint-visitor-keys": "^3.4.3",
+            "espree": "^9.6.1",
+            "esquery": "^1.4.2",
+            "esutils": "^2.0.2",
+            "fast-deep-equal": "^3.1.3",
+            "file-entry-cache": "^6.0.1",
+            "find-up": "^5.0.0",
+            "glob-parent": "^6.0.2",
+            "globals": "^13.19.0",
+            "graphemer": "^1.4.0",
+            "ignore": "^5.2.0",
+            "imurmurhash": "^0.1.4",
+            "is-glob": "^4.0.0",
+            "is-path-inside": "^3.0.3",
+            "js-yaml": "^4.1.0",
+            "json-stable-stringify-without-jsonify": "^1.0.1",
+            "levn": "^0.4.1",
+            "lodash.merge": "^4.6.2",
+            "minimatch": "^3.1.2",
+            "natural-compare": "^1.4.0",
+            "optionator": "^0.9.3",
+            "strip-ansi": "^6.0.1",
+            "text-table": "^0.2.0"
+          }
+        },
+        "find-up": {
+          "version": "5.0.0",
+          "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+          "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+          "dev": true,
+          "requires": {
+            "locate-path": "^6.0.0",
+            "path-exists": "^4.0.0"
+          }
+        },
+        "glob-parent": {
+          "version": "6.0.2",
+          "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+          "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+          "dev": true,
+          "requires": {
+            "is-glob": "^4.0.3"
+          }
+        },
+        "locate-path": {
+          "version": "6.0.0",
+          "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+          "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+          "dev": true,
+          "requires": {
+            "p-locate": "^5.0.0"
+          }
+        },
+        "p-limit": {
+          "version": "3.1.0",
+          "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+          "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+          "dev": true,
+          "requires": {
+            "yocto-queue": "^0.1.0"
+          }
+        },
+        "p-locate": {
+          "version": "5.0.0",
+          "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+          "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+          "dev": true,
+          "requires": {
+            "p-limit": "^3.0.2"
+          }
+        },
+        "typescript": {
+          "version": "5.4.2",
+          "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz",
+          "integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==",
+          "devOptional": true
+        },
+        "viem": {
+          "version": "2.8.13",
+          "resolved": "https://registry.npmjs.org/viem/-/viem-2.8.13.tgz",
+          "integrity": "sha512-jEbRUjsiBwmoDr3fnKL1Bh1GhK5ERhmZcPLeARtEaQoBTPB6bcO2siKhNPVOF8qrYRnGHGQrZHncBWMQhTjGYg==",
+          "requires": {
+            "@adraffy/ens-normalize": "1.10.0",
+            "@noble/curves": "1.2.0",
+            "@noble/hashes": "1.3.2",
+            "@scure/bip32": "1.3.2",
+            "@scure/bip39": "1.2.1",
+            "abitype": "1.0.0",
+            "isows": "1.0.3",
+            "ws": "8.13.0"
+          }
+        },
+        "yargs": {
+          "version": "17.7.2",
+          "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+          "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+          "dev": true,
+          "requires": {
+            "cliui": "^8.0.1",
+            "escalade": "^3.1.1",
+            "get-caller-file": "^2.0.5",
+            "require-directory": "^2.1.1",
+            "string-width": "^4.2.3",
+            "y18n": "^5.0.5",
+            "yargs-parser": "^21.1.1"
+          }
+        },
+        "yargs-parser": {
+          "version": "21.1.1",
+          "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+          "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+          "dev": true
+        }
+      }
+    },
     "ecc-jsbn": {
       "version": "0.1.2",
       "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz",

+ 1 - 0
package.json

@@ -1,6 +1,7 @@
 {
   "name": "root",
   "workspaces": [
+    "express_relay/examples/easy_lend",
     "express_relay/sdk/js",
     "express_relay/sdk/solidity",
     "governance/xc_admin/packages/*",