From 4db0b8f246330b57acb98e7280ed80cce7272874 Mon Sep 17 00:00:00 2001
From: Jo D
Date: Tue, 26 May 2026 09:03:50 -0400
Subject: [PATCH 01/16] fix(webapp): bind marketplace pricing to plan mint
Display subscription plan amounts with the configured symbol and decimals for the plan mint, expose the mint in marketplace confirmation, and disable marketplace subscription actions for unsupported mints.
Finding: MULT-31
---
webapp/src/components/plan/plan-card.tsx | 89 ++++++++++++++++++++----
webapp/src/lib/token-display.ts | 40 +++++++++++
webapp/test/token-display.test.ts | 39 +++++++++++
3 files changed, 155 insertions(+), 13 deletions(-)
create mode 100644 webapp/src/lib/token-display.ts
create mode 100644 webapp/test/token-display.test.ts
diff --git a/webapp/src/components/plan/plan-card.tsx b/webapp/src/components/plan/plan-card.tsx
index 8a08d56..a725685 100644
--- a/webapp/src/components/plan/plan-card.tsx
+++ b/webapp/src/components/plan/plan-card.tsx
@@ -26,7 +26,7 @@ import {
DialogFooter,
} from '@/components/ui/dialog';
import { ZERO_ADDRESS, PlanStatus } from '@solana/subscriptions';
-import { cn, ellipsify, USDC_MULTIPLIER, fmtDate, fmtDateTime, formatPeriod, formatPeriodLabel } from '@/lib/utils';
+import { cn, ellipsify, fmtDate, fmtDateTime, formatPeriod, formatPeriodLabel } from '@/lib/utils';
import { useSubscriptionsMutations } from '@/hooks/use-subscriptions-mutations';
import { useSubscriptionAuthorityStatus } from '@/hooks/use-subscription-authority-status';
import { useTimeTravel } from '@/hooks/use-time-travel';
@@ -34,11 +34,13 @@ import { useWallet } from '@solana/connector/react';
import { address } from '@solana/kit';
import { findAssociatedTokenPda } from '@solana-program/token';
import { useClusterConfig } from '@/hooks/use-cluster-config';
+import { useTokenConfig } from '@/hooks/use-token-config';
import { resolveTokenProgram } from '@/lib/token-program';
import type { PlanItem } from '@/hooks/use-plans';
import { useMySubscriptions, useSubscriberCount } from '@/hooks/use-subscriptions';
import { PLAN_ICONS, ICON_MAP, parsePlanMeta, type PlanMeta } from '@/lib/plan-constants';
import { ExplorerLink } from '@/components/cluster/cluster-ui';
+import { formatPlanTokenAmount, resolvePlanTokenDisplay, type PlanTokenDisplay } from '@/lib/token-display';
function ImmutableField({ label, value }: { label: string; value: string }) {
return (
@@ -104,7 +106,9 @@ function EditPlanDialog({
setPullers(next);
};
- const amount = Number(plan.data.terms.amount) / USDC_MULTIPLIER;
+ const { data: tokens } = useTokenConfig();
+ const token = useMemo(() => resolvePlanTokenDisplay(plan.data.mint, tokens), [plan.data.mint, tokens]);
+ const amount = formatPlanTokenAmount(plan.data.terms.amount, token);
const activeDestinations = plan.data.destinations.filter(d => d !== ZERO_ADDRESS);
const metadataJson = useMemo(() => {
@@ -232,7 +236,7 @@ function EditPlanDialog({
-
+
@@ -444,11 +448,13 @@ function DeletePlanDialog({
function SubscribeDialog({
plan,
meta,
+ token,
open,
onOpenChange,
}: {
plan: PlanItem;
meta: PlanMeta;
+ token: PlanTokenDisplay;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
@@ -460,7 +466,7 @@ function SubscribeDialog({
} = useSubscriptionAuthorityStatus(plan.data.mint);
const { account } = useWallet();
const { url: rpcUrl } = useClusterConfig();
- const amount = Number(plan.data.terms.amount) / USDC_MULTIPLIER;
+ const amount = formatPlanTokenAmount(plan.data.terms.amount, token);
const handleInit = async () => {
if (!account) return;
@@ -487,11 +493,30 @@ function SubscribeDialog({
Subscribe to: {meta.n || 'Unnamed'}
- ${amount} / {formatPeriod(plan.data.terms.periodHours)} from merchant {ellipsify(plan.owner, 4)}
+ {amount} / {formatPeriod(plan.data.terms.periodHours)} from merchant {ellipsify(plan.owner, 4)}
- {statusLoading ? (
+
+
+ Token
+ {token.symbol}
+
+
+ Mint
+
+
+
+
+ {!token.supported ? (
+
+ This plan uses a token that is not configured for the selected network. Subscribing is disabled.
+
+ ) : statusLoading ? (
Checking wallet status...
@@ -672,9 +697,11 @@ export function PlanCard({
const canResumeSubscription = isCancelledSub && !isGhostSubscription && subDaysLeft !== null && subDaysLeft > 0;
const meta = useMemo(() => parsePlanMeta(plan.data.metadataUri), [plan.data.metadataUri]);
+ const { data: tokens } = useTokenConfig();
+ const token = useMemo(() => resolvePlanTokenDisplay(plan.data.mint, tokens), [plan.data.mint, tokens]);
const Icon = (meta.i && ICON_MAP[meta.i]) || Star;
- const amount = Number(plan.data.terms.amount) / USDC_MULTIPLIER;
+ const amount = formatPlanTokenAmount(plan.data.terms.amount, token);
const period = formatPeriod(plan.data.terms.periodHours);
const activeDestinations = plan.data.destinations.filter(d => d !== ZERO_ADDRESS).length;
const activePullers = plan.data.pullers.filter(p => p !== ZERO_ADDRESS).length;
@@ -682,6 +709,7 @@ export function PlanCard({
const { getCurrentTimestamp } = useTimeTravel();
const hasExpiry = Number(plan.data.endTs) > 0;
const isSunset = plan.status === PlanStatus.Sunset;
+ const hasUnsupportedMarketplaceMint = variant === 'marketplace' && !token.supported;
const [planExpired, setIsExpired] = useState(false);
const [sunsetIntensity, setSunsetIntensity] = useState(0);
@@ -777,13 +805,28 @@ export function PlanCard({
-
-
- ${amount}
+
+
+ {amount}
/{period}
-
+
+
+ {token.symbol}
+
+
{hasExpiry ? (
planExpired ? (
@@ -826,6 +869,12 @@ export function PlanCard({
{variant === 'owner' &&
}
+ {hasUnsupportedMarketplaceMint && (
+
+ This plan uses a token that is not configured for this network. Subscribing is disabled.
+
+ )}
+
{plan.address}
@@ -845,7 +894,15 @@ export function PlanCard({
{variant === 'marketplace' && (
- {canResumeSubscription && matchingSub ? (
+ {hasUnsupportedMarketplaceMint ? (
+
+ Unsupported Token
+
+ ) : canResumeSubscription && matchingSub ? (
{
@@ -934,7 +991,13 @@ export function PlanCard({
>
)}
{variant === 'marketplace' && (
-
+
)}
>
);
diff --git a/webapp/src/lib/token-display.ts b/webapp/src/lib/token-display.ts
new file mode 100644
index 0000000..e0da3f7
--- /dev/null
+++ b/webapp/src/lib/token-display.ts
@@ -0,0 +1,40 @@
+import type { TokenConfig } from '@/config/networks';
+
+export interface PlanTokenDisplay {
+ decimals: number;
+ mint: string;
+ name: string;
+ supported: boolean;
+ symbol: string;
+}
+
+export function resolvePlanTokenDisplay(mint: string, tokens: readonly TokenConfig[] | undefined): PlanTokenDisplay {
+ const token = tokens?.find(t => t.mint === mint);
+ if (token) return { ...token, supported: true };
+
+ return {
+ decimals: 0,
+ mint,
+ name: 'Unsupported token',
+ supported: false,
+ symbol: 'Unsupported token',
+ };
+}
+
+export function formatTokenAmount(amount: bigint, decimals: number): string {
+ const divisor = 10n ** BigInt(decimals);
+ const whole = amount / divisor;
+ const fraction = amount % divisor;
+ const wholeText = whole.toLocaleString('en-US');
+
+ if (decimals === 0 || fraction === 0n) return wholeText;
+
+ const fractionText = fraction.toString().padStart(decimals, '0').replace(/0+$/, '');
+ return `${wholeText}.${fractionText}`;
+}
+
+export function formatPlanTokenAmount(amount: bigint, token: PlanTokenDisplay): string {
+ if (!token.supported) return `${amount.toLocaleString('en-US')} raw units`;
+
+ return `${formatTokenAmount(amount, token.decimals)} ${token.symbol}`;
+}
diff --git a/webapp/test/token-display.test.ts b/webapp/test/token-display.test.ts
new file mode 100644
index 0000000..9e9c9bd
--- /dev/null
+++ b/webapp/test/token-display.test.ts
@@ -0,0 +1,39 @@
+import assert from 'node:assert/strict';
+import { describe, test } from 'node:test';
+
+import { formatPlanTokenAmount, formatTokenAmount, resolvePlanTokenDisplay } from '../src/lib/token-display.ts';
+
+describe('token display', () => {
+ test('formats known token amounts using configured decimals and symbol', () => {
+ const token = resolvePlanTokenDisplay('Mint1111111111111111111111111111111111111', [
+ {
+ decimals: 6,
+ mint: 'Mint1111111111111111111111111111111111111',
+ name: 'USD Coin',
+ symbol: 'USDC',
+ },
+ ]);
+
+ assert.equal(token.supported, true);
+ assert.equal(formatPlanTokenAmount(5_250_000n, token), '5.25 USDC');
+ });
+
+ test('does not apply configured token decimals to unknown mints', () => {
+ const token = resolvePlanTokenDisplay('Alt11111111111111111111111111111111111111', [
+ {
+ decimals: 6,
+ mint: 'Mint1111111111111111111111111111111111111',
+ name: 'USD Coin',
+ symbol: 'USDC',
+ },
+ ]);
+
+ assert.equal(token.supported, false);
+ assert.equal(formatPlanTokenAmount(5_250_000n, token), '5,250,000 raw units');
+ });
+
+ test('trims insignificant decimal zeroes', () => {
+ assert.equal(formatTokenAmount(1_230_000n, 6), '1.23');
+ assert.equal(formatTokenAmount(1_000_000n, 6), '1');
+ });
+});
From 172f34d7d0685b65e8723eb7cd73d289b933592f Mon Sep 17 00:00:00 2001
From: Jo D
Date: Tue, 26 May 2026 09:18:39 -0400
Subject: [PATCH 02/16] fix(webapp): avoid replaying ambiguous payment batches
Only split and retry collection batches after pre-broadcast simulation failures. Treat ambiguous send errors as unknown batch status so the same subscription pulls are not replayed across billing boundaries.
Finding: MULT-28
---
.../test/webapp-collect-utils.test.ts | 62 ++++++++++++++++++-
webapp/src/lib/collect-utils.ts | 16 +++++
2 files changed, 76 insertions(+), 2 deletions(-)
diff --git a/clients/typescript/test/webapp-collect-utils.test.ts b/clients/typescript/test/webapp-collect-utils.test.ts
index b96ee34..ce982b1 100644
--- a/clients/typescript/test/webapp-collect-utils.test.ts
+++ b/clients/typescript/test/webapp-collect-utils.test.ts
@@ -95,7 +95,7 @@ describe('webapp collection utilities', () => {
sendInstructions: async instructions => {
sentInstructionIds.push(instructions.map(ix => ix.data[0] ?? -1));
if (instructions.includes(failingInstruction)) {
- throw new Error('insufficient funds');
+ throw new Error('Transaction simulation failed: insufficient funds');
}
return `sig-${sentInstructionIds.length}`;
},
@@ -111,9 +111,67 @@ describe('webapp collection utilities', () => {
amount: 1n,
},
reason: 'transfer-failed',
- message: 'insufficient funds',
+ message: 'Transaction simulation failed: insufficient funds',
},
]);
expect(sentInstructionIds).toEqual([[1, 2, 3], [1], [2, 3], [2], [3]]);
});
+
+ test('does not split and replay a batch after an ambiguous send error', async () => {
+ const signer = createNoopSigner(address('11111111111111111111111111111112'));
+ const sentInstructionIds: number[][] = [];
+
+ const result = await sendBatchedSubscriberInstructions({
+ feePayer: signer,
+ transfers: [
+ {
+ subscriber: {
+ subscriptionAddress: 'sub-1',
+ delegator: 'delegator-1',
+ amount: 1n,
+ },
+ instruction: instruction(1),
+ },
+ {
+ subscriber: {
+ subscriptionAddress: 'sub-2',
+ delegator: 'delegator-2',
+ amount: 1n,
+ },
+ instruction: instruction(2),
+ },
+ ],
+ sendInstructions: async instructions => {
+ sentInstructionIds.push(instructions.map(ix => ix.data[0] ?? -1));
+ throw new Error('transport lost after broadcast');
+ },
+ });
+
+ expect(result.collected).toBe(0);
+ expect(result.signatures).toEqual([]);
+ expect(result.confirmed).toEqual([]);
+ expect(result.failures).toEqual([
+ {
+ subscriber: {
+ subscriptionAddress: 'sub-1',
+ delegator: 'delegator-1',
+ amount: 1n,
+ },
+ reason: 'transfer-failed',
+ message:
+ 'Payment batch status is unknown and was not retried automatically: transport lost after broadcast',
+ },
+ {
+ subscriber: {
+ subscriptionAddress: 'sub-2',
+ delegator: 'delegator-2',
+ amount: 1n,
+ },
+ reason: 'transfer-failed',
+ message:
+ 'Payment batch status is unknown and was not retried automatically: transport lost after broadcast',
+ },
+ ]);
+ expect(sentInstructionIds).toEqual([[1, 2]]);
+ });
});
diff --git a/webapp/src/lib/collect-utils.ts b/webapp/src/lib/collect-utils.ts
index ec0ca76..acbe6e4 100644
--- a/webapp/src/lib/collect-utils.ts
+++ b/webapp/src/lib/collect-utils.ts
@@ -11,6 +11,7 @@ import { packInstructionBatches } from './tx-packer';
const SUBSCRIPTION_AUTHORITY_SEED = 'SubscriptionAuthority';
const FAILURE_CACHE_STORAGE_KEY = 'collect-payments-subscriber-failures';
+const SIMULATION_FAILURE_PREFIX = 'Transaction simulation failed:';
const addressEncoder = getAddressEncoder();
const textEncoder = new TextEncoder();
@@ -223,6 +224,17 @@ export async function sendBatchedSubscriberInstructions
Date: Tue, 26 May 2026 09:50:43 -0400
Subject: [PATCH 03/16] fix(subscription): bind subscribe to authority
generation
---
clients/typescript/src/plugin.ts | 43 ++++---
.../test/subscription-security.test.ts | 108 ++++++++++++++++++
idl/subscriptions.json | 9 ++
program/src/instructions/subscribe.rs | 4 +
tests/integration-tests/src/test_subscribe.rs | 71 ++++++++++++
.../src/utils/test_helpers.rs | 8 ++
webapp/src/components/plan/plan-card.tsx | 12 +-
.../src/hooks/use-subscriptions-mutations.ts | 3 +
8 files changed, 241 insertions(+), 17 deletions(-)
diff --git a/clients/typescript/src/plugin.ts b/clients/typescript/src/plugin.ts
index e19bd5b..ea3f7b1 100644
--- a/clients/typescript/src/plugin.ts
+++ b/clients/typescript/src/plugin.ts
@@ -236,6 +236,7 @@ export type SubscribeInput = WithProgramAddress & {
expectedAmount?: bigint | number;
expectedCreatedAt?: bigint | number;
expectedPeriodHours?: bigint | number;
+ expectedSubscriptionAuthorityInitId?: bigint | number;
merchant: Address;
payer?: TransactionSigner;
planId: bigint | number;
@@ -533,10 +534,11 @@ export async function getSubscribeOverlayInstructionAsync(input: SubscribeInput)
if (
input.expectedAmount === undefined ||
input.expectedPeriodHours === undefined ||
- input.expectedCreatedAt === undefined
+ input.expectedCreatedAt === undefined ||
+ input.expectedSubscriptionAuthorityInitId === undefined
) {
throw new Error(
- 'getSubscribeOverlayInstructionAsync requires expectedAmount, expectedPeriodHours, and expectedCreatedAt. Use the plugin client `subscriptions.instructions.subscribe(...)` to auto-fetch from the live plan.',
+ 'getSubscribeOverlayInstructionAsync requires expectedAmount, expectedPeriodHours, expectedCreatedAt, and expectedSubscriptionAuthorityInitId. Use the plugin client `subscriptions.instructions.subscribe(...)` to auto-fetch from the live plan and authority.',
);
}
const [planPda, planBump] = await findPlanPda(
@@ -557,6 +559,7 @@ export async function getSubscribeOverlayInstructionAsync(input: SubscribeInput)
expectedCreatedAt: input.expectedCreatedAt,
expectedMint: input.tokenMint,
expectedPeriodHours: input.expectedPeriodHours,
+ expectedSubscriptionAuthorityInitId: input.expectedSubscriptionAuthorityInitId,
planBump,
planId: input.planId,
},
@@ -769,13 +772,18 @@ export function subscriptionsProgram() {
addSelfPlanAndSendFunctions(
client,
(async () => {
- let { expectedAmount, expectedPeriodHours, expectedCreatedAt } = input;
+ const subscriber = input.subscriber ?? client.identity;
+ let {
+ expectedAmount,
+ expectedCreatedAt,
+ expectedPeriodHours,
+ expectedSubscriptionAuthorityInitId,
+ } = input;
if (
expectedAmount === undefined ||
expectedPeriodHours === undefined ||
expectedCreatedAt === undefined
) {
- const subscriber = input.subscriber ?? client.identity;
const [planPda] = await findPlanPda(
{ owner: input.merchant, planId: input.planId },
pdaConfig(input.programAddress),
@@ -784,22 +792,31 @@ export function subscriptionsProgram() {
expectedAmount = expectedAmount ?? plan.data.data.terms.amount;
expectedPeriodHours = expectedPeriodHours ?? plan.data.data.terms.periodHours;
expectedCreatedAt = expectedCreatedAt ?? plan.data.data.terms.createdAt;
- return await getSubscribeOverlayInstructionAsync({
- ...input,
- expectedAmount,
- expectedCreatedAt,
- expectedPeriodHours,
- payer: input.payer ?? (client.payer === client.identity ? undefined : client.payer),
- subscriber,
- });
+ }
+ if (expectedSubscriptionAuthorityInitId === undefined) {
+ const [subscriptionAuthorityPda] = await findSubscriptionAuthorityPda(
+ { tokenMint: input.tokenMint, user: subscriber.address },
+ pdaConfig(input.programAddress),
+ );
+ const subscriptionAuthority = await fetchMaybeSubscriptionAuthority(
+ c.rpc,
+ subscriptionAuthorityPda,
+ );
+ if (!subscriptionAuthority.exists) {
+ throw new Error(
+ 'SubscriptionAuthority is not initialized for this subscriber and token mint.',
+ );
+ }
+ expectedSubscriptionAuthorityInitId = subscriptionAuthority.data.initId;
}
return await getSubscribeOverlayInstructionAsync({
...input,
expectedAmount,
expectedCreatedAt,
expectedPeriodHours,
+ expectedSubscriptionAuthorityInitId,
payer: input.payer ?? (client.payer === client.identity ? undefined : client.payer),
- subscriber: input.subscriber ?? client.identity,
+ subscriber,
});
})(),
),
diff --git a/clients/typescript/test/subscription-security.test.ts b/clients/typescript/test/subscription-security.test.ts
index 669e855..1705d15 100644
--- a/clients/typescript/test/subscription-security.test.ts
+++ b/clients/typescript/test/subscription-security.test.ts
@@ -1,3 +1,12 @@
+import {
+ appendTransactionMessageInstructions,
+ createTransactionMessage,
+ getBase64EncodedWireTransaction,
+ pipe,
+ setTransactionMessageFeePayerSigner,
+ setTransactionMessageLifetimeUsingBlockhash,
+ signTransactionMessageWithSigners,
+} from '@solana/kit';
import { describe, expect, test } from 'vitest';
import {
SUBSCRIPTIONS_ERROR__ALREADY_SUBSCRIBED,
@@ -17,14 +26,113 @@ import {
import {
fetchMaybePlan,
fetchMaybeSubscriptionDelegation,
+ fetchPlan,
+ fetchSubscriptionAuthority,
fetchSubscriptionDelegation,
findPlanPda,
+ findSubscriptionAuthorityPda,
findSubscriptionDelegationPda,
PlanStatus,
} from '../src/generated/index.ts';
+import { getSubscribeOverlayInstructionAsync } from '../src/index.ts';
import { DEFAULT_TEST_BALANCE, expectProgramError, initTestSuite } from './setup.ts';
describe('Subscription Security', () => {
+ test('subscribe rejects approval signed for a previous authority generation', async () => {
+ const t = await initTestSuite();
+
+ const [planPda] = await findPlanPda({
+ owner: t.payerKeypair.address,
+ planId: 1n,
+ });
+ await t.client.subscriptions.instructions
+ .createPlan({
+ owner: t.payerKeypair,
+ planId: 1n,
+ mint: t.tokenMint,
+ amount: 500_000n,
+ periodHours: 1n,
+ endTs: 0n,
+ destinations: [],
+ pullers: [],
+ metadataUri: 'https://example.com/plan.json',
+ })
+ .sendTransaction();
+
+ const subscriber = await t.createFundedKeypair();
+ const subscriberAta = await t.createAtaWithBalance(t.tokenMint, subscriber.address, DEFAULT_TEST_BALANCE);
+ await t.client.subscriptions.instructions
+ .initSubscriptionAuthority({
+ owner: subscriber,
+ tokenMint: t.tokenMint,
+ userAta: subscriberAta,
+ tokenProgram: t.tokenProgram,
+ })
+ .sendTransaction();
+
+ const [subscriptionAuthorityPda] = await findSubscriptionAuthorityPda({
+ tokenMint: t.tokenMint,
+ user: subscriber.address,
+ });
+ const authorityBeforeClose = await fetchSubscriptionAuthority(t.rpc, subscriptionAuthorityPda);
+ const plan = await fetchPlan(t.rpc, planPda);
+ const [subscriptionPda] = await findSubscriptionDelegationPda({
+ planPda,
+ subscriber: subscriber.address,
+ });
+ const subscribeInstruction = await getSubscribeOverlayInstructionAsync({
+ subscriber,
+ merchant: t.payerKeypair.address,
+ planId: 1n,
+ tokenMint: t.tokenMint,
+ expectedAmount: plan.data.data.terms.amount,
+ expectedCreatedAt: plan.data.data.terms.createdAt,
+ expectedPeriodHours: plan.data.data.terms.periodHours,
+ expectedSubscriptionAuthorityInitId: authorityBeforeClose.data.initId,
+ });
+ const { value: latestBlockhash } = await t.rpc.getLatestBlockhash().send();
+ const signedSubscribeTransaction = await signTransactionMessageWithSigners(
+ pipe(
+ createTransactionMessage({ version: 0 }),
+ tx => setTransactionMessageFeePayerSigner(subscriber, tx),
+ tx => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx),
+ tx => appendTransactionMessageInstructions([subscribeInstruction], tx),
+ ),
+ );
+
+ await t.client.subscriptions.instructions
+ .closeSubscriptionAuthority({
+ user: subscriber,
+ tokenMint: t.tokenMint,
+ })
+ .sendTransaction();
+ await t.client.subscriptions.instructions
+ .initSubscriptionAuthority({
+ owner: subscriber,
+ tokenMint: t.tokenMint,
+ userAta: subscriberAta,
+ tokenProgram: t.tokenProgram,
+ })
+ .sendTransaction();
+
+ const authorityAfterReinit = await fetchSubscriptionAuthority(t.rpc, subscriptionAuthorityPda);
+ expect(authorityAfterReinit.data.initId).not.toBe(authorityBeforeClose.data.initId);
+
+ await expectProgramError(
+ t.rpc
+ .sendTransaction(getBase64EncodedWireTransaction(signedSubscribeTransaction), {
+ encoding: 'base64',
+ preflightCommitment: 'confirmed',
+ skipPreflight: false,
+ })
+ .send({ abortSignal: AbortSignal.timeout(30_000) }),
+ SUBSCRIPTIONS_ERROR__STALE_SUBSCRIPTION_AUTHORITY,
+ );
+
+ const maybeSubscription = await fetchMaybeSubscriptionDelegation(t.rpc, subscriptionPda);
+ expect(maybeSubscription.exists).toBe(false);
+ });
+
test('revoke blocked during grace period, allowed after expiry', async () => {
const t = await initTestSuite();
diff --git a/idl/subscriptions.json b/idl/subscriptions.json
index b54e22d..895924a 100644
--- a/idl/subscriptions.json
+++ b/idl/subscriptions.json
@@ -586,6 +586,15 @@
"format": "i64",
"kind": "numberTypeNode"
}
+ },
+ {
+ "kind": "structFieldTypeNode",
+ "name": "expectedSubscriptionAuthorityInitId",
+ "type": {
+ "endian": "le",
+ "format": "i64",
+ "kind": "numberTypeNode"
+ }
}
],
"kind": "structTypeNode"
diff --git a/program/src/instructions/subscribe.rs b/program/src/instructions/subscribe.rs
index fea1521..d596a35 100644
--- a/program/src/instructions/subscribe.rs
+++ b/program/src/instructions/subscribe.rs
@@ -42,6 +42,7 @@ pub struct SubscribeData {
pub expected_amount: u64,
pub expected_period_hours: u64,
pub expected_created_at: i64,
+ pub expected_subscription_authority_init_id: i64,
}
impl SubscribeData {
@@ -115,6 +116,9 @@ pub fn process(accounts: &mut [AccountView], data: &SubscribeData) -> ProgramRes
if subscription_authority.token_mint != plan_mint {
return Err(SubscriptionsError::MintMismatch.into());
}
+ if subscription_authority.init_id != data.expected_subscription_authority_init_id {
+ return Err(SubscriptionsError::StaleSubscriptionAuthority.into());
+ }
init_id = subscription_authority.init_id;
}
diff --git a/tests/integration-tests/src/test_subscribe.rs b/tests/integration-tests/src/test_subscribe.rs
index f38279d..27e123c 100644
--- a/tests/integration-tests/src/test_subscribe.rs
+++ b/tests/integration-tests/src/test_subscribe.rs
@@ -184,6 +184,72 @@ fn subscribe_duplicate_rejected() {
res.assert_err(SubscriptionsError::AlreadySubscribed);
}
+#[test]
+fn subscribe_rejects_stale_subscription_authority_generation() {
+ use crate::tests::{
+ constants::{PROGRAM_ID, SYSTEM_PROGRAM_ID},
+ pda::get_subscription_authority_pda,
+ utils::{build_and_send_transaction, move_clock_forward, CloseSubscriptionAuthority},
+ };
+ 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);
+
+ 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 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 authority_before_account = litesvm.get_account(&subscription_authority_pda).unwrap();
+ let authority_before = crate::state::SubscriptionAuthority::load(&authority_before_account.data).unwrap();
+ let stale_init_id = authority_before.init_id;
+
+ CloseSubscriptionAuthority::new(&mut litesvm, &alice, mint).execute().assert_ok();
+ move_clock_forward(&mut litesvm, 1);
+ initialize_subscription_authority_action(&mut litesvm, &alice, mint).0.assert_ok();
+
+ let authority_after_account = litesvm.get_account(&subscription_authority_pda).unwrap();
+ let authority_after = crate::state::SubscriptionAuthority::load(&authority_after_account.data).unwrap();
+ let new_init_id = authority_after.init_id;
+ assert_ne!(new_init_id, stale_init_id);
+
+ 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(),
+ live_amount.to_le_bytes().to_vec(),
+ live_period_hours.to_le_bytes().to_vec(),
+ live_created_at.to_le_bytes().to_vec(),
+ stale_init_id.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::StaleSubscriptionAuthority);
+}
+
#[test]
fn subscribe_rejects_stale_expected_terms() {
use crate::tests::{
@@ -207,6 +273,10 @@ fn subscribe_rejects_stale_expected_terms() {
let live_mint = plan.data.mint;
let (subscription_authority_pda, _) = get_subscription_authority_pda(&alice.pubkey(), &mint);
+ let subscription_authority_account = litesvm.get_account(&subscription_authority_pda).unwrap();
+ let subscription_authority =
+ crate::state::SubscriptionAuthority::load(&subscription_authority_account.data).unwrap();
+ let live_subscription_authority_init_id = subscription_authority.init_id;
let (subscription_pda, _) = get_subscription_pda(&plan_pda, &alice.pubkey());
let event_authority = Pubkey::new_from_array(event_authority_pda::ID.to_bytes());
@@ -229,6 +299,7 @@ fn subscribe_rejects_stale_expected_terms() {
stale_amount.to_le_bytes().to_vec(),
live_period_hours.to_le_bytes().to_vec(),
live_created_at.to_le_bytes().to_vec(),
+ live_subscription_authority_init_id.to_le_bytes().to_vec(),
]
.concat();
diff --git a/tests/integration-tests/src/utils/test_helpers.rs b/tests/integration-tests/src/utils/test_helpers.rs
index c100e0e..b301e6a 100644
--- a/tests/integration-tests/src/utils/test_helpers.rs
+++ b/tests/integration-tests/src/utils/test_helpers.rs
@@ -1001,6 +1001,13 @@ impl<'a> Subscribe<'a> {
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 expected_subscription_authority_init_id = self
+ .litesvm
+ .get_account(&subscription_authority_pda)
+ .and_then(|account| {
+ crate::state::SubscriptionAuthority::load(&account.data).ok().map(|authority| authority.init_id)
+ })
+ .unwrap_or_default();
let data = [
vec![*subscribe::DISCRIMINATOR],
@@ -1010,6 +1017,7 @@ impl<'a> Subscribe<'a> {
expected_amount.to_le_bytes().to_vec(),
expected_period_hours.to_le_bytes().to_vec(),
expected_created_at.to_le_bytes().to_vec(),
+ expected_subscription_authority_init_id.to_le_bytes().to_vec(),
]
.concat();
diff --git a/webapp/src/components/plan/plan-card.tsx b/webapp/src/components/plan/plan-card.tsx
index a725685..caa1283 100644
--- a/webapp/src/components/plan/plan-card.tsx
+++ b/webapp/src/components/plan/plan-card.tsx
@@ -460,6 +460,7 @@ function SubscribeDialog({
}) {
const { subscribe, initSubscriptionAuthority } = useSubscriptionsMutations();
const {
+ data: statusData,
isInitialized,
isLoading: statusLoading,
refetch: refetchStatus,
@@ -467,6 +468,7 @@ function SubscribeDialog({
const { account } = useWallet();
const { url: rpcUrl } = useClusterConfig();
const amount = formatPlanTokenAmount(plan.data.terms.amount, token);
+ const authorityInitId = statusData?.data?.initId;
const handleInit = async () => {
if (!account) return;
@@ -540,7 +542,8 @@ function SubscribeDialog({
Cancel
+ onClick={() => {
+ if (authorityInitId == null) return;
subscribe.mutate(
{
merchant: plan.owner,
@@ -549,11 +552,12 @@ function SubscribeDialog({
expectedAmount: plan.data.terms.amount,
expectedPeriodHours: plan.data.terms.periodHours,
expectedCreatedAt: plan.data.terms.createdAt,
+ expectedSubscriptionAuthorityInitId: authorityInitId,
},
{ onSuccess: () => onOpenChange(false) },
- )
- }
- disabled={subscribe.isPending}
+ );
+ }}
+ disabled={subscribe.isPending || authorityInitId == null}
loading={subscribe.isPending}
>
Subscribe
diff --git a/webapp/src/hooks/use-subscriptions-mutations.ts b/webapp/src/hooks/use-subscriptions-mutations.ts
index c5724b1..be1b409 100644
--- a/webapp/src/hooks/use-subscriptions-mutations.ts
+++ b/webapp/src/hooks/use-subscriptions-mutations.ts
@@ -427,10 +427,12 @@ export function useSubscriptionsMutations() {
expectedAmount,
expectedPeriodHours,
expectedCreatedAt,
+ expectedSubscriptionAuthorityInitId,
}: {
expectedAmount: bigint;
expectedCreatedAt: bigint;
expectedPeriodHours: bigint;
+ expectedSubscriptionAuthorityInitId: bigint;
merchant: string;
planId: bigint;
tokenMint: string;
@@ -442,6 +444,7 @@ export function useSubscriptionsMutations() {
expectedAmount,
expectedCreatedAt,
expectedPeriodHours,
+ expectedSubscriptionAuthorityInitId,
merchant: address(merchant),
planId,
programAddress: progId,
From 3d61f94578398c18f4cdb14dafe8f0d50ac66f0e Mon Sep 17 00:00:00 2001
From: Jo D
Date: Tue, 26 May 2026 10:18:50 -0400
Subject: [PATCH 04/16] fix(delegation): bind creation to authority generation
---
clients/typescript/src/plugin.ts | 76 ++++++++++++++++---
idl/subscriptions.json | 18 +++++
.../instructions/create_fixed_delegation.rs | 9 ++-
.../create_recurring_delegation.rs | 9 ++-
.../src/instructions/helpers/delegation.rs | 4 +
.../src/test_create_fixed_delegation.rs | 49 +++++++++++-
.../src/test_create_recurring_delegation.rs | 39 +++++++++-
.../src/utils/test_helpers.rs | 41 +++++++++-
.../delegation/create-delegation-dialog.tsx | 15 +++-
.../src/hooks/use-subscriptions-mutations.ts | 32 +++++++-
10 files changed, 271 insertions(+), 21 deletions(-)
diff --git a/clients/typescript/src/plugin.ts b/clients/typescript/src/plugin.ts
index ea3f7b1..db5a2d5 100644
--- a/clients/typescript/src/plugin.ts
+++ b/clients/typescript/src/plugin.ts
@@ -147,6 +147,7 @@ export type CreateFixedDelegationInput = WithProgramAddress & {
amount: bigint | number;
delegatee: Address;
delegator: TransactionSigner;
+ expectedSubscriptionAuthorityInitId?: bigint | number;
expiryTs: bigint | number;
nonce: bigint | number;
payer?: TransactionSigner;
@@ -157,6 +158,7 @@ export type CreateRecurringDelegationInput = WithProgramAddress & {
amountPerPeriod: bigint | number;
delegatee: Address;
delegator: TransactionSigner;
+ expectedSubscriptionAuthorityInitId?: bigint | number;
expiryTs: bigint | number;
nonce: bigint | number;
payer?: TransactionSigner;
@@ -298,6 +300,11 @@ export async function getCreateFixedDelegationOverlayInstructionAsync(
input: CreateFixedDelegationInput,
): Promise {
assertPositive(input.amount, 'amount');
+ if (input.expectedSubscriptionAuthorityInitId === undefined) {
+ throw new Error(
+ 'getCreateFixedDelegationOverlayInstructionAsync requires expectedSubscriptionAuthorityInitId. Use the plugin client `subscriptions.instructions.createFixedDelegation(...)` to auto-fetch from the live authority.',
+ );
+ }
const [subscriptionAuthority] = await findSubscriptionAuthorityPda(
{ tokenMint: input.tokenMint, user: input.delegator.address },
pdaConfig(input.programAddress),
@@ -319,6 +326,7 @@ export async function getCreateFixedDelegationOverlayInstructionAsync(
delegator: input.delegator,
fixedDelegation: {
amount: input.amount,
+ expectedSubscriptionAuthorityInitId: input.expectedSubscriptionAuthorityInitId,
expiryTs: input.expiryTs,
nonce: input.nonce,
},
@@ -335,6 +343,11 @@ export async function getCreateRecurringDelegationOverlayInstructionAsync(
): Promise {
assertPositive(input.amountPerPeriod, 'amountPerPeriod');
assertPositive(input.periodLengthS, 'periodLengthS');
+ if (input.expectedSubscriptionAuthorityInitId === undefined) {
+ throw new Error(
+ 'getCreateRecurringDelegationOverlayInstructionAsync requires expectedSubscriptionAuthorityInitId. Use the plugin client `subscriptions.instructions.createRecurringDelegation(...)` to auto-fetch from the live authority.',
+ );
+ }
const [subscriptionAuthority] = await findSubscriptionAuthorityPda(
{ tokenMint: input.tokenMint, user: input.delegator.address },
pdaConfig(input.programAddress),
@@ -356,6 +369,7 @@ export async function getCreateRecurringDelegationOverlayInstructionAsync(
delegator: input.delegator,
recurringDelegation: {
amountPerPeriod: input.amountPerPeriod,
+ expectedSubscriptionAuthorityInitId: input.expectedSubscriptionAuthorityInitId,
expiryTs: input.expiryTs,
nonce: input.nonce,
periodLengthS: input.periodLengthS,
@@ -684,6 +698,26 @@ export function subscriptionsProgram() {
plansForOwner: owner => fetchPlansForOwner(c.rpc, owner),
};
+ const resolveExpectedSubscriptionAuthorityInitId = async (
+ tokenMint: Address,
+ user: Address,
+ programAddress: Address | undefined,
+ expectedSubscriptionAuthorityInitId: bigint | number | undefined,
+ ) => {
+ if (expectedSubscriptionAuthorityInitId !== undefined) {
+ return expectedSubscriptionAuthorityInitId;
+ }
+ const [subscriptionAuthorityPda] = await findSubscriptionAuthorityPda(
+ { tokenMint, user },
+ pdaConfig(programAddress),
+ );
+ const subscriptionAuthority = await fetchMaybeSubscriptionAuthority(c.rpc, subscriptionAuthorityPda);
+ if (!subscriptionAuthority.exists) {
+ throw new Error('SubscriptionAuthority is not initialized for this delegator and token mint.');
+ }
+ return subscriptionAuthority.data.initId;
+ };
+
const instructions: SubscriptionsPluginInstructions = {
cancelSubscription: input =>
addSelfPlanAndSendFunctions(
@@ -704,11 +738,22 @@ export function subscriptionsProgram() {
createFixedDelegation: input =>
addSelfPlanAndSendFunctions(
client,
- getCreateFixedDelegationOverlayInstructionAsync({
- ...input,
- delegator: input.delegator ?? client.identity,
- payer: input.payer ?? (client.payer === client.identity ? undefined : client.payer),
- }),
+ (async () => {
+ const delegator = input.delegator ?? client.identity;
+ const expectedSubscriptionAuthorityInitId =
+ await resolveExpectedSubscriptionAuthorityInitId(
+ input.tokenMint,
+ delegator.address,
+ input.programAddress,
+ input.expectedSubscriptionAuthorityInitId,
+ );
+ return await getCreateFixedDelegationOverlayInstructionAsync({
+ ...input,
+ delegator,
+ expectedSubscriptionAuthorityInitId,
+ payer: input.payer ?? (client.payer === client.identity ? undefined : client.payer),
+ });
+ })(),
),
createPlan: input =>
addSelfPlanAndSendFunctions(
@@ -721,11 +766,22 @@ export function subscriptionsProgram() {
createRecurringDelegation: input =>
addSelfPlanAndSendFunctions(
client,
- getCreateRecurringDelegationOverlayInstructionAsync({
- ...input,
- delegator: input.delegator ?? client.identity,
- payer: input.payer ?? (client.payer === client.identity ? undefined : client.payer),
- }),
+ (async () => {
+ const delegator = input.delegator ?? client.identity;
+ const expectedSubscriptionAuthorityInitId =
+ await resolveExpectedSubscriptionAuthorityInitId(
+ input.tokenMint,
+ delegator.address,
+ input.programAddress,
+ input.expectedSubscriptionAuthorityInitId,
+ );
+ return await getCreateRecurringDelegationOverlayInstructionAsync({
+ ...input,
+ delegator,
+ expectedSubscriptionAuthorityInitId,
+ payer: input.payer ?? (client.payer === client.identity ? undefined : client.payer),
+ });
+ })(),
),
deletePlan: input =>
addSelfPlanAndSendFunctions(
diff --git a/idl/subscriptions.json b/idl/subscriptions.json
index 895924a..58cebcc 100644
--- a/idl/subscriptions.json
+++ b/idl/subscriptions.json
@@ -353,6 +353,15 @@
"format": "i64",
"kind": "numberTypeNode"
}
+ },
+ {
+ "kind": "structFieldTypeNode",
+ "name": "expectedSubscriptionAuthorityInitId",
+ "type": {
+ "endian": "le",
+ "format": "i64",
+ "kind": "numberTypeNode"
+ }
}
],
"kind": "structTypeNode"
@@ -525,6 +534,15 @@
"format": "i64",
"kind": "numberTypeNode"
}
+ },
+ {
+ "kind": "structFieldTypeNode",
+ "name": "expectedSubscriptionAuthorityInitId",
+ "type": {
+ "endian": "le",
+ "format": "i64",
+ "kind": "numberTypeNode"
+ }
}
],
"kind": "structTypeNode"
diff --git a/program/src/instructions/create_fixed_delegation.rs b/program/src/instructions/create_fixed_delegation.rs
index 63b9e6a..8e21878 100644
--- a/program/src/instructions/create_fixed_delegation.rs
+++ b/program/src/instructions/create_fixed_delegation.rs
@@ -20,6 +20,8 @@ pub struct CreateFixedDelegationData {
/// Unix timestamp after which the delegation expires. Must be in the future
/// (with [`TIME_DRIFT_ALLOWED_SECS`] tolerance).
pub expiry_ts: i64,
+ /// SubscriptionAuthority generation the delegator approved.
+ pub expected_subscription_authority_init_id: i64,
}
impl CreateFixedDelegationData {
@@ -62,7 +64,12 @@ pub fn process(accounts: &mut [AccountView], call_data: &CreateFixedDelegationDa
let accounts = CreateDelegationAccounts::try_from(accounts)?;
- let (bump, init_id, mint) = create_delegation_account(&accounts, call_data.nonce, FixedDelegation::LEN)?;
+ let (bump, init_id, mint) = create_delegation_account(
+ &accounts,
+ call_data.nonce,
+ FixedDelegation::LEN,
+ call_data.expected_subscription_authority_init_id,
+ )?;
let binding = &mut accounts.delegation_account.try_borrow_mut()?;
// Set discriminator before load_mut so validation passes on freshly created account
diff --git a/program/src/instructions/create_recurring_delegation.rs b/program/src/instructions/create_recurring_delegation.rs
index d2c1f3d..c67c4a5 100644
--- a/program/src/instructions/create_recurring_delegation.rs
+++ b/program/src/instructions/create_recurring_delegation.rs
@@ -28,6 +28,8 @@ pub struct CreateRecurringDelegationData {
pub start_ts: i64,
/// Unix timestamp after which the delegation expires.
pub expiry_ts: i64,
+ /// SubscriptionAuthority generation the delegator approved.
+ pub expected_subscription_authority_init_id: i64,
}
impl CreateRecurringDelegationData {
@@ -76,7 +78,12 @@ pub fn process(accounts: &mut [AccountView], call_data: &CreateRecurringDelegati
let accounts = CreateDelegationAccounts::try_from(accounts)?;
- let (bump, init_id, mint) = create_delegation_account(&accounts, call_data.nonce, RecurringDelegation::LEN)?;
+ let (bump, init_id, mint) = create_delegation_account(
+ &accounts,
+ call_data.nonce,
+ RecurringDelegation::LEN,
+ call_data.expected_subscription_authority_init_id,
+ )?;
let binding = &mut accounts.delegation_account.try_borrow_mut()?;
// Set discriminator before load_mut so validation passes on freshly created account
diff --git a/program/src/instructions/helpers/delegation.rs b/program/src/instructions/helpers/delegation.rs
index cb5fe21..5fdabeb 100644
--- a/program/src/instructions/helpers/delegation.rs
+++ b/program/src/instructions/helpers/delegation.rs
@@ -51,6 +51,7 @@ pub fn create_delegation_account(
accounts: &CreateDelegationAccounts,
nonce: u64,
space: usize,
+ expected_subscription_authority_init_id: i64,
) -> Result<(u8, i64, Address), ProgramError> {
if accounts.delegation_account.data_len() > 0 {
return Err(SubscriptionsError::DelegationAlreadyExists.into());
@@ -62,6 +63,9 @@ pub fn create_delegation_account(
let md_data = accounts.subscription_authority.try_borrow()?;
let subscription_authority = SubscriptionAuthority::load(&md_data)?;
subscription_authority.check_owner(accounts.delegator.address())?;
+ if subscription_authority.init_id != expected_subscription_authority_init_id {
+ return Err(SubscriptionsError::StaleSubscriptionAuthority.into());
+ }
init_id = subscription_authority.init_id;
mint = subscription_authority.token_mint;
}
diff --git a/tests/integration-tests/src/test_create_fixed_delegation.rs b/tests/integration-tests/src/test_create_fixed_delegation.rs
index 01b1e9e..84cf79c 100644
--- a/tests/integration-tests/src/test_create_fixed_delegation.rs
+++ b/tests/integration-tests/src/test_create_fixed_delegation.rs
@@ -8,8 +8,8 @@ use crate::{
pda::get_delegation_pda,
utils::{
current_ts, days, get_ata_balance, init_ata, init_mint, init_wallet,
- initialize_subscription_authority_action, move_clock_forward, setup, CreateDelegation, RevokeDelegation,
- TransferDelegation,
+ initialize_subscription_authority_action, move_clock_forward, setup, CloseSubscriptionAuthority,
+ CreateDelegation, RevokeDelegation, TransferDelegation,
},
},
AccountDiscriminator, FixedDelegation, SubscriptionsError,
@@ -98,6 +98,41 @@ fn create_fixed_delegation() {
assert_eq!(del_expiry_s, expiry_ts);
}
+#[test]
+fn create_fixed_delegation_rejects_stale_subscription_authority_generation() {
+ let (litesvm, user) = &mut setup();
+ let payer = user;
+ let amount: u64 = 100_000_000;
+ let expiry_ts: i64 = current_ts() + days(1) as i64;
+ let nonce: u64 = 0;
+
+ let mint = init_mint(litesvm, TOKEN_PROGRAM_ID, MINT_DECIMALS, 1_000_000_000, Some(payer.pubkey()), &[]);
+ let _user_ata = init_ata(litesvm, mint, payer.pubkey(), 1_000_000);
+
+ let (_, subscription_authority_pda, _) = initialize_subscription_authority_action(litesvm, payer, mint);
+ let old_init_id =
+ crate::state::SubscriptionAuthority::load(&litesvm.get_account(&subscription_authority_pda).unwrap().data)
+ .unwrap()
+ .init_id;
+
+ CloseSubscriptionAuthority::new(litesvm, payer, mint).execute().assert_ok();
+ move_clock_forward(litesvm, 1);
+ initialize_subscription_authority_action(litesvm, payer, mint).0.assert_ok();
+
+ let new_init_id =
+ crate::state::SubscriptionAuthority::load(&litesvm.get_account(&subscription_authority_pda).unwrap().data)
+ .unwrap()
+ .init_id;
+ assert_ne!(old_init_id, new_init_id);
+
+ let delegatee = Pubkey::new_unique();
+ let (res, _) = CreateDelegation::new(litesvm, payer, mint, delegatee)
+ .expected_subscription_authority_init_id(old_init_id)
+ .nonce(nonce)
+ .fixed(amount, expiry_ts);
+ res.assert_err(SubscriptionsError::StaleSubscriptionAuthority);
+}
+
/// Verify that pre-funding a delegation PDA with lamports (DOS attack)
/// does not prevent the legitimate user from creating the delegation.
#[test]
@@ -273,6 +308,10 @@ fn writable_accounts_must_be_writable() {
let delegatee = Pubkey::new_unique();
let nonce: u64 = 0;
let (subscription_authority_pda, _) = get_subscription_authority_pda(&user.pubkey(), &mint);
+ let expected_subscription_authority_init_id =
+ crate::state::SubscriptionAuthority::load(&litesvm.get_account(&subscription_authority_pda).unwrap().data)
+ .unwrap()
+ .init_id;
let (delegation_pda, _) =
crate::tests::pda::get_delegation_pda(&subscription_authority_pda, &user.pubkey(), &delegatee, nonce);
@@ -294,6 +333,7 @@ fn writable_accounts_must_be_writable() {
nonce.to_le_bytes().to_vec(),
100u64.to_le_bytes().to_vec(),
(current_ts() + 1000i64).to_le_bytes().to_vec(),
+ expected_subscription_authority_init_id.to_le_bytes().to_vec(),
]
.concat();
@@ -331,6 +371,10 @@ fn signer_accounts_must_be_signers() {
let delegatee = Pubkey::new_unique();
let nonce: u64 = 0;
let (subscription_authority_pda, _) = get_subscription_authority_pda(&user.pubkey(), &mint);
+ let expected_subscription_authority_init_id =
+ crate::state::SubscriptionAuthority::load(&litesvm.get_account(&subscription_authority_pda).unwrap().data)
+ .unwrap()
+ .init_id;
let (delegation_pda, _) =
crate::tests::pda::get_delegation_pda(&subscription_authority_pda, &user.pubkey(), &delegatee, nonce);
@@ -353,6 +397,7 @@ fn signer_accounts_must_be_signers() {
nonce.to_le_bytes().to_vec(),
100u64.to_le_bytes().to_vec(),
(current_ts() + 1000i64).to_le_bytes().to_vec(),
+ expected_subscription_authority_init_id.to_le_bytes().to_vec(),
]
.concat();
diff --git a/tests/integration-tests/src/test_create_recurring_delegation.rs b/tests/integration-tests/src/test_create_recurring_delegation.rs
index 8b5cece..2fa2df2 100644
--- a/tests/integration-tests/src/test_create_recurring_delegation.rs
+++ b/tests/integration-tests/src/test_create_recurring_delegation.rs
@@ -10,7 +10,7 @@ use crate::{
constants::{MINT_DECIMALS, TOKEN_PROGRAM_ID},
utils::{
days, get_ata_balance, init_ata, init_mint, initialize_subscription_authority_action, move_clock_forward,
- setup, CreateDelegation, TransferDelegation,
+ setup, CloseSubscriptionAuthority, CreateDelegation, TransferDelegation,
},
},
AccountDiscriminator, RecurringDelegation, SubscriptionsError,
@@ -62,6 +62,43 @@ fn create_recurring_delegation() {
assert_eq!(del_current_period_start_ts, start_ts);
}
+#[test]
+fn create_recurring_delegation_rejects_stale_subscription_authority_generation() {
+ let (litesvm, user) = &mut setup();
+ let payer = user;
+ let amount_per_period: u64 = 50_000_000;
+ let period_length_s: u64 = 86400;
+ let start_ts: i64 = current_ts();
+ let expiry_ts = start_ts + days(7) as i64;
+ let nonce: u64 = 0;
+
+ let mint = init_mint(litesvm, TOKEN_PROGRAM_ID, MINT_DECIMALS, 1_000_000_000, Some(payer.pubkey()), &[]);
+ let _user_ata = init_ata(litesvm, mint, payer.pubkey(), 1_000_000);
+
+ let (_, subscription_authority_pda, _) = initialize_subscription_authority_action(litesvm, payer, mint);
+ let old_init_id =
+ crate::state::SubscriptionAuthority::load(&litesvm.get_account(&subscription_authority_pda).unwrap().data)
+ .unwrap()
+ .init_id;
+
+ CloseSubscriptionAuthority::new(litesvm, payer, mint).execute().assert_ok();
+ move_clock_forward(litesvm, 1);
+ initialize_subscription_authority_action(litesvm, payer, mint).0.assert_ok();
+
+ let new_init_id =
+ crate::state::SubscriptionAuthority::load(&litesvm.get_account(&subscription_authority_pda).unwrap().data)
+ .unwrap()
+ .init_id;
+ assert_ne!(old_init_id, new_init_id);
+
+ let delegatee = Pubkey::new_unique();
+ let (res, _) = CreateDelegation::new(litesvm, payer, mint, delegatee)
+ .expected_subscription_authority_init_id(old_init_id)
+ .nonce(nonce)
+ .recurring(amount_per_period, period_length_s, start_ts, expiry_ts);
+ res.assert_err(SubscriptionsError::StaleSubscriptionAuthority);
+}
+
#[test]
fn create_recurring_delegation_with_past_start_ts() {
let (litesvm, user) = &mut setup();
diff --git a/tests/integration-tests/src/utils/test_helpers.rs b/tests/integration-tests/src/utils/test_helpers.rs
index b301e6a..2d7b80e 100644
--- a/tests/integration-tests/src/utils/test_helpers.rs
+++ b/tests/integration-tests/src/utils/test_helpers.rs
@@ -294,11 +294,21 @@ pub struct CreateDelegation<'a> {
delegatee: Pubkey,
nonce: u64,
custom_pda: Option,
+ expected_subscription_authority_init_id: Option,
}
impl<'a> CreateDelegation<'a> {
pub fn new(litesvm: &'a mut LiteSVM, delegator: &'a Keypair, mint: Pubkey, delegatee: Pubkey) -> Self {
- Self { litesvm, delegator, payer: None, mint, delegatee, nonce: 0, custom_pda: None }
+ Self {
+ litesvm,
+ delegator,
+ payer: None,
+ mint,
+ delegatee,
+ nonce: 0,
+ custom_pda: None,
+ expected_subscription_authority_init_id: None,
+ }
}
pub fn payer(mut self, payer: &'a Keypair) -> Self {
@@ -316,11 +326,35 @@ impl<'a> CreateDelegation<'a> {
self
}
+ pub fn expected_subscription_authority_init_id(mut self, init_id: i64) -> Self {
+ self.expected_subscription_authority_init_id = Some(init_id);
+ self
+ }
+
+ fn resolved_expected_subscription_authority_init_id(&self) -> i64 {
+ self.expected_subscription_authority_init_id.unwrap_or_else(|| {
+ let (subscription_authority_pda, _) = get_subscription_authority_pda(&self.delegator.pubkey(), &self.mint);
+ self.litesvm
+ .get_account(&subscription_authority_pda)
+ .and_then(|account| {
+ crate::state::SubscriptionAuthority::load(&account.data).ok().map(|authority| authority.init_id)
+ })
+ .unwrap_or_default()
+ })
+ }
+
pub fn fixed(self, amount: u64, expiry_ts: i64) -> (TransactionResult, Pubkey) {
let nonce_bytes = self.nonce.to_le_bytes().to_vec();
+ let expected_subscription_authority_init_id = self.resolved_expected_subscription_authority_init_id();
self.execute(
*create_fixed_delegation::DISCRIMINATOR,
- [nonce_bytes, amount.to_le_bytes().to_vec(), expiry_ts.to_le_bytes().to_vec()].concat(),
+ [
+ nonce_bytes,
+ amount.to_le_bytes().to_vec(),
+ expiry_ts.to_le_bytes().to_vec(),
+ expected_subscription_authority_init_id.to_le_bytes().to_vec(),
+ ]
+ .concat(),
)
}
@@ -332,6 +366,7 @@ impl<'a> CreateDelegation<'a> {
expiry_ts: i64,
) -> (TransactionResult, Pubkey) {
let nonce_bytes = self.nonce.to_le_bytes().to_vec();
+ let expected_subscription_authority_init_id = self.resolved_expected_subscription_authority_init_id();
self.execute(
*create_recurring_delegation::DISCRIMINATOR,
[
@@ -340,6 +375,7 @@ impl<'a> CreateDelegation<'a> {
period_length_s.to_le_bytes().to_vec(),
start_ts.to_le_bytes().to_vec(),
expiry_ts.to_le_bytes().to_vec(),
+ expected_subscription_authority_init_id.to_le_bytes().to_vec(),
]
.concat(),
)
@@ -368,7 +404,6 @@ impl<'a> CreateDelegation<'a> {
fee_payer = p.pubkey();
}
- // Instruction data now includes the bump at the end
let ix = Instruction { program_id: PROGRAM_ID, accounts, data: [vec![discriminator], data].concat() };
(build_and_send_transaction(self.litesvm, &signers, &fee_payer, &ix), delegation_pda)
diff --git a/webapp/src/components/delegation/create-delegation-dialog.tsx b/webapp/src/components/delegation/create-delegation-dialog.tsx
index 7e59b7c..41dea81 100644
--- a/webapp/src/components/delegation/create-delegation-dialog.tsx
+++ b/webapp/src/components/delegation/create-delegation-dialog.tsx
@@ -5,6 +5,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, Dialog
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { useSubscriptionsMutations } from '@/hooks/use-subscriptions-mutations';
+import { useSubscriptionAuthorityStatus } from '@/hooks/use-subscription-authority-status';
import { DELEGATION_KINDS, type DelegationKindId } from '@/lib/delegation-kinds';
import { cn, USDC_MULTIPLIER, SECONDS_PER_DAY } from '@/lib/utils';
import { getBlockTimestamp } from '@/hooks/use-time-travel';
@@ -57,6 +58,8 @@ export function CreateDelegationDialog({ tokenMint, disabled }: CreateDelegation
const [periodDays, setPeriodDays] = useState('');
const { createFixedDelegation, createRecurringDelegation } = useSubscriptionsMutations();
+ const { data: authorityStatus } = useSubscriptionAuthorityStatus(tokenMint);
+ const authorityInitId = authorityStatus?.data?.initId;
const { url: rpcUrl } = useClusterConfig();
const [blockTime, setBlockTime] = useState();
@@ -105,6 +108,8 @@ export function CreateDelegationDialog({ tokenMint, disabled }: CreateDelegation
};
const handleSubmit = async () => {
+ if (authorityInitId == null) return;
+
const nonce = generateNonce();
let expiryTimestamp = 0;
if (!noExpiry) {
@@ -122,6 +127,7 @@ export function CreateDelegationDialog({ tokenMint, disabled }: CreateDelegation
nonce,
amount: amountInSmallestUnits,
expiryTs: expiryTimestamp,
+ expectedSubscriptionAuthorityInitId: authorityInitId,
},
{
onSuccess: () => {
@@ -140,6 +146,7 @@ export function CreateDelegationDialog({ tokenMint, disabled }: CreateDelegation
amountPerPeriod: amountInSmallestUnits,
periodLengthS: periodSeconds,
expiryTs: expiryTimestamp,
+ expectedSubscriptionAuthorityInitId: authorityInitId,
},
{
onSuccess: () => {
@@ -164,6 +171,7 @@ export function CreateDelegationDialog({ tokenMint, disabled }: CreateDelegation
delegatee.length <= 44 &&
amount.length > 0 &&
Number(amount) > 0 &&
+ authorityInitId != null &&
(noExpiry || expiryDate.length > 0) &&
isExpiryValid() &&
(selectedKind === 'fixed' || (periodDays.length > 0 && Number(periodDays) > 0));
@@ -171,7 +179,12 @@ export function CreateDelegationDialog({ tokenMint, disabled }: CreateDelegation
return (
- } radius="round" size="lg">
+ }
+ radius="round"
+ size="lg"
+ >
Create Delegation
diff --git a/webapp/src/hooks/use-subscriptions-mutations.ts b/webapp/src/hooks/use-subscriptions-mutations.ts
index be1b409..0e17fb1 100644
--- a/webapp/src/hooks/use-subscriptions-mutations.ts
+++ b/webapp/src/hooks/use-subscriptions-mutations.ts
@@ -53,6 +53,24 @@ export function useSubscriptionsMutations() {
const progId = programAddress ? address(programAddress) : undefined;
const resolveTokenProgramForMint = (mint: Address) => resolveTokenProgram(rpcUrl, mint);
+ const fetchCurrentAuthorityInitId = async (tokenMint: Address) => {
+ if (!signer) throw new Error('Wallet not connected');
+ if (!progId) throw new Error('Program address not configured');
+
+ const rpc = createSolanaRpc(rpcUrl);
+ const [subscriptionAuthorityPda] = await findSubscriptionAuthorityPda(
+ {
+ tokenMint,
+ user: signer.address,
+ },
+ { programAddress: progId },
+ );
+ const subscriptionAuthority = await fetchMaybeSubscriptionAuthority(rpc, subscriptionAuthorityPda);
+ if (!subscriptionAuthority.exists) {
+ throw new Error('Subscription authority is not initialized for this token mint.');
+ }
+ return subscriptionAuthority.data.initId;
+ };
const initSubscriptionAuthority = useMutation({
mutationFn: async ({
@@ -135,9 +153,11 @@ export function useSubscriptionsMutations() {
nonce,
amount,
expiryTs,
+ expectedSubscriptionAuthorityInitId,
}: {
amount: bigint | number;
delegatee: string;
+ expectedSubscriptionAuthorityInitId?: bigint | number;
expiryTs: bigint | number;
nonce: bigint | number;
tokenMint: string;
@@ -145,14 +165,17 @@ export function useSubscriptionsMutations() {
if (!signer) throw new Error('Wallet not connected');
if (!progId) throw new Error('Program address not configured');
+ const mint = address(tokenMint);
const instruction = await getCreateFixedDelegationOverlayInstructionAsync({
amount,
delegatee: address(delegatee),
delegator: signer,
+ expectedSubscriptionAuthorityInitId:
+ expectedSubscriptionAuthorityInitId ?? (await fetchCurrentAuthorityInitId(mint)),
expiryTs,
nonce,
programAddress: progId,
- tokenMint: address(tokenMint),
+ tokenMint: mint,
});
const signature = await signAndSend([instruction], signer);
@@ -174,9 +197,11 @@ export function useSubscriptionsMutations() {
periodLengthS,
expiryTs,
startTs,
+ expectedSubscriptionAuthorityInitId,
}: {
amountPerPeriod: bigint | number;
delegatee: string;
+ expectedSubscriptionAuthorityInitId?: bigint | number;
expiryTs: bigint | number;
nonce: bigint | number;
periodLengthS: bigint | number;
@@ -186,16 +211,19 @@ export function useSubscriptionsMutations() {
if (!signer) throw new Error('Wallet not connected');
if (!progId) throw new Error('Program address not configured');
+ const mint = address(tokenMint);
const instruction = await getCreateRecurringDelegationOverlayInstructionAsync({
amountPerPeriod,
delegatee: address(delegatee),
delegator: signer,
+ expectedSubscriptionAuthorityInitId:
+ expectedSubscriptionAuthorityInitId ?? (await fetchCurrentAuthorityInitId(mint)),
expiryTs,
nonce,
periodLengthS,
programAddress: progId,
startTs: startTs ?? (await getBlockTimestamp(rpcUrl)),
- tokenMint: address(tokenMint),
+ tokenMint: mint,
});
const signature = await signAndSend([instruction], signer);
From 8965c03e108cfbbff2bbca352db551145c5ae838 Mon Sep 17 00:00:00 2001
From: Jo D
Date: Tue, 26 May 2026 10:39:26 -0400
Subject: [PATCH 05/16] fix(webapp): allow configured payment tokens
---
.../components/plan/create-plan-dialog.tsx | 61 +++++++++++++++----
webapp/src/components/plan/plan-card.tsx | 26 +++-----
webapp/src/lib/token-display.ts | 33 +++++++---
webapp/test/token-display.test.ts | 18 +++++-
4 files changed, 98 insertions(+), 40 deletions(-)
diff --git a/webapp/src/components/plan/create-plan-dialog.tsx b/webapp/src/components/plan/create-plan-dialog.tsx
index 96b04ed..2996abd 100644
--- a/webapp/src/components/plan/create-plan-dialog.tsx
+++ b/webapp/src/components/plan/create-plan-dialog.tsx
@@ -5,11 +5,12 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { useSubscriptionsMutations } from '@/hooks/use-subscriptions-mutations';
-import { useUsdcMint } from '@/hooks/use-token-config';
-import { cn, USDC_MULTIPLIER } from '@/lib/utils';
+import { useTokenConfig } from '@/hooks/use-token-config';
+import { cn, ellipsify } from '@/lib/utils';
import { getBlockTimestamp } from '@/hooks/use-time-travel';
import { useClusterConfig } from '@/hooks/use-cluster-config';
import { PLAN_ICONS } from '@/lib/plan-constants';
+import { parseTokenAmount } from '@/lib/token-display';
const PLAN_TEMPLATES = [
{
@@ -72,11 +73,25 @@ export function CreatePlanDialog({ open, onOpenChange }: CreatePlanDialogProps)
const [endHour, setEndHour] = useState('12');
const [destinations, setDestinations] = useState([]);
const [pullers, setPullers] = useState([]);
+ const [selectedMint, setSelectedMint] = useState('');
const { createPlan } = useSubscriptionsMutations();
- const usdcMint = useUsdcMint();
+ const { data: tokens } = useTokenConfig();
const { url: rpcUrl } = useClusterConfig();
const [blockTime, setBlockTime] = useState();
+ const defaultToken = tokens?.[0] ?? null;
+ const selectedToken = useMemo(
+ () => tokens?.find(token => token.mint === selectedMint) ?? defaultToken,
+ [defaultToken, selectedMint, tokens],
+ );
+ const isAmountValid = useMemo(() => {
+ if (!selectedToken) return false;
+ try {
+ return parseTokenAmount(amount, selectedToken.decimals) > 0n;
+ } catch {
+ return false;
+ }
+ }, [amount, selectedToken]);
useEffect(() => {
if (open)
@@ -112,6 +127,7 @@ export function CreatePlanDialog({ open, onOpenChange }: CreatePlanDialogProps)
setEndHour('12');
setDestinations([]);
setPullers([]);
+ setSelectedMint('');
};
const handleOpenChange = (next: boolean) => {
@@ -153,17 +169,17 @@ export function CreatePlanDialog({ open, onOpenChange }: CreatePlanDialogProps)
planName.length > 0 &&
description.length > 0 &&
selectedIcon.length > 0 &&
- Number(amount) > 0 &&
+ isAmountValid &&
periodHours >= 1 &&
metadataBytes <= 128 &&
- usdcMint !== null &&
+ selectedToken !== null &&
isEndDateValid;
const handleSubmit = async () => {
- if (!usdcMint) return;
+ if (!selectedToken) return;
const planId = crypto.getRandomValues(new BigUint64Array(1))[0];
- const amountInSmallestUnits = BigInt(Math.round(Number(amount) * USDC_MULTIPLIER));
+ const amountInSmallestUnits = parseTokenAmount(amount, selectedToken.decimals);
const endTsRaw = endDate
? Math.floor(new Date(`${endDate}T${endHour.padStart(2, '0')}:00:00`).getTime() / 1000)
: 0;
@@ -175,7 +191,7 @@ export function CreatePlanDialog({ open, onOpenChange }: CreatePlanDialogProps)
await createPlan.mutateAsync(
{
planId,
- mint: usdcMint,
+ mint: selectedToken.mint,
amount: amountInSmallestUnits,
periodHours,
endTs,
@@ -336,16 +352,35 @@ export function CreatePlanDialog({ open, onOpenChange }: CreatePlanDialogProps)
id="plan-amount"
type="number"
min="0"
- step="0.01"
+ step={selectedToken?.decimals === 0 ? '1' : 'any'}
value={amount}
onChange={(e: React.ChangeEvent) => setAmount(e.target.value)}
- placeholder="9.99"
+ placeholder={selectedToken?.decimals === 0 ? '10' : '9.99'}
className="flex-1"
/>
-
- USDC
-
+ {
+ if (value) setSelectedMint(value);
+ }}
+ className="w-40 shrink-0"
+ >
+ {(tokens ?? []).map(token => (
+
+ {token.symbol}
+
+ ))}
+
+ {selectedToken ? (
+
+ {selectedToken.name} ยท {ellipsify(selectedToken.mint, 4)}
+
+ ) : (
+
+ No payment tokens are configured for this network.
+
+ )}
diff --git a/webapp/src/components/plan/plan-card.tsx b/webapp/src/components/plan/plan-card.tsx
index caa1283..41bfd9b 100644
--- a/webapp/src/components/plan/plan-card.tsx
+++ b/webapp/src/components/plan/plan-card.tsx
@@ -514,11 +514,14 @@ function SubscribeDialog({
- {!token.supported ? (
+ {token.decimals == null && (
- This plan uses a token that is not configured for the selected network. Subscribing is disabled.
+ This token is not configured for the selected network. Review the raw amount and mint before
+ subscribing.
- ) : statusLoading ? (
+ )}
+
+ {statusLoading ? (
Checking wallet status...
@@ -713,7 +716,6 @@ export function PlanCard({
const { getCurrentTimestamp } = useTimeTravel();
const hasExpiry = Number(plan.data.endTs) > 0;
const isSunset = plan.status === PlanStatus.Sunset;
- const hasUnsupportedMarketplaceMint = variant === 'marketplace' && !token.supported;
const [planExpired, setIsExpired] = useState(false);
const [sunsetIntensity, setSunsetIntensity] = useState(0);
@@ -819,7 +821,7 @@ export function PlanCard({
}
- {hasUnsupportedMarketplaceMint && (
+ {variant === 'marketplace' && token.decimals == null && (
- This plan uses a token that is not configured for this network. Subscribing is disabled.
+ This token is not configured for this network. Amounts are shown in raw units.
)}
@@ -898,15 +900,7 @@ export function PlanCard({
{variant === 'marketplace' && (
- {hasUnsupportedMarketplaceMint ? (
-
- Unsupported Token
-
- ) : canResumeSubscription && matchingSub ? (
+ {canResumeSubscription && matchingSub ? (
{
diff --git a/webapp/src/lib/token-display.ts b/webapp/src/lib/token-display.ts
index e0da3f7..2d8863a 100644
--- a/webapp/src/lib/token-display.ts
+++ b/webapp/src/lib/token-display.ts
@@ -1,23 +1,21 @@
import type { TokenConfig } from '@/config/networks';
export interface PlanTokenDisplay {
- decimals: number;
+ decimals: number | null;
mint: string;
name: string;
- supported: boolean;
symbol: string;
}
export function resolvePlanTokenDisplay(mint: string, tokens: readonly TokenConfig[] | undefined): PlanTokenDisplay {
const token = tokens?.find(t => t.mint === mint);
- if (token) return { ...token, supported: true };
+ if (token) return token;
return {
- decimals: 0,
+ decimals: null,
mint,
- name: 'Unsupported token',
- supported: false,
- symbol: 'Unsupported token',
+ name: 'Unknown token',
+ symbol: 'Unknown token',
};
}
@@ -34,7 +32,26 @@ export function formatTokenAmount(amount: bigint, decimals: number): string {
}
export function formatPlanTokenAmount(amount: bigint, token: PlanTokenDisplay): string {
- if (!token.supported) return `${amount.toLocaleString('en-US')} raw units`;
+ if (token.decimals == null) return `${amount.toLocaleString('en-US')} raw units`;
return `${formatTokenAmount(amount, token.decimals)} ${token.symbol}`;
}
+
+export function parseTokenAmount(value: string, decimals: number): bigint {
+ const trimmed = value.trim();
+ if (!/^\d+(\.\d+)?$/.test(trimmed)) {
+ throw new Error('Invalid token amount');
+ }
+
+ const [whole, fraction = ''] = trimmed.split('.');
+ const significantFraction = fraction.replace(/0+$/, '');
+ if (significantFraction.length > decimals) {
+ throw new Error(`Amount has more than ${decimals} decimal places`);
+ }
+
+ const divisor = 10n ** BigInt(decimals);
+ const wholeUnits = BigInt(whole) * divisor;
+ const fractionUnits = fraction.length === 0 ? 0n : BigInt(fraction.padEnd(decimals, '0') || '0');
+
+ return wholeUnits + fractionUnits;
+}
diff --git a/webapp/test/token-display.test.ts b/webapp/test/token-display.test.ts
index 9e9c9bd..045b4af 100644
--- a/webapp/test/token-display.test.ts
+++ b/webapp/test/token-display.test.ts
@@ -1,7 +1,12 @@
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
-import { formatPlanTokenAmount, formatTokenAmount, resolvePlanTokenDisplay } from '../src/lib/token-display.ts';
+import {
+ formatPlanTokenAmount,
+ formatTokenAmount,
+ parseTokenAmount,
+ resolvePlanTokenDisplay,
+} from '../src/lib/token-display.ts';
describe('token display', () => {
test('formats known token amounts using configured decimals and symbol', () => {
@@ -14,7 +19,7 @@ describe('token display', () => {
},
]);
- assert.equal(token.supported, true);
+ assert.equal(token.decimals, 6);
assert.equal(formatPlanTokenAmount(5_250_000n, token), '5.25 USDC');
});
@@ -28,7 +33,7 @@ describe('token display', () => {
},
]);
- assert.equal(token.supported, false);
+ assert.equal(token.decimals, null);
assert.equal(formatPlanTokenAmount(5_250_000n, token), '5,250,000 raw units');
});
@@ -36,4 +41,11 @@ describe('token display', () => {
assert.equal(formatTokenAmount(1_230_000n, 6), '1.23');
assert.equal(formatTokenAmount(1_000_000n, 6), '1');
});
+
+ test('parses token amounts without floating point math', () => {
+ assert.equal(parseTokenAmount('1.23', 6), 1_230_000n);
+ assert.equal(parseTokenAmount('1.230000', 6), 1_230_000n);
+ assert.equal(parseTokenAmount('10', 0), 10n);
+ assert.throws(() => parseTokenAmount('1.2345678', 6), /more than 6 decimal places/);
+ });
});
From 5786e40db272d3eacbf228b0d5e984788fdc47b5 Mon Sep 17 00:00:00 2001
From: Jo D
Date: Tue, 26 May 2026 10:45:15 -0400
Subject: [PATCH 06/16] test(subscription): move imports to module scope
---
tests/integration-tests/src/test_subscribe.rs | 47 ++++++-------------
1 file changed, 14 insertions(+), 33 deletions(-)
diff --git a/tests/integration-tests/src/test_subscribe.rs b/tests/integration-tests/src/test_subscribe.rs
index 27e123c..26ef239 100644
--- a/tests/integration-tests/src/test_subscribe.rs
+++ b/tests/integration-tests/src/test_subscribe.rs
@@ -1,16 +1,20 @@
use crate::{
- state::subscription_delegation::SubscriptionDelegation,
+ event_engine::event_authority_pda,
+ instructions::subscribe,
+ state::{Plan, PlanStatus, SubscriptionAuthority, SubscriptionDelegation},
tests::{
asserts::TransactionResultExt,
- constants::{MINT_DECIMALS, TOKEN_PROGRAM_ID},
- pda::{get_plan_pda, get_subscription_pda},
+ constants::{MINT_DECIMALS, PROGRAM_ID, SYSTEM_PROGRAM_ID, TOKEN_PROGRAM_ID},
+ pda::{get_plan_pda, get_subscription_authority_pda, get_subscription_pda},
utils::{
- current_ts, days, init_ata, init_mint, init_wallet, initialize_subscription_authority_action, setup,
- CreatePlan, Subscribe,
+ build_and_send_transaction, current_ts, days, init_ata, init_mint, init_wallet,
+ initialize_subscription_authority_action, move_clock_forward, setup, CloseSubscriptionAuthority,
+ CreatePlan, Subscribe, UpdatePlan,
},
},
AccountDiscriminator, SubscriptionsError,
};
+use solana_instruction::{AccountMeta, Instruction};
use solana_keypair::Keypair;
use solana_pubkey::Pubkey;
use solana_signer::Signer;
@@ -77,8 +81,6 @@ fn subscribe_plan_sunset_rejected() {
let end_ts = current_ts() + days(30) as i64;
let (mut litesvm, alice, merchant, mint, plan_pda, plan_bump) = setup_plan(1, end_ts);
- // Sunset the plan
- use crate::{state::common::PlanStatus, tests::utils::UpdatePlan};
UpdatePlan::new(&mut litesvm, &merchant, plan_pda).status(PlanStatus::Sunset).end_ts(end_ts).execute().assert_ok();
let res = Subscribe::new(&mut litesvm, &alice, merchant.pubkey(), plan_pda, 1, plan_bump, mint).execute();
@@ -90,8 +92,6 @@ fn subscribe_plan_expired_rejected() {
let end_ts = current_ts() + days(2) as i64;
let (mut litesvm, alice, merchant, mint, plan_pda, plan_bump) = setup_plan(1, end_ts);
- // Move past plan expiry
- use crate::tests::utils::move_clock_forward;
move_clock_forward(&mut litesvm, days(3));
let res = Subscribe::new(&mut litesvm, &alice, merchant.pubkey(), plan_pda, 1, plan_bump, mint).execute();
@@ -144,8 +144,6 @@ fn subscribe_no_subscription_authority_rejected() {
#[test]
fn subscribe_with_sponsor() {
- use crate::tests::utils::init_wallet;
-
let end_ts = current_ts() + days(30) as i64;
let (mut litesvm, alice, merchant, mint, plan_pda, plan_bump) = setup_plan(1, end_ts);
let sponsor = init_wallet(&mut litesvm, 10_000_000_000);
@@ -186,19 +184,11 @@ fn subscribe_duplicate_rejected() {
#[test]
fn subscribe_rejects_stale_subscription_authority_generation() {
- use crate::tests::{
- constants::{PROGRAM_ID, SYSTEM_PROGRAM_ID},
- pda::get_subscription_authority_pda,
- utils::{build_and_send_transaction, move_clock_forward, CloseSubscriptionAuthority},
- };
- 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);
let plan_account = litesvm.get_account(&plan_pda).unwrap();
- let plan = crate::state::Plan::load(&plan_account.data).unwrap();
+ let plan = Plan::load(&plan_account.data).unwrap();
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;
@@ -206,7 +196,7 @@ fn subscribe_rejects_stale_subscription_authority_generation() {
let (subscription_authority_pda, _) = get_subscription_authority_pda(&alice.pubkey(), &mint);
let authority_before_account = litesvm.get_account(&subscription_authority_pda).unwrap();
- let authority_before = crate::state::SubscriptionAuthority::load(&authority_before_account.data).unwrap();
+ let authority_before = SubscriptionAuthority::load(&authority_before_account.data).unwrap();
let stale_init_id = authority_before.init_id;
CloseSubscriptionAuthority::new(&mut litesvm, &alice, mint).execute().assert_ok();
@@ -214,7 +204,7 @@ fn subscribe_rejects_stale_subscription_authority_generation() {
initialize_subscription_authority_action(&mut litesvm, &alice, mint).0.assert_ok();
let authority_after_account = litesvm.get_account(&subscription_authority_pda).unwrap();
- let authority_after = crate::state::SubscriptionAuthority::load(&authority_after_account.data).unwrap();
+ let authority_after = SubscriptionAuthority::load(&authority_after_account.data).unwrap();
let new_init_id = authority_after.init_id;
assert_ne!(new_init_id, stale_init_id);
@@ -252,20 +242,12 @@ fn subscribe_rejects_stale_subscription_authority_generation() {
#[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 plan = 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;
@@ -274,8 +256,7 @@ fn subscribe_rejects_stale_expected_terms() {
let (subscription_authority_pda, _) = get_subscription_authority_pda(&alice.pubkey(), &mint);
let subscription_authority_account = litesvm.get_account(&subscription_authority_pda).unwrap();
- let subscription_authority =
- crate::state::SubscriptionAuthority::load(&subscription_authority_account.data).unwrap();
+ let subscription_authority = SubscriptionAuthority::load(&subscription_authority_account.data).unwrap();
let live_subscription_authority_init_id = subscription_authority.init_id;
let (subscription_pda, _) = get_subscription_pda(&plan_pda, &alice.pubkey());
let event_authority = Pubkey::new_from_array(event_authority_pda::ID.to_bytes());
From 6821ad16d9bac94b2878b0bf121a6a808d4d0840 Mon Sep 17 00:00:00 2001
From: Jo D
Date: Tue, 26 May 2026 11:02:57 -0400
Subject: [PATCH 07/16] fix(webapp): scope stale delegation cleanup by mint
---
.../delegation/active-delegations.tsx | 40 ++++++-------
webapp/src/hooks/use-delegations.ts | 8 +--
webapp/src/lib/delegation-filters.ts | 34 +++++++++++
webapp/test/delegation-filters.test.ts | 58 +++++++++++++++++++
4 files changed, 114 insertions(+), 26 deletions(-)
create mode 100644 webapp/src/lib/delegation-filters.ts
create mode 100644 webapp/test/delegation-filters.test.ts
diff --git a/webapp/src/components/delegation/active-delegations.tsx b/webapp/src/components/delegation/active-delegations.tsx
index d52adfd..5c3cb51 100644
--- a/webapp/src/components/delegation/active-delegations.tsx
+++ b/webapp/src/components/delegation/active-delegations.tsx
@@ -40,6 +40,7 @@ import { useClusterConfig } from '@/hooks/use-cluster-config';
import { getBlockTimestamp } from '@/hooks/use-time-travel';
import { useMySubscriptions } from '@/hooks/use-subscriptions';
import { getDelegationApprovalState } from '@/lib/delegation-approval-state';
+import { groupDelegationsByMint } from '@/lib/delegation-filters';
interface ActiveDelegationsProps {
tokenMint: string;
@@ -733,9 +734,12 @@ function CloseSubscriptionAuthorityDialog({
});
const [confirmText, setConfirmText] = useState('');
- const activeFixed = outgoing.fixed.filter(d => !isExpired(d.data.expiryTs, blockTime)).length;
- const activeRecurring = outgoing.recurring.filter(d => !isExpired(d.data.expiryTs, blockTime)).length;
- const activeSubscriptions = subscriptions?.filter(s => Number(s.subscription.expiresAtTs) === 0).length ?? 0;
+ const outgoingForMint = useMemo(() => groupDelegationsByMint(outgoing.all, tokenMint), [outgoing.all, tokenMint]);
+ const activeFixed = outgoingForMint.fixed.filter(d => !isExpired(d.data.expiryTs, blockTime)).length;
+ const activeRecurring = outgoingForMint.recurring.filter(d => !isExpired(d.data.expiryTs, blockTime)).length;
+ const activeSubscriptions =
+ subscriptions?.filter(s => s.plan?.data.mint === tokenMint && Number(s.subscription.expiresAtTs) === 0)
+ .length ?? 0;
const totalActive = activeFixed + activeRecurring + activeSubscriptions;
const hasActive = totalActive > 0;
@@ -835,10 +839,12 @@ export function ActiveDelegations({
const outgoing = useDelegations();
const incoming = useIncomingDelegations();
+ const outgoingForMint = useMemo(() => groupDelegationsByMint(outgoing.all, tokenMint), [outgoing.all, tokenMint]);
+ const incomingForMint = useMemo(() => groupDelegationsByMint(incoming.all, tokenMint), [incoming.all, tokenMint]);
const outgoingFiltered = useMemo(() => {
- const active = outgoing.all.filter(d => !isExpired(d.data.expiryTs, blockTime));
- const expired = outgoing.all.filter(d => isExpired(d.data.expiryTs, blockTime));
+ const active = outgoingForMint.all.filter(d => !isExpired(d.data.expiryTs, blockTime));
+ const expired = outgoingForMint.all.filter(d => isExpired(d.data.expiryTs, blockTime));
return {
active: {
all: active,
@@ -851,26 +857,18 @@ export function ActiveDelegations({
recurring: expired.filter(d => d.type === 'Recurring'),
},
};
- }, [outgoing.all, blockTime]);
-
- const incomingGrouped = useMemo(() => {
- return {
- all: incoming.all,
- fixed: incoming.all.filter(d => d.type === 'Fixed'),
- recurring: incoming.all.filter(d => d.type === 'Recurring'),
- };
- }, [incoming.all]);
+ }, [outgoingForMint, blockTime]);
const staleDelegations = useMemo(() => {
if (subscriptionAuthorityInitId == null) return [];
- return outgoing.all.filter(d => d.data.header.initId !== subscriptionAuthorityInitId);
- }, [outgoing.all, subscriptionAuthorityInitId]);
+ return outgoingForMint.all.filter(d => d.data.header.initId !== subscriptionAuthorityInitId);
+ }, [outgoingForMint.all, subscriptionAuthorityInitId]);
const { revokeMultipleDelegations } = useSubscriptionsMutations();
const approvalState = getDelegationApprovalState({
isInitialized,
isApproved,
- outgoingDelegationCount: outgoing.all.length,
+ outgoingDelegationCount: outgoingForMint.all.length,
});
const handleRevokeAllStale = async () => {
@@ -931,18 +929,18 @@ export function ActiveDelegations({
};
const renderIncomingContent = () => {
- if (incomingGrouped.all.length === 0) return ;
+ if (incomingForMint.all.length === 0) return ;
return (
setActiveTab('incoming')}
label="Delegated to Me"
- count={incomingGrouped.all.length}
+ count={incomingForMint.all.length}
subLabel="Active"
/>
d !== null);
- return {
- all,
- fixed: all.filter(d => d.type === 'Fixed'),
- recurring: all.filter(d => d.type === 'Recurring'),
- };
+ return groupDelegations(all);
}
function useDelegationsByRole(role: DelegationRole) {
diff --git a/webapp/src/lib/delegation-filters.ts b/webapp/src/lib/delegation-filters.ts
new file mode 100644
index 0000000..efd7704
--- /dev/null
+++ b/webapp/src/lib/delegation-filters.ts
@@ -0,0 +1,34 @@
+export interface DelegationFilterItem {
+ data: {
+ mint: string;
+ };
+ type: 'Fixed' | 'Recurring';
+}
+
+export interface GroupedDelegationItems {
+ all: T[];
+ fixed: T[];
+ recurring: T[];
+}
+
+export function filterDelegationsByMint(
+ delegations: readonly T[],
+ tokenMint: string,
+): T[] {
+ return delegations.filter(delegation => delegation.data.mint === tokenMint);
+}
+
+export function groupDelegations(delegations: readonly T[]): GroupedDelegationItems {
+ return {
+ all: [...delegations],
+ fixed: delegations.filter(delegation => delegation.type === 'Fixed'),
+ recurring: delegations.filter(delegation => delegation.type === 'Recurring'),
+ };
+}
+
+export function groupDelegationsByMint(
+ delegations: readonly T[],
+ tokenMint: string,
+): GroupedDelegationItems {
+ return groupDelegations(filterDelegationsByMint(delegations, tokenMint));
+}
diff --git a/webapp/test/delegation-filters.test.ts b/webapp/test/delegation-filters.test.ts
new file mode 100644
index 0000000..03f930d
--- /dev/null
+++ b/webapp/test/delegation-filters.test.ts
@@ -0,0 +1,58 @@
+import assert from 'node:assert/strict';
+import { describe, test } from 'node:test';
+
+import {
+ filterDelegationsByMint,
+ groupDelegationsByMint,
+ type DelegationFilterItem,
+} from '../src/lib/delegation-filters.ts';
+
+function delegation(
+ mint: string,
+ type: 'Fixed' | 'Recurring',
+ initId: bigint,
+): DelegationFilterItem & { initId: bigint } {
+ return {
+ data: { mint },
+ initId,
+ type,
+ };
+}
+
+describe('delegation filters', () => {
+ test('filters delegations by their own mint before stale checks', () => {
+ const usdcMint = 'USDC1111111111111111111111111111111111111';
+ const altMint = 'Alt11111111111111111111111111111111111111';
+ const delegations = [
+ delegation(usdcMint, 'Fixed', 10n),
+ delegation(altMint, 'Fixed', 20n),
+ delegation(altMint, 'Recurring', 20n),
+ ];
+
+ const usdcDelegations = filterDelegationsByMint(delegations, usdcMint);
+ const staleForUsdc = usdcDelegations.filter(item => item.initId !== 10n);
+
+ assert.deepEqual(usdcDelegations, [delegations[0]]);
+ assert.deepEqual(staleForUsdc, []);
+ });
+
+ test('groups only delegations for the selected mint', () => {
+ const usdcMint = 'USDC1111111111111111111111111111111111111';
+ const altMint = 'Alt11111111111111111111111111111111111111';
+ const delegations = [
+ delegation(usdcMint, 'Fixed', 10n),
+ delegation(usdcMint, 'Recurring', 10n),
+ delegation(altMint, 'Fixed', 20n),
+ ];
+
+ const grouped = groupDelegationsByMint(delegations, usdcMint);
+
+ assert.equal(grouped.all.length, 2);
+ assert.equal(grouped.fixed.length, 1);
+ assert.equal(grouped.recurring.length, 1);
+ assert.equal(
+ grouped.all.every(item => item.data.mint === usdcMint),
+ true,
+ );
+ });
+});
From 36557a9109e4bf5048c2f4e6d43a26be7a721f05 Mon Sep 17 00:00:00 2001
From: Jo D
Date: Tue, 26 May 2026 11:09:07 -0400
Subject: [PATCH 08/16] fix(webapp): bind local api to loopback
---
scripts/common.sh | 7 ++++++-
scripts/start-webapp.sh | 2 +-
webapp/api/server.ts | 29 +++++++++++++++--------------
3 files changed, 22 insertions(+), 16 deletions(-)
diff --git a/scripts/common.sh b/scripts/common.sh
index 0ef340f..d7a06c6 100755
--- a/scripts/common.sh
+++ b/scripts/common.sh
@@ -189,6 +189,11 @@ install_and_build_webapp() {
start_api_server() {
local project_root
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+ local api_host="${API_HOST:-127.0.0.1}"
+ local health_host="$api_host"
+ if [ "$health_host" = "0.0.0.0" ] || [ "$health_host" = "::" ]; then
+ health_host="127.0.0.1"
+ fi
pkill -f "tsx.*server.ts" 2>/dev/null || true
sleep 1
@@ -198,7 +203,7 @@ start_api_server() {
LAST_SERVICE_PID=$!
cd "$project_root"
echo " API PID: $LAST_SERVICE_PID"
- wait_for_http "http://localhost:3001/api/health" "API server" 15 '"status"' || exit 1
+ wait_for_http "http://$health_host:3001/api/health" "API server" 15 '"status"' || exit 1
}
# Start Vite dev server on :5173
diff --git a/scripts/start-webapp.sh b/scripts/start-webapp.sh
index 6fd721f..c83f323 100755
--- a/scripts/start-webapp.sh
+++ b/scripts/start-webapp.sh
@@ -50,7 +50,7 @@ echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN} Services started successfully!${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
-echo -e " ${BLUE}API Server:${NC} http://localhost:3001"
+echo -e " ${BLUE}API Server:${NC} http://${API_HOST:-127.0.0.1}:3001"
echo -e " ${BLUE}Webapp:${NC} http://localhost:5173"
echo ""
echo -e " ${YELLOW}Open http://localhost:5173 to begin setup${NC}"
diff --git a/webapp/api/server.ts b/webapp/api/server.ts
index 675b99d..ba62668 100644
--- a/webapp/api/server.ts
+++ b/webapp/api/server.ts
@@ -11,6 +11,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = join(__dirname, '../..');
const PORT = 3001;
+const HOST = process.env.API_HOST?.trim() || '127.0.0.1';
const RPC_URL = process.env.RPC_URL ?? 'http://127.0.0.1:8899';
const CONFIG_PATH = join(__dirname, '../config.json');
@@ -741,20 +742,20 @@ const server = createServer(async (req, res) => {
res.end(await response.text());
});
-server.listen(PORT, () => {
- console.log(`Subscriptions API server running on port ${PORT}`);
+server.listen(PORT, HOST, () => {
+ console.log(`Subscriptions API server running on http://${HOST}:${PORT}`);
console.log('');
console.log('Endpoints:');
- console.log(` GET http://localhost:${PORT}/api/health`);
- console.log(` GET http://localhost:${PORT}/api/config`);
- console.log(` GET http://localhost:${PORT}/api/tokens`);
- console.log(` POST http://localhost:${PORT}/api/airdrop/sol`);
- console.log(` POST http://localhost:${PORT}/api/airdrop/usdc`);
- console.log(` GET http://localhost:${PORT}/api/program/status`);
- console.log(` GET http://localhost:${PORT}/api/program/binary-info`);
- console.log(` POST http://localhost:${PORT}/api/program/prepare-deploy`);
- console.log(` POST http://localhost:${PORT}/api/setup/start-validator`);
- console.log(` GET http://localhost:${PORT}/api/setup/validator-status`);
- console.log(` POST http://localhost:${PORT}/api/setup/create-mock-usdc`);
- console.log(` POST http://localhost:${PORT}/api/setup/save-config`);
+ console.log(` GET http://${HOST}:${PORT}/api/health`);
+ console.log(` GET http://${HOST}:${PORT}/api/config`);
+ console.log(` GET http://${HOST}:${PORT}/api/tokens`);
+ console.log(` POST http://${HOST}:${PORT}/api/airdrop/sol`);
+ console.log(` POST http://${HOST}:${PORT}/api/airdrop/usdc`);
+ console.log(` GET http://${HOST}:${PORT}/api/program/status`);
+ console.log(` GET http://${HOST}:${PORT}/api/program/binary-info`);
+ console.log(` POST http://${HOST}:${PORT}/api/program/prepare-deploy`);
+ console.log(` POST http://${HOST}:${PORT}/api/setup/start-validator`);
+ console.log(` GET http://${HOST}:${PORT}/api/setup/validator-status`);
+ console.log(` POST http://${HOST}:${PORT}/api/setup/create-mock-usdc`);
+ console.log(` POST http://${HOST}:${PORT}/api/setup/save-config`);
});
From e0d333294ac859405fd83ce3a6c5480a022f9d15 Mon Sep 17 00:00:00 2001
From: Jo D
Date: Tue, 26 May 2026 11:13:20 -0400
Subject: [PATCH 09/16] fix(webapp): preserve collect all history transfers
---
.../plan/enhanced-collect-payments.tsx | 8 +----
webapp/src/lib/collection-history.ts | 7 ++--
webapp/test/collection-history.test.ts | 33 +++++++++++++++++++
3 files changed, 38 insertions(+), 10 deletions(-)
diff --git a/webapp/src/components/plan/enhanced-collect-payments.tsx b/webapp/src/components/plan/enhanced-collect-payments.tsx
index 879bf67..070c202 100644
--- a/webapp/src/components/plan/enhanced-collect-payments.tsx
+++ b/webapp/src/components/plan/enhanced-collect-payments.tsx
@@ -179,13 +179,7 @@ function CollectAllButton({
signature,
}));
addCollectionRecord(
- createSuccessRecord(
- pd.plan.address,
- planName,
- planTransfers,
- pd.currentSubscribers.length,
- planResult.total,
- ),
+ createSuccessRecord(pd.plan.address, planName, planTransfers, planResult.total, planResult.total),
);
}
diff --git a/webapp/src/lib/collection-history.ts b/webapp/src/lib/collection-history.ts
index fef32db..a101da0 100644
--- a/webapp/src/lib/collection-history.ts
+++ b/webapp/src/lib/collection-history.ts
@@ -57,14 +57,15 @@ export function createSuccessRecord(
subscribersTotal: number,
subscribersAttempted: number,
): CollectionRecord {
- const storedTransfers = transfers.slice(0, subscribersTotal).map(transfer => ({
+ const storedTransfers = transfers.map(transfer => ({
subscriptionAddress: transfer.subscriptionAddress,
amount: transfer.amount.toString(),
signature: transfer.signature,
}));
const totalAmount = storedTransfers.reduce((sum, transfer) => sum + BigInt(transfer.amount), 0n);
const subscribersCollected = storedTransfers.length;
- const attempted = Math.min(subscribersAttempted, subscribersTotal);
+ const effectiveSubscribersTotal = Math.max(subscribersTotal, subscribersAttempted, subscribersCollected);
+ const attempted = Math.min(subscribersAttempted, effectiveSubscribersTotal);
return {
id: crypto.randomUUID(),
@@ -72,7 +73,7 @@ export function createSuccessRecord(
planAddress,
planName,
subscribersCollected,
- subscribersTotal,
+ subscribersTotal: effectiveSubscribersTotal,
totalAmount: totalAmount.toString(),
transfers: storedTransfers,
status: subscribersCollected < attempted ? 'partial' : 'success',
diff --git a/webapp/test/collection-history.test.ts b/webapp/test/collection-history.test.ts
index 81711cd..97e088f 100644
--- a/webapp/test/collection-history.test.ts
+++ b/webapp/test/collection-history.test.ts
@@ -79,4 +79,37 @@ describe('collection history', () => {
assert.equal(getCollectionRecordTotalDisplayAmount(record, USDC_MULTIPLIER), 3);
assert.equal(record.status, 'partial');
});
+
+ test('keeps all collected transfers when a stale total is too low', () => {
+ const record = createSuccessRecord(
+ 'Plan111111111111111111111111111111111111111',
+ 'Growing Batch Plan',
+ [
+ {
+ subscriptionAddress: 'Sub111111111111111111111111111111111111111',
+ amount: 1n * USDC,
+ signature: 'signature-1',
+ },
+ {
+ subscriptionAddress: 'Sub222222222222222222222222222222222222222',
+ amount: 2n * USDC,
+ signature: 'signature-2',
+ },
+ {
+ subscriptionAddress: 'Sub333333333333333333333333333333333333333',
+ amount: 3n * USDC,
+ signature: 'signature-2',
+ },
+ ],
+ 2,
+ 3,
+ );
+
+ assert.equal(record.subscribersCollected, 3);
+ assert.equal(record.subscribersTotal, 3);
+ assert.equal(record.totalAmount, (6n * USDC).toString());
+ assert.equal(record.transfers?.length, 3);
+ assert.deepEqual(record.signatures, ['signature-1', 'signature-2']);
+ assert.equal(record.status, 'success');
+ });
});
From 79964875169af756ffb3f7e73ecb3b77bb55f065 Mon Sep 17 00:00:00 2001
From: Jo D
Date: Tue, 26 May 2026 11:40:41 -0400
Subject: [PATCH 10/16] fix(webapp): keep program keypair out of deploy plan
---
webapp/api/lib/deploy-builder.ts | 11 +--
webapp/api/server.ts | 28 ++++---
.../program/program-deploy-card.tsx | 20 ++++-
.../program/program-keypair-picker.tsx | 78 +++++++++++++++++++
webapp/src/hooks/use-program-deploy.ts | 26 +++++--
webapp/src/lib/api-client.ts | 8 +-
webapp/src/lib/program-keypair.ts | 24 ++++++
webapp/src/routes/setup.tsx | 29 ++++++-
webapp/test/deploy-builder.test.ts | 15 ++++
9 files changed, 209 insertions(+), 30 deletions(-)
create mode 100644 webapp/src/components/program/program-keypair-picker.tsx
create mode 100644 webapp/src/lib/program-keypair.ts
create mode 100644 webapp/test/deploy-builder.test.ts
diff --git a/webapp/api/lib/deploy-builder.ts b/webapp/api/lib/deploy-builder.ts
index 437adcc..f3dcd59 100644
--- a/webapp/api/lib/deploy-builder.ts
+++ b/webapp/api/lib/deploy-builder.ts
@@ -1,4 +1,4 @@
-import { getAddressFromPublicKey, createKeyPairFromBytes, createKeyPairFromPrivateKeyBytes } from '@solana/kit';
+import { getAddressFromPublicKey, createKeyPairFromPrivateKeyBytes } from '@solana/kit';
import crypto from 'node:crypto';
import { CHUNK_SIZE } from './bpf-loader.js';
@@ -23,7 +23,6 @@ async function generateKeypairBytes(): Promise<{ keypairBytes: Uint8Array; publi
export interface DeployPlan {
bufferKeypair: number[];
bufferAddress: string;
- programKeypair?: number[];
chunks: string[];
totalChunks: number;
programAddress: string;
@@ -42,24 +41,20 @@ function chunkSoBytes(soBytes: Uint8Array): string[] {
return chunks;
}
-export async function buildDeployPlan(soBytes: Uint8Array, programKeypairBytes: Uint8Array): Promise {
+export async function buildDeployPlan(soBytes: Uint8Array, programAddress: string): Promise {
const soHash = crypto.createHash('sha256').update(soBytes).digest('hex');
const { keypairBytes: bufferKeypairBytes, publicKey: bufferPubKey } = await generateKeypairBytes();
const bufferAddress = await getAddressFromPublicKey(bufferPubKey);
- const programKp = await createKeyPairFromBytes(programKeypairBytes);
- const programAddress = await getAddressFromPublicKey(programKp.publicKey);
-
const chunks = chunkSoBytes(soBytes);
return {
bufferKeypair: Array.from(bufferKeypairBytes),
bufferAddress: bufferAddress.toString(),
- programKeypair: Array.from(programKeypairBytes),
chunks,
totalChunks: chunks.length,
- programAddress: programAddress.toString(),
+ programAddress,
soHash,
soSize: soBytes.length,
};
diff --git a/webapp/api/server.ts b/webapp/api/server.ts
index ba62668..19b6276 100644
--- a/webapp/api/server.ts
+++ b/webapp/api/server.ts
@@ -23,7 +23,6 @@ const MIN_SOL_AIRDROP = 0.1;
const MAX_SOL_AIRDROP = 10;
const SO_PATH = join(__dirname, '../../target/deploy/subscriptions_program.so');
-const KEYPAIR_PATH = join(__dirname, '../../keys/subscriptions-keypair.json');
const PROGRAM_ADDRESS = 'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44';
@@ -255,8 +254,12 @@ async function handleBinaryInfo(): Promise {
}
}
-async function handlePrepareDeploy(body: { isUpgrade?: boolean; rpcUrl?: string }): Promise {
- const { isUpgrade, rpcUrl } = body;
+async function handlePrepareDeploy(body: {
+ isUpgrade?: boolean;
+ programAddress?: string;
+ rpcUrl?: string;
+}): Promise {
+ const { isUpgrade, programAddress, rpcUrl } = body;
try {
const soBytes = await readFile(SO_PATH);
@@ -268,9 +271,10 @@ async function handlePrepareDeploy(body: { isUpgrade?: boolean; rpcUrl?: string
const programAddr = config.networks[network]?.programAddress ?? PROGRAM_ADDRESS;
plan = await buildUpgradePlan(soBytes, programAddr);
} else {
- const keypairJson = await readFile(KEYPAIR_PATH, 'utf-8');
- const programKeypairBytes = new Uint8Array(JSON.parse(keypairJson));
- plan = await buildDeployPlan(soBytes, programKeypairBytes);
+ if (!programAddress || !BASE58_RE.test(programAddress)) {
+ return jsonResponse({ error: 'Program address required for initial deploy' }, 400);
+ }
+ plan = await buildDeployPlan(soBytes, programAddress);
}
return jsonResponse(plan);
@@ -653,10 +657,14 @@ async function handleRequest(req: Request): Promise {
if (!parseResult.success) {
response = jsonResponse({ error: parseResult.error }, 400);
} else {
- const deployBody = extractFields<{ isUpgrade?: boolean; rpcUrl?: string }>(parseResult.data, {
- isUpgrade: 'optional_boolean',
- rpcUrl: 'optional_string',
- });
+ const deployBody = extractFields<{ isUpgrade?: boolean; programAddress?: string; rpcUrl?: string }>(
+ parseResult.data,
+ {
+ isUpgrade: 'optional_boolean',
+ programAddress: 'optional_string',
+ rpcUrl: 'optional_string',
+ },
+ );
response = await handlePrepareDeploy(deployBody);
}
} else if (url.pathname === '/api/setup/start-validator' && req.method === 'POST') {
diff --git a/webapp/src/components/program/program-deploy-card.tsx b/webapp/src/components/program/program-deploy-card.tsx
index aea7a4b..843e9f1 100644
--- a/webapp/src/components/program/program-deploy-card.tsx
+++ b/webapp/src/components/program/program-deploy-card.tsx
@@ -14,8 +14,10 @@ import {
import { Button as SolanaButton, CopyButton, TextInput } from '@solana/design-system';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { ProgramKeypairPicker } from '@/components/program/program-keypair-picker';
import { useProgramStatus } from '@/hooks/use-program-status';
import { useProgramDeploy, type DeployProgress } from '@/hooks/use-program-deploy';
+import type { ProgramKeypairImport } from '@/lib/program-keypair';
import { useProgramAddress } from '@/hooks/use-token-config';
import { useClusterConfig } from '@/hooks/use-cluster-config';
import { useKitTransactionSigner, useWallet } from '@solana/connector/react';
@@ -242,6 +244,7 @@ export function ProgramDeployCard() {
const isActive = deploy.isPending;
const isUpgrade = status?.deployed ?? false;
const [lastFailedChunk, setLastFailedChunk] = useState(null);
+ const [programKeypair, setProgramKeypair] = useState(null);
const progressRef = useRef(progress);
useEffect(() => {
progressRef.current = progress;
@@ -254,7 +257,12 @@ export function ProgramDeployCard() {
const handleDeploy = (resumeFrom?: number) => {
setLastFailedChunk(null);
deploy.mutate(
- { isUpgrade, resumeFrom },
+ {
+ isUpgrade,
+ programAddress: programKeypair?.programAddress,
+ programKeypairBytes: programKeypair?.bytes,
+ resumeFrom,
+ },
{
onError: () => {
if (progressRef.current.phase === 'writing' && progressRef.current.current > 0) {
@@ -280,10 +288,18 @@ export function ProgramDeployCard() {
)}
+ {!isUpgrade && !isActive && progress.phase !== 'done' && (
+
+ )}
+
{!isActive && progress.phase !== 'done' && progress.phase !== 'error' && (
handleDeploy()}
- disabled={!!authorityMismatch}
+ disabled={!!authorityMismatch || (!isUpgrade && !programKeypair)}
iconLeft={ }
style={{ width: '100%' }}
>
diff --git a/webapp/src/components/program/program-keypair-picker.tsx b/webapp/src/components/program/program-keypair-picker.tsx
new file mode 100644
index 0000000..11a9d55
--- /dev/null
+++ b/webapp/src/components/program/program-keypair-picker.tsx
@@ -0,0 +1,78 @@
+import { Upload, XCircle } from 'lucide-react';
+import { useId, useState, type ChangeEvent } from 'react';
+import { Button } from '@/components/ui/button';
+import { parseProgramKeypairJson, type ProgramKeypairImport } from '@/lib/program-keypair';
+import { truncateAddress } from '@/lib/format';
+
+interface ProgramKeypairPickerProps {
+ disabled?: boolean;
+ onChange: (keypair: ProgramKeypairImport | null) => void;
+ value: ProgramKeypairImport | null;
+}
+
+export function ProgramKeypairPicker({ disabled, onChange, value }: ProgramKeypairPickerProps) {
+ const inputId = useId();
+ const [error, setError] = useState('');
+
+ const handleFileChange = async (event: ChangeEvent) => {
+ const file = event.currentTarget.files?.[0];
+ event.currentTarget.value = '';
+ if (!file) return;
+ setError('');
+ try {
+ onChange(await parseProgramKeypairJson(await file.text(), file.name));
+ } catch (e) {
+ onChange(null);
+ setError(e instanceof Error ? e.message : String(e));
+ }
+ };
+
+ return (
+
+
+
+
Program keypair
+ {value ? (
+
+ {value.fileName} ยท {truncateAddress(value.programAddress)}
+
+ ) : (
+
Required for initial deploy
+ )}
+
+
+ {value && (
+ {
+ onChange(null);
+ setError('');
+ }}
+ variant="ghost"
+ size="sm"
+ aria-label="Clear program keypair"
+ className="h-8 px-2 text-sand-1000 hover:text-foreground"
+ disabled={disabled}
+ >
+
+
+ )}
+
+
+
+ Select JSON
+
+
+
+
+
+ {error &&
{error}
}
+
+ );
+}
diff --git a/webapp/src/hooks/use-program-deploy.ts b/webapp/src/hooks/use-program-deploy.ts
index 4f548d1..91e4b9b 100644
--- a/webapp/src/hooks/use-program-deploy.ts
+++ b/webapp/src/hooks/use-program-deploy.ts
@@ -58,6 +58,13 @@ export interface DeployProgress {
total: number;
}
+interface DeployMutationInput {
+ isUpgrade: boolean;
+ programAddress?: string;
+ programKeypairBytes?: Uint8Array;
+ resumeFrom?: number;
+}
+
async function createKeypairSigner(bytes: Uint8Array): Promise {
const kp = await createKeyPairFromBytes(bytes);
return await createSignerFromKeyPair(kp);
@@ -89,6 +96,7 @@ export function useProgramDeploy() {
async function fetchOrResumePlan(
signer: TransactionSigner,
isUpgrade: boolean,
+ programAddress?: string,
resumeFrom?: number,
): Promise<{ bufferKpSigner: KeyPairSigner; plan: DeployPlan }> {
if (resumeFrom !== undefined && lastPlanRef.current && bufferSignerRef.current) {
@@ -97,6 +105,7 @@ export function useProgramDeploy() {
const plan = await api.program.prepareDeploy({
isUpgrade,
payerAddress: signer.address,
+ programAddress,
rpcUrl,
});
const bufferKpSigner = await createKeypairSigner(new Uint8Array(plan.bufferKeypair));
@@ -266,6 +275,7 @@ export function useProgramDeploy() {
plan: DeployPlan,
bufferKpSigner: KeyPairSigner,
isUpgrade: boolean,
+ programKeypairBytes?: Uint8Array,
) {
const freshBlockhash = (await rpc.getLatestBlockhash().send()).value;
const programAddr = address(plan.programAddress);
@@ -282,8 +292,11 @@ export function useProgramDeploy() {
);
finalTx = buildV0Tx(signer, freshBlockhash, [upgradeIx]);
} else {
- if (!plan.programKeypair) throw new Error('Program keypair required for initial deploy');
- const programKpSigner = await createKeypairSigner(new Uint8Array(plan.programKeypair));
+ if (!programKeypairBytes) throw new Error('Program keypair required for initial deploy');
+ const programKpSigner = await createKeypairSigner(programKeypairBytes);
+ if (programKpSigner.address !== programAddr) {
+ throw new Error('Program keypair does not match deploy plan address');
+ }
const programRent = await rpc.getMinimumBalanceForRentExemption(36n).send();
const createProgramIx = buildCreateAccountIx(
signer,
@@ -361,13 +374,16 @@ export function useProgramDeploy() {
});
const deploy = useMutation({
- mutationFn: async ({ isUpgrade, resumeFrom }: { isUpgrade: boolean; resumeFrom?: number }) => {
+ mutationFn: async ({ isUpgrade, programAddress, programKeypairBytes, resumeFrom }: DeployMutationInput) => {
if (!walletSigner) throw new Error('Wallet not connected');
+ if (!isUpgrade && (!programAddress || !programKeypairBytes)) {
+ throw new Error('Program keypair required for initial deploy');
+ }
const signer = walletSigner;
setProgress({ current: 0, message: 'Fetching program data...', phase: 'preparing', total: 0 });
- const { plan, bufferKpSigner } = await fetchOrResumePlan(signer, isUpgrade, resumeFrom);
+ const { plan, bufferKpSigner } = await fetchOrResumePlan(signer, isUpgrade, programAddress, resumeFrom);
lastPlanRef.current = plan;
bufferSignerRef.current = bufferKpSigner;
@@ -404,7 +420,7 @@ export function useProgramDeploy() {
total: totalChunks,
});
- const signature = await finalizeDeployment(signer, plan, bufferKpSigner, isUpgrade);
+ const signature = await finalizeDeployment(signer, plan, bufferKpSigner, isUpgrade, programKeypairBytes);
await reclaimFeePayerSol(feePayerKp, signer);
diff --git a/webapp/src/lib/api-client.ts b/webapp/src/lib/api-client.ts
index 80b3f4e..5718e48 100644
--- a/webapp/src/lib/api-client.ts
+++ b/webapp/src/lib/api-client.ts
@@ -76,7 +76,6 @@ export interface ProgramStatus {
export interface DeployPlan {
bufferKeypair: number[];
bufferAddress: string;
- programKeypair?: number[];
chunks: string[];
totalChunks: number;
programAddress: string;
@@ -147,7 +146,12 @@ export const api = {
apiClient(
`/api/program/status?programAddress=${encodeURIComponent(programAddress)}&rpcUrl=${encodeURIComponent(rpcUrl)}`,
),
- prepareDeploy: (params: { payerAddress: string; rpcUrl: string; isUpgrade: boolean }) =>
+ prepareDeploy: (params: {
+ payerAddress: string;
+ programAddress?: string;
+ rpcUrl: string;
+ isUpgrade: boolean;
+ }) =>
apiClient('/api/program/prepare-deploy', {
method: 'POST',
body: JSON.stringify(params),
diff --git a/webapp/src/lib/program-keypair.ts b/webapp/src/lib/program-keypair.ts
new file mode 100644
index 0000000..828776d
--- /dev/null
+++ b/webapp/src/lib/program-keypair.ts
@@ -0,0 +1,24 @@
+import { createKeyPairFromBytes, getAddressFromPublicKey } from '@solana/kit';
+
+export interface ProgramKeypairImport {
+ bytes: Uint8Array;
+ fileName: string;
+ programAddress: string;
+}
+
+export async function parseProgramKeypairJson(input: string, fileName = 'keypair.json'): Promise {
+ const parsed: unknown = JSON.parse(input);
+ if (!Array.isArray(parsed) || parsed.length !== 64) {
+ throw new Error('Expected a Solana keypair JSON array with 64 bytes');
+ }
+ const values = parsed.map(value => {
+ if (!Number.isInteger(value) || value < 0 || value > 255) {
+ throw new Error('Keypair JSON must contain byte values from 0 to 255');
+ }
+ return value;
+ });
+ const bytes = new Uint8Array(values);
+ const keypair = await createKeyPairFromBytes(bytes);
+ const programAddress = await getAddressFromPublicKey(keypair.publicKey);
+ return { bytes, fileName, programAddress: programAddress.toString() };
+}
diff --git a/webapp/src/routes/setup.tsx b/webapp/src/routes/setup.tsx
index 98a6c55..177e871 100644
--- a/webapp/src/routes/setup.tsx
+++ b/webapp/src/routes/setup.tsx
@@ -30,6 +30,7 @@ import {
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { WalletButton } from '@/components/solana/solana-provider';
+import { ProgramKeypairPicker } from '@/components/program/program-keypair-picker';
import { useLocalnetSetup, useDevnetSetup, type SetupStep } from '@/hooks/use-setup-wizard';
import { useProgramDeploy } from '@/hooks/use-program-deploy';
import { useProgramStatus } from '@/hooks/use-program-status';
@@ -45,6 +46,7 @@ import { truncateAddress } from '@/lib/format';
import { isValidBase58Address } from '@/lib/validators';
import { useRpc } from '@/hooks/use-rpc';
import { api } from '@/lib/api-client';
+import type { ProgramKeypairImport } from '@/lib/program-keypair';
import solanaLogo from '@/assets/solana-logo.svg';
type Network = 'localnet' | 'devnet' | null;
@@ -405,6 +407,7 @@ function DevnetWizard({ onComplete, onBack }: { onComplete: () => void; onBack:
const [usdcVerifyFailed, setUsdcVerifyFailed] = useState(false);
const [multisigAddress, setMultisigAddress] = useState('');
const [confirmClose, setConfirmClose] = useState(false);
+ const [programKeypair, setProgramKeypair] = useState(null);
const [existingUsdcMint, setExistingUsdcMint] = useState(null);
const [configUsdcOnline, setConfigUsdcOnline] = useState(null);
const [checkingUsdc, setCheckingUsdc] = useState(false);
@@ -530,7 +533,11 @@ function DevnetWizard({ onComplete, onBack }: { onComplete: () => void; onBack:
markStepRunning('deploy-program', 'Deploying program via wallet...');
try {
log('info', 'Preparing deploy plan (fetching .so binary from API)...');
- const deployResult = await programDeploy.mutateAsync({ isUpgrade: false });
+ const deployResult = await programDeploy.mutateAsync({
+ isUpgrade: false,
+ programAddress: programKeypair?.programAddress,
+ programKeypairBytes: programKeypair?.bytes,
+ });
log('success', 'Program deployed successfully');
if (deployResult?.programAddress) {
setResult({ programId: deployResult.programAddress, usdcMint: '' });
@@ -542,7 +549,16 @@ function DevnetWizard({ onComplete, onBack }: { onComplete: () => void; onBack:
log('error', `Deploy failed: ${msg}`);
markStepError('deploy-program', msg);
}
- }, [programStatus.data, programDeploy, markStepDone, markStepError, markStepRunning, setResult, log]);
+ }, [
+ programStatus.data,
+ programDeploy,
+ programKeypair,
+ markStepDone,
+ markStepError,
+ markStepRunning,
+ setResult,
+ log,
+ ]);
const handleTransferAuthority = useCallback(async () => {
if (!walletSigner) return;
@@ -847,9 +863,16 @@ function DevnetWizard({ onComplete, onBack }: { onComplete: () => void; onBack:
{phase === 'deploy' && (
+ {!programStatus.data?.deployed && (
+
+ )}
diff --git a/webapp/test/deploy-builder.test.ts b/webapp/test/deploy-builder.test.ts
new file mode 100644
index 0000000..f52408f
--- /dev/null
+++ b/webapp/test/deploy-builder.test.ts
@@ -0,0 +1,15 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+import { buildDeployPlan } from '../api/lib/deploy-builder.ts';
+
+test('initial deploy plan does not serialize a program keypair', async () => {
+ const programAddress = '11111111111111111111111111111111';
+ const plan = await buildDeployPlan(new Uint8Array([1, 2, 3, 4]), programAddress);
+ const planRecord = plan as unknown as Record;
+
+ assert.equal(plan.programAddress, programAddress);
+ assert.equal(planRecord.programKeypair, undefined);
+ assert.equal(plan.bufferKeypair.length, 64);
+ assert.ok(plan.totalChunks > 0);
+});
From 2c2c178ab590134aa09314db818d6d8b9765e039 Mon Sep 17 00:00:00 2001
From: Jo D
Date: Tue, 26 May 2026 11:53:12 -0400
Subject: [PATCH 11/16] fix(webapp): bind setup authority transfer to deploy
target
---
webapp/src/routes/setup.tsx | 62 ++++++++++++++++++++-----------------
1 file changed, 33 insertions(+), 29 deletions(-)
diff --git a/webapp/src/routes/setup.tsx b/webapp/src/routes/setup.tsx
index 177e871..d99675c 100644
--- a/webapp/src/routes/setup.tsx
+++ b/webapp/src/routes/setup.tsx
@@ -521,11 +521,10 @@ function DevnetWizard({ onComplete, onBack }: { onComplete: () => void; onBack:
);
const handleDeploy = useCallback(async () => {
- const alreadyDeployed = programStatus.data?.deployed;
- if (alreadyDeployed) {
- log('info', 'Program already deployed, skipping...');
- markStepDone('deploy-program', 'Already deployed');
- setPhase('transfer-authority');
+ if (!programKeypair) {
+ const msg = 'Select a program keypair before deployment';
+ log('error', msg);
+ markStepError('deploy-program', msg);
return;
}
@@ -538,10 +537,11 @@ function DevnetWizard({ onComplete, onBack }: { onComplete: () => void; onBack:
programAddress: programKeypair?.programAddress,
programKeypairBytes: programKeypair?.bytes,
});
- log('success', 'Program deployed successfully');
- if (deployResult?.programAddress) {
- setResult({ programId: deployResult.programAddress, usdcMint: '' });
+ if (!deployResult?.programAddress) {
+ throw new Error('Deployment did not return a program address');
}
+ log('success', 'Program deployed successfully');
+ setResult({ programId: deployResult.programAddress, usdcMint: '' });
markStepDone('deploy-program', 'Program deployed');
setPhase('transfer-authority');
} catch (e) {
@@ -549,24 +549,21 @@ function DevnetWizard({ onComplete, onBack }: { onComplete: () => void; onBack:
log('error', `Deploy failed: ${msg}`);
markStepError('deploy-program', msg);
}
- }, [
- programStatus.data,
- programDeploy,
- programKeypair,
- markStepDone,
- markStepError,
- markStepRunning,
- setResult,
- log,
- ]);
+ }, [programDeploy, programKeypair, markStepDone, markStepError, markStepRunning, setResult, log]);
const handleTransferAuthority = useCallback(async () => {
if (!walletSigner) return;
+ if (!result?.programId) {
+ const msg = 'No deployed program target available';
+ log('error', msg);
+ markStepError('deploy-program', msg);
+ return;
+ }
const newAuth = multisigAddress;
log('info', `Transferring authority to ${newAuth}...`);
markStepRunning('deploy-program', 'Transferring upgrade authority...');
try {
- const programAddr = (result?.programId ?? configProgramAddress ?? '') as Address;
+ const programAddr = result.programId as Address;
const programDataPDA = await deriveProgramDataAddress(programAddr);
const ix = buildSetAuthorityIx(programDataPDA, walletSigner, newAuth as Address);
const sig = await walletSignAndSend(ix, walletSigner);
@@ -585,7 +582,6 @@ function DevnetWizard({ onComplete, onBack }: { onComplete: () => void; onBack:
txToast,
multisigAddress,
result,
- configProgramAddress,
markStepDone,
markStepError,
markStepRunning,
@@ -863,16 +859,14 @@ function DevnetWizard({ onComplete, onBack }: { onComplete: () => void; onBack:
{phase === 'deploy' && (
- {!programStatus.data?.deployed && (
-
- )}
+
@@ -934,6 +928,14 @@ function DevnetWizard({ onComplete, onBack }: { onComplete: () => void; onBack:
Optional
+ {result?.programId && (
+
+ Target:{' '}
+
+ {truncateAddress(result.programId, 12)}
+
+
+ )}
setMultisigAddress(e.target.value)}
@@ -942,7 +944,9 @@ function DevnetWizard({ onComplete, onBack }: { onComplete: () => void; onBack:
/>
From b7479b319d07954d6640741f1c13c6f37d179ee4 Mon Sep 17 00:00:00 2001
From: Jo D
Date: Tue, 26 May 2026 11:58:50 -0400
Subject: [PATCH 12/16] fix(webapp): exclude stale authority subscribers from
collection
---
.../plan/collect-payments-panel.tsx | 16 ++++++-
.../plan/enhanced-collect-payments.tsx | 45 +++++++++++++++----
webapp/src/hooks/use-plan-subscribers.ts | 28 +++++++++---
webapp/src/hooks/use-subscriptions.ts | 42 +++++++++++++++++
webapp/src/lib/plan-subscriber-authority.ts | 15 +++++++
webapp/test/plan-subscriber-authority.test.ts | 28 ++++++++++++
6 files changed, 158 insertions(+), 16 deletions(-)
create mode 100644 webapp/src/lib/plan-subscriber-authority.ts
create mode 100644 webapp/test/plan-subscriber-authority.test.ts
diff --git a/webapp/src/components/plan/collect-payments-panel.tsx b/webapp/src/components/plan/collect-payments-panel.tsx
index 4c15da4..933097d 100644
--- a/webapp/src/components/plan/collect-payments-panel.tsx
+++ b/webapp/src/components/plan/collect-payments-panel.tsx
@@ -6,7 +6,12 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { cn, USDC_MULTIPLIER, ellipsify, fmtDateTime } from '@/lib/utils';
import { ExplorerLink } from '@/components/cluster/cluster-ui';
import { useMyPlans, type PlanItem } from '@/hooks/use-plans';
-import { useSubscriberCounts, fetchPlanSubscriptions } from '@/hooks/use-subscriptions';
+import {
+ fetchPlanSubscriptions,
+ getLivePlanSubscribers,
+ resolvePlanSubscriberAuthorities,
+ useSubscriberCounts,
+} from '@/hooks/use-subscriptions';
import { useSubscriptionsMutations } from '@/hooks/use-subscriptions-mutations';
import { useClusterConfig } from '@/hooks/use-cluster-config';
import { useProgramAddress } from '@/hooks/use-token-config';
@@ -78,7 +83,14 @@ function CollectPlanCard({
const handleCollect = useCallback(async () => {
setIsCollecting(true);
try {
- const subscribers = await fetchPlanSubscriptions(rpcUrl, plan.address, progAddr);
+ const subscribers = getLivePlanSubscribers(
+ await resolvePlanSubscriberAuthorities(
+ rpcUrl,
+ await fetchPlanSubscriptions(rpcUrl, plan.address, progAddr),
+ plan.data.mint,
+ progAddr,
+ ),
+ );
const ts = await getBlockTimestamp(rpcUrl);
const eligible = computeEligibleSubscribers(subscribers, plan.data.terms, ts);
const currentSubscriberCount = subscribers.filter(sub => hasMatchingPlanTerms(sub, plan.data.terms)).length;
diff --git a/webapp/src/components/plan/enhanced-collect-payments.tsx b/webapp/src/components/plan/enhanced-collect-payments.tsx
index 070c202..0818cab 100644
--- a/webapp/src/components/plan/enhanced-collect-payments.tsx
+++ b/webapp/src/components/plan/enhanced-collect-payments.tsx
@@ -21,7 +21,11 @@ import { useQueryClient } from '@tanstack/react-query';
import { useSubscriptionsMutations } from '@/hooks/use-subscriptions-mutations';
import { useClusterConfig } from '@/hooks/use-cluster-config';
import { useProgramAddress } from '@/hooks/use-token-config';
-import { fetchPlanSubscriptions } from '@/hooks/use-subscriptions';
+import {
+ fetchPlanSubscriptions,
+ getLivePlanSubscribers,
+ resolvePlanSubscriberAuthorities,
+} from '@/hooks/use-subscriptions';
import { getBlockTimestamp } from '@/hooks/use-time-travel';
import { computeEligibleSubscribers, hasMatchingPlanTerms } from '@/lib/collect-utils';
import {
@@ -138,7 +142,14 @@ function CollectAllButton({
const ts = await getBlockTimestamp(rpcUrl);
for (const pd of eligiblePlans) {
- const subscribers = await fetchPlanSubscriptions(rpcUrl, pd.plan.address, progAddr!);
+ const subscribers = getLivePlanSubscribers(
+ await resolvePlanSubscriberAuthorities(
+ rpcUrl,
+ await fetchPlanSubscriptions(rpcUrl, pd.plan.address, progAddr!),
+ pd.plan.data.mint,
+ progAddr!,
+ ),
+ );
const eligible = computeEligibleSubscribers(subscribers, pd.plan.data.terms, ts);
if (eligible.length === 0) continue;
plans.push({
@@ -223,7 +234,7 @@ function EnhancedPlanCard({ planData, blockTs }: { planData: PlanSubscriberData;
const progAddr = useProgramAddress();
const { collectSubscriptionPayments } = useSubscriptionsMutations();
- const { plan, subscribers, currentSubscribers, staleSubscribers, eligible } = planData;
+ const { plan, subscribers, currentSubscribers, staleAuthoritySubscribers, staleSubscribers, eligible } = planData;
const meta = useMemo(() => parsePlanMeta(plan.data.metadataUri), [plan.data.metadataUri]);
const planName = meta.n || `Plan ${ellipsify(plan.address)}`;
const PlanIcon = (meta.i && ICON_MAP[meta.i]) || Star;
@@ -233,6 +244,10 @@ function EnhancedPlanCard({ planData, blockTs }: { planData: PlanSubscriberData;
() => new Set(staleSubscribers.map(sub => sub.subscriptionAddress)),
[staleSubscribers],
);
+ const staleAuthoritySubscriberAddresses = useMemo(
+ () => new Set(staleAuthoritySubscribers.map(sub => sub.subscriptionAddress)),
+ [staleAuthoritySubscribers],
+ );
// eslint-disable-next-line react-hooks/exhaustive-deps
const history = useMemo(() => getCollectionHistory(plan.address), [plan.address, historyVersion]);
@@ -240,10 +255,16 @@ function EnhancedPlanCard({ planData, blockTs }: { planData: PlanSubscriberData;
const handleCollect = useCallback(async () => {
setIsCollecting(true);
try {
- const subs = await fetchPlanSubscriptions(rpcUrl, plan.address, progAddr!);
+ const subs = await resolvePlanSubscriberAuthorities(
+ rpcUrl,
+ await fetchPlanSubscriptions(rpcUrl, plan.address, progAddr!),
+ plan.data.mint,
+ progAddr!,
+ );
+ const liveSubs = getLivePlanSubscribers(subs);
const ts = await getBlockTimestamp(rpcUrl);
- const elig = computeEligibleSubscribers(subs, plan.data.terms, ts);
- const currentSubscriberCount = subs.filter(sub => hasMatchingPlanTerms(sub, plan.data.terms)).length;
+ const elig = computeEligibleSubscribers(liveSubs, plan.data.terms, ts);
+ const currentSubscriberCount = liveSubs.filter(sub => hasMatchingPlanTerms(sub, plan.data.terms)).length;
if (elig.length === 0) {
toast.info(
@@ -317,7 +338,8 @@ function EnhancedPlanCard({ planData, blockTs }: { planData: PlanSubscriberData;
${amountUsd.toFixed(2)}/period · {eligible.length}/{currentSubscribers.length}{' '}
eligible
- {staleSubscribers.length > 0 && ` / ${staleSubscribers.length} stale`}
+ {staleSubscribers.length + staleAuthoritySubscribers.length > 0 &&
+ ` / ${staleSubscribers.length + staleAuthoritySubscribers.length} stale`}
@@ -377,6 +399,9 @@ function EnhancedPlanCard({ planData, blockTs }: { planData: PlanSubscriberData;
e => e.subscriptionAddress === sub.subscriptionAddress,
);
const isStale = staleSubscriberAddresses.has(sub.subscriptionAddress);
+ const isAuthorityStale = staleAuthoritySubscriberAddresses.has(
+ sub.subscriptionAddress,
+ );
const collectibleUsd = eligEntry
? Number(eligEntry.collectAmount) / USDC_MULTIPLIER
: null;
@@ -391,7 +416,9 @@ function EnhancedPlanCard({ planData, blockTs }: { planData: PlanSubscriberData;
/>
- {isStale ? (
+ {isAuthorityStale ? (
+ Stale Authority
+ ) : isStale ? (
Stale Terms
) : isActive ? (
Active
@@ -406,7 +433,7 @@ function EnhancedPlanCard({ planData, blockTs }: { planData: PlanSubscriberData;
{fmtDateShort(periodEnd)}
- {isStale ? (
+ {isAuthorityStale || isStale ? (
Excluded
) : collectibleUsd !== null ? (
diff --git a/webapp/src/hooks/use-plan-subscribers.ts b/webapp/src/hooks/use-plan-subscribers.ts
index 2a0d792..32f2f27 100644
--- a/webapp/src/hooks/use-plan-subscribers.ts
+++ b/webapp/src/hooks/use-plan-subscribers.ts
@@ -3,7 +3,14 @@ import { useMemo } from 'react';
import { useClusterConfig } from '@/hooks/use-cluster-config';
import { type PlanItem, useMyPlans } from '@/hooks/use-plans';
-import { fetchPlanSubscriptions, type PlanSubscriber, useSubscriberCounts } from '@/hooks/use-subscriptions';
+import {
+ fetchPlanSubscriptions,
+ getAuthorityStalePlanSubscribers,
+ getLivePlanSubscribers,
+ type PlanSubscriber,
+ resolvePlanSubscriberAuthorities,
+ useSubscriberCounts,
+} from '@/hooks/use-subscriptions';
import { getBlockTimestamp } from '@/hooks/use-time-travel';
import { useProgramAddress } from '@/hooks/use-token-config';
import {
@@ -19,6 +26,7 @@ export interface PlanSubscriberData {
currentSubscribers: PlanSubscriber[];
eligible: EligibleSubscriber[];
plan: PlanItem;
+ staleAuthoritySubscribers: PlanSubscriber[];
staleSubscribers: PlanSubscriber[];
subscribers: PlanSubscriber[];
totalPending: bigint;
@@ -51,10 +59,19 @@ export function useAllPlanSubscribers() {
const planDataArr = await Promise.all(
plansWithSubs.map(async (plan): Promise => {
- const subscribers = await fetchPlanSubscriptions(rpcUrl, plan.address, progAddr!);
- const staleSubscribers = getStalePlanSubscribers(subscribers, plan.data.terms);
- const currentSubscribers = subscribers.filter(sub => hasMatchingPlanTerms(sub, plan.data.terms));
- const eligible = computeEligibleSubscribers(subscribers, plan.data.terms, blockTimestamp);
+ const subscribers = await resolvePlanSubscriberAuthorities(
+ rpcUrl,
+ await fetchPlanSubscriptions(rpcUrl, plan.address, progAddr!),
+ plan.data.mint,
+ progAddr!,
+ );
+ const liveSubscribers = getLivePlanSubscribers(subscribers);
+ const staleAuthoritySubscribers = getAuthorityStalePlanSubscribers(subscribers);
+ const staleSubscribers = getStalePlanSubscribers(liveSubscribers, plan.data.terms);
+ const currentSubscribers = liveSubscribers.filter(sub =>
+ hasMatchingPlanTerms(sub, plan.data.terms),
+ );
+ const eligible = computeEligibleSubscribers(liveSubscribers, plan.data.terms, blockTimestamp);
const totalPending = eligible.reduce((sum, e) => sum + e.collectAmount, 0n);
const activeCount = currentSubscribers.filter(s => s.expiresAtTs === 0n).length;
const cancelledCount = currentSubscribers.filter(
@@ -67,6 +84,7 @@ export function useAllPlanSubscribers() {
currentSubscribers,
eligible,
plan,
+ staleAuthoritySubscribers,
staleSubscribers,
subscribers,
totalPending,
diff --git a/webapp/src/hooks/use-subscriptions.ts b/webapp/src/hooks/use-subscriptions.ts
index 350da23..042cf01 100644
--- a/webapp/src/hooks/use-subscriptions.ts
+++ b/webapp/src/hooks/use-subscriptions.ts
@@ -5,7 +5,9 @@ import {
decodeSubscriptionDelegation,
DELEGATEE_OFFSET,
fetchAllMaybePlan,
+ fetchAllMaybeSubscriptionAuthority,
fetchSubscriptionsForUser,
+ findSubscriptionAuthorityPda,
type RawProgramAccount,
SUBSCRIPTION_SIZE,
toEncodedAccount,
@@ -14,12 +16,17 @@ import { useQuery } from '@tanstack/react-query';
import { useClusterConfig } from '@/hooks/use-cluster-config';
import { useProgramAddress } from '@/hooks/use-token-config';
+import type { PlanSubscriberAuthorityStatus } from '@/lib/plan-subscriber-authority';
+
+export { getAuthorityStalePlanSubscribers, getLivePlanSubscribers } from '@/lib/plan-subscriber-authority';
export interface PlanSubscriber {
amountPulledInPeriod: bigint;
+ authorityStatus?: PlanSubscriberAuthorityStatus;
currentPeriodStartTs: bigint;
delegator: string;
expiresAtTs: bigint;
+ initId: bigint;
subscriptionAddress: string;
terms: { amount: bigint; createdAt: bigint; periodHours: bigint };
}
@@ -94,6 +101,7 @@ export async function fetchPlanSubscriptions(
currentPeriodStartTs: sub.currentPeriodStartTs,
delegator: sub.header.delegator,
expiresAtTs: sub.expiresAtTs,
+ initId: sub.header.initId,
subscriptionAddress: entry.pubkey,
terms: sub.terms,
});
@@ -105,6 +113,40 @@ export async function fetchPlanSubscriptions(
return subscribers;
}
+export async function resolvePlanSubscriberAuthorities(
+ rpcUrl: string,
+ subscribers: PlanSubscriber[],
+ mint: string,
+ progAddr: string,
+): Promise {
+ if (subscribers.length === 0) return [];
+
+ const rpc = createSolanaRpc(rpcUrl);
+ const programAddress = address(progAddr);
+ const tokenMint = address(mint);
+ const authorityAddresses = await Promise.all(
+ subscribers.map(async subscriber => {
+ const [authorityAddress] = await findSubscriptionAuthorityPda(
+ { tokenMint, user: address(subscriber.delegator) },
+ { programAddress },
+ );
+ return authorityAddress;
+ }),
+ );
+ const authorities = await fetchAllMaybeSubscriptionAuthority(rpc, authorityAddresses);
+
+ return subscribers.map((subscriber, index) => {
+ const authority = authorities[index];
+ const authorityStatus: PlanSubscriberAuthorityStatus =
+ authority?.exists && authority.data.initId === subscriber.initId
+ ? 'live'
+ : authority?.exists
+ ? 'rotated'
+ : 'missing';
+ return { ...subscriber, authorityStatus };
+ });
+}
+
export function useMySubscriptions() {
const { account } = useWallet();
const clusterConfig = useClusterConfig();
diff --git a/webapp/src/lib/plan-subscriber-authority.ts b/webapp/src/lib/plan-subscriber-authority.ts
new file mode 100644
index 0000000..7de103b
--- /dev/null
+++ b/webapp/src/lib/plan-subscriber-authority.ts
@@ -0,0 +1,15 @@
+export type PlanSubscriberAuthorityStatus = 'live' | 'missing' | 'rotated';
+
+export interface PlanSubscriberAuthorityFields {
+ authorityStatus?: PlanSubscriberAuthorityStatus;
+}
+
+export function getLivePlanSubscribers(subscribers: T[]): T[] {
+ return subscribers.filter(subscriber => subscriber.authorityStatus === 'live');
+}
+
+export function getAuthorityStalePlanSubscribers(subscribers: T[]): T[] {
+ return subscribers.filter(
+ subscriber => subscriber.authorityStatus === 'missing' || subscriber.authorityStatus === 'rotated',
+ );
+}
diff --git a/webapp/test/plan-subscriber-authority.test.ts b/webapp/test/plan-subscriber-authority.test.ts
new file mode 100644
index 0000000..836bbe8
--- /dev/null
+++ b/webapp/test/plan-subscriber-authority.test.ts
@@ -0,0 +1,28 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+import {
+ getAuthorityStalePlanSubscribers,
+ getLivePlanSubscribers,
+ type PlanSubscriberAuthorityStatus,
+} from '../src/lib/plan-subscriber-authority.ts';
+
+type Subscriber = {
+ authorityStatus?: PlanSubscriberAuthorityStatus;
+};
+
+function subscriber(authorityStatus: PlanSubscriberAuthorityStatus): Subscriber {
+ return {
+ authorityStatus,
+ };
+}
+
+test('classifies authority-rotated subscribers outside live collection sets', () => {
+ const live = subscriber('live');
+ const missing = subscriber('missing');
+ const rotated = subscriber('rotated');
+ const subscribers = [live, missing, rotated];
+
+ assert.deepEqual(getLivePlanSubscribers(subscribers), [live]);
+ assert.deepEqual(getAuthorityStalePlanSubscribers(subscribers), [missing, rotated]);
+});
From a71eb45200fea51bc64b7ea8685a2e609af0c5c5 Mon Sep 17 00:00:00 2001
From: Jo D
Date: Tue, 26 May 2026 12:08:52 -0400
Subject: [PATCH 13/16] fix(webapp): recover deploy buffers after finalize
failure
---
webapp/src/hooks/use-program-deploy.ts | 104 ++++++++++++++++++-------
1 file changed, 75 insertions(+), 29 deletions(-)
diff --git a/webapp/src/hooks/use-program-deploy.ts b/webapp/src/hooks/use-program-deploy.ts
index 91e4b9b..72a33af 100644
--- a/webapp/src/hooks/use-program-deploy.ts
+++ b/webapp/src/hooks/use-program-deploy.ts
@@ -9,6 +9,7 @@ import {
createTransactionMessage,
generateKeyPair,
getAddressEncoder,
+ getBase58Decoder,
getBase64EncodedWireTransaction,
type Instruction,
type KeyPairSigner,
@@ -88,6 +89,9 @@ export function useProgramDeploy() {
const resetProgress = useCallback(() => {
setProgress({ current: 0, message: '', phase: 'preparing', total: 0 });
+ }, []);
+
+ const clearRecoveryRefs = useCallback(() => {
lastPlanRef.current = null;
bufferSignerRef.current = null;
feePayerRef.current = null;
@@ -102,6 +106,9 @@ export function useProgramDeploy() {
if (resumeFrom !== undefined && lastPlanRef.current && bufferSignerRef.current) {
return { bufferKpSigner: bufferSignerRef.current, plan: lastPlanRef.current };
}
+ if (lastPlanRef.current || bufferSignerRef.current || feePayerRef.current) {
+ throw new Error('Close the failed buffer before starting a new deployment.');
+ }
const plan = await api.program.prepareDeploy({
isUpgrade,
payerAddress: signer.address,
@@ -270,6 +277,18 @@ export function useProgramDeploy() {
}
}
+ async function getBufferAuthority(bufferAddress: string): Promise {
+ const acctInfo = await rpc.getAccountInfo(address(bufferAddress), { encoding: 'base64' }).send();
+ if (!acctInfo.value) return null;
+
+ const data = Uint8Array.from(atob(acctInfo.value.data[0] as string), c => c.charCodeAt(0));
+ if (data.length < 37 || data[4] !== 1) {
+ throw new Error('Buffer account is not in a recoverable buffer state');
+ }
+
+ return getBase58Decoder().decode(data.slice(5, 37));
+ }
+
async function finalizeDeployment(
signer: TransactionSigner,
plan: DeployPlan,
@@ -355,21 +374,39 @@ export function useProgramDeploy() {
const closeBuffer = useMutation({
mutationFn: async () => {
- if (!walletSigner || !bufferSignerRef.current) throw new Error('No buffer to close');
+ if (!walletSigner) throw new Error('Wallet not connected');
const signer = walletSigner;
const bufferKp = bufferSignerRef.current;
+ const feePayerKp = feePayerRef.current;
- const { value: latestBlockhash } = await rpc.getLatestBlockhash().send();
+ if (!bufferKp && !feePayerKp) throw new Error('No deploy recovery state found');
+
+ if (bufferKp) {
+ const currentAuthority = await getBufferAuthority(bufferKp.address);
+
+ if (currentAuthority) {
+ const authority = currentAuthority === signer.address ? signer : bufferKp;
+ if (authority.address !== currentAuthority) {
+ throw new Error(`Cannot close buffer because its authority is ${currentAuthority}`);
+ }
+
+ const { value: latestBlockhash } = await rpc.getLatestBlockhash().send();
- const closeIx = buildCloseBufferIx(bufferKp.address, signer.address, bufferKp);
- const closeTx = buildV0Tx(signer, latestBlockhash, [closeIx]);
- const signedCloseTx = await signTransactionMessageWithSigners(closeTx);
- await rpc.sendTransaction(getBase64EncodedWireTransaction(signedCloseTx), { encoding: 'base64' }).send();
- bufferSignerRef.current = null;
+ const closeIx = buildCloseBufferIx(bufferKp.address, signer.address, authority);
+ const closeTx = buildV0Tx(signer, latestBlockhash, [closeIx]);
+ const signedCloseTx = await signTransactionMessageWithSigners(closeTx);
+ await rpc
+ .sendTransaction(getBase64EncodedWireTransaction(signedCloseTx), { encoding: 'base64' })
+ .send();
+ }
+ }
+
+ if (feePayerKp) await reclaimFeePayerSol(feePayerKp, signer);
+ clearRecoveryRefs();
},
onError: e => toast.onError(e),
onSuccess: () => {
- toast.onSuccess('Buffer closed, SOL reclaimed');
+ toast.onSuccess('Deploy recovery SOL reclaimed');
},
});
@@ -409,33 +446,42 @@ export function useProgramDeploy() {
total: totalChunks,
});
- await transferBufferAuthority(signer, bufferKpSigner, feePayerKp);
+ try {
+ await transferBufferAuthority(signer, bufferKpSigner, feePayerKp);
- setProgress({
- current: totalChunks,
- message: isUpgrade
- ? 'Finalizing upgrade (approve in wallet)...'
- : 'Finalizing deployment (approve in wallet)...',
- phase: 'deploying',
- total: totalChunks,
- });
+ setProgress({
+ current: totalChunks,
+ message: isUpgrade
+ ? 'Finalizing upgrade (approve in wallet)...'
+ : 'Finalizing deployment (approve in wallet)...',
+ phase: 'deploying',
+ total: totalChunks,
+ });
- const signature = await finalizeDeployment(signer, plan, bufferKpSigner, isUpgrade, programKeypairBytes);
+ const signature = await finalizeDeployment(
+ signer,
+ plan,
+ bufferKpSigner,
+ isUpgrade,
+ programKeypairBytes,
+ );
- await reclaimFeePayerSol(feePayerKp, signer);
+ await reclaimFeePayerSol(feePayerKp, signer);
- setProgress({
- current: totalChunks,
- message: 'Deployment complete!',
- phase: 'done',
- total: totalChunks,
- });
+ setProgress({
+ current: totalChunks,
+ message: 'Deployment complete!',
+ phase: 'done',
+ total: totalChunks,
+ });
- bufferSignerRef.current = null;
- lastPlanRef.current = null;
- feePayerRef.current = null;
+ clearRecoveryRefs();
- return { programAddress: plan.programAddress, signature };
+ return { programAddress: plan.programAddress, signature };
+ } catch (error) {
+ await reclaimFeePayerSol(feePayerKp, signer);
+ throw error;
+ }
},
onError: error => {
console.error('Deploy/upgrade error:', error);
From 1b40c3d23cc05eee5c5c1b23d79da5b99d3e5535 Mon Sep 17 00:00:00 2001
From: Jo D
Date: Tue, 26 May 2026 13:26:17 -0400
Subject: [PATCH 14/16] fix(webapp): use configured token decimals in
delegation dialog
Replace hardcoded USDC_MULTIPLIER and "USDC" labels with token-config
lookup via resolvePlanTokenDisplay + parseTokenAmount. Disable submit
when the mint is unconfigured for the selected network. Mirrors the
plan-creation dialog and removes a latent mispricing path for non-
6-decimal mints.
---
.../delegation/create-delegation-dialog.tsx | 42 +++++++++++++++----
1 file changed, 34 insertions(+), 8 deletions(-)
diff --git a/webapp/src/components/delegation/create-delegation-dialog.tsx b/webapp/src/components/delegation/create-delegation-dialog.tsx
index 41dea81..4c2f119 100644
--- a/webapp/src/components/delegation/create-delegation-dialog.tsx
+++ b/webapp/src/components/delegation/create-delegation-dialog.tsx
@@ -1,4 +1,4 @@
-import { useState, useEffect } from 'react';
+import { useMemo, useState, useEffect } from 'react';
import { Coins, RefreshCw, Plus, ArrowLeft } from 'lucide-react';
import { Button as SolanaButton, Select, SelectItem, TextInput } from '@solana/design-system';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from '@/components/ui/dialog';
@@ -6,8 +6,10 @@ import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { useSubscriptionsMutations } from '@/hooks/use-subscriptions-mutations';
import { useSubscriptionAuthorityStatus } from '@/hooks/use-subscription-authority-status';
+import { useTokenConfig } from '@/hooks/use-token-config';
import { DELEGATION_KINDS, type DelegationKindId } from '@/lib/delegation-kinds';
-import { cn, USDC_MULTIPLIER, SECONDS_PER_DAY } from '@/lib/utils';
+import { cn, SECONDS_PER_DAY } from '@/lib/utils';
+import { parseTokenAmount, resolvePlanTokenDisplay } from '@/lib/token-display';
import { getBlockTimestamp } from '@/hooks/use-time-travel';
import { useClusterConfig } from '@/hooks/use-cluster-config';
@@ -61,6 +63,8 @@ export function CreateDelegationDialog({ tokenMint, disabled }: CreateDelegation
const { data: authorityStatus } = useSubscriptionAuthorityStatus(tokenMint);
const authorityInitId = authorityStatus?.data?.initId;
const { url: rpcUrl } = useClusterConfig();
+ const { data: tokens } = useTokenConfig();
+ const token = useMemo(() => resolvePlanTokenDisplay(tokenMint, tokens), [tokenMint, tokens]);
const [blockTime, setBlockTime] = useState();
useEffect(() => {
@@ -109,6 +113,7 @@ export function CreateDelegationDialog({ tokenMint, disabled }: CreateDelegation
const handleSubmit = async () => {
if (authorityInitId == null) return;
+ if (token.decimals == null) return;
const nonce = generateNonce();
let expiryTimestamp = 0;
@@ -117,7 +122,12 @@ export function CreateDelegationDialog({ tokenMint, disabled }: CreateDelegation
expiryTimestamp = Math.floor(expiryDateTime.getTime() / 1000);
if (Number.isNaN(expiryTimestamp) || expiryTimestamp <= 0) return;
}
- const amountInSmallestUnits = BigInt(Math.round(Number(amount) * USDC_MULTIPLIER));
+ let amountInSmallestUnits: bigint;
+ try {
+ amountInSmallestUnits = parseTokenAmount(amount, token.decimals);
+ } catch {
+ return;
+ }
if (selectedKind === 'fixed') {
await createFixedDelegation.mutateAsync(
@@ -166,11 +176,19 @@ export function CreateDelegationDialog({ tokenMint, disabled }: CreateDelegation
return expiryDateTime > blockDate;
};
+ const isAmountValid = useMemo(() => {
+ if (token.decimals == null) return false;
+ try {
+ return parseTokenAmount(amount, token.decimals) > 0n;
+ } catch {
+ return false;
+ }
+ }, [amount, token.decimals]);
+
const isFormValid =
delegatee.length >= 32 &&
delegatee.length <= 44 &&
- amount.length > 0 &&
- Number(amount) > 0 &&
+ isAmountValid &&
authorityInitId != null &&
(noExpiry || expiryDate.length > 0) &&
isExpiryValid() &&
@@ -239,17 +257,25 @@ export function CreateDelegationDialog({ tokenMint, disabled }: CreateDelegation
- {selectedKind === 'fixed' ? 'Total Amount (USDC)' : 'Amount per Period (USDC)'}
+ {selectedKind === 'fixed'
+ ? `Total Amount (${token.symbol})`
+ : `Amount per Period (${token.symbol})`}
) => setAmount(e.target.value)}
- placeholder="100.00"
+ placeholder={token.decimals === 0 ? '100' : '100.00'}
/>
+ {token.decimals == null && (
+
+ This token is not configured for the selected network. Delegation creation is
+ disabled.
+
+ )}
{selectedKind === 'recurring' && (
From a0d14b407e6aba6ccc874b3ca5ddac68713f100a Mon Sep 17 00:00:00 2001
From: Jo D
Date: Tue, 26 May 2026 13:26:35 -0400
Subject: [PATCH 15/16] fix(scripts): bracket IPv6 host in api health check
When API_HOST is an IPv6 literal (e.g. ::1), the health-check URL must
wrap the host in brackets. Without this, wait_for_http builds an
unparseable URL and the start script fails before the API server is
probed.
---
scripts/common.sh | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/scripts/common.sh b/scripts/common.sh
index d7a06c6..f37dea2 100755
--- a/scripts/common.sh
+++ b/scripts/common.sh
@@ -194,6 +194,10 @@ start_api_server() {
if [ "$health_host" = "0.0.0.0" ] || [ "$health_host" = "::" ]; then
health_host="127.0.0.1"
fi
+ local health_url_host="$health_host"
+ case "$health_host" in
+ *:*) health_url_host="[$health_host]" ;;
+ esac
pkill -f "tsx.*server.ts" 2>/dev/null || true
sleep 1
@@ -203,7 +207,7 @@ start_api_server() {
LAST_SERVICE_PID=$!
cd "$project_root"
echo " API PID: $LAST_SERVICE_PID"
- wait_for_http "http://$health_host:3001/api/health" "API server" 15 '"status"' || exit 1
+ wait_for_http "http://$health_url_host:3001/api/health" "API server" 15 '"status"' || exit 1
}
# Start Vite dev server on :5173
From 1332e9fbce98c24ef2598cc3d1ed1f0308324e8d Mon Sep 17 00:00:00 2001
From: Jo D
Date: Tue, 26 May 2026 13:34:03 -0400
Subject: [PATCH 16/16] docs: clarify authority rotation timing
---
docs/001-program-architecture.md | 25 +++++++++++++++----------
1 file changed, 15 insertions(+), 10 deletions(-)
diff --git a/docs/001-program-architecture.md b/docs/001-program-architecture.md
index a2aee97..7e7c47a 100644
--- a/docs/001-program-architecture.md
+++ b/docs/001-program-architecture.md
@@ -235,10 +235,13 @@ impl SubscriptionAuthority {
> **Note (init_id):** The `init_id` field is set from `Clock::slot` when the account is created.
> Every delegation header stores a copy of this value. On transfer, the program validates
-> `header.init_id == subscription_authority.init_id`. If a user closes and re-initializes their
-> SubscriptionAuthority, the new slot produces a different `init_id`, making all old delegations
-> non-transferable (error: `StaleSubscriptionAuthority`). This prevents orphaned delegations from
-> being revived and also makes closing an effective emergency kill switch.
+> `header.init_id == subscription_authority.init_id`. Closing a SubscriptionAuthority makes
+> existing delegations non-transferable while the authority account is closed.
+>
+> Re-initializing in a later slot creates a different `init_id`, so old delegations fail with
+> `StaleSubscriptionAuthority`. Re-initializing in the same slot can reuse the same `init_id`,
+> so same-slot rotation is not a reliable invalidation boundary. To stop a specific delegation
+> immediately, use `revoke_delegation`.
### Header
@@ -423,12 +426,14 @@ Closes a SubscriptionAuthority PDA and returns rent to the owner.
2. Verify PDA derivation from `["SubscriptionAuthority", user, token_mint]`
3. Close account and transfer lamports to user
-> **Emergency kill switch:** Closing does not revoke existing delegation PDAs, but they
-> become non-transferable because the SubscriptionAuthority account no longer exists. If the user
-> re-initializes, the new `init_id` invalidates all old delegations. This allows a user
-> to immediately cut off all delegatees in a single transaction without revoking each one
-> individually. The delegator can still call `revoke_delegation` on orphaned delegations
-> afterward to reclaim rent.
+> **Authority close and rotation:** Closing does not revoke existing delegation PDAs, but they
+> become non-transferable while the SubscriptionAuthority account does not exist. If the user
+> re-initializes in a later slot, the new `init_id` invalidates old delegations.
+>
+> Same-slot close and re-initialization can reuse the same `init_id`, so authority rotation should
+> not be used as an immediate same-slot revocation mechanism. To stop a delegation immediately,
+> revoke that delegation directly. The delegator can still call `revoke_delegation` afterward to
+> reclaim rent from orphaned delegations.
---