Skip to main content
In Part 1, you learned how to schedule future smart contract calls using Hedera’s Schedule Service. Now, let’s build something more sophisticated: a capacity-aware DeFi rebalancer that automatically adjusts its scheduling strategy based on network conditions. What makes this advanced? Most blockchain automation requires off-chain infrastructure to periodically check and execute operations. Even with Hedera’s Schedule Service, naively scheduling all operations at fixed intervals can create network congestion when many contracts compete for the same execution window. This tutorial demonstrates how to build intelligent on-chain automation that:
  • Queries network capacity before scheduling operations
  • Uses exponential backoff with jitter to find optimal execution times
  • Self-sustains by automatically rescheduling after each execution
  • Gracefully handles network congestion and capacity constraints
  • Supports multiple scheduling methods (scheduleCall and scheduleCallWithPayer)
  • Demonstrates one-shot immediate execution using executeCallOnPayerSignature
You can take a look at the complete code in the tutorial-hss-rebalancer-capacity-aware repository.

What You’ll Build

A RebalancerCapacityAware contract that:
  1. Starts a rebalancing loop with configurable intervals
  2. Checks network capacity using hasScheduleCapacity() before scheduling
  3. Applies intelligent retry logic with exponential backoff and randomized jitter
  4. Supports two scheduling methods: scheduleCall and scheduleCallWithPayer
  5. Executes rebalances automatically via scheduled transactions
  6. Reschedules itself after each execution, creating a self-sustaining loop
  7. Demonstrates one-shot execution using executeCallOnPayerSignature
  8. Can be stopped by canceling pending scheduled transactions
This pattern is perfect for:
  • DeFi vault rebalancing
  • Periodic token distributions
  • Automated treasury management
  • Time-based protocol operations

Prerequisites


Table of Contents

  1. Setup Project
  2. Step 1: Understanding the Architecture
  3. Step 2: Create the Rebalancer Contract
  4. Step 3: Deploy the Contract
  5. Step 4: Configure the Contract
  6. Step 5: Start Rebalancing
  7. Step 6: Monitor Rebalancing Operations
  8. Step 7: Stop Rebalancing
  9. Step 8: One-Shot Immediate Execution (Optional)
  10. Step 9: Run Tests (Optional)
  11. Conclusion
  12. Additional Resources

Setup Project

If you completed Part 1, you can use the same project. Otherwise, set up a new project:
mkdir tutorial-hss-rebalancer-capacity-aware
cd tutorial-hss-rebalancer-capacity-aware
npx hardhat --init
Make sure to select “Hardhat 3 -> Typescript Hardhat Project using Mocha and Ethers.js” and accept the default values. Hardhat will configure your project correctly and install the required dependencies.
Key differences in Hardhat 3:
  • compile → build
    npx hardhat compile is now npx hardhat build. This is the big one. The v3 migration guide explicitly shows using the build task.
  • project init switch
    v2 commonly used npx hardhat or npx hardhat init to bootstrap. In v3 it’s npx hardhat --init.
  • keystore helper commands are new
    v3’s recommended flow includes a keystore plugin with commands like npx hardhat keystore set HEDERA_RPC_URL and npx hardhat keystore set HEDERA_PRIVATE_KEY. These weren’t standard in v2.
  • Foundry-compatible Solidity tests
    In addition to offering Javascript/Typescript integration tests, Hardhat v3 also integrates Foundry-compatible Solidity tests that allows developers to write unit tests directly in Solidity
  • Enhanced Network Management
    v3 allows tasks to create and manage multiple network connections simultaneously which is a significant improvement over the single, fixed connection available in version 2. This provides greater flexibility for scripts and tests that interact with multiple networks.
