Chainlink Automation

Deep dive into the automated snapshot and yield distribution system powered by Chainlink Automation.


Traditional smart contracts cannot execute themselves. They require external transactions to trigger functions. This creates problems:

Without Automation:

  • ❌ Property owner must manually trigger snapshot at exact maturity time

  • ❌ Risk of delayed/missed snapshots

  • ❌ Users must manually claim yield (poor UX)

  • ❌ Gas costs for hundreds of individual claim transactions

  • ❌ No guarantee of fair simultaneous distribution

With Chainlink Automation:

  • ✅ Automatic snapshot trigger at maturity date

  • ✅ Zero-touch for property owners

  • ✅ Automatic yield delivery to all holders

  • ✅ Single batch distribution (gas efficient)

  • ✅ Guaranteed fair distribution


Architecture


How It Works

1. Upkeep Registration

When deploying AutoDistributor, we register an "Upkeep" with Chainlink:

// Register via Chainlink UI
const upkeepParams = {
    name: "Owna Finance Auto Distribution",
    contract: AUTO_DISTRIBUTOR_ADDRESS,
    gasLimit: 500000,
    checkData: "0x", // empty
    offchainConfig: "0x",
    amount: parseEther("5") // 5 LINK funding
};

// Chainlink provides Upkeep ID
const upkeepId = "93020392068544314578722444806384967116734687134361189041908582162518677744524";

Deployed Upkeep:

  • Network: Base Sepolia

  • Upkeep ID: 93020392068544314578722444806384967116734687134361189041908582162518677744524

  • Status: ✅ Active

2. Continuous Monitoring (checkUpkeep)

Chainlink Keeper nodes call checkUpkeep() every block to check if work is needed:

function checkUpkeep(bytes calldata /* checkData */)
    external view returns (bool upkeepNeeded, bytes memory performData)
{
    uint256[] memory allSeries = factory.getAllSeriesIds();

    // Loop through all series and periods
    for (uint256 i = 0; i < allSeries.length; i++) {
        uint256 seriesId = allSeries[i];
        (address tokenAddress,,,, ,,bool isActive) = factory.seriesInfo(seriesId);
        if (!isActive) continue;

        YRTToken token = YRTToken(tokenAddress);
        uint256 currentPeriod = token.currentPeriod();

        for (uint256 periodId = 1; periodId <= currentPeriod; periodId++) {
            (uint96 maturityDate,, uint128 totalYield,, bool periodActive) =
                factory.periodInfo(seriesId, periodId);

            // Check if snapshot needed
            if (periodActive &&
                block.timestamp >= maturityDate &&  // Maturity reached
                !token.isSnapshotTakenForPeriod(periodId) &&  // No snapshot yet
                totalYield > 0) {  // Yield deposited
                return (true, abi.encode(seriesId, periodId));
            }
        }
    }

    return (false, "");
}

What it checks:

  1. Is period active?

  2. Has maturity date been reached?

  3. Is snapshot not taken yet?

  4. Has yield been deposited?

If ALL conditions true → upkeepNeeded = true

3. Automatic Execution (performUpkeep)

When upkeepNeeded = true, Chainlink calls performUpkeep():

function performUpkeep(bytes calldata performData) external {
    (uint256 seriesId, uint256 periodId) = abi.decode(performData, (uint256, uint256));

    address tokenAddress;
    (tokenAddress,,,,,,) = factory.seriesInfo(seriesId);
    YRTToken token = YRTToken(tokenAddress);

    require(!token.isSnapshotTakenForPeriod(periodId), "Snapshot taken");

    (uint96 maturityDate,,,,) = factory.periodInfo(seriesId, periodId);
    require(block.timestamp >= maturityDate, "Not matured");

    // Trigger snapshot
    factory.triggerSnapshotForPeriod(seriesId, periodId);

    emit DistributionExecuted(seriesId, periodId, 0, block.timestamp);
}

What happens:

  1. Decodes seriesId and periodId from performData

  2. Validates conditions

  3. Calls factory.triggerSnapshotForPeriod()

  4. Snapshot taken automatically! 📸

4. Manual Distribution (After Snapshot)

After snapshot, property owner calls distributeToAllHolders():

function distributeToAllHolders(uint256 _seriesId, uint256 _periodId)
    external onlyRole(MANAGER_ROLE)
{
    YRTToken token = YRTToken(tokenAddress);
    require(token.isSnapshotTakenForPeriod(_periodId), "No snapshot");

    // Get snapshot holders (frozen list)
    address[] memory holders = token.getSnapshotHoldersForPeriod(_periodId);

    uint256 processed = 0;
    for (uint256 i = 0; i < holders.length; i++) {
        if (!factory.hasUserClaimedYieldForPeriod(_seriesId, _periodId, holders[i])) {
            try factory.distributeYieldToHolder(_seriesId, _periodId, holders[i]) {
                processed++;
            } catch {}
        }
    }

    emit DistributionExecuted(_seriesId, _periodId, processed, block.timestamp);
}

Result: USDC sent to all holders automatically!


Complete Flow Example

