From e127d092598a4331261d532eb2fc3932977c70ee Mon Sep 17 00:00:00 2001 From: Jo D Date: Tue, 28 Apr 2026 09:51:45 -0400 Subject: [PATCH 1/9] fix(audit): lock plan v1 layout (MULT-16) - PLAN_LEN_V1 + assert in plan.rs - PLAN_DATA_LEN_V1 + assert in create_plan.rs --- programs/subscriptions/src/instructions/create_plan.rs | 3 +++ programs/subscriptions/src/state/plan.rs | 3 +++ 2 files changed, 6 insertions(+) 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/state/plan.rs b/programs/subscriptions/src/state/plan.rs index 930504e..bf80550 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::(); From b17a0736fa22d1444b95a617b0242b0c762601a5 Mon Sep 17 00:00:00 2001 From: Jo D Date: Tue, 28 Apr 2026 10:44:56 -0400 Subject: [PATCH 2/9] fix(audit): block recurring rollover into post-expiry periods (MULT-7) Skip rollover when candidate_start >= expiry_ts. Pulls within drift window remain valid in the final authorized period; no fresh allowance is granted in terminal periods. Affects validate_recurring_transfer (used by recurring delegations and subscription transfers). --- .../helpers/transfer_validation.rs | 7 ++-- .../transfer_recurring_delegation.rs | 33 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/programs/subscriptions/src/instructions/helpers/transfer_validation.rs b/programs/subscriptions/src/instructions/helpers/transfer_validation.rs index de345ba..5271c57 100644 --- a/programs/subscriptions/src/instructions/helpers/transfer_validation.rs +++ b/programs/subscriptions/src/instructions/helpers/transfer_validation.rs @@ -71,10 +71,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/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; From 5a270fd77752c643d7090fc738149068f54b0a48 Mon Sep 17 00:00:00 2001 From: Jo D Date: Tue, 28 Apr 2026 10:58:08 -0400 Subject: [PATCH 3/9] fix(audit): align sponsor revoke with transfer drift window (MULT-5) Extract is_effectively_expired helper. Sponsor revocation now waits the same TIME_DRIFT_ALLOWED_SECS past expiry that transfers tolerate, so sponsor cannot close a delegation while the delegatee can still pull. --- .../helpers/transfer_validation.rs | 11 +++- .../src/instructions/revoke_delegation.rs | 53 +++++++++++++++++-- 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/programs/subscriptions/src/instructions/helpers/transfer_validation.rs b/programs/subscriptions/src/instructions/helpers/transfer_validation.rs index 5271c57..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()); } 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(); From 338d03838b5bb9017905731ab09eeb836d56ba16 Mon Sep 17 00:00:00 2001 From: Jo D Date: Tue, 28 Apr 2026 11:11:26 -0400 Subject: [PATCH 4/9] fix(audit): bind subscribe to live plan terms (MULT-6) Extend SubscribeData with expected_mint/amount/period_hours/created_at. Program rejects with PlanTermsMismatch if the live plan disagrees with what the subscriber signed. Stale-signed subscribe transactions can no longer enroll into a recreated plan with different terms. SDK and webapp callers fetch plan data and pass the snapshot. --- apps/web/src/instructions/Subscribe.tsx | 12 +++ clients/typescript/src/client.ts | 32 +++++++- .../src/instructions/subscription.ts | 27 ++++++- programs/subscriptions/idl/subscriptions.json | 34 ++++++++ .../src/instructions/subscribe.rs | 80 +++++++++++++++++++ programs/subscriptions/src/tests/utils.rs | 12 +++ webapp/src/components/plan/plan-card.tsx | 3 + .../src/hooks/use-subscriptions-mutations.ts | 9 +++ 8 files changed, 203 insertions(+), 6 deletions(-) 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/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/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/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/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/hooks/use-subscriptions-mutations.ts b/webapp/src/hooks/use-subscriptions-mutations.ts index 5f52801..0d25dfd 100644 --- a/webapp/src/hooks/use-subscriptions-mutations.ts +++ b/webapp/src/hooks/use-subscriptions-mutations.ts @@ -400,10 +400,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 +419,9 @@ export function useSubscriptionsMutations() { merchant: address(merchant), planId, tokenMint: address(tokenMint), + expectedAmount, + expectedPeriodHours, + expectedCreatedAt, programAddress: progId, }); From b38fc5f7523a18b01968dcfae7a634e666eb96c8 Mon Sep 17 00:00:00 2001 From: Jo D Date: Tue, 28 Apr 2026 11:24:13 -0400 Subject: [PATCH 5/9] fix(audit): skip zero-padded slots in destination/puller checks (MULT-8) Plan::check_destination and Plan::can_pull now filter out zero-padded slots before membership tests. A plan with fewer than four configured destinations no longer authorizes a zero-owned receiver, and a plan with fewer than four pullers no longer authorizes a zero-pubkey caller. --- programs/subscriptions/src/state/plan.rs | 73 +++++++++++++++++++++++- 1 file changed, 70 insertions(+), 3 deletions(-) diff --git a/programs/subscriptions/src/state/plan.rs b/programs/subscriptions/src/state/plan.rs index bf80550..65ca2e0 100644 --- a/programs/subscriptions/src/state/plan.rs +++ b/programs/subscriptions/src/state/plan.rs @@ -77,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()) @@ -87,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()); + } +} From 90514ef6ad693a0af286f6b4b75fe3c2dbcb9a7c Mon Sep 17 00:00:00 2001 From: Jo D Date: Tue, 28 Apr 2026 11:58:12 -0400 Subject: [PATCH 6/9] fix(audit): thread sponsor receiver through webapp revoke/close (MULT-10) Webapp exit flows now pass the on-chain payer as receiver when it differs from the connected signer, so sponsor-funded delegations and SubscriptionAuthority accounts can actually be closed. Also migrates revokeSubscription and cancelAndRevokeSubscription from buildRevokeDelegation to buildRevokeSubscription with planPda + receiver, fixing subscription revoke for both sponsor and non-sponsor cases. --- clients/typescript/test/setup.ts | 20 ++++-- .../delegation/active-delegations.tsx | 3 +- .../subscription/my-subscriptions-panel.tsx | 3 + .../use-subscription-authority-status.ts | 1 + .../src/hooks/use-subscriptions-mutations.ts | 71 ++++++++++++++++--- 5 files changed, 85 insertions(+), 13 deletions(-) diff --git a/clients/typescript/test/setup.ts b/clients/typescript/test/setup.ts index b150d6b..f9b4b5f 100644 --- a/clients/typescript/test/setup.ts +++ b/clients/typescript/test/setup.ts @@ -216,10 +216,22 @@ export class IntegrationTest { } async getValidatorTime(): Promise { - 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); + // Surfpool can return a stale genesis-era blockTime briefly after startup, + // which makes computed expiry/start timestamps look "in the past" to the + // program. Poll until blockTime is within sight of wall clock. + const MIN_REASONABLE_TS = 1_700_000_000n; + for (let attempt = 0; attempt < 20; attempt++) { + const slot = await this.rpc.getSlot().send(); + const blockTime = await this.rpc.getBlockTime(slot).send(); + if (blockTime != null) { + const ts = BigInt(blockTime); + if (ts >= MIN_REASONABLE_TS) return ts; + } + await new Promise((r) => setTimeout(r, 200)); + } + throw new Error( + 'getValidatorTime: blockTime never reached a reasonable epoch', + ); } async minPlanEndTs(periodHours: bigint): Promise { diff --git a/webapp/src/components/delegation/active-delegations.tsx b/webapp/src/components/delegation/active-delegations.tsx index 8be1883..2896767 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,7 +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), + delegations: staleDelegations.map((d) => ({ address: d.address, payer: d.data.header.payer })), tokenMint, }) onInitSuccess?.() 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 0d25dfd..0ce5898 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, }); @@ -466,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, }); @@ -491,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), @@ -505,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, }); @@ -694,22 +727,44 @@ export function useSubscriptionsMutations() { }); const revokeMultipleDelegations = useMutation({ - mutationFn: async ({ delegationAccounts, tokenMint }: { delegationAccounts: string[]; tokenMint: string }) => { + mutationFn: async ({ + delegations, + tokenMint, + authorityPayer, + }: { + delegations: Array<{ address: string; payer: string }>; + tokenMint: string; + authorityPayer?: 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]; }); + let storedAuthorityPayer = authorityPayer; + if (!storedAuthorityPayer) { + const rpc = createSolanaRpc(rpcUrl); + const [pda] = await getSubscriptionAuthorityPDA(signer.address, address(tokenMint), progId); + const maybe = await fetchMaybeSubscriptionAuthority(rpc, pda); + if (maybe.exists) storedAuthorityPayer = maybe.data.payer; + } + const closeReceiver = storedAuthorityPayer && storedAuthorityPayer !== signer.address + ? address(storedAuthorityPayer) + : undefined; + const { instructions: closeIxs } = await buildCloseSubscriptionAuthority({ user: signer, tokenMint: address(tokenMint), + receiver: closeReceiver, programAddress: progId, }); @@ -721,7 +776,7 @@ export function useSubscriptionsMutations() { signatures.push(await signAndSend(batch, signer)); } - return { signatures, revoked: delegationAccounts.length }; + return { signatures, revoked: delegations.length }; }, onSuccess: (res) => { toast.onSuccess(res.signatures[0]); From bfb3899ca947c1b64c631ffd184b5454a68c75a6 Mon Sep 17 00:00:00 2001 From: Jo D Date: Tue, 28 Apr 2026 12:02:59 -0400 Subject: [PATCH 7/9] fix(audit): drop SubscriptionAuthority close from stale-revoke batch (MULT-9) Stale-delegation cleanup no longer appends a close on the current SubscriptionAuthority. Revoking stale delegations is now scoped to the supplied delegation accounts; the SA stays open and current grants remain valid. --- clients/typescript/test/setup.ts | 14 +++++------ .../delegation/active-delegations.tsx | 1 - .../src/hooks/use-subscriptions-mutations.ts | 25 +------------------ 3 files changed, 7 insertions(+), 33 deletions(-) diff --git a/clients/typescript/test/setup.ts b/clients/typescript/test/setup.ts index f9b4b5f..df830aa 100644 --- a/clients/typescript/test/setup.ts +++ b/clients/typescript/test/setup.ts @@ -216,22 +216,20 @@ export class IntegrationTest { } async getValidatorTime(): Promise { - // Surfpool can return a stale genesis-era blockTime briefly after startup, - // which makes computed expiry/start timestamps look "in the past" to the - // program. Poll until blockTime is within sight of wall clock. - const MIN_REASONABLE_TS = 1_700_000_000n; + // Surfpool occasionally returns a stale genesis-era blockTime briefly + // after startup; poll until blockTime is at or past wall time so + // computed expiry/start timestamps don't end up "in the past". + const wall = BigInt(Math.floor(Date.now() / 1000)); for (let attempt = 0; attempt < 20; attempt++) { const slot = await this.rpc.getSlot().send(); const blockTime = await this.rpc.getBlockTime(slot).send(); if (blockTime != null) { const ts = BigInt(blockTime); - if (ts >= MIN_REASONABLE_TS) return ts; + if (ts >= wall) return ts; } await new Promise((r) => setTimeout(r, 200)); } - throw new Error( - 'getValidatorTime: blockTime never reached a reasonable epoch', - ); + throw new Error('getValidatorTime: blockTime never caught up to wall time'); } async minPlanEndTs(periodHours: bigint): Promise { diff --git a/webapp/src/components/delegation/active-delegations.tsx b/webapp/src/components/delegation/active-delegations.tsx index 2896767..5090c2b 100644 --- a/webapp/src/components/delegation/active-delegations.tsx +++ b/webapp/src/components/delegation/active-delegations.tsx @@ -666,7 +666,6 @@ export function ActiveDelegations({ tokenMint, isApproved, subscriptionAuthority if (staleDelegations.length === 0) return await revokeMultipleDelegations.mutateAsync({ delegations: staleDelegations.map((d) => ({ address: d.address, payer: d.data.header.payer })), - tokenMint, }) onInitSuccess?.() } diff --git a/webapp/src/hooks/use-subscriptions-mutations.ts b/webapp/src/hooks/use-subscriptions-mutations.ts index 0ce5898..c6e729d 100644 --- a/webapp/src/hooks/use-subscriptions-mutations.ts +++ b/webapp/src/hooks/use-subscriptions-mutations.ts @@ -729,12 +729,8 @@ export function useSubscriptionsMutations() { const revokeMultipleDelegations = useMutation({ mutationFn: async ({ delegations, - tokenMint, - authorityPayer, }: { delegations: Array<{ address: string; payer: string }>; - tokenMint: string; - authorityPayer?: string; }) => { if (!signer) throw new Error("Wallet not connected"); if (!progId) throw new Error("Program address not configured"); @@ -750,26 +746,7 @@ export function useSubscriptionsMutations() { return instructions[0]; }); - let storedAuthorityPayer = authorityPayer; - if (!storedAuthorityPayer) { - const rpc = createSolanaRpc(rpcUrl); - const [pda] = await getSubscriptionAuthorityPDA(signer.address, address(tokenMint), progId); - const maybe = await fetchMaybeSubscriptionAuthority(rpc, pda); - if (maybe.exists) storedAuthorityPayer = maybe.data.payer; - } - const closeReceiver = storedAuthorityPayer && storedAuthorityPayer !== signer.address - ? address(storedAuthorityPayer) - : undefined; - - const { instructions: closeIxs } = await buildCloseSubscriptionAuthority({ - user: signer, - tokenMint: address(tokenMint), - receiver: closeReceiver, - programAddress: progId, - }); - - const allIxs = [...revokeIxs, ...closeIxs]; - const batches = packInstructionBatches(allIxs, signer); + const batches = packInstructionBatches(revokeIxs, signer); const signatures: string[] = []; for (const batch of batches) { From 8c5deb1d88771bf62965caa907abfd8f86360d5a Mon Sep 17 00:00:00 2001 From: Jo D Date: Tue, 28 Apr 2026 14:21:08 -0400 Subject: [PATCH 8/9] test: getValidatorTime falls back to wall time when blockTime stale --- clients/typescript/test/setup.ts | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/clients/typescript/test/setup.ts b/clients/typescript/test/setup.ts index df830aa..5d72892 100644 --- a/clients/typescript/test/setup.ts +++ b/clients/typescript/test/setup.ts @@ -216,20 +216,18 @@ export class IntegrationTest { } async getValidatorTime(): Promise { - // Surfpool occasionally returns a stale genesis-era blockTime briefly - // after startup; poll until blockTime is at or past wall time so - // computed expiry/start timestamps don't end up "in the past". + // Surfpool's transaction-mode validator can return a stale or null + // blockTime until the first tx is processed. Use blockTime when sane, + // otherwise fall back to wall time. Never time-travels so explicit + // timeTravel calls in tests are not disturbed. + const slot = await this.rpc.getSlot().send(); + const blockTime = await this.rpc.getBlockTime(slot).send(); const wall = BigInt(Math.floor(Date.now() / 1000)); - for (let attempt = 0; attempt < 20; attempt++) { - const slot = await this.rpc.getSlot().send(); - const blockTime = await this.rpc.getBlockTime(slot).send(); - if (blockTime != null) { - const ts = BigInt(blockTime); - if (ts >= wall) return ts; - } - await new Promise((r) => setTimeout(r, 200)); + if (blockTime != null) { + const ts = BigInt(blockTime); + if (ts + 60n >= wall) return ts; } - throw new Error('getValidatorTime: blockTime never caught up to wall time'); + return wall; } async minPlanEndTs(periodHours: bigint): Promise { From fa2a3da4f53233c71e4bf559258f804a40c1b419 Mon Sep 17 00:00:00 2001 From: Jo D Date: Tue, 28 Apr 2026 15:04:50 -0400 Subject: [PATCH 9/9] test: read validator clock sysvar in integration tests --- clients/typescript/test/setup.ts | 59 +++++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 17 deletions(-) diff --git a/clients/typescript/test/setup.ts b/clients/typescript/test/setup.ts index 5d72892..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,9 @@ export class IntegrationTest { } async getValidatorTime(): Promise { - // Surfpool's transaction-mode validator can return a stale or null - // blockTime until the first tx is processed. Use blockTime when sane, - // otherwise fall back to wall time. Never time-travels so explicit - // timeTravel calls in tests are not disturbed. + 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(); const wall = BigInt(Math.floor(Date.now() / 1000)); @@ -237,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; @@ -314,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. *