Skip to main content
Fund lineage tracking enables you to trace the source and flow of money through your ledger system. This is essential for regulatory compliance, anti-money laundering (AML) requirements, and forensic accounting.

Overview

Blnk’s lineage system tracks fund provenance by creating shadow balances that maintain separate accounting for each fund source (provider). When money moves between balances, the lineage follows, ensuring complete traceability. Key Concepts:
  • Provider: The source/origin of funds (e.g., “stripe”, “bank_transfer”, “loan”)
  • Shadow Balance: Hidden balance tracking a specific provider’s funds
  • Aggregate Balance: Sum of all shadow balances for an identity
  • Lineage Mapping: Association between main balance, provider, and shadow balances

Architecture

The lineage system (implemented in /lineage.go) uses a shadow balance architecture:
Main Balance (tracks lineage)
    ├── Shadow Balance: Provider A
    ├── Shadow Balance: Provider B
    └── Aggregate Balance: Sum of all providers
Example:
User Alice's USD Balance
    ├── @stripe_alice_xyz123_lineage (shadow)
    ├── @bank_alice_xyz123_lineage (shadow)  
    └── @alice_xyz123_lineage (aggregate)

Shadow Balance Naming

Shadow balances use indicator-based naming (lineage.go:288-308):
func (l *Blnk) getOrCreateLineageBalances(ctx context.Context, identityID, provider, currency string) (*model.Balance, *model.Balance, error) {
    identity, err := l.datasource.GetIdentityByID(identityID)
    if err != nil {
        return nil, nil, fmt.Errorf("failed to get identity %s: %w", identityID, err)
    }
    
    identifier := l.getIdentityIdentifier(identity)
    shadowBalanceIndicator := fmt.Sprintf("@%s_%s_lineage", provider, identifier)
    aggregateBalanceIndicator := fmt.Sprintf("@%s_lineage", identifier)
    
    shadowBalance, err := l.getOrCreateBalanceByIndicator(ctx, shadowBalanceIndicator, currency)
    aggregateBalance, err := l.getOrCreateBalanceByIndicator(ctx, aggregateBalanceIndicator, currency)
    
    return shadowBalance, aggregateBalance, nil
}
Naming Pattern:
  • Shadow: @{provider}_{firstname}_{lastname}_{id_prefix}_lineage
  • Aggregate: @{firstname}_{lastname}_{id_prefix}_lineage

Enabling Lineage Tracking

On Balance Creation

Enable lineage tracking when creating a balance:
POST /balances
Content-Type: application/json

{
  "ledger_id": "ldg_abc123",
  "currency": "USD",
  "identity_id": "idt_alice_xyz",
  "track_fund_lineage": true,
  "allocation_strategy": "FIFO"
}
Parameters:
  • track_fund_lineage: Enable lineage tracking (default: false)
  • allocation_strategy: How to allocate funds on debit (FIFO, LIFO, PROPORTIONAL)
  • identity_id: Required for lineage tracking (used to generate shadow balance names)
Response:
{
  "balance_id": "bal_alice_usd",
  "ledger_id": "ldg_abc123",
  "currency": "USD",
  "identity_id": "idt_alice_xyz",
  "track_fund_lineage": true,
  "allocation_strategy": "FIFO",
  "balance": 0,
  "created_at": "2024-01-15T10:30:00Z"
}

Recording Transactions with Lineage

Credit (Incoming Funds)

Specify the fund provider when crediting a balance:
POST /transactions
Content-Type: application/json

{
  "source": "@world",
  "destination": "bal_alice_usd",
  "amount": 1000,
  "currency": "USD",
  "reference": "stripe-payment-001",
  "meta_data": {
    "BLNK_LINEAGE_PROVIDER": "stripe"
  }
}
What Happens (lineage.go:236-274):
  1. Main transaction: @world → bal_alice_usd ($1000)
  2. Shadow transaction: @stripe_alice_xyz_lineage → @alice_xyz_lineage ($1000)
  3. Lineage mapping created linking provider to shadow balance
