Skip to content
Merged
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
42 changes: 42 additions & 0 deletions contracts/tipstream.clar
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
(
Expand Down Expand Up @@ -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)))
)
8 changes: 4 additions & 4 deletions deployments/default.simnet-plan.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 33 additions & 2 deletions frontend/src/components/SendTip.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,24 @@ 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();
const [recipient, setRecipient] = useState('');
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('');
Expand Down Expand Up @@ -169,15 +180,16 @@ export default function SendTip({ addToast }) {
const functionArgs = [
principalCV(recipient),
uintCV(microSTX),
stringUtf8CV(message || 'Thanks!')
stringUtf8CV(message || 'Thanks!'),
uintCV(category)
];

const options = {
network,
appDetails,
contractAddress: CONTRACT_ADDRESS,
contractName: CONTRACT_NAME,
functionName: 'send-tip',
functionName: 'send-categorized-tip',
functionArgs,
postConditions,
postConditionMode: PostConditionMode.Deny,
Expand All @@ -191,6 +203,7 @@ export default function SendTip({ addToast }) {
setRecipient('');
setAmount('');
setMessage('');
setCategory(0);
notifyTipSent();
refetchBalance();
startCooldown();
Expand Down Expand Up @@ -299,6 +312,23 @@ export default function SendTip({ addToast }) {
</div>
</div>

<div>
<label className="block text-sm font-semibold text-gray-600 dark:text-gray-300 mb-2">
Category
</label>
<select
value={category}
onChange={(e) => setCategory(Number(e.target.value))}
className="w-full px-4 py-2 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-white rounded-lg focus:ring-2 focus:ring-gray-900 dark:focus:ring-gray-400 focus:border-transparent transition-all outline-none"
>
{TIP_CATEGORIES.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.label}
</option>
))}
</select>
</div>

{amount && parseFloat(amount) > 0 && (
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200 text-sm">
<p className="font-semibold text-gray-700 mb-2">Transaction Breakdown</p>
Expand Down Expand Up @@ -385,6 +415,7 @@ export default function SendTip({ addToast }) {
<div className="space-y-2">
<p>You are about to send <strong>{amount} STX</strong> to:</p>
<p className="font-mono text-xs bg-gray-100 p-2 rounded break-all">{recipient}</p>
<p className="text-sm text-gray-600">Category: <strong>{TIP_CATEGORIES.find(c => c.id === category)?.label || 'General'}</strong></p>
{message && <p className="italic text-gray-500">"{message}"</p>}
</div>
</ConfirmDialog>
Expand Down
59 changes: 50 additions & 9 deletions frontend/src/components/TipHistory.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,24 @@ 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);
const [tips, setTips] = useState([]);
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 () => {
Expand All @@ -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);
Expand Down Expand Up @@ -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] : '',
Expand All @@ -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;
Expand All @@ -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;
});

Expand Down Expand Up @@ -195,9 +219,9 @@ export default function TipHistory({ userAddress }) {
</div>

<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-6">
<h3 className="text-lg font-bold text-gray-800">Tip History</h3>
<div className="flex gap-2">
<div className="flex flex-wrap items-center gap-2">
{['all', 'sent', 'received'].map((t) => (
<button
key={t}
Expand All @@ -207,6 +231,16 @@ export default function TipHistory({ userAddress }) {
{t}
</button>
))}
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="px-3 py-1.5 rounded-lg text-xs font-bold bg-slate-100 text-slate-600 border-none outline-none hover:bg-slate-200 transition-all dark:bg-gray-800 dark:text-gray-300"
>
<option value="all">All Categories</option>
{Object.entries(CATEGORY_LABELS).map(([id, label]) => (
<option key={id} value={id}>{label}</option>
))}
</select>
</div>
</div>

Expand Down Expand Up @@ -235,9 +269,16 @@ export default function TipHistory({ userAddress }) {
className="text-slate-400 hover:text-slate-600"
/>
</div>
{tip.message && (
<p className="text-xs text-slate-400 mt-0.5 italic">"{tip.message}"</p>
)}
<div className="flex items-center gap-1.5 mt-0.5">
{tip.category != null && CATEGORY_LABELS[tip.category] && (
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
{CATEGORY_LABELS[tip.category]}
</span>
)}
{tip.message && (
<span className="text-xs text-slate-400 italic">"{tip.message}"</span>
)}
</div>
</div>
</div>
<p className={`font-black ${tip.direction === 'sent' ? 'text-red-600' : 'text-green-600'}`}>
Expand Down
78 changes: 78 additions & 0 deletions tests/tipstream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
Loading