Security best practices

Build secure Stacks applications with proven patterns


Overview

Security in blockchain applications is paramount—smart contracts are immutable, transactions are irreversible, and vulnerabilities can lead to permanent loss of funds. This guide covers essential security practices for building robust Stacks applications.

Transaction security

Always validate and constrain transaction behavior:

async function secureTransaction() {
// 1. Validate inputs before transaction
const recipient = validateAddress(inputAddress);
const amount = validateAmount(inputAmount);
// 2. Check current state
const balance = await getBalance(senderAddress);
if (balance < amount + estimatedFee) {
throw new Error('Insufficient balance including fees');
}
// 3. Add comprehensive post-conditions
const postConditions = [
makeStandardSTXPostCondition(
senderAddress,
FungibleConditionCode.Equal,
amount
),
];
// 4. Use strict post-condition mode
const txOptions = {
recipient,
amount,
postConditions,
postConditionMode: PostConditionMode.Deny,
senderKey: privateKey,
network,
anchorMode: AnchorMode.Any,
};
// 5. Handle errors gracefully
try {
const tx = await makeSTXTokenTransfer(txOptions);
return await broadcastTransaction(tx, network);
} catch (error) {
console.error('Transaction failed safely:', error);
throw error;
}
}

Input validation

Never trust user input—validate everything:

import { validateStacksAddress } from '@stacks/transactions';
function validateTransactionInputs(params: any) {
// Validate addresses
if (!validateStacksAddress(params.recipient)) {
throw new Error('Invalid recipient address');
}
// Validate amounts
const amount = Number(params.amount);
if (isNaN(amount) || amount <= 0) {
throw new Error('Invalid amount');
}
// Check for integer overflow
if (amount > Number.MAX_SAFE_INTEGER) {
throw new Error('Amount too large');
}
// Validate contract names
if (params.contractName && !/^[a-zA-Z0-9-]+$/.test(params.contractName)) {
throw new Error('Invalid contract name');
}
// Validate function arguments
if (params.functionArgs) {
params.functionArgs.forEach((arg: any, index: number) => {
if (arg === null || arg === undefined) {
throw new Error(`Invalid argument at position ${index}`);
}
});
}
return {
recipient: params.recipient,
amount,
contractName: params.contractName,
functionArgs: params.functionArgs,
};
}

Private key management

Secure key handling is critical:

// Never do this!
const privateKey = 'ef234807...'; // ❌ Hardcoded key
// Better: Environment variables
const privateKey = process.env.STACKS_PRIVATE_KEY;
if (!privateKey) {
throw new Error('Private key not configured');
}
// Best: Secure key management service
import { SecretManagerServiceClient } from '@google-cloud/secret-manager';
async function getPrivateKey(): Promise<string> {
const client = new SecretManagerServiceClient();
const [version] = await client.accessSecretVersion({
name: 'projects/my-project/secrets/stacks-key/versions/latest',
});
return version.payload?.data?.toString() || '';
}
// For browser apps: Never store keys
// Always use wallet connections instead
function connectWallet() {
showConnect({
appDetails: {
name: 'My App',
icon: '/logo.png',
},
onFinish: () => {
// Wallet manages keys securely
},
userSession,
});
}

Contract interaction safety

Implement defensive patterns when calling contracts:

