From ed7eb67651cd929192eac60f80bd4ecfea73640a Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Mon, 18 May 2026 18:01:30 +0100 Subject: [PATCH 01/17] add refund data maps and error constants to tipstream contract --- contracts/tipstream.clar | 165 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) 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) + ) +) From 2a54d514aef5abc2ca7461d9a3659c39cba8af71 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Mon, 18 May 2026 18:01:45 +0100 Subject: [PATCH 02/17] add refund function name constants to contracts config --- frontend/src/config/contracts.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/frontend/src/config/contracts.js b/frontend/src/config/contracts.js index 58708f82..31bf00da 100644 --- a/frontend/src/config/contracts.js +++ b/frontend/src/config/contracts.js @@ -61,6 +61,15 @@ export const FN_GET_USER_STATS = 'get-user-stats'; export const FN_GET_PLATFORM_STATS = 'get-platform-stats'; export const FN_GET_CURRENT_FEE_BASIS_POINTS = 'get-current-fee-basis-points'; +// Refund +export const FN_REQUEST_REFUND = 'request-refund'; +export const FN_APPROVE_REFUND = 'approve-refund'; +export const FN_REJECT_REFUND = 'reject-refund'; +export const FN_GET_REFUND_REQUEST = 'get-refund-request'; +export const FN_IS_TIP_REFUNDED = 'is-tip-refunded'; +export const FN_IS_REFUND_ELIGIBLE = 'is-refund-eligible'; +export const FN_GET_REFUND_WINDOW_BLOCKS = 'get-refund-window-blocks'; + // Contract validation helper export function validateContractDeployment() { return { From 776f003e24bb35d70f99a84ec60a047b9d71bc88 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Mon, 18 May 2026 18:02:25 +0100 Subject: [PATCH 03/17] add refund route constant and metadata to routes config --- frontend/src/config/routes.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/frontend/src/config/routes.js b/frontend/src/config/routes.js index 4bfb5035..2527c1c9 100644 --- a/frontend/src/config/routes.js +++ b/frontend/src/config/routes.js @@ -103,6 +103,12 @@ export const ROUTE_ADMIN = '/admin'; */ export const ROUTE_TELEMETRY = '/telemetry'; +/** + * Refund requests management. + * @type {string} + */ +export const ROUTE_REFUNDS = '/refunds'; + /** * The route that "/" redirects to when the user is authenticated. * Change this single value to alter the default landing page site-wide. @@ -130,6 +136,7 @@ export const ROUTE_LABELS = { [ROUTE_STATS]: 'Stats', [ROUTE_ADMIN]: 'Admin', [ROUTE_TELEMETRY]: 'Telemetry', + [ROUTE_REFUNDS]: 'Refunds', }; /** @@ -155,6 +162,7 @@ export const ROUTE_TITLES = { [ROUTE_STATS]: 'Platform Stats -- TipStream', [ROUTE_ADMIN]: 'Admin Dashboard -- TipStream', [ROUTE_TELEMETRY]: 'Telemetry -- TipStream', + [ROUTE_REFUNDS]: 'Refunds -- TipStream', }; /** @@ -244,4 +252,9 @@ export const ROUTE_META = { requiresAuth: true, adminOnly: true, }, + [ROUTE_REFUNDS]: { + description: 'View and manage refund requests for sent and received tips.', + requiresAuth: true, + adminOnly: false, + }, }; From 0a699c9202337c0906b3bd9f8e7911a6fd1695b3 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Mon, 18 May 2026 18:03:10 +0100 Subject: [PATCH 04/17] add MemoryRefundStore and PostgresRefundStore to storage layer --- chainhook/storage.js | 279 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 279 insertions(+) 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 }; From 071bf1797d84ab34fc3098bcf8cd5586ba221aa4 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Mon, 18 May 2026 18:03:25 +0100 Subject: [PATCH 05/17] add refund_requests table and indexes to schema --- chainhook/schema.sql | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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); From ccd005eaafaac84f6b16f6f7a6f0b79a3a28fa09 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Mon, 18 May 2026 18:06:19 +0100 Subject: [PATCH 06/17] add refund API endpoints to chainhook server --- chainhook/server.js | 203 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 201 insertions(+), 2 deletions(-) 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(); From ab5e721ca4ea43078151ff3000ba2895fd1aa881 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Mon, 18 May 2026 18:07:25 +0100 Subject: [PATCH 07/17] add refund store unit tests --- chainhook/refund.test.js | 178 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 chainhook/refund.test.js 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()); + }); +}); From 5663869ebcf85e8e0d642516c9d0546db6429fa3 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Mon, 18 May 2026 18:08:33 +0100 Subject: [PATCH 08/17] add useRefund hook for refund request lifecycle --- frontend/src/hooks/useRefund.js | 118 ++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 frontend/src/hooks/useRefund.js diff --git a/frontend/src/hooks/useRefund.js b/frontend/src/hooks/useRefund.js new file mode 100644 index 00000000..ec252609 --- /dev/null +++ b/frontend/src/hooks/useRefund.js @@ -0,0 +1,118 @@ +import { useState, useCallback } from 'react'; + +const REFUND_WINDOW_MS = 24 * 60 * 60 * 1000; + +export function isWithinRefundWindow(tipTimestamp) { + if (!tipTimestamp) return false; + const ts = typeof tipTimestamp === 'number' ? tipTimestamp : new Date(tipTimestamp).getTime(); + return Date.now() - ts < REFUND_WINDOW_MS; +} + +export function useRefund() { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const requestRefund = useCallback(async ({ tipId, txId, sender, recipient, amount, reason }) => { + setLoading(true); + setError(null); + try { + const response = await fetch('/api/refunds', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tipId, txId, sender, recipient, amount, reason }), + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.message || 'Failed to submit refund request'); + } + return { ok: true, refundRequest: data.refundRequest }; + } catch (err) { + const msg = err.message || 'Failed to submit refund request'; + setError(msg); + return { ok: false, error: msg }; + } finally { + setLoading(false); + } + }, []); + + const resolveRefund = useCallback(async ({ tipId, action, recipient, refundTxId }) => { + setLoading(true); + setError(null); + try { + const response = await fetch(`/api/refunds/${tipId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action, recipient, refundTxId }), + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.message || 'Failed to update refund request'); + } + return { ok: true, refundRequest: data.refundRequest }; + } catch (err) { + const msg = err.message || 'Failed to update refund request'; + setError(msg); + return { ok: false, error: msg }; + } finally { + setLoading(false); + } + }, []); + + const fetchRefundRequest = useCallback(async (tipId) => { + setLoading(true); + setError(null); + try { + const response = await fetch(`/api/refunds/${tipId}`); + if (response.status === 404) { + return { ok: true, refundRequest: null }; + } + const data = await response.json(); + if (!response.ok) { + throw new Error(data.message || 'Failed to fetch refund request'); + } + return { ok: true, refundRequest: data }; + } catch (err) { + const msg = err.message || 'Failed to fetch refund request'; + setError(msg); + return { ok: false, error: msg }; + } finally { + setLoading(false); + } + }, []); + + const fetchUserRefunds = useCallback(async ({ sender, recipient, status, limit, offset } = {}) => { + setLoading(true); + setError(null); + try { + const params = new URLSearchParams(); + if (sender) params.set('sender', sender); + if (recipient) params.set('recipient', recipient); + if (status) params.set('status', status); + if (limit) params.set('limit', String(limit)); + if (offset) params.set('offset', String(offset)); + + const response = await fetch(`/api/refunds?${params}`); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.message || 'Failed to fetch refund requests'); + } + return { ok: true, refundRequests: data.refundRequests, total: data.total }; + } catch (err) { + const msg = err.message || 'Failed to fetch refund requests'; + setError(msg); + return { ok: false, error: msg }; + } finally { + setLoading(false); + } + }, []); + + return { + loading, + error, + requestRefund, + resolveRefund, + fetchRefundRequest, + fetchUserRefunds, + isWithinRefundWindow, + }; +} From 0fb6df115d513a6cb6205c3e7c7c26fd81f0012e Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Mon, 18 May 2026 18:10:10 +0100 Subject: [PATCH 09/17] add RefundRequest component for sender-initiated refunds --- frontend/src/components/RefundRequest.jsx | 116 ++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 frontend/src/components/RefundRequest.jsx 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. +

+ +