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]);