Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
67dde04
Add timelock bypass vulnerability audit report
Mosas2000 Mar 10, 2026
fc75916
Add admin operations guide with timelocked procedures
Mosas2000 Mar 10, 2026
ebee1d6
Add contract upgrade strategy for timelock bypass remediation
Mosas2000 Mar 10, 2026
ee9fbcf
Add security policy with timelock bypass disclosure
Mosas2000 Mar 10, 2026
ff53177
Fix incorrect admin function names in README contract table
Mosas2000 Mar 10, 2026
1902f6d
Add pending change and governance read-only functions to README
Mosas2000 Mar 10, 2026
f080cda
Add timelock and governance safeguards to README security section
Mosas2000 Mar 10, 2026
b5bdc6a
Add timelocked pause change test suite with proposal and execution
Mosas2000 Mar 10, 2026
b1c00f0
Fix read-only result access pattern in timelock tests
Mosas2000 Mar 10, 2026
b21daa3
Add timelock utility module for block-based countdown calculations
Mosas2000 Mar 10, 2026
ac62e02
Add admin contract read-only query helpers
Mosas2000 Mar 10, 2026
c1fce54
Add useAdmin hook for contract owner and pending change state
Mosas2000 Mar 10, 2026
c6acce4
Add timelocked admin transaction builders
Mosas2000 Mar 10, 2026
21a746a
Add AdminDashboard component with timelocked-only controls
Mosas2000 Mar 10, 2026
3969eb9
Import Shield icon and lazy-load AdminDashboard component
Mosas2000 Mar 10, 2026
3e31d45
Add /admin route for owner-only admin dashboard
Mosas2000 Mar 10, 2026
9d00972
Add admin link to main navigation bar
Mosas2000 Mar 10, 2026
fff69df
Implement timelock utility module with countdown calculations
Mosas2000 Mar 10, 2026
661babf
Implement AdminDashboard with timelocked-only pause and fee controls
Mosas2000 Mar 10, 2026
0e73778
Add 40 unit tests for timelock utility module
Mosas2000 Mar 10, 2026
80b728f
Add 18 unit tests for Clarity hex value parser
Mosas2000 Mar 10, 2026
f325fd3
Add 19 unit tests for admin transaction builders
Mosas2000 Mar 10, 2026
88399f8
Add ESLint rules to ban direct bypass function references
Mosas2000 Mar 10, 2026
f8ce071
Add changelog entry for timelock bypass mitigation
Mosas2000 Mar 10, 2026
51ace51
Add chainhook bypass detection module for admin event monitoring
Mosas2000 Mar 10, 2026
66987f9
Integrate bypass detection into chainhook callback server
Mosas2000 Mar 10, 2026
d8c6f0a
Add admin events and bypass detection API endpoints to chainhook server
Mosas2000 Mar 10, 2026
6884ca3
Restrict admin navigation link to contract owner only
Mosas2000 Mar 10, 2026
e9cb86d
Add timelock progress bar to PendingChangeCard in admin dashboard
Mosas2000 Mar 10, 2026
1a532cf
Write admin operations guide with timelocked procedures and monitoring
Mosas2000 Mar 10, 2026
1136f5b
Write timelock bypass vulnerability audit report with risk assessment
Mosas2000 Mar 10, 2026
1a336ee
Update changelog with chainhook monitoring and admin dashboard improv…
Mosas2000 Mar 10, 2026
956f0c7
Add print event verification tests for timelocked and bypass operations
Mosas2000 Mar 10, 2026
a377bc5
Merge branch 'main' into fix/set-paused-timelock-bypass
Mosas2000 Mar 10, 2026
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
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,36 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
transaction paths used `PostConditionMode.Allow`, which permits the
contract to transfer arbitrary STX from the user's account without
explicit bounds.
- Documented timelock bypass vulnerability in `set-paused` and `set-fee-basis-points`
(see [TIMELOCK-BYPASS-AUDIT.md](docs/TIMELOCK-BYPASS-AUDIT.md))
- Added frontend AdminDashboard that exclusively uses timelocked propose-wait-execute path
- ESLint rules ban direct bypass function references (`set-paused`, `set-fee-basis-points`)
- All admin transaction builders enforce `PostConditionMode.Deny`
- Chainhook bypass detection module flags direct admin calls that skip the timelock

