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
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,16 +190,16 @@ Both default to `http://localhost:8899`.

The `@solana/subscriptions` package in `clients/typescript` provides a high-level `SubscriptionsClient` class wrapping all program instructions:

| Method | Purpose |
| ----------------------------------------------------------- | -------------------------------------------------------------- |
| `initSubscriptionAuthority` / `closeSubscriptionAuthority` | Create or close the SA for a (user, mint) pair |
| `createFixedDelegation` / `transferFixed` | Create a fixed delegation and execute transfers against it |
| `createRecurringDelegation` / `transferRecurring` | Create a recurring delegation and execute transfers against it |
| `createPlan` / `updatePlan` / `deletePlan` | Manage merchant subscription plans |
| `subscribe` / `cancelSubscription` / `transferSubscription` | Subscribe to plans, cancel, and pull payments |
| `revokeDelegation` | Close any delegation PDA and return rent to the original payer |
| `getDelegationsForWallet` / `getPlansForOwner` | Query on-chain accounts |
| `isSubscriptionAuthorityInitialized` | Check if an SA exists for a wallet/mint pair |
| Method | Purpose |
| ---------------------------------------------------------------------------------- | -------------------------------------------------------------- |
| `initSubscriptionAuthority` / `closeSubscriptionAuthority` | Create or close the SA for a (user, mint) pair |
| `createFixedDelegation` / `transferFixed` | Create a fixed delegation and execute transfers against it |
| `createRecurringDelegation` / `transferRecurring` | Create a recurring delegation and execute transfers against it |
| `createPlan` / `updatePlan` / `deletePlan` | Manage merchant subscription plans |
| `subscribe` / `cancelSubscription` / `resumeSubscription` / `transferSubscription` | Subscribe to plans, cancel or resume, and pull payments |
| `revokeDelegation` | Close any delegation PDA and return rent to the original payer |
| `getDelegationsForWallet` / `getPlansForOwner` | Query on-chain accounts |
| `isSubscriptionAuthorityInitialized` | Check if an SA exists for a wallet/mint pair |

PDA derivation helpers are exported from `pdas.ts`: `getSubscriptionAuthorityPDA`, `getDelegationPDA`, `getPlanPDA`, `getSubscriptionPDA`, `getEventAuthorityPDA`.

Expand Down
1 change: 1 addition & 0 deletions clients/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ For custom wallet flows, use the exported `get*OverlayInstruction*` functions. T
| `deletePlan` / `getDeletePlanOverlayInstruction` | Delete an expired plan and reclaim rent |
| `subscribe` / `getSubscribeOverlayInstructionAsync` | Subscribe to a plan |
| `cancelSubscription` / `getCancelSubscriptionOverlayInstructionAsync` | Cancel a subscription (grace period until end of billing period) |
| `resumeSubscription` / `getResumeSubscriptionOverlayInstructionAsync` | Resume a cancelled subscription before revocation |

### Account Queries

