diff --git a/apps/web/src/instructions/Subscribe.tsx b/apps/web/src/instructions/Subscribe.tsx index 9fd3cde..e6f7e0c 100644 --- a/apps/web/src/instructions/Subscribe.tsx +++ b/apps/web/src/instructions/Subscribe.tsx @@ -17,6 +17,9 @@ export function Subscribe() { const [merchant, setMerchant] = useState(''); const [planId, setPlanId] = useState('0'); const [tokenMint, setTokenMint] = useState(''); + const [expectedAmount, setExpectedAmount] = useState('0'); + const [expectedPeriodHours, setExpectedPeriodHours] = useState('0'); + const [expectedCreatedAt, setExpectedCreatedAt] = useState('0'); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); @@ -27,6 +30,9 @@ export function Subscribe() { const { instructions, subscriptionPda } = await buildSubscribe({ subscriber: signer, merchant: merchant.trim() as Address, planId: BigInt(planId), tokenMint: tokenMint.trim() as Address, + expectedAmount: BigInt(expectedAmount), + expectedPeriodHours: BigInt(expectedPeriodHours), + expectedCreatedAt: BigInt(expectedCreatedAt), programAddress: getProgramAddress(), }); @@ -46,6 +52,12 @@ export function Subscribe() { + + + diff --git a/clients/typescript/src/client.ts b/clients/typescript/src/client.ts index 7593c48..a9988db 100644 --- a/clients/typescript/src/client.ts +++ b/clients/typescript/src/client.ts @@ -6,7 +6,10 @@ import { } from './accounts/delegations.js'; import { fetchPlansForOwner } from './accounts/plans.js'; import type { PlanStatus } from './generated/index.js'; -import { fetchMaybeSubscriptionAuthority } from './generated/index.js'; +import { + fetchMaybeSubscriptionAuthority, + fetchPlan, +} from './generated/index.js'; import { buildCloseSubscriptionAuthority, buildCreateFixedDelegation, @@ -29,7 +32,7 @@ import { buildTransferRecurring, buildTransferSubscription, } from './instructions/transfer.js'; -import { getSubscriptionAuthorityPDA } from './pdas.js'; +import { getPlanPDA, getSubscriptionAuthorityPDA } from './pdas.js'; import type { SolanaClient, TransactionResult } from './types/common.js'; import type { Delegation } from './types/delegation.js'; import type { PlanWithAddress } from './types/plan.js'; @@ -319,9 +322,32 @@ export class SubscriptionsClient { merchant: Address; planId: number | bigint; tokenMint: Address; + /** Plan terms the subscriber consents to. If omitted, the live plan is + * fetched and its current terms are used; the program still rejects on + * mismatch at submit time. */ + expectedAmount?: number | bigint; + expectedPeriodHours?: number | bigint; + expectedCreatedAt?: number | bigint; payer?: TransactionSigner; }): Promise { - const { instructions, subscriptionPda } = await buildSubscribe(params); + let { expectedAmount, expectedPeriodHours, expectedCreatedAt } = params; + if ( + expectedAmount === undefined || + expectedPeriodHours === undefined || + expectedCreatedAt === undefined + ) { + const [planPda] = await getPlanPDA(params.merchant, params.planId); + const plan = await fetchPlan(this.client.rpc, planPda); + expectedAmount ??= plan.data.data.terms.amount; + expectedPeriodHours ??= plan.data.data.terms.periodHours; + expectedCreatedAt ??= plan.data.data.terms.createdAt; + } + const { instructions, subscriptionPda } = await buildSubscribe({ + ...params, + expectedAmount, + expectedPeriodHours, + expectedCreatedAt, + }); const signature = await this.buildAndSendTransaction( instructions, params.payer ?? params.subscriber, diff --git a/clients/typescript/src/instructions/subscription.ts b/clients/typescript/src/instructions/subscription.ts index e2774d9..b84d8b8 100644 --- a/clients/typescript/src/instructions/subscription.ts +++ b/clients/typescript/src/instructions/subscription.ts @@ -31,11 +31,25 @@ export async function buildSubscribe(params: { merchant: Address; planId: number | bigint; tokenMint: Address; + /** Plan terms the subscriber is consenting to. Caller must fetch from the + * live plan; the program rejects if the on-chain plan disagrees at submit. */ + expectedAmount: number | bigint; + expectedPeriodHours: number | bigint; + expectedCreatedAt: number | bigint; payer?: TransactionSigner; programAddress?: Address; }): Promise<{ instructions: Instruction[]; subscriptionPda: Address }> { - const { subscriber, merchant, planId, tokenMint, payer, programAddress } = - params; + const { + subscriber, + merchant, + planId, + tokenMint, + expectedAmount, + expectedPeriodHours, + expectedCreatedAt, + payer, + programAddress, + } = params; const config = programAddress ? { programAddress } : undefined; const [planPda, planBump] = await getPlanPDA( @@ -61,7 +75,14 @@ export async function buildSubscribe(params: { planPda, subscriptionPda, subscriptionAuthorityPda, - subscribeData: { planId, planBump }, + subscribeData: { + planId, + planBump, + expectedMint: tokenMint, + expectedAmount, + expectedPeriodHours, + expectedCreatedAt, + }, }, config, ); diff --git a/clients/typescript/test/setup.ts b/clients/typescript/test/setup.ts index b150d6b..da55220 100644 --- a/clients/typescript/test/setup.ts +++ b/clients/typescript/test/setup.ts @@ -35,6 +35,9 @@ export const SURFPOOL_RPC_URL = `http://127.0.0.1:${SURFPOOL_PORT}`; export const DEFAULT_TEST_BALANCE = 1_000_000n; export const ONE_HOUR_IN_SECONDS = 3600; export const ONE_DAY_IN_SECONDS = 86400; +const SYSVAR_CLOCK_ADDRESS = + 'SysvarC1ock11111111111111111111111111111111' as Address; +const SYSVAR_CLOCK_UNIX_TIMESTAMP_OFFSET = 32; type SolanaClient = ReturnType; export type SmartWalletName = 'swig' | 'squads'; @@ -147,6 +150,7 @@ export class IntegrationTest { */ static async create(): Promise { await isSurfnetRunning(); // Just verify surfpool is running + const solanaClient = createSolanaClient({ urlOrMoniker: 'localnet' }); const client = new SubscriptionsClient(solanaClient); @@ -216,10 +220,17 @@ export class IntegrationTest { } async getValidatorTime(): Promise { + const clockTime = await getClockSysvarTime(this.rpc); + if (clockTime != null) return clockTime; + const slot = await this.rpc.getSlot().send(); const blockTime = await this.rpc.getBlockTime(slot).send(); - if (blockTime == null) throw new Error('blockTime is null'); - return BigInt(blockTime); + const wall = BigInt(Math.floor(Date.now() / 1000)); + if (blockTime != null) { + const ts = BigInt(blockTime); + if (ts + 60n >= wall) return ts; + } + return wall; } async minPlanEndTs(periodHours: bigint): Promise { @@ -229,19 +240,7 @@ export class IntegrationTest { } async timeTravel(targetTimestampSec: number): Promise { - const res = await fetch(SURFPOOL_RPC_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'surfnet_timeTravel', - params: [{ absoluteTimestamp: targetTimestampSec * 1000 }], - }), - }); - if (!res.ok) throw new Error(`surfnet_timeTravel failed: ${res.status}`); - const data = (await res.json()) as { error?: { message: string } }; - if (data.error) throw new Error(data.error.message); + await setSurfpoolClock(targetTimestampSec); } private smartWalletsInitialized = false; @@ -306,6 +305,40 @@ export class IntegrationTest { // Private Helper Functions // ============================================================================ +async function setSurfpoolClock(targetTimestampSec: number): Promise { + const res = await fetch(SURFPOOL_RPC_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'surfnet_timeTravel', + params: [{ absoluteTimestamp: targetTimestampSec * 1000 }], + }), + }); + if (!res.ok) throw new Error(`surfnet_timeTravel failed: ${res.status}`); + const data = (await res.json()) as { error?: { message: string } }; + if (data.error) throw new Error(data.error.message); +} + +async function getClockSysvarTime( + rpc: SolanaClient['rpc'], +): Promise { + const account = await rpc + .getAccountInfo(SYSVAR_CLOCK_ADDRESS, { encoding: 'base64' }) + .send(); + const encodedData = account.value?.data; + if (!Array.isArray(encodedData) || typeof encodedData[0] !== 'string') { + return null; + } + + const clockData = Buffer.from(encodedData[0], 'base64'); + if (clockData.length < SYSVAR_CLOCK_UNIX_TIMESTAMP_OFFSET + 8) { + return null; + } + return clockData.readBigInt64LE(SYSVAR_CLOCK_UNIX_TIMESTAMP_OFFSET); +} + /** * Checks that Surfpool is running and returns the RPC URL. * diff --git a/programs/subscriptions/idl/subscriptions.json b/programs/subscriptions/idl/subscriptions.json index aaf6dfd..911f6ed 100644 --- a/programs/subscriptions/idl/subscriptions.json +++ b/programs/subscriptions/idl/subscriptions.json @@ -492,6 +492,40 @@ "format": "u8", "kind": "numberTypeNode" } + }, + { + "kind": "structFieldTypeNode", + "name": "expectedMint", + "type": { + "kind": "publicKeyTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "expectedAmount", + "type": { + "endian": "le", + "format": "u64", + "kind": "numberTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "expectedPeriodHours", + "type": { + "endian": "le", + "format": "u64", + "kind": "numberTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "expectedCreatedAt", + "type": { + "endian": "le", + "format": "i64", + "kind": "numberTypeNode" + } } ], "kind": "structTypeNode" diff --git a/programs/subscriptions/src/instructions/create_plan.rs b/programs/subscriptions/src/instructions/create_plan.rs index 12d910a..49c925b 100644 --- a/programs/subscriptions/src/instructions/create_plan.rs +++ b/programs/subscriptions/src/instructions/create_plan.rs @@ -57,6 +57,9 @@ pub struct PlanData { pub metadata_uri: [u8; 128], } +pub const PLAN_DATA_LEN_V1: usize = 456; +const _: () = assert!(PlanData::LEN == PLAN_DATA_LEN_V1); + impl PlanData { /// Serialized size in bytes. pub const LEN: usize = size_of::(); diff --git a/programs/subscriptions/src/instructions/helpers/transfer_validation.rs b/programs/subscriptions/src/instructions/helpers/transfer_validation.rs index de345ba..f780759 100644 --- a/programs/subscriptions/src/instructions/helpers/transfer_validation.rs +++ b/programs/subscriptions/src/instructions/helpers/transfer_validation.rs @@ -3,6 +3,13 @@ use pinocchio::ProgramResult; use crate::constants::TIME_DRIFT_ALLOWED_SECS; use crate::SubscriptionsError; +/// Returns true when the delegation has expired past the drift tolerance window. +/// Shared lifecycle gate used by transfer paths and sponsor revocation so both +/// agree on when a finite-expiry delegation is unspendable. +pub fn is_effectively_expired(expiry_ts: i64, current_ts: i64) -> bool { + expiry_ts != 0 && current_ts > expiry_ts.saturating_add(TIME_DRIFT_ALLOWED_SECS) +} + /// Validates a fixed transfer against the delegation's remaining allowance and expiry. /// /// Returns an error if: @@ -18,7 +25,7 @@ pub fn validate_fixed_transfer( if transfer_amount == 0 { return Err(SubscriptionsError::InvalidAmount.into()); } - if expiry_ts != 0 && current_ts > expiry_ts.saturating_add(TIME_DRIFT_ALLOWED_SECS) { + if is_effectively_expired(expiry_ts, current_ts) { return Err(SubscriptionsError::DelegationExpired.into()); } if transfer_amount > remaining { @@ -50,7 +57,7 @@ pub fn validate_recurring_transfer( if transfer_amount == 0 { return Err(SubscriptionsError::InvalidAmount.into()); } - if expiry_ts != 0 && current_ts > expiry_ts.saturating_add(TIME_DRIFT_ALLOWED_SECS) { + if is_effectively_expired(expiry_ts, current_ts) { return Err(SubscriptionsError::DelegationExpired.into()); } @@ -71,10 +78,13 @@ pub fn validate_recurring_transfer( let increment = periods_passed .checked_mul(period_length) .ok_or(SubscriptionsError::ArithmeticOverflow)?; - *current_period_start_ts = current_period_start_ts + let candidate_start = current_period_start_ts .checked_add(increment) .ok_or(SubscriptionsError::ArithmeticOverflow)?; - *amount_pulled_in_period = 0; + if expiry_ts == 0 || candidate_start < expiry_ts { + *current_period_start_ts = candidate_start; + *amount_pulled_in_period = 0; + } } let available = amount_per_period diff --git a/programs/subscriptions/src/instructions/revoke_delegation.rs b/programs/subscriptions/src/instructions/revoke_delegation.rs index 87e244a..26cf907 100644 --- a/programs/subscriptions/src/instructions/revoke_delegation.rs +++ b/programs/subscriptions/src/instructions/revoke_delegation.rs @@ -6,6 +6,7 @@ use pinocchio::{ use crate::{ check_and_update_version, + helpers::is_effectively_expired, state::{ common::AccountDiscriminator, fixed_delegation::FixedDelegation, plan::Plan, recurring_delegation::RecurringDelegation, subscription_delegation::SubscriptionDelegation, @@ -148,11 +149,8 @@ pub fn process(accounts: &[AccountView]) -> ProgramResult { } _ => RecurringDelegation::load_with_min_size(&data)?.expiry_ts, }; - if expiry_ts == 0 { - return Err(SubscriptionsError::Unauthorized.into()); - } let current_ts = Clock::get()?.unix_timestamp; - if expiry_ts > current_ts { + if !is_effectively_expired(expiry_ts, current_ts) { return Err(SubscriptionsError::Unauthorized.into()); } } @@ -975,6 +973,53 @@ mod tests { .assert_err(SubscriptionsError::Unauthorized); } + #[test] + fn sponsor_cannot_revoke_within_drift_window() { + let (litesvm, user) = &mut setup(); + let delegator = user; + let sponsor = init_wallet(litesvm, 10_000_000_000); + + let mint = init_mint( + litesvm, + TOKEN_PROGRAM_ID, + MINT_DECIMALS, + 1_000_000_000, + Some(delegator.pubkey()), + &[], + ); + let _user_ata = init_ata(litesvm, mint, delegator.pubkey(), 1_000_000); + + initialize_subscription_authority_action(litesvm, delegator, mint) + .0 + .assert_ok(); + + let delegatee = Pubkey::new_unique(); + let nonce: u64 = 0; + let expiry_ts = current_ts() + 100; + + let (res, _) = CreateDelegation::new(litesvm, delegator, mint, delegatee) + .payer(&sponsor) + .nonce(nonce) + .fixed(100, expiry_ts); + res.assert_ok(); + + // 110s after creation: past expiry but still within 120s drift window. + move_clock_forward(litesvm, 110); + + RevokeDelegation::new(litesvm, delegator, mint, delegatee, nonce) + .signer(&sponsor) + .execute() + .assert_err(SubscriptionsError::Unauthorized); + + // Past the drift window: sponsor can revoke. + move_clock_forward(litesvm, 121); + + RevokeDelegation::new(litesvm, delegator, mint, delegatee, nonce) + .signer(&sponsor) + .execute() + .assert_ok(); + } + #[test] fn delegator_can_revoke_sponsor_funded_before_expiry() { let (litesvm, user) = &mut setup(); diff --git a/programs/subscriptions/src/instructions/subscribe.rs b/programs/subscriptions/src/instructions/subscribe.rs index 90ba709..71e19ae 100644 --- a/programs/subscriptions/src/instructions/subscribe.rs +++ b/programs/subscriptions/src/instructions/subscribe.rs @@ -8,6 +8,8 @@ use pinocchio::{ AccountView, ProgramResult, }; +use pinocchio::Address; + use crate::{ event_engine::{self, EventSerialize}, events::SubscriptionCreatedEvent, @@ -32,6 +34,13 @@ pub struct SubscribeData { pub plan_id: u64, /// The plan PDA's bump seed (avoids an on-chain `find_program_address` call). pub plan_bump: u8, + /// Plan terms the subscriber consented to. The program rejects if the live + /// plan disagrees, preventing a stale signed subscribe from binding the + /// subscriber to terms different from what was displayed at signing time. + pub expected_mint: Address, + pub expected_amount: u64, + pub expected_period_hours: u64, + pub expected_created_at: i64, } impl SubscribeData { @@ -87,6 +96,18 @@ pub fn process(accounts: &[AccountView], data: &SubscribeData) -> ProgramResult return Err(SubscriptionsError::PlanExpired.into()); } + // Bind subscriber consent to the live plan terms. + let live_amount = plan.data.terms.amount; + let live_period_hours = plan.data.terms.period_hours; + let live_created_at = plan.data.terms.created_at; + if plan.data.mint != data.expected_mint + || live_amount != data.expected_amount + || live_period_hours != data.expected_period_hours + || live_created_at != data.expected_created_at + { + return Err(SubscriptionsError::PlanTermsMismatch.into()); + } + plan_mint = plan.data.mint; plan_terms = plan.data.terms; } @@ -525,4 +546,63 @@ mod tests { .execute(); res.assert_err(SubscriptionsError::AlreadySubscribed); } + + #[test] + fn subscribe_rejects_stale_expected_terms() { + use crate::tests::{ + constants::{PROGRAM_ID, SYSTEM_PROGRAM_ID}, + pda::get_subscription_authority_pda, + utils::build_and_send_transaction, + }; + use crate::{event_engine::event_authority_pda, instructions::subscribe}; + use solana_instruction::{AccountMeta, Instruction}; + + let end_ts = current_ts() + days(30) as i64; + let (mut litesvm, alice, merchant, mint, plan_pda, plan_bump) = setup_plan(1, end_ts); + + // Snapshot live terms, then submit subscribe with a stale `expected_amount`. + let plan_account = litesvm.get_account(&plan_pda).unwrap(); + let plan = crate::state::Plan::load(&plan_account.data).unwrap(); + let live_amount = plan.data.terms.amount; + let stale_amount = live_amount.wrapping_add(1); + let live_period_hours = plan.data.terms.period_hours; + let live_created_at = plan.data.terms.created_at; + let live_mint = plan.data.mint; + + let (subscription_authority_pda, _) = + get_subscription_authority_pda(&alice.pubkey(), &mint); + let (subscription_pda, _) = get_subscription_pda(&plan_pda, &alice.pubkey()); + let event_authority = Pubkey::new_from_array(event_authority_pda::ID.to_bytes()); + + let accounts = vec![ + AccountMeta::new(alice.pubkey(), true), + AccountMeta::new_readonly(merchant.pubkey(), false), + AccountMeta::new_readonly(plan_pda, false), + AccountMeta::new(subscription_pda, false), + AccountMeta::new_readonly(subscription_authority_pda, false), + AccountMeta::new_readonly(SYSTEM_PROGRAM_ID, false), + AccountMeta::new_readonly(event_authority, false), + AccountMeta::new_readonly(PROGRAM_ID, false), + ]; + + let data = [ + vec![*subscribe::DISCRIMINATOR], + 1u64.to_le_bytes().to_vec(), + vec![plan_bump], + live_mint.as_ref().to_vec(), + stale_amount.to_le_bytes().to_vec(), + live_period_hours.to_le_bytes().to_vec(), + live_created_at.to_le_bytes().to_vec(), + ] + .concat(); + + let ix = Instruction { + program_id: PROGRAM_ID, + accounts, + data, + }; + + let res = build_and_send_transaction(&mut litesvm, &[&alice], &alice.pubkey(), &ix); + res.assert_err(SubscriptionsError::PlanTermsMismatch); + } } diff --git a/programs/subscriptions/src/instructions/transfer_recurring_delegation.rs b/programs/subscriptions/src/instructions/transfer_recurring_delegation.rs index 2ff0ed4..b5cd7f8 100644 --- a/programs/subscriptions/src/instructions/transfer_recurring_delegation.rs +++ b/programs/subscriptions/src/instructions/transfer_recurring_delegation.rs @@ -1027,6 +1027,39 @@ mod tests { .assert_ok(); } + #[test] + fn test_recurring_rollover_blocked_at_expiry_boundary() { + let amount_per_period: u64 = 1_000_000; + let period_length_s: u64 = 1; + let start_ts: i64 = current_ts(); + let expiry_ts: i64 = start_ts + period_length_s as i64; + let nonce = 0; + + let (mut litesvm, alice, bob, delegation_pda, mint, _, bob_ata, _) = + setup_recurring_delegation( + amount_per_period, + period_length_s, + start_ts, + expiry_ts, + nonce, + ); + + TransferDelegation::new(&mut litesvm, &bob, alice.pubkey(), mint, delegation_pda) + .amount(amount_per_period) + .recurring() + .assert_ok(); + assert_eq!(get_ata_balance(&litesvm, &bob_ata), amount_per_period); + + move_clock_forward(&mut litesvm, period_length_s); + + let result = + TransferDelegation::new(&mut litesvm, &bob, alice.pubkey(), mint, delegation_pda) + .amount(amount_per_period) + .recurring(); + result.assert_err(SubscriptionsError::AmountExceedsPeriodLimit); + assert_eq!(get_ata_balance(&litesvm, &bob_ata), amount_per_period); + } + #[test] fn test_recurring_transfer_past_drift_window() { let amount_per_period: u64 = 50_000_000; diff --git a/programs/subscriptions/src/state/fixed_delegation.rs b/programs/subscriptions/src/state/fixed_delegation.rs index 44bd9a9..97aacd2 100644 --- a/programs/subscriptions/src/state/fixed_delegation.rs +++ b/programs/subscriptions/src/state/fixed_delegation.rs @@ -67,3 +67,6 @@ impl FixedDelegation { Ok(unsafe { &*transmute::<*const u8, *const Self>(bytes.as_ptr()) }) } } + +pub const FIXED_DELEGATION_LEN_V1: usize = 123; +const _: () = assert!(FixedDelegation::LEN == FIXED_DELEGATION_LEN_V1); diff --git a/programs/subscriptions/src/state/header.rs b/programs/subscriptions/src/state/header.rs index 90de8c7..3c2322b 100644 --- a/programs/subscriptions/src/state/header.rs +++ b/programs/subscriptions/src/state/header.rs @@ -79,3 +79,10 @@ impl Header { self.init_id = init_id; } } + +pub const HEADER_LEN_V1: usize = 107; +const _: () = assert!(Header::LEN == HEADER_LEN_V1); +const _: () = assert!(INIT_ID_OFFSET == 99); +const _: () = assert!(DELEGATOR_OFFSET == 3); +const _: () = assert!(DELEGATEE_OFFSET == 35); +const _: () = assert!(PAYER_OFFSET == 67); diff --git a/programs/subscriptions/src/state/plan.rs b/programs/subscriptions/src/state/plan.rs index 930504e..65ca2e0 100644 --- a/programs/subscriptions/src/state/plan.rs +++ b/programs/subscriptions/src/state/plan.rs @@ -34,6 +34,9 @@ pub struct Plan { pub data: PlanData, } +pub const PLAN_LEN_V1: usize = 491; +const _: () = assert!(Plan::LEN == PLAN_LEN_V1); + impl Plan { /// Total serialized size in bytes. pub const LEN: usize = size_of::(); @@ -74,7 +77,8 @@ impl Plan { if *caller == self.owner { return Ok(()); } - if self.data.pullers.contains(caller) { + let zero = Address::default(); + if self.data.pullers.iter().any(|p| *p != zero && p == caller) { return Ok(()); } Err(SubscriptionsError::Unauthorized.into()) @@ -84,12 +88,78 @@ impl Plan { /// /// If no destinations are configured (all zero), any receiver is valid. /// Otherwise the receiver must appear in the `destinations` whitelist. + /// Zero-padded slots are skipped so they cannot match a zero-owned receiver. pub fn check_destination(&self, receiver_owner: &Address) -> Result<(), ProgramError> { let zero = Address::default(); - let has_destinations = self.data.destinations.iter().any(|d| *d != zero); - if has_destinations && !self.data.destinations.contains(receiver_owner) { + let mut configured = self.data.destinations.iter().filter(|d| **d != zero); + if configured.clone().next().is_some() && !configured.any(|d| d == receiver_owner) { return Err(SubscriptionsError::UnauthorizedDestination.into()); } Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use core::mem::transmute; + + fn make_plan(destinations: [Address; 4], pullers: [Address; 4]) -> Plan { + let mut bytes = vec![0u8; Plan::LEN]; + bytes[0] = AccountDiscriminator::Plan as u8; + let plan = unsafe { &mut *transmute::<*mut u8, *mut Plan>(bytes.as_mut_ptr()) }; + plan.data.destinations = destinations; + plan.data.pullers = pullers; + unsafe { core::ptr::read(plan as *const Plan) } + } + + fn addr(byte: u8) -> Address { + let mut a = [0u8; 32]; + a[0] = byte; + Address::from(a) + } + + #[test] + fn check_destination_rejects_zero_owned_receiver_with_partial_whitelist() { + let merchant = addr(1); + let plan = make_plan( + [ + merchant, + Address::default(), + Address::default(), + Address::default(), + ], + [Address::default(); 4], + ); + + plan.check_destination(&merchant).unwrap(); + assert!(plan.check_destination(&Address::default()).is_err()); + } + + #[test] + fn check_destination_open_when_all_zero() { + let plan = make_plan([Address::default(); 4], [Address::default(); 4]); + plan.check_destination(&addr(7)).unwrap(); + plan.check_destination(&Address::default()).unwrap(); + } + + #[test] + fn can_pull_rejects_zero_caller_with_partial_whitelist() { + let owner = addr(2); + let puller = addr(3); + let mut plan = make_plan( + [Address::default(); 4], + [ + puller, + Address::default(), + Address::default(), + Address::default(), + ], + ); + plan.owner = owner; + + plan.can_pull(&owner).unwrap(); + plan.can_pull(&puller).unwrap(); + assert!(plan.can_pull(&Address::default()).is_err()); + } +} diff --git a/programs/subscriptions/src/state/recurring_delegation.rs b/programs/subscriptions/src/state/recurring_delegation.rs index 070b16d..24807a0 100644 --- a/programs/subscriptions/src/state/recurring_delegation.rs +++ b/programs/subscriptions/src/state/recurring_delegation.rs @@ -73,3 +73,6 @@ impl RecurringDelegation { Ok(unsafe { &*transmute::<*const u8, *const Self>(bytes.as_ptr()) }) } } + +pub const RECURRING_DELEGATION_LEN_V1: usize = 147; +const _: () = assert!(RecurringDelegation::LEN == RECURRING_DELEGATION_LEN_V1); diff --git a/programs/subscriptions/src/state/subscription_delegation.rs b/programs/subscriptions/src/state/subscription_delegation.rs index 20a1ac8..e6db794 100644 --- a/programs/subscriptions/src/state/subscription_delegation.rs +++ b/programs/subscriptions/src/state/subscription_delegation.rs @@ -99,3 +99,6 @@ impl SubscriptionDelegation { Ok(()) } } + +pub const SUBSCRIPTION_DELEGATION_LEN_V1: usize = 155; +const _: () = assert!(SubscriptionDelegation::LEN == SUBSCRIPTION_DELEGATION_LEN_V1); diff --git a/programs/subscriptions/src/tests/utils.rs b/programs/subscriptions/src/tests/utils.rs index cea255e..3cca4ed 100644 --- a/programs/subscriptions/src/tests/utils.rs +++ b/programs/subscriptions/src/tests/utils.rs @@ -1178,10 +1178,22 @@ impl<'a> Subscribe<'a> { fee_payer = p.pubkey(); } + // Snapshot live plan terms to bind subscriber consent. + let plan_account = self.litesvm.get_account(&self.plan_pda).unwrap(); + let plan = crate::state::Plan::load(&plan_account.data).unwrap(); + let expected_amount = plan.data.terms.amount; + let expected_period_hours = plan.data.terms.period_hours; + let expected_created_at = plan.data.terms.created_at; + let expected_mint = plan.data.mint; + let data = [ vec![*subscribe::DISCRIMINATOR], self.plan_id.to_le_bytes().to_vec(), vec![self.plan_bump], + expected_mint.as_ref().to_vec(), + expected_amount.to_le_bytes().to_vec(), + expected_period_hours.to_le_bytes().to_vec(), + expected_created_at.to_le_bytes().to_vec(), ] .concat(); diff --git a/webapp/src/components/delegation/active-delegations.tsx b/webapp/src/components/delegation/active-delegations.tsx index 8be1883..5090c2b 100644 --- a/webapp/src/components/delegation/active-delegations.tsx +++ b/webapp/src/components/delegation/active-delegations.tsx @@ -94,6 +94,7 @@ function RevokeDelegationButton({ delegation }: RevokeDelegationButtonProps) { try { await revokeDelegation.mutateAsync({ delegationAccount: delegation.address, + payer: delegation.data.header.payer, }) setOpen(false) } catch { @@ -664,8 +665,7 @@ export function ActiveDelegations({ tokenMint, isApproved, subscriptionAuthority const handleRevokeAllStale = async () => { if (staleDelegations.length === 0) return await revokeMultipleDelegations.mutateAsync({ - delegationAccounts: staleDelegations.map((d) => d.address), - tokenMint, + delegations: staleDelegations.map((d) => ({ address: d.address, payer: d.data.header.payer })), }) onInitSuccess?.() } diff --git a/webapp/src/components/plan/plan-card.tsx b/webapp/src/components/plan/plan-card.tsx index 2a7c499..0314b6c 100644 --- a/webapp/src/components/plan/plan-card.tsx +++ b/webapp/src/components/plan/plan-card.tsx @@ -463,6 +463,9 @@ function SubscribeDialog({ plan, meta, open, onOpenChange }: { merchant: plan.owner, planId: plan.data.planId, tokenMint: plan.data.mint, + expectedAmount: plan.data.terms.amount, + expectedPeriodHours: plan.data.terms.periodHours, + expectedCreatedAt: plan.data.terms.createdAt, }, { onSuccess: () => onOpenChange(false) })} disabled={subscribe.isPending} className="bg-emerald-600 hover:bg-emerald-500 text-white" diff --git a/webapp/src/components/subscription/my-subscriptions-panel.tsx b/webapp/src/components/subscription/my-subscriptions-panel.tsx index 27978e9..5295d27 100644 --- a/webapp/src/components/subscription/my-subscriptions-panel.tsx +++ b/webapp/src/components/subscription/my-subscriptions-panel.tsx @@ -88,6 +88,8 @@ function RevokeSubscriptionDialog({ item, open, onOpenChange }: { variant="destructive" onClick={() => revokeSubscription.mutate({ subscriptionPda: item.address, + planPda: item.subscription.header.delegatee, + payer: item.subscription.header.payer, }, { onSuccess: () => onOpenChange(false) })} disabled={!canRevoke || revokeSubscription.isPending} > @@ -125,6 +127,7 @@ function CancelAndRevokeDialog({ item, isGhostPlan, open, onOpenChange }: { onClick={() => cancelAndRevokeSubscription.mutate({ planPda: item.subscription.header.delegatee, subscriptionPda: item.address, + payer: item.subscription.header.payer, }, { onSuccess: () => onOpenChange(false) })} disabled={cancelAndRevokeSubscription.isPending} > diff --git a/webapp/src/hooks/use-subscription-authority-status.ts b/webapp/src/hooks/use-subscription-authority-status.ts index 791dc37..f3b3390 100644 --- a/webapp/src/hooks/use-subscription-authority-status.ts +++ b/webapp/src/hooks/use-subscription-authority-status.ts @@ -9,6 +9,7 @@ import { useProgramAddress } from '@/hooks/use-token-config' export interface SubscriptionAuthorityData { owner: string tokenMint: string + payer: string bump: number initId: bigint } diff --git a/webapp/src/hooks/use-subscriptions-mutations.ts b/webapp/src/hooks/use-subscriptions-mutations.ts index 5f52801..c6e729d 100644 --- a/webapp/src/hooks/use-subscriptions-mutations.ts +++ b/webapp/src/hooks/use-subscriptions-mutations.ts @@ -11,6 +11,7 @@ import { buildCreateFixedDelegation, buildCreateRecurringDelegation, buildRevokeDelegation, + buildRevokeSubscription, buildTransferFixed, buildTransferRecurring, buildTransferSubscription, @@ -19,9 +20,12 @@ import { buildDeletePlan, buildSubscribe, buildCancelSubscription, + fetchMaybeSubscriptionAuthority, + getSubscriptionAuthorityPDA, ZERO_ADDRESS, PlanStatus, } from "@subscriptions/client"; +import { createSolanaRpc } from "gill"; import { useClusterConfig } from "@/hooks/use-cluster-config"; import { useWalletUiSigner } from "../components/solana/use-wallet-ui-signer"; import { useWalletTransactionSignAndSend } from "../components/solana/use-wallet-transaction-sign-and-send"; @@ -78,13 +82,23 @@ export function useSubscriptionsMutations() { }); const closeSubscriptionAuthority = useMutation({ - mutationFn: async ({ tokenMint }: { tokenMint: string }) => { + mutationFn: async ({ tokenMint, payer }: { tokenMint: string; payer?: string }) => { if (!signer) throw new Error("Wallet not connected"); if (!progId) throw new Error("Program address not configured"); + let storedPayer = payer; + if (!storedPayer) { + const rpc = createSolanaRpc(rpcUrl); + const [pda] = await getSubscriptionAuthorityPDA(signer.address, address(tokenMint), progId); + const maybe = await fetchMaybeSubscriptionAuthority(rpc, pda); + if (maybe.exists) storedPayer = maybe.data.payer; + } + const receiver = storedPayer && storedPayer !== signer.address ? address(storedPayer) : undefined; + const { instructions } = await buildCloseSubscriptionAuthority({ user: signer, tokenMint: address(tokenMint), + receiver, programAddress: progId, }); @@ -185,14 +199,19 @@ export function useSubscriptionsMutations() { const revokeDelegation = useMutation({ mutationFn: async ({ delegationAccount, + payer, }: { delegationAccount: string; + payer: string; }) => { if (!signer) throw new Error("Wallet not connected"); + const receiver = payer !== signer.address ? address(payer) : undefined; + const { instructions } = buildRevokeDelegation({ authority: signer, delegationAccount: address(delegationAccount), + receiver, programAddress: progId, }); @@ -400,10 +419,16 @@ export function useSubscriptionsMutations() { merchant, planId, tokenMint, + expectedAmount, + expectedPeriodHours, + expectedCreatedAt, }: { merchant: string; planId: bigint; tokenMint: string; + expectedAmount: bigint; + expectedPeriodHours: bigint; + expectedCreatedAt: bigint; }) => { if (!signer) throw new Error("Wallet not connected"); if (!progId) throw new Error("Program address not configured"); @@ -413,6 +438,9 @@ export function useSubscriptionsMutations() { merchant: address(merchant), planId, tokenMint: address(tokenMint), + expectedAmount, + expectedPeriodHours, + expectedCreatedAt, programAddress: progId, }); @@ -457,14 +485,22 @@ export function useSubscriptionsMutations() { const revokeSubscription = useMutation({ mutationFn: async ({ subscriptionPda, + planPda, + payer, }: { subscriptionPda: string; + planPda: string; + payer: string; }) => { if (!signer) throw new Error("Wallet not connected"); - const { instructions } = buildRevokeDelegation({ + const receiver = payer !== signer.address ? address(payer) : undefined; + + const { instructions } = buildRevokeSubscription({ authority: signer, - delegationAccount: address(subscriptionPda), + subscriptionPda: address(subscriptionPda), + planPda: address(planPda), + receiver, programAddress: progId, }); @@ -482,13 +518,17 @@ export function useSubscriptionsMutations() { mutationFn: async ({ planPda, subscriptionPda, + payer, }: { planPda: string; subscriptionPda: string; + payer: string; }) => { if (!signer) throw new Error("Wallet not connected"); if (!progId) throw new Error("Program address not configured"); + const receiver = payer !== signer.address ? address(payer) : undefined; + const { instructions: cancelIxs } = await buildCancelSubscription({ subscriber: signer, planPda: address(planPda), @@ -496,9 +536,11 @@ export function useSubscriptionsMutations() { programAddress: progId, }); - const { instructions: revokeIxs } = buildRevokeDelegation({ + const { instructions: revokeIxs } = buildRevokeSubscription({ authority: signer, - delegationAccount: address(subscriptionPda), + subscriptionPda: address(subscriptionPda), + planPda: address(planPda), + receiver, programAddress: progId, }); @@ -685,34 +727,33 @@ export function useSubscriptionsMutations() { }); const revokeMultipleDelegations = useMutation({ - mutationFn: async ({ delegationAccounts, tokenMint }: { delegationAccounts: string[]; tokenMint: string }) => { + mutationFn: async ({ + delegations, + }: { + delegations: Array<{ address: string; payer: string }>; + }) => { if (!signer) throw new Error("Wallet not connected"); if (!progId) throw new Error("Program address not configured"); - const revokeIxs = delegationAccounts.map((account) => { + const revokeIxs = delegations.map(({ address: account, payer }) => { + const receiver = payer !== signer.address ? address(payer) : undefined; const { instructions } = buildRevokeDelegation({ authority: signer, delegationAccount: address(account), + receiver, programAddress: progId, }); return instructions[0]; }); - const { instructions: closeIxs } = await buildCloseSubscriptionAuthority({ - user: signer, - tokenMint: address(tokenMint), - programAddress: progId, - }); - - const allIxs = [...revokeIxs, ...closeIxs]; - const batches = packInstructionBatches(allIxs, signer); + const batches = packInstructionBatches(revokeIxs, signer); const signatures: string[] = []; for (const batch of batches) { signatures.push(await signAndSend(batch, signer)); } - return { signatures, revoked: delegationAccounts.length }; + return { signatures, revoked: delegations.length }; }, onSuccess: (res) => { toast.onSuccess(res.signatures[0]);