Lineage Processing:
// Source: lineage.go:236-274
func (l *Blnk) processLineageCredit(ctx context.Context, txn *model.Transaction, destBalance *model.Balance, provider string) error {
    identityID := destBalance.IdentityID
    if identityID == "" {
        return fmt.Errorf("destination balance %s has no identity_id for lineage tracking", destBalance.BalanceID)
    }
    
    // Get or create the shadow and aggregate balances
    shadowBalance, aggregateBalance, err := l.getOrCreateLineageBalances(ctx, identityID, provider, txn.Currency)
    if err != nil {
        return err
    }
    
    // Acquire locks on both shadow and aggregate balances
    locker, err := l.acquireLineageLocks(ctx, []string{shadowBalance.BalanceID, aggregateBalance.BalanceID})
    if err != nil {
        return err
    }
    defer l.releaseLock(ctx, locker)
    
    // Queue shadow transaction
    if err := l.queueShadowCreditTransaction(ctx, txn, destBalance, provider, shadowBalance, aggregateBalance, identityID); err != nil {
        return err
    }
    
    // Create lineage mapping
    if err := l.upsertCreditLineageMapping(ctx, destBalance, provider, shadowBalance, aggregateBalance, identityID); err != nil {
        logrus.Errorf("failed to create lineage mapping: %v", err)
    }
    
    return nil
}

Debit (Outgoing Funds)

When debiting a balance with lineage, funds are allocated from shadow balances:
POST /transactions
Content-Type: application/json

{
  "source": "bal_alice_usd",
  "destination": "bal_bob_usd",
  "amount": 600,
  "currency": "USD",
  "reference": "payment-to-bob-001"
}
What Happens (lineage.go:395-464):
  1. Main transaction: bal_alice_usd → bal_bob_usd ($600)
  2. Allocation (based on strategy):
    • If FIFO and Alice has 400fromStripe,400 from Stripe, 600 from Bank:
      • Release $400 from Stripe shadow
      • Release $200 from Bank shadow
  3. Shadow release transactions created
  4. If Bob also tracks lineage, receive transactions created
  5. Metadata updated with fund allocation breakdown
Debit Processing:
// Source: lineage.go:395-464
func (l *Blnk) processLineageDebit(ctx context.Context, txn *model.Transaction, sourceBalance, destinationBalance *model.Balance) error {
    // Get lineage mappings for the source balance
    mappings, err := l.datasource.GetLineageMappings(ctx, sourceBalance.BalanceID)
    if err != nil {
        return fmt.Errorf("failed to get lineage mappings: %w", err)
    }
    
    if len(mappings) == 0 {
        return nil
    }
    
    // Collect all balance IDs for locking
    lockKeys := make([]string, 0, len(mappings)+1)
    for _, m := range mappings {
        lockKeys = append(lockKeys, m.ShadowBalanceID)
    }
    lockKeys = append(lockKeys, mappings[0].AggregateBalanceID)
    
    // Acquire locks
    locker, err := l.acquireLineageLocks(ctx, lockKeys)
    if err != nil {
        return err
    }
    defer l.releaseLock(ctx, locker)
    
    // Get available sources
    sources, err := l.getLineageSources(ctx, mappings)
    if err != nil {
        return fmt.Errorf("failed to get lineage sources: %w", err)
    }
    
    // Calculate allocation based on strategy
    allocations := l.calculateAllocation(sources, txn.PreciseAmount, sourceBalance.AllocationStrategy)
    
    // Process allocations (create release transactions)
    l.processAllocations(ctx, txn, allocations, mappings, sourceBalance, destinationBalance, sourceAggBalance, destLineageBalances)
    
    // Update transaction metadata with fund allocation
    l.updateFundAllocationMetadata(ctx, txn, allocations, mappings)
    
    return nil
}

Allocation Strategies

When debiting a balance, funds are allocated from shadow balances using one of three strategies:

FIFO (First In, First Out)

Allocate from the oldest funds first:
// Source: lineage.go:887-906
func (l *Blnk) calculateAllocation(sources []LineageSource, amount *big.Int, strategy string) []Allocation {
    switch strategy {
    case AllocationFIFO:
        sort.Slice(sources, func(i, j int) bool {
            return sources[i].CreatedAt.Before(sources[j].CreatedAt)
        })
        return l.sequentialAllocation(sources, amount)
    // ...
    }
}
Example:
Alice's Balance:
  Stripe:  $400 (Jan 1, 2024)
  Bank:    $600 (Jan 5, 2024)

Debit: $800

Allocation (FIFO):
  1. $400 from Stripe (oldest)
  2. $400 from Bank (remaining)
Use Cases:
  • Regulatory compliance requiring oldest funds spent first
  • Inventory accounting
  • Default strategy

LIFO (Last In, First Out)

Allocate from the newest funds first:
case AllocationLIFO:
    sort.Slice(sources, func(i, j int) bool {
        return sources[i].CreatedAt.After(sources[j].CreatedAt)
    })
    return l.sequentialAllocation(sources, amount)
