Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions program/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,6 @@ pub const TOKEN_ACCOUNT_OWNER_OFFSET: usize = 32;

/// Byte offset one past the end of the `owner` pubkey (i.e., `OWNER_OFFSET + 32`).
pub const TOKEN_ACCOUNT_OWNER_END: usize = TOKEN_ACCOUNT_OWNER_OFFSET + 32;

/// Number of seconds in one hour. Used to convert plan/subscription period (hours) to seconds.
pub const SECS_PER_HOUR: u64 = 3600;
17 changes: 4 additions & 13 deletions program/src/instructions/cancel_subscription.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,57 +32,48 @@ pub fn process(accounts: &[AccountView]) -> ProgramResult {
check_and_update_version(&mut binding)?;
let subscription = SubscriptionDelegation::load_mut_with_min_size(&mut binding)?;

// Verify caller is the subscriber (delegator)
if subscription.header.delegator != *accounts_struct.subscriber.address() {
return Err(SubscriptionsError::Unauthorized.into());
}

// Check not already cancelled
if subscription.expires_at_ts != 0 {
return Err(SubscriptionsError::SubscriptionAlreadyCancelled.into());
}

// Validate subscription's delegatee matches the passed plan_pda
if subscription.header.delegatee != *accounts_struct.plan_pda.address() {
return Err(SubscriptionsError::SubscriptionPlanMismatch.into());
}

plan_pda = subscription.header.delegatee;

// Compute expires_at_ts based on plan state
if accounts_struct.plan_pda.owned_by(&crate::ID) {
// Plan is valid — load it and verify terms match
let plan_data = accounts_struct.plan_pda.try_borrow()?;
let plan = Plan::load(&plan_data)?;

if subscription.check_plan_terms(&plan.data.terms).is_err() {
// Plan terms mismatch (ghost plan) — expire immediately
// Ghost plan (terms mismatch) — expire immediately so the subscriber can revoke without paying.
expires_at_ts = current_ts;
} else {
// Terms match — compute end of current period
let period_length_s = (subscription.terms.period_hours as i64)
.checked_mul(3600)
.ok_or::<ProgramError>(SubscriptionsError::ArithmeticOverflow.into())?;
let period_length_s = subscription.terms.period_length_secs() as i64;
let period_start = subscription.current_period_start_ts;
let elapsed = current_ts.saturating_sub(period_start);
let periods_elapsed = elapsed / period_length_s;
expires_at_ts = periods_elapsed
.checked_add(1)
.and_then(|p| p.checked_mul(period_length_s))
.and_then(|offset| period_start.checked_add(offset))
// Cap at plan end so subscriber can revoke as soon as the plan expires
// Cap at plan end so the subscriber can revoke as soon as the plan expires.
.map(|ts| if plan.data.end_ts != 0 { ts.min(plan.data.end_ts) } else { ts })
.ok_or::<ProgramError>(SubscriptionsError::ArithmeticOverflow.into())?;
}
} else {
// Plan is closed (not owned by our program) — expire immediately
// Plan account closed — expire immediately.
expires_at_ts = current_ts;
}

subscription.expires_at_ts = expires_at_ts;
}

// Emit SubscriptionCancelled event via self-CPI
let event = SubscriptionCancelledEvent::new(plan_pda, *accounts_struct.subscriber.address(), expires_at_ts);
let event_data = event.to_bytes();
event_engine::emit_event(&crate::ID, accounts_struct.event_authority, accounts_struct.self_program, &event_data)?;
Expand Down
10 changes: 10 additions & 0 deletions program/src/instructions/create_plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ pub struct PlanTerms {
pub created_at: i64,
}

impl PlanTerms {
/// Returns the period length in seconds.
///
/// Overflow is impossible because `period_hours` is bounded by [`MAX_PLAN_PERIOD_HOURS`]
/// (validated at plan creation in [`PlanData::validate`]); the maximum result is well below `u64::MAX`.
pub fn period_length_secs(&self) -> u64 {
self.period_hours * crate::constants::SECS_PER_HOUR
}
}

/// Configuration data embedded in a [`Plan`] account and supplied when creating one.
#[repr(C, packed)]
#[derive(Debug, Clone, CodamaType)]
Expand Down
33 changes: 4 additions & 29 deletions program/src/instructions/helpers/delegation.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
use pinocchio::{cpi::Seed, error::ProgramError, AccountView, Address};

