diff --git a/clients/typescript/src/plugin.ts b/clients/typescript/src/plugin.ts
index e19bd5b..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;
@@ -236,6 +238,7 @@ export type SubscribeInput = WithProgramAddress & {
expectedAmount?: bigint | number;
expectedCreatedAt?: bigint | number;
expectedPeriodHours?: bigint | number;
+ expectedSubscriptionAuthorityInitId?: bigint | number;
merchant: Address;
payer?: TransactionSigner;
planId: bigint | number;
@@ -297,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),
@@ -318,6 +326,7 @@ export async function getCreateFixedDelegationOverlayInstructionAsync(
delegator: input.delegator,
fixedDelegation: {
amount: input.amount,
+ expectedSubscriptionAuthorityInitId: input.expectedSubscriptionAuthorityInitId,
expiryTs: input.expiryTs,
nonce: input.nonce,
},
@@ -334,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),
@@ -355,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,
@@ -533,10 +548,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 +573,7 @@ export async function getSubscribeOverlayInstructionAsync(input: SubscribeInput)
expectedCreatedAt: input.expectedCreatedAt,
expectedMint: input.tokenMint,
expectedPeriodHours: input.expectedPeriodHours,
+ expectedSubscriptionAuthorityInitId: input.expectedSubscriptionAuthorityInitId,
planBump,
planId: input.planId,
},
@@ -681,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(
@@ -701,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(
@@ -718,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(
@@ -769,13 +828,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 +848,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/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/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.
---
diff --git a/idl/subscriptions.json b/idl/subscriptions.json
index b54e22d..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"
@@ -586,6 +604,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/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/scripts/common.sh b/scripts/common.sh
index 0ef340f..f37dea2 100755
--- a/scripts/common.sh
+++ b/scripts/common.sh
@@ -189,6 +189,15 @@ 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
+ 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
@@ -198,7 +207,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_url_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/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/test_subscribe.rs b/tests/integration-tests/src/test_subscribe.rs
index f38279d..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);
@@ -185,21 +183,71 @@ fn subscribe_duplicate_rejected() {
}
#[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};
+fn subscribe_rejects_stale_subscription_authority_generation() {
+ 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 = 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 = 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 = 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() {
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;
@@ -207,6 +255,9 @@ 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 = 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 +280,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..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)
@@ -1001,6 +1036,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 +1052,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/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 675b99d..19b6276 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');
@@ -22,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';
@@ -254,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);
@@ -267,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);
@@ -652,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') {
@@ -741,20 +750,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`);
});
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"
/>
resolvePlanTokenDisplay(tokenMint, tokens), [tokenMint, tokens]);
const [blockTime, setBlockTime] = useState();
useEffect(() => {
@@ -105,6 +112,9 @@ export function CreateDelegationDialog({ tokenMint, disabled }: CreateDelegation
};
const handleSubmit = async () => {
+ if (authorityInitId == null) return;
+ if (token.decimals == null) return;
+
const nonce = generateNonce();
let expiryTimestamp = 0;
if (!noExpiry) {
@@ -112,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(
@@ -122,6 +137,7 @@ export function CreateDelegationDialog({ tokenMint, disabled }: CreateDelegation
nonce,
amount: amountInSmallestUnits,
expiryTs: expiryTimestamp,
+ expectedSubscriptionAuthorityInitId: authorityInitId,
},
{
onSuccess: () => {
@@ -140,6 +156,7 @@ export function CreateDelegationDialog({ tokenMint, disabled }: CreateDelegation
amountPerPeriod: amountInSmallestUnits,
periodLengthS: periodSeconds,
expiryTs: expiryTimestamp,
+ expectedSubscriptionAuthorityInitId: authorityInitId,
},
{
onSuccess: () => {
@@ -159,11 +176,20 @@ 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() &&
(selectedKind === 'fixed' || (periodDays.length > 0 && Number(periodDays) > 0));
@@ -171,7 +197,12 @@ export function CreateDelegationDialog({ tokenMint, disabled }: CreateDelegation
return (
+ {selectedToken ? (
+
+ {selectedToken.name} ยท {ellipsify(selectedToken.mint, 4)}
+
+ ) : (
+
+ No payment tokens are configured for this network.
+
+ )}
diff --git a/webapp/src/components/plan/enhanced-collect-payments.tsx b/webapp/src/components/plan/enhanced-collect-payments.tsx
index 879bf67..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({
@@ -179,13 +190,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),
);
}
@@ -229,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;
@@ -239,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]);
@@ -246,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(
@@ -323,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`}
@@ -383,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;
@@ -397,7 +416,9 @@ function EnhancedPlanCard({ planData, blockTs }: { planData: PlanSubscriberData;
/>
- {isStale ? (
+ {isAuthorityStale ? (
+ Stale Authority
+ ) : isStale ? (
Stale Terms
) : isActive ? (
Active
@@ -412,7 +433,7 @@ function EnhancedPlanCard({ planData, blockTs }: { planData: PlanSubscriberData;
{fmtDateShort(periodEnd)}
- {isStale ? (
+ {isAuthorityStale || isStale ? (
Excluded
) : collectibleUsd !== null ? (
diff --git a/webapp/src/components/plan/plan-card.tsx b/webapp/src/components/plan/plan-card.tsx
index 8a08d56..41bfd9b 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,23 +448,27 @@ function DeletePlanDialog({
function SubscribeDialog({
plan,
meta,
+ token,
open,
onOpenChange,
}: {
plan: PlanItem;
meta: PlanMeta;
+ token: PlanTokenDisplay;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { subscribe, initSubscriptionAuthority } = useSubscriptionsMutations();
const {
+ data: statusData,
isInitialized,
isLoading: statusLoading,
refetch: refetchStatus,
} = 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 authorityInitId = statusData?.data?.initId;
const handleInit = async () => {
if (!account) return;
@@ -487,10 +495,32 @@ 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)}
+
+
+ Token
+ {token.symbol}
+
+
+ Mint
+
+
+
+
+ {token.decimals == null && (
+
+ This token is not configured for the selected network. Review the raw amount and mint before
+ subscribing.
+
+ )}
+
{statusLoading ? (
Checking wallet status...
@@ -515,7 +545,8 @@ function SubscribeDialog({
Cancel
+ onClick={() => {
+ if (authorityInitId == null) return;
subscribe.mutate(
{
merchant: plan.owner,
@@ -524,11 +555,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
@@ -672,9 +704,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;
@@ -777,13 +811,28 @@ export function PlanCard({
-
-
- ${amount}
+
+
+ {amount}
/{period}
-
+
+
+ {token.symbol}
+
+
{hasExpiry ? (
planExpired ? (
@@ -826,6 +875,12 @@ export function PlanCard({
{variant === 'owner' &&
}
+ {variant === 'marketplace' && token.decimals == null && (
+
+ This token is not configured for this network. Amounts are shown in raw units.
+
+ )}
+
{plan.address}
@@ -934,7 +989,13 @@ export function PlanCard({
>
)}
{variant === 'marketplace' && (
-
+
)}
>
);
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 && (
+
+ )}
+
+
+
+
+ {error &&
{error}
}
+
+ );
+}
diff --git a/webapp/src/hooks/use-delegations.ts b/webapp/src/hooks/use-delegations.ts
index a2dadb1..59cd9b7 100644
--- a/webapp/src/hooks/use-delegations.ts
+++ b/webapp/src/hooks/use-delegations.ts
@@ -5,6 +5,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useClusterConfig } from '@/hooks/use-cluster-config';
import { useProgramAddress } from '@/hooks/use-token-config';
+import { groupDelegations } from '@/lib/delegation-filters';
export interface DelegationData {
amount: bigint;
@@ -19,6 +20,7 @@ export interface DelegationData {
payer: string;
version: number;
};
+ mint: string;
periodLengthS: bigint;
}
@@ -65,11 +67,7 @@ async function fetchDelegationsByRole(
const delegations = await fetchFn(rpc, address(walletAddress), address(progAddr));
const all = delegations.map(toDelegationItem).filter((d): d is DelegationItem => 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/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-program-deploy.ts b/webapp/src/hooks/use-program-deploy.ts
index 4f548d1..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,
@@ -58,6 +59,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);
@@ -81,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;
@@ -89,14 +100,19 @@ 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) {
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,
+ programAddress,
rpcUrl,
});
const bufferKpSigner = await createKeypairSigner(new Uint8Array(plan.bufferKeypair));
@@ -261,11 +277,24 @@ 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,
bufferKpSigner: KeyPairSigner,
isUpgrade: boolean,
+ programKeypairBytes?: Uint8Array,
) {
const freshBlockhash = (await rpc.getLatestBlockhash().send()).value;
const programAddr = address(plan.programAddress);
@@ -282,8 +311,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,
@@ -342,32 +374,53 @@ 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, authority);
+ const closeTx = buildV0Tx(signer, latestBlockhash, [closeIx]);
+ const signedCloseTx = await signTransactionMessageWithSigners(closeTx);
+ await rpc
+ .sendTransaction(getBase64EncodedWireTransaction(signedCloseTx), { encoding: 'base64' })
+ .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;
+ 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');
},
});
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;
@@ -393,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);
+ 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);
diff --git a/webapp/src/hooks/use-subscriptions-mutations.ts b/webapp/src/hooks/use-subscriptions-mutations.ts
index c5724b1..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);
@@ -427,10 +455,12 @@ export function useSubscriptionsMutations() {
expectedAmount,
expectedPeriodHours,
expectedCreatedAt,
+ expectedSubscriptionAuthorityInitId,
}: {
expectedAmount: bigint;
expectedCreatedAt: bigint;
expectedPeriodHours: bigint;
+ expectedSubscriptionAuthorityInitId: bigint;
merchant: string;
planId: bigint;
tokenMint: string;
@@ -442,6 +472,7 @@ export function useSubscriptionsMutations() {
expectedAmount,
expectedCreatedAt,
expectedPeriodHours,
+ expectedSubscriptionAuthorityInitId,
merchant: address(merchant),
planId,
programAddress: progId,
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/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/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 ({
+ 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/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/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/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/lib/token-display.ts b/webapp/src/lib/token-display.ts
new file mode 100644
index 0000000..2d8863a
--- /dev/null
+++ b/webapp/src/lib/token-display.ts
@@ -0,0 +1,57 @@
+import type { TokenConfig } from '@/config/networks';
+
+export interface PlanTokenDisplay {
+ decimals: number | null;
+ mint: string;
+ name: string;
+ symbol: string;
+}
+
+export function resolvePlanTokenDisplay(mint: string, tokens: readonly TokenConfig[] | undefined): PlanTokenDisplay {
+ const token = tokens?.find(t => t.mint === mint);
+ if (token) return token;
+
+ return {
+ decimals: null,
+ mint,
+ name: 'Unknown token',
+ symbol: 'Unknown 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.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/src/routes/setup.tsx b/webapp/src/routes/setup.tsx
index 98a6c55..d99675c 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);
@@ -518,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;
}
@@ -530,11 +532,16 @@ 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 });
- log('success', 'Program deployed successfully');
- if (deployResult?.programAddress) {
- setResult({ programId: deployResult.programAddress, usdcMint: '' });
+ const deployResult = await programDeploy.mutateAsync({
+ isUpgrade: false,
+ programAddress: programKeypair?.programAddress,
+ programKeypairBytes: programKeypair?.bytes,
+ });
+ 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) {
@@ -542,15 +549,21 @@ function DevnetWizard({ onComplete, onBack }: { onComplete: () => void; onBack:
log('error', `Deploy failed: ${msg}`);
markStepError('deploy-program', msg);
}
- }, [programStatus.data, programDeploy, 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);
@@ -569,7 +582,6 @@ function DevnetWizard({ onComplete, onBack }: { onComplete: () => void; onBack:
txToast,
multisigAddress,
result,
- configProgramAddress,
markStepDone,
markStepError,
markStepRunning,
@@ -847,9 +859,14 @@ function DevnetWizard({ onComplete, onBack }: { onComplete: () => void; onBack:
{phase === 'deploy' && (
+
@@ -911,6 +928,14 @@ function DevnetWizard({ onComplete, onBack }: { onComplete: () => void; onBack:
Optional
+ {result?.programId && (
+
+ Target:{' '}
+
+ {truncateAddress(result.programId, 12)}
+
+
+ )}
setMultisigAddress(e.target.value)}
@@ -919,7 +944,9 @@ function DevnetWizard({ onComplete, onBack }: { onComplete: () => void; onBack:
/>
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');
+ });
});
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,
+ );
+ });
+});
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);
+});
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]);
+});
diff --git a/webapp/test/token-display.test.ts b/webapp/test/token-display.test.ts
new file mode 100644
index 0000000..045b4af
--- /dev/null
+++ b/webapp/test/token-display.test.ts
@@ -0,0 +1,51 @@
+import assert from 'node:assert/strict';
+import { describe, test } from 'node:test';
+
+import {
+ formatPlanTokenAmount,
+ formatTokenAmount,
+ parseTokenAmount,
+ 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.decimals, 6);
+ 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.decimals, null);
+ 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');
+ });
+
+ 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/);
+ });
+});