Expand Down
2 changes: 2 additions & 0 deletions clients/typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export {
getCreateRecurringDelegationOverlayInstructionAsync,
getDeletePlanOverlayInstruction,
getInitSubscriptionAuthorityOverlayInstructionAsync,
getResumeSubscriptionOverlayInstructionAsync,
getRevokeDelegationOverlayInstruction,
getRevokeSubscriptionOverlayInstruction,
getSubscribeOverlayInstructionAsync,
Expand All @@ -31,6 +32,7 @@ export {
getTransferSubscriptionOverlayInstructionAsync,
getUpdatePlanOverlayInstruction,
type InitSubscriptionAuthorityInput,
type ResumeSubscriptionInput,
type RevokeDelegationInput,
type RevokeSubscriptionInput,
type SubscribeInput,
Expand Down
27 changes: 27 additions & 0 deletions clients/typescript/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import {
getCreateRecurringDelegationInstruction,
getDeletePlanInstruction,
getInitSubscriptionAuthorityInstructionAsync,
getResumeSubscriptionInstructionAsync,
getRevokeDelegationInstruction,
getSubscribeInstructionAsync,
getTransferFixedInstruction,
Expand Down Expand Up @@ -248,6 +249,12 @@ export type CancelSubscriptionInput = WithProgramAddress & {
subscriptionPda?: Address;
};

export type ResumeSubscriptionInput = WithProgramAddress & {
planPda: Address;
subscriber: TransactionSigner;
subscriptionPda?: Address;
};

// ============================================================================
// Overlay instruction builders (return Instruction / Promise<Instruction>)
// ============================================================================
Expand Down Expand Up @@ -573,6 +580,17 @@ export function getCancelSubscriptionOverlayInstructionAsync(input: CancelSubscr
);
}

export function getResumeSubscriptionOverlayInstructionAsync(input: ResumeSubscriptionInput): Promise<Instruction> {
return getResumeSubscriptionInstructionAsync(
{
planPda: input.planPda,
subscriber: input.subscriber,
subscriptionPda: input.subscriptionPda,
},
pdaConfig(input.programAddress),
);
}

// ============================================================================
// Plugin
// ============================================================================
Expand Down Expand Up @@ -600,6 +618,7 @@ export type SubscriptionsPluginInstructions = {
initSubscriptionAuthority: (
input: MakeOptional<InitSubscriptionAuthorityInput, 'owner' | 'payer'>,
) => Self<Promise<Instruction>>;
resumeSubscription: (input: MakeOptional<ResumeSubscriptionInput, 'subscriber'>) => Self<Promise<Instruction>>;
revokeDelegation: (input: MakeOptional<RevokeDelegationInput, 'authority'>) => Self<Instruction>;
revokeSubscription: (input: MakeOptional<RevokeSubscriptionInput, 'authority'>) => Self<Instruction>;
subscribe: (input: MakeOptional<SubscribeInput, 'payer' | 'subscriber'>) => Self<Promise<Instruction>>;
Expand Down Expand Up @@ -722,6 +741,14 @@ export function subscriptionsProgram() {
payer: input.payer ?? (client.payer === client.identity ? undefined : client.payer),
}),
),
resumeSubscription: input =>
addSelfPlanAndSendFunctions(
client,
getResumeSubscriptionOverlayInstructionAsync({
...input,
subscriber: input.subscriber ?? client.identity,
}),
),
revokeDelegation: input =>
addSelfPlanAndSendFunctions(
client,
Expand Down
164 changes: 163 additions & 1 deletion clients/typescript/test/subscription-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, expect, test } from 'vitest';
import { SUBSCRIPTIONS_ERROR__PLAN_TERMS_MISMATCH } from '../src/generated/errors/subscriptions.ts';
import {
SUBSCRIPTIONS_ERROR__PLAN_TERMS_MISMATCH,
SUBSCRIPTIONS_ERROR__SUBSCRIPTION_NOT_CANCELLED,
SUBSCRIPTIONS_ERROR__UNAUTHORIZED,
} from '../src/generated/errors/subscriptions.ts';
import {
fetchMaybePlan,
fetchMaybeSubscriptionDelegation,
Expand Down Expand Up @@ -350,4 +354,162 @@ describe('Subscription Lifecycle', () => {
const subAfterRevoke = await fetchMaybeSubscriptionDelegation(t.rpc, subscriptionPda);
expect(subAfterRevoke.exists).toBe(false);
});

test('resume: subscriber clears pending cancellation and pulls continue', async () => {
const t = await initTestSuite();
const periodHours = 1n;

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: 250_000n,
periodHours,
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 [subscriptionPda] = await findSubscriptionDelegationPda({ planPda, subscriber: subscriber.address });
await t.client.subscriptions.instructions
.subscribe({ subscriber, merchant: t.payerKeypair.address, planId: 1n, tokenMint: t.tokenMint })
.sendTransaction();

await t.client.subscriptions.instructions
.cancelSubscription({ subscriber, planPda, subscriptionPda })
.sendTransaction();

const subAfterCancel = (await fetchSubscriptionDelegation(t.rpc, subscriptionPda)).data;
expect(subAfterCancel.expiresAtTs).not.toBe(0n);
const periodStart = subAfterCancel.currentPeriodStartTs;
const amountPulled = subAfterCancel.amountPulledInPeriod;

await t.client.subscriptions.instructions
.resumeSubscription({ subscriber, planPda, subscriptionPda })
.sendTransaction();

const subAfterResume = (await fetchSubscriptionDelegation(t.rpc, subscriptionPda)).data;
expect(subAfterResume.expiresAtTs).toBe(0n);
// Resume must not reset period accounting, otherwise subscribers could
// dodge the per-period limit by cancelling and resuming after pulling.
expect(subAfterResume.currentPeriodStartTs).toBe(periodStart);
expect(subAfterResume.amountPulledInPeriod).toBe(amountPulled);

const merchantAta = await t.createAtaWithBalance(t.tokenMint, t.payerKeypair.address, 0n);
await t.client.subscriptions.instructions
.transferSubscription({
caller: t.payerKeypair,
delegator: subscriber.address,
tokenMint: t.tokenMint,
subscriptionPda,
planPda,
amount: 100_000n,
receiverAta: merchantAta,
tokenProgram: t.tokenProgram,
})
.sendTransaction();
});

test('resume: rejected when subscription is not cancelled', 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: 250_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 [subscriptionPda] = await findSubscriptionDelegationPda({ planPda, subscriber: subscriber.address });
await t.client.subscriptions.instructions
.subscribe({ subscriber, merchant: t.payerKeypair.address, planId: 1n, tokenMint: t.tokenMint })
.sendTransaction();

await expectProgramError(
t.client.subscriptions.instructions
.resumeSubscription({ subscriber, planPda, subscriptionPda })
.sendTransaction(),
SUBSCRIPTIONS_ERROR__SUBSCRIPTION_NOT_CANCELLED,
);
});

test('resume: rejected when caller is not the subscriber', 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: 250_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 [subscriptionPda] = await findSubscriptionDelegationPda({ planPda, subscriber: subscriber.address });
await t.client.subscriptions.instructions
.subscribe({ subscriber, merchant: t.payerKeypair.address, planId: 1n, tokenMint: t.tokenMint })
.sendTransaction();
await t.client.subscriptions.instructions
.cancelSubscription({ subscriber, planPda, subscriptionPda })
.sendTransaction();

const attacker = await t.createFundedKeypair();
await expectProgramError(
t.client.subscriptions.instructions
.resumeSubscription({ subscriber: attacker, planPda, subscriptionPda })
.sendTransaction(),
SUBSCRIPTIONS_ERROR__UNAUTHORIZED,
);
});
});
Loading
Loading