IO.NET ~ IDE Tokenomics
Smart Contract Security Assessment
May 11, 2026
May 19, 2026 (revision)

SUMMARY
ABSTRACT
Dedaub was commissioned to perform a security audit of IO.NET’s IDE (Incentive Dynamic Engine) Tokenomics protocol. Some medium and low-severity issues were identified. All of them were properly addressed or acknowledged by the team providing detailed reasoning around each item.
[Revision] After the initial review and the first fixes, some more instructions (burn and payout_reserve) were introduced to the protocol and were requested to be included in the review. Only a few small considerations were raised, all of which were properly addressed.
BACKGROUND
This protocol is a permissioned settlement system for io.net GPU supplier payments. It organizes payments into settlement periods, tracks each period’s lifecycle, and allows approved operators to send IO token payments to suppliers. The program does not calculate supplier work or payment amounts. Those decisions are made off-chain. On-chain, the program mainly enforces operational controls, such as approved signers, configured payment wallets, duplicate-payment prevention, and supplier wallet validation. The protocol relies on trusted roles:
- An administrative authority manages the configuration
- An operational signer runs the day-to-day settlement
- A service signer manages supplier wallet records
Suppliers do not initiate payments themselves. As a result, the program is a controlled execution layer for a trusted settlement process.
SETTING & CAVEATS
This audit report mainly covers the contracts of the at-the-time private ionet-official/tokenomics_contract repository of the IO.NET’s IDE Tokenomics protocol at commit 60671f407e8f5c1f952144c2caa6ade88a758134.
Audit Start Date: May 06, 2026
Report Submission Date: May 11, 2026
Report Revision Date: May 19, 2026
Three auditors worked on the following contracts:
As part of the audit, we also reviewed the fixes of the issues included in the report, which were delivered as part of PR #3, merged in main at commit 622dfd1c05f3681ef2cfed2025d178795a72394f and we found that they have been implemented correctly.
[Revision - 19/05/2026]
The changes of the additional instructions were delivered at commit 76c4f72896c2f9c9761165668452f93c72f914ea.
Additional fixes for some remaining items were delivered as part of PR #11, merged in main at commit cf4d75e2016bcaabf35d073cfa1c441749ed4b9c. Only a few additional questions were raised, which the team resolved. The final fixes were delivered as part of PR #13, merged in main at commit 10914096b8bcd1c66b3f20f17f4c3e1ba2fb3826 and we found that they all have been implemented correctly.
The audit’s main target is security threats, i.e., what the community understanding would likely call "hacking", rather than the regular use of the protocol. Functional correctness (i.e. issues in "regular use") is a secondary consideration. Typically it can only be covered if we are provided with unambiguous (i.e. full-detail) specifications of what is the expected, correct behavior. In terms of functional correctness, we often trusted the code’s calculations and interactions, in the absence of any other specification. Functional correctness relative to low-level calculations (including units, scaling and quantities returned from external protocols) is generally most effectively done through thorough testing rather than human auditing.
VULNERABILITIES & FUNCTIONAL ISSUES
This section details issues affecting the functionality of the contract. Dedaub generally categorizes issues according to the following severities, but may also take other considerations into account such as impact or difficulty in exploitation:
- User or system funds can be lost when third-party systems misbehave
- DoS, under specific conditions
- Part of the functionality becomes unusable due to a programming error
- Breaking important system invariants but without apparent consequences
- Buggy functionality for trusted users where a workaround exists
- Security issues which may manifest when the system evolves
Issue resolution includes “dismissed” or “acknowledged” but no action taken, by the client, or “resolved”, per the auditors.
CRITICAL SEVERITY
[No critical severity issues]
HIGH SEVERITY
[No high severity issues]
MEDIUM SEVERITY
Resolved
Hardcoded EXPECTED_DEPLOYER pubkey as a constraint on the payer account. Only the authorized deployer can call initialize. Authority remains a Pubkey argument (cold key does not need to sign init).
(commit: 4990c36)
The program uses a singleton IdeConfig PDA derived from a fixed seed. This account is created by initialize, but the only required signer in the initialization context is the payer. The actual cold authority and hot operational signers are not signer accounts. They are instruction arguments supplied by whoever sends the first successful initialization transaction.
instructions/initialize.rs::initialize:17-53#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(mut)]
pub payer: Signer<'info>,
#[account(
init,
payer = payer,
space = 8 + IdeConfig::INIT_SPACE,
seeds = [b"ide_config"],
bump,
)]
pub ide_config: Box<Account<'info, IdeConfig>>,
...
}
The handler accepts the role keys as plain Pubkey parameters:
instructions/initialize.rs::create():61-70pub fn create(
ctx: Context<Self>,
authority: Pubkey,
active_signer: Pubkey,
service_signer: Pubkey,
...
) -> Result<()> {}
After basic nonzero and key-separation checks, the supplied keys are stored directly into the singleton config:
instructions/initialize.rs::create():108-125let ide_config = &mut ctx.accounts.ide_config;
ide_config.authority = authority;
ide_config.active_signer = active_signer;
ide_config.service_signer = service_signer;
Those stored values gate later privileged operations. For example, cold-admin updates require the stored authority:
instructions/update_active_signer.rs::UpdateActiveSigner:09-19#[derive(Accounts)]
pub struct UpdateActiveSigner<'info> {
pub authority: Signer<'info>,
#[account(
mut,
seeds = [b"ide_config"],
bump = ide_config.bump,
constraint = ide_config.authority == authority.key()
@ IdeError::UnauthorizedAuthority,
)]
pub ide_config: Box<Account<'info, IdeConfig>>,
}
Hot operational flows require the stored active_signer, such as in the example below:
instructions/reward.rs::active_signer:20-25#[account(
mut,
constraint = active_signer.key() == ide_config.active_signer
@ IdeError::UnauthorizedActiveSigner,
)]
pub active_signer: Signer<'info>,
Supplier wallet registry management requires the stored service_signer:
instructions/update_supplier_wallet.rs::UpdateSupplierWallet:11-15#[account(
mut,
constraint = service_signer.key() == ide_config.service_signer
@ IdeError::UnauthorizedServiceSigner,
)]
pub service_signer: Signer<'info>,
As a result, if the canonical IdeConfig PDA has not yet been initialized, any payer can initialize it first and choose the authority, active signer, service signer, mint, and configured token wallets. Because the account is created with Anchor init, later attempts to initialize the legitimate configuration will fail.
Resolved
update_supplier_walletwithis_active=falsenow removes the entry entirely (shift + decrement entry_count), freeing the slot.- Added
delete_wallet_registryinstruction (service_signer) to close and recreate registry with new capacity. - Added
update_max_registry_capacityinstruction (cold authority) with bounds validation (250 – 150,000). - Added
create_supplier_wallet_registryinstruction (service_signer) for re-creation after delete. (commits:c5c5f8a,c47f1ff,6624ced,969d993)
The global supplier wallet registry has a fixed maximum capacity that is copied from IdeConfig when the registry is created:
instructions/supplier_wallet_registry.rs::insert():82-84let len = self.entry_count as usize;
let cap = self.capacity as usize;
require!(len < cap, IdeError::WalletRegistryFull);
The registry tracks the number of populated entries separately from the current physical allocation and the configured maximum. When a new supplier wallet is registered, the program inserts a new sorted entry and increments entry_count. If the current physical allocation is full, the grow helper reallocates the registry account. However, growth stops permanently once the physical capacity reaches the registry’s fixed max_capacity.
The program supports disabling an existing supplier wallet entry, but this only updates the is_active flag:
state/supplier_wallet_registry.rs::update():140-143if let Some(active) = new_is_active {
entries[idx].is_active = u8::from(active);
}
It does not remove the entry, decrement entry_count, compact the registry, or make the slot reusable. Therefore, inactive supplier entries still consume registry capacity.
There is also no instruction to increase max_capacity after the wallet registry is created. Although the cold authority can update some config fields, the wallet registry stores its own max_capacity, and the program does not expose a migration, resize, delete, or recreate path for the singleton wallet registry.
As a result, once entry_count == max_capacity, all future register_supplier_wallet calls fail with WalletRegistryFull, even if many existing supplier entries have been disabled.
LOW SEVERITY
Resolved
SupplierWalletRegistry.insert()now checksentry_count < max_capacity(the hard upper bound) before checking physical capacity.- This check was deemed redundant in a later review and was removed in PR #11 at commit
bc25293. initializevalidatesmax_registry_capacity >= INITIAL_REGISTRY_ENTRIESand>= INITIAL_WALLET_ENTRIES.SupplierRegistry(per-epoch dedup) does not storemax_capacityin its header - the growth ceiling is enforced byensure_registry_capacity()which readsmax_registry_capacityfromIdeConfig. Setting it below INITIAL is prevented by the new bounds validation. (commits:07995ee,6624ced)
The program exposes max_registry_capacity as a configuration value during initialization. The only validation is that the value is nonzero:
instructions/initialize.rs::create():103-106,119require!(
max_registry_capacity > 0,
IdeError::InvalidMaxRegistryCapacity
);
ide_config.max_registry_capacity = max_registry_capacity;
However, both registry account types are created with fixed initial physical capacities that do not depend on max_registry_capacity:
- The per-epoch supplier operation registry starts with 250 entries:
state/supplier_registry.rs:09pub const INITIAL_REGISTRY_ENTRIES: u32 = 250;
When a per-epoch registry is created, its physical capacity is set to this fixed value:
instructions/create_registry.rs::create_registry:55-60let mut reg = ctx.accounts.registry.load_init()?;
reg.epoch_id = epoch_id;
reg.entry_count = 0;
reg.capacity = INITIAL_REGISTRY_ENTRIES;
reg.protocol_fee_paid = 0;
reg.bump = bump;
- The global supplier wallet registry starts with 100 entries:
state/supplier_wallet_registry.rs::create():09pub const INITIAL_WALLET_ENTRIES: u32 = 100;
When the wallet registry is created, its physical capacity is set to 100, while max_capacity is copied from IdeConfig.max_registry_capacity:
instructions/create_supplier_wallet_registry.rs::create():44-53let max_cap = ctx.accounts.ide_config.max_registry_capacity;
let bump = ctx.bumps.wallet_registry;
{
let mut reg = ctx.accounts.wallet_registry.load_init()?;
reg.entry_count = 0;
reg.capacity = INITIAL_WALLET_ENTRIES;
reg.max_capacity = max_cap;
reg.bump = bump;
}
The grow helpers only check the configured maximum after the current physical capacity has already been filled. If there is still room in the existing account, the helper returns before checking max_registry_capacity:
instructions/transfer_helper.rs::ensure_registry_capacity():49-60let (entry_count, capacity) = {
let data = registry_info.try_borrow_data()?;
let header: &SupplierRegistry =
bytemuck::from_bytes(
&data[8..8 + std::mem::size_of::<SupplierRegistry>()]);
(header.entry_count, header.capacity)
};
if entry_count < capacity {
return Ok(());
}
require!(capacity < max_registry_capacity, IdeError::RegistryFull);
The wallet registry has the same pattern:
instructions/transfer_helper.rs::ensure_wallet_registry_capacity():101-112let (entry_count, capacity, max_capacity) = {
let data = registry_info.try_borrow_data()?;
let header: &SupplierWalletRegistry =
bytemuck::from_bytes(
&data[8..8 + std::mem::size_of::<SupplierWalletRegistry>()]);
(header.entry_count, header.capacity, header.max_capacity)
};
if entry_count < capacity {
return Ok(());
}
require!(capacity < max_capacity, IdeError::WalletRegistryFull);
Insertion itself checks the physical capacity, not the configured cap. For the per-epoch supplier registry:
state/supplier_registry.rs::insert():84-86let len = self.entry_count as usize;
let cap = self.capacity as usize;
require!(len < cap, IdeError::RegistryFull);
For the supplier wallet registry:
state/supplier_wallet_registry.rs::insert():82-84let len = self.entry_count as usize;
let cap = self.capacity as usize;
require!(len < cap, IdeError::WalletRegistryFull);
As a result, if max_registry_capacity is initialized below the fixed initial capacities, the configured cap is not enforced as a true entry limit. This creates confusion and inconsistent behavior. The configuration value is documented and exposed as a registry capacity limit, but for low values, it behaves only as a growth limit, not as an actual maximum number of entries. This can weaken operator assumptions around rent exposure, settlement capacity, and blast-radius limits.
Resolved
All three transfer instructions now enforce max_total_epoch_amount as a per-source-wallet epoch cap:
-
reward:
total_reward + amount <= max_total_epoch_amount(source: reward_wallet) -
payout:
(total_payout + total_protocol_fee) + amount <= max_total_epoch_amount(source: escrow_wallet) -
protocol_fee:
(total_payout + total_protocol_fee) + amount <= max_total_epoch_amount(source: escrow_wallet) Payout and protocol_fee share the escrow_wallet, so their cumulative totals are checked together.service_total_drainedandServiceDrainCapExceededremain reserved for futureadmin_force_*instructions. New error:EpochTransferCapExceeded. (commit:d27fa95)
The program stores both a per-transfer limit and a per-epoch aggregate limit in IdeConfig:
state/ide_config.rs::IdeConfig:41-45pub max_transfer_amount: u64,
/// Per-epoch cumulative transfer cap in IO lamports.
/// Reserved for future enforcement.
pub max_total_epoch_amount: u64,
Initialization requires both values to be nonzero, and requires the per-transfer limit to be no greater than the configured aggregate limit:
instructions/initialize.rs::create():94-102require!(max_transfer_amount > 0, IdeError::InvalidMaxTransferAmount);
require!(
max_total_epoch_amount > 0,
IdeError::InvalidMaxTotalEpochAmount
);
require!(
max_transfer_amount <= max_total_epoch_amount,
IdeError::InvalidMaxTransferAmount
);
The program also exposes authority-gated updates for both values and preserves the same relationship between them. Updating the per-transfer cap requires it to remain less than or equal to max_total_epoch_amount:
instructions/update_max_transfer_amount.rs::update():30-41require!(new_max > 0, IdeError::InvalidMaxTransferAmount);
require!(
new_max <= ide_config.max_total_epoch_amount,
IdeError::InvalidMaxTransferAmount
);
ide_config.max_transfer_amount = new_max;
Updating the aggregate cap requires it to remain greater than or equal to max_transfer_amount:
instructions/update_max_total_epoch_amount.rs::update():30-41require!(new_max > 0, IdeError::InvalidMaxTotalEpochAmount);
require!(
new_max >= ide_config.max_transfer_amount,
IdeError::InvalidMaxTotalEpochAmount
);
ide_config.max_total_epoch_amount = new_max;
This can make operators expect max_total_epoch_amount to be an active per-epoch settlement limit. However, the normal settlement handlers enforce only max_transfer_amount for each individual transfer. The reward handler checks only the single transfer amount:
instructions/reward.rs::transfer():98-103require!(amount > 0, IdeError::ZeroTransferAmount);
require!(
amount <= ctx.accounts.ide_config.max_transfer_amount,
IdeError::TransferAmountExceedsMax,
);
It then performs the token transfer and increments only epoch.total_reward:
instructions/reward.rs::transfer():131-145transfer_checked_cpi(
ctx.accounts.token_program.to_account_info(),
ctx.accounts.reward_wallet.to_account_info(),
ctx.accounts.io_token_mint.to_account_info(),
ctx.accounts.supplier_token_account.to_account_info(),
ctx.accounts.reward_wallet_authority.to_account_info(),
amount,
decimals,
)?;
let epoch = &mut ctx.accounts.epoch;
epoch.total_reward = epoch
.total_reward
.checked_add(amount)
.ok_or(IdeError::Overflow)?;
The payout handler has the same structure. It enforces the per-transfer maximum. Then it transfers tokens and increments only epoch.total_payout. The protocol-fee handler also checks only the individual transfer amount. It then performs the token transfer and increments only epoch.total_protocol_fee.
None of these handlers checks whether the cumulative epoch settlement amount remains below ide_config.max_total_epoch_amount.
As a result, multiple individually valid transfers can exceed the configured aggregate amount. For example, if both limits are set to 100, a reward of 100, a payout of 100, and a protocol_fee of 100 can all pass their per-transfer checks in the same epoch. The epoch would then record a cumulative settlement of 300, while max_total_epoch_amount is 100.
The source comments suggest that max_total_epoch_amount may be reserved for future enforcement. The error enum also refers to future cumulative service-drain enforcement.
If this field is intentionally reserved and not meant to apply to normal settlement, the current behavior is mainly a configuration and documentation mismatch. However, if operators rely on max_total_epoch_amount as an active per-epoch cap, it does not bound settlement executed through reward, payout, or protocol_fee.
Acknowledged
The team indicated that this is by design. The program is a controlled execution layer for a trusted settlement process. The backend determines when settlement is complete - the on-chain program cannot know how many suppliers should be paid or what the expected totals are, as these are off-chain. The active signer is trusted to finalize epochs only after settlement is complete.
Normal reward, payout, and protocol-fee settlement is only allowed while an epoch is in Closing. For example, reward and payout both require:
instructions/rewards.rs::Reward:36constraint = epoch.status == EpochStatus::Closing
@ IdeError::EpochNotClosing,
The protocol-fee instruction has the same “Closing”-only restriction. However, close_epoch can mark a Closing epoch as Closed without checking that settlement is complete.
There is no check for expected supplier count, expected reward total, expected payout total, protocol-fee payment, a settlement manifest, or approval from another authority. Once the epoch is Closed, normal reward, payout, and protocol-fee transfers for that epoch can no longer execute.
The protocol-fee path is also not enforced before close. The registry tracks whether the fee has been paid and protocol_fee sets that flag after accepting a positive amount.
But close_epoch does not require protocol_fee_paid == 1, does not require a minimum fee amount, and does not support an explicit fee waiver. Therefore, if protocol fees are mandatory, an epoch can be closed with no fee, and a simple paid-flag check would still need an expected amount or waiver predicate to distinguish a valid fee from a dust fee.
This is bounded by the active signer trust model. The active signer is authorized to manage epoch lifecycle, but if it should not be able to unilaterally decide that settlement is complete, close_epoch needs an additional finality condition.
Acknowledged
The team indicated that this is by design. The deduplication registry enforces execution uniqueness, not settlement correctness. Corrections for incorrect amounts will be handled through admin_force_reward / admin_force_payout instructions (planned, not yet implemented), which bypass deduplication and require an audit memo.
Reward and payout enforce at-most-once execution per (supplier_id, operation) in each epoch. The reward flow records a supplier as processed by inserting (supplier_id, Reward) into the per-epoch registry:
instructions/rewards.rs::transfer():127header.insert(entry_region, supplier_id, OperationType::Reward as u8)?;
The payout flow records a supplier as processed by inserting (supplier_id, Payout):
instructions/payout.rs::transfer():127header.insert(entry_region, supplier_id, OperationType::Payout as u8)?;
After an entry is inserted, another transfer with the same supplier ID and operation type in the same epoch is rejected as a duplicate.
However, the amount is not checked against any on-chain expected amount. The transfer amount only needs to be positive and below the per-transfer cap:
instructions/payout.rs::transfer():98-102require!(amount > 0, IdeError::ZeroTransferAmount);
require!(
amount <= ctx.accounts.ide_config.max_transfer_amount,
IdeError::TransferAmountExceedsMax,
);
As a result, any accepted positive amount marks that supplier operation as completed. If a supplier is paid an incorrect amount, including a dust amount, the normal settlement path cannot later correct that same reward or payout in the same epoch because the registry already contains the (supplier_id, operation) key.
This issue exists even before the epoch is closed. For example, if a supplier should receive a payout of 1_000_000 but receives 1, the (supplier_id, Payout) slot is consumed. A later attempt to pay the remaining amount through payout for the same supplier and epoch will fail as a duplicate.
This does not create a permissionless denial of service. The transfer still requires the active signer and the relevant source token-account authority. The issue is that the registry enforces execution uniqueness, not settlement correctness. The program can prove that a supplier operation happened once, but it cannot prove that the operation used the intended amount.
This is especially relevant because the README references future admin override instructions, but the current program entrypoints do not expose a correction flow that can bypass deduplication.
L5
Rewards and Payouts require the supplier wallet registry account, even when the wallet validation is disabled
Resolved
The wallet registry is now created atomically during initialize, guaranteeing it exists before any settlement. The validation toggle controls runtime whitelist checking, not account existence.
As part of the fixes for M2, the delete_wallet_registry instruction was introduced that allows deleting the SupplierWalletRegistry seemingly making the issue possible to manifest again. However, delete_wallet_registry is a service_signer operation. Between delete and recreate, the program is intentionally non-operational. This is an expected maintenance window, and not a regression that could be used to abuse the protocol. The service_signer is a trusted role.
The program allows the cold authority to disable supplier wallet validation. The reward and payout handlers then skip the runtime whitelist check when wallet_validation_enabled == 0.
instructions/reward.rs::Reward:107-113if ctx.accounts.ide_config.wallet_validation_enabled != 0 {
validate_supplier_wallet(
&ctx.accounts.wallet_registry.to_account_info(),
supplier_id,
ctx.accounts.supplier_token_account.key(),
)?;
}
However, both instructions still require the wallet_registry account unconditionally in their Anchor account contexts:
instructions/reward.rs::Reward:75-80#[account(
seeds = [b"supplier_wallet_registry"],
bump = wallet_registry.load()?.bump,
)]
pub wallet_registry: AccountLoader<'info, SupplierWalletRegistry>,
Anchor validates and deserializes this account before entering the instruction handler. Therefore, even when validation is disabled, the transaction must still provide an initialized supplier wallet registry PDA. If the registry has not been created yet, reward and payout fail before the code reaches the branch that skips validate_supplier_wallet.
This means the validation toggle does not fully remove the wallet-registry dependency. In bootstrap or emergency scenarios, operators may disable validation expecting transfers to proceed without the registry, but the account-level requirement still blocks those transfers until the registry PDA exists.
Resolved
Added require! checks in reward and payout that the supplier destination account differs from the source wallet.
(commit: 0939d43)
The reward and payout instructions validate their source and destination token accounts independently, but they do not require them to be different accounts.
The reward source must match the configured reward wallet:
instructions/reward.rs::Reward:51-60#[account(
mut,
constraint = reward_wallet.key() == ide_config.reward_wallet
@ IdeError::InvalidSourceWallet,
constraint = reward_wallet.mint == ide_config.io_token_mint
@ IdeError::InvalidSourceWallet,
constraint = reward_wallet.owner == reward_wallet_authority.key()
@ IdeError::InvalidSourceWallet,
)]
pub reward_wallet: Box<Account<'info, TokenAccount>>,
The supplier destination only needs to be an IO token account:
instructions/reward.rs::Reward:62-67#[account(
mut,
constraint = supplier_token_account.mint == ide_config.io_token_mint
@ IdeError::InvalidSupplierTokenAccount,
)]
pub supplier_token_account: Box<Account<'info, TokenAccount>>,
When wallet validation is enabled, the whitelist helper checks that the registered supplier wallet equals the supplied destination account. However, it does not check that the supplier destination differs from the configured source wallet.
The reward handler then performs the token CPI and increments the epoch’s reward total. The payout flow has the same issue. The escrow wallet is validated as the source. The supplier destination is independently validated as an IO token account. After the CPI, the payout total is incremented.
For classic SPL Token transfers, a self-transfer is valid and returns success without changing balances. The token processor detects source == destination and exits before modifying token amounts.
Therefore, if the supplier destination is the same token account as the reward wallet or escrow wallet, the CPI can succeed while no token balance changes. The program will still insert the supplier operation into the registry, increment epoch.total_reward or epoch.total_payout, and emit the corresponding settlement event.
The path still requires the active signer and the source token account authority. With wallet validation enabled, it also requires a supplier wallet mapping that points to the source wallet. With validation disabled, the caller can supply the source wallet directly as the supplier destination. The impact is incorrect settlement accounting: the epoch can record a reward or payout as completed even though no tokens left the source account.
Resolved
The README (L174-175) incorrectly stated that max_transfer_amount applies to all transfers. This has been corrected in f1e33c3 to explicitly exclude burn from the per-transfer cap.
The burn.test.ts -> R2 test that asserted TransferAmountExceedsMax and was a false positive, has been removed and replaced with proper tests that verify the actual protection - EpochTransferCapExceeded:
-
R2: single burn exceeding max_total_epoch_amount is rejected R3: sequential burns that cumulatively exceed the cap are rejected(commit236e57f)
In burn.rs, which was introduced in 76c4f72, in the transfer function, the max_transfer_amount is not enforced. Each burn is only limited by the per-epoch max_total_epoch_amount, compared to all other similar instructions (reward, payout, payout_reserve, protocol_fee) that explicitly enforce each transferred amount to be capped by the per-transfer limit.
Moreover, the README and the relevant tests found in tests/burn.test.ts also suggest that the intention was to have the max_transfer_amount limit applied.
README.md:28 & 174-175Transfers are only permitted during **Closing** state. Each supplier can receive at most one reward, one payout, and one payout_reserve per epoch (enforced by on-chain dedup). The protocol fee runs exactly once per epoch. Burns can run multiple times per epoch (no dedup).
...
- **Per-transfer cap** (`max_transfer_amount`) — rejects any single transfer exceeding the configured limit
- **Per-epoch source wallet cap** (`max_total_epoch_amount`) — limits cumulative transfers per source wallet per epoch (burn amounts count toward escrow cap, payout_reserve toward reserve cap)
tests/burn.test.ts:99-116it("R2: rejects amount above max_transfer_amount", async () => {
try {
await program.methods
.burn(EPOCH_ID, TEST_MAX_TRANSFER_AMOUNT.add(new anchor.BN(1)))
.accounts({
activeSigner: activeSigner.publicKey,
escrowWalletAuthority: escrowWalletOwner.publicKey,
escrowWallet: escrowWalletAta,
burnWallet: burnWalletAta,
ioTokenMint: ioTokenMint,
})
.signers([activeSigner, escrowWalletOwner])
.rpc();
expect.fail("expected TransferAmountExceedsMax");
} catch (err: any) {
expect(err.toString()).to.include("TransferAmountExceedsMax");
}
});
CENTRALIZATION ISSUES
It is often desirable for DeFi protocols to assume no trust in a central authority, including the protocol’s owner. Even if the owner is reputable, users are more likely to engage with a protocol that guarantees no catastrophic failure even in the case the owner gets hacked/compromised. We list issues of this kind below. (These issues should be considered in the context of usage/deployment, as they are not uncommon. Several high-profile, high-value protocols have significant centralization threats.)
Acknowledged
The team indicated that this is by design. The protocol is explicitly a permissioned settlement layer with trusted roles. Three-key separation (cold authority, hot active_signer, service_signer) limits blast radius.
The protocol is permissioned and relies on a small set of privileged roles and externally controlled token-wallet authorities. The cold authority can change critical configuration, including signers and settlement wallets. The active signer controls epoch progression and can decide when settlement starts and ends. The service signer controls supplier wallet registry entries. In addition, the authorities of the configured reward and escrow token wallets retain direct control over the funds and can move tokens outside the program flow. As a result, compromise or misuse of these privileged keys can disrupt settlement, alter configuration, misroute payments, bypass the intended operational flow, or move funds directly.
OTHER / ADVISORY ISSUES
This section details issues that are not thought to directly affect the functionality of the project, but we recommend considering them.
Acknowledged
The team indicated that this is by design. Epoch accounts do not snapshot configuration at open time. Immediate application allows operational flexibility (e.g., emergency wallet rotation applies immediately to in-flight settlement). Operators are aware that config changes are effective immediately.
The program stores epoch duration and settlement wallet addresses as global configuration in IdeConfig:
instructions/reward.rs::transfer():20-27,39/// Source token account for reward transfers.
pub reward_wallet: Pubkey,
/// Source token account for payouts and protocol fees.
pub escrow_wallet: Pubkey,
/// Destination token account for protocol fees.
pub protocol_fee_wallet: Pubkey,
/// Minimum slots an epoch must stay Open before transition_to_closing.
pub epoch_duration_slots: u64,
When an epoch is opened, the epoch account stores the opening slot and accounting totals, but it does not snapshot the duration or the configured wallet addresses that were active at the time of opening. The cold authority can update the global epoch duration via the update() function. When an open epoch transitions to Closing, the program reads the current global duration from IdeConfig. This means an epoch opened under one duration can later be evaluated under another duration. For example, an epoch opened when epoch_duration_slots = 100 can become eligible for transition earlier if the authority changes the global duration to 1 while that epoch is still open.
The cold authority can also rotate configured settlement wallets. Settlement instructions validate accounts against the current global wallet pointers, not wallet addresses stored in the epoch. Reward requires the source wallet to match the current global reward wallet. Payout requires the source wallet to match the current global escrow wallet. Protocol fee also uses the current global escrow and protocol-fee wallets.
As a result, wallet rotations affect epochs that are already open or closing. An epoch opened under one reward, escrow, or protocol-fee wallet configuration must settle against whatever wallet configuration is live at settlement time.
This creates an important lifecycle semantic. If operators expect epoch parameters to be fixed once an epoch is opened, the current implementation does not enforce that. Duration changes can alter transition timing for already-open epochs, and wallet rotations can change the accounts required to settle already-closing epochs. There could be cases in which this behavior is as expected, but we wanted to raise it for visibility and awareness.
Acknowledged
In production, close_epoch will be called with close_registry = true by default, reclaiming SupplierRegistry rent on every epoch close. EpochAccount PDAs are retained permanently as on-chain historical records - this is intentional. They remain directly readable by RPC clients without relying on off-chain event indexing (see also A7).
Per-epoch supplier registries are created and rent-funded by the active signer. The registry account uses active_signer as the payer:
instructions/create_registry.rs::CreateRegistry:14-44#[derive(Accounts)]
#[instruction(epoch_id: u64)]
pub struct CreateRegistry<'info> {
#[account(
mut,
constraint = active_signer.key() == ide_config.active_signer
@ IdeError::UnauthorizedActiveSigner,
)]
pub active_signer: Signer<'info>,
#[account(
init,
payer = active_signer,
space = INITIAL_REGISTRY_SPACE,
seeds = [b"supplier_registry".as_ref(),
epoch_id.to_le_bytes().as_ref()],
bump,
)]
pub registry: AccountLoader<'info, SupplierRegistry>,
pub system_program: Program<'info, System>,
}
When the epoch is finalized, close_epoch accepts a close_registry flag. The epoch is marked as closed regardless of whether the registry is closed. The registry lamports are drained only when close_registry is true. If close_registry is false, the registry remains funded. However, there is no separate instruction to close it later. A second call to close_epoch cannot be used for cleanup because the account constraints require the epoch to still be in Closing status.
Once the epoch is Closed, the preserved registry rent cannot be recovered through the current program interface. The refund recipient is also tied to the active signer at close time, not the signer who funded the registry. The cold authority can rotate the active signer.
Therefore, active signer A can fund a registry, the authority can rotate the active signer to B, and B can later receive the registry rent refund when closing the epoch.
This does not affect IO token custody and is bounded to SOL lamports held in registry accounts. It may be intentional if registry preservation is used for permanent on-chain audit history and if rent refunds are treated as operational treasury funds. However, the current behavior should be explicit because preserved registries cannot be cleaned up later, and refund ownership changes with active signer rotation.
Resolved
Supplier wallet registration stores a supplier-to-wallet mapping, but the registered wallet is passed as a raw Pubkey:
lib.rs::register_supplier_wallet():110-117pub fn register_supplier_wallet(
ctx: Context<RegisterSupplierWallet>,
supplier_id: UUID,
wallet: Pubkey,
) -> Result<()> {
RegisterSupplierWallet::register(ctx, supplier_id, wallet)
}
The registration handler rejects only an all-zero supplier ID and the zero pubkey:
instructions/register_supplier_wallet.rs::register():38-66pub fn register(
ctx: Context<Self>, supplier_id: UUID, wallet: Pubkey
) -> Result<()> {
require!(supplier_id != [0u8; 16], IdeError::InvalidSupplierId);
require!(wallet != Pubkey::default(), IdeError::InvalidWalletAddress);
ensure_wallet_registry_capacity(
&ctx.accounts.wallet_registry.to_account_info(),
&ctx.accounts.service_signer.to_account_info(),
&ctx.accounts.system_program.to_account_info(),
)?;
{
let registry_info =
ctx.accounts.wallet_registry.to_account_info();
let mut data = registry_info.try_borrow_mut_data()?;
let (header_bytes, entry_region) =
data.split_at_mut(WALLET_REGISTRY_HEADER_SIZE);
let header: &mut SupplierWalletRegistry =
bytemuck::from_bytes_mut(&mut header_bytes[8..]);
header.insert(entry_region, supplier_id, wallet)?;
}
Ok(())
}
The update flow has the same model. It accepts an optional raw pubkey and rejects only the zero address. Reward and payout later require the supplied destination account to be an SPL token account for the configured IO mint, and the whitelist helper requires the supplied destination key to match the registered wallet.
Therefore, registering a non-token account or a token account for the wrong mint does not allow an invalid transfer to succeed. The later reward or payout will fail at account validation or wallet matching. However, it can create an operational denial of service for that supplier while wallet validation is enabled: settlement cannot proceed until the service signer corrects the mapping.
Resolved
The reward, payout and payout_fee instructions require two signer accounts: the operational active_signer and the SPL token account authority for the source wallet.
In the reward flow:
instructions/reward.rs::Reward:19-27#[account(
mut,
constraint = active_signer.key() == ide_config.active_signer
@ IdeError::UnauthorizedActiveSigner,
)]
pub active_signer: Signer<'info>,
pub reward_wallet_authority: Signer<'info>,
The configured reward wallet must be owned by the provided reward wallet authority:
instructions/reward.rs::Reward:51-60#[account(
mut,
constraint = reward_wallet.key() == ide_config.reward_wallet
@ IdeError::InvalidSourceWallet,
constraint = reward_wallet.mint == ide_config.io_token_mint
@ IdeError::InvalidSourceWallet,
constraint = reward_wallet.owner == reward_wallet_authority.key()
@ IdeError::InvalidSourceWallet,
)]
pub reward_wallet: Box<Account<'info, TokenAccount>>,
The payout and payout_fee flows follow the same pattern for the escrow wallet. However, the programs do not require the source wallet authority to be distinct from the active signer.
// No check equivalent to:
require_keys_neq!(
active_signer.key(),
reward_wallet_authority.key(),
IdeError::KeySeparationRequired
);
The initialization flow also stores configured source wallets without requiring their SPL token account owner to differ from the configured active signer:
instructions/initialize.rs::create:110,113-114ide_config.active_signer = active_signer;
ide_config.reward_wallet = ctx.accounts.reward_wallet.key();
ide_config.escrow_wallet = ctx.accounts.escrow_wallet.key();
Wallet rotation similarly validates the new wallet mint, but does not enforce owner separation from the active signer.
As a result, the same key can satisfy both signer roles if the configured reward or escrow token account is owned by the active signer. In that configuration, a transfer described operationally as requiring “dual signatures” is reduced to a single independent key for program-mediated reward, payout or fee transfers.
Acknowledged
The team indicated that this is by design. The program mediates settlement but does not custody funds. Wallet authorities retain direct SPL token control. This program did not utilize PDA-owned vaults. Monitoring of direct wallet movements is handled off-chain.
The reward and payout instructions require the source token account authority to sign program-mediated transfers and then the program performs a checked SPL token transfer through CPI:
instructions/transfer_helper.rs::transfer_checked_cpi():24-36token::transfer_checked(
CpiContext::new(
token_program,
TransferChecked {
from: source,
mint,
to: destination,
authority,
},
),
amount,
decimals,
)
Because the configured reward and escrow wallets are ordinary SPL token accounts, their owners can also call the SPL Token program directly. A direct SPL transfer from the reward or escrow wallet does not pass through this program and therefore does not enforce the program’s epoch status checks, transfer caps, supplier deduplication, supplier wallet validation, or emitted settlement events.
Resolved
The program derives its main PDAs from fixed seed strings, such as the config account, epoch accounts, per-epoch supplier registries, and the global supplier wallet registry. These seed strings are repeated inline across multiple account constraints instead of being centralized as shared constants.
For example, the config PDA seed is repeated in several instructions:
instructions/close_epoch.rs::CloseEpoch:21-26#[account(
mut,
seeds = [b"ide_config"],
bump = ide_config.bump,
)]
pub ide_config: Box<Account<'info, IdeConfig>>,
Epoch account seeds are also repeated across lifecycle and settlement instructions. The per-epoch supplier registry seed is similarly duplicated. The supplier wallet registry seed is repeated in the whitelist management and settlement paths.
Repeating seed strings across many instructions increases maintenance risk. A future typo or inconsistent update at one derivation site could cause an instruction to derive a different PDA than the rest of the program. Depending on where the mismatch is introduced, this could break an instruction, make an account unreachable through that flow, or create confusing deployment and debugging behavior.
Acknowledged
The drain_epoch instruction (on feat/admin-cleanup branch) closes both the EpochAccount and SupplierRegistry for finalized epochs, reclaiming rent. This gives operators the choice to either retain closed epochs as on-chain history or drain them after off-chain indexing is confirmed (see also A2).
Each epoch is represented by an EpochAccount PDA:
state/epoch_state.rs::EpochAccount:16-56#[account]
#[derive(InitSpace)]
pub struct EpochAccount {
/// Monotonically increasing epoch identifier assigned by the backend.
pub epoch_id: u64,
...
}
The account is created when an epoch is opened. When the epoch is finalized, close_epoch marks the epoch as Closed and records the close metadata.
However, the instruction does not close the EpochAccount itself. The epoch account lamports are not refunded, the account data is not zeroed, and there is no separate cleanup instruction for closed epoch accounts.
The program does provide a rent-recovery option for a different PDA: the per-epoch SupplierRegistry. The close_epoch instruction accepts a close_registry flag. When that flag is true, the instruction drains the SupplierRegistry account. That cleanup does not apply to the epoch PDA.
As a result, every opened epoch permanently retains one rent-funded EpochAccount, even after it reaches the terminal Closed state.
This may be intentional if closed epoch accounts are meant to serve as permanent on-chain historical records. Unlike emitted events, retained accounts remain directly readable by on-chain programs and RPC clients without relying on off-chain event indexing. In that model, the locked rent is the cost of preserving canonical on-chain history.
However, if permanent epoch-account retention is not required, the current design accumulates rent cost over time with no program-level recovery path. The presence of the close_registry option for per-epoch supplier registries shows that rent lifecycle was considered for at least one epoch-related PDA, so the intended treatment of closed EpochAccount PDAs should be made explicit as well.
Resolved
Resolution comments from the team per item listed below:
- Anchor validates account ownership via PDA seeds + discriminator in the account constraints. The manual check was redundant and used an incorrect error variant (commit:
bc25293). - The
ifwrapper was an artifact from early development. Now usesrequire!directly, consistent withupdate_service_signer.rs(commit:bc25293). - Removed from all 5 files (
reward.rs,payout.rs,payout_reserve.rs,protocol_fee.rs,close_epoch.rs). PDA derivation usingepoch_idas seed already guarantees the match (commits:e972969,17d7b14).
Across the different instructions, there are some checks that seem redundant and possibly originating from older implementations:
- In
transfer_helpers.rs, thevalidate_supplier_walletfunction validates that thewallet_registryaccount is owned by the current program.
transfer_helpers.rs::validate_supplier_wallet:153-156pub fn validate_supplier_wallet(
wallet_registry_info: &AccountInfo,
...
) -> Result<()> {
// Ensure the account is owned by this program, not a spoofed
// external account.
require!(
wallet_registry_info.owner == &crate::ID,
IdeError::InvalidEpochAccount
);
...
}
This program ownership check is also performed by Anchor during account validation. Moreover, this check only exists in one place which makes it inconsistent, should the intention was to explicitly preserve it in the code to manually verify the account ownership.
- In
update_active_signer.rs, thectx.accounts.ide_config.service_signer != Pubkey::default()check is redundant, under the current implementation, since no authority key can be the defaultPubkey. This is enforced upon initialization and account key rotation. The require statement could be used directly like inupdate_service_signer, which does not perform this extra check.
update_active_signer.rs::update:36-41pub fn update(
ctx: Context<Self>, new_active_signer: Pubkey
) -> Result<()> {
...
// Dedaub:
// This check is always true under the current implementation
// that enforces all the signer keys to be initialized and
// rotated with a valid Pubkey.
if ctx.accounts.ide_config.service_signer != Pubkey::default() {
require!(
new_active_signer != ctx.accounts.ide_config.service_signer,
IdeError::KeySeparationRequired
);
}
...
}
-
In the current implementation, the
constraint = registry.load()?.epoch_id == epoch.epoch_idabove all thepub registry: AccountLoader<'info, SupplierRegistry>(close_epoch.rs,payout.rs,reward.rs,payout_fee.rs) seems redundant since it is also implicitly satisfied by the seeds used for the PDA derivation.#[account(
mut,
seeds = [SEED_SUPPLIER_REGISTRY, epoch_id.to_le_bytes().as_ref()],
bump = registry.load()?.bump,
constraint = registry.load()?.epoch_id == epoch.epoch_id
@ IdeError::RegistryEpochMismatch,
)]
pub registry: AccountLoader<'info, SupplierRegistry>,
The stored epoch does not currently have a way to be changed after initialization, which ensures that the stored value will be the same as the seed used for derivation.
Resolved
Introduced expectError() helper (236e57f). All rejection tests across test files migrated to this helper (105fa08). After migration, all tests pass with correct assertions, confirming that every on-chain check works as expected.
open_epochzeroepoch_idtest moved before first epoch creation to correctly assertEpochIdNotMonotonicinstead of accidentally matchingOpenEpochAlreadyExists(105fa08).- Added shared
withRetryhelper for transient timeout resilience inepoch_close_ordering.
Following the comments mentioned in L7 that raised the issue of a passing test case that should be failing, detecting the described misalignment between the test cases and the actual implementation, we raise this general consideration for the test suite in its entirety since the issue seems to exist in various places across the test suite.
The following consideration should be carefully taken into account since it makes the test suite fail to detect the cases that should not report success. The test quoted in L7 is one example of a case that should fail, but all tests currently pass.
The core of the issue is that the inside the try/catch block, if the instruction fails with an error other than the expected one (e.g. TransferAmountExceedsMax in the example above), the expect.fail call will throw an exception in the attempt to match the error message logging an error of type: AssertionError: expected TransferAmountExceedsMax.
As a result, this error string will be used in the catch block which just checks if the TransferAmountExceedsMax substring exists in the error message, which it does, virtually making the test case appear as successful when the actual instruction reverted with an unexpected error message.
This problematic pattern was detected in at least 47 places in the test suite. It would be good to make sure that the test cases only succeed when the expected outcome is received and report failure in any other case.
DISCLAIMER
The audited contracts have been analyzed using automated techniques and extensive human inspection in accordance with state-of-the-art practices as of the date of this report. The audit makes no statements or warranties on the security of the code. On its own, it cannot be considered a sufficient assessment of the correctness of the contract. While we have conducted an analysis to the best of our ability, it is our recommendation for high-value contracts to commission several independent audits, a public bug bounty program, as well as continuous security auditing and monitoring through Dedaub Security Suite.
ABOUT DEDAUB
Dedaub offers significant security expertise combined with cutting-edge program analysis technology to secure some of the most prominent protocols in DeFi. The founders, as well as many of Dedaub's auditors, have a strong academic research background together with a real-world hacker mentality to secure code. Protocol blockchain developers hire us for our foundational analysis tools and deep expertise in program analysis, reverse engineering, DeFi exploits, cryptography and financial mathematics.