From fad67316d57493936d9d8b4c56f00396647e6608 Mon Sep 17 00:00:00 2001 From: thewealthyplace Date: Tue, 24 Feb 2026 23:06:12 +0100 Subject: [PATCH 01/17] feat(oracle): add bSTX/STX exchange rate oracle base contract Implements core oracle with spot rate calculation using (total-stx * 1_000_000) / total-bstx-supply at 6 decimal precision. Includes circular observation buffer (10 slots), halted state flag, and authorized-updater mechanism. --- contracts/bitstake-bstx-oracle.clar | 221 ++++++++++++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 contracts/bitstake-bstx-oracle.clar 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) + }) +) From 62c2ef8e8e0539a1880320a1b3e09311b963fc93 Mon Sep 17 00:00:00 2001 From: thewealthyplace Date: Tue, 24 Feb 2026 23:06:12 +0100 Subject: [PATCH 02/17] feat(oracle): add block-time-weighted TWAP accumulator contract Implements cumulative price accumulator pattern (Uniswap V2-style). TWAP = delta(cumulative_price) / delta(block_height) over any window. Supports up to 20 checkpoints in circular buffer for flexible window queries. --- contracts/bitstake-bstx-oracle-twap.clar | 154 +++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 contracts/bitstake-bstx-oracle-twap.clar 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)) +) From 0ca470fa85e449c1b22399864b042b466dad7773 Mon Sep 17 00:00:00 2001 From: thewealthyplace Date: Tue, 24 Feb 2026 23:06:12 +0100 Subject: [PATCH 03/17] feat(oracle): add standalone circuit breaker contract Implements deviation check (per-update), velocity check (rolling window), and manual admin trip/reset. Configurable thresholds (default 5% deviation, 10% velocity over 100 blocks). Emits structured print events on every trip. --- .../bitstake-oracle-circuit-breaker.clar | 212 ++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 contracts/bitstake-oracle-circuit-breaker.clar 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)) +) From 2ec6c2b171f8686e4bb743ff64f944ed18e95ac6 Mon Sep 17 00:00:00 2001 From: thewealthyplace Date: Tue, 24 Feb 2026 23:06:12 +0100 Subject: [PATCH 04/17] feat(defi): add ALEX governance proposal contract for bSTX collateral listing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records bSTX listing proposal on-chain: 75% LTV, 80% liquidation threshold, 5% liquidation bonus. Full lifecycle (DRAFT→SUBMITTED→ACTIVE→PASSED/REJECTED), signalling vote tally, and duplicate-vote prevention. Seeds default proposal at deploy. --- contracts/bitstake-alex-governance.clar | 182 ++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 contracts/bitstake-alex-governance.clar 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 + )) +) From fe84281c950989cb150dda7828571d2eb4802288 Mon Sep 17 00:00:00 2001 From: thewealthyplace Date: Tue, 24 Feb 2026 23:06:12 +0100 Subject: [PATCH 05/17] feat(defi): add bSTX/STX concentrated liquidity pool contract for Bitflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements add/remove liquidity, STX→bSTX swap with 0.05% fee tier, and price-range enforcement (0.95–1.05 tick bounds). Records provider positions, accrued fees, and pool reserves. Seeded with Bitflow-compatible pool structure. --- contracts/bitstake-bitflow-pool.clar | 248 +++++++++++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 contracts/bitstake-bitflow-pool.clar 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) }) + ) +) From 1e17db64dd72842a916d3bd8d4680238ac712f8e Mon Sep 17 00:00:00 2001 From: thewealthyplace Date: Tue, 24 Feb 2026 23:06:12 +0100 Subject: [PATCH 06/17] chore: register oracle, ALEX governance, and Bitflow pool contracts in Clarinet.toml Adds deployment entries for: - bitstake-bstx-oracle - bitstake-bstx-oracle-twap - bitstake-oracle-circuit-breaker - bitstake-alex-governance - bitstake-bitflow-pool --- Clarinet.toml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) 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]] From e3b6991582b39a070fd707edfd50c66e3aa33745 Mon Sep 17 00:00:00 2001 From: thewealthyplace Date: Tue, 24 Feb 2026 23:06:23 +0100 Subject: [PATCH 07/17] test(oracle): add comprehensive tests for bSTX oracle spot rate and TWAP Covers: correct 1:1 rate, premium calculation, zero-supply error, observation recording, frequency guard, non-owner rejection, authorized-updater delegation, TWAP after multiple observations, circuit breaker read-only state, and get-rates snapshot. --- tests/bitstake-bstx-oracle.test.ts | 150 +++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 tests/bitstake-bstx-oracle.test.ts 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({}) + ); + }); +}); From d288d2e543f06a0501cdb346266abde8017796d0 Mon Sep 17 00:00:00 2001 From: thewealthyplace Date: Tue, 24 Feb 2026 23:06:52 +0100 Subject: [PATCH 08/17] test(oracle): add circuit breaker tests covering trip, reset, and thresholds Tests: initial state, price within/outside deviation threshold, breaker open/close state, manual trip, invalid threshold params, non-owner rejections, and last-price read-only. --- tests/bitstake-oracle-circuit-breaker.test.ts | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 tests/bitstake-oracle-circuit-breaker.test.ts 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)); + }); +}); From 7abd4650a94e34cad1e9aa2e1d91dfc780bca600 Mon Sep 17 00:00:00 2001 From: thewealthyplace Date: Tue, 24 Feb 2026 23:07:26 +0100 Subject: [PATCH 09/17] test(governance): add ALEX governance proposal tests Covers: default proposal seeding, LTV params, proposal creation, non-owner rejection, invalid LTV params, full status lifecycle, voting (for/against/duplicate), and invalid transition guard. --- tests/bitstake-alex-governance.test.ts | 181 +++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 tests/bitstake-alex-governance.test.ts 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); + }); +}); From c32af145f1cb4ee5e1662befe7dad3d17e371552 Mon Sep 17 00:00:00 2001 From: thewealthyplace Date: Tue, 24 Feb 2026 23:09:00 +0100 Subject: [PATCH 10/17] test(defi): add Bitflow bSTX/STX pool tests covering liquidity and swaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers: initial stats, liquidity add/remove, reserve accounting, position recording, slippage guard, pool-paused rejection, non-owner removal, STX→bSTX swap, quote helper, tick-range price update guard. --- tests/bitstake-bitflow-pool.test.ts | 146 ++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 tests/bitstake-bitflow-pool.test.ts 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()); + }); +}); From 650e1db9f1f8b80d20e1b93a772c60dcb8a1a0c7 Mon Sep 17 00:00:00 2001 From: thewealthyplace Date: Tue, 24 Feb 2026 23:09:28 +0100 Subject: [PATCH 11/17] feat(registry): add get-total-stacked and get-aggregate-stacked read-only functions Exposes per-pool and cross-pool total stacked STX so the bSTX oracle can compute the exchange rate purely from on-chain data without off-chain inputs. --- contracts/bitstake-pool-registry.clar | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) 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 From 63bb0269b01c5cb8eab6d37ae43d34e0eabd4579 Mon Sep 17 00:00:00 2001 From: thewealthyplace Date: Tue, 24 Feb 2026 23:10:07 +0100 Subject: [PATCH 12/17] test(oracle): add TWAP accumulator tests for block-time-weighted price feed Covers: initial zero state, push-price success, non-owner rejection, zero-supply rejection, cumulative price growth, TWAP window queries, checkpoint slot population, and write-index advancement. --- tests/bitstake-bstx-oracle-twap.test.ts | 122 ++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 tests/bitstake-bstx-oracle-twap.test.ts 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); + }); +}); From 98933aff01dc2853a193ec97719e4f886e8547ba Mon Sep 17 00:00:00 2001 From: thewealthyplace Date: Tue, 24 Feb 2026 23:10:32 +0100 Subject: [PATCH 13/17] test(registry): add tests for get-total-stacked and get-aggregate-stacked Verifies oracle data-feed helpers: fresh pool returns 0, unknown pool errors, aggregate of 0 before deposits, single-pool deposit reflection, and cross-pool sum correctness. --- tests/bitstake-pool-registry.test.ts | 66 ++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) 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)); + }); }); From 45a11b93aca58bba4b56a5a0834b285068e5e69d Mon Sep 17 00:00:00 2001 From: thewealthyplace Date: Tue, 24 Feb 2026 23:11:30 +0100 Subject: [PATCH 14/17] docs: add comprehensive bSTX DeFi collateral integration guide Covers oracle interface (spot + TWAP), circuit breaker integration pattern, ALEX governance proposal steps, Bitflow pool parameters and usage, deployment order, and security considerations (TWAP manipulation, depeg risk, liquidation cascade, oracle freshness). --- docs/bstx-defi-collateral.md | 208 +++++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 docs/bstx-defi-collateral.md 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). From ebfbc2fc35b01f722c282efa96330e86d9957839 Mon Sep 17 00:00:00 2001 From: thewealthyplace Date: Tue, 24 Feb 2026 23:15:17 +0100 Subject: [PATCH 15/17] docs: add ALEX governance forum proposal for bSTX collateral listing Includes: summary, motivation, proposed LTV/liquidation parameters, oracle design rationale, risk analysis (depeg, liquidation cascade, oracle freshness, smart contract risk), and implementation checklist. --- docs/alex-governance-proposal.md | 112 +++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 docs/alex-governance-proposal.md 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 From bbd9be31920a0e8899ebff50857e3d126b3c717e Mon Sep 17 00:00:00 2001 From: thewealthyplace Date: Tue, 24 Feb 2026 23:15:55 +0100 Subject: [PATCH 16/17] docs(readme): add DeFi collateral integration section and update roadmap Documents Phase 1 DeFi integrations (ALEX, Bitflow, Velar), oracle usage, Bitflow pool interaction, and links to the full integration guide. Updates roadmap to mark oracle and DeFi contracts as complete. --- README.md | 45 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) 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 From a8d1df2d1a3cd748eb9e2b0393a3ce0b72263eef Mon Sep 17 00:00:00 2001 From: thewealthyplace Date: Tue, 24 Feb 2026 23:16:22 +0100 Subject: [PATCH 17/17] ci: update workflow to run oracle, governance, and Bitflow pool tests Renames workflow to 'Contract Tests', adds oracle-tests job that runs after clarinet-test, covering all 5 new test files: oracle spot/TWAP, circuit breaker, ALEX governance, and Bitflow pool. --- .github/workflows/pool-tests.yml | 36 +++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) 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