Example:
Alice's Balance:
  Stripe:  $400 (Jan 1, 2024)
  Bank:    $600 (Jan 5, 2024)

Debit: $800

Allocation (LIFO):
  1. $600 from Bank (newest)
  2. $200 from Stripe (remaining)
Use Cases:
  • Tax optimization
  • Prefer spending recent deposits

Proportional

Allocate proportionally across all sources:
case AllocationProp:
    return l.proportionalAllocation(sources, amount)
Algorithm (lineage.go:967-1023):
func (l *Blnk) proportionalAllocation(sources []LineageSource, amount *big.Int) []Allocation {
    total := big.NewInt(0)
    for _, source := range sources {
        total.Add(total, source.Balance)
    }
    
    for i, source := range sources {
        if i == len(sources)-1 {
            // Last source gets the remainder
            alloc = new(big.Int).Set(remaining)
        } else {
            // Calculate proportional share: (amount * source.Balance) / total
            proportion := new(big.Int).Mul(amount, source.Balance)
            alloc = new(big.Int).Div(proportion, total)
        }
        
        allocations = append(allocations, Allocation{
            BalanceID: source.BalanceID,
            Amount:    alloc,
        })
    }
    
    return allocations
}
Example:
Alice's Balance:
  Stripe:  $400 (40%)
  Bank:    $600 (60%)
  Total:   $1000

Debit: $800

Allocation (Proportional):
  1. $320 from Stripe (40% of $800)
  2. $480 from Bank (60% of $800)
Use Cases:
  • Fair distribution across sources
  • Risk spreading
  • Regulatory requirements for balanced allocation

Querying Lineage

Balance Lineage

Get fund breakdown for a balance:
GET /balances/bal_alice_usd/lineage
Response:
{
  "balance_id": "bal_alice_usd",
  "total_with_lineage": "1000.00",
  "aggregate_balance_id": "@alice_doe_xyz123_lineage",
  "providers": [
    {
      "provider": "stripe",
      "amount": "600.00",
      "available": "400.00",
      "spent": "200.00",
      "shadow_balance_id": "@stripe_alice_doe_xyz123_lineage"
    },
    {
      "provider": "bank",
      "amount": "800.00",
      "available": "600.00",
      "spent": "200.00",
      "shadow_balance_id": "@bank_alice_doe_xyz123_lineage"
    }
  ]
}
Implementation (lineage.go:1053-1080):
func (l *Blnk) GetBalanceLineage(ctx context.Context, balanceID string) (*BalanceLineage, error) {
    balance, err := l.datasource.GetBalanceByID(balanceID, nil, false)
    if err != nil {
        return nil, fmt.Errorf("failed to get balance: %w", err)
    }
    
    if !balance.TrackFundLineage {
        return nil, fmt.Errorf("balance %s does not have fund lineage tracking enabled", balanceID)
    }
    
    mappings, err := l.datasource.GetLineageMappings(ctx, balanceID)
    if err != nil {
        return nil, fmt.Errorf("failed to get lineage mappings: %w", err)
    }
    
    lineage := &BalanceLineage{
        BalanceID:        balanceID,
        Providers:        make([]ProviderBreakdown, 0),
        TotalWithLineage: big.NewInt(0),
    }
    
    l.populateLineageProviders(lineage, mappings)
    
    return lineage, nil
}

Transaction Lineage

Get fund allocation for a transaction:
GET /transactions/txn_abc123/lineage
Response:
{
  "transaction_id": "txn_abc123",
  "fund_allocation": [
    {
      "provider": "stripe",
      "amount": "400.00"
    },
    {
      "provider": "bank",
      "amount": "200.00"
    }
  ],
  "shadow_transactions": [
    {
      "transaction_id": "txn_shadow_001",
      "source": "@alice_xyz_lineage",
      "destination": "@stripe_alice_xyz_lineage",
      "amount": 400.00,
      "meta_data": {
        "_shadow_for": "txn_abc123",
        "_provider": "stripe",
        "_lineage_type": "release"
      }
    },
    {
      "transaction_id": "txn_shadow_002",
      "source": "@alice_xyz_lineage",
      "destination": "@bank_alice_xyz_lineage",
      "amount": 200.00,
      "meta_data": {
        "_shadow_for": "txn_abc123",
        "_provider": "bank",
        "_lineage_type": "release"
      }
    }
  ]
}
Implementation (lineage.go:1160-1181):
func (l *Blnk) GetTransactionLineage(ctx context.Context, transactionID string) (*TransactionLineage, error) {
    txn, err := l.GetTransaction(ctx, transactionID)
    if err != nil {
        return nil, fmt.Errorf("failed to get transaction: %w", err)
    }
    
    lineage := &TransactionLineage{
        TransactionID:      transactionID,
        FundAllocation:     l.extractFundAllocation(txn.MetaData),
        ShadowTransactions: make([]model.Transaction, 0),
    }
    
    shadowTxns, err := l.datasource.GetTransactionsByShadowFor(ctx, transactionID)
    if err == nil {
        lineage.ShadowTransactions = shadowTxns
    }
    
    return lineage, nil
}