class SafeContractCaller {
constructor(
private network: StacksNetwork,
private contractAddress: string,
private contractName: string
) {}
async safeCall(
functionName: string,
functionArgs: ClarityValue[],
options: {
expectedReturnType?: ClarityType;
maxFee?: number;
postConditions?: PostCondition[];
} = {}
) {
// 1. Validate contract exists
await this.validateContract();
// 2. Check function exists and matches expected signature
await this.validateFunction(functionName, functionArgs);
// 3. Simulate call first (read-only)
const simulation = await callReadOnlyFunction({
network: this.network,
contractAddress: this.contractAddress,
contractName: this.contractName,
functionName,
functionArgs,
senderAddress: this.contractAddress,
});
// 4. Validate simulation result
if (options.expectedReturnType) {
this.validateReturnType(simulation, options.expectedReturnType);
}
// 5. Build transaction with constraints
const txOptions = {
contractAddress: this.contractAddress,
contractName: this.contractName,
functionName,
functionArgs,
postConditions: options.postConditions || [],
postConditionMode: PostConditionMode.Deny,
fee: options.maxFee || 1000,
network: this.network,
anchorMode: AnchorMode.Any,
};
return makeContractCall(txOptions);
}
private async validateContract() {
const response = await fetch(
`${this.network.coreApiUrl}/v2/contracts/interface/${this.contractAddress}/${this.contractName}`
);
if (!response.ok) {
throw new Error('Contract does not exist or is not accessible');
}
}
private async validateFunction(name: string, args: ClarityValue[]) {
// Implement function signature validation
}
private validateReturnType(value: ClarityValue, expectedType: ClarityType) {
if (value.type !== expectedType) {
throw new Error(`Unexpected return type: ${value.type}`);
}
}
}

Reentrancy protection

Prevent reentrancy attacks in your contracts and calls:

class ReentrancyGuard {
private locked = new Set<string>();
async protectedCall<T>(
key: string,
operation: () => Promise<T>
): Promise<T> {
if (this.locked.has(key)) {
throw new Error('Reentrancy detected');
}
this.locked.add(key);
try {
return await operation();
} finally {
this.locked.delete(key);
}
}
}
// Usage
const guard = new ReentrancyGuard();
async function swapTokens() {
return guard.protectedCall('swap', async () => {
// Perform swap operations
const tx1 = await approveTokens();
await waitForConfirmation(tx1);
const tx2 = await executeSwap();
await waitForConfirmation(tx2);
return tx2;
});
}

Network security

Protect against network-level attacks:

class SecureNetworkClient {
private readonly timeout = 30000; // 30 seconds
private readonly maxRetries = 3;
async secureRequest(url: string, options: RequestInit = {}) {
// 1. Validate URL
const validatedUrl = this.validateUrl(url);
// 2. Add security headers
const secureOptions = {
...options,
headers: {
...options.headers,
'X-Request-ID': crypto.randomUUID(),
'X-Timestamp': Date.now().toString(),
},
};
// 3. Implement retry with backoff
let lastError;
for (let i = 0; i < this.maxRetries; i++) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
const response = await fetch(validatedUrl, {
...secureOptions,
signal: controller.signal,
});
clearTimeout(timeoutId);
// 4. Validate response
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response;
} catch (error) {
lastError = error;
// Exponential backoff
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i)));
}
}
throw lastError;
}
private validateUrl(url: string): string {
const parsed = new URL(url);
// Only allow HTTPS in production
if (process.env.NODE_ENV === 'production' && parsed.protocol !== 'https:') {
throw new Error('Only HTTPS URLs allowed in production');
}
// Whitelist allowed domains
const allowedDomains = ['api.hiro.so', 'api.testnet.hiro.so'];
if (!allowedDomains.includes(parsed.hostname)) {
throw new Error('Domain not whitelisted');
}
return url;
}
}

Front-running protection

Protect against transaction front-running:

async function protectedSwap(
amountIn: number,
minAmountOut: number,
deadline: number
) {
// 1. Add commitment phase
const commitment = crypto.randomBytes(32);
const commitmentHash = crypto.createHash('sha256')
.update(commitment)
.digest('hex');
// 2. Submit commitment first
const commitTx = await makeContractCall({
contractAddress: dexContract,
contractName: 'dex-v1',
functionName: 'commit-swap',
functionArgs: [
bufferCV(Buffer.from(commitmentHash, 'hex')),
uintCV(deadline),
],
senderKey: privateKey,
network,
anchorMode: AnchorMode.Any,
});
await broadcastTransaction(commitTx, network);
await waitForConfirmation(commitTx.txid);
// 3. Reveal and execute
const revealTx = await makeContractCall({
contractAddress: dexContract,
contractName: 'dex-v1',
functionName: 'reveal-and-swap',
functionArgs: [
bufferCV(commitment),
uintCV(amountIn),
uintCV(minAmountOut),
],
postConditions: [
// Strict conditions prevent manipulation
makeStandardFungiblePostCondition(
userAddress,
FungibleConditionCode.Equal,
amountIn,
tokenInAsset
),
makeStandardFungiblePostCondition(
userAddress,
FungibleConditionCode.GreaterEqual,
-minAmountOut,
tokenOutAsset
),
],
postConditionMode: PostConditionMode.Deny,
senderKey: privateKey,
network,
anchorMode: AnchorMode.Any,
});
return broadcastTransaction(revealTx, network);
}

