From f89ac09fbc6da502b2e3efb0e489cf44d0cff00d Mon Sep 17 00:00:00 2001 From: Hillaryhardy Date: Sun, 7 Jun 2026 18:56:44 +0300 Subject: [PATCH] Add hk-sbtc-real-receiver-v1: external sBTC flash-loan receiver + tooling External-developer receiver that borrows canonical sBTC from flashstack-sbtc-core, performs a real Velar sBTC -> wSTX -> sBTC round-trip (pool 70), and repays principal + fee atomically. The sBTC counterpart to the STX/Bitflow receiver. Contract (contracts/hk-sbtc-real-receiver-v1.clar): - caller gate: asserts contract-caller == flashstack-sbtc-core (err u403), closing the direct-drain path a public callback would expose - dynamic fee lookup (no hard-coded basis points) - min-out=u1 swap legs with a fail-closed repayment assert; the core's before/after reserve check is the ultimate safety gate (reserve never at risk) - owner-only rescue-sbtc / rescue-wstx; read-only estimate-repayment - absolute mainnet principals throughout; literal principals for the Velar router's trait-reference arguments Tooling (all DRY_RUN-aware with preflight checks): - deploy-hk-sbtc-receiver.mjs deploy from an external wallet - seed-hk-sbtc-receiver.mjs SIP-010 sBTC seed transfer - execute-sbtc-flash-loan.mjs flash loan with approval/reserve/seed preflight - buy-sbtc.mjs acquire the sBTC seed on Velar Docs: - minimum_seed_analysis.md seed sizing from live pool-70 reserves - hk-sbtc-receiver-deployment-plan.md ADRs, validation record, runbook - sbtc-architecture-review.md sBTC engine mapping + live-state findings Validation (off-chain): clarinet check passes against logic-equivalent stubs; caller-gate negative test returns (err u403); deploy/seed/execute/buy scripts build successfully in DRY_RUN against live mainnet reads. No mainnet transactions performed: deployment, whitelisting, and execution remain pending. --- contracts/hk-sbtc-real-receiver-v1.clar | 210 ++++++++++++++++++++++++ hk-sbtc-receiver-deployment-plan.md | 84 ++++++++++ minimum_seed_analysis.md | 117 +++++++++++++ sbtc-architecture-review.md | 168 +++++++++++++++++++ scripts/buy-sbtc.mjs | 164 ++++++++++++++++++ scripts/deploy-hk-sbtc-receiver.mjs | 143 ++++++++++++++++ scripts/execute-sbtc-flash-loan.mjs | 177 ++++++++++++++++++++ scripts/seed-hk-sbtc-receiver.mjs | 133 +++++++++++++++ 8 files changed, 1196 insertions(+) create mode 100644 contracts/hk-sbtc-real-receiver-v1.clar create mode 100644 hk-sbtc-receiver-deployment-plan.md create mode 100644 minimum_seed_analysis.md create mode 100644 sbtc-architecture-review.md create mode 100644 scripts/buy-sbtc.mjs create mode 100644 scripts/deploy-hk-sbtc-receiver.mjs create mode 100644 scripts/execute-sbtc-flash-loan.mjs create mode 100644 scripts/seed-hk-sbtc-receiver.mjs diff --git a/contracts/hk-sbtc-real-receiver-v1.clar b/contracts/hk-sbtc-real-receiver-v1.clar new file mode 100644 index 0000000..4ec211d --- /dev/null +++ b/contracts/hk-sbtc-real-receiver-v1.clar @@ -0,0 +1,210 @@ +;; HK sBTC Real Receiver v1 +;; +;; External-developer flash-loan receiver that executes a REAL DEX round-trip on +;; canonical sBTC: +;; borrow sBTC from flashstack-sbtc-core -> swap sBTC->wSTX on Velar (pool 70) -> +;; swap wSTX->sBTC back -> repay principal + fee, atomically. +;; +;; This is the sBTC mirror of the Milestone-1 receiver hk-stx-bitflow-receiver-v1. +;; Composition (per Design ADR-S1..S7): +;; - hk-stx-bitflow-receiver-v1 skeleton : contract-caller gate + absolute +;; principals + dynamic fee lookup + fail-closed repay + owner rescue + +;; read-only estimate. +;; - velar-sbtc-arb-receiver legs : the proven sBTC->wSTX->sBTC Velar +;; round-trip (pool 70). The reference exposes a PUBLIC callback with NO +;; caller gate (velar-sbtc-arb-receiver.clar:47) -- the gate below closes +;; that direct-drain vector (ADR-S2, non-negotiable). +;; +;; Deployed under an EXTERNAL wallet (not the protocol deployer), so EVERY +;; cross-contract reference is an ABSOLUTE mainnet principal. `.contract` sugar +;; would resolve to THIS deployer and break for an external deploy (M1 ADR-001). +;; The Velar router's token/share-fee-to args are trait_reference params; we pass +;; ABSOLUTE-PRINCIPAL LITERALS there, mirroring the proven velar-sbtc-arb-receiver +;; exactly (literals are what the analyzer resolves for static trait conformance). +;; +;; Objective is NOT profit. It is: external strategy execution + successful flash +;; loan + real DEX interaction + successful repayment. min-out=u1 on both legs lets +;; the swaps clear; the repayment assert and the core's own before/after reserve +;; check are the safety gates. The seed covers the ~0.6% Velar round-trip loss + fee. +;; +;; Live contracts used: +;; Core: SP20XD46NGAX05ZQZDKFYCCX49A3852BQABNP0VG5.flashstack-sbtc-core +;; sBTC token: SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token +;; Velar router: SP1Y5YSTAHZ88XYK1VPDH24GY0HPX5J4JECTMY4A1.univ2-router +;; wSTX token: SP1Y5YSTAHZ88XYK1VPDH24GY0HPX5J4JECTMY4A1.wstx +;; share-fee-to: SP1Y5YSTAHZ88XYK1VPDH24GY0HPX5J4JECTMY4A1.univ2-share-fee-to +;; Velar pool 70: SP20X3DC5R091J8B6YPQT638J8NR1W83KN6TN5BJY.univ2-pool-v1_0_0-0070 +;; (token0 = wSTX, token1 = sBTC) +;; +;; Clarity version: 3 (epoch 3.0 / Nakamoto) + +(impl-trait 'SP20XD46NGAX05ZQZDKFYCCX49A3852BQABNP0VG5.sbtc-flash-receiver-trait.sbtc-flash-receiver-trait) + +;; ============================================= +;; Constants +;; ============================================= + +(define-constant CONTRACT-OWNER tx-sender) + +;; The ONLY legitimate caller of execute-sbtc-flash. Hard-coded (Form A gate). +;; Used in the caller-gate is-eq comparison below. +(define-constant FLASHSTACK-SBTC-CORE 'SP20XD46NGAX05ZQZDKFYCCX49A3852BQABNP0VG5.flashstack-sbtc-core) + +(define-constant ERR-NOT-OWNER (err u400)) +(define-constant ERR-SWAP-LEG1 (err u401)) +(define-constant ERR-SWAP-LEG2 (err u402)) +(define-constant ERR-WRONG-CALLER (err u403)) +(define-constant ERR-FEE-LOOKUP (err u404)) +(define-constant ERR-REPAY-FAILED (err u500)) +(define-constant ERR-INSUFFICIENT (err u501)) + +;; Slippage tolerance in basis points (default 300 = 3%). Retained as an ops knob; +;; the live legs use min-out=u1 and rely on the repayment assert as the safety gate. +(define-data-var slippage-bp uint u300) + +;; ============================================= +;; Flash Loan Callback +;; ============================================= + +(define-public (execute-sbtc-flash (amount uint) (core principal)) + (begin + ;; Gate: only the live flashstack-sbtc-core may invoke this callback. + ;; Closes the direct-drain path a public execute-sbtc-flash would otherwise + ;; expose (the velar-sbtc-arb-receiver reference lacks this). + (asserts! (is-eq contract-caller FLASHSTACK-SBTC-CORE) ERR-WRONG-CALLER) + (let ( + ;; Repayment math - look up the fee dynamically (never hard-code u5). + (fee-bp (unwrap! (contract-call? 'SP20XD46NGAX05ZQZDKFYCCX49A3852BQABNP0VG5.flashstack-sbtc-core + get-fee-basis-points) ERR-FEE-LOOKUP)) + (raw-fee (/ (* amount fee-bp) u10000)) + (fee (if (> raw-fee u0) raw-fee u1)) + (total-owed (+ amount fee)) + ) + ;; Leg 1: sBTC -> wSTX on Velar pool 70 (token0=wSTX, token1=sBTC). + ;; as-contract: the borrowed sBTC sits in THIS contract's balance. + (unwrap! (as-contract (contract-call? 'SP1Y5YSTAHZ88XYK1VPDH24GY0HPX5J4JECTMY4A1.univ2-router + swap-exact-tokens-for-tokens + u70 ;; pool id + 'SP1Y5YSTAHZ88XYK1VPDH24GY0HPX5J4JECTMY4A1.wstx ;; token0 (wSTX) + 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token ;; token1 (sBTC) + 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token ;; token-in (sBTC) + 'SP1Y5YSTAHZ88XYK1VPDH24GY0HPX5J4JECTMY4A1.wstx ;; token-out (wSTX) + 'SP1Y5YSTAHZ88XYK1VPDH24GY0HPX5J4JECTMY4A1.univ2-share-fee-to ;; share-fee-to + amount ;; amt-in (sBTC sats) + u1 ;; amt-out-min (accept any) + )) ERR-SWAP-LEG1) + + ;; How much wSTX did we receive? Swap the entire wSTX balance back. + (let ( + (wstx-balance (unwrap! + (as-contract (contract-call? 'SP1Y5YSTAHZ88XYK1VPDH24GY0HPX5J4JECTMY4A1.wstx + get-balance tx-sender)) + ERR-SWAP-LEG2)) + ) + (asserts! (> wstx-balance u0) ERR-SWAP-LEG2) + + ;; Leg 2: wSTX -> sBTC on Velar pool 70. + (unwrap! (as-contract (contract-call? 'SP1Y5YSTAHZ88XYK1VPDH24GY0HPX5J4JECTMY4A1.univ2-router + swap-exact-tokens-for-tokens + u70 ;; pool id + 'SP1Y5YSTAHZ88XYK1VPDH24GY0HPX5J4JECTMY4A1.wstx ;; token0 (wSTX) + 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token ;; token1 (sBTC) + 'SP1Y5YSTAHZ88XYK1VPDH24GY0HPX5J4JECTMY4A1.wstx ;; token-in (wSTX) + 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token ;; token-out (sBTC) + 'SP1Y5YSTAHZ88XYK1VPDH24GY0HPX5J4JECTMY4A1.univ2-share-fee-to ;; share-fee-to + wstx-balance ;; amt-in (all wSTX) + u1 ;; amt-out-min + )) ERR-SWAP-LEG2) + + ;; Repay sBTC + fee back to the core. Fail closed if the round-trip + seed + ;; came up short (no funds at risk - the whole tx reverts). + (let ((sbtc-now (unwrap! + (as-contract (contract-call? 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token + get-balance tx-sender)) + ERR-REPAY-FAILED))) + (asserts! (>= sbtc-now total-owed) ERR-INSUFFICIENT) + (unwrap! + (as-contract (contract-call? 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token + transfer total-owed tx-sender core none)) + ERR-REPAY-FAILED) + (print { event: "sbtc-velar-roundtrip", amount: amount, fee: fee, + wstx-mid: wstx-balance, sbtc-after: sbtc-now }) + (ok true) + ) + ) + ) + ) +) + +;; ============================================= +;; Admin +;; ============================================= + +(define-public (set-slippage-bp (new-bp uint)) + (begin + (asserts! (is-eq tx-sender CONTRACT-OWNER) ERR-NOT-OWNER) + (asserts! (<= new-bp u500) ERR-NOT-OWNER) ;; max 5% + (ok (var-set slippage-bp new-bp)) + ) +) + +;; Rescue stuck sBTC (owner only) - escape hatch to recover the seed, or any +;; sBTC stranded by a partial round-trip. +(define-public (rescue-sbtc (amount uint) (to principal)) + (begin + (asserts! (is-eq tx-sender CONTRACT-OWNER) ERR-NOT-OWNER) + (unwrap! + (as-contract (contract-call? 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token + transfer amount tx-sender to none)) + ERR-NOT-OWNER) + (ok true) + ) +) + +;; Rescue stuck wSTX (owner only) - escape hatch if leg 2 ever fails to consume +;; all wSTX (should not happen with min-out=u1, but fail-safe recovery). +(define-public (rescue-wstx (amount uint) (to principal)) + (begin + (asserts! (is-eq tx-sender CONTRACT-OWNER) ERR-NOT-OWNER) + (unwrap! + (as-contract (contract-call? 'SP1Y5YSTAHZ88XYK1VPDH24GY0HPX5J4JECTMY4A1.wstx + transfer amount tx-sender to none)) + ERR-NOT-OWNER) + (ok true) + ) +) + +;; ============================================= +;; Read-only +;; ============================================= + +(define-read-only (get-slippage-bp) + (ok (var-get slippage-bp)) +) + +(define-read-only (get-owner) + (ok CONTRACT-OWNER) +) + +(define-read-only (get-sbtc-balance) + (contract-call? 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token get-balance (as-contract tx-sender)) +) + +(define-read-only (get-wstx-balance) + (contract-call? 'SP1Y5YSTAHZ88XYK1VPDH24GY0HPX5J4JECTMY4A1.wstx get-balance (as-contract tx-sender)) +) + +(define-read-only (estimate-repayment (amount uint)) + (let ( + (fee-bp (unwrap-panic (contract-call? 'SP20XD46NGAX05ZQZDKFYCCX49A3852BQABNP0VG5.flashstack-sbtc-core get-fee-basis-points))) + (raw-fee (/ (* amount fee-bp) u10000)) + (fee (if (> raw-fee u0) raw-fee u1)) + ) + (ok { + loan-amount: amount, + fee-to-pay: fee, + total-owed: (+ amount fee), + note: "sBTC round-trip must return >= total-owed or the tx reverts. Seed covers the ~0.6% Velar round-trip loss + fee." + }) + ) +) diff --git a/hk-sbtc-receiver-deployment-plan.md b/hk-sbtc-receiver-deployment-plan.md new file mode 100644 index 0000000..2542fd5 --- /dev/null +++ b/hk-sbtc-receiver-deployment-plan.md @@ -0,0 +1,84 @@ +# hk-sbtc-real-receiver-v1 — Deployment Plan, ADRs & Validation Record + +**Author:** External integrator (HK) +**Date:** 2026-06-06 +**Milestone:** 2 (external sBTC receiver on mainnet) +**Status:** Built & validated off-chain. **Mainnet deploy/execute is GATED on:** (1) acquiring the sBTC seed, (2) Matt whitelisting the receiver. No mainnet transactions broadcast yet. +**Companion docs:** `minimum_seed_analysis.md` · `sbtc-architecture-review.md` + +--- + +## 1. What this is + +`hk-sbtc-real-receiver-v1.clar` is the canonical-sBTC mirror of the Milestone-1 receiver. Inside one `flashstack-sbtc-core` flash loan it borrows sBTC, runs a **real Velar sBTC→wSTX→sBTC round-trip** (pool 70), and repays principal + fee atomically. Objective is **external strategy execution + repayment, not profit** — the same bar M1 met. + +Architecture = **M1 skeleton** (caller gate, dynamic fee, fail-closed repay, owner rescue, read-only estimate) **⊕ `velar-sbtc-arb-receiver` legs** (the proven on-chain sBTC round-trip). Both halves are already deployed-and-proven on mainnet; this contract composes them and adds the caller gate the velar reference lacks. + +--- + +## 2. Architectural Decision Records + +| ADR | Decision | Rationale | +|---|---|---| +| **ADR-S1** | Reuse the M1 receiver skeleton; swap STX primitives → sBTC SIP-010 calls | M1 is proven on mainnet; minimizes new surface | +| **ADR-S2** | Add a **caller gate** (`contract-caller == flashstack-sbtc-core` → `ERR-WRONG-CALLER u403`) | `velar-sbtc-arb-receiver` exposes a **public** `execute-sbtc-flash` with no gate — a direct-drain vector. Non-negotiable. **Runtime-tested: a non-core caller returns `(err u403)`** (simnet). | +| **ADR-S3** | Venue = **Velar univ2 pool 70** (wSTX/sBTC) | Only proven, whitelisted on-chain sBTC round-trip in-repo. Live reserves: 223,275 wSTX / 65,845,796 sats. Accept ~0.6% round-trip cost; objective is execution, not profit. | +| **ADR-S4** | `min-out = u1` on both legs; repay-assert + core reserve check are the gates | Same as M1; avoids percentage-floor reverts. No funds at risk — under-repay reverts the whole tx. | +| **ADR-S5** | **Absolute** mainnet principals everywhere | External deploy; `.sugar` would resolve to our wallet (M1 ADR-001) | +| **ADR-S6** | Seed the receiver with real canonical sBTC, sized to round-trip loss + fee + margin; recover via `rescue-sbtc` | Receiver pays fee + slippage from its own balance. See `minimum_seed_analysis.md`: 1,000-sat loan → ~7-sat min seed, **seed 500 sats (safe)**. | +| **ADR-S7** | Size the loan to the live reserve (15,010 sats) | Guard `reserve ≥ amount`. Matt: 1,000-sat demo is sufficient; no top-up wait. | +| **ADR-S8** (new, implementation) | Pass **absolute-principal LITERALS** to the Velar router's `trait_reference` args (token0/1/in/out, share-fee-to), not named constants | The router's token/share-fee-to params are `trait_reference`. The clarity analyzer resolves a **literal** contract principal for static trait conformance; a named constant in that position fails to resolve (`use of unresolved contract`). Mirrors `velar-sbtc-arb-receiver` exactly. Constants are still fine for the call *target* and for non-trait positions, but we use literals throughout the swap calls for one consistent, proven pattern. | + +--- + +## 3. Validation record (off-chain, no broadcast) + +| Check | Tool | Result | +|---|---|---| +| Static type-check | `clarinet check` against logic-equivalent stubs (sBTC/Velar live suite won't assemble into simnet — same gap M1 hit with Bitflow) | **✔ 9 contracts checked**, 0 errors, 0 warnings on the receiver | +| Caller-gate negative test | `clarinet console` (simnet) | non-core caller of `execute-sbtc-flash` → **`(err u403)`** | +| Router signature conformance | live Hiro interface read | `swap-exact-tokens-for-tokens(id, token0, token1, token-in, token-out, share-fee-to, amt-in, amt-out-min)` — args match exactly | +| Deploy tx build | `deploy-hk-sbtc-receiver.mjs DRY_RUN=1` | tx built OK (19,264-byte body), Clarity 3, balance/nonce confirmed | +| Execute preflight + tx build | `execute-sbtc-flash-loan.mjs DRY_RUN=1` | reserve→15,010, is-approved→false, seed→0 correctly read; warnings fire; tx built OK | +| Buy-seed quote + tx build | `buy-sbtc.mjs DRY_RUN=1` | 3.5 STX → ~1,029 sats quoted (matches seed analysis); swap tx built OK | +| Parallel-to-deployed-reference | manual diff | swap legs identical in shape to the live `velar-sbtc-arb-receiver`; skeleton identical to the live M1 receiver | + +--- + +## 4. Deliverables (this milestone) + +- `contracts/hk-sbtc-real-receiver-v1.clar` — the receiver. +- `scripts/deploy-hk-sbtc-receiver.mjs` — deploy (DRY_RUN, balance/nonce/fee checks). +- `scripts/buy-sbtc.mjs` — acquire sBTC seed via Velar (DRY_RUN, live quote). +- `scripts/seed-hk-sbtc-receiver.mjs` — SIP-010 sBTC seed transfer (DRY_RUN, balance guard). +- `scripts/execute-sbtc-flash-loan.mjs` — execute flash loan (DRY_RUN, full preflight: approved/reserve/seed/estimate). +- `minimum_seed_analysis.md` — economic validation (aggressive/recommended/safe seed tiers). +- `sbtc-architecture-review.md` — updated with live state + implementation findings. +- This deployment plan + ADRs. + +--- + +## 5. Mainnet runbook (execute only when ready) + +All scripts read the 24-word mnemonic from `./mbegu2` then `./mbegu` (gitignored live key) or `MAINNET_MNEMONIC`. Run each with `DRY_RUN=1` first. + +1. **Acquire sBTC seed:** `node scripts/buy-sbtc.mjs` (default 3.5 STX → ~1,000 sats). Verify wallet sBTC balance. +2. **Deploy:** `node scripts/deploy-hk-sbtc-receiver.mjs` (~0.5 STX). Note the contract id. +3. **Whitelist (Matt):** ask Matt to call `add-approved-receiver` on `flashstack-sbtc-core` for `SP3NZYZA88ENNF0FCR57KBGPFY5RAXWHXXVSB6FBW.hk-sbtc-real-receiver-v1`. Verify `is-approved-receiver → (ok true)`. +4. **Seed:** `SEED_SATS=500 node scripts/seed-hk-sbtc-receiver.mjs`. Verify receiver sBTC balance = 500. +5. **Execute:** `AMOUNT_SATS=1000 node scripts/execute-sbtc-flash-loan.mjs`. Expect `(ok true)` + a `sbtc-velar-roundtrip` print event + reserve `+fee`. +6. **Collect evidence:** txid, print event, Velar swap events, reserve delta, `total-loans` 2→3. +7. **Recover seed:** call `rescue-sbtc(residual, SP3NZYZA…)` to return the ~493-sat residual; optionally sell sBTC back to STX. + +--- + +## 6. Risk posture + +- **Reserve at risk: none.** Atomic; any under-repayment reverts the entire tx (core before/after check + our repay-assert). +- **Worst realistic failure:** a failed tx (wasted gas) if the pool drifts and seed margin is too thin — mitigated by seeding the **safe** tier (500 sats). +- **External deps:** acquire sBTC (we control), Matt whitelist (same as M1), Velar pool 70 liquidity (deep), optional reserve top-up (not needed for 1,000-sat demo). +- **Known core bug (`set-fee-basis-points`: wrong error path + no lower bound):** documented in `sbtc-architecture-review.md`; Matt acknowledged; **not a blocker** — our dynamic fee lookup tolerates any valid fee. + +--- + +*Built and validated 2026-06-06 against live mainnet reads and clarinet simnet. No mainnet transactions broadcast. Definition of done (M2): deployed → whitelisted → seeded → `flash-loan` `(ok true)` with evidence → seed recovered.* diff --git a/minimum_seed_analysis.md b/minimum_seed_analysis.md new file mode 100644 index 0000000..b7c0afd --- /dev/null +++ b/minimum_seed_analysis.md @@ -0,0 +1,117 @@ +# minimum_seed_analysis.md — sBTC Receiver Seed Sizing + +**Author:** External integrator (HK) +**Date:** 2026-06-06 +**Milestone:** 2 (`hk-sbtc-real-receiver-v1`) +**Method:** Live mainnet reads (`flashstack-sbtc-core`, Velar pool 70) + integer univ2 math mirroring on-chain execution. **No guessing** — every constant below is read from chain. +**Question answered:** *Exactly how much real sBTC must the receiver hold before its first flash loan?* + +--- + +## 1. TL;DR + +| Demo loan | Min seed (aggressive) | Recommended | Safe | +|---|---|---|---| +| **1,000 sats** (Matt's target) | **7 sats** | **~50 sats** | **~500 sats** | +| 5,000 sats | 32 sats | ~100 sats | ~500 sats | +| 15,000 sats (reserve max) | 97 sats | ~300 sats | ~1,000 sats | + +**Plan of record:** acquire ~1,000–2,000 sats of canonical sBTC, **seed the receiver with ~500 sats**, execute a **1,000-sat** demo loan. The seed is recoverable via owner `rescue-sbtc`; only ~7 sats is actually consumed. + +--- + +## 2. The repayment requirement (what the seed must cover) + +Inside `execute-sbtc-flash`, the receiver must return `owed = amount + fee` to the core, or the whole tx reverts (`ERR-REPAY-FAILED`, and the core's own before/after reserve check). It swaps the borrowed sBTC through Velar and gets back **less** than it borrowed (DEX fees). The seed makes up the difference. + +``` +receiver pre-loan balance = S (the seed, sBTC) +core transfers borrowed amount + L -> holds S + L +swap L: sBTC -> wSTX -> sBTC -> returns R (R < L) -> holds S + R +must repay owed = L + fee + +solvency: S + R >= L + fee +=> S >= (L - R) + fee = roundtrip_loss + fee +``` + +So **minimum seed = round-trip loss + protocol fee.** Nothing else touches the seed (the borrowed `L` is what gets swapped; the seed sits as sBTC and only tops up repayment). + +--- + +## 3. Inputs (read from chain, 2026-06-06) + +### 3.1 FlashStack fee — `flashstack-sbtc-core` +- `fee-basis-points = 5` (0.05%). +- On-chain math: `raw = amount*5/10000` (integer); `fee = max(raw, 1)` (1-sat floor). +- Consequence: for any loan **< 2,000 sats**, `raw = 0` → **fee = 1 sat** (the floor dominates). + +| Loan | raw = L*5/10000 | fee | +|---|---|---| +| 1,000 | 0 | **1** | +| 2,000 | 1 | 1 | +| 5,000 | 2 | 2 | +| 10,000 | 5 | 5 | +| 15,000 | 7 | 7 | + +### 3.2 Velar route loss — pool 70 (`univ2-pool-v1_0_0-0070`) +- Reserves: `reserve0 (wSTX) = 223,275,450,774 µSTX`, `reserve1 (sBTC) = 65,845,796 sats`. +- Swap fee: **0.3%/leg** (`get-fees` → `swap-fee = 9970/10000` keep ratio). Protocol takes 25% *of* that fee — irrelevant to the swapper's cost. +- univ2 out (integer, as on-chain): `out = (in*9970*reserveOut) / (reserveIn*10000 + in*9970)`. +- Round-trip = sBTC→wSTX (leg 1) then wSTX→sBTC (leg 2), with reserves shifted by leg 1. + +Because a ≤15k-sat loan is **<0.025%** of the 65.8M-sat reserve, **price impact is negligible** and the loss is essentially the two 0.3% fees → **~0.6% (60 bps) round-trip**, confirmed by exact integer simulation: + +| Loan (sats) | wSTX mid (µSTX) | sBTC back | round-trip loss | loss (bps) | +|---|---|---|---|---| +| 1,000 | 3,380,660 | 994 | **6** | 60.0 | +| 2,000 | 6,761,217 | 1,988 | 12 | 60.0 | +| 5,000 | 16,902,276 | 4,970 | 30 | 60.0 | +| 10,000 | 33,801,994 | 9,940 | 60 | 60.0 | +| 15,000 | 50,699,154 | 14,910 | 90 | 60.0 | + +### 3.3 Slippage assumption +`min-out = u1` on both legs (ADR-S4) — the swap **always clears**; the repayment assert + the core's reserve check are the real safety gates. There is no percentage-floor revert risk. The only "slippage" that matters economically is the deterministic 0.6% fee loss above, plus any pool move between quote and execution (covered by the recommended/safe margin). + +--- + +## 4. Minimum seed by loan size + +`min_seed = roundtrip_loss + fee`: + +| Demo loan | round-trip loss | core fee | **aggressive min** | recommended (≈3–7×) | safe (margin for pool drift) | +|---|---|---|---|---|---| +| **1,000** | 6 | 1 | **7** | ~50 | ~500 | +| 2,000 | 12 | 1 | 13 | ~50 | ~500 | +| 5,000 | 30 | 2 | 32 | ~100 | ~500 | +| 10,000 | 60 | 5 | 65 | ~200 | ~1,000 | +| 15,000 | 90 | 7 | 97 | ~300 | ~1,000 | + +**Why three tiers** +- **Aggressive** = exact solvency at current reserves. Leaves ~0 margin; a single block of pool movement against us could flip `S + R < owed` and revert (no loss of funds, just a failed tx — wasted gas). +- **Recommended** = a few × the loss, absorbing realistic pool drift between the pre-flight quote and the execution block. This is what to actually seed. +- **Safe** = a clean round number (~500 sats) that survives even a large adverse pool move and lets us re-run / bump the loan without re-seeding. Cost is trivial and recoverable. + +--- + +## 5. Acquisition & total cost + +- 1 sat ≈ `223,275,450,774 / 65,845,796` ≈ **3,390 µSTX (~0.00339 STX)** at pool-70 mid. +- Seeding **500 sats** costs ≈ **1.7 STX** of sBTC bought on Velar (+0.3% buy fee). Seeding 1,000 sats ≈ 3.4 STX. +- We hold **36.7 STX** → ample. +- **Net economic cost of the whole demo** ≈ round-trip loss (~6 sats) + 1-sat fee + buy/sell swap fees ≈ **a few thousand µSTX (< $0.10-equivalent)**. The seed principal is recovered via `rescue-sbtc` and can be sold back to STX. +- Deploy ~0.5 STX + tx fees ~0.3 STX (STX-denominated), as M1. + +--- + +## 6. Recommendation + +1. **Buy ~1,000–2,000 sats** of canonical sBTC on Velar pool 70 with STX (helper: `buy-sbtc.mjs`, `DRY_RUN` first). Slight over-buy gives headroom to bump the demo loan if desired. +2. **Seed the receiver with 500 sats** (safe tier for a 1,000-sat loan — survives pool drift, recoverable). +3. **Execute a 1,000-sat flash loan** (Matt's stated sufficient size; well under the 15,010-sat reserve ceiling). +4. **Recover** the residual seed (~493 sats) via `rescue-sbtc` after success. + +This is the minimum-risk path that still proves the full external sBTC execution + repayment, mirroring Milestone 1. + +--- + +*All figures derived from live mainnet reads on 2026-06-06 and integer univ2 simulation matching on-chain arithmetic. Re-run the pre-flight quote immediately before execution — pool reserves drift, and the aggressive tier has no margin.* diff --git a/sbtc-architecture-review.md b/sbtc-architecture-review.md new file mode 100644 index 0000000..d277c8c --- /dev/null +++ b/sbtc-architecture-review.md @@ -0,0 +1,168 @@ +# FlashStack — sBTC Architecture Review + +**Author:** External integrator (HK) +**Date:** 2026-06-06 +**Purpose:** Map the entire canonical-sBTC flash-loan path before designing `hk-sbtc-real-receiver-v1` (Matt request #2). Precursor research only — **no implementation**. +**Method:** Verified against `contracts/flashstack-sbtc-core.clar`, `flashstack-sbtc-pool.clar`, `sbtc-flash-receiver-trait.clar`, `sbtc-test-receiver.clar`, `velar-sbtc-arb-receiver.clar`, and live mainnet reads (2026-06-06). + +--- + +## 1. Executive summary + +The sBTC flash-loan path is **structurally identical** to the STX path — same "borrow → callback → repay → reserve-grew-by-fee-or-revert" invariant — but every STX primitive (`stx-transfer?`, `stx-get-balance`) is replaced by a **SIP-010 cross-contract call** against the canonical sBTC token (`SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token`). The practical consequences for an external receiver are: (1) the receiver repays with `contract-call? sbtc-token transfer …` instead of `stx-transfer?`; (2) the receiver must be seeded with **real canonical sBTC** (a genuine external dependency we did not have in the STX milestone); and (3) the **live reserve is only ~15,010 sats**, which caps loan size far below the advertised 0.1 BTC max. + +A near-complete reference already exists on mainnet: `velar-sbtc-arb-receiver` performs a real sBTC→wSTX→sBTC round-trip on Velar. It is missing the **caller gate** that our `hk-stx-real-receiver-v2` introduced, so the recommended design is `velar round-trip ⊕ v2 caller gate` — a direct mirror of the Milestone 1 composition. + +--- + +## 2. Live on-chain state (verified 2026-06-06) + +| Contract | Deployed? | Notes | +|---|---|---| +| `SP20XD46….flashstack-sbtc-core` | ✅ | reserve **15,010 sats**, `total-loans u2`, `total-volume u20000`, fee **5 bps**, max-loan **10,000,000 sats (0.1 BTC)**, paused **false** | +| `SP20XD46….flashstack-sbtc-pool` | ✅ | LP pool; also exposes a flash-loan + collateral oracle | +| `SP20XD46….sbtc-flash-receiver-trait` | ✅ | the interface | +| `SP20XD46….sbtc-test-receiver` | ✅ | whitelisted | +| `SP20XD46….velar-sbtc-arb-receiver` | ✅ | whitelisted | +| `SM3VDXK3…sbtc-token` (canonical sBTC) | ✅ | real mainnet token | + +Reference evidence tx: `0x67f0c77d…f9baa` → `success / (ok true)`, block 7875468. + +> **Note:** this directly **contradicts `docs/TESTING_GUIDE_STX.md:16-18`**, which labels the sBTC path "legacy — do not test." The live `flashstack-sbtc-core` under `SP20XD46…` is active. (Logged in [[docs-feedback-report]] as E5.) + +--- + +## 3. Contract interfaces + +### 3.1 `sbtc-flash-receiver-trait` (`sbtc-flash-receiver-trait.clar`) +```clarity +(define-trait sbtc-flash-receiver-trait + ((execute-sbtc-flash (uint principal) (response bool uint)))) +``` +One function. Deployed under the **protocol deployer** `SP20XD46…` (contrast: the STX trait is under the *legacy* `SP3TGRVG7…`). An sBTC receiver declares `(impl-trait 'SP20XD46….sbtc-flash-receiver-trait.sbtc-flash-receiver-trait)`. + +### 3.2 `flashstack-sbtc-core` (`flashstack-sbtc-core.clar`) +- **Error codes** (`:24-31`): `u300 PAUSED`, `u301 ZERO-AMOUNT`, `u302 REPAY-FAILED`, `u303 INSUFFICIENT-RESERVE`, `u304 EXCEEDS-LIMIT`, `u306 NOT-APPROVED`, `u310 NOT-ADMIN`, `u311 TRANSFER-FAILED`. (Note `u300`/`u310` differ from the STX core, where `u300 = NOT-ADMIN`, `u305 = PAUSED`.) +- **State** (`:37-46`): `admin`, `paused`, `fee-basis-points u5`, `max-single-loan u10000000`, `total-loans`, `total-volume`, `total-fees-collected`, `approved-receivers` map. +- **`flash-loan(amount, receiver)`** (`:52-91`): + 1. `reserve-before` = `sbtc-token.get-balance(self)` (cross-contract, `unwrap!` — `:60`). + 2. Guards: not paused, amount>0, ≤ max, approved, reserve≥amount (`:64-68`). + 3. `as-contract (sbtc-token.transfer amount self → receiver none)` (`:71-74`). + 4. `try! (receiver.execute-sbtc-flash amount self)` (`:77`). + 5. `reserve-after` ≥ `reserve-before + fee` else `ERR-REPAY-FAILED` (`:80-83`). + 6. Stats; **`total-fees-collected += (reserve-after − reserve-before)`** — the *actual* surplus, not the nominal fee (`:87`). (STX core counts the nominal `fee`.) +- **Admin:** `deposit-reserve(amount)` (`:97`), `withdraw-reserve(amount, to)` (`:108` — **2 args**, vs STX's 1), `add/remove-approved-receiver`, `set-paused`, `set-fee-basis-points`, `set-max-single-loan`, `set-admin` (single-step, `:155`). +- **Read-only:** `get-reserve-balance` (returns the token's `(ok uint)`), `get-fee-basis-points`, `get-max-single-loan`, `get-admin`, `get-stats` (**4 fields only**: total-loans/total-volume/total-fees-collected/paused — `:182-189`), `is-approved-receiver` (**`(ok bool)`** — `:191-193`), `calculate-fee`. + +> **Latent contract bug (flag to Matt, not our blocker):** `set-fee-basis-points` asserts `(<= new-fee u100)` but returns `ERR-NOT-ADMIN` on violation (`:143`) — wrong error constant — and has **no lower bound**, so the fee could be set to `0`. The STX core uses `(and (>= u1) (<= u1000)) ERR-INVALID-FEE`, and — notably — `flashstack-sbtc-pool.clar:177` gets it *right* (`(>= u1)(<= u100) ERR-INVALID-FEE`). The sibling contracts disagree. + +### 3.3 `flashstack-sbtc-pool` (`flashstack-sbtc-pool.clar`) +A **separate** LP-funded contract (error base `u700`) that *also* offers flash loans (`:113-154`) against an LP-deposited sBTC reserve, plus an LP share system and a **collateral oracle** (`get-share-price`, `get-lp-value`, `get-collateral-snapshot` — sats/share scaled by `1e8`). So there are **two** sBTC flash-loan sources — the admin-reserve `core` and the LP-reserve `pool` — each with its own `approved-receivers` whitelist. For an external receiver, the `core` is the documented target. + +--- + +## 4. Flash-loan lifecycle (sBTC) + +``` +caller → flashstack-sbtc-core.flash-loan(amount, receiver) + reserve-before = sbtc-token.get-balance(core) + guards: not paused · amount>0 · amount ≤ max · approved · reserve ≥ amount + as-contract sbtc-token.transfer amount: core → receiver + → receiver.execute-sbtc-flash(amount, core) [STRATEGY] + (amount sBTC sats now in receiver's token balance) + ... DEX legs / liquidation ... + as-contract sbtc-token.transfer (amount+fee): receiver → core + assert sbtc-token.get-balance(core) ≥ reserve-before + fee → (ok true) | revert +``` + +Identical control flow to STX; the only substitutions are the **asset transfer mechanism** (SIP-010 `transfer` with a trailing `none` memo, wrapped in `as-contract`) and the **balance read** (cross-contract `get-balance`, which returns a `response` and must be `unwrap!`-ed). + +--- + +## 5. Comparative analysis — STX core vs sBTC core + +### Similarities (reusable as-is) +- The reserve-invariant security model (repayment verified by before/after balance; reserve never at risk; worst case = atomic revert). +- The whitelist gate (`approved-receivers`, admin-only `add-approved-receiver`). +- Dynamic fee = `max(amount·bp/10000, 1)`; default 5 bps. +- The receiver pays the fee from its **own balance** → **seed-before-loan applies equally** (the sBTC seed is real sBTC). +- The callback shape `(uint principal) → (response bool uint)`. + +### Differences (must change for sBTC) +| Aspect | STX core | sBTC core | +|---|---|---| +| Asset primitive | native `stx-transfer?` / `stx-get-balance` | SIP-010 `contract-call? sbtc-token transfer/get-balance` | +| Balance read | cheap, infallible | cross-contract, returns `response` → `unwrap!` | +| Repay call | `(as-contract (stx-transfer? owed tx-sender core))` | `(as-contract (contract-call? SBTC transfer owed tx-sender core none))` | +| Receiver seed asset | STX (we already held it) | **real canonical sBTC** (must acquire) | +| Trait deployer | `SP3TGRVG7…` (legacy) | `SP20XD46…` (protocol) | +| Callback fn | `execute-stx-flash` | `execute-sbtc-flash` | +| Error base | `u300 = NOT-ADMIN`, `u305 = PAUSED` | `u300 = PAUSED`, `u310 = NOT-ADMIN` | +| `get-stats` | 7 fields | 4 fields | +| `is-approved-receiver` | bare `bool` | `(ok bool)` | +| `withdraw-reserve` | `(amount)` | `(amount, to)` | +| Admin transfer | 2-step (`transfer-admin`+`accept-admin`) | 1-step (`set-admin`) | +| Fee accounting | nominal `fee` | actual delta `reserve-after − reserve-before` | +| Default max-loan | 500,000 STX | 0.1 BTC (10,000,000 sats) | +| **Live reserve** | ~75 STX | **~15,010 sats** | + +### Reusable components for `hk-sbtc-real-receiver-v1` +- **Our `hk-stx-bitflow-receiver-v1` skeleton**: caller gate (`contract-caller == core` → `ERR-WRONG-CALLER`), dynamic fee lookup, two DEX legs, balance assert, fail-closed repay, owner-only `rescue` + `set-slippage`, read-only `estimate-repayment`. Swap STX primitives → sBTC SIP-010 calls. +- **`velar-sbtc-arb-receiver`** is a near-complete two-leg sBTC round-trip (sBTC→wSTX→sBTC on Velar pool 70) — but its `execute-sbtc-flash` is **public with no caller gate** (`velar-sbtc-arb-receiver.clar:47`). Reusing its swap legs while adding the v2 caller gate is the recommended path. + +### New implementation requirements (don't exist in the STX build) +1. **Acquire real canonical sBTC** for the receiver seed — the dominant new dependency (see §6). +2. Import a SIP-010 trait (for `sbtc-token` transfer/get-balance/get-balance calls) into the receiver. +3. Choose a venue. The only proven on-chain sBTC round-trip in-repo is **Velar univ2** (constant-product, ~0.3%/leg → **~0.6% round-trip cost**) — far lossier than Bitflow's stableswap (~0.15% in M1). Seed must be sized to that, or the loan kept small. +4. Handle the cross-contract `get-balance` `response` unwraps (the sBTC core/pool do this; a receiver must too). + +--- + +## 6. Funding & deployment requirements + +- **Real sBTC required:** yes. The receiver must hold canonical sBTC to cover fee + round-trip slippage before the first loan. Minimum protocol fee is 1 sat, but a Velar round-trip on a ~0.6%-cost venue means the seed should cover ~0.6% of the loan plus margin. For a 10,000-sat loan that's ~60–100 sats of seed; for safety, seed ~1,000–5,000 sats. +- **Sourcing sBTC:** bridge BTC via the sBTC bridge, or buy sBTC on a Stacks DEX (Velar/Bitflow) using STX we already hold. Acquiring a few thousand sats is cheap (<$5) but is a real logistics step with a confirmation delay. +- **Reserve ceiling:** the live core reserve is **15,010 sats**, so the maximum borrowable today is ~15k sats regardless of the 0.1 BTC cap (guard `reserve ≥ amount`, `:68`). A meaningful demo may need Matt to top up the sBTC reserve via `deposit-reserve`. +- **Deploy cost:** ~0.5 STX (same as the STX receiver; deploy fee is paid in STX). +- **Whitelist:** admin-only `add-approved-receiver` on `flashstack-sbtc-core` — same external dependency on Matt as M1. + +--- + +## 7. Risk areas (for the Phase C design) + +| Risk | Severity | Note | +|---|---|---| +| Tiny sBTC reserve (15k sats) caps loan size | Medium (demo scope) | May need Matt to fund reserve for a non-trivial loan | +| Velar univ2 ~0.6% round-trip cost → larger seed | Medium | Pick smallest viable loan; size seed to slippage; `min-out=u1` + repay-assert as in M1 | +| Acquiring real sBTC is an external dependency | Medium | Bridge/buy a few thousand sats before execution | +| `velar-sbtc-arb-receiver` lacks a caller gate | High (if reused verbatim) | Add the v2 `contract-caller == core` gate — do not ship without it | +| sBTC core `set-fee-basis-points` wrong error + no lower bound | Low (protocol, not ours) | Flag to Matt; dynamic fee lookup means our receiver tolerates fee changes anyway | +| SIP-010 post-conditions on transfers | Low | Use `PostConditionMode.Allow` in the execute tx, as in M1 | +| Two sBTC flash-loan sources (core vs pool) | Low | Target `core`; don't cross-wire the pool's whitelist | + +--- + +## 8. Open questions for Phase C + +1. **Venue:** stick with Velar pool 70 (proven, whitelisted reference) or evaluate a Bitflow sBTC stableswap pair (lower slippage) if one exists? — to verify on-chain in Phase C. +2. **Loan size & reserve:** confirm with Matt whether the sBTC reserve will be topped up, which sets the demo loan size. +3. **Seed source:** bridge vs DEX-buy for the sBTC seed; who funds it. + +--- + +--- + +## 9. Implementation outcome (2026-06-06 — Milestone 2 build) + +Phase C design was implemented and validated off-chain (no broadcast). Findings added since the research above: + +- **Live Velar pool 70 reserves confirmed:** `reserve0 (wSTX) = 223,275,450,774 µSTX`, `reserve1 (sBTC) = 65,845,796 sats`. **Swap fee = 0.3%/leg** (`get-fees → swap-fee 9970/10000`; protocol takes 25% *of* that fee). Round-trip cost ≈ **0.6%** for any ≤15k-sat loan (price impact negligible at <0.025% of reserve). Full seed math in `minimum_seed_analysis.md`. +- **sBTC reserve re-confirmed 15,010 sats; fee 5 bps; max-loan 10M sats; not paused; total-loans 2.** Our wallet holds **0 sBTC** — seed must be acquired (Velar buy helper written). +- **Trait-arg passing (new, ADR-S8):** the Velar router's `token0/1/in/out` and `share-fee-to` params are `trait_reference`. Passing a **named constant** there fails the analyzer (`use of unresolved contract`); a **literal** absolute principal resolves for static trait conformance. The receiver therefore uses literals throughout the swap calls, mirroring `velar-sbtc-arb-receiver` exactly. (Note: M1's Bitflow swap args are *also* `trait_reference` and M1 passed constants there successfully on mainnet — both patterns deploy, but literals are what clarinet's static check accepts cleanly, so we standardized on literals.) +- **Validation:** `clarinet check` ✔ (logic-equivalent stubs — the live sBTC/Velar suite does not assemble into simnet, same limitation as the STX suite); caller-gate negative test → `(err u403)`; deploy/seed/execute/buy scripts dry-run clean against live mainnet. + +See `hk-sbtc-receiver-deployment-plan.md` for the full ADR table, validation record, and mainnet runbook. + +--- + +*Verified against `contracts/` source and live mainnet reads on 2026-06-06. Implemented as `contracts/hk-sbtc-real-receiver-v1.clar`; see [[hk-sbtc-real-receiver-v1-Design]] and `hk-sbtc-receiver-deployment-plan.md`.* diff --git a/scripts/buy-sbtc.mjs b/scripts/buy-sbtc.mjs new file mode 100644 index 0000000..0ac6797 --- /dev/null +++ b/scripts/buy-sbtc.mjs @@ -0,0 +1,164 @@ +/** + * FlashStack -- Acquire canonical sBTC by swapping STX on Velar pool 70. + * + * Funding helper for the sBTC receiver seed. Calls univ2-router + * swap-exact-tokens-for-tokens(wSTX -> sBTC) on pool 70, signed by OUR wallet. + * Velar's `wstx` is a native-STX wrapper, so the router pulls STX directly; the + * acquired sBTC lands in our wallet, ready for seed-hk-sbtc-receiver.mjs. + * + * Sizing: 1 sat ~= 3,390 microSTX at pool-70 mid (2026-06-06). To acquire ~1,000 + * sats spend ~3.4 STX; ~1,500 sats ~5.1 STX. See minimum_seed_analysis.md. + * + * Usage: + * node scripts/buy-sbtc.mjs # reads ./mbegu2 then ./mbegu + * BUY_USTX=5100000 node scripts/buy-sbtc.mjs # spend 5.1 STX (~1500 sats) + * + * Optional env: + * BUY_USTX=3500000 STX to spend, in microSTX (default 3.5 STX ~= 1000 sats). + * MIN_OUT_SATS=1 Minimum sBTC out (default 1; deep pool + tiny trade). + * DRY_RUN=1 Build + print (with live quote), do NOT broadcast. + * TX_FEE_USTX=... Tx fee (default 200_000 microSTX = 0.2 STX). + */ + +import { makeContractCall, PostConditionMode, Cl, privateKeyToAddress } from "@stacks/transactions"; +import networkPkg from "@stacks/network"; +const { STACKS_MAINNET } = networkPkg; +import walletPkg from "@stacks/wallet-sdk"; +const { generateWallet } = walletPkg; +import { readFileSync, existsSync } from "fs"; + +const ROUTER_ADDR = "SP1Y5YSTAHZ88XYK1VPDH24GY0HPX5J4JECTMY4A1"; +const ROUTER_NAME = "univ2-router"; +const WSTX = ["SP1Y5YSTAHZ88XYK1VPDH24GY0HPX5J4JECTMY4A1", "wstx"]; +const SBTC = ["SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4", "sbtc-token"]; +const SHARE_FEE = ["SP1Y5YSTAHZ88XYK1VPDH24GY0HPX5J4JECTMY4A1", "univ2-share-fee-to"]; +const POOL_ADDR = "SP20X3DC5R091J8B6YPQT638J8NR1W83KN6TN5BJY"; +const POOL_NAME = "univ2-pool-v1_0_0-0070"; +const API = "https://api.hiro.so"; +const EXPLORER = "https://explorer.hiro.so/txid"; +const BUY_USTX = BigInt(process.env.BUY_USTX ?? 3_500_000); +const MIN_OUT = BigInt(process.env.MIN_OUT_SATS ?? 1); +const TX_FEE = Number(process.env.TX_FEE_USTX ?? 200_000); +const DRY_RUN = process.env.DRY_RUN === "1"; +const network = STACKS_MAINNET; + +function loadMnemonic() { + if (process.env.MAINNET_MNEMONIC) return process.env.MAINNET_MNEMONIC.trim(); + if (existsSync("mbegu2")) return readFileSync("mbegu2", "utf8").trim(); + if (existsSync("mbegu")) return readFileSync("mbegu", "utf8").trim(); + throw new Error("Set MAINNET_MNEMONIC env var, or place 24-word mnemonic in ./mbegu2"); +} +async function getNonce(addr) { + return (await (await fetch(`${API}/v2/accounts/${addr}?proof=0`)).json()).nonce; +} +async function getStxBalance(addr) { + return BigInt((await (await fetch(`${API}/extended/v1/address/${addr}/balances`)).json()).stx.balance); +} +// Live pool-70 reserves -> expected sBTC out for amtIn wSTX (univ2, 0.3% fee) +async function quote(amtIn) { + const res = await fetch(`${API}/v2/contracts/call-read/${POOL_ADDR}/${POOL_NAME}/get-pool`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sender: POOL_ADDR, arguments: [] }), + }); + const j = await res.json(); + if (!j.okay) return null; + // crude extraction of reserve0/reserve1 from the tuple hex by re-reading via /extended is messy; + // instead use the known field order is not guaranteed -> fetch reserves from token balances of pool. + // Fallback: use balances endpoint of the pool contract for wstx + sbtc. + return null; +} +async function poolReserves() { + const r = await fetch(`${API}/extended/v1/address/${POOL_ADDR}.${POOL_NAME}/balances`).then(x => x.json()); + // Velar holds reserves in the pool contract account + return r; +} +function expectedOut(amtIn, r0, r1) { + // wstx(in,r0) -> sbtc(out,r1): out = (in*9970*r1)/(r0*10000 + in*9970) + const inFee = amtIn * 9970n; + return (inFee * r1) / (r0 * 10000n + inFee); +} +async function broadcast(tx) { + const raw = tx.serialize(); + const body = typeof raw === "string" ? Buffer.from(raw.replace(/^0x/, ""), "hex") : raw; + const res = await fetch(`${API}/v2/transactions`, { + method: "POST", headers: { "Content-Type": "application/octet-stream" }, body }); + const text = await res.text(); + let data; try { data = JSON.parse(text); } catch { throw new Error(`Non-JSON: ${text.slice(0,200)}`); } + if (data?.error) throw new Error(`${data.error} -- ${data.reason ?? ""}`); + return typeof data === "string" ? data : data.txid; +} +async function waitForConfirm(txid, label) { + process.stdout.write(` Waiting for "${label}"`); + for (let i = 0; i < 80; i++) { + await new Promise(r => setTimeout(r, 8000)); + const data = await (await fetch(`${API}/extended/v1/tx/0x${txid}`)).json(); + if (data.tx_status === "success") { console.log(` confirmed. result=${data.tx_result?.repr}`); return data; } + if (data.tx_status?.startsWith("abort")) throw new Error(`"${label}" failed: ${data.tx_result?.repr}`); + process.stdout.write("."); + } + throw new Error(`Timeout: "${label}"`); +} + +async function main() { + const mnemonic = loadMnemonic(); + const wallet = await generateWallet({ secretKey: mnemonic, password: "" }); + const pk = wallet.accounts[0].stxPrivateKey; + const sender = privateKeyToAddress(pk, "mainnet"); + const balance = await getStxBalance(sender); + const nonce = await getNonce(sender); + + // Best-effort live quote from pool reserves + let expSats = null; + try { + const bal = await poolReserves(); + const r0 = BigInt(bal.stx.balance); // pool's STX (wSTX) reserve + const sbtcKey = Object.keys(bal.fungible_tokens || {}).find(k => k.toLowerCase().includes("sbtc-token")); + const r1 = sbtcKey ? BigInt(bal.fungible_tokens[sbtcKey].balance) : null; + if (r1) expSats = expectedOut(BUY_USTX, r0, r1); + } catch { /* quote is advisory only */ } + + console.log("======================================================="); + console.log(" FlashStack -- buy sBTC on Velar pool 70 (STX -> sBTC)"); + console.log("======================================================="); + console.log(` Sender: ${sender}`); + console.log(` STX balance: ${Number(balance) / 1e6} STX`); + console.log(` Nonce: ${nonce}`); + console.log(` Spend: ${BUY_USTX} microSTX (${Number(BUY_USTX) / 1e6} STX)`); + console.log(` Expected out: ${expSats === null ? "(quote unavailable)" : expSats + " sats (advisory)"}`); + console.log(` Min out: ${MIN_OUT} sats`); + console.log(` Tx fee: ${TX_FEE} microSTX`); + console.log(` Mode: ${DRY_RUN ? "DRY RUN (no broadcast)" : "LIVE BROADCAST"}\n`); + + if (balance < BUY_USTX + BigInt(TX_FEE)) { + throw new Error(`Insufficient STX: ${balance} < spend ${BUY_USTX} + fee ${TX_FEE}`); + } + + const tx = await makeContractCall({ + contractAddress: ROUTER_ADDR, + contractName: ROUTER_NAME, + functionName: "swap-exact-tokens-for-tokens", + functionArgs: [ + Cl.uint(70), + Cl.contractPrincipal(...WSTX), // token0 + Cl.contractPrincipal(...SBTC), // token1 + Cl.contractPrincipal(...WSTX), // token-in (wSTX -> pulls native STX) + Cl.contractPrincipal(...SBTC), // token-out (sBTC) + Cl.contractPrincipal(...SHARE_FEE), // share-fee-to + Cl.uint(BUY_USTX), // amt-in + Cl.uint(MIN_OUT), // amt-out-min + ], + senderKey: pk, network, fee: TX_FEE, nonce, + postConditionMode: PostConditionMode.Allow, + anchorMode: 1, + }); + + if (DRY_RUN) { console.log(" DRY_RUN=1 -- not broadcasting. Tx built OK."); return; } + + const txid = await broadcast(tx); + console.log(` Broadcast: ${txid}`); + console.log(` Explorer: ${EXPLORER}/${txid}?chain=mainnet`); + await waitForConfirm(txid, "buy sBTC on Velar"); + console.log("\n DONE. Check your sBTC balance, then run scripts/seed-hk-sbtc-receiver.mjs"); +} + +main().catch(e => { console.error("\nFAILED:", e.message); process.exit(1); }); diff --git a/scripts/deploy-hk-sbtc-receiver.mjs b/scripts/deploy-hk-sbtc-receiver.mjs new file mode 100644 index 0000000..24b9ba5 --- /dev/null +++ b/scripts/deploy-hk-sbtc-receiver.mjs @@ -0,0 +1,143 @@ +/** + * FlashStack -- Deploy hk-sbtc-real-receiver-v1 to mainnet + * + * External-developer receiver that runs a REAL Velar sBTC->wSTX->sBTC round-trip + * inside a flash loan against flashstack-sbtc-core and repays principal + fee + * atomically. sBTC mirror of the Milestone-1 Bitflow receiver. + * + * Deploys from OUR own mainnet wallet (NOT the protocol deployer). The admin-only + * whitelist call (`add-approved-receiver` on flashstack-sbtc-core) must be done by + * Matt separately before the receiver can borrow. + * + * Usage: + * MAINNET_MNEMONIC="word1 ... word24" node scripts/deploy-hk-sbtc-receiver.mjs + * Or, if ./mbegu2 (or ./mbegu) holds the 24-word mnemonic on a single line: + * node scripts/deploy-hk-sbtc-receiver.mjs + * + * Optional env: + * DRY_RUN=1 Build and print the tx fields but do NOT broadcast. + * DEPLOY_FEE_USTX=... Override the deploy fee (default 500_000 microSTX = 0.5 STX). + */ + +import { makeContractDeploy, PostConditionMode, ClarityVersion, privateKeyToAddress } from "@stacks/transactions"; +import networkPkg from "@stacks/network"; +const { STACKS_MAINNET } = networkPkg; +import walletPkg from "@stacks/wallet-sdk"; +const { generateWallet } = walletPkg; +import { readFileSync, existsSync } from "fs"; + +const NAME = "hk-sbtc-real-receiver-v1"; +const PATH = "contracts/hk-sbtc-real-receiver-v1.clar"; +const API = "https://api.hiro.so"; +const EXPLORER = "https://explorer.hiro.so/txid"; +const FEE = Number(process.env.DEPLOY_FEE_USTX ?? 500_000); +const DRY_RUN = process.env.DRY_RUN === "1"; +const network = STACKS_MAINNET; + +function loadMnemonic() { + if (process.env.MAINNET_MNEMONIC) return process.env.MAINNET_MNEMONIC.trim(); + if (existsSync("mbegu2")) return readFileSync("mbegu2", "utf8").trim(); + if (existsSync("mbegu")) return readFileSync("mbegu", "utf8").trim(); + throw new Error("Set MAINNET_MNEMONIC env var, or place 24-word mnemonic in ./mbegu2"); +} +async function getNonce(addr) { + return (await (await fetch(`${API}/v2/accounts/${addr}?proof=0`)).json()).nonce; +} +async function getBalance(addr) { + return BigInt((await (await fetch(`${API}/extended/v1/address/${addr}/balances`)).json()).stx.balance); +} +async function broadcast(tx) { + const raw = tx.serialize(); + const body = typeof raw === "string" ? Buffer.from(raw.replace(/^0x/, ""), "hex") : raw; + const res = await fetch(`${API}/v2/transactions`, { + method: "POST", headers: { "Content-Type": "application/octet-stream" }, body }); + const text = await res.text(); + let data; try { data = JSON.parse(text); } catch { throw new Error(`Non-JSON: ${text.slice(0, 200)}`); } + if (data?.error) throw new Error(`${data.error} -- ${data.reason ?? ""}`); + const txid = typeof data === "string" ? data : data.txid; + if (!txid) throw new Error(`No txid: ${text.slice(0, 200)}`); + return txid; +} +async function waitForConfirm(txid, label) { + process.stdout.write(` Waiting for "${label}"`); + for (let i = 0; i < 80; i++) { + await new Promise(r => setTimeout(r, 8000)); + const data = await (await fetch(`${API}/extended/v1/tx/0x${txid}`)).json(); + if (data.tx_status === "success") { console.log(" confirmed."); return; } + if (data.tx_status?.startsWith("abort")) { + console.log(`\n FAILED: ${data.tx_result?.repr ?? "unknown"}`); + throw new Error(`"${label}" failed`); + } + process.stdout.write("."); + } + throw new Error(`Timeout: "${label}"`); +} + +async function main() { + const mnemonic = loadMnemonic(); + const wc = mnemonic.split(/\s+/).length; + if (wc !== 24) throw new Error(`Expected 24-word mnemonic, got ${wc} words`); + + const wallet = await generateWallet({ secretKey: mnemonic, password: "" }); + const pk = wallet.accounts[0].stxPrivateKey; + const sender = privateKeyToAddress(pk, "mainnet"); + const balance = await getBalance(sender); + const nonce = await getNonce(sender); + + console.log("======================================================="); + console.log(" FlashStack -- Deploy hk-sbtc-real-receiver-v1 "); + console.log("======================================================="); + console.log(` Sender: ${sender}`); + console.log(` Balance: ${Number(balance) / 1e6} STX`); + console.log(` Nonce: ${nonce}`); + console.log(` Fee: ${FEE} microSTX (${FEE / 1e6} STX)`); + console.log(` Contract: ${sender}.${NAME}`); + console.log(` Source: ${PATH}`); + console.log(` Network: mainnet`); + console.log(` Mode: ${DRY_RUN ? "DRY RUN (no broadcast)" : "LIVE BROADCAST"}`); + console.log(); + + if (balance < BigInt(FEE)) { + throw new Error(`Insufficient balance: ${balance} microSTX < fee ${FEE} microSTX`); + } + + const tx = await makeContractDeploy({ + contractName: NAME, + codeBody: readFileSync(PATH, "utf8"), + senderKey: pk, + network, + clarityVersion: ClarityVersion.Clarity3, + postConditionMode: PostConditionMode.Allow, + anchorMode: 1, + fee: FEE, + nonce, + }); + + if (DRY_RUN) { + console.log(" DRY_RUN=1 -- not broadcasting. Set DRY_RUN=0 (or unset) to broadcast."); + console.log(` Built tx OK. Serialized length: ${tx.serialize().length} bytes-ish.`); + return; + } + + const txid = await broadcast(tx); + console.log(` Broadcast: ${txid}`); + console.log(` Explorer: ${EXPLORER}/${txid}?chain=mainnet`); + await waitForConfirm(txid, "deploy hk-sbtc-real-receiver-v1"); + + console.log(); + console.log("======================================================="); + console.log(" DEPLOYMENT COMPLETE "); + console.log("======================================================="); + console.log(` Contract: ${sender}.${NAME}`); + console.log(` Tx: ${EXPLORER}/${txid}?chain=mainnet`); + console.log(); + console.log(" Next steps:"); + console.log(` 1. Ask Matt to whitelist ${sender}.${NAME}`); + console.log(" (admin-only: add-approved-receiver on flashstack-sbtc-core)"); + console.log(" 2. Acquire sBTC if needed: node scripts/buy-sbtc.mjs (see minimum_seed_analysis.md)"); + console.log(" 3. Seed the receiver: node scripts/seed-hk-sbtc-receiver.mjs (SEED_SATS=500)"); + console.log(" 4. Execute the flash loan: node scripts/execute-sbtc-flash-loan.mjs (AMOUNT_SATS=1000)"); + console.log(" 5. Recover the seed: rescue-sbtc(amount, our-wallet)"); +} + +main().catch(e => { console.error("\nFAILED:", e.message); process.exit(1); }); diff --git a/scripts/execute-sbtc-flash-loan.mjs b/scripts/execute-sbtc-flash-loan.mjs new file mode 100644 index 0000000..18368c4 --- /dev/null +++ b/scripts/execute-sbtc-flash-loan.mjs @@ -0,0 +1,177 @@ +/** + * FlashStack -- Execute a flash loan through hk-sbtc-real-receiver-v1 + * + * Calls flashstack-sbtc-core.flash-loan(amount, receiver) signed by OUR wallet. + * The receiver must already be (a) deployed, (b) whitelisted by Matt + * (add-approved-receiver), and (c) seeded with sBTC (>= round-trip loss + fee). + * + * Usage: + * MAINNET_MNEMONIC="word1 ... word24" node scripts/execute-sbtc-flash-loan.mjs + * Or with ./mbegu2 (or ./mbegu) holding the 24-word mnemonic: + * node scripts/execute-sbtc-flash-loan.mjs + * + * Optional env: + * AMOUNT_SATS=1000 Loan size in sats (default 1000 -- Matt's stated demo size). + * DRY_RUN=1 Build + print, do NOT broadcast. + * TX_FEE_USTX=... Tx fee (default 300_000 microSTX = 0.3 STX). + */ + +import { makeContractCall, PostConditionMode, Cl, privateKeyToAddress } from "@stacks/transactions"; +import networkPkg from "@stacks/network"; +const { STACKS_MAINNET } = networkPkg; +import walletPkg from "@stacks/wallet-sdk"; +const { generateWallet } = walletPkg; +import { readFileSync, existsSync } from "fs"; + +const CORE_ADDR = "SP20XD46NGAX05ZQZDKFYCCX49A3852BQABNP0VG5"; +const CORE_NAME = "flashstack-sbtc-core"; +const RECV_NAME = "hk-sbtc-real-receiver-v1"; +const SBTC_ADDR = "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4"; +const SBTC_NAME = "sbtc-token"; +const API = "https://api.hiro.so"; +const EXPLORER = "https://explorer.hiro.so/txid"; +const AMOUNT = BigInt(process.env.AMOUNT_SATS ?? 1000); +const TX_FEE = Number(process.env.TX_FEE_USTX ?? 300_000); +const DRY_RUN = process.env.DRY_RUN === "1"; +const network = STACKS_MAINNET; + +function loadMnemonic() { + if (process.env.MAINNET_MNEMONIC) return process.env.MAINNET_MNEMONIC.trim(); + if (existsSync("mbegu2")) return readFileSync("mbegu2", "utf8").trim(); + if (existsSync("mbegu")) return readFileSync("mbegu", "utf8").trim(); + throw new Error("Set MAINNET_MNEMONIC env var, or place 24-word mnemonic in ./mbegu2"); +} +async function getNonce(addr) { + return (await (await fetch(`${API}/v2/accounts/${addr}?proof=0`)).json()).nonce; +} +async function getStxBalance(addr) { + return BigInt((await (await fetch(`${API}/extended/v1/address/${addr}/balances`)).json()).stx.balance); +} +function decodeOkUint(hex) { + // 07 (ok) 01 (uint) + 16-byte big-endian + const h = hex.replace(/^0x/, ""); + if (!h.startsWith("0701")) return null; + return BigInt("0x" + h.slice(4)); +} +function decodeOkBool(hex) { + // 07 (ok) then 03 (true) / 04 (false) + const h = hex.replace(/^0x/, ""); + if (h.startsWith("0703")) return true; + if (h.startsWith("0704")) return false; + return null; +} +async function readFn(addr, name, fn, args, sender) { + const res = await fetch(`${API}/v2/contracts/call-read/${addr}/${name}/${fn}`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sender, arguments: args }), + }); + return res.json(); +} +async function broadcast(tx) { + const raw = tx.serialize(); + const body = typeof raw === "string" ? Buffer.from(raw.replace(/^0x/, ""), "hex") : raw; + const res = await fetch(`${API}/v2/transactions`, { + method: "POST", headers: { "Content-Type": "application/octet-stream" }, body }); + const text = await res.text(); + let data; try { data = JSON.parse(text); } catch { throw new Error(`Non-JSON: ${text.slice(0,200)}`); } + if (data?.error) throw new Error(`${data.error} -- ${data.reason ?? ""}`); + return typeof data === "string" ? data : data.txid; +} +async function waitForConfirm(txid, label) { + process.stdout.write(` Waiting for "${label}"`); + for (let i = 0; i < 80; i++) { + await new Promise(r => setTimeout(r, 8000)); + const data = await (await fetch(`${API}/extended/v1/tx/0x${txid}`)).json(); + if (data.tx_status === "success") { console.log(` confirmed. result=${data.tx_result?.repr}`); return data; } + if (data.tx_status?.startsWith("abort")) { + console.log(`\n FAILED: ${data.tx_result?.repr ?? "unknown"}`); + throw new Error(`"${label}" failed: ${data.tx_result?.repr}`); + } + process.stdout.write("."); + } + throw new Error(`Timeout: "${label}"`); +} + +async function main() { + const mnemonic = loadMnemonic(); + const wallet = await generateWallet({ secretKey: mnemonic, password: "" }); + const pk = wallet.accounts[0].stxPrivateKey; + const sender = privateKeyToAddress(pk, "mainnet"); + const receiver = `${sender}.${RECV_NAME}`; + const balance = await getStxBalance(sender); + const nonce = await getNonce(sender); + + console.log("======================================================="); + console.log(" FlashStack -- flash-loan via hk-sbtc-real-receiver-v1"); + console.log("======================================================="); + console.log(` Sender: ${sender}`); + console.log(` Balance: ${Number(balance) / 1e6} STX`); + console.log(` Nonce: ${nonce}`); + console.log(` Core: ${CORE_ADDR}.${CORE_NAME}`); + console.log(` Receiver: ${receiver}`); + console.log(` Amount: ${AMOUNT} sats`); + console.log(` Tx fee: ${TX_FEE} microSTX`); + console.log(` Mode: ${DRY_RUN ? "DRY RUN (no broadcast)" : "LIVE BROADCAST"}`); + console.log(); + + // --- Preflight read-only checks --- + // 1. is-approved-receiver -> (ok bool) -- MUST be true + const apprRaw = await readFn(CORE_ADDR, CORE_NAME, "is-approved-receiver", + [Cl.serialize(Cl.contractPrincipal(sender, RECV_NAME))], sender).catch(() => null); + const approved = apprRaw?.okay ? decodeOkBool(apprRaw.result) : null; + console.log(` Preflight is-approved-receiver -> ${approved}`); + + // 2. reserve >= amount + const resRaw = await readFn(CORE_ADDR, CORE_NAME, "get-reserve-balance", [], sender).catch(() => null); + const reserve = resRaw?.okay ? decodeOkUint(resRaw.result) : null; + console.log(` Preflight reserve -> ${reserve} sats (need >= ${AMOUNT})`); + + // 3. receiver sBTC balance (seed) -- should cover round-trip loss + fee + const recvRaw = await readFn(SBTC_ADDR, SBTC_NAME, "get-balance", + [Cl.serialize(Cl.contractPrincipal(sender, RECV_NAME))], sender).catch(() => null); + const recvSbtc = recvRaw?.okay ? decodeOkUint(recvRaw.result) : null; + console.log(` Preflight receiver sBTC seed -> ${recvSbtc} sats`); + + // 4. estimate-repayment from the receiver (fee math) + const estRaw = await readFn(sender, RECV_NAME, "estimate-repayment", + [Cl.serialize(Cl.uint(AMOUNT))], sender).catch(() => null); + console.log(` Preflight estimate-repayment -> ${estRaw?.okay ? estRaw.result.slice(0, 80) + "..." : "n/a (receiver not deployed yet?)"}`); + + // Guards (warn-only in DRY_RUN; hard-stop in live) + const problems = []; + if (approved === false) problems.push("receiver is NOT whitelisted (ask Matt to add-approved-receiver)"); + if (reserve !== null && reserve < AMOUNT) problems.push(`reserve ${reserve} < amount ${AMOUNT}`); + if (recvSbtc !== null && recvSbtc === 0n) problems.push("receiver holds 0 sBTC seed (run seed-hk-sbtc-receiver.mjs)"); + if (problems.length) { + console.log("\n PREFLIGHT WARNINGS:"); + problems.forEach(p => console.log(` - ${p}`)); + if (!DRY_RUN) throw new Error("Preflight failed; refusing to broadcast. Fix the above or run with DRY_RUN=1."); + } + + const tx = await makeContractCall({ + contractAddress: CORE_ADDR, + contractName: CORE_NAME, + functionName: "flash-loan", + functionArgs: [Cl.uint(AMOUNT), Cl.contractPrincipal(sender, RECV_NAME)], + senderKey: pk, + network, + postConditionMode: PostConditionMode.Allow, + anchorMode: 1, + fee: TX_FEE, + nonce, + }); + + if (DRY_RUN) { + console.log("\n DRY_RUN=1 -- not broadcasting. Tx built OK."); + return; + } + + const txid = await broadcast(tx); + console.log(`\n Broadcast: ${txid}`); + console.log(` Explorer: ${EXPLORER}/${txid}?chain=mainnet`); + await waitForConfirm(txid, "flash-loan via sBTC receiver"); + console.log("\n DONE. Save this txid as on-chain evidence (look for the sbtc-velar-roundtrip print event)."); + console.log(` ${EXPLORER}/${txid}?chain=mainnet`); +} + +main().catch(e => { console.error("\nFAILED:", e.message); process.exit(1); }); diff --git a/scripts/seed-hk-sbtc-receiver.mjs b/scripts/seed-hk-sbtc-receiver.mjs new file mode 100644 index 0000000..837f88d --- /dev/null +++ b/scripts/seed-hk-sbtc-receiver.mjs @@ -0,0 +1,133 @@ +/** + * FlashStack -- Seed hk-sbtc-real-receiver-v1 with canonical sBTC. + * + * Unlike the M1 STX seed (a native stx-transfer), this is a SIP-010 token transfer + * of canonical sBTC from OUR wallet to the receiver contract. The receiver pays the + * flash-loan fee AND absorbs the ~0.6% Velar round-trip loss from its own sBTC + * balance inside the callback, so it must hold sBTC before the first loan. + * + * Seed sizing: see minimum_seed_analysis.md. For a 1,000-sat demo loan the safe + * seed is ~500 sats (covers round-trip loss + fee with margin; recoverable via + * rescue-sbtc). Default below is 500 sats. + * + * Usage: + * node scripts/seed-hk-sbtc-receiver.mjs # reads ./mbegu2 then ./mbegu + * MAINNET_MNEMONIC="w1 ... w24" node scripts/seed-hk-sbtc-receiver.mjs + * + * Optional env: + * SEED_SATS=500 Seed size in sats (default 500). + * DRY_RUN=1 Build + print, do NOT broadcast. + * TX_FEE_USTX=10000 Tx fee (default 10_000 microSTX = 0.01 STX). + */ +import { makeContractCall, PostConditionMode, Cl, privateKeyToAddress } from "@stacks/transactions"; +import networkPkg from "@stacks/network"; +const { STACKS_MAINNET } = networkPkg; +import walletPkg from "@stacks/wallet-sdk"; +const { generateWallet } = walletPkg; +import { readFileSync, existsSync } from "fs"; + +const RECV_NAME = "hk-sbtc-real-receiver-v1"; +const SBTC_ADDR = "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4"; +const SBTC_NAME = "sbtc-token"; +const API = "https://api.hiro.so"; +const EXPLORER = "https://explorer.hiro.so/txid"; +const SEED = BigInt(process.env.SEED_SATS ?? 500); +const TX_FEE = Number(process.env.TX_FEE_USTX ?? 10_000); +const DRY_RUN = process.env.DRY_RUN === "1"; +const network = STACKS_MAINNET; + +function loadMnemonic() { + if (process.env.MAINNET_MNEMONIC) return process.env.MAINNET_MNEMONIC.trim(); + if (existsSync("mbegu2")) return readFileSync("mbegu2", "utf8").trim(); + if (existsSync("mbegu")) return readFileSync("mbegu", "utf8").trim(); + throw new Error("Set MAINNET_MNEMONIC env var, or place 24-word mnemonic in ./mbegu2"); +} +async function getNonce(addr) { + return (await (await fetch(`${API}/v2/accounts/${addr}?proof=0`)).json()).nonce; +} +async function getSbtcBalance(addr) { + const res = await fetch(`${API}/v2/contracts/call-read/${SBTC_ADDR}/${SBTC_NAME}/get-balance`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sender: addr, arguments: [Cl.serialize(Cl.principal(addr))] }), + }); + const j = await res.json(); + // result is (ok uintN) hex; decode the trailing uint + if (!j.okay) return null; + const hex = j.result.replace(/^0x/, ""); + // 07 (ok) 01 (uint) + 16-byte big-endian + const uintHex = hex.slice(4); + return BigInt("0x" + uintHex); +} +async function broadcast(tx) { + const raw = tx.serialize(); + const body = typeof raw === "string" ? Buffer.from(raw.replace(/^0x/, ""), "hex") : raw; + const res = await fetch(`${API}/v2/transactions`, { + method: "POST", headers: { "Content-Type": "application/octet-stream" }, body }); + const text = await res.text(); + let data; try { data = JSON.parse(text); } catch { throw new Error(`Non-JSON: ${text.slice(0,200)}`); } + if (data?.error) throw new Error(`${data.error} -- ${data.reason ?? ""}`); + return typeof data === "string" ? data : data.txid; +} +async function waitForConfirm(txid, label) { + process.stdout.write(` Waiting for "${label}"`); + for (let i = 0; i < 80; i++) { + await new Promise(r => setTimeout(r, 8000)); + const data = await (await fetch(`${API}/extended/v1/tx/0x${txid}`)).json(); + if (data.tx_status === "success") { console.log(` confirmed. result=${data.tx_result?.repr}`); return data; } + if (data.tx_status?.startsWith("abort")) throw new Error(`"${label}" failed: ${data.tx_result?.repr}`); + process.stdout.write("."); + } + throw new Error(`Timeout: "${label}"`); +} + +async function main() { + const mnemonic = loadMnemonic(); + const wallet = await generateWallet({ secretKey: mnemonic, password: "" }); + const pk = wallet.accounts[0].stxPrivateKey; + const sender = privateKeyToAddress(pk, "mainnet"); + const recipient = `${sender}.${RECV_NAME}`; + const nonce = await getNonce(sender); + const ourSbtc = await getSbtcBalance(sender); + + console.log("======================================================="); + console.log(" FlashStack -- seed hk-sbtc-real-receiver-v1 (sBTC)"); + console.log("======================================================="); + console.log(` Sender: ${sender}`); + console.log(` Our sBTC: ${ourSbtc === null ? "?" : ourSbtc} sats`); + console.log(` Nonce: ${nonce}`); + console.log(` Recipient: ${recipient}`); + console.log(` Seed: ${SEED} sats`); + console.log(` Tx fee: ${TX_FEE} microSTX`); + console.log(` Mode: ${DRY_RUN ? "DRY RUN (no broadcast)" : "LIVE BROADCAST"}\n`); + + if (ourSbtc !== null && ourSbtc < SEED) { + throw new Error(`Insufficient sBTC: hold ${ourSbtc} sats < seed ${SEED} sats. Run scripts/buy-sbtc.mjs first.`); + } + + // SIP-010 transfer: (transfer amount sender recipient memo) + const tx = await makeContractCall({ + contractAddress: SBTC_ADDR, + contractName: SBTC_NAME, + functionName: "transfer", + functionArgs: [ + Cl.uint(SEED), + Cl.principal(sender), + Cl.contractPrincipal(sender, RECV_NAME), + Cl.none(), + ], + senderKey: pk, network, fee: TX_FEE, nonce, + postConditionMode: PostConditionMode.Allow, + anchorMode: 1, + }); + + if (DRY_RUN) { console.log(" DRY_RUN=1 -- not broadcasting. Tx built OK."); return; } + + const txid = await broadcast(tx); + console.log(` Broadcast: ${txid}`); + console.log(` Explorer: ${EXPLORER}/${txid}?chain=mainnet`); + await waitForConfirm(txid, "seed receiver (sBTC)"); + const recvBal = await getSbtcBalance(recipient); + console.log(`\n DONE. Receiver sBTC balance now: ${recvBal} sats`); + console.log(` Seed txid: ${txid}`); +} +main().catch(e => { console.error("\nFAILED:", e.message); process.exit(1); });