Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions chainhook/bypass-detection.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { normalizeClarityEventFields } from "../shared/clarityValues.js";

/**
* Timelock bypass detection for chainhook events.
*
Expand Down Expand Up @@ -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: '' };
}

Expand All @@ -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';
Expand Down Expand Up @@ -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)) {
Expand Down
2 changes: 1 addition & 1 deletion chainhook/server.integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
11 changes: 6 additions & 5 deletions chainhook/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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);
}
Expand All @@ -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,
Expand Down
8 changes: 4 additions & 4 deletions chainhook/server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
14 changes: 8 additions & 6 deletions frontend/src/lib/contractEvents.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
}

/**
Expand All @@ -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,
Expand Down
39 changes: 35 additions & 4 deletions frontend/src/lib/eventParser.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getEventSchema, isValidEventType } from './eventSchemas';
import { normalizeClarityEventFields } from '../../../shared/clarityValues.js';

function tokenizeClarityRepr(repr) {
const tokens = [];
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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;
}
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/lib/eventParser.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
109 changes: 1 addition & 108 deletions frontend/src/lib/eventSchemas.js
Original file line number Diff line number Diff line change
@@ -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';
3 changes: 2 additions & 1 deletion frontend/src/lib/parseTipEvent.js
Original file line number Diff line number Diff line change
@@ -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`
Expand Down
Loading
Loading