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() {
)
}
/>
+
Your Balance
++ {balanceLoading ? 'Loading...' : balanceSTX !== null + ? formatBalance(balance) + : 'Unavailable'} +
+{recipientError}
} + {validationMsg &&{validationMsg}
} + > + ); + })()} +{amountError}
} +{scheduleError}
} + +Fee Preview
+Schedule {amount} STX to:
+{recipient}
++ Scheduled for: {scheduledDate && scheduledTime ? new Date(`${scheduledDate}T${scheduledTime}`).toLocaleString() : ''} +
+Category: {TIP_CATEGORIES.find(c => c.id === category)?.label}
+ {message &&"{message}"
} + {amount && parseFloat(amount) > 0 && ( +Connect your wallet to view scheduled tips
++ {filter === 'all' ? 'No scheduled tips yet' : `No ${filter} scheduled tips`} +
++ "{tip.message}" +
+ )} + + {tip.txId && ( + + )} + + {tip.failureReason && ( +Are you sure you want to cancel this scheduled tip?
+This action cannot be undone.
+