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
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
3 changes: 3 additions & 0 deletions programs/subscriptions/src/instructions/create_plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<PlanData>();
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
Loading
Loading