diff --git a/chainhook/refund-api.test.js b/chainhook/refund-api.test.js new file mode 100644 index 00000000..2d5575b0 --- /dev/null +++ b/chainhook/refund-api.test.js @@ -0,0 +1,211 @@ +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { MemoryRefundStore, REFUND_STATUSES } from './storage.js'; +import { isValidStacksAddress } from './validation.js'; + +function makeRequest(overrides = {}) { + return { + tipId: 'tip-1', + txId: '0xabc123', + sender: 'SP1SENDER000000000000000000000000000', + recipient: 'SP2RECIPIENT00000000000000000000000', + amount: 95000, + reason: '', + status: REFUND_STATUSES.PENDING, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +function validateRefundBody(body) { + if (!body.tipId || typeof body.tipId !== 'string') { + return { valid: false, error: 'tipId is required' }; + } + if (!body.txId || typeof body.txId !== 'string') { + return { valid: false, error: 'txId is required' }; + } + if (!body.sender || typeof body.sender !== 'string') { + return { valid: false, error: 'sender address is required' }; + } + if (!isValidStacksAddress(body.sender)) { + return { valid: false, error: 'invalid sender address format' }; + } + if (!body.recipient || typeof body.recipient !== 'string') { + return { valid: false, error: 'recipient address is required' }; + } + if (!isValidStacksAddress(body.recipient)) { + return { valid: false, error: 'invalid recipient address format' }; + } + const amountNum = Number(body.amount); + if (!body.amount || isNaN(amountNum) || amountNum <= 0) { + return { valid: false, error: 'amount must be a positive number' }; + } + return { valid: true }; +} + +function validateResolveBody(body) { + if (!body.action || !['approve', 'reject'].includes(body.action)) { + return { valid: false, error: "action must be 'approve' or 'reject'" }; + } + if (!body.recipient || typeof body.recipient !== 'string') { + return { valid: false, error: 'recipient address is required' }; + } + if (!isValidStacksAddress(body.recipient)) { + return { valid: false, error: 'invalid recipient address format' }; + } + return { valid: true }; +} + +describe('refund request body validation', () => { + it('accepts a valid refund request body', () => { + const result = validateRefundBody({ + tipId: 'tip-1', + txId: '0xabc', + sender: 'SP1SENDER000000000000000000000000000', + recipient: 'SP2RECIPIENT00000000000000000000000', + amount: 95000, + }); + assert.strictEqual(result.valid, true); + }); + + it('rejects missing tipId', () => { + const result = validateRefundBody({ txId: '0x', sender: 'SP1SENDER000000000000000000000000000', recipient: 'SP2RECIPIENT00000000000000000000000', amount: 1000 }); + assert.strictEqual(result.valid, false); + assert.match(result.error, /tipId/); + }); + + it('rejects missing txId', () => { + const result = validateRefundBody({ tipId: 'tip-1', sender: 'SP1SENDER000000000000000000000000000', recipient: 'SP2RECIPIENT00000000000000000000000', amount: 1000 }); + assert.strictEqual(result.valid, false); + assert.match(result.error, /txId/); + }); + + it('rejects missing sender', () => { + const result = validateRefundBody({ tipId: 'tip-1', txId: '0x', recipient: 'SP2RECIPIENT00000000000000000000000', amount: 1000 }); + assert.strictEqual(result.valid, false); + assert.match(result.error, /sender/); + }); + + it('rejects missing recipient', () => { + const result = validateRefundBody({ tipId: 'tip-1', txId: '0x', sender: 'SP1SENDER000000000000000000000000000', amount: 1000 }); + assert.strictEqual(result.valid, false); + assert.match(result.error, /recipient/); + }); + + it('rejects zero amount', () => { + const result = validateRefundBody({ tipId: 'tip-1', txId: '0x', sender: 'SP1SENDER000000000000000000000000000', recipient: 'SP2RECIPIENT00000000000000000000000', amount: 0 }); + assert.strictEqual(result.valid, false); + assert.match(result.error, /amount/); + }); + + it('rejects negative amount', () => { + const result = validateRefundBody({ tipId: 'tip-1', txId: '0x', sender: 'SP1SENDER000000000000000000000000000', recipient: 'SP2RECIPIENT00000000000000000000000', amount: -100 }); + assert.strictEqual(result.valid, false); + assert.match(result.error, /amount/); + }); +}); + +describe('refund resolve body validation', () => { + it('accepts approve action', () => { + const result = validateResolveBody({ action: 'approve', recipient: 'SP2RECIPIENT00000000000000000000000' }); + assert.strictEqual(result.valid, true); + }); + + it('accepts reject action', () => { + const result = validateResolveBody({ action: 'reject', recipient: 'SP2RECIPIENT00000000000000000000000' }); + assert.strictEqual(result.valid, true); + }); + + it('rejects invalid action', () => { + const result = validateResolveBody({ action: 'cancel', recipient: 'SP2RECIPIENT00000000000000000000000' }); + assert.strictEqual(result.valid, false); + assert.match(result.error, /action/); + }); + + it('rejects missing action', () => { + const result = validateResolveBody({ recipient: 'SP2RECIPIENT0000000000000000000000' }); + assert.strictEqual(result.valid, false); + }); + + it('rejects missing recipient', () => { + const result = validateResolveBody({ action: 'approve' }); + assert.strictEqual(result.valid, false); + assert.match(result.error, /recipient/); + }); +}); + +describe('refund request lifecycle via MemoryRefundStore', () => { + let store; + + beforeEach(() => { + store = new MemoryRefundStore(); + }); + + it('full approve lifecycle', async () => { + const request = makeRequest(); + const inserted = await store.insertRefundRequest(request); + assert.strictEqual(inserted.inserted, true); + assert.strictEqual(inserted.request.status, REFUND_STATUSES.PENDING); + + const found = await store.getRefundRequest('tip-1'); + assert.ok(found); + assert.strictEqual(found.status, REFUND_STATUSES.PENDING); + + const updated = await store.updateRefundRequest('tip-1', { + status: REFUND_STATUSES.APPROVED, + resolvedAt: new Date(), + refundTxId: '0xrefundtx', + }); + assert.strictEqual(updated.updated, true); + assert.strictEqual(updated.request.status, REFUND_STATUSES.APPROVED); + assert.strictEqual(updated.request.refundTxId, '0xrefundtx'); + }); + + it('full reject lifecycle', async () => { + await store.insertRefundRequest(makeRequest()); + + const updated = await store.updateRefundRequest('tip-1', { + status: REFUND_STATUSES.REJECTED, + resolvedAt: new Date(), + }); + assert.strictEqual(updated.updated, true); + assert.strictEqual(updated.request.status, REFUND_STATUSES.REJECTED); + assert.ok(updated.request.resolvedAt); + }); + + it('prevents duplicate refund request for same tip', async () => { + await store.insertRefundRequest(makeRequest()); + const second = await store.insertRefundRequest(makeRequest({ reason: 'second attempt' })); + assert.strictEqual(second.inserted, false); + assert.strictEqual(second.request.reason, ''); + }); + + it('only recipient can resolve — enforced at application layer', async () => { + await store.insertRefundRequest(makeRequest()); + const found = await store.getRefundRequest('tip-1'); + assert.strictEqual(found.recipient, 'SP2RECIPIENT00000000000000000000000'); + + const wrongRecipient = 'SPWRONG000000000000000000000000000'; + assert.notStrictEqual(found.recipient, wrongRecipient); + }); + + it('lists pending requests for a sender', async () => { + await store.insertRefundRequest(makeRequest({ tipId: 'tip-1', sender: 'SPAAAA' })); + await store.insertRefundRequest(makeRequest({ tipId: 'tip-2', sender: 'SPAAAA', status: REFUND_STATUSES.APPROVED })); + await store.insertRefundRequest(makeRequest({ tipId: 'tip-3', sender: 'SPBBBB' })); + + const pending = await store.listRefundRequests({ sender: 'SPAAAA', status: REFUND_STATUSES.PENDING }); + assert.strictEqual(pending.total, 1); + assert.strictEqual(pending.requests[0].tipId, 'tip-1'); + }); + + it('lists pending requests for a recipient', async () => { + await store.insertRefundRequest(makeRequest({ tipId: 'tip-1', recipient: 'SPRECIP1' })); + await store.insertRefundRequest(makeRequest({ tipId: 'tip-2', recipient: 'SPRECIP2' })); + + const result = await store.listRefundRequests({ recipient: 'SPRECIP1' }); + assert.strictEqual(result.total, 1); + assert.strictEqual(result.requests[0].tipId, 'tip-1'); + }); +}); diff --git a/chainhook/refund.test.js b/chainhook/refund.test.js new file mode 100644 index 00000000..7f1eaf9e --- /dev/null +++ b/chainhook/refund.test.js @@ -0,0 +1,178 @@ +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { MemoryRefundStore, REFUND_STATUSES, createRefundStore } from './storage.js'; + +function makeRefundRequest(overrides = {}) { + return { + tipId: 'tip-42', + txId: '0xabc123def456', + sender: 'SP1SENDER000000000000000000000000000', + recipient: 'SP2RECIPIENT0000000000000000000000', + amount: 95000, + status: REFUND_STATUSES.PENDING, + reason: 'sent to wrong address', + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +describe('MemoryRefundStore', () => { + let store; + + beforeEach(() => { + store = new MemoryRefundStore(); + }); + + it('inserts a new refund request', async () => { + const request = makeRefundRequest(); + const result = await store.insertRefundRequest(request); + + assert.strictEqual(result.inserted, true); + assert.strictEqual(result.request.tipId, 'tip-42'); + assert.strictEqual(result.request.status, REFUND_STATUSES.PENDING); + }); + + it('rejects duplicate refund request for same tipId', async () => { + const request = makeRefundRequest(); + await store.insertRefundRequest(request); + + const duplicate = makeRefundRequest({ reason: 'different reason' }); + const result = await store.insertRefundRequest(duplicate); + + assert.strictEqual(result.inserted, false); + assert.strictEqual(result.request.reason, 'sent to wrong address'); + }); + + it('retrieves a refund request by tipId', async () => { + const request = makeRefundRequest(); + await store.insertRefundRequest(request); + + const found = await store.getRefundRequest('tip-42'); + assert.ok(found); + assert.strictEqual(found.tipId, 'tip-42'); + assert.strictEqual(found.sender, request.sender); + assert.strictEqual(found.recipient, request.recipient); + assert.strictEqual(found.amount, 95000); + }); + + it('returns null for non-existent tipId', async () => { + const found = await store.getRefundRequest('tip-999'); + assert.strictEqual(found, null); + }); + + it('lists all refund requests', async () => { + await store.insertRefundRequest(makeRefundRequest({ tipId: 'tip-1' })); + await store.insertRefundRequest(makeRefundRequest({ tipId: 'tip-2' })); + + const result = await store.listRefundRequests(); + assert.strictEqual(result.total, 2); + assert.strictEqual(result.requests.length, 2); + }); + + it('filters by sender', async () => { + await store.insertRefundRequest(makeRefundRequest({ tipId: 'tip-1', sender: 'SPAAAA' })); + await store.insertRefundRequest(makeRefundRequest({ tipId: 'tip-2', sender: 'SPBBBB' })); + + const result = await store.listRefundRequests({ sender: 'SPAAAA' }); + assert.strictEqual(result.total, 1); + assert.strictEqual(result.requests[0].sender, 'SPAAAA'); + }); + + it('filters by recipient', async () => { + await store.insertRefundRequest(makeRefundRequest({ tipId: 'tip-1', recipient: 'SPRECIP1' })); + await store.insertRefundRequest(makeRefundRequest({ tipId: 'tip-2', recipient: 'SPRECIP2' })); + + const result = await store.listRefundRequests({ recipient: 'SPRECIP2' }); + assert.strictEqual(result.total, 1); + assert.strictEqual(result.requests[0].recipient, 'SPRECIP2'); + }); + + it('filters by status', async () => { + await store.insertRefundRequest(makeRefundRequest({ tipId: 'tip-1', status: REFUND_STATUSES.PENDING })); + await store.insertRefundRequest(makeRefundRequest({ tipId: 'tip-2', status: REFUND_STATUSES.APPROVED })); + await store.insertRefundRequest(makeRefundRequest({ tipId: 'tip-3', status: REFUND_STATUSES.REJECTED })); + + const pending = await store.listRefundRequests({ status: REFUND_STATUSES.PENDING }); + assert.strictEqual(pending.total, 1); + + const approved = await store.listRefundRequests({ status: REFUND_STATUSES.APPROVED }); + assert.strictEqual(approved.total, 1); + }); + + it('respects limit and offset', async () => { + for (let i = 1; i <= 5; i++) { + await store.insertRefundRequest(makeRefundRequest({ tipId: `tip-${i}` })); + } + + const page1 = await store.listRefundRequests({ limit: 2, offset: 0 }); + assert.strictEqual(page1.requests.length, 2); + assert.strictEqual(page1.total, 5); + + const page2 = await store.listRefundRequests({ limit: 2, offset: 2 }); + assert.strictEqual(page2.requests.length, 2); + assert.strictEqual(page2.total, 5); + }); + + it('updates a refund request status to approved', async () => { + await store.insertRefundRequest(makeRefundRequest()); + + const result = await store.updateRefundRequest('tip-42', { + status: REFUND_STATUSES.APPROVED, + resolvedAt: new Date(), + refundTxId: '0xrefundtx', + }); + + assert.strictEqual(result.updated, true); + assert.strictEqual(result.request.status, REFUND_STATUSES.APPROVED); + assert.strictEqual(result.request.refundTxId, '0xrefundtx'); + assert.ok(result.request.resolvedAt); + }); + + it('updates a refund request status to rejected', async () => { + await store.insertRefundRequest(makeRefundRequest()); + + const result = await store.updateRefundRequest('tip-42', { + status: REFUND_STATUSES.REJECTED, + resolvedAt: new Date(), + }); + + assert.strictEqual(result.updated, true); + assert.strictEqual(result.request.status, REFUND_STATUSES.REJECTED); + }); + + it('returns updated false for non-existent tipId', async () => { + const result = await store.updateRefundRequest('tip-999', { + status: REFUND_STATUSES.APPROVED, + }); + + assert.strictEqual(result.updated, false); + assert.strictEqual(result.request, null); + }); +}); + +describe('REFUND_STATUSES', () => { + it('defines expected status values', () => { + assert.strictEqual(REFUND_STATUSES.PENDING, 'pending'); + assert.strictEqual(REFUND_STATUSES.APPROVED, 'approved'); + assert.strictEqual(REFUND_STATUSES.REJECTED, 'rejected'); + }); +}); + +describe('createRefundStore', () => { + it('creates a memory store when mode is memory', async () => { + const store = await createRefundStore({ mode: 'memory' }); + assert.ok(store instanceof MemoryRefundStore); + }); + + it('memory store init returns self', async () => { + const store = new MemoryRefundStore(); + const result = await store.init(); + assert.strictEqual(result, store); + }); + + it('memory store close resolves without error', async () => { + const store = new MemoryRefundStore(); + await assert.doesNotReject(() => store.close()); + }); +}); diff --git a/chainhook/schema.sql b/chainhook/schema.sql index 03c4c631..700e5171 100644 --- a/chainhook/schema.sql +++ b/chainhook/schema.sql @@ -42,3 +42,21 @@ CREATE INDEX IF NOT EXISTS scheduled_tips_recipient_idx ON scheduled_tips (recip 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'; + +CREATE TABLE IF NOT EXISTS refund_requests ( + tip_id TEXT PRIMARY KEY, + tx_id TEXT NOT NULL DEFAULT '', + sender TEXT NOT NULL, + recipient TEXT NOT NULL, + amount BIGINT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + reason TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + resolved_at TIMESTAMPTZ, + refund_tx_id TEXT +); + +CREATE INDEX IF NOT EXISTS refund_requests_sender_idx ON refund_requests (sender); +CREATE INDEX IF NOT EXISTS refund_requests_recipient_idx ON refund_requests (recipient); +CREATE INDEX IF NOT EXISTS refund_requests_status_idx ON refund_requests (status); diff --git a/chainhook/server.js b/chainhook/server.js index 47b6e525..be0d3452 100644 --- a/chainhook/server.js +++ b/chainhook/server.js @@ -9,11 +9,12 @@ 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, createScheduledTipStore, getRetentionCutoff, parseRetentionDays } from "./storage.js"; +import { createEventStore, createScheduledTipStore, createRefundStore, 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"; import { wsManager } from "./websocket.js"; +import { REFUND_STATUSES, REFUND_WINDOW_MS } from "./storage.js"; const PORT = process.env.PORT || 3100; const AUTH_TOKEN = process.env.CHAINHOOK_AUTH_TOKEN || ""; @@ -29,6 +30,7 @@ const RATE_LIMIT_WINDOW_MS = parseInt(process.env.RATE_LIMIT_WINDOW_MS || "60000 const rateLimiter = new RateLimiter(RATE_LIMIT_MAX_REQUESTS, RATE_LIMIT_WINDOW_MS); let eventStore = null; let scheduledTipStore = null; +let refundStore = null; /** * Get the rate limiter instance for runtime configuration. @@ -69,6 +71,20 @@ async function getScheduledTipStore() { return scheduledTipStore; } +async function getRefundStore() { + if (!refundStore) { + if (STORAGE_MODE === "postgres" && !DATABASE_URL) { + throw new Error("DATABASE_URL is required when CHAINHOOK_STORAGE=postgres"); + } + refundStore = await createRefundStore({ + mode: STORAGE_MODE, + databaseUrl: DATABASE_URL, + }); + await refundStore.init(); + } + return refundStore; +} + /** * Read and parse a JSON request body from a readable stream. * Rejects if the body exceeds MAX_BODY_SIZE or contains invalid JSON. @@ -274,7 +290,7 @@ function parseTipEvent(event) { }; } -export { parseBody, extractEvents, parseTipEvent, sendJson, getEventStore, checkShutdownState, validatePayloadStructure, validateBlock, validateTransaction, getRateLimiter, wsManager }; +export { parseBody, extractEvents, parseTipEvent, sendJson, getEventStore, checkShutdownState, validatePayloadStructure, validateBlock, validateTransaction, getRateLimiter, wsManager, getRefundStore }; function checkShutdownState(res, requestId) { if (isShuttingDown()) { @@ -646,6 +662,189 @@ const server = http.createServer(async (req, res) => { } } + // POST /api/refunds -- create a refund request + if (req.method === "POST" && path === "/api/refunds") { + const startTime = Date.now(); + + try { + const body = await parseBody(req); + const { tipId, txId, sender, recipient, amount, reason } = body; + + if (!tipId || typeof tipId !== 'string') { + return sendError(res, new BadRequestError('tipId is required'), requestId, { path }); + } + if (!txId || typeof txId !== 'string') { + return sendError(res, new BadRequestError('txId is required'), requestId, { path }); + } + if (!sender || typeof sender !== 'string') { + return sendError(res, new BadRequestError('sender address is required'), requestId, { path }); + } + if (!isValidStacksAddress(sender)) { + return sendError(res, new BadRequestError('invalid sender address format'), requestId, { path }); + } + if (!recipient || typeof recipient !== 'string') { + return sendError(res, new BadRequestError('recipient address is required'), requestId, { path }); + } + if (!isValidStacksAddress(recipient)) { + return sendError(res, new BadRequestError('invalid recipient address format'), requestId, { path }); + } + const amountNum = Number(amount); + if (!amount || isNaN(amountNum) || amountNum <= 0) { + return sendError(res, new BadRequestError('amount must be a positive number'), requestId, { path }); + } + + const store = await getRefundStore(); + + const existing = await store.getRefundRequest(tipId); + if (existing) { + return sendError(res, new BadRequestError('a refund request already exists for this tip'), requestId, { path }); + } + + const request = { + tipId, + txId, + sender, + recipient, + amount: amountNum, + status: REFUND_STATUSES.PENDING, + reason: reason || '', + createdAt: new Date(), + updatedAt: new Date(), + }; + + const result = await store.insertRefundRequest(request); + + const processingMs = Date.now() - startTime; + logger.info('Refund request created', { + tip_id: tipId, + sender, + recipient, + request_id: requestId, + processing_ms: processingMs, + }); + + metrics.recordRequest(true); + return sendJson(res, 201, { ok: true, refundRequest: result.request }); + } catch (err) { + const processingMs = Date.now() - startTime; + metrics.recordRequest(false); + return sendError(res, err, requestId, { path, processing_ms: processingMs }); + } + } + + // GET /api/refunds -- list refund requests with optional filters + if (req.method === "GET" && path === "/api/refunds") { + 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 }); + } + + if (sender && !isValidStacksAddress(sender)) { + return sendError(res, new BadRequestError("invalid sender address format"), requestId, { path }); + } + if (recipient && !isValidStacksAddress(recipient)) { + return sendError(res, new BadRequestError("invalid recipient address format"), requestId, { path }); + } + if (status && !Object.values(REFUND_STATUSES).includes(status)) { + return sendError(res, new BadRequestError("invalid status value"), requestId, { path }); + } + + const store = await getRefundStore(); + const result = await store.listRefundRequests({ sender, recipient, status, limit, offset }); + + return sendJson(res, 200, { refundRequests: result.requests, total: result.total }); + } catch (err) { + return sendError(res, err, requestId, { path }); + } + } + + // GET /api/refunds/:tipId -- get a single refund request + if (req.method === "GET" && path.match(/^\/api\/refunds\/[^/]+$/)) { + try { + const tipId = path.split("/api/refunds/")[1]; + const store = await getRefundStore(); + const request = await store.getRefundRequest(tipId); + + if (!request) { + return sendJson(res, 404, { error: "refund request not found" }); + } + + return sendJson(res, 200, request); + } catch (err) { + return sendError(res, err, requestId, { path }); + } + } + + // PATCH /api/refunds/:tipId -- approve or reject a refund request + if (req.method === "PATCH" && path.match(/^\/api\/refunds\/[^/]+$/)) { + const startTime = Date.now(); + + try { + const tipId = path.split("/api/refunds/")[1]; + const body = await parseBody(req); + const { action, recipient, refundTxId } = body; + + if (!action || !['approve', 'reject'].includes(action)) { + return sendError(res, new BadRequestError("action must be 'approve' or 'reject'"), requestId, { path }); + } + if (!recipient || typeof recipient !== 'string') { + return sendError(res, new BadRequestError('recipient address is required'), requestId, { path }); + } + if (!isValidStacksAddress(recipient)) { + return sendError(res, new BadRequestError('invalid recipient address format'), requestId, { path }); + } + + const store = await getRefundStore(); + const existing = await store.getRefundRequest(tipId); + + if (!existing) { + return sendJson(res, 404, { error: "refund request not found" }); + } + if (existing.recipient !== recipient) { + return sendError(res, new BadRequestError('only the tip recipient can approve or reject a refund'), requestId, { path }); + } + if (existing.status !== REFUND_STATUSES.PENDING) { + return sendError(res, new BadRequestError('refund request is no longer pending'), requestId, { path }); + } + + const newStatus = action === 'approve' ? REFUND_STATUSES.APPROVED : REFUND_STATUSES.REJECTED; + const updates = { + status: newStatus, + resolvedAt: new Date(), + }; + if (action === 'approve' && refundTxId) { + updates.refundTxId = refundTxId; + } + + const result = await store.updateRefundRequest(tipId, updates); + + const processingMs = Date.now() - startTime; + logger.info('Refund request updated', { + tip_id: tipId, + action, + recipient, + request_id: requestId, + processing_ms: processingMs, + }); + + metrics.recordRequest(true); + return sendJson(res, 200, { ok: true, refundRequest: result.request }); + } 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.js b/chainhook/storage.js index 50a8fc6c..162d7f5d 100644 --- a/chainhook/storage.js +++ b/chainhook/storage.js @@ -832,3 +832,282 @@ export async function createScheduledTipStore(options = {}) { } export { MemoryScheduledTipStore, PostgresScheduledTipStore }; + +const REFUND_WINDOW_MS = 24 * 60 * 60 * 1000; + +const REFUND_STATUSES = { + PENDING: 'pending', + APPROVED: 'approved', + REJECTED: 'rejected', +}; + +class MemoryRefundStore { + constructor() { + this.requests = []; + } + + async init() { + return this; + } + + async insertRefundRequest(request) { + const existing = this.requests.find(r => r.tipId === request.tipId); + if (existing) { + return { inserted: false, request: existing }; + } + this.requests.push({ ...request }); + return { inserted: true, request }; + } + + async getRefundRequest(tipId) { + return this.requests.find(r => r.tipId === tipId) || null; + } + + async listRefundRequests(filters = {}) { + let results = [...this.requests]; + + if (filters.sender) { + results = results.filter(r => r.sender === filters.sender); + } + if (filters.recipient) { + results = results.filter(r => r.recipient === filters.recipient); + } + if (filters.status) { + results = results.filter(r => r.status === filters.status); + } + + results.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + + const offset = filters.offset || 0; + const limit = filters.limit || 50; + const total = results.length; + results = results.slice(offset, offset + limit); + + return { requests: results, total }; + } + + async updateRefundRequest(tipId, updates) { + const index = this.requests.findIndex(r => r.tipId === tipId); + if (index === -1) { + return { updated: false, request: null }; + } + this.requests[index] = { ...this.requests[index], ...updates, updatedAt: new Date() }; + return { updated: true, request: this.requests[index] }; + } + + async close() {} +} + +class PostgresRefundStore { + constructor(pool, poolConfig = {}, retryOptions = {}) { + this.pool = pool; + this.poolConfig = poolConfig; + this.retryOptions = { ...retryConfig, ...retryOptions }; + this.ready = null; + } + + async init() { + if (!this.ready) { + this.ready = this.#initialize(); + } + return this.ready; + } + + async #initialize() { + await withRetry( + () => this.pool.query('SELECT 1'), + { operationName: 'postgres_refund_connect', maxAttempts: 5, baseDelayMs: 500 } + ); + + await this.pool.query(` + CREATE TABLE IF NOT EXISTS refund_requests ( + tip_id TEXT PRIMARY KEY, + tx_id TEXT NOT NULL, + sender TEXT NOT NULL, + recipient TEXT NOT NULL, + amount BIGINT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + reason TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + resolved_at TIMESTAMPTZ, + refund_tx_id TEXT + ); + `); + + await this.pool.query('CREATE INDEX IF NOT EXISTS refund_requests_sender_idx ON refund_requests (sender);'); + await this.pool.query('CREATE INDEX IF NOT EXISTS refund_requests_recipient_idx ON refund_requests (recipient);'); + await this.pool.query('CREATE INDEX IF NOT EXISTS refund_requests_status_idx ON refund_requests (status);'); + } + + async insertRefundRequest(request) { + await this.init(); + + const result = await withRetry( + () => this.pool.query( + `INSERT INTO refund_requests (tip_id, tx_id, sender, recipient, amount, status, reason, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (tip_id) DO NOTHING + RETURNING *`, + [ + request.tipId, + request.txId || '', + request.sender, + request.recipient, + request.amount, + request.status || REFUND_STATUSES.PENDING, + request.reason || '', + request.createdAt || new Date(), + request.updatedAt || new Date(), + ] + ), + { ...this.retryOptions, operationName: 'postgres_insert_refund_request' }, + ); + + if (result.rowCount === 0) { + const existing = await this.getRefundRequest(request.tipId); + return { inserted: false, request: existing }; + } + + return { inserted: true, request: this.#rowToRequest(result.rows[0]) }; + } + + async getRefundRequest(tipId) { + await this.init(); + const result = await withRetry( + () => this.pool.query('SELECT * FROM refund_requests WHERE tip_id = $1', [tipId]), + { ...this.retryOptions, operationName: 'postgres_get_refund_request' }, + ); + return result.rows[0] ? this.#rowToRequest(result.rows[0]) : null; + } + + async listRefundRequests(filters = {}) { + await this.init(); + + const conditions = ['1=1']; + const values = []; + let paramIndex = 1; + + if (filters.sender) { + conditions.push(`sender = $${paramIndex++}`); + values.push(filters.sender); + } + if (filters.recipient) { + conditions.push(`recipient = $${paramIndex++}`); + values.push(filters.recipient); + } + if (filters.status) { + conditions.push(`status = $${paramIndex++}`); + values.push(filters.status); + } + + const where = conditions.join(' AND '); + const offset = filters.offset || 0; + const limit = filters.limit || 50; + + const dataQuery = `SELECT * FROM refund_requests WHERE ${where} ORDER BY created_at DESC LIMIT $${paramIndex++} OFFSET $${paramIndex++}`; + const countQuery = `SELECT COUNT(*)::int AS count FROM refund_requests WHERE ${where}`; + + const dataValues = [...values, limit, offset]; + const countValues = [...values]; + + const [dataResult, countResult] = await Promise.all([ + withRetry( + () => this.pool.query(dataQuery, dataValues), + { ...this.retryOptions, operationName: 'postgres_list_refund_requests' }, + ), + withRetry( + () => this.pool.query(countQuery, countValues), + { ...this.retryOptions, operationName: 'postgres_count_refund_requests' }, + ), + ]); + + return { + requests: dataResult.rows.map(r => this.#rowToRequest(r)), + total: countResult.rows[0]?.count || 0, + }; + } + + async updateRefundRequest(tipId, 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.resolvedAt !== undefined) { + setClauses.push(`resolved_at = $${paramIndex++}`); + values.push(updates.resolvedAt); + } + if (updates.refundTxId !== undefined) { + setClauses.push(`refund_tx_id = $${paramIndex++}`); + values.push(updates.refundTxId); + } + + setClauses.push(`updated_at = $${paramIndex++}`); + values.push(new Date()); + + values.push(tipId); + + const result = await withRetry( + () => this.pool.query( + `UPDATE refund_requests SET ${setClauses.join(', ')} WHERE tip_id = $${paramIndex} RETURNING *`, + values + ), + { ...this.retryOptions, operationName: 'postgres_update_refund_request' }, + ); + + if (result.rowCount === 0) { + return { updated: false, request: null }; + } + + return { updated: true, request: this.#rowToRequest(result.rows[0]) }; + } + + async close() {} + + #rowToRequest(row) { + return { + tipId: row.tip_id, + txId: row.tx_id, + sender: row.sender, + recipient: row.recipient, + amount: Number(row.amount), + status: row.status, + reason: row.reason || '', + createdAt: row.created_at, + updatedAt: row.updated_at, + resolvedAt: row.resolved_at, + refundTxId: row.refund_tx_id, + }; + } +} + +export async function createRefundStore(options = {}) { + const mode = options.mode || process.env.CHAINHOOK_STORAGE || (process.env.NODE_ENV === 'test' ? 'memory' : 'postgres'); + + if (mode === 'memory') { + return new MemoryRefundStore(); + } + + 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 PostgresRefundStore(pool, poolConfig, options.retryOptions || {}); +} + +export { MemoryRefundStore, PostgresRefundStore, REFUND_STATUSES, REFUND_WINDOW_MS }; diff --git a/contracts/tipstream.clar b/contracts/tipstream.clar index 65816d11..7a2258c0 100644 --- a/contracts/tipstream.clar +++ b/contracts/tipstream.clar @@ -24,6 +24,11 @@ (define-constant err-token-transfer-failed (err u112)) (define-constant err-token-not-whitelisted (err u113)) (define-constant err-invalid-category (err u114)) +(define-constant err-refund-window-expired (err u115)) +(define-constant err-already-refunded (err u116)) +(define-constant err-not-tip-sender (err u117)) +(define-constant err-refund-not-found (err u118)) +(define-constant err-refund-not-pending (err u119)) ;; Tip Categories (uint enum) (define-constant category-general u0) @@ -40,6 +45,14 @@ (define-constant min-fee u1) (define-constant timelock-delay u144) +;; Refund window: ~24 hours at ~10 min/block = 144 blocks +(define-constant refund-window-blocks u144) + +;; Refund request statuses +(define-constant refund-status-pending u0) +(define-constant refund-status-approved u1) +(define-constant refund-status-rejected u2) + ;; Data Variables (define-data-var contract-owner principal tx-sender) (define-data-var pending-owner (optional principal) none) @@ -99,6 +112,20 @@ } ) +;; Refund tracking +(define-map refund-requests + { tip-id: uint } + { + sender: principal, + recipient: principal, + amount: uint, + request-height: uint, + status: uint + } +) + +(define-map refunded-tips { tip-id: uint } bool) + ;; Private Functions (define-private (calculate-fee (amount uint)) (let @@ -460,6 +487,121 @@ (fold strict-tip-fold tips-list (ok u0)) ) +;; Refund Functions + +(define-public (request-refund (tip-id uint)) + (let + ( + (tip (unwrap! (map-get? tips { tip-id: tip-id }) err-not-found)) + (sender (get sender tip)) + (recipient (get recipient tip)) + (amount (get amount tip)) + (tip-height (get tip-height tip)) + ) + (asserts! (not (var-get is-paused)) err-contract-paused) + (asserts! (is-eq tx-sender sender) err-not-tip-sender) + (asserts! (is-none (map-get? refunded-tips { tip-id: tip-id })) err-already-refunded) + (asserts! (is-none (map-get? refund-requests { tip-id: tip-id })) err-already-refunded) + (asserts! (<= block-height (+ tip-height refund-window-blocks)) err-refund-window-expired) + + (map-set refund-requests + { tip-id: tip-id } + { + sender: sender, + recipient: recipient, + amount: amount, + request-height: block-height, + status: refund-status-pending + } + ) + + (print { + event: "refund-requested", + tip-id: tip-id, + sender: sender, + recipient: recipient, + amount: amount, + request-height: block-height + }) + + (ok tip-id) + ) +) + +(define-public (approve-refund (tip-id uint)) + (let + ( + (request (unwrap! (map-get? refund-requests { tip-id: tip-id }) err-refund-not-found)) + (tip (unwrap! (map-get? tips { tip-id: tip-id }) err-not-found)) + (sender (get sender request)) + (recipient (get recipient request)) + (amount (get amount tip)) + (fee (calculate-fee amount)) + (net-amount (- amount fee)) + (status (get status request)) + ) + (asserts! (not (var-get is-paused)) err-contract-paused) + (asserts! (is-eq tx-sender recipient) err-not-authorized) + (asserts! (is-eq status refund-status-pending) err-refund-not-pending) + (asserts! (is-none (map-get? refunded-tips { tip-id: tip-id })) err-already-refunded) + + (try! (stx-transfer? net-amount tx-sender sender)) + + (map-set refund-requests + { tip-id: tip-id } + (merge request { status: refund-status-approved }) + ) + (map-set refunded-tips { tip-id: tip-id } true) + + (map-set user-total-sent sender + (let ((current (default-to u0 (map-get? user-total-sent sender)))) + (if (>= current amount) (- current amount) u0) + ) + ) + (map-set user-total-received recipient + (let ((current (default-to u0 (map-get? user-total-received recipient)))) + (if (>= current net-amount) (- current net-amount) u0) + ) + ) + + (print { + event: "refund-approved", + tip-id: tip-id, + sender: sender, + recipient: recipient, + refund-amount: net-amount + }) + + (ok tip-id) + ) +) + +(define-public (reject-refund (tip-id uint)) + (let + ( + (request (unwrap! (map-get? refund-requests { tip-id: tip-id }) err-refund-not-found)) + (recipient (get recipient request)) + (status (get status request)) + ) + (asserts! (is-eq tx-sender recipient) err-not-authorized) + (asserts! (is-eq status refund-status-pending) err-refund-not-pending) + + (map-set refund-requests + { tip-id: tip-id } + (merge request { status: refund-status-rejected }) + ) + + (print { + event: "refund-rejected", + tip-id: tip-id, + sender: (get sender request), + recipient: recipient + }) + + (ok tip-id) + ) +) + ;; Read-only Functions (define-read-only (get-tip (tip-id uint)) (map-get? tips { tip-id: tip-id }) @@ -570,3 +712,26 @@ (define-read-only (get-category-count (category uint)) (ok (default-to u0 (map-get? category-tip-count category))) ) + +(define-read-only (get-refund-request (tip-id uint)) + (ok (map-get? refund-requests { tip-id: tip-id })) +) + +(define-read-only (is-tip-refunded (tip-id uint)) + (ok (default-to false (map-get? refunded-tips { tip-id: tip-id }))) +) + +(define-read-only (get-refund-window-blocks) + (ok refund-window-blocks) +) + +(define-read-only (is-refund-eligible (tip-id uint)) + (match (map-get? tips { tip-id: tip-id }) + tip (ok (and + (<= block-height (+ (get tip-height tip) refund-window-blocks)) + (is-none (map-get? refunded-tips { tip-id: tip-id })) + (is-none (map-get? refund-requests { tip-id: tip-id })) + )) + (err err-not-found) + ) +) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a31fab68..b163b7f2 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -20,10 +20,10 @@ import { useDemoMode } from './context/DemoContext'; import { ROUTE_SEND, ROUTE_BATCH, ROUTE_TOKEN_TIP, ROUTE_SCHEDULE, ROUTE_SCHEDULED_TIPS, ROUTE_FEED, ROUTE_LEADERBOARD, ROUTE_ACTIVITY, ROUTE_PROFILE, ROUTE_ADDRESS_BOOK, - ROUTE_BLOCK, ROUTE_STATS, ROUTE_ADMIN, ROUTE_TELEMETRY, + ROUTE_BLOCK, ROUTE_STATS, ROUTE_ADMIN, ROUTE_TELEMETRY, ROUTE_REFUNDS, DEFAULT_AUTHENTICATED_ROUTE, ROUTE_META, } from './config/routes'; -import { Zap, Radio, Trophy, User, BarChart3, Users, ShieldBan, Coins, UserCircle, Shield, Gauge, Calendar, Clock, BookUser } from 'lucide-react'; +import { Zap, Radio, Trophy, User, BarChart3, Users, ShieldBan, Coins, UserCircle, Shield, Gauge, Calendar, Clock, BookUser, RotateCcw } from 'lucide-react'; import { activateDemo, deactivateDemo } from './lib/demo-utils'; const AnimatedHero = lazy(() => import('./components/ui/animated-hero').then(m => ({ default: m.AnimatedHero }))); @@ -43,6 +43,7 @@ const TokenTip = lazy(() => import('./components/TokenTip')); const NotFound = lazy(() => import('./components/NotFound')); const AdminDashboard = lazy(() => import('./components/AdminDashboard')); const TelemetryDashboard = lazy(() => import('./components/TelemetryDashboard')); +const RefundManager = lazy(() => import('./components/RefundManager')); function App() { const [userData, setUserData] = useState(null); @@ -168,6 +169,7 @@ function App() { { path: ROUTE_PROFILE, label: 'Profile', icon: UserCircle }, { path: ROUTE_ADDRESS_BOOK, label: 'Address Book', icon: BookUser }, { path: ROUTE_BLOCK, label: 'Block', icon: ShieldBan }, + { path: ROUTE_REFUNDS, label: 'Refunds', icon: RotateCcw }, { path: ROUTE_STATS, label: 'Stats', icon: BarChart3 }, ]; @@ -338,10 +340,10 @@ function App() { path={ROUTE_ACTIVITY} element={ userData || demoEnabled ? ( - + ) : ( - + ) } @@ -386,6 +388,20 @@ function App() { {/* Admin-only routes */} } /> } /> + + {/* Refund management */} + + ) : ( + + + + ) + } + /> {/* Root and fallback */} } /> diff --git a/frontend/src/components/RefundApproval.jsx b/frontend/src/components/RefundApproval.jsx new file mode 100644 index 00000000..af0e156e --- /dev/null +++ b/frontend/src/components/RefundApproval.jsx @@ -0,0 +1,151 @@ +import { useState, useEffect, useCallback } from 'react'; +import { openContractCall } from '@stacks/connect'; +import { uintCV } from '@stacks/transactions'; +import { network, appDetails } from '../utils/stacks'; +import { CONTRACT_ADDRESS, CONTRACT_NAME, FN_APPROVE_REFUND, FN_REJECT_REFUND } from '../config/contracts'; +import { formatSTX, formatAddress } from '../lib/utils'; +import { useRefund } from '../hooks/useRefund'; +import { useDemoMode } from '../context/DemoContext'; +import ConfirmDialog from './ui/confirm-dialog'; + +export default function RefundApproval({ tip, recipientAddress, addToast, onResolved }) { + const { demoEnabled } = useDemoMode(); + const { loading, resolveRefund, fetchRefundRequest } = useRefund(); + const [refundRequest, setRefundRequest] = useState(null); + const [fetching, setFetching] = useState(true); + const [confirmAction, setConfirmAction] = useState(null); + + const isRecipient = tip.recipient === recipientAddress; + + const loadRefundRequest = useCallback(async () => { + if (!tip.tipId) return; + setFetching(true); + const result = await fetchRefundRequest(String(tip.tipId)); + if (result.ok) { + setRefundRequest(result.refundRequest); + } + setFetching(false); + }, [tip.tipId, fetchRefundRequest]); + + useEffect(() => { + if (isRecipient) { + loadRefundRequest(); + } + }, [isRecipient, loadRefundRequest]); + + if (!isRecipient || fetching) return null; + if (!refundRequest || refundRequest.status !== 'pending') return null; + + const handleAction = async (action) => { + setConfirmAction(null); + + if (demoEnabled) { + addToast(`Refund ${action === 'approve' ? 'approved' : 'rejected'} (demo).`, 'success'); + if (onResolved) onResolved({ ...refundRequest, status: action === 'approve' ? 'approved' : 'rejected' }); + return; + } + + const fnName = action === 'approve' ? FN_APPROVE_REFUND : FN_REJECT_REFUND; + + try { + await openContractCall({ + network, + appDetails, + contractAddress: CONTRACT_ADDRESS, + contractName: CONTRACT_NAME, + functionName: fnName, + functionArgs: [uintCV(Number(tip.tipId))], + onFinish: async (data) => { + const result = await resolveRefund({ + tipId: String(tip.tipId), + action, + recipient: recipientAddress, + refundTxId: action === 'approve' ? data.txId : undefined, + }); + if (result.ok) { + const label = action === 'approve' ? 'approved' : 'rejected'; + addToast(`Refund request ${label}.`, 'success'); + setRefundRequest(result.refundRequest); + if (onResolved) onResolved(result.refundRequest); + } else { + addToast(result.error || 'Failed to update refund request.', 'error'); + } + }, + onCancel: () => { + addToast('Transaction cancelled.', 'info'); + }, + }); + } catch (err) { + addToast(err.message || 'Transaction failed.', 'error'); + } + }; + + return ( + <> +
+

Refund Requested

+

+ {formatAddress(refundRequest.sender, 8, 6)} is requesting a refund of{' '} + {formatSTX(refundRequest.amount, 2)} STX. + {refundRequest.reason ? ` Reason: "${refundRequest.reason}"` : ''} +

+
+ + +
+
+ + handleAction('approve')} + onCancel={() => setConfirmAction(null)} + confirmLabel="Approve Refund" + cancelLabel="Cancel" + > +

+ You will return{' '} + + {formatSTX(refundRequest.amount, 2)} STX + {' '} + to{' '} + + {formatAddress(refundRequest.sender, 8, 6)} + + . This action cannot be undone. +

+
+ + handleAction('reject')} + onCancel={() => setConfirmAction(null)} + confirmLabel="Reject Refund" + cancelLabel="Cancel" + > +

+ You are declining the refund request from{' '} + + {formatAddress(refundRequest.sender, 8, 6)} + + . The tip will remain with you. +

+
+ + ); +} diff --git a/frontend/src/components/RefundHistory.jsx b/frontend/src/components/RefundHistory.jsx new file mode 100644 index 00000000..feb18f03 --- /dev/null +++ b/frontend/src/components/RefundHistory.jsx @@ -0,0 +1,271 @@ +import { useState, useEffect, useCallback } from 'react'; +import { formatSTX, formatAddress } from '../lib/utils'; +import { useRefund } from '../hooks/useRefund'; +import { useDemoMode } from '../context/DemoContext'; +import CopyButton from './ui/copy-button'; + +const STATUS_STYLES = { + pending: 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300', + approved: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400', + rejected: 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400', +}; + +const STATUS_LABELS = { + pending: 'Pending', + approved: 'Approved', + rejected: 'Rejected', +}; + +const PAGE_SIZE = 20; + +function formatDate(value) { + if (!value) return null; + const d = new Date(value); + if (isNaN(d.getTime())) return null; + return d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }); +} + +export default function RefundHistory({ userAddress }) { + const { demoEnabled } = useDemoMode(); + const { fetchUserRefunds } = useRefund(); + + const [requests, setRequests] = useState([]); + const [total, setTotal] = useState(0); + const [offset, setOffset] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [tab, setTab] = useState('all'); + const [statusFilter, setStatusFilter] = useState('all'); + const [loadingMore, setLoadingMore] = useState(false); + + const load = useCallback(async (reset = true) => { + if (!userAddress) { + setRequests([]); + setLoading(false); + return; + } + + if (demoEnabled) { + setRequests([]); + setTotal(0); + setLoading(false); + return; + } + + const currentOffset = reset ? 0 : offset; + if (reset) setLoading(true); + + const params = { limit: PAGE_SIZE, offset: currentOffset }; + if (tab === 'sent') params.sender = userAddress; + else if (tab === 'received') params.recipient = userAddress; + else { + params.sender = userAddress; + } + if (statusFilter !== 'all') params.status = statusFilter; + + const result = await fetchUserRefunds(params); + + if (result.ok) { + if (tab === 'all') { + const receivedResult = await fetchUserRefunds({ + recipient: userAddress, + limit: PAGE_SIZE, + offset: currentOffset, + ...(statusFilter !== 'all' ? { status: statusFilter } : {}), + }); + + const combined = [...(result.refundRequests || [])]; + if (receivedResult.ok) { + for (const r of receivedResult.refundRequests || []) { + if (!combined.find(x => x.tipId === r.tipId)) { + combined.push(r); + } + } + } + combined.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + + setRequests(reset ? combined : prev => { + const merged = [...prev]; + for (const r of combined) { + if (!merged.find(x => x.tipId === r.tipId)) merged.push(r); + } + return merged; + }); + setTotal(combined.length); + } else { + setRequests(reset ? result.refundRequests : prev => [...prev, ...result.refundRequests]); + setTotal(result.total); + } + setOffset(currentOffset + PAGE_SIZE); + setError(null); + } else { + setError(result.error || 'Failed to load refund history'); + } + + setLoading(false); + }, [userAddress, tab, statusFilter, offset, demoEnabled, fetchUserRefunds]); + + useEffect(() => { + load(true); + }, [userAddress, tab, statusFilter]); + + const handleLoadMore = async () => { + setLoadingMore(true); + await load(false); + setLoadingMore(false); + }; + + const hasMore = requests.length < total; + + if (loading) { + return ( +
+
+

Loading refund history...

+
+ ); + } + + if (error) { + return ( +
+

{error}

+ +
+ ); + } + + return ( +
+
+

Refund History

+ +
+ +
+
+
+ {['all', 'sent', 'received'].map((t) => ( + + ))} +
+ + + +
+ + {requests.length === 0 ? ( +
+

No refund requests found

+
+ ) : ( +
+ {requests.map((r) => { + const isSender = r.sender === userAddress; + return ( +
+
+
+
+ + {STATUS_LABELS[r.status] || r.status} + + + {isSender ? 'You requested' : 'Requested by'} + +
+ +
+ {isSender ? 'To' : 'From'} + + {formatAddress(isSender ? r.recipient : r.sender, 8, 6)} + + +
+ + {r.reason ? ( +

“{r.reason}”

+ ) : null} + +

+ Requested {formatDate(r.createdAt)} + {r.resolvedAt ? ` · Resolved ${formatDate(r.resolvedAt)}` : ''} +

+
+ +
+

+ {formatSTX(r.amount, 2)} STX +

+ {r.refundTxId ? ( + + View tx + + ) : null} +
+
+
+ ); + })} +
+ )} + + {hasMore && ( +
+ +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/RefundHistory.test.jsx b/frontend/src/components/RefundHistory.test.jsx new file mode 100644 index 00000000..75e97178 --- /dev/null +++ b/frontend/src/components/RefundHistory.test.jsx @@ -0,0 +1,151 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import RefundHistory from './RefundHistory'; + +vi.mock('../context/DemoContext', () => ({ + useDemoMode: () => ({ demoEnabled: false }), +})); + +const mockFetchUserRefunds = vi.fn(); + +vi.mock('../hooks/useRefund', () => ({ + useRefund: () => ({ + loading: false, + error: null, + fetchUserRefunds: mockFetchUserRefunds, + requestRefund: vi.fn(), + resolveRefund: vi.fn(), + fetchRefundRequest: vi.fn(), + isWithinRefundWindow: vi.fn(() => true), + }), +})); + +const USER = 'SP1SENDER000000000000000000000000000'; + +function makeRefundRequest(overrides = {}) { + return { + tipId: 'tip-1', + txId: '0xabc', + sender: USER, + recipient: 'SP2RECIPIENT00000000000000000000000', + amount: 95000, + status: 'pending', + reason: 'wrong address', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + resolvedAt: null, + refundTxId: null, + ...overrides, + }; +} + +describe('RefundHistory', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('shows loading spinner initially', () => { + mockFetchUserRefunds.mockReturnValue(new Promise(() => {})); + render(); + expect(screen.getByText(/loading refund history/i)).toBeInTheDocument(); + }); + + it('shows empty state when no requests exist', async () => { + mockFetchUserRefunds.mockResolvedValue({ ok: true, refundRequests: [], total: 0 }); + render(); + await waitFor(() => { + expect(screen.getByText(/no refund requests found/i)).toBeInTheDocument(); + }); + }); + + it('renders a pending refund request', async () => { + const request = makeRefundRequest(); + mockFetchUserRefunds.mockResolvedValue({ ok: true, refundRequests: [request], total: 1 }); + render(); + await waitFor(() => { + const spans = screen.getAllByText('Pending'); + expect(spans.some(el => el.tagName === 'SPAN')).toBe(true); + }); + }); + + it('renders an approved refund request', async () => { + const request = makeRefundRequest({ tipId: 'tip-2', status: 'approved', resolvedAt: new Date().toISOString() }); + mockFetchUserRefunds.mockResolvedValue({ ok: true, refundRequests: [request], total: 1 }); + render(); + await waitFor(() => { + const spans = screen.getAllByText('Approved'); + expect(spans.some(el => el.tagName === 'SPAN')).toBe(true); + }); + }); + + it('renders a rejected refund request', async () => { + const request = makeRefundRequest({ tipId: 'tip-3', status: 'rejected', resolvedAt: new Date().toISOString() }); + mockFetchUserRefunds.mockResolvedValue({ ok: true, refundRequests: [request], total: 1 }); + render(); + await waitFor(() => { + const spans = screen.getAllByText('Rejected'); + expect(spans.some(el => el.tagName === 'SPAN')).toBe(true); + }); + }); + + it('shows the refund amount', async () => { + const request = makeRefundRequest(); + mockFetchUserRefunds.mockResolvedValue({ ok: true, refundRequests: [request], total: 1 }); + render(); + await waitFor(() => { + expect(screen.getByText(/0\.10\s*STX/i)).toBeInTheDocument(); + }); + }); + + it('shows the reason when present', async () => { + const request = makeRefundRequest({ reason: 'wrong address' }); + mockFetchUserRefunds.mockResolvedValue({ ok: true, refundRequests: [request], total: 1 }); + render(); + await waitFor(() => { + expect(screen.getByText(/wrong address/i)).toBeInTheDocument(); + }); + }); + + it('shows error state and retry button on failure', async () => { + mockFetchUserRefunds.mockResolvedValue({ ok: false, error: 'Network error' }); + render(); + await waitFor(() => { + expect(screen.getByText('Network error')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument(); + }); + }); + + it('renders the Refund History heading', async () => { + mockFetchUserRefunds.mockResolvedValue({ ok: true, refundRequests: [], total: 0 }); + render(); + await waitFor(() => { + expect(screen.getByText('Refund History')).toBeInTheDocument(); + }); + }); + + it('renders direction filter tabs', async () => { + mockFetchUserRefunds.mockResolvedValue({ ok: true, refundRequests: [], total: 0 }); + render(); + await waitFor(() => { + expect(screen.getByRole('tab', { name: /all/i })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /sent/i })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /received/i })).toBeInTheDocument(); + }); + }); + + it('renders status filter dropdown', async () => { + mockFetchUserRefunds.mockResolvedValue({ ok: true, refundRequests: [], total: 0 }); + render(); + await waitFor(() => { + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); + }); + + it('shows empty state when userAddress is null', async () => { + mockFetchUserRefunds.mockResolvedValue({ ok: true, refundRequests: [], total: 0 }); + render(); + await waitFor(() => { + expect(screen.getByText(/no refund requests found/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/RefundManager.jsx b/frontend/src/components/RefundManager.jsx new file mode 100644 index 00000000..ed3debe5 --- /dev/null +++ b/frontend/src/components/RefundManager.jsx @@ -0,0 +1,38 @@ +import { useState } from 'react'; +import RefundHistory from './RefundHistory'; +import { useDemoMode } from '../context/DemoContext'; + +export default function RefundManager({ userAddress, addToast }) { + const { demoEnabled } = useDemoMode(); + const [activeTab, setActiveTab] = useState('history'); + + const address = demoEnabled ? null : userAddress; + + return ( +
+
+

Refunds

+

+ Manage refund requests for tips you have sent or received. Refunds must be requested within 24 hours of the tip. +

+
+ +
+ +
+ + {activeTab === 'history' && ( + + )} +
+ ); +} diff --git a/frontend/src/components/RefundRequest.jsx b/frontend/src/components/RefundRequest.jsx new file mode 100644 index 00000000..32a46a57 --- /dev/null +++ b/frontend/src/components/RefundRequest.jsx @@ -0,0 +1,116 @@ +import { useState } from 'react'; +import { openContractCall } from '@stacks/connect'; +import { uintCV } from '@stacks/transactions'; +import { network, appDetails } from '../utils/stacks'; +import { CONTRACT_ADDRESS, CONTRACT_NAME, FN_REQUEST_REFUND } from '../config/contracts'; +import { formatSTX, formatAddress } from '../lib/utils'; +import { useRefund, isWithinRefundWindow } from '../hooks/useRefund'; +import { useDemoMode } from '../context/DemoContext'; +import ConfirmDialog from './ui/confirm-dialog'; + +export default function RefundRequest({ tip, senderAddress, addToast, onRefundSubmitted }) { + const { demoEnabled } = useDemoMode(); + const { loading, requestRefund } = useRefund(); + const [showConfirm, setShowConfirm] = useState(false); + const [reason, setReason] = useState(''); + const [pendingTxId, setPendingTxId] = useState(null); + + const eligible = isWithinRefundWindow(tip.timestamp); + const isSender = tip.sender === senderAddress; + + if (!isSender || !eligible) return null; + + const handleSubmit = async () => { + setShowConfirm(false); + + if (demoEnabled) { + addToast('Refund request submitted (demo).', 'success'); + if (onRefundSubmitted) onRefundSubmitted({ tipId: tip.tipId, status: 'pending' }); + return; + } + + try { + await openContractCall({ + network, + appDetails, + contractAddress: CONTRACT_ADDRESS, + contractName: CONTRACT_NAME, + functionName: FN_REQUEST_REFUND, + functionArgs: [uintCV(Number(tip.tipId))], + onFinish: async (data) => { + setPendingTxId(data.txId); + const result = await requestRefund({ + tipId: String(tip.tipId), + txId: data.txId, + sender: tip.sender, + recipient: tip.recipient, + amount: Number(tip.amount), + reason: reason.trim(), + }); + if (result.ok) { + addToast('Refund request submitted. Waiting for recipient approval.', 'success'); + if (onRefundSubmitted) onRefundSubmitted(result.refundRequest); + } else { + addToast(result.error || 'Failed to record refund request.', 'error'); + } + }, + onCancel: () => { + addToast('Refund request cancelled.', 'info'); + }, + }); + } catch (err) { + addToast(err.message || 'Failed to submit refund request.', 'error'); + } + }; + + return ( + <> + + + setShowConfirm(false)} + confirmLabel="Submit Request" + cancelLabel="Cancel" + > +

+ You are requesting a refund of{' '} + + {formatSTX(tip.amount, 2)} STX + {' '} + sent to{' '} + + {formatAddress(tip.recipient, 8, 6)} + + . +

+

+ The recipient must approve this refund. If they do not respond within the refund window, the request will expire. +

+ +