📚 Learn more from the official Hardhat documentation.
Before we make any changes to our Hardhat configuration file, let’s set some configuration variables we will be referring to within the file later.
# If you have already set this before, please use the --force flag
npx hardhat keystore set HEDERA_RPC_URL
For HEDERA_RPC_URL, we’ll have https://testnet.hashio.io/api
# If you have already set this before, please use the --force flag
npx hardhat keystore set HEDERA_PRIVATE_KEY
For HEDERA_PRIVATE_KEY, enter the HEX Encoded Private Key for your ECDSA account from the Hedera Portal. We also need a second private key for testing purposes:
npx hardhat keystore set HEDERA_PRIVATE_KEY_2
For HEDERA_PRIVATE_KEY_2, enter another HEX Encoded Private Key for a second ECDSA account. Now let’s remove the default contracts and scripts that come with the Hardhat project:
rm -rf contracts/* scripts/* test/*
rm -rf ignition

Install Dependencies

Next, install the required dependencies:
npm install github:hashgraph/hedera-smart-contracts
Note that we are installing the latest code from the main branch when we install github:hashgraph/hedera-smart-contracts. This also gets installed at @hashgraph/smart-contracts so we can easily call these contracts from our own contract. Configure hardhat.config.ts:
hardhat.config.ts
import type { HardhatUserConfig } from "hardhat/config";
import hardhatToolboxMochaEthersPlugin from "@nomicfoundation/hardhat-toolbox-mocha-ethers";
import { configVariable } from "hardhat/config";

const config: HardhatUserConfig = {
  plugins: [hardhatToolboxMochaEthersPlugin],
  solidity: {
    profiles: {
      default: {
        version: "0.8.31"
      },
      production: {
        version: "0.8.31",
        settings: {
          optimizer: {
            enabled: true,
            runs: 200
          }
        }
      }
    }
  },
  networks: {
    testnet: {
      type: "http",
      url: configVariable("HEDERA_RPC_URL"),
      accounts: [configVariable("HEDERA_PRIVATE_KEY")]
    }
  }
};

export default config;

Step 1: Understanding the Architecture

Before diving into code, let’s understand the key concepts that make this rebalancer capacity-aware.

The Capacity Problem

When multiple contracts schedule transactions for the same future time:
  • Network capacity for that second may be exhausted
  • Subsequent scheduling attempts fail
  • Operations get delayed or fail entirely

The Solution: Capacity-Aware Scheduling

Our rebalancer uses three key Hedera features: 1. hasScheduleCapacity(expirySecond, gasLimit)
  • Queries if a specific future second can accept a scheduled transaction
  • Returns true if capacity is available, false otherwise
  • Allows contracts to “probe” future availability
2. Exponential Backoff with Jitter
  • If desired time lacks capacity, try progressively later times: +1s, +2s, +4s, +8s…
  • Add random jitter to avoid “thundering herd” where all contracts retry at the same moment
  • Spreads load across multiple seconds
3. Hedera PRNG System Contract (0x169)
  • Provides pseudorandom seeds for jitter calculation
  • Enables true on-chain randomness without external oracles
  • Each contract gets different jitter, naturally distributing load

Scheduling Methods

This tutorial demonstrates three different scheduling approaches:
MethodUse CasePayerLoopable
scheduleCallAutomated recurring operationsCaller✅ Yes
scheduleCallWithPayerRecurring with contract as payerContract✅ Yes
executeCallOnPayerSignatureOne-shot immediate executionContract❌ No
Important: executeCallOnPayerSignature is not supported for recursive/looped/cron operations due to Hedera mainnet recursion protection (NO_SCHEDULING_ALLOWED_AFTER_SCHEDULED_RECURSION). Use scheduleCall or scheduleCallWithPayer for all automated recurring scheduling.

How It Works Together

User calls: startRebalancing(60) // 60-second intervals

Contract:
1. Calculates desired time: now + 60 seconds
2. Checks: hasScheduleCapacity(desiredTime, gasLimit)?
   - YES → Schedule at desiredTime
   - NO → Try exponential backoff with jitter:
     * Try desiredTime + 1 + random(0-1)
     * Try desiredTime + 2 + random(0-2)
     * Try desiredTime + 4 + random(0-4)
     * Try desiredTime + 8 + random(0-8)
     * ...until capacity found or max retries reached

3. Schedule rebalance() at chosen time (using selected method)
4. When rebalance() executes (automatically):
   - Increment counter (or perform real DeFi operation)
   - Calculate next desired time:   now + 60 seconds
   - Repeat capacity-aware scheduling process

Result: Self-sustaining loop that respects network capacity
Why This MattersOn traditional EVM chains, you’d need:
  • Off-chain service to monitor network congestion
  • Manual intervention to adjust timing
  • External keeper network that understands capacity
On Hedera, the contract itself is capacity-aware and self-adjusting!

Step 2: Create the Rebalancer Contract

Create RebalancerCapacityAware.sol in your contracts directory:
contracts/RebalancerCapacityAware.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.31;

import {
    HederaScheduleService
} from "@hashgraph/smart-contracts/contracts/system-contracts/hedera-schedule-service/HederaScheduleService.sol";
import {
    HederaResponseCodes
} from "@hashgraph/smart-contracts/contracts/system-contracts/HederaResponseCodes.sol";
import {
    PrngSystemContract
} from "@hashgraph/smart-contracts/contracts/system-contracts/pseudo-random-number-generator/PrngSystemContract.sol";

contract RebalancerCapacityAware is HederaScheduleService {
    uint256 internal constant REBALANCE_GAS_LIMIT = 2_000_000;

    struct RebalanceConfig {
        bool active;
        uint256 intervalSeconds;
        uint256 lastRebalanceTime;
        uint256 rebalanceCount;
        address lastScheduleAddress;
        address payer;
        bool usePayerScheduling;
    }

    RebalanceConfig public config;

    event RebalancingStarted(
        uint256 intervalSeconds,
        uint256 firstScheduledAt,
        address payer,
        bool usePayerScheduling
    );
    event RebalanceScheduled(
        uint256 chosenTime,
        uint256 desiredTime,
        address scheduleAddress,
        string schedulingMethod
    );
    event RebalanceExecuted(uint256 timestamp, uint256 count);
    event RebalancingStopped();
    event PayerSet(address payer);
    event SchedulingMethodChanged(bool usePayerScheduling);
    event OneShotExecuted(address scheduleAddress, string method);
    event DemoActionExecuted(address caller, uint256 value);

    constructor() payable {}
    receive() external payable {}

    function setPayer(address _payer) external {
        config.payer = _payer;
        emit PayerSet(_payer);
    }

    function setSchedulingMethod(bool _usePayerScheduling) external {
        require(!config.active, "stop rebalancing first");
        config.usePayerScheduling = _usePayerScheduling;
        emit SchedulingMethodChanged(_usePayerScheduling);
    }

    function startRebalancing(uint256 intervalSeconds) external {
        require(intervalSeconds > 0, "interval must be > 0");
        require(!config.active, "already active");

        config.active = true;
        config.intervalSeconds = intervalSeconds;
        config.lastRebalanceTime = block.timestamp;
        config.rebalanceCount = 0;

        uint256 desiredTime = block.timestamp + intervalSeconds;
        uint256 scheduledAt = _scheduleNextRebalance(desiredTime);
        emit RebalancingStarted(
            intervalSeconds,
            scheduledAt,
            config.payer,
            config.usePayerScheduling
        );
    }

    function rebalance() external {
        require(config.active, "not active");
        config.rebalanceCount += 1;
        config.lastRebalanceTime = block.timestamp;

        emit RebalanceExecuted(block.timestamp, config.rebalanceCount);

        uint256 desiredTime = block.timestamp + config.intervalSeconds;
        _scheduleNextRebalance(desiredTime);
    }

    function stopRebalancing() external {
        if (config.lastScheduleAddress != address(0)) {
            address scheduleAddress = config.lastScheduleAddress;
            deleteSchedule(scheduleAddress);
            config.lastScheduleAddress = address(0);
        }
        config.active = false;
        emit RebalancingStopped();
    }

    function _scheduleNextRebalance(
        uint256 desiredTime
    ) internal returns (uint256 chosenTime) {
        chosenTime = _findAvailableSecond(desiredTime, REBALANCE_GAS_LIMIT, 8);
        bytes memory callData = abi.encodeWithSelector(this.rebalance.selector);
        int64 rc;
        address scheduleAddress;
        string memory method;
        if (config.usePayerScheduling && config.payer != address(0)) {
            (rc, scheduleAddress) = scheduleCallWithPayer(
                address(this),
                config.payer,
                chosenTime,
                REBALANCE_GAS_LIMIT,
                0,
                callData
            );
            method = "scheduleCallWithPayer";
        } else {
            (rc, scheduleAddress) = scheduleCall(
                address(this),
                chosenTime,
                REBALANCE_GAS_LIMIT,
                0,
                callData
            );
            method = "scheduleCall";
        }
        require(rc == HederaResponseCodes.SUCCESS, "scheduleCall failed");
        config.lastScheduleAddress = scheduleAddress;
        emit RebalanceScheduled(
            chosenTime,
            desiredTime,
            scheduleAddress,
            method
        );
    }

    function _findAvailableSecond(
        uint256 expiry,
        uint256 gasLimit,
        uint256 maxProbes
    ) internal returns (uint256 second) {
        if (hasScheduleCapacity(expiry, gasLimit)) {
            return expiry;
        }
        bytes32 seed = PrngSystemContract(address(0x169)).getPseudorandomSeed();
        for (uint256 i = 0; i < maxProbes; i++) {
            uint256 baseDelay = 1 << i;
            bytes32 hash = keccak256(abi.encodePacked(seed, i));
            uint16 randomValue = uint16(uint256(hash));
            uint256 jitter = uint256(randomValue) % (baseDelay + 1);

            uint256 candidate = expiry + baseDelay + jitter;
            if (hasScheduleCapacity(candidate, gasLimit)) {
                return candidate;
            }
        }
        revert("No capacity after maxProbes");
    }

    // ----------- One-shot immediate execution demo BEGIN -----------

    function demoImmediateExecution(
        uint256 timestamp,
        bytes memory callData
    ) external returns (address, int64) {
        require(config.payer != address(0), "set payer");
        int64 rc;
        address scheduleAddress;
        (rc, scheduleAddress) = executeCallOnPayerSignature(
            address(this),
            config.payer,
            timestamp,
            REBALANCE_GAS_LIMIT,
            0,
            callData
        );
        emit OneShotExecuted(scheduleAddress, "executeCallOnPayerSignature");
        return (scheduleAddress, rc);
    }

    function demoAction(uint256 value) public {
        emit DemoActionExecuted(msg.sender, value);
    }
    // ----------- One-shot immediate execution demo END -----------

    function getConfig()
        external
        view
        returns (
            bool active,
            uint256 intervalSeconds,
            uint256 lastRebalanceTime,
            uint256 rebalanceCount,
            address lastScheduleAddress,
            address payer,
            bool usePayerScheduling
        )
    {
        return (
            config.active,
            config.intervalSeconds,
            config.lastRebalanceTime,
            config.rebalanceCount,
            config.lastScheduleAddress,
            config.payer,
            config.usePayerScheduling
        );
    }
}
How It Works
  1. setPayer(): Configures which address will pay for scheduled transactions (typically the contract itself)
  2. setSchedulingMethod(): Switches between scheduleCall (false) and scheduleCallWithPayer (true)
  3. startRebalancing(): Initializes the loop and schedules the first rebalance using capacity-aware logic
  4. _findAvailableSecond(): The core capacity-awareness algorithm:
    • First checks if desired time has capacity
    • If not, tries exponentially increasing delays: +1s, +2s, +4s, +8s…
    • Adds random jitter (0 to baseDelay) to each attempt
    • Uses Hedera’s PRNG for true on-chain randomness
  5. rebalance(): Executed automatically by scheduled transactions:
    • Increments counter (in real DeFi, would perform actual rebalancing)
    • Schedules next execution using capacity-aware logic
    • Creates self-sustaining loop
  6. stopRebalancing(): Cancels pending schedule and marks loop inactive
  7. demoImmediateExecution(): Demonstrates one-shot execution using executeCallOnPayerSignature
  8. HBAR Requirement: Contract must hold HBAR to pay for all scheduled executions
Build the contract:
npx hardhat build

Step 3: Deploy the Contract

Create deploy. ts in the scripts directory:
scripts/deploy.ts
import { network } from "hardhat";
const { ethers } = await network.connect({ network: "testnet" });

async function main() {
  const [deployer] = await ethers.getSigners();
  console.log("Deploying with account:", deployer.address);

  const RebalancerCapacityAware = await ethers.getContractFactory(
    "RebalancerCapacityAware",
    deployer
  );

  const contract = await RebalancerCapacityAware.deploy({
    value: ethers.parseEther("20")
  });
  await contract.waitForDeployment();

  const contractAddress = await contract.getAddress();
  console.log("RebalancerCapacityAware deployed at:", contractAddress);

  const balance = await ethers.provider.getBalance(contractAddress);
  console.log("Contract HBAR balance:", ethers.formatEther(balance), "HBAR");

  console.log("📝 Save this address for the next steps!");
  console.log(`export CONTRACT_ADDRESS=${contractAddress}`);
}

main().catch(console.error);
Deploy:
npx hardhat run scripts/deploy.ts --network testnet
Copy the deployed contract address and set it as an environment variable for the next steps.
Expected output:
Deploying with account: 0xe3c0743e01bE37c42B2ee57BD1aA30c9c266c0Ae
RebalancerCapacityAware deployed at: 0xFAd66DAA323354799ADF0aF2a019Ce39211bA27F
Contract HBAR balance: 20.0 HBAR
📝 Save this address for the next steps!
export CONTRACT_ADDRESS=0xFAd66DAA323354799ADF0aF2a019Ce39211bA27F
Set the contract address as an environment variable:
export CONTRACT_ADDRESS=0xYOURDEPLOYEDADDRESS
In order to decode events emitted from the contract, the contract must be verified.
./generate_hedera_sc_metadata.sh RebalancerCapacityAware
You can then upload the verify-bundles/RebalancerCapacityAware/metadata.json file to Hashscan to verify this contract.

Step 4: Configure the Contract

Before starting the rebalancing loop, you need to configure the payer and scheduling method.

Set the Contract as Payer

Create setPayer.ts in the scripts directory:
scripts/setPayer. ts
import { network } from "hardhat";
const { ethers } = await network.connect({ network: "testnet" });

async function main() {
  const contractAddress =
    process.env.CONTRACT_ADDRESS || "<your-deployed-contract-address>";
  if (!contractAddress) throw new Error("Set CONTRACT_ADDRESS env var!");
  const [signer] = await ethers.getSigners();

  const rebalancer = await ethers.getContractAt(
    "RebalancerCapacityAware",
    contractAddress,
    signer
  );
  const tx = await rebalancer.setPayer(contractAddress);
  await tx.wait();

  console.log("Payer set to contract address:", contractAddress);
}

main().catch(console.error);
Run the script:
npx hardhat run scripts/setPayer.ts --network testnet

Choose a Scheduling Method

You have two options for scheduling. Choose one: Option 1: scheduleCall (default) Create setSchedulingMethodScheduleCall.ts:
scripts/setSchedulingMethodScheduleCall.ts
import { network } from "hardhat";
const { ethers } = await network.connect({ network: "testnet" });

async function main() {
  const contractAddress =
    process.env.CONTRACT_ADDRESS || "<your-deployed-contract-address>";
  if (!contractAddress) throw new Error("Set CONTRACT_ADDRESS env var!");
  const [signer] = await ethers.getSigners();

  const rebalancer = await ethers.getContractAt(
    "RebalancerCapacityAware",
    contractAddress,
    signer
  );
  const tx = await rebalancer.setSchedulingMethod(false);
  await tx.wait();

  console.log("Scheduling method set to:  scheduleCall");
}

main().catch(console.error);
npx hardhat run scripts/setSchedulingMethodScheduleCall.ts --network testnet
Option 2: scheduleCallWithPayer (contract as payer) Create setSchedulingMethodScheduleCallWithPayer.ts:
scripts/setSchedulingMethodScheduleCallWithPayer. ts
import { network } from "hardhat";
const { ethers } = await network.connect({ network: "testnet" });

async function main() {
  const contractAddress =
    process.env.CONTRACT_ADDRESS || "<your-deployed-contract-address>";
  if (!contractAddress) throw new Error("Set CONTRACT_ADDRESS env var!");
  const [signer] = await ethers.getSigners();

  const rebalancer = await ethers.getContractAt(
    "RebalancerCapacityAware",
    contractAddress,
    signer
  );
  const tx = await rebalancer.setSchedulingMethod(true);
  await tx.wait();

  console.log(
    "Scheduling method set to: scheduleCallWithPayer (contract as payer)"
  );
}

main().catch(console.error);
npx hardhat run scripts/setSchedulingMethodScheduleCallWithPayer.ts --network testnet

Step 5: Start Rebalancing

Create startRebalancing.ts in the scripts directory:
scripts/startRebalancing.ts
import { network } from "hardhat";
const { ethers } = await network.connect({ network: "testnet" });

async function main() {
  const contractAddress =
    process.env.CONTRACT_ADDRESS || "<your-deployed-contract-address>";
  if (!contractAddress) throw new Error("Set CONTRACT_ADDRESS env var!");
  const [signer] = await ethers.getSigners();

  const rebalancer = await ethers.getContractAt(
    "RebalancerCapacityAware",
    contractAddress,
    signer
  );
  const intervalSeconds = 15; // set your demo interval here

  const tx = await rebalancer.startRebalancing(intervalSeconds);
  await tx.wait();

  console.log(`Rebalancing started with interval: ${intervalSeconds} seconds`);
}

main().catch(console.error);
Run the script:
npx hardhat run scripts/startRebalancing.ts --network testnet
Expected output:
Rebalancing started with interval: 15 seconds
What’s Happening
  1. startRebalancing(15) calculates desired time: now + 15 seconds
  2. Contract checks: hasScheduleCapacity(desiredTime, 2_000_000)?
  3. If capacity available → schedules at desired time
  4. If not → applies exponential backoff with jitter to find available slot
  5. Emits RebalancingStarted with actual scheduled time and scheduling method
  6. After ~15 seconds, network automatically executes rebalance()
  7. rebalance() schedules next execution → creates self-sustaining loop

Step 6: Monitor Rebalancing Operations

Create monitorRebalancing. ts to observe the rebalancing loop:
scripts/monitorRebalancing.ts
import { network } from "hardhat";
const { ethers } = await network.connect({ network: "testnet" });

async function main() {
  const contractAddress =
    process.env.CONTRACT_ADDRESS || "<your-deployed-contract-address>";
  if (!contractAddress) throw new Error("Set CONTRACT_ADDRESS env var!");
  const rebalancer = await ethers.getContractAt(
    "RebalancerCapacityAware",
    contractAddress
  );

  console.log(
    "Monitoring Rebalancer:",
    contractAddress,
    "\nPress Ctrl+C to stop\n"
  );

  async function display() {
    const config = await rebalancer.getConfig();
    const balance = await ethers.provider.getBalance(contractAddress);
    console.log(`[${new Date().toISOString()}]`);
    console.log("  Active:", config.active);
    console.log("  Rebalance Count:", config.rebalanceCount.toString());
    console.log(
      "  Last Rebalance:",
      config.lastRebalanceTime > 0
        ? new Date(Number(config.lastRebalanceTime) * 1000).toISOString()
        : "Never"
    );
    console.log("  Interval:", config.intervalSeconds.toString(), "seconds");
    console.log("  Payer:", config.payer);
    console.log(
      "  Scheduling Method:",
      config.usePayerScheduling ? "scheduleCallWithPayer" : "scheduleCall"
    );
    console.log("  Contract Balance:", ethers.formatEther(balance), "HBAR");
    console.log("---");
  }

  await display();
  setInterval(display, 5000);
}

main().catch(console.error);
Run the monitoring script:
npx hardhat run scripts/monitorRebalancing.ts --network testnet
You’ll see output like:
Monitoring Rebalancer: 0xFAd66DAA323354799ADF0aF2a019Ce39211bA27F
Press Ctrl+C to stop

[2025-12-22T21:09:36.100Z]
  Active: true
  Rebalance Count: 4
  Last Rebalance: 2025-12-22T21:09:25.000Z
  Interval: 15 seconds
  Payer: 0xFAd66DAA323354799ADF0aF2a019Ce39211bA27F
  Scheduling Method: scheduleCall
  Contract Balance: 15.2 HBAR
---
[2025-12-22T21:09:41.391Z]
  Active: true
  Rebalance Count: 4
  Last Rebalance: 2025-12-22T21:09:25.000Z
  Interval: 15 seconds
  Payer: 0xFAd66DAA323354799ADF0aF2a019Ce39211bA27F
  Scheduling Method: scheduleCall
  Contract Balance: 15.2 HBAR
---
Note that the Rebalance Count increments every ~15 seconds as scheduled transactions execute automatically. When the contract runs out of HBAR, scheduling will fail, and the count will stop increasing however the state remains Active: true until you explicitly stop rebalancing.

Check Contract Config

You can also create a simple script to check the current configuration:
scripts/getConfig.ts
import { network } from "hardhat";
const { ethers } = await network.connect({ network: "testnet" });

async function main() {
  const contractAddress =
    process.env.CONTRACT_ADDRESS || "<your-deployed-contract-address>";

  if (!contractAddress) throw new Error("Set CONTRACT_ADDRESS env var!");

  const rebalancer = await ethers.getContractAt(
    "RebalancerCapacityAware",
    contractAddress
  );

  const config = await rebalancer.getConfig();
  const balance = await ethers.provider.getBalance(contractAddress);

  console.log("Config for contract:", contractAddress);
  console.log({
    active: config.active,
    intervalSeconds: config.intervalSeconds.toString(),
    lastRebalanceTime: config.lastRebalanceTime.toString(),
    rebalanceCount: config.rebalanceCount.toString(),
    lastScheduleAddress: config.lastScheduleAddress,
    payer: config.payer,
    usePayerScheduling: config.usePayerScheduling,
    contractBalance: ethers.formatEther(balance) + " HBAR"
  });
}

main().catch(console.error);
npx hardhat run scripts/getConfig.ts --network testnet
With output like:
Config for contract: 0xFAd66DAA323354799ADF0aF2a019Ce39211bA27F
{
  active: true,
  intervalSeconds: '15',
  lastRebalanceTime: '1766437807',
  rebalanceCount: '7',
  lastScheduleAddress: '0x00000000000000000000000000000000007294a6',
  payer: '0xFAd66DAA323354799ADF0aF2a019Ce39211bA27F',
  usePayerScheduling: false,
  contractBalance: '11.6 HBAR'
}

View Events on HashScan

Navigate to your contract’s events page to see: RebalanceScheduled Events:
RebalanceScheduled(
  chosenTime: 1734087330,
  desiredTime: 1734087330,
  scheduleAddress: 0x000000000000000000000000000000000068d3ef,
  schedulingMethod: "scheduleCall"
)
  • Shows when capacity-aware scheduling found an available slot
  • chosenTime === desiredTime means ideal time had capacity
  • chosenTime > desiredTime means backoff was needed
  • schedulingMethod shows which method was used
RebalanceExecuted Events:
RebalanceExecuted(
  timestamp: 1734087330,
  count: 1
)
  • Confirms automatic execution by the network
  • Tracks total rebalance operations performed
View live events at: https://hashscan.io/testnet/contract/$CONTRACT_ADDRESS/events

Step 7: Stop Rebalancing

Create stopRebalancing.ts to halt the loop:
scripts/stopRebalancing.ts
import { network } from "hardhat";
const { ethers } = await network.connect({ network: "testnet" });

async function main() {
  const contractAddress =
    process.env.CONTRACT_ADDRESS || "<your-deployed-contract-address>";
  if (!contractAddress) throw new Error("Set CONTRACT_ADDRESS env var!");
  const [signer] = await ethers.getSigners();

  const rebalancer = await ethers.getContractAt(
    "RebalancerCapacityAware",
    contractAddress,
    signer
  );

  const tx = await rebalancer.stopRebalancing();
  await tx.wait();

  console.log("Rebalancing stopped and schedule deleted.");
}

main().catch(console.error);
Run:
npx hardhat run scripts/stopRebalancing.ts --network testnet
Expected output:
Rebalancing stopped and schedule deleted.
What Happened
  1. stopRebalancing() called deleteSchedule(lastScheduleAddress)
  2. Pending scheduled transaction was canceled (best effort)
  3. config.active set to false
  4. Even if a scheduled rebalance() executes, the require(config.active) check prevents further scheduling
  5. Loop is fully stopped

Step 8: One-Shot Immediate Execution (Optional)

This demo shows how to use executeCallOnPayerSignature for a single, immediate function call. This method is not loopable due to Hedera’s recursion protection. Create demoImmediateExecution.ts:
scripts/demoImmediateExecution.ts
import { network } from "hardhat";
const { ethers } = await network.connect({ network: "testnet" });

async function main() {
  const contractAddress =
    process.env.CONTRACT_ADDRESS || "<your-deployed-contract-address>";
  if (!contractAddress) throw new Error("Set CONTRACT_ADDRESS env var!");
  const [signer] = await ethers.getSigners();

  const rebalancer = await ethers.getContractAt(
    "RebalancerCapacityAware",
    contractAddress,
    signer
  );

  const timestamp = Math.floor(Date.now() / 1000) + 60;
  const callData = rebalancer.interface.encodeFunctionData("demoAction", [
    12345
  ]);

  const tx = await rebalancer.demoImmediateExecution(timestamp, callData);
  const receipt = await tx.wait();

  console.log(
    "One-shot executeCallOnPayerSignature scheduled for",
    new Date(timestamp * 1000).toISOString()
  );
  if (receipt && Array.isArray(receipt.logs)) {
    receipt.logs.forEach((log: any) => {
      try {
        const parsed = rebalancer.interface.parseLog(log);
        if (parsed && parsed.name === "OneShotExecuted") {
          console.log("Schedule Address:", parsed.args.scheduleAddress);
          console.log("Method:", parsed.args.method);
        }
        if (parsed && parsed.name === "DemoActionExecuted") {
          console.log(
            "DemoActionExecuted:  caller",
            parsed.args.caller,
            "value",
            parsed.args.value.toString()
          );
        }
      } catch (_e) {}
    });
  }
}

main().catch(console.error);
Make sure the payer is set first, then run:
npx hardhat run scripts/setPayer.ts --network testnet
npx hardhat run scripts/demoImmediateExecution.ts --network testnet
With output like:
One-shot executeCallOnPayerSignature scheduled for 2025-12-22T21:13:53.000Z
Schedule Address: 0x00000000000000000000000000000000007294b1
Method: executeCallOnPayerSignature
You should see a DemoActionExecuted event emitted.

Step 9: Run Tests (Optional)

You can find both types of tests in the tutorial-hss-rebalancer-capacity-aware repository. You will find the following files: The repository includes both Solidity unit tests and TypeScript integration tests.

Solidity Unit Tests (contracts/RebalancerCapacityAware.t.sol)

These tests validate:
  • Initial state: Verifies contract deploys with inactive configuration
  • Payer configuration: Tests setting and changing the payer address
  • Scheduling method switching: Verifies switching between scheduleCall and scheduleCallWithPayer
  • Start/stop logic: Confirms only inactive rebalancers can be started and active ones can be stopped
  • Configuration validation: Ensures interval must be greater than zero
  • HBAR handling: Verifies contract can receive HBAR for funding scheduled operations
  • State management: Tests that rebalance count and timestamps are properly maintained

TypeScript Integration Tests (test/RebalancerCapacityAware.ts)

These tests run against Hedera testnet and validate:
  • Deployment and funding: Deploys with substantial HBAR balance and validates initial state
  • scheduleCall method: Tests automated recurring rebalancing with scheduleCall
  • scheduleCallWithPayer method: Tests automated recurring rebalancing with scheduleCallWithPayer (contract as payer)
  • executeCallOnPayerSignature: Demonstrates one-shot immediate execution
  • deleteSchedule: Verifies schedule deletion via stopRebalancing
  • Capacity awareness: Tests that the contract successfully finds available time slots using hasScheduleCapacity
  • Input validation: Tests error handling for invalid inputs
  • Scheduling method switching: Verifies switching between scheduling methods
Run the tests:
# Solidity unit tests
npx hardhat test solidity

# TypeScript integration tests against testnet
npx hardhat test mocha
You can also run both the solidity and mocha tests altogether:
npx hardhat test
Which should output something like:
Running Solidity tests

  contracts/RebalancerCapacityAware.t.sol:RebalancerCapacityAwareTest
 test_SwitchBetweenSchedulingMethods()
 test_StopRebalancing()
 test_SetSchedulingMethod()
 test_SetPayer()
 test_RevertWhen_SetSchedulingMethodWhileActive()
 test_RevertWhen_RebalanceNotActive()
 test_RevertWhen_IntervalIsZero()
 test_RevertWhen_AlreadyActive()
 test_ReceiveHBAR()
 test_PayerConfiguration()
 test_MultipleStartStopCycles()
 test_ManualRebalanceIncrementsCount()
 test_InitialState()
 test_ConfigUpdatesAfterStart()
 test_ConfigPersistsAcrossRebalances()

Running Mocha tests

  RebalancerCapacityAware - Comprehensive HSS Demo
Deployer: 0xe3c0743e01bE37c42B2ee57BD1aA30c9c266c0Ae
User: 0xe3c0743e01bE37c42B2ee57BD1aA30c9c266c0Ae
Contract deployed at: 0x2FA345Ad7609bc18d935e48D50F70dB8a1021Fcd
 should have correct initial state (223ms)
 should automate recurring rebalancing with scheduleCall (28027ms)
 should automate recurring rebalancing with scheduleCallWithPayer (contract as payer) (28267ms)
 should demonstrate executeCallOnPayerSignature as a one-shot (15515ms)
 should demonstrate deleteSchedule via stopRebalancing (26809ms)
 should demonstrate hasScheduleCapacity via capacity-aware scheduling (30746ms)
 should validate input and state transitions (28158ms)
 should support switching between scheduling methods (22519ms)
 should support payer configuration (6743ms)


  9 passing (3m)


15 passing (15 solidity)

Conclusion

You’ve built a sophisticated capacity-aware DeFi rebalancer that demonstrates advanced patterns with Hedera’s Schedule Service! In this tutorial, you learned how to:
  • Query network capacity using hasScheduleCapacity()
  • Implement exponential backoff with randomized jitter
  • Use Hedera’s PRNG for true on-chain randomness
  • Build self-sustaining loops that automatically reschedule
  • Choose between scheduling methods: scheduleCall vs scheduleCallWithPayer
  • Handle one-shot execution using executeCallOnPayerSignature
  • Handle network congestion gracefully
  • Cancel scheduled operations when needed

Key Takeaways

  • Capacity-aware scheduling prevents network congestion. Contracts cooperate with the network’s throttling model
  • Exponential backoff + jitter distributes load. Avoids “thundering herd” where all contracts compete for the same slot
  • True on-chain randomness via PRNG. No external oracles needed for jitter calculation
  • Multiple scheduling methods for different use cases. Use scheduleCall or scheduleCallWithPayer for recurring operations, executeCallOnPayerSignature for one-shots
  • This level of network awareness doesn’t exist on most EVM chains. Hedera enables truly intelligent on-chain automation

Real-World Applications

This pattern can be extended to:
  • DeFi Vaults: Automatic portfolio rebalancing based on price oracles
  • Liquidity Management: Periodic adjustment of AMM positions
  • Treasury Operations: Scheduled fund distributions or buybacks
  • Yield Optimization: Regular harvesting and compounding of rewards
  • DAO Governance: Time-delayed execution of approved proposals
All without relying on off-chain infrastructure or keeper networks!

Additional Resources

Writer: Kiran Pachhai, Developer Advocate