Solana Foundation: Subscriptions
Cantina Security Report
Organization
- @solana-foundation
Engagement Type
Cantina Reviews
Period
-
Repositories
Findings
Medium Risk
5 findings
5 fixed
0 acknowledged
Low Risk
24 findings
21 fixed
3 acknowledged
Informational
22 findings
12 fixed
10 acknowledged
Medium Risk5 findings
Creation signatures have no on-chain freshness deadline
Severity
- Severity: Medium
Submitted by
r0bert
Description
Several user-signed creation instructions bind the user to important terms, but not to the latest time those terms may be used on-chain.
CreateFixedDelegationDatabinds the delegator to a nonce, amount, expiry timestamp andSubscriptionAuthoritygeneration.CreateRecurringDelegationDatabinds the delegator to the recurring amount, period length, optional start time, expiry timestamp and authority generation.SubscribeDatabinds the subscriber to the plan terms and authority generation.None of those payloads include a
valid_until_ts,max_creation_ts,max_start_tsor equivalent freshness deadline. If the signed transaction remains usable through a durable nonce, relayer-controlled signing flow, merchant-controlled frontend, wallet queue or another delayed-submission path, the program can create the delegation or subscription much later than the user likely intended.The stale-creation effect is especially visible in three cases. A fixed delegation with
expiry_ts == 0can create a non-expiring allowance whenever the delayed transaction lands. A recurring delegation withstart_ts == 0uses the landing timestamp as the first period start, so a delayed transaction can create a fresh full-period allowance shortly before the signed expiry. A subscribe transaction starts the subscription at the landing timestamp as long as the plan snapshots still match, so an old subscription authorization can become freshly billable later.Consequently, a party that can hold and later submit one of these signed creation transactions can turn old user intent into a new spendable state. Delegatees can receive fixed or recurring pull rights later than the delegator expected and merchants or authorized pullers can create a billable subscription long after the subscriber's original interaction. Existing term and authority-generation checks prevent some stale-state mismatches, but they do not tell the program whether the user's creation authorization is still fresh.
Recommendation
Add an explicit freshness deadline to each user-signed creation payload. A common field such as
valid_until_tsworks or each instruction can use a more specific name such asmax_creation_tsfor delegation creation andmax_start_tsfor subscription creation.Reject the instruction when
Clock::unix_timestampis past the signed freshness deadline. Keep the existing plan-term, expiry and authority-generation checks, but do not treat them as a substitute for transaction freshness.Apply the check to all creation variants, including fixed delegations with
expiry_ts == 0, recurring delegations withstart_ts == 0and subscriptions to perpetual or still-active plans. For recurring delegations, keepexpiry_tsas the end of the delegation's allowed lifetime; it should not also serve as the latest acceptable creation time.Solana Foundation: Fixed in d71f709.
Cantina: Fix verified. Solved via documentation.
Owners can remove plan expiry after subscribe
Severity
- Severity: Medium
Submitted by
r0bert
Description
subscribebinds the subscriber to the plan mint, amount, period length, plan creation timestamp and SubscriptionAuthority generation. It does not bind the subscriber to the plan'send_ts, even when the plan is finite at the time of subscription.update_planlets the active plan owner later setend_tsto0, which means the plan no longer has an end date.transfer_subscriptionthen checks the live plan state and treats the subscription as still active, because the subscriber's account only snapshots the plan terms, not the original expiry.Consequently, a subscriber can agree to a finite plan and later become billable beyond the end date they saw when they subscribed. The plan owner or an authorized puller can keep collecting future full periods after removing the expiry, unless the subscriber notices the change and cancels. Wallets and marketplaces that display
end_tsas a fixed subscriber protection can also mislead users if they do not monitor later plan updates.This is different from charging a full amount for a partial final period. In that case, the original plan end still exists. Here, the owner removes the end date entirely, so later full billing periods remain collectible under the live plan.
Recommendation
If plan expiry is meant to limit subscriber authorization, bind it into the subscription itself. Add an
expected_end_tsfield toSubscribeData, reject subscription creation if the live plan expiry does not match what the subscriber signed and store the subscribed expiry inSubscriptionDelegation.transfer_subscriptionshould then enforce the stored subscriber expiry as a cap even if the live plan later extends the expiry or removes it by settingend_tsto0.If expiry removal is intended to remain owner-controlled, the UI and SDK should present
end_tsas mutable merchant administration rather than as a subscriber protection.Solana Foundation: Fixed in commit 4d623cd.
Cantina: Fix verified. The patch makes a finite
plan.data.end_tsmonotonic:update_plannow rejects any update where the existing end timestamp is non-zero and the new value is either0or greater than the existing value, so a merchant can only shorten a finite plan and cannot remove or extend the subscriber-visible expiry.transfer_subscriptionstill uses the live plan expiry for both the top-level expired-plan check and the recurring-transfer cap, so preserving the finite live expiry closes the original path where a subscriber agreed to a finite plan and was later made billable indefinitely.Authority generation can revive old delegations
Severity
- Severity: Medium
Submitted by
r0bert
Description
initialize_subscription_authorityonly assigns a newinit_idwhen theSubscriptionAuthorityaccount is first created. If the account already exists, the instruction reapproves the same authority as the token-account delegate while keeping the oldinit_id.revoke_subscription_authorityclears the token-account delegate approval, but it does not rotate the program generation or invalidate existing delegation records.The generation value also comes from the current slot when the account is created. If a user closes and recreates the authority in the same slot, the recreated account can receive the same generation as the closed account. Old fixed delegations, recurring delegations and signed subscribe data that compare only against
init_idcan still match the new live authority.Consequently, revocation and recreation can behave like a pause instead of a durable invalidation. Old delegatees, merchants or pullers can regain transfer authority after the user reapproves the same authority and same-slot recreation can leave stale delegation or subscription intent valid even though the user expected the authority lifetime to have changed.
Recommendation
Use a durable generation that changes every time the authority is revoked, reapproved, closed or recreated. The generation should not repeat within a slot and should not reset by closing and recreating the authority account.
Continue storing the generation in delegation and subscription records, but reject transfers and stale subscribe data whenever the stored generation differs from the current authority generation. If reactivation of old records is intended, expose it as a separate explicit instruction and make clients present that behavior clearly.
Solana Foundation: Fixed in commit a6ab692. The same-slot
init_idreuse caveat is documented in commit 2111261.Cantina: Solved. The close-on-revoke remediation addresses the primary revival vector, while the durable-generation / same-slot limitation is documented and accepted as residual behavior.
Sunset freezes compromised puller authority
Severity
- Severity: Medium
Submitted by
r0bert
Description
update_planrejects every update after a plan entersSunset. That includes updates that would only remove an existing puller from the plan's puller list.This matters because
transfer_subscriptionstill allows existing subscription pulls for sunset plans. It checks the live plan expiry, then authorizes the caller withplan.can_pull(accounts_struct.caller.address()). Therefore every puller stored at the moment of sunset remains authorized until the plan reachesend_ts.If a third-party billing operator is compromised after sunset, the plan owner cannot remove that operator from the plan. The compromised puller can continue initiating subscription charges for the remaining sunset period. When the plan has open destinations or when an allowed destination is controlled by the puller, this can become direct value extraction. Even with merchant-only destinations, the owner has no plan-level incident response tool to stop unwanted charges before expiry.
Recommendation
Allow limited updates after
Sunsetthat only reduce pull authority. Specifically, permit the owner to replace the puller array with a subset of the current nonzero pullers.Continue rejecting reactivation, new puller additions, puller substitutions, expiry extension, metadata changes and any other update that expands authority or changes the plan's public terms. Add a regression test that sunsets a plan with puller
P, removesPand confirms a latertransfer_subscriptionsigned byPfails withUnauthorized.Solana Foundation: Fixed in commit 31ae0e4.
Cantina: Fix verified. Commit 31ae0e4 replaces the previous unconditional
PlanImmutableAfterSunsetrejection with a dedicated sunset update path. The plan owner check still runs first andassert_sunset_puller_reductionrequires the submitted status to remainSunset, the submittedend_tsto equal the current planend_ts, the submitted metadata to equal the current metadata and every nonzero submitted puller to already exist in the current puller array beforeplan.data.pullersis overwritten. This permits owner-driven puller removal while still rejecting reactivation, puller additions, puller substitutions, expiry changes and metadata changes.The transfer path also reads the live plan at collection time and calls
plan.can_pull(accounts_struct.caller.address()), which authorizes only the plan owner or a nonzero address currently present inplan.data.pullers. After the owner removes a compromised puller from a sunset plan, that puller is no longer in the live authorization set andtransfer_subscriptionfails withUnauthorized.Active finite plans freeze during the final period
Severity
- Severity: Medium
Submitted by
r0bert
Description
update_planappliesvalidate_plan_end_tsto every Active plan update before it checks whether the plan already has the same finiteend_ts. That helper rejects any non-zero end timestamp less than one billing period from the current clock.Consequently, once an Active finite plan enters its final billing period, the owner cannot submit an update that keeps the existing
end_ts. Puller removal, metadata updates and an Active-to-Sunset transition with the same end timestamp all fail before the mutable fields are written.This leaves the owner without the intended incident-response control during the period where a compromised puller can still collect from existing subscriptions. The already-Sunset reduction logic does not help because the plan must first be updated into Sunset and that update is rejected while the plan is still Active.
Recommendation
Apply the one-period horizon check only when an Active update sets a new finite end timestamp or shortens the existing one. If
data.end_ts == plan.data.end_ts, allow puller removal, metadata updates and Active-to-Sunset transition to proceed.Keep rejecting end timestamp removal or extension for plans that already have a finite end. Add regression tests for unchanged-end puller removal and unchanged-end Sunset transition when the current time is inside the final billing period.
Solana Foundation: Fixed in commit 9f92282.
Cantina: Fix verified. The patch changes
update_planso unchanged finiteend_tsvalues no longer pass through the one-period horizon check.UpdatePlanData::validatenow only validates the status byte,update_planfirst rejects expired plans and finite-end removal or extension and then callsvalidate_plan_end_tsonly whendata.end_ts != old_end_ts.That preserves the intended protections: a finite plan still cannot clear or extend its end timestamp and a newly set or shortened end timestamp must still be at least one billing period away. But if the update keeps the existing
end_ts, the owner can remove pullers, edit metadata, or move an Active plan to Sunset during the final billing period and at the exact end timestamp while the plan is not yet expired.
Low Risk24 findings
Lifecycle signatures lack state binding
Severity
- Severity: Low
Submitted by
r0bert
Description
Several lifecycle instructions rely on a signer and the current account state, but the signed instruction data does not bind the user's authorization to a specific account generation, expected state or deadline.
CancelSubscription,CloseSubscriptionAuthorityand direct-delegationRevokeDelegationare dispatched as no-data instructions. Their handlers then authorize the action against whatever account currently exists at the supplied PDA.Those PDAs can be reused after the old account is closed. A signed instruction intended for an earlier subscription, authority, fixed delegation or recurring delegation can therefore fit a later account created with the same seeds.
CancelSubscriptionalso computes the cancellation expiry from the time the instruction executes, so a cancellation signed near a billing boundary can take effect later than the subscriber intended if submission is delayed.Consequently, a delayed transaction can cancel a recreated subscription, close a recreated
SubscriptionAuthority, revoke a later delegation that reused a nonce or leave one extra subscription period billable. The attacker or failure case needs access to a still-usable signed transaction, such as through a relayer, queued wallet submission, durable nonce or merchant-controlled frontend.Recommendation
Add explicit freshness and state-binding data to each affected instruction. Useful fields include
valid_until_ts, an expected authority or subscription generation, an expected account discriminator, an expected nonce, an expected current cancellation state and for cancellation a maximum acceptableexpires_at_ts.Reject instructions when the live account does not match the signed state or when the current clock is past the signed deadline. If account reuse should remain possible, include a non-repeating generation in the account state or PDA seeds so a signature for one account lifetime cannot authorize a later lifetime.
Solana Foundation: Fixed in d71f709.
Cantina: Fix verified. Solved via documentation.
IDL omits conditional trailing accounts
State
- Acknowledged
Severity
- Severity: Low
Submitted by
r0bert
Description
The Codama account annotations publish fixed account lists for instructions whose handlers accept additional accounts after the documented list.
InitSubscriptionAuthority,CreateFixedDelegation,CreateRecurringDelegationandSubscribecan use a trailing writable signer as the rent payer.CloseSubscriptionAuthoritycan require a trailing writable receiver when the stored payer is not the user.RevokeDelegationrequires a trailingplan_pdafor subscription delegations and can also require a trailing rent receiver.The generated TypeScript and Rust clients follow the published IDL, so they do not expose these conditional accounts as named inputs. A generic IDL consumer can therefore build only the simple variants, even though the program supports sponsor-funded creation, sponsor-funded close and subscription revocation with plan validation.
Consequently, sponsors, subscribers, wallets and SDK users can fail to use supported account states unless they already know the undocumented remaining-account layout. Transactions built directly from the official IDL can miss required accounts, charge the wrong payer or fail with missing-account and authorization errors despite matching the public interface artifact.
Recommendation
Expose every conditional trailing account in the public interface. If Codama cannot model these accounts directly, add official generated-client overlays that name the optional payer, sponsor receiver, subscription
plan_pdaand revoke receiver accounts.Document the exact account layout for each affected instruction and add release checks that construct sponsor-funded creation, sponsor-funded close and subscription revoke transactions from the published client surfaces.
Solana Foundation: Acknowledged.
Cantina: Not fully fixed. Commit 3cf6269 partially addresses the finding by adding omitted optional Codama accounts for the sponsor
payeroninitSubscriptionAuthority,createFixedDelegation,createRecurringDelegationandsubscribeand for the rentreceiveroncloseSubscriptionAuthority. The originalrevokeDelegationinterface gap remains.Transfer hook helper rejects valid hooks
Severity
- Severity: Low
Submitted by
r0bert
Description
The local Token-2022 transfer-hook helper is stricter than the Token-2022 hook interface. It rejects every active hook unless the hook's
extra-account-metasvalidation PDA is supplied, even though Token-2022 can invoke a hook that only needs the base transfer accounts. The same helper also caps forwarded hook accounts at the fixed static CPI buffer size, which blocks hooks that require more resolved extra accounts than this local limit allows.Both cases are compatibility failures in the subscriptions program rather than token-program failures. The mint and hook configuration can be valid, but delegated fixed, recurring and subscription transfers fail before Token-2022 gets to execute the hook under its normal rules.
Consequently, otherwise valid Token-2022 mints can be unusable through this program. Token issuers that use base-only hooks or hooks with larger policy account sets cannot rely on delegated subscription transfers, even though direct Token-2022 callers may support the same mint configuration.
Recommendation
Match Token-2022's transfer-hook account handling. Allow base-only hooks without requiring an
ExtraAccountMetaListvalidation PDA. When extra accounts are present, forward the resolved accounts through a CPI helper that supports the intended account limit.If the program intentionally supports only a narrower subset of hooks, document the exact boundary in the public docs and generated clients. Client-side validation should fail early with the same limit the program enforces.
Solana Foundation: Fixed across commits a06ecd6 and 0ed6d3c.
Cantina: Fix verified for the on-chain transfer-hook helper. Commit
a06ecd6removes the localExtraAccountMetaListvalidation-PDA guard frominvoke_transfer_checked_with_hookand forwards caller-supplied hook accounts directly into the Token-2022TransferCheckedCPI. Commit0ed6d3cremoves the remaining fixed static CPI account buffer by switching frominvoke_signed_with_boundstoinvoke_signed_with_slice; the helper now builds the CPI account metas and account view slices dynamically fromremaining, so the previous 60-account local cap is no longer present.Transfer events omit the credited token account
Severity
- Severity: Low
Submitted by
r0bert
Description
The transfer events record the receiver owner, but not the exact destination token account that received the tokens. This is only unambiguous when the destination is always the receiver's canonical token account or can otherwise be derived from the event fields.
The transfer helper enforces that the source is the delegator's canonical token account. For the destination, it only checks that the account uses the requested mint. A valid fixed, recurring or subscription transfer can therefore credit an auxiliary token account or a PDA-owned token account while the event only reports the token-account owner.
Consequently, event-only indexers cannot tell which token account actually received the funds. They may reconcile against the receiver owner's canonical token account even when a different token account was credited or they may be unable to compute the credited account's balance delta and withheld fees from the event stream alone. The on-chain transfer remains authorized, but billing, reconciliation, dispute handling and monitoring systems that use transfer events as their source of truth can record incomplete or misleading destination data.
Recommendation
Either enforce that destination accounts are canonical ATAs when the event only emits the receiver owner or add an explicit
receiver_token_account/destination_token_accountfield to all transfer events. Keep the owner field if it is useful, but document it as the token-account owner rather than the exact credited account. Update event serialization coverage, generated event schemas and downstream decoders so indexers can reconcile the actual destination account.Solana Foundation: Fixed in commit 071e59e.
Cantina: Fix verified. The fix adds
receiver_token_accounttoFixedTransferEvent,RecurringTransferEventandSubscriptionTransferEvent, serializes it immediately afterreceiverand populates it from the exact writable receiver token account passed to the transfer CPI in all three transfer handlers. The current IDL also exposesreceiverTokenAccountfor all three transfer event schemas, so IDL-based indexers can recover the credited account.Plan updates emit no lifecycle event
Severity
- Severity: Low
Submitted by
r0bert
Description
update_planchanges mutable plan fields such asstatus,end_ts,pullersandmetadata_uri, then returns without emitting a lifecycle event. The instruction schema also only namesownerandplan_pda, so generated clients do not supply theevent_authorityandself_programaccounts used by the program's self-CPI event pattern.These fields affect active subscriptions and monitoring assumptions.
end_tscan be shortened, extended or removed,statuscan move the plan toSunset,pullerscan add or remove accounts allowed to collect subscription payments andmetadata_uricontrols marketplace-facing plan data.Consequently, event-driven indexers, marketplaces, billing systems and subscriber alerts can miss important plan changes unless they also poll and diff plan accounts.
Recommendation
Add a
PlanUpdatedEventemitted fromupdate_planafter the new fields are written. Include the plan PDA, owner, status, end timestamp, updated pullers and either the metadata bytes or a stable metadata hash. Addevent_authorityandself_programaccounts to theUpdatePlaninstruction schema so generated clients can supply the self-CPI event accounts.If event emission is intentionally omitted to save compute, document that plan updates are account-state-only and provide an official polling and diffing strategy for indexers and marketplaces.
Solana Foundation: Fixed in commit f30fea2.
Cantina: Fix verified.
update_plannow requiresevent_authorityandself_program, writes the requested plan changes, drops the mutable plan borrow and emits aPlanUpdatedEventthrough the program's self-CPI event path.Exact expiry can block final period pull
Severity
- Severity: Low
Submitted by
r0bert
Description
validate_recurring_transferadvances recurring-period accounting by computing the latest period boundary ascandidate_start. When that boundary is at or after a finiteexpiry_ts, the helper skips the rollover entirely instead of moving accounting to the last billable period before expiry.This can leave
amount_pulled_in_periodfrom an older period in force at the exact expiry boundary. For subscriptions,transfer_subscriptiontreatsplan.end_tsas inclusive because it only rejects whencurrent_ts > plan_end_ts. A pull at the exact plan end can therefore still be allowed by the expiry check, but rejected by stale period accounting if an earlier period was already fully used.Consequently, an otherwise valid final-period pull can fail with
AmountExceedsPeriodLimitat the exact inclusive expiry timestamp. This does can deny the merchant or authorized puller the final amount the rest of the subscription logic still treats as billable. The same stale rollover rule can also reduce usable allowance for direct recurring delegations during the expiry drift window when the floored period boundary reaches or passesexpiry_ts.For subscribers, this creates inconsistent billing behavior at the boundary. Whether the final pull succeeds can depend on whether a previous transfer happened to reset the period state before expiry, rather than only on the plan terms and current timestamp.
For example:
- A merchant creates a subscription plan that allows charging up to 100 USDC per billing period.
- Alice subscribes at timestamp 1000. The first billing period starts at 1000.
- The plan has a finite end timestamp at 1010, and the program treats that exact timestamp as still billable.
- During the first period, the merchant already pulls the full 100 USDC.
- No other pull happens before timestamp 1010, so the subscription still records:
current_period_start_ts = 1000andamount_pulled_in_period = 100. - At timestamp 1010, the merchant tries to pull a small final amount, for example 10 USDC.
- The expiry check allows the request because the plan only rejects when current_ts > end_ts, and here current_ts == end_ts.
- But the period rollover logic refuses to advance the accounting because the new period boundary is exactly equal to expiry_ts.
- As a result, the program still thinks the current period has already used 100 USDC.
- The final 10 USDC pull fails with AmountExceedsPeriodLimit, even though the exact expiry timestamp is otherwise treated as billable.
Recommendation
When a finite expiry blocks
candidate_start, advance to the greatest period start that is still strictly beforeexpiry_ts, then resetamount_pulled_in_periodif that capped start is newer than the stored start.Alternatively, make expiry exclusive for recurring accounting and align all subscription expiry checks with that rule. The important property is that exact expiry should not keep stale accounting from an unrelated older period.
Solana Foundation: Fixed in commit 1ff98ec.
Cantina: Fix verified. The vulnerable behavior was that
validate_recurring_transferskipped rollover entirely when the flooredcandidate_startlanded at or after a finite expiry, leaving an olderamount_pulled_in_periodin force. The fix adds the capped branch that computes the greatest period boundary strictly beforeexpiry_tsand resetsamount_pulled_in_periodonly when that boundary is newer than the stored start.transfer_subscriptionstill treatsplan.end_tsas inclusive (current_ts > plan_end_ts) and passesplan_end_tsinto this helper, so an exact-end transfer now reaches the corrected capped accounting.Spent fixed delegations can lock sponsor rent until expiry
Severity
- Severity: Low
Submitted by
r0bert
Description
revoke_delegationuses the same sponsor-close condition for fixed and recurring delegations. When the caller is the recorded sponsor, the instruction reads the delegation'sexpiry_tsand only allows the close if the delegation is effectively expired. It does not also treat a fixed delegation withamount == 0as finished.A fixed delegation is terminal once its remaining
amountreaches zero.transfer_fixed_delegationsubtracts each successful transfer fromdelegation.amount, andvalidate_fixed_transferrejects any later non-zero transfer that exceeds the remaining allowance. When the remaining allowance is zero, the delegation cannot be used to move more tokens. This is different from a recurring delegation, where the current-period budget can reset later.Consequently, a sponsor who funded rent for a fully spent fixed delegation may be unable to reclaim that rent even though the account is no longer useful. If
expiry_ts == 0, the sponsor's normal expiry-based recovery never unlocks, so rent remains tied to the account until the delegator signs a revoke or the relatedSubscriptionAuthoritylater becomes abandoned.Recommendation
Allow the recorded sponsor to revoke a fixed delegation when
amount == 0. Keep the existing expiry requirement for recurring delegations, since a recurring delegation with no currently available allowance is not terminal and may become usable again in a later period.Solana Foundation: Fixed in commit 540db80.
Cantina: Fix verified.
revoke_delegationnow branches on the delegation discriminator and allows the recorded sponsor to close a fixed delegation whenFixedDelegation::amount == 0, while recurring delegations remain recoverable by the sponsor only through expiry. This matches the terminal lifecycle of fixed delegations becausecreate_fixed_delegationrejects an initial zero amount,transfer_fixed_delegationonly decreasesamountaftervalidate_fixed_transferaccepts a non-zero transfer within the remaining allowance and later fixed transfers are rejected once the remaining amount is exhausted.Stale subscription authorities lock sponsor-funded subscription rent
Severity
- Severity: Low
Submitted by
r0bert
Description
subscribesupports a separate sponsor payer and stores that payer in the subscription header together with the currentSubscriptionAuthority.init_id. Subscription transfers later compare the liveSubscriptionAuthority.init_idagainst the value stored in the subscription. If the subscriber closes the authority and recreates it in a later slot, the old subscription no longer matches the live authority generation and transfers fail withStaleSubscriptionAuthority.The sponsor cannot close the subscription in that state. The subscription branch of
revoke_delegationlets the sponsor close only when the subscription has been cancelled and expired, the plan account is closed, the plan terms changed or the plan ended. It does not treat a closed or later-generationSubscriptionAuthorityas an abandoned subscription, even though the same condition is already terminal for fixed and recurring delegations throughrevoke_abandoned_delegation.Consequently, a sponsor that funded a perpetual subscription can have the subscription account rent locked indefinitely after the subscriber rotates their authority. The subscriber can still cooperate by cancelling and waiting out the period, but the recorded sponsor has no unilateral recovery once the subscription can no longer bill.
Recommendation
Add a sponsor recovery condition for subscription delegations whose recorded
SubscriptionAuthorityis closed or whose liveinit_idno longer matches the subscription header. The fix can mirrorrevoke_abandoned_delegation, but it must supportSubscriptionDelegationand verify that the supplied authority account matches the subscription's recorded subscriber and mint before allowing closure back to the stored payer.Solana Foundation: Fixed in commit 0de29d1.
Cantina: Fix verified. The patch adds
RevokeAbandonedSubscriptionand wires discriminator16through the instruction parser, entrypoint, IDL and tests. The processor loads the subscription, requires the signer to be the recordedheader.payer, requires the suppliedplan_pdato equalheader.delegatee, reads the mint from that bound live plan, derives the canonicalSubscriptionAuthorityfor(header.delegator, plan.mint)and closes the subscription to the payer only when that exact authority is closed or its liveinit_iddiffers from the subscription header. This is the inverse of the transfer-time liveness check that producesStaleSubscriptionAuthority, so the sponsor now has a unilateral rent-recovery path exactly when the subscription has become permanently unbillable through authority closure or rotation.Fixed and recurring revocation bypasses version checks
Severity
- Severity: Low
Submitted by
r0bert
Description
check_and_update_versionis the version gate that prevents the program from reading delegation accounts with an unsupported schema. It rejects newer account versions and requires an explicit migration when an older account cannot be lazily upgraded.revoke_delegationapplies that guard only toSubscriptionDelegation. The fixed and recurring branches load the account withload_with_min_sizeand authorize closure using the current struct offsets without checkingdata[VERSION_OFFSET]. In the sponsor branch, this means the close decision can read fields such asexpiry_tsfrom whatever byte offset the current layout expects.revoke_abandoned_delegationhas the same pattern for fixed and recurring accounts. It readssubscription_authority,init_idandpayerfrom the current layout without first proving the account is at the current schema.Consequently, after a future schema change, migration or downgrade, fixed and recurring delegation accounts can be closed or rejected using stale field offsets rather than the rules for their actual version. The current v1 layout keeps the immediate impact low, but the inconsistent version gate makes account migrations and rollback scenarios more fragile. Users, sponsors and integrators could see closures or recovery failures that do not match the versioning rules enforced by the rest of the program.
Recommendation
Run
check_and_update_versionbefore every fixed and recurringload_with_min_sizecall in revoke instructions. If old accounts must remain closable without a full migration, make that an explicitNeedsAccountRecreatecase and decode only a version-stable close header. Do not read version-specific fields such asexpiry_tsuntil the account has been migrated to the current schema.Solana Foundation: Fixed in commit bac72d4.
Cantina: Fix verified. The patch resolves the issue by choosing the explicit version-agnostic recovery design instead of adding
check_and_update_versionto revocation.revoke_delegationandrevoke_abandoned_delegationnow load fixed and recurring accounts throughload_for_revoke, whose loader gates on each account type's frozenV1_LEN, validates the discriminator, copies the data into an owned zero-padded current-size buffer and ignores appended trailing bytes. ADR-003 now documents the required invariant: delegation and subscription layouts must be append-only and recovery paths must make close decisions only from V1 fields. The current close decisions satisfy that invariant: they read only V1 header fields (delegator,payer,init_id) and V1 fixed/recurring fields (subscription_authority,amount,expiry_ts), while mutable transfer/cancel/resume paths still callcheck_and_update_versionbefore typed mutable loading.Token approval loss does not unlock sponsor recovery
Severity
- Severity: Low
Submitted by
r0bert
Description
Sponsor recovery only treats a delegation as abandoned when the recorded
SubscriptionAuthorityaccount is closed or itsinit_idno longer matches. The recovery checks do not inspect whether the user's canonical token account still delegates to that authority or whether that token account is still usable for delegated transfers.revoke_subscription_authoritycan clear the SPL Token delegate approval while leaving theSubscriptionAuthorityaccount alive with the sameinit_id. After that, transfers through existing fixed, recurring or subscription delegations can no longer succeed because the authority is no longer the token-account delegate. The sponsor recovery paths still reject the recorded sponsor because the authority account itself is live and has the expected generation.Consequently, sponsor-paid rent can remain locked while the delegation is not currently chargeable. For direct no-expiry delegations, normal expiry-based recovery never becomes available. For sponsor-funded perpetual subscriptions, the sponsor cannot recover unless the subscriber cancels, the plan ends, the plan is closed or the plan is recreated with mismatched terms. The user can later reapprove the authority but while approval is absent the sponsor has no unilateral way to close the unusable account.
Recommendation
Extend sponsor recovery to account for token-account liveness, not only
SubscriptionAuthorityliveness. Recovery should accept the canonical source ATA, token mint and token program, then allow the recorded payer to close when the ATA is missing, not canonical or no longer delegates to the recordedSubscriptionAuthority.If immediate recovery after delegate revocation is too aggressive, add an explicit delay or a user-visible paused state. The important property is that a sponsor-funded delegation that cannot transfer because token approval is gone should not remain outside every sponsor recovery condition indefinitely.
Solana Foundation: Fixed for the program-mediated revoke path in commit a6ab692, with the residual direct SPL Token revoke behavior accepted as an intentional UX tradeoff for now.
Cantina: Fix verified with caveat. The implemented fix makes
revoke_subscription_authorityclose the liveSubscriptionAuthorityPDA after clearing the program delegate. This means the normal user-facing revoke path now also unlocks sponsor recovery because old fixed, recurring and subscription records fail once their recorded authority account is closed.The remaining direct SPL Token
Revokepath is understood and accepted by Solana Foundation for now. A token-account owner can still bypass this program and revoke the ATA delegate directly, which clearsdelegated_amountwhile leaving theSubscriptionAuthorityPDA live with the sameinit_id. In that state sponsor recovery still returnsUnauthorized. Solana Foundation explicitly accepted this residual behavior to avoid a worse UX case where a user temporarily revokes and re-approves token approval, but sponsors close live subscriptions during that temporary approval gap.Defensive loaders reject old delegation accounts after size increases
Severity
- Severity: Low
Submitted by
r0bert
Description
The delegation loaders expose
load_with_min_sizeandload_mut_with_min_sizehelpers that are intended to be safer than exact-size loading after schema changes. They are used by revoke and recovery code that may need to read enough account data to close or recover older delegation accounts.However, these helpers still pass the current struct
Self::LENintocheck_min_account_size. If a future release increases the size ofFixedDelegation,RecurringDelegationorSubscriptionDelegation, older accounts that were created with the smaller layout will be shorter than the newSelf::LEN. The defensive loader will reject them withInvalidAccountDatabefore any version-specific prefix parsing can recover the fields needed for closure.Consequently, a future size-increasing upgrade can turn old delegation accounts into liveness problems for users and sponsors. Accounts that still contain enough stable header data to prove the payer, authority and close destination can be rejected only because they are shorter than the new struct. This makes migrations and downgrade or recovery flows more fragile and can produce generic
InvalidAccountDatafailures instead of a clear migration or account-recreation path.Recommendation
Do not use the current struct size as the minimum size for old-account defensive loading. Add version-specific loaders that parse only the fields needed for closure or recovery from each supported historical layout. For revoke and recovery instructions, decode a stable close header before reading version-specific fields, then require migration or account recreation only when the old layout truly cannot be interpreted safely.
Solana Foundation: Fixed in commit bac72d4.
Cantina: Fix verified. The revoke/recovery loaders for
FixedDelegation,RecurringDelegationandSubscriptionDelegationnow use frozen first-version minimum sizes (V1_LEN) throughload_for_revoke, copy the account into a zero-paddedSelf::LENbuffer and return an owned value. This means a first-version account remains closable even if a later release appends fields and increasesSelf::LEN. The close/recovery call sites inrevoke_delegation,revoke_abandoned_delegationandrevoke_abandoned_subscriptionall useload_for_revoke, so they no longer reject recoverable old delegation data solely because it is shorter than the current struct size.Revoke subscription authority can clear unrelated delegates
Severity
- Severity: Low
Submitted by
r0bert
Description
revoke_subscription_authorityvalidates thatuser_atais the signer's canonical ATA fortoken_mint, then calls SPL TokenRevokewithout checking which delegate is currently set on that token account.This means the instruction clears any current delegate, not just the
SubscriptionAuthorityPDA thatinitialize_subscription_authorityapproved. A user or client can replace theSubscriptionAuthorityapproval with another delegate and later call this program instruction while trying to disable only the subscription authority. The instruction then clears the unrelated delegate and zeroes its delegated amount.Consequently, a subscriptions-specific cleanup instruction can unexpectedly remove an approval that belongs to another integration. The instruction does more than its name and documentation imply and wallets or official cleanup flows can clear a valid unrelated delegate on the same token account.
Recommendation
Before invoking SPL Token
Revoke, derive the expectedSubscriptionAuthorityPDA fromuserandtoken_mintand inspect the token account's current delegate field. If no delegate is set, return success or a clear no-op error. If a different delegate is set, reject the instruction instead of clearing it.Solana Foundation: Fixed in commit 642c2ca.
Cantina: Fix verified. The original vulnerable code invoked SPL Token
Revokeunconditionally after validating only the ATA owner, mint and canonical ATA address, so any current delegate on that ATA was cleared. Commit 642c2ca addedget_token_account_delegate, derived the expectedSubscriptionAuthorityPDA from(user, token_mint)and gated the token-programRevokeCPI on the current delegate matching that PDA. The current code, after the later a6ab692 authority-close change, preserves that safety property: it invokesRevokeonly whencurrent_delegate == Some(expected_delegate)and leaves foreign or absent delegates untouched.Valid Token-2022 mint padding can block delegated transfers
Severity
- Severity: Low
Submitted by
r0bert
Description
find_extension_valuemanually scans Token-2022 mint extension data to determine whether a mint has a transfer hook. The scanner requires the TLV data to end exactly at the last parsed extension or to contain a complete four-byte header for the next extension. Token-2022 can accept valid mint layouts that contain unused trailing padding, including padding used when the extension area is adjusted away from the legacy multisig account length.transfer_with_delegatecallsmint_transfer_hook_program_idbefore each delegated transfer. As a result, a valid padded Token-2022 mint can fail withInvalidToken2022MintAccountDatabefore the program invokesTransferChecked, even though the token program would otherwise accept the mint and process the transfer.Therefore, fixed, recurring and subscription transfers can be blocked for affected Token-2022 mints.
Recommendation
Match Token-2022's TLV handling when scanning for the transfer hook extension. Treat trailing bytes shorter than a type field and uninitialized zero extension markers, as the end of used TLV data instead of returning
InvalidToken2022MintAccountData. Prefer using the official Token-2022 extension parser if it is available for the target runtime.Solana Foundation: Fixed in commit 4b5b31b.
Cantina: Fix verified. The old parser only accepted TLV data that ended exactly on an extension boundary, so valid trailing padding caused
mint_transfer_hook_program_idto returnInvalidToken2022MintAccountDatabefore the program invoked Token-2022TransferChecked. The fix changesfind_extension_valueto stop successfully when fewer than a two-byte extension type remains or when it reaches an uninitialized zero extension marker, while still rejecting malformed nonzero partial headers, overlong values and malformed transfer-hook lengths. This matches the relevant valid-padding behavior in Token-2022's TLV walk and preserves hook detection when aTransferHookextension appears before padding.Subscription created event omits sponsored payer
Severity
- Severity: Low
Submitted by
r0bert
Description
SubscriptionCreatedEventrecords the plan, subscriber, token mint and creation timestamp. It does not record the account that funded the newSubscriptionDelegation.This matters because
subscribesupports an optional sponsor payer throughresolve_optional_payer. When a sponsor is provided, the program stores that account insubscription.header.payerand rent is later returned to the recorded payer when the subscription is revoked. The on-chain state therefore distinguishes subscriber-funded subscriptions from sponsor-funded subscriptions, but the creation event does not.Therefore, indexers and off-chain billing systems that rely on events cannot tell who funded the subscription account. They may attribute rent costs or future rent refunds to the subscriber even when a sponsor paid for the account. This makes event-only accounting incomplete for a supported subscription mode.
Recommendation
Include the recorded payer in
SubscriptionCreatedEvent, populated fromaccounts_struct.payer.address()insubscribe. This makes the event match the subscription state and lets event consumers distinguish sponsor-funded subscriptions from subscriber-funded subscriptions.Solana Foundation: Fixed in commit a2b0161.
Cantina: Fix verified.
SubscriptionCreatedEventnow includes and serializes apayeraddress as the final event field andsubscribepopulates it fromaccounts_struct.payer.address(), the same resolved account used to fund theSubscriptionDelegationrent and stored insubscription.header.payer.resolve_optional_payerrequires any sponsor payer to be signer and writable, while the no-sponsor path falls back to the subscriber, so the emitted value matches both supported funding modes. The Codama IDL also exposespayeronsubscriptionCreatedEvent, allowing event consumers to decode the new field.Subscription transfer event omits authorized puller
Severity
- Severity: Low
Submitted by
r0bert
Description
transfer_subscriptionallows either the plan owner or a whitelisted puller to initiate a subscription payment. The instruction checksplan.can_pull(accounts_struct.caller.address()), butSubscriptionTransferEventdoes not record that caller. The event records the subscription, plan, subscriber, mint, amount, period accounting and receiver only.This loses the identity of the account that exercised pull authority. Direct fixed and recurring transfer events include the initiating
delegatee, but subscription transfer events do not include the equivalent signer. For plans with multiple authorized pullers, the event alone cannot show whether the owner or a specific puller initiated the payment.Consequently, event-only billing, monitoring, compliance and dispute systems cannot attribute subscription pulls to the actor that signed them.
Recommendation
Add an authorized-puller field to
SubscriptionTransferEventand populate it withaccounts_struct.caller.address()intransfer_subscription.Update the event serializer, generated event schema, documentation and downstream decoders so subscription transfer events preserve the signer that passed
plan.can_pull.Solana Foundation: Fixed in commit 1032d39; the generated IDL event schema was registered in commit e290958.
Cantina: Fix verified.
SubscriptionTransferEventnow appends apullerfield, serializes that field andtransfer_subscriptionpopulates it fromaccounts_struct.caller.address()after the same signer has passedplan.can_pull(...). Appending the field preserves the offsets of the pre-existing event fields, whileidl/subscriptions.jsonexposessubscriptionTransferEvent.pulleras a public key for downstream decoders.Recurring transfer event overstates final period end
Severity
- Severity: Low
Submitted by
r0bert
Description
transfer_recurring_delegationemitsRecurringTransferEvent.period_end_tsasperiod_start + period_length_sfor every successful recurring delegation transfer. The emitted value is not capped by the delegation's finiteexpiry_ts.This makes direct recurring delegation events less precise than subscription transfer events.
transfer_subscriptioncaps its emittedperiod_end_tsby the finite plan end timestamp, but the direct recurring transfer event always reports the nominal period boundary. A transfer near the end of a finite recurring delegation can therefore emit a period end later than the delegation's actual expiry.Consequently, off-chain billing, reconciliation and monitoring systems can display or account for a paid period that extends past the delegation's real lifetime. Consumers relying on the event stream can overstate the covered service window for the final period.
Recommendation
Cap the emitted direct recurring transfer period end by the delegation expiry, matching the subscription transfer behavior.
let end = period_start + period_length_s as i64;let period_end_ts = if expiry_ts != 0 && end > expiry_ts { expiry_ts } else { end };If consumers need both timestamps, emit separate fields for the nominal period end and the effective expiry-capped period end.
Solana Foundation: Fixed in commit 3d0136f.
Cantina: Fix verified.
transfer_recurring_delegationnow carries the recurring delegation'sexpiry_tsout of the mutable delegation borrow and computesRecurringTransferEvent.period_end_tsas the nominalperiod_start + period_length_scapped toexpiry_tswhen the delegation has a finite expiry. This matches the existing subscription-transfer event behavior and directly closes the original event-only overstatement path.Published event schema can drift from emitted events
Severity
- Severity: Low
Submitted by
r0bert
Description
The program emits self-CPI events through local
EventSerializeimplementations, but those event structs are not registered as Codama events. The published IDL therefore has an empty event list. At the same time, the architecture documentation describes event payloads manually and does not match the fields that the program actually serializes.This leaves two separate descriptions of the event interface: private Rust serializers in the program and manually maintained public documentation. Generated clients and IDL-based indexers cannot discover the event schemas, while hand-written decoders can drift from the emitted bytes.
Consequently, off-chain billing, reconciliation, monitoring and dispute systems can miss events or decode them incorrectly. Consumers may expect fields that are never emitted, omit fields needed for period accounting or rely on documentation that no longer matches the event wire format.
Recommendation
Register every emitted event with Codama so
idl/subscriptions.jsonand generated clients expose the same schema the program serializes. The registered events should cover the six emitted event types and their discriminators, field order and field types.Generate or check the public documentation from the same schema source.
Solana Foundation: Fixed in commit e290958. The later
PlanUpdatedEventadded in commit f30fea2 is also registered in the IDL.Cantina: Fix verified. The six events covered by the original finding now derive
CodamaEventand declare the exact self-CPI wire discriminators: the 8-byte Anchor event tag at offset 0 and the one-byteEventDiscriminatorsvalue at offset 8. The current codebase has seven emitted event types afterPlanUpdatedEventwas added and all seven are present inidl/subscriptions.jsonunderprogram.eventswith matching discriminators, field order and field types.Generated event defaults ignore custom program addresses
Severity
- Severity: Low
Submitted by
r0bert
Description
The Codama account annotations for event-emitting instructions hardcode
event_authorityandself_programas fixed public keys. The generated TypeScript builders still acceptconfig.programAddress, but their default event accounts do not follow that address.For example, a caller can build a
Subscribeinstruction with a custom program address. The instruction itself uses the custom address, but the generated defaults still filleventAuthoritywith the published program's event authority andselfProgramwith the published program address. The on-chain event code then verifies the suppliedevent_authorityand performs the event self-CPI through the suppliedself_program, so those accounts must match the program that is actually executing.Consequently, custom, cloned or local deployments can build instructions that look valid but fail during event emission unless the integrator manually overrides both event accounts. This affects subscribe, cancel, resume and transfer instructions that emit events. It does not bypass authorization or move funds, but it makes custom deployments harder to test and support.
Recommendation
Generate event defaults from the active program address instead of fixed public keys.
self_programshould default to the same program address used for the instruction andevent_authorityshould default to the PDA derived fromevent_authorityand that program address. If Codama cannot express that dependency directly in the instruction annotations, add generated-client overlays that resolve these accounts fromprogramAddressfor every event-emitting instruction.Solana Foundation: Fixed in commit 290c185.
Cantina: Fix verified. The fix adds a shared Codama visitor in
scripts/event-account-fixups.tsand applies it from bothscripts/generate-clients.tsandscripts/generate-ts-client.ts.TokenAccount2022Account::check panics (out-of-bounds index) on short Token-2022-owned accounts
Severity
- Severity: Low
Submitted by
Sujith S
Description
In
token.rs, the validator early-returns Ok only whendata_len() == 165; for any other length it borrows the data and indexes data [TOKEN_2022_ACCOUNT_DISCRIMINATOR_OFFSET] (= data[165], line 222) without a length guard.The sibling
Mint2022Account::checkcorrectly guardsdata.len() <= 165before the identical access. Because anyone can create an account owned by the Token-2022 program with an arbitrary sub-166-byte length, passing such an account asdelegator_ata/receiver_atain any transfer path makes data[165] an out-of-bounds slice index, which panics/aborts instead of returning a cleanInvalidToken2022TokenAccountData.Recommendation
Mirror the mint validator: before indexing,
if data.len() <= TOKEN_2022_ACCOUNT_DISCRIMINATOR_OFFSET || data[TOKEN_2022_ACCOUNT_DISCRIMINATOR_OFFSET] != TOKEN_2022_TOKEN_ACCOUNT_DISCRIMINATOR { return Err(SubscriptionsError::InvalidToken2022TokenAccountData.into()); }Solana Foundation: Fixed in commit 771e913.
Cantina: Fix verified.
TokenAccount2022Account::checknow rejects any non-base-length Token-2022 token account whose borrowed data is<= TOKEN_2022_ACCOUNT_DISCRIMINATOR_OFFSETbefore reading byte 165, so sub-166-byte Token-2022-owned accounts returnInvalidToken2022TokenAccountDatainstead of panicking. The shared validator is still used byrevoke_subscription_authority, byDelegationTransferAccountsfor fixed and recurring transfers and byTransferSubscriptionAccountsfor subscription transfers before any later raw token-account reads; those later reads also have their own minimum-length guards.resume_subscription reactivates a subscription with no authority/init_id liveness check
Severity
- Severity: Low
Submitted by
Sujith S
Description
subscribeand everytransferenforcesubscription_authority.init_id == header.init_id, butresume_subscriptionthe only lifecycle write that re-activates a subscription never loads theSubscriptionAuthorityand never checksinit_id(the account is not in its account list).A subscriber can cancel, then close and re-initialize their
SubscriptionAuthority(bumpinginit_id), then callresume, which succeeds, clearsexpires_at_tsand emitsSubscriptionResumedEvent, while every subsequent pull failsStaleSubscriptionAuthority. This will result in an "zombie-active" subscription plus a misleading lifecycle event that indexers/merchants treat as chargeable (no funds move).Recommendation
Require the
SubscriptionAuthorityPDA inResumeSubscriptionAccounts, validate owner/PDA and reject whensubscription.header.init_id != subscription_authority.init_idbefore clearingexpires_at_tsand emitting the event.Solana Foundation: Fixed in commit 7f51c7a.
Cantina: Fix verified. The patched
resume_subscriptionaccount list now requiressubscription_authority, validates it as a live program-ownedSubscriptionAuthority, loads the plan mint, checks that the authority belongs to the subscriber, checks that its mint matches the plan mint and rejects withStaleSubscriptionAuthoritywhenauthority.init_id != subscription.header.init_idbefore clearingexpires_at_tsor emittingSubscriptionResumedEvent.Subscriber can stop payment while the subscription stays "Active", with no on-chain delinquency state
Severity
- Severity: Low
Submitted by
Sujith S
Description
A subscriber can call
RevokeSubscriptionAuthority(owner-signed SPLRevoke), or freeze/empty/close their source ATA, so all future pulls fail while theSubscriptionDelegationremainsexpires_at_ts == 0(Active).The program has no on-chain delinquency/lapse state and no merchant-visible signal distinguishing "paid" from "approval revoked". Off-chain merchant logic that trusts on-chain "Active" can be gamed into providing service without payment, with no on-chain recourse.
Recommendation
Document that on-chain "Active" is not proof of collectability; consider a last-successful-pull timestamp or delinquency flag (set when a due period's pull fails / a period elapses uncollected) so merchants can detect lapses on-chain.
Solana Foundation: Documented this behavior in b4fdfd2, but won't implement behavior change. It's on the merchant to validate before providing services.
SPL Token multisig owners cannot use subscription authorities
State
- Acknowledged
Severity
- Severity: Low
Submitted by
r0bert
Description
initialize_subscription_authorityrequiresuserto be a transaction signer, then passes that same account as the token authority in the SPL Token or Token-2022ApproveCPI. This only works when the token account owner is a normal signer account.Native SPL Token multisignature accounts are valid token authorities, but the multisig account itself is not expected to sign. The token program authorizes those accounts by receiving the multisig account as authority together with the required member signer accounts. This program does not accept or forward those member signers. The optional trailing account in initialization is interpreted as a payer, so extra signer accounts cannot repair the approval call.
revoke_subscription_authorityhas the same limitation. It requiresuserto sign atprogram/src/instructions/revoke_subscription_authority.rs:28, then callsRevokewith no multisig member signers and ignores trailing accounts. Consequently, an ATA owned by a native SPL Token multisig cannot initialize aSubscriptionAuthoritythrough this program and cannot use the program's revoke instruction.Treasury accounts that use native SPL Token multisig ownership are valid token accounts, but they cannot use this subscription authority model unless ownership is moved to a normal signer or a different wallet abstraction.
Recommendation
Separate the rent payer from the token authority account. Keep the existing single-signer case, but also accept trailing multisig member signer accounts when the token authority is a native SPL Token multisig.
When approving or revoking, pass the multisig account as the token authority and forward the member signer metas to the token program. Let SPL Token or Token-2022 enforce the configured signature threshold.
Solana Foundation: Acknowledged. We will not fix this for now. Most users usually use squads or other smart wallet. In the future we will consider adding a new instruction for multi-sigs directly.
Mandatory event self-CPI can block cancellation at max depth
State
- Acknowledged
Severity
- Severity: Low
Submitted by
r0bert
Description
CancelSubscriptioncommits the cancellation state only if the later event emission succeeds. It first setssubscription.expires_at_ts, then immediately callsevent_engine::emit_event(...)?. That helper emits events through a nested invocation back into this program, so cancellation always needs one additional CPI frame even though the state change itself does not need to call another program.Solana rejects nested invocations once a transaction is already at the maximum call depth. If a subscriber is a PDA controlled by another program and its only authorized way to sign reaches
CancelSubscriptionat that depth, the event CPI fails with a call-depth error. The propagated error rolls backexpires_at_ts, leaving the subscription active. Authorized pullers can continue charging until the subscriber can use a shallower signing arrangement or another lifecycle condition ends the subscription.ResumeSubscriptionhas the same pattern after clearingexpires_at_ts, so resumption also loses composability at the same boundary. Cancellation is more sensitive because it is the subscriber's way to stop future subscription pulls.Recommendation
Do not make cancellation or resumption depend on an additional self-CPI for event emission. Prefer an in-program log or event primitive that does not consume another invocation frame for these lifecycle changes.
If the self-CPI format must remain, add a cancellation and resumption fallback that commits the state change when only the event CPI would fail because the call-depth limit was reached. Document how indexers should handle that fallback so event consumers do not treat the missing self-CPI event as proof that the lifecycle change did not happen.
Solana Foundation: Acknowledged. We will not fix it. CPI limit is 4 which seems reasonable and an increase to 8 should be coming fairly soon. Users can still revoke the subscription if they really are stuck cancelling.
RevokeSubscriptionAuthority can skip closing the real authority
Severity
- Severity: Low
Submitted by
r0bert
Description
revoke_subscription_authority only verifies that the supplied
subscription_authorityaccount is the expected PDA when the supplied account is program-owned and has non-empty data (see . If the transaction supplies a writable system account or any already-closed account in that slot, the instruction can still return success without closing the realSubscriptionAuthorityPDA.This weakens the fix for Token-2022 mints whose
PermanentDelegateis the realSubscriptionAuthorityPDA. A malformed revoke can clear the ordinary token-account delegate while leaving the real authority account live. Existing fixed, recurring or subscription delegation records can then continue to call the transfer entrypoints. The transfer helper loads the still-open authority account and signs the Token-2022 CPI and Token-2022 accepts the signer through the mint-level permanent delegate even though the token account delegate is gone.Recommendation
Always require the supplied
subscription_authorityaccount address to equalSubscriptionAuthority::find_pda(user, token_mint).0, even when the account is closed or system-owned. After that address check, keep the existing close behavior for open program-owned accounts.Solana Foundation: Fixed in commit 84b689a.
Cantina: Fix verified. The fix changes
revoke_subscription_authority::processso the suppliedsubscription_authorityaccount must always equal the canonicalSubscriptionAuthority::find_pda(user, token_mint).0address before the instruction decides whether to close the account. This removes the bypass where a writable system account or unrelated already-closed account could be supplied in the authority slot, allowing the instruction to return success while leaving the real authority PDA open. The fix also addsrevoke_subscription_authority_rejects_spoofed_authority_account, which supplies a non-canonical authority account and expectsInvalidSubscriptionAuthorityPdawhile confirming the real authority PDA remains open after the rejected transaction.
Informational22 findings
Token-2022 permanent delegate can bypass authority revoke
Severity
- Severity: Informational
Submitted by
r0bert
Description
transfer_with_delegatesigns token transfers with the user'sSubscriptionAuthorityPDA after checking the live authority account, source token account, mint and stored generation.revoke_subscription_authorityonly clears the normal per-account delegate approval from the user's token account.For Token-2022 mints, that is not enough when the mint's
PermanentDelegateextension is set to the sameSubscriptionAuthorityPDA. Token-2022 can authorize the transfer through the mint-level permanent delegate even if the user's token account no longer has a delegate and its delegated amount is zero.Consequently, a user-facing revoke may not actually pause delegated spending for this Token-2022 mint configuration. Old fixed, recurring subscription delegations can remain usable immediately after
RevokeSubscriptionAuthority, as long as theSubscriptionAuthorityaccount is still live and the protocol's own delegation checks pass. Delegatees and merchants tied to those old records can continue pulling tokens even though the user's token account shows no normal delegate approval.Recommendation
Do not treat token-account
Revokeas sufficient to pause this program's transfer authority for Token-2022 mints whose permanent delegate is theSubscriptionAuthorityPDA.Consider rejecting authority initialization or delegated transfers when the mint has
PermanentDelegateset to the derivedSubscriptionAuthorityPDA, unless the user explicitly opts into that behavior. Alternatively, makerevoke_subscription_authorityrotate or close the program authority generation so old delegation records fail before the token CPI, regardless of Token-2022 permanent-delegate authorization.Solana Foundation: Fixed across commits a6ab692 and 84b689a.
Cantina: Fix verified. Commit a6ab692 changes the canonical
RevokeSubscriptionAuthoritypath so it clears the ordinary token-account delegate when it is the expected PDA and then closes the liveSubscriptionAuthorityaccount. This addresses the root permanent-delegate issue through the alternate remediation from the recommendation: old fixed, recurring and subscription records fail at the program authority check before the Token-2022 CPI can use mint-level permanent-delegate authorization.Commit 84b689a fixes the remaining bypass from the prior Cantina verification by requiring the supplied
subscription_authorityaccount address to equalSubscriptionAuthority::find_pda(user, token_mint).0before the close decision. A transaction can no longer supply a writable system account or unrelated already-closed account in the authority slot to make revoke return success while leaving the real PDA live.Subscriptions can charge full amount for partial final period
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
r0bert
Description
validate_recurring_transfergives a subscription the full per-period allowance as long as the current billing period starts before the plan expires. It does not check whether the full billing period fits inside the remaining plan lifetime.This means any final partial period is treated like a complete period for payment purposes.
subscribeonly rejects a finite plan after it has already expired, so a subscriber can join shortly before plan expiry and still be charged the full period amount.update_planvalidates a newend_tsagainst the update timestamp, not against each subscriber's next billing boundary, so an existing subscriber can also enter a final period with only a small amount of time left before the updated plan end.Consequently, subscribers can pay a full period price for a very small remaining service window near plan expiry. Merchants and authorized pullers can collect the full
amount_per_periodeven when the finite plan cannot provide a full period of service and marketplaces or support teams may see billing records that are hard to reconcile with the displayed plan end date.The transfer event caps
period_end_tsat the plan end timestamp, so the emitted event can show the truncated billing window. The token amount transferred is still the fullamount_per_period.Recommendation
Do not grant a full subscription period allowance when the period cannot complete before
plan_end_ts. For subscriptions with a finite plan expiry, reject transfers unlesscurrent_period_start_ts + period_length_s <= plan_end_tsor explicitly prorate the allowed amount for the final partial period.Also apply the same full-period-remaining check when subscribing to a finite plan. This prevents a new subscription from starting inside the final partial period.
Solana Foundation: Acknowledged. This is by design. Users agree to the terms set in the Plan, this includes the plan end ts. We don't want to enforce that at the contract level.
Self-transfers consume allowance without moving tokens
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
r0bert
Description
transfer_with_delegatechecks that the source token account is the delegator's canonical ATA and that the destination token account uses the requested mint. It does not reject the same token account being passed as both source and destination.SPL Token and Token-2022 accept self-transfers as successful no-ops. They validate the authority, then return without moving tokens. The subscriptions program updates fixed delegation allowance, recurring period accounting or subscription period accounting before it calls
transfer_with_delegate. Therefore a self-transfer can consume allowance or mark a billing period as pulled even though no token balance changed.For fixed and recurring delegations, the authorized delegatee can burn their own allowance without receiving funds. For subscriptions, an authorized puller can consume the subscriber's period allowance without paying the merchant when the plan's destination rules allow the subscriber's token account or allow any destination. This breaks the invariant that consumed allowance corresponds to tokens leaving the delegator's account.
Therefore:
- For subscribers, allowance or period budget can be consumed even though no merchant account receives tokens.
- For merchants and pullers, billing state can show a charge consumed while their receiver balance did not increase.
- For indexers and support teams, they must reconcile a successful transfer event with no meaningful balance movement.
Recommendation
Reject self-transfers in the shared transfer helper before invoking the token program.
if accounts.delegator_ata.address() == accounts.to_ata.address() { return Err(SubscriptionsError::UnauthorizedDestination.into());}Alternatively, add a dedicated self-transfer error if the existing destination error is too broad. Add regression tests for fixed, recurring and subscription transfers that pass the delegator ATA as the receiver. The expected result should be an error and unchanged delegation or subscription accounting.
Solana Foundation: Acknowledged. Self transfer would be the problem of the delegatee, if they self transfer it can be considered their fault. We want to keep the program as open as possible and let merchant decide on rules.
Sponsor-funded authority rent has no recovery
Severity
- Severity: Informational
Submitted by
r0bert
Description
initialize_subscription_authoritycan record a sponsor asSubscriptionAuthority.payerwhen the sponsor funds the authority account. The stored payer is the account that receives rent when the authority is eventually closed.However,
close_subscription_authorityrequires theuseraccount to sign and checks that the stored authority owner matches that signer. If the stored payer is different from the user, the instruction only sends rent to the sponsor when the user also supplies the sponsor as the receiver. The sponsor is the rent recipient, but it is not allowed to initiate the close.Consequently, a sponsor that funded a
SubscriptionAuthoritycannot recover the rent if the user disappears, refuses to sign or simply never closes the account.Recommendation
Either remove sponsor support for
SubscriptionAuthoritycreation or document that this rent is a non-recoverable subsidy unless the user cooperates. If sponsor recovery is intended, add a sponsor-controlled close instruction with explicit safety checks, such as requiring that the token delegate no longer points to the authority and that no active dependent subscriptions or delegations remain.Solana Foundation: Fixed in commit 84e0e9b.
Cantina: Fix verified. The implemented remediation follows the issue's documentation option:
docs/001-program-architecture.mdnow states that only the user can close aSubscriptionAuthority, that a sponsor is only the recorded rent recipient and that sponsoring this authority is a non-recoverable subsidy unless the user cooperates. The program behavior matches that disclosure.initialize_subscription_authorityrecords the sponsor asSubscriptionAuthority.payeronly when the sponsor funds first creation, whileclose_subscription_authorityandrevoke_subscription_authorityboth route throughclose_authority, which requires the stored authority owner to match the signing user before closing and only uses the sponsor as the lamport receiver when that user supplies the recorded payer asreceiver.Transfer events report gross amount as received amount
Severity
- Severity: Informational
Submitted by
r0bert
Description
The transfer event schemas expose a single
amountfield and areceiverfield. For ordinary tokens this can look like the amount received by the receiver, but that interpretation breaks for Token-2022 mints with transfer fees.For Token-2022 mints with
TransferFeeConfig, the value emitted inamountis the gross transfer amount requested from the delegator. Token-2022 debits that gross amount from the source account but credits the receiver only with the net amount after the token program withholds its fee. Existing transfer-fee tests demonstrate this behavior: a10_000_000transfer debits the delegator by10_000_000and credits the receiver with9_900_000.Consequently, off-chain consumers can overstate received payments for fee-charging mints. Indexers, billing systems, merchant dashboards, settlement jobs or support tooling may treat the event as proof that the receiver obtained the full gross amount even though the receiver balance increased by less. This can produce incorrect revenue records, invoice-crediting errors or payment disputes.
The program currently also tracks allowances and billing-period pulls by the gross transfer amount. This finding does not decide whether that accounting behavior is correct. If plan or delegation amounts are intended to represent the net amount received by the merchant or delegatee, that should be handled as a separate payment-accounting issue.
Recommendation
Make transfer events explicit about gross and net amounts. Either rename and document
amountas the debited amount and add anet_amount_receivedfield or emit both the source debit and destination credit by comparing balances around the token transfer CPI. Downstream systems should use the net received amount when crediting invoices, service access or merchant balances for transfer-fee mints.Separately decide whether configured subscription and delegation amounts are intended to mean gross amount debited or net amount received. If the intended value is net received, reject
TransferFeeConfigmints or implement gross-up logic with a caller- or user-supplied maximum fee.Solana Foundation: Fixed by adding some doc comments in 2ab16b2. Gross amount is what the event includes; indexers should derive net received off-chain from balances.
Cantina: Fix verified. Commit 2ab16b2 changes the Rust comments on
FixedTransferEvent,RecurringTransferEventandSubscriptionTransferEventto sayamountis the gross amount debited and that net received should be derived from balances off-chain.Mint validators accept accounts that are not initialized mints
Severity
- Severity: Informational
Submitted by
r0bert
Description
MintAccount::checkandMint2022Account::checkvalidate only the account owner, length and Token-2022 marker byte. They do not parse the account as an initialized mint. For SPL Token, an 82-byte token-program-owned account passes even if the mint state is still uninitialized. For Token-2022, an account can pass when it has the mint discriminator at byte 165, even if the underlying account is not a usable initialized mint.create_planrelies onMintInterface::check_with_program, so a merchant can publish a plan whosemintfield points to an account that only looks mint-shaped. Later token approval and transfer CPIs should still reject unusable mints, so this does not create direct token movement by itself.Consequently, the plan registry can contain plans that appear to reference real token mints but cannot actually be used for token operations. Wallets, marketplaces and indexers that trust the registry may display misleading payment information or route users toward plans that fail once token-program validation is enforced.
Recommendation
Validate mint accounts by unpacking initialized mint state instead of checking only owner, length and marker bytes. Use
Pack::unpackfor SPL Token mints. For Token-2022, useStateWithExtensions::<Mint>::unpackor an equivalent parser that confirms the account is an initialized mint and rejects malformed extension layouts, multisigs and marker-byte false positives.Solana Foundation: Fixed in commit 8ef1503.
Cantina: Fix verified.
MintAccount::checknow rejects SPL Token mint-sized accounts unless byte 45, the shared mintis_initializedflag, is set to1;Mint2022Account::checknow applies the same initialized check before accepting either base-length Token-2022 mints or extended mints with the mint discriminator at byte 165.CreatePlanAccounts::try_fromstill routescreate_planthroughMintInterface::check_with_program, so a zeroed token-program-owned account can no longer be registered as a plan mint.Direct delegation expiry has 120-second spend grace
Severity
- Severity: Informational
Submitted by
r0bert
Description
is_effectively_expireddoes not treat a finite delegation as expired atexpiry_ts. Instead, it only returns expired when the current timestamp is greater thanexpiry_ts + TIME_DRIFT_ALLOWED_SECSandTIME_DRIFT_ALLOWED_SECSis 120 seconds.Fixed transfers, recurring transfers and sponsor revocation all rely on this helper. As a result, a direct delegation can remain spendable for roughly two minutes after the expiry timestamp selected by the delegator. During that same window, a sponsor that funded the delegation also cannot reclaim rent based on expiry.
Consequently,
expiry_tsis not a hard stop even though the comments and architecture documentation describe direct delegations as usable until expiry and sponsor revocation as available after expiry. A delegator may believe a delegatee can no longer spend after the displayed expiry time, while the delegatee can still submit a transfer during the drift window if allowance remains. Sponsors can also see rent recovery delayed by the same grace period.For example, if a fixed delegation expires at 12:00:00, a transfer submitted at 12:01:30 can still pass the expiry check because the helper only treats the delegation as expired after the extra 120 seconds have elapsed.
Recommendation
Apply the clock-drift tolerance only where it is needed for timestamp validation at creation time. For transfer execution and sponsor revocation, treat direct delegations as expired once the current timestamp is greater than
expiry_ts.If the post-expiry spend window is intentional, document it clearly in the public docs, SDK and UI wherever users set or review
expiry_ts.Solana Foundation: Fixed in commit 2fd1812.
Cantina: Fix verified. Commit
2fd18127f6022d07a4479a968eb1dc28fe1e8d7cremovesTIME_DRIFT_ALLOWED_SECSfrom the shared direct-delegation expiry gate.is_expirednow returns true for finite expiries whencurrent_ts > expiry_tsand fixed transfers, recurring transfers and sponsor revocation all use that hard-stop check before spending or sponsor recovery. The remaining on-chain drift tolerance is limited to creation-time timestamp validation.Pausable mints let the issuer freeze all in-flight subscriptions/delegations mid-term
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Sujith S
Description
Mint validation checks only program-ownership and the type-discriminator byte and inspects no extensions, so mints carrying the Pausable extension are accepted.
A Pausable mint's pause authority - the token issuer, which is a third party for any token the merchant/subscriber did not issue can pause the mint, after which every transfer, including delegated pulls, reverts until unpaused. A third party can therefore freeze every active subscription and delegation denominated in that mint at will, with no on-chain mitigation.
Recommendation
Maintain an explicit allow-list of supported Token-2022 extensions and reject Pausable (and other issuer-controllable freeze extensions) during mint validation, or explicitly document the trust assumption placed on the mint issuer for plans/delegations denominated in such mints.
Solana Foundation: Acknowledged. T22 extension, which is by design accepted.
createPlan IDL hard-codes the SPL Token program as the token_program default
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Sujith S
Description
The codama attribute on
CreatePlanmakes the generated IDL defaulttoken_programto SPL Token. Clients relying on the default cannot create plans for Token-2022 mints.Recommendation
Remove the hard-coded default (require the caller to supply it) or resolve it from the mint's owning program.
Solana Foundation: Acknowledged.
check_and_update_version may write the version byte before the account kind is validated
Severity
- Severity: Informational
Submitted by
Sujith S
Description
check_and_update_versiondispatches ondata[VERSION_OFFSET]and (on the migration branch) writes it before the typed loader checks the discriminator.Inert today (CURRENT_VERSION=1: fast path writes nothing; callers pre-check ownership+writability;
revoke_delegationchecks the discriminator first), but an ordering/trust-boundary hazard once a real in-place migration is added.Recommendation
Pass the expected
AccountDiscriminatorintocheck_and_update_versionand validate kind/min-length before any mutating migration stepSolana Foundation: Fixed in commit 40d9e00.
Cantina: Fix verified.
check_and_update_versionnow takes anexpected: AccountDiscriminator, rejects a mismatched discriminator before readingVERSION_OFFSETor enteringtry_lazy_updateand all five production callers pass the concrete account kind (FixedDelegation,RecurringDelegationorSubscriptionDelegation).Refactor replaced a self-contained checked_mul with an unchecked period_length_secs()
Severity
- Severity: Informational
Submitted by
Sujith S
Description
Commit
12728d1replaced a locally-bounded(period_hours as i64).checked_mul(3600)withPlanTerms::period_length_secs(), an unchecked multiply whose safety now depends onperiod_hours <= MAX_PLAN_PERIOD_HOURS (8760)enforced in a different instruction (and on no future migration writing unboundedperiod_hours). Currently unreachable as an overflow.Recommendation
Restore
period_hours.checked_mul(SECS_PER_HOUR).ok_or(ArithmeticOverflow), or re-validate the bound at load.Solana Foundation: Fixed in commit 4497133.
Cantina: Fix verified.
PlanTerms::period_length_secsnow returnsResult<u64, ProgramError>and converts hours to seconds withchecked_mul(SECS_PER_HOUR), returningSubscriptionsError::ArithmeticOverflowon overflow instead of relying on the plan-creation bound. Both production callers that use snapshotted subscription terms,transfer_subscriptionandcancel_subscription, now propagate that error with?, so a malformed futureperiod_hourscannot overflow inside this helper.get_mint_decimals uses unpack_from_slice, which panics on <82-byte mint data
Severity
- Severity: Informational
Submitted by
Sujith S
Description
get_mint_decimalscallsTokenMint::unpack_from_slice, whosearray_ref![src, 0, 82]panics on inputs <82 bytes (the.map_errdoes not catch it) and reads only the first 82 bytes of an extended mint.Currently gated (
MintInterface::check_with_programrejects sub-82-byte mints first;decimalslives at offset 44, so the read is correct), so no reachable panic but a robustness regression that replaced a self-contained bounded reader with a panic-capable one relying on an upstream invariant.Recommendation
Read
decimalsvia a bounded direct offset (as beforef25ccf7) or use the length-checkedunpack.Solana Foundation: Fixed in commit 7bd3788.
Cantina: Fix verified.
get_mint_decimalsnow readsdata.get(MINT_DECIMALS_OFFSET)instead of callingTokenMint::unpack_from_slice, so short mint data returnsInvalidAccountDatarather than panicking, while base and extended SPL Token / Token-2022 mints still read the canonical decimals byte at offset 44.emit_event does not assert the caller-supplied self_program equals crate::ID
Severity
- Severity: Informational
Submitted by
Sujith S
Description
emit_eventpassesprogram_id = &crate::IDbut takesself_programfrom caller-supplied accounts without asserting equality. Safe today (invoke_signedrequires the program account to match, so a wrongself_programfails the CPI), but the explicit assertion is missing.Recommendation
Assert
self_program.address() == &crate::IDbefore the CPI for a clear, program-defined error.Solana Foundation: Fixed in commit 2fe917c.
Cantina: Fix verified.
event_engine::emit_eventnow rejectsself_program.address() != &crate::IDwithInvalidSelfProgrambefore constructing or invoking the self-CPI.Non-transferable mints create unusable payment rights
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
r0bert
Description
Mint2022Account::checkaccepts any Token-2022 mint account with the correct owner and mint account marker. It does not parse the mint extension list or rejectNonTransferable, even though the program already defines aMintHasNonTransferableerror.Consequently, users and sponsors can create live-looking subscription authorities, fixed delegations, plans and subscriptions for a mint whose token program will never allow normal transfers between token accounts. The subscriptions program records the payment right as valid, but every delegated pull fails inside Token-2022. This is different from a temporary pause: the mint's non-transferable property is permanent for the mint, so the created payment right is unusable from the start.
This can strand account rent and break billing liveness. For example, a sponsor can fund a subscription or direct delegation for a non-transferable mint, the setup transactions can all succeed and the merchant or delegatee can still be unable to collect any payment. The affected accounts then need separate cancellation, expiry, plan deletion or manual cleanup even though they could never transfer.
Recommendation
Reject unsupported Token-2022 extensions during mint validation. At minimum, parse Token-2022 mint state in
Mint2022Account::checkand returnMintHasNonTransferablewhen the mint includes theNonTransferableextension.If the protocol wants to support only a known-safe subset of Token-2022, make that allow-list explicit and apply it consistently in authority initialization, direct delegation creation, plan creation and transfer-time validation.
Solana Foundation: Acknowledged.
Memo-required Token-2022 accounts cannot receive program transfers
Severity
- Severity: Informational
Submitted by
r0bert
Description
transfer_with_delegateinvokes Token-2022 directly for delegated transfers. It does not emit a Memo-program CPI immediately before calling Token-2022 and it does not reject destination accounts that require incoming transfer memos.Token-2022's
MemoTransferaccount extension requires an immediately preceding Memo instruction at the token CPI's invocation level. A top-level transaction memo does not reliably satisfy this requirement for a nested transfer made by the subscriptions program. Consequently, fixed, recurring and subscription transfers to otherwise valid memo-required Token-2022 accounts can fail atomically inside Token-2022.This is a compatibility issue since a receiver can use a valid Token-2022 account configuration, but that account is unusable as a payment destination through this program.
Recommendation
Detect
MemoTransferon the destination token account before transferring. Either invoke the Memo program immediately before the Token-2022 transfer CPI and require the Memo program account in the transfer interface or reject memo-required destinations with a clear program error before any transfer accounting is updated.Solana Foundation: Solved through documentation in fd4dd65 to avoid a breaking behavior/interface change.
Cantina: Solved through documentation. Commit fd4dd65 updates
README.mdto state that memo-required Token-2022 destination accounts are unsupported and that the transfer fails atomically.CPI Guard blocks subscription-authority initialization
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
r0bert
Description
initialize_subscription_authorityalways performs a Token-2022ApproveCPI when the mint uses Token-2022. This happens even if the user's token account already records the derivedSubscriptionAuthorityas the delegate with enough allowance.Token-2022 accounts can enable the
CpiGuardextension. When that guard is active, Token-2022 rejectsApproveinstructions executed through CPI. The user's signature on the outer subscriptions instruction does not bypass this rule because Token-2022 checks that the approval itself is being processed during CPI.Consequently, a valid single-owner Token-2022 account with CPI Guard enabled cannot initialize or refresh a
SubscriptionAuthoritythrough this program while keeping the guard enabled. Re-running initialization also fails for an already-correct authority because the instruction still issues the prohibited inner approval.Recommendation
Add a state-only initialization mode for CPI Guard accounts. When CPI Guard is enabled, require the user to approve the derived
SubscriptionAuthoritywith a top-level Token-2022 instruction, then haveinitialize_subscription_authorityverify the existing delegate and allowance without issuing anotherApproveCPI.For existing authorities, skip the approval CPI when the token account already has the expected delegate and sufficient delegated amount. If the required approval is missing, fail before account creation or durable state changes and return a clear error.
Solana Foundation: Acknowledged.
Transfer-hook payments are only tested for one of the three payment types
Severity
- Severity: Informational
Submitted by
Sujith S
Description
The program can pull funds in three ways including fixed delegations, recurring delegations, and subscriptions. All three share the same code for handling Token-2022 "transfer-hook" tokens (tokens that run an extra checker program on every transfer).
However, only the fixed-delegation path has a test that actually runs a real, switched-on hook (test_transfer_fixed_delegation.rs:180, plus a missing-account test at :227).
The recurring path is only tested with a switched-off hook, so the hook code never actually executes. And the subscription path has no hook test at all.
Recommendation
Copy the fixed-delegation hook tests to the recurring and subscription paths: for each, add one test that completes a transfer with a live hook and one that confirms the transfer is rejected when a required hook account is missing.
Solana Foundation: Fixed in commit f276855.
Cantina: Fix verified. The fix adds live Token-2022 transfer-hook coverage for both previously uncovered payment paths:
test_recurring_transfer_token_2022_active_transfer_hookandtest_subscription_transfer_token_2022_active_transfer_hookconfigure an active hook program, forward the hook program/validation/counter accounts, complete the transfer and assert the hook counter increments.A mint-reading helper assumes its caller already confirmed the account is a real mint
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Sujith S
Description
The helper
mint_transfer_hook_program_idinspects a token's mint account to determine whether a transfer hook is configured.It does this by jumping to a fixed byte offset and interpreting the data there, but it never checks that the account it was handed is actually a mint and it relies on every caller to have validated that first.
As of now this is safe, because all three transfer paths call
MintInterface::check_with_programbeforehand. However if a future caller invokes the helper without that prior check, it would read meaningless bytes from a non-mint account and could return a bogus hook program, causing confusing failures.Recommendation
Have the helper validate the account itself before reading and confirm it is a Token-2022 mint (e.g. the mint discriminator byte is set for accounts larger than the base size) so it is safe regardless of who calls it.
Solana Foundation: Acknowledged. Caller's responsibility accepted, we want to avoid duplicating validation for CU.
Mutable transfer hooks can freeze existing delegations
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
r0bert
Description
Mint2022Account::checkaccepts Token-2022 mints with aTransferHookextension without checking whether the hook program is immutable. Authority initialization, direct delegation creation, plan creation and subscription creation therefore bind user consent only to the mint pubkey. They do not bind consent to the transfer hook program or the hook authority.transfer_with_delegatethen reads the current hook program from the live mint before each delegated transfer. Token-2022 allows the mint's transfer-hook authority to update that program id after the mint has been initialized. Consequently, a third-party hook authority can leave the hook inactive while users create fixed delegations, recurring delegations, plans or subscriptions, then later set an arbitrary hook program or account policy. Existing payment rights can stop transferring even though the subscriptions program state has not changed.This is a durable liveness break for delegated payments. For example, a mint issuer can activate an unusable hook after a user creates a fixed delegation. The same delegation that previously transferred successfully then fails before tokens move and subscription pullers face the same live hook lookup when collecting plan payments.
Recommendation
Reject mutable transfer-hook mints anywhere durable payment consent is created, unless this trust assumption is explicitly part of the product. At minimum, parse the Token-2022
TransferHookextension during mint validation and reject mints whose hook authority can still update the program id.If mutable hooks must be supported, snapshot the accepted hook program and hook authority into the delegation or subscription consent data. Delegated transfers should reject when the live mint hook differs from the approved hook state or the UI and SDK should clearly present the hook authority as a party that can freeze future delegated payments.
Solana Foundation: Acknowledged.
Closeable Token-2022 mints can rebind old delegations
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
r0bert
Description
Mint2022Account::checkaccepts any Token-2022 mint account with the correct owner and mint marker. It does not rejectMintCloseAuthority, even though the program already defines aMintHasMintCloseAuthorityerror.This matters because the program stores only the mint pubkey in long-lived consent records.
initialize_subscription_authorityapproves theSubscriptionAuthorityPDA as the user's token-account delegate and later transfers check the mint pubkey, authority fields andinit_id. They do not prove that the live mint account is the same asset instance the user approved.A zero-supply Token-2022 mint with
MintCloseAuthoritycan be closed and later initialized again at the same address. Existing token accounts still point to that mint pubkey and their delegate fields are not tied to a mint generation. If new tokens are then placed in the existing user token account, an old fixed, recurring or subscription delegation can spend that new balance without fresh user consent for the reinitialized mint.Recommendation
Reject Token-2022 mints with
MintCloseAuthorityin every instruction that creates durable user consent, including subscription authority initialization, direct delegation creation, plan creation and subscription creation.The minimal fix is to parse Token-2022 mint extensions in
Mint2022Account::checkand returnMintHasMintCloseAuthoritywhen the extension is present. If closeable mints must be supported, store and verify a mint-generation value that changes across close and reinitialize events before any delegated transfer succeeds.Solana Foundation: Acknowledged.
Document that u64::MAX delegate approvals are finite
Severity
- Severity: Informational
Submitted by
r0bert
Description
initialize_subscription_authorityapproves theSubscriptionAuthorityPDA foru64::MAXraw token units. That is the largest SPL Token approval the program can request, but it is still a finite allowance rather than a true unlimited approval.Each successful delegated transfer decreases the token account's
delegated_amount. The protocol's recurring delegation accounting can reset every period, but the underlying token-program approval remains one shared counter for the same user and mint.This is unlikely to matter for ordinary subscription amounts, especially with common 6- or 9-decimal mints. For example,
u64::MAXrepresents about 18.4 billion whole tokens for a 9-decimal mint. The main concern is expectation-setting: if docs or clients describe the approval as permanently unlimited, that wording is technically inaccurate for arbitrary mints, very large cumulative transfer volume or long-lived replenished accounts.This does not allow unauthorized spending. It also does not create a realistic near-term failure for typical token subscriptions. If the allowance ever becomes too low, the user can sign another initialization or approval transaction to refresh it.
Recommendation
Document the approval as "maximum token-program allowance" rather than "unlimited". Make clear that the allowance is denominated in raw token units and can be refreshed by a user-signed approval or initialization transaction.
As a client hardening improvement, consider showing a renewal prompt if the source token account still delegates to the
SubscriptionAuthorityPDA but its remainingdelegated_amountis below the next expected charge. This keeps the edge case understandable without presenting it as a security issue.Solana Foundation: Fixed in commit fed0e5f.
Cantina: Fix verified. The fix removes the two inaccurate
unlimitedapproval statements fromdocs/001-program-architecture.mdand replaces them with wording that describes the approval as near-unlimitedu64::MAX, finite and refreshable.Freeze authorities can freeze existing delegated payments
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
r0bert
Description
MintAccount::checkaccepts a classic SPL Token mint when the account is owned by the SPL Token program and has the standard mint length. It does not unpack the mint state. Therefore it never checks whether the mint still has afreeze_authority.That omission lets users create subscription authorities, fixed delegations, recurring delegations, plans and subscriptions for a mint whose issuer can still freeze token accounts. After the payment approval exists, the issuer can freeze the delegator or subscriber token account with the normal SPL Token
FreezeAccountinstruction. Future delegated transfers then fail insideTransferCheckedbecause SPL Token rejects transfers from a frozen account.The same missing base-mint check exists in
Mint2022Account::check, but the classic SPL Token case is enough to trigger the issue without any Token-2022 extension. This is separate from Token-2022Pausable: the issuer is not pausing a mint extension. The issuer is using the standard mint freeze authority to freeze an existing source token account.Consequently, a third-party token issuer can halt existing delegated payments after consent has already been recorded. Merchants and delegatees can be left with live-looking payment rights that cannot collect until the issuer thaws the affected token account.
Recommendation
Unpack the mint state in
MintAccount::checkandMint2022Account::check. Reject mints with a configuredfreeze_authorityunless the product explicitly accepts issuer-controlled account freezes.If these mints remain supported, show this trust assumption before users create durable payment approvals. For Token-2022, include the base mint freeze authority in the same token-policy checks used for issuer-controlled transfer restrictions.
Solana Foundation: Acknowledged. Creating subscriptions or allowance with a
freeze_authorityis considered user's risk.