diff --git a/apps/web/src/instructions/Subscribe.tsx b/apps/web/src/instructions/Subscribe.tsx
index 9fd3cde..e6f7e0c 100644
--- a/apps/web/src/instructions/Subscribe.tsx
+++ b/apps/web/src/instructions/Subscribe.tsx
@@ -17,6 +17,9 @@ export function Subscribe() {
const [merchant, setMerchant] = useState('');
const [planId, setPlanId] = useState('0');
const [tokenMint, setTokenMint] = useState('');
+ const [expectedAmount, setExpectedAmount] = useState('0');
+ const [expectedPeriodHours, setExpectedPeriodHours] = useState('0');
+ const [expectedCreatedAt, setExpectedCreatedAt] = useState('0');
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
@@ -27,6 +30,9 @@ export function Subscribe() {
const { instructions, subscriptionPda } = await buildSubscribe({
subscriber: signer, merchant: merchant.trim() as Address,
planId: BigInt(planId), tokenMint: tokenMint.trim() as Address,
+ expectedAmount: BigInt(expectedAmount),
+ expectedPeriodHours: BigInt(expectedPeriodHours),
+ expectedCreatedAt: BigInt(expectedCreatedAt),
programAddress: getProgramAddress(),
});
@@ -46,6 +52,12 @@ export function Subscribe() {
+
+
+
diff --git a/clients/typescript/src/client.ts b/clients/typescript/src/client.ts
index 7593c48..a9988db 100644
--- a/clients/typescript/src/client.ts
+++ b/clients/typescript/src/client.ts
@@ -6,7 +6,10 @@ import {
} from './accounts/delegations.js';
import { fetchPlansForOwner } from './accounts/plans.js';
import type { PlanStatus } from './generated/index.js';
-import { fetchMaybeSubscriptionAuthority } from './generated/index.js';
+import {
+ fetchMaybeSubscriptionAuthority,
+ fetchPlan,
+} from './generated/index.js';
import {
buildCloseSubscriptionAuthority,
buildCreateFixedDelegation,
@@ -29,7 +32,7 @@ import {
buildTransferRecurring,
buildTransferSubscription,
} from './instructions/transfer.js';
-import { getSubscriptionAuthorityPDA } from './pdas.js';
+import { getPlanPDA, getSubscriptionAuthorityPDA } from './pdas.js';
import type { SolanaClient, TransactionResult } from './types/common.js';
import type { Delegation } from './types/delegation.js';
import type { PlanWithAddress } from './types/plan.js';
@@ -319,9 +322,32 @@ export class SubscriptionsClient {
merchant: Address;
planId: number | bigint;
tokenMint: Address;
+ /** Plan terms the subscriber consents to. If omitted, the live plan is
+ * fetched and its current terms are used; the program still rejects on
+ * mismatch at submit time. */
+ expectedAmount?: number | bigint;
+ expectedPeriodHours?: number | bigint;
+ expectedCreatedAt?: number | bigint;
payer?: TransactionSigner;
}): Promise {
- const { instructions, subscriptionPda } = await buildSubscribe(params);
+ let { expectedAmount, expectedPeriodHours, expectedCreatedAt } = params;
+ if (
+ expectedAmount === undefined ||
+ expectedPeriodHours === undefined ||
+ expectedCreatedAt === undefined
+ ) {
+ const [planPda] = await getPlanPDA(params.merchant, params.planId);
+ const plan = await fetchPlan(this.client.rpc, planPda);
+ expectedAmount ??= plan.data.data.terms.amount;
+ expectedPeriodHours ??= plan.data.data.terms.periodHours;
+ expectedCreatedAt ??= plan.data.data.terms.createdAt;
+ }
+ const { instructions, subscriptionPda } = await buildSubscribe({
+ ...params,
+ expectedAmount,
+ expectedPeriodHours,
+ expectedCreatedAt,
+ });
const signature = await this.buildAndSendTransaction(
instructions,
params.payer ?? params.subscriber,
diff --git a/clients/typescript/src/instructions/subscription.ts b/clients/typescript/src/instructions/subscription.ts
index e2774d9..b84d8b8 100644
--- a/clients/typescript/src/instructions/subscription.ts
+++ b/clients/typescript/src/instructions/subscription.ts
@@ -31,11 +31,25 @@ export async function buildSubscribe(params: {
merchant: Address;
planId: number | bigint;
tokenMint: Address;
+ /** Plan terms the subscriber is consenting to. Caller must fetch from the
+ * live plan; the program rejects if the on-chain plan disagrees at submit. */
+ expectedAmount: number | bigint;
+ expectedPeriodHours: number | bigint;
+ expectedCreatedAt: number | bigint;
payer?: TransactionSigner;
programAddress?: Address;
}): Promise<{ instructions: Instruction[]; subscriptionPda: Address }> {
- const { subscriber, merchant, planId, tokenMint, payer, programAddress } =
- params;
+ const {
+ subscriber,
+ merchant,
+ planId,
+ tokenMint,
+ expectedAmount,
+ expectedPeriodHours,
+ expectedCreatedAt,
+ payer,
+ programAddress,
+ } = params;
const config = programAddress ? { programAddress } : undefined;
const [planPda, planBump] = await getPlanPDA(
@@ -61,7 +75,14 @@ export async function buildSubscribe(params: {
planPda,
subscriptionPda,
subscriptionAuthorityPda,
- subscribeData: { planId, planBump },
+ subscribeData: {
+ planId,
+ planBump,
+ expectedMint: tokenMint,
+ expectedAmount,
+ expectedPeriodHours,
+ expectedCreatedAt,
+ },
},
config,
);
diff --git a/clients/typescript/test/setup.ts b/clients/typescript/test/setup.ts
index b150d6b..da55220 100644
--- a/clients/typescript/test/setup.ts
+++ b/clients/typescript/test/setup.ts
@@ -35,6 +35,9 @@ export const SURFPOOL_RPC_URL = `http://127.0.0.1:${SURFPOOL_PORT}`;
export const DEFAULT_TEST_BALANCE = 1_000_000n;
export const ONE_HOUR_IN_SECONDS = 3600;
export const ONE_DAY_IN_SECONDS = 86400;
+const SYSVAR_CLOCK_ADDRESS =
+ 'SysvarC1ock11111111111111111111111111111111' as Address;
+const SYSVAR_CLOCK_UNIX_TIMESTAMP_OFFSET = 32;
type SolanaClient = ReturnType;
export type SmartWalletName = 'swig' | 'squads';
@@ -147,6 +150,7 @@ export class IntegrationTest {
*/
static async create(): Promise {
await isSurfnetRunning(); // Just verify surfpool is running
+
const solanaClient = createSolanaClient({ urlOrMoniker: 'localnet' });
const client = new SubscriptionsClient(solanaClient);
@@ -216,10 +220,17 @@ export class IntegrationTest {
}
async getValidatorTime(): Promise {
+ const clockTime = await getClockSysvarTime(this.rpc);
+ if (clockTime != null) return clockTime;
+
const slot = await this.rpc.getSlot().send();
const blockTime = await this.rpc.getBlockTime(slot).send();
- if (blockTime == null) throw new Error('blockTime is null');
- return BigInt(blockTime);
+ const wall = BigInt(Math.floor(Date.now() / 1000));
+ if (blockTime != null) {
+ const ts = BigInt(blockTime);
+ if (ts + 60n >= wall) return ts;
+ }
+ return wall;
}
async minPlanEndTs(periodHours: bigint): Promise {
@@ -229,19 +240,7 @@ export class IntegrationTest {
}
async timeTravel(targetTimestampSec: number): Promise {
- const res = await fetch(SURFPOOL_RPC_URL, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- jsonrpc: '2.0',
- id: 1,
- method: 'surfnet_timeTravel',
- params: [{ absoluteTimestamp: targetTimestampSec * 1000 }],
- }),
- });
- if (!res.ok) throw new Error(`surfnet_timeTravel failed: ${res.status}`);
- const data = (await res.json()) as { error?: { message: string } };
- if (data.error) throw new Error(data.error.message);
+ await setSurfpoolClock(targetTimestampSec);
}
private smartWalletsInitialized = false;
@@ -306,6 +305,40 @@ export class IntegrationTest {
// Private Helper Functions
// ============================================================================
+async function setSurfpoolClock(targetTimestampSec: number): Promise {
+ const res = await fetch(SURFPOOL_RPC_URL, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ jsonrpc: '2.0',
+ id: 1,
+ method: 'surfnet_timeTravel',
+ params: [{ absoluteTimestamp: targetTimestampSec * 1000 }],
+ }),
+ });
+ if (!res.ok) throw new Error(`surfnet_timeTravel failed: ${res.status}`);
+ const data = (await res.json()) as { error?: { message: string } };
+ if (data.error) throw new Error(data.error.message);
+}
+
+async function getClockSysvarTime(
+ rpc: SolanaClient['rpc'],
+): Promise {
+ const account = await rpc
+ .getAccountInfo(SYSVAR_CLOCK_ADDRESS, { encoding: 'base64' })
+ .send();
+ const encodedData = account.value?.data;
+ if (!Array.isArray(encodedData) || typeof encodedData[0] !== 'string') {
+ return null;
+ }
+
+ const clockData = Buffer.from(encodedData[0], 'base64');
+ if (clockData.length < SYSVAR_CLOCK_UNIX_TIMESTAMP_OFFSET + 8) {
+ return null;
+ }
+ return clockData.readBigInt64LE(SYSVAR_CLOCK_UNIX_TIMESTAMP_OFFSET);
+}
+
/**
* Checks that Surfpool is running and returns the RPC URL.
*
diff --git a/programs/subscriptions/idl/subscriptions.json b/programs/subscriptions/idl/subscriptions.json
index aaf6dfd..911f6ed 100644
--- a/programs/subscriptions/idl/subscriptions.json
+++ b/programs/subscriptions/idl/subscriptions.json
@@ -492,6 +492,40 @@
"format": "u8",
"kind": "numberTypeNode"
}
+ },
+ {
+ "kind": "structFieldTypeNode",
+ "name": "expectedMint",
+ "type": {
+ "kind": "publicKeyTypeNode"
+ }
+ },
+ {
+ "kind": "structFieldTypeNode",
+ "name": "expectedAmount",
+ "type": {
+ "endian": "le",
+ "format": "u64",
+ "kind": "numberTypeNode"
+ }
+ },
+ {
+ "kind": "structFieldTypeNode",
+ "name": "expectedPeriodHours",
+ "type": {
+ "endian": "le",
+ "format": "u64",
+ "kind": "numberTypeNode"
+ }
+ },
+ {
+ "kind": "structFieldTypeNode",
+ "name": "expectedCreatedAt",
+ "type": {
+ "endian": "le",
+ "format": "i64",
+ "kind": "numberTypeNode"
+ }
}
],
"kind": "structTypeNode"
diff --git a/programs/subscriptions/src/instructions/subscribe.rs b/programs/subscriptions/src/instructions/subscribe.rs
index 90ba709..71e19ae 100644
--- a/programs/subscriptions/src/instructions/subscribe.rs
+++ b/programs/subscriptions/src/instructions/subscribe.rs
@@ -8,6 +8,8 @@ use pinocchio::{
AccountView, ProgramResult,
};
+use pinocchio::Address;
+
use crate::{
event_engine::{self, EventSerialize},
events::SubscriptionCreatedEvent,
@@ -32,6 +34,13 @@ pub struct SubscribeData {
pub plan_id: u64,
/// The plan PDA's bump seed (avoids an on-chain `find_program_address` call).
pub plan_bump: u8,
+ /// Plan terms the subscriber consented to. The program rejects if the live
+ /// plan disagrees, preventing a stale signed subscribe from binding the
+ /// subscriber to terms different from what was displayed at signing time.
+ pub expected_mint: Address,
+ pub expected_amount: u64,
+ pub expected_period_hours: u64,
+ pub expected_created_at: i64,
}
impl SubscribeData {
@@ -87,6 +96,18 @@ pub fn process(accounts: &[AccountView], data: &SubscribeData) -> ProgramResult
return Err(SubscriptionsError::PlanExpired.into());
}
+ // Bind subscriber consent to the live plan terms.
+ let live_amount = plan.data.terms.amount;
+ let live_period_hours = plan.data.terms.period_hours;
+ let live_created_at = plan.data.terms.created_at;
+ if plan.data.mint != data.expected_mint
+ || live_amount != data.expected_amount
+ || live_period_hours != data.expected_period_hours
+ || live_created_at != data.expected_created_at
+ {
+ return Err(SubscriptionsError::PlanTermsMismatch.into());
+ }
+
plan_mint = plan.data.mint;
plan_terms = plan.data.terms;
}
@@ -525,4 +546,63 @@ mod tests {
.execute();
res.assert_err(SubscriptionsError::AlreadySubscribed);
}
+
+ #[test]
+ fn subscribe_rejects_stale_expected_terms() {
+ use crate::tests::{
+ constants::{PROGRAM_ID, SYSTEM_PROGRAM_ID},
+ pda::get_subscription_authority_pda,
+ utils::build_and_send_transaction,
+ };
+ use crate::{event_engine::event_authority_pda, instructions::subscribe};
+ use solana_instruction::{AccountMeta, Instruction};
+
+ let end_ts = current_ts() + days(30) as i64;
+ let (mut litesvm, alice, merchant, mint, plan_pda, plan_bump) = setup_plan(1, end_ts);
+
+ // Snapshot live terms, then submit subscribe with a stale `expected_amount`.
+ let plan_account = litesvm.get_account(&plan_pda).unwrap();
+ let plan = crate::state::Plan::load(&plan_account.data).unwrap();
+ let live_amount = plan.data.terms.amount;
+ let stale_amount = live_amount.wrapping_add(1);
+ let live_period_hours = plan.data.terms.period_hours;
+ let live_created_at = plan.data.terms.created_at;
+ let live_mint = plan.data.mint;
+
+ let (subscription_authority_pda, _) =
+ get_subscription_authority_pda(&alice.pubkey(), &mint);
+ let (subscription_pda, _) = get_subscription_pda(&plan_pda, &alice.pubkey());
+ let event_authority = Pubkey::new_from_array(event_authority_pda::ID.to_bytes());
+
+ let accounts = vec![
+ AccountMeta::new(alice.pubkey(), true),
+ AccountMeta::new_readonly(merchant.pubkey(), false),
+ AccountMeta::new_readonly(plan_pda, false),
+ AccountMeta::new(subscription_pda, false),
+ AccountMeta::new_readonly(subscription_authority_pda, false),
+ AccountMeta::new_readonly(SYSTEM_PROGRAM_ID, false),
+ AccountMeta::new_readonly(event_authority, false),
+ AccountMeta::new_readonly(PROGRAM_ID, false),
+ ];
+
+ let data = [
+ vec![*subscribe::DISCRIMINATOR],
+ 1u64.to_le_bytes().to_vec(),
+ vec![plan_bump],
+ live_mint.as_ref().to_vec(),
+ stale_amount.to_le_bytes().to_vec(),
+ live_period_hours.to_le_bytes().to_vec(),
+ live_created_at.to_le_bytes().to_vec(),
+ ]
+ .concat();
+
+ let ix = Instruction {
+ program_id: PROGRAM_ID,
+ accounts,
+ data,
+ };
+
+ let res = build_and_send_transaction(&mut litesvm, &[&alice], &alice.pubkey(), &ix);
+ res.assert_err(SubscriptionsError::PlanTermsMismatch);
+ }
}
diff --git a/programs/subscriptions/src/state/plan.rs b/programs/subscriptions/src/state/plan.rs
index bf80550..65ca2e0 100644
--- a/programs/subscriptions/src/state/plan.rs
+++ b/programs/subscriptions/src/state/plan.rs
@@ -77,7 +77,8 @@ impl Plan {
if *caller == self.owner {
return Ok(());
}
- if self.data.pullers.contains(caller) {
+ let zero = Address::default();
+ if self.data.pullers.iter().any(|p| *p != zero && p == caller) {
return Ok(());
}
Err(SubscriptionsError::Unauthorized.into())
@@ -87,12 +88,78 @@ impl Plan {
///
/// If no destinations are configured (all zero), any receiver is valid.
/// Otherwise the receiver must appear in the `destinations` whitelist.
+ /// Zero-padded slots are skipped so they cannot match a zero-owned receiver.
pub fn check_destination(&self, receiver_owner: &Address) -> Result<(), ProgramError> {
let zero = Address::default();
- let has_destinations = self.data.destinations.iter().any(|d| *d != zero);
- if has_destinations && !self.data.destinations.contains(receiver_owner) {
+ let mut configured = self.data.destinations.iter().filter(|d| **d != zero);
+ if configured.clone().next().is_some() && !configured.any(|d| d == receiver_owner) {
return Err(SubscriptionsError::UnauthorizedDestination.into());
}
Ok(())
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use core::mem::transmute;
+
+ fn make_plan(destinations: [Address; 4], pullers: [Address; 4]) -> Plan {
+ let mut bytes = vec![0u8; Plan::LEN];
+ bytes[0] = AccountDiscriminator::Plan as u8;
+ let plan = unsafe { &mut *transmute::<*mut u8, *mut Plan>(bytes.as_mut_ptr()) };
+ plan.data.destinations = destinations;
+ plan.data.pullers = pullers;
+ unsafe { core::ptr::read(plan as *const Plan) }
+ }
+
+ fn addr(byte: u8) -> Address {
+ let mut a = [0u8; 32];
+ a[0] = byte;
+ Address::from(a)
+ }
+
+ #[test]
+ fn check_destination_rejects_zero_owned_receiver_with_partial_whitelist() {
+ let merchant = addr(1);
+ let plan = make_plan(
+ [
+ merchant,
+ Address::default(),
+ Address::default(),
+ Address::default(),
+ ],
+ [Address::default(); 4],
+ );
+
+ plan.check_destination(&merchant).unwrap();
+ assert!(plan.check_destination(&Address::default()).is_err());
+ }
+
+ #[test]
+ fn check_destination_open_when_all_zero() {
+ let plan = make_plan([Address::default(); 4], [Address::default(); 4]);
+ plan.check_destination(&addr(7)).unwrap();
+ plan.check_destination(&Address::default()).unwrap();
+ }
+
+ #[test]
+ fn can_pull_rejects_zero_caller_with_partial_whitelist() {
+ let owner = addr(2);
+ let puller = addr(3);
+ let mut plan = make_plan(
+ [Address::default(); 4],
+ [
+ puller,
+ Address::default(),
+ Address::default(),
+ Address::default(),
+ ],
+ );
+ plan.owner = owner;
+
+ plan.can_pull(&owner).unwrap();
+ plan.can_pull(&puller).unwrap();
+ assert!(plan.can_pull(&Address::default()).is_err());
+ }
+}
diff --git a/programs/subscriptions/src/tests/utils.rs b/programs/subscriptions/src/tests/utils.rs
index cea255e..3cca4ed 100644
--- a/programs/subscriptions/src/tests/utils.rs
+++ b/programs/subscriptions/src/tests/utils.rs
@@ -1178,10 +1178,22 @@ impl<'a> Subscribe<'a> {
fee_payer = p.pubkey();
}
+ // Snapshot live plan terms to bind subscriber consent.
+ let plan_account = self.litesvm.get_account(&self.plan_pda).unwrap();
+ let plan = crate::state::Plan::load(&plan_account.data).unwrap();
+ let expected_amount = plan.data.terms.amount;
+ let expected_period_hours = plan.data.terms.period_hours;
+ let expected_created_at = plan.data.terms.created_at;
+ let expected_mint = plan.data.mint;
+
let data = [
vec![*subscribe::DISCRIMINATOR],
self.plan_id.to_le_bytes().to_vec(),
vec![self.plan_bump],
+ expected_mint.as_ref().to_vec(),
+ expected_amount.to_le_bytes().to_vec(),
+ expected_period_hours.to_le_bytes().to_vec(),
+ expected_created_at.to_le_bytes().to_vec(),
]
.concat();
diff --git a/webapp/src/components/delegation/active-delegations.tsx b/webapp/src/components/delegation/active-delegations.tsx
index 8be1883..5090c2b 100644
--- a/webapp/src/components/delegation/active-delegations.tsx
+++ b/webapp/src/components/delegation/active-delegations.tsx
@@ -94,6 +94,7 @@ function RevokeDelegationButton({ delegation }: RevokeDelegationButtonProps) {
try {
await revokeDelegation.mutateAsync({
delegationAccount: delegation.address,
+ payer: delegation.data.header.payer,
})
setOpen(false)
} catch {
@@ -664,8 +665,7 @@ export function ActiveDelegations({ tokenMint, isApproved, subscriptionAuthority
const handleRevokeAllStale = async () => {
if (staleDelegations.length === 0) return
await revokeMultipleDelegations.mutateAsync({
- delegationAccounts: staleDelegations.map((d) => d.address),
- tokenMint,
+ delegations: staleDelegations.map((d) => ({ address: d.address, payer: d.data.header.payer })),
})
onInitSuccess?.()
}
diff --git a/webapp/src/components/plan/plan-card.tsx b/webapp/src/components/plan/plan-card.tsx
index 2a7c499..0314b6c 100644
--- a/webapp/src/components/plan/plan-card.tsx
+++ b/webapp/src/components/plan/plan-card.tsx
@@ -463,6 +463,9 @@ function SubscribeDialog({ plan, meta, open, onOpenChange }: {
merchant: plan.owner,
planId: plan.data.planId,
tokenMint: plan.data.mint,
+ expectedAmount: plan.data.terms.amount,
+ expectedPeriodHours: plan.data.terms.periodHours,
+ expectedCreatedAt: plan.data.terms.createdAt,
}, { onSuccess: () => onOpenChange(false) })}
disabled={subscribe.isPending}
className="bg-emerald-600 hover:bg-emerald-500 text-white"
diff --git a/webapp/src/components/subscription/my-subscriptions-panel.tsx b/webapp/src/components/subscription/my-subscriptions-panel.tsx
index 27978e9..5295d27 100644
--- a/webapp/src/components/subscription/my-subscriptions-panel.tsx
+++ b/webapp/src/components/subscription/my-subscriptions-panel.tsx
@@ -88,6 +88,8 @@ function RevokeSubscriptionDialog({ item, open, onOpenChange }: {
variant="destructive"
onClick={() => revokeSubscription.mutate({
subscriptionPda: item.address,
+ planPda: item.subscription.header.delegatee,
+ payer: item.subscription.header.payer,
}, { onSuccess: () => onOpenChange(false) })}
disabled={!canRevoke || revokeSubscription.isPending}
>
@@ -125,6 +127,7 @@ function CancelAndRevokeDialog({ item, isGhostPlan, open, onOpenChange }: {
onClick={() => cancelAndRevokeSubscription.mutate({
planPda: item.subscription.header.delegatee,
subscriptionPda: item.address,
+ payer: item.subscription.header.payer,
}, { onSuccess: () => onOpenChange(false) })}
disabled={cancelAndRevokeSubscription.isPending}
>
diff --git a/webapp/src/hooks/use-subscription-authority-status.ts b/webapp/src/hooks/use-subscription-authority-status.ts
index 791dc37..f3b3390 100644
--- a/webapp/src/hooks/use-subscription-authority-status.ts
+++ b/webapp/src/hooks/use-subscription-authority-status.ts
@@ -9,6 +9,7 @@ import { useProgramAddress } from '@/hooks/use-token-config'
export interface SubscriptionAuthorityData {
owner: string
tokenMint: string
+ payer: string
bump: number
initId: bigint
}
diff --git a/webapp/src/hooks/use-subscriptions-mutations.ts b/webapp/src/hooks/use-subscriptions-mutations.ts
index 5f52801..c6e729d 100644
--- a/webapp/src/hooks/use-subscriptions-mutations.ts
+++ b/webapp/src/hooks/use-subscriptions-mutations.ts
@@ -11,6 +11,7 @@ import {
buildCreateFixedDelegation,
buildCreateRecurringDelegation,
buildRevokeDelegation,
+ buildRevokeSubscription,
buildTransferFixed,
buildTransferRecurring,
buildTransferSubscription,
@@ -19,9 +20,12 @@ import {
buildDeletePlan,
buildSubscribe,
buildCancelSubscription,
+ fetchMaybeSubscriptionAuthority,
+ getSubscriptionAuthorityPDA,
ZERO_ADDRESS,
PlanStatus,
} from "@subscriptions/client";
+import { createSolanaRpc } from "gill";
import { useClusterConfig } from "@/hooks/use-cluster-config";
import { useWalletUiSigner } from "../components/solana/use-wallet-ui-signer";
import { useWalletTransactionSignAndSend } from "../components/solana/use-wallet-transaction-sign-and-send";
@@ -78,13 +82,23 @@ export function useSubscriptionsMutations() {
});
const closeSubscriptionAuthority = useMutation({
- mutationFn: async ({ tokenMint }: { tokenMint: string }) => {
+ mutationFn: async ({ tokenMint, payer }: { tokenMint: string; payer?: string }) => {
if (!signer) throw new Error("Wallet not connected");
if (!progId) throw new Error("Program address not configured");
+ let storedPayer = payer;
+ if (!storedPayer) {
+ const rpc = createSolanaRpc(rpcUrl);
+ const [pda] = await getSubscriptionAuthorityPDA(signer.address, address(tokenMint), progId);
+ const maybe = await fetchMaybeSubscriptionAuthority(rpc, pda);
+ if (maybe.exists) storedPayer = maybe.data.payer;
+ }
+ const receiver = storedPayer && storedPayer !== signer.address ? address(storedPayer) : undefined;
+
const { instructions } = await buildCloseSubscriptionAuthority({
user: signer,
tokenMint: address(tokenMint),
+ receiver,
programAddress: progId,
});
@@ -185,14 +199,19 @@ export function useSubscriptionsMutations() {
const revokeDelegation = useMutation({
mutationFn: async ({
delegationAccount,
+ payer,
}: {
delegationAccount: string;
+ payer: string;
}) => {
if (!signer) throw new Error("Wallet not connected");
+ const receiver = payer !== signer.address ? address(payer) : undefined;
+
const { instructions } = buildRevokeDelegation({
authority: signer,
delegationAccount: address(delegationAccount),
+ receiver,
programAddress: progId,
});
@@ -400,10 +419,16 @@ export function useSubscriptionsMutations() {
merchant,
planId,
tokenMint,
+ expectedAmount,
+ expectedPeriodHours,
+ expectedCreatedAt,
}: {
merchant: string;
planId: bigint;
tokenMint: string;
+ expectedAmount: bigint;
+ expectedPeriodHours: bigint;
+ expectedCreatedAt: bigint;
}) => {
if (!signer) throw new Error("Wallet not connected");
if (!progId) throw new Error("Program address not configured");
@@ -413,6 +438,9 @@ export function useSubscriptionsMutations() {
merchant: address(merchant),
planId,
tokenMint: address(tokenMint),
+ expectedAmount,
+ expectedPeriodHours,
+ expectedCreatedAt,
programAddress: progId,
});
@@ -457,14 +485,22 @@ export function useSubscriptionsMutations() {
const revokeSubscription = useMutation({
mutationFn: async ({
subscriptionPda,
+ planPda,
+ payer,
}: {
subscriptionPda: string;
+ planPda: string;
+ payer: string;
}) => {
if (!signer) throw new Error("Wallet not connected");
- const { instructions } = buildRevokeDelegation({
+ const receiver = payer !== signer.address ? address(payer) : undefined;
+
+ const { instructions } = buildRevokeSubscription({
authority: signer,
- delegationAccount: address(subscriptionPda),
+ subscriptionPda: address(subscriptionPda),
+ planPda: address(planPda),
+ receiver,
programAddress: progId,
});
@@ -482,13 +518,17 @@ export function useSubscriptionsMutations() {
mutationFn: async ({
planPda,
subscriptionPda,
+ payer,
}: {
planPda: string;
subscriptionPda: string;
+ payer: string;
}) => {
if (!signer) throw new Error("Wallet not connected");
if (!progId) throw new Error("Program address not configured");
+ const receiver = payer !== signer.address ? address(payer) : undefined;
+
const { instructions: cancelIxs } = await buildCancelSubscription({
subscriber: signer,
planPda: address(planPda),
@@ -496,9 +536,11 @@ export function useSubscriptionsMutations() {
programAddress: progId,
});
- const { instructions: revokeIxs } = buildRevokeDelegation({
+ const { instructions: revokeIxs } = buildRevokeSubscription({
authority: signer,
- delegationAccount: address(subscriptionPda),
+ subscriptionPda: address(subscriptionPda),
+ planPda: address(planPda),
+ receiver,
programAddress: progId,
});
@@ -685,34 +727,33 @@ export function useSubscriptionsMutations() {
});
const revokeMultipleDelegations = useMutation({
- mutationFn: async ({ delegationAccounts, tokenMint }: { delegationAccounts: string[]; tokenMint: string }) => {
+ mutationFn: async ({
+ delegations,
+ }: {
+ delegations: Array<{ address: string; payer: string }>;
+ }) => {
if (!signer) throw new Error("Wallet not connected");
if (!progId) throw new Error("Program address not configured");
- const revokeIxs = delegationAccounts.map((account) => {
+ const revokeIxs = delegations.map(({ address: account, payer }) => {
+ const receiver = payer !== signer.address ? address(payer) : undefined;
const { instructions } = buildRevokeDelegation({
authority: signer,
delegationAccount: address(account),
+ receiver,
programAddress: progId,
});
return instructions[0];
});
- const { instructions: closeIxs } = await buildCloseSubscriptionAuthority({
- user: signer,
- tokenMint: address(tokenMint),
- programAddress: progId,
- });
-
- const allIxs = [...revokeIxs, ...closeIxs];
- const batches = packInstructionBatches(allIxs, signer);
+ const batches = packInstructionBatches(revokeIxs, signer);
const signatures: string[] = [];
for (const batch of batches) {
signatures.push(await signAndSend(batch, signer));
}
- return { signatures, revoked: delegationAccounts.length };
+ return { signatures, revoked: delegations.length };
},
onSuccess: (res) => {
toast.onSuccess(res.signatures[0]);