diff --git a/README.md b/README.md index fda4cef..7d7a331 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/clients/typescript/README.md b/clients/typescript/README.md index 0d5ba47..07e38ad 100644 --- a/clients/typescript/README.md +++ b/clients/typescript/README.md @@ -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 diff --git a/clients/typescript/src/index.ts b/clients/typescript/src/index.ts index 0e71a2a..adae297 100644 --- a/clients/typescript/src/index.ts +++ b/clients/typescript/src/index.ts @@ -23,6 +23,7 @@ export { getCreateRecurringDelegationOverlayInstructionAsync, getDeletePlanOverlayInstruction, getInitSubscriptionAuthorityOverlayInstructionAsync, + getResumeSubscriptionOverlayInstructionAsync, getRevokeDelegationOverlayInstruction, getRevokeSubscriptionOverlayInstruction, getSubscribeOverlayInstructionAsync, @@ -31,6 +32,7 @@ export { getTransferSubscriptionOverlayInstructionAsync, getUpdatePlanOverlayInstruction, type InitSubscriptionAuthorityInput, + type ResumeSubscriptionInput, type RevokeDelegationInput, type RevokeSubscriptionInput, type SubscribeInput, diff --git a/clients/typescript/src/plugin.ts b/clients/typescript/src/plugin.ts index c5cfe8b..e19bd5b 100644 --- a/clients/typescript/src/plugin.ts +++ b/clients/typescript/src/plugin.ts @@ -72,6 +72,7 @@ import { getCreateRecurringDelegationInstruction, getDeletePlanInstruction, getInitSubscriptionAuthorityInstructionAsync, + getResumeSubscriptionInstructionAsync, getRevokeDelegationInstruction, getSubscribeInstructionAsync, getTransferFixedInstruction, @@ -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) // ============================================================================ @@ -573,6 +580,17 @@ export function getCancelSubscriptionOverlayInstructionAsync(input: CancelSubscr ); } +export function getResumeSubscriptionOverlayInstructionAsync(input: ResumeSubscriptionInput): Promise { + return getResumeSubscriptionInstructionAsync( + { + planPda: input.planPda, + subscriber: input.subscriber, + subscriptionPda: input.subscriptionPda, + }, + pdaConfig(input.programAddress), + ); +} + // ============================================================================ // Plugin // ============================================================================ @@ -600,6 +618,7 @@ export type SubscriptionsPluginInstructions = { initSubscriptionAuthority: ( input: MakeOptional, ) => Self>; + resumeSubscription: (input: MakeOptional) => Self>; revokeDelegation: (input: MakeOptional) => Self; revokeSubscription: (input: MakeOptional) => Self; subscribe: (input: MakeOptional) => Self>; @@ -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, diff --git a/clients/typescript/test/subscription-lifecycle.test.ts b/clients/typescript/test/subscription-lifecycle.test.ts index f650afa..0dea16f 100644 --- a/clients/typescript/test/subscription-lifecycle.test.ts +++ b/clients/typescript/test/subscription-lifecycle.test.ts @@ -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, @@ -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, + ); + }); }); diff --git a/docs/002-subscriptions-architecture.md b/docs/002-subscriptions-architecture.md index 8b5337a..2824c18 100644 --- a/docs/002-subscriptions-architecture.md +++ b/docs/002-subscriptions-architecture.md @@ -135,16 +135,16 @@ ADR-001 provides the core delegation infrastructure. Plans add a subscription mo ### Comparison to Direct Delegation -| Aspect | ADR-001 Direct Delegation | ADR-002 Plan Subscriptions | -| ---------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **Creation** | Delegator initiates | Delegatee publishes, delegators subscribe | -| **Terms Storage** | Embedded per delegation | Single Plan for all | -| **Cost Structure** | Full cost per delegation | Plan cost (1x) + Delegation cost (Nx) | -| **Term Mutability** | Per delegation | Core billing terms (amount, period_hours, created_at) immutable and snapshotted per subscription; pullers, status, end_ts, metadata_uri mutable | -| **Discoverability** | Manual PDA sharing | Plans can be discovered/marketplace | -| **Use Cases** | P2P, custom, one-off | Subscription services, SaaS, platforms | -| **Pull Authorization** | Delegatee-only (`transfer_fixed`/`transfer_recurring`) | Owner + configurable pullers array (`transfer_subscription`) | -| **Cancellability** | Not implemented (add later) | Two-step: `cancel_subscription` (pre-computes expiration at end of current period) then `revoke_delegation` (close after expiration). Plan can sunset. | +| Aspect | ADR-001 Direct Delegation | ADR-002 Plan Subscriptions | +| ---------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Creation** | Delegator initiates | Delegatee publishes, delegators subscribe | +| **Terms Storage** | Embedded per delegation | Single Plan for all | +| **Cost Structure** | Full cost per delegation | Plan cost (1x) + Delegation cost (Nx) | +| **Term Mutability** | Per delegation | Core billing terms (amount, period_hours, created_at) immutable and snapshotted per subscription; pullers, status, end_ts, metadata_uri mutable | +| **Discoverability** | Manual PDA sharing | Plans can be discovered/marketplace | +| **Use Cases** | P2P, custom, one-off | Subscription services, SaaS, platforms | +| **Pull Authorization** | Delegatee-only (`transfer_fixed`/`transfer_recurring`) | Owner + configurable pullers array (`transfer_subscription`) | +| **Cancellability** | Not implemented (add later) | `cancel_subscription` pre-computes expiration at end of current period, `resume_subscription` clears a pending cancellation, and `revoke_delegation` closes after expiration. Plan can sunset. | ### Integration With ADR-001 @@ -199,6 +199,7 @@ When a subscriber subscribes, the plan's `PlanTerms` (amount, period_hours, crea | **NEW**: `SubscriptionDelegation` PDA | - | Tracks per-subscriber billing state | | **NEW**: `subscribe` instruction | - | Creates SubscriptionDelegation referencing a Plan | | **NEW**: `cancel_subscription` instruction | - | Sets expires_at_ts, grace period | +| **NEW**: `resume_subscription` instruction | - | Clears expires_at_ts to resume autopay | | **NEW**: `transfer_subscription` instruction | - | Pulls tokens using Plan terms + Delegation state | **Seeded Separation for Coexistence:** @@ -211,7 +212,7 @@ When a subscriber subscribes, the plan's `PlanTerms` (amount, period_hours, crea **Flows Remain Available:** - All ADR-001 instructions (`initialize_subscription_authority`, `create_fixed_delegation`, `create_recurring_delegation`) continue to work unchanged -- New ADR-002 instructions (`create_plan`, `update_plan`, `delete_plan`, `subscribe`, `cancel_subscription`, `transfer_subscription`) add subscription capability +- New ADR-002 instructions (`create_plan`, `update_plan`, `delete_plan`, `subscribe`, `cancel_subscription`, `resume_subscription`, `transfer_subscription`) add subscription capability - Direct delegations and subscriptions can be created and withdrawn independently --- @@ -277,7 +278,7 @@ Per-subscriber billing state linked to a Plan: **PDA seeds**: `["subscription", plan_pda, subscriber]` -**Cancellation semantics**: `expires_at_ts == 0` means the subscription is active. When the subscriber cancels, `expires_at_ts` is set to the end of the current billing period. Transfers are blocked after this timestamp, and the account can be closed via `revoke_delegation`. +**Cancellation semantics**: `expires_at_ts == 0` means the subscription is active. When the subscriber cancels, `expires_at_ts` is set to the end of the current billing period. The subscriber can call `resume_subscription` to clear the pending cancellation before the account is revoked. Transfers are blocked after `expires_at_ts`, and the account can be closed via `revoke_delegation`. --- @@ -486,11 +487,42 @@ After `expires_at_ts` passes, pulls are blocked. The subscriber can then call `r 3. If plan is closed (not program-owned): set `expires_at_ts = current_ts` 4. Emit `SubscriptionCancelled` event via self-CPI -**Two-step revocation flow:** +**Cancellation flow:** - `cancel_subscription` → pre-computes `expires_at_ts` (end of current period), allows pulls until then +- `resume_subscription` → clears `expires_at_ts` back to `0` before the cancellation period ends, provided the plan is not closed, expired, or recreated with different terms - `revoke_delegation` → closes account (requires `expires_at_ts != 0` and `expires_at_ts <= current_ts`) +### `resume_subscription` (Discriminator: 13) + +Subscriber resumes a cancelled subscription by clearing `expires_at_ts`. This does not change `current_period_start_ts` or `amount_pulled_in_period`, so the billing period and allowance accounting continue from the existing subscription state. + +| Account | Type | Description | +| ------- | -------- | -------------------------- | +| 0 | signer | Subscriber (delegator) | +| 1 | | Plan PDA | +| 2 | writable | SubscriptionDelegation PDA | +| 3 | | Event authority PDA | +| 4 | | Self program | + +**Parameters:** None (only discriminator byte) + +**Validation:** + +1. Verify caller is the subscription's delegator (else `Unauthorized`) +2. Verify subscription's delegatee matches the plan PDA (else `SubscriptionPlanMismatch`) +3. Verify `expires_at_ts != 0` (else `SubscriptionNotCancelled`) +4. Verify the plan account is still program-owned (else `PlanClosed`) +5. Verify the plan has not reached `end_ts` (else `PlanExpired`) +6. Verify the live plan terms still match the subscription's snapshotted terms (else `PlanTermsMismatch`) +7. Verify `expires_at_ts > current_ts` (else `SubscriptionCancelled`) + +**Process:** + +1. Clear `expires_at_ts` to `0` +2. Leave `current_period_start_ts` and `amount_pulled_in_period` unchanged +3. Emit `SubscriptionResumed` event via self-CPI + --- ## Sequence Diagrams @@ -551,10 +583,11 @@ sequenceDiagram ### Delegator Cancels and Revokes Subscription -Cancellation is a two-step process: +Cancellation is a three-step flow (resume is optional): 1. **`cancel_subscription`** — Sets `expires_at_ts`. Three paths: terms match (grace period until end of billing period), terms mismatch/ghost plan (immediate), plan closed (immediate). -2. **`revoke_delegation`** — Closes the subscription account and reclaims rent. Only allowed after `expires_at_ts` is in the past. +2. **`resume_subscription`** — Optional. Clears a pending cancellation without resetting period accounting. Rejected once the cancellation period elapses, or if the plan is closed, expired, or recreated with different terms. Sunset plans may still resume existing subscriptions before `end_ts`. +3. **`revoke_delegation`** — Closes the subscription account and reclaims rent. Only allowed after `expires_at_ts` is in the past. ```mermaid sequenceDiagram @@ -568,6 +601,15 @@ sequenceDiagram X->>P: transfer_subscription(amount, delegator, mint) Note over P: Within cancellation period:
Pull allowed (grace period) + D->>P: resume_subscription(plan_pda, subscription_pda) + Note over P: expires_at_ts = 0
Period state unchanged + + X->>P: transfer_subscription(amount, delegator, mint) + Note over P: Pulls continue as an active subscription + + D->>P: cancel_subscription(plan_pda, subscription_pda) + Note over P: Subscriber cancels again + Note over D,P: Period boundary passes... X->>P: transfer_subscription(amount, delegator, mint) @@ -677,6 +719,7 @@ All transfer and lifecycle instructions emit events via self-CPI through an even | ---------------------------- | ----------------------- | ----------------------------------------------------------------- | | `SubscriptionCreatedEvent` | `subscribe` | plan_pda, subscriber, mint, timestamp | | `SubscriptionCancelledEvent` | `cancel_subscription` | plan_pda, subscriber, expires_at_ts, timestamp | +| `SubscriptionResumedEvent` | `resume_subscription` | plan_pda, subscriber, timestamp | | `SubscriptionTransferEvent` | `transfer_subscription` | plan_pda, subscriber, amount, receiver, timestamp | | `FixedTransferEvent` | `transfer_fixed` | delegation_pda, delegator, delegatee, amount, receiver, timestamp | | `RecurringTransferEvent` | `transfer_recurring` | delegation_pda, delegator, delegatee, amount, receiver, timestamp | diff --git a/idl/subscriptions.json b/idl/subscriptions.json index 46f0a0a..b54e22d 100644 --- a/idl/subscriptions.json +++ b/idl/subscriptions.json @@ -1158,7 +1158,7 @@ { "code": 510, "kind": "errorNode", - "message": "Subscription must be cancelled before revoke", + "message": "Subscription is not cancelled", "name": "subscriptionNotCancelled" }, { @@ -2424,6 +2424,113 @@ ], "kind": "instructionNode", "name": "cancelSubscription" + }, + { + "accounts": [ + { + "docs": [ + "The subscriber resuming the subscription" + ], + "isSigner": true, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "subscriber" + }, + { + "docs": [ + "The plan PDA for the subscription" + ], + "isSigner": false, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "planPda" + }, + { + "defaultValue": { + "kind": "pdaValueNode", + "pda": { + "kind": "pdaLinkNode", + "name": "subscriptionDelegation" + }, + "seeds": [ + { + "kind": "pdaSeedValueNode", + "name": "planPda", + "value": { + "kind": "accountValueNode", + "name": "planPda" + } + }, + { + "kind": "pdaSeedValueNode", + "name": "subscriber", + "value": { + "kind": "accountValueNode", + "name": "subscriber" + } + } + ] + }, + "docs": [ + "The subscription PDA being resumed" + ], + "isSigner": false, + "isWritable": true, + "kind": "instructionAccountNode", + "name": "subscriptionPda" + }, + { + "defaultValue": { + "kind": "publicKeyValueNode", + "publicKey": "3Hnj4BYoDgtpBuqXfiy7Y8cNa3jXaNd4oqgSXBzkMcH7" + }, + "docs": [ + "The event authority PDA" + ], + "isSigner": false, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "eventAuthority" + }, + { + "defaultValue": { + "kind": "publicKeyValueNode", + "publicKey": "De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44" + }, + "docs": [ + "This program (for self-CPI)" + ], + "isSigner": false, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "selfProgram" + } + ], + "arguments": [ + { + "defaultValue": { + "kind": "numberValueNode", + "number": 13 + }, + "defaultValueStrategy": "omitted", + "kind": "instructionArgumentNode", + "name": "discriminator", + "type": { + "endian": "le", + "format": "u8", + "kind": "numberTypeNode" + } + } + ], + "discriminators": [ + { + "kind": "fieldDiscriminatorNode", + "name": "discriminator", + "offset": 0 + } + ], + "kind": "instructionNode", + "name": "resumeSubscription" } ], "kind": "programNode", diff --git a/program/src/entrypoint.rs b/program/src/entrypoint.rs index 459bbed..455744c 100644 --- a/program/src/entrypoint.rs +++ b/program/src/entrypoint.rs @@ -2,9 +2,9 @@ use pinocchio::{account::AccountView, entrypoint, Address, ProgramResult}; use crate::instructions::{ cancel_subscription, close_subscription_authority, create_fixed_delegation, create_plan, - create_recurring_delegation, delete_plan, emit_event, initialize_subscription_authority, revoke_delegation, - subscribe, transfer_fixed_delegation, transfer_recurring_delegation, transfer_subscription, update_plan, - SubscriptionsInstruction, + create_recurring_delegation, delete_plan, emit_event, initialize_subscription_authority, resume_subscription, + revoke_delegation, subscribe, transfer_fixed_delegation, transfer_recurring_delegation, transfer_subscription, + update_plan, SubscriptionsInstruction, }; entrypoint!(process_instruction); @@ -32,6 +32,7 @@ pub fn process_instruction( SubscriptionsInstruction::TransferSubscription(data) => transfer_subscription::process(accounts, &data), SubscriptionsInstruction::Subscribe(data) => subscribe::process(accounts, &data), SubscriptionsInstruction::CancelSubscription => cancel_subscription::process(accounts), + SubscriptionsInstruction::ResumeSubscription => resume_subscription::process(accounts), SubscriptionsInstruction::EmitEvent => emit_event::process(program_id, accounts), } } diff --git a/program/src/errors.rs b/program/src/errors.rs index 50e8978..3fc6263 100644 --- a/program/src/errors.rs +++ b/program/src/errors.rs @@ -229,7 +229,7 @@ pub enum SubscriptionsError { SubscriptionCancelled, #[error("Subscription already cancelled")] SubscriptionAlreadyCancelled, - #[error("Subscription must be cancelled before revoke")] + #[error("Subscription is not cancelled")] SubscriptionNotCancelled, #[error("End timestamp must be zero or in the future")] InvalidEndTs, diff --git a/program/src/event_engine.rs b/program/src/event_engine.rs index 0c86bc1..7f12b2f 100644 --- a/program/src/event_engine.rs +++ b/program/src/event_engine.rs @@ -109,6 +109,8 @@ pub enum EventDiscriminators { FixedTransfer = 3, /// A transfer was executed against a recurring delegation. RecurringTransfer = 4, + /// A cancelled subscription was resumed by the subscriber. + SubscriptionResumed = 5, } impl TryFrom for EventDiscriminators { @@ -121,6 +123,7 @@ impl TryFrom for EventDiscriminators { 2 => Ok(Self::SubscriptionTransfer), 3 => Ok(Self::FixedTransfer), 4 => Ok(Self::RecurringTransfer), + 5 => Ok(Self::SubscriptionResumed), _ => Err(value), } } diff --git a/program/src/events/mod.rs b/program/src/events/mod.rs index 3ad9349..ed11cce 100644 --- a/program/src/events/mod.rs +++ b/program/src/events/mod.rs @@ -9,12 +9,14 @@ pub mod fixed_transfer; pub mod recurring_transfer; pub mod subscription_cancelled; pub mod subscription_created; +pub mod subscription_resumed; pub mod subscription_transfer; pub use fixed_transfer::*; pub use recurring_transfer::*; pub use subscription_cancelled::*; pub use subscription_created::*; +pub use subscription_resumed::*; pub use subscription_transfer::*; /// Typed reference to one of the program's events, used for decoding. @@ -29,4 +31,6 @@ pub enum Event<'a> { FixedTransfer(&'a FixedTransferEvent), /// See [`RecurringTransferEvent`]. RecurringTransfer(&'a RecurringTransferEvent), + /// See [`SubscriptionResumedEvent`]. + SubscriptionResumed(&'a SubscriptionResumedEvent), } diff --git a/program/src/events/subscription_resumed.rs b/program/src/events/subscription_resumed.rs new file mode 100644 index 0000000..6b9c044 --- /dev/null +++ b/program/src/events/subscription_resumed.rs @@ -0,0 +1,85 @@ +use core::mem::size_of; + +use alloc::vec::Vec; +use pinocchio::Address; + +use crate::event_engine::{EventDiscriminator, EventDiscriminators, EventSerialize}; + +/// Emitted when a subscriber resumes a previously cancelled subscription. +#[repr(C, packed)] +pub struct SubscriptionResumedEvent { + /// The plan PDA the subscription belongs to. + pub plan: Address, + /// The subscriber's wallet address. + pub subscriber: Address, + /// Unix timestamp when the subscription was resumed. + pub resumed_ts: i64, +} + +impl SubscriptionResumedEvent { + /// Wire-format payload size (excluding tag and discriminator). + pub const DATA_LEN: usize = size_of::(); + + /// Constructs a new event. + pub fn new(plan: Address, subscriber: Address, resumed_ts: i64) -> Self { + Self { plan, subscriber, resumed_ts } + } +} + +impl EventDiscriminator for SubscriptionResumedEvent { + const DISCRIMINATOR: u8 = EventDiscriminators::SubscriptionResumed as u8; +} + +impl EventSerialize for SubscriptionResumedEvent { + const DATA_LEN: usize = Self::DATA_LEN; + + fn write_inner(&self, writer: &mut Vec) { + writer.extend_from_slice(self.plan.as_ref()); + writer.extend_from_slice(self.subscriber.as_ref()); + writer.extend_from_slice(&{ self.resumed_ts }.to_le_bytes()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::event_engine::EVENT_IX_TAG_LE; + use crate::events::Event; + use crate::tests::events::decode_event; + + fn plan() -> Address { + Address::new_from_array([1u8; 32]) + } + + fn subscriber() -> Address { + Address::new_from_array([2u8; 32]) + } + + #[test] + fn roundtrip() { + let event = SubscriptionResumedEvent::new(plan(), subscriber(), 1_700_000_000); + let bytes = event.to_bytes(); + let decoded = decode_event(&bytes).unwrap(); + + match decoded { + Event::SubscriptionResumed(e) => { + assert_eq!(e.plan, plan()); + assert_eq!(e.subscriber, subscriber()); + assert_eq!({ e.resumed_ts }, 1_700_000_000); + } + _ => panic!("expected Resumed event"), + } + } + + #[test] + fn wire_format() { + let event = SubscriptionResumedEvent::new(plan(), subscriber(), 99); + let bytes = event.to_bytes(); + + assert_eq!(&bytes[..8], &EVENT_IX_TAG_LE); + assert_eq!(bytes[8], SubscriptionResumedEvent::DISCRIMINATOR); + assert_eq!(&bytes[9..41], plan().as_ref()); + assert_eq!(&bytes[41..73], subscriber().as_ref()); + assert_eq!(&bytes[73..81], &99i64.to_le_bytes()); + } +} diff --git a/program/src/instructions/mod.rs b/program/src/instructions/mod.rs index 29ef59c..26d6533 100644 --- a/program/src/instructions/mod.rs +++ b/program/src/instructions/mod.rs @@ -17,6 +17,7 @@ pub use create_recurring_delegation::CreateRecurringDelegationData; pub mod emit_event; pub mod helpers; pub mod initialize_subscription_authority; +pub mod resume_subscription; pub mod revoke_delegation; pub mod transfer_fixed_delegation; pub mod transfer_recurring_delegation; @@ -237,6 +238,26 @@ pub enum SubscriptionsInstruction { ))] CancelSubscription = 12, + #[codama(account(name = "subscriber", signer, docs = "The subscriber resuming the subscription"))] + #[codama(account(name = "plan_pda", docs = "The plan PDA for the subscription"))] + #[codama(account( + name = "subscription_pda", + writable, + docs = "The subscription PDA being resumed", + default_value = pda("subscriptionDelegation", [seed("planPda", account("plan_pda")), seed("subscriber", account("subscriber"))]) + ))] + #[codama(account( + name = "event_authority", + docs = "The event authority PDA", + default_value = public_key("3Hnj4BYoDgtpBuqXfiy7Y8cNa3jXaNd4oqgSXBzkMcH7") + ))] + #[codama(account( + name = "self_program", + docs = "This program (for self-CPI)", + default_value = public_key("De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44") + ))] + ResumeSubscription = 13, + #[codama(skip)] #[codama(account( name = "event_authority", @@ -291,6 +312,7 @@ impl SubscriptionsInstruction { Ok(Self::Subscribe(loaded.clone())) } cancel_subscription::DISCRIMINATOR => Ok(Self::CancelSubscription), + resume_subscription::DISCRIMINATOR => Ok(Self::ResumeSubscription), &EMIT_EVENT_IX_DISC => Ok(Self::EmitEvent), _ => Err(SubscriptionsError::InvalidInstruction.into()), } @@ -313,6 +335,7 @@ impl fmt::Display for SubscriptionsInstruction { Self::TransferSubscription(_) => write!(f, "transfer_subscription"), Self::Subscribe(_) => write!(f, "subscribe"), Self::CancelSubscription => write!(f, "cancel_subscription"), + Self::ResumeSubscription => write!(f, "resume_subscription"), Self::EmitEvent => write!(f, "emit_event"), } } diff --git a/program/src/instructions/resume_subscription.rs b/program/src/instructions/resume_subscription.rs new file mode 100644 index 0000000..71e7937 --- /dev/null +++ b/program/src/instructions/resume_subscription.rs @@ -0,0 +1,99 @@ +use pinocchio::{ + error::ProgramError, + sysvars::{clock::Clock, Sysvar}, + AccountView, ProgramResult, +}; + +use crate::{ + check_and_update_version, + event_engine::{self, EventSerialize}, + events::SubscriptionResumedEvent, + state::{plan::Plan, subscription_delegation::SubscriptionDelegation}, + AccountCheck, ProgramAccount, SignerAccount, SubscriptionsError, WritableAccount, +}; + +/// Instruction discriminator byte for `ResumeSubscription`. +pub const DISCRIMINATOR: &u8 = &13; + +/// Resumes a cancelled subscription by clearing its `expires_at_ts`. +/// +/// Rejects when the cancellation period has elapsed, the plan account is +/// closed, expired, or no longer matches the subscription's snapshotted terms. +/// Period accounting (`current_period_start_ts`, `amount_pulled_in_period`) is +/// unchanged. Emits a [`SubscriptionResumedEvent`]. +pub fn process(accounts: &mut [AccountView]) -> ProgramResult { + let accounts_struct = ResumeSubscriptionAccounts::try_from(accounts)?; + let current_ts = Clock::get()?.unix_timestamp; + + let plan_pda; + { + let mut binding = accounts_struct.subscription_pda.try_borrow_mut()?; + check_and_update_version(&mut binding)?; + let subscription = SubscriptionDelegation::load_mut_with_min_size(&mut binding)?; + + if subscription.header.delegator != *accounts_struct.subscriber.address() { + return Err(SubscriptionsError::Unauthorized.into()); + } + + if subscription.header.delegatee != *accounts_struct.plan_pda.address() { + return Err(SubscriptionsError::SubscriptionPlanMismatch.into()); + } + + if subscription.expires_at_ts == 0 { + return Err(SubscriptionsError::SubscriptionNotCancelled.into()); + } + + if !accounts_struct.plan_pda.owned_by(&crate::ID) { + return Err(SubscriptionsError::PlanClosed.into()); + } + + { + let plan_data = accounts_struct.plan_pda.try_borrow()?; + let plan = Plan::load(&plan_data)?; + + if plan.data.end_ts != 0 && current_ts > plan.data.end_ts { + return Err(SubscriptionsError::PlanExpired.into()); + } + + subscription.check_plan_terms(&plan.data.terms)?; + } + + if subscription.expires_at_ts <= current_ts { + return Err(SubscriptionsError::SubscriptionCancelled.into()); + } + + plan_pda = subscription.header.delegatee; + subscription.expires_at_ts = 0; + } + + let event = SubscriptionResumedEvent::new(plan_pda, *accounts_struct.subscriber.address(), current_ts); + let event_data = event.to_bytes(); + event_engine::emit_event(&crate::ID, accounts_struct.event_authority, accounts_struct.self_program, &event_data)?; + + Ok(()) +} + +/// Validated accounts for the [`ResumeSubscription`](crate::SubscriptionsInstruction::ResumeSubscription) instruction. +pub struct ResumeSubscriptionAccounts<'a> { + pub subscriber: &'a AccountView, + pub plan_pda: &'a AccountView, + pub subscription_pda: &'a mut AccountView, + pub event_authority: &'a AccountView, + pub self_program: &'a AccountView, +} + +impl<'a> TryFrom<&'a mut [AccountView]> for ResumeSubscriptionAccounts<'a> { + type Error = ProgramError; + + fn try_from(accounts: &'a mut [AccountView]) -> Result { + let [subscriber, plan_pda, subscription_pda, event_authority, self_program] = accounts else { + return Err(SubscriptionsError::NotEnoughAccountKeys.into()); + }; + + SignerAccount::check(subscriber)?; + ProgramAccount::check(subscription_pda)?; + WritableAccount::check(subscription_pda)?; + + Ok(Self { subscriber, plan_pda, subscription_pda, event_authority, self_program }) + } +} diff --git a/program/src/tests/events.rs b/program/src/tests/events.rs index f50ac57..6304484 100644 --- a/program/src/tests/events.rs +++ b/program/src/tests/events.rs @@ -3,7 +3,7 @@ use pinocchio::error::ProgramError; use crate::event_engine::{EventDiscriminators, EventSerialize, EVENT_DISCRIMINATOR_LEN, EVENT_IX_TAG_LE}; use crate::events::{ Event, FixedTransferEvent, RecurringTransferEvent, SubscriptionCancelledEvent, SubscriptionCreatedEvent, - SubscriptionTransferEvent, + SubscriptionResumedEvent, SubscriptionTransferEvent, }; use crate::SubscriptionsError; @@ -34,5 +34,8 @@ pub fn decode_event<'a>(data: &'a [u8]) -> Result, ProgramError> { } EventDiscriminators::FixedTransfer => Ok(Event::FixedTransfer(FixedTransferEvent::load(payload)?)), EventDiscriminators::RecurringTransfer => Ok(Event::RecurringTransfer(RecurringTransferEvent::load(payload)?)), + EventDiscriminators::SubscriptionResumed => { + Ok(Event::SubscriptionResumed(SubscriptionResumedEvent::load(payload)?)) + } } } diff --git a/tests/integration-tests/src/lib.rs b/tests/integration-tests/src/lib.rs index ede3cf4..3537438 100644 --- a/tests/integration-tests/src/lib.rs +++ b/tests/integration-tests/src/lib.rs @@ -25,6 +25,8 @@ mod test_delete_plan; #[cfg(test)] mod test_initialize_subscription_authority; #[cfg(test)] +mod test_resume_subscription; +#[cfg(test)] mod test_revoke_delegation; #[cfg(test)] mod test_subscribe; diff --git a/tests/integration-tests/src/test_resume_subscription.rs b/tests/integration-tests/src/test_resume_subscription.rs new file mode 100644 index 0000000..5bb3a09 --- /dev/null +++ b/tests/integration-tests/src/test_resume_subscription.rs @@ -0,0 +1,226 @@ +use crate::{ + state::{common::PlanStatus, header::VERSION_OFFSET, subscription_delegation::SubscriptionDelegation}, + tests::{ + asserts::TransactionResultExt, + constants::{MINT_DECIMALS, TOKEN_PROGRAM_ID}, + pda::{get_plan_pda, get_subscription_pda}, + utils::{ + current_ts, days, hours, init_ata, init_mint, init_wallet, initialize_subscription_authority_action, + move_clock_forward, setup, setup_with_subscription, CancelSubscription, CreatePlan, DeletePlan, + ResumeSubscription, Subscribe, TransferSubscription, UpdatePlan, + }, + }, + SubscriptionsError, +}; +use litesvm::LiteSVM; +use solana_keypair::Keypair; +use solana_pubkey::Pubkey; +use solana_signer::Signer; + +/// Creates a subscription whose plan ends exactly one period after creation, so +/// the cancellation `expires_at_ts` is pinned to `plan.end_ts` and the +/// `PlanExpired`/`PlanClosed` guards in resume become reachable. +fn setup_subscription_with_tight_plan_end() -> (LiteSVM, Keypair, Keypair, Pubkey, Pubkey) { + let (mut litesvm, alice) = setup(); + let merchant = Keypair::new(); + litesvm.airdrop(&merchant.pubkey(), 10_000_000_000).unwrap(); + + let mint = init_mint(&mut litesvm, TOKEN_PROGRAM_ID, MINT_DECIMALS, 1_000_000_000, Some(alice.pubkey()), &[]); + init_ata(&mut litesvm, mint, alice.pubkey(), 100_000_000); + + initialize_subscription_authority_action(&mut litesvm, &alice, mint).0.assert_ok(); + + let end_ts = current_ts() + hours(1) as i64; + let (res, plan_pda) = CreatePlan::new(&mut litesvm, &merchant, mint) + .plan_id(1) + .amount(50_000_000) + .period_hours(1) + .end_ts(end_ts) + .execute(); + res.assert_ok(); + + let (_, plan_bump) = get_plan_pda(&merchant.pubkey(), 1); + Subscribe::new(&mut litesvm, &alice, merchant.pubkey(), plan_pda, 1, plan_bump, mint).execute().assert_ok(); + + let (subscription_pda, _) = get_subscription_pda(&plan_pda, &alice.pubkey()); + + (litesvm, alice, merchant, plan_pda, subscription_pda) +} + +#[test] +fn resume_subscription_happy_path() { + let (mut litesvm, alice, _merchant, _mint, plan_pda, _plan_bump, subscription_pda) = setup_with_subscription(); + + CancelSubscription::new(&mut litesvm, &alice, plan_pda, subscription_pda).execute().assert_ok(); + + let sub_account = litesvm.get_account(&subscription_pda).unwrap(); + let sub = SubscriptionDelegation::load(&sub_account.data).unwrap(); + let period_start = sub.current_period_start_ts; + let amount_pulled = sub.amount_pulled_in_period; + assert_ne!({ sub.expires_at_ts }, 0); + + ResumeSubscription::new(&mut litesvm, &alice, plan_pda, subscription_pda).execute().assert_ok(); + + let sub_account = litesvm.get_account(&subscription_pda).unwrap(); + let sub = SubscriptionDelegation::load(&sub_account.data).unwrap(); + assert_eq!({ sub.expires_at_ts }, 0); + assert_eq!({ sub.current_period_start_ts }, period_start); + assert_eq!({ sub.amount_pulled_in_period }, amount_pulled); +} + +#[test] +fn resume_subscription_rejected_at_cancelled_period_end() { + let (mut litesvm, alice, merchant, mint, plan_pda, _plan_bump, subscription_pda) = setup_with_subscription(); + init_ata(&mut litesvm, mint, merchant.pubkey(), 0); + + CancelSubscription::new(&mut litesvm, &alice, plan_pda, subscription_pda).execute().assert_ok(); + move_clock_forward(&mut litesvm, hours(1)); + + TransferSubscription::new(&mut litesvm, &merchant, alice.pubkey(), mint, subscription_pda, plan_pda) + .amount(10_000_000) + .execute() + .assert_err(SubscriptionsError::SubscriptionCancelled); + + ResumeSubscription::new(&mut litesvm, &alice, plan_pda, subscription_pda) + .execute() + .assert_err(SubscriptionsError::SubscriptionCancelled); +} + +#[test] +fn resume_subscription_not_cancelled_rejected() { + let (mut litesvm, alice, _merchant, _mint, plan_pda, _plan_bump, subscription_pda) = setup_with_subscription(); + + ResumeSubscription::new(&mut litesvm, &alice, plan_pda, subscription_pda) + .execute() + .assert_err(SubscriptionsError::SubscriptionNotCancelled); +} + +#[test] +fn resume_subscription_non_subscriber_rejected() { + let (mut litesvm, alice, _merchant, _mint, plan_pda, _plan_bump, subscription_pda) = setup_with_subscription(); + + CancelSubscription::new(&mut litesvm, &alice, plan_pda, subscription_pda).execute().assert_ok(); + + let attacker = init_wallet(&mut litesvm, 10_000_000_000); + ResumeSubscription::new(&mut litesvm, &attacker, plan_pda, subscription_pda) + .execute() + .assert_err(SubscriptionsError::Unauthorized); +} + +#[test] +fn resume_subscription_plan_mismatch_rejected() { + let (mut litesvm, alice, merchant, mint, plan_pda, _plan_bump, subscription_pda) = setup_with_subscription(); + + CancelSubscription::new(&mut litesvm, &alice, plan_pda, subscription_pda).execute().assert_ok(); + + let (res, wrong_plan) = CreatePlan::new(&mut litesvm, &merchant, mint) + .plan_id(2) + .amount(50_000_000) + .period_hours(1) + .end_ts(current_ts() + days(30) as i64) + .execute(); + res.assert_ok(); + + ResumeSubscription::new(&mut litesvm, &alice, wrong_plan, subscription_pda) + .execute() + .assert_err(SubscriptionsError::SubscriptionPlanMismatch); +} + +#[test] +fn resume_subscription_rejected_after_cancelled_period_elapsed() { + let (mut litesvm, alice, _merchant, _mint, plan_pda, _plan_bump, subscription_pda) = setup_with_subscription(); + + CancelSubscription::new(&mut litesvm, &alice, plan_pda, subscription_pda).execute().assert_ok(); + move_clock_forward(&mut litesvm, hours(1) + 1); + + ResumeSubscription::new(&mut litesvm, &alice, plan_pda, subscription_pda) + .execute() + .assert_err(SubscriptionsError::SubscriptionCancelled); +} + +#[test] +fn resume_subscription_rejected_when_plan_expired() { + let (mut litesvm, alice, _merchant, plan_pda, subscription_pda) = setup_subscription_with_tight_plan_end(); + + CancelSubscription::new(&mut litesvm, &alice, plan_pda, subscription_pda).execute().assert_ok(); + + // Advance just past plan.end_ts. Resume is rejected because the plan no + // longer supports active subscriptions. + move_clock_forward(&mut litesvm, hours(1) + 1); + + ResumeSubscription::new(&mut litesvm, &alice, plan_pda, subscription_pda) + .execute() + .assert_err(SubscriptionsError::PlanExpired); +} + +#[test] +fn resume_subscription_allows_when_plan_sunset() { + let (mut litesvm, alice, merchant, _mint, plan_pda, _plan_bump, subscription_pda) = setup_with_subscription(); + + CancelSubscription::new(&mut litesvm, &alice, plan_pda, subscription_pda).execute().assert_ok(); + UpdatePlan::new(&mut litesvm, &merchant, plan_pda) + .status(PlanStatus::Sunset) + .end_ts(current_ts() + days(7) as i64) + .execute() + .assert_ok(); + + ResumeSubscription::new(&mut litesvm, &alice, plan_pda, subscription_pda).execute().assert_ok(); + + let account = litesvm.get_account(&subscription_pda).unwrap(); + let sub = SubscriptionDelegation::load(&account.data).unwrap(); + assert_eq!({ sub.expires_at_ts }, 0); +} + +#[test] +fn resume_subscription_rejected_when_plan_deleted() { + let (mut litesvm, alice, merchant, plan_pda, subscription_pda) = setup_subscription_with_tight_plan_end(); + + CancelSubscription::new(&mut litesvm, &alice, plan_pda, subscription_pda).execute().assert_ok(); + move_clock_forward(&mut litesvm, hours(1) + 1); + DeletePlan::new(&mut litesvm, &merchant, plan_pda).execute().assert_ok(); + + ResumeSubscription::new(&mut litesvm, &alice, plan_pda, subscription_pda) + .execute() + .assert_err(SubscriptionsError::PlanClosed); +} + +#[test] +fn resume_subscription_cancel_resume_cancel_across_period_boundary() { + let (mut litesvm, alice, _merchant, _mint, plan_pda, _plan_bump, subscription_pda) = setup_with_subscription(); + + CancelSubscription::new(&mut litesvm, &alice, plan_pda, subscription_pda).execute().assert_ok(); + let first_expires_at = { + let account = litesvm.get_account(&subscription_pda).unwrap(); + let sub = SubscriptionDelegation::load(&account.data).unwrap(); + sub.expires_at_ts + }; + + ResumeSubscription::new(&mut litesvm, &alice, plan_pda, subscription_pda).execute().assert_ok(); + + // Advance past the original period end so the second cancel must compute a + // new period boundary, not reuse the stale one. + move_clock_forward(&mut litesvm, hours(2)); + + CancelSubscription::new(&mut litesvm, &alice, plan_pda, subscription_pda).execute().assert_ok(); + let account = litesvm.get_account(&subscription_pda).unwrap(); + let sub = SubscriptionDelegation::load(&account.data).unwrap(); + assert!( + { sub.expires_at_ts } > first_expires_at, + "second cancel should advance expires_at_ts past the prior period boundary", + ); +} + +#[test] +fn resume_subscription_version_mismatch() { + let (mut litesvm, alice, _merchant, _mint, plan_pda, _plan_bump, subscription_pda) = setup_with_subscription(); + + CancelSubscription::new(&mut litesvm, &alice, plan_pda, subscription_pda).execute().assert_ok(); + + let mut account = litesvm.get_account(&subscription_pda).unwrap(); + account.data[VERSION_OFFSET] = 0; + litesvm.set_account(subscription_pda, account).unwrap(); + + ResumeSubscription::new(&mut litesvm, &alice, plan_pda, subscription_pda) + .execute() + .assert_err(SubscriptionsError::MigrationRequired); +} diff --git a/tests/integration-tests/src/utils/test_helpers.rs b/tests/integration-tests/src/utils/test_helpers.rs index 362f919..c100e0e 100644 --- a/tests/integration-tests/src/utils/test_helpers.rs +++ b/tests/integration-tests/src/utils/test_helpers.rs @@ -31,8 +31,9 @@ use crate::{ instructions::update_plan::UpdatePlanData, instructions::{ cancel_subscription, close_subscription_authority, create_fixed_delegation, create_plan, - create_recurring_delegation, delete_plan, initialize_subscription_authority, revoke_delegation, subscribe, - transfer_fixed_delegation, transfer_recurring_delegation, transfer_subscription, update_plan, + create_recurring_delegation, delete_plan, initialize_subscription_authority, resume_subscription, + revoke_delegation, subscribe, transfer_fixed_delegation, transfer_recurring_delegation, transfer_subscription, + update_plan, }, state::common::PlanStatus, tests::{ @@ -1048,6 +1049,36 @@ impl<'a> CancelSubscription<'a> { } } +pub struct ResumeSubscription<'a> { + litesvm: &'a mut LiteSVM, + subscriber: &'a Keypair, + plan_pda: Pubkey, + subscription_pda: Pubkey, +} + +impl<'a> ResumeSubscription<'a> { + pub fn new(litesvm: &'a mut LiteSVM, subscriber: &'a Keypair, plan_pda: Pubkey, subscription_pda: Pubkey) -> Self { + Self { litesvm, subscriber, plan_pda, subscription_pda } + } + + #[allow(clippy::result_large_err)] + pub fn execute(self) -> TransactionResult { + let event_authority = Pubkey::new_from_array(event_authority_pda::ID.to_bytes()); + + let accounts = vec![ + AccountMeta::new_readonly(self.subscriber.pubkey(), true), + AccountMeta::new_readonly(self.plan_pda, false), + AccountMeta::new(self.subscription_pda, false), + AccountMeta::new_readonly(event_authority, false), + AccountMeta::new_readonly(PROGRAM_ID, false), + ]; + + let ix = Instruction { program_id: PROGRAM_ID, accounts, data: vec![*resume_subscription::DISCRIMINATOR] }; + + build_and_send_transaction(self.litesvm, &[self.subscriber], &self.subscriber.pubkey(), &ix) + } +} + pub fn setup_with_subscription() -> ( LiteSVM, Keypair, // alice (subscriber) diff --git a/webapp/src/components/plan/plan-card.tsx b/webapp/src/components/plan/plan-card.tsx index 720c086..8a08d56 100644 --- a/webapp/src/components/plan/plan-card.tsx +++ b/webapp/src/components/plan/plan-card.tsx @@ -11,6 +11,7 @@ import { Star, Plus, X, + RotateCcw, } from 'lucide-react'; import { Badge, Button as SolanaButton, Select, SelectItem, TextInput } from '@solana/design-system'; import { Card, CardContent } from '@/components/ui/card'; @@ -653,6 +654,7 @@ export function PlanCard({ const [editOpen, setEditOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); const [subscribeOpen, setSubscribeOpen] = useState(false); + const { resumeSubscription } = useSubscriptionsMutations(); const { data: mySubscriptions } = useMySubscriptions(); const matchingSub = useMemo( () => mySubscriptions?.find(s => s.subscription.header.delegatee === plan.address) ?? null, @@ -661,7 +663,13 @@ export function PlanCard({ const isSubscribed = !!matchingSub; const subExpiresAtTs = matchingSub ? Number(matchingSub.subscription.expiresAtTs) : 0; const isCancelledSub = isSubscribed && subExpiresAtTs > 0; + const isGhostSubscription = + matchingSub != null && + (plan.data.terms.amount !== matchingSub.subscription.terms.amount || + plan.data.terms.periodHours !== matchingSub.subscription.terms.periodHours || + plan.data.terms.createdAt !== matchingSub.subscription.terms.createdAt); const [subDaysLeft, setSubDaysLeft] = useState(null); + const canResumeSubscription = isCancelledSub && !isGhostSubscription && subDaysLeft !== null && subDaysLeft > 0; const meta = useMemo(() => parsePlanMeta(plan.data.metadataUri), [plan.data.metadataUri]); @@ -837,7 +845,24 @@ export function PlanCard({ {variant === 'marketplace' && (
- {isCancelledSub ? ( + {canResumeSubscription && matchingSub ? ( + { + e.stopPropagation(); + resumeSubscription.mutate({ + planPda: plan.address, + subscriptionPda: matchingSub.address, + }); + }} + disabled={resumeSubscription.isPending} + loading={resumeSubscription.isPending} + iconLeft={} + style={{ width: '100%' }} + > + Resume Subscription + + ) : isCancelledSub ? ( Cancelled{' '} {subDaysLeft !== null && subDaysLeft > 0 diff --git a/webapp/src/components/subscription/my-subscriptions-panel.tsx b/webapp/src/components/subscription/my-subscriptions-panel.tsx index 4f4dc5f..4efc4c1 100644 --- a/webapp/src/components/subscription/my-subscriptions-panel.tsx +++ b/webapp/src/components/subscription/my-subscriptions-panel.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useMemo } from 'react'; -import { CalendarCheck, Trash2, Clock } from 'lucide-react'; +import { CalendarCheck, Trash2, Clock, RotateCcw } from 'lucide-react'; import { Badge } from '@solana/design-system'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -131,6 +131,54 @@ function RevokeSubscriptionDialog({ ); } +function ResumeSubscriptionDialog({ + item, + open, + onOpenChange, +}: { + item: EnrichedSubscription; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { resumeSubscription } = useSubscriptionsMutations(); + + return ( + + + + Resume Subscription + + Resuming clears the pending cancellation and lets authorized plan pullers collect future + payments automatically. + + + + + + + + + ); +} + function CancelAndRevokeDialog({ item, isGhostPlan, @@ -191,6 +239,7 @@ function SubscriptionCard({ }) { const [cancelOpen, setCancelOpen] = useState(false); const [revokeOpen, setRevokeOpen] = useState(false); + const [resumeOpen, setResumeOpen] = useState(false); const [cancelAndRevokeOpen, setCancelAndRevokeOpen] = useState(false); const { cancelSubscription } = useSubscriptionsMutations(); const { getCurrentTimestamp } = useTimeTravel(); @@ -229,6 +278,7 @@ function SubscriptionCard({ const pulled = Number(item.subscription.amountPulledInPeriod) / USDC_MULTIPLIER; const subInitId = item.subscription.header.initId; const isStale = subscriptionAuthorityInitId != null && subInitId !== subscriptionAuthorityInitId; + const canResume = isCancelled && daysLeft !== null && daysLeft > 0 && !planDeleted && !isGhostPlan && !isStale; return ( <> @@ -325,6 +375,33 @@ function SubscriptionCard({ > {cancelSubscription.isPending ? 'Unsubscribing...' : 'Unsubscribe'} + ) : canResume ? ( +
+ + +
) : (