Error handling patterns

Implement comprehensive error handling:

class TransactionError extends Error {
constructor(
message: string,
public code: string,
public txId?: string,
public details?: any
) {
super(message);
this.name = 'TransactionError';
}
}
async function robustTransactionHandler(
operation: () => Promise<any>
) {
try {
return await operation();
} catch (error: any) {
// Categorize errors
if (error.message?.includes('Insufficient balance')) {
throw new TransactionError(
'Not enough funds to complete transaction',
'INSUFFICIENT_FUNDS',
error.txId
);
}
if (error.message?.includes('ContractNotFound')) {
throw new TransactionError(
'Smart contract not found',
'CONTRACT_NOT_FOUND'
);
}
if (error.message?.includes('NetworkError')) {
throw new TransactionError(
'Network connection failed',
'NETWORK_ERROR'
);
}
if (error.message?.includes('PostConditionFailed')) {
throw new TransactionError(
'Transaction safety check failed',
'POST_CONDITION_FAILED',
error.txId,
error.postConditions
);
}
// Unknown error
throw new TransactionError(
'Transaction failed',
'UNKNOWN_ERROR',
error.txId,
error
);
}
}

Audit checklist

Security checklist for your application:

interface SecurityAudit {
inputValidation: boolean;
postConditions: boolean;
errorHandling: boolean;
keyManagement: boolean;
networkSecurity: boolean;
reentrancyProtection: boolean;
frontRunningProtection: boolean;
}
function auditTransaction(txOptions: any): SecurityAudit {
return {
inputValidation: !!(
txOptions.recipient &&
validateStacksAddress(txOptions.recipient)
),
postConditions: !!(
txOptions.postConditions &&
txOptions.postConditions.length > 0
),
errorHandling: !!(
txOptions.onError ||
txOptions.catch
),
keyManagement: !!(
!txOptions.senderKey ||
txOptions.senderKey.startsWith('$')
),
networkSecurity: !!(
txOptions.network &&
txOptions.network.coreApiUrl.startsWith('https')
),
reentrancyProtection: !!(
txOptions.nonce !== undefined
),
frontRunningProtection: !!(
txOptions.postConditionMode === PostConditionMode.Deny
),
};
}

Security monitoring

Monitor your application for suspicious activity:

class SecurityMonitor {
private alerts: Alert[] = [];
async monitorTransaction(txId: string) {
const tx = await this.fetchTransaction(txId);
// Check for unusual patterns
if (this.isHighValue(tx)) {
this.alert('HIGH_VALUE_TX', { txId, amount: tx.amount });
}
if (this.isRapidSequence(tx)) {
this.alert('RAPID_TX_SEQUENCE', { txId, sender: tx.sender });
}
if (this.isUnknownContract(tx)) {
this.alert('UNKNOWN_CONTRACT', {
txId,
contract: tx.contractAddress
});
}
}
private alert(type: string, data: any) {
const alert = {
type,
timestamp: Date.now(),
data,
};
this.alerts.push(alert);
console.warn('Security Alert:', alert);
// Send to monitoring service
this.sendToMonitoring(alert);
}
}

Best practices summary

  1. 1Always use post-conditions - They're your safety net
  2. 2Validate all inputs - Never trust user data
  3. 3Handle errors gracefully - Expect and plan for failures
  4. 4Secure key management - Never expose private keys
  5. 5Monitor transactions - Detect issues early
  6. 6Test security scenarios - Include attack vectors in tests
  7. 7Keep dependencies updated - Security patches matter

Next steps