use crate::{
state::common::find_delegation_pda, AccountCheck, AccountDiscriminator, Header, ProgramAccount, ProgramAccountInit,
SignerAccount, SubscriptionAuthority, SubscriptionAuthorityAccount, SubscriptionsError, SystemAccount,
WritableAccount, CURRENT_VERSION, DELEGATE_BASE_SEED,
helpers::system::resolve_optional_payer, state::common::find_delegation_pda, AccountCheck, Header, ProgramAccount,
ProgramAccountInit, SignerAccount, SubscriptionAuthority, SubscriptionAuthorityAccount, SubscriptionsError,
SystemAccount, WritableAccount, DELEGATE_BASE_SEED,
};

/// Validated accounts shared by `CreateFixedDelegation` and `CreateRecurringDelegation`.
Expand Down Expand Up @@ -37,13 +37,7 @@ impl<'a> TryFrom<&'a [AccountView]> for CreateDelegationAccounts<'a> {
SystemAccount::check(system_program)?;
SubscriptionAuthorityAccount::check(subscription_authority)?;

let payer = if let Some(payer) = rem.first() {
SignerAccount::check(payer)?;
WritableAccount::check(payer)?;
payer
} else {
delegator
};
let payer = resolve_optional_payer(delegator, rem)?;

Ok(Self { delegator, subscription_authority, delegation_account, delegatee, system_program, payer })
}
Expand Down Expand Up @@ -100,25 +94,6 @@ pub fn create_delegation_account(
Ok((bump, init_id, mint))
}

/// Populates a delegation [`Header`] with the standard fields.
pub fn init_header(
header: &mut Header,
discriminator: AccountDiscriminator,
bump: u8,
delegator: &Address,
delegatee: &Address,
payer: &Address,
init_id: i64,
) {
header.version = CURRENT_VERSION;
header.discriminator = discriminator.into();
header.bump = bump;
header.delegator = *delegator;
header.delegatee = *delegatee;
header.payer = *payer;
header.init_id = init_id;
}

/// Authorization checker for delegation transfers.
///
/// Verifies that the delegation belongs to the claimed delegator and that
Expand Down
14 changes: 14 additions & 0 deletions program/src/instructions/helpers/system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,20 @@ impl AccountCheck for SystemAccount {
}
}

/// Returns the optional sponsor (signer + writable) from the trailing remainder if present, else falls back to `primary`.
pub fn resolve_optional_payer<'a>(
primary: &'a AccountView,
rem: &'a [AccountView],
) -> Result<&'a AccountView, ProgramError> {
if let Some(payer) = rem.first() {
SignerAccount::check(payer)?;
WritableAccount::check(payer)?;
Ok(payer)
} else {
Ok(primary)
}
}

/// Validates that the account is a program-owned [`SubscriptionAuthority`] PDA with the correct
/// discriminator and size.
pub struct SubscriptionAuthorityAccount;
Expand Down
48 changes: 47 additions & 1 deletion program/src/instructions/helpers/transfer_utils.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use pinocchio::{
cpi::{Seed, Signer},
error::ProgramError,
AccountView, Address, ProgramResult,
};
use pinocchio_token_2022::instructions::Transfer;
Expand All @@ -8,7 +9,8 @@ use crate::{
constants::{
TOKEN_ACCOUNT_MINT_END, TOKEN_ACCOUNT_MINT_OFFSET, TOKEN_ACCOUNT_OWNER_END, TOKEN_ACCOUNT_OWNER_OFFSET,
},
SubscriptionAuthority, SubscriptionsError,
AccountCheck, ProgramAccount, SignerAccount, SubscriptionAuthority, SubscriptionAuthorityAccount,
SubscriptionsError, TokenAccountInterface, TokenProgramInterface, WritableAccount,
};

/// Verifies that the token account's owner field matches `expected`.
Expand Down Expand Up @@ -43,6 +45,50 @@ pub fn get_token_account_owner(data: &[u8]) -> Result<Address, SubscriptionsErro
Ok(Address::from(owner))
}

/// Validated accounts shared by `TransferFixed` and `TransferRecurring` (identical layouts).
pub struct DelegationTransferAccounts<'a> {
pub delegation_pda: &'a AccountView,
pub subscription_authority: &'a AccountView,
pub delegator_ata: &'a AccountView,
pub receiver_ata: &'a AccountView,
pub token_program: &'a AccountView,
pub delegatee: &'a AccountView,
pub event_authority: &'a AccountView,
pub self_program: &'a AccountView,
}

