Skip to content

Protocol

Settlement rails

Every payout the treasury makes goes through one private function, _payout. On Hedera it asks the Hedera Token Service system contract to move the tokens; if the token is not an HTS entity, or HTS says no, it falls back to a plain ERC-20 transfer. Which rail ran is never guessed - the MicroSettlement event records it.

The rail logic lives in two files: contracts/HederaTokenServiceLib.sol, an internal library that wraps the system contract, and contracts/AetherisTreasury.sol (0x1036…5598), which decides when to use it. The library is inlined at compile time: no delegatecall, no separate deployment.

The HTS system contract at 0x167

On every Hedera network the token service is reachable as an EVM contract at 0x0000000000000000000000000000000000000167 (HederaTokenServiceLib.HTS_PRECOMPILE = address(0x167)). The library calls it with the selectors declared in contracts/interfaces/IHederaTokenService.sol: transferToken, transferFrom, associateToken, dissociateToken and isToken. Amounts on the transfer path are int64, Hedera's native integer; toInt64 range-checks a uint256 and reverts with HtsAmountOverflow above MAX_HTS_AMOUNT (type(int64).max).

int64 response codes, not booleans

HTS never returns a bool. Each call returns a signed 64-bit Hedera response code, and a low-level call that reports success == true only says the EVM frame did not revert - the token operation may still have failed. The library therefore decodes the code from returndata every time (_responseCode) and the safe* helpers revert with HtsCallFailed(selector, responseCode), carrying the raw code so it can be looked up in Hedera's ResponseCodeEnum.

Response codes the library treats specially
SUCCESS = 22The only code tryTransferToken accepts as a completed transfer.
UNKNOWN = 21Hedera's own UNKNOWN, reused by the library for 'no usable returndata': a failed call, fewer than 32 bytes returned, or a word outside the int64 range. Also returned by tryTransferToken for amounts above int64 max.
TOKEN_ALREADY_ASSOCIATED_TO_ACCOUNT = 194Accepted as success by safeAssociateToken so association is idempotent.

Library surface

contracts/HederaTokenServiceLib.sol
address internal constant HTS_PRECOMPILE = address(0x167);
int64   internal constant SUCCESS = 22;
int64   internal constant UNKNOWN = 21;
int64   internal constant TOKEN_ALREADY_ASSOCIATED_TO_ACCOUNT = 194;
uint256 internal constant MAX_HTS_AMOUNT = uint256(uint64(type(int64).max));

error HtsCallFailed(bytes4 selector, int64 responseCode);
error HtsAmountOverflow(uint256 amount);

// transfers
function transferToken(address token, address sender, address receiver, int64 amount)
    internal returns (int64 responseCode);                     // raw code, never reverts
function safeTransferToken(address token, address sender, address receiver, uint256 amount)
    internal;                                                  // reverts unless SUCCESS
function tryTransferToken(address token, address sender, address receiver, uint256 amount)
    internal returns (bool success, int64 responseCode);       // the dual-path primitive
function transferFrom(address token, address from, address to, uint256 amount)
    internal returns (int64 responseCode);

// association
function associateToken(address account, address token) internal returns (int64 responseCode);
function safeAssociateToken(address account, address token) internal returns (int64 responseCode);
function dissociateToken(address account, address token) internal returns (int64 responseCode);

// introspection (staticcall, usable from view)
function isHtsToken(address token) internal view returns (bool);
function isHtsAvailable() internal view returns (bool);

// numeric
function toInt64(uint256 amount) internal pure returns (int64);

isHtsToken issues isToken(token) as a staticcall and demands a full 64-byte (int64, bool) tuple with SUCCESS and true; anything else is false. isHtsAvailable is the same probe against the zero address and is what treasury.htsAvailable() exposes so the deploy script can print which rail a deployment will take.

The dual path in the treasury

contracts/AetherisTreasury.sol
function _payout(address token, address to, uint256 amount) private returns (bool viaHts) {
    if (htsEnabled && HederaTokenServiceLib.isHtsToken(token)) {
        (bool ok, int64 responseCode) =
            HederaTokenServiceLib.tryTransferToken(token, address(this), to, amount);
        if (ok) {
            return true;
        }
        emit HtsPayoutFallback(token, to, amount, responseCode);
    }

    IERC20(token).safeTransfer(to, amount);
    return false;
}

