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 ( - } radius="round" size="lg"> + } + radius="round" + size="lg" + > Create Delegation @@ -226,17 +257,25 @@ export function CreateDelegationDialog({ tokenMint, disabled }: CreateDelegation
) => setAmount(e.target.value)} - placeholder="100.00" + placeholder={token.decimals === 0 ? '100' : '100.00'} /> + {token.decimals == null && ( +

+ This token is not configured for the selected network. Delegation creation is + disabled. +

+ )}
{selectedKind === 'recurring' && ( diff --git a/webapp/src/components/plan/collect-payments-panel.tsx b/webapp/src/components/plan/collect-payments-panel.tsx index 4c15da4..933097d 100644 --- a/webapp/src/components/plan/collect-payments-panel.tsx +++ b/webapp/src/components/plan/collect-payments-panel.tsx @@ -6,7 +6,12 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { cn, USDC_MULTIPLIER, ellipsify, fmtDateTime } from '@/lib/utils'; import { ExplorerLink } from '@/components/cluster/cluster-ui'; import { useMyPlans, type PlanItem } from '@/hooks/use-plans'; -import { useSubscriberCounts, fetchPlanSubscriptions } from '@/hooks/use-subscriptions'; +import { + fetchPlanSubscriptions, + getLivePlanSubscribers, + resolvePlanSubscriberAuthorities, + useSubscriberCounts, +} from '@/hooks/use-subscriptions'; import { useSubscriptionsMutations } from '@/hooks/use-subscriptions-mutations'; import { useClusterConfig } from '@/hooks/use-cluster-config'; import { useProgramAddress } from '@/hooks/use-token-config'; @@ -78,7 +83,14 @@ function CollectPlanCard({ const handleCollect = useCallback(async () => { setIsCollecting(true); try { - const subscribers = await fetchPlanSubscriptions(rpcUrl, plan.address, progAddr); + const subscribers = getLivePlanSubscribers( + await resolvePlanSubscriberAuthorities( + rpcUrl, + await fetchPlanSubscriptions(rpcUrl, plan.address, progAddr), + plan.data.mint, + progAddr, + ), + ); const ts = await getBlockTimestamp(rpcUrl); const eligible = computeEligibleSubscribers(subscribers, plan.data.terms, ts); const currentSubscriberCount = subscribers.filter(sub => hasMatchingPlanTerms(sub, plan.data.terms)).length; diff --git a/webapp/src/components/plan/create-plan-dialog.tsx b/webapp/src/components/plan/create-plan-dialog.tsx index 96b04ed..2996abd 100644 --- a/webapp/src/components/plan/create-plan-dialog.tsx +++ b/webapp/src/components/plan/create-plan-dialog.tsx @@ -5,11 +5,12 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from ' import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { useSubscriptionsMutations } from '@/hooks/use-subscriptions-mutations'; -import { useUsdcMint } from '@/hooks/use-token-config'; -import { cn, USDC_MULTIPLIER } from '@/lib/utils'; +import { useTokenConfig } from '@/hooks/use-token-config'; +import { cn, ellipsify } from '@/lib/utils'; import { getBlockTimestamp } from '@/hooks/use-time-travel'; import { useClusterConfig } from '@/hooks/use-cluster-config'; import { PLAN_ICONS } from '@/lib/plan-constants'; +import { parseTokenAmount } from '@/lib/token-display'; const PLAN_TEMPLATES = [ { @@ -72,11 +73,25 @@ export function CreatePlanDialog({ open, onOpenChange }: CreatePlanDialogProps) const [endHour, setEndHour] = useState('12'); const [destinations, setDestinations] = useState([]); const [pullers, setPullers] = useState([]); + const [selectedMint, setSelectedMint] = useState(''); const { createPlan } = useSubscriptionsMutations(); - const usdcMint = useUsdcMint(); + const { data: tokens } = useTokenConfig(); const { url: rpcUrl } = useClusterConfig(); const [blockTime, setBlockTime] = useState(); + const defaultToken = tokens?.[0] ?? null; + const selectedToken = useMemo( + () => tokens?.find(token => token.mint === selectedMint) ?? defaultToken, + [defaultToken, selectedMint, tokens], + ); + const isAmountValid = useMemo(() => { + if (!selectedToken) return false; + try { + return parseTokenAmount(amount, selectedToken.decimals) > 0n; + } catch { + return false; + } + }, [amount, selectedToken]); useEffect(() => { if (open) @@ -112,6 +127,7 @@ export function CreatePlanDialog({ open, onOpenChange }: CreatePlanDialogProps) setEndHour('12'); setDestinations([]); setPullers([]); + setSelectedMint(''); }; const handleOpenChange = (next: boolean) => { @@ -153,17 +169,17 @@ export function CreatePlanDialog({ open, onOpenChange }: CreatePlanDialogProps) planName.length > 0 && description.length > 0 && selectedIcon.length > 0 && - Number(amount) > 0 && + isAmountValid && periodHours >= 1 && metadataBytes <= 128 && - usdcMint !== null && + selectedToken !== null && isEndDateValid; const handleSubmit = async () => { - if (!usdcMint) return; + if (!selectedToken) return; const planId = crypto.getRandomValues(new BigUint64Array(1))[0]; - const amountInSmallestUnits = BigInt(Math.round(Number(amount) * USDC_MULTIPLIER)); + const amountInSmallestUnits = parseTokenAmount(amount, selectedToken.decimals); const endTsRaw = endDate ? Math.floor(new Date(`${endDate}T${endHour.padStart(2, '0')}:00:00`).getTime() / 1000) : 0; @@ -175,7 +191,7 @@ export function CreatePlanDialog({ open, onOpenChange }: CreatePlanDialogProps) await createPlan.mutateAsync( { planId, - mint: usdcMint, + mint: selectedToken.mint, amount: amountInSmallestUnits, periodHours, endTs, @@ -336,16 +352,35 @@ export function CreatePlanDialog({ open, onOpenChange }: CreatePlanDialogProps) id="plan-amount" type="number" min="0" - step="0.01" + step={selectedToken?.decimals === 0 ? '1' : 'any'} value={amount} onChange={(e: React.ChangeEvent) => setAmount(e.target.value)} - placeholder="9.99" + placeholder={selectedToken?.decimals === 0 ? '10' : '9.99'} className="flex-1" /> - - USDC - +
+ {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/); + }); +});