Timeline: Period 1 (90 Days)

Day 1 (Jan 1, 2024):
├─ Property owner calls factory.createSeries()
├─ Period 1 starts, maturity: Apr 1, 2024
├─ Chainlink starts monitoring
└─ checkUpkeep() returns false (not matured yet)

Day 1-90 (Jan 1 - Mar 31):
├─ Users trade YRT on DEX
├─ Balances changing constantly
├─ checkUpkeep() returns false (not matured)
└─ Chainlink keeps monitoring

Day 85 (Mar 26):
├─ Property owner deposits 100,000 USDC yield
├─ factory.depositYield(seriesId, periodId, 100000e18)
└─ checkUpkeep() still returns false (not matured)

Day 90 (Apr 1, 2024 00:00:00 UTC):
├─ ⏰ Maturity date reached!
├─ Chainlink checkUpkeep() returns TRUE
│   └─ Conditions met: matured + yield deposited + no snapshot
├─ Chainlink calls performUpkeep(seriesId, periodId)
├─ 📸 Snapshot taken automatically!
│   └─ Records: [Alice: 250, Bob: 200, Charlie: 150, Owner: 400]
└─ Event: PeriodSnapshotTaken emitted

Day 90 (Apr 1, 2024 01:00:00 UTC):
├─ Property owner (or MANAGER) calls:
│   └─ autoDistributor.distributeToAllHolders(seriesId, periodId)
├─ 💰 Yield distributed to ALL holders:
│   ├─ Alice receives: 25,000 USDC
│   ├─ Bob receives: 20,000 USDC
│   ├─ Charlie receives: 15,000 USDC
│   └─ Owner receives: 40,000 USDC
└─ Event: DistributionExecuted emitted

Day 91+:
└─ Property owner can start Period 2

Gas Costs & Economics

Upkeep Funding

Chainlink Automation requires LINK token funding:

Gas Limit: 500,000 gas
Gas Price (Base): ~0.01 gwei
Cost per execution: ~0.005 LINK (~$0.05)

Monthly cost (4 properties × 1 period/month):
= 4 executions × $0.05
= $0.20/month

Annual cost:
= $0.20 × 12
= $2.40/year

Extremely cost-effective compared to manual operations!

Distribution Gas Costs

// Gas costs (Base Sepolia):
distributeToAllHolders(100 holders): ~500,000 gas (~$0.50)
distributeToAllHolders(1000 holders): ~5,000,000 gas (~$5.00)

// Compare to manual claims:
100 users × claimYield(): 100 × 150,000 gas = 15,000,000 gas (~$15.00)

Batch distribution saves 3x gas!


Monitoring & Debugging

Check Upkeep Status

// Frontend: Check if upkeep needed
const [upkeepNeeded, performData] = await autoDistributor.checkUpkeep("0x");

if (upkeepNeeded) {
    const [seriesId, periodId] = ethers.utils.defaultAbiCoder.decode(
        ["uint256", "uint256"],
        performData
    );
    console.log(`Snapshot needed: Series ${seriesId}, Period ${periodId}`);
}

Get Pending Distributions

// Query pending snapshots
const [seriesIds, periodIds] = await autoDistributor.getPendingDistributions();

console.log("Pending snapshots:");
for (let i = 0; i < seriesIds.length; i++) {
    console.log(`- Series ${seriesIds[i]}, Period ${periodIds[i]}`);
}

Check Distribution Status

// Check if specific holders have been distributed
const holders = ["0xAlice...", "0xBob...", "0xCharlie..."];
const claimed = await autoDistributor.getDistributionStatus(
    seriesId,
    periodId,
    holders
);

holders.forEach((holder, i) => {
    console.log(`${holder}: ${claimed[i] ? "Distributed ✅" : "Pending ⏳"}`);
});

Advanced Features

Batch Distribution

For large holder counts, distribute in batches to avoid gas limits:

function batchDistribute(
    uint256 _seriesId,
    uint256 _periodId,
    address[] calldata _holders,
    uint256 _batchSize
) external onlyRole(MANAGER_ROLE) {
    require(_batchSize > 0 && _batchSize <= _holders.length, "Invalid batch size");

    uint256 processed = 0;
    for (uint256 i = 0; i < _batchSize && i < _holders.length; i++) {
        if (!factory.hasUserClaimedYieldForPeriod(_seriesId, _periodId, _holders[i])) {
            try factory.distributeYieldToHolder(_seriesId, _periodId, _holders[i]) {
                processed++;
            } catch {}
        }
    }

    emit DistributionExecuted(_seriesId, _periodId, processed, block.timestamp);
}

Usage:

// Distribute to first 100 holders
await autoDistributor.batchDistribute(seriesId, periodId, allHolders, 100);

// Then next 100
await autoDistributor.batchDistribute(seriesId, periodId, allHolders.slice(100), 100);

Manual Single Holder Distribution

For edge cases, distribute to single holder:

function distributeToSingleHolder(uint256 _seriesId, uint256 _periodId, address _holder)
    external onlyRole(MANAGER_ROLE)
{
    require(!factory.hasUserClaimedYieldForPeriod(_seriesId, _periodId, _holder), "Already claimed");
    factory.distributeYieldToHolder(_seriesId, _periodId, _holder);
    emit HolderDistributed(_seriesId, _periodId, _holder, 0);
}

Security & Access Control

Roles

bytes32 public constant MANAGER_ROLE = keccak256("MANAGER_ROLE");

// DEFAULT_ADMIN_ROLE: Can grant/revoke roles
// MANAGER_ROLE: Can trigger distributions

Deployed Roles:

  • DEFAULT_ADMIN_ROLE: 0x77c4a1cD22005b67Eb9CcEaE7E9577188d7Bca82 (Deployer)

  • MANAGER_ROLE: 0x77c4a1cD22005b67Eb9CcEaE7E9577188d7Bca82 (Deployer)

Important: performUpkeep() can be called by anyone!

This is intentional - Chainlink Keepers are permissionless. However:

  • Function only succeeds if conditions met

  • Cannot manipulate snapshot (atomically recorded)

  • Cannot steal funds (distribution to snapshot holders only)

Why it's safe:

// Checks in performUpkeep:
require(!token.isSnapshotTakenForPeriod(periodId), "Snapshot taken");
require(block.timestamp >= maturityDate, "Not matured");

// Snapshot records exact balances atomically
// No room for manipulation

Troubleshooting

Problem: Snapshot Not Triggered

Possible causes:

  1. Maturity date not reached

    const maturityDate = await token.periodMaturityDate(periodId);
    console.log(`Maturity: ${new Date(maturityDate * 1000)}`);
    console.log(`Now: ${new Date()}`);
  2. Yield not deposited

    const periodInfo = await factory.periodInfo(seriesId, periodId);
    console.log(`Total yield: ${periodInfo.totalYield}`);
    // Must be > 0
  3. Snapshot already taken

    const taken = await token.isSnapshotTakenForPeriod(periodId);
    console.log(`Snapshot taken: ${taken}`);
  4. Period inactive

    const periodInfo = await factory.periodInfo(seriesId, periodId);
    console.log(`Period active: ${periodInfo.isActive}`);
  5. Upkeep unfunded

Problem: Distribution Failed

Possible causes:

  1. Snapshot not taken

    require(token.isSnapshotTakenForPeriod(_periodId), "No snapshot");
  2. Insufficient yield balance

    const yieldBalance = await usdc.balanceOf(FACTORY_ADDRESS);
    const totalYield = await factory.periodInfo(seriesId, periodId).totalYield;
    console.log(`Balance: ${yieldBalance}, Required: ${totalYield}`);
  3. Gas limit exceeded (too many holders)

    • Solution: Use batchDistribute() instead


Best Practices

For Property Owners

  1. Deposit yield 1-2 days before maturity

    // Good: Deposit early
    await factory.depositYield(seriesId, periodId, yieldAmount);
    // Chainlink will trigger snapshot automatically at maturity
  2. Monitor Chainlink Upkeep

    • Check dashboard: https://automation.chain.link/base-sepolia

    • Ensure LINK balance > 5 LINK

    • Top up if needed

  3. Call distributeToAllHolders() soon after snapshot

    // Wait for snapshot event
    token.on("PeriodSnapshotTaken", async (periodId) => {
        // Distribute immediately
        await autoDistributor.distributeToAllHolders(seriesId, periodId);
    });

For Developers

  1. Listen to events

    // Monitor snapshot events
    token.on("PeriodSnapshotTaken", (periodId, timestamp, totalSupply, holdersCount) => {
        console.log(`Snapshot taken for period ${periodId}`);
        console.log(`Holders: ${holdersCount}, Supply: ${totalSupply}`);
    });
    
    // Monitor distribution events
    autoDistributor.on("DistributionExecuted", (seriesId, periodId, holdersProcessed) => {
        console.log(`Distribution complete: ${holdersProcessed} holders`);
    });
  2. Implement retry logic

    async function ensureDistribution(seriesId, periodId, maxRetries = 3) {
        for (let i = 0; i < maxRetries; i++) {
            try {
                const tx = await autoDistributor.distributeToAllHolders(seriesId, periodId);
                await tx.wait();
                console.log("Distribution successful");
                return;
            } catch (error) {
                console.log(`Attempt ${i + 1} failed, retrying...`);
                await new Promise(r => setTimeout(r, 5000)); // Wait 5s
            }
        }
        throw new Error("Distribution failed after retries");
    }


Key Takeaways:

  1. Chainlink Automation provides zero-touch snapshot triggering

  2. checkUpkeep() monitors all periods continuously

  3. performUpkeep() triggers snapshot at maturity automatically

  4. distributeToAllHolders() sends yield to all snapshot holders

  5. Cost-effective: ~$2.40/year for automated operations


Deployed Contracts:

  • AutoDistributor: 0x8C9edAB077038B4f2e74d79663d79f3fc12Ca945

  • Upkeep ID: 93020392068544314578722444806384967116734687134361189041908582162518677744524

  • Network: Base Sepolia


Last Updated: october 2025

Last updated