Skip to main content
IO NETIO.NET ~ IDE Tokenomics - May 11, 2026

IO.NET ~ IDE Tokenomics

Smart Contract Security Assessment

May 11, 2026

May 19, 2026 (revision)

IO_NET

SUMMARY

ID
DESCRIPTION
STATUS
MEDIUM SEVERITY
M1
Permissionless initialization allows first caller to capture the protocol configuration
resolved
M2
Supplier wallet registry capacity is append-only and disabled entries do not free slots
resolved
LOW SEVERITY
L1
The registry capacity limit is not enforced below the initial physical capacities
resolved
L2
Aggregate epoch transfer limit is configured but not enforced for normal settlement
resolved
L3
Epoch can be finalized before settlement is complete
acknowledged
L4
Incorrect settlement amount consumes a supplier operation slot
acknowledged
L5
Rewards and Payouts require the supplier wallet registry account, even when the wallet validation is disabled
resolved
L6
Reward and payout self-transfers can record settlement without moving tokens
resolved
L7
Missing checks in the new burn instruction
resolved
CENTRALIZATION ISSUES
N1
Trusted operator and wallet authority dependency
acknowledged
OTHER / ADVISORY ISSUES
A1
Epoch duration and configured wallet changes apply to in-flight epochs
acknowledged
A2
Per-epoch registry rent lifecycle is ambiguous after finalization and signer rotation
acknowledged
A3
Supplier wallet registration accepts raw Pubkeys without token account validation
resolved
A4
Dual-signature transfers do not enforce distinct signers
resolved
A5
Source wallet authorities can bypass program settlement controls
acknowledged
A6
PDA seed literals are duplicated across instruction account constraints
resolved
A7
Closed epoch accounts are retained permanently with no rent recovery path
acknowledged
A8
Redundant checks
resolved
A9
Tests trivially succeed even on failed cases
resolved

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:

CATEGORY
DESCRIPTION
CRITICAL
Can be profitably exploited by any knowledgeable third-party attacker to drain a portion of the system’s or users’ funds OR the contract does not function as intended and severe loss of funds may result.
HIGH
Third-party attackers or faulty functionality may block the system or cause the system or users to lose funds. Important system invariants can be violated.
MEDIUM
Examples:
  • 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
LOW
Examples:
  • 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

M1

Permissionless initialization allows first caller to capture the protocol configuration

MEDIUM
resolved

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-70
pub 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-125
let 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.

M2

Supplier wallet registry capacity is append-only and disabled entries do not free slots

MEDIUM
resolved

Resolved

  • update_supplier_wallet with is_active=false now removes the entry entirely (shift + decrement entry_count), freeing the slot.
  • Added delete_wallet_registry instruction (service_signer) to close and recreate registry with new capacity.
  • Added update_max_registry_capacity instruction (cold authority) with bounds validation (250 – 150,000).
  • Added create_supplier_wallet_registry instruction (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-84
let 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-143
if 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

L1

The registry capacity limit is not enforced below the initial physical capacities

LOW
resolved

Resolved

  • SupplierWalletRegistry.insert() now checks entry_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.
  • initialize validates max_registry_capacity >= INITIAL_REGISTRY_ENTRIES and >= INITIAL_WALLET_ENTRIES.
  • SupplierRegistry (per-epoch dedup) does not store max_capacity in its header - the growth ceiling is enforced by ensure_registry_capacity() which reads max_registry_capacity from IdeConfig. 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,119
require!(
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:09
pub 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-60
let 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():09
pub 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-53
let 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-60
let (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-112
let (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-86
let 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-84
let 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.

L2

Aggregate epoch transfer limit is configured but not enforced for normal settlement

LOW
resolved

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_drained and ServiceDrainCapExceeded remain reserved for future admin_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-45
pub 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-102
require!(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-41
require!(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-41
require!(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-103
require!(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-145
transfer_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.

L3

Epoch can be finalized before settlement is complete

LOW
acknowledged

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:36
constraint = 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.

L4

Incorrect settlement amount consumes a supplier operation slot

LOW
acknowledged

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():127
header.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():127
header.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-102
require!(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

LOW
resolved

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-113
if 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.

L6

Reward and payout self-transfers can record settlement without moving tokens

LOW
resolved

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.

L7

Missing checks in the new burn instruction

LOW
resolved

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 (commit 236e57f)

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-175
Transfers 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-116
it("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.)

N1

Trusted operator and wallet authority dependency

CENTRALIZATION
acknowledged

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.

A1

Epoch duration and configured wallet changes apply to in-flight epochs

ADVISORY
acknowledged

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.

A2

Per-epoch registry rent lifecycle is ambiguous after finalization and signer rotation

ADVISORY
acknowledged

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.

A3

Supplier wallet registration accepts raw Pubkeys without token account validation

ADVISORY
resolved

Resolved

register_supplier_wallet and update_supplier_wallet now require the wallet as an Account in the accounts struct with mint validation against io_token_mint. Invalid token accounts are rejected at registration time. (commits: 90c2bf9, 7140357)

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-117
pub 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-66
pub 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.

A4

Dual-signature transfers do not enforce distinct signers

ADVISORY
resolved

Resolved

Added require_keys_neq! in validate() for:

  • reward (active_signer != reward_wallet_authority)
  • payout (active_signer != escrow_wallet_authority)
  • protocol_fee (active_signer != escrow_wallet_authority) (commits: b04e845, ccfc125)

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-114
ide_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.

A5

Source wallet authorities can bypass program settlement controls

ADVISORY
acknowledged

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-36
token::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.

A6

PDA seed literals are duplicated across instruction account constraints

ADVISORY
resolved

Resolved

Extracted all PDA seed literals into shared constants in state/mod.rs: SEED_IDE_CONFIG, SEED_EPOCH, SEED_SUPPLIER_REGISTRY, SEED_WALLET_REGISTRY. All instruction files reference these constants. (commits: 24be315, 921ff3e)

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.

A7

Closed epoch accounts are retained permanently with no rent recovery path

ADVISORY
acknowledged

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.

A8

Redundant checks

ADVISORY
resolved

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 if wrapper was an artifact from early development. Now uses require! directly, consistent with update_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 using epoch_id as 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, the validate_supplier_wallet function validates that the wallet_registry account is owned by the current program.
transfer_helpers.rs::validate_supplier_wallet:153-156
pub 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, the ctx.accounts.ide_config.service_signer != Pubkey::default() check is redundant, under the current implementation, since no authority key can be the default Pubkey. This is enforced upon initialization and account key rotation. The require statement could be used directly like in update_service_signer, which does not perform this extra check.
update_active_signer.rs::update:36-41
pub 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_id above all the pub 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.

A9

Tests trivially succeed even on failed cases

ADVISORY
resolved

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.

Additional fixes:
  • open_epoch zero epoch_id test moved before first epoch creation to correctly assert EpochIdNotMonotonic instead of accidentally matching OpenEpochAlreadyExists (105fa08).
  • Added shared withRetry helper for transient timeout resilience in epoch_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.