Cranking
Running crank infrastructure for the Seesaw protocol.
Overview
Cranks are permissionless operators that execute protocol lifecycle transitions. Anyone can run a crank and earn rewards for keeping the protocol operational.
Market Lifecycle
Epoch Timeline
Epoch N (market_id = M)
├─────────────────────────────────────────────────────────────────┤
│ duration_seconds (configurable) │
├─────────────────────────────────────────────────────────────────┤
│ │
t_start = M × D t_end = (M+1) × D
│ │
▼ ▼
CREATE ──► SNAPSHOT_START ──► TRADING ──► SNAPSHOT_END ──► RESOLVE
Crank Operations
| Operation | Trigger | Reward |
|---|---|---|
create_market | t >= t_start - 300 | 0.001 SOL |
snapshot_start | t >= t_start AND valid Pyth price | 0.001 SOL |
snapshot_end | t >= t_end AND valid Pyth price | 0.001 SOL |
resolve_market | Both snapshots captured | 0.001 SOL |
settle_position | Market resolved, per user | 0.001 SOL |
close_market | All positions settled | 0.001 SOL |
Setup Guide
Prerequisites
# Node.js 18+
node --version # v18.x.x
# Solana CLI (must match CI version - see .github/workflows/test.yml)
solana --version # v3.1.8
# Funded wallet (keypair)
solana balance /path/to/keypair.json
Installation
# Install crank package
pnpm add -g @seesaw/crank
# Or clone and build
git clone https://github.com/seesaw-markets/crank
cd crank
pnpm install
pnpm run build
Configuration
Create crank.config.json:
{
"rpcUrl": "https://api.mainnet-beta.solana.com",
"wsUrl": "wss://api.mainnet-beta.solana.com",
"keypairPath": "/path/to/keypair.json",
"programId": "SEEsawgSrxRsgtKRbaThZFEKrVqX3Y64hDipTWyi8F8",
"pythProgramId": "FsJ3A3u2vn5cTVofAjvy6y5kwABJAqYWpe4975bi2epH",
"pollIntervalMs": 1000,
"maxRetries": 3,
"priorityFeeLamports": 1000
}
Running
# Start crank service
seesaw-crank start --config crank.config.json
# With environment variables
SEESAW_RPC_URL=https://api.mainnet-beta.solana.com \
SEESAW_KEYPAIR=/path/to/keypair.json \
seesaw-crank start
Crank Loop
Main Loop
Market Window Calculation
function getActiveMarketIds(currentTime: number): bigint[] {
const durationSeconds = 900; // default; iterate over all active durations
const currentEpoch = BigInt(Math.floor(currentTime / durationSeconds));
const ids: bigint[] = [];
// Look back up to 24 hours (96 epochs) for unsettled markets
for (let offset = 0; offset <= 96; offset++) {
if (currentEpoch >= BigInt(offset)) {
ids.push(currentEpoch - BigInt(offset));
}
}
// Look ahead 1 epoch for pre-creation
ids.push(currentEpoch + 1n);
return ids;
}
Decision Logic
async function processMarket(marketId: bigint, currentTime: number) {
const tStart = Number(marketId) * 900;
const tEnd = tStart + 900;
const market = await fetchMarket(marketId);
if (!market) {
// Market doesn't exist - try to create
if (currentTime >= tStart - 300) {
await createMarket(marketId);
}
return;
}
// Process based on state
if (market.startPrice === 0 && currentTime >= tStart) {
await snapshotStart(market);
} else if (market.endPrice === 0 && currentTime >= tEnd) {
await snapshotEnd(market);
} else if (market.outcome === 0 && market.startPrice !== 0 && market.endPrice !== 0) {
await resolveMarket(market);
} else if (market.outcome !== 0) {
await settleAndClose(market);
}
}
Idempotency
All crank operations are idempotent - safe to execute multiple times.
Idempotency Rules
| Operation | Duplicate Behavior |
|---|---|
create_market | Returns success (no-op) |
snapshot_start | Returns success (no-op) |
snapshot_end | Returns success (no-op) |
resolve_market | Returns success (no-op) |
settle_position | Returns success (no-op) |
close_market | Returns success (no-op) |
Reward Protection
Rewards are only paid on the first successful execution:
if already_executed {
// No reward for no-op
return Ok(());
}
// Transfer reward to cranker
transfer_reward(cranker, config.crank_reward_lamports)?;
Failure Recovery
Failure Modes
| Failure | Impact | Recovery |
|---|---|---|
| Crank offline | Operations delayed | Other cranks take over |
| Pyth feed stale | Snapshot fails | Retry when feed updates |
| Insufficient SOL | Transaction fails | Top up wallet |
| Compute exceeded | Transaction fails | Reduce batch size |
| Network congestion | Transaction dropped | Retry with higher priority |
Retry Logic
async function executeWithRetry(
instruction: () => TransactionInstruction,
maxRetries: number = 3
): Promise<string> {
let lastError: Error | null = null;
for (let i = 0; i < maxRetries; i++) {
try {
return await sendTransaction(instruction());
} catch (e) {
lastError = e as Error;
if (!isRetryable(e)) {
throw e;
}
// Exponential backoff
await sleep(1000 * Math.pow(2, i));
}
}
throw lastError;
}
function isRetryable(error: Error): boolean {
const message = error.message.toLowerCase();
return (
message.includes('blockhash') ||
message.includes('network') ||
message.includes('timeout') ||
message.includes('rate limit')
);
}
Stuck Market Recovery
function checkMarketHealth(market: Market, currentTime: number): MarketHealth {
const age = currentTime - market.tStart;
// Market should resolve within 30 minutes normally
if (age > 1800 && market.outcome === 0) {
if (market.startPrice === 0) {
return MarketHealth.StuckAtCreated;
}
if (market.endPrice === 0) {
return MarketHealth.StuckAtTrading;
}
return MarketHealth.StuckAtSettling;
}
// Market should close within 24 hours
if (age > 86400 && !market.isClosed) {
return MarketHealth.StuckAtResolved;
}
return MarketHealth.Healthy;
}
Advanced Configuration
Priority Fees
During network congestion, increase priority fees:
const priorityFee = computeUnits * microLamportsPerCU;
const modifyComputeUnits = ComputeBudgetProgram.setComputeUnitLimit({
units: 200_000,
});
const addPriorityFee = ComputeBudgetProgram.setComputeUnitPrice({
microLamports: 1_000, // 0.001 lamports per CU
});
Multi-RPC Setup
For reliability, use multiple RPC providers:
const rpcEndpoints = [
'https://api.mainnet-beta.solana.com',
'https://solana-mainnet.rpc.extrnode.com',
'https://rpc.ankr.com/solana',
];
async function sendWithFallback(tx: Transaction): Promise<string> {
for (const endpoint of rpcEndpoints) {
try {
const connection = new Connection(endpoint);
return await connection.sendTransaction(tx);
} catch (e) {
console.warn(`Failed on ${endpoint}, trying next...`);
}
}
throw new Error('All RPC endpoints failed');
}
Batching Settlements
Batch multiple settlements for efficiency:
async function batchSettle(market: Market, positions: Position[]) {
const BATCH_SIZE = 5;
for (let i = 0; i < positions.length; i += BATCH_SIZE) {
const batch = positions.slice(i, i + BATCH_SIZE);
const tx = new Transaction();
for (const pos of batch) {
tx.add(createSettleInstruction(market, pos.owner));
}
await sendTransaction(tx);
}
}
TukTuk Integration
For production deployments, integrate with TukTuk for managed infrastructure:
import { TukTukClient } from '@helium/tuktuk';
async function registerTasks(marketId: bigint) {
const tStart = Number(marketId) * 900;
const tEnd = tStart + 900;
// Schedule create_market
await tuktuk.scheduleTask({
instruction: createMarketInstruction(marketId),
executeAt: tStart - 300,
reward: 1_000_000,
});
// Schedule snapshot_start
await tuktuk.scheduleTask({
instruction: snapshotStartInstruction(marketId),
executeAt: tStart,
reward: 1_000_000,
});
// Schedule snapshot_end
await tuktuk.scheduleTask({
instruction: snapshotEndInstruction(marketId),
executeAt: tEnd,
reward: 1_000_000,
});
}
Operational Runbook
Starting a New Crank
- Generate keypair:
solana-keygen new -o crank.json - Fund with SOL:
solana transfer crank.json 1 - Configure RPC endpoint
- Start crank service
- Monitor logs for first successful operations
Handling Stuck Markets
- Identify stuck market via monitoring
- Check oracle feed status
- If oracle healthy: retry crank operations manually
- If oracle stale: wait for feed recovery
- If past expiry: execute
force_close
Treasury Refill
# Check treasury balance
solana balance <treasury_pubkey>
# Transfer from protocol authority
solana transfer <treasury_pubkey> 50 --from authority.json
Next Steps
- Set up Monitoring
- Review API Reference