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
119 changes: 96 additions & 23 deletions clients/typescript/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ export type CreateFixedDelegationInput = WithProgramAddress & {
amount: bigint | number;
delegatee: Address;
delegator: TransactionSigner;
expectedSubscriptionAuthorityInitId?: bigint | number;
expiryTs: bigint | number;
nonce: bigint | number;
payer?: TransactionSigner;
Expand All @@ -157,6 +158,7 @@ export type CreateRecurringDelegationInput = WithProgramAddress & {
amountPerPeriod: bigint | number;
delegatee: Address;
delegator: TransactionSigner;
expectedSubscriptionAuthorityInitId?: bigint | number;
expiryTs: bigint | number;
nonce: bigint | number;
payer?: TransactionSigner;
Expand Down Expand Up @@ -236,6 +238,7 @@ export type SubscribeInput = WithProgramAddress & {
expectedAmount?: bigint | number;
expectedCreatedAt?: bigint | number;
expectedPeriodHours?: bigint | number;
expectedSubscriptionAuthorityInitId?: bigint | number;
merchant: Address;
payer?: TransactionSigner;
planId: bigint | number;
Expand Down Expand Up @@ -297,6 +300,11 @@ export async function getCreateFixedDelegationOverlayInstructionAsync(
input: CreateFixedDelegationInput,
): Promise<Instruction> {
assertPositive(input.amount, 'amount');
if (input.expectedSubscriptionAuthorityInitId === undefined) {
throw new Error(
'getCreateFixedDelegationOverlayInstructionAsync requires expectedSubscriptionAuthorityInitId. Use the plugin client `subscriptions.instructions.createFixedDelegation(...)` to auto-fetch from the live authority.',
);
}
const [subscriptionAuthority] = await findSubscriptionAuthorityPda(
{ tokenMint: input.tokenMint, user: input.delegator.address },
pdaConfig(input.programAddress),
Expand All @@ -318,6 +326,7 @@ export async function getCreateFixedDelegationOverlayInstructionAsync(
delegator: input.delegator,
fixedDelegation: {
amount: input.amount,
expectedSubscriptionAuthorityInitId: input.expectedSubscriptionAuthorityInitId,
expiryTs: input.expiryTs,
nonce: input.nonce,
},
Expand All @@ -334,6 +343,11 @@ export async function getCreateRecurringDelegationOverlayInstructionAsync(
): Promise<Instruction> {
assertPositive(input.amountPerPeriod, 'amountPerPeriod');
assertPositive(input.periodLengthS, 'periodLengthS');
if (input.expectedSubscriptionAuthorityInitId === undefined) {
throw new Error(
'getCreateRecurringDelegationOverlayInstructionAsync requires expectedSubscriptionAuthorityInitId. Use the plugin client `subscriptions.instructions.createRecurringDelegation(...)` to auto-fetch from the live authority.',
);
}
const [subscriptionAuthority] = await findSubscriptionAuthorityPda(
{ tokenMint: input.tokenMint, user: input.delegator.address },
pdaConfig(input.programAddress),
Expand All @@ -355,6 +369,7 @@ export async function getCreateRecurringDelegationOverlayInstructionAsync(
delegator: input.delegator,
recurringDelegation: {
amountPerPeriod: input.amountPerPeriod,
expectedSubscriptionAuthorityInitId: input.expectedSubscriptionAuthorityInitId,
expiryTs: input.expiryTs,
nonce: input.nonce,
periodLengthS: input.periodLengthS,
Expand Down Expand Up @@ -533,10 +548,11 @@ export async function getSubscribeOverlayInstructionAsync(input: SubscribeInput)
if (
input.expectedAmount === undefined ||
input.expectedPeriodHours === undefined ||
input.expectedCreatedAt === undefined
input.expectedCreatedAt === undefined ||
input.expectedSubscriptionAuthorityInitId === undefined
) {
throw new Error(
'getSubscribeOverlayInstructionAsync requires expectedAmount, expectedPeriodHours, and expectedCreatedAt. Use the plugin client `subscriptions.instructions.subscribe(...)` to auto-fetch from the live plan.',
'getSubscribeOverlayInstructionAsync requires expectedAmount, expectedPeriodHours, expectedCreatedAt, and expectedSubscriptionAuthorityInitId. Use the plugin client `subscriptions.instructions.subscribe(...)` to auto-fetch from the live plan and authority.',
);
}
const [planPda, planBump] = await findPlanPda(
Expand All @@ -557,6 +573,7 @@ export async function getSubscribeOverlayInstructionAsync(input: SubscribeInput)
expectedCreatedAt: input.expectedCreatedAt,
expectedMint: input.tokenMint,
expectedPeriodHours: input.expectedPeriodHours,
expectedSubscriptionAuthorityInitId: input.expectedSubscriptionAuthorityInitId,
planBump,
planId: input.planId,
},
Expand Down Expand Up @@ -681,6 +698,26 @@ export function subscriptionsProgram() {
plansForOwner: owner => fetchPlansForOwner(c.rpc, owner),
};

const resolveExpectedSubscriptionAuthorityInitId = async (
tokenMint: Address,
user: Address,
programAddress: Address | undefined,
expectedSubscriptionAuthorityInitId: bigint | number | undefined,
) => {
if (expectedSubscriptionAuthorityInitId !== undefined) {
return expectedSubscriptionAuthorityInitId;
}
const [subscriptionAuthorityPda] = await findSubscriptionAuthorityPda(
{ tokenMint, user },
pdaConfig(programAddress),
);
const subscriptionAuthority = await fetchMaybeSubscriptionAuthority(c.rpc, subscriptionAuthorityPda);
if (!subscriptionAuthority.exists) {
throw new Error('SubscriptionAuthority is not initialized for this delegator and token mint.');
}
return subscriptionAuthority.data.initId;
};

const instructions: SubscriptionsPluginInstructions = {
cancelSubscription: input =>
addSelfPlanAndSendFunctions(
Expand All @@ -701,11 +738,22 @@ export function subscriptionsProgram() {
createFixedDelegation: input =>
addSelfPlanAndSendFunctions(
client,
getCreateFixedDelegationOverlayInstructionAsync({
...input,
delegator: input.delegator ?? client.identity,
payer: input.payer ?? (client.payer === client.identity ? undefined : client.payer),
}),
(async () => {
const delegator = input.delegator ?? client.identity;
const expectedSubscriptionAuthorityInitId =
await resolveExpectedSubscriptionAuthorityInitId(
input.tokenMint,
delegator.address,
input.programAddress,
input.expectedSubscriptionAuthorityInitId,
);
return await getCreateFixedDelegationOverlayInstructionAsync({
...input,
delegator,
expectedSubscriptionAuthorityInitId,
payer: input.payer ?? (client.payer === client.identity ? undefined : client.payer),
});
})(),
),
createPlan: input =>
addSelfPlanAndSendFunctions(
Expand All @@ -718,11 +766,22 @@ export function subscriptionsProgram() {
createRecurringDelegation: input =>
addSelfPlanAndSendFunctions(
client,
getCreateRecurringDelegationOverlayInstructionAsync({
...input,
delegator: input.delegator ?? client.identity,
payer: input.payer ?? (client.payer === client.identity ? undefined : client.payer),
}),
(async () => {
const delegator = input.delegator ?? client.identity;
const expectedSubscriptionAuthorityInitId =
await resolveExpectedSubscriptionAuthorityInitId(
input.tokenMint,
delegator.address,
input.programAddress,
input.expectedSubscriptionAuthorityInitId,
);
return await getCreateRecurringDelegationOverlayInstructionAsync({
...input,
delegator,
expectedSubscriptionAuthorityInitId,
payer: input.payer ?? (client.payer === client.identity ? undefined : client.payer),
});
})(),
),
deletePlan: input =>
addSelfPlanAndSendFunctions(
Expand Down Expand Up @@ -769,13 +828,18 @@ export function subscriptionsProgram() {
addSelfPlanAndSendFunctions(
client,
(async () => {
let { expectedAmount, expectedPeriodHours, expectedCreatedAt } = input;
const subscriber = input.subscriber ?? client.identity;
let {
expectedAmount,
expectedCreatedAt,
expectedPeriodHours,
expectedSubscriptionAuthorityInitId,
} = input;
if (
expectedAmount === undefined ||
expectedPeriodHours === undefined ||
expectedCreatedAt === undefined
) {
const subscriber = input.subscriber ?? client.identity;
const [planPda] = await findPlanPda(
{ owner: input.merchant, planId: input.planId },
pdaConfig(input.programAddress),
Expand All @@ -784,22 +848,31 @@ export function subscriptionsProgram() {
expectedAmount = expectedAmount ?? plan.data.data.terms.amount;
expectedPeriodHours = expectedPeriodHours ?? plan.data.data.terms.periodHours;
expectedCreatedAt = expectedCreatedAt ?? plan.data.data.terms.createdAt;
return await getSubscribeOverlayInstructionAsync({
...input,
expectedAmount,
expectedCreatedAt,
expectedPeriodHours,
payer: input.payer ?? (client.payer === client.identity ? undefined : client.payer),
subscriber,
});
}
if (expectedSubscriptionAuthorityInitId === undefined) {
const [subscriptionAuthorityPda] = await findSubscriptionAuthorityPda(
{ tokenMint: input.tokenMint, user: subscriber.address },
pdaConfig(input.programAddress),
);
const subscriptionAuthority = await fetchMaybeSubscriptionAuthority(
c.rpc,
subscriptionAuthorityPda,
);
if (!subscriptionAuthority.exists) {
throw new Error(
'SubscriptionAuthority is not initialized for this subscriber and token mint.',
);
}
expectedSubscriptionAuthorityInitId = subscriptionAuthority.data.initId;
}
return await getSubscribeOverlayInstructionAsync({
...input,
expectedAmount,
expectedCreatedAt,
expectedPeriodHours,
expectedSubscriptionAuthorityInitId,
payer: input.payer ?? (client.payer === client.identity ? undefined : client.payer),
subscriber: input.subscriber ?? client.identity,
subscriber,
});
})(),
),
Expand Down
108 changes: 108 additions & 0 deletions clients/typescript/test/subscription-security.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
import {
appendTransactionMessageInstructions,
createTransactionMessage,
getBase64EncodedWireTransaction,
pipe,
setTransactionMessageFeePayerSigner,
setTransactionMessageLifetimeUsingBlockhash,
signTransactionMessageWithSigners,
} from '@solana/kit';
import { describe, expect, test } from 'vitest';
import {
SUBSCRIPTIONS_ERROR__ALREADY_SUBSCRIBED,
Expand All @@ -17,14 +26,113 @@ import {
import {
fetchMaybePlan,
fetchMaybeSubscriptionDelegation,
fetchPlan,
fetchSubscriptionAuthority,
fetchSubscriptionDelegation,
findPlanPda,
findSubscriptionAuthorityPda,
findSubscriptionDelegationPda,
PlanStatus,
} from '../src/generated/index.ts';
import { getSubscribeOverlayInstructionAsync } from '../src/index.ts';
import { DEFAULT_TEST_BALANCE, expectProgramError, initTestSuite } from './setup.ts';

describe('Subscription Security', () => {
test('subscribe rejects approval signed for a previous authority generation', async () => {
const t = await initTestSuite();

const [planPda] = await findPlanPda({
owner: t.payerKeypair.address,
planId: 1n,
});
await t.client.subscriptions.instructions
.createPlan({
owner: t.payerKeypair,
planId: 1n,
mint: t.tokenMint,
amount: 500_000n,
periodHours: 1n,
endTs: 0n,
destinations: [],
pullers: [],
metadataUri: 'https://example.com/plan.json',
})
.sendTransaction();

const subscriber = await t.createFundedKeypair();
const subscriberAta = await t.createAtaWithBalance(t.tokenMint, subscriber.address, DEFAULT_TEST_BALANCE);
await t.client.subscriptions.instructions
.initSubscriptionAuthority({
owner: subscriber,
tokenMint: t.tokenMint,
userAta: subscriberAta,
tokenProgram: t.tokenProgram,
})
.sendTransaction();

const [subscriptionAuthorityPda] = await findSubscriptionAuthorityPda({
tokenMint: t.tokenMint,
user: subscriber.address,
});
const authorityBeforeClose = await fetchSubscriptionAuthority(t.rpc, subscriptionAuthorityPda);
const plan = await fetchPlan(t.rpc, planPda);
const [subscriptionPda] = await findSubscriptionDelegationPda({
planPda,
subscriber: subscriber.address,
});
const subscribeInstruction = await getSubscribeOverlayInstructionAsync({
subscriber,
merchant: t.payerKeypair.address,
planId: 1n,
tokenMint: t.tokenMint,
expectedAmount: plan.data.data.terms.amount,
expectedCreatedAt: plan.data.data.terms.createdAt,
expectedPeriodHours: plan.data.data.terms.periodHours,
expectedSubscriptionAuthorityInitId: authorityBeforeClose.data.initId,
});
const { value: latestBlockhash } = await t.rpc.getLatestBlockhash().send();
const signedSubscribeTransaction = await signTransactionMessageWithSigners(
pipe(
createTransactionMessage({ version: 0 }),
tx => setTransactionMessageFeePayerSigner(subscriber, tx),
tx => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx),
tx => appendTransactionMessageInstructions([subscribeInstruction], tx),
),
);

await t.client.subscriptions.instructions
.closeSubscriptionAuthority({
user: subscriber,
tokenMint: t.tokenMint,
})
.sendTransaction();
await t.client.subscriptions.instructions
.initSubscriptionAuthority({
owner: subscriber,
tokenMint: t.tokenMint,
userAta: subscriberAta,
tokenProgram: t.tokenProgram,
})
.sendTransaction();

const authorityAfterReinit = await fetchSubscriptionAuthority(t.rpc, subscriptionAuthorityPda);
expect(authorityAfterReinit.data.initId).not.toBe(authorityBeforeClose.data.initId);

await expectProgramError(
t.rpc
.sendTransaction(getBase64EncodedWireTransaction(signedSubscribeTransaction), {
encoding: 'base64',
preflightCommitment: 'confirmed',
skipPreflight: false,
})
.send({ abortSignal: AbortSignal.timeout(30_000) }),
SUBSCRIPTIONS_ERROR__STALE_SUBSCRIPTION_AUTHORITY,
);

const maybeSubscription = await fetchMaybeSubscriptionDelegation(t.rpc, subscriptionPda);
expect(maybeSubscription.exists).toBe(false);
});

test('revoke blocked during grace period, allowed after expiry', async () => {
const t = await initTestSuite();

Expand Down
Loading
Loading