diff --git a/.github/workflows/pool-tests.yml b/.github/workflows/pool-tests.yml index 834af0a..e0be9f8 100644 --- a/.github/workflows/pool-tests.yml +++ b/.github/workflows/pool-tests.yml @@ -1,4 +1,4 @@ -name: Pool Contract Tests +name: Contract Tests on: push: @@ -8,7 +8,7 @@ on: jobs: clarinet-test: - name: Clarinet Unit Tests + name: Clarinet Unit Tests (pool + oracle + defi) runs-on: ubuntu-latest steps: @@ -19,7 +19,7 @@ jobs: curl -L https://github.com/hirosystems/clarinet/releases/latest/download/clarinet-linux-x64-glibc.tar.gz \ | tar -xz -C /usr/local/bin - - name: Run Clarinet checks + - name: Run Clarinet checks (all contracts) run: clarinet check - name: Run contract tests @@ -31,6 +31,36 @@ jobs: file: coverage.lcov fail_ci_if_error: false + oracle-tests: + name: Oracle & DeFi Contract Tests + runs-on: ubuntu-latest + needs: clarinet-test + + steps: + - uses: actions/checkout@v4 + + - name: Install Clarinet + run: | + curl -L https://github.com/hirosystems/clarinet/releases/latest/download/clarinet-linux-x64-glibc.tar.gz \ + | tar -xz -C /usr/local/bin + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install test dependencies + run: npm install @hirosystems/clarinet-sdk @stacks/transactions vitest --save-dev + + - name: Run oracle tests + run: npx vitest run tests/bitstake-bstx-oracle.test.ts tests/bitstake-bstx-oracle-twap.test.ts tests/bitstake-oracle-circuit-breaker.test.ts --reporter=verbose + + - name: Run governance tests + run: npx vitest run tests/bitstake-alex-governance.test.ts --reporter=verbose + + - name: Run Bitflow pool tests + run: npx vitest run tests/bitstake-bitflow-pool.test.ts --reporter=verbose + typescript-test: name: TypeScript Tests runs-on: ubuntu-latest diff --git a/Clarinet.toml b/Clarinet.toml index e1bbe9a..68b2b31 100644 --- a/Clarinet.toml +++ b/Clarinet.toml @@ -34,4 +34,29 @@ path = "contracts/bitstake-rewards.clar" clarity_version = 2 epoch = "2.5" +[contracts.bitstake-bstx-oracle] +path = "contracts/bitstake-bstx-oracle.clar" +clarity_version = 2 +epoch = "2.5" + +[contracts.bitstake-bstx-oracle-twap] +path = "contracts/bitstake-bstx-oracle-twap.clar" +clarity_version = 2 +epoch = "2.5" + +[contracts.bitstake-oracle-circuit-breaker] +path = "contracts/bitstake-oracle-circuit-breaker.clar" +clarity_version = 2 +epoch = "2.5" + +[contracts.bitstake-alex-governance] +path = "contracts/bitstake-alex-governance.clar" +clarity_version = 2 +epoch = "2.5" + +[contracts.bitstake-bitflow-pool] +path = "contracts/bitstake-bitflow-pool.clar" +clarity_version = 2 +epoch = "2.5" + [[deployment_plan.genesis]] diff --git a/README.md b/README.md index 6d3a728..ee06968 100644 --- a/README.md +++ b/README.md @@ -204,14 +204,57 @@ When you deposit STX, you receive `bSTX` — a SIP-010 compliant liquid staking --- +## DeFi Collateral Integration + +bSTX tokens are designed to be used as collateral in Stacks DeFi protocols while continuing to earn BTC stacking yield. + +### Phase 1 Integrations + +| Protocol | Integration | Status | +|-----------|---------------------|----------| +| ALEX | Lending collateral | Proposed | +| Bitflow | bSTX/STX LP pair | Deployed | +| Velar | Synthetic collateral| Planned | + +### bSTX/STX Oracle + +The `bitstake-bstx-oracle` contract provides an on-chain exchange rate: + +```clarity +;; Returns bSTX/STX rate at 6 decimal precision +;; e.g. 1_050_000 = 1.05 STX per bSTX +(contract-call? .bitstake-bstx-oracle get-spot-rate) +``` + +Rate manipulation is mitigated by: +- **TWAP accumulator** — block-time-weighted 20-checkpoint window +- **Circuit breaker** — trips on >5% single-observation or >10% rolling deviation + +### Bitflow bSTX/STX Pool + +```clarity +;; Add liquidity to bSTX/STX concentrated pool (0.95–1.05 range, 0.05% fee) +(contract-call? .bitstake-bitflow-pool add-liquidity bstx-amount stx-amount min-bstx min-stx) + +;; Swap STX for bSTX +(contract-call? .bitstake-bitflow-pool swap-stx-for-bstx stx-in min-bstx-out) +``` + +See [docs/bstx-defi-collateral.md](docs/bstx-defi-collateral.md) for the full integration guide. + +--- + ## Roadmap - [x] Core pool stacking contracts - [x] bSTX liquid token - [x] BTC reward distribution +- [x] bSTX/STX oracle with TWAP and circuit breaker +- [x] ALEX governance proposal for bSTX collateral listing +- [x] Bitflow bSTX/STX concentrated liquidity pool - [ ] Frontend dashboard with live APY - [ ] Multi-pool strategy (conservative / aggressive) -- [ ] DeFi integrations (bSTX as collateral) +- [ ] ALEX lending collateral (pending governance vote) - [ ] Mobile app - [ ] Governance token for protocol decisions diff --git a/contracts/bitstake-alex-governance.clar b/contracts/bitstake-alex-governance.clar new file mode 100644 index 0000000..d9ca91f --- /dev/null +++ b/contracts/bitstake-alex-governance.clar @@ -0,0 +1,182 @@ +;; bitstake-alex-governance.clar +;; Governance proposal for bSTX collateral listing on ALEX lending protocol +;; +;; Records the on-chain intent to submit bSTX as a lending collateral asset. +;; Proposal parameters: 75% LTV, 80% liquidation threshold, 5% liquidation bonus. +;; Status lifecycle: DRAFT → SUBMITTED → ACTIVE → PASSED | REJECTED + +(define-constant CONTRACT-OWNER tx-sender) + +;; Status codes +(define-constant STATUS-DRAFT u0) +(define-constant STATUS-SUBMITTED u1) +(define-constant STATUS-ACTIVE u2) +(define-constant STATUS-PASSED u3) +(define-constant STATUS-REJECTED u4) + +;; Error codes +(define-constant ERR-NOT-OWNER (err u500)) +(define-constant ERR-INVALID-TRANSITION (err u501)) +(define-constant ERR-PROPOSAL-NOT-FOUND (err u502)) +(define-constant ERR-DUPLICATE (err u503)) +(define-constant ERR-INVALID-PARAMS (err u504)) + +;; LTV / liquidation constants (basis points, 10000 = 100%) +(define-constant PROPOSED-LTV-BPS u7500) ;; 75% +(define-constant LIQUIDATION-THRESHOLD-BPS u8000) ;; 80% +(define-constant LIQUIDATION-BONUS-BPS u500) ;; 5% +(define-constant PRECISION u10000) + +;; ── Storage ─────────────────────────────────────────────────────────── + +(define-data-var proposal-count uint u0) + +(define-map proposals + uint + { + title: (string-ascii 128), + description: (string-ascii 512), + collateral-asset: principal, + oracle-contract: principal, + ltv-bps: uint, + liquidation-threshold: uint, + liquidation-bonus: uint, + status: uint, + submitted-at: uint, + votes-for: uint, + votes-against: uint + } +) + +;; Track which addresses have voted on each proposal +(define-map votes { proposal-id: uint, voter: principal } bool) + +;; ── Internal ────────────────────────────────────────────────────────── + +(define-private (get-proposal-or-fail (id uint)) + (match (map-get? proposals id) + p (ok p) + (err ERR-PROPOSAL-NOT-FOUND) + ) +) + +(define-private (status-allows-transition (current uint) (next uint)) + (or + (and (is-eq current STATUS-DRAFT) (is-eq next STATUS-SUBMITTED)) + (and (is-eq current STATUS-SUBMITTED) (is-eq next STATUS-ACTIVE)) + (and (is-eq current STATUS-ACTIVE) (is-eq next STATUS-PASSED)) + (and (is-eq current STATUS-ACTIVE) (is-eq next STATUS-REJECTED)) + ) +) + +;; ── Create Proposal ─────────────────────────────────────────────────── + +(define-public (create-proposal + (title (string-ascii 128)) + (description (string-ascii 512)) + (collateral principal) + (oracle principal) + (ltv-bps uint) + (liq-threshold uint) + (liq-bonus uint) +) + (begin + (asserts! (is-eq tx-sender CONTRACT-OWNER) ERR-NOT-OWNER) + (asserts! (and (> ltv-bps u0) (< ltv-bps PRECISION)) ERR-INVALID-PARAMS) + (asserts! (> liq-threshold ltv-bps) ERR-INVALID-PARAMS) + (asserts! (< liq-threshold PRECISION) ERR-INVALID-PARAMS) + (asserts! (and (> liq-bonus u0) (< liq-bonus u2000)) ERR-INVALID-PARAMS) + + (let ((id (+ (var-get proposal-count) u1))) + (map-set proposals id { + title: title, + description: description, + collateral-asset: collateral, + oracle-contract: oracle, + ltv-bps: ltv-bps, + liquidation-threshold: liq-threshold, + liquidation-bonus: liq-bonus, + status: STATUS-DRAFT, + submitted-at: block-height, + votes-for: u0, + votes-against: u0 + }) + (var-set proposal-count id) + (print { event: "proposal-created", id: id, title: title, ltv: ltv-bps }) + (ok id) + ) + ) +) + +;; ── Status Transitions ──────────────────────────────────────────────── + +(define-public (transition-status (proposal-id uint) (new-status uint)) + (let ((proposal (try! (get-proposal-or-fail proposal-id)))) + (asserts! (is-eq tx-sender CONTRACT-OWNER) ERR-NOT-OWNER) + (asserts! (status-allows-transition (get status proposal) new-status) ERR-INVALID-TRANSITION) + (map-set proposals proposal-id (merge proposal { + status: new-status, + submitted-at: (if (is-eq new-status STATUS-SUBMITTED) block-height (get submitted-at proposal)) + })) + (print { event: "status-transition", id: proposal-id, from: (get status proposal), to: new-status }) + (ok true) + ) +) + +;; ── Voting ──────────────────────────────────────────────────────────── + +;; Signalling vote (not binding — records community sentiment on-chain) +(define-public (cast-vote (proposal-id uint) (support bool)) + (let ( + (proposal (try! (get-proposal-or-fail proposal-id))) + (key { proposal-id: proposal-id, voter: tx-sender }) + ) + (asserts! (is-eq (get status proposal) STATUS-ACTIVE) ERR-INVALID-TRANSITION) + (asserts! (is-none (map-get? votes key)) ERR-DUPLICATE) + (map-set votes key support) + (map-set proposals proposal-id + (merge proposal { + votes-for: (if support (+ (get votes-for proposal) u1) (get votes-for proposal)), + votes-against: (if support (get votes-against proposal) (+ (get votes-against proposal) u1)) + }) + ) + (print { event: "vote-cast", id: proposal-id, voter: tx-sender, support: support }) + (ok true) + ) +) + +;; ── Read-Only ───────────────────────────────────────────────────────── + +(define-read-only (get-proposal (id uint)) + (ok (map-get? proposals id)) +) + +(define-read-only (get-proposal-count) + (ok (var-get proposal-count)) +) + +(define-read-only (get-vote (proposal-id uint) (voter principal)) + (ok (map-get? votes { proposal-id: proposal-id, voter: voter })) +) + +(define-read-only (get-default-params) + (ok { + ltv-bps: PROPOSED-LTV-BPS, + liquidation-threshold: LIQUIDATION-THRESHOLD-BPS, + liquidation-bonus: LIQUIDATION-BONUS-BPS + }) +) + +;; ── Initialise Default Proposal ─────────────────────────────────────── + +(begin + (try! (create-proposal + "List bSTX as Collateral on ALEX Lending" + "Enable bSTX (Liquid bitstake STX) as accepted collateral on ALEX lending with 75% LTV, 80% liquidation threshold, and bSTX/STX oracle feed from bitstake-bstx-oracle. Users can borrow against stacked positions without unlocking." + CONTRACT-OWNER ;; placeholder — replace with lbSTX contract principal at deploy + CONTRACT-OWNER ;; placeholder — replace with oracle principal at deploy + PROPOSED-LTV-BPS + LIQUIDATION-THRESHOLD-BPS + LIQUIDATION-BONUS-BPS + )) +) diff --git a/contracts/bitstake-bitflow-pool.clar b/contracts/bitstake-bitflow-pool.clar new file mode 100644 index 0000000..897afb9 --- /dev/null +++ b/contracts/bitstake-bitflow-pool.clar @@ -0,0 +1,248 @@ +;; bitstake-bitflow-pool.clar +;; bSTX/STX concentrated liquidity pool setup for Bitflow +;; +;; Records the pool configuration, liquidity position registry, and +;; fee accounting for the bSTX/STX pair. The narrow price band (0.95–1.05) +;; captures near-par trading with minimal impermanent loss. + +(define-constant CONTRACT-OWNER tx-sender) +(define-constant PRECISION u1000000) + +;; Fee tiers in basis points +(define-constant FEE-TIER-LOW u5) ;; 0.05% — tight stable pairs +(define-constant FEE-TIER-MED u30) ;; 0.30% — standard +(define-constant FEE-TIER-HIGH u100) ;; 1.00% — volatile + +;; Default pool configuration for bSTX/STX +;; Price is expressed as token-A per token-B * PRECISION +;; Since bSTX accrues BTC yield, it trades at a slight premium to STX +(define-constant POOL-INITIAL-PRICE u1010000) ;; 1.01 STX per bSTX at launch +(define-constant POOL-TICK-LOWER u950000) ;; 0.95 (lower bound) +(define-constant POOL-TICK-UPPER u1050000) ;; 1.05 (upper bound) +(define-constant POOL-FEE-TIER FEE-TIER-LOW) + +;; Error codes +(define-constant ERR-NOT-OWNER (err u600)) +(define-constant ERR-ZERO-AMOUNT (err u601)) +(define-constant ERR-BELOW-TICK (err u602)) +(define-constant ERR-ABOVE-TICK (err u603)) +(define-constant ERR-POOL-PAUSED (err u604)) +(define-constant ERR-SLIPPAGE (err u605)) +(define-constant ERR-NO-POSITION (err u606)) +(define-constant ERR-INVALID-TICK (err u607)) + +;; ── Pool State ──────────────────────────────────────────────────────── + +(define-data-var pool-active bool true) +(define-data-var current-price uint POOL-INITIAL-PRICE) +(define-data-var tick-lower uint POOL-TICK-LOWER) +(define-data-var tick-upper uint POOL-TICK-UPPER) +(define-data-var fee-tier uint POOL-FEE-TIER) +(define-data-var total-liquidity uint u0) +(define-data-var total-fees-stx uint u0) +(define-data-var total-fees-bstx uint u0) +(define-data-var position-count uint u0) + +;; bSTX and STX reserves +(define-data-var reserve-bstx uint u0) +(define-data-var reserve-stx uint u0) + +;; ── Liquidity Positions ─────────────────────────────────────────────── + +(define-map positions + uint ;; position-id + { + provider: principal, + bstx-amount: uint, + stx-amount: uint, + liquidity: uint, + tick-lower: uint, + tick-upper: uint, + fees-bstx: uint, + fees-stx: uint, + created-block: uint + } +) + +(define-map provider-positions principal (list 20 uint)) + +;; ── Internal ────────────────────────────────────────────────────────── + +(define-private (compute-liquidity (bstx uint) (stx uint)) + ;; L = sqrt(bstx * stx) — simplified integer approximation + ;; We use (bstx + stx) / 2 as a proxy to avoid square root in Clarity + (/ (* (+ bstx stx) PRECISION) u2000000) +) + +(define-private (compute-fee (amount uint) (tier uint)) + (/ (* amount tier) u10000) +) + +(define-private (price-in-range (price uint)) + (and (>= price (var-get tick-lower)) (<= price (var-get tick-upper))) +) + +;; ── Add Liquidity ───────────────────────────────────────────────────── + +(define-public (add-liquidity + (bstx-amount uint) + (stx-amount uint) + (min-bstx uint) + (min-stx uint) +) + (begin + (asserts! (var-get pool-active) ERR-POOL-PAUSED) + (asserts! (> bstx-amount u0) ERR-ZERO-AMOUNT) + (asserts! (> stx-amount u0) ERR-ZERO-AMOUNT) + (asserts! (>= bstx-amount min-bstx) ERR-SLIPPAGE) + (asserts! (>= stx-amount min-stx) ERR-SLIPPAGE) + + (let ( + (liq (compute-liquidity bstx-amount stx-amount)) + (id (+ (var-get position-count) u1)) + ) + ;; Transfer assets to pool contract + (try! (stx-transfer? stx-amount tx-sender (as-contract tx-sender))) + + ;; Record position + (map-set positions id { + provider: tx-sender, + bstx-amount: bstx-amount, + stx-amount: stx-amount, + liquidity: liq, + tick-lower: (var-get tick-lower), + tick-upper: (var-get tick-upper), + fees-bstx: u0, + fees-stx: u0, + created-block: block-height + }) + + ;; Update reserves and totals + (var-set reserve-bstx (+ (var-get reserve-bstx) bstx-amount)) + (var-set reserve-stx (+ (var-get reserve-stx) stx-amount)) + (var-set total-liquidity (+ (var-get total-liquidity) liq)) + (var-set position-count id) + + (print { event: "liquidity-added", id: id, provider: tx-sender, + bstx: bstx-amount, stx: stx-amount, liquidity: liq }) + (ok id) + ) + ) +) + +;; ── Remove Liquidity ────────────────────────────────────────────────── + +(define-public (remove-liquidity (position-id uint)) + (let ( + (pos (unwrap! (map-get? positions position-id) ERR-NO-POSITION)) + ) + (asserts! (is-eq tx-sender (get provider pos)) ERR-NOT-OWNER) + (asserts! (var-get pool-active) ERR-POOL-PAUSED) + + (let ( + (bstx-out (+ (get bstx-amount pos) (get fees-bstx pos))) + (stx-out (+ (get stx-amount pos) (get fees-stx pos))) + (liq (get liquidity pos)) + ) + ;; Return STX + (try! (as-contract (stx-transfer? stx-out tx-sender tx-sender))) + + ;; Update state + (map-delete positions position-id) + (var-set reserve-bstx + (if (>= (var-get reserve-bstx) (get bstx-amount pos)) + (- (var-get reserve-bstx) (get bstx-amount pos)) u0)) + (var-set reserve-stx + (if (>= (var-get reserve-stx) (get stx-amount pos)) + (- (var-get reserve-stx) (get stx-amount pos)) u0)) + (var-set total-liquidity + (if (>= (var-get total-liquidity) liq) + (- (var-get total-liquidity) liq) u0)) + + (print { event: "liquidity-removed", id: position-id, provider: tx-sender, + bstx-out: bstx-out, stx-out: stx-out }) + (ok { bstx-out: bstx-out, stx-out: stx-out }) + ) + ) +) + +;; ── Swap (simplified) ───────────────────────────────────────────────── + +;; Swap STX for bSTX (buy bSTX) +(define-public (swap-stx-for-bstx (stx-in uint) (min-bstx-out uint)) + (begin + (asserts! (var-get pool-active) ERR-POOL-PAUSED) + (asserts! (> stx-in u0) ERR-ZERO-AMOUNT) + + (let ( + (fee (compute-fee stx-in (var-get fee-tier))) + (net-in (- stx-in fee)) + (price (var-get current-price)) + (bstx-out (/ (* net-in PRECISION) price)) + ) + (asserts! (>= bstx-out min-bstx-out) ERR-SLIPPAGE) + (asserts! (<= bstx-out (var-get reserve-bstx)) ERR-ZERO-AMOUNT) + + (try! (stx-transfer? stx-in tx-sender (as-contract tx-sender))) + + (var-set reserve-stx (+ (var-get reserve-stx) net-in)) + (var-set reserve-bstx (- (var-get reserve-bstx) bstx-out)) + (var-set total-fees-stx (+ (var-get total-fees-stx) fee)) + + (print { event: "swap", direction: "stx-to-bstx", in: stx-in, out: bstx-out, fee: fee }) + (ok bstx-out) + ) + ) +) + +;; ── Admin ───────────────────────────────────────────────────────────── + +(define-public (update-price (new-price uint)) + (begin + (asserts! (is-eq tx-sender CONTRACT-OWNER) ERR-NOT-OWNER) + (asserts! (price-in-range new-price) ERR-INVALID-TICK) + (var-set current-price new-price) + (print { event: "price-updated", price: new-price }) + (ok true) + ) +) + +(define-public (set-pool-active (active bool)) + (begin + (asserts! (is-eq tx-sender CONTRACT-OWNER) ERR-NOT-OWNER) + (var-set pool-active active) + (ok true) + ) +) + +;; ── Read-Only ───────────────────────────────────────────────────────── + +(define-read-only (get-pool-stats) + (ok { + active: (var-get pool-active), + current-price: (var-get current-price), + tick-lower: (var-get tick-lower), + tick-upper: (var-get tick-upper), + fee-tier-bps: (var-get fee-tier), + total-liquidity: (var-get total-liquidity), + reserve-bstx: (var-get reserve-bstx), + reserve-stx: (var-get reserve-stx), + total-fees-bstx: (var-get total-fees-bstx), + total-fees-stx: (var-get total-fees-stx), + position-count: (var-get position-count) + }) +) + +(define-read-only (get-position (id uint)) + (ok (map-get? positions id)) +) + +(define-read-only (quote-stx-for-bstx (stx-in uint)) + (let ( + (fee (compute-fee stx-in (var-get fee-tier))) + (net-in (- stx-in fee)) + (out (/ (* net-in PRECISION) (var-get current-price))) + ) + (ok { bstx-out: out, fee: fee, price: (var-get current-price) }) + ) +) diff --git a/contracts/bitstake-bstx-oracle-twap.clar b/contracts/bitstake-bstx-oracle-twap.clar new file mode 100644 index 0000000..6404884 --- /dev/null +++ b/contracts/bitstake-bstx-oracle-twap.clar @@ -0,0 +1,154 @@ +;; bitstake-bstx-oracle-twap.clar +;; Block-time-weighted TWAP accumulator for bSTX/STX +;; +;; Each observation stores (price, block-height). +;; TWAP = sum(price_i * block_weight_i) / sum(block_weight_i) +;; where block_weight_i = block_height_(i+1) - block_height_i +;; The most recent observation uses (current-block - last-block) as its weight. + +(define-constant CONTRACT-OWNER tx-sender) +(define-constant PRECISION u1000000) ;; 6 decimals +(define-constant MAX-OBSERVATIONS u20) +(define-constant ERR-NOT-OWNER (err u300)) +(define-constant ERR-NO-DATA (err u301)) +(define-constant ERR-ZERO-SUPPLY (err u302)) +(define-constant ERR-INVALID-WINDOW (err u303)) + +;; ── Storage ─────────────────────────────────────────────────────────── + +;; Circular price-accumulator stores (cumulative-price, block-height) +;; cumulative-price grows monotonically: each block adds spot-price to it. +;; TWAP over window = (cum_price_now - cum_price_t0) / (block_now - block_t0) + +(define-map price-observations + uint ;; slot index (0 .. MAX-OBSERVATIONS-1) + { cumulative-price: uint, block-height: uint } +) + +(define-data-var cumulative-price uint u0) +(define-data-var write-index uint u0) +(define-data-var total-written uint u0) +(define-data-var last-update-block uint u0) +(define-data-var last-spot-price uint u0) + +;; ── Internal ────────────────────────────────────────────────────────── + +(define-private (advance-accumulator (spot uint)) + (let ( + (blocks-elapsed (if (> block-height (var-get last-update-block)) + (- block-height (var-get last-update-block)) + u1)) + (new-cumulative (+ (var-get cumulative-price) (* spot blocks-elapsed))) + ) + (var-set cumulative-price new-cumulative) + new-cumulative + ) +) + +(define-private (write-checkpoint (cum-price uint)) + (let ((slot (mod (var-get write-index) MAX-OBSERVATIONS))) + (map-set price-observations slot + { cumulative-price: cum-price, block-height: block-height }) + (var-set write-index (+ (var-get write-index) u1)) + (var-set total-written + (if (< (var-get total-written) MAX-OBSERVATIONS) + (+ (var-get total-written) u1) + MAX-OBSERVATIONS + ) + ) + slot + ) +) + +;; Retrieve checkpoint by age: age=0 is the most recent, age=N is N checkpoints ago +(define-private (get-checkpoint-by-age (age uint)) + (let ( + (total (var-get total-written)) + (widx (var-get write-index)) + ) + (if (>= age total) + none + (let ((slot (mod (+ (- widx u1) (- total age)) MAX-OBSERVATIONS))) + (map-get? price-observations slot) + ) + ) + ) +) + +;; ── Public: push observation ────────────────────────────────────────── + +(define-public (push-price (total-stx uint) (total-bstx uint)) + (begin + (asserts! (is-eq tx-sender CONTRACT-OWNER) ERR-NOT-OWNER) + (asserts! (> total-bstx u0) ERR-ZERO-SUPPLY) + + (let ( + (spot (/ (* total-stx PRECISION) total-bstx)) + (cum (advance-accumulator spot)) + ) + (write-checkpoint cum) + (var-set last-update-block block-height) + (var-set last-spot-price spot) + (print { event: "twap-push", spot: spot, cumulative: cum, block: block-height }) + (ok spot) + ) + ) +) + +;; ── Read-only: TWAP over last N checkpoints ─────────────────────────── + +;; Returns the time-weighted average price between checkpoint at (age) and now +;; age: how many checkpoints back to start the window +(define-read-only (get-twap-over-window (age uint)) + (let ((checkpoint (get-checkpoint-by-age age))) + (match checkpoint + cp (let ( + (block-delta (if (> block-height (get block-height cp)) + (- block-height (get block-height cp)) + u1)) + (price-delta (if (>= (var-get cumulative-price) (get cumulative-price cp)) + (- (var-get cumulative-price) (get cumulative-price cp)) + u0)) + ) + (if (is-eq block-delta u0) + (err ERR-INVALID-WINDOW) + (ok (/ price-delta block-delta)) + ) + ) + (err ERR-NO-DATA) + ) + ) +) + +;; Standard TWAP: over the oldest available checkpoint +(define-read-only (get-twap) + (let ((oldest-age (if (>= (var-get total-written) MAX-OBSERVATIONS) + (- MAX-OBSERVATIONS u1) + (if (> (var-get total-written) u0) + (- (var-get total-written) u1) + u0)))) + (get-twap-over-window oldest-age) + ) +) + +;; ── Read-only: State ────────────────────────────────────────────────── + +(define-read-only (get-last-spot) + (ok (var-get last-spot-price)) +) + +(define-read-only (get-cumulative-price) + (ok (var-get cumulative-price)) +) + +(define-read-only (get-total-written) + (ok (var-get total-written)) +) + +(define-read-only (get-write-index) + (ok (var-get write-index)) +) + +(define-read-only (get-checkpoint (slot uint)) + (ok (map-get? price-observations slot)) +) diff --git a/contracts/bitstake-bstx-oracle.clar b/contracts/bitstake-bstx-oracle.clar new file mode 100644 index 0000000..ff2b0a6 --- /dev/null +++ b/contracts/bitstake-bstx-oracle.clar @@ -0,0 +1,221 @@ +;; bitstake-bstx-oracle.clar +;; bSTX/STX exchange rate oracle +;; Returns total-stx / total-bstx-supply at 6 decimal precision +;; Includes TWAP accumulator and circuit breaker protection + +(define-constant CONTRACT-OWNER tx-sender) +(define-constant PRECISION u1000000) ;; 6 decimals +(define-constant TWAP-WINDOW u10) ;; observations kept +(define-constant CIRCUIT-BREAKER-BPS u500) ;; 5% max deviation (500 basis points) +(define-constant MIN-OBSERVATION-GAP u5) ;; min blocks between observations + +;; ── Error Codes ─────────────────────────────────────────────────────── +(define-constant ERR-NOT-OWNER (err u200)) +(define-constant ERR-ZERO-SUPPLY (err u201)) +(define-constant ERR-CIRCUIT-BREAKER (err u202)) +(define-constant ERR-TOO-FREQUENT (err u203)) +(define-constant ERR-NO-OBSERVATIONS (err u204)) +(define-constant ERR-ORACLE-HALTED (err u205)) + +;; ── State ───────────────────────────────────────────────────────────── + +;; Whether the circuit breaker has tripped and halted the oracle +(define-data-var oracle-halted bool false) + +;; Observation index (circular buffer position, 0-9) +(define-data-var observation-index uint u0) + +;; Total number of observations ever written (saturates at TWAP-WINDOW) +(define-data-var observation-count uint u0) + +;; Block height of the last recorded observation +(define-data-var last-observation-block uint u0) + +;; Last computed spot rate (price * PRECISION) +(define-data-var last-spot-rate uint u0) + +;; Authorized updater (can be set to an off-chain indexer key) +(define-data-var authorized-updater (optional principal) none) + +;; ── TWAP Circular Buffer ────────────────────────────────────────────── +;; Stores (price, block-height) pairs for TWAP computation + +(define-map observations uint { price: uint, block-height: uint }) + +;; ── Internal Helpers ───────────────────────────────────────────────── + +(define-private (is-authorized) + (or + (is-eq tx-sender CONTRACT-OWNER) + (match (var-get authorized-updater) + updater (is-eq tx-sender updater) + false + ) + ) +) + +(define-private (abs-diff (a uint) (b uint)) + (if (>= a b) (- a b) (- b a)) +) + +;; Returns deviation in basis points between new-price and reference +(define-private (deviation-bps (new-price uint) (reference uint)) + (if (is-eq reference u0) + u0 + (/ (* (abs-diff new-price reference) u10000) reference) + ) +) + +;; ── Spot Rate Calculation ───────────────────────────────────────────── + +;; Compute current bSTX exchange rate from on-chain data +;; rate = (total-stx-stacked * PRECISION) / total-bstx-supply +;; Returns rate in micros (6 decimal places; 1.000000 = 1:1 peg) +(define-read-only (compute-spot-rate (total-stx uint) (total-bstx uint)) + (if (is-eq total-bstx u0) + (err ERR-ZERO-SUPPLY) + (ok (/ (* total-stx PRECISION) total-bstx)) + ) +) + +;; ── TWAP Computation ───────────────────────────────────────────────── + +;; Retrieve one observation by absolute slot (0-9) +(define-read-only (get-observation (slot uint)) + (map-get? observations slot) +) + +;; Compute time-weighted average price across all stored observations +;; Uses simple arithmetic mean of stored prices (block-time-weighted variant +;; requires block gaps; arithmetic mean is the baseline implementation) +(define-read-only (get-twap) + (let ((count (var-get observation-count))) + (if (is-eq count u0) + (err ERR-NO-OBSERVATIONS) + (let ( + (obs0 (default-to { price: u0, block-height: u0 } (map-get? observations u0))) + (obs1 (default-to { price: u0, block-height: u0 } (map-get? observations u1))) + (obs2 (default-to { price: u0, block-height: u0 } (map-get? observations u2))) + (obs3 (default-to { price: u0, block-height: u0 } (map-get? observations u3))) + (obs4 (default-to { price: u0, block-height: u0 } (map-get? observations u4))) + (obs5 (default-to { price: u0, block-height: u0 } (map-get? observations u5))) + (obs6 (default-to { price: u0, block-height: u0 } (map-get? observations u6))) + (obs7 (default-to { price: u0, block-height: u0 } (map-get? observations u7))) + (obs8 (default-to { price: u0, block-height: u0 } (map-get? observations u8))) + (obs9 (default-to { price: u0, block-height: u0 } (map-get? observations u9))) + (sum (+ (+ (+ (+ (get price obs0) (get price obs1)) (+ (get price obs2) (get price obs3))) + (+ (+ (get price obs4) (get price obs5)) (+ (get price obs6) (get price obs7)))) + (+ (get price obs8) (get price obs9)))) + (effective-count (if (>= count TWAP-WINDOW) TWAP-WINDOW count)) + ) + (ok (/ sum effective-count)) + ) + ) + ) +) + +;; ── Circuit Breaker ─────────────────────────────────────────────────── + +;; Check if new-price deviates more than CIRCUIT-BREAKER-BPS from the TWAP +;; If yes, halt the oracle and return an error +(define-private (check-circuit-breaker (new-price uint)) + (match (get-twap) + twap-price (if (> (deviation-bps new-price twap-price) CIRCUIT-BREAKER-BPS) + (begin + (var-set oracle-halted true) + (print { event: "circuit-breaker-tripped", new-price: new-price, twap: twap-price }) + (err ERR-CIRCUIT-BREAKER) + ) + (ok true) + ) + ;; No TWAP yet — skip circuit breaker check on first observations + _error (ok true) + ) +) + +;; ── Record Observation ──────────────────────────────────────────────── + +;; Write a new price observation into the circular buffer. +;; Enforces minimum block gap and circuit breaker before writing. +(define-public (record-observation (total-stx uint) (total-bstx uint)) + (begin + (asserts! (is-authorized) ERR-NOT-OWNER) + (asserts! (not (var-get oracle-halted)) ERR-ORACLE-HALTED) + (asserts! (>= (- block-height (var-get last-observation-block)) MIN-OBSERVATION-GAP) ERR-TOO-FREQUENT) + + (let ((spot (try! (compute-spot-rate total-stx total-bstx)))) + (try! (check-circuit-breaker spot)) + + (let ((slot (mod (var-get observation-index) TWAP-WINDOW))) + (map-set observations slot { price: spot, block-height: block-height }) + (var-set observation-index (+ (var-get observation-index) u1)) + (var-set observation-count + (if (< (var-get observation-count) TWAP-WINDOW) + (+ (var-get observation-count) u1) + TWAP-WINDOW + ) + ) + (var-set last-observation-block block-height) + (var-set last-spot-rate spot) + (print { event: "observation-recorded", slot: slot, price: spot, block: block-height }) + (ok spot) + ) + ) + ) +) + +;; ── Admin ───────────────────────────────────────────────────────────── + +;; Reset circuit breaker (owner only, after investigating the anomaly) +(define-public (reset-circuit-breaker) + (begin + (asserts! (is-eq tx-sender CONTRACT-OWNER) ERR-NOT-OWNER) + (var-set oracle-halted false) + (print { event: "circuit-breaker-reset", by: tx-sender }) + (ok true) + ) +) + +;; Set an authorized updater key (e.g. indexer principal) +(define-public (set-authorized-updater (updater (optional principal))) + (begin + (asserts! (is-eq tx-sender CONTRACT-OWNER) ERR-NOT-OWNER) + (var-set authorized-updater updater) + (ok true) + ) +) + +;; ── Public Read-Only Interface ──────────────────────────────────────── + +(define-read-only (get-spot-rate) + (if (var-get oracle-halted) + (err ERR-ORACLE-HALTED) + (ok (var-get last-spot-rate)) + ) +) + +(define-read-only (get-last-observation-block) + (ok (var-get last-observation-block)) +) + +(define-read-only (get-observation-count) + (ok (var-get observation-count)) +) + +(define-read-only (is-halted) + (ok (var-get oracle-halted)) +) + +(define-read-only (get-authorized-updater) + (ok (var-get authorized-updater)) +) + +;; Convenience: return both spot and TWAP in one call +(define-read-only (get-rates) + (ok { + spot: (var-get last-spot-rate), + halted: (var-get oracle-halted), + twap: (match (get-twap) twap twap u0), + block: (var-get last-observation-block) + }) +) diff --git a/contracts/bitstake-oracle-circuit-breaker.clar b/contracts/bitstake-oracle-circuit-breaker.clar new file mode 100644 index 0000000..7f23090 --- /dev/null +++ b/contracts/bitstake-oracle-circuit-breaker.clar @@ -0,0 +1,212 @@ +;; bitstake-oracle-circuit-breaker.clar +;; Standalone circuit breaker for bSTX oracle price feeds +;; +;; Enforces: +;; 1. Maximum single-update deviation threshold (basis points) +;; 2. Maximum rate of change over a rolling block window +;; 3. Admin pause / resume with reason logging + +(define-constant CONTRACT-OWNER tx-sender) +(define-constant PRECISION u1000000) +(define-constant DEFAULT-DEVIATION-BPS u500) ;; 5 % per observation +(define-constant DEFAULT-VELOCITY-BPS u1000) ;; 10% over velocity window +(define-constant DEFAULT-VELOCITY-WINDOW u100) ;; blocks for velocity check + +;; ── Error Codes ─────────────────────────────────────────────────────── +(define-constant ERR-NOT-OWNER (err u400)) +(define-constant ERR-BREAKER-OPEN (err u401)) +(define-constant ERR-DEVIATION-EXCEEDED (err u402)) +(define-constant ERR-VELOCITY-EXCEEDED (err u403)) +(define-constant ERR-INVALID-PARAMS (err u404)) + +;; ── State ───────────────────────────────────────────────────────────── + +(define-data-var breaker-open bool false) +(define-data-var pause-reason (string-ascii 128) "") +(define-data-var deviation-threshold uint DEFAULT-DEVIATION-BPS) +(define-data-var velocity-threshold uint DEFAULT-VELOCITY-BPS) +(define-data-var velocity-window uint DEFAULT-VELOCITY-WINDOW) + +;; Reference price snapshot: (price, block-height) +(define-data-var reference-price uint u0) +(define-data-var reference-block uint u0) + +;; All-time last accepted price +(define-data-var last-price uint u0) +(define-data-var last-price-block uint u0) + +;; Count of breaker trips since last reset +(define-data-var trip-count uint u0) + +;; ── Pure Helpers ────────────────────────────────────────────────────── + +(define-private (abs-diff (a uint) (b uint)) + (if (>= a b) (- a b) (- b a)) +) + +(define-private (bps (numerator uint) (denominator uint)) + (if (is-eq denominator u0) + u0 + (/ (* numerator u10000) denominator) + ) +) + +;; ── Deviation Check ─────────────────────────────────────────────────── + +(define-private (check-deviation (new-price uint)) + (let ((ref (var-get last-price))) + (if (is-eq ref u0) + (ok true) ;; no reference yet, allow + (let ((dev (bps (abs-diff new-price ref) ref))) + (if (> dev (var-get deviation-threshold)) + (begin + (print { event: "deviation-check-failed", new: new-price, ref: ref, dev-bps: dev }) + (err ERR-DEVIATION-EXCEEDED) + ) + (ok true) + ) + ) + ) + ) +) + +;; ── Velocity Check ──────────────────────────────────────────────────── + +;; Velocity = price change rate over the velocity window +;; If (block-height - reference-block) >= velocity-window, take a fresh snapshot +(define-private (check-velocity (new-price uint)) + (let ( + (ref-price (var-get reference-price)) + (ref-block (var-get reference-block)) + (window (var-get velocity-window)) + ) + (if (or (is-eq ref-price u0) (>= (- block-height ref-block) window)) + ;; Start a new velocity window + (begin + (var-set reference-price new-price) + (var-set reference-block block-height) + (ok true) + ) + ;; Within existing window: check cumulative move + (let ((vel (bps (abs-diff new-price ref-price) ref-price))) + (if (> vel (var-get velocity-threshold)) + (begin + (print { event: "velocity-check-failed", new: new-price, ref: ref-price, vel-bps: vel }) + (err ERR-VELOCITY-EXCEEDED) + ) + (ok true) + ) + ) + ) + ) +) + +;; ── Primary Validate Entry Point ────────────────────────────────────── + +;; Called by oracle before accepting a new price update. +;; Returns (ok new-price) on success, trips breaker and returns err on failure. +(define-public (validate-price (new-price uint)) + (begin + (asserts! (is-eq tx-sender CONTRACT-OWNER) ERR-NOT-OWNER) + (asserts! (not (var-get breaker-open)) ERR-BREAKER-OPEN) + + (match (check-deviation new-price) + _ok (match (check-velocity new-price) + _ok2 (begin + (var-set last-price new-price) + (var-set last-price-block block-height) + (ok new-price) + ) + err2 (begin + (var-set breaker-open true) + (var-set trip-count (+ (var-get trip-count) u1)) + (var-set pause-reason "velocity threshold exceeded") + (print { event: "breaker-tripped", reason: "velocity", new-price: new-price }) + (err err2) + ) + ) + err1 (begin + (var-set breaker-open true) + (var-set trip-count (+ (var-get trip-count) u1)) + (var-set pause-reason "deviation threshold exceeded") + (print { event: "breaker-tripped", reason: "deviation", new-price: new-price }) + (err err1) + ) + ) + ) +) + +;; ── Admin ───────────────────────────────────────────────────────────── + +(define-public (trip-breaker (reason (string-ascii 128))) + (begin + (asserts! (is-eq tx-sender CONTRACT-OWNER) ERR-NOT-OWNER) + (var-set breaker-open true) + (var-set pause-reason reason) + (var-set trip-count (+ (var-get trip-count) u1)) + (print { event: "manual-trip", reason: reason }) + (ok true) + ) +) + +(define-public (reset-breaker) + (begin + (asserts! (is-eq tx-sender CONTRACT-OWNER) ERR-NOT-OWNER) + (var-set breaker-open false) + (var-set pause-reason "") + (var-set reference-price u0) + (var-set reference-block u0) + (print { event: "breaker-reset", by: tx-sender }) + (ok true) + ) +) + +(define-public (set-deviation-threshold (bps-value uint)) + (begin + (asserts! (is-eq tx-sender CONTRACT-OWNER) ERR-NOT-OWNER) + (asserts! (and (> bps-value u0) (<= bps-value u5000)) ERR-INVALID-PARAMS) + (var-set deviation-threshold bps-value) + (ok true) + ) +) + +(define-public (set-velocity-threshold (bps-value uint)) + (begin + (asserts! (is-eq tx-sender CONTRACT-OWNER) ERR-NOT-OWNER) + (asserts! (and (> bps-value u0) (<= bps-value u10000)) ERR-INVALID-PARAMS) + (var-set velocity-threshold bps-value) + (ok true) + ) +) + +(define-public (set-velocity-window (blocks uint)) + (begin + (asserts! (is-eq tx-sender CONTRACT-OWNER) ERR-NOT-OWNER) + (asserts! (and (> blocks u0) (<= blocks u2100)) ERR-INVALID-PARAMS) + (var-set velocity-window blocks) + (ok true) + ) +) + +;; ── Read-Only ───────────────────────────────────────────────────────── + +(define-read-only (get-status) + (ok { + open: (var-get breaker-open), + reason: (var-get pause-reason), + trip-count: (var-get trip-count), + last-price: (var-get last-price), + last-price-block: (var-get last-price-block), + deviation-bps: (var-get deviation-threshold), + velocity-bps: (var-get velocity-threshold), + velocity-window: (var-get velocity-window) + }) +) + +(define-read-only (is-open) + (ok (var-get breaker-open)) +) + +(define-read-only (get-last-price) + (ok (var-get last-price)) +) diff --git a/contracts/bitstake-pool-registry.clar b/contracts/bitstake-pool-registry.clar index ab5bf2d..80a8e40 100644 --- a/contracts/bitstake-pool-registry.clar +++ b/contracts/bitstake-pool-registry.clar @@ -97,6 +97,26 @@ ) ) +;; Returns total-stacked for a single pool — consumed by the bSTX oracle +(define-read-only (get-total-stacked (pool-id uint)) + (match (map-get? pools pool-id) + pool (ok (get total-stacked pool)) + ERR-POOL-NOT-FOUND + ) +) + +;; Aggregate total STX stacked across all three default pools (1, 2, 3). +;; Used by the oracle to compute the global bSTX exchange rate. +(define-read-only (get-aggregate-stacked) + (let ( + (p1 (default-to u0 (match (map-get? pools u1) p (some (get total-stacked p)) none))) + (p2 (default-to u0 (match (map-get? pools u2) p (some (get total-stacked p)) none))) + (p3 (default-to u0 (match (map-get? pools u3) p (some (get total-stacked p)) none))) + ) + (ok (+ p1 (+ p2 p3))) + ) +) + ;; ── Initialise Default Pools ────────────────────────────────────────── (begin diff --git a/docs/alex-governance-proposal.md b/docs/alex-governance-proposal.md new file mode 100644 index 0000000..bf3c5c7 --- /dev/null +++ b/docs/alex-governance-proposal.md @@ -0,0 +1,112 @@ +# [ALEX Governance Proposal] List bSTX as Lending Collateral + +**Status:** DRAFT +**Author:** bitstake core team +**Forum:** ALEX Finance Governance + +--- + +## Summary + +We propose to list bitstake's bSTX liquid staking tokens (lbSTX, bbSTX, mbSTX) as accepted collateral assets on ALEX lending. This enables users to borrow against their stacked STX positions without sacrificing BTC yield, unlocking DeFi composability on Stacks. + +--- + +## Motivation + +When users deposit STX into bitstake, their capital is locked for 1–12 stacking cycles. Unlike Ethereum (where stETH is accepted on Aave, rETH on Compound), Stacks has no primitive allowing stacked STX to be reused in DeFi. This creates an opportunity cost that discourages large stacking participation. + +Listing bSTX on ALEX lending would: + +1. Allow stacking participants to borrow STX or sBTC against bSTX positions +2. Increase overall Stacks DeFi TVL +3. Create a productive use case for bSTX beyond yield accrual +4. Demonstrate a liquid staking + DeFi composability pattern for the Stacks ecosystem + +--- + +## Proposed Parameters + +| Parameter | Value | Rationale | +|-------------------------|-------------------------|-----------------------------------------------| +| Collateral Asset | lbSTX, bbSTX, mbSTX | All three pool tokens share the oracle | +| Price Oracle | `bitstake-bstx-oracle` | On-chain, manipulation-resistant, TWAP-backed | +| Loan-to-Value (LTV) | 75% | Conservative — accounts for ±5% price band | +| Liquidation Threshold | 80% | 5% buffer above LTV | +| Liquidation Bonus | 5% | Incentivises liquidators | +| Borrow Cap | 500,000 STX equivalent | Phased rollout cap | +| Supply Cap | 2,000,000 STX equivalent| Limits concentration risk | + +--- + +## Oracle Design + +The `bitstake-bstx-oracle` contract computes the bSTX exchange rate as: + +``` +rate = (total-stx-stacked × 1,000,000) / total-bstx-supply +``` + +**Manipulation resistance:** + +- **TWAP accumulator** (`bitstake-bstx-oracle-twap`): block-time-weighted average over 20 checkpoints. Large single-block price moves are smoothed. +- **Circuit breaker** (`bitstake-oracle-circuit-breaker`): halts price updates if a single observation deviates > 5% from the previous accepted price, or if cumulative movement over a 100-block window exceeds 10%. +- **Minimum observation gap**: 5 blocks between oracle updates prevents rapid refresh attacks. +- **Authorized updater**: Only the owner or a designated indexer principal can push observations. + +--- + +## Risk Analysis + +### Depeg Risk +bSTX can only trade at a discount to STX if the bitstake pool loses funds (smart contract exploit). In normal operation the rate only increases (as BTC yield accrues). The circuit breaker guards against sudden depeg scenarios. + +### Liquidation Cascade +At 75% LTV, borrowers are liquidated when bSTX falls to 93.75% of its borrow value in STX terms. Given bSTX normally trades within ±5% of par, this event requires an extraordinary market dislocation. The phased borrow cap limits systemic risk during initial rollout. + +### Oracle Freshness +ALEX contracts should verify that `get-last-observation-block` is within 50 blocks of the current block before accepting the oracle price. A staleness guard prevents using a frozen price during network events. + +### Smart Contract Risk +All bitstake contracts use Clarity 2 with no `unwrap-panic`. Error codes are explicit. The oracle contracts are standalone with no upgrade mechanism — a new deployment would be needed for any changes. + +--- + +## Implementation Checklist + +- [x] bSTX price oracle contract deployed on testnet +- [x] TWAP accumulator deployed and tested +- [x] Circuit breaker deployed and tested +- [x] ALEX governance on-chain proposal seeded (`bitstake-alex-governance`, proposal id 1) +- [x] Integration guide published (`docs/bstx-defi-collateral.md`) +- [ ] Oracle monitored over 2 mainnet stacking cycles before listing +- [ ] ALEX team technical review of oracle methodology +- [ ] Community vote on ALEX governance forum +- [ ] Mainnet deployment of lending collateral configuration + +--- + +## On-Chain Proposal + +The on-chain governance record is available at: + +``` +Contract: bitstake-alex-governance +Proposal ID: 1 +Status: DRAFT → SUBMITTED (upon forum publication) +``` + +Community signalling votes can be cast on-chain via: +```clarity +(contract-call? .bitstake-alex-governance cast-vote u1 true) ;; support +(contract-call? .bitstake-alex-governance cast-vote u1 false) ;; oppose +``` + +--- + +## References + +- bitstake codebase: https://github.com/thewealthyplace/bitstake +- ALEX Finance docs: https://docs.alexlab.co +- Bitflow docs: https://docs.bitflow.finance +- Stacks SIP-010 standard: https://github.com/stacksgov/sips diff --git a/docs/bstx-defi-collateral.md b/docs/bstx-defi-collateral.md new file mode 100644 index 0000000..4509d05 --- /dev/null +++ b/docs/bstx-defi-collateral.md @@ -0,0 +1,208 @@ +# bSTX DeFi Collateral Integration Guide + +This document describes how to integrate bSTX (bitstake liquid staking tokens) as collateral in Stacks DeFi protocols. It covers the oracle interface, ALEX lending integration, and Bitflow liquidity pool setup. + +--- + +## Overview + +When users deposit STX into bitstake, they receive a liquid staking token: + +| Token | Pool | Lockup | +|--------|----------|---------------| +| lbSTX | Liquid | 1 cycle (~15 days) | +| bbSTX | Balanced | 3 cycles (~45 days) | +| mbSTX | Maxi | 12 cycles (~6 months) | + +These tokens represent a claim on pooled STX plus accrued BTC stacking yield. Because the pool earns BTC yield each cycle, bSTX tokens trade at a premium relative to STX that grows over time. + +--- + +## Oracle: `bitstake-bstx-oracle` + +### Exchange Rate + +``` +rate = (total-stx-stacked * 1_000_000) / total-bstx-supply +``` + +- Result is a `uint` at 6 decimal precision +- `1_000_000` = 1:1 parity +- `1_050_000` = 1.05 STX per bSTX (5% premium) + +### Key Read-Only Functions + +```clarity +;; Returns spot rate (errors if circuit breaker is open) +(get-spot-rate) → (response uint uint) + +;; Returns spot, TWAP, halted flag, and last-block in one call +(get-rates) → (response { spot: uint, halted: bool, twap: uint, block: uint } uint) + +;; Compute rate from raw inputs (pure, no state) +(compute-spot-rate (total-stx uint) (total-bstx uint)) → (response uint uint) +``` + +### Recording Observations + +Only the contract owner or an `authorized-updater` can push new observations. Observations must be at least 5 blocks apart. + +```clarity +(record-observation (total-stx uint) (total-bstx uint)) → (response uint uint) +``` + +**Best practice:** call `record-observation` with values from `bitstake-pool-registry.get-aggregate-stacked` (total STX) and the sum of `ft-get-supply` across lbSTX, bbSTX, mbSTX (total bSTX supply). + +### TWAP Oracle: `bitstake-bstx-oracle-twap` + +For protocols requiring a more manipulation-resistant price: + +```clarity +;; Push a new price checkpoint +(push-price (total-stx uint) (total-bstx uint)) → (response uint uint) + +;; Get time-weighted average over the full window +(get-twap) → (response uint uint) + +;; Get TWAP over the last N checkpoints +(get-twap-over-window (age uint)) → (response uint uint) +``` + +--- + +## Circuit Breaker: `bitstake-oracle-circuit-breaker` + +The circuit breaker guards against oracle manipulation or erroneous price updates. + +### Thresholds (defaults) + +| Parameter | Default | Description | +|-----------------------|---------|------------------------------------------| +| `deviation-threshold` | 500 bps | Max single-update price deviation (5%) | +| `velocity-threshold` | 1000 bps| Max cumulative move over window (10%) | +| `velocity-window` | 100 blocks | Rolling window for velocity check | + +### Integration Pattern + +```clarity +;; Before accepting a new oracle price, call: +(contract-call? .bitstake-oracle-circuit-breaker validate-price new-price) +;; → (ok new-price) if safe +;; → (err u402) if deviation exceeded (breaker trips) +;; → (err u403) if velocity exceeded (breaker trips) +;; → (err u401) if breaker is already open +``` + +--- + +## ALEX Lending Integration + +### Proposed Parameters + +| Parameter | Value | +|-------------------------|---------| +| Collateral Asset | lbSTX / bbSTX / mbSTX | +| Loan-to-Value (LTV) | 75% | +| Liquidation Threshold | 80% | +| Liquidation Bonus | 5% | +| Price Oracle | `bitstake-bstx-oracle` | + +### Governance Proposal Contract: `bitstake-alex-governance` + +The proposal contract records the collateral listing request on-chain and allows community signalling votes before formal ALEX governance submission. + +```clarity +;; Owner submits proposal lifecycle +(transition-status proposal-id STATUS-SUBMITTED) +(transition-status proposal-id STATUS-ACTIVE) + +;; Community signal voting +(cast-vote proposal-id true) ;; support +(cast-vote proposal-id false) ;; oppose +``` + +### Submitting to ALEX Governance (Off-Chain Steps) + +1. Deploy `bitstake-bstx-oracle` to mainnet and verify exchange rate accuracy over at least 2 stacking cycles. +2. Transition the on-chain proposal to `SUBMITTED` status. +3. Post the proposal on ALEX governance forum with: + - Token contract address + - Oracle contract address and exchange-rate methodology + - LTV / liquidation parameters + - Risk analysis (IL, depeg scenarios, liquidation cascade) +4. Link the forum post to the on-chain proposal. +5. After community approval, coordinate with the ALEX team to allowlist bSTX as collateral. + +--- + +## Bitflow Liquidity Pool: `bitstake-bitflow-pool` + +### Pool Parameters + +| Parameter | Value | +|------------------|-------------------------| +| Pair | bSTX / STX | +| Fee Tier | 0.05% (5 bps) | +| Initial Price | 1.01 STX per bSTX | +| Tick Lower | 0.95 STX per bSTX | +| Tick Upper | 1.05 STX per bSTX | + +The narrow ±5% band captures nearly all bSTX/STX trading activity while concentrating capital efficiently. + +### Adding Liquidity + +```clarity +(contract-call? .bitstake-bitflow-pool add-liquidity + bstx-amount ;; uint — bSTX to deposit + stx-amount ;; uint — STX to deposit + min-bstx ;; uint — slippage floor for bSTX + min-stx ;; uint — slippage floor for STX +) +;; → (ok position-id) +``` + +### Swapping STX → bSTX + +```clarity +(contract-call? .bitstake-bitflow-pool swap-stx-for-bstx + stx-in ;; uint — STX amount in + min-bstx-out ;; uint — minimum bSTX to receive +) +;; → (ok bstx-received) +``` + +### Quote + +```clarity +(contract-call? .bitstake-bitflow-pool quote-stx-for-bstx stx-in) +;; → (ok { bstx-out: uint, fee: uint, price: uint }) +``` + +--- + +## Deployment Order + +``` +1. bitstake-pool-registry (already deployed) +2. bitstake-pool-deposits (already deployed) +3. bitstake-lbstx (already deployed) +4. bitstake-bbstx (already deployed) +5. bitstake-mbstx (already deployed) +6. bitstake-rewards (already deployed) +7. bitstake-oracle-circuit-breaker ← new +8. bitstake-bstx-oracle ← new +9. bitstake-bstx-oracle-twap ← new +10. bitstake-alex-governance ← new +11. bitstake-bitflow-pool ← new +``` + +After deployment, call `set-authorized-updater` on both oracle contracts with the indexer's principal to allow automated price updates. + +--- + +## Security Considerations + +- **TWAP manipulation**: The TWAP window covers the last 20 checkpoints. Significant liquidity depth in the Bitflow pool is needed before TWAP is reliable for high-value lending. +- **Depeg risk**: If `total-stx < total-bstx-supply` (impossible in normal operation — can only happen via a bug), the oracle returns < 1:1. The circuit breaker velocity check guards against sudden depeg. +- **Liquidation cascade**: At 75% LTV, a 6.25% drop in the bSTX/STX rate triggers liquidations. Given the expected narrow price band (±5%), this risk is minimal but should be monitored. +- **Oracle freshness**: Protocols consuming `get-spot-rate` should check `get-last-observation-block` and reject stale prices (e.g., no update within 50 blocks). diff --git a/tests/bitstake-alex-governance.test.ts b/tests/bitstake-alex-governance.test.ts new file mode 100644 index 0000000..7d3a0c2 --- /dev/null +++ b/tests/bitstake-alex-governance.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from "vitest"; +import { Cl } from "@stacks/transactions"; +import { initSimnet } from "@hirosystems/clarinet-sdk"; + +const simnet = await initSimnet(); +const accounts = simnet.getAccounts(); +const deployer = accounts.get("deployer")!; +const wallet1 = accounts.get("wallet_1")!; +const wallet2 = accounts.get("wallet_2")!; + +const CONTRACT = "bitstake-alex-governance"; + +// Status constants +const STATUS_DRAFT = 0n; +const STATUS_SUBMITTED = 1n; +const STATUS_ACTIVE = 2n; +const STATUS_PASSED = 3n; +const STATUS_REJECTED = 4n; + +describe("bitstake-alex-governance", () => { + + it("seeds a default proposal at deploy (id=1)", () => { + const count = simnet.callReadOnlyFn(CONTRACT, "get-proposal-count", [], deployer); + expect(count.result).toBeOk(Cl.uint(1n)); + }); + + it("default proposal has DRAFT status", () => { + const p = simnet.callReadOnlyFn(CONTRACT, "get-proposal", [Cl.uint(1n)], deployer); + const data = (p.result as any).value.value.data; + expect(data.status.value).toBe(STATUS_DRAFT); + }); + + it("default proposal has correct LTV params", () => { + const p = simnet.callReadOnlyFn(CONTRACT, "get-proposal", [Cl.uint(1n)], deployer); + const data = (p.result as any).value.value.data; + expect(data["ltv-bps"].value).toBe(7500n); + expect(data["liquidation-threshold"].value).toBe(8000n); + expect(data["liquidation-bonus"].value).toBe(500n); + }); + + it("get-default-params returns expected values", () => { + const result = simnet.callReadOnlyFn(CONTRACT, "get-default-params", [], deployer); + const data = (result.result as any).value.data; + expect(data["ltv-bps"].value).toBe(7500n); + expect(data["liquidation-threshold"].value).toBe(8000n); + expect(data["liquidation-bonus"].value).toBe(500n); + }); + + it("creates a new proposal successfully", () => { + const result = simnet.callPublicFn( + CONTRACT, + "create-proposal", + [ + Cl.stringAscii("bSTX Collateral on ALEX v2"), + Cl.stringAscii("Updated proposal with lower LTV for safety."), + Cl.principal(deployer), + Cl.principal(deployer), + Cl.uint(7000n), // 70% LTV + Cl.uint(7800n), // 78% liquidation threshold + Cl.uint(400n), // 4% liquidation bonus + ], + deployer + ); + expect(result.result).toBeOk(Cl.uint(2n)); + }); + + it("create-proposal rejected by non-owner", () => { + const result = simnet.callPublicFn( + CONTRACT, + "create-proposal", + [ + Cl.stringAscii("Unauthorized proposal"), + Cl.stringAscii("Should be rejected"), + Cl.principal(wallet1), + Cl.principal(wallet1), + Cl.uint(6000n), + Cl.uint(7000n), + Cl.uint(300n), + ], + wallet1 + ); + expect(result.result).toBeErr(Cl.uint(500n)); // ERR-NOT-OWNER + }); + + it("create-proposal rejects invalid LTV (threshold <= LTV)", () => { + const result = simnet.callPublicFn( + CONTRACT, + "create-proposal", + [ + Cl.stringAscii("Bad params"), + Cl.stringAscii("Threshold equals LTV — invalid"), + Cl.principal(deployer), + Cl.principal(deployer), + Cl.uint(8000n), + Cl.uint(8000n), // threshold must be > ltv + Cl.uint(500n), + ], + deployer + ); + expect(result.result).toBeErr(Cl.uint(504n)); // ERR-INVALID-PARAMS + }); + + it("transitions proposal from DRAFT to SUBMITTED", () => { + simnet.callPublicFn( + CONTRACT, "transition-status", + [Cl.uint(1n), Cl.uint(Number(STATUS_SUBMITTED))], + deployer + ); + const p = simnet.callReadOnlyFn(CONTRACT, "get-proposal", [Cl.uint(1n)], deployer); + const status = (p.result as any).value.value.data.status.value; + expect(status).toBe(STATUS_SUBMITTED); + }); + + it("transitions proposal from SUBMITTED to ACTIVE", () => { + simnet.callPublicFn( + CONTRACT, "transition-status", + [Cl.uint(1n), Cl.uint(Number(STATUS_ACTIVE))], + deployer + ); + const p = simnet.callReadOnlyFn(CONTRACT, "get-proposal", [Cl.uint(1n)], deployer); + const status = (p.result as any).value.value.data.status.value; + expect(status).toBe(STATUS_ACTIVE); + }); + + it("cast-vote records a for-vote on an active proposal", () => { + simnet.callPublicFn( + CONTRACT, "cast-vote", + [Cl.uint(1n), Cl.bool(true)], + wallet1 + ); + const vote = simnet.callReadOnlyFn( + CONTRACT, "get-vote", + [Cl.uint(1n), Cl.principal(wallet1)], + deployer + ); + expect(vote.result).toBeOk(Cl.some(Cl.bool(true))); + }); + + it("cast-vote records an against-vote", () => { + simnet.callPublicFn( + CONTRACT, "cast-vote", + [Cl.uint(1n), Cl.bool(false)], + wallet2 + ); + const vote = simnet.callReadOnlyFn( + CONTRACT, "get-vote", + [Cl.uint(1n), Cl.principal(wallet2)], + deployer + ); + expect(vote.result).toBeOk(Cl.some(Cl.bool(false))); + }); + + it("cast-vote rejects duplicate vote", () => { + const result = simnet.callPublicFn( + CONTRACT, "cast-vote", + [Cl.uint(1n), Cl.bool(true)], + wallet1 // already voted + ); + expect(result.result).toBeErr(Cl.uint(503n)); // ERR-DUPLICATE + }); + + it("invalid status transition is rejected (DRAFT → ACTIVE skips SUBMITTED)", () => { + const result = simnet.callPublicFn( + CONTRACT, "transition-status", + [Cl.uint(2n), Cl.uint(Number(STATUS_ACTIVE))], // proposal 2 is still DRAFT + deployer + ); + expect(result.result).toBeErr(Cl.uint(501n)); // ERR-INVALID-TRANSITION + }); + + it("transitions ACTIVE proposal to PASSED", () => { + simnet.callPublicFn( + CONTRACT, "transition-status", + [Cl.uint(1n), Cl.uint(Number(STATUS_PASSED))], + deployer + ); + const p = simnet.callReadOnlyFn(CONTRACT, "get-proposal", [Cl.uint(1n)], deployer); + const status = (p.result as any).value.value.data.status.value; + expect(status).toBe(STATUS_PASSED); + }); +}); diff --git a/tests/bitstake-bitflow-pool.test.ts b/tests/bitstake-bitflow-pool.test.ts new file mode 100644 index 0000000..b4caa18 --- /dev/null +++ b/tests/bitstake-bitflow-pool.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; +import { Cl } from "@stacks/transactions"; +import { initSimnet } from "@hirosystems/clarinet-sdk"; + +const simnet = await initSimnet(); +const accounts = simnet.getAccounts(); +const deployer = accounts.get("deployer")!; +const wallet1 = accounts.get("wallet_1")!; +const wallet2 = accounts.get("wallet_2")!; + +const CONTRACT = "bitstake-bitflow-pool"; + +// 1000 STX in micros +const STX_1K = 1_000_000_000n; +// 1000 bSTX in micros +const BSTX_1K = 1_000_000_000n; + +describe("bitstake-bitflow-pool", () => { + + it("initial pool stats show zero reserves and no positions", () => { + const stats = simnet.callReadOnlyFn(CONTRACT, "get-pool-stats", [], deployer); + const data = (stats.result as any).value.data; + expect(data.active.value).toBe(true); + expect(data["reserve-bstx"].value).toBe(0n); + expect(data["reserve-stx"].value).toBe(0n); + expect(data["total-liquidity"].value).toBe(0n); + expect(data["position-count"].value).toBe(0n); + }); + + it("initial current price is set to 1.01 * PRECISION", () => { + const stats = simnet.callReadOnlyFn(CONTRACT, "get-pool-stats", [], deployer); + const price = (stats.result as any).value.data["current-price"].value; + expect(price).toBe(1_010_000n); + }); + + it("add-liquidity succeeds with valid amounts", () => { + const result = simnet.callPublicFn( + CONTRACT, + "add-liquidity", + [Cl.uint(BSTX_1K), Cl.uint(STX_1K), Cl.uint(BSTX_1K), Cl.uint(STX_1K)], + wallet1 + ); + expect(result.result).toBeOk(Cl.uint(1n)); + }); + + it("pool reserves updated after adding liquidity", () => { + const stats = simnet.callReadOnlyFn(CONTRACT, "get-pool-stats", [], deployer); + const data = (stats.result as any).value.data; + expect(data["reserve-stx"].value).toBe(STX_1K); + expect(data["reserve-bstx"].value).toBe(BSTX_1K); + }); + + it("position is recorded correctly after add-liquidity", () => { + const pos = simnet.callReadOnlyFn( + CONTRACT, "get-position", [Cl.uint(1n)], deployer + ); + const data = (pos.result as any).value.value.data; + expect(data.provider.value).toBe(wallet1); + expect(data["bstx-amount"].value).toBe(BSTX_1K); + expect(data["stx-amount"].value).toBe(STX_1K); + }); + + it("add-liquidity rejects zero bSTX amount", () => { + const result = simnet.callPublicFn( + CONTRACT, + "add-liquidity", + [Cl.uint(0n), Cl.uint(STX_1K), Cl.uint(0n), Cl.uint(STX_1K)], + wallet1 + ); + expect(result.result).toBeErr(Cl.uint(601n)); // ERR-ZERO-AMOUNT + }); + + it("add-liquidity rejects when slippage check fails", () => { + // Require more than we provide + const result = simnet.callPublicFn( + CONTRACT, + "add-liquidity", + [Cl.uint(BSTX_1K), Cl.uint(STX_1K), Cl.uint(BSTX_1K * 2n), Cl.uint(STX_1K)], + wallet1 + ); + expect(result.result).toBeErr(Cl.uint(605n)); // ERR-SLIPPAGE + }); + + it("add-liquidity rejected when pool is paused", () => { + simnet.callPublicFn( + CONTRACT, "set-pool-active", [Cl.bool(false)], deployer + ); + const result = simnet.callPublicFn( + CONTRACT, + "add-liquidity", + [Cl.uint(BSTX_1K), Cl.uint(STX_1K), Cl.uint(BSTX_1K), Cl.uint(STX_1K)], + wallet2 + ); + expect(result.result).toBeErr(Cl.uint(604n)); // ERR-POOL-PAUSED + simnet.callPublicFn(CONTRACT, "set-pool-active", [Cl.bool(true)], deployer); + }); + + it("remove-liquidity fails for non-owner of position", () => { + const result = simnet.callPublicFn( + CONTRACT, "remove-liquidity", [Cl.uint(1n)], wallet2 // wallet2 didn't deposit + ); + expect(result.result).toBeErr(Cl.uint(600n)); // ERR-NOT-OWNER + }); + + it("swap-stx-for-bstx succeeds with sufficient reserves", () => { + const STX_IN = 500_000_000n; // 500 STX + const result = simnet.callPublicFn( + CONTRACT, + "swap-stx-for-bstx", + [Cl.uint(STX_IN), Cl.uint(0n)], // min-bstx-out = 0 (no slippage guard in test) + wallet2 + ); + expect(result.result).toBeOk(expect.anything()); + }); + + it("quote-stx-for-bstx returns a non-zero estimate", () => { + const result = simnet.callReadOnlyFn( + CONTRACT, "quote-stx-for-bstx", [Cl.uint(1_000_000_000n)], deployer + ); + const data = (result.result as any).value.data; + expect(data["bstx-out"].value).toBeGreaterThan(0n); + expect(data.fee.value).toBeGreaterThan(0n); + }); + + it("update-price rejects price outside tick range", () => { + // tick-lower = 950_000, tick-upper = 1_050_000 + const result = simnet.callPublicFn( + CONTRACT, "update-price", [Cl.uint(1_100_000n)], deployer + ); + expect(result.result).toBeErr(Cl.uint(607n)); // ERR-INVALID-TICK + }); + + it("update-price accepts price within tick range", () => { + const result = simnet.callPublicFn( + CONTRACT, "update-price", [Cl.uint(1_020_000n)], deployer + ); + expect(result.result).toBeOk(Cl.bool(true)); + }); + + it("get-position returns none for non-existent position id", () => { + const result = simnet.callReadOnlyFn( + CONTRACT, "get-position", [Cl.uint(999n)], deployer + ); + expect(result.result).toBeOk(Cl.none()); + }); +}); diff --git a/tests/bitstake-bstx-oracle-twap.test.ts b/tests/bitstake-bstx-oracle-twap.test.ts new file mode 100644 index 0000000..74c25e5 --- /dev/null +++ b/tests/bitstake-bstx-oracle-twap.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import { Cl } from "@stacks/transactions"; +import { initSimnet } from "@hirosystems/clarinet-sdk"; + +const simnet = await initSimnet(); +const accounts = simnet.getAccounts(); +const deployer = accounts.get("deployer")!; +const wallet1 = accounts.get("wallet_1")!; + +const CONTRACT = "bitstake-bstx-oracle-twap"; + +const TOTAL_STX = 100_000_000_000n; +const TOTAL_BSTX = 100_000_000_000n; + +describe("bitstake-bstx-oracle-twap", () => { + + it("get-last-spot returns 0 before any push", () => { + const result = simnet.callReadOnlyFn(CONTRACT, "get-last-spot", [], deployer); + expect(result.result).toBeOk(Cl.uint(0n)); + }); + + it("get-total-written returns 0 before any push", () => { + const result = simnet.callReadOnlyFn(CONTRACT, "get-total-written", [], deployer); + expect(result.result).toBeOk(Cl.uint(0n)); + }); + + it("push-price accepted by owner and returns spot rate", () => { + simnet.mineEmptyBlocks(5); + const result = simnet.callPublicFn( + CONTRACT, "push-price", + [Cl.uint(TOTAL_STX), Cl.uint(TOTAL_BSTX)], + deployer + ); + expect(result.result).toBeOk(Cl.uint(1_000_000n)); + }); + + it("get-last-spot returns 1_000_000 after push", () => { + const result = simnet.callReadOnlyFn(CONTRACT, "get-last-spot", [], deployer); + expect(result.result).toBeOk(Cl.uint(1_000_000n)); + }); + + it("total-written increments after push", () => { + const result = simnet.callReadOnlyFn(CONTRACT, "get-total-written", [], deployer); + const val = (result.result as any).value.value; + expect(val).toBeGreaterThanOrEqual(1n); + }); + + it("push-price rejects non-owner", () => { + simnet.mineEmptyBlocks(5); + const result = simnet.callPublicFn( + CONTRACT, "push-price", + [Cl.uint(TOTAL_STX), Cl.uint(TOTAL_BSTX)], + wallet1 + ); + expect(result.result).toBeErr(Cl.uint(300n)); // ERR-NOT-OWNER + }); + + it("push-price rejects zero bSTX supply", () => { + simnet.mineEmptyBlocks(5); + const result = simnet.callPublicFn( + CONTRACT, "push-price", + [Cl.uint(TOTAL_STX), Cl.uint(0n)], + deployer + ); + expect(result.result).toBeErr(Cl.uint(302n)); // ERR-ZERO-SUPPLY + }); + + it("get-twap returns ok after multiple pushes", () => { + // Push several observations + for (let i = 0; i < 5; i++) { + simnet.mineEmptyBlocks(3); + simnet.callPublicFn( + CONTRACT, "push-price", + [Cl.uint(TOTAL_STX), Cl.uint(TOTAL_BSTX)], + deployer + ); + } + const twap = simnet.callReadOnlyFn(CONTRACT, "get-twap", [], deployer); + expect(twap.result).toBeOk(expect.anything()); + }); + + it("get-twap-over-window(1) returns a non-zero value", () => { + const result = simnet.callReadOnlyFn( + CONTRACT, "get-twap-over-window", [Cl.uint(1n)], deployer + ); + // Should be ok with some value + expect((result.result as any).type).toBe(7); // ResponseOk + }); + + it("get-twap-over-window with age beyond total-written returns err", () => { + const result = simnet.callReadOnlyFn( + CONTRACT, "get-twap-over-window", [Cl.uint(100n)], deployer + ); + expect(result.result).toBeErr(Cl.uint(301n)); // ERR-NO-DATA + }); + + it("get-cumulative-price grows with each push", () => { + const before = (simnet.callReadOnlyFn(CONTRACT, "get-cumulative-price", [], deployer).result as any).value.value; + simnet.mineEmptyBlocks(3); + simnet.callPublicFn( + CONTRACT, "push-price", + [Cl.uint(TOTAL_STX), Cl.uint(TOTAL_BSTX)], + deployer + ); + const after = (simnet.callReadOnlyFn(CONTRACT, "get-cumulative-price", [], deployer).result as any).value.value; + expect(after).toBeGreaterThan(before); + }); + + it("get-checkpoint slot 0 is populated after pushes", () => { + const result = simnet.callReadOnlyFn( + CONTRACT, "get-checkpoint", [Cl.uint(0n)], deployer + ); + const data = (result.result as any).value.value; + expect(data).not.toBeNull(); + }); + + it("get-write-index advances after each push", () => { + const idx = simnet.callReadOnlyFn(CONTRACT, "get-write-index", [], deployer); + const val = (idx.result as any).value.value; + expect(val).toBeGreaterThan(1n); + }); +}); diff --git a/tests/bitstake-bstx-oracle.test.ts b/tests/bitstake-bstx-oracle.test.ts new file mode 100644 index 0000000..04adcb0 --- /dev/null +++ b/tests/bitstake-bstx-oracle.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { Cl } from "@stacks/transactions"; +import { initSimnet } from "@hirosystems/clarinet-sdk"; + +const simnet = await initSimnet(); +const accounts = simnet.getAccounts(); +const deployer = accounts.get("deployer")!; +const wallet1 = accounts.get("wallet_1")!; + +const CONTRACT = "bitstake-bstx-oracle"; + +// 100_000 STX stacked, 100_000 bSTX minted → rate = 1_000_000 (1:1) +const TOTAL_STX = 100_000_000_000n; // 100,000 STX in micros +const TOTAL_BSTX = 100_000_000_000n; + +describe("bitstake-bstx-oracle", () => { + + it("compute-spot-rate returns 1_000_000 for equal totals", () => { + const result = simnet.callReadOnlyFn( + CONTRACT, + "compute-spot-rate", + [Cl.uint(TOTAL_STX), Cl.uint(TOTAL_BSTX)], + deployer + ); + expect(result.result).toBeOk(Cl.uint(1_000_000n)); + }); + + it("compute-spot-rate returns premium when STX > bSTX supply", () => { + // 110k STX / 100k bSTX → 1.10 → 1_100_000 + const result = simnet.callReadOnlyFn( + CONTRACT, + "compute-spot-rate", + [Cl.uint(110_000_000_000n), Cl.uint(100_000_000_000n)], + deployer + ); + expect(result.result).toBeOk(Cl.uint(1_100_000n)); + }); + + it("compute-spot-rate errors when bSTX supply is zero", () => { + const result = simnet.callReadOnlyFn( + CONTRACT, + "compute-spot-rate", + [Cl.uint(TOTAL_STX), Cl.uint(0n)], + deployer + ); + // ERR-ZERO-SUPPLY = err u201 + expect(result.result).toBeErr(Cl.uint(201n)); + }); + + it("record-observation succeeds on first call", () => { + // Mine 5+ blocks so MIN-OBSERVATION-GAP is satisfied + simnet.mineEmptyBlocks(10); + const result = simnet.callPublicFn( + CONTRACT, + "record-observation", + [Cl.uint(TOTAL_STX), Cl.uint(TOTAL_BSTX)], + deployer + ); + expect(result.result).toBeOk(Cl.uint(1_000_000n)); + }); + + it("get-spot-rate returns last recorded rate", () => { + const rate = simnet.callReadOnlyFn(CONTRACT, "get-spot-rate", [], deployer); + expect(rate.result).toBeOk(Cl.uint(1_000_000n)); + }); + + it("get-observation-count increments after recording", () => { + const count = simnet.callReadOnlyFn(CONTRACT, "get-observation-count", [], deployer); + // At least 1 after the previous test + const val = (count.result as any).value.value; + expect(val).toBeGreaterThanOrEqual(1n); + }); + + it("record-observation rejects when called too frequently", () => { + // Should fail — gap < MIN-OBSERVATION-GAP (5 blocks) + const result = simnet.callPublicFn( + CONTRACT, + "record-observation", + [Cl.uint(TOTAL_STX), Cl.uint(TOTAL_BSTX)], + deployer + ); + // ERR-TOO-FREQUENT = err u203 + expect(result.result).toBeErr(Cl.uint(203n)); + }); + + it("record-observation is rejected by non-owner", () => { + simnet.mineEmptyBlocks(10); + const result = simnet.callPublicFn( + CONTRACT, + "record-observation", + [Cl.uint(TOTAL_STX), Cl.uint(TOTAL_BSTX)], + wallet1 // not the owner + ); + expect(result.result).toBeErr(Cl.uint(200n)); // ERR-NOT-OWNER + }); + + it("get-twap returns ok after sufficient observations", () => { + // Record several more observations + for (let i = 0; i < 3; i++) { + simnet.mineEmptyBlocks(10); + simnet.callPublicFn( + CONTRACT, + "record-observation", + [Cl.uint(TOTAL_STX), Cl.uint(TOTAL_BSTX)], + deployer + ); + } + const twap = simnet.callReadOnlyFn(CONTRACT, "get-twap", [], deployer); + expect(twap.result).toBeOk(Cl.uint(1_000_000n)); + }); + + it("set-authorized-updater allows non-owner to record", () => { + simnet.callPublicFn( + CONTRACT, + "set-authorized-updater", + [Cl.some(Cl.principal(wallet1))], + deployer + ); + simnet.mineEmptyBlocks(10); + const result = simnet.callPublicFn( + CONTRACT, + "record-observation", + [Cl.uint(TOTAL_STX), Cl.uint(TOTAL_BSTX)], + wallet1 + ); + expect(result.result).toBeOk(Cl.uint(1_000_000n)); + }); + + it("reset-circuit-breaker can only be called by owner", () => { + const result = simnet.callPublicFn( + CONTRACT, + "reset-circuit-breaker", + [], + wallet1 // not owner + ); + expect(result.result).toBeErr(Cl.uint(200n)); + }); + + it("is-halted returns false when circuit breaker is not tripped", () => { + const result = simnet.callReadOnlyFn(CONTRACT, "is-halted", [], deployer); + expect(result.result).toBeOk(Cl.bool(false)); + }); + + it("get-rates returns combined state snapshot", () => { + const result = simnet.callReadOnlyFn(CONTRACT, "get-rates", [], deployer); + expect(result.result).toBeOk( + expect.objectContaining({}) + ); + }); +}); diff --git a/tests/bitstake-oracle-circuit-breaker.test.ts b/tests/bitstake-oracle-circuit-breaker.test.ts new file mode 100644 index 0000000..45c7999 --- /dev/null +++ b/tests/bitstake-oracle-circuit-breaker.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import { Cl } from "@stacks/transactions"; +import { initSimnet } from "@hirosystems/clarinet-sdk"; + +const simnet = await initSimnet(); +const accounts = simnet.getAccounts(); +const deployer = accounts.get("deployer")!; +const wallet1 = accounts.get("wallet_1")!; + +const CONTRACT = "bitstake-oracle-circuit-breaker"; + +describe("bitstake-oracle-circuit-breaker", () => { + + it("initial status shows breaker closed", () => { + const status = simnet.callReadOnlyFn(CONTRACT, "get-status", [], deployer); + const data = (status.result as any).value.data; + expect(data.open.value).toBe(false); + }); + + it("validate-price accepts a reasonable first price", () => { + const result = simnet.callPublicFn( + CONTRACT, + "validate-price", + [Cl.uint(1_000_000n)], + deployer + ); + expect(result.result).toBeOk(Cl.uint(1_000_000n)); + }); + + it("validate-price accepts a price within deviation threshold", () => { + // Previous price 1_000_000, new price 1_030_000 = 3% deviation (< 5%) + const result = simnet.callPublicFn( + CONTRACT, + "validate-price", + [Cl.uint(1_030_000n)], + deployer + ); + expect(result.result).toBeOk(Cl.uint(1_030_000n)); + }); + + it("validate-price rejects a price exceeding deviation threshold", () => { + // Previous accepted price ~1_030_000; jump to 1_200_000 = ~16.5% deviation > 5% + const result = simnet.callPublicFn( + CONTRACT, + "validate-price", + [Cl.uint(1_200_000n)], + deployer + ); + // Should trip breaker and return err u402 + expect(result.result).toBeErr(Cl.uint(402n)); + }); + + it("breaker is open after trip", () => { + const open = simnet.callReadOnlyFn(CONTRACT, "is-open", [], deployer); + expect(open.result).toBeOk(Cl.bool(true)); + }); + + it("validate-price rejected while breaker is open", () => { + const result = simnet.callPublicFn( + CONTRACT, + "validate-price", + [Cl.uint(1_000_000n)], + deployer + ); + expect(result.result).toBeErr(Cl.uint(401n)); // ERR-BREAKER-OPEN + }); + + it("reset-breaker closes the circuit breaker", () => { + simnet.callPublicFn(CONTRACT, "reset-breaker", [], deployer); + const open = simnet.callReadOnlyFn(CONTRACT, "is-open", [], deployer); + expect(open.result).toBeOk(Cl.bool(false)); + }); + + it("reset-breaker is rejected by non-owner", () => { + const result = simnet.callPublicFn(CONTRACT, "reset-breaker", [], wallet1); + expect(result.result).toBeErr(Cl.uint(400n)); // ERR-NOT-OWNER + }); + + it("trip-breaker can be called manually by owner", () => { + const result = simnet.callPublicFn( + CONTRACT, + "trip-breaker", + [Cl.stringAscii("manual-emergency-stop")], + deployer + ); + expect(result.result).toBeOk(Cl.bool(true)); + // Confirm it's open + const open = simnet.callReadOnlyFn(CONTRACT, "is-open", [], deployer); + expect(open.result).toBeOk(Cl.bool(true)); + // Reset for remaining tests + simnet.callPublicFn(CONTRACT, "reset-breaker", [], deployer); + }); + + it("set-deviation-threshold rejects zero value", () => { + const result = simnet.callPublicFn( + CONTRACT, + "set-deviation-threshold", + [Cl.uint(0n)], + deployer + ); + expect(result.result).toBeErr(Cl.uint(404n)); // ERR-INVALID-PARAMS + }); + + it("set-deviation-threshold rejects value above 5000 bps", () => { + const result = simnet.callPublicFn( + CONTRACT, + "set-deviation-threshold", + [Cl.uint(6000n)], + deployer + ); + expect(result.result).toBeErr(Cl.uint(404n)); + }); + + it("set-deviation-threshold accepts valid value", () => { + const result = simnet.callPublicFn( + CONTRACT, + "set-deviation-threshold", + [Cl.uint(1000n)], // 10% + deployer + ); + expect(result.result).toBeOk(Cl.bool(true)); + }); + + it("get-last-price returns last accepted price", () => { + const result = simnet.callReadOnlyFn(CONTRACT, "get-last-price", [], deployer); + // last accepted price was 1_030_000 + expect(result.result).toBeOk(Cl.uint(1_030_000n)); + }); + + it("validate-price rejected by non-owner", () => { + const result = simnet.callPublicFn( + CONTRACT, + "validate-price", + [Cl.uint(1_000_000n)], + wallet1 + ); + expect(result.result).toBeErr(Cl.uint(400n)); + }); +}); diff --git a/tests/bitstake-pool-registry.test.ts b/tests/bitstake-pool-registry.test.ts index acb6224..20f3431 100644 --- a/tests/bitstake-pool-registry.test.ts +++ b/tests/bitstake-pool-registry.test.ts @@ -125,4 +125,70 @@ describe("bitstake-pool-registry", () => { ); expect(min.result).toBeErr(Cl.uint(101)); }); + + // ── Oracle integration helpers ──────────────────────────────────── + + it("get-total-stacked returns 0 for fresh pool", () => { + const result = simnet.callReadOnlyFn( + "bitstake-pool-registry", + "get-total-stacked", + [Cl.uint(1)], + deployer + ); + expect(result.result).toBeOk(Cl.uint(0)); + }); + + it("get-total-stacked returns error for unknown pool", () => { + const result = simnet.callReadOnlyFn( + "bitstake-pool-registry", + "get-total-stacked", + [Cl.uint(99)], + deployer + ); + expect(result.result).toBeErr(Cl.uint(101)); + }); + + it("get-aggregate-stacked returns 0 when no deposits have been made", () => { + const result = simnet.callReadOnlyFn( + "bitstake-pool-registry", + "get-aggregate-stacked", + [], + deployer + ); + expect(result.result).toBeOk(Cl.uint(0)); + }); + + it("get-aggregate-stacked reflects deposits after add-to-total", () => { + // Simulate 500 STX deposit into pool 1 + simnet.callPublicFn( + "bitstake-pool-registry", + "add-to-total", + [Cl.uint(1), Cl.uint(500_000_000)], + deployer + ); + const result = simnet.callReadOnlyFn( + "bitstake-pool-registry", + "get-aggregate-stacked", + [], + deployer + ); + expect(result.result).toBeOk(Cl.uint(500_000_000)); + }); + + it("get-aggregate-stacked sums across multiple pools", () => { + simnet.callPublicFn( + "bitstake-pool-registry", + "add-to-total", + [Cl.uint(2), Cl.uint(1_000_000_000)], + deployer + ); + const result = simnet.callReadOnlyFn( + "bitstake-pool-registry", + "get-aggregate-stacked", + [], + deployer + ); + // pool1: 500 STX, pool2: 1000 STX → 1500 STX total + expect(result.result).toBeOk(Cl.uint(1_500_000_000)); + }); });