diff --git a/contracts/tipstream.clar b/contracts/tipstream.clar index c56821a4..3fdda4e1 100644 --- a/contracts/tipstream.clar +++ b/contracts/tipstream.clar @@ -1,6 +1,8 @@ ;; TipStream - Micro-tipping platform on Stacks ;; Version: 1.0.0 +(use-trait sip-010-trait .tipstream-traits.sip-010-trait) + ;; Version Tracking (define-constant contract-version u1) (define-constant contract-name "tipstream-core") @@ -19,6 +21,9 @@ (define-constant err-no-pending-change (err u110)) (define-constant err-not-authorized (err u111)) +(define-constant err-token-transfer-failed (err u112)) +(define-constant err-token-not-whitelisted (err u113)) + (define-constant basis-points-divisor u10000) (define-constant min-tip-amount u1000) (define-constant min-fee u1) @@ -66,6 +71,20 @@ (define-map blocked-users { blocker: principal, blocked: principal } bool) +(define-map whitelisted-tokens principal bool) +(define-data-var total-token-tips uint u0) +(define-map token-tips + { token-tip-id: uint } + { + sender: principal, + recipient: principal, + token-contract: principal, + amount: uint, + message: (string-utf8 280), + tip-height: uint + } +) + ;; Private Functions (define-private (calculate-fee (amount uint)) (let @@ -181,6 +200,63 @@ ) ) +;; SIP-010 Token Tipping +(define-public (send-token-tip + (token ) + (recipient principal) + (amount uint) + (message (string-utf8 280)) +) + (let + ( + (token-principal (contract-of token)) + (tip-id (var-get total-token-tips)) + ) + (asserts! (not (var-get is-paused)) err-contract-paused) + (asserts! (> amount u0) err-invalid-amount) + (asserts! (not (is-eq tx-sender recipient)) err-invalid-amount) + (asserts! (default-to false (map-get? whitelisted-tokens token-principal)) err-token-not-whitelisted) + (asserts! (not (default-to false (map-get? blocked-users { blocker: recipient, blocked: tx-sender }))) err-user-blocked) + + (unwrap! (contract-call? token transfer amount tx-sender recipient none) err-token-transfer-failed) + + (map-set token-tips + { token-tip-id: tip-id } + { + sender: tx-sender, + recipient: recipient, + token-contract: token-principal, + amount: amount, + message: message, + tip-height: block-height + } + ) + + (var-set total-token-tips (+ tip-id u1)) + + (print { + event: "token-tip-sent", + token-tip-id: tip-id, + sender: tx-sender, + recipient: recipient, + token-contract: token-principal, + amount: amount, + message: message + }) + + (ok tip-id) + ) +) + +(define-public (whitelist-token (token-contract principal) (allowed bool)) + (begin + (asserts! (is-admin) err-owner-only) + (map-set whitelisted-tokens token-contract allowed) + (print { event: "token-whitelist-updated", token-contract: token-contract, allowed: allowed }) + (ok true) + ) +) + ;; Privacy Functions (define-public (toggle-block-user (user principal)) (let @@ -418,3 +494,15 @@ (define-read-only (get-contract-version) (ok { version: contract-version, name: contract-name }) ) + +(define-read-only (get-token-tip (token-tip-id uint)) + (map-get? token-tips { token-tip-id: token-tip-id }) +) + +(define-read-only (is-token-whitelisted (token-contract principal)) + (ok (default-to false (map-get? whitelisted-tokens token-contract))) +) + +(define-read-only (get-total-token-tips) + (ok (var-get total-token-tips)) +) diff --git a/tests/tipstream.test.ts b/tests/tipstream.test.ts index 7f1d1623..57e5db05 100644 --- a/tests/tipstream.test.ts +++ b/tests/tipstream.test.ts @@ -891,4 +891,129 @@ describe("TipStream Contract Tests", () => { expect(msig).toBeOk(Cl.none()); }); }); + + describe("SIP-010 Token Tipping", () => { + it("rejects token tip for non-whitelisted token", () => { + const { result } = simnet.callPublicFn( + "tipstream", + "send-token-tip", + [ + Cl.contractPrincipal(deployer.split(".")[0] || deployer, "tipstream-token"), + Cl.principal(wallet2), + Cl.uint(1000), + Cl.stringUtf8("token tip"), + ], + wallet1 + ); + expect(result).toBeErr(Cl.uint(113)); + }); + + it("admin can whitelist a token", () => { + const tokenPrincipal = `${deployer}.tipstream-token`; + const { result } = simnet.callPublicFn( + "tipstream", + "whitelist-token", + [Cl.principal(tokenPrincipal), Cl.bool(true)], + deployer + ); + expect(result).toBeOk(Cl.bool(true)); + + const { result: check } = simnet.callReadOnlyFn( + "tipstream", + "is-token-whitelisted", + [Cl.principal(tokenPrincipal)], + deployer + ); + expect(check).toBeOk(Cl.bool(true)); + }); + + it("non-admin cannot whitelist tokens", () => { + const tokenPrincipal = `${deployer}.tipstream-token`; + const { result } = simnet.callPublicFn( + "tipstream", + "whitelist-token", + [Cl.principal(tokenPrincipal), Cl.bool(true)], + wallet1 + ); + expect(result).toBeErr(Cl.uint(100)); + }); + + it("sends token tip with whitelisted token", () => { + const tokenPrincipal = `${deployer}.tipstream-token`; + + simnet.callPublicFn( + "tipstream", + "whitelist-token", + [Cl.principal(tokenPrincipal), Cl.bool(true)], + deployer + ); + + simnet.callPublicFn( + "tipstream-token", + "mint", + [Cl.uint(1000000), Cl.principal(wallet1)], + deployer + ); + + const { result } = simnet.callPublicFn( + "tipstream", + "send-token-tip", + [ + Cl.contractPrincipal(deployer.split(".")[0] || deployer, "tipstream-token"), + Cl.principal(wallet2), + Cl.uint(5000), + Cl.stringUtf8("TIPS for you!"), + ], + wallet1 + ); + expect(result).toBeOk(Cl.uint(0)); + + const { result: tipData } = simnet.callReadOnlyFn( + "tipstream", + "get-token-tip", + [Cl.uint(0)], + deployer + ); + expect(tipData).not.toBeNone(); + }); + + it("tracks total token tips count", () => { + const tokenPrincipal = `${deployer}.tipstream-token`; + + simnet.callPublicFn( + "tipstream", + "whitelist-token", + [Cl.principal(tokenPrincipal), Cl.bool(true)], + deployer + ); + + simnet.callPublicFn( + "tipstream-token", + "mint", + [Cl.uint(1000000), Cl.principal(wallet1)], + deployer + ); + + simnet.callPublicFn( + "tipstream", + "send-token-tip", + [ + Cl.contractPrincipal(deployer.split(".")[0] || deployer, "tipstream-token"), + Cl.principal(wallet2), + Cl.uint(1000), + Cl.stringUtf8("tip 1"), + ], + wallet1 + ); + + const { result } = simnet.callReadOnlyFn( + "tipstream", + "get-total-token-tips", + [], + deployer + ); + const count = (result as any).value.value; + expect(Number(count)).toBeGreaterThanOrEqual(1); + }); + }); });