diff --git a/README.md b/README.md index 3de02591..9b4af560 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 | diff --git a/chainhook/job-processor.js b/chainhook/job-processor.js new file mode 100644 index 00000000..e0122b97 --- /dev/null +++ b/chainhook/job-processor.js @@ -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 }; diff --git a/chainhook/scheduler.js b/chainhook/scheduler.js new file mode 100644 index 00000000..d2ce5aa5 --- /dev/null +++ b/chainhook/scheduler.js @@ -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, +}; diff --git a/chainhook/scheduler.test.js b/chainhook/scheduler.test.js new file mode 100644 index 00000000..43b28897 --- /dev/null +++ b/chainhook/scheduler.test.js @@ -0,0 +1,190 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; +import { ScheduledTip, validateScheduledTipParams, validateScheduledTime, SCHEDULED_TIP_STATUSES, MIN_SCHEDULE_MINUTES, MAX_SCHEDULE_DAYS } from './scheduler.js'; + +test('ScheduledTip creates with default values', () => { + const tip = new ScheduledTip({ + sender: 'SP2J6Z...ABC', + recipient: 'SP3K5...XYZ', + amount: 1000000, + scheduledFor: new Date(Date.now() + 3600000).toISOString(), + }); + + assert.ok(tip.id); + assert.strictEqual(tip.sender, 'SP2J6Z...ABC'); + assert.strictEqual(tip.recipient, 'SP3K5...XYZ'); + assert.strictEqual(tip.amount, 1000000); + assert.strictEqual(tip.status, SCHEDULED_TIP_STATUSES.PENDING); + assert.strictEqual(tip.message, ''); + assert.strictEqual(tip.category, 0); + assert.ok(tip.createdAt); + assert.ok(tip.updatedAt); + assert.strictEqual(tip.executedAt, null); + assert.strictEqual(tip.txId, null); + assert.strictEqual(tip.failureReason, null); +}); + +test('ScheduledTip.toJSON returns expected structure', () => { + const tip = new ScheduledTip({ + sender: 'SP2J6Z...ABC', + recipient: 'SP3K5...XYZ', + amount: 1000000, + scheduledFor: new Date(Date.now() + 3600000).toISOString(), + }); + + const json = tip.toJSON(); + + assert.strictEqual(typeof json.id, 'string'); + assert.strictEqual(json.sender, 'SP2J6Z...ABC'); + assert.strictEqual(json.recipient, 'SP3K5...XYZ'); + assert.strictEqual(json.amount, 1000000); + assert.strictEqual(json.status, 'pending'); +}); + +test('ScheduledTip.isPending returns true for pending status', () => { + const tip = new ScheduledTip({ + sender: 'SP2J6Z...ABC', + recipient: 'SP3K5...XYZ', + amount: 1000000, + scheduledFor: new Date(Date.now() + 3600000).toISOString(), + status: SCHEDULED_TIP_STATUSES.PENDING, + }); + + assert.strictEqual(tip.isPending(), true); +}); + +test('ScheduledTip.isPending returns false for non-pending status', () => { + const tip = new ScheduledTip({ + sender: 'SP2J6Z...ABC', + recipient: 'SP3K5...XYZ', + amount: 1000000, + scheduledFor: new Date(Date.now() + 3600000).toISOString(), + status: SCHEDULED_TIP_STATUSES.EXECUTED, + }); + + assert.strictEqual(tip.isPending(), false); +}); + +test('ScheduledTip.isExecutable returns true when scheduled time has passed', () => { + const tip = new ScheduledTip({ + sender: 'SP2J6Z...ABC', + recipient: 'SP3K5...XYZ', + amount: 1000000, + scheduledFor: new Date(Date.now() - 1000).toISOString(), + status: SCHEDULED_TIP_STATUSES.PENDING, + }); + + assert.strictEqual(tip.isExecutable(), true); +}); + +test('ScheduledTip.isExecutable returns false when scheduled time has not passed', () => { + const tip = new ScheduledTip({ + sender: 'SP2J6Z...ABC', + recipient: 'SP3K5...XYZ', + amount: 1000000, + scheduledFor: new Date(Date.now() + 3600000).toISOString(), + status: SCHEDULED_TIP_STATUSES.PENDING, + }); + + assert.strictEqual(tip.isExecutable(), false); +}); + +test('validateScheduledTime rejects past dates', () => { + const pastDate = new Date(Date.now() - 3600000).toISOString(); + const result = validateScheduledTime(pastDate); + + assert.strictEqual(result.valid, false); + assert.ok(result.error.includes('at least')); +}); + +test('validateScheduledTime accepts future dates', () => { + const futureDate = new Date(Date.now() + (MIN_SCHEDULE_MINUTES + 5) * 60 * 1000).toISOString(); + const result = validateScheduledTime(futureDate); + + assert.strictEqual(result.valid, true); +}); + +test('validateScheduledTime rejects dates too far in the future', () => { + const farFutureDate = new Date(Date.now() + (MAX_SCHEDULE_DAYS + 10) * 24 * 60 * 60 * 1000).toISOString(); + const result = validateScheduledTime(farFutureDate); + + assert.strictEqual(result.valid, false); + assert.ok(result.error.includes('cannot be more than')); +}); + +test('validateScheduledTime rejects invalid date format', () => { + const result = validateScheduledTime('not-a-date'); + + assert.strictEqual(result.valid, false); + assert.strictEqual(result.error, 'Invalid scheduled date format'); +}); + +test('validateScheduledTipParams validates required fields', () => { + const result = validateScheduledTipParams({}); + + assert.strictEqual(result.valid, false); + assert.strictEqual(result.error, 'Sender address is required'); +}); + +test('validateScheduledTipParams rejects sender equals recipient', () => { + const result = validateScheduledTipParams({ + sender: 'SP2J6Z...ABC', + recipient: 'SP2J6Z...ABC', + amount: 1000000, + scheduledFor: new Date(Date.now() + 3600000).toISOString(), + }); + + assert.strictEqual(result.valid, false); + assert.strictEqual(result.error, 'Cannot schedule a tip to yourself'); +}); + +test('validateScheduledTipParams validates amount', () => { + const result = validateScheduledTipParams({ + sender: 'SP2J6Z...ABC', + recipient: 'SP3K5...XYZ', + amount: -100, + scheduledFor: new Date(Date.now() + 3600000).toISOString(), + }); + + assert.strictEqual(result.valid, false); + assert.ok(result.error.includes('positive number')); +}); + +test('validateScheduledTipParams validates minimum amount', () => { + const result = validateScheduledTipParams({ + sender: 'SP2J6Z...ABC', + recipient: 'SP3K5...XYZ', + amount: 100, + scheduledFor: new Date(Date.now() + 3600000).toISOString(), + }); + + assert.strictEqual(result.valid, false); + assert.ok(result.error.includes('Minimum tip')); +}); + +test('validateScheduledTipParams validates message length', () => { + const longMessage = 'a'.repeat(281); + const result = validateScheduledTipParams({ + sender: 'SP2J6Z...ABC', + recipient: 'SP3K5...XYZ', + amount: 1000000, + scheduledFor: new Date(Date.now() + 3600000).toISOString(), + message: longMessage, + }); + + assert.strictEqual(result.valid, false); + assert.ok(result.error.includes('280 characters')); +}); + +test('validateScheduledTipParams accepts valid params', () => { + const result = validateScheduledTipParams({ + sender: 'SP2J6Z...ABC', + recipient: 'SP3K5...XYZ', + amount: 1000000, + scheduledFor: new Date(Date.now() + (MIN_SCHEDULE_MINUTES + 5) * 60 * 1000).toISOString(), + message: 'Test message', + category: 1, + }); + + assert.strictEqual(result.valid, true); +}); diff --git a/chainhook/schema.sql b/chainhook/schema.sql index cb43284b..713eda90 100644 --- a/chainhook/schema.sql +++ b/chainhook/schema.sql @@ -13,3 +13,26 @@ CREATE INDEX IF NOT EXISTS chainhook_events_tx_id_idx ON chainhook_events (tx_id CREATE INDEX IF NOT EXISTS chainhook_events_block_height_idx ON chainhook_events (block_height DESC); CREATE INDEX IF NOT EXISTS chainhook_events_contract_idx ON chainhook_events (contract); CREATE INDEX IF NOT EXISTS chainhook_events_ingested_at_idx ON chainhook_events (ingested_at DESC); + +CREATE TABLE IF NOT EXISTS scheduled_tips ( + id TEXT PRIMARY KEY, + sender TEXT NOT NULL, + recipient TEXT NOT NULL, + amount BIGINT NOT NULL, + scheduled_for TIMESTAMPTZ NOT NULL, + message TEXT NOT NULL DEFAULT '', + category INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'pending', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + executed_at TIMESTAMPTZ, + tx_id TEXT, + failure_reason TEXT, + notified_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS scheduled_tips_sender_idx ON scheduled_tips (sender); +CREATE INDEX IF NOT EXISTS scheduled_tips_recipient_idx ON scheduled_tips (recipient); +CREATE INDEX IF NOT EXISTS scheduled_tips_status_idx ON scheduled_tips (status); +CREATE INDEX IF NOT EXISTS scheduled_tips_scheduled_for_idx ON scheduled_tips (scheduled_for); +CREATE INDEX IF NOT EXISTS scheduled_tips_pending_due_idx ON scheduled_tips (scheduled_for) WHERE status = 'pending'; diff --git a/chainhook/server.js b/chainhook/server.js index b692535c..3315b910 100644 --- a/chainhook/server.js +++ b/chainhook/server.js @@ -9,9 +9,10 @@ import { parseAllowedOrigins, getCorsHeaders } from "./cors.js"; import { RateLimiter, getClientIp, validateRateLimitConfig } from "./rate-limit.js"; import { logger } from "./logging.js"; import { setupGracefulShutdown, isShuttingDown } from "./graceful-shutdown.js"; -import { createEventStore, getRetentionCutoff, parseRetentionDays } from "./storage.js"; +import { createEventStore, createScheduledTipStore, getRetentionCutoff, parseRetentionDays } from "./storage.js"; import { normalizeClarityEventFields } from "../shared/clarityValues.js"; import { BadRequestError, PayloadTooLargeError, RateLimitError, UnauthorizedError, ServiceUnavailableError, classifyError, toErrorResponse } from "./errors.js"; +import { ScheduledTip, validateScheduledTipParams, SCHEDULED_TIP_STATUSES } from "./scheduler.js"; const PORT = process.env.PORT || 3100; const AUTH_TOKEN = process.env.CHAINHOOK_AUTH_TOKEN || ""; @@ -26,6 +27,7 @@ const RATE_LIMIT_MAX_REQUESTS = parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || const RATE_LIMIT_WINDOW_MS = parseInt(process.env.RATE_LIMIT_WINDOW_MS || "60000", 10); const rateLimiter = new RateLimiter(RATE_LIMIT_MAX_REQUESTS, RATE_LIMIT_WINDOW_MS); let eventStore = null; +let scheduledTipStore = null; /** * Get the rate limiter instance for runtime configuration. @@ -52,6 +54,20 @@ async function getEventStore() { return eventStore; } +async function getScheduledTipStore() { + if (!scheduledTipStore) { + if (STORAGE_MODE === "postgres" && !DATABASE_URL) { + throw new Error("DATABASE_URL is required when CHAINHOOK_STORAGE=postgres"); + } + scheduledTipStore = await createScheduledTipStore({ + mode: STORAGE_MODE, + databaseUrl: DATABASE_URL, + }); + await scheduledTipStore.init(); + } + return scheduledTipStore; +} + /** * Read and parse a JSON request body from a readable stream. * Rejects if the body exceeds MAX_BODY_SIZE or contains invalid JSON. @@ -483,6 +499,136 @@ const server = http.createServer(async (req, res) => { }); } + // POST /api/scheduled-tips -- create a scheduled tip + if (req.method === "POST" && path === "/api/scheduled-tips") { + const startTime = Date.now(); + + try { + const body = await parseBody(req); + const validation = validateScheduledTipParams(body); + + if (!validation.valid) { + return sendError(res, new BadRequestError(validation.error), requestId, { path }); + } + + const scheduledTip = new ScheduledTip({ + sender: body.sender, + recipient: body.recipient, + amount: body.amount, + scheduledFor: body.scheduledFor, + message: body.message || '', + category: body.category ?? 0, + }); + + const store = await getScheduledTipStore(); + const result = await store.insertScheduledTip(scheduledTip); + + if (!result.inserted) { + return sendError(res, new BadRequestError('Scheduled tip with this ID already exists'), requestId, { path }); + } + + const processingMs = Date.now() - startTime; + logger.info('Scheduled tip created', { + id: scheduledTip.id, + sender: scheduledTip.sender, + recipient: scheduledTip.recipient, + scheduled_for: scheduledTip.scheduledFor.toISOString(), + request_id: requestId, + processing_ms: processingMs, + }); + + metrics.recordRequest(true); + return sendJson(res, 201, { ok: true, scheduledTip: result.tip }); + } catch (err) { + const processingMs = Date.now() - startTime; + metrics.recordRequest(false); + return sendError(res, err, requestId, { path, processing_ms: processingMs }); + } + } + + // GET /api/scheduled-tips -- list scheduled tips with filters + if (req.method === "GET" && path === "/api/scheduled-tips") { + try { + const sender = url.searchParams.get("sender"); + const recipient = url.searchParams.get("recipient"); + const status = url.searchParams.get("status"); + const limit = sanitizeQueryInt(url.searchParams.get("limit") || "50", 1, 100); + const offset = sanitizeQueryInt(url.searchParams.get("offset") || "0", 0, Number.MAX_SAFE_INTEGER); + + if (isNaN(limit)) { + return sendError(res, new BadRequestError("limit must be between 1 and 100"), requestId, { path }); + } + if (isNaN(offset)) { + return sendError(res, new BadRequestError("offset must be a non-negative integer"), requestId, { path }); + } + + const store = await getScheduledTipStore(); + const result = await store.listScheduledTips({ sender, recipient, status, limit, offset }); + + return sendJson(res, 200, { scheduledTips: result.tips, total: result.total }); + } catch (err) { + return sendError(res, err, requestId, { path }); + } + } + + // GET /api/scheduled-tips/:id -- get a single scheduled tip + if (req.method === "GET" && path.match(/^\/api\/scheduled-tips\/[a-f0-9-]+$/)) { + try { + const id = path.split("/api/scheduled-tips/")[1]; + const store = await getScheduledTipStore(); + const tip = await store.getScheduledTip(id); + + if (!tip) { + return sendJson(res, 404, { error: "scheduled tip not found" }); + } + + return sendJson(res, 200, tip); + } catch (err) { + return sendError(res, err, requestId, { path }); + } + } + + // DELETE /api/scheduled-tips/:id -- cancel a scheduled tip + if (req.method === "DELETE" && path.match(/^\/api\/scheduled-tips\/[a-f0-9-]+$/)) { + const startTime = Date.now(); + + try { + const id = path.split("/api/scheduled-tips/")[1]; + const body = await parseBody(req); + + if (!body.sender || typeof body.sender !== 'string') { + return sendError(res, new BadRequestError('sender address is required'), requestId, { path }); + } + + const store = await getScheduledTipStore(); + const result = await store.cancelScheduledTip(id, body.sender); + + if (!result.cancelled) { + if (result.reason === 'not_found') { + return sendJson(res, 404, { error: "scheduled tip not found or you are not the sender" }); + } + if (result.reason === 'not_pending') { + return sendError(res, new BadRequestError('can only cancel pending scheduled tips'), requestId, { path }); + } + } + + const processingMs = Date.now() - startTime; + logger.info('Scheduled tip cancelled', { + id, + sender: body.sender, + request_id: requestId, + processing_ms: processingMs, + }); + + metrics.recordRequest(true); + return sendJson(res, 200, { ok: true, scheduledTip: result.tip }); + } catch (err) { + const processingMs = Date.now() - startTime; + metrics.recordRequest(false); + return sendError(res, err, requestId, { path, processing_ms: processingMs }); + } + } + // GET /api/admin/events -- admin event log if (req.method === "GET" && path === "/api/admin/events") { const store = await getEventStore(); diff --git a/chainhook/storage-scheduled.test.js b/chainhook/storage-scheduled.test.js new file mode 100644 index 00000000..02e1f35a --- /dev/null +++ b/chainhook/storage-scheduled.test.js @@ -0,0 +1,282 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; +import { MemoryScheduledTipStore } from './storage.js'; + +test('MemoryScheduledTipStore inserts scheduled tip', async () => { + const store = new MemoryScheduledTipStore(); + await store.init(); + + const tip = { + id: 'test-id-1', + sender: 'SP2J6Z...ABC', + recipient: 'SP3K5...XYZ', + amount: 1000000, + scheduledFor: new Date(Date.now() + 3600000), + message: 'Test tip', + category: 1, + status: 'pending', + createdAt: new Date(), + updatedAt: new Date(), + }; + + const result = await store.insertScheduledTip(tip); + + assert.strictEqual(result.inserted, true); + assert.strictEqual(result.tip.id, 'test-id-1'); +}); + +test('MemoryScheduledTipStore prevents duplicate inserts', async () => { + const store = new MemoryScheduledTipStore(); + await store.init(); + + const tip = { + id: 'test-id-1', + sender: 'SP2J6Z...ABC', + recipient: 'SP3K5...XYZ', + amount: 1000000, + scheduledFor: new Date(Date.now() + 3600000), + status: 'pending', + }; + + await store.insertScheduledTip(tip); + const result = await store.insertScheduledTip(tip); + + assert.strictEqual(result.inserted, false); +}); + +test('MemoryScheduledTipStore gets scheduled tip by id', async () => { + const store = new MemoryScheduledTipStore(); + await store.init(); + + const tip = { + id: 'test-id-1', + sender: 'SP2J6Z...ABC', + recipient: 'SP3K5...XYZ', + amount: 1000000, + scheduledFor: new Date(Date.now() + 3600000), + status: 'pending', + }; + + await store.insertScheduledTip(tip); + const retrieved = await store.getScheduledTip('test-id-1'); + + assert.strictEqual(retrieved.id, 'test-id-1'); + assert.strictEqual(retrieved.sender, 'SP2J6Z...ABC'); +}); + +test('MemoryScheduledTipStore returns null for non-existent tip', async () => { + const store = new MemoryScheduledTipStore(); + await store.init(); + + const retrieved = await store.getScheduledTip('non-existent'); + + assert.strictEqual(retrieved, null); +}); + +test('MemoryScheduledTipStore lists scheduled tips with filters', async () => { + const store = new MemoryScheduledTipStore(); + await store.init(); + + await store.insertScheduledTip({ + id: 'tip-1', + sender: 'SP2J6Z...ABC', + recipient: 'SP3K5...XYZ', + amount: 1000000, + scheduledFor: new Date(Date.now() + 3600000), + status: 'pending', + }); + + await store.insertScheduledTip({ + id: 'tip-2', + sender: 'SP2J6Z...ABC', + recipient: 'SP4L7...DEF', + amount: 2000000, + scheduledFor: new Date(Date.now() + 7200000), + status: 'executed', + }); + + const allTips = await store.listScheduledTips({}); + assert.strictEqual(allTips.tips.length, 2); + assert.strictEqual(allTips.total, 2); + + const pendingTips = await store.listScheduledTips({ status: 'pending' }); + assert.strictEqual(pendingTips.tips.length, 1); + assert.strictEqual(pendingTips.tips[0].id, 'tip-1'); + + const senderTips = await store.listScheduledTips({ sender: 'SP2J6Z...ABC' }); + assert.strictEqual(senderTips.tips.length, 2); +}); + +test('MemoryScheduledTipStore updates scheduled tip', async () => { + const store = new MemoryScheduledTipStore(); + await store.init(); + + await store.insertScheduledTip({ + id: 'tip-1', + sender: 'SP2J6Z...ABC', + recipient: 'SP3K5...XYZ', + amount: 1000000, + scheduledFor: new Date(Date.now() + 3600000), + status: 'pending', + }); + + const result = await store.updateScheduledTip('tip-1', { + status: 'executed', + txId: '0xabc123', + executedAt: new Date(), + }); + + assert.strictEqual(result.updated, true); + assert.strictEqual(result.tip.status, 'executed'); + assert.strictEqual(result.tip.txId, '0xabc123'); +}); + +test('MemoryScheduledTipStore cancels scheduled tip', async () => { + const store = new MemoryScheduledTipStore(); + await store.init(); + + await store.insertScheduledTip({ + id: 'tip-1', + sender: 'SP2J6Z...ABC', + recipient: 'SP3K5...XYZ', + amount: 1000000, + scheduledFor: new Date(Date.now() + 3600000), + status: 'pending', + }); + + const result = await store.cancelScheduledTip('tip-1', 'SP2J6Z...ABC'); + + assert.strictEqual(result.cancelled, true); + assert.strictEqual(result.tip.status, 'cancelled'); +}); + +test('MemoryScheduledTipStore prevents cancelling non-pending tip', async () => { + const store = new MemoryScheduledTipStore(); + await store.init(); + + await store.insertScheduledTip({ + id: 'tip-1', + sender: 'SP2J6Z...ABC', + recipient: 'SP3K5...XYZ', + amount: 1000000, + scheduledFor: new Date(Date.now() + 3600000), + status: 'executed', + }); + + const result = await store.cancelScheduledTip('tip-1', 'SP2J6Z...ABC'); + + assert.strictEqual(result.cancelled, false); + assert.strictEqual(result.reason, 'not_pending'); +}); + +test('MemoryScheduledTipStore prevents cancelling by wrong sender', async () => { + const store = new MemoryScheduledTipStore(); + await store.init(); + + await store.insertScheduledTip({ + id: 'tip-1', + sender: 'SP2J6Z...ABC', + recipient: 'SP3K5...XYZ', + amount: 1000000, + scheduledFor: new Date(Date.now() + 3600000), + status: 'pending', + }); + + const result = await store.cancelScheduledTip('tip-1', 'SP4L7...DEF'); + + assert.strictEqual(result.cancelled, false); + assert.strictEqual(result.reason, 'not_found'); +}); + +test('MemoryScheduledTipStore gets pending scheduled tips', async () => { + const store = new MemoryScheduledTipStore(); + await store.init(); + + await store.insertScheduledTip({ + id: 'tip-1', + sender: 'SP2J6Z...ABC', + recipient: 'SP3K5...XYZ', + amount: 1000000, + scheduledFor: new Date(Date.now() - 1000), + status: 'pending', + }); + + await store.insertScheduledTip({ + id: 'tip-2', + sender: 'SP2J6Z...ABC', + recipient: 'SP4L7...DEF', + amount: 2000000, + scheduledFor: new Date(Date.now() + 3600000), + status: 'pending', + }); + + const pendingTips = await store.getPendingScheduledTips(); + + assert.strictEqual(pendingTips.length, 1); + assert.strictEqual(pendingTips[0].id, 'tip-1'); +}); + +test('MemoryScheduledTipStore gets notifiable scheduled tips', async () => { + const store = new MemoryScheduledTipStore(); + await store.init(); + + const now = Date.now(); + const notificationTime = now + 30 * 60 * 1000; + + await store.insertScheduledTip({ + id: 'tip-1', + sender: 'SP2J6Z...ABC', + recipient: 'SP3K5...XYZ', + amount: 1000000, + scheduledFor: new Date(notificationTime), + status: 'pending', + notifiedAt: null, + }); + + await store.insertScheduledTip({ + id: 'tip-2', + sender: 'SP2J6Z...ABC', + recipient: 'SP4L7...DEF', + amount: 2000000, + scheduledFor: new Date(now + 120 * 60 * 1000), + status: 'pending', + notifiedAt: null, + }); + + const notifiableTips = await store.getNotifiableScheduledTips(60); + + assert.strictEqual(notifiableTips.length, 1); + assert.strictEqual(notifiableTips[0].id, 'tip-1'); +}); + +test('MemoryScheduledTipStore counts scheduled tips', async () => { + const store = new MemoryScheduledTipStore(); + await store.init(); + + await store.insertScheduledTip({ + id: 'tip-1', + sender: 'SP2J6Z...ABC', + recipient: 'SP3K5...XYZ', + amount: 1000000, + scheduledFor: new Date(Date.now() + 3600000), + status: 'pending', + }); + + await store.insertScheduledTip({ + id: 'tip-2', + sender: 'SP2J6Z...ABC', + recipient: 'SP4L7...DEF', + amount: 2000000, + scheduledFor: new Date(Date.now() + 7200000), + status: 'executed', + }); + + const totalCount = await store.countScheduledTips(); + assert.strictEqual(totalCount, 2); + + const pendingCount = await store.countScheduledTips('pending'); + assert.strictEqual(pendingCount, 1); + + const executedCount = await store.countScheduledTips('executed'); + assert.strictEqual(executedCount, 1); +}); diff --git a/chainhook/storage.js b/chainhook/storage.js index 187d2a52..44a05d6a 100644 --- a/chainhook/storage.js +++ b/chainhook/storage.js @@ -349,3 +349,359 @@ export async function createEventStore(options = {}) { export { MemoryEventStore, PostgresEventStore }; export { DEFAULT_POOL_MAX, DEFAULT_POOL_IDLE_TIMEOUT_MS, DEFAULT_POOL_CONNECTION_TIMEOUT_MS, DEFAULT_STATEMENT_TIMEOUT_MS }; + +class MemoryScheduledTipStore { + constructor() { + this.tips = []; + } + + async init() { + return this; + } + + async insertScheduledTip(tip) { + const existing = this.tips.find(t => t.id === tip.id); + if (existing) { + return { inserted: false, tip: existing }; + } + this.tips.push(tip); + return { inserted: true, tip }; + } + + async getScheduledTip(id) { + return this.tips.find(t => t.id === id) || null; + } + + async listScheduledTips(filters = {}) { + let results = [...this.tips]; + + if (filters.sender) { + results = results.filter(t => t.sender === filters.sender); + } + if (filters.recipient) { + results = results.filter(t => t.recipient === filters.recipient); + } + if (filters.status) { + results = results.filter(t => t.status === filters.status); + } + + results.sort((a, b) => new Date(b.scheduledFor) - new Date(a.scheduledFor)); + + const offset = filters.offset || 0; + const limit = filters.limit || 50; + const total = results.length; + results = results.slice(offset, offset + limit); + + return { tips: results, total }; + } + + async updateScheduledTip(id, updates) { + const index = this.tips.findIndex(t => t.id === id); + if (index === -1) { + return { updated: false, tip: null }; + } + this.tips[index] = { ...this.tips[index], ...updates, updatedAt: new Date() }; + return { updated: true, tip: this.tips[index] }; + } + + async cancelScheduledTip(id, sender) { + const tip = this.tips.find(t => t.id === id && t.sender === sender); + if (!tip) { + return { cancelled: false, reason: 'not_found' }; + } + if (tip.status !== 'pending') { + return { cancelled: false, reason: 'not_pending' }; + } + tip.status = 'cancelled'; + tip.updatedAt = new Date(); + return { cancelled: true, tip }; + } + + async getPendingScheduledTips() { + const now = new Date(); + return this.tips.filter(t => t.status === 'pending' && new Date(t.scheduledFor) <= now); + } + + async getNotifiableScheduledTips(leadMinutes = 60) { + const now = new Date(); + return this.tips.filter(t => { + if (t.status !== 'pending' || t.notifiedAt) return false; + const notificationTime = new Date(new Date(t.scheduledFor).getTime() - leadMinutes * 60 * 1000); + return now >= notificationTime; + }); + } + + async countScheduledTips(status = null) { + if (status) { + return this.tips.filter(t => t.status === status).length; + } + return this.tips.length; + } + + async close() {} +} + +class PostgresScheduledTipStore { + constructor(pool, poolConfig = {}) { + this.pool = pool; + this.poolConfig = poolConfig; + this.ready = null; + } + + async init() { + if (!this.ready) { + this.ready = this.#initialize(); + } + return this.ready; + } + + async #initialize() { + await this.pool.query(` + CREATE TABLE IF NOT EXISTS scheduled_tips ( + id TEXT PRIMARY KEY, + sender TEXT NOT NULL, + recipient TEXT NOT NULL, + amount BIGINT NOT NULL, + scheduled_for TIMESTAMPTZ NOT NULL, + message TEXT NOT NULL DEFAULT '', + category INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'pending', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + executed_at TIMESTAMPTZ, + tx_id TEXT, + failure_reason TEXT, + notified_at TIMESTAMPTZ + ); + `); + + await this.pool.query('CREATE INDEX IF NOT EXISTS scheduled_tips_sender_idx ON scheduled_tips (sender);'); + await this.pool.query('CREATE INDEX IF NOT EXISTS scheduled_tips_recipient_idx ON scheduled_tips (recipient);'); + await this.pool.query('CREATE INDEX IF NOT EXISTS scheduled_tips_status_idx ON scheduled_tips (status);'); + await this.pool.query('CREATE INDEX IF NOT EXISTS scheduled_tips_scheduled_for_idx ON scheduled_tips (scheduled_for);'); + await this.pool.query('CREATE INDEX IF NOT EXISTS scheduled_tips_pending_due_idx ON scheduled_tips (scheduled_for) WHERE status = \'pending\';'); + } + + async insertScheduledTip(tip) { + await this.init(); + + const result = await this.pool.query( + `INSERT INTO scheduled_tips (id, sender, recipient, amount, scheduled_for, message, category, status, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (id) DO NOTHING + RETURNING *`, + [ + tip.id, + tip.sender, + tip.recipient, + tip.amount, + tip.scheduledFor, + tip.message || '', + tip.category || 0, + tip.status || 'pending', + tip.createdAt || new Date(), + tip.updatedAt || new Date(), + ] + ); + + if (result.rowCount === 0) { + const existing = await this.getScheduledTip(tip.id); + return { inserted: false, tip: existing }; + } + + return { inserted: true, tip: this.#rowToTip(result.rows[0]) }; + } + + async getScheduledTip(id) { + await this.init(); + const result = await this.pool.query('SELECT * FROM scheduled_tips WHERE id = $1', [id]); + return result.rows[0] ? this.#rowToTip(result.rows[0]) : null; + } + + async listScheduledTips(filters = {}) { + await this.init(); + + let query = 'SELECT * FROM scheduled_tips WHERE 1=1'; + const values = []; + let paramIndex = 1; + + if (filters.sender) { + query += ` AND sender = $${paramIndex++}`; + values.push(filters.sender); + } + if (filters.recipient) { + query += ` AND recipient = $${paramIndex++}`; + values.push(filters.recipient); + } + if (filters.status) { + query += ` AND status = $${paramIndex++}`; + values.push(filters.status); + } + + query += ' ORDER BY scheduled_for DESC'; + + const offset = filters.offset || 0; + const limit = filters.limit || 50; + query += ` LIMIT $${paramIndex++} OFFSET $${paramIndex}`; + values.push(limit, offset); + + const result = await this.pool.query(query, values); + + const countResult = await this.pool.query( + 'SELECT COUNT(*)::int AS count FROM scheduled_tips WHERE 1=1' + + (filters.sender ? ' AND sender = $1' : '') + + (filters.recipient ? ` AND recipient = $${filters.sender ? 2 : 1}` : '') + + (filters.status ? ` AND status = $${(filters.sender ? 1 : 0) + (filters.recipient ? 1 : 0) + 1}` : ''), + values.slice(0, -2) + ); + + return { + tips: result.rows.map(r => this.#rowToTip(r)), + total: countResult.rows[0]?.count || 0, + }; + } + + async updateScheduledTip(id, updates) { + await this.init(); + + const setClauses = []; + const values = []; + let paramIndex = 1; + + if (updates.status !== undefined) { + setClauses.push(`status = $${paramIndex++}`); + values.push(updates.status); + } + if (updates.executedAt !== undefined) { + setClauses.push(`executed_at = $${paramIndex++}`); + values.push(updates.executedAt); + } + if (updates.txId !== undefined) { + setClauses.push(`tx_id = $${paramIndex++}`); + values.push(updates.txId); + } + if (updates.failureReason !== undefined) { + setClauses.push(`failure_reason = $${paramIndex++}`); + values.push(updates.failureReason); + } + if (updates.notifiedAt !== undefined) { + setClauses.push(`notified_at = $${paramIndex++}`); + values.push(updates.notifiedAt); + } + + setClauses.push(`updated_at = $${paramIndex++}`); + values.push(new Date()); + + values.push(id); + + const result = await this.pool.query( + `UPDATE scheduled_tips SET ${setClauses.join(', ')} WHERE id = $${paramIndex} RETURNING *`, + values + ); + + if (result.rowCount === 0) { + return { updated: false, tip: null }; + } + + return { updated: true, tip: this.#rowToTip(result.rows[0]) }; + } + + async cancelScheduledTip(id, sender) { + await this.init(); + + const result = await this.pool.query( + `UPDATE scheduled_tips SET status = 'cancelled', updated_at = NOW() + WHERE id = $1 AND sender = $2 AND status = 'pending' + RETURNING *`, + [id, sender] + ); + + if (result.rowCount === 0) { + const tip = await this.getScheduledTip(id); + if (!tip || tip.sender !== sender) { + return { cancelled: false, reason: 'not_found' }; + } + return { cancelled: false, reason: 'not_pending' }; + } + + return { cancelled: true, tip: this.#rowToTip(result.rows[0]) }; + } + + async getPendingScheduledTips() { + await this.init(); + const result = await this.pool.query( + "SELECT * FROM scheduled_tips WHERE status = 'pending' AND scheduled_for <= NOW() ORDER BY scheduled_for ASC" + ); + return result.rows.map(r => this.#rowToTip(r)); + } + + async getNotifiableScheduledTips(leadMinutes = 60) { + await this.init(); + const result = await this.pool.query( + `SELECT * FROM scheduled_tips + WHERE status = 'pending' + AND notified_at IS NULL + AND scheduled_for <= NOW() + INTERVAL '1 minute' * $1 + AND scheduled_for > NOW() + ORDER BY scheduled_for ASC`, + [leadMinutes] + ); + return result.rows.map(r => this.#rowToTip(r)); + } + + async countScheduledTips(status = null) { + await this.init(); + if (status) { + const result = await this.pool.query('SELECT COUNT(*)::int AS count FROM scheduled_tips WHERE status = $1', [status]); + return result.rows[0]?.count || 0; + } + const result = await this.pool.query('SELECT COUNT(*)::int AS count FROM scheduled_tips'); + return result.rows[0]?.count || 0; + } + + async close() {} + + #rowToTip(row) { + return { + id: row.id, + sender: row.sender, + recipient: row.recipient, + amount: Number(row.amount), + scheduledFor: row.scheduled_for, + message: row.message || '', + category: row.category || 0, + status: row.status, + createdAt: row.created_at, + updatedAt: row.updated_at, + executedAt: row.executed_at, + txId: row.tx_id, + failureReason: row.failure_reason, + notifiedAt: row.notified_at, + }; + } +} + +export async function createScheduledTipStore(options = {}) { + const mode = options.mode || process.env.CHAINHOOK_STORAGE || (process.env.NODE_ENV === 'test' ? 'memory' : 'postgres'); + + if (mode === 'memory') { + return new MemoryScheduledTipStore(); + } + + const databaseUrl = options.databaseUrl || process.env.DATABASE_URL; + const ssl = options.ssl ?? process.env.DATABASE_SSL === 'true'; + const poolConfig = options.poolConfig || parsePoolConfig(process.env); + + const pool = new Pool({ + connectionString: databaseUrl, + ssl: ssl ? { rejectUnauthorized: false } : undefined, + max: poolConfig.max, + idleTimeoutMillis: poolConfig.idleTimeoutMillis, + connectionTimeoutMillis: poolConfig.connectionTimeoutMillis, + statement_timeout: poolConfig.statement_timeout, + }); + + return new PostgresScheduledTipStore(pool, poolConfig); +} + +export { MemoryScheduledTipStore, PostgresScheduledTipStore }; diff --git a/docs/SCHEDULED_TIPS.md b/docs/SCHEDULED_TIPS.md new file mode 100644 index 00000000..53d57ae9 --- /dev/null +++ b/docs/SCHEDULED_TIPS.md @@ -0,0 +1,288 @@ +# Scheduled Tips + +The scheduled tips feature allows users to schedule tips to be sent at a future date and time. This is useful for recurring payments, delayed rewards, or planning ahead. + +## Features + +- Schedule tips up to 365 days in advance +- Minimum scheduling window of 5 minutes +- View and manage all scheduled tips +- Cancel pending scheduled tips +- Automatic execution when scheduled time arrives +- Optional notification before execution +- Full transaction history tracking + +## User Interface + +### Schedule a Tip + +Navigate to the "Schedule" tab to create a new scheduled tip: + +1. Enter the recipient's Stacks address +2. Specify the tip amount in STX +3. Select the date and time for execution +4. Add an optional message (up to 280 characters) +5. Choose a category +6. Review the fee breakdown +7. Confirm the scheduled tip + +### View Scheduled Tips + +Navigate to the "Scheduled" tab to view all your scheduled tips: + +- Filter by status: All, Pending, Executed, Cancelled, Failed +- View scheduled date and time +- See recipient and amount details +- Cancel pending tips +- View transaction IDs for executed tips + +## API Endpoints + +### Create Scheduled Tip + +```http +POST /api/scheduled-tips +Content-Type: application/json + +{ + "sender": "SP2J6Z...", + "recipient": "SP3K5...", + "amount": 1000000, + "scheduledFor": "2026-05-20T14:30:00.000Z", + "message": "Monthly tip", + "category": 1 +} +``` + +**Response:** +```json +{ + "ok": true, + "scheduledTip": { + "id": "uuid", + "sender": "SP2J6Z...", + "recipient": "SP3K5...", + "amount": 1000000, + "scheduledFor": "2026-05-20T14:30:00.000Z", + "message": "Monthly tip", + "category": 1, + "status": "pending", + "createdAt": "2026-05-14T10:00:00.000Z", + "updatedAt": "2026-05-14T10:00:00.000Z" + } +} +``` + +### List Scheduled Tips + +```http +GET /api/scheduled-tips?sender=SP2J6Z...&status=pending&limit=50&offset=0 +``` + +**Query Parameters:** +- `sender` (optional): Filter by sender address +- `recipient` (optional): Filter by recipient address +- `status` (optional): Filter by status (pending, processing, executed, cancelled, failed) +- `limit` (optional): Number of results per page (1-100, default: 50) +- `offset` (optional): Pagination offset (default: 0) + +**Response:** +```json +{ + "scheduledTips": [...], + "total": 10 +} +``` + +### Get Scheduled Tip + +```http +GET /api/scheduled-tips/:id +``` + +**Response:** +```json +{ + "id": "uuid", + "sender": "SP2J6Z...", + "recipient": "SP3K5...", + "amount": 1000000, + "scheduledFor": "2026-05-20T14:30:00.000Z", + "status": "pending", + ... +} +``` + +### Cancel Scheduled Tip + +```http +DELETE /api/scheduled-tips/:id +Content-Type: application/json + +{ + "sender": "SP2J6Z..." +} +``` + +**Response:** +```json +{ + "ok": true, + "scheduledTip": { + "id": "uuid", + "status": "cancelled", + ... + } +} +``` + +## Job Processor + +The job processor runs in the background to execute scheduled tips and send notifications. + +### Configuration + +Environment variables: +- `PROCESSING_INTERVAL_MS`: How often to check for pending tips (default: 60000ms) +- `NOTIFICATION_LEAD_MINUTES`: How many minutes before execution to send notification (default: 60) + +### Execution Flow + +1. Job processor checks for pending tips every minute +2. Tips with scheduled time in the past are marked as "processing" +3. Execution handler is called (must be provided by implementation) +4. On success, tip is marked as "executed" with transaction ID +5. On failure, tip is marked as "failed" with failure reason + +### Notifications + +Tips can trigger notifications before execution: +- Default: 60 minutes before scheduled time +- Only sent once per tip +- Notification handler must be provided by implementation + +## Database Schema + +### scheduled_tips Table + +```sql +CREATE TABLE scheduled_tips ( + id TEXT PRIMARY KEY, + sender TEXT NOT NULL, + recipient TEXT NOT NULL, + amount BIGINT NOT NULL, + scheduled_for TIMESTAMPTZ NOT NULL, + message TEXT NOT NULL DEFAULT '', + category INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'pending', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + executed_at TIMESTAMPTZ, + tx_id TEXT, + failure_reason TEXT, + notified_at TIMESTAMPTZ +); +``` + +### Indexes + +- `scheduled_tips_sender_idx`: Fast lookup by sender +- `scheduled_tips_recipient_idx`: Fast lookup by recipient +- `scheduled_tips_status_idx`: Fast filtering by status +- `scheduled_tips_scheduled_for_idx`: Fast sorting by scheduled time +- `scheduled_tips_pending_due_idx`: Optimized for job processor queries + +## Status Flow + +``` +pending → processing → executed + ↘ failed + +pending → cancelled +``` + +- **pending**: Scheduled tip waiting for execution time +- **processing**: Currently being executed +- **executed**: Successfully executed with transaction ID +- **failed**: Execution failed with reason +- **cancelled**: Cancelled by sender before execution + +## Validation Rules + +### Amount +- Minimum: 1,000 microSTX (0.001 STX) +- Maximum: 10,000,000,000,000 microSTX (10,000 STX) + +### Scheduled Time +- Minimum: 5 minutes in the future +- Maximum: 365 days in the future + +### Message +- Maximum length: 280 characters +- Optional field + +### Category +- Must be a non-negative integer +- Default: 0 (General) + +### Sender/Recipient +- Must be valid Stacks addresses +- Cannot be the same address + +## Analytics + +The following metrics are tracked: +- `scheduledTipsCreated`: Number of scheduled tips created +- `scheduledTipsCancelled`: Number of scheduled tips cancelled +- `scheduledTipsExecuted`: Number of scheduled tips executed successfully +- `scheduledTipsFailed`: Number of scheduled tips that failed execution + +## Error Handling + +### Common Errors + +**Invalid scheduled time:** +```json +{ + "error": "bad_request", + "message": "Scheduled time must be at least 5 minutes in the future" +} +``` + +**Insufficient balance:** +Checked at execution time, not at scheduling time. If balance is insufficient when execution time arrives, the tip will be marked as failed. + +**Cancelled tip:** +```json +{ + "error": "bad_request", + "message": "can only cancel pending scheduled tips" +} +``` + +**Not found:** +```json +{ + "error": "scheduled tip not found or you are not the sender" +} +``` + +## Testing + +Run tests: +```bash +cd chainhook +npm test scheduler.test.js +npm test storage-scheduled.test.js +``` + +## Future Enhancements + +Potential improvements for future versions: +- Recurring scheduled tips (daily, weekly, monthly) +- Batch scheduled tips +- Email/SMS notifications +- Scheduled tip templates +- Balance verification at scheduling time +- Automatic retry on failure +- Scheduled tip marketplace diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e75e7701..7ff40aa2 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -18,17 +18,19 @@ import { usePageTitle } from './hooks/usePageTitle'; import { useSessionSync } from './hooks/useSessionSync'; import { useDemoMode } from './context/DemoContext'; import { - ROUTE_SEND, ROUTE_BATCH, ROUTE_TOKEN_TIP, ROUTE_FEED, + ROUTE_SEND, ROUTE_BATCH, ROUTE_TOKEN_TIP, ROUTE_SCHEDULE, ROUTE_SCHEDULED_TIPS, ROUTE_FEED, ROUTE_LEADERBOARD, ROUTE_ACTIVITY, ROUTE_PROFILE, ROUTE_BLOCK, ROUTE_STATS, ROUTE_ADMIN, ROUTE_TELEMETRY, DEFAULT_AUTHENTICATED_ROUTE, ROUTE_META, } from './config/routes'; -import { Zap, Radio, Trophy, User, BarChart3, Users, ShieldBan, Coins, UserCircle, Shield, Gauge } from 'lucide-react'; +import { Zap, Radio, Trophy, User, BarChart3, Users, ShieldBan, Coins, UserCircle, Shield, Gauge, Calendar, Clock } from 'lucide-react'; import { activateDemo, deactivateDemo } from './lib/demo-utils'; const AnimatedHero = lazy(() => import('./components/ui/animated-hero').then(m => ({ default: m.AnimatedHero }))); const MaintenancePage = lazy(() => import('./components/MaintenancePage')); const SendTip = lazy(() => import('./components/SendTip')); +const ScheduleTip = lazy(() => import('./components/ScheduleTip')); +const ScheduledTipsList = lazy(() => import('./components/ScheduledTipsList')); const TipHistory = lazy(() => import('./components/TipHistory')); const PlatformStats = lazy(() => import('./components/PlatformStats')); const RecentTips = lazy(() => import('./components/RecentTips')); @@ -157,6 +159,8 @@ function App() { { path: ROUTE_SEND, label: 'Send Tip', icon: Zap }, { path: ROUTE_BATCH, label: 'Batch', icon: Users }, { path: ROUTE_TOKEN_TIP, label: 'Token Tip', icon: Coins }, + { path: ROUTE_SCHEDULE, label: 'Schedule', icon: Calendar }, + { path: ROUTE_SCHEDULED_TIPS, label: 'Scheduled', icon: Clock }, { path: ROUTE_FEED, label: 'Live Feed', icon: Radio }, { path: ROUTE_LEADERBOARD, label: 'Leaderboard', icon: Trophy }, { path: ROUTE_ACTIVITY, label: 'My Activity', icon: User }, @@ -297,6 +301,30 @@ function App() { ) } /> + + ) : ( + + + + ) + } + /> + + ) : ( + + + + ) + } + /> {/* Public routes - accessible to all */} } /> diff --git a/frontend/src/components/ScheduleTip.jsx b/frontend/src/components/ScheduleTip.jsx new file mode 100644 index 00000000..b702de61 --- /dev/null +++ b/frontend/src/components/ScheduleTip.jsx @@ -0,0 +1,401 @@ +import { useState, useMemo, useCallback, useEffect } from 'react'; +import { toMicroSTX, formatSTX } from '../lib/utils'; +import { formatBalance, hasSufficientMicroStx } from '../lib/balance-utils'; +import { isValidStacksPrincipal } from '../lib/stacks-principal'; +import { canProceedWithRecipient, getRecipientValidationMessage } from '../lib/recipient-validation'; +import { totalDeduction, feeForTip, recipientReceives, FEE_PERCENT } from '../lib/post-conditions'; +import { useDemoMode } from '../context/DemoContext'; +import { useBalance } from '../hooks/useBalance'; +import { useBlockCheck } from '../hooks/useBlockCheck'; +import { useStxPrice } from '../hooks/useStxPrice'; +import { useSenderAddress } from '../hooks/useSenderAddress'; +import { analytics } from '../lib/analytics'; +import ConfirmDialog from './ui/confirm-dialog'; +import { Calendar, Clock } from 'lucide-react'; + +const MIN_TIP_STX = 0.001; +const MAX_TIP_STX = 10000; +const MIN_SCHEDULE_MINUTES = 5; +const MAX_SCHEDULE_DAYS = 365; + +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 ScheduleTip({ addToast }) { + const { demoEnabled, getDemoData } = useDemoMode(); + const { toUsd } = useStxPrice(); + const { blocked: blockedWarning, checkBlocked, reset: resetBlockCheck } = useBlockCheck(); + const [recipient, setRecipient] = useState(''); + const [amount, setAmount] = useState(''); + const [message, setMessage] = useState(''); + const [category, setCategory] = useState(0); + const [scheduledDate, setScheduledDate] = useState(''); + const [scheduledTime, setScheduledTime] = useState(''); + const [loading, setLoading] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); + const [recipientError, setRecipientError] = useState(''); + const [amountError, setAmountError] = useState(''); + const [scheduleError, setScheduleError] = useState(''); + + const walletSenderAddress = useSenderAddress(); + const senderAddress = demoEnabled ? getDemoData().mockWalletAddress : walletSenderAddress; + const realBalanceState = useBalance(senderAddress); + const balance = demoEnabled ? getDemoData().mockBalance : realBalanceState.balance; + const balanceLoading = demoEnabled ? false : realBalanceState.loading; + + const balanceSTX = useMemo(() => { + if (balance === null || balance === undefined) return null; + return typeof balance === 'string' ? Number(balance) / 1000000 : balance / 1000000; + }, [balance]); + + const isRecipientHighRisk = !canProceedWithRecipient(recipient, blockedWarning); + + const minScheduleDateTime = useMemo(() => { + const now = new Date(); + now.setMinutes(now.getMinutes() + MIN_SCHEDULE_MINUTES); + return now.toISOString().slice(0, 16); + }, []); + + const maxScheduleDateTime = useMemo(() => { + const now = new Date(); + now.setDate(now.getDate() + MAX_SCHEDULE_DAYS); + return now.toISOString().slice(0, 16); + }, []); + + const validateRecipient = useCallback((value) => { + resetBlockCheck(); + setRecipientError(''); + + if (value && !isValidStacksPrincipal(value)) { + setRecipientError('Enter a valid Stacks principal'); + return; + } + + if (value.trim() === senderAddress) { + setRecipientError('You cannot send a tip to yourself'); + return; + } + + if (value) { + checkBlocked(value); + } + }, [checkBlocked, resetBlockCheck, senderAddress]); + + useEffect(() => { + Promise.resolve().then(() => { + validateRecipient(recipient); + }); + }, [recipient, validateRecipient]); + + const handleRecipientChange = (value) => { + setRecipient(value); + }; + + const handleAmountChange = (value) => { + setAmount(value); + if (!value) { setAmountError(''); return; } + const parsed = parseFloat(value); + if (isNaN(parsed) || parsed <= 0) { + setAmountError('Amount must be a positive number'); + } else if (parsed < MIN_TIP_STX) { + setAmountError(`Minimum tip is ${MIN_TIP_STX} STX`); + } else if (parsed > MAX_TIP_STX) { + setAmountError(`Maximum tip is ${MAX_TIP_STX.toLocaleString()} STX`); + } else if (balanceSTX !== null) { + const microSTX = toMicroSTX(parsed.toString()); + if (!hasSufficientMicroStx(balance, totalDeduction(microSTX))) { + setAmountError('Insufficient balance'); + } else { + setAmountError(''); + } + } else { + setAmountError(''); + } + }; + + const validateScheduleDateTime = useCallback(() => { + setScheduleError(''); + + if (!scheduledDate || !scheduledTime) { + return; + } + + const scheduledDateTime = new Date(`${scheduledDate}T${scheduledTime}`); + const now = new Date(); + const minTime = new Date(now.getTime() + MIN_SCHEDULE_MINUTES * 60 * 1000); + const maxTime = new Date(now.getTime() + MAX_SCHEDULE_DAYS * 24 * 60 * 60 * 1000); + + if (scheduledDateTime < minTime) { + setScheduleError(`Scheduled time must be at least ${MIN_SCHEDULE_MINUTES} minutes in the future`); + } else if (scheduledDateTime > maxTime) { + setScheduleError(`Scheduled time cannot be more than ${MAX_SCHEDULE_DAYS} days in the future`); + } + }, [scheduledDate, scheduledTime]); + + useEffect(() => { + validateScheduleDateTime(); + }, [validateScheduleDateTime]); + + const validateAndConfirm = () => { + if (!recipient || !amount || !scheduledDate || !scheduledTime) { + addToast('Please fill in all required fields', 'warning'); + return; + } + + if (!isValidStacksPrincipal(recipient)) { + addToast('Invalid Stacks principal format', 'warning'); + return; + } + + if (recipient.trim() === senderAddress) { + addToast('You cannot send a tip to yourself', 'warning'); + return; + } + + if (!canProceedWithRecipient(recipient, blockedWarning)) { + const validationMessage = getRecipientValidationMessage(recipient, blockedWarning); + addToast(validationMessage || 'Cannot send tip to this recipient', 'error'); + return; + } + + const parsedAmount = parseFloat(amount); + if (isNaN(parsedAmount) || parsedAmount <= 0) { + addToast('Please enter a valid amount', 'warning'); + return; + } + + if (parsedAmount < MIN_TIP_STX || parsedAmount > MAX_TIP_STX) { + addToast(`Amount must be between ${MIN_TIP_STX} and ${MAX_TIP_STX.toLocaleString()} STX`, 'warning'); + return; + } + + if (balanceSTX !== null) { + const microSTX = toMicroSTX(amount); + if (!hasSufficientMicroStx(balance, totalDeduction(microSTX))) { + addToast('Insufficient balance', 'warning'); + return; + } + } + + const scheduledDateTime = new Date(`${scheduledDate}T${scheduledTime}`); + const now = new Date(); + const minTime = new Date(now.getTime() + MIN_SCHEDULE_MINUTES * 60 * 1000); + + if (scheduledDateTime < minTime) { + addToast(`Scheduled time must be at least ${MIN_SCHEDULE_MINUTES} minutes in the future`, 'warning'); + return; + } + + setShowConfirm(true); + analytics.trackTipStarted(); + }; + + const handleScheduleTip = async () => { + setShowConfirm(false); + setLoading(true); + + try { + const scheduledDateTime = new Date(`${scheduledDate}T${scheduledTime}`); + const microSTX = toMicroSTX(amount); + + const payload = { + sender: senderAddress, + recipient: recipient.trim(), + amount: microSTX, + scheduledFor: scheduledDateTime.toISOString(), + message: message || '', + category, + }; + + const response = await fetch('/api/scheduled-tips', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Failed to schedule tip'); + } + + const result = await response.json(); + + setRecipient(''); + setAmount(''); + setMessage(''); + setCategory(0); + setScheduledDate(''); + setScheduledTime(''); + + addToast(`Tip scheduled for ${scheduledDateTime.toLocaleString()}`, 'success'); + analytics.trackScheduledTipCreated(); + } catch (error) { + console.error('Failed to schedule tip:', error); + addToast(error.message || 'Failed to schedule tip', 'error'); + analytics.trackScheduledTipFailed(); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

Schedule a Tip

+ + {senderAddress && ( +
+
+

Your Balance

+

+ {balanceLoading ? 'Loading...' : balanceSTX !== null + ? formatBalance(balance) + : 'Unavailable'} +

+
+
+ )} + +
+
+ + {(() => { + const isRisky = !canProceedWithRecipient(recipient, blockedWarning); + const validationMsg = getRecipientValidationMessage(recipient, blockedWarning); + return ( + <> + handleRecipientChange(e.target.value)} + className={`w-full px-4 py-2.5 border rounded-xl text-sm focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 outline-none transition-all bg-white dark:bg-gray-800 dark:text-white ${recipientError || (isRisky && validationMsg) ? 'border-red-300 dark:border-red-600' : 'border-gray-200 dark:border-gray-700'}`} + placeholder="SP2..." /> + {recipientError &&

{recipientError}

} + {validationMsg &&

{validationMsg}

} + + ); + })()} +
+ +
+ + handleAmountChange(e.target.value)} + className={`w-full px-4 py-2.5 border rounded-xl text-sm focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 outline-none transition-all bg-white dark:bg-gray-800 dark:text-white ${amountError ? 'border-red-300 dark:border-red-600' : 'border-gray-200 dark:border-gray-700'}`} + placeholder="0.5" step="0.001" min={MIN_TIP_STX} max={MAX_TIP_STX} /> + {amountError &&

{amountError}

} +
+ +
+
+ + setScheduledDate(e.target.value)} + min={minScheduleDateTime.split('T')[0]} + max={maxScheduleDateTime.split('T')[0]} + className="w-full px-4 py-2.5 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 dark:text-white rounded-xl text-sm focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 outline-none transition-all" /> +
+
+ + setScheduledTime(e.target.value)} + className="w-full px-4 py-2.5 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 dark:text-white rounded-xl text-sm focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 outline-none transition-all" /> +
+
+ {scheduleError &&

{scheduleError}

} + +
+ +