diff --git a/chainhook/bypass-detection.js b/chainhook/bypass-detection.js index a49e753..b202d5f 100644 --- a/chainhook/bypass-detection.js +++ b/chainhook/bypass-detection.js @@ -1,3 +1,5 @@ +import { normalizeClarityEventFields } from "../shared/clarityValues.js"; + /** * Timelock bypass detection for chainhook events. * @@ -38,8 +40,8 @@ const TIMELOCKED_EVENTS = new Set([ * @returns {{ isBypass: boolean, eventType: string, detail: string }} */ export function detectBypass(event, recentEvents = []) { - const value = event?.event; - if (!value || typeof value !== 'object') { + const value = normalizeClarityEventFields(event?.event); + if (!value) { return { isBypass: false, eventType: '', detail: '' }; } @@ -51,8 +53,8 @@ export function detectBypass(event, recentEvents = []) { // Check if there was a corresponding proposal in recent history const hasProposal = recentEvents.some((e) => { - const v = e?.event; - if (!v || typeof v !== 'object') return false; + const v = normalizeClarityEventFields(e?.event); + if (!v) return false; if (eventType === 'contract-paused') { return v.event === 'pause-change-executed'; @@ -82,8 +84,8 @@ export function detectBypass(event, recentEvents = []) { * @returns {object|null} Parsed admin event or null */ export function parseAdminEvent(event) { - const val = event?.event; - if (!val || typeof val !== 'object') return null; + const val = normalizeClarityEventFields(event?.event); + if (!val) return null; const eventType = val.event; if (!BYPASS_EVENTS.has(eventType) && !TIMELOCKED_EVENTS.has(eventType)) { diff --git a/chainhook/server.integration.test.js b/chainhook/server.integration.test.js index b984fc1..9d551b5 100644 --- a/chainhook/server.integration.test.js +++ b/chainhook/server.integration.test.js @@ -105,7 +105,7 @@ describe('chainhook server integration', () => { assert.strictEqual(tips.status, 200); assert.strictEqual(tips.body.total, 1); - assert.strictEqual(tips.body.tips[0].tipId, 1); + assert.strictEqual(tips.body.tips[0].tipId, '1'); assert.strictEqual(tips.body.tips[0].sender, 'SP1SENDER'); const duplicate = await request({ diff --git a/chainhook/server.js b/chainhook/server.js index 36e3e1c..855e2fb 100644 --- a/chainhook/server.js +++ b/chainhook/server.js @@ -10,6 +10,7 @@ import { RateLimiter, getClientIp } from "./rate-limit.js"; import { logger } from "./logging.js"; import { setupGracefulShutdown } from "./graceful-shutdown.js"; import { createEventStore, getRetentionCutoff, parseRetentionDays } from "./storage.js"; +import { normalizeClarityEventFields } from "../shared/clarityValues.js"; import { BadRequestError, PayloadTooLargeError, RateLimitError, UnauthorizedError, classifyError, toErrorResponse } from "./errors.js"; const PORT = process.env.PORT || 3100; @@ -145,8 +146,8 @@ function sendError(res, error, requestId, context = {}) { * @returns {object|null} */ function parseTipEvent(event) { - const val = event.event; - if (!val || typeof val !== "object") return null; + const val = normalizeClarityEventFields(event.event); + if (!val) return null; if (val.event !== "tip-sent") return null; return { tipId: val["tip-id"], @@ -348,7 +349,7 @@ const server = http.createServer(async (req, res) => { }); } const allEvents = await store.listEvents(); - const tip = allEvents.map(parseTipEvent).find((t) => t && t.tipId === tipId); + const tip = allEvents.map(parseTipEvent).find((t) => t && Number(t.tipId) === tipId); if (!tip) return sendJson(res, 404, { error: "tip not found" }); return sendJson(res, 200, tip); } @@ -358,8 +359,8 @@ const server = http.createServer(async (req, res) => { const store = await getEventStore(); const allEvents = await store.listEvents(); const tips = allEvents.map(parseTipEvent).filter(Boolean); - const totalVolume = tips.reduce((sum, t) => sum + (t.amount || 0), 0); - const totalFees = tips.reduce((sum, t) => sum + (t.fee || 0), 0); + const totalVolume = tips.reduce((sum, t) => sum + Number(t.amount || 0), 0); + const totalFees = tips.reduce((sum, t) => sum + Number(t.fee || 0), 0); return sendJson(res, 200, { totalTips: tips.length, totalVolume, diff --git a/chainhook/server.test.js b/chainhook/server.test.js index fc5318e..9fd485a 100644 --- a/chainhook/server.test.js +++ b/chainhook/server.test.js @@ -260,12 +260,12 @@ describe("parseTipEvent", () => { }, }; const tip = parseTipEvent(event); - assert.strictEqual(tip.tipId, 42); + assert.strictEqual(tip.tipId, '42'); assert.strictEqual(tip.sender, "SP1SENDER"); assert.strictEqual(tip.recipient, "SP2RECIPIENT"); - assert.strictEqual(tip.amount, 100000); - assert.strictEqual(tip.fee, 5000); - assert.strictEqual(tip.netAmount, 95000); + assert.strictEqual(tip.amount, '100000'); + assert.strictEqual(tip.fee, '5000'); + assert.strictEqual(tip.netAmount, '95000'); assert.strictEqual(tip.txId, "0xabc"); assert.strictEqual(tip.blockHeight, 200); }); diff --git a/frontend/src/lib/contractEvents.js b/frontend/src/lib/contractEvents.js index 3483cfd..b6d0f8f 100644 --- a/frontend/src/lib/contractEvents.js +++ b/frontend/src/lib/contractEvents.js @@ -30,7 +30,7 @@ export const POLL_INTERVAL_MS = 30_000; */ function buildEventsUrl(limit, offset) { const contractId = `${CONTRACT_ADDRESS}.${CONTRACT_NAME}`; - return `${STACKS_API_BASE}/extended/v1/contract/${contractId}/events?limit=${limit}&offset=${offset}`; + return `${STACKS_API_BASE}/extended/v1/contract/${contractId}/events?limit=${limit}&offset=${offset}&decode_clarity_values=true`; } /** @@ -49,18 +49,20 @@ async function fetchEventsPage(offset) { /** * Parse raw API results into typed tip event objects. * - * Filters for entries that have a contract_log value, runs them through - * parseTipEvent, and enriches each with the block timestamp and txId - * from the original API entry. + * Filters for entries that have a contract_log value, prefers decoded + * Clarity data when present, and falls back to repr strings when needed. + * Each parsed event is enriched with the block timestamp and txId from the + * original API entry. * * @param {Array} results - Raw results array from the Stacks API. * @returns {Array} Parsed tip event objects. */ export function parseRawEvents(results) { return results - .filter(e => e.contract_log?.value?.repr) + .filter((e) => e.contract_log?.value) .map(e => { - const parsed = parseTipEvent(e.contract_log.value.repr); + const value = e.contract_log.value; + const parsed = parseTipEvent(value.value ?? value.raw_value ?? value.repr ?? value); if (!parsed) return null; return { ...parsed, diff --git a/frontend/src/lib/eventParser.js b/frontend/src/lib/eventParser.js index a1d577a..09212c5 100644 --- a/frontend/src/lib/eventParser.js +++ b/frontend/src/lib/eventParser.js @@ -1,4 +1,5 @@ import { getEventSchema, isValidEventType } from './eventSchemas'; +import { normalizeClarityEventFields } from '../../../shared/clarityValues.js'; function tokenizeClarityRepr(repr) { const tokens = []; @@ -312,6 +313,15 @@ function parseTupleFields(repr) { return fields; } +function parseStructuredFields(value) { + const fields = normalizeClarityEventFields(value); + if (!fields) { + return null; + } + + return fields; +} + function extractEventType(fields) { const eventField = fields.event; if (typeof eventField === 'string') { @@ -391,25 +401,46 @@ function parseReprObjectLenient(repr) { return result; } +function parseStructuredObject(value, strict = true) { + const fields = parseStructuredFields(value); + if (!fields) { + return null; + } + + const eventType = extractEventType(fields); + if (!eventType || !isValidEventType(eventType)) { + return null; + } + + const schema = getEventSchema(eventType); + const result = { event: eventType }; + + if (!hydrateSchemaFields(fields, schema, result, strict)) { + return null; + } + + return result; +} + export function parseContractEvent(repr) { - if (!repr || typeof repr !== 'string') { + if (!repr) { return null; } try { - return parseReprObject(repr); + return typeof repr === 'string' ? parseReprObject(repr) : parseStructuredObject(repr, true); } catch { return null; } } export function parseContractEventLenient(repr) { - if (!repr || typeof repr !== 'string') { + if (!repr) { return null; } try { - return parseReprObjectLenient(repr); + return typeof repr === 'string' ? parseReprObjectLenient(repr) : parseStructuredObject(repr, false); } catch { return null; } diff --git a/frontend/src/lib/eventParser.test.js b/frontend/src/lib/eventParser.test.js index 147fe85..24e66d3 100644 --- a/frontend/src/lib/eventParser.test.js +++ b/frontend/src/lib/eventParser.test.js @@ -90,6 +90,22 @@ describe('eventParser', () => { expect(result.amount).toBe('5000'); }); + it('parses a structured clarity object', () => { + const value = { + event: 'tip-sent', + 'tip-id': 42, + sender: 'SP1SENDER', + recipient: 'SP2RECV', + amount: 1000, + }; + + const result = parseContractEvent(value); + + expect(result.event).toBe('tip-sent'); + expect(result['tip-id']).toBe('42'); + expect(result.amount).toBe('1000'); + }); + it('parses profile-updated event with optional fields', () => { const repr = '(tuple (event "profile-updated") (user \'SP1USER) (display-name u"Alice") (bio u"Hello"))'; const result = parseContractEvent(repr); diff --git a/frontend/src/lib/eventSchemas.js b/frontend/src/lib/eventSchemas.js index 21801f4..8ceb249 100644 --- a/frontend/src/lib/eventSchemas.js +++ b/frontend/src/lib/eventSchemas.js @@ -1,108 +1 @@ -export const eventSchemas = { - 'tip-sent': { - type: 'tip-sent', - required: ['tip-id', 'sender', 'recipient', 'amount'], - optional: ['fee', 'message', 'category'], - defaults: { - fee: '0', - message: '', - category: null, - }, - }, - 'tip-categorized': { - type: 'tip-categorized', - required: ['tip-id', 'category'], - optional: [], - }, - 'token-tip-sent': { - type: 'token-tip-sent', - required: ['token-tip-id', 'sender', 'recipient', 'token-contract', 'amount'], - optional: ['fee'], - defaults: { - fee: '0', - }, - }, - 'profile-updated': { - type: 'profile-updated', - required: ['user'], - optional: ['display-name', 'bio', 'avatar-uri'], - }, - 'user-blocked': { - type: 'user-blocked', - required: ['blocker', 'blocked-user'], - optional: [], - }, - 'user-unblocked': { - type: 'user-unblocked', - required: ['unblocker', 'unblocked-user'], - optional: [], - }, - 'token-whitelist-updated': { - type: 'token-whitelist-updated', - required: ['token-contract', 'allowed'], - optional: [], - }, - 'contract-paused': { - type: 'contract-paused', - required: ['paused'], - optional: [], - }, - 'fee-updated': { - type: 'fee-updated', - required: ['new-fee'], - optional: [], - }, - 'fee-change-proposed': { - type: 'fee-change-proposed', - required: ['new-fee', 'effective-height'], - optional: [], - }, - 'fee-change-executed': { - type: 'fee-change-executed', - required: ['new-fee'], - optional: [], - }, - 'fee-change-cancelled': { - type: 'fee-change-cancelled', - required: [], - optional: [], - }, - 'pause-change-proposed': { - type: 'pause-change-proposed', - required: ['paused', 'effective-height'], - optional: [], - }, - 'pause-change-executed': { - type: 'pause-change-executed', - required: ['paused'], - optional: [], - }, - 'pause-change-cancelled': { - type: 'pause-change-cancelled', - required: [], - optional: [], - }, - 'multisig-updated': { - type: 'multisig-updated', - required: ['multisig'], - optional: [], - }, - 'ownership-proposed': { - type: 'ownership-proposed', - required: ['current-owner', 'proposed-owner'], - optional: [], - }, - 'ownership-transferred': { - type: 'ownership-transferred', - required: ['new-owner'], - optional: [], - }, -}; - -export function getEventSchema(eventType) { - return eventSchemas[eventType] || null; -} - -export function isValidEventType(eventType) { - return eventType in eventSchemas; -} +export { eventSchemas, getEventSchema, isValidEventType } from '../../../shared/eventSchemas.js'; diff --git a/frontend/src/lib/parseTipEvent.js b/frontend/src/lib/parseTipEvent.js index 4913fcb..a54116e 100644 --- a/frontend/src/lib/parseTipEvent.js +++ b/frontend/src/lib/parseTipEvent.js @@ -1,7 +1,8 @@ import { parseContractEventLenient } from './eventParser'; /** - * Parse a Clarity contract event `repr` string into a structured object. + * Parse a Clarity contract event into a structured object. + * Accepts either a raw `repr` string or a decoded Clarity value object. * Used by TipHistory, RecentTips, Leaderboard, and useNotifications. * * NOTE: The contract's `send-tip` print event does NOT include the `message` diff --git a/frontend/src/test/contractEvents.test.js b/frontend/src/test/contractEvents.test.js index 909b1ba..8c38085 100644 --- a/frontend/src/test/contractEvents.test.js +++ b/frontend/src/test/contractEvents.test.js @@ -11,8 +11,9 @@ import { parseRawEvents, fetchAllContractEvents, PAGE_LIMIT, MAX_INITIAL_PAGES, * @param {Object} [overrides] - Optional field overrides. */ function fakeApiEntry(repr, overrides = {}) { + const value = typeof repr === 'string' ? { repr } : { value: repr }; return { - contract_log: { value: { repr } }, + contract_log: { value }, block_time: overrides.block_time ?? 1700000000, tx_id: overrides.tx_id ?? '0xabc123', }; @@ -56,7 +57,7 @@ describe('parseRawEvents', () => { expect(parsed[0].txId).toBe('0xdef456'); }); - it('filters out entries without contract_log.value.repr', () => { + it('filters out entries without contract_log data', () => { const results = [ { tx_id: '0x111' }, { contract_log: { value: {} } }, @@ -67,6 +68,25 @@ describe('parseRawEvents', () => { expect(parsed).toHaveLength(1); }); + it('parses structured decoded clarity values when available', () => { + const results = [ + fakeApiEntry({ + event: 'tip-sent', + 'tip-id': 1, + sender: 'SP1SENDER', + recipient: 'SP2RECEIVER', + amount: 1000000, + fee: 50000, + }), + ]; + + const parsed = parseRawEvents(results); + + expect(parsed).toHaveLength(1); + expect(parsed[0].tipId).toBe('1'); + expect(parsed[0].amount).toBe('1000000'); + }); + it('filters out entries that parseTipEvent cannot parse', () => { const results = [fakeApiEntry('(tuple (invalid-structure))')]; const parsed = parseRawEvents(results); @@ -113,6 +133,7 @@ describe('parseRawEvents', () => { const parsed = parseRawEvents(results); expect(parsed[0].timestamp).toBeNull(); }); + }); // --------------------------------------------------------------------------- @@ -262,6 +283,15 @@ describe('fetchAllContractEvents', () => { expect(result.events).toHaveLength(PAGE_LIMIT + 1); }); + it('requests decoded clarity values from the stacks api', async () => { + fetchSpy.mockReturnValueOnce(mockFetchResponse([], 0, 0)); + + await fetchAllContractEvents(); + + const url = fetchSpy.mock.calls[0][0]; + expect(url).toContain('decode_clarity_values=true'); + }); + it('throws when second page fails mid-pagination', async () => { const fullPage = Array.from({ length: PAGE_LIMIT }, (_, i) => fakeApiEntry(tipSentRepr({ tipId: String(i) })), diff --git a/frontend/src/test/parseTipEvent.test.js b/frontend/src/test/parseTipEvent.test.js index ce65412..73c47b2 100644 --- a/frontend/src/test/parseTipEvent.test.js +++ b/frontend/src/test/parseTipEvent.test.js @@ -28,6 +28,24 @@ describe('parseTipEvent', () => { expect(result.tipId).toBe('42'); }); + it('parses a structured clarity event object', () => { + const value = { + event: 'tip-sent', + 'tip-id': 42, + sender: 'SP1SENDER', + recipient: 'SP2RECV', + amount: 5000000, + fee: 50000, + }; + + const result = parseTipEvent(value); + + expect(result.event).toBe('tip-sent'); + expect(result.tipId).toBe('42'); + expect(result.amount).toBe('5000000'); + expect(result.fee).toBe('50000'); + }); + it('parses a tip-categorized event', () => { const repr = '(tuple (event "tip-categorized") (tip-id u7) (category u3))'; const result = parseTipEvent(repr); diff --git a/shared/clarityValues.js b/shared/clarityValues.js new file mode 100644 index 0000000..40b5e2b --- /dev/null +++ b/shared/clarityValues.js @@ -0,0 +1,101 @@ +function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function decodeNumericValue(value) { + if (typeof value === 'bigint') { + return value.toString(); + } + + if (typeof value === 'number') { + return Number.isFinite(value) ? String(value) : null; + } + + return value; +} + +function decodeClarityWrapper(node) { + if (!isPlainObject(node) || !('type' in node)) { + return null; + } + + const { type, value } = node; + + switch (type) { + case 'bool': + return Boolean(value); + case 'uint': + case 'int': + return decodeNumericValue(value); + case 'principal': + if (typeof value === 'string') { + return value; + } + if (isPlainObject(value)) { + return value.repr || value.address || value.id || value.value || null; + } + return decodeNumericValue(value); + case 'string-ascii': + case 'string-utf8': + case 'buffer': + return value == null ? '' : String(value); + case 'none': + return null; + case 'optional': + case 'some': + case 'response': + case 'ok': + case 'err': + return normalizeClarityValue(value); + case 'tuple': + if (!isPlainObject(value)) { + return null; + } + return normalizeClarityValue(value); + case 'list': + if (!Array.isArray(value)) { + return []; + } + return value.map((item) => normalizeClarityValue(item)); + default: + return null; + } +} + +export function normalizeClarityValue(value) { + if (value == null) { + return null; + } + + if (Array.isArray(value)) { + return value.map((item) => normalizeClarityValue(item)); + } + + if (typeof value === 'bigint' || typeof value === 'number') { + return decodeNumericValue(value); + } + + if (typeof value !== 'object') { + return value; + } + + const wrapped = decodeClarityWrapper(value); + if (wrapped !== null) { + return wrapped; + } + + const result = {}; + for (const [key, entry] of Object.entries(value)) { + result[key] = normalizeClarityValue(entry); + } + return result; +} + +export function normalizeClarityEventFields(value) { + const decoded = normalizeClarityValue(value); + if (!decoded || typeof decoded !== 'object' || Array.isArray(decoded)) { + return null; + } + + return decoded; +} diff --git a/shared/eventSchemas.js b/shared/eventSchemas.js new file mode 100644 index 0000000..21801f4 --- /dev/null +++ b/shared/eventSchemas.js @@ -0,0 +1,108 @@ +export const eventSchemas = { + 'tip-sent': { + type: 'tip-sent', + required: ['tip-id', 'sender', 'recipient', 'amount'], + optional: ['fee', 'message', 'category'], + defaults: { + fee: '0', + message: '', + category: null, + }, + }, + 'tip-categorized': { + type: 'tip-categorized', + required: ['tip-id', 'category'], + optional: [], + }, + 'token-tip-sent': { + type: 'token-tip-sent', + required: ['token-tip-id', 'sender', 'recipient', 'token-contract', 'amount'], + optional: ['fee'], + defaults: { + fee: '0', + }, + }, + 'profile-updated': { + type: 'profile-updated', + required: ['user'], + optional: ['display-name', 'bio', 'avatar-uri'], + }, + 'user-blocked': { + type: 'user-blocked', + required: ['blocker', 'blocked-user'], + optional: [], + }, + 'user-unblocked': { + type: 'user-unblocked', + required: ['unblocker', 'unblocked-user'], + optional: [], + }, + 'token-whitelist-updated': { + type: 'token-whitelist-updated', + required: ['token-contract', 'allowed'], + optional: [], + }, + 'contract-paused': { + type: 'contract-paused', + required: ['paused'], + optional: [], + }, + 'fee-updated': { + type: 'fee-updated', + required: ['new-fee'], + optional: [], + }, + 'fee-change-proposed': { + type: 'fee-change-proposed', + required: ['new-fee', 'effective-height'], + optional: [], + }, + 'fee-change-executed': { + type: 'fee-change-executed', + required: ['new-fee'], + optional: [], + }, + 'fee-change-cancelled': { + type: 'fee-change-cancelled', + required: [], + optional: [], + }, + 'pause-change-proposed': { + type: 'pause-change-proposed', + required: ['paused', 'effective-height'], + optional: [], + }, + 'pause-change-executed': { + type: 'pause-change-executed', + required: ['paused'], + optional: [], + }, + 'pause-change-cancelled': { + type: 'pause-change-cancelled', + required: [], + optional: [], + }, + 'multisig-updated': { + type: 'multisig-updated', + required: ['multisig'], + optional: [], + }, + 'ownership-proposed': { + type: 'ownership-proposed', + required: ['current-owner', 'proposed-owner'], + optional: [], + }, + 'ownership-transferred': { + type: 'ownership-transferred', + required: ['new-owner'], + optional: [], + }, +}; + +export function getEventSchema(eventType) { + return eventSchemas[eventType] || null; +} + +export function isValidEventType(eventType) { + return eventType in eventSchemas; +}