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 }) { +
+ + +
+ {amount && parseFloat(amount) > 0 && (

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}"

}
diff --git a/frontend/src/components/TipHistory.jsx b/frontend/src/components/TipHistory.jsx index 4a60cbbe..2d6c266a 100644 --- a/frontend/src/components/TipHistory.jsx +++ b/frontend/src/components/TipHistory.jsx @@ -9,6 +9,16 @@ import ShareTip from './ShareTip'; const API_BASE = 'https://api.hiro.so'; +const CATEGORY_LABELS = { + 0: 'General', + 1: 'Content Creation', + 2: 'Open Source', + 3: 'Community Help', + 4: 'Appreciation', + 5: 'Education', + 6: 'Bug Bounty', +}; + export default function TipHistory({ userAddress }) { const { refreshCounter } = useTipContext(); const [stats, setStats] = useState(null); @@ -16,6 +26,7 @@ export default function TipHistory({ userAddress }) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [tab, setTab] = useState('all'); + const [categoryFilter, setCategoryFilter] = useState('all'); const [lastRefresh, setLastRefresh] = useState(null); const fetchData = useCallback(async () => { @@ -42,14 +53,24 @@ export default function TipHistory({ userAddress }) { const jsonResult = cvToJSON(statsResult); setStats(jsonResult.value); - const userTips = tipsResult.results + const allEvents = tipsResult.results .filter(e => e.contract_log?.value?.repr) .map(e => parseTipEvent(e.contract_log.value.repr)) - .filter(t => t && t.event === 'tip-sent') + .filter(Boolean); + + // Build a map of tip-id → category from tip-categorized events + const categoryMap = {}; + allEvents + .filter(e => e.event === 'tip-categorized') + .forEach(e => { categoryMap[e.tipId] = Number(e.category || 0); }); + + const userTips = allEvents + .filter(t => t.event === 'tip-sent') .filter(t => t.sender === userAddress || t.recipient === userAddress) .map(t => ({ ...t, direction: t.sender === userAddress ? 'sent' : 'received', + category: categoryMap[t.tipId] ?? null, })); setTips(userTips); @@ -86,6 +107,7 @@ export default function TipHistory({ userAddress }) { const feeMatch = repr.match(/fee\s+u(\d+)/); const messageMatch = repr.match(/message\s+u"([^"]*)"/); const tipIdMatch = repr.match(/tip-id\s+u(\d+)/); + const categoryMatch = repr.match(/category\s+u(\d+)/); return { event: eventMatch[1], sender: senderMatch ? senderMatch[1] : '', @@ -94,6 +116,7 @@ export default function TipHistory({ userAddress }) { fee: feeMatch ? feeMatch[1] : '0', message: messageMatch ? messageMatch[1] : '', tipId: tipIdMatch ? tipIdMatch[1] : '0', + category: categoryMatch ? categoryMatch[1] : null, }; } catch { return null; @@ -103,8 +126,9 @@ export default function TipHistory({ userAddress }) { const truncateAddr = (addr) => `${addr.slice(0, 8)}...${addr.slice(-6)}`; const filteredTips = tips.filter(t => { - if (tab === 'sent') return t.direction === 'sent'; - if (tab === 'received') return t.direction === 'received'; + if (tab === 'sent' && t.direction !== 'sent') return false; + if (tab === 'received' && t.direction !== 'received') return false; + if (categoryFilter !== 'all' && t.category !== Number(categoryFilter)) return false; return true; }); @@ -195,9 +219,9 @@ export default function TipHistory({ userAddress }) {
-
+

Tip History

-
+
{['all', 'sent', 'received'].map((t) => (
@@ -235,9 +269,16 @@ export default function TipHistory({ userAddress }) { className="text-slate-400 hover:text-slate-600" />
- {tip.message && ( -

"{tip.message}"

- )} +
+ {tip.category != null && CATEGORY_LABELS[tip.category] && ( + + {CATEGORY_LABELS[tip.category]} + + )} + {tip.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); + }); + }); });