Skip to main content

Overview

Wallet rebalancing automatically redistributes your ecash balance across multiple mints according to target percentages. This helps:
  • Reduce custodial risk - Spread funds across trusted mints
  • Maintain liquidity - Ensure each mint has usable balance
  • Optimize fees - Consolidate fragmented proofs

Balance Distribution Store

The mintDistributionStore.ts manages target allocations per currency unit.

Data Structure

interface MintDistributionState {
  // Map of unit -> { mintUrl -> distributionBp }
  distributions: Record<string, Record<string, number>>;
}

// 10,000 basis points = 100%
const TOTAL_BASIS_POINTS = 10_000;

Usage Example

import { useMintDistributionStore, bpToPercent } from 'stores/mintDistributionStore';

function DistributionEditor({ unit }: { unit: string }) {
  const distribution = useMintDistributionStore(state => 
    state.getDistribution(unit)
  );
  const setMintDistribution = useMintDistributionStore(state => 
    state.setMintDistribution
  );
  
  // distribution: { 'https://mint-a.com': 5000, 'https://mint-b.com': 5000 }
  // Mint A: 50%, Mint B: 50%
  
  return (
    <>
      {Object.entries(distribution).map(([mintUrl, bp]) => (
        <Slider
          key={mintUrl}
          value={bp}
          max={TOTAL_BASIS_POINTS}
          onChange={(newBp) => {
            setMintDistribution(unit, mintUrl, newBp, allMintUrls);
          }}
        />
        <Text>{bpToPercent(bp)}%</Text>
      ))}
    </>
  );
}

Automatic Redistribution

When you adjust one mint’s allocation, others are automatically rebalanced:
// User sets Mint A from 33% to 60%
setMintDistribution('sat', 'https://mint-a.com', 6000, allMintUrls);

// System automatically reduces Mint B and Mint C proportionally
// Before: A=33%, B=33%, C=34%
// After:  A=60%, B=20%, C=20%

Special Cases

100% → Less than 100%: When reducing a mint from 100%, all other mints are activated:
// Before: A=100%, B=0%, C=0%
setMintDistribution('sat', 'https://mint-a.com', 7000, allMintUrls);
// After:  A=70%, B=15%, C=15%
Active-only redistribution: Normally, only mints with bp > 0 participate in redistribution:
// Before: A=50%, B=50%, C=0%
setMintDistribution('sat', 'https://mint-a.com', 7000, allMintUrls);
// After:  A=70%, B=30%, C=0%  (C stays at 0)

Quick Actions

const store = useMintDistributionStore.getState();

// Equalize all active mints
store.equalizeMints('sat', allMintUrls);
// Result: Each active mint gets equal share

// Set mint to 100%
store.maxMint('sat', mintUrl, allMintUrls);
// Result: Selected mint = 100%, all others = 0%

// Set mint to 0% and redistribute
store.minMint('sat', mintUrl, allMintUrls);
// Result: Selected mint = 0%, freed bp distributed to others

Rebalance Planning

The rebalance planner (components/blocks/rebalance/rebalancePlanner.ts) calculates transfer steps:

Algorithm

interface RebalancePlan {
  steps: TransferStep[];
  currentBalances: Record<string, number>;
  targetBalances: Record<string, number>;
}

interface TransferStep {
  id: string;
  fromMintUrl: string;
  toMintUrl: string;
  amount: number;
}

function computeRebalancePlan(
  mintBalances: { mintUrl: string; balance: number }[],
  distribution: Record<string, number>,  // basis points
  minTransferThreshold: number = 10
): RebalancePlan {
  const totalBalance = mintBalances.reduce((sum, m) => sum + m.balance, 0);
  
  // Calculate target balances
  const targetBalances: Record<string, number> = {};
  for (const { mintUrl } of mintBalances) {
    const bp = distribution[mintUrl] || 0;
    targetBalances[mintUrl] = Math.floor((bp / 10_000) * totalBalance);
  }
  
  // Calculate deltas
  const deltas = mintBalances.map(({ mintUrl, balance }) => ({
    mintUrl,
    delta: balance - targetBalances[mintUrl]
  }));
  
  // Separate surplus (positive) and deficit (negative)
  const surplus = deltas.filter(d => d.delta > minTransferThreshold);
  const deficit = deltas.filter(d => d.delta < -minTransferThreshold);
  
  // Generate transfer steps
  const steps: TransferStep[] = [];
  let stepCounter = 0;
  
  for (const from of surplus) {
    let remaining = from.delta;
    
    for (const to of deficit) {
      if (remaining <= minTransferThreshold) break;
      
      const transferAmount = Math.min(remaining, Math.abs(to.delta));
      if (transferAmount < minTransferThreshold) continue;
      
      steps.push({
        id: `step-${stepCounter++}`,
        fromMintUrl: from.mintUrl,
        toMintUrl: to.mintUrl,
        amount: transferAmount
      });
      
      remaining -= transferAmount;
      to.delta += transferAmount;
    }
  }
  
  return { steps, currentBalances, targetBalances };
}

