Skip to content
Merged
12 changes: 12 additions & 0 deletions apps/web/src/instructions/Subscribe.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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(),
});

Expand All @@ -46,6 +52,12 @@ export function Subscribe() {
<FormField label="Token Mint" value={tokenMint} onChange={setTokenMint}
autoFillValue={defaultMint} onAutoFill={setTokenMint}
placeholder="Mint address" required />
<FormField label="Expected Amount" value={expectedAmount} onChange={setExpectedAmount} type="number"
hint="Live plan terms.amount; binds subscriber consent" required />
<FormField label="Expected Period Hours" value={expectedPeriodHours} onChange={setExpectedPeriodHours} type="number"
hint="Live plan terms.periodHours" required />
<FormField label="Expected Created At" value={expectedCreatedAt} onChange={setExpectedCreatedAt} type="number"
hint="Live plan terms.createdAt (unix ts)" required />
<SendButton sending={sending} />
<TxResultDisplay signature={signature} error={error} />
</form>
Expand Down
32 changes: 29 additions & 3 deletions clients/typescript/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -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<TransactionResult & { subscriptionPda: Address }> {
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,
Expand Down
27 changes: 24 additions & 3 deletions clients/typescript/src/instructions/subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -61,7 +75,14 @@ export async function buildSubscribe(params: {
planPda,
subscriptionPda,
subscriptionAuthorityPda,
subscribeData: { planId, planBump },
subscribeData: {
planId,
planBump,
expectedMint: tokenMint,
expectedAmount,
expectedPeriodHours,
expectedCreatedAt,
},
},
config,
);
Expand Down
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
34 changes: 34 additions & 0 deletions programs/subscriptions/idl/subscriptions.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 {
Expand Down Expand Up @@ -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());
}

Expand All @@ -71,10 +78,13 @@ pub fn validate_recurring_transfer(
let increment = periods_passed
.checked_mul(period_length)
.ok_or(SubscriptionsError::ArithmeticOverflow)?;
*current_period_start_ts = current_period_start_ts
let candidate_start = current_period_start_ts
.checked_add(increment)
.ok_or(SubscriptionsError::ArithmeticOverflow)?;
*amount_pulled_in_period = 0;
if expiry_ts == 0 || candidate_start < expiry_ts {
*current_period_start_ts = candidate_start;
*amount_pulled_in_period = 0;
}
}

let available = amount_per_period
Expand Down
53 changes: 49 additions & 4 deletions programs/subscriptions/src/instructions/revoke_delegation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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());
}
}
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading