From f196a99d002816f5594211e90ddc9e32c3beecc4 Mon Sep 17 00:00:00 2001 From: emmanuelist Date: Tue, 20 Oct 2026 18:01:40 +0100 Subject: [PATCH] fix(web): receiver snippet arms each loan (vetting C7) and reads the fee Follow-up to #127, per review: anyone can call flash-loan naming a receiver, so a receiver built from the #127 snippet could be made to run its strategy and spend its fee buffer by a stranger. The snippet now follows docs/security/RECEIVER_VETTING.md the way hk-sbtc-real-receiver-v2 does: - C7: the owner arms exactly one loan amount (arm-loan / disarm, both owner-only, nonzero). The callback requires an exact match and clears the arm before the strategy runs, so an unarmed loan reverts (u402). - B4: the contract-caller gate runs before any state is read. - D12: fee from the core's get-fee-basis-points with max(1, floor), instead of a hardcoded 5 bp. - Points to E17/E18 (owner-only rescue, two-step ownership) and C9 (measure profit against the starting balance). - Labelled Clarity 3: the live core requires epoch 3.4, and Clarity 4 replaces as-contract, so the snippet as written compiles only as Clarity 3 at epoch 3.4. Verified on the exact text shown on each page: clarinet check against the real mainnet trait and core, and 9 simnet tests through the real core at its mainnet address (stranger reverts, arm single-use and bound to amount, fee follows the core). Removing the arm check fails 5 of 9. --- web/src/app/(app)/receivers/page.tsx | 52 ++++++++++++++++++++++------ web/src/app/page.tsx | 46 +++++++++++++++++++----- 2 files changed, 80 insertions(+), 18 deletions(-) diff --git a/web/src/app/(app)/receivers/page.tsx b/web/src/app/(app)/receivers/page.tsx index 8b7c581..f8280e1 100644 --- a/web/src/app/(app)/receivers/page.tsx +++ b/web/src/app/(app)/receivers/page.tsx @@ -96,30 +96,62 @@ export default function ReceiversPage() {

Build Your Own Receiver

- Implement stx-flash-receiver-trait: one function. + Implement stx-flash-receiver-trait. Lock it to the core with a contract-caller check and repay a hardcoded core, - never the core argument. Receivers without this could be drained and were - de-approved (finding F-10). Then deploy and DM us to get whitelisted. If your strategy can't repay, the entire tx reverts. + never the core argument (finding F-10). Anyone can call + flash-loan naming your receiver, so only run a loan you armed from the owner wallet first. The full list is in the{" "} + receiver vetting checklist. + Then deploy and DM us to get whitelisted. If your strategy can't repay, the entire tx reverts.