Already Balanced Check

function isAlreadyBalanced(
  currentBalances: Record<string, number>,
  targetBalances: Record<string, number>,
  threshold: number = 10
): boolean {
  for (const [mintUrl, target] of Object.entries(targetBalances)) {
    const current = currentBalances[mintUrl] || 0;
    if (Math.abs(current - target) > threshold) {
      return false;
    }
  }
  return true;
}

Rebalance Execution

The rebalance plan screen (app/(mint-flow)/rebalancePlan.tsx) executes transfer steps sequentially:

Step States

type StepStatus = 
  | 'pending'
  | 'creatingInvoice'
  | 'invoiceReady'
  | 'melting'
  | 'verifying'
  | 'routing'      // Middleman routing in progress
  | 'done'
  | 'failed'
  | 'skipped';     // Source balance too low

interface StepState {
  status: StepStatus;
  invoice?: string;
  operationId?: string;
  errorMessage?: string;
  routingDetail?: string;
  routeSuggestion?: RouteSuggestion;
}

Execution Flow

async function executeStep(step: TransferStep): Promise<boolean> {
  const { fromMintUrl, toMintUrl, amount } = step;
  
  try {
    // 1. Check source balance
    const balances = await manager.wallet.getBalances();
    const sourceBalance = balances[fromMintUrl] || 0;
    
    if (sourceBalance < minTransferThreshold + feeHeadroom) {
      updateStepState(step.id, { status: 'skipped' });
      return true; // Not a failure
    }
    
    // 2. Create invoice on destination
    updateStepState(step.id, { status: 'creatingInvoice' });
    const mintQuote = await requestLightningInvoice(toMintUrl, amount);
    const invoice = mintQuote.request;
    updateStepState(step.id, { status: 'invoiceReady', invoice });
    
    // 3. Prepare melt on source (get exact fees)
    const prepared = await manager.quotes.prepareMeltBolt11(fromMintUrl, invoice);
    updateStepState(step.id, { operationId: prepared.id });
    
    // 4. Execute melt
    updateStepState(step.id, { status: 'melting' });
    await manager.quotes.executeMelt(prepared.id);
    
    // 5. Verify destination balance increased
    updateStepState(step.id, { status: 'verifying' });
    await waitForBalanceIncrease(toMintUrl, amount, 15000);
    
    // 6. Done
    updateStepState(step.id, { status: 'done' });
    return true;
    
  } catch (err) {
    // Handle no_route with middleman routing
    if (err.message.includes('no_route')) {
      return await handleMiddlemanRouting(step);
    }
    
    updateStepState(step.id, { 
      status: 'failed', 
      errorMessage: err.message 
    });
    return false;
  }
}

Middleman Routing

When direct routing fails, automatically find intermediary path:
async function handleMiddlemanRouting(step: TransferStep) {
  updateStepState(step.id, { 
    status: 'routing',
    routeSuggestion: { status: 'searching' }
  });
  
  // Build candidate routes
  const suggestion = await computeRouteSuggestion(
    step.fromMintUrl, 
    step.toMintUrl
  );
  
  if (!suggestion?.path) {
    throw new Error('No route found');
  }
  
  // suggestion.path: ['mint-a', 'mint-m', 'mint-b']
  updateStepState(step.id, {
    routeSuggestion: { 
      status: 'found', 
      path: suggestion.path,
      pathNames: suggestion.pathNames
    }
  });
  
  // Execute chain of hops
  for (let i = 0; i < suggestion.path.length - 1; i++) {
    const hopFrom = suggestion.path[i];
    const hopTo = suggestion.path[i + 1];
    
    // Execute hop...
  }
  
  return true;
}

Execution Lock

Prevent concurrent melt operations:
const executionLockRef = useRef(false);

async function executeStep(step: TransferStep) {
  // Wait for lock
  while (executionLockRef.current) {
    await new Promise(resolve => setTimeout(resolve, 100));
  }
  
  executionLockRef.current = true;
  
  try {
    // Execute step...
  } finally {
    executionLockRef.current = false;
  }
}

UI Components

Distribution Editor