### Added

- Chainhook `/api/admin/events` endpoint for querying admin event history
- Chainhook `/api/admin/bypasses` endpoint for querying detected bypass events
- Chainhook `bypass-detection.js` module for real-time admin event monitoring
- Admin navigation link only visible to the contract owner
- Visual timelock progress bar on pending change cards in AdminDashboard
- `AdminDashboard` component with owner-only access, pause/fee controls, and pending change display
- `useAdmin` hook for polling contract owner, pending changes, and block height
- `admin-contract.js` library with read-only query helpers and Clarity hex parser
- `admin-transactions.js` library with timelocked propose/execute/cancel transaction builders
- `timelock.js` utility module with countdown calculations and formatting
- Admin operations guide ([ADMIN-OPERATIONS.md](docs/ADMIN-OPERATIONS.md))
- Contract upgrade strategy ([CONTRACT-UPGRADE-STRATEGY.md](docs/CONTRACT-UPGRADE-STRATEGY.md))
- Security policy ([SECURITY.md](SECURITY.md))
- Timelock bypass audit report ([TIMELOCK-BYPASS-AUDIT.md](docs/TIMELOCK-BYPASS-AUDIT.md))
- 40 unit tests for timelock utilities
- 18 unit tests for Clarity hex value parser
- 19 unit tests for admin transaction builders
- Contract tests for timelocked pause change propose/execute cycle
- Contract tests for timelocked fee change propose/execute/cancel cycle
- Contract tests comparing direct bypass vs timelocked path behavior
- `/admin` route in App.jsx with lazy-loaded AdminDashboard
- Shared post-condition helper modules for frontend (`lib/post-conditions.js`)
and CLI scripts (`scripts/lib/post-conditions.cjs`).
- Fee calculation helpers: `feeForTip`, `totalDeduction`, `recipientReceives`.
Expand All @@ -40,3 +67,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- SendTip fee preview now uses dynamic fee percentage from shared constants
instead of a hardcoded "0.5%" string.
- Test script output now shows full fee breakdown before broadcasting.

### Fixed

- Corrected `set-fee` and `toggle-pause` to actual function names in README contract table
- Added missing read-only functions to README documentation
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,14 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for the full system design.
| `tip-a-tip` | Recursive tip referencing a previous tip ID |
| `update-profile` | Set display name, bio, avatar URL |
| `toggle-block-user` | Block or unblock a principal |
| `set-fee` | Admin: update fee basis points |
| `toggle-pause` | Admin: pause/resume contract |
| `propose-new-owner` | Admin: initiate ownership transfer |
| `set-fee-basis-points` | Admin: update fee basis points (direct, bypasses timelock) |
| `set-paused` | Admin: pause/resume contract (direct, bypasses timelock) |
| `propose-fee-change` | Admin: propose timelocked fee change (144-block delay) |
| `execute-fee-change` | Admin: execute pending fee change after timelock |
| `cancel-fee-change` | Admin: cancel a pending fee proposal |
| `propose-pause-change` | Admin: propose timelocked pause change (144-block delay) |
| `execute-pause-change` | Admin: execute pending pause change after timelock |
| `propose-new-owner` | Admin: initiate two-step ownership transfer |
| `accept-ownership` | Accept pending ownership transfer |

**Read-only:**
Expand All @@ -111,6 +116,10 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for the full system design.
| `is-user-blocked` | Check if one user has blocked another |
| `get-contract-owner` | Current contract owner |
| `get-pending-owner` | Pending ownership transfer target |
| `get-pending-fee-change` | Pending fee proposal and execution height |
| `get-pending-pause-change` | Pending pause proposal and execution height |
| `get-multisig` | Authorized multisig contract address |
| `get-contract-version` | Contract version and name |

### Frontend Components

Expand Down Expand Up @@ -200,6 +209,10 @@ settings/
- Blocked users cannot receive tips from the blocker
- Admin functions are owner-only with on-chain assertions
- Two-step ownership transfer prevents accidental loss
- Post conditions on all transactions restrict STX movement
- **Timelocked admin changes**: Fee and pause changes use a 144-block (~24 hour) propose-wait-execute cycle
- **Frontend enforces timelocked paths**: The AdminDashboard never calls direct bypass functions
- **Multisig governance**: Optional multi-signature approval layer for admin actions

