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/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()); + } +} 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/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..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, }); @@ -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,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]);