From b916404a09c4732222740d9e3ac6abc807d3b2ae Mon Sep 17 00:00:00 2001 From: Jo D Date: Tue, 28 Apr 2026 11:24:13 -0400 Subject: [PATCH 1/5] 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 73cce374e5fe6a4f3101ebbd4f38ac9f879ab86f Mon Sep 17 00:00:00 2001 From: Jo D Date: Tue, 28 Apr 2026 11:58:12 -0400 Subject: [PATCH 2/5] 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 e57bac386ee83ba6aecd93d5924cc80e7f21c2ff Mon Sep 17 00:00:00 2001 From: Jo D Date: Tue, 28 Apr 2026 12:02:59 -0400 Subject: [PATCH 3/5] 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 5c18bd387992e47c553dfb3f2b6483d2fb0b7742 Mon Sep 17 00:00:00 2001 From: Jo D Date: Tue, 28 Apr 2026 14:21:08 -0400 Subject: [PATCH 4/5] 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 fabf32bf6a4e8e6b15118c1e18de7c2baf78a38d Mon Sep 17 00:00:00 2001 From: Jo D Date: Tue, 28 Apr 2026 15:04:50 -0400 Subject: [PATCH 5/5] 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. *