Use Cases

1. Regulatory Compliance

Scenario: Track loan funds separately from customer deposits
# Receive loan
POST /transactions
{
  "source": "@world",
  "destination": "bal_customer_usd",
  "amount": 50000,
  "meta_data": {
    "BLNK_LINEAGE_PROVIDER": "loan_program_a"
  }
}

# Receive deposit
POST /transactions
{
  "source": "@world",
  "destination": "bal_customer_usd",
  "amount": 10000,
  "meta_data": {
    "BLNK_LINEAGE_PROVIDER": "bank_deposit"
  }
}

# Payment (will show how much came from loan vs deposit)
POST /transactions
{
  "source": "bal_customer_usd",
  "destination": "bal_merchant_usd",
  "amount": 15000,
  "reference": "purchase-001"
}
Get allocation:
GET /transactions/purchase-001/lineage

# Response shows:
# - $10,000 from bank_deposit (FIFO: deposited first)
# - $5,000 from loan_program_a

2. Anti-Money Laundering (AML)

Scenario: Trace funds through multiple hops
# 1. Suspicious source credits Alice
POST /transactions
{
  "source": "@world",
  "destination": "bal_alice_usd",
  "amount": 100000,
  "meta_data": {
    "BLNK_LINEAGE_PROVIDER": "crypto_exchange_x"
  }
}

# 2. Alice sends to Bob
POST /transactions
{
  "source": "bal_alice_usd",
  "destination": "bal_bob_usd",
  "amount": 50000
}

# 3. Bob sends to Charlie
POST /transactions
{
  "source": "bal_bob_usd",
  "destination": "bal_charlie_usd",
  "amount": 30000
}

# Audit: Trace funds at each step
GET /balances/bal_charlie_usd/lineage
# Shows: $30,000 from crypto_exchange_x

3. Escrow Services

Scenario: Track buyer funds vs platform fees
# Buyer deposits to escrow
POST /transactions
{
  "source": "@world",
  "destination": "bal_escrow_usd",
  "amount": 10000,
  "meta_data": {
    "BLNK_LINEAGE_PROVIDER": "buyer_order_12345"
  }
}

# Platform adds fee subsidy
POST /transactions
{
  "source": "@world",
  "destination": "bal_escrow_usd",
  "amount": 500,
  "meta_data": {
    "BLNK_LINEAGE_PROVIDER": "platform_subsidy"
  }
}

# Release to seller (shows how much from buyer vs platform)
POST /transactions
{
  "source": "bal_escrow_usd",
  "destination": "bal_seller_usd",
  "amount": 10500
}

GET /transactions/[txn_id]/lineage
# Shows allocation: $10,000 buyer + $500 platform

Performance Considerations

Locking Strategy

Lineage uses multi-lock acquisition to prevent race conditions (lineage.go:505-521):
func (l *Blnk) acquireLineageLocks(ctx context.Context, balanceIDs []string) (*redlock.MultiLocker, error) {
    // Prefix all keys to avoid collision with main transaction locks
    lockKeys := make([]string, 0, len(balanceIDs))
    for _, id := range balanceIDs {
        lockKeys = append(lockKeys, fmt.Sprintf("lineage:%s", id))
    }
    
    // MultiLocker handles deduplication and sorts keys lexicographically
    locker := redlock.NewMultiLocker(l.redis, lockKeys, model.GenerateUUIDWithSuffix("loc"))
    
    err := locker.Lock(ctx, l.Config().Transaction.LockDuration)
    if err != nil {
        return nil, fmt.Errorf("failed to acquire lineage locks: %w", err)
    }
    
    return locker, nil
}
Key Features:
  • Sorts lock keys to prevent deadlocks
  • Deduplicates lock keys
  • Uses Redis distributed locks
  • Separate namespace from main transaction locks

Batch Optimization