The `settings/Devnet.toml` file contains mnemonic phrases and private keys for Clarinet devnet test accounts. These hold no real value and exist only in the local devnet sandbox. Never use devnet mnemonics or keys on mainnet or testnet.

Expand Down
72 changes: 72 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Security Policy

## Reporting Vulnerabilities

If you discover a security vulnerability in TipStream, please report it responsibly:

1. **Do not** open a public issue
2. Email security concerns to the project maintainers
3. Include a detailed description of the vulnerability
4. Provide steps to reproduce if possible
5. Allow 72 hours for initial response

## Known Security Considerations

### Contract Administration

The deployed TipStream contract has both direct and timelocked admin functions. The direct
functions (`set-paused`, `set-fee-basis-points`) can bypass the 144-block timelock. This is
documented in [docs/TIMELOCK-BYPASS-AUDIT.md](docs/TIMELOCK-BYPASS-AUDIT.md) and mitigated
through frontend controls and operational policies.

### Timelock Mechanism

Administrative changes use a 144-block (~24 hour) delay:

| Operation | Timelocked Function | Direct Bypass |
|---|---|---|
| Pause/Unpause | `propose-pause-change` / `execute-pause-change` | `set-paused` |
| Fee Change | `propose-fee-change` / `execute-fee-change` | `set-fee-basis-points` |

The frontend AdminDashboard exclusively uses the timelocked functions. Direct bypass
functions are reserved for documented emergencies only.

### Post Conditions

All STX transfers enforce `PostConditionMode.Deny` to prevent transactions from moving
more STX than explicitly authorized. See the post-condition documentation for details.

### Ownership Transfer

Contract ownership uses a two-step process (`propose-new-owner` / `accept-ownership`)
to prevent accidental transfers.

### Fee Limits

The contract enforces a maximum fee of 1000 basis points (10%). The current fee is 50
basis points (0.5%).

### Minimum Tip Amount

Tips below 1000 microSTX (0.001 STX) are rejected to prevent dust spam.

## Devnet Credentials

The `settings/Devnet.toml` file contains mnemonic phrases and private keys for Clarinet
devnet test accounts. These are sandbox-only credentials with no real value. Never use
devnet mnemonics or keys on mainnet or testnet.

## Dependencies

- Frontend dependencies are audited with `npm audit` in CI
- Stacks SDK versions are pinned to prevent supply-chain attacks
- Contract interactions use explicit post conditions

## Security Checklist for Contributors

- [ ] Never commit mainnet private keys or seed phrases
- [ ] Use `PostConditionMode.Deny` for all contract calls
- [ ] Validate all user inputs before contract interaction
- [ ] Use timelocked admin functions in the frontend
- [ ] Run `npm audit` before submitting PRs
- [ ] Test contract changes on simnet before deployment
124 changes: 124 additions & 0 deletions chainhook/bypass-detection.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* Timelock bypass detection for chainhook events.
*
* Monitors contract events for direct bypass function calls
* (set-paused, set-fee-basis-points) that circumvent the
* 144-block timelock mechanism. Logs warnings when detected.
*/

/**
* Admin events that indicate a direct bypass was used.
* These events are emitted by functions that skip the timelock.
*/
const BYPASS_EVENTS = new Set([
'contract-paused',
'fee-updated',
]);

/**
* Admin events that indicate the timelocked path was used.
* These are the expected events for proper admin operations.
*/
const TIMELOCKED_EVENTS = new Set([
'pause-change-proposed',
'pause-change-executed',
'fee-change-proposed',
'fee-change-executed',
'fee-change-cancelled',
]);

/**
* Check if an event represents a direct bypass of the timelock.
*
* A bypass is detected when a contract-paused or fee-updated event
* occurs without a corresponding proposal event in the recent history.
*
* @param {object} event - The contract event
* @param {Array} recentEvents - Recent event history for context
* @returns {{ isBypass: boolean, eventType: string, detail: string }}
*/
export function detectBypass(event, recentEvents = []) {
const value = event?.event;
if (!value || typeof value !== 'object') {
return { isBypass: false, eventType: '', detail: '' };
}

const eventType = value.event;

if (!BYPASS_EVENTS.has(eventType)) {
return { isBypass: false, eventType, detail: '' };
}

// 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;

if (eventType === 'contract-paused') {
return v.event === 'pause-change-executed';
}
if (eventType === 'fee-updated') {
return v.event === 'fee-change-executed';
}
return false;
});