impl<'a> TryFrom<&'a [AccountView]> for DelegationTransferAccounts<'a> {
type Error = ProgramError;

fn try_from(accounts: &'a [AccountView]) -> Result<Self, Self::Error> {
let [delegation_pda, subscription_authority, delegator_ata, receiver_ata, token_program, delegatee, event_authority, self_program] =
accounts
else {
return Err(SubscriptionsError::NotEnoughAccountKeys.into());
};

ProgramAccount::check(delegation_pda)?;
WritableAccount::check(delegation_pda)?;
WritableAccount::check(delegator_ata)?;
WritableAccount::check(receiver_ata)?;
SubscriptionAuthorityAccount::check(subscription_authority)?;
TokenProgramInterface::check(token_program)?;
TokenAccountInterface::check_accounts_with_program(token_program, &[delegator_ata, receiver_ata])?;
SignerAccount::check(delegatee)?;

Ok(Self {
delegation_pda,
subscription_authority,
delegator_ata,
receiver_ata,
token_program,
delegatee,
event_authority,
self_program,
})
}
}

/// Accounts required to execute a delegated token transfer.
pub struct TransferAccounts<'a> {
/// The delegator's Associated Token Account (source).
Expand Down
16 changes: 5 additions & 11 deletions program/src/instructions/initialize_subscription_authority.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ use pinocchio_token::instructions::Approve as ApproveSpl;
use pinocchio_token_2022::instructions::Approve as Approve2022;

use crate::{
check_token_account_mint, check_token_account_owner, constants::TOKEN_2022_PROGRAM_ID, AccountCheck,
AssociatedTokenAccount, AssociatedTokenAccountCheck, MintInterface, ProgramAccount, ProgramAccountInit,
SignerAccount, SubscriptionAuthority, SubscriptionsError, SystemAccount, TokenAccountInterface,
TokenProgramInterface, WritableAccount,
check_token_account_mint, check_token_account_owner, constants::TOKEN_2022_PROGRAM_ID,
helpers::system::resolve_optional_payer, AccountCheck, AssociatedTokenAccount, AssociatedTokenAccountCheck,
MintInterface, ProgramAccount, ProgramAccountInit, SignerAccount, SubscriptionAuthority, SubscriptionsError,
SystemAccount, TokenAccountInterface, TokenProgramInterface, WritableAccount,
};

/// Validated accounts for the [`InitSubscriptionAuthority`](crate::SubscriptionsInstruction::InitSubscriptionAuthority) instruction.
Expand Down Expand Up @@ -45,13 +45,7 @@ impl<'a> TryFrom<&'a [AccountView]> for InitializeSubscriptionAuthorityAccounts<
TokenProgramInterface::check(token_program)?;
SystemAccount::check(system_program)?;

let payer = if let Some(payer) = rem.first() {
SignerAccount::check(payer)?;
WritableAccount::check(payer)?;
payer
} else {
user
};
let payer = resolve_optional_payer(user, rem)?;

Ok(Self { subscription_authority, user, token_mint, user_ata, system_program, token_program, payer })
}
Expand Down
13 changes: 2 additions & 11 deletions program/src/instructions/subscribe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use pinocchio::Address;
use crate::{
event_engine::{self, EventSerialize},
events::SubscriptionCreatedEvent,
helpers::system::resolve_optional_payer,
state::{
common::{find_subscription_pda, AccountDiscriminator, PlanStatus},
plan::Plan,
Expand Down Expand Up @@ -80,10 +81,6 @@ pub fn process(accounts: &[AccountView], data: &SubscribeData) -> ProgramResult
let plan_data = accounts_struct.plan_pda.try_borrow()?;
let plan = Plan::load(&plan_data)?;

if data.plan_bump != plan.bump {
return Err(SubscriptionsError::InvalidPlanPda.into());
}

if PlanStatus::try_from(plan.status)? != PlanStatus::Active {
return Err(SubscriptionsError::PlanSunset.into());
}
Expand Down Expand Up @@ -217,13 +214,7 @@ impl<'a> TryFrom<&'a [AccountView]> for SubscribeAccounts<'a> {
SubscriptionAuthorityAccount::check(subscription_authority_pda)?;
SystemAccount::check(system_program)?;

let payer = if let Some(payer) = rem.first() {
SignerAccount::check(payer)?;
WritableAccount::check(payer)?;
payer
} else {
subscriber
};
let payer = resolve_optional_payer(subscriber, rem)?;

Ok(Self {
subscriber,
Expand Down
Loading
Loading