Three things decide the rail. htsEnabled is a master switch the owner can flip with setHtsEnabled(bool) - a circuit breaker, and the way to force ERC-20 payouts on a non-Hedera fork. isHtsToken must positively identify the token. And tryTransferToken must come back with SUCCESS. If any of the three fails the treasury drops to SafeERC20.safeTransfer; a failed HTS attempt additionally emits HtsPayoutFallback(token, to, amount, responseCode) with the raw code, so a downgrade is visible on chain rather than silent.

_payout is shared by settleSubAgent, refundEscrow, claimProfit and sweepSurplus, so refunds and margin claims ride the same rail as micro-payments.

viaHts

settleSubAgent returns _payout's result and emits it as the last field of the frozen event MicroSettlement(jobId, taskId, subAgent, token, amount, viaHts) (contracts/interfaces/IAetherisEvents.sol). The subgraph copies it onto the Settlement entity as viaHts: Boolean! (subgraph/schema.graphql), which is why the rail can be queried directly:

graphql
{ settlements(where: { viaHts: true }) { amount subAgent { id } } }

Association before receipt

A Hedera account cannot hold a token it has not associated with, and HTS refuses the transfer rather than creating the balance. Two parties need association for an HTS-settled job:

  • The treasury, because createJob moves the deposit into it. The owner calls treasury.associateToken(token) once per HTS token; it runs safeAssociateToken(address(this), token) and emits TokenAssociated(token, responseCode) with 22 or 194. scripts/deploy.js does this automatically when AETHERIS_HTS_TOKEN_ADDRESS is set, and scripts/seed.js (ensureTreasuryAssociated) checks the mirror node first and only sends the transaction when it is missing. On a chain without HTS the call reverts with HtsCallFailed, which is the correct signal that association does not apply there.
  • Every payee, because settleSubAgent credits them. The contract cannot associate on someone else's behalf. When a payee is not associated the HTS transfer fails, HtsPayoutFallback fires, and the ERC-20 fallback is refused by the token for the same reason, so settleJob reverts as a whole (test: reverts settlement when a sub-agent has not associated the HTS token, where MockHtsToken enforces association on both paths).

The tests associates the treasury through the precompile and is idempotent and cannot be funded with an HTS token before the treasury is associated in test/aetheris.test.js pin both rules down, using contracts/mocks/MockHtsPrecompile.sol installed at 0x167 with hardhat_setCode and contracts/mocks/MockHtsToken.sol, which models mandatory association.

How the seeded sub-agent accounts were created

The three Hedera-native payees were created once, with the Hedera SDK, by the first version of scripts/seed.js (git commit 93ea725): an AccountCreateTransaction with setKeyWithoutAlias(publicKey), a 1 HBAR initial balance and setMaxAutomaticTokenAssociations(-1). The -1 means unlimited automatic association: the account accepts any token sent to it without an explicit association handshake, which removes the per-payee step the treasury would otherwise depend on. Creating the key without an alias gives each account a long-zero EVM address (the account number in the last eight bytes), which is why they look the way they do in the subgraph. The current scripts/seed.js reuses these accounts from its KNOWN_SUB_AGENTS roster instead of provisioning new ones.

Measured gas

Both rails have been exercised on testnet with the same contracts. From the seeded lifecycle (README.md):

Gas per settlement rail
Hedera Token Service2,360,527 gas. Token aUSD 0.0.10484673 (0x0000…fBC1, 6 decimals). 6 settlements on chain with viaHts = true (jobs 1 and 5).
ERC-20 fallback229,111 gas. Token aUSDC 0x21DC…5961 (test ERC-20, 6 decimals, open mint). 4 settlements on chain with viaHts = false (jobs 2 and 6).

The roughly tenfold difference is the system contract doing real work rather than an EVM storage write. It is also why the scripts set explicit limits: scripts/seed.js uses a 1,000,000 gas limit for createJob and associateToken (HTS_GAS) and 3,000,000 for settleJob, and lib/write.ts sends refundJob from the browser with 1,000,000.

Off Hedera

Nothing in the contracts assumes Hedera. On a chain where 0x167 is empty, isHtsToken returns false, _payout never touches HTS and every settlement is a plain ERC-20 transfer with viaHts = false. The test wires the treasury to the agency and reports HTS as unavailable off-Hedera checks exactly that, and still uses the ERC-20 fallback for non-HTS tokens on a Hedera-like chain checks the reverse: with the mock precompile installed, a non-HTS token still takes the ERC-20 route.