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
80 changes: 80 additions & 0 deletions programs/subscriptions/src/instructions/subscribe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ use pinocchio::{
AccountView, ProgramResult,
};

use pinocchio::Address;

use crate::{
event_engine::{self, EventSerialize},
events::SubscriptionCreatedEvent,
Expand All @@ -32,6 +34,13 @@ pub struct SubscribeData {
pub plan_id: u64,
/// The plan PDA's bump seed (avoids an on-chain `find_program_address` call).
pub plan_bump: u8,
/// Plan terms the subscriber consented to. The program rejects if the live
/// plan disagrees, preventing a stale signed subscribe from binding the
/// subscriber to terms different from what was displayed at signing time.
pub expected_mint: Address,
pub expected_amount: u64,
pub expected_period_hours: u64,
pub expected_created_at: i64,
}

impl SubscribeData {
Expand Down Expand Up @@ -87,6 +96,18 @@ pub fn process(accounts: &[AccountView], data: &SubscribeData) -> ProgramResult
return Err(SubscriptionsError::PlanExpired.into());
}

// Bind subscriber consent to the live plan terms.
let live_amount = plan.data.terms.amount;
let live_period_hours = plan.data.terms.period_hours;
let live_created_at = plan.data.terms.created_at;
if plan.data.mint != data.expected_mint
|| live_amount != data.expected_amount
|| live_period_hours != data.expected_period_hours
|| live_created_at != data.expected_created_at
{
return Err(SubscriptionsError::PlanTermsMismatch.into());
}

plan_mint = plan.data.mint;
plan_terms = plan.data.terms;
}
Expand Down Expand Up @@ -525,4 +546,63 @@ mod tests {
.execute();
res.assert_err(SubscriptionsError::AlreadySubscribed);
}

#[test]
fn subscribe_rejects_stale_expected_terms() {
use crate::tests::{
constants::{PROGRAM_ID, SYSTEM_PROGRAM_ID},
pda::get_subscription_authority_pda,
utils::build_and_send_transaction,
};
use crate::{event_engine::event_authority_pda, instructions::subscribe};
use solana_instruction::{AccountMeta, Instruction};

let end_ts = current_ts() + days(30) as i64;
let (mut litesvm, alice, merchant, mint, plan_pda, plan_bump) = setup_plan(1, end_ts);

// Snapshot live terms, then submit subscribe with a stale `expected_amount`.
let plan_account = litesvm.get_account(&plan_pda).unwrap();
let plan = crate::state::Plan::load(&plan_account.data).unwrap();
let live_amount = plan.data.terms.amount;
let stale_amount = live_amount.wrapping_add(1);
let live_period_hours = plan.data.terms.period_hours;
let live_created_at = plan.data.terms.created_at;
let live_mint = plan.data.mint;

let (subscription_authority_pda, _) =
get_subscription_authority_pda(&alice.pubkey(), &mint);
let (subscription_pda, _) = get_subscription_pda(&plan_pda, &alice.pubkey());
let event_authority = Pubkey::new_from_array(event_authority_pda::ID.to_bytes());

let accounts = vec![
AccountMeta::new(alice.pubkey(), true),
AccountMeta::new_readonly(merchant.pubkey(), false),
AccountMeta::new_readonly(plan_pda, false),
AccountMeta::new(subscription_pda, false),
AccountMeta::new_readonly(subscription_authority_pda, false),
AccountMeta::new_readonly(SYSTEM_PROGRAM_ID, false),
AccountMeta::new_readonly(event_authority, false),
AccountMeta::new_readonly(PROGRAM_ID, false),
];

let data = [
vec![*subscribe::DISCRIMINATOR],
1u64.to_le_bytes().to_vec(),
vec![plan_bump],
live_mint.as_ref().to_vec(),
stale_amount.to_le_bytes().to_vec(),
live_period_hours.to_le_bytes().to_vec(),
live_created_at.to_le_bytes().to_vec(),
]
.concat();

let ix = Instruction {
program_id: PROGRAM_ID,
accounts,
data,
};

let res = build_and_send_transaction(&mut litesvm, &[&alice], &alice.pubkey(), &ix);
res.assert_err(SubscriptionsError::PlanTermsMismatch);
}
}
Loading
Loading