diff --git a/guilds/Clarinet.toml b/guilds/Clarinet.toml index 19b293b..fa61ded 100644 --- a/guilds/Clarinet.toml +++ b/guilds/Clarinet.toml @@ -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'] diff --git a/guilds/contracts/legion-engage.clar b/guilds/contracts/legion-engage.clar new file mode 100644 index 0000000..2d124b8 --- /dev/null +++ b/guilds/contracts/legion-engage.clar @@ -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 ) + (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 ) + (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 )) + (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 }) + ) +) diff --git a/guilds/tests/engage.test.ts b/guilds/tests/engage.test.ts new file mode 100644 index 0000000..0375afa --- /dev/null +++ b/guilds/tests/engage.test.ts @@ -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)); + }); +});