From c8ca3e08c9b8f7a4ca42083c9ff0c2f4b0453a49 Mon Sep 17 00:00:00 2001 From: hackercf <140406910+hackercf@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:59:18 +0530 Subject: [PATCH] fix: break critical cross-contract cascade chain (C-1) Phase 1 security fixes from v2 audit (2026-03-10): pyth-adapter-v1: - Reject zero/negative prices before to-uint conversion (H-1, H-9, M-15) - Bound exponent to [-18, 18] to prevent pow overflow (M-3) - Cap future timestamps to 60s tolerance (M-2) - Enforce time-delta range [60s, 7200s] (M-1) - Remove dead zero-price branch in check-confidence - Add named constants MAXIMUM_TIME_DELTA, FUTURE_TIMESTAMP_TOLERANCE linear-kinked-ir-v1: - Guard utilization-calc division by zero on total-assets (H-2) - Guard elapsed-block-time underflow with saturation to u0 (H-10) linear-kinked-ir-utility: - Same division-by-zero and elapsed-time underflow guards (L-32) liquidator-v1: - Unconditionally reject zero repay amount (H-1 defense-in-depth) tests: - Add pyth-adapter oracle hardening tests (price, exponent, timestamp, time-delta) - Add utilization zero-total-assets test - Fix pyth test helpers to use simnet-aligned timestamps - Update all test files for time-delta max of 7200 - Add price refreshes for long-running test scenarios 207/207 tests pass. No state-v1 modifications. --- contracts/liquidator-v1.clar | 7 +- .../modules/linear-kinked-ir-utility.clar | 4 +- contracts/modules/linear-kinked-ir-v1.clar | 4 +- contracts/modules/pyth-adapter-v1.clar | 17 +- contracts/test/faucet.clar | 4 + deployments/default.simnet-plan.yaml | 10 + tests/borrower.test.ts | 2 +- tests/borrower_flow.test.ts | 7 +- tests/governance.test.ts | 8 +- tests/liquidation.test.ts | 48 +++- tests/liquidity_provider.test.ts | 2 +- tests/liquidity_provider_flow.test.ts | 2 +- tests/modules/linear-kinked-ir.test.ts | 15 + tests/modules/pyth-adapter.test.ts | 264 ++++++++++++++++++ tests/modules/withdrawal-caps.test.ts | 6 +- tests/poc-slash-underflow.test.ts | 2 +- tests/pyth.ts | 42 ++- tests/staking.test.ts | 2 +- 18 files changed, 412 insertions(+), 34 deletions(-) create mode 100644 tests/modules/pyth-adapter.test.ts diff --git a/contracts/liquidator-v1.clar b/contracts/liquidator-v1.clar index 14e2d09..4b2aaf5 100644 --- a/contracts/liquidator-v1.clar +++ b/contracts/liquidator-v1.clar @@ -492,13 +492,10 @@ )) (define-private (ensure-non-zero-repay-amount (amount uint) (collateral-price uint)) - (if (<= collateral-price u0) - ;; if collateral price is zero, we don't care about repay amount, anything is accepted + (if (> amount u0) SUCCESS - ;; if not zero, then amount must be > 0 - (if (<= amount u0) ERR-NON-ZERO-REPAY-AMOUNT SUCCESS) + ERR-NON-ZERO-REPAY-AMOUNT ) - ) (define-private (socialize-bad-debt (bad-debt bool) (user principal)) diff --git a/contracts/modules/linear-kinked-ir-utility.clar b/contracts/modules/linear-kinked-ir-utility.clar index 719b400..e5f1cc3 100644 --- a/contracts/modules/linear-kinked-ir-utility.clar +++ b/contracts/modules/linear-kinked-ir-utility.clar @@ -29,7 +29,7 @@ (ir-slope-1 uint) (ir-slope-2 uint) (utilization-kink uint) (base-ir uint) ) (let ( - (elapsed-block-time (- time-now last-accrued-block-time)) + (elapsed-block-time (if (> time-now last-accrued-block-time) (- time-now last-accrued-block-time) u0)) (premature-return (asserts! (not (is-eq u0 elapsed-block-time)) (ok { @@ -67,7 +67,7 @@ ;; PRIVATE HELPER FUNCTIONS ;; total-assets and open-interest are fixed to u8 precision (define-private (utilization-calc (total-assets uint) (open-interest uint)) - (if (> (+ total-assets open-interest) u0) (/ (* open-interest one-12) total-assets) u0) + (if (> total-assets u0) (/ (* open-interest one-12) total-assets) u0) ) (define-private (get-ir (total-assets uint) (open-interest uint) (ir-slope-1 uint) (ir-slope-2 uint) (utilization-kink uint) (base-ir uint)) diff --git a/contracts/modules/linear-kinked-ir-v1.clar b/contracts/modules/linear-kinked-ir-v1.clar index 89b2163..da8bcef 100644 --- a/contracts/modules/linear-kinked-ir-v1.clar +++ b/contracts/modules/linear-kinked-ir-v1.clar @@ -77,7 +77,7 @@ (define-read-only (accrue-interest (last-accrued-block-time uint) (lp-open-interest uint) (staked-open-interest uint) (staking-reward-percentage uint) (protocol-open-interest uint) (protocol-reserve-percentage uint) (total-assets uint)) (let ( (time-now (+ (unwrap-panic (get-stacks-block-info? time (- stacks-block-height u1))) STACKS_BLOCK_TIME)) - (elapsed-block-time (- time-now last-accrued-block-time)) + (elapsed-block-time (if (> time-now last-accrued-block-time) (- time-now last-accrued-block-time) u0)) (premature-return (asserts! (not (or (is-eq u0 elapsed-block-time) (not (contract-call? .state-v1 is-interest-accrual-enabled)))) (ok { @@ -114,7 +114,7 @@ ;; total-assets and open-interest are fixed to u8 precision (define-read-only (utilization-calc (total-assets uint) (open-interest uint)) - (if (> (+ total-assets open-interest) u0) (/ (* open-interest one-12) total-assets) u0) + (if (> total-assets u0) (/ (* open-interest one-12) total-assets) u0) ) (define-read-only (get-ir (total-assets uint) (open-interest uint)) diff --git a/contracts/modules/pyth-adapter-v1.clar b/contracts/modules/pyth-adapter-v1.clar index b9cb2fb..f00a1fa 100644 --- a/contracts/modules/pyth-adapter-v1.clar +++ b/contracts/modules/pyth-adapter-v1.clar @@ -6,10 +6,17 @@ (define-constant ERR-PYTH-PRICE-STALE (err u80002)) (define-constant ERR-INVALID-MAX-CONFIDENCE-RATIO (err u80003)) (define-constant ERR-PRICE-CONFIDENCE-LOW (err u80004)) +(define-constant ERR-INVALID-PRICE (err u80005)) +(define-constant ERR-INVALID-EXPONENT (err u80006)) +(define-constant ERR-INVALID-TIME-DELTA (err u80007)) (define-constant STACKS_BLOCK_TIME (contract-call? .constants-v1 get-stacks-block-time )) ;; Minimum time delta of 1 minute. (define-constant MINIMUM_TIME_DELTA u60) +;; Maximum time delta of 2 hours. +(define-constant MAXIMUM_TIME_DELTA u7200) +;; Maximum future timestamp tolerance of 60 seconds. +(define-constant FUTURE_TIMESTAMP_TOLERANCE u60) ;; Confidence ratio scaling factor = 100% confidence (define-constant CONFIDENCE_SCALING_FACTOR u10000) ;; Price scaling factor decimals @@ -44,6 +51,8 @@ (define-public (update-time-delta (delta uint)) (begin (asserts! (is-eq (contract-call? .state-v1 get-governance) contract-caller) ERR-NOT-AUTHORIZED) + (asserts! (>= delta MINIMUM_TIME_DELTA) ERR-INVALID-TIME-DELTA) + (asserts! (<= delta MAXIMUM_TIME_DELTA) ERR-INVALID-TIME-DELTA) (print { event-type: "update-time-delta", old-val: (var-get time-delta), @@ -120,13 +129,15 @@ (price-conf (get conf pyth-record)) ) + (asserts! (> price 0) ERR-INVALID-PRICE) + (asserts! (and (>= expo -18) (<= expo 18)) ERR-INVALID-EXPONENT) (asserts! (is-valid timestamp) ERR-PYTH-PRICE-STALE) (try! (check-confidence (to-uint price) price-conf max-confidence-ratio)) (ok (to-uint (convert-res price expo PRICE_DECIMALS))) )) (define-private (check-confidence (price uint) (confidence uint) (max-confidence-ratio uint)) - (if (or (is-eq u0 price) (<= confidence (/ (* price max-confidence-ratio) CONFIDENCE_SCALING_FACTOR))) + (if (<= confidence (/ (* price max-confidence-ratio) CONFIDENCE_SCALING_FACTOR)) (ok true) ERR-PRICE-CONFIDENCE-LOW ) @@ -134,8 +145,8 @@ (define-private (is-valid (timestamp uint)) (let ((block-timestamp (+ (unwrap-panic (get-stacks-block-info? time (- stacks-block-height u1))) STACKS_BLOCK_TIME))) - (if (>= timestamp block-timestamp) - true + (if (>= timestamp block-timestamp) + (<= timestamp (+ block-timestamp FUTURE_TIMESTAMP_TOLERANCE)) (> timestamp (- block-timestamp (var-get time-delta)))) ) ) diff --git a/contracts/test/faucet.clar b/contracts/test/faucet.clar index b9903cb..66e26bc 100644 --- a/contracts/test/faucet.clar +++ b/contracts/test/faucet.clar @@ -35,6 +35,10 @@ ) ) +(define-read-only (get-block-time) + (+ (unwrap-panic (get-stacks-block-info? time (- stacks-block-height u1))) u5) +) + (define-public (get-mock-eth (amount uint)) (let ( diff --git a/deployments/default.simnet-plan.yaml b/deployments/default.simnet-plan.yaml index de3cbb1..6ff0a4b 100644 --- a/deployments/default.simnet-plan.yaml +++ b/deployments/default.simnet-plan.yaml @@ -7,33 +7,43 @@ genesis: - name: deployer address: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM balance: "100000000000000" + sbtc-balance: "1000000000" - name: faucet address: STNHKEPYEPJ8ET55ZZ0M5A34J0R3N5FM2CMMMAZ6 balance: "100000000000000" + sbtc-balance: "1000000000" - name: wallet_1 address: ST1SJ3DTE5DN7X54YDH5D64R3BCB6A2AG2ZQ8YPD5 balance: "100000000000000" + sbtc-balance: "1000000000" - name: wallet_2 address: ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG balance: "100000000000000" + sbtc-balance: "1000000000" - name: wallet_3 address: ST2JHG361ZXG51QTKY2NQCVBPPRRE2KZB1HR05NNC balance: "100000000000000" + sbtc-balance: "1000000000" - name: wallet_4 address: ST2NEB84ASENDXKYGJPQW86YXQCEFEX2ZQPG87ND balance: "100000000000000" + sbtc-balance: "1000000000" - name: wallet_5 address: ST2REHHS5J3CERCRBEPMGH7921Q6PYKAADT7JP2VB balance: "100000000000000" + sbtc-balance: "1000000000" - name: wallet_6 address: ST3AM1A56AK2C1XAFJ4115ZSV26EB49BVQ10MGCS0 balance: "100000000000000" + sbtc-balance: "1000000000" - name: wallet_7 address: ST3PF13W7Z0RRM42A8VZRVFQ75SV1K26RXEP8YGKJ balance: "100000000000000" + sbtc-balance: "1000000000" - name: wallet_8 address: ST3NBRSFKX28FQ2ZJ1MAKX58HKHSDGNV5N7R21XCP balance: "100000000000000" + sbtc-balance: "1000000000" contracts: - costs - pox diff --git a/tests/borrower.test.ts b/tests/borrower.test.ts index afd1e98..4e4ad2d 100644 --- a/tests/borrower.test.ts +++ b/tests/borrower.test.ts @@ -32,7 +32,7 @@ const btc_collateral_contract = contractPrincipalCV(deployer, "mock-btc"); describe("borrower tests", () => { beforeEach(async () => { init_pyth(deployer); - set_pyth_time_delta(100000, deployer); + set_pyth_time_delta(7200, deployer); set_allowed_contracts(deployer); set_asset_cap(deployer, 10000000000000n); // 100k USDC initialize_ir(deployer); diff --git a/tests/borrower_flow.test.ts b/tests/borrower_flow.test.ts index 55ea8e9..20c5bad 100644 --- a/tests/borrower_flow.test.ts +++ b/tests/borrower_flow.test.ts @@ -26,7 +26,7 @@ const borrower1 = accounts.get("wallet_2")!; describe("Borrower User flow tests", () => { beforeEach(async () => { init_pyth(deployer); - set_pyth_time_delta(100000, deployer); + set_pyth_time_delta(7200, deployer); set_allowed_contracts(deployer); set_asset_cap(deployer, 10000000000000n); // 100k USDC initialize_ir(deployer); @@ -63,6 +63,11 @@ describe("Borrower User flow tests", () => { // accrue interest simnet.mineEmptyBlocks(5); + // refresh prices after mining blocks + await set_price("mock-usdc", 1n, deployer); + await set_price("mock-btc", 10n, deployer); + await set_price("mock-eth", 1n, deployer); + // repay mint_token("mock-usdc", 693, borrower1); let repay = simnet.callPublicFn( diff --git a/tests/governance.test.ts b/tests/governance.test.ts index edc5958..80bed7b 100644 --- a/tests/governance.test.ts +++ b/tests/governance.test.ts @@ -55,7 +55,7 @@ function execute_proposal_failed(response: any, error: any) { describe("governance tests", () => { beforeEach(async () => { init_pyth(deployer); - await set_pyth_time_delta(100000000, deployer); + await set_pyth_time_delta(7200, deployer); set_allowed_contracts(deployer); set_asset_cap(deployer, 10000000000000n); // 100k USDC initialize_ir(deployer); @@ -205,6 +205,10 @@ describe("governance tests", () => { expect(response.result.type).toBe(ClarityType.ResponseOk); execute_proposal(response); + // refresh prices after mining blocks for governance proposals + await set_price("mock-usdc", 1n, deployer); + await set_price("mock-btc", 1n, deployer); + remove_collateral("mock-btc", 1000, deployer, governance_account); }); @@ -1312,7 +1316,7 @@ describe("governance tests", () => { [], deployer ); - expect(response.result).toEqual(Cl.uint(100000000)); + expect(response.result).toEqual(Cl.uint(7200)); response = simnet.callPublicFn( "governance-v1", diff --git a/tests/liquidation.test.ts b/tests/liquidation.test.ts index c28c3f4..699a416 100644 --- a/tests/liquidation.test.ts +++ b/tests/liquidation.test.ts @@ -40,7 +40,7 @@ const flashLoanCallbackContract = Cl.contractPrincipal( describe("liquidation tests", () => { beforeEach(async () => { init_pyth(deployer); - set_pyth_time_delta(100000, deployer); + set_pyth_time_delta(7200, deployer); set_allowed_contracts(deployer); set_asset_cap(deployer, 10000000000000n); // 100k USDC initialize_ir(deployer); @@ -987,7 +987,7 @@ describe("liquidation tests", () => { expect(liquidate.result).toBeOk(Cl.bool(true)); }); - it("should liquidate correctly a collateral with price 0", async () => { + it("should reject liquidation when collateral price is 0 (H-1 fix)", async () => { mint_token("mock-usdc", 100000000000, depositor); deposit(100000000000, depositor); @@ -1006,6 +1006,9 @@ describe("liquidation tests", () => { await set_price("mock-btc", 0n, deployer); + // With H-1 fix, zero price is rejected at pyth-adapter level. + // read-price returns ERR-INVALID-PRICE (u80005), which propagates unchanged via try! through + // account-health -> check-account-unhealthy -> get-liquidate-params -> get-liquidation-info. let liquidate = simnet.callPublicFn( "liquidator-v1", "liquidate-collateral", @@ -1018,7 +1021,7 @@ describe("liquidation tests", () => { ], depositor ); - expect(liquidate.result).toBeOk(Cl.bool(true)); + expect(liquidate.result).toBeErr(Cl.uint(80005)); // ERR-INVALID-PRICE (from pyth-adapter, propagated via try!) }); it("test liquidation buffer", async () => { @@ -1532,4 +1535,43 @@ describe("liquidation tests", () => { // mock liquidator should have all the btc collateral expectUserBTCBalance(flashLoanCallbackContract, 20000000000n, deployer); }); + + it("zero repay amount rejected regardless of collateral price (H-1 defense-in-depth)", async () => { + mint_token("mock-usdc", 100000000000, depositor); + deposit(100000000000, depositor); + + update_supported_collateral( + "mock-btc", + 90000000, + 95000000, + 5000000, + 8, + deployer + ); + mint_token("mock-btc", 100000000000, borrower1); + add_collateral("mock-btc", 20000000000, deployer, borrower1); + + borrow(18000000000, borrower1); + + // Drop price to make position unhealthy + await set_price("mock-btc", 0n, deployer); + + // Attempt liquidation with both zero repay amount and zero collateral price. + // The zero price is caught first at the oracle level (H-1 primary fix), + // before ensure-non-zero-repay-amount is reached. + let liquidate = simnet.callPublicFn( + "liquidator-v1", + "liquidate-collateral", + [ + Cl.none(), + btc_collateral_contract, + Cl.principal(borrower1), + Cl.uint(0), + Cl.uint(0), + ], + depositor + ); + // Zero price is caught first at oracle level (H-1 primary fix) + expect(liquidate.result).toBeErr(Cl.uint(80005)); // ERR-INVALID-PRICE (from pyth-adapter, propagated via try!) + }); }); diff --git a/tests/liquidity_provider.test.ts b/tests/liquidity_provider.test.ts index 80fec7a..c585fab 100644 --- a/tests/liquidity_provider.test.ts +++ b/tests/liquidity_provider.test.ts @@ -19,7 +19,7 @@ const deployer = accounts.get("deployer")!; describe("liquidity-provider tests", () => { beforeEach(async () => { init_pyth(deployer); - set_pyth_time_delta(100000, deployer); + set_pyth_time_delta(7200, deployer); set_allowed_contracts(deployer); set_asset_cap(deployer, 10000000000000n); // 100k USDC initialize_ir(deployer); diff --git a/tests/liquidity_provider_flow.test.ts b/tests/liquidity_provider_flow.test.ts index 6c7b98a..34d0f15 100644 --- a/tests/liquidity_provider_flow.test.ts +++ b/tests/liquidity_provider_flow.test.ts @@ -18,7 +18,7 @@ const borrower1 = accounts.get("wallet_2")!; describe("LP User flow tests", () => { beforeEach(async () => { init_pyth(deployer); - set_pyth_time_delta(100000, deployer); + set_pyth_time_delta(7200, deployer); set_allowed_contracts(deployer); set_asset_cap(deployer, 10000000000000n); // 100k USDC initialize_ir(deployer); diff --git a/tests/modules/linear-kinked-ir.test.ts b/tests/modules/linear-kinked-ir.test.ts index d59a86d..4924f8b 100644 --- a/tests/modules/linear-kinked-ir.test.ts +++ b/tests/modules/linear-kinked-ir.test.ts @@ -107,6 +107,21 @@ describe("linear kinked interest rate module tests", () => { ); }); + it("utilization with zero total-assets returns 0 (H-2 div-by-zero guard)", () => { + const totalAssets = Cl.uint(0); + const openInterest = Cl.uint(1000); + + const args = [totalAssets, openInterest]; + const ur = simnet.callReadOnlyFn( + "linear-kinked-ir-v1", + "utilization-calc", + args, + address1 + ); + + expect(ur.result).toStrictEqual(Cl.uint(0)); + }); + it("utilization should be 0 when 0 open interest", () => { const totalAssets = Cl.uint(50000000000); // 500 * 10^8 = 500 usd const openInterest = Cl.uint(0); diff --git a/tests/modules/pyth-adapter.test.ts b/tests/modules/pyth-adapter.test.ts new file mode 100644 index 0000000..ae66d75 --- /dev/null +++ b/tests/modules/pyth-adapter.test.ts @@ -0,0 +1,264 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { Cl, ClarityType } from "@stacks/transactions"; +import { + init_pyth, + set_pyth_time_delta, + guardianSet, + get_token_feed, +} from "../pyth"; +import { pyth } from "../../contracts/pyth/unit-tests/pyth/helpers"; +import { wormhole } from "../../contracts/pyth/unit-tests/wormhole/helpers"; + +const accounts = simnet.getAccounts(); +const deployer = accounts.get("deployer")!; +const address1 = accounts.get("wallet_1")!; + +/** + * Returns the current simnet block time. + */ +const getSimnetBlockTime = (): bigint => { + const r = simnet.callReadOnlyFn( + "faucet", + "get-block-time", + [], + deployer + ); + return r.result.value as bigint; +}; + +/** + * Helper to submit a raw price update to pyth storage with custom price/expo values. + * Uses simnet-aligned timestamps. + */ +const set_raw_price = async ( + token: string, + price: bigint, + expo: number = -8, + conf?: bigint +): Promise => { + const feed = get_token_feed(token); + const publishTime = getSimnetBlockTime(); + let actualPricesUpdates = pyth.buildPriceUpdateBatch([ + [feed, { price, expo, publishTime, conf }], + ]); + let actualPricesUpdatesVaaPayload = + pyth.buildAuwvVaaPayload(actualPricesUpdates); + let payload = pyth.serializeAuwvVaaPayloadToBuffer( + actualPricesUpdatesVaaPayload + ); + let vaaBody = wormhole.buildValidVaaBodySpecs({ + payload, + emitter: pyth.DefaultPricesDataSources[0], + }); + let vaaHeader = wormhole.buildValidVaaHeader(guardianSet, vaaBody, { + version: 1, + guardianSetId: 1, + }); + let vaa = wormhole.serializeVaaToBuffer(vaaHeader, vaaBody); + let pnauHeader = pyth.buildPnauHeader(); + let pricesUpdatesToSubmit = [feed]; + let pnau = pyth.serializePnauToBuffer(pnauHeader, { + vaa, + pricesUpdates: actualPricesUpdates, + pricesUpdatesToSubmit, + }); + + const res = simnet.callPublicFn( + "pyth-adapter-v1", + "update-pyth", + [Cl.some(Cl.buffer(pnau))], + deployer + ); + expect(res.result).toHaveClarityType(ClarityType.ResponseOk); + + return publishTime; +}; + +/** + * Helper to submit a price update with a custom publish time. + */ +const set_price_with_time = async ( + token: string, + price: bigint, + publishTime: bigint, + expo: number = -8 +): Promise => { + const feed = get_token_feed(token); + let actualPricesUpdates = pyth.buildPriceUpdateBatch([ + [feed, { price, expo, publishTime }], + ]); + let actualPricesUpdatesVaaPayload = + pyth.buildAuwvVaaPayload(actualPricesUpdates); + let payload = pyth.serializeAuwvVaaPayloadToBuffer( + actualPricesUpdatesVaaPayload + ); + let vaaBody = wormhole.buildValidVaaBodySpecs({ + payload, + emitter: pyth.DefaultPricesDataSources[0], + }); + let vaaHeader = wormhole.buildValidVaaHeader(guardianSet, vaaBody, { + version: 1, + guardianSetId: 1, + }); + let vaa = wormhole.serializeVaaToBuffer(vaaHeader, vaaBody); + let pnauHeader = pyth.buildPnauHeader(); + let pricesUpdatesToSubmit = [feed]; + let pnau = pyth.serializePnauToBuffer(pnauHeader, { + vaa, + pricesUpdates: actualPricesUpdates, + pricesUpdatesToSubmit, + }); + + const res = simnet.callPublicFn( + "pyth-adapter-v1", + "update-pyth", + [Cl.some(Cl.buffer(pnau))], + deployer + ); + expect(res.result).toHaveClarityType(ClarityType.ResponseOk); +}; + +describe("pyth-adapter-v1 oracle hardening tests", () => { + beforeEach(async () => { + init_pyth(deployer); + set_pyth_time_delta(7200, deployer); + + // Register BTC price feed + const feed = get_token_feed("mock-btc"); + simnet.callPublicFn( + "pyth-adapter-v1", + "update-price-feed-id", + [ + Cl.contractPrincipal(deployer, "mock-btc"), + Cl.buffer(feed), + Cl.uint(500), + ], + deployer + ); + }); + + it("zero price rejected (H-1, M-15)", async () => { + await set_raw_price("mock-btc", 0n); + const result = simnet.callReadOnlyFn( + "pyth-adapter-v1", + "read-price", + [Cl.contractPrincipal(deployer, "mock-btc")], + address1 + ); + expect(result.result).toBeErr(Cl.uint(80005)); // ERR-INVALID-PRICE + }); + + it("negative price rejected (H-9)", async () => { + await set_raw_price("mock-btc", -100n); + const result = simnet.callReadOnlyFn( + "pyth-adapter-v1", + "read-price", + [Cl.contractPrincipal(deployer, "mock-btc")], + address1 + ); + expect(result.result).toBeErr(Cl.uint(80005)); // ERR-INVALID-PRICE + }); + + it("extreme positive exponent rejected (M-3)", async () => { + await set_raw_price("mock-btc", 100n, 30); + const result = simnet.callReadOnlyFn( + "pyth-adapter-v1", + "read-price", + [Cl.contractPrincipal(deployer, "mock-btc")], + address1 + ); + expect(result.result).toBeErr(Cl.uint(80006)); // ERR-INVALID-EXPONENT + }); + + it("extreme negative exponent rejected (M-3)", async () => { + await set_raw_price("mock-btc", 100n, -30); + const result = simnet.callReadOnlyFn( + "pyth-adapter-v1", + "read-price", + [Cl.contractPrincipal(deployer, "mock-btc")], + address1 + ); + expect(result.result).toBeErr(Cl.uint(80006)); // ERR-INVALID-EXPONENT + }); + + it("future timestamp rejected (M-2)", async () => { + // Set a publish time far in the future (1 hour ahead of simnet block time) + const blockTime = getSimnetBlockTime(); + const farFuture = blockTime + 3600n; + await set_price_with_time("mock-btc", 100n, farFuture); + const result = simnet.callReadOnlyFn( + "pyth-adapter-v1", + "read-price", + [Cl.contractPrincipal(deployer, "mock-btc")], + address1 + ); + expect(result.result).toBeErr(Cl.uint(80002)); // ERR-PYTH-PRICE-STALE + }); + + it("valid price and exponent accepted", async () => { + await set_raw_price("mock-btc", 10000000000n, -8); + const result = simnet.callReadOnlyFn( + "pyth-adapter-v1", + "read-price", + [Cl.contractPrincipal(deployer, "mock-btc")], + address1 + ); + expect(result.result).toHaveClarityType(ClarityType.ResponseOk); + }); +}); + +describe("pyth-adapter-v1 time-delta bounds (M-1)", () => { + beforeEach(() => { + init_pyth(deployer); + }); + + it("time-delta below minimum rejected", () => { + const result = simnet.callPublicFn( + "pyth-adapter-v1", + "update-time-delta", + [Cl.uint(0)], + deployer + ); + expect(result.result).toBeErr(Cl.uint(80007)); // ERR-INVALID-TIME-DELTA + }); + + it("time-delta of 59 rejected", () => { + const result = simnet.callPublicFn( + "pyth-adapter-v1", + "update-time-delta", + [Cl.uint(59)], + deployer + ); + expect(result.result).toBeErr(Cl.uint(80007)); // ERR-INVALID-TIME-DELTA + }); + + it("time-delta above maximum rejected", () => { + const result = simnet.callPublicFn( + "pyth-adapter-v1", + "update-time-delta", + [Cl.uint(10000)], + deployer + ); + expect(result.result).toBeErr(Cl.uint(80007)); // ERR-INVALID-TIME-DELTA + }); + + it("time-delta at minimum accepted", () => { + const result = simnet.callPublicFn( + "pyth-adapter-v1", + "update-time-delta", + [Cl.uint(60)], + deployer + ); + expect(result.result).toBeOk(Cl.bool(true)); + }); + + it("time-delta at maximum accepted", () => { + const result = simnet.callPublicFn( + "pyth-adapter-v1", + "update-time-delta", + [Cl.uint(7200)], + deployer + ); + expect(result.result).toBeOk(Cl.bool(true)); + }); +}); diff --git a/tests/modules/withdrawal-caps.test.ts b/tests/modules/withdrawal-caps.test.ts index fb5cfa6..f7f2537 100644 --- a/tests/modules/withdrawal-caps.test.ts +++ b/tests/modules/withdrawal-caps.test.ts @@ -47,7 +47,7 @@ function execute_proposal(response: any) { describe("withdrawal caps tests", () => { beforeEach(async () => { init_pyth(deployer); - set_pyth_time_delta(100000000, deployer); + set_pyth_time_delta(7200, deployer); set_allowed_contracts(deployer); set_asset_cap(deployer, 2n ** 128n - 1n); initialize_ir(deployer); @@ -320,6 +320,10 @@ describe("withdrawal caps tests", () => { simnet.mineEmptyBlocks(20); + // refresh prices after mining blocks + await set_price("mock-usdc", 1n, deployer); + await set_price("mock-btc", 10n, deployer); + // Remove 70 btc. It should be bloked let resp = simnet.callPublicFn( "borrower-v1", diff --git a/tests/poc-slash-underflow.test.ts b/tests/poc-slash-underflow.test.ts index 3688a95..baeabba 100644 --- a/tests/poc-slash-underflow.test.ts +++ b/tests/poc-slash-underflow.test.ts @@ -87,7 +87,7 @@ const getUserLpBalance = (user: ClarityValue): bigint => { describe("PoC: slash-total-staked-lp-tokens underflow", () => { beforeEach(async () => { init_pyth(deployer); - set_pyth_time_delta(100000, deployer); + set_pyth_time_delta(7200, deployer); set_allowed_contracts(deployer); set_asset_cap(deployer, 10000000000000n); initialize_ir(deployer); diff --git a/tests/pyth.ts b/tests/pyth.ts index eea6c58..6ba41f3 100644 --- a/tests/pyth.ts +++ b/tests/pyth.ts @@ -10,7 +10,26 @@ export const pythStorageContractName = "pyth-storage-v4"; export const wormholeCoreContractName = "wormhole-core-v4"; export const guardianSet = wormhole.generateGuardianSetKeychain(19); +// Monotonically increasing publish time counter to ensure unique timestamps +let lastPublishTime = 0n; + +/** + * Returns the current simnet block time by reading the faucet helper. + * This is aligned with the simnet's internal clock, not wall-clock time. + */ +const getSimnetBlockTime = (): bigint => { + const r = simnet.callReadOnlyFn( + "faucet", + "get-block-time", + [], + "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM" + ); + return r.result.value as bigint; +}; + export const init_pyth = (sender: any) => { + lastPublishTime = 0n; + wormhole.applyGuardianSetUpdate( guardianSet, 1, @@ -85,6 +104,17 @@ export const set_pyth_time_delta = async (delta: number, deployer: any) => { expect(result.result).toBeOk(Cl.bool(true)); }; +/** + * Returns a publish time aligned with simnet block time. + * Ensures monotonically increasing timestamps for pyth updates. + */ +const getPublishTime = (): bigint => { + const blockTime = getSimnetBlockTime(); + const publishTime = blockTime > lastPublishTime ? blockTime : lastPublishTime + 1n; + lastPublishTime = publishTime; + return publishTime; +}; + export const set_price = async ( token: string, price: bigint, @@ -93,8 +123,7 @@ export const set_price = async ( prevPublishTime?: bigint ): Promise => { const feed = get_token_feed(token); - await sleep(800); - const publishTime = pyth.timestampNow(); + const publishTime = getPublishTime(); let actualPricesUpdates = pyth.buildPriceUpdateBatch([ [ feed, @@ -142,8 +171,7 @@ export const set_price_without_scaling = async ( prevPublishTime?: bigint ): Promise => { const feed = get_token_feed(token); - await sleep(800); - const publishTime = pyth.timestampNow(); + const publishTime = getPublishTime(); let actualPricesUpdates = pyth.buildPriceUpdateBatch([ [feed, { price: price, expo, publishTime, prevPublishTime }], ]); @@ -179,9 +207,3 @@ export const set_price_without_scaling = async ( return publishTime; }; - -const sleep = async (ms: number) => { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -}; diff --git a/tests/staking.test.ts b/tests/staking.test.ts index 8bfc3a0..93a1583 100644 --- a/tests/staking.test.ts +++ b/tests/staking.test.ts @@ -125,7 +125,7 @@ export const increaseLpTokensOfStakingContract = (amount: number) => { describe("staking tests", () => { beforeEach(async () => { init_pyth(deployer); - set_pyth_time_delta(100000, deployer); + set_pyth_time_delta(7200, deployer); set_allowed_contracts(deployer); set_asset_cap(deployer, 10000000000000n); // 100k USDC initialize_ir(deployer);