Shadow balances are fetched in a single query to reduce database round trips (lineage.go:837-874):
func (l *Blnk) getLineageSources(ctx context.Context, mappings []model.LineageMapping) ([]LineageSource, error) {
    if len(mappings) == 0 {
        return nil, nil
    }
    
    // Collect all shadow balance IDs for batch query
    shadowBalanceIDs := make([]string, 0, len(mappings))
    for _, mapping := range mappings {
        shadowBalanceIDs = append(shadowBalanceIDs, mapping.ShadowBalanceID)
    }
    
    // Fetch all shadow balances in a single query
    balances, err := l.datasource.GetBalancesByIDsLite(ctx, shadowBalanceIDs)
    if err != nil {
        return nil, fmt.Errorf("failed to fetch shadow balances: %w", err)
    }
    
    // Build sources from the fetched balances
    var sources []LineageSource
    for _, mapping := range mappings {
        balance, exists := balances[mapping.ShadowBalanceID]
        if !exists {
            continue
        }
        
        if balance.DebitBalance != nil && balance.CreditBalance != nil {
            available := new(big.Int).Sub(balance.DebitBalance, balance.CreditBalance)
            if available.Cmp(big.NewInt(0)) > 0 {
                sources = append(sources, LineageSource{
                    BalanceID: mapping.ShadowBalanceID,
                    Balance:   available,
                    CreatedAt: mapping.CreatedAt,
                })
            }
        }
    }
    
    return sources, nil
}
Before: N queries (one per shadow balance) After: 1 query (batch fetch)

Transaction Impact

Lineage adds overhead per transaction: Credit Transaction:
  • Main transaction: 1
  • Shadow transaction: 1
  • Lineage mapping insert: 1
  • Total: 3 database operations
Debit Transaction:
  • Main transaction: 1
  • Shadow release transactions: N (one per provider allocated)
  • Shadow receive transactions: N (if destination tracks lineage)
  • Metadata update: 1
  • Total: 2 + 2N database operations
Optimization Tips:
  1. Use lineage only where compliance requires it
  2. Limit number of providers per balance (< 10)
  3. Use appropriate allocation strategy (FIFO is fastest)
  4. Consider batch processing for bulk transfers

Best Practices

  1. Enable Lineage at Balance Creation: Cannot be enabled retroactively
  2. Use Meaningful Provider Names: Easy to audit
    Good: "stripe_settlement_2024_01", "bank_wire_chase"
    Bad: "p1", "source_a"
    
  3. Consistent Provider Naming: Use standardized format
    {payment_method}_{institution}_{type}
    Examples:
    - card_visa_consumer
    - bank_chase_business
    - crypto_coinbase_btc
    
  4. Set Identity ID: Required for lineage tracking
    {
      "identity_id": "idt_customer_123",
      "track_fund_lineage": true
    }
    
  5. Choose Appropriate Strategy: Based on compliance needs
    • FIFO: Most regulatory scenarios
    • LIFO: Tax optimization
    • Proportional: Risk distribution
  6. Monitor Shadow Balances: Set up alerts for discrepancies
    -- Main balance should equal aggregate balance
    SELECT 
        b.balance_id,
        b.balance as main_balance,
        agg.balance as aggregate_balance
    FROM balances b
    JOIN balances agg ON agg.indicator = '@' || b.identity_id || '_lineage'
    WHERE b.track_fund_lineage = true
    AND b.balance != agg.balance;
    

Troubleshooting

Lineage Not Tracking

Symptom: Transactions processed but no lineage data Solutions:
  1. Verify track_fund_lineage enabled on balance
  2. Check identity_id is set on balance
  3. Ensure provider specified in meta_data.BLNK_LINEAGE_PROVIDER
  4. Check logs for lineage processing errors

Balance Mismatch

Symptom: Main balance ≠ sum of shadow balances Diagnosis:
SELECT 
    b.balance_id,
    b.balance,
    SUM(s.balance) as shadow_total
FROM balances b
JOIN lineage_mappings lm ON lm.balance_id = b.balance_id
JOIN balances s ON s.balance_id = lm.shadow_balance_id
WHERE b.track_fund_lineage = true
GROUP BY b.balance_id, b.balance
HAVING b.balance != SUM(s.balance);
Solutions:
  1. Check for failed shadow transactions
  2. Review outbox for pending lineage work
  3. Manually reconcile discrepancies
  4. Contact support if persistent

Provider Validation Failed

Symptom:
WARN: lineage provider validation: provider "xyz" does not exist on source balance
Cause: Attempting to debit funds from a provider that doesn’t exist in the source balance Solution:
  1. Verify provider exists: GET /balances/{id}/lineage
  2. Check provider name spelling
  3. Ensure funds were credited with that provider

Build docs developers (and LLMs) love