Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
14e4af1
Import post-condition constructors in test-contract script
Mosas2000 Mar 9, 2026
712a12d
Switch PostConditionMode from Allow to Deny in test script
Mosas2000 Mar 9, 2026
ad9cea5
Extract fee constants to match contract parameters
Mosas2000 Mar 9, 2026
0284f9e
Allow configurable tip amount and message via environment
Mosas2000 Mar 9, 2026
627b96b
Add dry-run mode to preview transactions without broadcasting
Mosas2000 Mar 9, 2026
0cbde83
Reject self-tips before building the transaction
Mosas2000 Mar 9, 2026
c7fc9f3
Create shared post-condition helper module for scripts
Mosas2000 Mar 9, 2026
5a52939
Refactor test script to use shared post-condition module
Mosas2000 Mar 9, 2026
e23eb75
Create frontend post-condition utility module
Mosas2000 Mar 9, 2026
2187492
Refactor SendTip to use shared post-condition helpers
Mosas2000 Mar 9, 2026
a68cc9c
Refactor RecentTips tip-back to use shared post-condition helpers
Mosas2000 Mar 9, 2026
473a3aa
Add unit tests for post-condition helpers
Mosas2000 Mar 9, 2026
d5f5e66
Add ESLint rule to ban PostConditionMode.Allow in frontend
Mosas2000 Mar 9, 2026
deeb567
Add fee preview with total wallet deduction and post-condition ceilin…
Mosas2000 Mar 9, 2026
21e27a3
Show fee breakdown and total wallet deduction in tip confirmation dialog
Mosas2000 Mar 9, 2026
640e91b
Include platform fee in balance sufficiency check on amount input
Mosas2000 Mar 9, 2026
db58386
Apply fee-aware balance check in validateAndConfirm submission guard
Mosas2000 Mar 9, 2026
306792b
Add feeForTip, totalDeduction, and recipientReceives helpers
Mosas2000 Mar 9, 2026
5911bfa
Replace inline fee math in SendTip with shared helper functions
Mosas2000 Mar 9, 2026
623773f
Add feeForTip, totalDeduction, and recipientReceives to CJS module
Mosas2000 Mar 9, 2026
e7b1346
Add tests for feeForTip, totalDeduction, and recipientReceives helpers
Mosas2000 Mar 10, 2026
877ab7c
Document AMOUNT, MESSAGE, and DRY_RUN env vars in .env.example
Mosas2000 Mar 10, 2026
7d72687
Add post-condition enforcement guide for contributors
Mosas2000 Mar 10, 2026
9f2f081
Add shell script to audit codebase for PostConditionMode.Allow usage
Mosas2000 Mar 10, 2026
c814ef8
Add post-condition audit job to CI pipeline
Mosas2000 Mar 10, 2026
801206f
Add scripts directory README with post-condition documentation
Mosas2000 Mar 10, 2026
b16d80c
Add file-level usage documentation to test-contract.cjs
Mosas2000 Mar 10, 2026
8d2ba42
Detect post-condition failures and show user-friendly error in SendTip
Mosas2000 Mar 10, 2026
a78a4b0
Detect post-condition failures in RecentTips tip-back error handler
Mosas2000 Mar 10, 2026
c614e1d
Validate mnemonic word count and recipient address format in test script
Mosas2000 Mar 10, 2026
4b7423e
Add integration tests covering post-condition helpers across tip ranges
Mosas2000 Mar 10, 2026
e9b7914
Show fee breakdown and post-condition details in test script output
Mosas2000 Mar 10, 2026
782a3bb
Add no-restricted-syntax rule to catch bracket-notation Allow access
Mosas2000 Mar 10, 2026
4898e54
Add CHANGELOG entry for post-condition enforcement changes
Mosas2000 Mar 10, 2026
7ccd5e8
Add module-level JSDoc and FEE_PERCENT constant to post-conditions
Mosas2000 Mar 10, 2026
0398c6e
Use FEE_PERCENT constant in SendTip and remove unused imports
Mosas2000 Mar 10, 2026
4860fc7
Add FEE_PERCENT constant test and verify derivation from basis points
Mosas2000 Mar 10, 2026
681b663
Update README security section with post-condition enforcement details
Mosas2000 Mar 10, 2026
b377d0c
Update project structure in README to include new scripts and docs
Mosas2000 Mar 10, 2026
f792d76
Sanitize error output in test script to prevent mnemonic leakage
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
13 changes: 12 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,16 @@
# NEVER use a real wallet mnemonic here.
MNEMONIC=your_test_mnemonic_here