if (hasProposal) {
// The timelocked path was used - this is not a bypass
return { isBypass: false, eventType, detail: 'Timelocked path confirmed' };
}

const detail = eventType === 'contract-paused'
? `Direct set-paused detected: paused=${value.paused}`
: `Direct set-fee-basis-points detected: new-fee=${value['new-fee']}`;

return { isBypass: true, eventType, detail };
}

/**
* Parse an admin event from the chainhook payload.
*
* @param {object} event - Raw chainhook event
* @returns {object|null} Parsed admin event or null
*/
export function parseAdminEvent(event) {
const val = event?.event;
if (!val || typeof val !== 'object') return null;

const eventType = val.event;
if (!BYPASS_EVENTS.has(eventType) && !TIMELOCKED_EVENTS.has(eventType)) {
return null;
}

return {
eventType,
txId: event.txId,
blockHeight: event.blockHeight,
timestamp: event.timestamp,
data: val,
isBypass: BYPASS_EVENTS.has(eventType),
isTimelocked: TIMELOCKED_EVENTS.has(eventType),
};
}

/**
* Format a bypass detection alert for logging.
*
* @param {object} detection - Detection result from detectBypass
* @param {object} event - The original event
* @returns {string} Formatted alert message
*/
export function formatBypassAlert(detection, event) {
if (!detection.isBypass) return '';

const lines = [
'[SECURITY ALERT] Timelock bypass detected',
` Event: ${detection.eventType}`,
` Detail: ${detection.detail}`,
` TX: ${event?.txId || 'unknown'}`,
` Block: ${event?.blockHeight || 'unknown'}`,
` Time: ${new Date(event?.timestamp || Date.now()).toISOString()}`,
];

return lines.join('\n');
}
40 changes: 40 additions & 0 deletions chainhook/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import http from "node:http";
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { detectBypass, parseAdminEvent, formatBypassAlert } from "./bypass-detection.js";

const __dirname = dirname(fileURLToPath(import.meta.url));
const PORT = process.env.PORT || 3100;
Expand Down Expand Up @@ -118,6 +119,19 @@ const server = http.createServer(async (req, res) => {
const newEvents = extractEvents(payload);
if (newEvents.length > 0) {
const stored = loadEvents();

// Check for timelock bypass events
for (const evt of newEvents) {
const detection = detectBypass(evt, stored.slice(-50));
if (detection.isBypass) {
console.warn(formatBypassAlert(detection, evt));
}
const adminEvt = parseAdminEvent(evt);
if (adminEvt) {
console.log(`Admin event: ${adminEvt.eventType} at block ${adminEvt.blockHeight}`);
}
}

stored.push(...newEvents);
saveEvents(stored);
console.log(`Indexed ${newEvents.length} events (total: ${stored.length})`);
Expand Down Expand Up @@ -173,6 +187,32 @@ const server = http.createServer(async (req, res) => {
});
}

if (req.method === "GET" && path === "/api/admin/events") {
const allEvents = loadEvents();
const adminEvents = allEvents
.map(parseAdminEvent)
.filter(Boolean)
.reverse();
return sendJson(res, 200, { events: adminEvents, total: adminEvents.length });
}

if (req.method === "GET" && path === "/api/admin/bypasses") {
const allEvents = loadEvents();
const bypasses = [];
for (let i = 0; i < allEvents.length; i++) {
const detection = detectBypass(allEvents[i], allEvents.slice(Math.max(0, i - 50), i));
if (detection.isBypass) {
bypasses.push({
...detection,
txId: allEvents[i].txId,
blockHeight: allEvents[i].blockHeight,
timestamp: allEvents[i].timestamp,
});
}
}
return sendJson(res, 200, { bypasses, total: bypasses.length });
}

sendJson(res, 404, { error: "not found" });
});

Expand Down
Loading
Loading