-{`;; Hardened receiver: only the core may call it, and it
-;; repays a hardcoded core, never the core argument (F-10)
+{`;; Hardened receiver, per docs/security/RECEIVER_VETTING.md:
+;; only the core may call it and it repays a hardcoded core (F-10),
+;; and only a loan the owner armed can run (C7). Anyone can call
+;; flash-loan naming this receiver; without the arm, they could make
+;; it run your strategy and spend its fee buffer.
+;; Clarity 3: Clarity 4 replaces as-contract with as-contract?.
 (impl-trait
   'SP3TGRVG7DKGFVRTTVGGS60S59R916FWB4DAB9STZ.stx-flash-receiver-trait.stx-flash-receiver-trait)
 
 (define-constant FLASH-CORE
   'SP20XD46NGAX05ZQZDKFYCCX49A3852BQABNP0VG5.flashstack-stx-core)
 
+(define-data-var owner principal tx-sender)
+(define-data-var armed (optional uint) none)
+
+;; Owner arms exactly one loan amount, then calls flash-loan
+(define-public (arm-loan (amount uint))
+  (begin
+    (asserts! (is-eq tx-sender (var-get owner)) (err u403))
+    (asserts! (> amount u0) (err u400))
+    (ok (var-set armed (some amount)))))
+
+(define-public (disarm)
+  (begin
+    (asserts! (is-eq tx-sender (var-get owner)) (err u403))
+    (ok (var-set armed none))))
+
 (define-public (execute-stx-flash (amount uint) (core principal))
-  (let ((raw-fee (/ (* amount u5) u10000))            ;; 0.05%
-        (owed (+ amount (if (> raw-fee u0) raw-fee u1))))
+  (begin
     (asserts! (is-eq contract-caller FLASH-CORE) (err u401))
     (asserts! (is-eq core FLASH-CORE) (err u401))
+    ;; a loan the owner didn't arm stops here; the arm is single-use
+    (asserts! (is-eq (var-get armed) (some amount)) (err u402))
+    (var-set armed none)
+    (let ((fee-bp (unwrap! (contract-call?
+                    'SP20XD46NGAX05ZQZDKFYCCX49A3852BQABNP0VG5.flashstack-stx-core
+                    get-fee-basis-points) (err u404)))
+          (raw-fee (/ (* amount fee-bp) u10000))
+          (owed (+ amount (if (> raw-fee u0) raw-fee u1))))
+
+      ;; --- YOUR STRATEGY HERE ---
+      ;; arbitrage, liquidate, swap...
+      ;; check profit against the balance read at the start (C9)
 
-    ;; --- YOUR STRATEGY HERE ---
-    ;; arbitrage, liquidate, swap...
+      (as-contract (stx-transfer? owed tx-sender FLASH-CORE)))))
 
-    (as-contract (stx-transfer? owed tx-sender FLASH-CORE))))
+;; Before mainnet, also add owner-only rescue (E17) and a
+;; two-step ownership transfer (E18).
 
 ;; Deploy, then ask to be approved on the core`}
         
diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 5f4b2fe..932f32c 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -228,7 +228,7 @@ export default function LandingPage() {

Build your strategy

- Any Clarity contract can be a FlashStack receiver. Implement one function, deploy, get whitelisted, and access the full STX reserve or up to 0.1 BTC per transaction. + Any Clarity contract can be a FlashStack receiver. Implement the callback, lock it to the core, arm each loan from your owner wallet, deploy, get whitelisted, and access the full STX reserve or up to 0.1 BTC per transaction.

@@ -250,24 +250,54 @@ export default function LandingPage() {
my-receiver.clar
-
{`;; Hardened receiver: only the core may call it, and it
-;; repays a hardcoded core, never the core argument (F-10)
+            
{`;; Hardened receiver, per docs/security/RECEIVER_VETTING.md:
+;; only the core may call it and it repays a hardcoded core (F-10),
+;; and only a loan the owner armed can run (C7). Anyone can call
+;; flash-loan naming this receiver; without the arm, they could make
+;; it run your strategy and spend its fee buffer.
+;; Clarity 3: Clarity 4 replaces as-contract with as-contract?.
 (impl-trait
   'SP3TGRVG7DKGFVRTTVGGS60S59R916FWB4DAB9STZ.stx-flash-receiver-trait.stx-flash-receiver-trait)
 
 (define-constant FLASH-CORE
   'SP20XD46NGAX05ZQZDKFYCCX49A3852BQABNP0VG5.flashstack-stx-core)
 
+(define-data-var owner principal tx-sender)
+(define-data-var armed (optional uint) none)
+
+;; Owner arms exactly one loan amount, then calls flash-loan
+(define-public (arm-loan (amount uint))
+  (begin
+    (asserts! (is-eq tx-sender (var-get owner)) (err u403))
+    (asserts! (> amount u0) (err u400))
+    (ok (var-set armed (some amount)))))
+
+(define-public (disarm)
+  (begin
+    (asserts! (is-eq tx-sender (var-get owner)) (err u403))
+    (ok (var-set armed none))))
+
 (define-public (execute-stx-flash (amount uint) (core principal))
-  (let ((raw-fee (/ (* amount u5) u10000))            ;; 0.05%
-        (owed (+ amount (if (> raw-fee u0) raw-fee u1))))
+  (begin
     (asserts! (is-eq contract-caller FLASH-CORE) (err u401))
     (asserts! (is-eq core FLASH-CORE) (err u401))
+    ;; a loan the owner didn't arm stops here; the arm is single-use
+    (asserts! (is-eq (var-get armed) (some amount)) (err u402))
+    (var-set armed none)
+    (let ((fee-bp (unwrap! (contract-call?
+                    'SP20XD46NGAX05ZQZDKFYCCX49A3852BQABNP0VG5.flashstack-stx-core
+                    get-fee-basis-points) (err u404)))
+          (raw-fee (/ (* amount fee-bp) u10000))
+          (owed (+ amount (if (> raw-fee u0) raw-fee u1))))
+
+      ;; --- YOUR STRATEGY HERE ---
+      ;; arbitrage, liquidate, swap...
+      ;; check profit against the balance read at the start (C9)
 
-    ;; --- YOUR STRATEGY HERE ---
-    ;; arbitrage, liquidate, swap...
+      (as-contract (stx-transfer? owed tx-sender FLASH-CORE)))))
 
-    (as-contract (stx-transfer? owed tx-sender FLASH-CORE))))`}
+;; Before mainnet, also add owner-only rescue (E17) and a +;; two-step ownership transfer (E18).`}