# Optional: recipient address for test-contract.cjs
# Recipient address for test-contract.cjs (required)
# Must be a valid SP... mainnet address and different from the sender.
RECIPIENT=SP_YOUR_TEST_RECIPIENT_ADDRESS

# Tip amount in microSTX (default: 1000, minimum: 1000 = 0.001 STX)
AMOUNT=1000

# Tip message attached to the on-chain transaction (default: "On-chain test tip")
MESSAGE=On-chain test tip

# Set to 1 to build the transaction without broadcasting.
# Useful for verifying post-conditions and transaction size.
DRY_RUN=0
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,12 @@ jobs:
name: frontend-dist
path: frontend/dist
retention-days: 7

post-condition-audit:
name: Post-Condition Audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Verify no PostConditionMode.Allow in production code
run: bash scripts/audit-post-conditions.sh
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Security

- Enforce `PostConditionMode.Deny` across all contract interactions
(frontend and CLI scripts). Previously the test script and some
transaction paths used `PostConditionMode.Allow`, which permits the
contract to transfer arbitrary STX from the user's account without
explicit bounds.

### Added

- 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`.
- Fee preview panel in SendTip showing tip, fee, total wallet deduction,
recipient net, and on-chain post-condition ceiling.
- Fee breakdown in the tip confirmation dialog.
- Fee-aware balance sufficiency checks that account for the 0.5% platform fee.
- Post-condition-specific error messages when transactions fail.
- ESLint rules (`no-restricted-properties` and `no-restricted-syntax`) banning
`PostConditionMode.Allow` in frontend code.
- CI job running `scripts/audit-post-conditions.sh` to grep-audit all source
files for `Allow` mode usage.
- 82 unit and integration tests for post-condition helpers.
- `docs/POST-CONDITION-GUIDE.md` explaining the enforcement strategy.
- `scripts/README.md` documenting all utility scripts.
- Mnemonic word-count and recipient address format validation in test script.
- Dry-run mode (`DRY_RUN=1`) for the test script.
- `.env.example` with documentation for all supported environment variables.

### Changed

- 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.
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,12 +169,18 @@ frontend/
components/ React components
config/ Contract address configuration
context/ TipContext (shared state)
lib/ Utility functions
lib/ Utility functions and post-condition helpers
test/ Unit and integration tests
utils/ Stacks wallet/network helpers
tests/
tipstream.test.ts Vitest contract tests
scripts/
lib/ Shared modules (post-conditions)
audit-post-conditions.sh CI audit for Allow mode usage
deploy.sh Deployment script
test-contract.cjs Mainnet test tip script
docs/
POST-CONDITION-GUIDE.md Post-condition enforcement strategy
deployments/
*.yaml Clarinet deployment plans
settings/
Expand All @@ -183,17 +189,22 @@ settings/

## Security

- **PostConditionMode.Deny** enforced on every user-facing transaction, preventing
the contract from transferring more STX than explicitly permitted
- Shared post-condition modules (`lib/post-conditions.js`, `scripts/lib/post-conditions.cjs`)
centralize fee-aware ceiling calculations
- ESLint rules and CI pipeline block `PostConditionMode.Allow` from entering the codebase
- Fee calculation enforces a minimum of 1 microSTX to prevent zero-fee abuse
- Minimum tip amount of 1000 microSTX (0.001 STX)
- Self-tipping is rejected at the contract level
- 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

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.

See [SECURITY.md](SECURITY.md) for the full security audit and vulnerability reporting guidelines.
See [docs/POST-CONDITION-GUIDE.md](docs/POST-CONDITION-GUIDE.md) for the post-condition enforcement strategy.

## Contributing

Expand Down
137 changes: 137 additions & 0 deletions docs/POST-CONDITION-GUIDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# Post-Condition Enforcement Guide

This document explains the post-condition strategy used across TipStream
to protect users from unintended STX transfers.

## Background

Stacks transactions support **post conditions** — on-chain assertions
that are checked after a contract executes but before the transaction
is committed. If any assertion fails the entire transaction is aborted
and no assets move.

TipStream uses `PostConditionMode.Deny` on every user-facing
transaction. In `Deny` mode, any STX transfer that is not covered by
an explicit post condition will cause the transaction to fail. This is
the opposite of `Allow` mode, which permits unconstrained transfers and
is a known security risk for wallets.

## Fee Model

The TipStream contract charges a platform fee on every tip:

| Parameter | Value | Source |
|--------------------|-------|----------------------------------|
| `fee-basis-points` | 50 | `tipstream.clar` constant |
| Divisor | 10000 | Basis-point standard |
| Effective rate | 0.5% | 50 / 10000 |

When a user sends X microSTX as a tip, the contract transfers:

- **Recipient**: X minus the floored fee
- **Vault**: The floored fee

The total STX leaving the sender's wallet is X (the full tip amount).
The fee is taken from the tip, not added on top.

## Post-Condition Ceiling

The shared helper `maxTransferForTip(amount)` computes:

```
fee = ceil(amount * 50 / 10000)
max = amount + fee + 1
```

The `+1` is a rounding buffer. The actual on-chain transfer will
always be less than or equal to this ceiling.

## Shared Modules

Both the frontend and CLI scripts use centralized post-condition
modules to avoid drift:

| Context | Module | Format |
|----------|------------------------------------|--------|
| Frontend | `frontend/src/lib/post-conditions.js` | ESM |
| Scripts | `scripts/lib/post-conditions.cjs` | CJS |

### Exported Helpers

| Function | Purpose |
|---------------------|---------------------------------------------|
| `maxTransferForTip` | Upper bound for the STX post condition |
| `tipPostCondition` | Build the post-condition object |
| `feeForTip` | Compute the platform fee (ceil) |
| `totalDeduction` | Tip plus fee (what leaves the wallet) |
| `recipientReceives` | Net amount after the fee split |

### Constants

| Name | Value | Purpose |
|--------------------------|-------|-------------------------------|
| `FEE_BASIS_POINTS` | 50 | Fee numerator |
| `BASIS_POINTS_DIVISOR` | 10000 | Fee denominator |
| `SAFE_POST_CONDITION_MODE` | Deny | The only allowed mode |

## ESLint Enforcement

The frontend ESLint config includes a `no-restricted-properties` rule
that flags any reference to `PostConditionMode.Allow` as an error:

```javascript
'no-restricted-properties': ['error', {
object: 'PostConditionMode',
property: 'Allow',
message: 'Use PostConditionMode.Deny with explicit post conditions.',
}],
```

## CI Enforcement

The CI pipeline runs the `scripts/audit-post-conditions.sh` script on
every pull request. It grep-searches all JavaScript and TypeScript
files for `PostConditionMode.Allow` and fails the build if any match
is found outside of test fixtures.

## Adding a New Contract Call

When adding a new function that calls a TipStream contract:

1. Import `tipPostCondition` and `SAFE_POST_CONDITION_MODE` from the
shared module.
2. Compute the microSTX amount.
3. Build the post-condition array: `[tipPostCondition(sender, amount)]`.
4. Set `postConditionMode: SAFE_POST_CONDITION_MODE` in the tx options.
5. Never use `PostConditionMode.Allow`.

Example:

```javascript
import {
tipPostCondition,
SAFE_POST_CONDITION_MODE,
} from '../lib/post-conditions';

