Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 48 additions & 15 deletions clients/typescript/test/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createSolanaClient>;
export type SmartWalletName = 'swig' | 'squads';
Expand Down Expand Up @@ -147,6 +150,7 @@ export class IntegrationTest {
*/
static async create(): Promise<IntegrationTest> {
await isSurfnetRunning(); // Just verify surfpool is running

const solanaClient = createSolanaClient({ urlOrMoniker: 'localnet' });
const client = new SubscriptionsClient(solanaClient);

Expand Down Expand Up @@ -216,10 +220,17 @@ export class IntegrationTest {
}

async getValidatorTime(): Promise<bigint> {
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<bigint> {
Expand All @@ -229,19 +240,7 @@ export class IntegrationTest {
}

async timeTravel(targetTimestampSec: number): Promise<void> {
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;
Expand Down Expand Up @@ -306,6 +305,40 @@ export class IntegrationTest {
// Private Helper Functions
// ============================================================================

async function setSurfpoolClock(targetTimestampSec: number): Promise<void> {
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<bigint | null> {
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.
*
Expand Down
73 changes: 70 additions & 3 deletions programs/subscriptions/src/state/plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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());
}
}
4 changes: 2 additions & 2 deletions webapp/src/components/delegation/active-delegations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ function RevokeDelegationButton({ delegation }: RevokeDelegationButtonProps) {
try {
await revokeDelegation.mutateAsync({
delegationAccount: delegation.address,
payer: delegation.data.header.payer,
})
setOpen(false)
} catch {
Expand Down Expand Up @@ -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?.()
}
Expand Down
3 changes: 3 additions & 0 deletions webapp/src/components/subscription/my-subscriptions-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
>
Expand Down Expand Up @@ -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}
>
Expand Down
1 change: 1 addition & 0 deletions webapp/src/hooks/use-subscription-authority-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useProgramAddress } from '@/hooks/use-token-config'
export interface SubscriptionAuthorityData {
owner: string
tokenMint: string
payer: string
bump: number
initId: bigint
}
Expand Down
Loading
Loading