Skip to content

fix(web): receiver snippet arms each loan (vetting C7) and reads the fee - #136

Open
emmanuelist wants to merge 1 commit into
mattglory:mainfrom
emmanuelist:fix/web-armed-receiver-snippet
Open

emmanuelist wants to merge 1 commit into
mattglory:mainfrom
emmanuelist:fix/web-armed-receiver-snippet

Conversation

@emmanuelist

Copy link
Copy Markdown
Contributor

Description

Follow-up to #127, as you asked. Anyone can call flash-loan naming a receiver, so a receiver copied 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:

Item What the snippet does
C7 arm-loan (amount) and disarm are owner-only (and reject zero). The callback requires (is-eq (var-get armed) (some amount)) and clears the arm before the strategy runs. An unarmed loan reverts with u402.
B4 / B5 contract-caller gate first, before any state is read; repays the hardcoded core.
D12 Fee from the core's own get-fee-basis-points with max(1, floor(...)), instead of the hardcoded 5 bp in #127.
C9, E17, E18 Pointed to in comments (measure profit against the starting balance; owner-only rescue; two-step ownership) rather than inlined, to keep the teaching snippet readable.

/receivers intro text now says why the arm matters and links the vetting checklist. The landing paragraph no longer says "implement one function".

Clarity version: the snippet is labelled Clarity 3. The live flashstack-stx-core requires epoch 3.4, and Clarity 4 replaces as-contract, so this exact code compiles only as Clarity 3 at epoch 3.4 (checked both; Clarity 4 fails with "unresolved function as-contract"). #127's check used Clarity 2 / epoch 2.5 with a local copy of the trait. That was fine for #127 because its snippet never called the core, but this one does, so this time I checked against the real mainnet contracts.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • 📝 Documentation update

Related Issues

Follow-up to #127. Related to #111.

Testing Performed

All on the exact text shown on each page (extracted from the source; the /receivers copy is byte-for-byte the landing one plus its "deploy" note):

  • clarinet check, Clarity 3 / epoch 3.4, with the real SP3TGRVG….stx-flash-receiver-trait and SP20XD46….flashstack-stx-core pulled in as [[project.requirements]]: ✔.
  • 9 simnet tests, all through the real core at its real mainnet address. Requirements are deployed at their original principals, so contract-caller matches FLASH-CORE and the full flash-loan path runs. That's the path bitflow-arb-receiver-v5-security.test.ts notes it can't reach with the local core copy.
    • direct execute-stx-flash call → u401
    • stranger's flash-loan, nothing armed → u402, no balance moves
    • only the owner can arm/disarm, never for zero (u403, u400)
    • armed loan closes: core +fee, receiver −fee, arm cleared
    • arm is single-use; arm is bound to the amount (a different amount reverts and the arm survives)
    • a stranger matching the armed amount only runs the loan the owner already asked for, once
    • fee follows set-fee-basis-points (20 bp test)
  • Mutation check: deleting the arm assertion makes 5 of the 9 tests fail. Restored: 9/9.
  • tsc --noEmit passes. No new lint problems (the 2 in page.tsx were already on main).
  • No screenshots this time. My browser tooling didn't connect in this session. The change is text inside the same <pre> blocks whose overflow behaviour was checked at 390/1440 in fix(web): teach the hardened receiver pattern, not the F-10 one #127.
Clarinet.toml and test file (vitest 4.1, clarinet-sdk 3.25), if Hillary wants to rerun

contracts/armed-receiver.clar is the landing snippet verbatim.

[project]
name = "armcheck"

[[project.requirements]]
contract_id = "SP3TGRVG7DKGFVRTTVGGS60S59R916FWB4DAB9STZ.stx-flash-receiver-trait"

[[project.requirements]]
contract_id = "SP20XD46NGAX05ZQZDKFYCCX49A3852BQABNP0VG5.flashstack-stx-core"

[contracts.armed-receiver]
path = "contracts/armed-receiver.clar"
clarity_version = 3
epoch = 3.4
import { beforeEach, describe, expect, it } from "vitest";
import { Cl, cvToValue } from "@stacks/transactions";

// The receiver under test is the landing-page snippet, byte for byte
// (contracts/armed-receiver.clar). The lender is the real mainnet
// flashstack-stx-core, deployed by simnet at its real address from
// [[project.requirements]], so contract-caller matches FLASH-CORE exactly
// and the full flash-loan path runs.

const CORE_ADDR = "SP20XD46NGAX05ZQZDKFYCCX49A3852BQABNP0VG5";
const CORE = `${CORE_ADDR}.flashstack-stx-core`;
const LOAN = 10_000_000; // 10 STX
const FEE = Math.max(1, Math.floor((LOAN * 5) / 10_000)); // 5 bp, min 1 µSTX
const BUFFER = 1_000_000; // receiver pre-funded with 1 STX for fees

let deployer: string; // receiver owner
let stranger: string;
let admin: string; // core admin
let RX: string;

const stx = (who: string) => Number(simnet.getAssetsMap().get("STX")?.get(who) ?? 0n);
const armed = () => cvToValue(simnet.getDataVar(RX, "armed"));
const flashLoan = (amount: number, sender: string) =>
  simnet.callPublicFn(CORE, "flash-loan", [Cl.uint(amount), Cl.principal(RX)], sender).result;
const arm = (amount: number, sender: string) =>
  simnet.callPublicFn(RX, "arm-loan", [Cl.uint(amount)], sender).result;