The distribution screen (app/(mint-flow)/distribution.tsx) shows:
  • Sliders for each mint’s allocation
  • Quick actions (Equalize, Max, Min)
  • Preview of target balances
  • Launch rebalance button

Rebalance Plan Screen

The plan screen (app/(mint-flow)/rebalancePlan.tsx) displays:
import { RebalanceStepRow } from 'components/blocks/rebalance';
import { RebalanceChainCard } from 'components/blocks/rebalance';

function RebalancePlanScreen() {
  const plan = computeRebalancePlan(mintBalances, distribution, minTransferThreshold);
  const [stepStates, setStepStates] = useState<Record<string, StepState>>({});
  
  return (
    <>
      {/* Progress indicator */}
      <ProgressBar 
        value={completedSteps} 
        max={plan.steps.length} 
      />
      
      {/* Step list */}
      {groupedSteps.map((group) => (
        group.type === 'chain' ? (
          <RebalanceChainCard
            key={group.id}
            chain={group}
            stepStates={stepStates}
          />
        ) : (
          <RebalanceStepRow
            key={group.step.id}
            step={group.step}
            stepState={stepStates[group.step.id]}
          />
        )
      ))}
      
      {/* Action buttons */}
      <ButtonHandler
        buttons={[
          {
            text: 'Run Rebalance',
            onPress: runRebalance,
            disabled: isRunning
          },
          {
            text: 'Cancel',
            onPress: () => router.back()
          }
        ]}
      />
    </>
  );
}

Step Grouping

Chain steps (middleman routes) are grouped visually:
import { groupStepsForDisplay } from 'components/blocks/rebalance/groupSteps';

const groupedSteps = groupStepsForDisplay(plan.steps);

// Returns:
// [
//   { type: 'single', step: { ... } },
//   { type: 'chain', id: 'chain-1', steps: [...] },
//   { type: 'single', step: { ... } }
// ]

Advanced: Routing Graph

The routing system uses BFS to find intermediary paths:

Graph Construction

import { buildSwapGraph } from 'components/blocks/rebalance/routing';

interface SwapEdge {
  from: string;
  to: string;
  weight: number;  // Lower = better (derived from success rate)
}

type SwapGraph = Record<string, SwapEdge[]>;

// Build from auditor data
const graph = buildSwapGraph(auditResponses);

// Add local history
import { addLocalHistoryEdges } from 'components/blocks/rebalance/routing';

const swapGroups = Object.values(useSwapTransactionsStore.getState().groups);
addLocalHistoryEdges(graph, swapGroups);

Path Selection

import { pickIntermediaryPath } from 'components/blocks/rebalance/routing';

const result = pickIntermediaryPath({
  from: 'https://mint-a.com',
  to: 'https://mint-b.com',
  graph,
  settings: {
    maxHops: 3,
    preferTrusted: true
  },
  trustedMintUrls: new Set(trustedMints)
});

// result.path: ['mint-a', 'mint-m', 'mint-b']
// result.totalWeight: 0.05

Local History Candidates

import { getLocalCandidatesForDestination } from 'components/blocks/rebalance/routing';

// Find mints that successfully swapped TO destination in the past
const candidates = getLocalCandidatesForDestination(
  swapGroups,
  toMintUrl,
  fromMintUrl  // Exclude source
);

// Returns: ['https://mint-m.com', 'https://mint-n.com']

Best Practices

Avoid setting a single mint to >50% allocation. Distribute across 3-5 mints for redundancy.
Set minTransferThreshold to 10-20 sats to avoid wasting fees on micro-transfers:
const minTransferThreshold = useSettingsStore(state => state.minTransferThreshold);
Run rebalance weekly or when distributions drift >10% from targets.
If steps consistently fail for a mint, consider removing it from your trusted list.

Debugging

Rebalance operations log to console with structured debug events:
const appendDebug = (entry: Record<string, unknown>) => {
  console.log('[REBALANCE]', JSON.stringify({
    ...entry,
    _ts: new Date().toISOString()
  }));
};

appendDebug({
  event: 'step_start',
  stepId: step.id,
  fromMintUrl,
  toMintUrl,
  amount
});
Key events:
  • step_start - Transfer step begins
  • balances_fetched - Current balances retrieved
  • fee_headroom_computed - Dynamic fees calculated
  • melt_prepared - Quote received from mint
  • no_route_auto_routing - Routing failure, trying middleman
  • chain_hop_start - Middleman hop begins
  • step_done - Transfer complete

Mint Swapping

Learn about the underlying swap mechanism

Know Your Mint

Use trust signals to set distributions

Build docs developers (and LLMs) love