From cbeb1333518117f7354671a26f79572a7d29d5dc Mon Sep 17 00:00:00 2001 From: Jo D Date: Thu, 21 May 2026 14:38:38 -0400 Subject: [PATCH 1/4] feat(subscription): add resume instruction Add a subscriber-signed resume_subscription instruction that clears pending cancellation without resetting period accounting. Update IDL, TypeScript overlay exports, docs, and integration coverage. --- README.md | 20 ++-- clients/typescript/README.md | 1 + clients/typescript/src/index.ts | 2 + clients/typescript/src/plugin.ts | 27 +++++ docs/002-subscriptions-architecture.md | 62 +++++++--- idl/subscriptions.json | 83 ++++++++++++- program/src/entrypoint.rs | 7 +- program/src/errors.rs | 2 +- program/src/instructions/mod.rs | 13 +++ .../src/instructions/resume_subscription.rs | 62 ++++++++++ tests/integration-tests/src/lib.rs | 2 + .../src/test_resume_subscription.rs | 109 ++++++++++++++++++ .../src/utils/test_helpers.rs | 31 ++++- 13 files changed, 391 insertions(+), 30 deletions(-) create mode 100644 program/src/instructions/resume_subscription.rs create mode 100644 tests/integration-tests/src/test_resume_subscription.rs 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..3d920f4 100644 --- a/clients/typescript/src/index.ts +++ b/clients/typescript/src/index.ts @@ -25,6 +25,7 @@ export { getInitSubscriptionAuthorityOverlayInstructionAsync, getRevokeDelegationOverlayInstruction, getRevokeSubscriptionOverlayInstruction, + getResumeSubscriptionOverlayInstructionAsync, getSubscribeOverlayInstructionAsync, getTransferFixedOverlayInstructionAsync, getTransferRecurringOverlayInstructionAsync, @@ -33,6 +34,7 @@ export { type InitSubscriptionAuthorityInput, type RevokeDelegationInput, type RevokeSubscriptionInput, + type ResumeSubscriptionInput, type SubscribeInput, type SubscriptionsPlugin, type SubscriptionsPluginInstructions, 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/docs/002-subscriptions-architecture.md b/docs/002-subscriptions-architecture.md index 8b5337a..e9b6e42 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`. --- @@ -489,8 +490,33 @@ After `expires_at_ts` passes, pulls are blocked. The subscriber can then call `r **Two-step revocation 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` if the subscriber changes their mind before revocation - `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 | + +**Parameters:** None (only discriminator byte) + +**Validation:** + +1. Verify the plan account is still program-owned (else `PlanClosed`) +2. Verify caller is the subscription's delegator (else `Unauthorized`) +3. Verify subscription's delegatee matches the plan PDA (else `SubscriptionPlanMismatch`) +4. Verify `expires_at_ts != 0` (else `SubscriptionNotCancelled`) + +**Process:** + +1. Clear `expires_at_ts` to `0` +2. Leave `current_period_start_ts` and `amount_pulled_in_period` unchanged + --- ## Sequence Diagrams @@ -554,7 +580,8 @@ sequenceDiagram Cancellation is a two-step process: 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. +3. **`revoke_delegation`** — Closes the subscription account and reclaims rent. Only allowed after `expires_at_ts` is in the past. ```mermaid sequenceDiagram @@ -568,6 +595,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) diff --git a/idl/subscriptions.json b/idl/subscriptions.json index 46f0a0a..d0c9cd1 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,87 @@ ], "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" + } + ], + "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/instructions/mod.rs b/program/src/instructions/mod.rs index 29ef59c..4bcddb3 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,16 @@ 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"))]) + ))] + ResumeSubscription = 13, + #[codama(skip)] #[codama(account( name = "event_authority", @@ -291,6 +302,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 +325,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..e60656c --- /dev/null +++ b/program/src/instructions/resume_subscription.rs @@ -0,0 +1,62 @@ +use pinocchio::{error::ProgramError, AccountView, ProgramResult}; + +use crate::{ + check_and_update_version, state::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`. +/// +/// The current billing period start and pulled amount are left unchanged. +pub fn process(accounts: &mut [AccountView]) -> ProgramResult { + let accounts_struct = ResumeSubscriptionAccounts::try_from(accounts)?; + + 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()); + } + + subscription.expires_at_ts = 0; + + 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, +} + +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] = accounts else { + return Err(SubscriptionsError::NotEnoughAccountKeys.into()); + }; + + SignerAccount::check(subscriber)?; + if !plan_pda.owned_by(&crate::ID) { + return Err(SubscriptionsError::PlanClosed.into()); + } + ProgramAccount::check(subscription_pda)?; + WritableAccount::check(subscription_pda)?; + + Ok(Self { subscriber, plan_pda, subscription_pda }) + } +} 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..1968d79 --- /dev/null +++ b/tests/integration-tests/src/test_resume_subscription.rs @@ -0,0 +1,109 @@ +use crate::{ + state::{header::VERSION_OFFSET, subscription_delegation::SubscriptionDelegation}, + tests::{ + asserts::TransactionResultExt, + utils::{ + current_ts, days, hours, init_ata, init_wallet, move_clock_forward, setup_with_subscription, + CancelSubscription, CreatePlan, ResumeSubscription, TransferSubscription, + }, + }, + SubscriptionsError, +}; +use solana_signer::Signer; + +#[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_allows_transfer_after_cancelled_period_elapsed() { + 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_ok(); + + TransferSubscription::new(&mut litesvm, &merchant, alice.pubkey(), mint, subscription_pda, plan_pda) + .amount(10_000_000) + .execute() + .assert_ok(); +} + +#[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_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..89af678 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,32 @@ 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 accounts = vec![ + AccountMeta::new_readonly(self.subscriber.pubkey(), true), + AccountMeta::new_readonly(self.plan_pda, false), + AccountMeta::new(self.subscription_pda, 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) From f550c30e3fbb1c0960e477b272f0964cacf40ff5 Mon Sep 17 00:00:00 2001 From: Jo D Date: Thu, 21 May 2026 15:06:32 -0400 Subject: [PATCH 2/4] feat(webapp): add resume subscription action --- webapp/src/components/plan/plan-card.tsx | 27 ++++++- .../subscription/my-subscriptions-panel.tsx | 80 ++++++++++++++++++- .../src/hooks/use-subscriptions-mutations.ts | 24 ++++++ 3 files changed, 129 insertions(+), 2 deletions(-) diff --git a/webapp/src/components/plan/plan-card.tsx b/webapp/src/components/plan/plan-card.tsx index 720c086..be5ef0f 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,6 +663,12 @@ 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 canResumeSubscription = isCancelledSub && !isGhostSubscription; const [subDaysLeft, setSubDaysLeft] = useState(null); 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..993caef 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 && !planDeleted && !isGhostPlan && !isStale; return ( <> @@ -325,6 +375,33 @@ function SubscriptionCard({ > {cancelSubscription.isPending ? 'Unsubscribing...' : 'Unsubscribe'} + ) : canResume ? ( +
+ + +
) : (