beforeEach(() => {
  const accounts = simnet.getAccounts();
  deployer = accounts.get("deployer")!;
  stranger = accounts.get("wallet_2")!;
  RX = `${deployer}.armed-receiver`;
  admin = cvToValue(simnet.getDataVar(CORE, "admin"));

  simnet.transferSTX(2_000_000_000, admin, deployer);
  expect(simnet.callPublicFn(CORE, "deposit-reserve", [Cl.uint(1_000_000_000)], admin).result).toStrictEqual(Cl.ok(Cl.bool(true)));
  expect(simnet.callPublicFn(CORE, "add-approved-receiver", [Cl.principal(RX)], admin).result).toStrictEqual(Cl.ok(Cl.bool(true)));
  simnet.transferSTX(BUFFER, RX, deployer);
});

describe("armed receiver snippet (RECEIVER_VETTING B4, B5, C7, D12)", () => {
  it("B4: rejects execute-stx-flash called directly, even with the real core as argument", () => {
    const r = simnet.callPublicFn(RX, "execute-stx-flash", [Cl.uint(LOAN), Cl.principal(CORE)], stranger).result;
    expect(r).toStrictEqual(Cl.error(Cl.uint(401)));
  });

  it("C7: a stranger's flash-loan naming the receiver reverts when nothing is armed, and nothing moves", () => {
    const before = { rx: stx(RX), core: stx(CORE) };
    expect(flashLoan(LOAN, stranger)).toStrictEqual(Cl.error(Cl.uint(402)));
    expect({ rx: stx(RX), core: stx(CORE) }).toEqual(before);
  });

  it("only the owner can arm or disarm, and never for zero", () => {
    expect(arm(LOAN, stranger)).toStrictEqual(Cl.error(Cl.uint(403)));
    expect(arm(0, deployer)).toStrictEqual(Cl.error(Cl.uint(400)));
    expect(simnet.callPublicFn(RX, "disarm", [], stranger).result).toStrictEqual(Cl.error(Cl.uint(403)));
    expect(armed()).toBeNull();
  });

  it("an armed loan closes the loop: core gains the fee, receiver pays it, the arm is cleared", () => {
    const before = { rx: stx(RX), core: stx(CORE) };
    expect(arm(LOAN, deployer)).toStrictEqual(Cl.ok(Cl.bool(true)));
    expect(flashLoan(LOAN, deployer)).toStrictEqual(Cl.ok(Cl.bool(true)));
    expect(stx(CORE)).toBe(before.core + FEE);
    expect(stx(RX)).toBe(before.rx - FEE);
    expect(armed()).toBeNull();
  });

  it("C7: the arm is single-use", () => {
    arm(LOAN, deployer);
    expect(flashLoan(LOAN, deployer)).toStrictEqual(Cl.ok(Cl.bool(true)));
    expect(flashLoan(LOAN, deployer)).toStrictEqual(Cl.error(Cl.uint(402)));
  });

  it("C7: the arm is bound to the amount; a different amount reverts and the arm survives", () => {
    arm(LOAN, deployer);
    expect(flashLoan(LOAN + 1, stranger)).toStrictEqual(Cl.error(Cl.uint(402)));
    expect(simnet.getDataVar(RX, "armed")).toStrictEqual(Cl.some(Cl.uint(LOAN)));
  });

  it("C7: a stranger matching the armed amount only runs the loan the owner already asked for", () => {
    arm(LOAN, deployer);
    const before = stx(RX);
    expect(flashLoan(LOAN, stranger)).toStrictEqual(Cl.ok(Cl.bool(true)));
    expect(stx(RX)).toBe(before - FEE); // exactly the fee the owner accepted, once
    expect(armed()).toBeNull();
    expect(flashLoan(LOAN, stranger)).toStrictEqual(Cl.error(Cl.uint(402)));
  });

  it("disarm clears a pending arm", () => {
    arm(LOAN, deployer);
    expect(simnet.callPublicFn(RX, "disarm", [], deployer).result).toStrictEqual(Cl.ok(Cl.bool(true)));
    expect(flashLoan(LOAN, deployer)).toStrictEqual(Cl.error(Cl.uint(402)));
  });

  it("D12: the fee follows the core's get-fee-basis-points, not a hardcoded 5 bp", () => {
    expect(simnet.callPublicFn(CORE, "set-fee-basis-points", [Cl.uint(20)], admin).result).toStrictEqual(Cl.ok(Cl.bool(true)));
    const before = stx(CORE);
    arm(LOAN, deployer);
    expect(flashLoan(LOAN, deployer)).toStrictEqual(Cl.ok(Cl.bool(true)));
    expect(stx(CORE)).toBe(before + Math.floor((LOAN * 20) / 10_000));
  });
});

Happy to add this test to tests/ if you want it in CI. It needs the snippet as a contract entry in Clarinet.toml, which I didn't want to add to the deployment config without asking.

Additional Context: the 5,000 vs 500,000 cap

While reading the core for this, I think I found where the 5,000 STX number comes from. flashstack-stx-core.clar has:

(define-data-var max-single-loan   uint      u500000000000) ;; 5,000 STX (microstacks)

u500000000000 µSTX is 500,000 STX, so the comment is off by 100×, and the form's MAX_LOAN_STX = 5000 (with its own contradictory comment) looks copied from it. Which value is intended is still your call; I only fixed text here.

Deployment Considerations

  • Backward compatible

No contract changes.


By submitting this PR, I confirm that:

  • My contribution is made under the MIT License
  • I have read and followed the CONTRIBUTING.md guidelines
  • I am the original author or have permission to contribute this code

Follow-up to mattglory#127, per review: anyone can call flash-loan naming a
receiver, so a receiver built from the mattglory#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.
@vercel

vercel Bot commented Oct 20, 2026

Copy link
Copy Markdown

@emmanuelist is attempting to deploy a commit to the matt glory's projects Team on Vercel.

A member of the Team first needs to authorize it.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant