diff --git a/contracts/tipstream.clar b/contracts/tipstream.clar index 3fdda4e1..d05210ea 100644 --- a/contracts/tipstream.clar +++ b/contracts/tipstream.clar @@ -23,6 +23,17 @@ (define-constant err-token-transfer-failed (err u112)) (define-constant err-token-not-whitelisted (err u113)) +(define-constant err-invalid-category (err u114)) + +;; Tip Categories (uint enum) +(define-constant category-general u0) +(define-constant category-content-creation u1) +(define-constant category-open-source u2) +(define-constant category-community-help u3) +(define-constant category-appreciation u4) +(define-constant category-education u5) +(define-constant category-bug-bounty u6) +(define-constant max-category u6) (define-constant basis-points-divisor u10000) (define-constant min-tip-amount u1000) @@ -71,6 +82,9 @@ (define-map blocked-users { blocker: principal, blocked: principal } bool) +(define-map tip-category { tip-id: uint } uint) +(define-map category-tip-count uint uint) + (define-map whitelisted-tokens principal bool) (define-data-var total-token-tips uint u0) (define-map token-tips @@ -190,6 +204,26 @@ ) ) +(define-public (send-categorized-tip (recipient principal) (amount uint) (message (string-utf8 280)) (category uint)) + (begin + (asserts! (<= category max-category) err-invalid-category) + (let + ( + (tip-id-response (try! (send-tip recipient amount message))) + (current-count (default-to u0 (map-get? category-tip-count category))) + ) + (map-set tip-category { tip-id: tip-id-response } category) + (map-set category-tip-count category (+ current-count u1)) + (print { + event: "tip-categorized", + tip-id: tip-id-response, + category: category + }) + (ok tip-id-response) + ) + ) +) + (define-public (tip-a-tip (target-tip-id uint) (amount uint) (message (string-utf8 280))) (let ( @@ -506,3 +540,11 @@ (define-read-only (get-total-token-tips) (ok (var-get total-token-tips)) ) + +(define-read-only (get-tip-category (tip-id uint)) + (ok (default-to u0 (map-get? tip-category { tip-id: tip-id }))) +) + +(define-read-only (get-category-count (category uint)) + (ok (default-to u0 (map-get? category-tip-count category))) +) diff --git a/deployments/default.simnet-plan.yaml b/deployments/default.simnet-plan.yaml index 6026e8a7..a840ff83 100644 --- a/deployments/default.simnet-plan.yaml +++ b/deployments/default.simnet-plan.yaml @@ -64,14 +64,14 @@ plan: - id: 0 transactions: - emulated-contract-publish: - contract-name: tipstream + contract-name: tipstream-traits emulated-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM - path: contracts/tipstream.clar + path: contracts/tipstream-traits.clar clarity-version: 2 - emulated-contract-publish: - contract-name: tipstream-traits + contract-name: tipstream emulated-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM - path: contracts/tipstream-traits.clar + path: contracts/tipstream.clar clarity-version: 2 - emulated-contract-publish: contract-name: tipstream-badges diff --git a/frontend/src/components/SendTip.jsx b/frontend/src/components/SendTip.jsx index ced5c2ff..a5a92618 100644 --- a/frontend/src/components/SendTip.jsx +++ b/frontend/src/components/SendTip.jsx @@ -23,6 +23,16 @@ const MIN_TIP_STX = 0.001; const MAX_TIP_STX = 10000; const COOLDOWN_SECONDS = 10; +const TIP_CATEGORIES = [ + { id: 0, label: 'General' }, + { id: 1, label: 'Content Creation' }, + { id: 2, label: 'Open Source' }, + { id: 3, label: 'Community Help' }, + { id: 4, label: 'Appreciation' }, + { id: 5, label: 'Education' }, + { id: 6, label: 'Bug Bounty' }, +]; + export default function SendTip({ addToast }) { const { notifyTipSent } = useTipContext(); const { toUsd } = useStxPrice(); @@ -30,6 +40,7 @@ export default function SendTip({ addToast }) { const [amount, setAmount] = useState(''); const [message, setMessage] = useState(''); const [loading, setLoading] = useState(false); + const [category, setCategory] = useState(0); const [showConfirm, setShowConfirm] = useState(false); const [pendingTx, setPendingTx] = useState(null); const [recipientError, setRecipientError] = useState(''); @@ -169,7 +180,8 @@ export default function SendTip({ addToast }) { const functionArgs = [ principalCV(recipient), uintCV(microSTX), - stringUtf8CV(message || 'Thanks!') + stringUtf8CV(message || 'Thanks!'), + uintCV(category) ]; const options = { @@ -177,7 +189,7 @@ export default function SendTip({ addToast }) { appDetails, contractAddress: CONTRACT_ADDRESS, contractName: CONTRACT_NAME, - functionName: 'send-tip', + functionName: 'send-categorized-tip', functionArgs, postConditions, postConditionMode: PostConditionMode.Deny, @@ -191,6 +203,7 @@ export default function SendTip({ addToast }) { setRecipient(''); setAmount(''); setMessage(''); + setCategory(0); notifyTipSent(); refetchBalance(); startCooldown(); @@ -299,6 +312,23 @@ export default function SendTip({ addToast }) { +
Transaction Breakdown
@@ -385,6 +415,7 @@ export default function SendTip({ addToast }) {You are about to send {amount} STX to:
{recipient}
+Category: {TIP_CATEGORIES.find(c => c.id === category)?.label || 'General'}
{message &&"{message}"
}"{tip.message}"
- )} +diff --git a/tests/tipstream.test.ts b/tests/tipstream.test.ts index 57e5db05..72781544 100644 --- a/tests/tipstream.test.ts +++ b/tests/tipstream.test.ts @@ -1016,4 +1016,82 @@ describe("TipStream Contract Tests", () => { expect(Number(count)).toBeGreaterThanOrEqual(1); }); }); + + describe("Tip Categories", () => { + it("can send a categorized tip", () => { + const { result } = simnet.callPublicFn( + "tipstream", + "send-categorized-tip", + [ + Cl.principal(wallet2), + Cl.uint(1000000), + Cl.stringUtf8("Great open-source work!"), + Cl.uint(2) // category-open-source + ], + wallet1 + ); + expect(result).not.toBeErr(); + }); + + it("rejects invalid category", () => { + const { result } = simnet.callPublicFn( + "tipstream", + "send-categorized-tip", + [ + Cl.principal(wallet2), + Cl.uint(1000000), + Cl.stringUtf8("Bad category"), + Cl.uint(99) // invalid category + ], + wallet1 + ); + expect(result).toBeErr(Cl.uint(114)); + }); + + it("tracks category counts", () => { + // Send two tips in education category + simnet.callPublicFn( + "tipstream", + "send-categorized-tip", + [Cl.principal(wallet2), Cl.uint(1000000), Cl.stringUtf8("Edu tip 1"), Cl.uint(5)], + wallet1 + ); + simnet.callPublicFn( + "tipstream", + "send-categorized-tip", + [Cl.principal(wallet2), Cl.uint(1000000), Cl.stringUtf8("Edu tip 2"), Cl.uint(5)], + wallet1 + ); + + const { result } = simnet.callReadOnlyFn( + "tipstream", + "get-category-count", + [Cl.uint(5)], + deployer + ); + const count = (result as any).value.value; + expect(Number(count)).toBeGreaterThanOrEqual(2); + }); + + it("can read tip category", () => { + const { result: sendResult } = simnet.callPublicFn( + "tipstream", + "send-categorized-tip", + [Cl.principal(wallet2), Cl.uint(1000000), Cl.stringUtf8("Bug bounty!"), Cl.uint(6)], + wallet1 + ); + expect(sendResult).not.toBeErr(); + + // Tip ID from first ever send-tip would be 0, but many tests ran before + // Just verify the read-only works for the bug-bounty category count + const { result } = simnet.callReadOnlyFn( + "tipstream", + "get-category-count", + [Cl.uint(6)], + deployer + ); + const count = (result as any).value.value; + expect(Number(count)).toBeGreaterThanOrEqual(1); + }); + }); });