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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ Content creators and community contributors lack a simple, transparent way to re
## Features

- **Direct STX Tipping** - Send micro-tips to any Stacks address with optional messages
- **Scheduled Tips** - Schedule tips to be sent at a future date and time (up to 365 days in advance)
- **Batch Tipping** - Tip up to 50 recipients in a single transaction with strict or partial modes
- **Recursive Tipping (Tip-a-Tip)** - Tip someone back directly from the live feed
- **User Profiles** - Set a display name, bio, and avatar URL stored on-chain
Expand Down Expand Up @@ -137,6 +138,8 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for the full system design.
| `/send` | Send Tip | Send a single STX tip | Stable |
| `/batch` | Batch Tip | Tip up to 50 recipients at once | Stable |
| `/token-tip` | Token Tip | Send a SIP-010 token tip (beta) | Beta |
| `/schedule` | Schedule Tip | Schedule a tip for future execution | Stable |
| `/scheduled-tips` | Scheduled Tips | View and manage scheduled tips | Stable |
| `/feed` | Live Feed | Real-time feed of recent tips with pagination | Stable |
| `/leaderboard` | Leaderboard | Top senders and receivers | Stable |
| `/activity` | My Activity | Personal tip history | Stable |
Expand Down
220 changes: 220 additions & 0 deletions chainhook/job-processor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import { logger } from './logging.js';
import { SCHEDULED_TIP_STATUSES } from './scheduler.js';

const PROCESSING_INTERVAL_MS = 60000;
const NOTIFICATION_LEAD_MINUTES = 60;

class JobProcessor {
constructor(scheduledTipStore, options = {}) {
this.store = scheduledTipStore;
this.processingInterval = options.processingInterval || PROCESSING_INTERVAL_MS;
this.notificationLeadMinutes = options.notificationLeadMinutes || NOTIFICATION_LEAD_MINUTES;
this.intervalId = null;
this.isProcessing = false;
this.onExecuteTip = options.onExecuteTip || null;
this.onNotifyTip = options.onNotifyTip || null;
}

start() {
if (this.intervalId) {
logger.warn('Job processor already running');
return;
}

logger.info('Starting scheduled tip job processor', {
processing_interval_ms: this.processingInterval,
notification_lead_minutes: this.notificationLeadMinutes,
});

this.intervalId = setInterval(() => {
this.processJobs().catch(err => {
logger.error('Job processing failed', err, {
error_message: err.message,
});
});
}, this.processingInterval);

this.processJobs().catch(err => {
logger.error('Initial job processing failed', err);
});
}

stop() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
logger.info('Scheduled tip job processor stopped');
}
}

async processJobs() {
if (this.isProcessing) {
logger.debug('Job processing already in progress, skipping');
return;
}

this.isProcessing = true;

try {
await this.processNotifications();
await this.processPendingTips();
} finally {
this.isProcessing = false;
}
}

async processNotifications() {
try {
const notifiableTips = await this.store.getNotifiableScheduledTips(this.notificationLeadMinutes);

if (notifiableTips.length === 0) {
return;
}

logger.info('Processing notifications for scheduled tips', {
count: notifiableTips.length,
});

for (const tip of notifiableTips) {
try {
if (this.onNotifyTip) {
await this.onNotifyTip(tip);
}

await this.store.updateScheduledTip(tip.id, {
notifiedAt: new Date(),
});

logger.info('Notification sent for scheduled tip', {
id: tip.id,
sender: tip.sender,
scheduled_for: tip.scheduledFor,
});
} catch (err) {
logger.error('Failed to notify scheduled tip', err, {
id: tip.id,
error_message: err.message,
});
}
}
} catch (err) {
logger.error('Failed to process notifications', err);
}
}

async processPendingTips() {
try {
const pendingTips = await this.store.getPendingScheduledTips();

if (pendingTips.length === 0) {
return;
}

logger.info('Processing pending scheduled tips', {
count: pendingTips.length,
});

for (const tip of pendingTips) {
await this.processSingleTip(tip);
}
} catch (err) {
logger.error('Failed to process pending tips', err);
}
}

async processSingleTip(tip) {
try {
await this.store.updateScheduledTip(tip.id, {
status: SCHEDULED_TIP_STATUSES.PROCESSING,
});

logger.info('Executing scheduled tip', {
id: tip.id,
sender: tip.sender,
recipient: tip.recipient,
amount: tip.amount,
});

let txId = null;
let failureReason = null;

if (this.onExecuteTip) {
try {
const result = await this.onExecuteTip(tip);
txId = result.txId;
} catch (err) {
failureReason = err.message || 'execution failed';
logger.error('Failed to execute scheduled tip', err, {
id: tip.id,
error_message: err.message,
});
}
} else {
logger.warn('No execution handler configured for scheduled tips', {
id: tip.id,
});
failureReason = 'no execution handler configured';
}

if (txId) {
await this.store.updateScheduledTip(tip.id, {
status: SCHEDULED_TIP_STATUSES.EXECUTED,
executedAt: new Date(),
txId,
});

logger.info('Scheduled tip executed successfully', {
id: tip.id,
tx_id: txId,
});
} else {
await this.store.updateScheduledTip(tip.id, {
status: SCHEDULED_TIP_STATUSES.FAILED,
failureReason,
});

logger.error('Scheduled tip execution failed', {
id: tip.id,
failure_reason: failureReason,
});
}
} catch (err) {
logger.error('Failed to process scheduled tip', err, {
id: tip.id,
error_message: err.message,
});

try {
await this.store.updateScheduledTip(tip.id, {
status: SCHEDULED_TIP_STATUSES.FAILED,
failureReason: err.message || 'processing error',
});
} catch (updateErr) {
logger.error('Failed to update failed scheduled tip status', updateErr, {
id: tip.id,
});
}
}
}

