Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions guilds/Clarinet.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ path = 'contracts/legion-providers.clar'
clarity_version = 3
epoch = 3.0

[contracts.legion-engage]
path = 'contracts/legion-engage.clar'
clarity_version = 3
epoch = 3.0

[repl.analysis]
passes = ['check_checker']

Expand Down
141 changes: 141 additions & 0 deletions guilds/contracts/legion-engage.clar
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
;; legion-engage
;; Providers STAKE sBTC to join a legion and unlock benefits (ranking weight,
;; treasury share, governance). This is OPTIONAL engagement, NOT a requirement to
;; earn: earning is free at the gateway. The stake only buys you "more".
;;
;; There is NO admin and NO slash. Nothing in this contract can seize a member's
;; stake. A member's stake is fully refundable on `leave`, MINUS a fixed exit fee
;; that is routed into the legion treasury (so leaving leaves a little for the
;; commons). The wired sBTC token is a compile-time constant, so there is not even
;; a wiring admin.

(use-trait sip010-trait 'STTWD9SPRQVD3P733V89SV0P8RZRZNQADG034F0A.faktory-trait-v1.sip-010-trait)

;; The only sBTC this contract accepts (the Faktory sBTC the Legion is wired to).
(define-constant SBTC 'STV9K21TBFAK4KNRJXF5DFP8N7W46G4V9RJ5XDY2.sbtc-token)
;; Exit fees route into `.legion-treasury` (same guild bundle); contract-call?
;; requires the literal contract principal, so it is referenced inline below.

;; -------------------------------------------------------------------
;; Errors
;; -------------------------------------------------------------------
(define-constant ERR_NOT_FOUND (err u404))
(define-constant ERR_ALREADY_MEMBER (err u406))
(define-constant ERR_BELOW_MIN_STAKE (err u405))
(define-constant ERR_ZERO_AMOUNT (err u409))
(define-constant ERR_WRONG_TOKEN (err u412))

;; -------------------------------------------------------------------
;; Config
;; -------------------------------------------------------------------
(define-constant MIN_STAKE u10000) ;; 10k sats minimum to join
(define-constant EXIT_FEE_BPS u1000) ;; 10% exit fee, in basis points

;; -------------------------------------------------------------------
;; State
;; -------------------------------------------------------------------
(define-data-var TotalStaked uint u0)

(define-map Members
principal
{
stake: uint,
joined-at: uint,
}
)

;; -------------------------------------------------------------------
;; Read-only views
;; -------------------------------------------------------------------
(define-read-only (get-member (who principal)) (map-get? Members who))
(define-read-only (is-member (who principal)) (is-some (map-get? Members who)))
(define-read-only (get-stake (who principal))
(match (map-get? Members who) m (get stake m) u0)
)
(define-read-only (get-total-staked) (var-get TotalStaked))
(define-read-only (get-min-stake) MIN_STAKE)
(define-read-only (get-exit-fee-bps) EXIT_FEE_BPS)
(define-read-only (get-token) SBTC)

;; What a member would get back if they left now: { stake, fee, refund }.
(define-read-only (quote-exit (who principal))
(match (map-get? Members who)
m (let (
(s (get stake m))
(fee (/ (* (get stake m) EXIT_FEE_BPS) u10000))
)
(some {
stake: s,
fee: fee,
refund: (- s fee),
}))
none
)
)

;; -------------------------------------------------------------------
;; Join: lock sBTC and become a member.
;; -------------------------------------------------------------------
(define-public (join
(ft <sip010-trait>)
(amount uint)
)
(begin
(asserts! (is-eq (contract-of ft) SBTC) ERR_WRONG_TOKEN)
(asserts! (is-none (map-get? Members tx-sender)) ERR_ALREADY_MEMBER)
(asserts! (>= amount MIN_STAKE) ERR_BELOW_MIN_STAKE)
(try! (contract-call? ft transfer amount tx-sender (as-contract tx-sender) none))
(map-set Members tx-sender {
stake: amount,
joined-at: stacks-block-height,
})
(var-set TotalStaked (+ (var-get TotalStaked) amount))
(print { event: "join", member: tx-sender, stake: amount })
(ok true)
)
)

;; -------------------------------------------------------------------
;; Top up an existing stake.
;; -------------------------------------------------------------------
(define-public (add-stake
(ft <sip010-trait>)
(amount uint)
)
(let ((m (unwrap! (map-get? Members tx-sender) ERR_NOT_FOUND)))
(asserts! (is-eq (contract-of ft) SBTC) ERR_WRONG_TOKEN)
(asserts! (> amount u0) ERR_ZERO_AMOUNT)
(try! (contract-call? ft transfer amount tx-sender (as-contract tx-sender) none))
(map-set Members tx-sender (merge m { stake: (+ (get stake m) amount) }))
(var-set TotalStaked (+ (var-get TotalStaked) amount))
(print { event: "add-stake", member: tx-sender, added: amount, stake: (+ (get stake m) amount) })
(ok true)
)
)

;; -------------------------------------------------------------------
;; Leave: refund stake minus the exit fee. Fee -> treasury, refund -> member.
;; Effects-before-interaction: state is cleared before any token movement.
;; -------------------------------------------------------------------
(define-public (leave (ft <sip010-trait>))
(let (
(m (unwrap! (map-get? Members tx-sender) ERR_NOT_FOUND))
(who tx-sender)
(s (get stake m))
(fee (/ (* (get stake m) EXIT_FEE_BPS) u10000))
(refund (- (get stake m) (/ (* (get stake m) EXIT_FEE_BPS) u10000)))
)
(asserts! (is-eq (contract-of ft) SBTC) ERR_WRONG_TOKEN)
(map-delete Members who)
(var-set TotalStaked (- (var-get TotalStaked) s))
;; refund the member (stake minus fee)
(try! (as-contract (contract-call? ft transfer refund tx-sender who none)))
;; route the exit fee into the treasury's accounted balance (governable)
(if (> fee u0)
(try! (as-contract (contract-call? .legion-treasury deposit ft fee)))
true
)
(print { event: "leave", member: who, refund: refund, fee: fee })
(ok { refund: refund, fee: fee })
)
)
91 changes: 91 additions & 0 deletions guilds/tests/engage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, expect, it, beforeEach } from "vitest";
import { Cl } from "@stacks/transactions";

const accounts = simnet.getAccounts();
const deployer = accounts.get("deployer")!;
const m1 = accounts.get("wallet_1")!;
const m2 = accounts.get("wallet_2")!;

const E = "legion-engage";
const T = "legion-treasury";
const SBTC = "STV9K21TBFAK4KNRJXF5DFP8N7W46G4V9RJ5XDY2.sbtc-token";
const MIN_STAKE = 10_000;

function faucet(who: string, times = 1) {
for (let i = 0; i < times; i++) {
expect(simnet.callPublicFn(SBTC, "faucet", [], who).result).toBeOk(Cl.bool(true));
}
}
function sbtcOf(who: string): bigint {
const r = simnet.callReadOnlyFn(SBTC, "get-balance", [Cl.principal(who)], deployer);
return (r.result as any).value.value as bigint;
}
// The treasury must have its token wired to accept the exit-fee deposit.
function wireTreasuryToken() {
expect(simnet.callPublicFn(T, "set-token", [Cl.principal(SBTC)], deployer).result).toBeOk(Cl.bool(true));
}
function join(who: string, amount: number) {
return simnet.callPublicFn(E, "join", [Cl.principal(SBTC), Cl.uint(amount)], who);
}
function getStake(who: string): bigint {
const r = simnet.callReadOnlyFn(E, "get-stake", [Cl.principal(who)], deployer);
return (r.result as any).value as bigint;
}
function treasuryBalance(): bigint {
const r = simnet.callReadOnlyFn(T, "get-balance", [], deployer);
return (r.result as any).value as bigint;
}

describe("legion-engage", () => {
beforeEach(() => { wireTreasuryToken(); faucet(m1, 1); });

it("rejects a stake below MIN_STAKE (u405) and joins at/above it", () => {
expect(join(m1, MIN_STAKE - 1).result).toBeErr(Cl.uint(405));
expect(join(m1, MIN_STAKE).result).toBeOk(Cl.bool(true));
expect(simnet.callReadOnlyFn(E, "is-member", [Cl.principal(m1)], deployer).result).toBeBool(true);
expect(getStake(m1)).toBe(BigInt(MIN_STAKE));
});

it("holds the stake in-contract on join", () => {
const before = sbtcOf(m1);
expect(join(m1, 100_000).result).toBeOk(Cl.bool(true));
expect(before - sbtcOf(m1)).toBe(100_000n);
});

it("blocks a double join (u406)", () => {
expect(join(m1, MIN_STAKE).result).toBeOk(Cl.bool(true));
expect(join(m1, MIN_STAKE).result).toBeErr(Cl.uint(406));
});

it("add-stake increases the member's stake", () => {
expect(join(m1, MIN_STAKE).result).toBeOk(Cl.bool(true));
expect(simnet.callPublicFn(E, "add-stake", [Cl.principal(SBTC), Cl.uint(5_000)], m1).result).toBeOk(Cl.bool(true));
expect(getStake(m1)).toBe(BigInt(MIN_STAKE + 5_000));
});

it("leave refunds 90% to the member and routes 10% to the treasury", () => {
const before = sbtcOf(m1);
expect(join(m1, 100_000).result).toBeOk(Cl.bool(true));
const tBefore = treasuryBalance();
const res = simnet.callPublicFn(E, "leave", [Cl.principal(SBTC)], m1);
expect(res.result).toBeOk(Cl.tuple({ refund: Cl.uint(90_000), fee: Cl.uint(10_000) }));
// member's net loss over the whole join+leave cycle is exactly the 10% fee
expect(before - sbtcOf(m1)).toBe(10_000n);
// the fee landed in the treasury's accounted (governable) balance
expect(treasuryBalance() - tBefore).toBe(10_000n);
// membership is cleared
expect(simnet.callReadOnlyFn(E, "is-member", [Cl.principal(m1)], deployer).result).toBeBool(false);
expect(getStake(m1)).toBe(0n);
});

it("quote-exit reports the 90/10 split for a member", () => {
expect(join(m1, 100_000).result).toBeOk(Cl.bool(true));
expect(simnet.callReadOnlyFn(E, "quote-exit", [Cl.principal(m1)], deployer).result)
.toBeSome(Cl.tuple({ stake: Cl.uint(100_000), fee: Cl.uint(10_000), refund: Cl.uint(90_000) }));
});

it("rejects leave / add-stake when not a member (u404)", () => {
expect(simnet.callPublicFn(E, "leave", [Cl.principal(SBTC)], m2).result).toBeErr(Cl.uint(404));
expect(simnet.callPublicFn(E, "add-stake", [Cl.principal(SBTC), Cl.uint(5_000)], m2).result).toBeErr(Cl.uint(404));
});
});