sherlock-audit / 2024-08-woofi-solana-deployment-judging

2 stars 2 forks source link

S3v3ru5 - Dividing large swaps into smaller swaps does not update spread #46

Open sherlock-admin3 opened 2 months ago

sherlock-admin3 commented 2 months ago

S3v3ru5

High

Dividing large swaps into smaller swaps does not update spread

Summary

The WooFi team has updated the wooracle to update the spread for new price. This is a fix for many issues reported in the past audits of WooFi Solodity .

  1. Issue H-1 in Sherlock audit
  2. Issue 3.1 in Zellic report
  3. Issue 3.6 in Zellic report

The fix is incomplete as user can divide the large swap into multiple smaller swaps such that the condition for updating the spread is not satisfied without any additional costs.

Vulnerability details

The new spread is calculated as

spread_new = 1 - (min_p/max_p) / (1-spread_old)
if spread_new > spread_old:
      wooracle.spread = spread_new

https://github.com/sherlock-audit/2024-08-woofi-solana-deployment/blob/main/WOOFi_Solana/programs/woofi/src/state/wooracle.rs#L151-L176

Consider the case of Base-to-Quote swap

spread is update if new_s > s

Graphing the equations here with x = B = number of base tokens swapped without any decimals i.e If x = 2 then that means 2 SOL are swapped not 2 lamports.

The WooFi program only updates the spread if new_s > s i.e where "black graph" is above the "red graph". Its easy to see that price can be decreased significantly without affecting the spread.

In the example, chosen randomly, shown in the graph:

The red and black graphs meet at x = 5.3 i.e User can swap upto 5.3 base tokens and not cause an update to the spread. The price decreased upon 5.3 tokens is 3.

The User can repeat the process multiple times without additional costs because:

  1. The transaction costs are very minimal on Solana
  2. Dividing a large swap into smaller swaps is more profitable as shown in the issue M-2 in the Sherlock report.
  3. The swap fee is a percentage of the swapped amount and there's no fixed component in swap fee. Dividing the swap into smaller amounts does not increase swap fee.

This issue makes the implemented fix incomplete.

Root Cause

  1. The defined formula for updating spread depends only on ratio of previous price to the new price and the spread is only updated if that ratio results in spread greater than the current spread. The formula should be defined such that the spread is incremented with a value of continuous function based on the price ratio instead of the current formula which only results in updates for big jumps in prices.
  2. Not having a fixed component in swap fee making the swaps of smaller amounts profitable

Internal pre-conditions

No response

External pre-conditions

No response

Attack Path

  1. Price manipulation has described in Issue H-1 of the Sherlock audit report. For the attack to be profitable Pyth price also needs to be inaccurate because of external conditions.

  2. User can divide large swaps into smaller swaps. The net effect of amount swapped is same in both cases, however spread is not updated when smaller swaps are performed. This leads to loss of more amount to arbitage as described in Issue 3.1 of the zellic report.

  3. Sandwich attack can be used as described in issue 3.6 of the Zellic report.

Impact

Because WooFi simulates centralized exchange, one of invariants it needs to ensure is to update spread upon large swaps. The user can divide into smaller swaps without additional costs. As a result of which, the spread is not updated even after large amount is swapped.

Impact noted in Issue 3.1 of the Zellic report:

Users who wish to do large swaps may see better execution if they break up their swap into smaller swaps. Also, large swaps can cause the pool to lose an undue amount of value to arbitrage due to an overestimation of how accurate the current recorded price is.

PoC

No response

Mitigation

  1. Rewrite the formula for updating spread: Derive a continuous function based on the price change ratio and increment/decrement the spread using the result of this function.
  2. Add a fixed component to the swap fee disincentivizing division of large swap into smaller swaps.
toprince commented 2 months ago

Need further investigation.

S3v3ru5 commented 1 month ago

The graph here (mentioned in the report) should show exactly what was described in the issue. The graph uses the correct formulas as in the codebase. The report explains the graph.

S3v3ru5 commented 1 month ago

POC to show spread does not change when swap is divided into small swaps

create a 3_woofi_spread.ts file in tests/ dir alongside 0_woofi.ts, ... and copy the following contents to the file:

import * as anchor from "@coral-xyz/anchor";
import * as borsh from "borsh";
import { BN, Program } from "@coral-xyz/anchor";
import * as token from "@solana/spl-token";
import { LAMPORTS_PER_SOL, SystemProgram, Transaction, sendAndConfirmTransaction } from "@solana/web3.js";
import { getLogs } from "@solana-developers/helpers";
import { assert } from "chai";
import { createAssociatedTokenAccount, transferToken } from "./utils/token";
import { runQuery } from "./utils/pyth";
import { PoolUtils } from "./utils/pool";
import { getCluster } from "./global";
import { usdcTokenMint, solTokenMint, solPriceUpdate, usdcPriceUpdate, confirmOptionsRetryTres, SupportedToken } from "./utils/test-consts";
import moment from "moment";

describe("woofi_spread", () => {
  const poolUtils = new PoolUtils();
  poolUtils.initEnv();

  const provider = poolUtils.provider;
  const program = poolUtils.program;

  // SOL/USD
  const solFeedAccount = poolUtils.solFeedAccount;
  // USDC/USD
  const usdcFeedAccount = poolUtils.usdcFeedAccount;
  const quoteFeedAccount = usdcFeedAccount;

  // Note: test account only in devnet
  const keypair = anchor.web3.Keypair.fromSecretKey(
    Uint8Array.from([
        14, 134,  28, 211,  88,  74,  30, 241,  77, 166,  34,
        106, 198, 235, 100, 159,  82, 127,  72,  10, 101, 146,
        137,  83,  43,  25,  38,  10,  26, 217, 248,  64, 165,
        109,  48, 119, 128,  14, 170,  92,  99,  37,  71,  77,
         90, 116,  13,  67, 176, 214,   9,  47,  46, 103, 197,
        222,  76, 186, 193, 143, 114, 203, 154, 225
    ]),
  )
  const tenpow18 = new BN(10).pow(new BN(18));
  const tenpow16 = new BN(10).pow(new BN(16));
  const tenpow15 = new BN(10).pow(new BN(15));
  const tenpow12 = new BN(10).pow(new BN(12));
  const tenpow14 = new BN(10).pow(new BN(14));

  // coeff = 0.000003
  const testCoeff = new BN(3).mul(tenpow12);
  // spread = 0.0005
  const testSpread = new BN(5).mul(tenpow14);
  // max current feasible price
  let testPrice;

  // for swap test usage,
  // swap payer wallet
  const fromWallet = anchor.web3.Keypair.generate();

  let signers: anchor.web3.Keypair[] = [fromWallet];
  let ataSigner: anchor.web3.Keypair[] = [];
  let payerWallet = provider.wallet;
  if (getCluster() == 'localnet') {
    payerWallet = keypair;
    signers.push(keypair);
    ataSigner.push(payerWallet);
  }
  let payerSolTokenAccount: anchor.web3.PublicKey;
  let payerUsdcTokenAccount: anchor.web3.PublicKey;

  let wooconfigAccount;
  let solWooracle;
  let solPool;
  let usdcPool;
  let solTokenVault;
  let usdcTokenVault;
  let pythoracle_price: BN;
  let pythoracle_decimal: number;
  let traderSetPrice;

  const getPoolData = async (woopool: anchor.web3.PublicKey) => {
    let woopoolData = null;
    try {
      woopoolData = await program.account.wooPool.fetch(woopool);
    } catch (e) {
        console.log(e);
        console.log("Pool fetch failed");
        return;
    }

    return woopoolData;
  }

  const getWooOracleData = async (wooracle: anchor.web3.PublicKey) => {
    let wooracleData = null;
    try {
      wooracleData = await program.account.wooracle.fetch(wooracle);
    } catch (e) {
      console.log(e);
      console.log("Wooracle fetch failed");
      return;
    }

    return wooracleData;
  }

  const syncPythPrice = async () => {
    const pythPriceFeed = await runQuery();
    const solPrice = pythPriceFeed[0].getPriceNoOlderThan(1000);
    console.log("solPrice", solPrice);

    const usdcPrice = pythPriceFeed[1].getPriceNoOlderThan(1000);
    console.log("usdcPrice", usdcPrice);

    // use usdc as quote token
    pythoracle_decimal = Math.abs(solPrice.expo);
    pythoracle_price = new BN(solPrice.price).mul(new BN(10).pow(new BN(pythoracle_decimal))).div(new BN(usdcPrice.price));

    const updatedAt = moment.unix(solPrice.publishTime);

    console.log(`pythoracle_price:${pythoracle_price}`);
    console.log(`pythoracle_decimal:${pythoracle_decimal}`);
    console.log(`updated at - ${updatedAt}`);

    // TODO Prince: The price is the latest pyth price
    // May slightly differ from the one in wooracle's pyth price (clone program in localnet)
    traderSetPrice = pythoracle_price;
  }

  describe("#test_spread_not_updated", async () => {
    it("fails to update spread", async () => {
      await syncPythPrice();

      console.log("1. Mint 150 SOL to from wallet");

      payerSolTokenAccount = await createAssociatedTokenAccount(
        provider,
        solTokenMint,
        fromWallet.publicKey,
        payerWallet.publicKey,
        ataSigner
      );

      payerUsdcTokenAccount = await createAssociatedTokenAccount(
        provider,
        usdcTokenMint,
        fromWallet.publicKey,
        payerWallet.publicKey,
        ataSigner
      );

      const solTokenAccount = payerSolTokenAccount;
      const usdcTokenAccount = payerUsdcTokenAccount;

      let fromAmount = 150 * LAMPORTS_PER_SOL;
      await provider.connection.confirmTransaction(
        await provider.connection.requestAirdrop(
            fromWallet.publicKey,
            fromAmount,
        )
      );

      const fromPoolParams = await poolUtils.generatePoolParams(solTokenMint, usdcTokenMint, solFeedAccount, solPriceUpdate);
      const toPoolParams = await poolUtils.generatePoolParams(usdcTokenMint, usdcTokenMint, usdcFeedAccount, usdcPriceUpdate);

      wooconfigAccount = fromPoolParams.wooconfig;
      solWooracle = fromPoolParams.wooracle;
      solPool = fromPoolParams.woopool;
      solTokenVault = fromPoolParams.tokenVault;

      usdcPool = toPoolParams.woopool;
      usdcTokenVault = toPoolParams.tokenVault;

      // 1. Deposit 20k USDC into USDC pool for swaps
      console.log("2. Deposit 20k USDC into USDC pool for swaps");
      const depositAmount = new BN(20000_000000);
      let usdcAta = token.getAssociatedTokenAddressSync(usdcTokenMint, keypair.publicKey);

      const tx2 = new anchor.web3.Transaction();
      const amountVal = BigInt(depositAmount.toString()); // 20k USDC
      tx2.add(
        token.createMintToInstruction(usdcTokenMint, usdcAta, keypair.publicKey, amountVal)
      );

      await provider.sendAndConfirm(tx2, [keypair], {maxRetries: 5, commitment: "confirmed"})

      // 20k USDC
      const tx = await program
      .methods
      .deposit(depositAmount)
      .accounts({
        wooconfig: wooconfigAccount,
        tokenMint: usdcTokenMint,
        authority: keypair.publicKey,
        tokenOwnerAccount: usdcAta,
        woopool: usdcPool,
        tokenVault: usdcTokenVault,
        tokenProgram: token.TOKEN_PROGRAM_ID,
      })
      .signers([keypair])
      .rpc(confirmOptionsRetryTres);

      console.log("150 SOL Airdrop complete");

      const solTransferTranscation = new Transaction().add(
        // trasnfer SOL to WSOL into ata account
        SystemProgram.transfer({
          fromPubkey: fromWallet.publicKey,
          toPubkey: solTokenAccount,
          lamports: fromAmount,
        }),
      );

      await provider.sendAndConfirm(solTransferTranscation, [fromWallet], { maxRetries: 5, commitment: "confirmed" });

      console.log("SOL transferred");

      const syncWsol = new Transaction().add(
        // sync wrapped SOL balance
        token.createSyncNativeInstruction(solTokenAccount)
      );
      await provider.sendAndConfirm(syncWsol, [], { maxRetries: 5, commitment: "confirmed" });

      console.log("SOL converted to WSOL");
      // step 3: Set Max Gamma.
      console.log("3. Set Max Gamma to 5000000000000000 (same as ARB on arbitrum)");
      // max gamma is set to 300 (impractically small value) on creation
      // hence set max gamma to a meaningful value

      // ARB on arbitrum has these values
      // reserve   uint192 :  307455046866289276248073
      // feeRate   uint16 :  25
      // maxGamma   uint128 :  5000000000000000
      // maxNotionalSwap   uint128 :  500000000000

      await poolUtils.program
      .methods
      .setPoolMaxGamma(new BN(5000000000000000)) // same as ARB on arbitrum
      .accounts({
        wooconfig: wooconfigAccount,
        woopool: solPool,
        authority: provider.wallet.publicKey
      }).rpc(confirmOptionsRetryTres);

      // 4: initialize testPrice to max feasible price. bound is 2.5% same as Arbitrum
      console.log("4. initialize testPrice to max feasible price. bound is 2.5% same as Arbitrum");
      // set test price to max feasible price
      const bound = new BN(24).mul(tenpow15);
      const low_bound = pythoracle_price.mul(tenpow18.sub(bound)).div(tenpow18);
      const upper_bound = pythoracle_price.mul(tenpow18.add(bound)).div(tenpow18);

      testPrice = upper_bound;

      // ---------------- Set up finish -----------

      // test values:
      console.log("\nTest values:");
      console.log("test price:" + testPrice);
      console.log("test spread:" + testSpread);
      console.log("test coeff:" + testCoeff);

      console.log("\nTest A: swap 100 SOL; 2 SOL at a time and show spread is not updated");

      console.log("\nA.1 Set wooracle state to test values");
      // set price to 0 to not trigger update_spread when setting the test price
      await program
        .methods
        .setWooPrice(new BN(0))
        .accounts({
          wooconfig: wooconfigAccount,
          wooracle: solWooracle,
          authority: provider.wallet.publicKey,
        })
        .rpc(confirmOptionsRetryTres);

      // Set the wooracle state to test values
      await program
      .methods
      .setWooState(testPrice, testCoeff, testSpread)
      .accounts({
        wooconfig: wooconfigAccount,
        wooracle: solWooracle,
        authority: provider.wallet.publicKey,
      })
      .rpc(confirmOptionsRetryTres);

      const wooracleBeforeSwap100 = await program.account.wooracle.fetch(solWooracle);

      // Assert the price, spread and coeff are set to the test values
      assert.ok(wooracleBeforeSwap100.price.eq(testPrice));
      assert.ok(wooracleBeforeSwap100.spread.eq(testSpread));
      assert.ok(wooracleBeforeSwap100.coeff.eq(testCoeff));

      console.log("\nWooracle state before swaps:\n");
      console.log(`price - ${wooracleBeforeSwap100.price}`);
      console.log(`coeff - ${wooracleBeforeSwap100.coeff}`);
      console.log(`spread - ${wooracleBeforeSwap100.spread}`);

      const [fromPrice, fromFeasible] = await poolUtils.getOraclePriceResult(fromPoolParams.wooconfig, fromPoolParams.wooracle, solPriceUpdate, usdcPriceUpdate);
      console.log(`swap price - ${fromPrice}`);
      console.log(`swap feasible - ${fromFeasible}`);

      // perform swap of 100 SOL; 2 SOL at a time
      console.log("\nA.2 Swap 100 SOL; 2 SOL at a time");

      console.log("\n Swapping 100 SOL; 2 SOL at a time to not trigger update to spread\n");
      for (let i = 1; i <= 50; i++) {
        await program
        .methods
        .swap(new BN(2 * LAMPORTS_PER_SOL), new BN(0))
        .accounts({
          wooconfig: fromPoolParams.wooconfig,
          tokenProgram: token.TOKEN_PROGRAM_ID,
          payer: fromWallet.publicKey,  // is the user want to do swap
          wooracleFrom: fromPoolParams.wooracle,
          woopoolFrom: fromPoolParams.woopool,
          tokenOwnerAccountFrom: solTokenAccount,
          tokenVaultFrom: fromPoolParams.tokenVault,
          priceUpdateFrom: solPriceUpdate,
          wooracleTo: toPoolParams.wooracle,
          woopoolTo: toPoolParams.woopool,
          tokenOwnerAccountTo: usdcTokenAccount,
          tokenVaultTo: toPoolParams.tokenVault,
          priceUpdateTo: usdcPriceUpdate,
          woopoolQuote: toPoolParams.woopool,
          quotePriceUpdate: usdcPriceUpdate,
          quoteTokenVault: toPoolParams.tokenVault,
          rebateTo: fromWallet.publicKey,
        })
        .signers([fromWallet])
        .rpc(confirmOptionsRetryTres);
      }

      console.log("Wooracle state after swapping 100 SOL\n");
      const wooracleAfterSwap100 = await program.account.wooracle.fetch(solWooracle);

      console.log(`price - ${wooracleAfterSwap100.price}`);
      console.log(`coeff - ${wooracleAfterSwap100.coeff}`);
      console.log(`spread - ${wooracleAfterSwap100.spread}`);

      console.log("Price difference:" + wooracleBeforeSwap100.price.sub(wooracleAfterSwap100.price));
      console.log("spread before swap of 100 SOL == spread after swap == " + testSpread);

      console.log("\nA.3 Assert spread has not changed");
      // assert spread has not changed
      assert.ok(wooracleAfterSwap100.spread.eq(testSpread));

      // reset the wooracle state to test values

      console.log("\nTest B: Swap 5 SOL with the same wooracle state and show spread is updated");
      console.log("\nB.1 Reset wooracle state to test values");
      // set price to 0 to not trigger update_spread when setting the test price
      await program
      .methods
      .setWooPrice(new BN(0))
      .accounts({
        wooconfig: wooconfigAccount,
        wooracle: solWooracle,
        authority: provider.wallet.publicKey,
      })
      .rpc(confirmOptionsRetryTres);

      // Set the wooracle state to test values
      await program
      .methods
      .setWooState(testPrice, testCoeff, testSpread)
      .accounts({
        wooconfig: wooconfigAccount,
        wooracle: solWooracle,
        authority: provider.wallet.publicKey,
      })
      .rpc(confirmOptionsRetryTres);

      const wooracleBeforeSwap5 = await program.account.wooracle.fetch(solWooracle);

      // Assert the price, spread and coeff are set to the test values
      assert.ok(wooracleBeforeSwap5.price.eq(testPrice));
      assert.ok(wooracleBeforeSwap5.spread.eq(testSpread));
      assert.ok(wooracleBeforeSwap5.coeff.eq(testCoeff));

      console.log(`spread before swap of 5 sol == test spread == ${wooracleBeforeSwap5.spread}`);

      // swap 5 SOL
      console.log("\nB.2 Swap 5 SOL");

      await program
      .methods
      .swap(new BN(5 * LAMPORTS_PER_SOL), new BN(0))
      .accounts({
        wooconfig: fromPoolParams.wooconfig,
        tokenProgram: token.TOKEN_PROGRAM_ID,
        payer: fromWallet.publicKey,  // is the user want to do swap
        wooracleFrom: fromPoolParams.wooracle,
        woopoolFrom: fromPoolParams.woopool,
        tokenOwnerAccountFrom: solTokenAccount,
        tokenVaultFrom: fromPoolParams.tokenVault,
        priceUpdateFrom: solPriceUpdate,
        wooracleTo: toPoolParams.wooracle,
        woopoolTo: toPoolParams.woopool,
        tokenOwnerAccountTo: usdcTokenAccount,
        tokenVaultTo: toPoolParams.tokenVault,
        priceUpdateTo: usdcPriceUpdate,
        woopoolQuote: toPoolParams.woopool,
        quotePriceUpdate: usdcPriceUpdate,
        quoteTokenVault: toPoolParams.tokenVault,
        rebateTo: fromWallet.publicKey,
      })
      .signers([fromWallet])
      .rpc(confirmOptionsRetryTres);

      console.log("Wooracle state after swapping 5 SOL\n");
      const wooracleAfterSwap5 = await program.account.wooracle.fetch(solWooracle);

      console.log(`price - ${wooracleAfterSwap5.price}`);
      console.log(`coeff - ${wooracleAfterSwap5.coeff}`);
      console.log(`spread - ${wooracleAfterSwap5.spread}`);

      console.log("\nPrice difference:" + wooracleBeforeSwap5.price.sub(wooracleAfterSwap5.price));
      console.log("\nspread before swap of 5 SOL != spread after swap of 5 SOL:", wooracleBeforeSwap5.spread != wooracleAfterSwap5.spread);

      console.log("\nB.3 Assert spread has changed after the swap of 5 SOL");

      assert.ok(!wooracleAfterSwap5.spread.eq(testSpread));
    });
  });
});