const microSTX = toMicroSTX(amount);
const postConditions = [tipPostCondition(senderAddress, microSTX)];

await openContractCall({
// ...other options
postConditions,
postConditionMode: SAFE_POST_CONDITION_MODE,
});
```

## Testing

Unit tests live in `frontend/src/test/post-conditions.test.js` and
cover all exported functions including edge cases for rounding, zero
fees, and the relationship between `maxTransferForTip` and
`totalDeduction`.

Run them with:

```bash
cd frontend && npx vitest run src/test/post-conditions.test.js
```
11 changes: 11 additions & 0 deletions frontend/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,17 @@ export default defineConfig([
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
// Ban PostConditionMode.Allow — always use Deny with explicit conditions
'no-restricted-properties': ['error', {
object: 'PostConditionMode',
property: 'Allow',
message: 'Use PostConditionMode.Deny with explicit post conditions. See lib/post-conditions.js.',
}],
// Also catch string-literal access like PostConditionMode['Allow']
'no-restricted-syntax': ['error', {
selector: "MemberExpression[object.name='PostConditionMode'][property.value='Allow']",
message: 'Use PostConditionMode.Deny with explicit post conditions. See lib/post-conditions.js.',
}],
},
},
])
16 changes: 11 additions & 5 deletions frontend/src/components/RecentTips.jsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useEffect, useState, useCallback, useMemo } from 'react';
import { openContractCall } from '@stacks/connect';
import { uintCV, stringUtf8CV, PostConditionMode, Pc } from '@stacks/transactions';
import { uintCV, stringUtf8CV } from '@stacks/transactions';
import { CONTRACT_ADDRESS, CONTRACT_NAME, STACKS_API_BASE } from '../config/contracts';
import { formatSTX, toMicroSTX, formatAddress } from '../lib/utils';
import { tipPostCondition, SAFE_POST_CONDITION_MODE } from '../lib/post-conditions';
import { network, appDetails, userSession } from '../utils/stacks';
import { parseTipEvent } from '../lib/parseTipEvent';
import { fetchTipMessages, clearTipCache } from '../lib/fetchTipDetails';
Expand Down Expand Up @@ -131,14 +132,19 @@ export default function RecentTips({ addToast }) {
contractAddress: CONTRACT_ADDRESS, contractName: CONTRACT_NAME,
functionName: 'tip-a-tip',
functionArgs: [uintCV(parseInt(tip.tipId)), uintCV(microSTX), stringUtf8CV(tipBackMessage || 'Tipping back!')],
postConditions: [Pc.principal(senderAddress).willSendLte(microSTX).ustx()],
postConditionMode: PostConditionMode.Deny,
postConditions: [tipPostCondition(senderAddress, microSTX)],
postConditionMode: SAFE_POST_CONDITION_MODE,
onFinish: (data) => { setSending(false); setTipBackTarget(null); setTipBackMessage(''); addToast?.('Tip-a-tip sent! Tx: ' + data.txId, 'success'); },
onCancel: () => { setSending(false); addToast?.('Tip-a-tip cancelled', 'info'); },
});
} catch (err) {
console.error('Tip-a-tip failed:', err.message || err);
addToast?.('Failed to send tip-a-tip', 'error');
const msg = err.message || String(err);
console.error('Tip-a-tip failed:', msg);
if (msg.toLowerCase().includes('post-condition') || msg.toLowerCase().includes('postcondition')) {
addToast?.('Transaction rejected by post-condition check. Your funds are safe.', 'error');
} else {
addToast?.('Failed to send tip-a-tip', 'error');
}
setSending(false);
}
};
Expand Down
Loading
Loading