The PostageStamp contract manages postage stamp batches that users purchase to store chunks on the Swarm network. It implements a sophisticated price normalization system that tracks storage costs over time.
Users buy postage stamps (batches) upfront to pay for future data storage. The contract:
- Tracks batches with their storage capacity and balance
- Manages batch expiration based on price accumulation
- Accumulates expired batch funds into a pot for redistribution
- Provides role-based access for price updates and withdrawals
The contract uses a "normalized balance" system to track the actual storage cost accumulated over time:
normalizedBalance = totalOutPayment + initialBalancePerChunktotalOutPayment: Accumulated per-chunk cost since contract deployment- New batches are credited with current
totalOutPaymentas if they existed since inception - When price changes,
totalOutPaymentis updated based on blocks elapsed
struct Batch {
address owner; // Owner of the batch
uint8 depth; // Total depth (2^depth = max chunks)
uint8 bucketDepth; // Bucket depth for addressing
bool immutableFlag; // Whether batch can be modified
uint256 normalisedBalance; // Normalized balance per chunk
uint256 lastUpdatedBlockNumber; // Last update timestamp
}Batches are stored in an ordered tree structure sorted by normalized balance. This enables:
- Efficient expiration checking (start from lowest balance)
- O(log n) operations for insert/remove
- Predictable gas costs for batch lookups
Creates a new postage stamp batch.
Parameters:
_owner: Address that will own the batch_initialBalancePerChunk: Balance to add per chunk_depth: Total batch depth (capacity = 2^depth)_bucketDepth: Bucket depth for chunk addressing_nonce: Random nonce for batch ID generation_immutable: Whether batch can be topped up later
Requirements:
_initialBalancePerChunk >= minimumInitialBalancePerChunk()(24h minimum validity)_bucketDepth >= minimumBucketDepth && _bucketDepth < _depth- Sufficient ERC20 token approval
Returns: bytes32 batchId
Batch ID Generation:
batchId = keccak256(abi.encode(msg.sender, _nonce))Adds more balance to an existing batch.
Parameters:
_batchId: ID of the batch to top up_topupAmountPerChunk: Additional balance per chunk
Requirements:
- Batch must exist and not be expired
- Batch depth must be > minimumBucketDepth
- New total balance must meet minimum validity
Effects:
- Transfers tokens from caller
- Updates normalized balance
- Reinserts batch into tree with new balance
Increases the depth (capacity) of a batch.
Parameters:
_batchId: ID of the batch_newDepth: New depth value (must be larger than current)
Requirements:
- Caller must be batch owner
_newDepth > batch.depth- Batch must not be expired
- New balance per chunk must meet minimum validity
Effects:
- Doubles capacity for each additional depth level
- Redistributes existing balance across new capacity
Manually creates a batch (for migrations).
Parameters: Same as createBatch(), plus _batchId (the specific ID to use)
Requirements:
- Only
DEFAULT_ADMIN_ROLEcan call - Used during contract migrations to preserve batch data
Bulk import batches (for large migrations).
Parameters:
bulkBatches: Array of ImportBatch structures
Requirements:
- Only
DEFAULT_ADMIN_ROLEcan call - Processes 60-90 batches optimally
- Emits
CopyBatchFailedevent if batch import fails
Updates the price per chunk.
Parameters:
_price: New price value
Requirements:
- Only
PRICE_ORACLE_ROLEcan call
Logic:
if (lastPrice != 0) {
// Account for price accumulation since last update
totalOutPayment = currentTotalOutPayment()
}
lastPrice = _price
lastUpdatedBlock = block.numberReclaims expired batches (called automatically or manually).
Parameters:
limit: Maximum number of batches to expire (prevents gas limit issues)
Logic:
- Iterate batches in ascending balance order
- If
remainingBalance(batch) <= 0:- Remove chunks from
validChunkCount - Add to pot:
pot += batchSize * (normalizedBalance - lastExpiryBalance) - Delete batch
- Remove chunks from
- For remaining valid batches:
pot += validChunkCount * (currentTotalOutPayment - lastExpiryBalance)
- Update
lastExpiryBalance
Withdraws the accumulated pot to a beneficiary.
Parameters:
beneficiary: Address to receive the funds
Requirements:
- Only
REDISTRIBUTOR_ROLEcan call
Returns: Transfers current pot amount and resets it to 0
Returns the unused balance per chunk for a batch.
Returns the total per-chunk cost since contract deployment.
Returns the current pot amount (also calls expireLimited).
Public variable representing total chunks available from all active batches.
Returns minimum balance for 24h validity: minimumValidityBlocks * lastPrice
event BatchCreated(
bytes32 indexed batchId,
uint256 totalAmount,
uint256 normalisedBalance,
address owner,
uint8 depth,
uint8 bucketDepth,
bool immutableFlag
);
event BatchTopUp(
bytes32 indexed batchId,
uint256 topupAmount,
uint256 normalisedBalance
);
event BatchDepthIncrease(
bytes32 indexed batchId,
uint8 newDepth,
uint256 normalisedBalance
);
event PriceUpdate(uint256 price);
event PotWithdrawn(address recipient, uint256 totalAmount);- DEFAULT_ADMIN_ROLE: Full admin access, can grant/revoke other roles
- PRICE_ORACLE_ROLE: Can update prices (typically PriceOracle contract)
- REDISTRIBUTOR_ROLE: Can withdraw pot (typically Redistribution contract)
- PAUSER_ROLE: Can pause/unpause the contract
constructor(address _bzzToken, uint8 _minimumBucketDepth)_bzzToken: ERC20 token address for payments_minimumBucketDepth: Minimum bucket depth (typically 16)
The contract implements Pausable from OpenZeppelin:
- Pauses all user operations (createBatch, topUp, increaseDepth)
- Admin operations (setPrice, copyBatch) can still proceed
- Can be made immutable by renouncing Pauser and Admin roles
- Batch expiration is bounded (
expireLimited()) to prevent gas limit issues - Tree operations are O(log n)
- Bulk imports optimize gas usage (60-90 batches per transaction)
// User approves tokens
ERC20(bzzToken).approve(postageStamp, amount);
// Create batch
bytes32 batchId = PostageStamp(postageStamp).createBatch(
owner,
1000000000000000, // 0.001 tokens per chunk
20, // depth = 2^20 = 1,048,576 chunks
16, // bucketDepth
keccak256("nonce"), // unique nonce
false // mutable
);PostageStamp(postageStamp).topUp(
batchId,
500000000000000 // add 0.0005 tokens per chunk
);uint256 remaining = PostageStamp(postageStamp).remainingBalance(batchId);
if (remaining > 0) {
// Batch is still valid
}- PriceOracle: Sets price via PRICE_ORACLE_ROLE
- Redistribution: Withdraws pot via REDISTRIBUTOR_ROLE
- Token: ERC20 token used for payments
- Batch IDs are derived from transaction sender and nonce to prevent collisions
- Minimum balance enforces 24h minimum batch validity
- Normalized balance system prevents price manipulation attacks
- Expiration process is atomic and gas-bounded
- Admin functions protected by role-based access control
error ZeroAddress(); // Owner cannot be zero
error InvalidDepth(); // Invalid depth parameters
error BatchExists(); // Batch ID already exists
error InsufficientBalance(); // Below minimum balance requirement
error BatchExpired(); // Batch has expired
error BatchTooSmall(); // Depth too small for top-up
error NotBatchOwner(); // Caller is not batch owner
error PriceOracleOnly(); // Only price oracle can set price
error InsufficienChunkCount(); // Invalid chunk count
error OnlyRedistributor(); // Only redistributor can withdraw
error OnlyPauser(); // Only pauser can pause/unpause