running anchor test results in the following output:

  woofi_spread
    #test_spread_not_updated
solPrice Price {
  conf: '9077400',
  expo: -8,
  price: '14233670000',
  publishTime: 1728146557
}
usdcPrice Price {
  conf: '111040',
  expo: -8,
  price: '100002593',
  publishTime: 1728146557
}
pythoracle_price:14233300930
pythoracle_decimal:8
updated at - Sat Oct 05 2024 22:12:37 GMT+0530
1. Mint 150 SOL to from wallet
2. Deposit 20k USDC into USDC pool for swaps
150 SOL Airdrop complete
SOL transferred
SOL converted to WSOL
3. Set Max Gamma to 5000000000000000 (same as ARB on arbitrum)
4. initialize testPrice to max feasible price. bound is 2.5% same as Arbitrum

Test values:
test price:14574900152
test spread:500000000000000
test coeff:3000000000000

Test A: swap 100 SOL; 2 SOL at a time and show spread is not updated

A.1 Set wooracle state to test values

Wooracle state before swaps:

price - 14574900152
coeff - 3000000000000
spread - 500000000000000
swap price - 14574900152
swap feasible - 1

A.2 Swap 100 SOL; 2 SOL at a time

 Swapping 100 SOL; 2 SOL at a time to not trigger update to spread

