diff --git a/.env.example b/.env.example
index 596ce7f5..948822e5 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ec15102c..c543219b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 00000000..0d469567
--- /dev/null
+++ b/CHANGELOG.md
@@ -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.
diff --git a/README.md b/README.md
index c29c9bc3..ee1b413b 100644
--- a/README.md
+++ b/README.md
@@ -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/
@@ -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
diff --git a/docs/POST-CONDITION-GUIDE.md b/docs/POST-CONDITION-GUIDE.md
new file mode 100644
index 00000000..dc562e5b
--- /dev/null
+++ b/docs/POST-CONDITION-GUIDE.md
@@ -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
+```
diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
index 4fa125da..d78e552d 100644
--- a/frontend/eslint.config.js
+++ b/frontend/eslint.config.js
@@ -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.',
+ }],
},
},
])
diff --git a/frontend/src/components/RecentTips.jsx b/frontend/src/components/RecentTips.jsx
index 152b8fc3..cb8604b9 100644
--- a/frontend/src/components/RecentTips.jsx
+++ b/frontend/src/components/RecentTips.jsx
@@ -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';
@@ -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);
}
};
diff --git a/frontend/src/components/SendTip.jsx b/frontend/src/components/SendTip.jsx
index b630607e..b7f5f77a 100644
--- a/frontend/src/components/SendTip.jsx
+++ b/frontend/src/components/SendTip.jsx
@@ -4,12 +4,11 @@ import {
stringUtf8CV,
uintCV,
principalCV,
- PostConditionMode,
- Pc
} from '@stacks/transactions';
import { network, appDetails, userSession } from '../utils/stacks';
import { CONTRACT_ADDRESS, CONTRACT_NAME } from '../config/contracts';
import { toMicroSTX, formatSTX } from '../lib/utils';
+import { tipPostCondition, maxTransferForTip, feeForTip, totalDeduction, recipientReceives, SAFE_POST_CONDITION_MODE, FEE_PERCENT } from '../lib/post-conditions';
import { useTipContext } from '../context/TipContext';
import { useBalance } from '../hooks/useBalance';
import { useStxPrice } from '../hooks/useStxPrice';
@@ -17,8 +16,6 @@ import { analytics } from '../lib/analytics';
import ConfirmDialog from './ui/confirm-dialog';
import TxStatus from './ui/tx-status';
-const FEE_BASIS_POINTS = 50;
-const BASIS_POINTS_DIVISOR = 10000;
const MIN_TIP_STX = 0.001;
const MAX_TIP_STX = 10000;
const COOLDOWN_SECONDS = 10;
@@ -105,8 +102,15 @@ export default function SendTip({ addToast }) {
setAmountError(`Minimum tip is ${MIN_TIP_STX} STX`);
} else if (parsed > MAX_TIP_STX) {
setAmountError(`Maximum tip is ${MAX_TIP_STX.toLocaleString()} STX`);
- } else if (balanceSTX !== null && parsed > balanceSTX) {
- setAmountError('Insufficient balance');
+ } else if (balanceSTX !== null) {
+ // Account for the platform fee when checking balance
+ const microSTX = toMicroSTX(parsed.toString());
+ const totalSTX = totalDeduction(microSTX) / 1_000_000;
+ if (totalSTX > balanceSTX) {
+ setAmountError('Insufficient balance (tip + 0.5% fee exceeds balance)');
+ } else {
+ setAmountError('');
+ }
} else {
setAmountError('');
}
@@ -121,7 +125,13 @@ export default function SendTip({ addToast }) {
if (isNaN(parsedAmount) || parsedAmount <= 0) { addToast('Please enter a valid amount', 'warning'); return; }
if (parsedAmount < MIN_TIP_STX) { addToast(`Minimum tip is ${MIN_TIP_STX} STX`, 'warning'); return; }
if (parsedAmount > MAX_TIP_STX) { addToast(`Maximum tip is ${MAX_TIP_STX.toLocaleString()} STX`, 'warning'); return; }
- if (balanceSTX !== null && parsedAmount > balanceSTX) { addToast('Insufficient STX balance', 'warning'); return; }
+ if (balanceSTX !== null) {
+ const microSTX = toMicroSTX(amount);
+ if (totalDeduction(microSTX) / 1_000_000 > balanceSTX) {
+ addToast('Insufficient balance to cover tip plus platform fee', 'warning');
+ return;
+ }
+ }
setShowConfirm(true);
analytics.trackTipStarted();
};
@@ -134,7 +144,7 @@ export default function SendTip({ addToast }) {
try {
const microSTX = toMicroSTX(amount);
const postConditions = [
- Pc.principal(senderAddress).willSendLte(microSTX).ustx()
+ tipPostCondition(senderAddress, microSTX)
];
const functionArgs = [
@@ -152,7 +162,7 @@ export default function SendTip({ addToast }) {
functionName: 'send-categorized-tip',
functionArgs,
postConditions,
- postConditionMode: PostConditionMode.Deny,
+ postConditionMode: SAFE_POST_CONDITION_MODE,
onFinish: (data) => {
setLoading(false);
setPendingTx({ txId: data.txId, recipient, amount: parseFloat(amount) });
@@ -173,9 +183,16 @@ export default function SendTip({ addToast }) {
}
});
} catch (error) {
- console.error('Failed to send tip:', error.message || error);
+ const msg = error.message || String(error);
+ console.error('Failed to send tip:', msg);
analytics.trackTipFailed();
- addToast('Failed to send tip. Please try again.', 'error');
+
+ // Provide a more specific message for post-condition failures
+ 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. Please try again.', 'error');
+ }
setLoading(false);
}
};
@@ -246,10 +263,10 @@ export default function SendTip({ addToast }) {
- {/* Breakdown */}
+ {/* Breakdown with fee preview and post-condition ceiling */}
{amount && parseFloat(amount) > 0 && (
-
Breakdown
+
Fee Preview
Tip amount
@@ -259,15 +276,25 @@ export default function SendTip({ addToast }) {
- Platform fee (0.5%)
- {formatSTX(Math.floor(toMicroSTX(amount) * FEE_BASIS_POINTS / BASIS_POINTS_DIVISOR), 6)} STX
+ Platform fee ({FEE_PERCENT}%)
+ {formatSTX(feeForTip(toMicroSTX(amount)), 6)} STX
+ Total from wallet
+
+ {formatSTX(totalDeduction(toMicroSTX(amount)), 6)} STX
+
+
+
Recipient receives
- {formatSTX(toMicroSTX(amount) - Math.floor(toMicroSTX(amount) * FEE_BASIS_POINTS / BASIS_POINTS_DIVISOR), 6)} STX
+ {formatSTX(recipientReceives(toMicroSTX(amount)), 6)} STX
+
+ Post-condition ceiling
+ {formatSTX(maxTransferForTip(toMicroSTX(amount)), 6)} STX
+
)}
@@ -306,6 +333,18 @@ export default function SendTip({ addToast }) {
{recipient}
Category: {TIP_CATEGORIES.find(c => c.id === category)?.label}
{message && "{message}"
}
+ {amount && parseFloat(amount) > 0 && (
+
+
+ Platform fee ({FEE_PERCENT}%)
+ {formatSTX(feeForTip(toMicroSTX(amount)), 6)} STX
+
+
+ Total from your wallet
+ {formatSTX(totalDeduction(toMicroSTX(amount)), 6)} STX
+
+
+ )}
diff --git a/frontend/src/lib/post-conditions.js b/frontend/src/lib/post-conditions.js
new file mode 100644
index 00000000..4e196a9c
--- /dev/null
+++ b/frontend/src/lib/post-conditions.js
@@ -0,0 +1,90 @@
+/**
+ * @module post-conditions
+ *
+ * Post-condition helpers for TipStream frontend contract calls.
+ *
+ * All user-facing transactions must use PostConditionMode.Deny and
+ * attach explicit STX transfer limits. This module provides the
+ * same fee-aware calculation used in the CLI scripts so the frontend
+ * and backend stay in sync.
+ *
+ * @see docs/POST-CONDITION-GUIDE.md
+ */
+
+import { PostConditionMode, Pc } from '@stacks/transactions';
+
+// Contract fee parameters — keep in sync with tipstream.clar
+export const FEE_BASIS_POINTS = 50;
+export const BASIS_POINTS_DIVISOR = 10000;
+
+/** Human-readable fee percentage (e.g. 0.5 for 0.5%). */
+export const FEE_PERCENT = FEE_BASIS_POINTS / BASIS_POINTS_DIVISOR * 100;
+
+/**
+ * The only acceptable post-condition mode for production transactions.
+ * Using PostConditionMode.Allow is a security risk because it permits
+ * the contract to transfer arbitrary STX from the user's account.
+ */
+export const SAFE_POST_CONDITION_MODE = PostConditionMode.Deny;
+
+/**
+ * Calculate the maximum microSTX the sender will transfer for a tip,
+ * including the contract fee and a 1-uSTX rounding buffer.
+ *
+ * @param {number} amountMicroSTX Tip amount in microSTX.
+ * @param {number} [feeBps=50] Fee in basis points.
+ * @returns {number}
+ */
+export function maxTransferForTip(amountMicroSTX, feeBps = FEE_BASIS_POINTS) {
+ const fee = Math.ceil(amountMicroSTX * feeBps / BASIS_POINTS_DIVISOR);
+ return amountMicroSTX + fee + 1;
+}
+
+/**
+ * Build a Pc-based STX post condition for a tip transaction.
+ *
+ * @param {string} senderAddress The sender's Stacks principal.
+ * @param {number} amountMicroSTX Tip amount in microSTX.
+ * @param {number} [feeBps=50] Fee in basis points.
+ * @returns {object}
+ */
+export function tipPostCondition(senderAddress, amountMicroSTX, feeBps = FEE_BASIS_POINTS) {
+ return Pc.principal(senderAddress)
+ .willSendLte(maxTransferForTip(amountMicroSTX, feeBps))
+ .ustx();
+}
+
+/**
+ * Calculate the platform fee in microSTX for a given tip amount.
+ * Uses Math.ceil to match the on-chain rounding behavior.
+ *
+ * @param {number} amountMicroSTX Tip amount in microSTX.
+ * @param {number} [feeBps=50] Fee in basis points.
+ * @returns {number}
+ */
+export function feeForTip(amountMicroSTX, feeBps = FEE_BASIS_POINTS) {
+ return Math.ceil(amountMicroSTX * feeBps / BASIS_POINTS_DIVISOR);
+}
+
+/**
+ * Calculate the total microSTX deducted from the sender's wallet,
+ * which is the tip amount plus the platform fee.
+ *
+ * @param {number} amountMicroSTX Tip amount in microSTX.
+ * @param {number} [feeBps=50] Fee in basis points.
+ * @returns {number}
+ */
+export function totalDeduction(amountMicroSTX, feeBps = FEE_BASIS_POINTS) {
+ return amountMicroSTX + feeForTip(amountMicroSTX, feeBps);
+}
+
+/**
+ * Calculate the net amount the recipient receives after the fee split.
+ *
+ * @param {number} amountMicroSTX Tip amount in microSTX.
+ * @param {number} [feeBps=50] Fee in basis points.
+ * @returns {number}
+ */
+export function recipientReceives(amountMicroSTX, feeBps = FEE_BASIS_POINTS) {
+ return amountMicroSTX - Math.floor(amountMicroSTX * feeBps / BASIS_POINTS_DIVISOR);
+}
diff --git a/frontend/src/test/post-conditions-integration.test.js b/frontend/src/test/post-conditions-integration.test.js
new file mode 100644
index 00000000..8ec46616
--- /dev/null
+++ b/frontend/src/test/post-conditions-integration.test.js
@@ -0,0 +1,88 @@
+import { describe, it, expect } from 'vitest';
+import {
+ maxTransferForTip,
+ totalDeduction,
+ feeForTip,
+ recipientReceives,
+ FEE_BASIS_POINTS,
+ BASIS_POINTS_DIVISOR,
+} from '../../src/lib/post-conditions';
+
+/**
+ * Integration-style tests that verify the consistency between
+ * all post-condition helpers across a range of tip amounts.
+ *
+ * These tests ensure that the fee math in the frontend stays
+ * correct and self-consistent for any realistic tip value.
+ */
+describe('post-condition integration', () => {
+ const testAmounts = [
+ 1000, // minimum tip (0.001 STX)
+ 10_000, // 0.01 STX
+ 100_000, // 0.1 STX
+ 500_000, // 0.5 STX (common default)
+ 1_000_000, // 1 STX
+ 10_000_000, // 10 STX
+ 100_000_000, // 100 STX
+ 1_000_000_000, // 1000 STX
+ 10_000_000_000,// 10000 STX (maximum tip)
+ ];
+
+ describe('maxTransferForTip is always greater than totalDeduction', () => {
+ testAmounts.forEach(amount => {
+ it(`amount = ${amount} uSTX`, () => {
+ expect(maxTransferForTip(amount)).toBeGreaterThan(totalDeduction(amount));
+ });
+ });
+ });
+
+ describe('maxTransferForTip exceeds totalDeduction by exactly 1', () => {
+ testAmounts.forEach(amount => {
+ it(`amount = ${amount} uSTX`, () => {
+ expect(maxTransferForTip(amount) - totalDeduction(amount)).toBe(1);
+ });
+ });
+ });
+
+ describe('recipientReceives is always less than the tip amount', () => {
+ testAmounts.forEach(amount => {
+ it(`amount = ${amount} uSTX`, () => {
+ expect(recipientReceives(amount)).toBeLessThan(amount);
+ });
+ });
+ });
+
+ describe('recipientReceives plus fee accounts for the full amount', () => {
+ testAmounts.forEach(amount => {
+ it(`amount = ${amount} uSTX`, () => {
+ const net = recipientReceives(amount);
+ const fee = feeForTip(amount);
+ // Due to ceil/floor rounding, fee + net can differ from amount by at most 1
+ expect(Math.abs(net + fee - amount)).toBeLessThanOrEqual(1);
+ });
+ });
+ });
+
+ describe('fee percentage stays within expected range', () => {
+ testAmounts.forEach(amount => {
+ it(`amount = ${amount} uSTX`, () => {
+ const fee = feeForTip(amount);
+ const effectiveRate = fee / amount;
+ // Should be approximately 0.5% (0.005), but ceil rounding can push slightly higher
+ expect(effectiveRate).toBeGreaterThanOrEqual(FEE_BASIS_POINTS / BASIS_POINTS_DIVISOR);
+ // Never more than double the expected rate for small amounts
+ expect(effectiveRate).toBeLessThan(2 * FEE_BASIS_POINTS / BASIS_POINTS_DIVISOR);
+ });
+ });
+ });
+
+ describe('totalDeduction never exceeds maxTransferForTip', () => {
+ // Fuzz-style test with edge-case amounts
+ const edgeCases = [1, 2, 3, 99, 100, 101, 199, 200, 201, 999, 1000, 9999, 10000, 10001];
+ edgeCases.forEach(amount => {
+ it(`edge case amount = ${amount} uSTX`, () => {
+ expect(totalDeduction(amount)).toBeLessThan(maxTransferForTip(amount));
+ });
+ });
+ });
+});
diff --git a/frontend/src/test/post-conditions.test.js b/frontend/src/test/post-conditions.test.js
new file mode 100644
index 00000000..1075279b
--- /dev/null
+++ b/frontend/src/test/post-conditions.test.js
@@ -0,0 +1,152 @@
+import { describe, it, expect } from 'vitest';
+import {
+ FEE_BASIS_POINTS,
+ BASIS_POINTS_DIVISOR,
+ FEE_PERCENT,
+ SAFE_POST_CONDITION_MODE,
+ maxTransferForTip,
+ tipPostCondition,
+ feeForTip,
+ totalDeduction,
+ recipientReceives,
+} from '../../src/lib/post-conditions';
+
+describe('post-conditions', () => {
+ describe('constants', () => {
+ it('uses the correct default fee basis points', () => {
+ expect(FEE_BASIS_POINTS).toBe(50);
+ });
+
+ it('uses the correct basis points divisor', () => {
+ expect(BASIS_POINTS_DIVISOR).toBe(10000);
+ });
+
+ it('exports Deny as the safe post-condition mode', () => {
+ // PostConditionMode.Deny is 0x02
+ expect(SAFE_POST_CONDITION_MODE).toBeDefined();
+ });
+
+ it('exports FEE_PERCENT as a derived percentage', () => {
+ expect(FEE_PERCENT).toBe(0.5);
+ expect(FEE_PERCENT).toBe(FEE_BASIS_POINTS / BASIS_POINTS_DIVISOR * 100);
+ });
+ });
+
+ describe('maxTransferForTip', () => {
+ it('returns amount plus fee plus one for minimum tip', () => {
+ // 1000 uSTX * 50 / 10000 = 5, ceil(5) = 5
+ // max = 1000 + 5 + 1 = 1006
+ expect(maxTransferForTip(1000)).toBe(1006);
+ });
+
+ it('handles larger amounts correctly', () => {
+ // 1_000_000 uSTX * 50 / 10000 = 5000
+ // max = 1_000_000 + 5000 + 1 = 1_005_001
+ expect(maxTransferForTip(1_000_000)).toBe(1_005_001);
+ });
+
+ it('ceils the fee for non-round amounts', () => {
+ // 1001 * 50 / 10000 = 5.005, ceil = 6
+ // max = 1001 + 6 + 1 = 1008
+ expect(maxTransferForTip(1001)).toBe(1008);
+ });
+
+ it('accepts custom fee basis points', () => {
+ // 1000 * 100 / 10000 = 10
+ // max = 1000 + 10 + 1 = 1011
+ expect(maxTransferForTip(1000, 100)).toBe(1011);
+ });
+
+ it('handles zero fee', () => {
+ // 1000 * 0 / 10000 = 0
+ // max = 1000 + 0 + 1 = 1001
+ expect(maxTransferForTip(1000, 0)).toBe(1001);
+ });
+
+ it('handles maximum allowed fee of 1000 basis points', () => {
+ // 1000 * 1000 / 10000 = 100
+ // max = 1000 + 100 + 1 = 1101
+ expect(maxTransferForTip(1000, 1000)).toBe(1101);
+ });
+ });
+
+ describe('tipPostCondition', () => {
+ it('returns an object for a valid sender and amount', () => {
+ const pc = tipPostCondition('SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T', 1000);
+ expect(pc).toBeDefined();
+ });
+
+ it('accepts custom fee basis points', () => {
+ const pc = tipPostCondition('SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T', 1000, 100);
+ expect(pc).toBeDefined();
+ });
+ });
+
+ describe('feeForTip', () => {
+ it('computes correct fee for round amounts', () => {
+ // 1_000_000 * 50 / 10000 = 5000
+ expect(feeForTip(1_000_000)).toBe(5000);
+ });
+
+ it('ceils the fee for non-round amounts', () => {
+ // 1001 * 50 / 10000 = 5.005 -> ceil = 6
+ expect(feeForTip(1001)).toBe(6);
+ });
+
+ it('returns zero fee when basis points are zero', () => {
+ expect(feeForTip(1_000_000, 0)).toBe(0);
+ });
+
+ it('handles minimum tip amount', () => {
+ // 1000 * 50 / 10000 = 5
+ expect(feeForTip(1000)).toBe(5);
+ });
+ });
+
+ describe('totalDeduction', () => {
+ it('equals amount plus fee for round amounts', () => {
+ // 1_000_000 + 5000 = 1_005_000
+ expect(totalDeduction(1_000_000)).toBe(1_005_000);
+ });
+
+ it('equals amount plus ceiled fee for non-round amounts', () => {
+ // 1001 + 6 = 1007
+ expect(totalDeduction(1001)).toBe(1007);
+ });
+
+ it('equals amount when fee is zero', () => {
+ expect(totalDeduction(1000, 0)).toBe(1000);
+ });
+
+ it('is always less than maxTransferForTip by exactly 1', () => {
+ // maxTransferForTip adds a 1-uSTX rounding buffer
+ expect(maxTransferForTip(5000) - totalDeduction(5000)).toBe(1);
+ });
+ });
+
+ describe('recipientReceives', () => {
+ it('returns amount minus floored fee for round amounts', () => {
+ // 1_000_000 - floor(1_000_000 * 50 / 10000) = 1_000_000 - 5000 = 995000
+ expect(recipientReceives(1_000_000)).toBe(995000);
+ });
+
+ it('floors the fee deduction for non-round amounts', () => {
+ // 1001 - floor(1001 * 50 / 10000) = 1001 - floor(5.005) = 1001 - 5 = 996
+ expect(recipientReceives(1001)).toBe(996);
+ });
+
+ it('returns full amount when fee is zero', () => {
+ expect(recipientReceives(1000, 0)).toBe(1000);
+ });
+
+ it('fee charged and net received differ by at most 1 from original amount', () => {
+ // feeForTip uses ceil, recipientReceives uses floor for the deduction.
+ // The contract charges the sender ceil(fee) and gives the recipient
+ // amount - floor(fee), so fee + net can exceed amount by at most 1.
+ const amount = 1001;
+ const fee = feeForTip(amount);
+ const net = recipientReceives(amount);
+ expect(Math.abs(fee + net - amount)).toBeLessThanOrEqual(1);
+ });
+ });
+});
diff --git a/scripts/README.md b/scripts/README.md
new file mode 100644
index 00000000..0d22d5fc
--- /dev/null
+++ b/scripts/README.md
@@ -0,0 +1,70 @@
+# Scripts
+
+Utility scripts for TipStream contract interaction, deployment, and
+code quality enforcement.
+
+## test-contract.cjs
+
+Send a test tip on Stacks mainnet.
+
+```bash
+MNEMONIC="..." RECIPIENT="SP..." node scripts/test-contract.cjs
+```
+
+### Environment Variables
+
+| Variable | Required | Default | Description |
+|-------------|----------|----------------------|------------------------------------|
+| `MNEMONIC` | Yes | — | BIP-39 mnemonic for sender wallet |
+| `RECIPIENT` | Yes | — | SP... mainnet address |
+| `AMOUNT` | No | `1000` | Tip amount in microSTX (min 1000) |
+| `MESSAGE` | No | `"On-chain test tip"`| Message attached to the tip |
+| `DRY_RUN` | No | `0` | Set to `1` to skip broadcasting |
+
+The script uses `PostConditionMode.Deny` with an explicit STX ceiling
+computed by the shared `scripts/lib/post-conditions.cjs` module.
+
+## lib/post-conditions.cjs
+
+Shared CommonJS module exporting fee calculation and post-condition
+builders. Both this script module and the frontend ESM module
+(`frontend/src/lib/post-conditions.js`) share the same logic.
+
+See [docs/POST-CONDITION-GUIDE.md](../docs/POST-CONDITION-GUIDE.md)
+for the full strategy explanation.
+
+## audit-post-conditions.sh
+
+Grep-based audit that fails if any production source file uses
+`PostConditionMode.Allow`. Run locally or as part of CI.
+
+```bash
+bash scripts/audit-post-conditions.sh
+```
+
+## deploy.sh
+
+Deployment helper. Validates credentials, checks git tracking, and
+timestamps the deployment.
+
+```bash
+bash scripts/deploy.sh
+```
+
+## setup-hooks.sh
+
+Install the git pre-commit hook that runs secret scanning before each
+commit.
+
+```bash
+bash scripts/setup-hooks.sh
+```
+
+## verify-no-secrets.sh
+
+Scan the repository for accidentally committed credentials. Used by
+the pre-commit hook and CI secret-scan workflow.
+
+```bash
+bash scripts/verify-no-secrets.sh
+```
diff --git a/scripts/audit-post-conditions.sh b/scripts/audit-post-conditions.sh
new file mode 100755
index 00000000..e1f70e70
--- /dev/null
+++ b/scripts/audit-post-conditions.sh
@@ -0,0 +1,63 @@
+#!/usr/bin/env bash
+# scripts/audit-post-conditions.sh
+#
+# Fail if any JavaScript or TypeScript source file uses
+# PostConditionMode.Allow outside of test fixtures.
+#
+# Run locally: bash scripts/audit-post-conditions.sh
+# CI integration: see .github/workflows/ci.yml
+
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+
+# Directories to scan
+DIRS=(
+ "$ROOT/frontend/src"
+ "$ROOT/scripts"
+ "$ROOT/chainhook"
+)
+
+# Patterns that indicate Allow mode usage
+PATTERNS=(
+ "PostConditionMode\.Allow"
+ "postConditionMode.*Allow"
+ "PostConditionMode\[.Allow.\]"
+)
+
+FOUND=0
+
+for dir in "${DIRS[@]}"; do
+ if [ ! -d "$dir" ]; then
+ continue
+ fi
+
+ for pattern in "${PATTERNS[@]}"; do
+ # Exclude test files and comment-only lines (JSDoc, //, #)
+ # The grep -n output format is "file:line: content", so filter on content after the second colon
+ MATCHES=$(grep -rn --include='*.js' --include='*.jsx' --include='*.ts' --include='*.tsx' \
+ -E "$pattern" "$dir" \
+ | grep -v '__test__\|\.test\.\|\.spec\.\|test/\|\.eslintrc' \
+ | grep -v ':[[:space:]]*//' \
+ | grep -v ':[[:space:]]*\*' \
+ | grep -v ':[[:space:]]*/\*' \
+ | grep -v "message:.*Allow" || true)
+
+ if [ -n "$MATCHES" ]; then
+ echo "ERROR: Found PostConditionMode.Allow usage:"
+ echo "$MATCHES"
+ FOUND=1
+ fi
+ done
+done
+
+if [ "$FOUND" -eq 1 ]; then
+ echo ""
+ echo "PostConditionMode.Allow is banned in production code."
+ echo "Use PostConditionMode.Deny with explicit post conditions."
+ echo "See docs/POST-CONDITION-GUIDE.md for details."
+ exit 1
+fi
+
+echo "Post-condition audit passed: no Allow mode usage found."
+exit 0
diff --git a/scripts/lib/post-conditions.cjs b/scripts/lib/post-conditions.cjs
new file mode 100644
index 00000000..e2569db1
--- /dev/null
+++ b/scripts/lib/post-conditions.cjs
@@ -0,0 +1,99 @@
+/**
+ * Post-condition helpers for TipStream contract interactions.
+ *
+ * Every on-chain call should use PostConditionMode.Deny paired with
+ * explicit conditions that cap how much STX the sender can transfer.
+ * This module centralizes the fee calculation and condition building
+ * so all scripts use the same logic.
+ */
+
+const {
+ makeStandardSTXPostCondition,
+ FungibleConditionCode,
+ PostConditionMode,
+} = require('@stacks/transactions');
+
+// Default contract fee — keep in sync with tipstream.clar
+const FEE_BASIS_POINTS = 50;
+const BASIS_POINTS_DIVISOR = 10000;
+
+/**
+ * Calculate the maximum STX (in microSTX) the sender should transfer
+ * for a given tip amount, accounting for the platform fee.
+ *
+ * The contract deducts `amount * feeBasisPoints / 10000` as a fee.
+ * We add 1 microSTX as a rounding buffer to avoid off-by-one rejections.
+ *
+ * @param {number} amount Tip amount in microSTX.
+ * @param {number} [feeBps=50] Fee in basis points.
+ * @returns {number} Maximum microSTX the sender will send.
+ */
+function maxTransferForTip(amount, feeBps = FEE_BASIS_POINTS) {
+ const fee = Math.ceil(amount * feeBps / BASIS_POINTS_DIVISOR);
+ return amount + fee + 1;
+}
+
+/**
+ * Build a standard STX post condition for a tip transaction.
+ *
+ * @param {string} senderAddress The sender's Stacks principal.
+ * @param {number} amount Tip amount in microSTX.
+ * @param {number} [feeBps=50] Fee in basis points.
+ * @returns {object} A Stacks post condition object.
+ */
+function tipPostCondition(senderAddress, amount, feeBps = FEE_BASIS_POINTS) {
+ return makeStandardSTXPostCondition(
+ senderAddress,
+ FungibleConditionCode.LessEqual,
+ maxTransferForTip(amount, feeBps)
+ );
+}
+
+/**
+ * Calculate the platform fee in microSTX for a given tip amount.
+ *
+ * @param {number} amount Tip amount in microSTX.
+ * @param {number} [feeBps=50] Fee in basis points.
+ * @returns {number}
+ */
+function feeForTip(amount, feeBps = FEE_BASIS_POINTS) {
+ return Math.ceil(amount * feeBps / BASIS_POINTS_DIVISOR);
+}
+
+/**
+ * Calculate the total microSTX deducted from the sender's wallet.
+ *
+ * @param {number} amount Tip amount in microSTX.
+ * @param {number} [feeBps=50] Fee in basis points.
+ * @returns {number}
+ */
+function totalDeduction(amount, feeBps = FEE_BASIS_POINTS) {
+ return amount + feeForTip(amount, feeBps);
+}
+
+/**
+ * Calculate the net amount the recipient receives after the fee.
+ *
+ * @param {number} amount Tip amount in microSTX.
+ * @param {number} [feeBps=50] Fee in basis points.
+ * @returns {number}
+ */
+function recipientReceives(amount, feeBps = FEE_BASIS_POINTS) {
+ return amount - Math.floor(amount * feeBps / BASIS_POINTS_DIVISOR);
+}
+
+/**
+ * The only acceptable post-condition mode for production transactions.
+ */
+const SAFE_POST_CONDITION_MODE = PostConditionMode.Deny;
+
+module.exports = {
+ FEE_BASIS_POINTS,
+ BASIS_POINTS_DIVISOR,
+ maxTransferForTip,
+ tipPostCondition,
+ feeForTip,
+ totalDeduction,
+ recipientReceives,
+ SAFE_POST_CONDITION_MODE,
+};
diff --git a/scripts/test-contract.cjs b/scripts/test-contract.cjs
index bf131eb8..b96b5537 100644
--- a/scripts/test-contract.cjs
+++ b/scripts/test-contract.cjs
@@ -1,14 +1,35 @@
+/**
+ * test-contract.cjs — Send a test tip on Stacks mainnet.
+ *
+ * Usage:
+ * MNEMONIC="..." RECIPIENT="SP..." node scripts/test-contract.cjs
+ *
+ * Optional env vars:
+ * AMOUNT — Tip in microSTX (default 1000, min 1000)
+ * MESSAGE — On-chain message (default "On-chain test tip")
+ * DRY_RUN — Set to "1" to build the tx without broadcasting
+ *
+ * Security:
+ * This script uses PostConditionMode.Deny with an explicit STX ceiling.
+ * See scripts/lib/post-conditions.cjs and docs/POST-CONDITION-GUIDE.md.
+ */
const {
makeContractCall,
broadcastTransaction,
AnchorMode,
- PostConditionMode,
principalCV,
uintCV,
stringUtf8CV,
} = require('@stacks/transactions');
const { STACKS_MAINNET: network } = require('@stacks/network');
-const { generateWallet, getStxAddress } = require('@stacks/wallet-sdk');
+const { generateWallet } = require('@stacks/wallet-sdk');
+const {
+ tipPostCondition,
+ maxTransferForTip,
+ feeForTip,
+ totalDeduction,
+ SAFE_POST_CONDITION_MODE,
+} = require('./lib/post-conditions.cjs');
// Use MNEMONIC environment variable for security
const mnemonic = process.env.MNEMONIC;
@@ -19,6 +40,13 @@ if (!mnemonic) {
process.exit(1);
}
+// BIP-39 mnemonics are 12 or 24 words
+const wordCount = mnemonic.trim().split(/\s+/).length;
+if (wordCount !== 12 && wordCount !== 24) {
+ console.error(`Error: MNEMONIC has ${wordCount} words. Expected 12 or 24.`);
+ process.exit(1);
+}
+
const recipientArg = process.env.RECIPIENT;
if (!recipientArg) {
console.error("Error: RECIPIENT environment variable not set.");
@@ -27,11 +55,19 @@ if (!recipientArg) {
process.exit(1);
}
+// Validate recipient address format
+if (!/^SP[0-9A-Z]{33,39}$/i.test(recipientArg.trim())) {
+ console.error("Error: RECIPIENT does not look like a valid mainnet address (SP...).");
+ process.exit(1);
+}
+
async function runTestTip() {
const contractAddress = "SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T";
const contractName = "tipstream";
const functionName = "send-tip";
+ // Fee constants are defined in scripts/lib/post-conditions.cjs
+
try {
// Derive wallet and private key
const wallet = await generateWallet({
@@ -43,13 +79,33 @@ async function runTestTip() {
const senderAddress = account.address;
const recipient = recipientArg;
- const amount = 1000;
- const message = "On-chain test tip";
+ const amount = parseInt(process.env.AMOUNT || '1000', 10);
+ const message = process.env.MESSAGE || "On-chain test tip";
+
+ if (isNaN(amount) || amount < 1000) {
+ console.error("Error: AMOUNT must be at least 1000 uSTX (0.001 STX).");
+ process.exit(1);
+ }
+
+ // The contract rejects self-tips; catch it early
+ if (senderAddress === recipient) {
+ console.error("Error: sender and recipient are the same address.");
+ console.error("The contract does not allow self-tipping.");
+ process.exit(1);
+ }
console.log(`Calling ${contractName}.${functionName} on Mainnet...`);
- console.log(`Sender: ${senderAddress}`);
+ console.log(`Sender: ${senderAddress}`);
console.log(`Recipient: ${recipient}`);
- console.log(`Amount: ${amount} uSTX (0.001 STX)`);
+ console.log(`Amount: ${amount} uSTX (${(amount / 1_000_000).toFixed(6)} STX)`);
+ console.log(`Fee: ${feeForTip(amount)} uSTX (0.5%)`);
+ console.log(`Total: ${totalDeduction(amount)} uSTX`);
+ console.log(`Ceiling: ${maxTransferForTip(amount)} uSTX (post-condition limit)`);
+ console.log(`Mode: PostConditionMode.Deny`);
+ console.log(`Message: "${message}"`);
+
+ // Build post conditions using the shared helper.
+ const postConditions = [tipPostCondition(senderAddress, amount)];
const txOptions = {
contractAddress,
@@ -60,13 +116,24 @@ async function runTestTip() {
uintCV(amount),
stringUtf8CV(message),
],
+ postConditions,
senderKey,
network,
anchorMode: AnchorMode.Any,
- postConditionMode: PostConditionMode.Allow,
+ postConditionMode: SAFE_POST_CONDITION_MODE,
};
const transaction = await makeContractCall(txOptions);
+
+ // Dry-run mode: build and display the transaction without broadcasting
+ if (process.env.DRY_RUN === '1') {
+ console.log("Dry run — transaction built but NOT broadcast.");
+ console.log(`Post-condition: sender can send at most ${maxTransferForTip(amount)} uSTX`);
+ console.log(`PostConditionMode: Deny`);
+ console.log(`Transaction size: ${transaction.serialize().byteLength} bytes`);
+ return;
+ }
+
const response = await broadcastTransaction(transaction, network);
if (response.error) {
@@ -78,7 +145,14 @@ async function runTestTip() {
console.log(`Explorer Link: https://explorer.hiro.so/txid/0x${response.txid}?chain=mainnet`);
}
} catch (error) {
- console.error("Error creating/broadcasting transaction:", error);
+ // Sanitize the error output to ensure mnemonics and private keys
+ // are never leaked to logs or CI output.
+ let safeMessage = (error.message || String(error));
+ if (mnemonic) {
+ safeMessage = safeMessage.replace(new RegExp(mnemonic.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '[REDACTED]');
+ }
+ console.error("Error creating/broadcasting transaction:", safeMessage);
+ process.exit(1);
}
}