async getStats() {
const pendingCount = await this.store.countScheduledTips(SCHEDULED_TIP_STATUSES.PENDING);
const processingCount = await this.store.countScheduledTips(SCHEDULED_TIP_STATUSES.PROCESSING);
const executedCount = await this.store.countScheduledTips(SCHEDULED_TIP_STATUSES.EXECUTED);
const failedCount = await this.store.countScheduledTips(SCHEDULED_TIP_STATUSES.FAILED);
const cancelledCount = await this.store.countScheduledTips(SCHEDULED_TIP_STATUSES.CANCELLED);

return {
pending: pendingCount,
processing: processingCount,
executed: executedCount,
failed: failedCount,
cancelled: cancelledCount,
total: pendingCount + processingCount + executedCount + failedCount + cancelledCount,
is_running: !!this.intervalId,
processing_interval_ms: this.processingInterval,
};
}
}

export { JobProcessor, PROCESSING_INTERVAL_MS, NOTIFICATION_LEAD_MINUTES };
142 changes: 142 additions & 0 deletions chainhook/scheduler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { randomUUID } from 'node:crypto';

const SCHEDULED_TIP_STATUSES = {
PENDING: 'pending',
PROCESSING: 'processing',
EXECUTED: 'executed',
CANCELLED: 'cancelled',
FAILED: 'failed',
};

const MIN_SCHEDULE_MINUTES = 5;
const MAX_SCHEDULE_DAYS = 365;

function validateScheduledTime(scheduledFor) {
const scheduledDate = new Date(scheduledFor);
const now = new Date();

if (isNaN(scheduledDate.getTime())) {
return { valid: false, error: 'Invalid scheduled date format' };
}

const minTime = new Date(now.getTime() + MIN_SCHEDULE_MINUTES * 60 * 1000);
if (scheduledDate < minTime) {
return { valid: false, error: `Scheduled time must be at least ${MIN_SCHEDULE_MINUTES} minutes in the future` };
}

const maxTime = new Date(now.getTime() + MAX_SCHEDULE_DAYS * 24 * 60 * 60 * 1000);
if (scheduledDate > maxTime) {
return { valid: false, error: `Scheduled time cannot be more than ${MAX_SCHEDULE_DAYS} days in the future` };
}

return { valid: true };
}

function validateScheduledTipParams(params) {
const { sender, recipient, amount, scheduledFor, message, category } = params;

if (!sender || typeof sender !== 'string') {
return { valid: false, error: 'Sender address is required' };
}

if (!recipient || typeof recipient !== 'string') {
return { valid: false, error: 'Recipient address is required' };
}

if (sender === recipient) {
return { valid: false, error: 'Cannot schedule a tip to yourself' };
}

const amountNum = Number(amount);
if (!amount || isNaN(amountNum) || amountNum <= 0) {
return { valid: false, error: 'Amount must be a positive number' };
}

if (amountNum < 1000) {
return { valid: false, error: 'Minimum tip amount is 1000 microSTX (0.001 STX)' };
}

if (amountNum > 10000000000000) {
return { valid: false, error: 'Maximum tip amount is 10,000,000,000,000 microSTX (10,000 STX)' };
}

const timeValidation = validateScheduledTime(scheduledFor);
if (!timeValidation.valid) {
return timeValidation;
}

if (message && typeof message !== 'string') {
return { valid: false, error: 'Message must be a string' };
}

if (message && message.length > 280) {
return { valid: false, error: 'Message must be 280 characters or less' };
}

if (category !== undefined && (typeof category !== 'number' || category < 0)) {
return { valid: false, error: 'Category must be a non-negative number' };
}

return { valid: true };
}

class ScheduledTip {
constructor(data) {
this.id = data.id || randomUUID();
this.sender = data.sender;
this.recipient = data.recipient;
this.amount = data.amount;
this.scheduledFor = new Date(data.scheduledFor);
this.message = data.message || '';
this.category = data.category ?? 0;
this.status = data.status || SCHEDULED_TIP_STATUSES.PENDING;
this.createdAt = data.createdAt ? new Date(data.createdAt) : new Date();
this.updatedAt = data.updatedAt ? new Date(data.updatedAt) : new Date();
this.executedAt = data.executedAt ? new Date(data.executedAt) : null;
this.txId = data.txId || null;
this.failureReason = data.failureReason || null;
this.notifiedAt = data.notifiedAt ? new Date(data.notifiedAt) : null;
}

toJSON() {
return {
id: this.id,
sender: this.sender,
recipient: this.recipient,
amount: this.amount,
scheduledFor: this.scheduledFor.toISOString(),
message: this.message,
category: this.category,
status: this.status,
createdAt: this.createdAt.toISOString(),
updatedAt: this.updatedAt.toISOString(),
executedAt: this.executedAt ? this.executedAt.toISOString() : null,
txId: this.txId,
failureReason: this.failureReason,
notifiedAt: this.notifiedAt ? this.notifiedAt.toISOString() : null,
};
}

isPending() {
return this.status === SCHEDULED_TIP_STATUSES.PENDING;
}

isExecutable() {
return this.isPending() && this.scheduledFor <= new Date();
}

isNotifiable(notificationLeadMinutes = 60) {
if (!this.isPending() || this.notifiedAt) return false;
const notificationTime = new Date(this.scheduledFor.getTime() - notificationLeadMinutes * 60 * 1000);
return new Date() >= notificationTime;
}
}

export {
ScheduledTip,
SCHEDULED_TIP_STATUSES,
validateScheduledTime,
validateScheduledTipParams,
MIN_SCHEDULE_MINUTES,
MAX_SCHEDULE_DAYS,
};
Loading
Loading