Wooracle state after swapping 100 SOL

price - 13963813321
coeff - 3000000000000
spread - 500000000000000
Price difference:611086831
spread before swap of 100 SOL == spread after swap == 500000000000000

A.3 Assert spread has not changed

Test B: Swap 5 SOL with the same wooracle state and show spread is updated

B.1 Reset wooracle state to test values
spread before swap of 5 sol == test spread == 500000000000000

B.2 Swap 5 SOL
Wooracle state after swapping 5 SOL

price - 14543035994
coeff - 3000000000000
spread - 1687078619323403

Price difference:31864158

spread before swap of 5 SOL != spread after swap of 5 SOL: true

B.3 Assert spread has changed after the swap of 5 SOL
      ✔ fails to update spread (29992ms)
S3v3ru5 commented 1 month ago

The graph here uses the values used for the PoC:

Test values:

price = 145.74
coeff = c =0.000003
spread = 0.0005

It can be seen in the graph that the spread is only updated if the swap value > 2.28 SOL (intersection point of red and black graphs).

The PoC swaps 2 SOL at a time to not trigger spread update while also changing the price. After swapping 100 SOL, the SOL token price comes down by 6.1 without triggering an update to the spread.

The PoC also shows that the same wooracle state would result in update to spread if the swap size is 5 SOL (> 2.28).

i.e For the same initial wooracle state:

S3v3ru5 commented 1 month ago

for base-to-quote swap, spread is updated if and only if

Gamma = G = B * p * k
new price = p1 = p * (1 - G)
Because 0 <= G < 1 => p1 < p and min_p = p1, max_p = p
price_decrease = p - p1
anti_s = l = (p1 / p) * (1 / (1-s))
new_s = 1 - l

G is the rate of price change. After the swap, price decreases by G * p.

new_s =

$$ 1 - (\frac{p1}{p} * \frac{1}{1-s}) $$

$$ = 1 - ((1 - G) * \frac{1}{1-s}) $$

$$ = \frac{G - s}{1 - s} $$

spread is not updated if

$$ s > \frac{G - s}{1 - s} $$

$G$ solely depends on the coefficient, base amount and the price. Base amount can be chosen to meet the above condition to not update the spread.

Intuitively, In the issue H.1 of the sherlock report, max gamma served as the upper bound for the max change in the price. The exploit used swaps of smaller amounts such that gamma is less than the max gamma.

After the fix (current implementation), the upper bound for max change (i.e gamma) in the price has changed. It is the above mentioned condition to not trigger spread update. Same exploit with smaller amounts such that gamma does not satisfy the above condition can be used. This is the issue described here

S3v3ru5 commented 1 month ago

One other way of thinking about the issue is

Because only the price change between the most recent price and the current price is considered, it might not always meet the required threshold. A swap could be divided into two swaps executed at price p1, p2 and current price p3. The price changes (p2 - p1), (p3 - p2) might not meet the threshold individually, but (p3 - p1) might meet the threshold.

The code should either consider change of price for a certain period instead of just the previous and the current. Or spread update should not require a threshold. It should be a continuous function.