diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 697102c8a..3c2192325 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -731,9 +731,12 @@ jobs: # recipients-vs-splits) — the integration radar for the fabricated-deposit # class. boot-policy asserts every SDK's server/adapter fails CLOSED when it # would default to an in-memory replay store off-localnet without the - # PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE opt-in; it self-skips any SDK whose - # toolchain is absent in this Node job (Go/Python get full coverage in the - # multi-toolchain harness.yml legs), so it degrades rather than false-greens. + # PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE opt-in. The TypeScript high-level + # probe is mandatory in this job. A covered probe is REQUIRED only once its + # SDK carries the fail-closed guard marker in-tree (boot-policy git-greps + # the SDK source): Go+TS ship it, so they run wherever their toolchain is + # present; Python asserts-SKIP until its remediation lands, then the same + # grep auto-promotes it to a required probe in harness.yml. # Selection is by the MPP_HARNESS_* env selectors alone (the old free-text # --testNamePattern matched every one of these pairs, so it was redundant). # assert-run-count pins the executed pair count so a title rename or a diff --git a/.github/workflows/harness.yml b/.github/workflows/harness.yml index 16b3ee344..5c283d6c7 100644 --- a/.github/workflows/harness.yml +++ b/.github/workflows/harness.yml @@ -13,7 +13,7 @@ name: Harness # └─ … ← legs reuse the build's mpp dist + cargo cache # # Each leg adds only its own toolchain + adapter build + smoke run; the -# duplicated pnpm/node + build-mpp + build-rust + install + typecheck lives once +# duplicated pnpm/node + @solana/mpp + Rust build/install/typecheck lives once # in the build job (see .github/actions/setup-harness and setup-harness-leg). permissions: diff --git a/Justfile b/Justfile index dfda31e98..6276115c8 100644 --- a/Justfile +++ b/Justfile @@ -52,8 +52,23 @@ subscriptions-generate-rs: codegen-install -exec perl -i -pe 's/\bcrate::(?!generated::subscriptions::)/crate::generated::subscriptions::/g' {} + cd rust && cargo fmt -p solana-pay-kit -# Full refresh: pull IDL + regenerate Rust client. -subscriptions-sync: subscriptions-pull-idl subscriptions-generate-rs +subscriptions-generate-ts: codegen-install + cd {{codegen_dir}} && pnpm run subscriptions:ts + cd typescript && pnpm exec prettier --write "packages/mpp/src/generated/subscriptions/**/*.ts" + +# Full refresh: pull the pinned IDL and regenerate every checked-in client. +subscriptions-sync: subscriptions-pull-idl subscriptions-generate-rs subscriptions-generate-ts + +# Regenerate from the vendored IDL and fail when checked-in clients were stale. +subscriptions-check: + @set -eu; \ + tmp=$(mktemp -d); \ + trap 'rm -rf "$tmp"' EXIT; \ + cp -R rust/crates/kit/src/generated/subscriptions/generated "$tmp/rust"; \ + cp -R typescript/packages/mpp/src/generated/subscriptions "$tmp/typescript"; \ + just subscriptions-generate-rs subscriptions-generate-ts; \ + diff -ru "$tmp/rust" rust/crates/kit/src/generated/subscriptions/generated; \ + diff -ru "$tmp/typescript" typescript/packages/mpp/src/generated/subscriptions # Fetch the payment-channels IDL from the pinned upstream commit into # `idl/payment-channels.json`. diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 748de4516..062d4ddf2 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -8412,6 +8412,7 @@ dependencies = [ "solana-pubkey 3.0.0", "solana-rpc-client", "solana-rpc-client-api", + "solana-sanitize 3.0.1", "solana-signature", "solana-signer", "solana-system-interface 2.0.0", diff --git a/rust/crates/kit/Cargo.toml b/rust/crates/kit/Cargo.toml index 6accf7fc8..e7def4bac 100644 --- a/rust/crates/kit/Cargo.toml +++ b/rust/crates/kit/Cargo.toml @@ -89,6 +89,7 @@ solana-rpc-client = { version = "4", default-features = false } # (whose response context carries the slot embedded as `recentSlot`). Always # on — it is already a transitive dep of solana-rpc-client. solana-rpc-client-api = { version = "4", default-features = false } +solana-sanitize = { version = "3", default-features = false } solana-signature = { version = "3.1", default-features = false, features = ["default", "verify"] } solana-system-interface = { version = "2.0", default-features = false } solana-transaction = { version = "3", default-features = false } diff --git a/rust/crates/kit/src/core/blockhash.rs b/rust/crates/kit/src/core/blockhash.rs index d5b826631..30bd44155 100644 --- a/rust/crates/kit/src/core/blockhash.rs +++ b/rust/crates/kit/src/core/blockhash.rs @@ -11,7 +11,8 @@ //! to a direct RPC fetch only when the cache is empty or stale, so correctness //! never depends on the refresher having run. -use std::sync::{Arc, RwLock}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock, RwLock}; use std::time::{Duration, Instant}; use solana_commitment_config::CommitmentConfig; @@ -83,6 +84,48 @@ impl BlockhashCache { let (entry, stamped_at) = guard.as_ref()?; (stamped_at.elapsed() < MAX_AGE).then(|| entry.clone()) } + + /// Return a cached blockhash if one is present and younger than [`MAX_AGE`], + /// otherwise fetch a fresh one over `rpc`, cache it, and return it. + /// + /// Fail-closed: a failed RPC fetch propagates the error rather than serving + /// stale data, and [`Self::get`] never yields an entry past the TTL — so a + /// caller building a transaction always gets a blockhash within the cache's + /// freshness bound or a hard error. + pub fn get_or_fetch( + &self, + rpc: &RpcClient, + commitment: CommitmentConfig, + ) -> Result { + if let Some(cached) = self.get() { + return Ok(cached); + } + let fetched = fetch_blockhash_with_slot(rpc, commitment)?; + self.set( + fetched.blockhash.clone(), + fetched.last_valid_block_height, + fetched.slot, + ); + Ok(fetched) + } +} + +/// Return the process-wide blockhash cache for `rpc_url`, creating it on first +/// use. Client-side builders that hold no injected cache (the subscription +/// activation path) share one memo per endpoint so repeated and concurrent +/// activation bursts collapse into one `getLatestBlockhash` within the TTL +/// window. Keyed by URL so a process talking to two networks never serves one +/// network's blockhash to the other. +/// +/// ponytail: global per-URL memo; two simultaneous misses can still each fetch +/// (no single-flight), matching the existing cache's semantics. Inject a +/// [`BlockhashCache`] directly (as the server builders do) if a caller needs +/// isolation or strict coalescing. +pub fn shared_blockhash_cache(rpc_url: &str) -> BlockhashCache { + static REGISTRY: OnceLock>> = OnceLock::new(); + let registry = REGISTRY.get_or_init(|| Mutex::new(HashMap::new())); + let mut guard = registry.lock().unwrap_or_else(|e| e.into_inner()); + guard.entry(rpc_url.to_owned()).or_default().clone() } /// Fetch the latest blockhash **and the slot it was observed at** in one RPC @@ -111,3 +154,39 @@ pub fn fetch_blockhash_with_slot( slot: response.context.slot, }) } + +#[cfg(test)] +mod tests { + use super::*; + + /// A cache hit must be served without ever touching the RPC endpoint — the + /// dummy client below points at an unroutable port and would error if used. + #[test] + fn get_or_fetch_serves_cache_hit_without_rpc() { + let cache = BlockhashCache::new(); + cache.set("HashOnCacheHit".to_owned(), 123, 456); + let rpc = RpcClient::new("http://127.0.0.1:1".to_owned()); + let got = cache + .get_or_fetch(&rpc, CommitmentConfig::confirmed()) + .expect("cache hit must not fetch"); + assert_eq!(got.blockhash, "HashOnCacheHit"); + assert_eq!(got.last_valid_block_height, 123); + assert_eq!(got.slot, 456); + } + + /// The shared registry returns handles onto one inner cell per URL, so a + /// value stored through one handle is visible through another for the same + /// URL and invisible across URLs. + #[test] + fn shared_cache_is_per_url() { + let url = "http://shared-cache-test.invalid/rpc"; + shared_blockhash_cache(url).set("SharedHash".to_owned(), 1, 2); + assert_eq!( + shared_blockhash_cache(url).get().map(|c| c.blockhash), + Some("SharedHash".to_owned()), + ); + assert!(shared_blockhash_cache("http://other.invalid/rpc") + .get() + .is_none()); + } +} diff --git a/rust/crates/kit/src/mpp/client/mod.rs b/rust/crates/kit/src/mpp/client/mod.rs index 4e6fa3384..ff9cf49be 100644 --- a/rust/crates/kit/src/mpp/client/mod.rs +++ b/rust/crates/kit/src/mpp/client/mod.rs @@ -30,6 +30,7 @@ pub use session::{ pub use session_consumer::*; pub use subscription::{ build_subscription_activation_transaction, - build_subscription_activation_transaction_with_options, BuildSubscriptionActivationOptions, - SubscriptionMethodDetails, + build_subscription_activation_transaction_with_init_id, + build_subscription_activation_transaction_with_options, initialize_subscription_authority, + BuildSubscriptionActivationOptions, SubscriptionMethodDetails, }; diff --git a/rust/crates/kit/src/mpp/client/subscription.rs b/rust/crates/kit/src/mpp/client/subscription.rs index 4e2ea6982..571cf5588 100644 --- a/rust/crates/kit/src/mpp/client/subscription.rs +++ b/rust/crates/kit/src/mpp/client/subscription.rs @@ -2,8 +2,8 @@ //! //! Builds the activation transaction defined by `draft-solana-subscription-00`: //! -//! `[compute_budget_*, initialize_subscription_authority?, subscribe, -//! transfer_subscription, memo(externalId)?]` +//! `[compute_budget_*, create_subscriber_ata, create_recipient_ata, +//! subscribe, transfer_subscription, memo(externalId)?]` //! //! The transaction is signed (or partially signed when the server is fee //! payer) and returned as a `CredentialPayload::Transaction` ready to send @@ -23,7 +23,7 @@ use crate::mpp::error::Error; use crate::mpp::program::subscriptions::{ default_program_id, find_subscription_authority_pda, find_subscription_pda, parse_pubkey, }; -use crate::mpp::protocol::solana::CredentialPayload; +use crate::mpp::protocol::solana::{programs, CredentialPayload}; /// Raw byte length of the on-chain `SubscriptionAuthority` PDA. Mirrors /// `#[repr(C, packed)]` layout: discriminator(1) + user(32) + token_mint(32) @@ -34,20 +34,31 @@ const SUBSCRIPTION_AUTHORITY_INIT_ID_OFFSET: usize = 1 + 32 + 32 + 32 + 1; pub use crate::mpp::protocol::intents::SubscriptionMethodDetails; +/// The documented safe compute budget accepted by fee-sponsored activation +/// servers. Keep the client default at or below that cap so a no-options +/// public activation is always co-signable. +const DEFAULT_ACTIVATION_COMPUTE_UNIT_LIMIT: u32 = 200_000; + /// Options for building a Solana subscription activation transaction. #[derive(Debug, Clone, Default)] pub struct BuildSubscriptionActivationOptions { + /// Opt-in: sign for an unknown Token-2022 mint. + /// + /// Token-2022 supports transfer hooks that run arbitrary program code on + /// every transfer. The client refuses unknown Token-2022 mints unless the + /// caller explicitly accepts that risk. Arbitrary mints on the canonical + /// Token Program remain allowed because it has no transfer hooks. + pub allow_unknown_token_2022: bool, /// Optional memo with the merchant's external reference, embedded as a /// trailing memo instruction. pub external_id: Option, - /// Compute unit limit. Defaults to 400,000 (activation includes up to - /// three subscriptions-program instructions plus token transfers). + /// Compute unit limit. Defaults to the documented safe sponsored cap of + /// 200,000 compute units. pub compute_unit_limit: Option, /// Compute unit price in microlamports. Defaults to 1. pub compute_unit_price: Option, - /// Pre-resolved `SubscriptionAuthority::init_id`. When set, the builder - /// skips the on-chain SA lookup and the init-tx broadcast — useful for - /// tests, and for callers that have already resolved the SA state. + /// Pre-resolved `SubscriptionAuthority::init_id`. When omitted, the legacy + /// compatibility path initializes or reads the authority before building. pub subscription_authority_init_id: Option, } @@ -71,6 +82,31 @@ pub async fn build_subscription_activation_transaction( .await } +/// Build an activation transaction from an explicitly resolved authority init id. +/// +/// Unlike the compatibility wrapper, this function never broadcasts an +/// initialization transaction. +pub async fn build_subscription_activation_transaction_with_init_id( + signer: &dyn SolanaSigner, + rpc: &RpcClient, + method_details: &SubscriptionMethodDetails, + subscription_authority_init_id: i64, +) -> Result { + build_subscription_activation_transaction_with_options( + signer, + rpc, + method_details, + BuildSubscriptionActivationOptions { + allow_unknown_token_2022: false, + external_id: None, + compute_unit_limit: None, + compute_unit_price: None, + subscription_authority_init_id: Some(subscription_authority_init_id), + }, + ) + .await +} + /// Build the subscription activation transaction with additional options. pub async fn build_subscription_activation_transaction_with_options( signer: &dyn SolanaSigner, @@ -86,8 +122,10 @@ pub async fn build_subscription_activation_transaction_with_options( let subscriber = signer.pubkey(); let mint = parse_pubkey(&method_details.mint, "mint")?; let token_program = parse_pubkey(&method_details.token_program, "tokenProgram")?; + validate_subscription_token_program(&mint, &token_program, options.allow_unknown_token_2022)?; let plan_pda = parse_pubkey(&method_details.plan_id, "planId")?; let puller = parse_pubkey(&method_details.puller, "puller")?; + let fee_payer = resolve_fee_payer(method_details, puller)?; // Plan owner — defaults to the puller when the operator publishes // its own plan and is its own puller (the common pay-server case). let merchant = match method_details.merchant.as_deref() { @@ -109,7 +147,7 @@ pub async fn build_subscription_activation_transaction_with_options( // `[owner, token_program, mint]`. Both SPL Token and Token-2022 share // the same associated-token-program ID. let associated_token_program = parse_pubkey( - "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL", + programs::ASSOCIATED_TOKEN_PROGRAM, "associated_token_program", )?; @@ -160,7 +198,9 @@ pub async fn build_subscription_activation_transaction_with_options( options.compute_unit_price.unwrap_or(1), )); instructions.push(compute_unit_limit_ix( - options.compute_unit_limit.unwrap_or(400_000), + options + .compute_unit_limit + .unwrap_or(DEFAULT_ACTIVATION_COMPUTE_UNIT_LIMIT), )); // ATA bootstrap. The on-chain `init_subscription_authority` (and @@ -171,13 +211,7 @@ pub async fn build_subscription_activation_transaction_with_options( // `CreateIdempotent` is a no-op when the ATA is already in place, // so we always prepend it. Rent is paid by the fee_payer when // sponsorship is on, otherwise by the subscriber. - let ata_funder = match ( - method_details.fee_payer, - method_details.fee_payer_key.as_deref(), - ) { - (true, Some(k)) => parse_pubkey(k, "feePayerKey")?, - _ => subscriber, - }; + let ata_funder = fee_payer.unwrap_or(subscriber); instructions.push(build_create_idempotent_ata_ix( ata_funder, subscriber_ata, @@ -186,58 +220,64 @@ pub async fn build_subscription_activation_transaction_with_options( token_program, associated_token_program, )); + instructions.push(build_create_idempotent_ata_ix( + ata_funder, + recipient_ata, + recipient, + mint, + token_program, + associated_token_program, + )); - // The on-chain `Subscribe` instruction binds the subscriber's signature - // to a specific `SubscriptionAuthority::init_id`, set from `Clock::slot` - // at SA creation. If the SA is created in the same tx as `Subscribe`, - // the client can't predict the landing slot — so SA must exist (and - // we must read its `init_id`) before signing the activation tx. When - // missing, broadcast a one-off init tx as the subscriber, paid by the - // subscriber (~0.002 SOL rent + ~5k lamports fee). - let blockhash_str = method_details.recent_blockhash.as_deref().ok_or_else(|| { - Error::Other( - "Challenge is missing methodDetails.recentBlockhash — the server failed \ - to pre-fetch one. Check the server's operator.rpc_url config." - .into(), - ) - })?; - let blockhash: solana_hash::Hash = blockhash_str - .parse() - .map_err(|e| Error::Other(format!("Invalid recentBlockhash: {e}")))?; - - let expected_subscription_authority_init_id = match options.subscription_authority_init_id { - Some(id) => id, + // Validate a server-provided hash before doing any implicit authority + // initialization. If initialization creates the authority, the supplied + // hash is deliberately discarded below: the init transaction consumes a + // blockhash first, so reusing the challenge hash can leave activation with + // an already-expired or otherwise stale lifetime. + let provided_blockhash = parse_recent_blockhash(method_details)?; + let (expected_subscription_authority_init_id, blockhash) = match options + .subscription_authority_init_id + { + Some(init_id) => { + let blockhash = match provided_blockhash { + Some(blockhash) => blockhash, + None => cached_recent_blockhash(rpc)?, + }; + (init_id, blockhash) + } None => { - ensure_subscription_authority_init_id( + let (init_id, initialized) = initialize_subscription_authority_with_state( signer, rpc, - program_id, - subscriber, - mint, - subscriber_ata, - subscription_authority, - token_program, - &blockhash, + method_details, + options.allow_unknown_token_2022, ) - .await? + .await?; + let blockhash = if initialized { + // Deliberately uncached and direct: the init tx we just + // broadcast consumed a blockhash, so activation must fetch a + // fresh one (see the note above). A shared-cache hit could hand + // back the init tx's own blockhash and leave activation with an + // already-stale lifetime. + rpc.get_latest_blockhash().map_err(|e| { + Error::Other(format!( + "Failed to fetch fresh activation blockhash after SubscriptionAuthority init: {e}" + )) + })? + } else { + match provided_blockhash { + Some(blockhash) => blockhash, + None => cached_recent_blockhash(rpc)?, + } + }; + (init_id, blockhash) } }; // Optional rent payer for the subscribe ix: fee_payer when // configured, otherwise the subscriber pays its own rent (no extra // account meta). - let subscribe_payer = if method_details.fee_payer { - match method_details.fee_payer_key.as_deref() { - Some(k) => Some(parse_pubkey(k, "feePayerKey")?), - None => { - return Err(Error::Other( - "feePayer=true requires feePayerKey in methodDetails".into(), - )); - } - } - } else { - None - }; + let subscribe_payer = fee_payer; instructions.push(crate::mpp::program::subscriptions::build_subscribe_ix( program_id, @@ -288,20 +328,10 @@ pub async fn build_subscription_activation_transaction_with_options( } // Fee payer + blockhash. - let fee_payer_pubkey = if method_details.fee_payer { - method_details - .fee_payer_key - .as_deref() - .map(|k| parse_pubkey(k, "feePayerKey")) - .transpose()? - .unwrap_or(subscriber) - } else { - subscriber - }; + let fee_payer_pubkey = fee_payer.unwrap_or(subscriber); - // Blockhash was already parsed above for the SA-init pre-step; reuse it - // for the activation tx. Both transactions share the same recentBlockhash - // so they land in the same ~150-slot window. + // The activation transaction uses the hash resolved after any implicit + // authority initialization, so its lifetime starts after the init step. let message = Message::new_with_blockhash(&instructions, Some(&fee_payer_pubkey), &blockhash); let mut tx = Transaction::new_unsigned(message); @@ -329,31 +359,60 @@ pub async fn build_subscription_activation_transaction_with_options( Ok(CredentialPayload::Transaction { transaction: b64 }) } -/// Resolve the `SubscriptionAuthority::init_id` the activation tx must -/// reference, broadcasting a one-off subscriber-signed init tx when the SA -/// PDA hasn't been created yet. +/// Explicitly initialize the subscriber's subscription authority when absent, +/// then return the live `init_id` required by the activation builder. /// /// The init tx is intentionally paid by the subscriber (no fee-payer /// trailing account): the on-chain `init` records the funder as the rent /// recipient on close, and we want that to be the subscriber so they get /// their rent back when they tear the SA down later. Subscribers must /// hold ~0.002 SOL the first time they subscribe with a given mint. -#[allow(clippy::too_many_arguments)] -async fn ensure_subscription_authority_init_id( +pub async fn initialize_subscription_authority( signer: &dyn SolanaSigner, rpc: &RpcClient, - program_id: Pubkey, - subscriber: Pubkey, - mint: Pubkey, - subscriber_ata: Pubkey, - subscription_authority: Pubkey, - token_program: Pubkey, - blockhash: &solana_hash::Hash, + method_details: &SubscriptionMethodDetails, ) -> Result { - if let Ok(account) = rpc.get_account(&subscription_authority) { - return parse_subscription_authority_init_id(&account.data); + initialize_subscription_authority_with_state(signer, rpc, method_details, false) + .await + .map(|(init_id, _)| init_id) +} + +async fn initialize_subscription_authority_with_state( + signer: &dyn SolanaSigner, + rpc: &RpcClient, + method_details: &SubscriptionMethodDetails, + allow_unknown_token_2022: bool, +) -> Result<(i64, bool), Error> { + let program_id = match method_details.program_id.as_deref() { + Some(p) => parse_pubkey(p, "programId")?, + None => default_program_id(), + }; + let subscriber = signer.pubkey(); + let mint = parse_pubkey(&method_details.mint, "mint")?; + let token_program = parse_pubkey(&method_details.token_program, "tokenProgram")?; + validate_subscription_token_program(&mint, &token_program, allow_unknown_token_2022)?; + let associated_token_program = parse_pubkey( + programs::ASSOCIATED_TOKEN_PROGRAM, + "associated_token_program", + )?; + let (subscriber_ata, _) = Pubkey::find_program_address( + &[subscriber.as_ref(), token_program.as_ref(), mint.as_ref()], + &associated_token_program, + ); + let (subscription_authority, _) = + find_subscription_authority_pda(&subscriber, &mint, &program_id); + let authority_account = rpc + .get_account_with_commitment( + &subscription_authority, + solana_commitment_config::CommitmentConfig::confirmed(), + ) + .map_err(|e| Error::Other(format!("Failed to read SubscriptionAuthority: {e}")))?; + if let Some(account) = authority_account.value { + return parse_subscription_authority_init_id(&account.data).map(|init_id| (init_id, false)); } + let blockhash = cached_recent_blockhash(rpc)?; + // SA doesn't exist — broadcast a subscriber-only-signed init tx so the // SA lands on-chain (and its `init_id` is recorded) before we sign the // activation tx. @@ -368,7 +427,16 @@ async fn ensure_subscription_authority_init_id( }, ); - let message = Message::new_with_blockhash(&[init_ix], Some(&subscriber), blockhash); + let create_ata_ix = build_create_idempotent_ata_ix( + subscriber, + subscriber_ata, + subscriber, + mint, + token_program, + associated_token_program, + ); + let message = + Message::new_with_blockhash(&[create_ata_ix, init_ix], Some(&subscriber), &blockhash); let mut tx = Transaction::new_unsigned(message); let sig_bytes = signer .sign_message(&tx.message_data()) @@ -383,12 +451,70 @@ async fn ensure_subscription_authority_init_id( )) })?; - let account = rpc.get_account(&subscription_authority).map_err(|e| { - Error::Other(format!( - "SubscriptionAuthority still missing after init broadcast: {e}" - )) + let account = rpc + .get_account_with_commitment( + &subscription_authority, + solana_commitment_config::CommitmentConfig::confirmed(), + ) + .map_err(|e| { + Error::Other(format!( + "Failed to read SubscriptionAuthority after init broadcast: {e}" + )) + })? + .value + .ok_or_else(|| { + Error::Other("SubscriptionAuthority still missing after init broadcast".into()) + })?; + parse_subscription_authority_init_id(&account.data).map(|init_id| (init_id, true)) +} + +/// A recent blockhash for a client-built transaction, served from the shared +/// per-endpoint blockhash cache so repeated or concurrent activations don't each +/// pay a `getLatestBlockhash` round-trip. Fail-closed: a failed fetch propagates +/// and the cache never returns an entry past its TTL, so the caller always gets +/// a blockhash within the freshness bound or a hard error. +fn cached_recent_blockhash(rpc: &RpcClient) -> Result { + let cached = crate::core::blockhash::shared_blockhash_cache(&rpc.url()) + .get_or_fetch(rpc, rpc.commitment()) + .map_err(|e| Error::Other(format!("Failed to fetch recent blockhash: {e}")))?; + cached + .blockhash + .parse() + .map_err(|e| Error::Other(format!("Invalid recent blockhash from cache: {e}"))) +} + +fn parse_recent_blockhash( + method_details: &SubscriptionMethodDetails, +) -> Result, Error> { + method_details + .recent_blockhash + .as_deref() + .map(|value| { + value + .parse() + .map_err(|e| Error::Other(format!("Invalid recentBlockhash: {e}"))) + }) + .transpose() +} + +fn resolve_fee_payer( + method_details: &SubscriptionMethodDetails, + puller: Pubkey, +) -> Result, Error> { + if !method_details.fee_payer { + return Ok(None); + } + let fee_payer_key = method_details.fee_payer_key.as_deref().ok_or_else(|| { + Error::Other("feePayer=true requires feePayerKey in methodDetails".into()) })?; - parse_subscription_authority_init_id(&account.data) + let fee_payer = parse_pubkey(fee_payer_key, "feePayerKey")?; + if fee_payer != puller { + return Err(Error::Other( + "feePayerKey must equal puller so the server signs the canonical transfer caller" + .into(), + )); + } + Ok(Some(fee_payer)) } /// Extract `init_id` (i64 LE) from a serialised `SubscriptionAuthority` @@ -486,6 +612,35 @@ fn compute_unit_limit_ix(units: u32) -> Instruction { } } +fn validate_subscription_token_program( + mint: &Pubkey, + token_program: &Pubkey, + allow_unknown_token_2022: bool, +) -> Result<(), Error> { + let token_program_str = token_program.to_string(); + if token_program_str != programs::TOKEN_PROGRAM + && token_program_str != programs::TOKEN_2022_PROGRAM + { + return Err(Error::Other(format!( + "Unsupported token program: {token_program}" + ))); + } + + let mint_str = mint.to_string(); + if token_program_str == programs::TOKEN_2022_PROGRAM + && !crate::mpp::protocol::solana::is_known_stablecoin_mint(&mint_str) + && !allow_unknown_token_2022 + { + return Err(Error::Other(format!( + "Refusing to sign for unknown Token-2022 mint {mint}: \ + set BuildSubscriptionActivationOptions::allow_unknown_token_2022 \ + to opt in (Token-2022 supports transfer hooks)" + ))); + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -551,35 +706,52 @@ mod tests { } } - /// Build helper for tests: pins `subscription_authority_init_id` to 0 - /// so the activation builder skips the on-chain SA lookup (the RPC mock - /// would otherwise try to broadcast an init tx and panic on the second - /// `getAccountInfo` returning AccountNotFound). - fn pinned_options( - extras: BuildSubscriptionActivationOptions, - ) -> BuildSubscriptionActivationOptions { + fn test_options() -> BuildSubscriptionActivationOptions { BuildSubscriptionActivationOptions { + allow_unknown_token_2022: false, + external_id: None, + compute_unit_limit: None, + compute_unit_price: None, subscription_authority_init_id: Some(0), - ..extras } } + #[test] + fn legacy_builder_signature_and_default_options_remain_available() { + let signer = make_signer(); + let rpc = RpcClient::new_mock("succeeds".to_string()); + let md = make_method_details(false, None); + let future = build_subscription_activation_transaction(&*signer, &rpc, &md); + drop(future); + assert!(BuildSubscriptionActivationOptions::default() + .subscription_authority_init_id + .is_none()); + } + #[tokio::test(flavor = "multi_thread")] - async fn builds_activation_tx_with_pinned_init_id() { + async fn public_activation_default_uses_server_safe_compute_limit() { let signer = make_signer(); let rpc = RpcClient::new_mock("succeeds".to_string()); let md = make_method_details(false, None); - let payload = build_subscription_activation_transaction_with_options( - &*signer, - &rpc, - &md, - pinned_options(BuildSubscriptionActivationOptions::default()), - ) - .await - .expect("activation tx"); + let payload = + build_subscription_activation_transaction_with_init_id(&*signer, &rpc, &md, 0) + .await + .expect("activation tx"); match payload { CredentialPayload::Transaction { transaction } => { assert!(!transaction.is_empty()); + let raw = base64::Engine::decode( + &base64::engine::general_purpose::STANDARD, + &transaction, + ) + .expect("base64 decode"); + let tx: Transaction = bincode::deserialize(&raw).expect("bincode tx"); + let limit_ix = &tx.message.instructions[1]; + assert_eq!(limit_ix.data[0], 2); + assert_eq!( + u32::from_le_bytes(limit_ix.data[1..5].try_into().expect("limit bytes")), + DEFAULT_ACTIVATION_COMPUTE_UNIT_LIMIT + ); } _ => panic!("expected Transaction payload"), } @@ -594,12 +766,13 @@ mod tests { &*signer, &rpc, &md, - pinned_options(BuildSubscriptionActivationOptions { + BuildSubscriptionActivationOptions { + allow_unknown_token_2022: false, external_id: Some("order-42".into()), compute_unit_limit: Some(123_456), compute_unit_price: Some(1_000), - ..Default::default() - }), + subscription_authority_init_id: Some(0), + }, ) .await .expect("activation tx"); @@ -611,12 +784,11 @@ mod tests { ) .expect("base64 decode"); let tx: Transaction = bincode::deserialize(&raw).expect("bincode tx"); - // [compute_price, compute_limit, ata_create_idempotent, - // subscribe, transfer, memo] = 6. Init runs as a separate - // pre-broadcast tx, not bundled into the activation. - assert_eq!(tx.message.instructions.len(), 6); + // [compute_price, compute_limit, subscriber ATA, recipient + // ATA, subscribe, transfer, memo] = 7. + assert_eq!(tx.message.instructions.len(), 7); // Last instruction must be the memo. - let last = &tx.message.instructions[5]; + let last = &tx.message.instructions[6]; let memo_program_id = Pubkey::from_str("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr").unwrap(); let last_program = tx.message.account_keys[last.program_id_index as usize]; @@ -636,7 +808,7 @@ mod tests { &*signer, &rpc, &md, - pinned_options(BuildSubscriptionActivationOptions::default()), + test_options(), ) .await .expect_err("missing feePayerKey"); @@ -647,13 +819,13 @@ mod tests { async fn fee_payer_true_with_fee_payer_key_sets_fee_payer_account() { let signer = make_signer(); let rpc = RpcClient::new_mock("succeeds".to_string()); - let fee_payer_key = "FeePayerJ7vuK99c7cFwqbixzL3bFrzPy9PUhCtDPAYJ"; + let fee_payer_key = "5fKb5cF22cFybZB1H4hLDydFhwoQy9JzKzRWaSbMkB6h"; let md = make_method_details(true, Some(fee_payer_key)); let payload = build_subscription_activation_transaction_with_options( &*signer, &rpc, &md, - pinned_options(BuildSubscriptionActivationOptions::default()), + test_options(), ) .await .expect("activation tx"); @@ -673,6 +845,22 @@ mod tests { } } + #[tokio::test(flavor = "multi_thread")] + async fn fee_payer_true_with_nonmatching_fee_payer_key_errors() { + let signer = make_signer(); + let rpc = RpcClient::new_mock("succeeds".to_string()); + let md = make_method_details(true, Some("FeePayerJ7vuK99c7cFwqbixzL3bFrzPy9PUhCtDPAYJ")); + let err = build_subscription_activation_transaction_with_options( + &*signer, + &rpc, + &md, + test_options(), + ) + .await + .expect_err("fee payer must match puller"); + assert!(format!("{err}").contains("feePayerKey must equal puller")); + } + #[tokio::test(flavor = "multi_thread")] async fn invalid_pubkey_in_method_details_surfaces_typed_error() { let signer = make_signer(); @@ -683,13 +871,95 @@ mod tests { &*signer, &rpc, &md, - pinned_options(BuildSubscriptionActivationOptions::default()), + test_options(), ) .await .expect_err("invalid mint"); assert!(format!("{err}").contains("mint")); } + #[tokio::test(flavor = "multi_thread")] + async fn rejects_server_selected_custom_token_program() { + let signer = make_signer(); + let rpc = RpcClient::new_mock("succeeds".to_string()); + let mut md = make_method_details(false, None); + md.token_program = Pubkey::new_unique().to_string(); + + let err = build_subscription_activation_transaction_with_options( + &*signer, + &rpc, + &md, + test_options(), + ) + .await + .expect_err("custom token program must be rejected"); + + assert!(format!("{err}").contains("Unsupported token program")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn rejects_unknown_token_2022_mint_without_opt_in() { + let signer = make_signer(); + let rpc = RpcClient::new_mock("succeeds".to_string()); + let mut md = make_method_details(false, None); + md.mint = Pubkey::new_unique().to_string(); + md.token_program = programs::TOKEN_2022_PROGRAM.into(); + + let err = build_subscription_activation_transaction_with_options( + &*signer, + &rpc, + &md, + test_options(), + ) + .await + .expect_err("unknown Token-2022 mint must require opt-in"); + + assert!(format!("{err}").contains("unknown Token-2022 mint")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn allows_unknown_token_2022_mint_with_explicit_opt_in() { + let signer = make_signer(); + let rpc = RpcClient::new_mock("succeeds".to_string()); + let mut md = make_method_details(false, None); + md.mint = Pubkey::new_unique().to_string(); + md.token_program = programs::TOKEN_2022_PROGRAM.into(); + + let payload = build_subscription_activation_transaction_with_options( + &*signer, + &rpc, + &md, + BuildSubscriptionActivationOptions { + allow_unknown_token_2022: true, + ..test_options() + }, + ) + .await + .expect("explicit opt-in permits unknown Token-2022 mint"); + + assert!(matches!(payload, CredentialPayload::Transaction { .. })); + } + + #[tokio::test(flavor = "multi_thread")] + async fn allows_known_token_2022_stablecoin() { + let signer = make_signer(); + let rpc = RpcClient::new_mock("succeeds".to_string()); + let mut md = make_method_details(false, None); + md.mint = crate::mpp::protocol::solana::mints::PYUSD_MAINNET.into(); + md.token_program = programs::TOKEN_2022_PROGRAM.into(); + + let payload = build_subscription_activation_transaction_with_options( + &*signer, + &rpc, + &md, + test_options(), + ) + .await + .expect("known Token-2022 stablecoin remains supported"); + + assert!(matches!(payload, CredentialPayload::Transaction { .. })); + } + #[tokio::test(flavor = "multi_thread")] async fn explicit_program_id_override_is_honored() { let signer = make_signer(); @@ -700,7 +970,7 @@ mod tests { &*signer, &rpc, &md, - pinned_options(BuildSubscriptionActivationOptions::default()), + test_options(), ) .await .expect("activation tx"); @@ -717,13 +987,30 @@ mod tests { &*signer, &rpc, &md, - pinned_options(BuildSubscriptionActivationOptions::default()), + test_options(), ) .await .expect_err("bad blockhash"); assert!(format!("{err}").contains("blockhash") || format!("{err}").contains("Invalid")); } + #[tokio::test(flavor = "multi_thread")] + async fn missing_recent_blockhash_falls_back_to_client_rpc() { + let signer = make_signer(); + let rpc = RpcClient::new_mock("succeeds".to_string()); + let mut md = make_method_details(false, None); + md.recent_blockhash = None; + let payload = build_subscription_activation_transaction_with_options( + &*signer, + &rpc, + &md, + test_options(), + ) + .await + .expect("client RPC blockhash fallback"); + assert!(matches!(payload, CredentialPayload::Transaction { .. })); + } + #[test] fn method_details_default_program_id_when_absent() { let value = serde_json::json!({ diff --git a/rust/crates/kit/src/mpp/server/subscription.rs b/rust/crates/kit/src/mpp/server/subscription.rs index cd2a321ff..ff93e688c 100644 --- a/rust/crates/kit/src/mpp/server/subscription.rs +++ b/rust/crates/kit/src/mpp/server/subscription.rs @@ -32,14 +32,17 @@ use std::sync::Arc; use solana_pubkey::Pubkey; +use solana_sanitize::Sanitize; use solana_signature::Signature; use solana_transaction::Transaction; use crate::mpp::error::Error; use crate::mpp::expires; use crate::mpp::program::subscriptions::{ - find_subscription_pda, parse_pubkey, INSTRUCTION_SUBSCRIBE, INSTRUCTION_TRANSFER_SUBSCRIPTION, - SUBSCRIPTIONS_PROGRAM_ID, + find_event_authority_pda, find_subscription_authority_pda, find_subscription_pda, parse_pubkey, + ASSOCIATED_TOKEN_PROGRAM_ID, COMPUTE_BUDGET_PROGRAM_ID, INSTRUCTION_SUBSCRIBE, + INSTRUCTION_TRANSFER_SUBSCRIPTION, MEMO_PROGRAM_ID, SUBSCRIPTIONS_PROGRAM_ID, + SYSTEM_PROGRAM_ID, }; use crate::mpp::protocol::core::{ compute_challenge_id, Base64UrlJson, PaymentChallenge, PaymentCredential, Receipt, ReceiptKind, @@ -49,9 +52,9 @@ use crate::mpp::protocol::intents::{ ActivatePayload, SubscriptionAction, SubscriptionPeriodUnit, SubscriptionReceiptExtensions, SubscriptionRequest, }; -use crate::mpp::protocol::solana::default_rpc_url; +use crate::mpp::protocol::solana::{default_rpc_url, programs}; use crate::mpp::server::charge::VerificationError; -use crate::mpp::store::{MemoryStore, Store}; +use crate::mpp::store::Store; const METHOD_NAME: &str = "solana"; const INTENT_NAME: &str = "subscription"; @@ -164,12 +167,11 @@ impl Default for SubscriptionConfig { #[derive(Clone)] pub struct SubscriptionServer { config: SubscriptionConfig, + merchant: String, program_id: String, challenge_binding_secret: String, realm: String, - #[allow(dead_code)] store: Arc, - #[allow(dead_code)] rpc_url: String, } @@ -178,6 +180,20 @@ impl SubscriptionServer { /// period bounds eagerly; misconfigured servers fail at boot, not on /// the first challenge. pub fn new(config: SubscriptionConfig) -> Result { + let merchant = config.puller.clone(); + Self::new_with_merchant(config, merchant) + } + + /// Create a server whose Plan owner differs from its delegated puller. + /// + /// This constructor preserves [`SubscriptionConfig`]'s source-compatible + /// shape while allowing the challenge and verifier to bind the canonical + /// `subscribe` merchant account independently from the puller. + pub fn new_with_merchant( + config: SubscriptionConfig, + merchant: impl Into, + ) -> Result { + let merchant = merchant.into(); if config.plan_id.is_empty() { return Err(Error::InvalidConfig("plan_id is required".into())); } @@ -201,14 +217,57 @@ impl SubscriptionServer { if config.realm.is_empty() { return Err(Error::InvalidConfig("realm is required".into())); } + if config.store.is_none() { + return Err(Error::InvalidConfig( + "subscription replay store with atomic put_if_absent is required".into(), + )); + } + if config.fee_payer + && (config.plan_id_numeric.is_none() + || config.plan_bump.is_none() + || config.plan_created_at.is_none()) + { + return Err(Error::InvalidConfig( + "fee-sponsored subscription requires plan_id_numeric, plan_bump, and plan_created_at snapshots".into(), + )); + } // Validate all pubkeys parse. parse_pubkey(&config.plan_id, "plan_id")?; parse_pubkey(&config.mint, "mint")?; parse_pubkey(&config.token_program, "token_program")?; - parse_pubkey(&config.puller, "puller")?; + let puller = parse_pubkey(&config.puller, "puller")?; + parse_pubkey(&merchant, "merchant")?; parse_pubkey(&config.recipient, "recipient")?; + if config.fee_payer { + let fee_payer = match config.fee_payer_pubkey.as_deref() { + Some(value) => parse_pubkey(value, "fee_payer_pubkey")?, + None => config + .fee_payer_signer + .as_ref() + .map(|signer| signer.pubkey()) + .ok_or_else(|| { + Error::InvalidConfig( + "fee-sponsored subscription requires a fee payer signer or pubkey" + .into(), + ) + })?, + }; + if fee_payer != puller { + return Err(Error::InvalidConfig( + "subscription fee payer must equal puller".into(), + )); + } + if let Some(signer) = config.fee_payer_signer.as_ref() { + if signer.pubkey() != fee_payer { + return Err(Error::InvalidConfig( + "subscription fee payer signer must equal fee payer pubkey".into(), + )); + } + } + } + // Validate the period mapping. config.period_unit.to_period_hours(config.period_count)?; @@ -221,10 +280,7 @@ impl SubscriptionServer { let challenge_binding_secret = config.challenge_binding_secret.clone(); let realm = config.realm.clone(); - let store: Arc = config - .store - .clone() - .unwrap_or_else(|| Arc::new(MemoryStore::new())); + let store = config.store.clone().expect("validated above"); let rpc_url = config .rpc_url @@ -233,6 +289,7 @@ impl SubscriptionServer { Ok(SubscriptionServer { config, + merchant, program_id, challenge_binding_secret, realm, @@ -290,13 +347,7 @@ impl SubscriptionServer { token_program: self.config.token_program.clone(), decimals: Some(self.config.decimals), puller: self.config.puller.clone(), - // The Plan owner / merchant. The server doesn't model this - // separately yet — pay-server uses operator.recipient (the - // puller) as the plan owner. When the operator publishes - // the plan they ARE the merchant; this is correct for the - // common case. Surface it explicitly in methodDetails so - // the client doesn't have to guess. - merchant: Some(self.config.puller.clone()), + merchant: Some(self.merchant.clone()), recipient: Some(self.config.recipient.clone()), amount: Some(amount_base_units.to_string()), program_id: Some(self.program_id.clone()), @@ -380,7 +431,10 @@ impl SubscriptionServer { credential.challenge.digest.as_deref(), credential.challenge.opaque.as_ref().map(|o| o.raw()), ); - if credential.challenge.id != expected_id { + if !crate::mpp::protocol::core::challenge::constant_time_eq( + &credential.challenge.id, + &expected_id, + ) { return Err(VerificationError::credential_mismatch( "Challenge HMAC mismatch — this server did not issue the echoed challenge", )); @@ -439,7 +493,15 @@ impl SubscriptionServer { } // ── Settle the activation transaction ──────────────────────────── - let (subscriber, activation_signature) = match payload_type { + // + // The `"transaction"` arm claims the replay marker atomically before + // broadcasting, then owns a reservation that MUST be released on every + // failure path (inside this arm and in the tail below) until a receipt + // is produced. The arm hands the claimed key back so the tail can + // release it on error; the other arms early-return and never claim. The + // happy path is disarmed explicitly at the end so a real replay stays + // rejected. + let (subscriber, activation_signature, claimed_key) = match payload_type { "transaction" => { let tx_b64 = activate.transaction.as_deref().ok_or_else(|| { VerificationError::invalid_payload( @@ -448,13 +510,73 @@ impl SubscriptionServer { })?; let mut tx = decode_base64_transaction(tx_b64)?; let subscriber = extract_subscriber_from_tx(&tx, &request, &self.config)?; - validate_activation_scope(&tx, &request, &self.program_id)?; + let activation_scope = validate_activation_scope_with_merchant( + &tx, + &request, + &self.program_id, + &self.config, + &self.merchant, + &subscriber, + )?; + + if let Some(expected_init_id) = activation_scope.expected_init_id { + let live_init_id = self + .fetch_subscription_authority_init_id(&activation_scope.authority) + .await?; + if live_init_id != expected_init_id { + return Err(VerificationError::credential_mismatch( + "Subscribe authority init_id does not match the live authority account", + )); + } + } if fee_payer_configured { co_sign_as_fee_payer(&mut tx, self.config.fee_payer_signer.as_ref().unwrap()) .await?; } + // Replay guard. The activation signature is the transaction's + // own first signature — the fee payer's (index 0) once the + // server co-signs, or the subscriber's otherwise — and it is + // exactly what `sendTransaction` echoes back. The key mirrors + // the TypeScript port (`solana-subscription:consumed:`) so + // a shared store rejects the replay across language runtimes. + let activation_sig = + tx.signatures + .first() + .map(|s| s.to_string()) + .ok_or_else(|| { + VerificationError::invalid_payload( + "Activation transaction has no signature", + ) + })?; + let consumed_key = format!("solana-subscription:consumed:{activation_sig}"); + + // Atomically claim the marker up front, before any RPC + // broadcast or receipt re-issuance. `put_if_absent` returns + // `false` when the key already exists, which means a prior (or + // concurrent) activation already owns this signature — reject + // the replay. A non-atomic get-then-put would let two + // concurrent identical activations both pass the check and both + // issue a server-signed receipt. On a winning claim the caller + // now owns the reservation and MUST release it on every failure + // path until a receipt is produced (below); broadcast itself is + // idempotent on-chain, so the marker's sole job is to prevent + // re-issuing a receipt for an already-settled activation. + let claimed = self + .store + .put_if_absent(&consumed_key, serde_json::json!(true)) + .await + .map_err(|e| VerificationError::network_error(e.to_string()))?; + if !claimed { + return Err(VerificationError::signature_consumed( + "Activation signature already consumed", + )); + } + // We own the reservation now; from here every failure path + // must release `consumed_key` via `release_on_err`. + let claimed_key = consumed_key; + // Idempotent broadcast: if the delegation PDA already exists // (a previous activation landed on-chain but the receipt // round-trip failed), skip the broadcast — the on-chain @@ -462,27 +584,65 @@ impl SubscriptionServer { // `AlreadySubscribed` (0x205) and abort the whole tx, // burying the actual outcome. The subsequent fetch + // terms-check on lines below catches any divergence. - let program_id = parse_pubkey(&self.program_id, "program_id") - .map_err(|e| VerificationError::new(e.to_string()))?; - let plan_pda = parse_pubkey(&self.config.plan_id, "plan_id") - .map_err(|e| VerificationError::new(e.to_string()))?; + // + // Any error between the claim above and the receipt release + // (parse, PDA fetch, broadcast) must release the marker so a + // transient RPC failure does not permanently brick this + // activation signature — `release_on_err` wraps each fallible + // step to guarantee that. + let program_id = release_on_err( + &*self.store, + &claimed_key, + parse_pubkey(&self.program_id, "program_id") + .map_err(|e| VerificationError::new(e.to_string())), + ) + .await?; + let plan_pda = release_on_err( + &*self.store, + &claimed_key, + parse_pubkey(&self.config.plan_id, "plan_id") + .map_err(|e| VerificationError::new(e.to_string())), + ) + .await?; let (delegation_pda, _) = find_subscription_pda(&plan_pda, &subscriber, &program_id); - let delegation_already_exists = self - .fetch_subscription_delegation(&delegation_pda) - .await - .is_ok(); + let delegation_already_exists = release_on_err( + &*self.store, + &claimed_key, + self.fetch_optional_subscription_delegation(&delegation_pda) + .await, + ) + .await? + .is_some(); let sig = if delegation_already_exists { - // Look up the original landing tx so the receipt can - // carry the signature instead of the bare PDA. - self.fetch_subscription_creation_signature(&delegation_pda) - .await - .ok() + let confirmed = release_on_err( + &*self.store, + &claimed_key, + self.is_signature_confirmed(&activation_sig).await, + ) + .await?; + if !confirmed { + let err = VerificationError::invalid_payload( + "Subscription already exists, but the exact submitted activation signature is not confirmed", + ); + let _ = self.store.delete(&claimed_key).await; + return Err(err); + } + Some(activation_sig) } else { - Some(self.broadcast_and_confirm(&tx).await?.to_string()) + Some( + release_on_err( + &*self.store, + &claimed_key, + self.broadcast_and_confirm(&tx).await, + ) + .await? + .to_string(), + ) }; - (subscriber, sig) + + (subscriber, sig, claimed_key) } "signature" => { // Push mode (client already broadcast). Not yet supported @@ -503,76 +663,51 @@ impl SubscriptionServer { }; // ── Derive subscription PDA and read on-chain state ────────────── - let program_id = parse_pubkey(&self.program_id, "program_id") - .map_err(|e| VerificationError::new(e.to_string()))?; - let plan_pda = parse_pubkey(&self.config.plan_id, "plan_id") - .map_err(|e| VerificationError::new(e.to_string()))?; + // Still inside the reservation window: any error here (PDA parse, + // on-chain fetch, terms mismatch) must release the marker so a + // legitimate client can retry, so every fallible step below is wrapped. + let program_id = release_on_err( + &*self.store, + &claimed_key, + parse_pubkey(&self.program_id, "program_id") + .map_err(|e| VerificationError::new(e.to_string())), + ) + .await?; + let plan_pda = release_on_err( + &*self.store, + &claimed_key, + parse_pubkey(&self.config.plan_id, "plan_id") + .map_err(|e| VerificationError::new(e.to_string())), + ) + .await?; let (subscription_pda, _) = find_subscription_pda(&plan_pda, &subscriber, &program_id); - let delegation = self - .fetch_subscription_delegation(&subscription_pda) - .await?; - - // ── Validate snapshotted terms ────────────────────────────────── - let expected_amount: u64 = request.amount.parse().map_err(|_| { - VerificationError::invalid_payload(format!( - "Challenge amount `{}` is not a positive integer", - request.amount - )) - })?; - let expected_period_hours = request - .period_hours() - .map_err(|e| VerificationError::invalid_payload(e.to_string()))?; - - if delegation.amount_per_period != expected_amount { - return Err(VerificationError::credential_mismatch(format!( - "SubscriptionDelegation amount mismatch: expected {expected_amount}, got {}", - delegation.amount_per_period - ))); - } - if delegation.period_hours != expected_period_hours { - return Err(VerificationError::credential_mismatch(format!( - "SubscriptionDelegation period mismatch: expected {expected_period_hours}h, got {}h", - delegation.period_hours - ))); - } - if delegation.plan_pda != plan_pda { - return Err(VerificationError::credential_mismatch(format!( - "SubscriptionDelegation plan mismatch: expected {plan_pda}, got {}", - delegation.plan_pda - ))); - } - // Mint isn't stored on the delegation — the on-chain `Subscribe` - // ix binds the delegation's terms to the parent Plan's mint, and - // we already validated `plan_pda` matches the configured plan. - if delegation.amount_pulled_in_period != expected_amount { - return Err(VerificationError::new( - "Activation transaction did not execute the first-period charge", - )); - } + let delegation = release_on_err( + &*self.store, + &claimed_key, + self.fetch_subscription_delegation(&subscription_pda).await, + ) + .await?; - // ── Build the receipt ─────────────────────────────────────────── - let period_start_secs = delegation.current_period_start_ts; - let period_end_secs = period_start_secs.saturating_add(expected_period_hours as i64 * 3600); - - let receipt = ReceiptKind::Subscription { - base: Receipt { - status: crate::mpp::protocol::core::ReceiptStatus::Success, - method: METHOD_NAME.into(), - timestamp: format_rfc3339_seconds(now_unix_secs()), - reference: subscription_pda.to_string(), - challenge_id: credential.challenge.id.clone(), - }, - extensions: SubscriptionReceiptExtensions { - subscription_id: subscription_pda.to_string(), - plan_id: self.config.plan_id.clone(), - period_index: "0".to_string(), - period_start_ts: format_rfc3339_seconds(period_start_secs), - period_end_ts: format_rfc3339_seconds(period_end_secs), - expires_at: request.subscription_expires.clone(), + // ── Validate snapshotted terms + build the receipt ─────────────── + let receipt = release_on_err( + &*self.store, + &claimed_key, + validate_terms_and_build_receipt( + &delegation, + &request, + &plan_pda, + &subscription_pda, + &self.config.plan_id, + &credential.challenge.id, activation_signature, - }, - }; + ), + ) + .await?; + + // Happy path: a receipt was produced, so the claim is permanent. Do NOT + // release — a genuine replay of this activation signature must stay + // rejected. Ok(receipt) } @@ -609,51 +744,84 @@ impl SubscriptionServer { &self, subscription_pda: &Pubkey, ) -> Result { + self.fetch_optional_subscription_delegation(subscription_pda) + .await? + .ok_or_else(|| { + VerificationError::not_found(format!( + "SubscriptionDelegation account {subscription_pda} not found" + )) + }) + } + + async fn fetch_optional_subscription_delegation( + &self, + subscription_pda: &Pubkey, + ) -> Result, VerificationError> { + use solana_commitment_config::CommitmentConfig; use solana_rpc_client::rpc_client::RpcClient; let rpc_url = self.rpc_url.clone(); let pda = *subscription_pda; + tokio::task::spawn_blocking(move || { + let rpc = RpcClient::new_with_commitment(rpc_url, CommitmentConfig::confirmed()); + let response = rpc + .get_account_with_commitment(&pda, CommitmentConfig::confirmed()) + .map_err(|e| { + VerificationError::network_error(format!( + "getAccount({pda}) failed while reading SubscriptionDelegation: {e}" + )) + })?; + let Some(account) = response.value else { + return Ok(None); + }; + decode_subscription_delegation(&account.data) + .map(Some) + .map_err(VerificationError::new) + }) + .await + .map_err(|e| VerificationError::network_error(format!("RPC task join: {e}")))? + } + + async fn is_signature_confirmed(&self, signature: &str) -> Result { + use solana_rpc_client::rpc_client::RpcClient; + let rpc_url = self.rpc_url.clone(); + let signature = signature.parse::().map_err(|e| { + VerificationError::invalid_payload(format!("Invalid activation signature: {e}")) + })?; tokio::task::spawn_blocking(move || { let rpc = RpcClient::new(rpc_url); - let account = rpc.get_account(&pda).map_err(|e| { - VerificationError::not_found(format!( - "SubscriptionDelegation account {pda} not found: {e}" - )) - })?; - decode_subscription_delegation(&account.data).map_err(VerificationError::new) + rpc.get_signature_status(&signature) + .map_err(|e| { + VerificationError::network_error(format!( + "getSignatureStatuses({signature}) failed: {e}" + )) + }) + .map(|status| matches!(status, Some(Ok(())))) }) .await .map_err(|e| VerificationError::network_error(format!("RPC task join: {e}")))? } - /// Look up the activation transaction signature for an existing - /// `SubscriptionDelegation` PDA by walking `getSignaturesForAddress`. - /// - /// The PDA's signature history is "newest first"; the original - /// `Subscribe` transaction is the last entry. We page through with a - /// reasonable limit — for a freshly-activated subscription this is - /// just one entry, and a long-lived subscription with many renewals - /// would still yield the activation tx as the oldest record. - async fn fetch_subscription_creation_signature( + async fn fetch_subscription_authority_init_id( &self, - subscription_pda: &Pubkey, - ) -> Result { + authority: &Pubkey, + ) -> Result { use solana_rpc_client::rpc_client::RpcClient; let rpc_url = self.rpc_url.clone(); - let pda = *subscription_pda; + let authority = *authority; tokio::task::spawn_blocking(move || { - let rpc = RpcClient::new(rpc_url); - let sigs = rpc.get_signatures_for_address(&pda).map_err(|e| { - VerificationError::network_error(format!( - "getSignaturesForAddress({pda}) failed: {e}" - )) - })?; - // The activation tx is the oldest signature for this PDA — the - // RPC returns newest-first, so take the last entry. - sigs.into_iter().last().map(|s| s.signature).ok_or_else(|| { - VerificationError::not_found(format!( - "No signatures returned for SubscriptionDelegation {pda}" - )) - }) + let account = RpcClient::new(rpc_url) + .get_account(&authority) + .map_err(|e| { + VerificationError::network_error(format!("getAccount({authority}) failed: {e}")) + })?; + if account.data.len() != 106 { + return Err(VerificationError::invalid_payload( + "SubscriptionAuthority account has a non-canonical layout", + )); + } + Ok(i64::from_le_bytes( + account.data[98..106].try_into().unwrap(), + )) }) .await .map_err(|e| VerificationError::network_error(format!("RPC task join: {e}")))? @@ -696,6 +864,27 @@ impl SubscriptionServer { // ── Verify helpers ────────────────────────────────────────────────────────── +/// Release-on-failure guard for the activation replay reservation. +/// +/// Once the activation signature has been atomically claimed (via +/// `put_if_absent`), every fallible step up to receipt construction must +/// release that claim on error so a transient failure (RPC hiccup, terms +/// mismatch, …) does not permanently brick the activation signature for a +/// legitimate retry. Wrapping each `Result` in this helper deletes the marker +/// at `key` on `Err` and passes `Ok` through untouched. The delete is +/// best-effort: a failed delete cannot make the original error any worse, so it +/// is ignored. +async fn release_on_err( + store: &dyn Store, + key: &str, + result: Result, +) -> Result { + if result.is_err() { + let _ = store.delete(key).await; + } + result +} + /// Pluck the `ActivatePayload` out of a credential's `payload` field, /// accepting both the raw `ActivatePayload` shape (the v0 spec) and the /// tagged `SubscriptionAction::Activate(...)` wrapper. @@ -715,8 +904,11 @@ fn decode_activate_payload( fn decode_base64_transaction(b64: &str) -> Result { let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64) .map_err(|e| VerificationError::invalid_payload(format!("Tx base64 decode: {e}")))?; - bincode::deserialize(&bytes) - .map_err(|e| VerificationError::invalid_payload(format!("Tx bincode decode: {e}"))) + let tx: Transaction = bincode::deserialize(&bytes) + .map_err(|e| VerificationError::invalid_payload(format!("Tx bincode decode: {e}")))?; + tx.sanitize() + .map_err(|e| VerificationError::invalid_payload(format!("Tx sanitization failed: {e}")))?; + Ok(tx) } /// Extract the subscriber pubkey from the activation transaction. @@ -792,42 +984,226 @@ fn extract_subscriber_from_tx( /// Requires exactly one Subscribe instruction and exactly one /// TransferSubscription instruction on the configured subscriptions /// program, with Subscribe ordered before TransferSubscription. +#[derive(Debug, Clone, Copy)] +struct ActivationScope { + authority: Pubkey, + expected_init_id: Option, +} + +#[cfg(test)] fn validate_activation_scope( tx: &Transaction, - _request: &SubscriptionRequest, + request: &SubscriptionRequest, program_id_str: &str, -) -> Result<(), VerificationError> { + config: &SubscriptionConfig, + subscriber: &Pubkey, +) -> Result { + validate_activation_scope_with_merchant( + tx, + request, + program_id_str, + config, + &config.puller, + subscriber, + ) +} + +fn validate_activation_scope_with_merchant( + tx: &Transaction, + request: &SubscriptionRequest, + program_id_str: &str, + config: &SubscriptionConfig, + merchant: &str, + subscriber: &Pubkey, +) -> Result { let program_id = parse_pubkey(program_id_str, "program_id") .map_err(|e| VerificationError::new(e.to_string()))?; let keys = &tx.message.account_keys; + // Strict allowlist of the programs a legitimate activation transaction is + // permitted to invoke. Anything else is rejected outright — never skipped. + // + // The fee-payer co-sign below authorizes EVERY instruction in this message + // that names the server key as a required signer. If we merely scanned for + // the subscribe/transfer pair and ignored other instructions, a client + // could append e.g. a System transfer or an SPL token transfer that spends + // the server's sponsored fee-payer wallet, and the server would blindly + // co-sign it. Enforcing an allowlist (not a skip) closes that hole. + let compute_budget = parse_pubkey(COMPUTE_BUDGET_PROGRAM_ID, "compute_budget_program") + .map_err(|e| VerificationError::new(e.to_string()))?; + let ata_program = parse_pubkey(ASSOCIATED_TOKEN_PROGRAM_ID, "associated_token_program") + .map_err(|e| VerificationError::new(e.to_string()))?; + let memo_program = parse_pubkey(MEMO_PROGRAM_ID, "memo_program") + .map_err(|e| VerificationError::new(e.to_string()))?; + + let mint = + parse_pubkey(&config.mint, "mint").map_err(|e| VerificationError::new(e.to_string()))?; + let puller = parse_pubkey(&config.puller, "puller") + .map_err(|e| VerificationError::new(e.to_string()))?; + let merchant = + parse_pubkey(merchant, "merchant").map_err(|e| VerificationError::new(e.to_string()))?; + let recipient = parse_pubkey(&config.recipient, "recipient") + .map_err(|e| VerificationError::new(e.to_string()))?; + let plan = parse_pubkey(&config.plan_id, "plan_id") + .map_err(|e| VerificationError::new(e.to_string()))?; + let token_program = parse_pubkey(&config.token_program, "token_program") + .map_err(|e| VerificationError::new(e.to_string()))?; + let (subscription, _) = find_subscription_pda(&plan, subscriber, &program_id); + let (authority, _) = find_subscription_authority_pda(subscriber, &mint, &program_id); + let (event_authority, _) = find_event_authority_pda(&program_id); + let system_program = parse_pubkey(SYSTEM_PROGRAM_ID, "system_program") + .map_err(|e| VerificationError::new(e.to_string()))?; + let amount = request + .parse_amount() + .map_err(|e| VerificationError::invalid_payload(e.to_string()))?; + let period_hours = request + .period_hours() + .map_err(|e| VerificationError::invalid_payload(e.to_string()))?; + let strict = config.fee_payer; + let plan_id_numeric = config.plan_id_numeric.unwrap_or_default(); + let plan_bump = config.plan_bump.unwrap_or_default(); + let plan_created_at = config.plan_created_at.unwrap_or_default(); + + if strict + && (config.plan_id_numeric.is_none() + || config.plan_bump.is_none() + || config.plan_created_at.is_none()) + { + return Err(VerificationError::invalid_payload( + "Sponsored subscription requires planIdNumeric, planBump, and planCreatedAt snapshots", + )); + } + if strict && keys.first() != Some(&puller) { + return Err(VerificationError::invalid_payload( + "Activation fee payer must be the puller at account index 0", + )); + } + let signer_keys: Vec = keys + .iter() + .enumerate() + .filter_map(|(i, key)| tx.message.is_signer(i).then_some(*key)) + .collect(); + if strict + && (signer_keys.len() != 2 + || !signer_keys.contains(&puller) + || !signer_keys.contains(subscriber)) + { + return Err(VerificationError::invalid_payload( + "Activation must require exactly the puller fee payer and subscriber signers", + )); + } + let mut subscribe_idx: Option = None; let mut transfer_idx: Option = None; + let mut expected_init_id: Option = None; + let mut compute_limit_seen = false; + let mut compute_price_seen = false; for (i, ix) in tx.message.instructions.iter().enumerate() { let prog_idx = ix.program_id_index as usize; - if prog_idx >= keys.len() { - continue; - } - if keys[prog_idx] != program_id { - continue; - } - let Some(disc) = ix.data.first().copied() else { - continue; - }; - if disc == INSTRUCTION_SUBSCRIBE { - if subscribe_idx.is_some() { - return Err(VerificationError::invalid_payload( - "Activation tx contains multiple subscribe instructions", - )); + let prog = keys.get(prog_idx).ok_or_else(|| { + VerificationError::invalid_payload( + "Activation tx instruction references an out-of-range program id index", + ) + })?; + + if *prog == program_id { + let disc = ix.data.first().copied().ok_or_else(|| { + VerificationError::invalid_payload( + "Activation tx subscriptions-program instruction has empty data", + ) + })?; + match disc { + INSTRUCTION_SUBSCRIBE => { + if subscribe_idx.is_some() { + return Err(VerificationError::invalid_payload( + "Activation tx contains multiple subscribe instructions", + )); + } + if strict { + expected_init_id = Some(validate_subscribe_instruction( + ix, + &tx.message, + subscriber, + merchant, + puller, + plan, + subscription, + authority, + system_program, + event_authority, + program_id, + mint, + amount, + period_hours, + plan_id_numeric, + plan_bump, + plan_created_at, + )?); + } + subscribe_idx = Some(i); + } + INSTRUCTION_TRANSFER_SUBSCRIPTION => { + if transfer_idx.is_some() { + return Err(VerificationError::invalid_payload( + "Activation tx contains multiple transfer_subscription instructions", + )); + } + if strict { + validate_transfer_subscription_instruction( + ix, + &tx.message, + subscriber, + puller, + plan, + subscription, + authority, + mint, + token_program, + recipient, + event_authority, + program_id, + amount, + )?; + } + transfer_idx = Some(i); + } + other => { + return Err(VerificationError::invalid_payload(format!( + "Activation tx contains a disallowed subscriptions-program instruction \ + (discriminator {other}); only subscribe and transfer_subscription are allowed" + ))); + } } - subscribe_idx = Some(i); - } else if disc == INSTRUCTION_TRANSFER_SUBSCRIPTION { - if transfer_idx.is_some() { + } else if *prog == compute_budget { + validate_compute_budget_instruction( + ix, + &mut compute_limit_seen, + &mut compute_price_seen, + )?; + continue; + } else if *prog == memo_program { + if strict + && request.external_id.as_deref().map(str::as_bytes) != Some(ix.data.as_slice()) + { return Err(VerificationError::invalid_payload( - "Activation tx contains multiple transfer_subscription instructions", + "Activation memo must exactly match request.externalId", )); } - transfer_idx = Some(i); + continue; + } else if *prog == ata_program { + // The idempotent-ATA bootstrap DOES carry fee-payer fund risk: an + // ATA `CreateIdempotent` names the funding account (charged the + // rent) as a required signer, so a blind co-sign would let a client + // make the sponsored fee payer fund an arbitrary ATA. Validate the + // instruction the same way the charge verifier does before letting + // the fee payer co-sign it. + validate_activation_ata_instruction(ix, &tx.message, config, subscriber)?; + continue; + } else { + return Err(VerificationError::invalid_payload(format!( + "Activation tx invokes a disallowed program `{prog}`; the fee payer will not \ + co-sign a transaction outside the subscription activation allowlist" + ))); } } @@ -844,98 +1220,567 @@ fn validate_activation_scope( "subscribe must precede transfer_subscription in activation tx", )); } - Ok(()) + if !compute_limit_seen { + return Err(VerificationError::invalid_payload( + "Activation transaction must contain exactly one SetComputeUnitLimit instruction", + )); + } + let ata_owners: Vec = tx + .message + .instructions + .iter() + .filter(|ix| keys.get(ix.program_id_index as usize) == Some(&ata_program)) + .map(|ix| validate_activation_ata_instruction(ix, &tx.message, config, subscriber)) + .collect::>()?; + if strict + && (ata_owners.len() != 2 + || ata_owners + .iter() + .filter(|owner| **owner == *subscriber) + .count() + != 1 + || ata_owners + .iter() + .filter(|owner| **owner == recipient) + .count() + != 1) + { + return Err(VerificationError::invalid_payload( + "Activation must contain exactly one subscriber ATA and one recipient ATA CreateIdempotent instruction", + )); + } + Ok(ActivationScope { + authority, + expected_init_id, + }) } -/// Sign the transaction's message with the server's fee-payer signer and -/// fill the matching slot in `tx.signatures`. The client has already -/// signed as the subscriber and (when fee sponsorship is in play) left an -/// empty signature slot at the fee-payer index. -async fn co_sign_as_fee_payer( - tx: &mut Transaction, - signer: &Arc, +#[allow(clippy::too_many_arguments)] +fn validate_subscribe_instruction( + ix: &solana_message::compiled_instruction::CompiledInstruction, + message: &solana_message::Message, + subscriber: &Pubkey, + merchant: Pubkey, + puller: Pubkey, + plan: Pubkey, + subscription: Pubkey, + authority: Pubkey, + system_program: Pubkey, + event_authority: Pubkey, + program_id: Pubkey, + mint: Pubkey, + amount: u64, + period_hours: u64, + plan_id: u64, + plan_bump: u8, + created_at: i64, +) -> Result { + let merchant_is_signer = merchant == puller || merchant == *subscriber; + let expected = [ + (*subscriber, true, true), + (merchant, merchant_is_signer, merchant_is_signer), + (plan, false, false), + (subscription, true, false), + (authority, false, false), + (system_program, false, false), + (event_authority, false, false), + (program_id, false, false), + (puller, true, true), + ]; + validate_instruction_accounts(message, ix, &expected, "subscribe")?; + if ix.data.len() != 74 || ix.data[0] != INSTRUCTION_SUBSCRIBE { + return Err(VerificationError::invalid_payload( + "Non-canonical subscribe instruction data", + )); + } + let u64_at = |at| u64::from_le_bytes(ix.data[at..at + 8].try_into().unwrap()); + let i64_at = |at| i64::from_le_bytes(ix.data[at..at + 8].try_into().unwrap()); + let data_mint = Pubkey::new_from_array(ix.data[10..42].try_into().unwrap()); + if u64_at(1) != plan_id + || ix.data[9] != plan_bump + || data_mint != mint + || u64_at(42) != amount + || u64_at(50) != period_hours + || i64_at(58) != created_at + { + return Err(VerificationError::credential_mismatch( + "Subscribe data does not match the challenged plan snapshot", + )); + } + Ok(i64_at(66)) +} + +#[allow(clippy::too_many_arguments)] +fn validate_transfer_subscription_instruction( + ix: &solana_message::compiled_instruction::CompiledInstruction, + message: &solana_message::Message, + subscriber: &Pubkey, + puller: Pubkey, + plan: Pubkey, + subscription: Pubkey, + authority: Pubkey, + mint: Pubkey, + token_program: Pubkey, + recipient: Pubkey, + event_authority: Pubkey, + program_id: Pubkey, + amount: u64, ) -> Result<(), VerificationError> { - let pubkey = signer.pubkey(); - let idx = tx - .message - .account_keys - .iter() - .position(|k| *k == pubkey) - .ok_or_else(|| { - VerificationError::invalid_payload( - "Fee payer pubkey not present in activation transaction", - ) - })?; - let msg_bytes = tx.message_data(); - let sig_bytes = signer - .sign_message(&msg_bytes) - .await - .map_err(|e| VerificationError::new(format!("Fee-payer signing failed: {e}")))?; - if tx.signatures.len() <= idx { + let ata_program = parse_pubkey(ASSOCIATED_TOKEN_PROGRAM_ID, "associated_token_program") + .map_err(|e| VerificationError::new(e.to_string()))?; + let derive_ata = |owner: &Pubkey| { + Pubkey::find_program_address( + &[owner.as_ref(), token_program.as_ref(), mint.as_ref()], + &ata_program, + ) + .0 + }; + let expected = [ + (subscription, true, false), + (plan, false, false), + (authority, false, false), + (derive_ata(subscriber), true, false), + (derive_ata(&recipient), true, false), + (puller, true, true), + (mint, false, false), + (token_program, false, false), + (event_authority, false, false), + (program_id, false, false), + ]; + validate_instruction_accounts(message, ix, &expected, "transfer_subscription")?; + if ix.data.len() != 73 || ix.data[0] != INSTRUCTION_TRANSFER_SUBSCRIPTION { return Err(VerificationError::invalid_payload( - "Transaction signatures vec is shorter than account_keys", + "Non-canonical transfer_subscription instruction data", + )); + } + let data_amount = u64::from_le_bytes(ix.data[1..9].try_into().unwrap()); + let delegator = Pubkey::new_from_array(ix.data[9..41].try_into().unwrap()); + let data_mint = Pubkey::new_from_array(ix.data[41..73].try_into().unwrap()); + if data_amount != amount || delegator != *subscriber || data_mint != mint { + return Err(VerificationError::credential_mismatch( + "Transfer data does not match the challenged subscription", )); } - tx.signatures[idx] = Signature::from(<[u8; 64]>::from(sig_bytes)); Ok(()) } -/// Minimal in-process view of the on-chain `SubscriptionDelegation` -/// account — only the fields verify needs to validate against the -/// challenge. Mirrors the `#[repr(C, packed)]` layout in -/// `solana-program/subscriptions/program/src/state/subscription_delegation.rs`: -/// -/// ```text -/// off size field -/// --- ---- ----------------------------------------- -/// 0 1 header.discriminator -/// 1 1 header.version -/// 2 1 header.bump -/// 3 32 header.delegator (= subscriber) -/// 35 32 header.delegatee (= plan_pda) -/// 67 32 header.payer -/// 99 8 header.init_id -/// 107 8 terms.amount (= amount_per_period) -/// 115 8 terms.period_hours -/// 123 8 terms.created_at -/// 131 8 amount_pulled_in_period -/// 139 8 current_period_start_ts -/// 147 8 expires_at_ts -/// ``` -/// -/// Mint isn't stored on the delegation — it lives on the parent Plan, and -/// the on-chain `Subscribe` already binds the delegation's terms to that -/// plan's mint, so we don't re-check it here. -#[derive(Debug, Clone)] -pub struct SubscriptionDelegationView { - pub subscriber: Pubkey, - pub plan_pda: Pubkey, - pub amount_per_period: u64, - pub period_hours: u64, - pub current_period_start_ts: i64, - pub amount_pulled_in_period: u64, +fn validate_instruction_accounts( + message: &solana_message::Message, + ix: &solana_message::compiled_instruction::CompiledInstruction, + expected: &[(Pubkey, bool, bool)], + label: &str, +) -> Result<(), VerificationError> { + if ix.accounts.len() != expected.len() { + return Err(VerificationError::invalid_payload(format!( + "Non-canonical {label} account layout" + ))); + } + for (position, ((expected_key, expected_writable, expected_signer), index)) in + expected.iter().zip(&ix.accounts).enumerate() + { + let index = *index as usize; + let actual = message.account_keys.get(index).ok_or_else(|| { + VerificationError::invalid_payload(format!("Invalid {label} account index")) + })?; + if actual != expected_key { + return Err(VerificationError::invalid_payload(format!( + "Non-canonical {label} account layout at position {position}" + ))); + } + if message.is_signer(index) != *expected_signer { + return Err(VerificationError::invalid_payload(format!( + "Non-canonical {label} signer privilege at position {position}" + ))); + } + if is_writable_account_index(message, index) != *expected_writable { + return Err(VerificationError::invalid_payload(format!( + "Non-canonical {label} writable privilege at position {position}" + ))); + } + } + Ok(()) } -const SUBSCRIPTION_DELEGATION_LEN: usize = 1 // discriminator - + 1 // version - + 1 // bump - + 32 // header.delegator (subscriber) - + 32 // header.delegatee (plan_pda) - + 32 // header.payer - + 8 // header.init_id - + 8 // terms.amount - + 8 // terms.period_hours - + 8 // terms.created_at - + 8 // amount_pulled_in_period - + 8 // current_period_start_ts - + 8; // expires_at_ts +fn is_writable_account_index(message: &solana_message::Message, index: usize) -> bool { + let required_signatures = usize::from(message.header.num_required_signatures); + let readonly_signed = usize::from(message.header.num_readonly_signed_accounts); + let readonly_unsigned = usize::from(message.header.num_readonly_unsigned_accounts); + let account_keys_len = message.account_keys.len(); -fn decode_subscription_delegation(data: &[u8]) -> Result { - if data.len() < SUBSCRIPTION_DELEGATION_LEN { - return Err(format!( - "SubscriptionDelegation account too short: {} bytes (need >= {SUBSCRIPTION_DELEGATION_LEN})", - data.len() - )); + if index >= account_keys_len || required_signatures > account_keys_len { + return false; + } + let unsigned_accounts = account_keys_len - required_signatures; + if readonly_unsigned > unsigned_accounts { + return false; + } + let Some(writable_signed) = required_signatures.checked_sub(readonly_signed) else { + return false; + }; + let Some(writable_unsigned) = account_keys_len.checked_sub(readonly_unsigned) else { + return false; + }; + + if index < required_signatures { + index < writable_signed + } else { + index < writable_unsigned + } +} + +/// Validate an Associated-Token-Account `CreateIdempotent` instruction in an +/// activation transaction before the fee payer co-signs it. +/// +/// An ATA create names the funding account (which is charged the account's +/// rent) as a required signer. If the server blindly co-signed, a client could +/// make the sponsored fee payer fund the rent for an arbitrary ATA. This +/// mirrors the charge verifier's `validate_create_ata_idempotent_instruction`: +/// require the `CreateIdempotent` discriminator with the canonical 6-account +/// layout, and assert that the funding account is the transaction fee payer, +/// the mint is the plan mint, the token program is the configured one, the +/// owner is one the challenge authorizes (subscriber, recipient, or puller), +/// and that the ATA address re-derives from `(owner, token_program, mint)`. +fn validate_activation_ata_instruction( + ix: &solana_message::compiled_instruction::CompiledInstruction, + message: &solana_message::Message, + config: &SubscriptionConfig, + subscriber: &Pubkey, +) -> Result { + let account_keys = &message.account_keys; + if ix.data.as_slice() != [1] { + return Err(VerificationError::invalid_payload( + "Only idempotent ATA creation is allowed in activation tx", + )); + } + if ix.accounts.len() != 6 { + return Err(VerificationError::invalid_payload( + "Unexpected ATA creation account layout in activation tx", + )); + } + + let ata_account_key = |slot: usize, label: &str| -> Result { + let idx = ix.accounts[slot] as usize; + account_keys.get(idx).copied().ok_or_else(|| { + VerificationError::invalid_payload(format!("Invalid {label} account index")) + }) + }; + let payer = ata_account_key(0, "ATA payer")?; + let ata = ata_account_key(1, "ATA address")?; + let owner = ata_account_key(2, "ATA owner")?; + let mint = ata_account_key(3, "ATA mint")?; + let system_program = ata_account_key(4, "ATA system program")?; + let token_program = ata_account_key(5, "ATA token program")?; + + // The funding account (charged the rent) must be the transaction fee payer + // at index 0 — the slot the server signs as. Anything else means the client + // is asking the server to fund rent for an account it does not control. + let expected_payer = account_keys.first().ok_or_else(|| { + VerificationError::invalid_payload("Activation tx has no fee payer account") + })?; + if payer != *expected_payer { + return Err(VerificationError::invalid_payload( + "ATA payer must be the transaction fee payer in activation tx", + )); + } + + let expected_mint = + parse_pubkey(&config.mint, "mint").map_err(|e| VerificationError::new(e.to_string()))?; + if mint != expected_mint { + return Err(VerificationError::invalid_payload( + "ATA creation mint does not match the plan mint", + )); + } + + // Owner must be one of the parties the challenge authorizes: the subscriber + // themselves, the primary recipient, or the server puller. + let recipient = parse_pubkey(&config.recipient, "recipient") + .map_err(|e| VerificationError::new(e.to_string()))?; + if owner != *subscriber && owner != recipient { + return Err(VerificationError::invalid_payload( + "ATA creation owner is not authorized by the challenge", + )); + } + + let system_program_id = parse_pubkey(SYSTEM_PROGRAM_ID, "system_program") + .map_err(|e| VerificationError::new(e.to_string()))?; + if system_program != system_program_id { + return Err(VerificationError::invalid_payload( + "ATA creation must reference the System Program", + )); + } + + let token_program_str = token_program.to_string(); + if token_program_str != programs::TOKEN_PROGRAM + && token_program_str != programs::TOKEN_2022_PROGRAM + { + return Err(VerificationError::invalid_payload( + "ATA creation uses an unsupported token program", + )); + } + let expected_token_program = parse_pubkey(&config.token_program, "token_program") + .map_err(|e| VerificationError::new(e.to_string()))?; + if token_program != expected_token_program { + return Err(VerificationError::invalid_payload( + "ATA creation token program does not match the configured token program", + )); + } + + let ata_program = parse_pubkey(ASSOCIATED_TOKEN_PROGRAM_ID, "associated_token_program") + .map_err(|e| VerificationError::new(e.to_string()))?; + let (expected_ata, _) = Pubkey::find_program_address( + &[owner.as_ref(), token_program.as_ref(), mint.as_ref()], + &ata_program, + ); + if ata != expected_ata { + return Err(VerificationError::invalid_payload( + "ATA creation address does not match owner/mint/token program", + )); + } + + // Message account privileges are merged globally across instructions. The + // ATA program receives the owner as readonly, but the subscriber is also a + // writable signer of `subscribe` and the puller may be the fee payer. + let owner_is_transaction_signer = owner == *subscriber || owner == *expected_payer; + + validate_instruction_accounts( + message, + ix, + &[ + (payer, true, true), + (ata, true, false), + ( + owner, + owner_is_transaction_signer, + owner_is_transaction_signer, + ), + (mint, false, false), + (system_program, false, false), + (token_program, false, false), + ], + "ATA CreateIdempotent", + )?; + + Ok(owner) +} + +const MAX_COMPUTE_UNIT_LIMIT: u32 = 200_000; +const MAX_SPONSORED_COMPUTE_UNIT_PRICE_MICROLAMPORTS: u64 = 10_000; + +fn validate_compute_budget_instruction( + ix: &solana_message::compiled_instruction::CompiledInstruction, + compute_limit_seen: &mut bool, + compute_price_seen: &mut bool, +) -> Result<(), VerificationError> { + if !ix.accounts.is_empty() { + return Err(VerificationError::invalid_payload( + "Compute budget instruction must not have accounts", + )); + } + match ix.data.as_slice() { + [2, units @ ..] if units.len() == 4 => { + if *compute_limit_seen { + return Err(VerificationError::invalid_payload( + "Duplicate compute unit limit instruction", + )); + } + *compute_limit_seen = true; + let units = u32::from_le_bytes(units.try_into().expect("checked length")); + if units > MAX_COMPUTE_UNIT_LIMIT { + return Err(VerificationError::invalid_payload(format!( + "Compute unit limit {units} exceeds maximum {MAX_COMPUTE_UNIT_LIMIT}" + ))); + } + } + [3, price @ ..] if price.len() == 8 => { + if *compute_price_seen { + return Err(VerificationError::invalid_payload( + "Duplicate compute unit price instruction", + )); + } + *compute_price_seen = true; + let price = u64::from_le_bytes(price.try_into().expect("checked length")); + if price > MAX_SPONSORED_COMPUTE_UNIT_PRICE_MICROLAMPORTS { + return Err(VerificationError::invalid_payload(format!( + "Compute unit price {price} exceeds maximum {MAX_SPONSORED_COMPUTE_UNIT_PRICE_MICROLAMPORTS}" + ))); + } + } + _ => { + return Err(VerificationError::invalid_payload( + "Unsupported or malformed compute budget instruction", + )); + } + } + Ok(()) +} + +/// Sign the transaction's message with the server's fee-payer signer and +/// fill the matching slot in `tx.signatures`. The client has already +/// signed as the subscriber and (when fee sponsorship is in play) left an +/// empty signature slot at the fee-payer index. +async fn co_sign_as_fee_payer( + tx: &mut Transaction, + signer: &Arc, +) -> Result<(), VerificationError> { + let pubkey = signer.pubkey(); + // The fee payer MUST be account index 0 (Solana's transaction fee-payer + // slot). Signing wherever the key happens to appear would let a client + // place the server key at a non-zero index — as the authority/source of an + // attacker-inserted instruction — and still collect the server's signature. + // Pinning to index 0 means the server only ever signs as the fee payer. + let idx = 0usize; + match tx.message.account_keys.first() { + Some(k) if *k == pubkey => {} + Some(_) => { + return Err(VerificationError::invalid_payload( + "Fee payer must be the transaction fee-payer (account index 0)", + )); + } + None => { + return Err(VerificationError::invalid_payload( + "Activation transaction has no account keys", + )); + } + } + let msg_bytes = tx.message_data(); + let sig_bytes = signer + .sign_message(&msg_bytes) + .await + .map_err(|e| VerificationError::new(format!("Fee-payer signing failed: {e}")))?; + if tx.signatures.len() <= idx { + return Err(VerificationError::invalid_payload( + "Transaction signatures vec is shorter than account_keys", + )); + } + tx.signatures[idx] = Signature::from(<[u8; 64]>::from(sig_bytes)); + Ok(()) +} + +/// Validate a fetched `SubscriptionDelegation` against the challenge's +/// snapshotted terms, then build the success receipt. +#[allow(clippy::too_many_arguments)] +fn validate_terms_and_build_receipt( + delegation: &SubscriptionDelegationView, + request: &SubscriptionRequest, + plan_pda: &Pubkey, + subscription_pda: &Pubkey, + plan_id: &str, + challenge_id: &str, + activation_signature: Option, +) -> Result { + let expected_amount: u64 = request.amount.parse().map_err(|_| { + VerificationError::invalid_payload(format!( + "Challenge amount `{}` is not a positive integer", + request.amount + )) + })?; + let expected_period_hours = request + .period_hours() + .map_err(|e| VerificationError::invalid_payload(e.to_string()))?; + + if delegation.amount_per_period != expected_amount { + return Err(VerificationError::credential_mismatch(format!( + "SubscriptionDelegation amount mismatch: expected {expected_amount}, got {}", + delegation.amount_per_period + ))); + } + if delegation.period_hours != expected_period_hours { + return Err(VerificationError::credential_mismatch(format!( + "SubscriptionDelegation period mismatch: expected {expected_period_hours}h, got {}h", + delegation.period_hours + ))); + } + if delegation.plan_pda != *plan_pda { + return Err(VerificationError::credential_mismatch(format!( + "SubscriptionDelegation plan mismatch: expected {plan_pda}, got {}", + delegation.plan_pda + ))); + } + if delegation.amount_pulled_in_period != expected_amount { + return Err(VerificationError::new( + "Activation transaction did not execute the first-period charge", + )); + } + + let period_start_secs = delegation.current_period_start_ts; + let period_end_secs = period_start_secs.saturating_add(expected_period_hours as i64 * 3600); + + Ok(ReceiptKind::Subscription { + base: Receipt { + status: crate::mpp::protocol::core::ReceiptStatus::Success, + method: METHOD_NAME.into(), + timestamp: format_rfc3339_seconds(now_unix_secs()), + reference: subscription_pda.to_string(), + challenge_id: challenge_id.to_string(), + }, + extensions: SubscriptionReceiptExtensions { + subscription_id: subscription_pda.to_string(), + plan_id: plan_id.to_string(), + period_index: "0".to_string(), + period_start_ts: format_rfc3339_seconds(period_start_secs), + period_end_ts: format_rfc3339_seconds(period_end_secs), + expires_at: request.subscription_expires.clone(), + activation_signature, + }, + }) +} + +/// Minimal in-process view of the on-chain `SubscriptionDelegation` +/// account — only the fields verify needs to validate against the +/// challenge. Mirrors the `#[repr(C, packed)]` layout in +/// `solana-program/subscriptions/program/src/state/subscription_delegation.rs`: +/// +/// ```text +/// off size field +/// --- ---- ----------------------------------------- +/// 0 1 header.discriminator +/// 1 1 header.version +/// 2 1 header.bump +/// 3 32 header.delegator (= subscriber) +/// 35 32 header.delegatee (= plan_pda) +/// 67 32 header.payer +/// 99 8 header.init_id +/// 107 8 terms.amount (= amount_per_period) +/// 115 8 terms.period_hours +/// 123 8 terms.created_at +/// 131 8 amount_pulled_in_period +/// 139 8 current_period_start_ts +/// 147 8 expires_at_ts +/// ``` +/// +/// Mint isn't stored on the delegation — it lives on the parent Plan, and +/// the on-chain `Subscribe` already binds the delegation's terms to that +/// plan's mint, so we don't re-check it here. +#[derive(Debug, Clone)] +pub struct SubscriptionDelegationView { + pub subscriber: Pubkey, + pub plan_pda: Pubkey, + pub amount_per_period: u64, + pub period_hours: u64, + pub current_period_start_ts: i64, + pub amount_pulled_in_period: u64, +} + +const SUBSCRIPTION_DELEGATION_LEN: usize = 1 // discriminator + + 1 // version + + 1 // bump + + 32 // header.delegator (subscriber) + + 32 // header.delegatee (plan_pda) + + 32 // header.payer + + 8 // header.init_id + + 8 // terms.amount + + 8 // terms.period_hours + + 8 // terms.created_at + + 8 // amount_pulled_in_period + + 8 // current_period_start_ts + + 8; // expires_at_ts + +fn decode_subscription_delegation(data: &[u8]) -> Result { + if data.len() < SUBSCRIPTION_DELEGATION_LEN { + return Err(format!( + "SubscriptionDelegation account too short: {} bytes (need >= {SUBSCRIPTION_DELEGATION_LEN})", + data.len() + )); } // header.discriminator(1) + version(1) + bump(1) = 3 bytes before delegator. let mut off = 3; @@ -997,6 +1842,7 @@ fn now_unix_secs() -> i64 { #[cfg(test)] mod tests { use super::*; + use crate::mpp::store::MemoryStore; use solana_pubkey::Pubkey; fn keypair_base58() -> String { @@ -1013,10 +1859,42 @@ mod tests { recipient: keypair_base58(), challenge_binding_secret: "test-secret".to_string(), realm: "test-realm".to_string(), + store: Some(Arc::new(MemoryStore::new())), + plan_id_numeric: Some(1), + plan_bump: Some(255), + plan_created_at: Some(1_700_000_000), ..Default::default() } } + #[test] + fn subscription_config_exhaustive_literal_remains_source_compatible() { + let _config = SubscriptionConfig { + plan_id: keypair_base58(), + mint: keypair_base58(), + decimals: 6, + token_program: programs::TOKEN_PROGRAM.to_string(), + puller: keypair_base58(), + recipient: keypair_base58(), + period_unit: SubscriptionPeriodUnit::Day, + period_count: 30, + subscription_expires: None, + network: "devnet".to_string(), + program_id: None, + rpc_url: None, + challenge_binding_secret: "test-secret".to_string(), + realm: "test-realm".to_string(), + fee_payer: false, + fee_payer_signer: None, + fee_payer_pubkey: None, + store: Some(Arc::new(MemoryStore::new())), + plan_id_numeric: Some(1), + plan_bump: Some(255), + plan_created_at: Some(1_700_000_000), + description: None, + }; + } + #[test] fn rejects_missing_required_fields() { let mut cfg = make_config(); @@ -1068,6 +1946,27 @@ mod tests { assert_eq!(md.get("planId").unwrap().as_str().unwrap(), plan_id); } + #[test] + fn challenge_preserves_plan_owner_when_puller_is_delegated() { + let config = make_config(); + let merchant = Pubkey::new_unique().to_string(); + assert_ne!(merchant, config.puller); + let server = + SubscriptionServer::new_with_merchant(config, merchant.clone()).expect("server"); + let challenge = server.subscription_challenge("10000000").unwrap(); + let request: SubscriptionRequest = challenge.request.decode().unwrap(); + let method_details = request.method_details.expect("method details"); + assert_eq!( + method_details.get("merchant").and_then(|v| v.as_str()), + Some(merchant.as_str()) + ); + assert_ne!( + method_details.get("merchant"), + method_details.get("puller"), + "delegated puller must not replace the Plan owner", + ); + } + #[test] fn rejects_invalid_amount() { let server = SubscriptionServer::new(make_config()).unwrap(); @@ -1077,15 +1976,12 @@ mod tests { #[test] fn rejects_each_missing_required_field() { - type ConfigMutation = Box; + type ConfigMutation = fn(&mut SubscriptionConfig); let cases: Vec<(&str, ConfigMutation)> = vec![ - ("mint", Box::new(|c| c.mint = String::new())), - ( - "token_program", - Box::new(|c| c.token_program = String::new()), - ), - ("puller", Box::new(|c| c.puller = String::new())), - ("recipient", Box::new(|c| c.recipient = String::new())), + ("mint", |c| c.mint = String::new()), + ("token_program", |c| c.token_program = String::new()), + ("puller", |c| c.puller = String::new()), + ("recipient", |c| c.recipient = String::new()), ]; for (label, mutate) in cases { let mut cfg = make_config(); @@ -1123,14 +2019,16 @@ mod tests { #[test] fn challenge_emits_fee_payer_when_signer_configured() { - use solana_keychain::MemorySigner; + use solana_keychain::{MemorySigner, SolanaSigner}; let mut cfg = make_config(); cfg.fee_payer = true; let sk = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]); let mut kp = [0u8; 64]; kp[..32].copy_from_slice(sk.as_bytes()); kp[32..].copy_from_slice(sk.verifying_key().as_bytes()); - cfg.fee_payer_signer = Some(Arc::new(MemorySigner::from_bytes(&kp).expect("kp"))); + let signer = MemorySigner::from_bytes(&kp).expect("kp"); + cfg.puller = signer.pubkey().to_string(); + cfg.fee_payer_signer = Some(Arc::new(signer)); let server = SubscriptionServer::new(cfg).expect("server"); let challenge = server @@ -1149,6 +2047,33 @@ mod tests { assert!(md.get("feePayerKey").is_some()); } + #[test] + fn rejects_fee_payer_that_differs_from_puller() { + let mut cfg = make_config(); + cfg.fee_payer = true; + cfg.fee_payer_pubkey = Some(Pubkey::new_unique().to_string()); + let error = match SubscriptionServer::new(cfg) { + Ok(_) => panic!("fee payer must match puller"), + Err(error) => error, + }; + assert!(error.to_string().contains("must equal puller")); + } + + #[test] + fn rejects_fee_payer_signer_that_differs_from_explicit_pubkey() { + let mut cfg = make_config(); + cfg.fee_payer = true; + cfg.fee_payer_pubkey = Some(cfg.puller.clone()); + cfg.fee_payer_signer = Some(fee_payer_signer_and_key().0); + let error = match SubscriptionServer::new(cfg) { + Ok(_) => panic!("fee payer signer must match explicit pubkey"), + Err(error) => error, + }; + assert!(error + .to_string() + .contains("signer must equal fee payer pubkey")); + } + #[test] fn accessors_expose_config_values() { let cfg = make_config(); @@ -1379,4 +2304,2391 @@ mod tests { "{err:?}" ); } + + // ── Transaction-building helpers for the static verify checks ─────── + + /// Build a legacy `Transaction` over the given account keys and + /// instructions on the configured subscriptions program. `keys[0]` is + /// treated as the fee payer / first signer. + fn build_tx(keys: &[Pubkey], instructions: Vec<(u8, Vec)>) -> Transaction { + use solana_hash::Hash; + use solana_message::compiled_instruction::CompiledInstruction; + use solana_message::{Message, MessageHeader}; + + // Append the subscriptions and compute-budget programs so every + // otherwise-valid activation fixture carries the canonical explicit + // 200k limit. Only do so when there are instructions; signer-extraction + // tests intentionally construct messages with no program keys. + let program = parse_pubkey(SUBSCRIPTIONS_PROGRAM_ID, "program").unwrap(); + let compute_budget = + parse_pubkey(COMPUTE_BUDGET_PROGRAM_ID, "compute budget program").unwrap(); + let mut account_keys: Vec = keys.to_vec(); + let program_idx = account_keys.len() as u8; + let compute_budget_idx = program_idx + 1; + if !instructions.is_empty() { + account_keys.push(program); + account_keys.push(compute_budget); + } + + let mut compiled: Vec = instructions + .into_iter() + .map(|(disc, mut extra)| { + let mut data = vec![disc]; + data.append(&mut extra); + CompiledInstruction { + program_id_index: program_idx, + accounts: vec![], + data, + } + }) + .collect(); + if !compiled.is_empty() { + compiled.insert( + 0, + CompiledInstruction { + program_id_index: compute_budget_idx, + accounts: vec![], + data: [vec![2], 200_000u32.to_le_bytes().to_vec()].concat(), + }, + ); + } + + let readonly_unsigned = (account_keys.len() - keys.len()) as u8; + let message = Message { + header: MessageHeader { + num_required_signatures: keys.len() as u8, + num_readonly_signed_accounts: 0, + num_readonly_unsigned_accounts: readonly_unsigned, + }, + account_keys, + recent_blockhash: Hash::default(), + instructions: compiled, + }; + Transaction { + signatures: vec![Signature::default(); keys.len()], + message, + } + } + + fn dummy_request() -> SubscriptionRequest { + SubscriptionRequest { + amount: "1".into(), + currency: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".into(), + period_unit: SubscriptionPeriodUnit::Day, + period_count: "30".into(), + recipient: "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin".into(), + ..Default::default() + } + } + + /// A config for the scope tests. The subscribe/transfer ordering checks are + /// independent of these values; the ATA-layout checks read `mint`, + /// `token_program`, `recipient`, and `puller` from here. + fn scope_config() -> SubscriptionConfig { + make_config() + } + + /// A throwaway subscriber for scope tests that do not construct an ATA. + fn scope_subscriber() -> Pubkey { + Pubkey::new_unique() + } + + #[test] + fn subscribe_validation_keeps_merchant_distinct_from_delegated_puller() { + use crate::mpp::program::subscriptions::{ + build_subscribe_ix, find_event_authority_pda, find_subscription_authority_pda, + find_subscription_pda, SubscribeAccounts, SubscribeData, + }; + use solana_message::Message; + + let config = scope_config(); + let subscriber = Pubkey::new_unique(); + let puller = parse_pubkey(&config.puller, "puller").unwrap(); + let merchant = Pubkey::new_unique(); + let plan = parse_pubkey(&config.plan_id, "plan").unwrap(); + let mint = parse_pubkey(&config.mint, "mint").unwrap(); + let program_id = parse_pubkey(SUBSCRIPTIONS_PROGRAM_ID, "program").unwrap(); + let (subscription, _) = find_subscription_pda(&plan, &subscriber, &program_id); + let (authority, _) = find_subscription_authority_pda(&subscriber, &mint, &program_id); + let (event_authority, _) = find_event_authority_pda(&program_id); + let instruction = build_subscribe_ix( + program_id, + SubscribeAccounts { + subscriber, + merchant, + plan_pda: plan, + subscription_pda: subscription, + subscription_authority_pda: authority, + event_authority, + payer: Some(puller), + }, + &SubscribeData { + plan_id: config.plan_id_numeric.unwrap(), + plan_bump: config.plan_bump.unwrap(), + expected_mint: mint, + expected_amount: 1, + expected_period_hours: 720, + expected_created_at: config.plan_created_at.unwrap(), + expected_subscription_authority_init_id: 7, + }, + ); + let message = Message::new(&[instruction], Some(&puller)); + let system_program = parse_pubkey(SYSTEM_PROGRAM_ID, "system").unwrap(); + + let init_id = validate_subscribe_instruction( + &message.instructions[0], + &message, + &subscriber, + merchant, + puller, + plan, + subscription, + authority, + system_program, + event_authority, + program_id, + mint, + 1, + 720, + config.plan_id_numeric.unwrap(), + config.plan_bump.unwrap(), + config.plan_created_at.unwrap(), + ) + .expect("delegated puller layout remains canonical"); + assert_eq!(init_id, 7); + } + + #[test] + fn ata_validation_uses_effective_merged_subscriber_privileges() { + use solana_instruction::{AccountMeta, Instruction}; + use solana_message::Message; + + let config = scope_config(); + let subscriber = Pubkey::new_unique(); + let fee_payer = parse_pubkey(&config.puller, "puller").unwrap(); + let mint = parse_pubkey(&config.mint, "mint").unwrap(); + let token_program = parse_pubkey(&config.token_program, "token program").unwrap(); + let ata_program = parse_pubkey(ASSOCIATED_TOKEN_PROGRAM_ID, "ATA program").unwrap(); + let system_program = parse_pubkey(SYSTEM_PROGRAM_ID, "system program").unwrap(); + let (ata, _) = Pubkey::find_program_address( + &[subscriber.as_ref(), token_program.as_ref(), mint.as_ref()], + &ata_program, + ); + let create_ata = Instruction { + program_id: ata_program, + accounts: vec![ + AccountMeta::new(fee_payer, true), + AccountMeta::new(ata, false), + AccountMeta::new_readonly(subscriber, false), + AccountMeta::new_readonly(mint, false), + AccountMeta::new_readonly(system_program, false), + AccountMeta::new_readonly(token_program, false), + ], + data: vec![1], + }; + let subscriber_write = Instruction { + program_id: parse_pubkey(SUBSCRIPTIONS_PROGRAM_ID, "program").unwrap(), + accounts: vec![AccountMeta::new(subscriber, true)], + data: vec![INSTRUCTION_SUBSCRIBE], + }; + let message = Message::new(&[create_ata, subscriber_write], Some(&fee_payer)); + let owner_index = message.instructions[0].accounts[2] as usize; + assert!(message.is_signer(owner_index)); + assert!(is_writable_account_index(&message, owner_index)); + assert_eq!( + validate_activation_ata_instruction( + &message.instructions[0], + &message, + &config, + &subscriber, + ) + .expect("globally merged owner privileges are canonical"), + subscriber, + ); + } + + // ── extract_subscriber_from_tx ────────────────────────────────────── + + #[test] + fn extract_subscriber_rejects_empty_account_keys() { + let cfg = make_config(); + let tx = build_tx(&[], vec![]); + // build_tx appends the program id, so drop it to get a truly empty + // account_keys vec. + let mut tx = tx; + tx.message.account_keys.clear(); + let err = extract_subscriber_from_tx(&tx, &dummy_request(), &cfg).unwrap_err(); + assert!(err.message.contains("no account keys"), "{}", err.message); + } + + #[test] + fn extract_subscriber_returns_first_key_no_fee_payer() { + let cfg = make_config(); + let subscriber = Pubkey::new_unique(); + let tx = build_tx(&[subscriber], vec![]); + let got = extract_subscriber_from_tx(&tx, &dummy_request(), &cfg).unwrap(); + assert_eq!(got, subscriber); + } + + #[test] + fn extract_subscriber_rejects_subscriber_equal_puller() { + let cfg = make_config(); + let puller = parse_pubkey(&cfg.puller, "puller").unwrap(); + let tx = build_tx(&[puller], vec![]); + let err = extract_subscriber_from_tx(&tx, &dummy_request(), &cfg).unwrap_err(); + assert!( + err.message.contains("cannot equal the server puller"), + "{}", + err.message + ); + } + + #[test] + fn extract_subscriber_finds_subscriber_after_fee_payer() { + let mut cfg = make_config(); + let fee_payer = Pubkey::new_unique(); + cfg.fee_payer = true; + cfg.fee_payer_pubkey = Some(fee_payer.to_string()); + let puller = parse_pubkey(&cfg.puller, "puller").unwrap(); + let subscriber = Pubkey::new_unique(); + // account_keys[0] is the fee-payer, then puller, then subscriber. + let tx = build_tx(&[fee_payer, puller, subscriber], vec![]); + let got = extract_subscriber_from_tx(&tx, &dummy_request(), &cfg).unwrap(); + assert_eq!(got, subscriber); + } + + #[test] + fn extract_subscriber_fee_payer_no_candidate_rejects() { + let mut cfg = make_config(); + let fee_payer = Pubkey::new_unique(); + cfg.fee_payer = true; + cfg.fee_payer_pubkey = Some(fee_payer.to_string()); + let puller = parse_pubkey(&cfg.puller, "puller").unwrap(); + // Only fee-payer + puller signers, no subscriber to find. + let tx = build_tx(&[fee_payer, puller], vec![]); + let err = extract_subscriber_from_tx(&tx, &dummy_request(), &cfg).unwrap_err(); + assert!( + err.message.contains("Could not identify subscriber"), + "{}", + err.message + ); + } + + #[test] + fn extract_subscriber_fee_payer_from_signer_pubkey() { + use solana_keychain::{MemorySigner, SolanaSigner}; + let mut cfg = make_config(); + cfg.fee_payer = true; + let sk = ed25519_dalek::SigningKey::from_bytes(&[9u8; 32]); + let mut kp = [0u8; 64]; + kp[..32].copy_from_slice(sk.as_bytes()); + kp[32..].copy_from_slice(sk.verifying_key().as_bytes()); + let signer = MemorySigner::from_bytes(&kp).expect("kp"); + let fee_payer = signer.pubkey(); + cfg.fee_payer_signer = Some(Arc::new(signer)); + // No explicit fee_payer_pubkey — must derive it from the signer. + let subscriber = Pubkey::new_unique(); + let tx = build_tx(&[fee_payer, subscriber], vec![]); + let got = extract_subscriber_from_tx(&tx, &dummy_request(), &cfg).unwrap(); + assert_eq!(got, subscriber); + } + + // ── validate_activation_scope ─────────────────────────────────────── + + fn program_id_str() -> String { + SUBSCRIPTIONS_PROGRAM_ID.to_string() + } + + #[test] + fn validate_scope_accepts_subscribe_then_transfer() { + let subscriber = Pubkey::new_unique(); + let tx = build_tx( + &[subscriber], + vec![ + (INSTRUCTION_SUBSCRIBE, vec![]), + (INSTRUCTION_TRANSFER_SUBSCRIPTION, vec![]), + ], + ); + validate_activation_scope( + &tx, + &dummy_request(), + &program_id_str(), + &scope_config(), + &scope_subscriber(), + ) + .expect("valid scope"); + } + + #[test] + fn validate_scope_rejects_missing_subscribe() { + let subscriber = Pubkey::new_unique(); + let tx = build_tx( + &[subscriber], + vec![(INSTRUCTION_TRANSFER_SUBSCRIPTION, vec![])], + ); + let err = validate_activation_scope( + &tx, + &dummy_request(), + &program_id_str(), + &scope_config(), + &scope_subscriber(), + ) + .unwrap_err(); + assert!(err.message.contains("missing subscribe"), "{}", err.message); + } + + #[test] + fn validate_scope_rejects_missing_transfer() { + let subscriber = Pubkey::new_unique(); + let tx = build_tx(&[subscriber], vec![(INSTRUCTION_SUBSCRIBE, vec![])]); + let err = validate_activation_scope( + &tx, + &dummy_request(), + &program_id_str(), + &scope_config(), + &scope_subscriber(), + ) + .unwrap_err(); + assert!( + err.message.contains("missing transfer_subscription"), + "{}", + err.message + ); + } + + #[test] + fn validate_scope_rejects_duplicate_subscribe() { + let subscriber = Pubkey::new_unique(); + let tx = build_tx( + &[subscriber], + vec![ + (INSTRUCTION_SUBSCRIBE, vec![]), + (INSTRUCTION_SUBSCRIBE, vec![]), + (INSTRUCTION_TRANSFER_SUBSCRIPTION, vec![]), + ], + ); + let err = validate_activation_scope( + &tx, + &dummy_request(), + &program_id_str(), + &scope_config(), + &scope_subscriber(), + ) + .unwrap_err(); + assert!( + err.message.contains("multiple subscribe"), + "{}", + err.message + ); + } + + #[test] + fn validate_scope_rejects_duplicate_transfer() { + let subscriber = Pubkey::new_unique(); + let tx = build_tx( + &[subscriber], + vec![ + (INSTRUCTION_SUBSCRIBE, vec![]), + (INSTRUCTION_TRANSFER_SUBSCRIPTION, vec![]), + (INSTRUCTION_TRANSFER_SUBSCRIPTION, vec![]), + ], + ); + let err = validate_activation_scope( + &tx, + &dummy_request(), + &program_id_str(), + &scope_config(), + &scope_subscriber(), + ) + .unwrap_err(); + assert!( + err.message.contains("multiple transfer_subscription"), + "{}", + err.message + ); + } + + #[test] + fn validate_scope_rejects_transfer_before_subscribe() { + let subscriber = Pubkey::new_unique(); + let tx = build_tx( + &[subscriber], + vec![ + (INSTRUCTION_TRANSFER_SUBSCRIPTION, vec![]), + (INSTRUCTION_SUBSCRIBE, vec![]), + ], + ); + let err = validate_activation_scope( + &tx, + &dummy_request(), + &program_id_str(), + &scope_config(), + &scope_subscriber(), + ) + .unwrap_err(); + assert!(err.message.contains("must precede"), "{}", err.message); + } + + #[test] + fn validate_scope_rejects_instruction_on_unknown_program() { + // An instruction on a program outside the activation + // allowlist must be REJECTED, not skipped. The old behaviour scanned + // for the subscribe/transfer pair and ignored everything else, so a + // client could smuggle e.g. a System transfer that spends the + // sponsored fee payer and the server would still co-sign it. + use solana_hash::Hash; + use solana_message::compiled_instruction::CompiledInstruction; + use solana_message::{Message, MessageHeader}; + let fee_payer = Pubkey::new_unique(); + let attacker_recipient = Pubkey::new_unique(); + let system_program = parse_pubkey( + crate::mpp::program::subscriptions::SYSTEM_PROGRAM_ID, + "system", + ) + .unwrap(); + let sub_program = parse_pubkey(SUBSCRIPTIONS_PROGRAM_ID, "program").unwrap(); + // keys: [fee_payer(0), attacker_recipient(1), system(2), subscriptions(3)] + let account_keys = vec![fee_payer, attacker_recipient, system_program, sub_program]; + let instructions = vec![ + // Legit subscribe + transfer on the subscriptions program. + CompiledInstruction { + program_id_index: 3, + accounts: vec![], + data: vec![INSTRUCTION_SUBSCRIBE], + }, + CompiledInstruction { + program_id_index: 3, + accounts: vec![], + data: vec![INSTRUCTION_TRANSFER_SUBSCRIPTION], + }, + // Attacker-inserted System transfer draining the fee payer. + CompiledInstruction { + program_id_index: 2, + accounts: vec![0, 1], + data: vec![2, 0, 0, 0], // System::Transfer discriminator + }, + ]; + let message = Message { + header: MessageHeader { + num_required_signatures: 1, + num_readonly_signed_accounts: 0, + num_readonly_unsigned_accounts: 2, + }, + account_keys, + recent_blockhash: Hash::default(), + instructions, + }; + let tx = Transaction { + signatures: vec![Signature::default(); 1], + message, + }; + let err = validate_activation_scope( + &tx, + &dummy_request(), + &program_id_str(), + &scope_config(), + &scope_subscriber(), + ) + .unwrap_err(); + assert!( + err.message.contains("disallowed program"), + "{}", + err.message + ); + } + + #[test] + fn validate_scope_rejects_out_of_range_program_index() { + // A program-id index past the end of account_keys must be a hard + // error, not a silent skip (which previously let a crafted subscribe + // instruction hide behind an out-of-range index). + use solana_hash::Hash; + use solana_message::compiled_instruction::CompiledInstruction; + use solana_message::{Message, MessageHeader}; + let subscriber = Pubkey::new_unique(); + let sub_program = parse_pubkey(SUBSCRIPTIONS_PROGRAM_ID, "program").unwrap(); + let account_keys = vec![subscriber, sub_program]; + let instructions = vec![CompiledInstruction { + program_id_index: 99, + accounts: vec![], + data: vec![INSTRUCTION_SUBSCRIBE], + }]; + let message = Message { + header: MessageHeader { + num_required_signatures: 1, + num_readonly_signed_accounts: 0, + num_readonly_unsigned_accounts: 1, + }, + account_keys, + recent_blockhash: Hash::default(), + instructions, + }; + let tx = Transaction { + signatures: vec![Signature::default(); 1], + message, + }; + let err = validate_activation_scope( + &tx, + &dummy_request(), + &program_id_str(), + &scope_config(), + &scope_subscriber(), + ) + .unwrap_err(); + assert!(err.message.contains("out-of-range"), "{}", err.message); + } + + #[test] + fn validate_scope_rejects_unknown_subscriptions_instruction() { + // An instruction ON the subscriptions program but with a + // non-subscribe/transfer discriminator (e.g. a delegation revoke or + // plan mutation) must be rejected. + let subscriber = Pubkey::new_unique(); + let tx = build_tx( + &[subscriber], + vec![ + (INSTRUCTION_SUBSCRIBE, vec![]), + (INSTRUCTION_TRANSFER_SUBSCRIPTION, vec![]), + ( + crate::mpp::program::subscriptions::INSTRUCTION_REVOKE_DELEGATION, + vec![], + ), + ], + ); + let err = validate_activation_scope( + &tx, + &dummy_request(), + &program_id_str(), + &scope_config(), + &scope_subscriber(), + ) + .unwrap_err(); + assert!( + err.message + .contains("disallowed subscriptions-program instruction"), + "{}", + err.message + ); + } + + /// A per-account override for the ATA `CreateIdempotent` instruction so + /// negative tests can corrupt exactly one field. `None` fields fall back to + /// the canonical value derived from `config` + `subscriber`. + #[derive(Default)] + struct AtaOverrides { + payer: Option, + owner: Option, + mint: Option, + token_program: Option, + ata: Option, + data: Option>, + accounts: Option>, + } + + /// Build a full activation transaction in the exact shape the client + /// builder emits — compute-budget price/limit, an ATA `CreateIdempotent`, + /// subscribe, transfer, and a memo — with the ATA instruction fields + /// controllable via `overrides`. The tx fee payer is `account_keys[0]`. + fn build_activation_tx_with_ata( + config: &SubscriptionConfig, + subscriber: &Pubkey, + overrides: AtaOverrides, + ) -> Transaction { + use solana_hash::Hash; + use solana_message::compiled_instruction::CompiledInstruction; + use solana_message::{Message, MessageHeader}; + + let fee_payer = Pubkey::new_unique(); + let mint = overrides + .mint + .unwrap_or_else(|| parse_pubkey(&config.mint, "mint").unwrap()); + let token_program = overrides + .token_program + .unwrap_or_else(|| parse_pubkey(&config.token_program, "token_program").unwrap()); + let owner = overrides.owner.unwrap_or(*subscriber); + let payer = overrides.payer.unwrap_or(fee_payer); + let system_program = parse_pubkey(SYSTEM_PROGRAM_ID, "system").unwrap(); + let ata_program = parse_pubkey(ASSOCIATED_TOKEN_PROGRAM_ID, "ata").unwrap(); + // Canonical ATA re-derives from (owner, token_program, mint); a test + // that wants a bogus ATA address overrides `ata` directly. + let ata_addr = overrides.ata.unwrap_or_else(|| { + Pubkey::find_program_address( + &[owner.as_ref(), token_program.as_ref(), mint.as_ref()], + &ata_program, + ) + .0 + }); + + let cb = parse_pubkey(COMPUTE_BUDGET_PROGRAM_ID, "cb").unwrap(); + let memo = parse_pubkey(MEMO_PROGRAM_ID, "memo").unwrap(); + let sub = parse_pubkey(SUBSCRIPTIONS_PROGRAM_ID, "sub").unwrap(); + // Keep writable non-signers before every readonly non-signer, as the + // legacy message header requires. The ATA payer is the fee payer at + // account index 0, not a duplicate readonly copy of that key. + let account_keys = vec![ + fee_payer, + ata_addr, + cb, + ata_program, + memo, + sub, + payer, + owner, + mint, + system_program, + token_program, + ]; + // Default canonical CreateIdempotent layout referencing fee payer, + // writable ATA, then four readonly program/accounts. + let ata_accounts = overrides.accounts.unwrap_or_else(|| { + if payer == fee_payer { + vec![0, 1, 7, 8, 9, 10] + } else { + vec![6, 1, 7, 8, 9, 10] + } + }); + let ata_data = overrides.data.unwrap_or_else(|| vec![1]); + + let mk = |prog: u8, accounts: Vec, data: Vec| CompiledInstruction { + program_id_index: prog, + accounts, + data, + }; + let instructions = vec![ + mk(2, vec![], [vec![3], 1u64.to_le_bytes().to_vec()].concat()), + mk( + 2, + vec![], + [vec![2], 200_000u32.to_le_bytes().to_vec()].concat(), + ), + mk(3, ata_accounts, ata_data), + mk(5, vec![], vec![INSTRUCTION_SUBSCRIBE]), + mk(5, vec![], vec![INSTRUCTION_TRANSFER_SUBSCRIPTION]), + mk(4, vec![], b"external-id".to_vec()), + ]; + let message = Message { + header: MessageHeader { + num_required_signatures: 1, + num_readonly_signed_accounts: 0, + num_readonly_unsigned_accounts: (account_keys.len() - 2) as u8, + }, + account_keys, + recent_blockhash: Hash::default(), + instructions, + }; + Transaction { + signatures: vec![Signature::default(); 1], + message, + } + } + + #[test] + fn validate_scope_accepts_documented_default_compute_budget() { + // The exact shape the client builder emits: + // [compute_budget price, compute_budget limit, create_idempotent_ata, + // subscribe, transfer_subscription, memo] must pass, with a canonical + // ATA that funds from the fee payer and re-derives for the recipient. + // Its 200k limit is the documented safe sponsored cap and the Rust + // client's public default. + let config = scope_config(); + let subscriber = Pubkey::new_unique(); + let recipient = parse_pubkey(&config.recipient, "recipient").unwrap(); + let tx = build_activation_tx_with_ata( + &config, + &subscriber, + AtaOverrides { + owner: Some(recipient), + ..Default::default() + }, + ); + validate_activation_scope( + &tx, + &dummy_request(), + &program_id_str(), + &config, + &subscriber, + ) + .expect("valid scope"); + } + + #[test] + fn validate_scope_requires_exactly_one_compute_unit_limit() { + let config = scope_config(); + let subscriber = Pubkey::new_unique(); + let compute_budget = parse_pubkey(COMPUTE_BUDGET_PROGRAM_ID, "compute budget").unwrap(); + + let canonical = || { + build_tx( + &[subscriber], + vec![ + (INSTRUCTION_SUBSCRIBE, vec![]), + (INSTRUCTION_TRANSFER_SUBSCRIPTION, vec![]), + ], + ) + }; + let mut missing = canonical(); + let missing_keys = missing.message.account_keys.clone(); + missing.message.instructions.retain(|ix| { + missing_keys.get(ix.program_id_index as usize) != Some(&compute_budget) + || ix.data.first() != Some(&2) + }); + let err = validate_activation_scope( + &missing, + &dummy_request(), + &program_id_str(), + &config, + &subscriber, + ) + .unwrap_err(); + assert!( + err.message.contains("exactly one SetComputeUnitLimit"), + "{}", + err.message + ); + + let mut duplicate = canonical(); + let limit = duplicate + .message + .instructions + .iter() + .find(|ix| { + duplicate + .message + .account_keys + .get(ix.program_id_index as usize) + == Some(&compute_budget) + && ix.data.first() == Some(&2) + }) + .cloned() + .expect("fixture contains compute-unit limit"); + duplicate.message.instructions.push(limit); + let err = validate_activation_scope( + &duplicate, + &dummy_request(), + &program_id_str(), + &config, + &subscriber, + ) + .unwrap_err(); + assert!(err.message.contains("Duplicate"), "{}", err.message); + } + + #[test] + fn validate_scope_accepts_ata_owned_by_recipient() { + // An ATA created for the plan recipient (a split target) is authorized. + let config = scope_config(); + let subscriber = Pubkey::new_unique(); + let recipient = parse_pubkey(&config.recipient, "recipient").unwrap(); + let tx = build_activation_tx_with_ata( + &config, + &subscriber, + AtaOverrides { + owner: Some(recipient), + ..Default::default() + }, + ); + validate_activation_scope( + &tx, + &dummy_request(), + &program_id_str(), + &config, + &subscriber, + ) + .expect("recipient ATA is authorized"); + } + + #[test] + fn validate_scope_rejects_ata_funded_by_non_fee_payer() { + // (a) funding account != the fee payer at index 0. + let config = scope_config(); + let subscriber = Pubkey::new_unique(); + let tx = build_activation_tx_with_ata( + &config, + &subscriber, + AtaOverrides { + payer: Some(Pubkey::new_unique()), + ..Default::default() + }, + ); + let err = validate_activation_scope( + &tx, + &dummy_request(), + &program_id_str(), + &config, + &subscriber, + ) + .unwrap_err(); + assert!( + err.message + .contains("ATA payer must be the transaction fee payer"), + "{}", + err.message + ); + } + + #[test] + fn validate_scope_rejects_ata_wrong_mint() { + // (b) ATA created for a mint other than the plan mint. + let config = scope_config(); + let subscriber = Pubkey::new_unique(); + let tx = build_activation_tx_with_ata( + &config, + &subscriber, + AtaOverrides { + mint: Some(Pubkey::new_unique()), + ..Default::default() + }, + ); + let err = validate_activation_scope( + &tx, + &dummy_request(), + &program_id_str(), + &config, + &subscriber, + ) + .unwrap_err(); + assert!( + err.message + .contains("ATA creation mint does not match the plan mint"), + "{}", + err.message + ); + } + + #[test] + fn validate_scope_rejects_ata_wrong_owner() { + // (c) ATA owner is neither subscriber, recipient, nor puller. + let config = scope_config(); + let subscriber = Pubkey::new_unique(); + let tx = build_activation_tx_with_ata( + &config, + &subscriber, + AtaOverrides { + owner: Some(Pubkey::new_unique()), + ..Default::default() + }, + ); + let err = validate_activation_scope( + &tx, + &dummy_request(), + &program_id_str(), + &config, + &subscriber, + ) + .unwrap_err(); + assert!( + err.message + .contains("ATA creation owner is not authorized by the challenge"), + "{}", + err.message + ); + } + + #[test] + fn validate_scope_rejects_ata_non_canonical_layout() { + // (d1) Non-idempotent discriminator (Create = 0) is rejected. + let config = scope_config(); + let subscriber = Pubkey::new_unique(); + let tx = build_activation_tx_with_ata( + &config, + &subscriber, + AtaOverrides { + data: Some(vec![0]), + ..Default::default() + }, + ); + let err = validate_activation_scope( + &tx, + &dummy_request(), + &program_id_str(), + &config, + &subscriber, + ) + .unwrap_err(); + assert!( + err.message + .contains("Only idempotent ATA creation is allowed"), + "{}", + err.message + ); + + // (d2) Wrong account count (canonical is 6) is rejected. + let tx = build_activation_tx_with_ata( + &config, + &subscriber, + AtaOverrides { + accounts: Some(vec![5, 6, 7, 8, 9]), + ..Default::default() + }, + ); + let err = validate_activation_scope( + &tx, + &dummy_request(), + &program_id_str(), + &config, + &subscriber, + ) + .unwrap_err(); + assert!( + err.message + .contains("Unexpected ATA creation account layout"), + "{}", + err.message + ); + } + + #[test] + fn validate_scope_rejects_ata_wrong_token_program() { + // (d3) Token program does not match the configured one. Use the other + // supported token program so it passes the "supported" gate but fails + // the configured-program equality gate. + let config = scope_config(); + let subscriber = Pubkey::new_unique(); + let other_token_program = parse_pubkey(programs::TOKEN_2022_PROGRAM, "t22").unwrap(); + // Re-derive the ATA against the substituted token program so the only + // failing check is the token-program mismatch, not the re-derivation. + let mint = parse_pubkey(&config.mint, "mint").unwrap(); + let ata_program = parse_pubkey(ASSOCIATED_TOKEN_PROGRAM_ID, "ata").unwrap(); + let (ata_addr, _) = Pubkey::find_program_address( + &[ + subscriber.as_ref(), + other_token_program.as_ref(), + mint.as_ref(), + ], + &ata_program, + ); + let tx = build_activation_tx_with_ata( + &config, + &subscriber, + AtaOverrides { + token_program: Some(other_token_program), + ata: Some(ata_addr), + ..Default::default() + }, + ); + let err = validate_activation_scope( + &tx, + &dummy_request(), + &program_id_str(), + &config, + &subscriber, + ) + .unwrap_err(); + assert!( + err.message + .contains("ATA creation token program does not match the configured token program"), + "{}", + err.message + ); + } + + #[test] + fn validate_scope_rejects_ata_that_does_not_rederive() { + // (e) ATA address that does not re-derive from (owner, token, mint). + let config = scope_config(); + let subscriber = Pubkey::new_unique(); + let tx = build_activation_tx_with_ata( + &config, + &subscriber, + AtaOverrides { + ata: Some(Pubkey::new_unique()), + ..Default::default() + }, + ); + let err = validate_activation_scope( + &tx, + &dummy_request(), + &program_id_str(), + &config, + &subscriber, + ) + .unwrap_err(); + assert!( + err.message + .contains("ATA creation address does not match owner/mint/token program"), + "{}", + err.message + ); + } + + #[test] + fn validate_scope_rejects_ata_privilege_escalation() { + let config = scope_config(); + let subscriber = Pubkey::new_unique(); + let mut tx = build_activation_tx_with_ata(&config, &subscriber, AtaOverrides::default()); + // Mark every unsigned account readonly, making the ATA account + // incorrectly readonly even though CreateIdempotent writes it. + tx.message.header.num_readonly_unsigned_accounts = + (tx.message.account_keys.len() - 1) as u8; + let err = validate_activation_scope( + &tx, + &dummy_request(), + &program_id_str(), + &config, + &subscriber, + ) + .unwrap_err(); + assert!( + err.message.contains("writable privilege"), + "{}", + err.message + ); + } + + #[test] + fn compute_budget_rejects_malformed_duplicates_and_expensive_settings() { + use solana_message::compiled_instruction::CompiledInstruction; + + let instruction = |data: Vec| CompiledInstruction { + program_id_index: 0, + accounts: vec![], + data, + }; + let mut limit_seen = false; + let mut price_seen = false; + validate_compute_budget_instruction( + &instruction([vec![2], 200_000u32.to_le_bytes().to_vec()].concat()), + &mut limit_seen, + &mut price_seen, + ) + .expect("maximum compute-unit limit is allowed"); + + let err = validate_compute_budget_instruction( + &instruction([vec![2], 200_001u32.to_le_bytes().to_vec()].concat()), + &mut false, + &mut false, + ) + .unwrap_err(); + assert!(err.message.contains("exceeds maximum"), "{}", err.message); + + let err = + validate_compute_budget_instruction(&instruction(vec![3]), &mut false, &mut false) + .unwrap_err(); + assert!(err.message.contains("malformed"), "{}", err.message); + + let err = validate_compute_budget_instruction( + &instruction([vec![3], 10_001u64.to_le_bytes().to_vec()].concat()), + &mut false, + &mut false, + ) + .unwrap_err(); + assert!(err.message.contains("exceeds maximum"), "{}", err.message); + + let err = validate_compute_budget_instruction( + &instruction([vec![2], 1u32.to_le_bytes().to_vec()].concat()), + &mut limit_seen, + &mut price_seen, + ) + .unwrap_err(); + assert!(err.message.contains("Duplicate"), "{}", err.message); + } + + #[test] + fn validate_scope_rejects_invalid_program_id_string() { + let subscriber = Pubkey::new_unique(); + let tx = build_tx(&[subscriber], vec![]); + let err = validate_activation_scope( + &tx, + &dummy_request(), + "not-a-pubkey", + &scope_config(), + &scope_subscriber(), + ) + .unwrap_err(); + assert!(!err.message.is_empty()); + } + + // ── decode_base64_transaction ─────────────────────────────────────── + + #[test] + fn decode_base64_transaction_rejects_bad_base64() { + let err = decode_base64_transaction("!!!not base64!!!").unwrap_err(); + assert!(err.message.contains("base64 decode"), "{}", err.message); + } + + #[test] + fn decode_base64_transaction_rejects_bad_bincode() { + // Valid base64 but not a bincode-serialised Transaction. + let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, [0xFFu8; 8]); + let err = decode_base64_transaction(&b64).unwrap_err(); + assert!(err.message.contains("bincode decode"), "{}", err.message); + } + + #[test] + fn malformed_transaction_header_does_not_reach_writable_check() { + let subscriber = Pubkey::new_unique(); + let mut tx = build_tx(&[subscriber], vec![]); + tx.message.header.num_readonly_unsigned_accounts = + (tx.message.account_keys.len() + 1) as u8; + let bytes = bincode::serialize(&tx).unwrap(); + let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes); + + let result = std::panic::catch_unwind(|| decode_base64_transaction(&b64)); + assert!(result.is_ok(), "malformed header must not panic"); + let err = result + .unwrap() + .expect_err("malformed header must be rejected"); + assert_eq!(err.message, "Tx sanitization failed: index out of bounds"); + + assert!( + !is_writable_account_index( + &tx.message, + usize::from(tx.message.header.num_required_signatures), + ), + "defense-in-depth writable check must fail closed" + ); + } + + #[test] + fn decode_base64_transaction_round_trips_real_tx() { + let subscriber = Pubkey::new_unique(); + let tx = build_tx(&[subscriber], vec![(INSTRUCTION_SUBSCRIBE, vec![])]); + let bytes = bincode::serialize(&tx).unwrap(); + let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes); + let decoded = decode_base64_transaction(&b64).unwrap(); + assert_eq!(decoded.message.account_keys, tx.message.account_keys); + } + + // ── co_sign_as_fee_payer ──────────────────────────────────────────── + + fn fee_payer_signer_and_key() -> (Arc, Pubkey) { + use solana_keychain::{MemorySigner, SolanaSigner}; + let sk = ed25519_dalek::SigningKey::from_bytes(&[11u8; 32]); + let mut kp = [0u8; 64]; + kp[..32].copy_from_slice(sk.as_bytes()); + kp[32..].copy_from_slice(sk.verifying_key().as_bytes()); + let signer = MemorySigner::from_bytes(&kp).expect("kp"); + let pk = signer.pubkey(); + (Arc::new(signer), pk) + } + + #[tokio::test(flavor = "multi_thread")] + async fn co_sign_fills_fee_payer_slot() { + let (signer, fee_payer) = fee_payer_signer_and_key(); + let subscriber = Pubkey::new_unique(); + // fee-payer is account_keys[0]. + let mut tx = build_tx(&[fee_payer, subscriber], vec![]); + assert_eq!(tx.signatures[0], Signature::default()); + co_sign_as_fee_payer(&mut tx, &signer) + .await + .expect("co-sign"); + assert_ne!(tx.signatures[0], Signature::default()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn co_sign_rejects_missing_fee_payer_key() { + let (signer, _fee_payer) = fee_payer_signer_and_key(); + // A tx that does NOT include the fee-payer pubkey in account_keys. + let other = Pubkey::new_unique(); + let mut tx = build_tx(&[other], vec![]); + let err = co_sign_as_fee_payer(&mut tx, &signer).await.unwrap_err(); + assert!(err.message.contains("account index 0"), "{}", err.message); + } + + #[tokio::test(flavor = "multi_thread")] + async fn co_sign_rejects_fee_payer_not_at_index_zero() { + // The fee-payer key present at a non-zero index (e.g. + // as the authority of an attacker-inserted instruction) must NOT be + // co-signed. The old code used `position()` and would sign wherever + // the key appeared. + let (signer, fee_payer) = fee_payer_signer_and_key(); + let subscriber = Pubkey::new_unique(); + // subscriber at index 0, fee-payer at index 1. + let mut tx = build_tx(&[subscriber, fee_payer], vec![]); + let err = co_sign_as_fee_payer(&mut tx, &signer).await.unwrap_err(); + assert!(err.message.contains("account index 0"), "{}", err.message); + } + + #[tokio::test(flavor = "multi_thread")] + async fn co_sign_rejects_empty_account_keys() { + let (signer, _fee_payer) = fee_payer_signer_and_key(); + let mut tx = build_tx(&[Pubkey::new_unique()], vec![]); + tx.message.account_keys.clear(); + let err = co_sign_as_fee_payer(&mut tx, &signer).await.unwrap_err(); + assert!(err.message.contains("no account keys"), "{}", err.message); + } + + #[tokio::test(flavor = "multi_thread")] + async fn co_sign_rejects_short_signatures_vec() { + // Fee payer correctly at index 0, but the signatures vec is empty, so + // there is no slot 0 to fill. + let (signer, fee_payer) = fee_payer_signer_and_key(); + let mut tx = build_tx(&[fee_payer], vec![]); + tx.signatures.clear(); + let err = co_sign_as_fee_payer(&mut tx, &signer).await.unwrap_err(); + assert!( + err.message.contains("signatures vec is shorter"), + "{}", + err.message + ); + } + + // ── verify_credential pre-RPC error branches ──────────────────────── + + /// Build a credential whose challenge is issued by `server` (so HMAC + + /// pinned fields pass) carrying the given activation payload. + fn credential_for_server( + server: &SubscriptionServer, + payload: ActivatePayload, + ) -> PaymentCredential { + let challenge = server + .subscription_challenge("10000000") + .expect("challenge"); + PaymentCredential::new(challenge.to_echo(), payload) + } + + #[tokio::test(flavor = "multi_thread")] + async fn verify_rejects_wrong_method() { + let server = SubscriptionServer::new(make_config()).expect("server"); + let challenge = server.subscription_challenge("10000000").unwrap(); + let mut echo = challenge.to_echo(); + // Corrupt the id so it's recomputed to match, but flip the method. + // Instead: build a fresh challenge with a mismatched method via a + // server that pins a different method is not possible; directly + // tamper the echoed method — HMAC will then mismatch first, so we + // recompute the id to bind the tampered method. + echo.method = "notsolana".into(); + echo.id = compute_challenge_id( + &server.challenge_binding_secret, + &server.realm, + echo.method.as_str(), + echo.intent.as_str(), + echo.request.raw(), + echo.expires.as_deref(), + echo.digest.as_deref(), + echo.opaque.as_ref().map(|o| o.raw()), + ); + let credential = PaymentCredential::new( + echo, + ActivatePayload { + payload_type: "transaction".into(), + transaction: Some("AQAAAA==".into()), + signature: None, + }, + ); + let err = server.verify_credential(&credential).await.unwrap_err(); + assert!(err.message.contains("does not match"), "{}", err.message); + } + + #[tokio::test(flavor = "multi_thread")] + async fn verify_rejects_wrong_intent() { + let server = SubscriptionServer::new(make_config()).expect("server"); + let challenge = server.subscription_challenge("10000000").unwrap(); + let mut echo = challenge.to_echo(); + echo.intent = "charge".into(); + echo.id = compute_challenge_id( + &server.challenge_binding_secret, + &server.realm, + echo.method.as_str(), + echo.intent.as_str(), + echo.request.raw(), + echo.expires.as_deref(), + echo.digest.as_deref(), + echo.opaque.as_ref().map(|o| o.raw()), + ); + let credential = PaymentCredential::new( + echo, + ActivatePayload { + payload_type: "transaction".into(), + transaction: Some("AQAAAA==".into()), + signature: None, + }, + ); + let err = server.verify_credential(&credential).await.unwrap_err(); + assert!( + err.message.contains("not a subscription"), + "{}", + err.message + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn verify_rejects_wrong_realm() { + let server = SubscriptionServer::new(make_config()).expect("server"); + let challenge = server.subscription_challenge("10000000").unwrap(); + let mut echo = challenge.to_echo(); + echo.realm = "other-realm".into(); + echo.id = compute_challenge_id( + &server.challenge_binding_secret, + &server.realm, + echo.method.as_str(), + echo.intent.as_str(), + echo.request.raw(), + echo.expires.as_deref(), + echo.digest.as_deref(), + echo.opaque.as_ref().map(|o| o.raw()), + ); + let credential = PaymentCredential::new( + echo, + ActivatePayload { + payload_type: "transaction".into(), + transaction: Some("AQAAAA==".into()), + signature: None, + }, + ); + let err = server.verify_credential(&credential).await.unwrap_err(); + assert!(err.message.contains("realm"), "{}", err.message); + } + + #[tokio::test(flavor = "multi_thread")] + async fn verify_rejects_mint_mismatch() { + // Two servers with different mints but the SAME secret + realm, so + // the HMAC passes but the decoded mint won't match. + let mut cfg_a = make_config(); + cfg_a.challenge_binding_secret = "shared-secret".into(); + cfg_a.realm = "shared-realm".into(); + let mut cfg_b = cfg_a.clone(); + cfg_b.mint = keypair_base58(); // different mint + let server_a = SubscriptionServer::new(cfg_a).expect("a"); + let server_b = SubscriptionServer::new(cfg_b).expect("b"); + // Challenge issued by A (its mint), verified by B (different mint). + let challenge = server_a.subscription_challenge("10000000").unwrap(); + let credential = PaymentCredential::new( + challenge.to_echo(), + ActivatePayload { + payload_type: "transaction".into(), + transaction: Some("AQAAAA==".into()), + signature: None, + }, + ); + let err = server_b.verify_credential(&credential).await.unwrap_err(); + assert!(err.message.contains("mint"), "{}", err.message); + } + + #[tokio::test(flavor = "multi_thread")] + async fn verify_rejects_recipient_mismatch() { + let mut cfg_a = make_config(); + cfg_a.challenge_binding_secret = "shared-secret2".into(); + cfg_a.realm = "shared-realm2".into(); + let mut cfg_b = cfg_a.clone(); + cfg_b.recipient = keypair_base58(); // different recipient, same mint + let server_a = SubscriptionServer::new(cfg_a).expect("a"); + let server_b = SubscriptionServer::new(cfg_b).expect("b"); + let challenge = server_a.subscription_challenge("10000000").unwrap(); + let credential = PaymentCredential::new( + challenge.to_echo(), + ActivatePayload { + payload_type: "transaction".into(), + transaction: Some("AQAAAA==".into()), + signature: None, + }, + ); + let err = server_b.verify_credential(&credential).await.unwrap_err(); + assert!(err.message.contains("recipient"), "{}", err.message); + } + + #[tokio::test(flavor = "multi_thread")] + async fn verify_rejects_unsupported_payload_type() { + let server = SubscriptionServer::new(make_config()).expect("server"); + let credential = credential_for_server( + &server, + ActivatePayload { + payload_type: "wat".into(), + transaction: None, + signature: None, + }, + ); + let err = server.verify_credential(&credential).await.unwrap_err(); + assert!( + err.message.contains("Unsupported payload type"), + "{}", + err.message + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn verify_rejects_transaction_missing_transaction_field() { + let server = SubscriptionServer::new(make_config()).expect("server"); + let credential = credential_for_server( + &server, + ActivatePayload { + payload_type: "transaction".into(), + transaction: None, // missing + signature: None, + }, + ); + let err = server.verify_credential(&credential).await.unwrap_err(); + assert!( + err.message.contains("missing `transaction` field"), + "{}", + err.message + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn verify_rejects_signature_with_fee_sponsorship() { + // type="signature" combined with fee sponsorship must be rejected + // before the push-mode branch. + use solana_keychain::{MemorySigner, SolanaSigner}; + let mut cfg = make_config(); + cfg.fee_payer = true; + let sk = ed25519_dalek::SigningKey::from_bytes(&[13u8; 32]); + let mut kp = [0u8; 64]; + kp[..32].copy_from_slice(sk.as_bytes()); + kp[32..].copy_from_slice(sk.verifying_key().as_bytes()); + let signer = MemorySigner::from_bytes(&kp).expect("kp"); + cfg.puller = signer.pubkey().to_string(); + cfg.fee_payer_signer = Some(Arc::new(signer)); + let server = SubscriptionServer::new(cfg).expect("server"); + let credential = credential_for_server( + &server, + ActivatePayload { + payload_type: "signature".into(), + transaction: None, + signature: Some("5J8Sig".into()), + }, + ); + let err = server.verify_credential(&credential).await.unwrap_err(); + assert!(err.message.contains("fee sponsorship"), "{}", err.message); + } + + #[tokio::test(flavor = "multi_thread")] + async fn verify_rejects_transaction_bad_base64() { + let server = SubscriptionServer::new(make_config()).expect("server"); + let credential = credential_for_server( + &server, + ActivatePayload { + payload_type: "transaction".into(), + transaction: Some("!!!bad!!!".into()), + signature: None, + }, + ); + let err = server.verify_credential(&credential).await.unwrap_err(); + assert!(err.message.contains("base64 decode"), "{}", err.message); + } + + #[tokio::test(flavor = "multi_thread")] + async fn verify_rejects_transaction_empty_account_keys() { + // A real (decodable) tx with no accounts must be rejected by the + // transaction sanitization boundary before deeper verification. + use solana_hash::Hash; + use solana_message::{Message, MessageHeader}; + let server = SubscriptionServer::new(make_config()).expect("server"); + let tx = Transaction { + signatures: vec![], + message: Message { + header: MessageHeader { + num_required_signatures: 0, + num_readonly_signed_accounts: 0, + num_readonly_unsigned_accounts: 0, + }, + account_keys: vec![], + recent_blockhash: Hash::default(), + instructions: vec![], + }, + }; + let bytes = bincode::serialize(&tx).unwrap(); + let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes); + let credential = credential_for_server( + &server, + ActivatePayload { + payload_type: "transaction".into(), + transaction: Some(b64), + signature: None, + }, + ); + let err = server.verify_credential(&credential).await.unwrap_err(); + assert!( + err.message.contains("sanitization failed"), + "{}", + err.message + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn verify_rejects_transaction_missing_scope() { + // A decodable tx with a valid subscriber but no subscribe/transfer + // instructions reaches validate_activation_scope and fails there. + let server = SubscriptionServer::new(make_config()).expect("server"); + let subscriber = Pubkey::new_unique(); + let tx = build_tx(&[subscriber], vec![]); // no instructions + let bytes = bincode::serialize(&tx).unwrap(); + let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes); + let credential = credential_for_server( + &server, + ActivatePayload { + payload_type: "transaction".into(), + transaction: Some(b64), + signature: None, + }, + ); + let err = server.verify_credential(&credential).await.unwrap_err(); + assert!(err.message.contains("missing subscribe"), "{}", err.message); + } + + // ── validate_terms_and_build_receipt (post-fetch terms check) ─────── + + fn make_delegation_view( + plan_pda: Pubkey, + amount: u64, + period_hours: u64, + pulled: u64, + ) -> SubscriptionDelegationView { + SubscriptionDelegationView { + subscriber: Pubkey::new_unique(), + plan_pda, + amount_per_period: amount, + period_hours, + current_period_start_ts: 1_700_000_000, + amount_pulled_in_period: pulled, + } + } + + fn terms_request(amount: &str) -> SubscriptionRequest { + SubscriptionRequest { + amount: amount.into(), + currency: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".into(), + period_unit: SubscriptionPeriodUnit::Day, + period_count: "30".into(), // 30 days => 720 hours + recipient: "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin".into(), + subscription_expires: Some("2030-01-01T00:00:00Z".into()), + ..Default::default() + } + } + + #[test] + fn terms_check_success_builds_subscription_receipt() { + let plan_pda = Pubkey::new_unique(); + let sub_pda = Pubkey::new_unique(); + let delegation = make_delegation_view(plan_pda, 720, 720, 720); + let receipt = validate_terms_and_build_receipt( + &delegation, + &terms_request("720"), + &plan_pda, + &sub_pda, + "PLAN123", + "chal-id", + Some("sig123".into()), + ) + .expect("valid terms"); + match receipt { + ReceiptKind::Subscription { base, extensions } => { + assert_eq!(base.reference, sub_pda.to_string()); + assert_eq!(base.challenge_id, "chal-id"); + assert_eq!(extensions.subscription_id, sub_pda.to_string()); + assert_eq!(extensions.plan_id, "PLAN123"); + assert_eq!(extensions.period_index, "0"); + assert_eq!(extensions.activation_signature.as_deref(), Some("sig123")); + assert_eq!( + extensions.expires_at.as_deref(), + Some("2030-01-01T00:00:00Z") + ); + // period_start = 1_700_000_000 (2023-11-14T22:13:20Z); + // period_end = start + 720h. + assert_eq!(extensions.period_start_ts, "2023-11-14T22:13:20Z"); + } + other => panic!("expected Subscription receipt, got {other:?}"), + } + } + + #[test] + fn terms_check_rejects_amount_mismatch() { + let plan_pda = Pubkey::new_unique(); + let delegation = make_delegation_view(plan_pda, 999, 720, 999); + let err = validate_terms_and_build_receipt( + &delegation, + &terms_request("720"), // expects 720, delegation has 999 + &plan_pda, + &Pubkey::new_unique(), + "PLAN", + "id", + None, + ) + .unwrap_err(); + assert!(err.message.contains("amount mismatch"), "{}", err.message); + } + + #[test] + fn terms_check_rejects_period_mismatch() { + let plan_pda = Pubkey::new_unique(); + // amount matches, period_hours (24) != expected 720. + let delegation = make_delegation_view(plan_pda, 720, 24, 720); + let err = validate_terms_and_build_receipt( + &delegation, + &terms_request("720"), + &plan_pda, + &Pubkey::new_unique(), + "PLAN", + "id", + None, + ) + .unwrap_err(); + assert!(err.message.contains("period mismatch"), "{}", err.message); + } + + #[test] + fn terms_check_rejects_plan_mismatch() { + let expected_plan = Pubkey::new_unique(); + let wrong_plan = Pubkey::new_unique(); + // amount + period match, but delegation.plan_pda differs. + let delegation = make_delegation_view(wrong_plan, 720, 720, 720); + let err = validate_terms_and_build_receipt( + &delegation, + &terms_request("720"), + &expected_plan, + &Pubkey::new_unique(), + "PLAN", + "id", + None, + ) + .unwrap_err(); + assert!(err.message.contains("plan mismatch"), "{}", err.message); + } + + #[test] + fn terms_check_rejects_uncharged_first_period() { + let plan_pda = Pubkey::new_unique(); + // amount + period + plan match, but nothing was pulled (0 != 720). + let delegation = make_delegation_view(plan_pda, 720, 720, 0); + let err = validate_terms_and_build_receipt( + &delegation, + &terms_request("720"), + &plan_pda, + &Pubkey::new_unique(), + "PLAN", + "id", + None, + ) + .unwrap_err(); + assert!( + err.message + .contains("did not execute the first-period charge"), + "{}", + err.message + ); + } + + #[test] + fn terms_check_rejects_non_integer_amount() { + let plan_pda = Pubkey::new_unique(); + let delegation = make_delegation_view(plan_pda, 720, 720, 720); + let err = validate_terms_and_build_receipt( + &delegation, + &terms_request("not-a-number"), + &plan_pda, + &Pubkey::new_unique(), + "PLAN", + "id", + None, + ) + .unwrap_err(); + assert!( + err.message.contains("not a positive integer"), + "{}", + err.message + ); + } + + #[test] + fn terms_check_rejects_invalid_period_request() { + let plan_pda = Pubkey::new_unique(); + let delegation = make_delegation_view(plan_pda, 720, 720, 720); + // period_count that maps out of [1, 8760] hours (400 days => 9600h). + let mut req = terms_request("720"); + req.period_count = "400".into(); + let err = validate_terms_and_build_receipt( + &delegation, + &req, + &plan_pda, + &Pubkey::new_unique(), + "PLAN", + "id", + None, + ) + .unwrap_err(); + assert!(!err.message.is_empty()); + } + + // ── RPC method error paths (dead endpoint) ────────────────────────── + // + // These exercise the RPC round-trip + error `.map_err` closures without + // a live validator: a server pointed at a closed port fails fast with a + // connection-refused error, driving the not_found / transaction_failed / + // network_error branches. + + fn server_with_dead_rpc() -> SubscriptionServer { + let mut cfg = make_config(); + // Port 1 refuses connections immediately. + cfg.rpc_url = Some("http://127.0.0.1:1".into()); + SubscriptionServer::new(cfg).expect("server") + } + + /// Build the 155-byte on-chain `SubscriptionDelegation` account bytes + /// for the given plan/subscriber/terms, matching the layout the decoder + /// reads. + fn encode_delegation_account( + subscriber: [u8; 32], + plan_pda: [u8; 32], + amount: u64, + period_hours: u64, + pulled: u64, + period_start: i64, + ) -> Vec { + let mut data = Vec::with_capacity(SUBSCRIPTION_DELEGATION_LEN); + data.push(2); // discriminator + data.push(1); // version + data.push(255); // bump + data.extend_from_slice(&subscriber); // delegator + data.extend_from_slice(&plan_pda); // delegatee + data.extend_from_slice(&[3u8; 32]); // payer + data.extend_from_slice(&77i64.to_le_bytes()); // init_id + data.extend_from_slice(&amount.to_le_bytes()); + data.extend_from_slice(&period_hours.to_le_bytes()); + data.extend_from_slice(&1_780_000_000i64.to_le_bytes()); // created_at + data.extend_from_slice(&pulled.to_le_bytes()); + data.extend_from_slice(&period_start.to_le_bytes()); + data.extend_from_slice(&0i64.to_le_bytes()); // expires_at + data + } + + /// Spawn a one-shot HTTP server that answers a single JSON-RPC + /// `getAccountInfo` request with the given account bytes, then returns + /// the `http://127.0.0.1:` URL to point an RpcClient at. + fn spawn_mock_getaccount_rpc(account_data: Vec, owner: Pubkey) -> String { + use std::io::{Read, Write}; + use std::net::TcpListener; + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let url = format!("http://{}", listener.local_addr().unwrap()); + let data_b64 = + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &account_data); + let owner_str = owner.to_string(); + std::thread::spawn(move || { + // Serve several requests (verify makes multiple RPC round-trips: + // idempotency fetch, signature lookup, terms fetch). + for _ in 0..12 { + let Ok((mut stream, _)) = listener.accept() else { + return; + }; + let mut buf = [0u8; 8192]; + let _ = stream.read(&mut buf); + let body = format!( + "{{\"jsonrpc\":\"2.0\",\"result\":{{\"context\":{{\"slot\":1,\"apiVersion\":\"2.0.0\"}},\"value\":{{\"data\":[\"{data_b64}\",\"base64\"],\"executable\":false,\"lamports\":1000000,\"owner\":\"{owner_str}\",\"rentEpoch\":0,\"space\":{}}}}},\"id\":1}}", + account_data.len() + ); + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(resp.as_bytes()); + let _ = stream.flush(); + } + }); + url + } + + #[tokio::test(flavor = "multi_thread")] + async fn fetch_subscription_delegation_decodes_account_from_rpc() { + let mut cfg = make_config(); + let plan_pda = parse_pubkey(&cfg.plan_id, "plan").unwrap(); + let subscriber = Pubkey::new_unique(); + let program = parse_pubkey(SUBSCRIPTIONS_PROGRAM_ID, "prog").unwrap(); + let account = encode_delegation_account( + subscriber.to_bytes(), + plan_pda.to_bytes(), + 720, + 720, + 720, + 1_700_000_000, + ); + let url = spawn_mock_getaccount_rpc(account, program); + cfg.rpc_url = Some(url); + let server = SubscriptionServer::new(cfg).expect("server"); + let pda = Pubkey::new_unique(); + let view = server + .fetch_subscription_delegation(&pda) + .await + .expect("mock RPC returns a decodable delegation"); + assert_eq!(view.amount_per_period, 720); + assert_eq!(view.period_hours, 720); + assert_eq!(view.plan_pda, plan_pda); + assert_eq!(view.current_period_start_ts, 1_700_000_000); + } + + #[tokio::test(flavor = "multi_thread")] + async fn existing_delegation_does_not_prove_never_broadcast_activation_signature() { + // Point the server at a mock RPC that always returns a valid + // delegation account. verify_credential then: extracts the + // subscriber, validates scope, sees the delegation already exists + // (idempotent branch — skips broadcast), fetches it again, checks + // the terms, and builds the success receipt. Exercises the RPC + // success path end-to-end without a live validator. + let mut cfg = make_config(); + let plan_pda = parse_pubkey(&cfg.plan_id, "plan").unwrap(); + let program = parse_pubkey(SUBSCRIPTIONS_PROGRAM_ID, "prog").unwrap(); + // The delegation's amount/period must match the challenge terms: + // amount = 720 base units, Day * 30 => 720 period hours. + let subscriber = Pubkey::new_unique(); + let account = encode_delegation_account( + subscriber.to_bytes(), + plan_pda.to_bytes(), + 720, + 720, + 720, + 1_700_000_000, + ); + let url = spawn_mock_getaccount_rpc(account, program); + cfg.rpc_url = Some(url); + let server = SubscriptionServer::new(cfg).expect("server"); + + // Build an activation tx with a valid subscriber + subscribe/transfer + // scope, and issue a challenge for amount "720" so terms match. + let tx = build_tx( + &[subscriber], + vec![ + (INSTRUCTION_SUBSCRIBE, vec![]), + (INSTRUCTION_TRANSFER_SUBSCRIPTION, vec![]), + ], + ); + let bytes = bincode::serialize(&tx).unwrap(); + let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes); + let challenge = server.subscription_challenge("720").expect("challenge"); + let credential = PaymentCredential::new( + challenge.to_echo(), + ActivatePayload { + payload_type: "transaction".into(), + transaction: Some(b64), + signature: None, + }, + ); + + let err = server + .verify_credential(&credential) + .await + .expect_err("an unrelated existing delegation must not prove this signature landed"); + assert!( + err.message.contains("getSignatureStatuses") + || err.message.contains("exact submitted activation signature"), + "{}", + err.message + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn fetch_subscription_delegation_errors_on_dead_rpc() { + let server = server_with_dead_rpc(); + let pda = Pubkey::new_unique(); + let err = server + .fetch_subscription_delegation(&pda) + .await + .expect_err("dead RPC must error"); + assert!(!err.message.is_empty()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn exact_signature_confirmation_errors_on_dead_rpc() { + let server = server_with_dead_rpc(); + let signature = Signature::new_unique().to_string(); + let err = server + .is_signature_confirmed(&signature) + .await + .expect_err("dead RPC must error"); + assert!(!err.message.is_empty()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn broadcast_and_confirm_errors_on_dead_rpc() { + let server = server_with_dead_rpc(); + let subscriber = Pubkey::new_unique(); + let tx = build_tx( + &[subscriber], + vec![ + (INSTRUCTION_SUBSCRIBE, vec![]), + (INSTRUCTION_TRANSFER_SUBSCRIPTION, vec![]), + ], + ); + let err = server + .broadcast_and_confirm(&tx) + .await + .expect_err("dead RPC must error"); + assert!(!err.message.is_empty()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn verify_with_fee_sponsorship_cosigns_then_hits_rpc() { + // A fee-payer-configured server drives verify_credential through the + // co_sign_as_fee_payer branch before the (dead) RPC broadcast. + use solana_keychain::{MemorySigner, SolanaSigner}; + let mut cfg = make_config(); + cfg.rpc_url = Some("http://127.0.0.1:1".into()); + cfg.fee_payer = true; + let sk = ed25519_dalek::SigningKey::from_bytes(&[21u8; 32]); + let mut kp = [0u8; 64]; + kp[..32].copy_from_slice(sk.as_bytes()); + kp[32..].copy_from_slice(sk.verifying_key().as_bytes()); + let signer = MemorySigner::from_bytes(&kp).expect("kp"); + let fee_payer = signer.pubkey(); + cfg.puller = fee_payer.to_string(); + cfg.fee_payer_signer = Some(Arc::new(signer)); + let server = SubscriptionServer::new(cfg).expect("server"); + + // Build an activation tx: fee-payer at index 0, subscriber next, + // carrying subscribe + transfer instructions so scope validation + // passes and co-signing has a slot to fill. + let subscriber = Pubkey::new_unique(); + let tx = build_tx( + &[fee_payer, subscriber], + vec![ + (INSTRUCTION_SUBSCRIBE, vec![]), + (INSTRUCTION_TRANSFER_SUBSCRIPTION, vec![]), + ], + ); + let bytes = bincode::serialize(&tx).unwrap(); + let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes); + let credential = credential_for_server( + &server, + ActivatePayload { + payload_type: "transaction".into(), + transaction: Some(b64), + signature: None, + }, + ); + let err = server + .verify_credential(&credential) + .await + .expect_err("dead RPC after co-sign must error"); + assert!(!err.message.is_empty()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn verify_credential_hits_rpc_and_errors_on_dead_endpoint() { + // Drive the full verify path through instruction-scope validation + // up to the broadcast, which fails against the dead RPC. Covers the + // settlement branch of verify_credential (subscriber extraction + + // scope check + broadcast attempt). + let server = server_with_dead_rpc(); + let subscriber = Pubkey::new_unique(); + let tx = build_tx( + &[subscriber], + vec![ + (INSTRUCTION_SUBSCRIBE, vec![]), + (INSTRUCTION_TRANSFER_SUBSCRIPTION, vec![]), + ], + ); + let bytes = bincode::serialize(&tx).unwrap(); + let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes); + let credential = credential_for_server( + &server, + ActivatePayload { + payload_type: "transaction".into(), + transaction: Some(b64), + signature: None, + }, + ); + let err = server + .verify_credential(&credential) + .await + .expect_err("dead RPC must surface an error"); + assert!(!err.message.is_empty()); + } + + // ── format_rfc3339_seconds edge branches ──────────────────────────── + + #[test] + fn format_rfc3339_handles_jan_feb_and_pre_epoch() { + // A date in January exercises the `mp >= 10` month remap and the + // `mo <= 2` year adjustment. + // 2021-01-31T00:00:00Z = 1612051200 + assert_eq!( + format_rfc3339_seconds(1_612_051_200), + "2021-01-31T00:00:00Z" + ); + // A February date. + // 2020-02-29T23:59:59Z = 1583020799 (leap day) + assert_eq!( + format_rfc3339_seconds(1_583_020_799), + "2020-02-29T23:59:59Z" + ); + // A pre-epoch (negative) timestamp exercises the `z < 0` era branch. + // 1969-12-31T00:00:00Z = -86400 + assert_eq!(format_rfc3339_seconds(-86_400), "1969-12-31T00:00:00Z"); + } + + // ── Activation replay marker ──────────────────────────────────────── + // + // A duplicate activation credential must be rejected up front — before + // any RPC broadcast or receipt re-issuance — once the first activation + // has landed. The marker is keyed by the activation signature with the + // same key shape the TypeScript port writes + // (`solana-subscription:consumed:`), so a shared store rejects the + // replay across the two language runtimes. + + /// Per-method request counters for the stateful mock below, so a test + /// can assert that a rejected replay performs zero RPC work. + #[derive(Default)] + struct RpcCounters { + get_account: usize, + send: usize, + get_signatures: usize, + } + + /// Spawn a stateful, counting JSON-RPC mock for the activation happy + /// path. `getAccountInfo` returns account-not-found on its first call + /// (so `verify_credential` sees the delegation does not yet exist and + /// broadcasts) and the encoded delegation on every later call (so the + /// post-broadcast terms fetch succeeds). `sendTransaction` echoes the + /// submitted transaction's own first signature — matching what a real + /// node returns — and every method bumps a shared counter. + fn spawn_counting_activation_rpc( + account_data: Vec, + owner: Pubkey, + ) -> (String, Arc>) { + use std::io::{Read, Write}; + use std::net::TcpListener; + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let url = format!("http://{}", listener.local_addr().unwrap()); + let data_b64 = + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &account_data); + let owner_str = owner.to_string(); + let account_len = account_data.len(); + let counters = Arc::new(std::sync::Mutex::new(RpcCounters::default())); + let thread_counters = counters.clone(); + std::thread::spawn(move || { + for _ in 0..64 { + let Ok((mut stream, _)) = listener.accept() else { + return; + }; + let mut buf = [0u8; 8192]; + let n = stream.read(&mut buf).unwrap_or(0); + let body_start = buf[..n] + .windows(4) + .position(|w| w == b"\r\n\r\n") + .map(|p| p + 4) + .unwrap_or(0); + let req: serde_json::Value = + serde_json::from_slice(&buf[body_start..n]).unwrap_or(serde_json::Value::Null); + let method = req.get("method").and_then(|m| m.as_str()).unwrap_or(""); + let id = req.get("id").cloned().unwrap_or(serde_json::Value::Null); + let params = req + .get("params") + .cloned() + .unwrap_or(serde_json::Value::Null); + + let result = { + let mut c = thread_counters.lock().unwrap(); + match method { + "getLatestBlockhash" => serde_json::json!({ + "context": {"slot": 1}, + "value": {"blockhash": "11111111111111111111111111111111", "lastValidBlockHeight": 1000}, + }), + "getAccountInfo" => { + c.get_account += 1; + if c.get_account == 1 { + // First fetch: idempotency probe — not found. + serde_json::json!({"context": {"slot": 1}, "value": serde_json::Value::Null}) + } else { + serde_json::json!({ + "context": {"slot": 1}, + "value": { + "data": [data_b64, "base64"], + "executable": false, + "lamports": 1_000_000u64, + "owner": owner_str, + "rentEpoch": 0, + "space": account_len, + }, + }) + } + } + "sendTransaction" => { + c.send += 1; + let sig = params + .get(0) + .and_then(|p| p.as_str()) + .and_then(|encoded| { + let bytes = base64::Engine::decode( + &base64::engine::general_purpose::STANDARD, + encoded, + ) + .ok()?; + let tx: solana_transaction::versioned::VersionedTransaction = + bincode::deserialize(&bytes).ok()?; + Some(tx.signatures.first()?.to_string()) + }) + .unwrap_or_default(); + serde_json::Value::String(sig) + } + "getSignatureStatuses" => serde_json::json!({ + "context": {"slot": 1}, + "value": [{ + "slot": 1, + "confirmations": serde_json::Value::Null, + "status": {"Ok": serde_json::Value::Null}, + "err": serde_json::Value::Null, + "confirmationStatus": "finalized", + }], + }), + "getSignaturesForAddress" => { + c.get_signatures += 1; + serde_json::json!([]) + } + _ => serde_json::Value::Null, + } + }; + + let body = + serde_json::json!({"jsonrpc": "2.0", "result": result, "id": id}).to_string(); + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(resp.as_bytes()); + let _ = stream.flush(); + } + }); + (url, counters) + } + + #[tokio::test(flavor = "multi_thread")] + async fn verify_credential_rejects_replayed_activation_and_writes_ts_keyed_marker() { + // Drive one activation through the full verify path against the + // stateful counting mock: it broadcasts, confirms, fetches the + // delegation, and issues a receipt. The replay store must then carry + // `solana-subscription:consumed:` so a second, identical + // credential is rejected before any further broadcast/receipt work. + let store: Arc = Arc::new(MemoryStore::new()); + let mut cfg = make_config(); + cfg.store = Some(store.clone()); + let plan_pda = parse_pubkey(&cfg.plan_id, "plan").unwrap(); + let program = parse_pubkey(SUBSCRIPTIONS_PROGRAM_ID, "prog").unwrap(); + let subscriber = Pubkey::new_unique(); + let account = encode_delegation_account( + subscriber.to_bytes(), + plan_pda.to_bytes(), + 720, + 720, + 720, + 1_700_000_000, + ); + let (url, counters) = spawn_counting_activation_rpc(account, program); + cfg.rpc_url = Some(url); + let server = SubscriptionServer::new(cfg).expect("server"); + + // Build a client-signed activation tx with a distinctive first + // signature so the marker key is deterministic and easy to assert. + let mut tx = build_tx( + &[subscriber], + vec![ + (INSTRUCTION_SUBSCRIBE, vec![]), + (INSTRUCTION_TRANSFER_SUBSCRIPTION, vec![]), + ], + ); + tx.signatures[0] = Signature::from([7u8; 64]); + let expected_sig = tx.signatures[0].to_string(); + let expected_key = format!("solana-subscription:consumed:{expected_sig}"); + let bytes = bincode::serialize(&tx).unwrap(); + let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes); + let challenge = server.subscription_challenge("720").expect("challenge"); + let credential = PaymentCredential::new( + challenge.to_echo(), + ActivatePayload { + payload_type: "transaction".into(), + transaction: Some(b64), + signature: None, + }, + ); + + // First activation: succeeds, broadcasts exactly once. + server + .verify_credential(&credential) + .await + .expect("first activation succeeds"); + { + let c = counters.lock().unwrap(); + assert_eq!(c.send, 1, "first activation broadcasts exactly once"); + } + + // The consumed marker must be written under the TS-compatible key so + // a shared cross-language store rejects the replay. + assert_eq!( + store.get(&expected_key).await.unwrap(), + Some(serde_json::json!(true)), + "activation marker must be keyed as solana-subscription:consumed:", + ); + + let send_before_replay = counters.lock().unwrap().send; + let get_account_before_replay = counters.lock().unwrap().get_account; + + // Second, identical credential: rejected up front as a replay. + let err = server + .verify_credential(&credential) + .await + .expect_err("replayed activation must be rejected"); + assert_eq!( + err.code, + Some("signature-consumed"), + "replay must surface a signature-consumed error, got: {err:?}", + ); + + // The replay performed no further RPC work: no second broadcast and + // no additional account fetch / receipt re-issuance. + let c = counters.lock().unwrap(); + assert_eq!( + c.send, send_before_replay, + "replay must not broadcast a second transaction", + ); + assert_eq!( + c.get_account, get_account_before_replay, + "replay must be rejected before any RPC account fetch", + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn concurrent_identical_activations_admit_exactly_one() { + // The core TOCTOU gate. Two concurrent activation verifications with + // the SAME activation signature race through one shared store. The + // atomic `put_if_absent` claim admits exactly one — the other is + // rejected as `signature-consumed`. The winner runs the full happy + // path against the counting mock and keeps its marker (it never fails, + // so it never releases), so the loser observes the claim no matter how + // the two tasks interleave. + // + // Against the OLD non-atomic get-then-put, both verifications would + // pass the get() check before either wrote the marker and BOTH would + // broadcast + issue a receipt — zero `signature-consumed`, so this test + // fails. It passes only with the atomic claim. + let store: Arc = Arc::new(MemoryStore::new()); + let mut cfg = make_config(); + cfg.store = Some(store.clone()); + let plan_pda = parse_pubkey(&cfg.plan_id, "plan").unwrap(); + let program = parse_pubkey(SUBSCRIPTIONS_PROGRAM_ID, "prog").unwrap(); + let subscriber = Pubkey::new_unique(); + let account = encode_delegation_account( + subscriber.to_bytes(), + plan_pda.to_bytes(), + 720, + 720, + 720, + 1_700_000_000, + ); + let (url, counters) = spawn_counting_activation_rpc(account, program); + cfg.rpc_url = Some(url); + let server = Arc::new(SubscriptionServer::new(cfg).expect("server")); + + let mut tx = build_tx( + &[subscriber], + vec![ + (INSTRUCTION_SUBSCRIBE, vec![]), + (INSTRUCTION_TRANSFER_SUBSCRIPTION, vec![]), + ], + ); + tx.signatures[0] = Signature::from([9u8; 64]); + let bytes = bincode::serialize(&tx).unwrap(); + let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes); + let challenge = server.subscription_challenge("720").expect("challenge"); + let credential = PaymentCredential::new( + challenge.to_echo(), + ActivatePayload { + payload_type: "transaction".into(), + transaction: Some(b64), + signature: None, + }, + ); + + let s1 = server.clone(); + let s2 = server.clone(); + let c1 = credential.clone(); + let c2 = credential.clone(); + let h1 = tokio::spawn(async move { s1.verify_credential(&c1).await }); + let h2 = tokio::spawn(async move { s2.verify_credential(&c2).await }); + let r1 = h1.await.expect("task 1 joins"); + let r2 = h2.await.expect("task 2 joins"); + + let ok_count = [&r1, &r2].iter().filter(|r| r.is_ok()).count(); + let consumed_count = [&r1, &r2] + .iter() + .filter(|r| matches!(r, Err(e) if e.code == Some("signature-consumed"))) + .count(); + assert_eq!( + ok_count, 1, + "exactly one activation must succeed, got r1={r1:?} r2={r2:?}", + ); + assert_eq!( + consumed_count, 1, + "the loser must be rejected as signature-consumed, got r1={r1:?} r2={r2:?}", + ); + + // Exactly one broadcast reached the chain — the second activation was + // rejected before it could re-settle. + assert_eq!( + counters.lock().unwrap().send, + 1, + "only the winning activation may broadcast", + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn broadcast_failure_releases_claim_for_retry() { + // Release-on-failure. The claim is taken atomically up front, but a + // broadcast failure afterwards must delete the marker so a legitimate + // client can retry — otherwise a transient RPC error would permanently + // brick the activation signature. The dead RPC makes broadcast fail + // after the claim; the retry must NOT be rejected as consumed. + // + // Against the OLD get-then-put, the marker was only written AFTER a + // successful broadcast, so a failed broadcast left no marker and this + // assertion was vacuous. With the atomic upfront claim it is load- + // bearing: the marker exists after the claim and MUST be released on + // the broadcast error path. + let store: Arc = Arc::new(MemoryStore::new()); + let mut cfg = make_config(); + cfg.store = Some(store.clone()); + // Port 1 refuses connections immediately, so broadcast_and_confirm + // errors after the claim. + cfg.rpc_url = Some("http://127.0.0.1:1".into()); + let server = SubscriptionServer::new(cfg).expect("server"); + + let subscriber = Pubkey::new_unique(); + let mut tx = build_tx( + &[subscriber], + vec![ + (INSTRUCTION_SUBSCRIBE, vec![]), + (INSTRUCTION_TRANSFER_SUBSCRIPTION, vec![]), + ], + ); + tx.signatures[0] = Signature::from([5u8; 64]); + let sig = tx.signatures[0].to_string(); + let consumed_key = format!("solana-subscription:consumed:{sig}"); + let bytes = bincode::serialize(&tx).unwrap(); + let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes); + let credential = credential_for_server( + &server, + ActivatePayload { + payload_type: "transaction".into(), + transaction: Some(b64), + signature: None, + }, + ); + + // First attempt: broadcast fails against the dead RPC. + let err = server + .verify_credential(&credential) + .await + .expect_err("dead RPC must fail the broadcast"); + assert_ne!( + err.code, + Some("signature-consumed"), + "the first attempt is a broadcast failure, not a replay: {err:?}", + ); + + // The claim must have been released so the signature is not bricked. + assert_eq!( + store.get(&consumed_key).await.unwrap(), + None, + "a broadcast failure must release the activation claim for retry", + ); + + // Retry: still fails on the dead RPC, but MUST NOT be rejected as a + // replay — proving the marker was released rather than kept. + let err = server + .verify_credential(&credential) + .await + .expect_err("retry still hits the dead RPC"); + assert_ne!( + err.code, + Some("signature-consumed"), + "a retry after a released claim must not be rejected as consumed: {err:?}", + ); + } } diff --git a/skills/pay-sdk-implementation/codegen/generate-subscriptions-client-ts.ts b/skills/pay-sdk-implementation/codegen/generate-subscriptions-client-ts.ts new file mode 100644 index 000000000..40f880230 --- /dev/null +++ b/skills/pay-sdk-implementation/codegen/generate-subscriptions-client-ts.ts @@ -0,0 +1,57 @@ +import type { AnchorIdl } from '@codama/nodes-from-anchor'; +import { renderVisitor as renderJsVisitor } from '@codama/renderers-js'; +import { createFromJson } from 'codama'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, '..', '..', '..'); +const idlPath = path.join(repoRoot, 'idl', 'subscriptions.json'); +const tsClientDir = path.join(repoRoot, 'typescript', 'packages', 'mpp', 'src', 'generated', 'subscriptions'); + +if (!fs.existsSync(idlPath)) { + console.error(`[codegen] IDL not found at ${idlPath}`); + process.exit(1); +} + +const idl = JSON.parse(fs.readFileSync(idlPath, 'utf-8')) as AnchorIdl; +const codama = createFromJson(JSON.stringify(idl)); +const tmpPkg = fs.mkdtempSync(path.join(os.tmpdir(), 'subscriptions-ts-')); + +void (async () => { + await codama.accept(renderJsVisitor(tmpPkg, { formatCode: true })); + + const rendered = path.join(tmpPkg, 'src', 'generated'); + if (!fs.existsSync(rendered)) { + throw new Error(`Expected rendered client at ${rendered}`); + } + fs.rmSync(tsClientDir, { force: true, recursive: true }); + fs.cpSync(rendered, tsClientDir, { recursive: true }); + fs.rmSync(tmpPkg, { force: true, recursive: true }); + + const addJsExtensions = (dir: string): void => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + addJsExtensions(full); + continue; + } + if (!entry.name.endsWith('.ts')) continue; + const fileDir = path.dirname(full); + const patched = fs.readFileSync(full, 'utf8').replace( + /(from\s*['"])(\.\.?(?:\/[^'"]+)?)(['"])/g, + (match, prefix: string, specifier: string, suffix: string) => { + if (specifier.endsWith('.js')) return match; + const resolved = path.resolve(fileDir, specifier); + const extension = + fs.existsSync(resolved) && fs.statSync(resolved).isDirectory() ? '/index.js' : '.js'; + return `${prefix}${specifier}${extension}${suffix}`; + }, + ); + fs.writeFileSync(full, patched); + } + }; + addJsExtensions(tsClientDir); +})(); diff --git a/skills/pay-sdk-implementation/codegen/generate-subscriptions-client.ts b/skills/pay-sdk-implementation/codegen/generate-subscriptions-client.ts index 6ea6d5a40..7d2e2ba39 100644 --- a/skills/pay-sdk-implementation/codegen/generate-subscriptions-client.ts +++ b/skills/pay-sdk-implementation/codegen/generate-subscriptions-client.ts @@ -42,7 +42,7 @@ const codama = createFromJson(JSON.stringify(idl)); console.log(`[codegen] Rendering Rust client from ${path.relative(repoRoot, idlPath)}`); console.log(`[codegen] → ${path.relative(repoRoot, rustClientDir)}/generated/`); -void codama.accept( +await codama.accept( renderRustVisitor(rustClientDir, { // Pay-kit does not depend on Anchor at runtime. Generating bare Borsh // structs keeps the client free of `anchor-lang` transitively. @@ -51,7 +51,9 @@ void codama.accept( // means a removed instruction in the upstream IDL also disappears // here on regeneration. deleteFolderBeforeRendering: true, - formatCode: true, + // The generated tree is nested below the crate manifest, so the Just + // recipe formats it from the Rust workspace after path rewriting. + formatCode: false, generatedFolder: 'generated', }), ); diff --git a/skills/pay-sdk-implementation/codegen/package.json b/skills/pay-sdk-implementation/codegen/package.json index 229f317a0..2861fb9b4 100644 --- a/skills/pay-sdk-implementation/codegen/package.json +++ b/skills/pay-sdk-implementation/codegen/package.json @@ -6,6 +6,7 @@ "description": "Codama codegen tooling for pay-kit. Pulls IDL files from upstream Solana programs and generates per-language clients into the appropriate SDK trees.", "scripts": { "subscriptions:rust": "tsx ./generate-subscriptions-client.ts", + "subscriptions:ts": "tsx ./generate-subscriptions-client-ts.ts", "payment-channels:rust": "tsx ./generate-payment-channels-client-rs.ts", "payment-channels:go": "tsx ./generate-payment-channels-client-go.ts", "payment-channels:ts": "tsx ./generate-payment-channels-client-ts.ts", diff --git a/typescript/docs/snippets/subscription.server.ts b/typescript/docs/snippets/subscription.server.ts index 0b7fb0a39..43f84a6a7 100644 --- a/typescript/docs/snippets/subscription.server.ts +++ b/typescript/docs/snippets/subscription.server.ts @@ -1,36 +1,50 @@ // Server-side subscription: gate a route with a `subscription` pricing gate. // See ./README.md for the snippet:start/end convention. -import express from 'express' -import type { KeyPairSigner } from '@solana/kit' -import { createPayKit, subscription, usd } from '@solana/pay-kit' +import express from 'express'; +import type { KeyPairSigner } from '@solana/kit'; +import { createPayKit, subscription, usd, type AtomicSubscriptionReplayStore } from '@solana/pay-kit'; -declare const signer: KeyPairSigner -declare const RECIPIENT: string -declare const PLAN_ID: string -declare const rpcUrl: string +declare const signer: KeyPairSigner; +declare const RECIPIENT: string; +declare const PLAN_ID: string; +declare const PLAN_BUMP: number; +declare const PLAN_CREATED_AT: bigint; +declare const PLAN_ID_NUMERIC: bigint; +declare const PLAN_MERCHANT: string; +declare const replayStore: AtomicSubscriptionReplayStore; +declare const rpcUrl: string; // snippet:start const pay = await createPayKit({ - network: 'mainnet', - operator: { recipient: RECIPIENT, signer }, - pricing: { - feed: subscription(usd('0.10'), { - planId: PLAN_ID, // on-chain Plan PDA, created ahead of time - puller: signer.address, // entity allowed to pull renewals - periodUnit: 'day', - periodCount: 1, - }), - }, - rpcUrl, -}) + network: 'mainnet', + operator: { recipient: RECIPIENT, signer }, + replayStore, // shared/durable store with an atomic reserve() operation + pricing: { + feed: subscription(usd('0.10'), { + merchant: PLAN_MERCHANT, + planBump: PLAN_BUMP, + planCreatedAt: PLAN_CREATED_AT, + planId: PLAN_ID, // on-chain Plan PDA, created ahead of time + planIdNumeric: PLAN_ID_NUMERIC, + puller: signer.address, // entity allowed to pull renewals + periodUnit: 'day', + periodCount: 1, + }), + }, + rpcUrl, +}); -const app = express() +const app = express(); // First call activates the plan on-chain; `pay.express` gates the rest. app.get('${PATH}', pay.express('feed'), (_req, res) => { - res.json({ items: [/* … */] }) -}) + res.json({ + items: [ + /* … */ + ], + }); +}); // snippet:end -void app +void app; diff --git a/typescript/examples/playground-api/index.ts b/typescript/examples/playground-api/index.ts index 95e9fe291..a49b4eccc 100644 --- a/typescript/examples/playground-api/index.ts +++ b/typescript/examples/playground-api/index.ts @@ -13,119 +13,133 @@ * funding (faucet, account top-ups, plan bootstrap) is sandbox-only and lives in * `sandbox.ts`. Run against the hosted sandbox with zero config: `pnpm dev`. */ -import crypto from 'node:crypto' -import cors from 'cors' -import express, { type Request, type Response } from 'express' +import crypto from 'node:crypto'; +import cors from 'cors'; +import express, { type Request, type Response } from 'express'; import { - createKeyPairSignerFromBytes, - createSolanaRpc, - generateKeyPairSigner, - getBase58Encoder, - type KeyPairSigner, -} from '@solana/kit' -import { createPayKit, session, subscription, usage, usd } from '@solana/pay-kit' - -import { registerDocs } from './docs.js' -import { bootstrapPlan, fundSandbox, fundUsdc, registerFaucet } from './sandbox.js' - -const NETWORK = (process.env.NETWORK ?? 'localnet') as 'devnet' | 'localnet' | 'mainnet' -const RPC_URL = process.env.RPC_URL ?? 'https://402.surfnet.dev:8899' -const PORT = parseInt(process.env.PORT || '3000', 10) -const SECRET_KEY = process.env.MPP_SECRET_KEY ?? crypto.randomBytes(32).toString('hex') + createKeyPairSignerFromBytes, + createSolanaRpc, + generateKeyPairSigner, + getBase58Encoder, + type KeyPairSigner, +} from '@solana/kit'; +import { createPayKit, session, subscription, usage, usd } from '@solana/pay-kit'; + +import { registerDocs } from './docs.js'; +import { fundSandbox, fundUsdc, registerFaucet, subscriptionPlanFromEnv } from './sandbox.js'; + +const NETWORK = (process.env.NETWORK ?? 'localnet') as 'devnet' | 'localnet' | 'mainnet'; +const RPC_URL = process.env.RPC_URL ?? 'https://402.surfnet.dev:8899'; +const PORT = parseInt(process.env.PORT || '3000', 10); +const SECRET_KEY = process.env.MPP_SECRET_KEY ?? crypto.randomBytes(32).toString('hex'); // Operator: fee-payer + settlement signer. Generated when no key is supplied. const operator: KeyPairSigner = process.env.FEE_PAYER_KEY - ? await createKeyPairSignerFromBytes(getBase58Encoder().encode(process.env.FEE_PAYER_KEY)) - : await generateKeyPairSigner() -const RECIPIENT = process.env.RECIPIENT ?? operator.address + ? await createKeyPairSignerFromBytes(getBase58Encoder().encode(process.env.FEE_PAYER_KEY)) + : await generateKeyPairSigner(); +const RECIPIENT = process.env.RECIPIENT ?? operator.address; // A second recipient for the marketplace-split demo (the platform's cut). -const PLATFORM = (await generateKeyPairSigner()).address +const PLATFORM = (await generateKeyPairSigner()).address; -// Subscription plan PDA: bootstrapped on the sandbox (or supplied via env). The -// subscription route is only mounted when one is available. -const PLAN_ID = NETWORK === 'localnet' ? await bootstrapPlan(RPC_URL) : process.env.PLAN_ID ?? null +// Subscription activation is exposed only when a complete snapshot from a real +// on-chain Plan is supplied. Partial or synthetic plan data is never advertised. +const PLAN = subscriptionPlanFromEnv(); // ── PayKit: one config object declares the server + the priced routes ── const pay = await createPayKit({ - accept: ['x402', 'mpp'], - // `html: true` serves the interactive pay.sh payment page (+ service worker) - // on 402s for browser requests; API clients still get the JSON 402. - mpp: { - challengeBindingSecret: SECRET_KEY, - html: true, - ...(NETWORK === 'localnet' ? { allowUnsafeMemoryStore: true } : {}), - }, - network: NETWORK, - operator: { recipient: RECIPIENT, signer: operator }, - pricing: { - feed: subscription(usd('0.10'), { - description: 'Feed subscription', - periodCount: 1, - periodUnit: 'day', - planId: PLAN_ID ?? '', - puller: operator.address, - }), - // MPP charge that splits the payment: the platform takes 0.003 of the 0.01, - // the seller (operator) nets 0.007. Each transfer carries an on-chain memo — - // `externalId` labels the seller's payout, the split's `memo` labels the - // platform fee — so the settlement shows up itemized on-chain / in receipts. - // Multi-recipient splits aren't expressible in x402 `exact`, so this gate is - // MPP-only (auto-narrowed off x402). - joke: { - amount: usd('0.01'), - description: 'A programmer joke', - externalId: 'paykit/joke: seller payout', - feeWithin: { [PLATFORM]: { memo: 'paykit/joke: platform fee', price: usd('0.003') } }, + accept: ['x402', 'mpp'], + // `html: true` serves the interactive pay.sh payment page (+ service worker) + // on 402s for browser requests; API clients still get the JSON 402. + // On localnet, opt into the process-local replay store; off localnet the + // operator must inject a durable shared store (declareProductionReplayStore). + mpp: { + challengeBindingSecret: SECRET_KEY, + html: true, + ...(NETWORK === 'localnet' ? { allowUnsafeMemoryStore: true } : {}), + }, + network: NETWORK, + operator: { recipient: RECIPIENT, signer: operator }, + pricing: { + ...(PLAN + ? { + feed: subscription(usd('0.10'), { + description: 'Feed subscription', + merchant: PLAN.merchant, + periodCount: 1, + periodUnit: 'day', + planBump: PLAN.planBump, + planCreatedAt: PLAN.planCreatedAt, + planId: PLAN.planId, + planIdNumeric: PLAN.planIdNumeric, + puller: operator.address, + }), + } + : {}), + // MPP charge that splits the payment: the platform takes 0.003 of the 0.01, + // the seller (operator) nets 0.007. Each transfer carries an on-chain memo — + // `externalId` labels the seller's payout, the split's `memo` labels the + // platform fee — so the settlement shows up itemized on-chain / in receipts. + // Multi-recipient splits aren't expressible in x402 `exact`, so this gate is + // MPP-only (auto-narrowed off x402). + joke: { + amount: usd('0.01'), + description: 'A programmer joke', + externalId: 'paykit/joke: seller payout', + feeWithin: { [PLATFORM]: { memo: 'paykit/joke: platform fee', price: usd('0.003') } }, + }, + // Fixed charge, settled over MPP or x402 (client's choice). This is the + // canonical paid endpoint the payment-link E2E + cross-language harnesses + // drive (they expect GET /api/v1/fortune → 402 → `{ "fortune": ... }`). + fortune: { amount: usd('0.01'), description: 'A fortune cookie' }, + quote: { amount: usd('0.01'), description: 'Stock quote' }, + stream: session(usd('1.00'), { + closeDelayMs: 2000, + description: 'Metered token stream', + unitPrice: usd('0.0001'), + }), + summarize: usage(usd('0.1'), { description: 'Summarize text, billed per token' }), }, - // Fixed charge, settled over MPP or x402 (client's choice). This is the - // canonical paid endpoint the payment-link E2E + cross-language harnesses - // drive (they expect GET /api/v1/fortune → 402 → `{ "fortune": ... }`). - fortune: { amount: usd('0.01'), description: 'A fortune cookie' }, - quote: { amount: usd('0.01'), description: 'Stock quote' }, - stream: session(usd('1.00'), { closeDelayMs: 2000, description: 'Metered token stream', unitPrice: usd('0.0001') }), - summarize: usage(usd('0.1'), { description: 'Summarize text, billed per token' }), - }, - rpcUrl: RPC_URL, -}) + rpcUrl: RPC_URL, +}); const JOKES = [ - 'Why do programmers prefer dark mode? Because light attracts bugs.', - 'A SQL query walks into a bar, sees two tables, and asks: "Can I JOIN you?"', - 'There are 10 kinds of people: those who understand binary and those who don’t.', -] + 'Why do programmers prefer dark mode? Because light attracts bugs.', + 'A SQL query walks into a bar, sees two tables, and asks: "Can I JOIN you?"', + 'There are 10 kinds of people: those who understand binary and those who don’t.', +]; const FORTUNES = [ - 'A smooth long journey! Great expectations.', - 'Your code will compile on the first try today.', - 'A thrilling time is in your immediate future.', - 'The settlement you await will confirm on-chain.', -] + 'A smooth long journey! Great expectations.', + 'Your code will compile on the first try today.', + 'A thrilling time is in your immediate future.', + 'The settlement you await will confirm on-chain.', +]; const HEADLINES = [ - { tag: 'engineering', title: 'Solana session validators hit a new throughput record' }, - { tag: 'macro', title: 'Stablecoin transfer volume crosses $4T on-chain quarterly' }, - { tag: 'security', title: 'New payment-channel program audit lands' }, - { tag: 'protocol', title: 'MPP v2 extensions ship to mainnet beta' }, -] + { tag: 'engineering', title: 'Solana session validators hit a new throughput record' }, + { tag: 'macro', title: 'Stablecoin transfer volume crosses $4T on-chain quarterly' }, + { tag: 'security', title: 'New payment-channel program audit lands' }, + { tag: 'protocol', title: 'MPP v2 extensions ship to mainnet beta' }, +]; const TOKEN_CHUNKS = [ - 'A payment channel ', - 'lets a client and server ', - 'authorize many small ', - 'off-chain debits ', - 'against a single on-chain ', - 'deposit, settling the highest ', - 'cumulative voucher at close.', -] + 'A payment channel ', + 'lets a client and server ', + 'authorize many small ', + 'off-chain debits ', + 'against a single on-chain ', + 'deposit, settling the highest ', + 'cumulative voucher at close.', +]; /** Per-token price for the metered route, in USDC base units (6 decimals): $0.0001. */ -const PRICE_PER_TOKEN = 100n +const PRICE_PER_TOKEN = 100n; -const app = express() -app.use(express.json()) -app.use(cors({ exposedHeaders: ['www-authenticate', 'payment-required', 'x-payment-response', 'payment-receipt'] })) +const app = express(); +app.use(express.json()); +app.use(cors({ exposedHeaders: ['www-authenticate', 'payment-required', 'x-payment-response', 'payment-receipt'] })); // Local sandbox funding + faucet (no-op on real networks). if (NETWORK === 'localnet') { - await fundSandbox(RPC_URL, operator.address, RECIPIENT) - await fundUsdc(RPC_URL, PLATFORM) // the split recipient needs a USDC account to receive its cut - registerFaucet(app, RPC_URL) + await fundSandbox(RPC_URL, operator.address, RECIPIENT); + await fundUsdc(RPC_URL, PLATFORM); // the split recipient needs a USDC account to receive its cut + registerFaucet(app, RPC_URL); } // ── Priced routes ── @@ -134,68 +148,77 @@ if (NETWORK === 'localnet') { // Fixed charge, settled over whichever protocol the client picks. app.get('/api/v1/quote/:symbol', pay.express('quote'), (req: Request, res: Response) => { - const symbol = String(req.params.symbol).toUpperCase() - res.json({ price: 100 + (symbol.charCodeAt(0) % 50), symbol, via: pay.payment(req)?.protocol }) -}) + const symbol = String(req.params.symbol).toUpperCase(); + res.json({ price: 100 + (symbol.charCodeAt(0) % 50), symbol, via: pay.payment(req)?.protocol }); +}); // Fixed charge, settled over whichever protocol the client picks. Canonical // paid endpoint for the payment-link E2E + cross-language harnesses. app.get('/api/v1/fortune', pay.express('fortune'), (_req: Request, res: Response) => { - res.json({ fortune: FORTUNES[Math.floor(Math.random() * FORTUNES.length)] }) -}) + res.json({ fortune: FORTUNES[Math.floor(Math.random() * FORTUNES.length)] }); +}); // MPP charge with a split: the platform takes 0.003, the seller nets 0.007. app.get('/api/v1/joke', pay.express('joke'), (_req: Request, res: Response) => { - res.json({ joke: JOKES[Math.floor(Math.random() * JOKES.length)] }) -}) + res.json({ joke: JOKES[Math.floor(Math.random() * JOKES.length)] }); +}); // Usage-metered (x402 `upto`): authorize up to $0.10, bill the tokens produced. -app.post('/api/v1/summarize', express.text({ type: '*/*' }), pay.express('summarize'), (req: Request, res: Response) => { - const body = typeof req.body === 'string' ? req.body : '' - const tokens = BigInt(Math.max(1, Math.floor(body.length / 4))) - pay.charge(req)?.charge(tokens * PRICE_PER_TOKEN) - res.json({ billedBaseUnits: (tokens * PRICE_PER_TOKEN).toString(), summarizedBytes: body.length, tokens: tokens.toString() }) -}) +app.post( + '/api/v1/summarize', + express.text({ type: '*/*' }), + pay.express('summarize'), + (req: Request, res: Response) => { + const body = typeof req.body === 'string' ? req.body : ''; + const tokens = BigInt(Math.max(1, Math.floor(body.length / 4))); + pay.charge(req)?.charge(tokens * PRICE_PER_TOKEN); + res.json({ + billedBaseUnits: (tokens * PRICE_PER_TOKEN).toString(), + summarizedBytes: body.length, + tokens: tokens.toString(), + }); + }, +); // Subscription: the first call activates a recurring on-chain authorization // against PLAN_ID, then serves the gated feed. Mounted only when a plan exists. -if (PLAN_ID) { - app.get('/api/v1/feed', pay.express('feed'), (_req: Request, res: Response) => { - const headlines = [...HEADLINES].sort(() => Math.random() - 0.5).slice(0, 3) - res.json({ generatedAt: new Date().toISOString(), headlines }) - }) +if (PLAN) { + app.get('/api/v1/feed', pay.express('feed'), (_req: Request, res: Response) => { + const headlines = [...HEADLINES].sort(() => Math.random() - 0.5).slice(0, 3); + res.json({ generatedAt: new Date().toISOString(), headlines }); + }); } // Session: open a payment channel, then stream metered deliveries (SSE). Each // chunk costs 0.0001 USDC; settlement runs out-of-band when the channel // idle-closes (poll `/sessions/receipt/:channelId` for the settle signature). app.get('/api/v1/stream', pay.express('stream'), (_req: Request, res: Response) => { - res.writeHead(200, { 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'Content-Type': 'text/event-stream' }) - let i = 0 - const tick = (): void => { - if (i < TOKEN_CHUNKS.length) { - res.write(`data: ${JSON.stringify({ chunk: TOKEN_CHUNKS[i], cost: PRICE_PER_TOKEN.toString() })}\n\n`) - i += 1 - setTimeout(tick, 80) - } else { - res.write('data: [DONE]\n\n') - res.end() - } - } - tick() -}) + res.writeHead(200, { 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'Content-Type': 'text/event-stream' }); + let i = 0; + const tick = (): void => { + if (i < TOKEN_CHUNKS.length) { + res.write(`data: ${JSON.stringify({ chunk: TOKEN_CHUNKS[i], cost: PRICE_PER_TOKEN.toString() })}\n\n`); + i += 1; + setTimeout(tick, 80); + } else { + res.write('data: [DONE]\n\n'); + res.end(); + } + }; + tick(); +}); // Session side-channel + receipt routes — mounted explicitly (pay-kit, like // mppx, leaves route-mounting to the app rather than auto-injecting them). // These are protocol machinery, not gated endpoints, so they stay out of discovery. -const streamRoutes = pay.sessionRoutes('stream') -app.post('/api/v1/stream', streamRoutes.voucher) // voucher commits to the resource URL -app.post('/__402/session/deliveries', streamRoutes.deliveries) -app.post('/__402/session/commit', streamRoutes.commit) -app.get('/sessions/receipt/:channelId', streamRoutes.receipt) +const streamRoutes = pay.sessionRoutes('stream'); +app.post('/api/v1/stream', streamRoutes.voucher); // voucher commits to the resource URL +app.post('/__402/session/deliveries', streamRoutes.deliveries); +app.post('/__402/session/commit', streamRoutes.commit); +app.get('/sessions/receipt/:channelId', streamRoutes.receipt); // SDK reference docs (unpaid) for the playground's Docs / ApiReference pages. -registerDocs(app) +registerDocs(app); // ── Discovery + health ── @@ -204,41 +227,47 @@ registerDocs(app) // `x-payment-info.offers` carry the cost, network, recipient (`payTo`), and // fee payer straight from `pricing`. No route table or bespoke config endpoint // to keep in sync. The RPC endpoint is deliberately not advertised. -const openapi = await pay.openapiFromExpress(app, { info: { title: 'PayKit Playground', version: '1.0.0' } }) -app.get('/openapi.json', (_req: Request, res: Response) => res.json(openapi)) +const openapi = await pay.openapiFromExpress(app, { info: { title: 'PayKit Playground', version: '1.0.0' } }); +app.get('/openapi.json', (_req: Request, res: Response) => res.json(openapi)); app.get('/api/v1/health', async (_req: Request, res: Response) => { - let balance: number | undefined - try { - const { value } = await createSolanaRpc(RPC_URL).getBalance(operator.address).send() - balance = Number(value) / 1e9 - } catch { - /* sandbox may be unreachable */ - } - res.json({ feePayer: operator.address, feePayerBalance: balance, network: NETWORK, ok: true, recipient: RECIPIENT }) -}) - -const paths = (openapi as { paths?: Record> }).paths ?? {} + let balance: number | undefined; + try { + const { value } = await createSolanaRpc(RPC_URL).getBalance(operator.address).send(); + balance = Number(value) / 1e9; + } catch { + /* sandbox may be unreachable */ + } + res.json({ + feePayer: operator.address, + feePayerBalance: balance, + network: NETWORK, + ok: true, + recipient: RECIPIENT, + }); +}); + +const paths = (openapi as { paths?: Record> }).paths ?? {}; const server = app.listen(PORT, () => { - console.log(`\n PayKit Playground http://localhost:${PORT}`) - console.log(` Network ${NETWORK} RPC ${RPC_URL}`) - console.log(` Recipient ${RECIPIENT}\n`) - console.log(' GET /openapi.json (discovery)') - for (const [path, item] of Object.entries(paths)) { - for (const method of Object.keys(item)) console.log(` ${method.toUpperCase().padEnd(4)} ${path}`) - } - console.log() -}) + console.log(`\n PayKit Playground http://localhost:${PORT}`); + console.log(` Network ${NETWORK} RPC ${RPC_URL}`); + console.log(` Recipient ${RECIPIENT}\n`); + console.log(' GET /openapi.json (discovery)'); + for (const [path, item] of Object.entries(paths)) { + for (const method of Object.keys(item)) console.log(` ${method.toUpperCase().padEnd(4)} ${path}`); + } + console.log(); +}); // A taken port almost always means another instance is already serving — treat // it as "API is running elsewhere" and step aside cleanly instead of crashing // with an unhandled 'error' stack (e.g. when `pnpm dev` runs alongside a // separately-launched API). server.on('error', (err: NodeJS.ErrnoException) => { - if (err.code === 'EADDRINUSE') { - console.log(`\n Port ${PORT} is already in use — assuming the PayKit API is running elsewhere. Skipping.\n`) - process.exit(0) - } - throw err -}) + if (err.code === 'EADDRINUSE') { + console.log(`\n Port ${PORT} is already in use — assuming the PayKit API is running elsewhere. Skipping.\n`); + process.exit(0); + } + throw err; +}); diff --git a/typescript/examples/playground-api/sandbox.ts b/typescript/examples/playground-api/sandbox.ts index 71808499e..37658dba2 100644 --- a/typescript/examples/playground-api/sandbox.ts +++ b/typescript/examples/playground-api/sandbox.ts @@ -7,110 +7,116 @@ * (caught and warned). None of this is part of how a real PayKit server runs; * it just makes the playground work zero-config. */ -import type { Express, Request, Response } from 'express' -import { generateKeyPairSigner } from '@solana/kit' -import { TOKEN_PROGRAM_ADDRESS } from '@solana-program/token' -import { resolveStablecoinMint, SUBSCRIPTIONS_PROGRAM } from '@solana/mpp' +import type { Express, Request, Response } from 'express'; +import { TOKEN_PROGRAM_ADDRESS } from '@solana-program/token'; +import { resolveStablecoinMint } from '@solana/mpp'; // The sandbox clones mainnet state, so it funds the *mainnet* USDC mint // regardless of the configured network tag. -const USDC_MINT = resolveStablecoinMint('USDC', 'mainnet') ?? '' -const SYSTEM_PROGRAM = '11111111111111111111111111111111' -const SOL_FUND_LAMPORTS = 100_000_000_000 // 100 SOL -const USDC_FUND_AMOUNT = 100_000_000 // 100 USDC (6 decimals) +const USDC_MINT = resolveStablecoinMint('USDC', 'mainnet') ?? ''; +const SYSTEM_PROGRAM = '11111111111111111111111111111111'; +const SOL_FUND_LAMPORTS = 100_000_000_000; // 100 SOL +const USDC_FUND_AMOUNT = 100_000_000; // 100 USDC (6 decimals) /** Fund the given addresses with SOL + USDC on the local sandbox. Best-effort. */ export async function fundSandbox(rpcUrl: string, ...addresses: string[]): Promise { - try { - for (const address of addresses) { - await rpcCall(rpcUrl, 'surfnet_setAccount', [ - address, - { lamports: SOL_FUND_LAMPORTS, data: '', executable: false, owner: SYSTEM_PROGRAM, rentEpoch: 0 }, - ]) - await rpcCall(rpcUrl, 'surfnet_setTokenAccount', [ - address, - USDC_MINT, - { amount: USDC_FUND_AMOUNT, state: 'initialized' }, - TOKEN_PROGRAM_ADDRESS, - ]) + try { + for (const address of addresses) { + await rpcCall(rpcUrl, 'surfnet_setAccount', [ + address, + { lamports: SOL_FUND_LAMPORTS, data: '', executable: false, owner: SYSTEM_PROGRAM, rentEpoch: 0 }, + ]); + await rpcCall(rpcUrl, 'surfnet_setTokenAccount', [ + address, + USDC_MINT, + { amount: USDC_FUND_AMOUNT, state: 'initialized' }, + TOKEN_PROGRAM_ADDRESS, + ]); + } + } catch { + console.warn(' Sandbox RPC not reachable — accounts may be unfunded.'); } - } catch { - console.warn(' Sandbox RPC not reachable — accounts may be unfunded.') - } } /** Fund the given addresses with USDC only (no SOL) on the local sandbox. * Client wallets never pay network fees — the operator fee-pays every gate — * so they only need a USDC balance. Best-effort. */ export async function fundUsdc(rpcUrl: string, ...addresses: string[]): Promise { - try { - for (const address of addresses) { - await rpcCall(rpcUrl, 'surfnet_setTokenAccount', [ - address, - USDC_MINT, - { amount: USDC_FUND_AMOUNT, state: 'initialized' }, - TOKEN_PROGRAM_ADDRESS, - ]) + try { + for (const address of addresses) { + await rpcCall(rpcUrl, 'surfnet_setTokenAccount', [ + address, + USDC_MINT, + { amount: USDC_FUND_AMOUNT, state: 'initialized' }, + TOKEN_PROGRAM_ADDRESS, + ]); + } + } catch { + console.warn(' Sandbox RPC not reachable — USDC not funded.'); } - } catch { - console.warn(' Sandbox RPC not reachable — USDC not funded.') - } } /** Minimal JSON-RPC 2.0 call for the surfnet cheatcode methods. */ async function rpcCall(rpcUrl: string, method: string, params: unknown[]): Promise { - const res = await fetch(rpcUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), - signal: AbortSignal.timeout(8000), - }) - const data = (await res.json()) as { error?: { message: string } } - if (data.error) throw new Error(`${method}: ${data.error.message}`) + const res = await fetch(rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + signal: AbortSignal.timeout(8000), + }); + const data = (await res.json()) as { error?: { message: string } }; + if (data.error) throw new Error(`${method}: ${data.error.message}`); } -/** - * Stuff a synthetic subscription Plan account on the local sandbox and return - * its address (the `planId`), or `null` if the sandbox is unreachable. - * - * The plan lives at a fresh random address owned by the subscriptions program - * so the server can issue a subscription challenge that pins it — exercising the - * full challenge → sign → submit handshake. (On-chain activation needs the real - * program deployed; this stub just makes the playground self-contained.) The - * address is distinct from the operator/recipient so reassigning its owner can't - * break fee-payer eligibility for the other gates. - */ -export async function bootstrapPlan(rpcUrl: string): Promise { - try { - const planId = (await generateKeyPairSigner()).address - await rpcCall(rpcUrl, 'surfnet_setAccount', [ - planId, - { lamports: 1_000_000_000, data: '', executable: false, owner: SUBSCRIPTIONS_PROGRAM, rentEpoch: 0 }, - ]) - return planId - } catch (err) { - console.warn(' Sandbox plan bootstrap failed — subscription route disabled:', err instanceof Error ? err.message : String(err)) - return null - } +export type SubscriptionPlanSnapshot = { + merchant: string; + planBump: number; + planCreatedAt: bigint; + planId: string; + planIdNumeric: bigint; +}; + +/** Read an immutable snapshot from a separately provisioned on-chain Plan. */ +export function subscriptionPlanFromEnv(): SubscriptionPlanSnapshot | null { + const { PLAN_BUMP, PLAN_CREATED_AT, PLAN_ID, PLAN_ID_NUMERIC, PLAN_MERCHANT } = process.env; + const values = [PLAN_BUMP, PLAN_CREATED_AT, PLAN_ID, PLAN_ID_NUMERIC, PLAN_MERCHANT]; + if (values.every(value => value === undefined)) return null; + if (values.some(value => value === undefined || value === '')) { + throw new Error( + 'Subscription requires PLAN_ID, PLAN_BUMP, PLAN_CREATED_AT, PLAN_ID_NUMERIC, and PLAN_MERCHANT', + ); + } + const planBump = Number(PLAN_BUMP); + if (!Number.isInteger(planBump) || planBump < 0 || planBump > 255) throw new Error('PLAN_BUMP must be a u8'); + const planCreatedAt = BigInt(PLAN_CREATED_AT!); + const planIdNumeric = BigInt(PLAN_ID_NUMERIC!); + if (planCreatedAt < 0n) throw new Error('PLAN_CREATED_AT must be non-negative'); + if (planIdNumeric < 0n || planIdNumeric > 18_446_744_073_709_551_615n) { + throw new Error('PLAN_ID_NUMERIC must be a u64'); + } + return { merchant: PLAN_MERCHANT!, planBump, planCreatedAt, planId: PLAN_ID!, planIdNumeric }; } /** Mount a faucet that airdrops sandbox USDC to an address (no SOL needed). */ export function registerFaucet(app: Express, rpcUrl: string): void { - app.get('/api/v1/faucet/status', (_req: Request, res: Response) => { - res.json({ usdcAmount: '100 USDC', usdcMint: USDC_MINT }) - }) + app.get('/api/v1/faucet/status', (_req: Request, res: Response) => { + res.json({ usdcAmount: '100 USDC', usdcMint: USDC_MINT }); + }); - app.post('/api/v1/faucet/airdrop', async (req: Request, res: Response) => { - const { address } = req.body as { address?: string } - if (!address) { - res.status(400).json({ error: 'Missing `address` in request body' }) - return - } - try { - await fundUsdc(rpcUrl, address) - res.json({ ok: true, usdc: '100 USDC' }) - } catch (err) { - res.status(500).json({ error: 'Airdrop failed', details: err instanceof Error ? err.message : String(err) }) - } - }) + app.post('/api/v1/faucet/airdrop', async (req: Request, res: Response) => { + const { address } = req.body as { address?: string }; + if (!address) { + res.status(400).json({ error: 'Missing `address` in request body' }); + return; + } + try { + await fundUsdc(rpcUrl, address); + res.json({ ok: true, usdc: '100 USDC' }); + } catch (err) { + res.status(500).json({ + error: 'Airdrop failed', + details: err instanceof Error ? err.message : String(err), + }); + } + }); } diff --git a/typescript/packages/mpp/src/Methods.ts b/typescript/packages/mpp/src/Methods.ts index 93d0d79a2..dd8caabaf 100644 --- a/typescript/packages/mpp/src/Methods.ts +++ b/typescript/packages/mpp/src/Methods.ts @@ -151,16 +151,26 @@ export const subscription = Method.from({ methodDetails: z.object({ /** Token decimals. */ decimals: z.number(), + /** Plan creation timestamp snapshot, encoded as an i64 decimal string. */ + expectedCreatedAt: z.string(), + /** Plan billing cadence snapshot in hours, encoded as a u64 decimal string. */ + expectedPeriodHours: z.string(), /** If true, server pays activation transaction fees. */ feePayer: z.optional(z.boolean()), /** Server's base58 fee-payer pubkey. Required when feePayer is true. */ feePayerKey: z.optional(z.string()), + /** Merchant/plan-owner account required by the canonical subscribe instruction. */ + merchant: z.string(), /** Base58 of the SPL token mint. Must equal the on-chain plan.mint. */ mint: z.string(), /** Solana network: mainnet, devnet, or localnet. */ network: z.optional(z.string()), + /** Plan PDA bump snapshot. */ + planBump: z.number(), /** Base58 of the on-chain Plan PDA. */ planId: z.string(), + /** Numeric u64 plan id used by the canonical SubscribeData layout. */ + planIdNumeric: z.string(), /** Base58 of the subscriptions program ID. */ programId: z.optional(z.string()), /** Base58 of the server's puller pubkey (must be in plan.pullers or plan.owner). */ @@ -187,6 +197,8 @@ export const subscription = Method.from({ periodUnit: subscriptionPeriodUnit, /** Primary recipient's wallet pubkey (base58). */ recipient: z.string(), + /** Canonical resource path this subscription authorizes. */ + resource: z.optional(z.string()), /** RFC3339 expiry of the recurring authorization. */ subscriptionExpires: z.optional(z.string()), }), diff --git a/typescript/packages/mpp/src/__tests__/session-store.test.ts b/typescript/packages/mpp/src/__tests__/session-store.test.ts index eb34789d9..6c6f200f4 100644 --- a/typescript/packages/mpp/src/__tests__/session-store.test.ts +++ b/typescript/packages/mpp/src/__tests__/session-store.test.ts @@ -2,7 +2,7 @@ import { generateKeyPairSigner, getBase58Decoder, type KeyPairSigner } from '@so import type { SignedVoucher, VoucherData } from '../shared/session-types.js'; import { encodeVoucherMessage } from '../shared/voucher.js'; -import { type ChannelState, createMemorySessionStore } from '../server/session/store.js'; +import { type ChannelState, createMemorySessionStore, isMemorySessionStore } from '../server/session/store.js'; import { verifyVoucherForChannel } from '../server/session/voucher.js'; // Helpers ─────────────────────────────────────────────────────────────────── @@ -508,3 +508,40 @@ describe('verifyVoucherForChannel', () => { } }); }); + +describe('isMemorySessionStore brand detection', () => { + test('detects a store from the factory', () => { + expect(isMemorySessionStore(createMemorySessionStore())).toBe(true); + }); + + test('is invisible to JSON / Object.keys (symbol brand)', () => { + const store = createMemorySessionStore(); + expect(Object.keys(store)).not.toContain('memory-session-store'); + // The symbol brand never serializes; only the explicit durability + // declaration (a plain string field) is visible. + expect(JSON.stringify(store)).toBe('{"sessionStoreDurability":"ephemeral"}'); + }); + + test('a shallow spread copy stays detected (fail CLOSED)', () => { + // A `{ ...store }` copy of a process-local store is still process-local. + // The brand is enumerable so the spread carries it and mainnet policy + // still rejects the copy instead of trusting it as durable. + const copy = { ...createMemorySessionStore() }; + expect(isMemorySessionStore(copy)).toBe(true); + }); + + test('an unrelated object is not mistaken for a memory store', () => { + const durable = { + deleteChannel: async () => {}, + getChannel: async () => undefined, + listChannels: async () => [], + markSealed: async () => { + throw new Error('unused'); + }, + updateChannel: async () => { + throw new Error('unused'); + }, + }; + expect(isMemorySessionStore(durable)).toBe(false); + }); +}); diff --git a/typescript/packages/mpp/src/__tests__/subscription-client.test.ts b/typescript/packages/mpp/src/__tests__/subscription-client.test.ts index 4bbfe1333..19117005d 100644 --- a/typescript/packages/mpp/src/__tests__/subscription-client.test.ts +++ b/typescript/packages/mpp/src/__tests__/subscription-client.test.ts @@ -1,389 +1,383 @@ -/** - * Behavioral tests for the client-side Solana subscription activation - * transaction builder. - * - * Mocks `globalThis.fetch` to stand in for `createSolanaRpc()` so each test - * controls exactly which RPC calls succeed and what they return. - */ -import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { afterEach, describe, expect, test } from 'vitest'; import { - type Address, address, type Blockhash, + generateKeyPairSigner, getBase64Codec, getCompiledTransactionMessageDecoder, - generateKeyPairSigner, getTransactionDecoder, } from '@solana/kit'; import { - SUBSCRIPTIONS_INIT_AUTHORITY_DISCRIMINATOR, + ASSOCIATED_TOKEN_PROGRAM, + PYUSD, SUBSCRIPTIONS_PROGRAM, - SUBSCRIPTIONS_SUBSCRIBE_DISCRIMINATOR, - SUBSCRIPTIONS_TRANSFER_DISCRIMINATOR, + TOKEN_2022_PROGRAM, TOKEN_PROGRAM, } from '../constants.js'; -import { buildSubscriptionActivationTransaction, subscription as subscriptionClient } from '../client/Subscription.js'; +import { + buildSubscriptionActivationTransaction, + initializeSubscriptionAuthority, + subscription, +} from '../client/Subscription.js'; +import { getSubscriptionAuthorityEncoder } from '../generated/subscriptions/accounts/subscriptionAuthority.js'; +import { + getSubscribeInstructionDataDecoder, + SUBSCRIBE_DISCRIMINATOR, +} from '../generated/subscriptions/instructions/subscribe.js'; +import { + getTransferSubscriptionInstructionDataDecoder, + TRANSFER_SUBSCRIPTION_DISCRIMINATOR, +} from '../generated/subscriptions/instructions/transferSubscription.js'; +import { findEventAuthorityPda } from '../generated/subscriptions/pdas/eventAuthority.js'; +const BLOCKHASH = 'EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N' as Blockhash; const PLAN_ID = '8tWbqLkUJoYy7zXc5h2EvCRoaQEv2xnQjUuYhc3rzCgT'; const MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; -const PULLER = '5fKb5cF22cFybZB1H4hLDydFhwoQy9JzKzRWaSbMkB6h'; +const MERCHANT = '5fKb5cF22cFybZB1H4hLDydFhwoQy9JzKzRWaSbMkB6h'; const RECIPIENT = '9xAXssX9j7vuK99c7cFwqbixzL3bFrzPy9PUhCtDPAYJ'; -const FEE_PAYER = 'FeePayerJ7vuK99c7cFwqbixzL3bFrzPy9PUhCtDPAYJ'; -const BLOCKHASH = 'EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N'; - -// ── Test setup ── - -let originalFetch: typeof globalThis.fetch; - -beforeEach(() => { - originalFetch = globalThis.fetch; -}); +const INIT_ID = 9123n; +const originalFetch = globalThis.fetch; afterEach(() => { globalThis.fetch = originalFetch; }); -// ── Helpers ── - -function rpcSuccess(result: unknown) { - return new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, result }), { - headers: { 'Content-Type': 'application/json' }, - }); -} - -/** - * Default RPC mock: pretend the SubscriptionAuthority does not exist, return a - * blockhash for getLatestBlockhash, accept sendTransaction, and report the - * signature as confirmed. - */ -function defaultMockFetch(opts: { authorityExists?: boolean } = {}): typeof globalThis.fetch { - return async (_input: RequestInfo | URL, init?: RequestInit) => { - const body = JSON.parse(init?.body as string) as { method?: string }; - switch (body.method) { - case 'getAccountInfo': - return rpcSuccess( - opts.authorityExists - ? { - context: { slot: 1 }, - value: { - data: ['', 'base64'], - executable: false, - lamports: 1, - owner: SUBSCRIPTIONS_PROGRAM, - rentEpoch: 0, - space: 0, - }, - } - : { context: { slot: 1 }, value: null }, - ); - case 'getLatestBlockhash': - return rpcSuccess({ context: { slot: 1 }, value: { blockhash: BLOCKHASH, lastValidBlockHeight: 1 } }); - case 'sendTransaction': - return rpcSuccess( - '5J8KKfgKBLPDoCSk7B7TwAdSP3KtkfxYGYQH52SVgyM5XQXfeaG3xH8E3uYmGNLcoNNgWp3JjPdvzNwM4ZmJyREq', - ); - case 'getSignatureStatuses': - return rpcSuccess({ context: { slot: 1 }, value: [{ confirmationStatus: 'confirmed', err: null }] }); - default: - return rpcSuccess({}); - } - }; -} - -type CompiledMessage = { - instructions: readonly { data: Uint8Array; programAddressIndex: number }[]; - staticAccounts: readonly { toString(): string }[]; -}; - -function decodeMessage(base64Tx: string): CompiledMessage { - const txBytes = getBase64Codec().encode(base64Tx); - const decoded = getTransactionDecoder().decode(txBytes); - return getCompiledTransactionMessageDecoder().decode(decoded.messageBytes) as unknown as CompiledMessage; -} - -function instructionDiscriminatorsByProgram(message: CompiledMessage, programId: string): number[] { - return message.instructions - .filter(ix => message.staticAccounts[ix.programAddressIndex].toString() === programId) - .map(ix => ix.data[0]); -} - -function baseRequest(): Parameters[0]['request'] { +function request(feePayerKey: string, tokenProgram = TOKEN_PROGRAM) { return { amount: '10000000', currency: MINT, + externalId: 'invoice-42', methodDetails: { decimals: 6, + expectedCreatedAt: '1700000000', + expectedPeriodHours: '720', + feePayer: true, + feePayerKey, + merchant: MERCHANT, mint: MINT, network: 'devnet', + planBump: 254, planId: PLAN_ID, - puller: PULLER, - tokenProgram: TOKEN_PROGRAM, + planIdNumeric: '7', + programId: SUBSCRIPTIONS_PROGRAM, + puller: feePayerKey, + recentBlockhash: BLOCKHASH, + tokenProgram, }, periodCount: '30', - periodUnit: 'day', + periodUnit: 'day' as const, recipient: RECIPIENT, }; } -// ══════════════════════════════════════════════════════════════════════ -// buildSubscriptionActivationTransaction -// ══════════════════════════════════════════════════════════════════════ +function decodeWire(encoded: string) { + const transaction = getTransactionDecoder().decode(getBase64Codec().encode(encoded)); + return getCompiledTransactionMessageDecoder().decode(transaction.messageBytes) as unknown as { + header: { numSignerAccounts: number }; + instructions: Array<{ accountIndices: number[]; data: Uint8Array; programAddressIndex: number }>; + staticAccounts: string[]; + }; +} -describe('buildSubscriptionActivationTransaction', () => { - test('includes initialize_subscription_authority when the authority does not exist', async () => { - globalThis.fetch = defaultMockFetch(); - const signer = await generateKeyPairSigner(); - const tx = await buildSubscriptionActivationTransaction({ - request: baseRequest(), - rpcUrl: 'https://mock-rpc', - signer, +describe('canonical subscription activation builder', () => { + test('uses generated canonical data/account layouts and never broadcasts', async () => { + const subscriber = await generateKeyPairSigner(); + const server = await generateKeyPairSigner(); + let fetchCalls = 0; + globalThis.fetch = async () => { + fetchCalls += 1; + throw new Error('builder must not access the network when the challenge pins a blockhash'); + }; + + const encoded = await buildSubscriptionActivationTransaction({ + request: request(server.address), + signer: subscriber, + subscriptionAuthorityInitId: INIT_ID, }); - const message = decodeMessage(tx); - const discriminators = instructionDiscriminatorsByProgram(message, SUBSCRIPTIONS_PROGRAM); - expect(discriminators).toEqual([ - SUBSCRIPTIONS_INIT_AUTHORITY_DISCRIMINATOR, - SUBSCRIPTIONS_SUBSCRIBE_DISCRIMINATOR, - SUBSCRIPTIONS_TRANSFER_DISCRIMINATOR, - ]); - }); + expect(fetchCalls).toBe(0); - test('omits initialize_subscription_authority when the authority already exists', async () => { - globalThis.fetch = defaultMockFetch({ authorityExists: true }); - const signer = await generateKeyPairSigner(); - const tx = await buildSubscriptionActivationTransaction({ - request: baseRequest(), - rpcUrl: 'https://mock-rpc', - signer, + const message = decodeWire(encoded); + expect(message.staticAccounts[0]).toBe(server.address); + expect(message.header.numSignerAccounts).toBe(2); + + const programInstructions = message.instructions.filter( + ix => message.staticAccounts[ix.programAddressIndex] === SUBSCRIPTIONS_PROGRAM, + ); + expect(programInstructions).toHaveLength(2); + + const subscribeIx = programInstructions.find(ix => ix.data[0] === SUBSCRIBE_DISCRIMINATOR)!; + const subscribeData = getSubscribeInstructionDataDecoder().decode(subscribeIx.data).subscribeData; + expect(subscribeIx.accountIndices).toHaveLength(9); + expect(subscribeData).toMatchObject({ + expectedAmount: 10_000_000n, + expectedCreatedAt: 1_700_000_000n, + expectedMint: address(MINT), + expectedPeriodHours: 720n, + expectedSubscriptionAuthorityInitId: INIT_ID, + planBump: 254, + planId: 7n, }); - const message = decodeMessage(tx); - const discriminators = instructionDiscriminatorsByProgram(message, SUBSCRIPTIONS_PROGRAM); - expect(discriminators).toEqual([SUBSCRIPTIONS_SUBSCRIBE_DISCRIMINATOR, SUBSCRIPTIONS_TRANSFER_DISCRIMINATOR]); - }); - test('uses the server-provided recentBlockhash when present', async () => { - let blockhashFetched = false; - globalThis.fetch = async (_input, init) => { - const body = JSON.parse(init?.body as string) as { method?: string }; - if (body.method === 'getLatestBlockhash') { - blockhashFetched = true; - } - return defaultMockFetch()(_input, init); - }; - const signer = await generateKeyPairSigner(); - const req = baseRequest(); - req.methodDetails.recentBlockhash = BLOCKHASH; - await buildSubscriptionActivationTransaction({ - request: req, - rpcUrl: 'https://mock-rpc', - signer, + const transferIx = programInstructions.find(ix => ix.data[0] === TRANSFER_SUBSCRIPTION_DISCRIMINATOR)!; + const transferData = getTransferSubscriptionInstructionDataDecoder().decode(transferIx.data).transferData; + expect(transferIx.accountIndices).toHaveLength(10); + expect(transferData).toEqual({ + amount: 10_000_000n, + delegator: subscriber.address, + mint: address(MINT), }); - expect(blockhashFetched).toBe(false); + + const ataInstructions = message.instructions.filter( + ix => message.staticAccounts[ix.programAddressIndex] === ASSOCIATED_TOKEN_PROGRAM, + ); + expect(ataInstructions).toHaveLength(2); + expect(ataInstructions.every(ix => ix.data.length === 1 && ix.data[0] === 1)).toBe(true); }); - test('appends a memo instruction when externalId is supplied', async () => { - globalThis.fetch = defaultMockFetch(); - const signer = await generateKeyPairSigner(); - const req = baseRequest(); - req.externalId = 'order-42'; - const tx = await buildSubscriptionActivationTransaction({ - request: req, - rpcUrl: 'https://mock-rpc', - signer, - }); - const message = decodeMessage(tx); - const memoAddress = 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr'; - const memoIxs = message.instructions.filter( - ix => message.staticAccounts[ix.programAddressIndex].toString() === memoAddress, + test('allows a known Token-2022 stablecoin for both ATA layouts', async () => { + const subscriber = await generateKeyPairSigner(); + const server = await generateKeyPairSigner(); + const challenged = request(server.address, TOKEN_2022_PROGRAM); + challenged.methodDetails.mint = PYUSD.mainnet; + const message = decodeWire( + await buildSubscriptionActivationTransaction({ + request: challenged, + signer: subscriber, + subscriptionAuthorityInitId: INIT_ID, + }), ); - expect(memoIxs).toHaveLength(1); - expect(new TextDecoder().decode(memoIxs[0].data)).toBe('order-42'); + const ataInstructions = message.instructions.filter( + ix => message.staticAccounts[ix.programAddressIndex] === ASSOCIATED_TOKEN_PROGRAM, + ); + for (const ix of ataInstructions) { + expect(message.staticAccounts[ix.accountIndices[5]]).toBe(TOKEN_2022_PROGRAM); + } }); - test('rejects feePayer=true without a feePayerKey', async () => { - globalThis.fetch = defaultMockFetch(); - const signer = await generateKeyPairSigner(); - const req = baseRequest(); - req.methodDetails.feePayer = true; + test('rejects a server-selected custom token program', async () => { + const subscriber = await generateKeyPairSigner(); + const server = await generateKeyPairSigner(); + const customTokenProgram = (await generateKeyPairSigner()).address; + await expect( buildSubscriptionActivationTransaction({ - request: req, - rpcUrl: 'https://mock-rpc', - signer, + request: request(server.address, customTokenProgram), + signer: subscriber, + subscriptionAuthorityInitId: INIT_ID, }), - ).rejects.toThrow(/feePayerKey/); + ).rejects.toThrow(/Unsupported token program/); }); - test('uses the server fee-payer when feePayer=true with a feePayerKey', async () => { - globalThis.fetch = defaultMockFetch(); - const signer = await generateKeyPairSigner(); - const req = baseRequest(); - req.methodDetails.feePayer = true; - req.methodDetails.feePayerKey = FEE_PAYER; - const tx = await buildSubscriptionActivationTransaction({ - request: req, - rpcUrl: 'https://mock-rpc', - signer, - }); - const message = decodeMessage(tx); - // First static account is the fee payer in a v0 message. - expect(message.staticAccounts[0].toString()).toBe(FEE_PAYER); - }); + test('rejects an unknown Token-2022 mint unless explicitly opted in', async () => { + const subscriber = await generateKeyPairSigner(); + const server = await generateKeyPairSigner(); + const challenged = request(server.address, TOKEN_2022_PROGRAM); + challenged.methodDetails.mint = (await generateKeyPairSigner()).address; - test('rejects periodUnit="month" through the helper', async () => { - globalThis.fetch = defaultMockFetch(); - const signer = await generateKeyPairSigner(); - const req = baseRequest(); - (req as unknown as { periodUnit: string }).periodUnit = 'month'; await expect( buildSubscriptionActivationTransaction({ - request: req, - rpcUrl: 'https://mock-rpc', - signer, + request: challenged, + signer: subscriber, + subscriptionAuthorityInitId: INIT_ID, }), - ).rejects.toThrow(/rejects periodUnit/); - }); + ).rejects.toThrow(/unknown Token-2022 mint/); - test('rejects periodCount out of range for day', async () => { - globalThis.fetch = defaultMockFetch(); - const signer = await generateKeyPairSigner(); - const req = baseRequest(); - req.periodCount = '400'; await expect( buildSubscriptionActivationTransaction({ - request: req, - rpcUrl: 'https://mock-rpc', - signer, + allowUnknownToken2022: true, + request: challenged, + signer: subscriber, + subscriptionAuthorityInitId: INIT_ID, }), - ).rejects.toThrow(/exceeds 365/); + ).resolves.toEqual(expect.any(String)); }); - test('invokes onProgress callbacks during build', async () => { - globalThis.fetch = defaultMockFetch(); - const signer = await generateKeyPairSigner(); - const events: string[] = []; - await buildSubscriptionActivationTransaction({ - onProgress: ev => events.push((ev as { type: string }).type), - request: baseRequest(), - rpcUrl: 'https://mock-rpc', - signer, - }); - expect(events).toContain('challenge'); - expect(events).toContain('signing'); + test('derives event authority and self-program from a challenged custom program', async () => { + const subscriber = await generateKeyPairSigner(); + const server = await generateKeyPairSigner(); + const customProgram = (await generateKeyPairSigner()).address; + const challenged = request(server.address); + challenged.methodDetails.programId = customProgram; + + const message = decodeWire( + await buildSubscriptionActivationTransaction({ + request: challenged, + signer: subscriber, + subscriptionAuthorityInitId: INIT_ID, + }), + ); + const [eventAuthority] = await findEventAuthorityPda({ programAddress: address(customProgram) }); + const transferIx = message.instructions.find(ix => ix.data[0] === TRANSFER_SUBSCRIPTION_DISCRIMINATOR)!; + + expect(message.staticAccounts[transferIx.accountIndices[8]]).toBe(eventAuthority); + expect(message.staticAccounts[transferIx.accountIndices[9]]).toBe(customProgram); }); - test('falls back to the default RPC URL when no rpcUrl is provided', async () => { - const urls: string[] = []; - globalThis.fetch = async (input, init) => { - urls.push(String(input)); - return defaultMockFetch()(input, init); - }; - const signer = await generateKeyPairSigner(); - await buildSubscriptionActivationTransaction({ - request: baseRequest(), - signer, - }); - // devnet network → public devnet RPC - expect(urls.some(u => u.includes('devnet'))).toBe(true); + test('rejects inconsistent snapshots and noncanonical fee-payer configuration', async () => { + const subscriber = await generateKeyPairSigner(); + const server = await generateKeyPairSigner(); + const wrongPeriod = request(server.address); + wrongPeriod.methodDetails.expectedPeriodHours = '24'; + await expect( + buildSubscriptionActivationTransaction({ + request: wrongPeriod, + signer: subscriber, + subscriptionAuthorityInitId: INIT_ID, + }), + ).rejects.toThrow(/expectedPeriodHours/); + + const wrongPuller = request(server.address); + wrongPuller.methodDetails.puller = MERCHANT; + await expect( + buildSubscriptionActivationTransaction({ + request: wrongPuller, + signer: subscriber, + subscriptionAuthorityInitId: INIT_ID, + }), + ).rejects.toThrow(/feePayerKey must equal puller/); }); - test('normalizes a mixed-case network slug when resolving the default RPC URL', async () => { - const urls: string[] = []; - globalThis.fetch = async (input, init) => { - urls.push(String(input)); - return defaultMockFetch()(input, init); - }; - const signer = await generateKeyPairSigner(); - const req = baseRequest(); - // Upper-case mainnet slug must normalize (mainnet/mainnet-beta → mainnet) - // and resolve to the mainnet default RPC rather than falling through. - (req.methodDetails as { network: string }).network = 'MAINNET'; - await buildSubscriptionActivationTransaction({ - request: req, - signer, - }); - expect(urls.some(u => u.includes('api.mainnet-beta.solana.com'))).toBe(true); - expect(urls.some(u => u.includes('devnet'))).toBe(false); + test('rejects broadcast:true instead of producing unsupported signature credentials', async () => { + const subscriber = await generateKeyPairSigner(); + expect(() => + subscription({ + broadcast: true, + signer: subscriber, + subscriptionAuthorityInitId: INIT_ID, + } as never), + ).toThrow(/push activation is unsupported/i); }); -}); -// ══════════════════════════════════════════════════════════════════════ -// subscription() — Method.toClient wrapper (createCredential) -// ══════════════════════════════════════════════════════════════════════ - -describe('subscription() client wrapper', () => { - async function buildChallenge() { - return { - id: 'test-id', - realm: 'realm', - method: 'solana', - intent: 'subscription', - request: baseRequest(), - expires: undefined, - } as never; - } - - test('emits a credential in pull mode without broadcasting', async () => { - const calls: string[] = []; - globalThis.fetch = async (input, init) => { - const body = JSON.parse(init?.body as string) as { method?: string }; - calls.push(body.method ?? ''); - return defaultMockFetch()(input, init); + test('uses the challenged network RPC for initialization and activation', async () => { + const subscriber = await generateKeyPairSigner(); + const server = await generateKeyPairSigner(); + const encodedAuthority = getSubscriptionAuthorityEncoder().encode({ + bump: 253, + discriminator: 4, + initId: INIT_ID, + payer: subscriber.address, + tokenMint: address(MINT), + user: subscriber.address, + }); + const requestedUrls: string[] = []; + globalThis.fetch = async input => { + requestedUrls.push(String(input)); + return rpcSuccess({ + value: { + data: [getBase64Codec().decode(encodedAuthority), 'base64'], + executable: false, + lamports: 1, + owner: SUBSCRIPTIONS_PROGRAM, + rentEpoch: 0, + }, + }); }; - const signer = await generateKeyPairSigner(); - const method = subscriptionClient({ - rpcUrl: 'https://mock-rpc', - signer, + + const method = subscription({ initializeSubscriptionAuthority: true, signer: subscriber }); + await method.createCredential({ + challenge: { + id: 'challenge-id', + intent: 'subscription', + method: 'solana', + realm: 'test', + request: request(server.address), + } as never, }); - const cred = await method.createCredential!({ challenge: await buildChallenge() }); - // The mppx framework's Credential envelope is opaque; we assert the - // builder ran end-to-end and that no broadcast happened. - expect(typeof cred).toBe('string'); - expect(cred.length).toBeGreaterThan(0); - expect(calls).not.toContain('sendTransaction'); + + expect(requestedUrls).toEqual(['https://api.devnet.solana.com', 'https://api.devnet.solana.com']); }); +}); - test('broadcasts and emits a type="signature" credential when broadcast=true', async () => { - const calls: string[] = []; - globalThis.fetch = async (input, init) => { - const body = JSON.parse(init?.body as string) as { method?: string }; - calls.push(body.method ?? ''); - return defaultMockFetch()(input, init); - }; +describe('explicit SubscriptionAuthority initialization', () => { + test('returns the existing init id without broadcasting', async () => { const signer = await generateKeyPairSigner(); - const method = subscriptionClient({ - broadcast: true, - rpcUrl: 'https://mock-rpc', - signer, + const encoded = getSubscriptionAuthorityEncoder().encode({ + bump: 253, + discriminator: 4, + initId: INIT_ID, + payer: signer.address, + tokenMint: address(MINT), + user: signer.address, }); - const cred = await method.createCredential!({ challenge: await buildChallenge() }); - expect(cred).toBeTruthy(); - expect(calls).toContain('sendTransaction'); - expect(calls).toContain('getSignatureStatuses'); + const methods: string[] = []; + globalThis.fetch = async (_input, init) => { + const body = JSON.parse(init?.body as string) as { method: string }; + methods.push(body.method); + return rpcSuccess({ + value: { + data: [getBase64Codec().decode(encoded), 'base64'], + executable: false, + lamports: 1, + owner: SUBSCRIPTIONS_PROGRAM, + rentEpoch: 0, + }, + }); + }; + + await expect( + initializeSubscriptionAuthority({ + mint: MINT, + rpcUrl: 'https://mock-rpc', + signer, + tokenProgram: TOKEN_PROGRAM, + }), + ).resolves.toBe(INIT_ID); + expect(methods).toEqual(['getAccountInfo']); }); - test('rejects broadcast=true combined with feePayer sponsorship', async () => { - globalThis.fetch = defaultMockFetch(); + test('broadcasts only when the explicit helper is invoked for a missing authority', async () => { const signer = await generateKeyPairSigner(); - const method = subscriptionClient({ - broadcast: true, - rpcUrl: 'https://mock-rpc', - signer, + const encoded = getSubscriptionAuthorityEncoder().encode({ + bump: 253, + discriminator: 4, + initId: INIT_ID, + payer: signer.address, + tokenMint: address(MINT), + user: signer.address, }); - const challenge = { - id: 'test-id', - realm: 'realm', - method: 'solana', - intent: 'subscription', - request: { - ...baseRequest(), - methodDetails: { - ...baseRequest().methodDetails, - feePayer: true, - feePayerKey: FEE_PAYER, - }, - }, - } as never; - await expect(method.createCredential!({ challenge })).rejects.toThrow(/fee sponsorship/); + let accountReads = 0; + const methods: string[] = []; + globalThis.fetch = async (_input, init) => { + const body = JSON.parse(init?.body as string) as { method: string }; + methods.push(body.method); + if (body.method === 'getAccountInfo') { + accountReads += 1; + if (accountReads === 1) return rpcSuccess({ value: null }); + return rpcSuccess({ + value: { + data: [getBase64Codec().decode(encoded), 'base64'], + executable: false, + lamports: 1, + owner: SUBSCRIPTIONS_PROGRAM, + rentEpoch: 0, + }, + }); + } + if (body.method === 'getLatestBlockhash') { + return rpcSuccess({ value: { blockhash: BLOCKHASH, lastValidBlockHeight: 1 } }); + } + if (body.method === 'sendTransaction') return rpcSuccess('init-signature'); + if (body.method === 'getSignatureStatuses') { + return rpcSuccess({ value: [{ confirmationStatus: 'confirmed', err: null }] }); + } + return rpcSuccess({}); + }; + + await expect( + initializeSubscriptionAuthority({ + mint: MINT, + rpcUrl: 'https://mock-rpc', + signer, + tokenProgram: TOKEN_PROGRAM, + }), + ).resolves.toBe(INIT_ID); + expect(methods).toContain('sendTransaction'); }); }); + +function rpcSuccess(result: unknown) { + return new Response(JSON.stringify({ id: 1, jsonrpc: '2.0', result }), { + headers: { 'Content-Type': 'application/json' }, + }); +} diff --git a/typescript/packages/mpp/src/__tests__/subscription-server.test.ts b/typescript/packages/mpp/src/__tests__/subscription-server.test.ts index cfe2f7633..ac4edca8f 100644 --- a/typescript/packages/mpp/src/__tests__/subscription-server.test.ts +++ b/typescript/packages/mpp/src/__tests__/subscription-server.test.ts @@ -1,1129 +1,783 @@ -/** - * Behavioral tests for the server-side Solana subscription handler. - * - * Covers configuration validation, request() shaping, and the verify() flow - * across pull and push modes — with RPC interactions stubbed via globalThis.fetch. - * Internal pure helpers (instruction validators, base58/base64url codecs, - * SubscriptionDelegation decoder) are exercised directly through the - * `__testing` export. - */ -import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { afterEach, describe, expect, test } from 'vitest'; import { - AccountRole, address, - appendTransactionMessageInstructions, type Blockhash, - createTransactionMessage, generateKeyPairSigner, - getBase64EncodedWireTransaction, - type Instruction, - partiallySignTransactionMessageWithSigners, - pipe, - setTransactionMessageFeePayerSigner, - setTransactionMessageLifetimeUsingBlockhash, + getBase64Codec, + getCompiledTransactionMessageDecoder, + getCompiledTransactionMessageEncoder, + getSignatureFromTransaction, + getTransactionDecoder, + getTransactionEncoder, + type TransactionPartialSigner, } from '@solana/kit'; -import { Store } from 'mppx/server'; +import { Challenge, Credential } from 'mppx'; +import { Mppx } from 'mppx/server'; -import { - SUBSCRIPTIONS_PROGRAM, - SUBSCRIPTIONS_SUBSCRIBE_DISCRIMINATOR, - SUBSCRIPTIONS_TRANSFER_DISCRIMINATOR, - TOKEN_2022_PROGRAM, - TOKEN_PROGRAM, -} from '../constants.js'; -import { __testing, subscription } from '../server/Subscription.js'; +import { buildSubscriptionActivationTransaction } from '../client/Subscription.js'; +import { COMPUTE_BUDGET_PROGRAM, SUBSCRIPTIONS_PROGRAM, TOKEN_2022_PROGRAM, TOKEN_PROGRAM } from '../constants.js'; +import { getSubscriptionAuthorityEncoder } from '../generated/subscriptions/accounts/subscriptionAuthority.js'; +import { getSubscriptionDelegationEncoder } from '../generated/subscriptions/accounts/subscriptionDelegation.js'; +import { __testing, subscription, type SubscriptionReplayStore } from '../server/Subscription.js'; +import { deriveSubscriptionAuthorityPda, deriveSubscriptionPda } from '../shared/subscription.js'; const BLOCKHASH = 'EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N' as Blockhash; - const PLAN_ID = '8tWbqLkUJoYy7zXc5h2EvCRoaQEv2xnQjUuYhc3rzCgT'; const MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; -const PULLER = '5fKb5cF22cFybZB1H4hLDydFhwoQy9JzKzRWaSbMkB6h'; +const MERCHANT = '5fKb5cF22cFybZB1H4hLDydFhwoQy9JzKzRWaSbMkB6h'; const RECIPIENT = '9xAXssX9j7vuK99c7cFwqbixzL3bFrzPy9PUhCtDPAYJ'; +const INIT_ID = 9123n; -// ── Test setup ── - -let originalFetch: typeof globalThis.fetch; - -beforeEach(() => { - originalFetch = globalThis.fetch; - process.env.MPP_SECRET_KEY = 'test-secret'; -}); - +const originalFetch = globalThis.fetch; afterEach(() => { globalThis.fetch = originalFetch; }); -// ── Helpers ── +class AtomicStore implements SubscriptionReplayStore { + readonly isDurable?: boolean; + readonly isShared?: boolean; + readonly values = new Map(); -function rpcSuccess(result: unknown) { - return new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, result }), { - headers: { 'Content-Type': 'application/json' }, - }); + constructor(capabilities: { isDurable?: boolean; isShared?: boolean } = { isDurable: true, isShared: true }) { + this.isDurable = capabilities.isDurable; + this.isShared = capabilities.isShared; + } + + async get(key: string) { + return this.values.get(key) ?? null; + } + + async put(key: string, value: unknown) { + this.values.set(key, value); + } + + async delete(key: string) { + this.values.delete(key); + } + + async reserve(key: string, value: unknown = true) { + if (this.values.has(key)) return false; + this.values.set(key, value); + return true; + } } -/** Build a compiled-message-style activation transaction (subscriber-signed). */ -async function buildActivationTransactionBase64( - options: { - extraInstructions?: 'duplicate-subscribe' | 'duplicate-transfer' | 'reorder' | 'no-subscribe' | 'no-transfer'; - feePayerKey?: string; - } = {}, -): Promise<{ subscriberAddress: string; transaction: string }> { - const subscriber = await generateKeyPairSigner(); - const subscribeIx: Instruction = { - accounts: [{ address: subscriber.address, role: AccountRole.WRITABLE_SIGNER }], - data: new Uint8Array([SUBSCRIPTIONS_SUBSCRIBE_DISCRIMINATOR]), - programAddress: address(SUBSCRIPTIONS_PROGRAM), - }; - const transferIx: Instruction = { - accounts: [{ address: subscriber.address, role: AccountRole.READONLY }], - data: new Uint8Array([SUBSCRIPTIONS_TRANSFER_DISCRIMINATOR]), - programAddress: address(SUBSCRIPTIONS_PROGRAM), - }; +class DeleteFailingStore extends AtomicStore { + deleteCalls = 0; - let instructions: Instruction[]; - switch (options.extraInstructions) { - case 'duplicate-subscribe': - instructions = [subscribeIx, subscribeIx, transferIx]; - break; - case 'duplicate-transfer': - instructions = [subscribeIx, transferIx, transferIx]; - break; - case 'reorder': - instructions = [transferIx, subscribeIx]; - break; - case 'no-subscribe': - instructions = [transferIx]; - break; - case 'no-transfer': - instructions = [subscribeIx]; - break; - default: - instructions = [subscribeIx, transferIx]; + async delete(key: string) { + this.deleteCalls += 1; + throw new Error(`delete failed for ${key}`); } +} - const txMessage = pipe( - createTransactionMessage({ version: 0 }), - msg => setTransactionMessageFeePayerSigner(subscriber, msg), - msg => setTransactionMessageLifetimeUsingBlockhash({ blockhash: BLOCKHASH, lastValidBlockHeight: 1n }, msg), - msg => appendTransactionMessageInstructions(instructions, msg), - ); - const signed = await partiallySignTransactionMessageWithSigners(txMessage); +function request(serverAddress: string) { return { - subscriberAddress: subscriber.address, - transaction: getBase64EncodedWireTransaction(signed), + amount: '10000000', + currency: MINT, + externalId: 'invoice-42', + methodDetails: { + decimals: 6, + expectedCreatedAt: '1700000000', + expectedPeriodHours: '720', + feePayer: true, + feePayerKey: serverAddress, + merchant: MERCHANT, + mint: MINT, + network: 'devnet', + planBump: 254, + planId: PLAN_ID, + planIdNumeric: '7', + programId: SUBSCRIPTIONS_PROGRAM, + puller: serverAddress, + recentBlockhash: BLOCKHASH, + tokenProgram: TOKEN_PROGRAM, + }, + periodCount: '30', + periodUnit: 'day' as const, + recipient: RECIPIENT, }; } -// ══════════════════════════════════════════════════════════════════════ -// Configuration validation -// ══════════════════════════════════════════════════════════════════════ - -describe('subscription() config validation', () => { - const baseParams = { +function serverParameters(signer: TransactionPartialSigner, store: SubscriptionReplayStore) { + return { decimals: 6, + merchant: MERCHANT, mint: MINT, + network: 'devnet' as const, periodCount: 30, periodUnit: 'day' as const, + planBump: 254, + planCreatedAt: 1_700_000_000n, planId: PLAN_ID, - puller: PULLER, + planIdNumeric: 7n, + puller: signer.address, recipient: RECIPIENT, + rpcUrl: 'https://mock-rpc', + signer, + store, tokenProgram: TOKEN_PROGRAM, }; +} - test('rejects an unrecognised tokenProgram', () => { - expect(() => subscription({ ...baseParams, tokenProgram: 'not-a-token-program' })).toThrow( - /tokenProgram must be/, - ); - }); - - test('rejects a periodCount that is out of range for `day`', () => { - expect(() => subscription({ ...baseParams, periodCount: 400 })).toThrow(/exceeds 365/); - }); - - test('rejects a periodCount that is out of range for `week`', () => { - expect(() => subscription({ ...baseParams, periodCount: 60, periodUnit: 'week' })).toThrow(/exceeds 52/); - }); - - test('rejects a non-signer object passed as signer', () => { - expect(() => subscription({ ...baseParams, signer: {} as never })).toThrow(/signTransactions/); +async function fixture() { + const subscriber = await generateKeyPairSigner(); + const serverSigner = await generateKeyPairSigner(); + const challengedRequest = request(serverSigner.address); + const transaction = await buildSubscriptionActivationTransaction({ + request: challengedRequest, + signer: subscriber, + subscriptionAuthorityInitId: INIT_ID, + }); + const authority = await deriveSubscriptionAuthorityPda({ + mint: address(MINT), + programId: address(SUBSCRIPTIONS_PROGRAM), + subscriber: subscriber.address, + }); + const delegation = await deriveSubscriptionPda({ + planPda: address(PLAN_ID), + programId: address(SUBSCRIPTIONS_PROGRAM), + subscriber: subscriber.address, }); + return { + authority: authority.toString(), + challengedRequest, + delegation: delegation.toString(), + serverSigner, + subscriber, + transaction, + }; +} - test('accepts a valid Token-2022 configuration', () => { - expect(() => subscription({ ...baseParams, tokenProgram: TOKEN_2022_PROGRAM })).not.toThrow(); - }); -}); +function authorityAccount(subscriber: string, server: string) { + return getBase64Codec().decode( + getSubscriptionAuthorityEncoder().encode({ + bump: 253, + discriminator: 4, + initId: INIT_ID, + payer: address(server), + tokenMint: address(MINT), + user: address(subscriber), + }), + ); +} -// ══════════════════════════════════════════════════════════════════════ -// request() shaping -// ══════════════════════════════════════════════════════════════════════ +function delegationAccount(subscriber: string, server: string, amount = 10_000_000n) { + return getBase64Codec().decode( + getSubscriptionDelegationEncoder().encode({ + amountPulledInPeriod: amount, + currentPeriodStartTs: 1_737_216_000n, + expiresAtTs: 0n, + header: { + bump: 252, + delegatee: address(PLAN_ID), + delegator: address(subscriber), + discriminator: 5, + initId: 77n, + payer: address(server), + version: 1, + }, + terms: { + amount, + createdAt: 1_700_000_000n, + periodHours: 720n, + }, + }), + ); +} -describe('subscription().request()', () => { - test('builds canonical methodDetails when no credential is present', async () => { - globalThis.fetch = async () => rpcSuccess({ value: { blockhash: BLOCKHASH, lastValidBlockHeight: 1 } }); - const method = subscription({ - decimals: 6, - mint: MINT, - network: 'devnet', - periodCount: 30, - periodUnit: 'day', - planId: PLAN_ID, - puller: PULLER, - recipient: RECIPIENT, - tokenProgram: TOKEN_PROGRAM, - }); - const result = await method.request!({ - credential: null, - request: { - amount: '10000000', - currency: MINT, - methodDetails: { - decimals: 6, - mint: MINT, - planId: PLAN_ID, - puller: PULLER, - tokenProgram: TOKEN_PROGRAM, - }, - periodCount: '30', - periodUnit: 'day', - recipient: RECIPIENT, - } as never, - }); - expect(result.methodDetails.network).toBe('devnet'); - expect(result.methodDetails.planId).toBe(PLAN_ID); - expect(result.methodDetails.programId).toBe(SUBSCRIPTIONS_PROGRAM); - expect(result.methodDetails.recentBlockhash).toBe(BLOCKHASH); - expect(result.methodDetails.puller).toBe(PULLER); - expect(result.recipient).toBe(RECIPIENT); - }); +function accountInfo(encoded: string) { + return { + value: { + data: [encoded, 'base64'], + executable: false, + lamports: 1, + owner: SUBSCRIPTIONS_PROGRAM, + rentEpoch: 0, + }, + }; +} - test('skips blockhash fetch when a credential is present (verify path)', async () => { - let fetchCalls = 0; - globalThis.fetch = async () => { - fetchCalls += 1; - return rpcSuccess({}); - }; - const method = subscription({ - decimals: 6, - mint: MINT, - periodCount: 30, - periodUnit: 'day', - planId: PLAN_ID, - puller: PULLER, - recipient: RECIPIENT, - tokenProgram: TOKEN_PROGRAM, - }); - await method.request!({ - credential: { challenge: { request: { amount: '1', currency: MINT } } } as never, - request: { - amount: '10000000', - currency: MINT, - methodDetails: { - decimals: 6, - mint: MINT, - planId: PLAN_ID, - puller: PULLER, - tokenProgram: TOKEN_PROGRAM, - }, - periodCount: '30', - periodUnit: 'day', - recipient: RECIPIENT, - } as never, - }); - expect(fetchCalls).toBe(0); - }); +function credential(transaction: string, challengedRequest: ReturnType) { + return { + challenge: { id: 'challenge-id', request: challengedRequest }, + payload: { transaction, type: 'transaction' }, + }; +} - test('tolerates blockhash fetch failure', async () => { - globalThis.fetch = async () => { - throw new Error('rpc unreachable'); - }; - const method = subscription({ - decimals: 6, - mint: MINT, - periodCount: 30, - periodUnit: 'day', - planId: PLAN_ID, - puller: PULLER, - recipient: RECIPIENT, - tokenProgram: TOKEN_PROGRAM, - }); - const result = await method.request!({ - credential: null, - request: { - amount: '10000000', - currency: MINT, - methodDetails: { - decimals: 6, - mint: MINT, - planId: PLAN_ID, - puller: PULLER, - tokenProgram: TOKEN_PROGRAM, - }, - periodCount: '30', - periodUnit: 'day', - recipient: RECIPIENT, - } as never, - }); - expect(result.methodDetails.recentBlockhash).toBeUndefined(); - }); +function rewriteComputeUnitLimits(transactionBase64: string, count: 0 | 2): string { + const transaction = getTransactionDecoder().decode(getBase64Codec().encode(transactionBase64)); + const message = getCompiledTransactionMessageDecoder().decode(transaction.messageBytes) as unknown as { + instructions: Array<{ accountIndices: number[]; data: Uint8Array; programAddressIndex: number }>; + staticAccounts: string[]; + }; + const isComputeLimit = (instruction: (typeof message.instructions)[number]) => + message.staticAccounts[instruction.programAddressIndex] === COMPUTE_BUDGET_PROGRAM && instruction.data[0] === 2; + const limit = message.instructions.find(isComputeLimit); + if (!limit) throw new Error('fixture is missing SetComputeUnitLimit'); + const instructions = message.instructions.filter(instruction => !isComputeLimit(instruction)); + if (count === 2) instructions.unshift(limit, limit); + const messageBytes = new Uint8Array( + getCompiledTransactionMessageEncoder().encode({ ...message, instructions } as never), + ); + const rebuilt = getTransactionEncoder().encode({ ...transaction, messageBytes } as never); + return getBase64Codec().decode(new Uint8Array(rebuilt)); +} - test('emits feePayer/feePayerKey when a signer is configured', async () => { +describe('subscription server configuration', () => { + test('requires a signer/puller match and durable shared atomic replay storage outside localnet', async () => { const signer = await generateKeyPairSigner(); - globalThis.fetch = async () => rpcSuccess({ value: { blockhash: BLOCKHASH, lastValidBlockHeight: 1 } }); - const method = subscription({ - decimals: 6, - mint: MINT, - periodCount: 30, - periodUnit: 'day', - planId: PLAN_ID, - puller: PULLER, - recipient: RECIPIENT, - signer, - tokenProgram: TOKEN_PROGRAM, - }); - const result = await method.request!({ - credential: null, - request: { - amount: '10000000', - currency: MINT, - methodDetails: { - decimals: 6, - mint: MINT, - planId: PLAN_ID, - puller: PULLER, - tokenProgram: TOKEN_PROGRAM, - }, - periodCount: '30', - periodUnit: 'day', - recipient: RECIPIENT, - } as never, - }); - expect(result.methodDetails.feePayer).toBe(true); - expect(result.methodDetails.feePayerKey).toBe(signer.address); - }); - - test('echoes optional splits and subscriptionExpires when supplied', async () => { - globalThis.fetch = async () => rpcSuccess({ value: { blockhash: BLOCKHASH, lastValidBlockHeight: 1 } }); - const method = subscription({ - decimals: 6, - mint: MINT, - periodCount: 30, - periodUnit: 'day', - planId: PLAN_ID, - puller: PULLER, - recipient: RECIPIENT, - splits: [{ bps: 100, recipient: RECIPIENT }], - subscriptionExpires: '2026-07-14T12:00:00Z', - tokenProgram: TOKEN_PROGRAM, - }); - const result = await method.request!({ - credential: null, - request: { - amount: '10000000', - currency: MINT, - methodDetails: { - decimals: 6, - mint: MINT, - planId: PLAN_ID, - puller: PULLER, - tokenProgram: TOKEN_PROGRAM, - }, - periodCount: '30', - periodUnit: 'day', - recipient: RECIPIENT, - } as never, - }); - expect(result.methodDetails.splits).toEqual([{ bps: 100, recipient: RECIPIENT }]); - expect(result.subscriptionExpires).toBe('2026-07-14T12:00:00Z'); - }); -}); - -// ══════════════════════════════════════════════════════════════════════ -// validateActivationInstructions (pure) -// ══════════════════════════════════════════════════════════════════════ - -describe('validateActivationInstructions', () => { - const challenge = { - methodDetails: { programId: SUBSCRIPTIONS_PROGRAM }, - } as never; - - test('accepts a well-formed [subscribe, transfer_subscription] sequence', async () => { - const { transaction } = await buildActivationTransactionBase64(); - expect(() => __testing.validateActivationInstructions(transaction, challenge)).not.toThrow(); - }); - - test('rejects a transaction missing subscribe', async () => { - const { transaction } = await buildActivationTransactionBase64({ extraInstructions: 'no-subscribe' }); - expect(() => __testing.validateActivationInstructions(transaction, challenge)).toThrow(/missing subscribe/); - }); - - test('rejects a transaction missing transfer_subscription', async () => { - const { transaction } = await buildActivationTransactionBase64({ extraInstructions: 'no-transfer' }); - expect(() => __testing.validateActivationInstructions(transaction, challenge)).toThrow( - /missing transfer_subscription/, - ); - }); - - test('rejects multiple subscribe instructions', async () => { - const { transaction } = await buildActivationTransactionBase64({ extraInstructions: 'duplicate-subscribe' }); - expect(() => __testing.validateActivationInstructions(transaction, challenge)).toThrow(/Multiple subscribe/); - }); - - test('rejects multiple transfer_subscription instructions', async () => { - const { transaction } = await buildActivationTransactionBase64({ extraInstructions: 'duplicate-transfer' }); - expect(() => __testing.validateActivationInstructions(transaction, challenge)).toThrow( - /Multiple transfer_subscription/, - ); - }); - - test('rejects when transfer_subscription precedes subscribe', async () => { - const { transaction } = await buildActivationTransactionBase64({ extraInstructions: 'reorder' }); - expect(() => __testing.validateActivationInstructions(transaction, challenge)).toThrow( - /subscribe must precede/, - ); - }); - - test('rejects an undecodable base64 input', () => { - expect(() => __testing.validateActivationInstructions('not-a-real-tx', challenge)).toThrow( - /Invalid transaction/, + expect(() => + subscription({ ...serverParameters(signer, new AtomicStore()), signer: undefined as never }), + ).toThrow(/signer is required/); + expect(() => + subscription({ + ...serverParameters(signer, new AtomicStore()), + puller: MERCHANT, + }), + ).toThrow(/must equal puller/); + expect(() => + subscription({ + ...serverParameters(signer, new AtomicStore()), + store: { delete: async () => {}, get: async () => null, put: async () => {} } as never, + }), + ).toThrow(/atomic reserve/); + expect(() => subscription({ ...serverParameters(signer, new AtomicStore({})) })).toThrow( + /isShared=true and isDurable=true/, ); - }); -}); - -// ══════════════════════════════════════════════════════════════════════ -// extractSubscriberFromTransaction -// ══════════════════════════════════════════════════════════════════════ - -describe('extractSubscriberFromTransaction', () => { - test('returns the first signer when fee sponsorship is off', async () => { - const { transaction, subscriberAddress } = await buildActivationTransactionBase64(); - const challenge = { - methodDetails: { feePayer: false, puller: PULLER }, - } as never; - const subscriber = __testing.extractSubscriberFromTransaction(transaction, challenge); - expect(subscriber.toString()).toBe(subscriberAddress); - }); - - test('rejects when the first signer is the puller', async () => { - const { transaction, subscriberAddress } = await buildActivationTransactionBase64(); - const challenge = { - methodDetails: { feePayer: false, puller: subscriberAddress }, - } as never; - expect(() => __testing.extractSubscriberFromTransaction(transaction, challenge)).toThrow( - /Subscriber cannot be the server puller/, + expect(() => subscription({ ...serverParameters(signer, new AtomicStore({ isShared: true })) })).toThrow( + /isShared=true and isDurable=true/, ); - }); - - test('walks past the server fee payer when fee sponsorship is on', async () => { - // Build a tx whose fee payer is a server pubkey and the second signer is - // the subscriber, so extractSubscriberFromTransaction must skip slot 0. - const feePayer = await generateKeyPairSigner(); - const subscriber = await generateKeyPairSigner(); - const subscribeIx: Instruction = { - accounts: [ - { address: feePayer.address, role: AccountRole.WRITABLE_SIGNER }, - { address: subscriber.address, role: AccountRole.WRITABLE_SIGNER }, - ], - data: new Uint8Array([SUBSCRIPTIONS_SUBSCRIBE_DISCRIMINATOR]), - programAddress: address(SUBSCRIPTIONS_PROGRAM), - }; - const transferIx: Instruction = { - accounts: [ - { address: feePayer.address, role: AccountRole.WRITABLE_SIGNER }, - { address: subscriber.address, role: AccountRole.READONLY }, - ], - data: new Uint8Array([SUBSCRIPTIONS_TRANSFER_DISCRIMINATOR]), - programAddress: address(SUBSCRIPTIONS_PROGRAM), - }; - const txMessage = pipe( - createTransactionMessage({ version: 0 }), - msg => setTransactionMessageFeePayerSigner(feePayer, msg), - msg => setTransactionMessageLifetimeUsingBlockhash({ blockhash: BLOCKHASH, lastValidBlockHeight: 1n }, msg), - msg => appendTransactionMessageInstructions([subscribeIx, transferIx], msg), + expect(() => subscription({ ...serverParameters(signer, new AtomicStore({ isDurable: true })) })).toThrow( + /isShared=true and isDurable=true/, ); - const signed = await partiallySignTransactionMessageWithSigners(txMessage); - const txBase64 = getBase64EncodedWireTransaction(signed); - const challenge = { - methodDetails: { feePayer: true, feePayerKey: feePayer.address, puller: PULLER }, - } as never; - expect(__testing.extractSubscriberFromTransaction(txBase64, challenge).toString()).toBe(subscriber.address); + expect(() => + subscription({ + ...serverParameters(signer, new AtomicStore({})), + network: 'localnet', + }), + ).not.toThrow(); }); }); -// ══════════════════════════════════════════════════════════════════════ -// SubscriptionDelegation decoder -// ══════════════════════════════════════════════════════════════════════ - -describe('decodeSubscriptionDelegation', () => { - test('reads each field at the expected offset', () => { - const data = new Uint8Array(1 + 32 * 3 + 8 + 32 + 32 + 8 + 8 + 8 + 8); - let off = 0; - data[off] = 1; // discriminator - off += 1; - // subscriber, delegatee, payer pubkeys — fill with distinct patterns - data.set(new Uint8Array(32).fill(0xaa), off); - off += 32; - data.set(new Uint8Array(32).fill(0xbb), off); - off += 32; - data.set(new Uint8Array(32).fill(0xcc), off); - off += 32; - // init_id u64 - off += 8; - // plan_pda - data.set(new Uint8Array(32).fill(0xdd), off); - off += 32; - // mint - data.set(new Uint8Array(32).fill(0xee), off); - off += 32; - // amount_per_period u64 = 10_000_000 - writeU64Le(data, off, 10_000_000n); - off += 8; - // period_hours u64 = 720 - writeU64Le(data, off, 720n); - off += 8; - // current_period_start_ts i64 = 1737216000 (2025-01-18T16:00:00Z) - writeU64Le(data, off, 1737216000n); - off += 8; - // amount_pulled_in_period u64 = 10_000_000 - writeU64Le(data, off, 10_000_000n); - - const decoded = __testing.decodeSubscriptionDelegation(data); - expect(decoded.subscriber).toBe(__testing.encodeBase58(new Uint8Array(32).fill(0xaa))); - expect(decoded.planPda).toBe(__testing.encodeBase58(new Uint8Array(32).fill(0xdd))); - expect(decoded.mint).toBe(__testing.encodeBase58(new Uint8Array(32).fill(0xee))); - expect(decoded.amountPerPeriod).toBe('10000000'); - expect(decoded.periodHours).toBe(720); - expect(decoded.currentPeriodStartTs).toBe(1737216000); - expect(decoded.amountPulledInPeriod).toBe('10000000'); - }); +describe('canonical pre-sign validation', () => { + test('accepts the generated activation and rejects every challenged snapshot divergence', async () => { + const { challengedRequest, serverSigner, subscriber, transaction } = await fixture(); + globalThis.fetch = async () => + rpcSuccess(accountInfo(authorityAccount(subscriber.address, serverSigner.address))); - function writeU64Le(buf: Uint8Array, offset: number, value: bigint) { - for (let i = 0; i < 8; i += 1) { - buf[offset + i] = Number((value >> BigInt(i * 8)) & 0xffn); + await expect( + __testing.validateActivationInstructions(transaction, challengedRequest, 'https://mock-rpc'), + ).resolves.toBe(subscriber.address); + + const mutations: Array<[string, (value: ReturnType) => void]> = [ + ['merchant', value => (value.methodDetails.merchant = RECIPIENT)], + ['plan', value => (value.methodDetails.planId = RECIPIENT)], + ['plan id', value => (value.methodDetails.planIdNumeric = '8')], + ['plan bump', value => (value.methodDetails.planBump = 1)], + ['created at', value => (value.methodDetails.expectedCreatedAt = '1')], + ['period', value => (value.methodDetails.expectedPeriodHours = '24')], + ['amount', value => (value.amount = '999')], + ['recipient', value => (value.recipient = MERCHANT)], + ['puller', value => (value.methodDetails.puller = MERCHANT)], + ['token program', value => (value.methodDetails.tokenProgram = SUBSCRIPTIONS_PROGRAM)], + ]; + for (const [label, mutate] of mutations) { + const changed = structuredClone(challengedRequest); + mutate(changed); + await expect( + __testing.validateActivationInstructions(transaction, changed, 'https://mock-rpc'), + label, + ).rejects.toThrow(); } - } -}); - -// ══════════════════════════════════════════════════════════════════════ -// base58 / base64url codecs (round-trip) -// ══════════════════════════════════════════════════════════════════════ - -describe('encoding helpers', () => { - test('encodeBase58 and decodeBase58 roundtrip arbitrary bytes', () => { - const bytes = new Uint8Array([0, 0, 1, 255, 128, 64, 32, 16, 8, 4, 2, 1]); - const s = __testing.encodeBase58(bytes); - const back = __testing.decodeBase58(s); - expect(Array.from(back)).toEqual(Array.from(bytes)); }); - test('encodeBase58 handles all-zero leading bytes', () => { - const bytes = new Uint8Array([0, 0, 0, 42]); - const s = __testing.encodeBase58(bytes); - expect(s.startsWith('111')).toBe(true); - const back = __testing.decodeBase58(s); - expect(Array.from(back)).toEqual([0, 0, 0, 42]); + test('rejects when SubscribeData init id differs from the live authority account', async () => { + const { challengedRequest, serverSigner, subscriber, transaction } = await fixture(); + const different = getBase64Codec().decode( + getSubscriptionAuthorityEncoder().encode({ + bump: 253, + discriminator: 4, + initId: INIT_ID + 1n, + payer: serverSigner.address, + tokenMint: address(MINT), + user: subscriber.address, + }), + ); + globalThis.fetch = async () => rpcSuccess(accountInfo(different)); + await expect( + __testing.validateActivationInstructions(transaction, challengedRequest, 'https://mock-rpc'), + ).rejects.toThrow(/init id/); }); - test('encodeBase58 handles the empty input', () => { - expect(__testing.encodeBase58(new Uint8Array())).toBe(''); - expect(__testing.decodeBase58('').length).toBe(0); - }); + test('requires exactly one SetComputeUnitLimit instruction', async () => { + const { challengedRequest, serverSigner, subscriber, transaction } = await fixture(); + globalThis.fetch = async () => + rpcSuccess(accountInfo(authorityAccount(subscriber.address, serverSigner.address))); - test('decodeBase58 throws on invalid characters', () => { - expect(() => __testing.decodeBase58('0OIl')).toThrow(/Invalid base58 character/); - }); - - test('base64UrlEncodeNoPadding strips padding and remaps + and /', () => { - const bytes = new Uint8Array([0xfb, 0xff, 0xbf]); - const s = __testing.base64UrlEncodeNoPadding(bytes); - expect(s).not.toMatch(/=/); - expect(s).not.toMatch(/\+/); - expect(s).not.toMatch(/\//); + await expect( + __testing.validateActivationInstructions( + rewriteComputeUnitLimits(transaction, 0), + challengedRequest, + 'https://mock-rpc', + ), + ).rejects.toThrow(/exactly one SetComputeUnitLimit/); + await expect( + __testing.validateActivationInstructions( + rewriteComputeUnitLimits(transaction, 2), + challengedRequest, + 'https://mock-rpc', + ), + ).rejects.toThrow(/Duplicate compute unit limit/); }); }); -// ══════════════════════════════════════════════════════════════════════ -// verify() — end-to-end push-mode happy path -// ══════════════════════════════════════════════════════════════════════ - -describe('subscription().verify() (push mode)', () => { - test('rejects activation when first-period charge did not execute', async () => { - const { transaction, subscriberAddress } = await buildActivationTransactionBase64(); - const txSignature = '5J8KKfgKBLPDoCSk7B7TwAdSP3KtkfxYGYQH52SVgyM5XQXfeaG3xH8E3uYmGNLcoNNgWp3JjPdvzNwM4ZmJyREq'; - - // Build a `SubscriptionDelegation` byte buffer with amount_pulled = 0 so - // verify() raises "first-period charge not executed". - const data = new Uint8Array(1 + 32 * 3 + 8 + 32 + 32 + 8 + 8 + 8 + 8); - // Set subscriber bytes to match the activation tx signer (decoded base58). - const subscriberBytes = __testing.decodeBase58(subscriberAddress); - data.set(subscriberBytes, 1); - // plan_pda, mint left as zeros — verify will fail on plan mismatch before - // reaching amount checks. To exercise the "first charge" path we set them. - const planBytes = __testing.decodeBase58(PLAN_ID); - const mintBytes = __testing.decodeBase58(MINT); - data.set(planBytes, 1 + 32 * 3 + 8); - data.set(mintBytes, 1 + 32 * 3 + 8 + 32); - // amount_per_period = 10_000_000 - writeU64Le(data, 1 + 32 * 3 + 8 + 32 + 32, 10_000_000n); - // period_hours = 720 - writeU64Le(data, 1 + 32 * 3 + 8 + 32 + 32 + 8, 720n); - // current_period_start_ts = anything - writeU64Le(data, 1 + 32 * 3 + 8 + 32 + 32 + 8 + 8, 1737216000n); - // amount_pulled_in_period = 0 (the failure we want to test) - writeU64Le(data, 1 + 32 * 3 + 8 + 32 + 32 + 8 + 8 + 8, 0n); - - const accountB64 = Buffer.from(data).toString('base64'); - - globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { - const body = JSON.parse(init?.body as string) as { method?: string }; - switch (body.method) { - case 'simulateTransaction': - return rpcSuccess({ value: { err: null, logs: [] } }); - case 'sendTransaction': - return rpcSuccess(txSignature); - case 'getSignatureStatuses': - return rpcSuccess({ value: [{ confirmationStatus: 'confirmed', err: null }] }); - case 'getAccountInfo': - return rpcSuccess({ - value: { - data: [accountB64, 'base64'], - owner: SUBSCRIPTIONS_PROGRAM, - lamports: 0, - executable: false, - rentEpoch: 0, - }, - }); - default: - return rpcSuccess({}); - } +describe('settlement proof and replay', () => { + test('rejects signature/push activation before any RPC verification', async () => { + const serverSigner = await generateKeyPairSigner(); + let fetchCalls = 0; + globalThis.fetch = async () => { + fetchCalls += 1; + return rpcSuccess({}); }; - - const method = subscription({ - decimals: 6, - mint: MINT, - network: 'devnet', - periodCount: 30, - periodUnit: 'day', - planId: PLAN_ID, - puller: PULLER, - recipient: RECIPIENT, - rpcUrl: 'https://mock-rpc', - store: Store.memory(), - tokenProgram: TOKEN_PROGRAM, - }); - + const method = subscription(serverParameters(serverSigner, new AtomicStore())); await expect( method.verify!({ credential: { - challenge: { - id: 'test-challenge', - request: { - amount: '10000000', - currency: MINT, - methodDetails: { - decimals: 6, - mint: MINT, - network: 'devnet', - planId: PLAN_ID, - programId: SUBSCRIPTIONS_PROGRAM, - puller: PULLER, - tokenProgram: TOKEN_PROGRAM, - }, - periodCount: '30', - periodUnit: 'day', - recipient: RECIPIENT, - }, - }, - payload: { transaction, type: 'transaction' }, + challenge: { request: request(serverSigner.address) }, + payload: { signature: 'never-broadcast', type: 'signature' }, } as never, request: {} as never, }), - ).rejects.toThrow(/first-period charge/); - }); - - test('rejects type="signature" with feePayer=true', async () => { - globalThis.fetch = async () => rpcSuccess({}); - const method = subscription({ - decimals: 6, - mint: MINT, - periodCount: 30, - periodUnit: 'day', - planId: PLAN_ID, - puller: PULLER, - recipient: RECIPIENT, - tokenProgram: TOKEN_PROGRAM, - }); - await expect( - method.verify!({ - credential: { - challenge: { - request: { - amount: '10000000', - currency: MINT, - methodDetails: { - decimals: 6, - feePayer: true, - mint: MINT, - planId: PLAN_ID, - puller: PULLER, - tokenProgram: TOKEN_PROGRAM, - }, - periodCount: '30', - periodUnit: 'day', - recipient: RECIPIENT, - }, - }, - payload: { signature: 'abc', type: 'signature' }, - } as never, - request: {} as never, - }), - ).rejects.toThrow(/fee sponsorship/); + ).rejects.toThrow(/signature-mode activation is unsupported/); + expect(fetchCalls).toBe(0); }); - test('rejects an unknown payload type', async () => { - const method = subscription({ - decimals: 6, - mint: MINT, - periodCount: 30, - periodUnit: 'day', - planId: PLAN_ID, - puller: PULLER, - recipient: RECIPIENT, - tokenProgram: TOKEN_PROGRAM, - }); + test('does not treat an existing delegation as proof for a different never-broadcast activation', async () => { + const { authority, challengedRequest, delegation, serverSigner, subscriber, transaction } = await fixture(); + let sendCalls = 0; + globalThis.fetch = async (_input, init) => { + const body = JSON.parse(init?.body as string) as { method: string; params?: unknown[] }; + if (body.method === 'getAccountInfo') { + const account = String(body.params?.[0]); + if (account === authority) { + return rpcSuccess(accountInfo(authorityAccount(subscriber.address, serverSigner.address))); + } + if (account === delegation) { + return rpcSuccess(accountInfo(delegationAccount(subscriber.address, serverSigner.address))); + } + return rpcSuccess({ value: null }); + } + if (body.method === 'getSignatureStatuses') return rpcSuccess({ value: [null] }); + if (body.method === 'sendTransaction') sendCalls += 1; + return rpcSuccess({}); + }; + const method = subscription(serverParameters(serverSigner, new AtomicStore())); await expect( method.verify!({ - credential: { - challenge: { request: { methodDetails: {} } }, - payload: { type: 'mystery' }, - } as never, - request: {} as never, + credential: credential(transaction, challengedRequest) as never, + request: challengedRequest as never, }), - ).rejects.toThrow(/payload type/); + ).rejects.toThrow(/submitted activation signature was not confirmed/); + expect(sendCalls).toBe(0); }); - test('returns a successful receipt when on-chain state matches the challenge (pull mode)', async () => { - const { transaction, subscriberAddress } = await buildActivationTransactionBase64(); - const txSignature = '5J8KKfgKBLPDoCSk7B7TwAdSP3KtkfxYGYQH52SVgyM5XQXfeaG3xH8E3uYmGNLcoNNgWp3JjPdvzNwM4ZmJyREq'; - const data = new Uint8Array(1 + 32 * 3 + 8 + 32 + 32 + 8 + 8 + 8 + 8); - data.set(__testing.decodeBase58(subscriberAddress), 1); - data.set(__testing.decodeBase58(PLAN_ID), 1 + 32 * 3 + 8); - data.set(__testing.decodeBase58(MINT), 1 + 32 * 3 + 8 + 32); - writeU64Le(data, 1 + 32 * 3 + 8 + 32 + 32, 10_000_000n); - writeU64Le(data, 1 + 32 * 3 + 8 + 32 + 32 + 8, 720n); - writeU64Le(data, 1 + 32 * 3 + 8 + 32 + 32 + 8 + 8, 1737216000n); - writeU64Le(data, 1 + 32 * 3 + 8 + 32 + 32 + 8 + 8 + 8, 10_000_000n); - const accountB64 = Buffer.from(data).toString('base64'); - + test('preserves an RPC failure when pre-settlement reservation cleanup fails', async () => { + const { authority, challengedRequest, serverSigner, subscriber, transaction } = await fixture(); + const store = new DeleteFailingStore(); globalThis.fetch = async (_input, init) => { - const body = JSON.parse(init?.body as string) as { method?: string }; - switch (body.method) { - case 'simulateTransaction': - return rpcSuccess({ value: { err: null, logs: [] } }); - case 'sendTransaction': - return rpcSuccess(txSignature); - case 'getSignatureStatuses': - return rpcSuccess({ value: [{ confirmationStatus: 'confirmed', err: null }] }); - case 'getAccountInfo': - return rpcSuccess({ - value: { - data: [accountB64, 'base64'], - executable: false, - lamports: 0, - owner: SUBSCRIPTIONS_PROGRAM, - rentEpoch: 0, - }, - }); - default: - return rpcSuccess({}); + const body = JSON.parse(init?.body as string) as { method: string; params?: unknown[] }; + if (body.method === 'getAccountInfo') { + if (String(body.params?.[0]) === authority) { + return rpcSuccess(accountInfo(authorityAccount(subscriber.address, serverSigner.address))); + } + return rpcSuccess({ value: null }); } + if (body.method === 'simulateTransaction') return rpcError('simulation unavailable'); + return rpcSuccess({}); }; - const method = subscription({ - decimals: 6, - mint: MINT, - network: 'devnet', - periodCount: 30, - periodUnit: 'day', - planId: PLAN_ID, - puller: PULLER, - recipient: RECIPIENT, - rpcUrl: 'https://mock-rpc', - tokenProgram: TOKEN_PROGRAM, - }); - const receipt = await method.verify!({ - credential: { - challenge: { - id: 'test-challenge', - request: { - amount: '10000000', - currency: MINT, - externalId: 'order-99', - methodDetails: { - decimals: 6, - mint: MINT, - network: 'devnet', - planId: PLAN_ID, - programId: SUBSCRIPTIONS_PROGRAM, - puller: PULLER, - tokenProgram: TOKEN_PROGRAM, - }, - periodCount: '30', - periodUnit: 'day', - recipient: RECIPIENT, - }, - }, - payload: { transaction, type: 'transaction' }, - } as never, - request: {} as never, - }); - expect((receipt as { status: string }).status).toBe('success'); - }); + const method = subscription(serverParameters(serverSigner, store)); + const run = () => + method.verify!({ + credential: credential(transaction, challengedRequest) as never, + request: challengedRequest as never, + }); - test('rejects when on-chain delegation references a different plan', async () => { - const { transaction, subscriberAddress } = await buildActivationTransactionBase64(); - const wrongPlan = '11111111111111111111111111111111'; - const data = new Uint8Array(1 + 32 * 3 + 8 + 32 + 32 + 8 + 8 + 8 + 8); - data.set(__testing.decodeBase58(subscriberAddress), 1); - data.set(__testing.decodeBase58(wrongPlan), 1 + 32 * 3 + 8); - data.set(__testing.decodeBase58(MINT), 1 + 32 * 3 + 8 + 32); - writeU64Le(data, 1 + 32 * 3 + 8 + 32 + 32, 10_000_000n); - writeU64Le(data, 1 + 32 * 3 + 8 + 32 + 32 + 8, 720n); - writeU64Le(data, 1 + 32 * 3 + 8 + 32 + 32 + 8 + 8, 1737216000n); - writeU64Le(data, 1 + 32 * 3 + 8 + 32 + 32 + 8 + 8 + 8, 10_000_000n); - const accountB64 = Buffer.from(data).toString('base64'); + await expect(run()).rejects.toThrow(/RPC error: simulation unavailable/); + expect(store.deleteCalls).toBe(1); + await expect(run()).rejects.toThrow(/Activation signature already consumed/); + expect(store.deleteCalls).toBe(1); + }); + test('preserves a post-settlement terms failure when reservation cleanup fails', async () => { + const { authority, challengedRequest, delegation, serverSigner, subscriber, transaction } = await fixture(); + const store = new DeleteFailingStore(); + let landed = false; globalThis.fetch = async (_input, init) => { - const body = JSON.parse(init?.body as string) as { method?: string }; - switch (body.method) { - case 'simulateTransaction': - return rpcSuccess({ value: { err: null, logs: [] } }); - case 'sendTransaction': - return rpcSuccess('sigA'); - case 'getSignatureStatuses': - return rpcSuccess({ value: [{ confirmationStatus: 'confirmed', err: null }] }); - case 'getAccountInfo': - return rpcSuccess({ - value: { - data: [accountB64, 'base64'], - executable: false, - lamports: 0, - owner: SUBSCRIPTIONS_PROGRAM, - rentEpoch: 0, - }, - }); - default: - return rpcSuccess({}); + const body = JSON.parse(init?.body as string) as { method: string; params?: unknown[] }; + if (body.method === 'getAccountInfo') { + const account = String(body.params?.[0]); + if (account === authority) { + return rpcSuccess(accountInfo(authorityAccount(subscriber.address, serverSigner.address))); + } + if (account === delegation) { + return rpcSuccess( + landed + ? accountInfo(delegationAccount(subscriber.address, serverSigner.address, 1n)) + : { value: null }, + ); + } + return rpcSuccess({ value: null }); } + if (body.method === 'simulateTransaction') return rpcSuccess({ value: { err: null, logs: [] } }); + if (body.method === 'sendTransaction') { + landed = true; + const wire = String(body.params?.[0]); + const decoded = getTransactionDecoder().decode(getBase64Codec().encode(wire)); + return rpcSuccess(getSignatureFromTransaction(decoded)); + } + if (body.method === 'getSignatureStatuses') { + return rpcSuccess({ value: [{ confirmationStatus: 'confirmed', err: null }] }); + } + return rpcSuccess({}); }; - const method = subscription({ - decimals: 6, - mint: MINT, - network: 'devnet', - periodCount: 30, - periodUnit: 'day', - planId: PLAN_ID, - puller: PULLER, - recipient: RECIPIENT, - rpcUrl: 'https://mock-rpc', - tokenProgram: TOKEN_PROGRAM, - }); - await expect( + + const method = subscription(serverParameters(serverSigner, store)); + const run = () => method.verify!({ - credential: { - challenge: { - request: { - amount: '10000000', - currency: MINT, - methodDetails: { - decimals: 6, - mint: MINT, - planId: PLAN_ID, - programId: SUBSCRIPTIONS_PROGRAM, - puller: PULLER, - tokenProgram: TOKEN_PROGRAM, - }, - periodCount: '30', - periodUnit: 'day', - recipient: RECIPIENT, - }, - }, - payload: { transaction, type: 'transaction' }, - } as never, - request: {} as never, - }), - ).rejects.toThrow(/plan mismatch/); + credential: credential(transaction, challengedRequest) as never, + request: challengedRequest as never, + }); + + await expect(run()).rejects.toThrow(/SubscriptionDelegation amount mismatch/); + expect(store.deleteCalls).toBe(1); + await expect(run()).rejects.toThrow(/Activation signature already consumed/); + expect(store.deleteCalls).toBe(1); }); - test('rejects when SubscriptionDelegation account is absent after activation', async () => { - const { transaction } = await buildActivationTransactionBase64(); + test('two independent handlers sharing only an atomic store admit exactly one activation', async () => { + const { authority, challengedRequest, delegation, serverSigner, subscriber, transaction } = await fixture(); + const store = new AtomicStore(); + let landed = false; + let sendCalls = 0; globalThis.fetch = async (_input, init) => { - const body = JSON.parse(init?.body as string) as { method?: string }; - switch (body.method) { - case 'simulateTransaction': - return rpcSuccess({ value: { err: null, logs: [] } }); - case 'sendTransaction': - return rpcSuccess('sigA'); - case 'getSignatureStatuses': - return rpcSuccess({ value: [{ confirmationStatus: 'confirmed', err: null }] }); - case 'getAccountInfo': - return rpcSuccess({ value: null }); - default: - return rpcSuccess({}); + const body = JSON.parse(init?.body as string) as { method: string; params?: unknown[] }; + if (body.method === 'getAccountInfo') { + const account = String(body.params?.[0]); + if (account === authority) { + return rpcSuccess(accountInfo(authorityAccount(subscriber.address, serverSigner.address))); + } + if (account === delegation) { + return rpcSuccess( + landed + ? accountInfo(delegationAccount(subscriber.address, serverSigner.address)) + : { value: null }, + ); + } + return rpcSuccess({ value: null }); + } + if (body.method === 'simulateTransaction') return rpcSuccess({ value: { err: null, logs: [] } }); + if (body.method === 'sendTransaction') { + sendCalls += 1; + landed = true; + const wire = String(body.params?.[0]); + const decoded = getTransactionDecoder().decode(getBase64Codec().encode(wire)); + return rpcSuccess(getSignatureFromTransaction(decoded)); } + if (body.method === 'getSignatureStatuses') { + return rpcSuccess({ value: [{ confirmationStatus: 'confirmed', err: null }] }); + } + return rpcSuccess({}); }; - const method = subscription({ - decimals: 6, - mint: MINT, - network: 'devnet', - periodCount: 30, - periodUnit: 'day', - planId: PLAN_ID, - puller: PULLER, - recipient: RECIPIENT, - rpcUrl: 'https://mock-rpc', - tokenProgram: TOKEN_PROGRAM, - }); - await expect( + + const first = subscription(serverParameters(serverSigner, store)); + const second = subscription(serverParameters(serverSigner, store)); + const run = (method: ReturnType) => method.verify!({ - credential: { - challenge: { - request: { - amount: '10000000', - currency: MINT, - methodDetails: { - decimals: 6, - mint: MINT, - planId: PLAN_ID, - programId: SUBSCRIPTIONS_PROGRAM, - puller: PULLER, - tokenProgram: TOKEN_PROGRAM, - }, - periodCount: '30', - periodUnit: 'day', - recipient: RECIPIENT, - }, - }, - payload: { transaction, type: 'transaction' }, - } as never, - request: {} as never, - }), - ).rejects.toThrow(/SubscriptionDelegation account not found/); + credential: credential(transaction, challengedRequest) as never, + request: challengedRequest as never, + }); + const results = await Promise.allSettled([run(first), run(second)]); + const fulfilled = results.filter(result => result.status === 'fulfilled'); + expect(fulfilled).toHaveLength(1); + expect(results.filter(result => result.status === 'rejected')).toHaveLength(1); + expect(sendCalls).toBe(1); + expect(fulfilled[0]).toMatchObject({ + status: 'fulfilled', + value: { + challengeId: 'challenge-id', + externalId: 'invoice-42', + periodIndex: '0', + planId: PLAN_ID, + }, + }); + if (fulfilled[0]?.status !== 'fulfilled') throw new Error('expected one successful activation receipt'); + expect(fulfilled[0].value).toHaveProperty('subscriptionId'); + expect(fulfilled[0].value).toHaveProperty('periodStartTs'); + expect(fulfilled[0].value).toHaveProperty('periodEndTs'); }); +}); + +describe('cross-route subscription binding', () => { + test('serializes an explicit empty split list for credential binding', async () => { + const serverSigner = await generateKeyPairSigner(); + const gate = Mppx.create({ + methods: [subscription({ ...serverParameters(serverSigner, new AtomicStore()), splits: [] })], + realm: 'subscription-empty-splits', + secretKey: 'subscription-empty-splits-secret', + }); + + const issued = await gate.subscription({ + amount: '10000000', + currency: MINT, + expires: new Date(Date.now() + 60_000).toISOString(), + })(new Request('https://example.test/gate')); - test('rejects on simulation failure', async () => { - const { transaction } = await buildActivationTransactionBase64(); - const errorSpy = ((): { calls: number; restore: () => void } => { - const original = console.error; - let calls = 0; - console.error = () => { - calls += 1; - }; - return { - calls, - restore: () => { - console.error = original; + expect(issued.status).toBe(402); + if (issued.status !== 402) throw new Error('expected subscription challenge'); + expect((Challenge.fromResponse(issued.challenge).request.methodDetails as { splits?: unknown }).splits).toEqual( + [], + ); + }); + + test('rejects a valid gate-A credential before RPC when gate-B subscription fields diverge', async () => { + const { serverSigner, transaction } = await fixture(); + const secretKey = 'subscription-binding-test-secret'; + const expires = new Date(Date.now() + 60_000).toISOString(); + let fetchCalls = 0; + globalThis.fetch = async () => { + fetchCalls += 1; + return rpcSuccess({}); + }; + + const gateA = Mppx.create({ + methods: [subscription(serverParameters(serverSigner, new AtomicStore()))], + realm: 'subscription-binding-test', + secretKey, + }); + const issued = await gateA.subscription({ + amount: '10000000', + currency: MINT, + expires, + externalId: 'invoice-42', + resource: '/gate-a', + })(new Request('https://example.test/gate-a')); + expect(issued.status).toBe(402); + if (issued.status !== 402) throw new Error('expected subscription challenge'); + + const credentialFromGateA = Credential.from({ + challenge: Challenge.fromResponse(issued.challenge), + payload: { transaction, type: 'transaction' }, + }); + fetchCalls = 0; + + const alternateFeePayer = await generateKeyPairSigner(); + const variants = [ + { + label: 'plan', + parameters: { + ...serverParameters(serverSigner, new AtomicStore()), + planId: RECIPIENT, + planIdNumeric: 8n, }, - }; - })(); - try { - globalThis.fetch = async (_input, init) => { - const body = JSON.parse(init?.body as string) as { method?: string }; - if (body.method === 'simulateTransaction') { - return rpcSuccess({ value: { err: { InstructionError: [1, 'Custom'] }, logs: ['log line'] } }); - } - return rpcSuccess({}); - }; - const method = subscription({ - decimals: 6, - mint: MINT, - network: 'devnet', - periodCount: 30, - periodUnit: 'day', - planId: PLAN_ID, - puller: PULLER, - recipient: RECIPIENT, - rpcUrl: 'https://mock-rpc', - tokenProgram: TOKEN_PROGRAM, + }, + { + label: 'period', + parameters: { ...serverParameters(serverSigner, new AtomicStore()), periodCount: 1 }, + }, + { + label: 'puller and fee payer', + parameters: { + ...serverParameters(alternateFeePayer, new AtomicStore()), + puller: alternateFeePayer.address, + }, + }, + { + label: 'token program', + parameters: { ...serverParameters(serverSigner, new AtomicStore()), tokenProgram: TOKEN_2022_PROGRAM }, + }, + { + label: 'subscription program', + parameters: { ...serverParameters(serverSigner, new AtomicStore()), programId: RECIPIENT }, + }, + { + label: 'external id', + parameters: serverParameters(serverSigner, new AtomicStore()), + request: { externalId: 'invoice-43' }, + }, + { + label: 'resource', + parameters: serverParameters(serverSigner, new AtomicStore()), + request: { resource: '/gate-b' }, + }, + ]; + + for (const variant of variants) { + const gateB = Mppx.create({ + methods: [subscription(variant.parameters)], + realm: 'subscription-binding-test', + secretKey, }); - await expect( - method.verify!({ - credential: { - challenge: { - request: { - amount: '10000000', - currency: MINT, - methodDetails: { - decimals: 6, - mint: MINT, - planId: PLAN_ID, - programId: SUBSCRIPTIONS_PROGRAM, - puller: PULLER, - tokenProgram: TOKEN_PROGRAM, - }, - periodCount: '30', - periodUnit: 'day', - recipient: RECIPIENT, - }, - }, - payload: { transaction, type: 'transaction' }, - } as never, - request: {} as never, + const result = await gateB.subscription({ + amount: '10000000', + currency: MINT, + expires, + externalId: variant.request?.externalId ?? 'invoice-42', + resource: variant.request?.resource ?? '/gate-a', + })( + new Request('https://example.test/gate-b', { + headers: { Authorization: Credential.serialize(credentialFromGateA) }, }), - ).rejects.toThrow(/simulation failed/); - } finally { - errorSpy.restore(); + ); + + expect(result.status, variant.label).toBe(402); + expect(fetchCalls, variant.label).toBe(0); } }); - test('rejects on broadcast RPC error', async () => { - const { transaction } = await buildActivationTransactionBase64(); + test('uses the HMAC-bound resource to bind routes, not the top-level description', async () => { + const { authority, delegation, serverSigner, subscriber } = await fixture(); + const secretKey = 'subscription-description-binding-test-secret'; + const expires = new Date(Date.now() + 60_000).toISOString(); + let fetchCalls = 0; + let landed = false; globalThis.fetch = async (_input, init) => { - const body = JSON.parse(init?.body as string) as { method?: string }; - if (body.method === 'simulateTransaction') { - return rpcSuccess({ value: { err: null, logs: [] } }); + fetchCalls += 1; + const body = JSON.parse(init?.body as string) as { method: string; params?: unknown[] }; + if (body.method === 'getLatestBlockhash') return rpcSuccess({ value: { blockhash: BLOCKHASH } }); + if (body.method === 'getAccountInfo') { + const account = String(body.params?.[0]); + if (account === authority) { + return rpcSuccess(accountInfo(authorityAccount(subscriber.address, serverSigner.address))); + } + if (account === delegation) { + return rpcSuccess( + landed + ? accountInfo(delegationAccount(subscriber.address, serverSigner.address)) + : { value: null }, + ); + } + return rpcSuccess({ value: null }); } + if (body.method === 'simulateTransaction') return rpcSuccess({ value: { err: null, logs: [] } }); if (body.method === 'sendTransaction') { - return new Response( - JSON.stringify({ jsonrpc: '2.0', id: 1, error: { message: 'simulation rejected' } }), - { - headers: { 'Content-Type': 'application/json' }, - }, - ); + landed = true; + const wire = String(body.params?.[0]); + const decoded = getTransactionDecoder().decode(getBase64Codec().encode(wire)); + return rpcSuccess(getSignatureFromTransaction(decoded)); + } + if (body.method === 'getSignatureStatuses') { + return rpcSuccess({ value: [{ confirmationStatus: 'confirmed', err: null }] }); } return rpcSuccess({}); }; - const method = subscription({ - decimals: 6, - mint: MINT, - network: 'devnet', - periodCount: 30, - periodUnit: 'day', - planId: PLAN_ID, - puller: PULLER, - recipient: RECIPIENT, - rpcUrl: 'https://mock-rpc', - tokenProgram: TOKEN_PROGRAM, - }); - await expect( - method.verify!({ - credential: { - challenge: { - request: { - amount: '10000000', - currency: MINT, - methodDetails: { - decimals: 6, - mint: MINT, - planId: PLAN_ID, - programId: SUBSCRIPTIONS_PROGRAM, - puller: PULLER, - tokenProgram: TOKEN_PROGRAM, - }, - periodCount: '30', - periodUnit: 'day', - recipient: RECIPIENT, - }, - }, - payload: { transaction, type: 'transaction' }, - } as never, - request: {} as never, - }), - ).rejects.toThrow(/RPC error/); - }); - test('rejects when push-mode signature is replayed (consumed)', async () => { - const store = Store.memory(); - const sig = 'replayedSig0000000000000000000000000000000000'; - await store.put(`solana-subscription:consumed:${sig}`, true); - globalThis.fetch = async () => rpcSuccess({}); - const method = subscription({ - decimals: 6, - mint: MINT, - network: 'devnet', - periodCount: 30, - periodUnit: 'day', - planId: PLAN_ID, - puller: PULLER, - recipient: RECIPIENT, - rpcUrl: 'https://mock-rpc', - store, - tokenProgram: TOKEN_PROGRAM, + const gateA = Mppx.create({ + methods: [subscription(serverParameters(serverSigner, new AtomicStore()))], + realm: 'subscription-description-binding-test', + secretKey, }); - await expect( - method.verify!({ - credential: { - challenge: { - request: { - amount: '10000000', - currency: MINT, - methodDetails: { - decimals: 6, - mint: MINT, - planId: PLAN_ID, - programId: SUBSCRIPTIONS_PROGRAM, - puller: PULLER, - tokenProgram: TOKEN_PROGRAM, - }, - periodCount: '30', - periodUnit: 'day', - recipient: RECIPIENT, - }, - }, - payload: { signature: sig, type: 'signature' }, - } as never, - request: {} as never, + const routeA = { + amount: '10000000', + currency: MINT, + description: 'subscription access for team A', + expires, + resource: '/subscriptions/team-a', + }; + const issued = await gateA.subscription(routeA)(new Request('https://example.test/gate-a')); + + expect(issued.status).toBe(402); + if (issued.status !== 402) throw new Error('expected subscription challenge'); + const challenge = Challenge.fromResponse(issued.challenge); + const transaction = await buildSubscriptionActivationTransaction({ + request: challenge.request as Parameters[0]['request'], + signer: subscriber, + subscriptionAuthorityInitId: INIT_ID, + }); + const credentialFromGateA = Credential.from({ + challenge, + payload: { transaction, type: 'transaction' }, + }); + + fetchCalls = 0; + const sameRoute = await gateA.subscription(routeA)( + new Request('https://example.test/gate-a', { + headers: { Authorization: Credential.serialize(credentialFromGateA) }, }), - ).rejects.toThrow(/already consumed/); - }); + ); + expect(sameRoute.status).toBe(200); + expect(fetchCalls).toBeGreaterThan(0); - test('rejects when push-mode getTransaction returns null', async () => { - globalThis.fetch = async (_input, init) => { - const body = JSON.parse(init?.body as string) as { method?: string }; - if (body.method === 'getTransaction') return rpcSuccess(null); - return rpcSuccess({}); - }; - const method = subscription({ - decimals: 6, - mint: MINT, - network: 'devnet', - periodCount: 30, - periodUnit: 'day', - planId: PLAN_ID, - puller: PULLER, - recipient: RECIPIENT, - rpcUrl: 'https://mock-rpc', - tokenProgram: TOKEN_PROGRAM, + const gateB = Mppx.create({ + methods: [subscription(serverParameters(serverSigner, new AtomicStore()))], + realm: 'subscription-description-binding-test', + secretKey, }); - await expect( - method.verify!({ + const { resource: _resource, ...requestWithoutResource } = credentialFromGateA.challenge.request; + const credentialVariants = [ + { credential: credentialFromGateA, label: 'original credential' }, + { + credential: { + ...credentialFromGateA, + challenge: { ...credentialFromGateA.challenge, description: 'subscription access for team B' }, + }, + label: 'mutated description', + }, + { + credential: { + ...credentialFromGateA, + challenge: { ...credentialFromGateA.challenge, description: undefined }, + }, + label: 'stripped description', + }, + { credential: { + ...credentialFromGateA, challenge: { - request: { - amount: '10000000', - currency: MINT, - methodDetails: { - decimals: 6, - mint: MINT, - planId: PLAN_ID, - programId: SUBSCRIPTIONS_PROGRAM, - puller: PULLER, - tokenProgram: TOKEN_PROGRAM, - }, - periodCount: '30', - periodUnit: 'day', - recipient: RECIPIENT, - }, + ...credentialFromGateA.challenge, + request: { ...credentialFromGateA.challenge.request, resource: '/subscriptions/team-b' }, }, - payload: { signature: 'newsig', type: 'signature' }, - } as never, - request: {} as never, - }), - ).rejects.toThrow(/not found/); - }); + }, + label: 'mutated resource', + }, + { + credential: { + ...credentialFromGateA, + challenge: { ...credentialFromGateA.challenge, request: requestWithoutResource }, + }, + label: 'stripped resource', + }, + ]; + + for (const variant of credentialVariants) { + fetchCalls = 0; + const result = await gateB.subscription({ + amount: '10000000', + currency: MINT, + description: 'subscription access for team B', + expires, + resource: '/subscriptions/team-b', + })( + new Request('https://example.test/gate-b', { + headers: { Authorization: Credential.serialize(variant.credential) }, + }), + ); - function writeU64Le(buf: Uint8Array, offset: number, value: bigint) { - for (let i = 0; i < 8; i += 1) { - buf[offset + i] = Number((value >> BigInt(i * 8)) & 0xffn); + expect(result.status, variant.label).toBe(402); + expect(fetchCalls, variant.label).toBe(0); } - } + }); }); + +function rpcSuccess(result: unknown) { + return new Response(JSON.stringify({ id: 1, jsonrpc: '2.0', result }), { + headers: { 'Content-Type': 'application/json' }, + }); +} + +function rpcError(message: string) { + return new Response(JSON.stringify({ error: { message }, id: 1, jsonrpc: '2.0' }), { + headers: { 'Content-Type': 'application/json' }, + }); +} diff --git a/typescript/packages/mpp/src/__tests__/subscription.test.ts b/typescript/packages/mpp/src/__tests__/subscription.test.ts index 3b1c820ff..c82c7419f 100644 --- a/typescript/packages/mpp/src/__tests__/subscription.test.ts +++ b/typescript/packages/mpp/src/__tests__/subscription.test.ts @@ -33,8 +33,13 @@ describe('Methods.subscription', () => { currency: MINT, methodDetails: { decimals: 6, + expectedCreatedAt: '1700000000', + expectedPeriodHours: '720', + merchant: PULLER, mint: MINT, + planBump: 254, planId: PLAN_ID, + planIdNumeric: '7', puller: PULLER, tokenProgram: TOKEN_PROGRAM, }, @@ -51,8 +56,13 @@ describe('Methods.subscription', () => { currency: MINT, methodDetails: { decimals: 6, + expectedCreatedAt: '1700000000', + expectedPeriodHours: '336', + merchant: PULLER, mint: MINT, + planBump: 254, planId: PLAN_ID, + planIdNumeric: '7', puller: PULLER, tokenProgram: TOKEN_PROGRAM, }, diff --git a/typescript/packages/mpp/src/client/Methods.ts b/typescript/packages/mpp/src/client/Methods.ts index b883bea80..082ae7e0e 100644 --- a/typescript/packages/mpp/src/client/Methods.ts +++ b/typescript/packages/mpp/src/client/Methods.ts @@ -1,7 +1,11 @@ import { selectSolanaChargeChallenge } from './ChallengeSelection.js'; import { buildChargeTransaction, charge as charge_ } from './Charge.js'; import { session as session_ } from './Session.js'; -import { buildSubscriptionActivationTransaction, subscription as subscription_ } from './Subscription.js'; +import { + buildSubscriptionActivationTransaction, + initializeSubscriptionAuthority, + subscription as subscription_, +} from './Subscription.js'; /** * Creates a Solana `charge` method for usage on the client. @@ -24,6 +28,7 @@ export const solana: { buildChargeTransaction: typeof buildChargeTransaction; buildSubscriptionActivationTransaction: typeof buildSubscriptionActivationTransaction; charge: typeof charge_; + initializeSubscriptionAuthority: typeof initializeSubscriptionAuthority; selectChargeChallenge: typeof selectSolanaChargeChallenge; session: typeof session_; subscription: typeof subscription_; @@ -31,6 +36,7 @@ export const solana: { buildChargeTransaction, buildSubscriptionActivationTransaction, charge: charge_, + initializeSubscriptionAuthority, selectChargeChallenge: selectSolanaChargeChallenge, session: session_, subscription: subscription_, diff --git a/typescript/packages/mpp/src/client/Subscription.ts b/typescript/packages/mpp/src/client/Subscription.ts index 43b09c344..fca831666 100644 --- a/typescript/packages/mpp/src/client/Subscription.ts +++ b/typescript/packages/mpp/src/client/Subscription.ts @@ -7,6 +7,7 @@ import { type Blockhash, createSolanaRpc, createTransactionMessage, + getBase64Codec, getBase64EncodedWireTransaction, type Instruction, partiallySignTransactionMessageWithSigners, @@ -18,19 +19,24 @@ import { type TransactionSigner, } from '@solana/kit'; import { getSetComputeUnitLimitInstruction, getSetComputeUnitPriceInstruction } from '@solana-program/compute-budget'; -import { findAssociatedTokenPda } from '@solana-program/token'; +import { findAssociatedTokenPda, getCreateAssociatedTokenIdempotentInstruction } from '@solana-program/token'; import { Credential, Method } from 'mppx'; import { DEFAULT_RPC_URLS, MEMO_PROGRAM, normalizeNetwork, - SUBSCRIPTIONS_INIT_AUTHORITY_DISCRIMINATOR, + stablecoinSymbolForCurrency, SUBSCRIPTIONS_PROGRAM, - SUBSCRIPTIONS_SUBSCRIBE_DISCRIMINATOR, - SUBSCRIPTIONS_TRANSFER_DISCRIMINATOR, SYSTEM_PROGRAM, + TOKEN_2022_PROGRAM, + TOKEN_PROGRAM, } from '../constants.js'; +import { getSubscriptionAuthorityDecoder } from '../generated/subscriptions/accounts/subscriptionAuthority.js'; +import { getInitSubscriptionAuthorityInstructionAsync } from '../generated/subscriptions/instructions/initSubscriptionAuthority.js'; +import { getSubscribeInstructionAsync } from '../generated/subscriptions/instructions/subscribe.js'; +import { getTransferSubscriptionInstruction } from '../generated/subscriptions/instructions/transferSubscription.js'; +import { findEventAuthorityPda } from '../generated/subscriptions/pdas/eventAuthority.js'; import * as Methods from '../Methods.js'; import { assertPeriodHoursInRange, @@ -39,70 +45,46 @@ import { mapSubscriptionPeriodToHours, } from '../shared/subscription.js'; -/** - * Creates a Solana `subscription` method for usage on the client. - * - * Builds the activation transaction (initialize_subscription_authority if - * needed, subscribe, transfer_subscription) and signs as the subscriber. - * When `feePayer: true` is advertised in the challenge, the server's - * `feePayerKey` is used as fee payer and the transaction is partially - * signed; the server completes the signature before broadcasting. - * - * @example - * ```ts - * import { Mppx, solana } from '@solana/mpp/client' - * - * const method = solana.subscription({ signer, rpcUrl: 'https://api.devnet.solana.com' }) - * const mppx = Mppx.create({ methods: [method] }) - * - * const response = await mppx.fetch('https://api.example.com/paid-content') - * ``` - */ export function subscription(parameters: subscription.Parameters) { - const { signer, broadcast = false, onProgress } = parameters; + const { signer, onProgress } = parameters; + if ((parameters as { broadcast?: boolean }).broadcast === true) { + throw new Error('Subscription push activation is unsupported; use broadcast=false'); + } - const method = Method.toClient(Methods.subscription, { + return Method.toClient(Methods.subscription, { async createCredential({ challenge }) { - const { methodDetails } = challenge.request; - const { network, feePayer: serverPaysFees } = methodDetails; - - if (serverPaysFees && broadcast) { - throw new Error('broadcast=true cannot be used with fee sponsorship (feePayer: true)'); + const rpcUrl = resolveSubscriptionRpcUrl(parameters.rpcUrl, challenge.request.methodDetails.network); + const subscriptionAuthorityInitId = + parameters.subscriptionAuthorityInitId ?? + (parameters.initializeSubscriptionAuthority + ? await initializeSubscriptionAuthority({ + allowUnknownToken2022: parameters.allowUnknownToken2022, + mint: challenge.request.methodDetails.mint, + programId: challenge.request.methodDetails.programId, + rpcUrl, + signer, + tokenProgram: challenge.request.methodDetails.tokenProgram, + }) + : undefined); + const refreshBlockhash = + parameters.initializeSubscriptionAuthority === true && + parameters.subscriptionAuthorityInitId === undefined; + if (subscriptionAuthorityInitId === undefined) { + throw new Error( + 'subscriptionAuthorityInitId is required unless initializeSubscriptionAuthority=true is explicitly enabled', + ); } - const encodedTx = await buildSubscriptionActivationTransaction({ + allowUnknownToken2022: parameters.allowUnknownToken2022, computeUnitLimit: parameters.computeUnitLimit, computeUnitPrice: parameters.computeUnitPrice, onProgress, + ...(refreshBlockhash ? { refreshBlockhash: true } : {}), request: challenge.request, - rpcUrl: - parameters.rpcUrl ?? - DEFAULT_RPC_URLS[normalizeNetwork(network ?? 'mainnet')] ?? - DEFAULT_RPC_URLS.mainnet, + rpcUrl, signer, + subscriptionAuthorityInitId, }); - - const rpc = createSolanaRpc( - parameters.rpcUrl ?? - DEFAULT_RPC_URLS[normalizeNetwork(network ?? 'mainnet')] ?? - DEFAULT_RPC_URLS.mainnet, - ); - - if (broadcast) { - onProgress?.({ type: 'paying' }); - const signature = await rpc - .sendTransaction(encodedTx, { encoding: 'base64', skipPreflight: false }) - .send(); - onProgress?.({ signature, type: 'confirming' }); - await confirmTransaction(rpc, signature); - onProgress?.({ signature, type: 'activated' }); - - return Credential.serialize({ - challenge, - payload: { signature, type: 'signature' }, - }); - } - onProgress?.({ transaction: encodedTx, type: 'signed' }); return Credential.serialize({ challenge, @@ -110,82 +92,79 @@ export function subscription(parameters: subscription.Parameters) { }); }, }); - - return method; } /** - * Build and sign the activation transaction for a Solana subscription challenge. - * - * The transaction layout matches the spec's required ordering: + * Build and sign a canonical subscription activation transaction. * - * [ComputeBudgetSetUnitPrice, ComputeBudgetSetUnitLimit, - * initialize_subscription_authority?, - * subscribe, - * transfer_subscription, - * memo(externalId)?] + * This function never broadcasts. The caller must explicitly initialize the + * SubscriptionAuthority first and pass its live init id. */ export async function buildSubscriptionActivationTransaction( parameters: buildSubscriptionActivationTransaction.Parameters, ): Promise { const { signer, + subscriptionAuthorityInitId, request: { amount, externalId, recipient, methodDetails, periodCount, periodUnit }, onProgress, } = parameters; const { - network, + expectedCreatedAt, + expectedPeriodHours, + feePayer, + feePayerKey, + merchant, mint, + network, + planBump, planId, + planIdNumeric, programId = SUBSCRIPTIONS_PROGRAM, + puller, + recentBlockhash, tokenProgram, - feePayer: serverPaysFees, - feePayerKey, - recentBlockhash: serverBlockhash, } = methodDetails; + if (!feePayer || !feePayerKey) { + throw new Error('Canonical subscription activation requires feePayer=true and feePayerKey'); + } + if (feePayerKey !== puller) { + throw new Error('feePayerKey must equal puller so the server signs the canonical transfer caller'); + } + const periodHours = mapSubscriptionPeriodToHours(periodUnit, Number(periodCount)); assertPeriodHoursInRange(periodHours); - - const rpcUrl = - parameters.rpcUrl ?? DEFAULT_RPC_URLS[normalizeNetwork(network ?? 'mainnet')] ?? DEFAULT_RPC_URLS.mainnet; - const rpc = createSolanaRpc(rpcUrl); - - onProgress?.({ - amount, - mint, - periodHours, - planId, - recipient, - type: 'challenge', - }); - - if (serverPaysFees && !feePayerKey) { - throw new Error('feePayer=true requires feePayerKey in methodDetails'); + if (BigInt(expectedPeriodHours) !== BigInt(periodHours)) { + throw new Error('methodDetails.expectedPeriodHours does not match periodCount/periodUnit'); } - const useServerFeePayer = serverPaysFees === true; + assertTrustedSubscriptionTokenProgram(mint, tokenProgram, parameters.allowUnknownToken2022); - const subscriberAddress = signer.address; + const rpcUrl = resolveSubscriptionRpcUrl(parameters.rpcUrl, network); + const rpc = createSolanaRpc(rpcUrl); const mintAddress = address(mint); const planPda = address(planId); const programAddress = address(programId); const tokenProgramAddress = address(tokenProgram); const recipientAddress = address(recipient); + const serverSignerAddress = address(feePayerKey); + + onProgress?.({ amount, mint, periodHours, planId, recipient, type: 'challenge' }); const subscriptionAuthority = await deriveSubscriptionAuthorityPda({ mint: mintAddress, programId: programAddress, - subscriber: subscriberAddress, + subscriber: signer.address, }); const subscriptionPda = await deriveSubscriptionPda({ planPda, programId: programAddress, - subscriber: subscriberAddress, + subscriber: signer.address, }); - + const [eventAuthority] = await findEventAuthorityPda({ programAddress }); const [subscriberAta] = await findAssociatedTokenPda({ mint: mintAddress, - owner: subscriberAddress, + owner: signer.address, tokenProgram: tokenProgramAddress, }); const [recipientAta] = await findAssociatedTokenPda({ @@ -194,179 +173,227 @@ export async function buildSubscriptionActivationTransaction( tokenProgram: tokenProgramAddress, }); - const authorityExists = await checkAccountExists(rpc, subscriptionAuthority); - - const instructions: Instruction[] = []; - - if (!authorityExists) { - instructions.push( - buildInitSubscriptionAuthorityInstruction({ - ata: subscriberAta, - mint: mintAddress, - programAddress, - subscriber: subscriberAddress, - subscriptionAuthority, - tokenProgram: tokenProgramAddress, - }), - ); - } - - instructions.push( - buildSubscribeInstruction({ - payer: useServerFeePayer && feePayerKey ? address(feePayerKey) : subscriberAddress, - planPda, - programAddress, - subscriber: subscriberAddress, - subscriptionAuthority, - subscriptionPda, + const remoteServerSigner = remoteSigner(serverSignerAddress); + const subscriberAtaIx = stripRemoteSigner( + getCreateAssociatedTokenIdempotentInstruction({ + ata: subscriberAta, + mint: mintAddress, + owner: signer.address, + payer: remoteServerSigner, + tokenProgram: tokenProgramAddress, }), + serverSignerAddress, ); - - instructions.push( - buildTransferSubscriptionInstruction({ + const recipientAtaIx = stripRemoteSigner( + getCreateAssociatedTokenIdempotentInstruction({ + ata: recipientAta, mint: mintAddress, - planPda, - programAddress, - puller: methodDetails.puller ? address(methodDetails.puller) : subscriberAddress, - recipientAta, - subscriber: subscriberAddress, - subscriberAta, - subscriptionAuthority, - subscriptionPda, + owner: recipientAddress, + payer: remoteServerSigner, tokenProgram: tokenProgramAddress, }), + serverSignerAddress, ); - if (externalId) { - instructions.push(buildMemoInstruction(externalId)); - } + const generatedSubscribe = await getSubscribeInstructionAsync( + { + eventAuthority, + merchant: address(merchant), + planPda, + selfProgram: programAddress, + subscribeData: { + expectedAmount: BigInt(amount), + expectedCreatedAt: BigInt(expectedCreatedAt), + expectedMint: mintAddress, + expectedPeriodHours: BigInt(expectedPeriodHours), + expectedSubscriptionAuthorityInitId: subscriptionAuthorityInitId, + planBump, + planId: BigInt(planIdNumeric), + }, + subscriber: signer, + subscriptionAuthorityPda: subscriptionAuthority, + subscriptionPda, + systemProgram: address(SYSTEM_PROGRAM), + }, + { programAddress }, + ); + const subscribeIx: Instruction = { + ...generatedSubscribe, + accounts: [...generatedSubscribe.accounts, { address: serverSignerAddress, role: AccountRole.WRITABLE_SIGNER }], + }; - onProgress?.({ type: 'signing' }); + const transferIx = stripRemoteSigner( + getTransferSubscriptionInstruction( + { + caller: remoteServerSigner, + delegatorAta: subscriberAta, + eventAuthority, + planPda, + receiverAta: recipientAta, + selfProgram: programAddress, + subscriptionAuthority, + subscriptionPda, + tokenMint: mintAddress, + tokenProgram: tokenProgramAddress, + transferData: { + amount: BigInt(amount), + delegator: signer.address, + mint: mintAddress, + }, + }, + { programAddress }, + ), + serverSignerAddress, + ); - const latestBlockhash = serverBlockhash - ? { blockhash: serverBlockhash as Blockhash, lastValidBlockHeight: 0n } - : (await rpc.getLatestBlockhash().send()).value; + const instructions: Instruction[] = [subscriberAtaIx, recipientAtaIx, subscribeIx, transferIx]; + if (externalId) instructions.push(buildMemoInstruction(externalId)); + onProgress?.({ type: 'signing' }); + const latestBlockhash = + !parameters.refreshBlockhash && recentBlockhash + ? { blockhash: recentBlockhash as Blockhash, lastValidBlockHeight: 0n } + : (await rpc.getLatestBlockhash().send()).value; const txMessage = pipe( createTransactionMessage({ version: 0 }), - msg => - useServerFeePayer - ? setTransactionMessageFeePayer(address(feePayerKey!), msg) - : setTransactionMessageFeePayerSigner(signer, msg), + msg => setTransactionMessageFeePayer(serverSignerAddress, msg), msg => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, msg), msg => appendTransactionMessageInstructions(instructions, msg), msg => prependTransactionMessageInstructions( [ getSetComputeUnitPriceInstruction({ microLamports: parameters.computeUnitPrice ?? 1n }), - getSetComputeUnitLimitInstruction({ units: parameters.computeUnitLimit ?? 400_000 }), + getSetComputeUnitLimitInstruction({ units: parameters.computeUnitLimit ?? 200_000 }), ], msg, ), ); + return getBase64EncodedWireTransaction(await partiallySignTransactionMessageWithSigners(txMessage)); +} - const signedTx = useServerFeePayer - ? await partiallySignTransactionMessageWithSigners(txMessage) - : await partiallySignTransactionMessageWithSigners(txMessage); +/** + * Explicitly initialize (or read) the subscriber's SubscriptionAuthority and + * return its live init id. This is the only subscription client API that + * broadcasts implicitly as part of its explicitly requested operation. + */ +export async function initializeSubscriptionAuthority( + parameters: initializeSubscriptionAuthority.Parameters, +): Promise { + const { allowUnknownToken2022 = false, mint, signer, programId = SUBSCRIPTIONS_PROGRAM, tokenProgram } = parameters; + assertTrustedSubscriptionTokenProgram(mint, tokenProgram, allowUnknownToken2022); + const rpcUrl = resolveSubscriptionRpcUrl(parameters.rpcUrl, parameters.network); + const rpc = createSolanaRpc(rpcUrl); + const mintAddress = address(mint); + const programAddress = address(programId); + const tokenProgramAddress = address(tokenProgram); + const subscriptionAuthority = await deriveSubscriptionAuthorityPda({ + mint: mintAddress, + programId: programAddress, + subscriber: signer.address, + }); - return getBase64EncodedWireTransaction(signedTx); + const existing = await fetchSubscriptionAuthorityInitId(rpc, subscriptionAuthority); + if (existing !== null) return existing; + + const [subscriberAta] = await findAssociatedTokenPda({ + mint: mintAddress, + owner: signer.address, + tokenProgram: tokenProgramAddress, + }); + const createAtaIx = getCreateAssociatedTokenIdempotentInstruction({ + ata: subscriberAta, + mint: mintAddress, + owner: signer.address, + payer: signer, + tokenProgram: tokenProgramAddress, + }); + const initIx = await getInitSubscriptionAuthorityInstructionAsync( + { + owner: signer, + subscriptionAuthority, + tokenMint: mintAddress, + tokenProgram: tokenProgramAddress, + userAta: subscriberAta, + }, + { programAddress }, + ); + const latestBlockhash = (await rpc.getLatestBlockhash().send()).value; + const message = pipe( + createTransactionMessage({ version: 0 }), + msg => setTransactionMessageFeePayerSigner(signer, msg), + msg => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, msg), + msg => appendTransactionMessageInstructions([createAtaIx, initIx], msg), + ); + const encoded = getBase64EncodedWireTransaction(await partiallySignTransactionMessageWithSigners(message)); + const signature = await rpc.sendTransaction(encoded, { encoding: 'base64', skipPreflight: false }).send(); + await confirmTransaction(rpc, signature); + + const initialized = await fetchSubscriptionAuthorityInitId(rpc, subscriptionAuthority); + if (initialized === null) { + throw new Error('Subscription authority account still missing after explicit initialization'); + } + return initialized; } -// ── Instruction builders (v0, hand-rolled) ── -// -// These build the subscriptions program's instructions by inlining -// account orders and discriminator bytes. A follow-up should replace -// them with the Codama-generated overlay instructions exported by the -// `subscriptions` client package. +function resolveSubscriptionRpcUrl(rpcUrl?: string, network = 'mainnet'): string { + return rpcUrl ?? DEFAULT_RPC_URLS[normalizeNetwork(network)] ?? DEFAULT_RPC_URLS.mainnet; +} -function buildInitSubscriptionAuthorityInstruction(params: { - ata: Address; - mint: Address; - programAddress: Address; - subscriber: Address; - subscriptionAuthority: Address; - tokenProgram: Address; -}): Instruction { - return { - accounts: [ - { address: params.subscriber, role: AccountRole.WRITABLE_SIGNER }, - { address: params.subscriptionAuthority, role: AccountRole.WRITABLE }, - { address: params.mint, role: AccountRole.READONLY }, - { address: params.ata, role: AccountRole.WRITABLE }, - { address: params.tokenProgram, role: AccountRole.READONLY }, - { address: address(SYSTEM_PROGRAM), role: AccountRole.READONLY }, - ], - data: new Uint8Array([SUBSCRIPTIONS_INIT_AUTHORITY_DISCRIMINATOR]), - programAddress: params.programAddress, - }; +function assertTrustedSubscriptionTokenProgram( + mint: string, + tokenProgram: string, + allowUnknownToken2022 = false, +): void { + if (tokenProgram !== TOKEN_PROGRAM && tokenProgram !== TOKEN_2022_PROGRAM) { + throw new Error(`Unsupported token program: ${tokenProgram}`); + } + if ( + tokenProgram === TOKEN_2022_PROGRAM && + stablecoinSymbolForCurrency(mint) === undefined && + !allowUnknownToken2022 + ) { + throw new Error( + 'Refusing to sign an unknown Token-2022 mint (transfer-hook risk). ' + + 'Set allowUnknownToken2022: true to override.', + ); + } } -function buildSubscribeInstruction(params: { - payer: Address; - planPda: Address; - programAddress: Address; - subscriber: Address; - subscriptionAuthority: Address; - subscriptionPda: Address; -}): Instruction { +function remoteSigner(remoteAddress: Address): TransactionSigner { return { - accounts: [ - { address: params.subscriber, role: AccountRole.WRITABLE_SIGNER }, - { address: params.payer, role: AccountRole.WRITABLE_SIGNER }, - { address: params.planPda, role: AccountRole.READONLY }, - { address: params.subscriptionPda, role: AccountRole.WRITABLE }, - { address: params.subscriptionAuthority, role: AccountRole.READONLY }, - { address: address(SYSTEM_PROGRAM), role: AccountRole.READONLY }, - ], - data: new Uint8Array([SUBSCRIPTIONS_SUBSCRIBE_DISCRIMINATOR]), - programAddress: params.programAddress, - }; + address: remoteAddress, + signTransactions() { + return Promise.reject( + new Error(`Remote signer ${remoteAddress} must be completed by the subscription server`), + ); + }, + } as TransactionSigner; } -function buildTransferSubscriptionInstruction(params: { - mint: Address; - planPda: Address; - programAddress: Address; - puller: Address; - recipientAta: Address; - subscriber: Address; - subscriberAta: Address; - subscriptionAuthority: Address; - subscriptionPda: Address; - tokenProgram: Address; -}): Instruction { +function stripRemoteSigner(instruction: Instruction, remoteAddress: Address): Instruction { return { - accounts: [ - { address: params.puller, role: AccountRole.WRITABLE_SIGNER }, - { address: params.subscriptionPda, role: AccountRole.WRITABLE }, - { address: params.planPda, role: AccountRole.READONLY }, - { address: params.subscriptionAuthority, role: AccountRole.READONLY }, - { address: params.subscriber, role: AccountRole.READONLY }, - { address: params.subscriberAta, role: AccountRole.WRITABLE }, - { address: params.recipientAta, role: AccountRole.WRITABLE }, - { address: params.mint, role: AccountRole.READONLY }, - { address: params.tokenProgram, role: AccountRole.READONLY }, - ], - data: new Uint8Array([SUBSCRIPTIONS_TRANSFER_DISCRIMINATOR]), - programAddress: params.programAddress, + ...instruction, + accounts: instruction.accounts?.map(meta => + meta.address === remoteAddress ? { address: meta.address, role: meta.role } : meta, + ), }; } -function buildMemoInstruction(memo: string): Instruction { - const data = new TextEncoder().encode(memo); - if (data.byteLength > 566) { - throw new Error('memo cannot exceed 566 bytes'); - } - return { - accounts: [], - data, - programAddress: address(MEMO_PROGRAM), - }; +async function fetchSubscriptionAuthorityInitId( + rpc: ReturnType, + authority: Address, +): Promise { + const account = await rpc.getAccountInfo(authority, { encoding: 'base64' }).send(); + if (!account.value) return null; + const [encoded] = account.value.data; + const decoded = getSubscriptionAuthorityDecoder().decode(getBase64Codec().encode(encoded)); + return decoded.initId; } -async function checkAccountExists(rpc: ReturnType, accountAddress: Address): Promise { - const account = await rpc.getAccountInfo(accountAddress, { encoding: 'base64' }).send(); - return account.value !== null; +function buildMemoInstruction(memo: string): Instruction { + const data = new TextEncoder().encode(memo); + if (data.byteLength > 566) throw new Error('memo cannot exceed 566 bytes'); + return { accounts: [], data, programAddress: address(MEMO_PROGRAM) }; } async function confirmTransaction( @@ -376,98 +403,95 @@ async function confirmTransaction( ): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { - const { value } = await rpc.getSignatureStatuses([signature as unknown as never]).send(); + const { value } = await rpc.getSignatureStatuses([signature as never]).send(); const status = value[0]; if (status) { if (status.err) throw new Error(`Transaction failed: ${JSON.stringify(status.err)}`); if (status.confirmationStatus === 'confirmed' || status.confirmationStatus === 'finalized') return; } - await new Promise(r => setTimeout(r, 2_000)); + await new Promise(resolve => setTimeout(resolve, 2_000)); } throw new Error('Transaction confirmation timeout'); } +type SubscriptionRequest = { + amount: string; + currency: string; + externalId?: string; + methodDetails: { + decimals: number; + expectedCreatedAt: string; + expectedPeriodHours: string; + feePayer?: boolean; + feePayerKey?: string; + merchant: string; + mint: string; + network?: string; + planBump: number; + planId: string; + planIdNumeric: string; + programId?: string; + puller: string; + recentBlockhash?: string; + splits?: Array<{ bps: number; recipient: string }>; + tokenProgram: string; + }; + periodCount: string; + periodUnit: 'day' | 'week'; + recipient: string; + subscriptionExpires?: string; +}; + export declare namespace subscription { type Parameters = { /** - * If true, the client broadcasts the activation transaction itself and - * sends the signature as a `type="signature"` credential. Cannot be - * combined with server fee sponsorship. + * Opt-in: allow signing unknown Token-2022 mints. They may run transfer + * hooks; canonical Token and known Token-2022 stablecoins remain allowed. */ - broadcast?: boolean; - /** Compute unit limit. Defaults to 400,000 (activation can include three program calls). */ + allowUnknownToken2022?: boolean; + /** Must remain false; signature-mode subscription activation is rejected by servers. */ + broadcast?: false; computeUnitLimit?: number; - /** Compute unit price in micro-lamports for priority fees. Defaults to 1. */ computeUnitPrice?: bigint; - /** Called at each step of the activation process. */ + /** Explicitly initialize a missing authority before building activation. */ + initializeSubscriptionAuthority?: boolean; onProgress?: (event: ProgressEvent) => void; - /** Custom RPC URL. If not set, inferred from the challenge's network field. */ rpcUrl?: string; - /** Solana transaction signer. The subscriber's funding key. */ signer: TransactionSigner; + /** Live SubscriptionAuthority.initId obtained from initializeSubscriptionAuthority. */ + subscriptionAuthorityInitId?: bigint; }; type ProgressEvent = - | { - amount: string; - mint: string; - periodHours: number; - planId: string; - recipient: string; - type: 'challenge'; - } - | { signature: string; type: 'activated' } - | { signature: string; type: 'confirming' } + | { amount: string; mint: string; periodHours: number; planId: string; recipient: string; type: 'challenge' } | { transaction: string; type: 'signed' } - | { type: 'paying' } | { type: 'signing' }; } export declare namespace buildSubscriptionActivationTransaction { type Parameters = { - /** Compute unit limit. Defaults to 400,000. */ + /** Allow signing an unknown Token-2022 mint. Defaults to false. */ + allowUnknownToken2022?: boolean; computeUnitLimit?: number; - /** Compute unit price in micro-lamports for priority fees. Defaults to 1. */ computeUnitPrice?: bigint; - /** Called at each step of the activation build/signing process. */ - onProgress?: ( - event: - | { - amount: string; - mint: string; - periodHours: number; - planId: string; - recipient: string; - type: 'challenge'; - } - | { type: 'signing' }, - ) => void; - /** Decoded request from a Solana MPP subscription challenge. */ - request: { - amount: string; - currency: string; - externalId?: string; - methodDetails: { - decimals: number; - feePayer?: boolean; - feePayerKey?: string; - mint: string; - network?: string; - planId: string; - programId?: string; - puller: string; - recentBlockhash?: string; - splits?: Array<{ bps: number; recipient: string }>; - tokenProgram: string; - }; - periodCount: string; - periodUnit: 'day' | 'week'; - recipient: string; - subscriptionExpires?: string; - }; - /** Custom RPC URL. If not set, inferred from the challenge network field. */ + onProgress?: subscription.Parameters['onProgress']; + refreshBlockhash?: boolean; + request: SubscriptionRequest; + rpcUrl?: string; + signer: TransactionSigner; + subscriptionAuthorityInitId: bigint; + }; +} + +export declare namespace initializeSubscriptionAuthority { + type Parameters = { + /** Allow initializing an authority for an unknown Token-2022 mint. Defaults to false. */ + allowUnknownToken2022?: boolean; + mint: string; + network?: string; + programId?: string; rpcUrl?: string; - /** Solana transaction signer (the subscriber). */ signer: TransactionSigner; + tokenProgram: string; }; } diff --git a/typescript/packages/mpp/src/client/index.ts b/typescript/packages/mpp/src/client/index.ts index c39bca2b2..637c721fd 100644 --- a/typescript/packages/mpp/src/client/index.ts +++ b/typescript/packages/mpp/src/client/index.ts @@ -96,7 +96,11 @@ export type { } from './SessionFetch.js'; export { createSessionUsageMeter, SessionUsageMeter } from './SessionUsageMeter.js'; export type { SessionUsagePrice, SessionUsagePricer, SessionUsagePricingContext } from './SessionUsageMeter.js'; -export { buildSubscriptionActivationTransaction, subscription } from './Subscription.js'; +export { + buildSubscriptionActivationTransaction, + initializeSubscriptionAuthority, + subscription, +} from './Subscription.js'; export { assertPeriodHoursInRange, deriveSubscriptionAuthorityPda, diff --git a/typescript/packages/mpp/src/generated/subscriptions/accounts/eventAuthority.ts b/typescript/packages/mpp/src/generated/subscriptions/accounts/eventAuthority.ts new file mode 100644 index 000000000..7bbb46fec --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/accounts/eventAuthority.ts @@ -0,0 +1,118 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + assertAccountExists, + assertAccountsExist, + combineCodec, + decodeAccount, + fetchEncodedAccount, + fetchEncodedAccounts, + getStructDecoder, + getStructEncoder, + type Account, + type Address, + type EncodedAccount, + type FetchAccountConfig, + type FetchAccountsConfig, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, + type MaybeAccount, + type MaybeEncodedAccount, +} from '@solana/kit'; +import { findEventAuthorityPda } from '../pdas/index.js'; + +export type EventAuthority = {}; + +export type EventAuthorityArgs = EventAuthority; + +/** Gets the encoder for {@link EventAuthorityArgs} account data. */ +export function getEventAuthorityEncoder(): FixedSizeEncoder { + return getStructEncoder([]); +} + +/** Gets the decoder for {@link EventAuthority} account data. */ +export function getEventAuthorityDecoder(): FixedSizeDecoder { + return getStructDecoder([]); +} + +/** Gets the codec for {@link EventAuthority} account data. */ +export function getEventAuthorityCodec(): FixedSizeCodec { + return combineCodec(getEventAuthorityEncoder(), getEventAuthorityDecoder()); +} + +export function decodeEventAuthority( + encodedAccount: EncodedAccount, +): Account; +export function decodeEventAuthority( + encodedAccount: MaybeEncodedAccount, +): MaybeAccount; +export function decodeEventAuthority( + encodedAccount: EncodedAccount | MaybeEncodedAccount, +): Account | MaybeAccount { + return decodeAccount(encodedAccount as MaybeEncodedAccount, getEventAuthorityDecoder()); +} + +export async function fetchEventAuthority( + rpc: Parameters[0], + address: Address, + config?: FetchAccountConfig, +): Promise> { + const maybeAccount = await fetchMaybeEventAuthority(rpc, address, config); + assertAccountExists(maybeAccount); + return maybeAccount; +} + +export async function fetchMaybeEventAuthority( + rpc: Parameters[0], + address: Address, + config?: FetchAccountConfig, +): Promise> { + const maybeAccount = await fetchEncodedAccount(rpc, address, config); + return decodeEventAuthority(maybeAccount); +} + +export async function fetchAllEventAuthority( + rpc: Parameters[0], + addresses: Array
, + config?: FetchAccountsConfig, +): Promise[]> { + const maybeAccounts = await fetchAllMaybeEventAuthority(rpc, addresses, config); + assertAccountsExist(maybeAccounts); + return maybeAccounts; +} + +export async function fetchAllMaybeEventAuthority( + rpc: Parameters[0], + addresses: Array
, + config?: FetchAccountsConfig, +): Promise[]> { + const maybeAccounts = await fetchEncodedAccounts(rpc, addresses, config); + return maybeAccounts.map(maybeAccount => decodeEventAuthority(maybeAccount)); +} + +export async function fetchEventAuthorityFromSeeds( + rpc: Parameters[0], + + config: FetchAccountConfig & { programAddress?: Address } = {}, +): Promise> { + const maybeAccount = await fetchMaybeEventAuthorityFromSeeds(rpc, config); + assertAccountExists(maybeAccount); + return maybeAccount; +} + +export async function fetchMaybeEventAuthorityFromSeeds( + rpc: Parameters[0], + + config: FetchAccountConfig & { programAddress?: Address } = {}, +): Promise> { + const { programAddress, ...fetchConfig } = config; + const [address] = await findEventAuthorityPda({ programAddress }); + return await fetchMaybeEventAuthority(rpc, address, fetchConfig); +} diff --git a/typescript/packages/mpp/src/generated/subscriptions/accounts/fixedDelegation.ts b/typescript/packages/mpp/src/generated/subscriptions/accounts/fixedDelegation.ts new file mode 100644 index 000000000..9358fb92d --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/accounts/fixedDelegation.ts @@ -0,0 +1,149 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + assertAccountExists, + assertAccountsExist, + combineCodec, + decodeAccount, + fetchEncodedAccount, + fetchEncodedAccounts, + getAddressDecoder, + getAddressEncoder, + getI64Decoder, + getI64Encoder, + getStructDecoder, + getStructEncoder, + getU64Decoder, + getU64Encoder, + type Account, + type Address, + type EncodedAccount, + type FetchAccountConfig, + type FetchAccountsConfig, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, + type MaybeAccount, + type MaybeEncodedAccount, +} from '@solana/kit'; +import { findFixedDelegationPda, FixedDelegationSeeds } from '../pdas/index.js'; +import { getHeaderDecoder, getHeaderEncoder, type Header, type HeaderArgs } from '../types/index.js'; + +export type FixedDelegation = { + header: Header; + subscriptionAuthority: Address; + mint: Address; + amount: bigint; + expiryTs: bigint; +}; + +export type FixedDelegationArgs = { + header: HeaderArgs; + subscriptionAuthority: Address; + mint: Address; + amount: number | bigint; + expiryTs: number | bigint; +}; + +/** Gets the encoder for {@link FixedDelegationArgs} account data. */ +export function getFixedDelegationEncoder(): FixedSizeEncoder { + return getStructEncoder([ + ['header', getHeaderEncoder()], + ['subscriptionAuthority', getAddressEncoder()], + ['mint', getAddressEncoder()], + ['amount', getU64Encoder()], + ['expiryTs', getI64Encoder()], + ]); +} + +/** Gets the decoder for {@link FixedDelegation} account data. */ +export function getFixedDelegationDecoder(): FixedSizeDecoder { + return getStructDecoder([ + ['header', getHeaderDecoder()], + ['subscriptionAuthority', getAddressDecoder()], + ['mint', getAddressDecoder()], + ['amount', getU64Decoder()], + ['expiryTs', getI64Decoder()], + ]); +} + +/** Gets the codec for {@link FixedDelegation} account data. */ +export function getFixedDelegationCodec(): FixedSizeCodec { + return combineCodec(getFixedDelegationEncoder(), getFixedDelegationDecoder()); +} + +export function decodeFixedDelegation( + encodedAccount: EncodedAccount, +): Account; +export function decodeFixedDelegation( + encodedAccount: MaybeEncodedAccount, +): MaybeAccount; +export function decodeFixedDelegation( + encodedAccount: EncodedAccount | MaybeEncodedAccount, +): Account | MaybeAccount { + return decodeAccount(encodedAccount as MaybeEncodedAccount, getFixedDelegationDecoder()); +} + +export async function fetchFixedDelegation( + rpc: Parameters[0], + address: Address, + config?: FetchAccountConfig, +): Promise> { + const maybeAccount = await fetchMaybeFixedDelegation(rpc, address, config); + assertAccountExists(maybeAccount); + return maybeAccount; +} + +export async function fetchMaybeFixedDelegation( + rpc: Parameters[0], + address: Address, + config?: FetchAccountConfig, +): Promise> { + const maybeAccount = await fetchEncodedAccount(rpc, address, config); + return decodeFixedDelegation(maybeAccount); +} + +export async function fetchAllFixedDelegation( + rpc: Parameters[0], + addresses: Array
, + config?: FetchAccountsConfig, +): Promise[]> { + const maybeAccounts = await fetchAllMaybeFixedDelegation(rpc, addresses, config); + assertAccountsExist(maybeAccounts); + return maybeAccounts; +} + +export async function fetchAllMaybeFixedDelegation( + rpc: Parameters[0], + addresses: Array
, + config?: FetchAccountsConfig, +): Promise[]> { + const maybeAccounts = await fetchEncodedAccounts(rpc, addresses, config); + return maybeAccounts.map(maybeAccount => decodeFixedDelegation(maybeAccount)); +} + +export async function fetchFixedDelegationFromSeeds( + rpc: Parameters[0], + seeds: FixedDelegationSeeds, + config: FetchAccountConfig & { programAddress?: Address } = {}, +): Promise> { + const maybeAccount = await fetchMaybeFixedDelegationFromSeeds(rpc, seeds, config); + assertAccountExists(maybeAccount); + return maybeAccount; +} + +export async function fetchMaybeFixedDelegationFromSeeds( + rpc: Parameters[0], + seeds: FixedDelegationSeeds, + config: FetchAccountConfig & { programAddress?: Address } = {}, +): Promise> { + const { programAddress, ...fetchConfig } = config; + const [address] = await findFixedDelegationPda(seeds, { programAddress }); + return await fetchMaybeFixedDelegation(rpc, address, fetchConfig); +} diff --git a/typescript/packages/mpp/src/generated/subscriptions/accounts/index.ts b/typescript/packages/mpp/src/generated/subscriptions/accounts/index.ts new file mode 100644 index 000000000..9ea8947eb --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/accounts/index.ts @@ -0,0 +1,14 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +export * from './eventAuthority.js'; +export * from './fixedDelegation.js'; +export * from './plan.js'; +export * from './recurringDelegation.js'; +export * from './subscriptionAuthority.js'; +export * from './subscriptionDelegation.js'; diff --git a/typescript/packages/mpp/src/generated/subscriptions/accounts/plan.ts b/typescript/packages/mpp/src/generated/subscriptions/accounts/plan.ts new file mode 100644 index 000000000..7e10f209d --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/accounts/plan.ts @@ -0,0 +1,147 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + assertAccountExists, + assertAccountsExist, + combineCodec, + decodeAccount, + fetchEncodedAccount, + fetchEncodedAccounts, + getAddressDecoder, + getAddressEncoder, + getStructDecoder, + getStructEncoder, + getU8Decoder, + getU8Encoder, + type Account, + type Address, + type EncodedAccount, + type FetchAccountConfig, + type FetchAccountsConfig, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, + type MaybeAccount, + type MaybeEncodedAccount, +} from '@solana/kit'; +import { findPlanPda, PlanSeeds } from '../pdas/index.js'; +import { getPlanDataDecoder, getPlanDataEncoder, type PlanData, type PlanDataArgs } from '../types/index.js'; + +export type Plan = { + discriminator: number; + owner: Address; + bump: number; + status: number; + data: PlanData; +}; + +export type PlanArgs = { + discriminator: number; + owner: Address; + bump: number; + status: number; + data: PlanDataArgs; +}; + +/** Gets the encoder for {@link PlanArgs} account data. */ +export function getPlanEncoder(): FixedSizeEncoder { + return getStructEncoder([ + ['discriminator', getU8Encoder()], + ['owner', getAddressEncoder()], + ['bump', getU8Encoder()], + ['status', getU8Encoder()], + ['data', getPlanDataEncoder()], + ]); +} + +/** Gets the decoder for {@link Plan} account data. */ +export function getPlanDecoder(): FixedSizeDecoder { + return getStructDecoder([ + ['discriminator', getU8Decoder()], + ['owner', getAddressDecoder()], + ['bump', getU8Decoder()], + ['status', getU8Decoder()], + ['data', getPlanDataDecoder()], + ]); +} + +/** Gets the codec for {@link Plan} account data. */ +export function getPlanCodec(): FixedSizeCodec { + return combineCodec(getPlanEncoder(), getPlanDecoder()); +} + +export function decodePlan( + encodedAccount: EncodedAccount, +): Account; +export function decodePlan( + encodedAccount: MaybeEncodedAccount, +): MaybeAccount; +export function decodePlan( + encodedAccount: EncodedAccount | MaybeEncodedAccount, +): Account | MaybeAccount { + return decodeAccount(encodedAccount as MaybeEncodedAccount, getPlanDecoder()); +} + +export async function fetchPlan( + rpc: Parameters[0], + address: Address, + config?: FetchAccountConfig, +): Promise> { + const maybeAccount = await fetchMaybePlan(rpc, address, config); + assertAccountExists(maybeAccount); + return maybeAccount; +} + +export async function fetchMaybePlan( + rpc: Parameters[0], + address: Address, + config?: FetchAccountConfig, +): Promise> { + const maybeAccount = await fetchEncodedAccount(rpc, address, config); + return decodePlan(maybeAccount); +} + +export async function fetchAllPlan( + rpc: Parameters[0], + addresses: Array
, + config?: FetchAccountsConfig, +): Promise[]> { + const maybeAccounts = await fetchAllMaybePlan(rpc, addresses, config); + assertAccountsExist(maybeAccounts); + return maybeAccounts; +} + +export async function fetchAllMaybePlan( + rpc: Parameters[0], + addresses: Array
, + config?: FetchAccountsConfig, +): Promise[]> { + const maybeAccounts = await fetchEncodedAccounts(rpc, addresses, config); + return maybeAccounts.map(maybeAccount => decodePlan(maybeAccount)); +} + +export async function fetchPlanFromSeeds( + rpc: Parameters[0], + seeds: PlanSeeds, + config: FetchAccountConfig & { programAddress?: Address } = {}, +): Promise> { + const maybeAccount = await fetchMaybePlanFromSeeds(rpc, seeds, config); + assertAccountExists(maybeAccount); + return maybeAccount; +} + +export async function fetchMaybePlanFromSeeds( + rpc: Parameters[0], + seeds: PlanSeeds, + config: FetchAccountConfig & { programAddress?: Address } = {}, +): Promise> { + const { programAddress, ...fetchConfig } = config; + const [address] = await findPlanPda(seeds, { programAddress }); + return await fetchMaybePlan(rpc, address, fetchConfig); +} diff --git a/typescript/packages/mpp/src/generated/subscriptions/accounts/recurringDelegation.ts b/typescript/packages/mpp/src/generated/subscriptions/accounts/recurringDelegation.ts new file mode 100644 index 000000000..09c46d399 --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/accounts/recurringDelegation.ts @@ -0,0 +1,161 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + assertAccountExists, + assertAccountsExist, + combineCodec, + decodeAccount, + fetchEncodedAccount, + fetchEncodedAccounts, + getAddressDecoder, + getAddressEncoder, + getI64Decoder, + getI64Encoder, + getStructDecoder, + getStructEncoder, + getU64Decoder, + getU64Encoder, + type Account, + type Address, + type EncodedAccount, + type FetchAccountConfig, + type FetchAccountsConfig, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, + type MaybeAccount, + type MaybeEncodedAccount, +} from '@solana/kit'; +import { findRecurringDelegationPda, RecurringDelegationSeeds } from '../pdas/index.js'; +import { getHeaderDecoder, getHeaderEncoder, type Header, type HeaderArgs } from '../types/index.js'; + +export type RecurringDelegation = { + header: Header; + subscriptionAuthority: Address; + mint: Address; + currentPeriodStartTs: bigint; + periodLengthS: bigint; + expiryTs: bigint; + amountPerPeriod: bigint; + amountPulledInPeriod: bigint; +}; + +export type RecurringDelegationArgs = { + header: HeaderArgs; + subscriptionAuthority: Address; + mint: Address; + currentPeriodStartTs: number | bigint; + periodLengthS: number | bigint; + expiryTs: number | bigint; + amountPerPeriod: number | bigint; + amountPulledInPeriod: number | bigint; +}; + +/** Gets the encoder for {@link RecurringDelegationArgs} account data. */ +export function getRecurringDelegationEncoder(): FixedSizeEncoder { + return getStructEncoder([ + ['header', getHeaderEncoder()], + ['subscriptionAuthority', getAddressEncoder()], + ['mint', getAddressEncoder()], + ['currentPeriodStartTs', getI64Encoder()], + ['periodLengthS', getU64Encoder()], + ['expiryTs', getI64Encoder()], + ['amountPerPeriod', getU64Encoder()], + ['amountPulledInPeriod', getU64Encoder()], + ]); +} + +/** Gets the decoder for {@link RecurringDelegation} account data. */ +export function getRecurringDelegationDecoder(): FixedSizeDecoder { + return getStructDecoder([ + ['header', getHeaderDecoder()], + ['subscriptionAuthority', getAddressDecoder()], + ['mint', getAddressDecoder()], + ['currentPeriodStartTs', getI64Decoder()], + ['periodLengthS', getU64Decoder()], + ['expiryTs', getI64Decoder()], + ['amountPerPeriod', getU64Decoder()], + ['amountPulledInPeriod', getU64Decoder()], + ]); +} + +/** Gets the codec for {@link RecurringDelegation} account data. */ +export function getRecurringDelegationCodec(): FixedSizeCodec { + return combineCodec(getRecurringDelegationEncoder(), getRecurringDelegationDecoder()); +} + +export function decodeRecurringDelegation( + encodedAccount: EncodedAccount, +): Account; +export function decodeRecurringDelegation( + encodedAccount: MaybeEncodedAccount, +): MaybeAccount; +export function decodeRecurringDelegation( + encodedAccount: EncodedAccount | MaybeEncodedAccount, +): Account | MaybeAccount { + return decodeAccount(encodedAccount as MaybeEncodedAccount, getRecurringDelegationDecoder()); +} + +export async function fetchRecurringDelegation( + rpc: Parameters[0], + address: Address, + config?: FetchAccountConfig, +): Promise> { + const maybeAccount = await fetchMaybeRecurringDelegation(rpc, address, config); + assertAccountExists(maybeAccount); + return maybeAccount; +} + +export async function fetchMaybeRecurringDelegation( + rpc: Parameters[0], + address: Address, + config?: FetchAccountConfig, +): Promise> { + const maybeAccount = await fetchEncodedAccount(rpc, address, config); + return decodeRecurringDelegation(maybeAccount); +} + +export async function fetchAllRecurringDelegation( + rpc: Parameters[0], + addresses: Array
, + config?: FetchAccountsConfig, +): Promise[]> { + const maybeAccounts = await fetchAllMaybeRecurringDelegation(rpc, addresses, config); + assertAccountsExist(maybeAccounts); + return maybeAccounts; +} + +export async function fetchAllMaybeRecurringDelegation( + rpc: Parameters[0], + addresses: Array
, + config?: FetchAccountsConfig, +): Promise[]> { + const maybeAccounts = await fetchEncodedAccounts(rpc, addresses, config); + return maybeAccounts.map(maybeAccount => decodeRecurringDelegation(maybeAccount)); +} + +export async function fetchRecurringDelegationFromSeeds( + rpc: Parameters[0], + seeds: RecurringDelegationSeeds, + config: FetchAccountConfig & { programAddress?: Address } = {}, +): Promise> { + const maybeAccount = await fetchMaybeRecurringDelegationFromSeeds(rpc, seeds, config); + assertAccountExists(maybeAccount); + return maybeAccount; +} + +export async function fetchMaybeRecurringDelegationFromSeeds( + rpc: Parameters[0], + seeds: RecurringDelegationSeeds, + config: FetchAccountConfig & { programAddress?: Address } = {}, +): Promise> { + const { programAddress, ...fetchConfig } = config; + const [address] = await findRecurringDelegationPda(seeds, { programAddress }); + return await fetchMaybeRecurringDelegation(rpc, address, fetchConfig); +} diff --git a/typescript/packages/mpp/src/generated/subscriptions/accounts/subscriptionAuthority.ts b/typescript/packages/mpp/src/generated/subscriptions/accounts/subscriptionAuthority.ts new file mode 100644 index 000000000..c87a86fb7 --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/accounts/subscriptionAuthority.ts @@ -0,0 +1,154 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + assertAccountExists, + assertAccountsExist, + combineCodec, + decodeAccount, + fetchEncodedAccount, + fetchEncodedAccounts, + getAddressDecoder, + getAddressEncoder, + getI64Decoder, + getI64Encoder, + getStructDecoder, + getStructEncoder, + getU8Decoder, + getU8Encoder, + type Account, + type Address, + type EncodedAccount, + type FetchAccountConfig, + type FetchAccountsConfig, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, + type MaybeAccount, + type MaybeEncodedAccount, +} from '@solana/kit'; +import { findSubscriptionAuthorityPda, SubscriptionAuthoritySeeds } from '../pdas/index.js'; + +export type SubscriptionAuthority = { + discriminator: number; + user: Address; + tokenMint: Address; + payer: Address; + bump: number; + initId: bigint; +}; + +export type SubscriptionAuthorityArgs = { + discriminator: number; + user: Address; + tokenMint: Address; + payer: Address; + bump: number; + initId: number | bigint; +}; + +/** Gets the encoder for {@link SubscriptionAuthorityArgs} account data. */ +export function getSubscriptionAuthorityEncoder(): FixedSizeEncoder { + return getStructEncoder([ + ['discriminator', getU8Encoder()], + ['user', getAddressEncoder()], + ['tokenMint', getAddressEncoder()], + ['payer', getAddressEncoder()], + ['bump', getU8Encoder()], + ['initId', getI64Encoder()], + ]); +} + +/** Gets the decoder for {@link SubscriptionAuthority} account data. */ +export function getSubscriptionAuthorityDecoder(): FixedSizeDecoder { + return getStructDecoder([ + ['discriminator', getU8Decoder()], + ['user', getAddressDecoder()], + ['tokenMint', getAddressDecoder()], + ['payer', getAddressDecoder()], + ['bump', getU8Decoder()], + ['initId', getI64Decoder()], + ]); +} + +/** Gets the codec for {@link SubscriptionAuthority} account data. */ +export function getSubscriptionAuthorityCodec(): FixedSizeCodec { + return combineCodec(getSubscriptionAuthorityEncoder(), getSubscriptionAuthorityDecoder()); +} + +export function decodeSubscriptionAuthority( + encodedAccount: EncodedAccount, +): Account; +export function decodeSubscriptionAuthority( + encodedAccount: MaybeEncodedAccount, +): MaybeAccount; +export function decodeSubscriptionAuthority( + encodedAccount: EncodedAccount | MaybeEncodedAccount, +): Account | MaybeAccount { + return decodeAccount(encodedAccount as MaybeEncodedAccount, getSubscriptionAuthorityDecoder()); +} + +export async function fetchSubscriptionAuthority( + rpc: Parameters[0], + address: Address, + config?: FetchAccountConfig, +): Promise> { + const maybeAccount = await fetchMaybeSubscriptionAuthority(rpc, address, config); + assertAccountExists(maybeAccount); + return maybeAccount; +} + +export async function fetchMaybeSubscriptionAuthority( + rpc: Parameters[0], + address: Address, + config?: FetchAccountConfig, +): Promise> { + const maybeAccount = await fetchEncodedAccount(rpc, address, config); + return decodeSubscriptionAuthority(maybeAccount); +} + +export async function fetchAllSubscriptionAuthority( + rpc: Parameters[0], + addresses: Array
, + config?: FetchAccountsConfig, +): Promise[]> { + const maybeAccounts = await fetchAllMaybeSubscriptionAuthority(rpc, addresses, config); + assertAccountsExist(maybeAccounts); + return maybeAccounts; +} + +export async function fetchAllMaybeSubscriptionAuthority( + rpc: Parameters[0], + addresses: Array
, + config?: FetchAccountsConfig, +): Promise[]> { + const maybeAccounts = await fetchEncodedAccounts(rpc, addresses, config); + return maybeAccounts.map(maybeAccount => decodeSubscriptionAuthority(maybeAccount)); +} + +export async function fetchSubscriptionAuthorityFromSeeds( + rpc: Parameters[0], + seeds: SubscriptionAuthoritySeeds, + config: FetchAccountConfig & { programAddress?: Address } = {}, +): Promise> { + const maybeAccount = await fetchMaybeSubscriptionAuthorityFromSeeds(rpc, seeds, config); + assertAccountExists(maybeAccount); + return maybeAccount; +} + +export async function fetchMaybeSubscriptionAuthorityFromSeeds( + rpc: Parameters[0], + seeds: SubscriptionAuthoritySeeds, + config: FetchAccountConfig & { programAddress?: Address } = {}, +): Promise> { + const { programAddress, ...fetchConfig } = config; + const [address] = await findSubscriptionAuthorityPda(seeds, { + programAddress, + }); + return await fetchMaybeSubscriptionAuthority(rpc, address, fetchConfig); +} diff --git a/typescript/packages/mpp/src/generated/subscriptions/accounts/subscriptionDelegation.ts b/typescript/packages/mpp/src/generated/subscriptions/accounts/subscriptionDelegation.ts new file mode 100644 index 000000000..7a33b8644 --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/accounts/subscriptionDelegation.ts @@ -0,0 +1,158 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + assertAccountExists, + assertAccountsExist, + combineCodec, + decodeAccount, + fetchEncodedAccount, + fetchEncodedAccounts, + getI64Decoder, + getI64Encoder, + getStructDecoder, + getStructEncoder, + getU64Decoder, + getU64Encoder, + type Account, + type Address, + type EncodedAccount, + type FetchAccountConfig, + type FetchAccountsConfig, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, + type MaybeAccount, + type MaybeEncodedAccount, +} from '@solana/kit'; +import { findSubscriptionDelegationPda, SubscriptionDelegationSeeds } from '../pdas/index.js'; +import { + getHeaderDecoder, + getHeaderEncoder, + getPlanTermsDecoder, + getPlanTermsEncoder, + type Header, + type HeaderArgs, + type PlanTerms, + type PlanTermsArgs, +} from '../types/index.js'; + +export type SubscriptionDelegation = { + header: Header; + terms: PlanTerms; + amountPulledInPeriod: bigint; + currentPeriodStartTs: bigint; + expiresAtTs: bigint; +}; + +export type SubscriptionDelegationArgs = { + header: HeaderArgs; + terms: PlanTermsArgs; + amountPulledInPeriod: number | bigint; + currentPeriodStartTs: number | bigint; + expiresAtTs: number | bigint; +}; + +/** Gets the encoder for {@link SubscriptionDelegationArgs} account data. */ +export function getSubscriptionDelegationEncoder(): FixedSizeEncoder { + return getStructEncoder([ + ['header', getHeaderEncoder()], + ['terms', getPlanTermsEncoder()], + ['amountPulledInPeriod', getU64Encoder()], + ['currentPeriodStartTs', getI64Encoder()], + ['expiresAtTs', getI64Encoder()], + ]); +} + +/** Gets the decoder for {@link SubscriptionDelegation} account data. */ +export function getSubscriptionDelegationDecoder(): FixedSizeDecoder { + return getStructDecoder([ + ['header', getHeaderDecoder()], + ['terms', getPlanTermsDecoder()], + ['amountPulledInPeriod', getU64Decoder()], + ['currentPeriodStartTs', getI64Decoder()], + ['expiresAtTs', getI64Decoder()], + ]); +} + +/** Gets the codec for {@link SubscriptionDelegation} account data. */ +export function getSubscriptionDelegationCodec(): FixedSizeCodec { + return combineCodec(getSubscriptionDelegationEncoder(), getSubscriptionDelegationDecoder()); +} + +export function decodeSubscriptionDelegation( + encodedAccount: EncodedAccount, +): Account; +export function decodeSubscriptionDelegation( + encodedAccount: MaybeEncodedAccount, +): MaybeAccount; +export function decodeSubscriptionDelegation( + encodedAccount: EncodedAccount | MaybeEncodedAccount, +): Account | MaybeAccount { + return decodeAccount(encodedAccount as MaybeEncodedAccount, getSubscriptionDelegationDecoder()); +} + +export async function fetchSubscriptionDelegation( + rpc: Parameters[0], + address: Address, + config?: FetchAccountConfig, +): Promise> { + const maybeAccount = await fetchMaybeSubscriptionDelegation(rpc, address, config); + assertAccountExists(maybeAccount); + return maybeAccount; +} + +export async function fetchMaybeSubscriptionDelegation( + rpc: Parameters[0], + address: Address, + config?: FetchAccountConfig, +): Promise> { + const maybeAccount = await fetchEncodedAccount(rpc, address, config); + return decodeSubscriptionDelegation(maybeAccount); +} + +export async function fetchAllSubscriptionDelegation( + rpc: Parameters[0], + addresses: Array
, + config?: FetchAccountsConfig, +): Promise[]> { + const maybeAccounts = await fetchAllMaybeSubscriptionDelegation(rpc, addresses, config); + assertAccountsExist(maybeAccounts); + return maybeAccounts; +} + +export async function fetchAllMaybeSubscriptionDelegation( + rpc: Parameters[0], + addresses: Array
, + config?: FetchAccountsConfig, +): Promise[]> { + const maybeAccounts = await fetchEncodedAccounts(rpc, addresses, config); + return maybeAccounts.map(maybeAccount => decodeSubscriptionDelegation(maybeAccount)); +} + +export async function fetchSubscriptionDelegationFromSeeds( + rpc: Parameters[0], + seeds: SubscriptionDelegationSeeds, + config: FetchAccountConfig & { programAddress?: Address } = {}, +): Promise> { + const maybeAccount = await fetchMaybeSubscriptionDelegationFromSeeds(rpc, seeds, config); + assertAccountExists(maybeAccount); + return maybeAccount; +} + +export async function fetchMaybeSubscriptionDelegationFromSeeds( + rpc: Parameters[0], + seeds: SubscriptionDelegationSeeds, + config: FetchAccountConfig & { programAddress?: Address } = {}, +): Promise> { + const { programAddress, ...fetchConfig } = config; + const [address] = await findSubscriptionDelegationPda(seeds, { + programAddress, + }); + return await fetchMaybeSubscriptionDelegation(rpc, address, fetchConfig); +} diff --git a/typescript/packages/mpp/src/generated/subscriptions/errors/index.ts b/typescript/packages/mpp/src/generated/subscriptions/errors/index.ts new file mode 100644 index 000000000..7c70421aa --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/errors/index.ts @@ -0,0 +1,9 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +export * from './subscriptions.js'; diff --git a/typescript/packages/mpp/src/generated/subscriptions/errors/subscriptions.ts b/typescript/packages/mpp/src/generated/subscriptions/errors/subscriptions.ts new file mode 100644 index 000000000..09d707019 --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/errors/subscriptions.ts @@ -0,0 +1,259 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + isProgramError, + type Address, + type SOLANA_ERROR__INSTRUCTION_ERROR__CUSTOM, + type SolanaError, +} from '@solana/kit'; +import { SUBSCRIPTIONS_PROGRAM_ADDRESS } from '../programs/index.js'; + +export const SUBSCRIPTIONS_ERROR__NOT_SIGNER = 0x64; // 100 +export const SUBSCRIPTIONS_ERROR__INVALID_ADDRESS = 0x65; // 101 +export const SUBSCRIPTIONS_ERROR__INVALID_ESCROW_PDA = 0x66; // 102 +export const SUBSCRIPTIONS_ERROR__INVALID_SUBSCRIPTION_AUTHORITY_PDA = 0x67; // 103 +export const SUBSCRIPTIONS_ERROR__NOT_SYSTEM_PROGRAM = 0x68; // 104 +export const SUBSCRIPTIONS_ERROR__INVALID_TOKEN_PROGRAM = 0x69; // 105 +export const SUBSCRIPTIONS_ERROR__INVALID_TOKEN2022_MINT_ACCOUNT_DATA = 0x6a; // 106 +export const SUBSCRIPTIONS_ERROR__INVALID_TOKEN2022_TOKEN_ACCOUNT_DATA = 0x6b; // 107 +export const SUBSCRIPTIONS_ERROR__INVALID_ASSOCIATED_TOKEN_ACCOUNT_DERIVED_ADDRESS = 0x6c; // 108 +export const SUBSCRIPTIONS_ERROR__INVALID_TOKEN_SPL_MINT_ACCOUNT_DATA = 0x6d; // 109 +export const SUBSCRIPTIONS_ERROR__INVALID_TOKEN_SPL_TOKEN_ACCOUNT_DATA = 0x6e; // 110 +export const SUBSCRIPTIONS_ERROR__INVALID_ACCOUNT_DATA = 0x6f; // 111 +export const SUBSCRIPTIONS_ERROR__INVALID_INSTRUCTION_DATA = 0x70; // 112 +export const SUBSCRIPTIONS_ERROR__NOT_ENOUGH_ACCOUNT_KEYS = 0x71; // 113 +export const SUBSCRIPTIONS_ERROR__INVALID_INSTRUCTION = 0x72; // 114 +export const SUBSCRIPTIONS_ERROR__ARITHMETIC_OVERFLOW = 0x73; // 115 +export const SUBSCRIPTIONS_ERROR__ARITHMETIC_UNDERFLOW = 0x74; // 116 +export const SUBSCRIPTIONS_ERROR__INVALID_ACCOUNT_DISCRIMINATOR = 0x75; // 117 +export const SUBSCRIPTIONS_ERROR__MINT_HAS_CONFIDENTIAL_TRANSFER = 0x76; // 118 +export const SUBSCRIPTIONS_ERROR__MINT_HAS_NON_TRANSFERABLE = 0x77; // 119 +export const SUBSCRIPTIONS_ERROR__MINT_HAS_PERMANENT_DELEGATE = 0x78; // 120 +export const SUBSCRIPTIONS_ERROR__MINT_HAS_TRANSFER_HOOK = 0x79; // 121 +export const SUBSCRIPTIONS_ERROR__MINT_HAS_TRANSFER_FEE = 0x7a; // 122 +export const SUBSCRIPTIONS_ERROR__MINT_HAS_MINT_CLOSE_AUTHORITY = 0x7b; // 123 +export const SUBSCRIPTIONS_ERROR__MINT_HAS_PAUSABLE = 0x7c; // 124 +export const SUBSCRIPTIONS_ERROR__MINT_MISMATCH = 0x7d; // 125 +export const SUBSCRIPTIONS_ERROR__INVALID_DELEGATE_PDA = 0x7e; // 126 +export const SUBSCRIPTIONS_ERROR__INVALID_HEADER_DATA = 0x7f; // 127 +export const SUBSCRIPTIONS_ERROR__DELEGATION_EXPIRED = 0x80; // 128 +export const SUBSCRIPTIONS_ERROR__INVALID_AMOUNT = 0x81; // 129 +export const SUBSCRIPTIONS_ERROR__UNAUTHORIZED = 0x82; // 130 +export const SUBSCRIPTIONS_ERROR__ACCOUNT_NOT_WRITABLE = 0x83; // 131 +export const SUBSCRIPTIONS_ERROR__ATA_OWNER_MISMATCH = 0x84; // 132 +export const SUBSCRIPTIONS_ERROR__DELEGATION_VERSION_MISMATCH = 0x85; // 133 +export const SUBSCRIPTIONS_ERROR__MIGRATION_REQUIRED = 0x86; // 134 +export const SUBSCRIPTIONS_ERROR__DELEGATION_ALREADY_EXISTS = 0x87; // 135 +export const SUBSCRIPTIONS_ERROR__STALE_SUBSCRIPTION_AUTHORITY = 0x88; // 136 +export const SUBSCRIPTIONS_ERROR__AMOUNT_EXCEEDS_LIMIT = 0x12c; // 300 +export const SUBSCRIPTIONS_ERROR__FIXED_DELEGATION_EXPIRY_IN_PAST = 0x12d; // 301 +export const SUBSCRIPTIONS_ERROR__FIXED_DELEGATION_AMOUNT_ZERO = 0x12e; // 302 +export const SUBSCRIPTIONS_ERROR__AMOUNT_EXCEEDS_PERIOD_LIMIT = 0x190; // 400 +export const SUBSCRIPTIONS_ERROR__PERIOD_NOT_ELAPSED = 0x191; // 401 +export const SUBSCRIPTIONS_ERROR__INVALID_PERIOD_LENGTH = 0x192; // 402 +export const SUBSCRIPTIONS_ERROR__INVALID_PAYER_DATA = 0x193; // 403 +export const SUBSCRIPTIONS_ERROR__RECURRING_DELEGATION_START_TIME_IN_PAST = 0x194; // 404 +export const SUBSCRIPTIONS_ERROR__RECURRING_DELEGATION_START_TIME_GREATER_THAN_EXPIRY = 0x195; // 405 +export const SUBSCRIPTIONS_ERROR__RECURRING_DELEGATION_AMOUNT_ZERO = 0x196; // 406 +export const SUBSCRIPTIONS_ERROR__DELEGATION_NOT_STARTED = 0x197; // 407 +export const SUBSCRIPTIONS_ERROR__PLAN_SUNSET = 0x1f4; // 500 +export const SUBSCRIPTIONS_ERROR__PLAN_EXPIRED = 0x1f5; // 501 +export const SUBSCRIPTIONS_ERROR__INVALID_PLAN_PDA = 0x1f6; // 502 +export const SUBSCRIPTIONS_ERROR__INVALID_SUBSCRIPTION_PDA = 0x1f7; // 503 +export const SUBSCRIPTIONS_ERROR__NOT_PLAN_OWNER = 0x1f8; // 504 +export const SUBSCRIPTIONS_ERROR__SUBSCRIPTION_PLAN_MISMATCH = 0x1f9; // 505 +export const SUBSCRIPTIONS_ERROR__UNAUTHORIZED_DESTINATION = 0x1fa; // 506 +export const SUBSCRIPTIONS_ERROR__INVALID_NUM_DESTINATIONS = 0x1fb; // 507 +export const SUBSCRIPTIONS_ERROR__SUBSCRIPTION_CANCELLED = 0x1fc; // 508 +export const SUBSCRIPTIONS_ERROR__SUBSCRIPTION_ALREADY_CANCELLED = 0x1fd; // 509 +export const SUBSCRIPTIONS_ERROR__SUBSCRIPTION_NOT_CANCELLED = 0x1fe; // 510 +export const SUBSCRIPTIONS_ERROR__INVALID_END_TS = 0x1ff; // 511 +export const SUBSCRIPTIONS_ERROR__INVALID_PLAN_STATUS = 0x200; // 512 +export const SUBSCRIPTIONS_ERROR__PLAN_IMMUTABLE_AFTER_SUNSET = 0x201; // 513 +export const SUBSCRIPTIONS_ERROR__SUNSET_REQUIRES_END_TS = 0x202; // 514 +export const SUBSCRIPTIONS_ERROR__PLAN_NOT_EXPIRED = 0x203; // 515 +export const SUBSCRIPTIONS_ERROR__PLAN_CLOSED = 0x204; // 516 +export const SUBSCRIPTIONS_ERROR__ALREADY_SUBSCRIBED = 0x205; // 517 +export const SUBSCRIPTIONS_ERROR__PLAN_ALREADY_EXISTS = 0x206; // 518 +export const SUBSCRIPTIONS_ERROR__PLAN_TERMS_MISMATCH = 0x207; // 519 +export const SUBSCRIPTIONS_ERROR__INVALID_EVENT_AUTHORITY = 0x258; // 600 +export const SUBSCRIPTIONS_ERROR__INVALID_EVENT_DATA = 0x259; // 601 +export const SUBSCRIPTIONS_ERROR__INVALID_EVENT_TAG = 0x25a; // 602 +export const SUBSCRIPTIONS_ERROR__INVALID_EVENT_DISCRIMINATOR = 0x25b; // 603 + +export type SubscriptionsError = + | typeof SUBSCRIPTIONS_ERROR__ACCOUNT_NOT_WRITABLE + | typeof SUBSCRIPTIONS_ERROR__ALREADY_SUBSCRIBED + | typeof SUBSCRIPTIONS_ERROR__AMOUNT_EXCEEDS_LIMIT + | typeof SUBSCRIPTIONS_ERROR__AMOUNT_EXCEEDS_PERIOD_LIMIT + | typeof SUBSCRIPTIONS_ERROR__ARITHMETIC_OVERFLOW + | typeof SUBSCRIPTIONS_ERROR__ARITHMETIC_UNDERFLOW + | typeof SUBSCRIPTIONS_ERROR__ATA_OWNER_MISMATCH + | typeof SUBSCRIPTIONS_ERROR__DELEGATION_ALREADY_EXISTS + | typeof SUBSCRIPTIONS_ERROR__DELEGATION_EXPIRED + | typeof SUBSCRIPTIONS_ERROR__DELEGATION_NOT_STARTED + | typeof SUBSCRIPTIONS_ERROR__DELEGATION_VERSION_MISMATCH + | typeof SUBSCRIPTIONS_ERROR__FIXED_DELEGATION_AMOUNT_ZERO + | typeof SUBSCRIPTIONS_ERROR__FIXED_DELEGATION_EXPIRY_IN_PAST + | typeof SUBSCRIPTIONS_ERROR__INVALID_ACCOUNT_DATA + | typeof SUBSCRIPTIONS_ERROR__INVALID_ACCOUNT_DISCRIMINATOR + | typeof SUBSCRIPTIONS_ERROR__INVALID_ADDRESS + | typeof SUBSCRIPTIONS_ERROR__INVALID_AMOUNT + | typeof SUBSCRIPTIONS_ERROR__INVALID_ASSOCIATED_TOKEN_ACCOUNT_DERIVED_ADDRESS + | typeof SUBSCRIPTIONS_ERROR__INVALID_DELEGATE_PDA + | typeof SUBSCRIPTIONS_ERROR__INVALID_END_TS + | typeof SUBSCRIPTIONS_ERROR__INVALID_ESCROW_PDA + | typeof SUBSCRIPTIONS_ERROR__INVALID_EVENT_AUTHORITY + | typeof SUBSCRIPTIONS_ERROR__INVALID_EVENT_DATA + | typeof SUBSCRIPTIONS_ERROR__INVALID_EVENT_DISCRIMINATOR + | typeof SUBSCRIPTIONS_ERROR__INVALID_EVENT_TAG + | typeof SUBSCRIPTIONS_ERROR__INVALID_HEADER_DATA + | typeof SUBSCRIPTIONS_ERROR__INVALID_INSTRUCTION + | typeof SUBSCRIPTIONS_ERROR__INVALID_INSTRUCTION_DATA + | typeof SUBSCRIPTIONS_ERROR__INVALID_NUM_DESTINATIONS + | typeof SUBSCRIPTIONS_ERROR__INVALID_PAYER_DATA + | typeof SUBSCRIPTIONS_ERROR__INVALID_PERIOD_LENGTH + | typeof SUBSCRIPTIONS_ERROR__INVALID_PLAN_PDA + | typeof SUBSCRIPTIONS_ERROR__INVALID_PLAN_STATUS + | typeof SUBSCRIPTIONS_ERROR__INVALID_SUBSCRIPTION_AUTHORITY_PDA + | typeof SUBSCRIPTIONS_ERROR__INVALID_SUBSCRIPTION_PDA + | typeof SUBSCRIPTIONS_ERROR__INVALID_TOKEN2022_MINT_ACCOUNT_DATA + | typeof SUBSCRIPTIONS_ERROR__INVALID_TOKEN2022_TOKEN_ACCOUNT_DATA + | typeof SUBSCRIPTIONS_ERROR__INVALID_TOKEN_PROGRAM + | typeof SUBSCRIPTIONS_ERROR__INVALID_TOKEN_SPL_MINT_ACCOUNT_DATA + | typeof SUBSCRIPTIONS_ERROR__INVALID_TOKEN_SPL_TOKEN_ACCOUNT_DATA + | typeof SUBSCRIPTIONS_ERROR__MIGRATION_REQUIRED + | typeof SUBSCRIPTIONS_ERROR__MINT_HAS_CONFIDENTIAL_TRANSFER + | typeof SUBSCRIPTIONS_ERROR__MINT_HAS_MINT_CLOSE_AUTHORITY + | typeof SUBSCRIPTIONS_ERROR__MINT_HAS_NON_TRANSFERABLE + | typeof SUBSCRIPTIONS_ERROR__MINT_HAS_PAUSABLE + | typeof SUBSCRIPTIONS_ERROR__MINT_HAS_PERMANENT_DELEGATE + | typeof SUBSCRIPTIONS_ERROR__MINT_HAS_TRANSFER_FEE + | typeof SUBSCRIPTIONS_ERROR__MINT_HAS_TRANSFER_HOOK + | typeof SUBSCRIPTIONS_ERROR__MINT_MISMATCH + | typeof SUBSCRIPTIONS_ERROR__NOT_ENOUGH_ACCOUNT_KEYS + | typeof SUBSCRIPTIONS_ERROR__NOT_PLAN_OWNER + | typeof SUBSCRIPTIONS_ERROR__NOT_SIGNER + | typeof SUBSCRIPTIONS_ERROR__NOT_SYSTEM_PROGRAM + | typeof SUBSCRIPTIONS_ERROR__PERIOD_NOT_ELAPSED + | typeof SUBSCRIPTIONS_ERROR__PLAN_ALREADY_EXISTS + | typeof SUBSCRIPTIONS_ERROR__PLAN_CLOSED + | typeof SUBSCRIPTIONS_ERROR__PLAN_EXPIRED + | typeof SUBSCRIPTIONS_ERROR__PLAN_IMMUTABLE_AFTER_SUNSET + | typeof SUBSCRIPTIONS_ERROR__PLAN_NOT_EXPIRED + | typeof SUBSCRIPTIONS_ERROR__PLAN_SUNSET + | typeof SUBSCRIPTIONS_ERROR__PLAN_TERMS_MISMATCH + | typeof SUBSCRIPTIONS_ERROR__RECURRING_DELEGATION_AMOUNT_ZERO + | typeof SUBSCRIPTIONS_ERROR__RECURRING_DELEGATION_START_TIME_GREATER_THAN_EXPIRY + | typeof SUBSCRIPTIONS_ERROR__RECURRING_DELEGATION_START_TIME_IN_PAST + | typeof SUBSCRIPTIONS_ERROR__STALE_SUBSCRIPTION_AUTHORITY + | typeof SUBSCRIPTIONS_ERROR__SUBSCRIPTION_ALREADY_CANCELLED + | typeof SUBSCRIPTIONS_ERROR__SUBSCRIPTION_CANCELLED + | typeof SUBSCRIPTIONS_ERROR__SUBSCRIPTION_NOT_CANCELLED + | typeof SUBSCRIPTIONS_ERROR__SUBSCRIPTION_PLAN_MISMATCH + | typeof SUBSCRIPTIONS_ERROR__SUNSET_REQUIRES_END_TS + | typeof SUBSCRIPTIONS_ERROR__UNAUTHORIZED + | typeof SUBSCRIPTIONS_ERROR__UNAUTHORIZED_DESTINATION; + +let subscriptionsErrorMessages: Record | undefined; +if (process.env['NODE_ENV'] !== 'production') { + subscriptionsErrorMessages = { + [SUBSCRIPTIONS_ERROR__ACCOUNT_NOT_WRITABLE]: `Account must be writable`, + [SUBSCRIPTIONS_ERROR__ALREADY_SUBSCRIBED]: `Already subscribed to this plan`, + [SUBSCRIPTIONS_ERROR__AMOUNT_EXCEEDS_LIMIT]: `Transfer amount exceeds delegation limit`, + [SUBSCRIPTIONS_ERROR__AMOUNT_EXCEEDS_PERIOD_LIMIT]: `Transfer amount exceeds period limit`, + [SUBSCRIPTIONS_ERROR__ARITHMETIC_OVERFLOW]: `Arithmetic Overflow`, + [SUBSCRIPTIONS_ERROR__ARITHMETIC_UNDERFLOW]: `Arithmetic Underflow`, + [SUBSCRIPTIONS_ERROR__ATA_OWNER_MISMATCH]: `Token account owner does not match expected`, + [SUBSCRIPTIONS_ERROR__DELEGATION_ALREADY_EXISTS]: `Delegation account already exists`, + [SUBSCRIPTIONS_ERROR__DELEGATION_EXPIRED]: `Delegation has expired`, + [SUBSCRIPTIONS_ERROR__DELEGATION_NOT_STARTED]: `Delegation period has not started yet`, + [SUBSCRIPTIONS_ERROR__DELEGATION_VERSION_MISMATCH]: `Delegation header version is not compatible`, + [SUBSCRIPTIONS_ERROR__FIXED_DELEGATION_AMOUNT_ZERO]: `zero amount specified`, + [SUBSCRIPTIONS_ERROR__FIXED_DELEGATION_EXPIRY_IN_PAST]: `Expiry time specified is less than current time`, + [SUBSCRIPTIONS_ERROR__INVALID_ACCOUNT_DATA]: `Invalid account data`, + [SUBSCRIPTIONS_ERROR__INVALID_ACCOUNT_DISCRIMINATOR]: `Invalid account discriminator`, + [SUBSCRIPTIONS_ERROR__INVALID_ADDRESS]: `Invalid account address`, + [SUBSCRIPTIONS_ERROR__INVALID_AMOUNT]: `Invalid amount specified`, + [SUBSCRIPTIONS_ERROR__INVALID_ASSOCIATED_TOKEN_ACCOUNT_DERIVED_ADDRESS]: `Invalid associated token account address`, + [SUBSCRIPTIONS_ERROR__INVALID_DELEGATE_PDA]: `Invalid delegation PDA derivation`, + [SUBSCRIPTIONS_ERROR__INVALID_END_TS]: `End timestamp must be zero or in the future`, + [SUBSCRIPTIONS_ERROR__INVALID_ESCROW_PDA]: `Invalid escrow PDA derivation`, + [SUBSCRIPTIONS_ERROR__INVALID_EVENT_AUTHORITY]: `Invalid event authority PDA`, + [SUBSCRIPTIONS_ERROR__INVALID_EVENT_DATA]: `Invalid event data`, + [SUBSCRIPTIONS_ERROR__INVALID_EVENT_DISCRIMINATOR]: `Unknown event discriminator`, + [SUBSCRIPTIONS_ERROR__INVALID_EVENT_TAG]: `Invalid event tag prefix`, + [SUBSCRIPTIONS_ERROR__INVALID_HEADER_DATA]: `Invalid header data`, + [SUBSCRIPTIONS_ERROR__INVALID_INSTRUCTION]: `Invalid instruction`, + [SUBSCRIPTIONS_ERROR__INVALID_INSTRUCTION_DATA]: `Invalid instruction data`, + [SUBSCRIPTIONS_ERROR__INVALID_NUM_DESTINATIONS]: `No valid destinations provided`, + [SUBSCRIPTIONS_ERROR__INVALID_PAYER_DATA]: `Payer provided does not match delegation`, + [SUBSCRIPTIONS_ERROR__INVALID_PERIOD_LENGTH]: `Invalid Period length`, + [SUBSCRIPTIONS_ERROR__INVALID_PLAN_PDA]: `Invalid Plan PDA derivation`, + [SUBSCRIPTIONS_ERROR__INVALID_PLAN_STATUS]: `Invalid plan status value`, + [SUBSCRIPTIONS_ERROR__INVALID_SUBSCRIPTION_AUTHORITY_PDA]: `Invalid subscription-authority PDA derivation`, + [SUBSCRIPTIONS_ERROR__INVALID_SUBSCRIPTION_PDA]: `Invalid subscription PDA derivation`, + [SUBSCRIPTIONS_ERROR__INVALID_TOKEN2022_MINT_ACCOUNT_DATA]: `Invalid Token-2022 mint account data`, + [SUBSCRIPTIONS_ERROR__INVALID_TOKEN2022_TOKEN_ACCOUNT_DATA]: `Invalid Token-2022 token account data`, + [SUBSCRIPTIONS_ERROR__INVALID_TOKEN_PROGRAM]: `Token Program does not match other accounts`, + [SUBSCRIPTIONS_ERROR__INVALID_TOKEN_SPL_MINT_ACCOUNT_DATA]: `Invalid SPL Token mint account data`, + [SUBSCRIPTIONS_ERROR__INVALID_TOKEN_SPL_TOKEN_ACCOUNT_DATA]: `Invalid SPL Token account data`, + [SUBSCRIPTIONS_ERROR__MIGRATION_REQUIRED]: `Account requires explicit migration`, + [SUBSCRIPTIONS_ERROR__MINT_HAS_CONFIDENTIAL_TRANSFER]: `Mint has ConfidentialTransfer extension`, + [SUBSCRIPTIONS_ERROR__MINT_HAS_MINT_CLOSE_AUTHORITY]: `Mint has MintCloseAuthority extension`, + [SUBSCRIPTIONS_ERROR__MINT_HAS_NON_TRANSFERABLE]: `Mint has NonTransferable extension`, + [SUBSCRIPTIONS_ERROR__MINT_HAS_PAUSABLE]: `Mint has Pausable extension`, + [SUBSCRIPTIONS_ERROR__MINT_HAS_PERMANENT_DELEGATE]: `Mint has PermanentDelegate extension`, + [SUBSCRIPTIONS_ERROR__MINT_HAS_TRANSFER_FEE]: `Mint has TransferFee extension`, + [SUBSCRIPTIONS_ERROR__MINT_HAS_TRANSFER_HOOK]: `Mint has TransferHook extension`, + [SUBSCRIPTIONS_ERROR__MINT_MISMATCH]: `Token mint mismatch`, + [SUBSCRIPTIONS_ERROR__NOT_ENOUGH_ACCOUNT_KEYS]: `Not enough account keys provided`, + [SUBSCRIPTIONS_ERROR__NOT_PLAN_OWNER]: `Caller is not the plan owner`, + [SUBSCRIPTIONS_ERROR__NOT_SIGNER]: `Account must be a signer`, + [SUBSCRIPTIONS_ERROR__NOT_SYSTEM_PROGRAM]: `Expected system program`, + [SUBSCRIPTIONS_ERROR__PERIOD_NOT_ELAPSED]: `Period has not elapsed yet`, + [SUBSCRIPTIONS_ERROR__PLAN_ALREADY_EXISTS]: `Plan account already exists`, + [SUBSCRIPTIONS_ERROR__PLAN_CLOSED]: `Plan account has been closed`, + [SUBSCRIPTIONS_ERROR__PLAN_EXPIRED]: `Plan has expired`, + [SUBSCRIPTIONS_ERROR__PLAN_IMMUTABLE_AFTER_SUNSET]: `Plan cannot be updated after sunset`, + [SUBSCRIPTIONS_ERROR__PLAN_NOT_EXPIRED]: `Plan must be expired to delete`, + [SUBSCRIPTIONS_ERROR__PLAN_SUNSET]: `Plan is in sunset status`, + [SUBSCRIPTIONS_ERROR__PLAN_TERMS_MISMATCH]: `Subscription plan terms do not match the current plan`, + [SUBSCRIPTIONS_ERROR__RECURRING_DELEGATION_AMOUNT_ZERO]: `zero amount specified`, + [SUBSCRIPTIONS_ERROR__RECURRING_DELEGATION_START_TIME_GREATER_THAN_EXPIRY]: `start time specified is greater than expiry`, + [SUBSCRIPTIONS_ERROR__RECURRING_DELEGATION_START_TIME_IN_PAST]: `Past start time specified`, + [SUBSCRIPTIONS_ERROR__STALE_SUBSCRIPTION_AUTHORITY]: `Delegation init_id does not match current SubscriptionAuthority`, + [SUBSCRIPTIONS_ERROR__SUBSCRIPTION_ALREADY_CANCELLED]: `Subscription already cancelled`, + [SUBSCRIPTIONS_ERROR__SUBSCRIPTION_CANCELLED]: `Subscription cancelled and past valid period`, + [SUBSCRIPTIONS_ERROR__SUBSCRIPTION_NOT_CANCELLED]: `Subscription is not cancelled`, + [SUBSCRIPTIONS_ERROR__SUBSCRIPTION_PLAN_MISMATCH]: `Subscription does not belong to this plan`, + [SUBSCRIPTIONS_ERROR__SUNSET_REQUIRES_END_TS]: `Sunset requires a non-zero end timestamp`, + [SUBSCRIPTIONS_ERROR__UNAUTHORIZED]: `Caller not authorized for this action`, + [SUBSCRIPTIONS_ERROR__UNAUTHORIZED_DESTINATION]: `Destination not in plan whitelist`, + }; +} + +export function getSubscriptionsErrorMessage(code: SubscriptionsError): string { + if (process.env['NODE_ENV'] !== 'production') { + return (subscriptionsErrorMessages as Record)[code]; + } + + return 'Error message not available in production bundles.'; +} + +export function isSubscriptionsError( + error: unknown, + transactionMessage: { + instructions: Record; + }, + code?: TProgramErrorCode, +): error is SolanaError & + Readonly<{ context: Readonly<{ code: TProgramErrorCode }> }> { + return isProgramError(error, transactionMessage, SUBSCRIPTIONS_PROGRAM_ADDRESS, code); +} diff --git a/typescript/packages/mpp/src/generated/subscriptions/index.ts b/typescript/packages/mpp/src/generated/subscriptions/index.ts new file mode 100644 index 000000000..2e2b5c746 --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/index.ts @@ -0,0 +1,14 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +export * from './accounts/index.js'; +export * from './errors/index.js'; +export * from './instructions/index.js'; +export * from './pdas/index.js'; +export * from './programs/index.js'; +export * from './types/index.js'; diff --git a/typescript/packages/mpp/src/generated/subscriptions/instructions/cancelSubscription.ts b/typescript/packages/mpp/src/generated/subscriptions/instructions/cancelSubscription.ts new file mode 100644 index 000000000..fea1eb335 --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/instructions/cancelSubscription.ts @@ -0,0 +1,325 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getStructDecoder, + getStructEncoder, + getU8Decoder, + getU8Encoder, + SOLANA_ERROR__PROGRAM_CLIENTS__INSUFFICIENT_ACCOUNT_METAS, + SolanaError, + transformEncoder, + type AccountMeta, + type AccountSignerMeta, + type Address, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, + type Instruction, + type InstructionWithAccounts, + type InstructionWithData, + type ReadonlyAccount, + type ReadonlySignerAccount, + type ReadonlyUint8Array, + type TransactionSigner, + type WritableAccount, +} from '@solana/kit'; +import { + getAccountMetaFactory, + getAddressFromResolvedInstructionAccount, + type ResolvedInstructionAccount, +} from '@solana/program-client-core'; +import { findSubscriptionDelegationPda } from '../pdas/index.js'; +import { SUBSCRIPTIONS_PROGRAM_ADDRESS } from '../programs/index.js'; + +export const CANCEL_SUBSCRIPTION_DISCRIMINATOR = 12; + +export function getCancelSubscriptionDiscriminatorBytes(): ReadonlyUint8Array { + return getU8Encoder().encode(CANCEL_SUBSCRIPTION_DISCRIMINATOR); +} + +export type CancelSubscriptionInstruction< + TProgram extends string = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, + TAccountSubscriber extends string | AccountMeta = string, + TAccountPlanPda extends string | AccountMeta = string, + TAccountSubscriptionPda extends string | AccountMeta = string, + TAccountEventAuthority extends string | AccountMeta = '3Hnj4BYoDgtpBuqXfiy7Y8cNa3jXaNd4oqgSXBzkMcH7', + TAccountSelfProgram extends string | AccountMeta = 'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44', + TRemainingAccounts extends readonly AccountMeta[] = [], +> = Instruction & + InstructionWithData & + InstructionWithAccounts< + [ + TAccountSubscriber extends string + ? ReadonlySignerAccount & AccountSignerMeta + : TAccountSubscriber, + TAccountPlanPda extends string ? ReadonlyAccount : TAccountPlanPda, + TAccountSubscriptionPda extends string ? WritableAccount : TAccountSubscriptionPda, + TAccountEventAuthority extends string ? ReadonlyAccount : TAccountEventAuthority, + TAccountSelfProgram extends string ? ReadonlyAccount : TAccountSelfProgram, + ...TRemainingAccounts, + ] + >; + +export type CancelSubscriptionInstructionData = { discriminator: number }; + +export type CancelSubscriptionInstructionDataArgs = {}; + +export function getCancelSubscriptionInstructionDataEncoder(): FixedSizeEncoder { + return transformEncoder(getStructEncoder([['discriminator', getU8Encoder()]]), value => ({ + ...value, + discriminator: CANCEL_SUBSCRIPTION_DISCRIMINATOR, + })); +} + +export function getCancelSubscriptionInstructionDataDecoder(): FixedSizeDecoder { + return getStructDecoder([['discriminator', getU8Decoder()]]); +} + +export function getCancelSubscriptionInstructionDataCodec(): FixedSizeCodec< + CancelSubscriptionInstructionDataArgs, + CancelSubscriptionInstructionData +> { + return combineCodec(getCancelSubscriptionInstructionDataEncoder(), getCancelSubscriptionInstructionDataDecoder()); +} + +export type CancelSubscriptionAsyncInput< + TAccountSubscriber extends string = string, + TAccountPlanPda extends string = string, + TAccountSubscriptionPda extends string = string, + TAccountEventAuthority extends string = string, + TAccountSelfProgram extends string = string, +> = { + /** The subscriber cancelling the subscription */ + subscriber: TransactionSigner; + /** The plan PDA for the subscription */ + planPda: Address; + /** The subscription PDA being cancelled */ + subscriptionPda?: Address; + /** The event authority PDA */ + eventAuthority?: Address; + /** This program (for self-CPI) */ + selfProgram?: Address; +}; + +export async function getCancelSubscriptionInstructionAsync< + TAccountSubscriber extends string, + TAccountPlanPda extends string, + TAccountSubscriptionPda extends string, + TAccountEventAuthority extends string, + TAccountSelfProgram extends string, + TProgramAddress extends Address = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, +>( + input: CancelSubscriptionAsyncInput< + TAccountSubscriber, + TAccountPlanPda, + TAccountSubscriptionPda, + TAccountEventAuthority, + TAccountSelfProgram + >, + config?: { programAddress?: TProgramAddress }, +): Promise< + CancelSubscriptionInstruction< + TProgramAddress, + TAccountSubscriber, + TAccountPlanPda, + TAccountSubscriptionPda, + TAccountEventAuthority, + TAccountSelfProgram + > +> { + // Program address. + const programAddress = config?.programAddress ?? SUBSCRIPTIONS_PROGRAM_ADDRESS; + + // Original accounts. + const originalAccounts = { + subscriber: { value: input.subscriber ?? null, isWritable: false }, + planPda: { value: input.planPda ?? null, isWritable: false }, + subscriptionPda: { value: input.subscriptionPda ?? null, isWritable: true }, + eventAuthority: { value: input.eventAuthority ?? null, isWritable: false }, + selfProgram: { value: input.selfProgram ?? null, isWritable: false }, + }; + const accounts = originalAccounts as Record; + + // Resolve default values. + if (!accounts.subscriptionPda.value) { + accounts.subscriptionPda.value = await findSubscriptionDelegationPda({ + planPda: getAddressFromResolvedInstructionAccount('planPda', accounts.planPda.value), + subscriber: getAddressFromResolvedInstructionAccount('subscriber', accounts.subscriber.value), + }); + } + if (!accounts.eventAuthority.value) { + accounts.eventAuthority.value = + '3Hnj4BYoDgtpBuqXfiy7Y8cNa3jXaNd4oqgSXBzkMcH7' as Address<'3Hnj4BYoDgtpBuqXfiy7Y8cNa3jXaNd4oqgSXBzkMcH7'>; + } + if (!accounts.selfProgram.value) { + accounts.selfProgram.value = + 'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44' as Address<'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44'>; + } + + const getAccountMeta = getAccountMetaFactory(programAddress, 'programId'); + return Object.freeze({ + accounts: [ + getAccountMeta('subscriber', accounts.subscriber), + getAccountMeta('planPda', accounts.planPda), + getAccountMeta('subscriptionPda', accounts.subscriptionPda), + getAccountMeta('eventAuthority', accounts.eventAuthority), + getAccountMeta('selfProgram', accounts.selfProgram), + ], + data: getCancelSubscriptionInstructionDataEncoder().encode({}), + programAddress, + } as CancelSubscriptionInstruction< + TProgramAddress, + TAccountSubscriber, + TAccountPlanPda, + TAccountSubscriptionPda, + TAccountEventAuthority, + TAccountSelfProgram + >); +} + +export type CancelSubscriptionInput< + TAccountSubscriber extends string = string, + TAccountPlanPda extends string = string, + TAccountSubscriptionPda extends string = string, + TAccountEventAuthority extends string = string, + TAccountSelfProgram extends string = string, +> = { + /** The subscriber cancelling the subscription */ + subscriber: TransactionSigner; + /** The plan PDA for the subscription */ + planPda: Address; + /** The subscription PDA being cancelled */ + subscriptionPda: Address; + /** The event authority PDA */ + eventAuthority?: Address; + /** This program (for self-CPI) */ + selfProgram?: Address; +}; + +export function getCancelSubscriptionInstruction< + TAccountSubscriber extends string, + TAccountPlanPda extends string, + TAccountSubscriptionPda extends string, + TAccountEventAuthority extends string, + TAccountSelfProgram extends string, + TProgramAddress extends Address = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, +>( + input: CancelSubscriptionInput< + TAccountSubscriber, + TAccountPlanPda, + TAccountSubscriptionPda, + TAccountEventAuthority, + TAccountSelfProgram + >, + config?: { programAddress?: TProgramAddress }, +): CancelSubscriptionInstruction< + TProgramAddress, + TAccountSubscriber, + TAccountPlanPda, + TAccountSubscriptionPda, + TAccountEventAuthority, + TAccountSelfProgram +> { + // Program address. + const programAddress = config?.programAddress ?? SUBSCRIPTIONS_PROGRAM_ADDRESS; + + // Original accounts. + const originalAccounts = { + subscriber: { value: input.subscriber ?? null, isWritable: false }, + planPda: { value: input.planPda ?? null, isWritable: false }, + subscriptionPda: { value: input.subscriptionPda ?? null, isWritable: true }, + eventAuthority: { value: input.eventAuthority ?? null, isWritable: false }, + selfProgram: { value: input.selfProgram ?? null, isWritable: false }, + }; + const accounts = originalAccounts as Record; + + // Resolve default values. + if (!accounts.eventAuthority.value) { + accounts.eventAuthority.value = + '3Hnj4BYoDgtpBuqXfiy7Y8cNa3jXaNd4oqgSXBzkMcH7' as Address<'3Hnj4BYoDgtpBuqXfiy7Y8cNa3jXaNd4oqgSXBzkMcH7'>; + } + if (!accounts.selfProgram.value) { + accounts.selfProgram.value = + 'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44' as Address<'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44'>; + } + + const getAccountMeta = getAccountMetaFactory(programAddress, 'programId'); + return Object.freeze({ + accounts: [ + getAccountMeta('subscriber', accounts.subscriber), + getAccountMeta('planPda', accounts.planPda), + getAccountMeta('subscriptionPda', accounts.subscriptionPda), + getAccountMeta('eventAuthority', accounts.eventAuthority), + getAccountMeta('selfProgram', accounts.selfProgram), + ], + data: getCancelSubscriptionInstructionDataEncoder().encode({}), + programAddress, + } as CancelSubscriptionInstruction< + TProgramAddress, + TAccountSubscriber, + TAccountPlanPda, + TAccountSubscriptionPda, + TAccountEventAuthority, + TAccountSelfProgram + >); +} + +export type ParsedCancelSubscriptionInstruction< + TProgram extends string = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, + TAccountMetas extends readonly AccountMeta[] = readonly AccountMeta[], +> = { + programAddress: Address; + accounts: { + /** The subscriber cancelling the subscription */ + subscriber: TAccountMetas[0]; + /** The plan PDA for the subscription */ + planPda: TAccountMetas[1]; + /** The subscription PDA being cancelled */ + subscriptionPda: TAccountMetas[2]; + /** The event authority PDA */ + eventAuthority: TAccountMetas[3]; + /** This program (for self-CPI) */ + selfProgram: TAccountMetas[4]; + }; + data: CancelSubscriptionInstructionData; +}; + +export function parseCancelSubscriptionInstruction< + TProgram extends string, + TAccountMetas extends readonly AccountMeta[], +>( + instruction: Instruction & + InstructionWithAccounts & + InstructionWithData, +): ParsedCancelSubscriptionInstruction { + if (instruction.accounts.length < 5) { + throw new SolanaError(SOLANA_ERROR__PROGRAM_CLIENTS__INSUFFICIENT_ACCOUNT_METAS, { + actualAccountMetas: instruction.accounts.length, + expectedAccountMetas: 5, + }); + } + let accountIndex = 0; + const getNextAccount = () => { + const accountMeta = (instruction.accounts as TAccountMetas)[accountIndex]!; + accountIndex += 1; + return accountMeta; + }; + return { + programAddress: instruction.programAddress, + accounts: { + subscriber: getNextAccount(), + planPda: getNextAccount(), + subscriptionPda: getNextAccount(), + eventAuthority: getNextAccount(), + selfProgram: getNextAccount(), + }, + data: getCancelSubscriptionInstructionDataDecoder().decode(instruction.data), + }; +} diff --git a/typescript/packages/mpp/src/generated/subscriptions/instructions/closeSubscriptionAuthority.ts b/typescript/packages/mpp/src/generated/subscriptions/instructions/closeSubscriptionAuthority.ts new file mode 100644 index 000000000..d3f266d53 --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/instructions/closeSubscriptionAuthority.ts @@ -0,0 +1,171 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getStructDecoder, + getStructEncoder, + getU8Decoder, + getU8Encoder, + SOLANA_ERROR__PROGRAM_CLIENTS__INSUFFICIENT_ACCOUNT_METAS, + SolanaError, + transformEncoder, + type AccountMeta, + type AccountSignerMeta, + type Address, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, + type Instruction, + type InstructionWithAccounts, + type InstructionWithData, + type ReadonlyUint8Array, + type TransactionSigner, + type WritableAccount, + type WritableSignerAccount, +} from '@solana/kit'; +import { getAccountMetaFactory, type ResolvedInstructionAccount } from '@solana/program-client-core'; +import { SUBSCRIPTIONS_PROGRAM_ADDRESS } from '../programs/index.js'; + +export const CLOSE_SUBSCRIPTION_AUTHORITY_DISCRIMINATOR = 6; + +export function getCloseSubscriptionAuthorityDiscriminatorBytes(): ReadonlyUint8Array { + return getU8Encoder().encode(CLOSE_SUBSCRIPTION_AUTHORITY_DISCRIMINATOR); +} + +export type CloseSubscriptionAuthorityInstruction< + TProgram extends string = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, + TAccountUser extends string | AccountMeta = string, + TAccountSubscriptionAuthority extends string | AccountMeta = string, + TRemainingAccounts extends readonly AccountMeta[] = [], +> = Instruction & + InstructionWithData & + InstructionWithAccounts< + [ + TAccountUser extends string + ? WritableSignerAccount & AccountSignerMeta + : TAccountUser, + TAccountSubscriptionAuthority extends string + ? WritableAccount + : TAccountSubscriptionAuthority, + ...TRemainingAccounts, + ] + >; + +export type CloseSubscriptionAuthorityInstructionData = { + discriminator: number; +}; + +export type CloseSubscriptionAuthorityInstructionDataArgs = {}; + +export function getCloseSubscriptionAuthorityInstructionDataEncoder(): FixedSizeEncoder { + return transformEncoder(getStructEncoder([['discriminator', getU8Encoder()]]), value => ({ + ...value, + discriminator: CLOSE_SUBSCRIPTION_AUTHORITY_DISCRIMINATOR, + })); +} + +export function getCloseSubscriptionAuthorityInstructionDataDecoder(): FixedSizeDecoder { + return getStructDecoder([['discriminator', getU8Decoder()]]); +} + +export function getCloseSubscriptionAuthorityInstructionDataCodec(): FixedSizeCodec< + CloseSubscriptionAuthorityInstructionDataArgs, + CloseSubscriptionAuthorityInstructionData +> { + return combineCodec( + getCloseSubscriptionAuthorityInstructionDataEncoder(), + getCloseSubscriptionAuthorityInstructionDataDecoder(), + ); +} + +export type CloseSubscriptionAuthorityInput< + TAccountUser extends string = string, + TAccountSubscriptionAuthority extends string = string, +> = { + /** The user who owns the SubscriptionAuthority PDA (receives rent) */ + user: TransactionSigner; + /** The SubscriptionAuthority PDA to close */ + subscriptionAuthority: Address; +}; + +export function getCloseSubscriptionAuthorityInstruction< + TAccountUser extends string, + TAccountSubscriptionAuthority extends string, + TProgramAddress extends Address = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, +>( + input: CloseSubscriptionAuthorityInput, + config?: { programAddress?: TProgramAddress }, +): CloseSubscriptionAuthorityInstruction { + // Program address. + const programAddress = config?.programAddress ?? SUBSCRIPTIONS_PROGRAM_ADDRESS; + + // Original accounts. + const originalAccounts = { + user: { value: input.user ?? null, isWritable: true }, + subscriptionAuthority: { + value: input.subscriptionAuthority ?? null, + isWritable: true, + }, + }; + const accounts = originalAccounts as Record; + + const getAccountMeta = getAccountMetaFactory(programAddress, 'programId'); + return Object.freeze({ + accounts: [ + getAccountMeta('user', accounts.user), + getAccountMeta('subscriptionAuthority', accounts.subscriptionAuthority), + ], + data: getCloseSubscriptionAuthorityInstructionDataEncoder().encode({}), + programAddress, + } as CloseSubscriptionAuthorityInstruction); +} + +export type ParsedCloseSubscriptionAuthorityInstruction< + TProgram extends string = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, + TAccountMetas extends readonly AccountMeta[] = readonly AccountMeta[], +> = { + programAddress: Address; + accounts: { + /** The user who owns the SubscriptionAuthority PDA (receives rent) */ + user: TAccountMetas[0]; + /** The SubscriptionAuthority PDA to close */ + subscriptionAuthority: TAccountMetas[1]; + }; + data: CloseSubscriptionAuthorityInstructionData; +}; + +export function parseCloseSubscriptionAuthorityInstruction< + TProgram extends string, + TAccountMetas extends readonly AccountMeta[], +>( + instruction: Instruction & + InstructionWithAccounts & + InstructionWithData, +): ParsedCloseSubscriptionAuthorityInstruction { + if (instruction.accounts.length < 2) { + throw new SolanaError(SOLANA_ERROR__PROGRAM_CLIENTS__INSUFFICIENT_ACCOUNT_METAS, { + actualAccountMetas: instruction.accounts.length, + expectedAccountMetas: 2, + }); + } + let accountIndex = 0; + const getNextAccount = () => { + const accountMeta = (instruction.accounts as TAccountMetas)[accountIndex]!; + accountIndex += 1; + return accountMeta; + }; + return { + programAddress: instruction.programAddress, + accounts: { + user: getNextAccount(), + subscriptionAuthority: getNextAccount(), + }, + data: getCloseSubscriptionAuthorityInstructionDataDecoder().decode(instruction.data), + }; +} diff --git a/typescript/packages/mpp/src/generated/subscriptions/instructions/createFixedDelegation.ts b/typescript/packages/mpp/src/generated/subscriptions/instructions/createFixedDelegation.ts new file mode 100644 index 000000000..0439b96ac --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/instructions/createFixedDelegation.ts @@ -0,0 +1,258 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getStructDecoder, + getStructEncoder, + getU8Decoder, + getU8Encoder, + SOLANA_ERROR__PROGRAM_CLIENTS__INSUFFICIENT_ACCOUNT_METAS, + SolanaError, + transformEncoder, + type AccountMeta, + type AccountSignerMeta, + type Address, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, + type Instruction, + type InstructionWithAccounts, + type InstructionWithData, + type ReadonlyAccount, + type ReadonlyUint8Array, + type TransactionSigner, + type WritableAccount, + type WritableSignerAccount, +} from '@solana/kit'; +import { getAccountMetaFactory, type ResolvedInstructionAccount } from '@solana/program-client-core'; +import { SUBSCRIPTIONS_PROGRAM_ADDRESS } from '../programs/index.js'; +import { + getCreateFixedDelegationDataDecoder, + getCreateFixedDelegationDataEncoder, + type CreateFixedDelegationData, + type CreateFixedDelegationDataArgs, +} from '../types/index.js'; + +export const CREATE_FIXED_DELEGATION_DISCRIMINATOR = 1; + +export function getCreateFixedDelegationDiscriminatorBytes(): ReadonlyUint8Array { + return getU8Encoder().encode(CREATE_FIXED_DELEGATION_DISCRIMINATOR); +} + +export type CreateFixedDelegationInstruction< + TProgram extends string = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, + TAccountDelegator extends string | AccountMeta = string, + TAccountSubscriptionAuthority extends string | AccountMeta = string, + TAccountDelegationAccount extends string | AccountMeta = string, + TAccountDelegatee extends string | AccountMeta = string, + TAccountSystemProgram extends string | AccountMeta = '11111111111111111111111111111111', + TRemainingAccounts extends readonly AccountMeta[] = [], +> = Instruction & + InstructionWithData & + InstructionWithAccounts< + [ + TAccountDelegator extends string + ? WritableSignerAccount & AccountSignerMeta + : TAccountDelegator, + TAccountSubscriptionAuthority extends string + ? ReadonlyAccount + : TAccountSubscriptionAuthority, + TAccountDelegationAccount extends string + ? WritableAccount + : TAccountDelegationAccount, + TAccountDelegatee extends string ? ReadonlyAccount : TAccountDelegatee, + TAccountSystemProgram extends string ? ReadonlyAccount : TAccountSystemProgram, + ...TRemainingAccounts, + ] + >; + +export type CreateFixedDelegationInstructionData = { + discriminator: number; + fixedDelegation: CreateFixedDelegationData; +}; + +export type CreateFixedDelegationInstructionDataArgs = { + fixedDelegation: CreateFixedDelegationDataArgs; +}; + +export function getCreateFixedDelegationInstructionDataEncoder(): FixedSizeEncoder { + return transformEncoder( + getStructEncoder([ + ['discriminator', getU8Encoder()], + ['fixedDelegation', getCreateFixedDelegationDataEncoder()], + ]), + value => ({ + ...value, + discriminator: CREATE_FIXED_DELEGATION_DISCRIMINATOR, + }), + ); +} + +export function getCreateFixedDelegationInstructionDataDecoder(): FixedSizeDecoder { + return getStructDecoder([ + ['discriminator', getU8Decoder()], + ['fixedDelegation', getCreateFixedDelegationDataDecoder()], + ]); +} + +export function getCreateFixedDelegationInstructionDataCodec(): FixedSizeCodec< + CreateFixedDelegationInstructionDataArgs, + CreateFixedDelegationInstructionData +> { + return combineCodec( + getCreateFixedDelegationInstructionDataEncoder(), + getCreateFixedDelegationInstructionDataDecoder(), + ); +} + +export type CreateFixedDelegationInput< + TAccountDelegator extends string = string, + TAccountSubscriptionAuthority extends string = string, + TAccountDelegationAccount extends string = string, + TAccountDelegatee extends string = string, + TAccountSystemProgram extends string = string, +> = { + /** The user creating the delegation */ + delegator: TransactionSigner; + /** The subscription_authority PDA for this token */ + subscriptionAuthority: Address; + /** The fixed delegation PDA being created */ + delegationAccount: Address; + /** The user receiving delegation rights */ + delegatee: Address; + /** The system program */ + systemProgram?: Address; + fixedDelegation: CreateFixedDelegationInstructionDataArgs['fixedDelegation']; +}; + +export function getCreateFixedDelegationInstruction< + TAccountDelegator extends string, + TAccountSubscriptionAuthority extends string, + TAccountDelegationAccount extends string, + TAccountDelegatee extends string, + TAccountSystemProgram extends string, + TProgramAddress extends Address = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, +>( + input: CreateFixedDelegationInput< + TAccountDelegator, + TAccountSubscriptionAuthority, + TAccountDelegationAccount, + TAccountDelegatee, + TAccountSystemProgram + >, + config?: { programAddress?: TProgramAddress }, +): CreateFixedDelegationInstruction< + TProgramAddress, + TAccountDelegator, + TAccountSubscriptionAuthority, + TAccountDelegationAccount, + TAccountDelegatee, + TAccountSystemProgram +> { + // Program address. + const programAddress = config?.programAddress ?? SUBSCRIPTIONS_PROGRAM_ADDRESS; + + // Original accounts. + const originalAccounts = { + delegator: { value: input.delegator ?? null, isWritable: true }, + subscriptionAuthority: { + value: input.subscriptionAuthority ?? null, + isWritable: false, + }, + delegationAccount: { + value: input.delegationAccount ?? null, + isWritable: true, + }, + delegatee: { value: input.delegatee ?? null, isWritable: false }, + systemProgram: { value: input.systemProgram ?? null, isWritable: false }, + }; + const accounts = originalAccounts as Record; + + // Original args. + const args = { ...input }; + + // Resolve default values. + if (!accounts.systemProgram.value) { + accounts.systemProgram.value = + '11111111111111111111111111111111' as Address<'11111111111111111111111111111111'>; + } + + const getAccountMeta = getAccountMetaFactory(programAddress, 'programId'); + return Object.freeze({ + accounts: [ + getAccountMeta('delegator', accounts.delegator), + getAccountMeta('subscriptionAuthority', accounts.subscriptionAuthority), + getAccountMeta('delegationAccount', accounts.delegationAccount), + getAccountMeta('delegatee', accounts.delegatee), + getAccountMeta('systemProgram', accounts.systemProgram), + ], + data: getCreateFixedDelegationInstructionDataEncoder().encode(args as CreateFixedDelegationInstructionDataArgs), + programAddress, + } as CreateFixedDelegationInstruction< + TProgramAddress, + TAccountDelegator, + TAccountSubscriptionAuthority, + TAccountDelegationAccount, + TAccountDelegatee, + TAccountSystemProgram + >); +} + +export type ParsedCreateFixedDelegationInstruction< + TProgram extends string = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, + TAccountMetas extends readonly AccountMeta[] = readonly AccountMeta[], +> = { + programAddress: Address; + accounts: { + /** The user creating the delegation */ + delegator: TAccountMetas[0]; + /** The subscription_authority PDA for this token */ + subscriptionAuthority: TAccountMetas[1]; + /** The fixed delegation PDA being created */ + delegationAccount: TAccountMetas[2]; + /** The user receiving delegation rights */ + delegatee: TAccountMetas[3]; + /** The system program */ + systemProgram: TAccountMetas[4]; + }; + data: CreateFixedDelegationInstructionData; +}; + +export function parseCreateFixedDelegationInstruction< + TProgram extends string, + TAccountMetas extends readonly AccountMeta[], +>( + instruction: Instruction & + InstructionWithAccounts & + InstructionWithData, +): ParsedCreateFixedDelegationInstruction { + if (instruction.accounts.length < 5) { + throw new SolanaError(SOLANA_ERROR__PROGRAM_CLIENTS__INSUFFICIENT_ACCOUNT_METAS, { + actualAccountMetas: instruction.accounts.length, + expectedAccountMetas: 5, + }); + } + let accountIndex = 0; + const getNextAccount = () => { + const accountMeta = (instruction.accounts as TAccountMetas)[accountIndex]!; + accountIndex += 1; + return accountMeta; + }; + return { + programAddress: instruction.programAddress, + accounts: { + delegator: getNextAccount(), + subscriptionAuthority: getNextAccount(), + delegationAccount: getNextAccount(), + delegatee: getNextAccount(), + systemProgram: getNextAccount(), + }, + data: getCreateFixedDelegationInstructionDataDecoder().decode(instruction.data), + }; +} diff --git a/typescript/packages/mpp/src/generated/subscriptions/instructions/createPlan.ts b/typescript/packages/mpp/src/generated/subscriptions/instructions/createPlan.ts new file mode 100644 index 000000000..cb28deb4d --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/instructions/createPlan.ts @@ -0,0 +1,236 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getStructDecoder, + getStructEncoder, + getU8Decoder, + getU8Encoder, + SOLANA_ERROR__PROGRAM_CLIENTS__INSUFFICIENT_ACCOUNT_METAS, + SolanaError, + transformEncoder, + type AccountMeta, + type AccountSignerMeta, + type Address, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, + type Instruction, + type InstructionWithAccounts, + type InstructionWithData, + type ReadonlyAccount, + type ReadonlyUint8Array, + type TransactionSigner, + type WritableAccount, + type WritableSignerAccount, +} from '@solana/kit'; +import { getAccountMetaFactory, type ResolvedInstructionAccount } from '@solana/program-client-core'; +import { SUBSCRIPTIONS_PROGRAM_ADDRESS } from '../programs/index.js'; +import { getPlanDataDecoder, getPlanDataEncoder, type PlanData, type PlanDataArgs } from '../types/index.js'; + +export const CREATE_PLAN_DISCRIMINATOR = 7; + +export function getCreatePlanDiscriminatorBytes(): ReadonlyUint8Array { + return getU8Encoder().encode(CREATE_PLAN_DISCRIMINATOR); +} + +export type CreatePlanInstruction< + TProgram extends string = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, + TAccountMerchant extends string | AccountMeta = string, + TAccountPlanPda extends string | AccountMeta = string, + TAccountTokenMint extends string | AccountMeta = string, + TAccountSystemProgram extends string | AccountMeta = '11111111111111111111111111111111', + TAccountTokenProgram extends string | AccountMeta = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + TRemainingAccounts extends readonly AccountMeta[] = [], +> = Instruction & + InstructionWithData & + InstructionWithAccounts< + [ + TAccountMerchant extends string + ? WritableSignerAccount & AccountSignerMeta + : TAccountMerchant, + TAccountPlanPda extends string ? WritableAccount : TAccountPlanPda, + TAccountTokenMint extends string ? ReadonlyAccount : TAccountTokenMint, + TAccountSystemProgram extends string ? ReadonlyAccount : TAccountSystemProgram, + TAccountTokenProgram extends string ? ReadonlyAccount : TAccountTokenProgram, + ...TRemainingAccounts, + ] + >; + +export type CreatePlanInstructionData = { + discriminator: number; + planData: PlanData; +}; + +export type CreatePlanInstructionDataArgs = { planData: PlanDataArgs }; + +export function getCreatePlanInstructionDataEncoder(): FixedSizeEncoder { + return transformEncoder( + getStructEncoder([ + ['discriminator', getU8Encoder()], + ['planData', getPlanDataEncoder()], + ]), + value => ({ ...value, discriminator: CREATE_PLAN_DISCRIMINATOR }), + ); +} + +export function getCreatePlanInstructionDataDecoder(): FixedSizeDecoder { + return getStructDecoder([ + ['discriminator', getU8Decoder()], + ['planData', getPlanDataDecoder()], + ]); +} + +export function getCreatePlanInstructionDataCodec(): FixedSizeCodec< + CreatePlanInstructionDataArgs, + CreatePlanInstructionData +> { + return combineCodec(getCreatePlanInstructionDataEncoder(), getCreatePlanInstructionDataDecoder()); +} + +export type CreatePlanInput< + TAccountMerchant extends string = string, + TAccountPlanPda extends string = string, + TAccountTokenMint extends string = string, + TAccountSystemProgram extends string = string, + TAccountTokenProgram extends string = string, +> = { + /** The merchant creating the plan */ + merchant: TransactionSigner; + /** The plan PDA being created */ + planPda: Address; + /** The token mint */ + tokenMint: Address; + /** The system program */ + systemProgram?: Address; + /** The token program */ + tokenProgram?: Address; + planData: CreatePlanInstructionDataArgs['planData']; +}; + +export function getCreatePlanInstruction< + TAccountMerchant extends string, + TAccountPlanPda extends string, + TAccountTokenMint extends string, + TAccountSystemProgram extends string, + TAccountTokenProgram extends string, + TProgramAddress extends Address = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, +>( + input: CreatePlanInput< + TAccountMerchant, + TAccountPlanPda, + TAccountTokenMint, + TAccountSystemProgram, + TAccountTokenProgram + >, + config?: { programAddress?: TProgramAddress }, +): CreatePlanInstruction< + TProgramAddress, + TAccountMerchant, + TAccountPlanPda, + TAccountTokenMint, + TAccountSystemProgram, + TAccountTokenProgram +> { + // Program address. + const programAddress = config?.programAddress ?? SUBSCRIPTIONS_PROGRAM_ADDRESS; + + // Original accounts. + const originalAccounts = { + merchant: { value: input.merchant ?? null, isWritable: true }, + planPda: { value: input.planPda ?? null, isWritable: true }, + tokenMint: { value: input.tokenMint ?? null, isWritable: false }, + systemProgram: { value: input.systemProgram ?? null, isWritable: false }, + tokenProgram: { value: input.tokenProgram ?? null, isWritable: false }, + }; + const accounts = originalAccounts as Record; + + // Original args. + const args = { ...input }; + + // Resolve default values. + if (!accounts.systemProgram.value) { + accounts.systemProgram.value = + '11111111111111111111111111111111' as Address<'11111111111111111111111111111111'>; + } + if (!accounts.tokenProgram.value) { + accounts.tokenProgram.value = + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' as Address<'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'>; + } + + const getAccountMeta = getAccountMetaFactory(programAddress, 'programId'); + return Object.freeze({ + accounts: [ + getAccountMeta('merchant', accounts.merchant), + getAccountMeta('planPda', accounts.planPda), + getAccountMeta('tokenMint', accounts.tokenMint), + getAccountMeta('systemProgram', accounts.systemProgram), + getAccountMeta('tokenProgram', accounts.tokenProgram), + ], + data: getCreatePlanInstructionDataEncoder().encode(args as CreatePlanInstructionDataArgs), + programAddress, + } as CreatePlanInstruction< + TProgramAddress, + TAccountMerchant, + TAccountPlanPda, + TAccountTokenMint, + TAccountSystemProgram, + TAccountTokenProgram + >); +} + +export type ParsedCreatePlanInstruction< + TProgram extends string = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, + TAccountMetas extends readonly AccountMeta[] = readonly AccountMeta[], +> = { + programAddress: Address; + accounts: { + /** The merchant creating the plan */ + merchant: TAccountMetas[0]; + /** The plan PDA being created */ + planPda: TAccountMetas[1]; + /** The token mint */ + tokenMint: TAccountMetas[2]; + /** The system program */ + systemProgram: TAccountMetas[3]; + /** The token program */ + tokenProgram: TAccountMetas[4]; + }; + data: CreatePlanInstructionData; +}; + +export function parseCreatePlanInstruction( + instruction: Instruction & + InstructionWithAccounts & + InstructionWithData, +): ParsedCreatePlanInstruction { + if (instruction.accounts.length < 5) { + throw new SolanaError(SOLANA_ERROR__PROGRAM_CLIENTS__INSUFFICIENT_ACCOUNT_METAS, { + actualAccountMetas: instruction.accounts.length, + expectedAccountMetas: 5, + }); + } + let accountIndex = 0; + const getNextAccount = () => { + const accountMeta = (instruction.accounts as TAccountMetas)[accountIndex]!; + accountIndex += 1; + return accountMeta; + }; + return { + programAddress: instruction.programAddress, + accounts: { + merchant: getNextAccount(), + planPda: getNextAccount(), + tokenMint: getNextAccount(), + systemProgram: getNextAccount(), + tokenProgram: getNextAccount(), + }, + data: getCreatePlanInstructionDataDecoder().decode(instruction.data), + }; +} diff --git a/typescript/packages/mpp/src/generated/subscriptions/instructions/createRecurringDelegation.ts b/typescript/packages/mpp/src/generated/subscriptions/instructions/createRecurringDelegation.ts new file mode 100644 index 000000000..8cc59dad9 --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/instructions/createRecurringDelegation.ts @@ -0,0 +1,260 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getStructDecoder, + getStructEncoder, + getU8Decoder, + getU8Encoder, + SOLANA_ERROR__PROGRAM_CLIENTS__INSUFFICIENT_ACCOUNT_METAS, + SolanaError, + transformEncoder, + type AccountMeta, + type AccountSignerMeta, + type Address, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, + type Instruction, + type InstructionWithAccounts, + type InstructionWithData, + type ReadonlyAccount, + type ReadonlyUint8Array, + type TransactionSigner, + type WritableAccount, + type WritableSignerAccount, +} from '@solana/kit'; +import { getAccountMetaFactory, type ResolvedInstructionAccount } from '@solana/program-client-core'; +import { SUBSCRIPTIONS_PROGRAM_ADDRESS } from '../programs/index.js'; +import { + getCreateRecurringDelegationDataDecoder, + getCreateRecurringDelegationDataEncoder, + type CreateRecurringDelegationData, + type CreateRecurringDelegationDataArgs, +} from '../types/index.js'; + +export const CREATE_RECURRING_DELEGATION_DISCRIMINATOR = 2; + +export function getCreateRecurringDelegationDiscriminatorBytes(): ReadonlyUint8Array { + return getU8Encoder().encode(CREATE_RECURRING_DELEGATION_DISCRIMINATOR); +} + +export type CreateRecurringDelegationInstruction< + TProgram extends string = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, + TAccountDelegator extends string | AccountMeta = string, + TAccountSubscriptionAuthority extends string | AccountMeta = string, + TAccountDelegationAccount extends string | AccountMeta = string, + TAccountDelegatee extends string | AccountMeta = string, + TAccountSystemProgram extends string | AccountMeta = '11111111111111111111111111111111', + TRemainingAccounts extends readonly AccountMeta[] = [], +> = Instruction & + InstructionWithData & + InstructionWithAccounts< + [ + TAccountDelegator extends string + ? WritableSignerAccount & AccountSignerMeta + : TAccountDelegator, + TAccountSubscriptionAuthority extends string + ? ReadonlyAccount + : TAccountSubscriptionAuthority, + TAccountDelegationAccount extends string + ? WritableAccount + : TAccountDelegationAccount, + TAccountDelegatee extends string ? ReadonlyAccount : TAccountDelegatee, + TAccountSystemProgram extends string ? ReadonlyAccount : TAccountSystemProgram, + ...TRemainingAccounts, + ] + >; + +export type CreateRecurringDelegationInstructionData = { + discriminator: number; + recurringDelegation: CreateRecurringDelegationData; +}; + +export type CreateRecurringDelegationInstructionDataArgs = { + recurringDelegation: CreateRecurringDelegationDataArgs; +}; + +export function getCreateRecurringDelegationInstructionDataEncoder(): FixedSizeEncoder { + return transformEncoder( + getStructEncoder([ + ['discriminator', getU8Encoder()], + ['recurringDelegation', getCreateRecurringDelegationDataEncoder()], + ]), + value => ({ + ...value, + discriminator: CREATE_RECURRING_DELEGATION_DISCRIMINATOR, + }), + ); +} + +export function getCreateRecurringDelegationInstructionDataDecoder(): FixedSizeDecoder { + return getStructDecoder([ + ['discriminator', getU8Decoder()], + ['recurringDelegation', getCreateRecurringDelegationDataDecoder()], + ]); +} + +export function getCreateRecurringDelegationInstructionDataCodec(): FixedSizeCodec< + CreateRecurringDelegationInstructionDataArgs, + CreateRecurringDelegationInstructionData +> { + return combineCodec( + getCreateRecurringDelegationInstructionDataEncoder(), + getCreateRecurringDelegationInstructionDataDecoder(), + ); +} + +export type CreateRecurringDelegationInput< + TAccountDelegator extends string = string, + TAccountSubscriptionAuthority extends string = string, + TAccountDelegationAccount extends string = string, + TAccountDelegatee extends string = string, + TAccountSystemProgram extends string = string, +> = { + /** The user creating the delegation */ + delegator: TransactionSigner; + /** The subscription_authority PDA for this token */ + subscriptionAuthority: Address; + /** The recurring delegation PDA being created */ + delegationAccount: Address; + /** The user receiving delegation rights */ + delegatee: Address; + /** The system program */ + systemProgram?: Address; + recurringDelegation: CreateRecurringDelegationInstructionDataArgs['recurringDelegation']; +}; + +export function getCreateRecurringDelegationInstruction< + TAccountDelegator extends string, + TAccountSubscriptionAuthority extends string, + TAccountDelegationAccount extends string, + TAccountDelegatee extends string, + TAccountSystemProgram extends string, + TProgramAddress extends Address = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, +>( + input: CreateRecurringDelegationInput< + TAccountDelegator, + TAccountSubscriptionAuthority, + TAccountDelegationAccount, + TAccountDelegatee, + TAccountSystemProgram + >, + config?: { programAddress?: TProgramAddress }, +): CreateRecurringDelegationInstruction< + TProgramAddress, + TAccountDelegator, + TAccountSubscriptionAuthority, + TAccountDelegationAccount, + TAccountDelegatee, + TAccountSystemProgram +> { + // Program address. + const programAddress = config?.programAddress ?? SUBSCRIPTIONS_PROGRAM_ADDRESS; + + // Original accounts. + const originalAccounts = { + delegator: { value: input.delegator ?? null, isWritable: true }, + subscriptionAuthority: { + value: input.subscriptionAuthority ?? null, + isWritable: false, + }, + delegationAccount: { + value: input.delegationAccount ?? null, + isWritable: true, + }, + delegatee: { value: input.delegatee ?? null, isWritable: false }, + systemProgram: { value: input.systemProgram ?? null, isWritable: false }, + }; + const accounts = originalAccounts as Record; + + // Original args. + const args = { ...input }; + + // Resolve default values. + if (!accounts.systemProgram.value) { + accounts.systemProgram.value = + '11111111111111111111111111111111' as Address<'11111111111111111111111111111111'>; + } + + const getAccountMeta = getAccountMetaFactory(programAddress, 'programId'); + return Object.freeze({ + accounts: [ + getAccountMeta('delegator', accounts.delegator), + getAccountMeta('subscriptionAuthority', accounts.subscriptionAuthority), + getAccountMeta('delegationAccount', accounts.delegationAccount), + getAccountMeta('delegatee', accounts.delegatee), + getAccountMeta('systemProgram', accounts.systemProgram), + ], + data: getCreateRecurringDelegationInstructionDataEncoder().encode( + args as CreateRecurringDelegationInstructionDataArgs, + ), + programAddress, + } as CreateRecurringDelegationInstruction< + TProgramAddress, + TAccountDelegator, + TAccountSubscriptionAuthority, + TAccountDelegationAccount, + TAccountDelegatee, + TAccountSystemProgram + >); +} + +export type ParsedCreateRecurringDelegationInstruction< + TProgram extends string = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, + TAccountMetas extends readonly AccountMeta[] = readonly AccountMeta[], +> = { + programAddress: Address; + accounts: { + /** The user creating the delegation */ + delegator: TAccountMetas[0]; + /** The subscription_authority PDA for this token */ + subscriptionAuthority: TAccountMetas[1]; + /** The recurring delegation PDA being created */ + delegationAccount: TAccountMetas[2]; + /** The user receiving delegation rights */ + delegatee: TAccountMetas[3]; + /** The system program */ + systemProgram: TAccountMetas[4]; + }; + data: CreateRecurringDelegationInstructionData; +}; + +export function parseCreateRecurringDelegationInstruction< + TProgram extends string, + TAccountMetas extends readonly AccountMeta[], +>( + instruction: Instruction & + InstructionWithAccounts & + InstructionWithData, +): ParsedCreateRecurringDelegationInstruction { + if (instruction.accounts.length < 5) { + throw new SolanaError(SOLANA_ERROR__PROGRAM_CLIENTS__INSUFFICIENT_ACCOUNT_METAS, { + actualAccountMetas: instruction.accounts.length, + expectedAccountMetas: 5, + }); + } + let accountIndex = 0; + const getNextAccount = () => { + const accountMeta = (instruction.accounts as TAccountMetas)[accountIndex]!; + accountIndex += 1; + return accountMeta; + }; + return { + programAddress: instruction.programAddress, + accounts: { + delegator: getNextAccount(), + subscriptionAuthority: getNextAccount(), + delegationAccount: getNextAccount(), + delegatee: getNextAccount(), + systemProgram: getNextAccount(), + }, + data: getCreateRecurringDelegationInstructionDataDecoder().decode(instruction.data), + }; +} diff --git a/typescript/packages/mpp/src/generated/subscriptions/instructions/deletePlan.ts b/typescript/packages/mpp/src/generated/subscriptions/instructions/deletePlan.ts new file mode 100644 index 000000000..388119218 --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/instructions/deletePlan.ts @@ -0,0 +1,149 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getStructDecoder, + getStructEncoder, + getU8Decoder, + getU8Encoder, + SOLANA_ERROR__PROGRAM_CLIENTS__INSUFFICIENT_ACCOUNT_METAS, + SolanaError, + transformEncoder, + type AccountMeta, + type AccountSignerMeta, + type Address, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, + type Instruction, + type InstructionWithAccounts, + type InstructionWithData, + type ReadonlyUint8Array, + type TransactionSigner, + type WritableAccount, + type WritableSignerAccount, +} from '@solana/kit'; +import { getAccountMetaFactory, type ResolvedInstructionAccount } from '@solana/program-client-core'; +import { SUBSCRIPTIONS_PROGRAM_ADDRESS } from '../programs/index.js'; + +export const DELETE_PLAN_DISCRIMINATOR = 9; + +export function getDeletePlanDiscriminatorBytes(): ReadonlyUint8Array { + return getU8Encoder().encode(DELETE_PLAN_DISCRIMINATOR); +} + +export type DeletePlanInstruction< + TProgram extends string = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, + TAccountOwner extends string | AccountMeta = string, + TAccountPlanPda extends string | AccountMeta = string, + TRemainingAccounts extends readonly AccountMeta[] = [], +> = Instruction & + InstructionWithData & + InstructionWithAccounts< + [ + TAccountOwner extends string + ? WritableSignerAccount & AccountSignerMeta + : TAccountOwner, + TAccountPlanPda extends string ? WritableAccount : TAccountPlanPda, + ...TRemainingAccounts, + ] + >; + +export type DeletePlanInstructionData = { discriminator: number }; + +export type DeletePlanInstructionDataArgs = {}; + +export function getDeletePlanInstructionDataEncoder(): FixedSizeEncoder { + return transformEncoder(getStructEncoder([['discriminator', getU8Encoder()]]), value => ({ + ...value, + discriminator: DELETE_PLAN_DISCRIMINATOR, + })); +} + +export function getDeletePlanInstructionDataDecoder(): FixedSizeDecoder { + return getStructDecoder([['discriminator', getU8Decoder()]]); +} + +export function getDeletePlanInstructionDataCodec(): FixedSizeCodec< + DeletePlanInstructionDataArgs, + DeletePlanInstructionData +> { + return combineCodec(getDeletePlanInstructionDataEncoder(), getDeletePlanInstructionDataDecoder()); +} + +export type DeletePlanInput = { + /** The plan owner deleting the plan (receives rent) */ + owner: TransactionSigner; + /** The plan PDA being deleted */ + planPda: Address; +}; + +export function getDeletePlanInstruction< + TAccountOwner extends string, + TAccountPlanPda extends string, + TProgramAddress extends Address = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, +>( + input: DeletePlanInput, + config?: { programAddress?: TProgramAddress }, +): DeletePlanInstruction { + // Program address. + const programAddress = config?.programAddress ?? SUBSCRIPTIONS_PROGRAM_ADDRESS; + + // Original accounts. + const originalAccounts = { + owner: { value: input.owner ?? null, isWritable: true }, + planPda: { value: input.planPda ?? null, isWritable: true }, + }; + const accounts = originalAccounts as Record; + + const getAccountMeta = getAccountMetaFactory(programAddress, 'programId'); + return Object.freeze({ + accounts: [getAccountMeta('owner', accounts.owner), getAccountMeta('planPda', accounts.planPda)], + data: getDeletePlanInstructionDataEncoder().encode({}), + programAddress, + } as DeletePlanInstruction); +} + +export type ParsedDeletePlanInstruction< + TProgram extends string = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, + TAccountMetas extends readonly AccountMeta[] = readonly AccountMeta[], +> = { + programAddress: Address; + accounts: { + /** The plan owner deleting the plan (receives rent) */ + owner: TAccountMetas[0]; + /** The plan PDA being deleted */ + planPda: TAccountMetas[1]; + }; + data: DeletePlanInstructionData; +}; + +export function parseDeletePlanInstruction( + instruction: Instruction & + InstructionWithAccounts & + InstructionWithData, +): ParsedDeletePlanInstruction { + if (instruction.accounts.length < 2) { + throw new SolanaError(SOLANA_ERROR__PROGRAM_CLIENTS__INSUFFICIENT_ACCOUNT_METAS, { + actualAccountMetas: instruction.accounts.length, + expectedAccountMetas: 2, + }); + } + let accountIndex = 0; + const getNextAccount = () => { + const accountMeta = (instruction.accounts as TAccountMetas)[accountIndex]!; + accountIndex += 1; + return accountMeta; + }; + return { + programAddress: instruction.programAddress, + accounts: { owner: getNextAccount(), planPda: getNextAccount() }, + data: getDeletePlanInstructionDataDecoder().decode(instruction.data), + }; +} diff --git a/typescript/packages/mpp/src/generated/subscriptions/instructions/index.ts b/typescript/packages/mpp/src/generated/subscriptions/instructions/index.ts new file mode 100644 index 000000000..98c577cf6 --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/instructions/index.ts @@ -0,0 +1,22 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +export * from './cancelSubscription.js'; +export * from './closeSubscriptionAuthority.js'; +export * from './createFixedDelegation.js'; +export * from './createPlan.js'; +export * from './createRecurringDelegation.js'; +export * from './deletePlan.js'; +export * from './initSubscriptionAuthority.js'; +export * from './resumeSubscription.js'; +export * from './revokeDelegation.js'; +export * from './subscribe.js'; +export * from './transferFixed.js'; +export * from './transferRecurring.js'; +export * from './transferSubscription.js'; +export * from './updatePlan.js'; diff --git a/typescript/packages/mpp/src/generated/subscriptions/instructions/initSubscriptionAuthority.ts b/typescript/packages/mpp/src/generated/subscriptions/instructions/initSubscriptionAuthority.ts new file mode 100644 index 000000000..5aeee1d2a --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/instructions/initSubscriptionAuthority.ts @@ -0,0 +1,353 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getStructDecoder, + getStructEncoder, + getU8Decoder, + getU8Encoder, + SOLANA_ERROR__PROGRAM_CLIENTS__INSUFFICIENT_ACCOUNT_METAS, + SolanaError, + transformEncoder, + type AccountMeta, + type AccountSignerMeta, + type Address, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, + type Instruction, + type InstructionWithAccounts, + type InstructionWithData, + type ReadonlyAccount, + type ReadonlyUint8Array, + type TransactionSigner, + type WritableAccount, + type WritableSignerAccount, +} from '@solana/kit'; +import { + getAccountMetaFactory, + getAddressFromResolvedInstructionAccount, + type ResolvedInstructionAccount, +} from '@solana/program-client-core'; +import { findSubscriptionAuthorityPda } from '../pdas/index.js'; +import { SUBSCRIPTIONS_PROGRAM_ADDRESS } from '../programs/index.js'; + +export const INIT_SUBSCRIPTION_AUTHORITY_DISCRIMINATOR = 0; + +export function getInitSubscriptionAuthorityDiscriminatorBytes(): ReadonlyUint8Array { + return getU8Encoder().encode(INIT_SUBSCRIPTION_AUTHORITY_DISCRIMINATOR); +} + +export type InitSubscriptionAuthorityInstruction< + TProgram extends string = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, + TAccountOwner extends string | AccountMeta = string, + TAccountSubscriptionAuthority extends string | AccountMeta = string, + TAccountTokenMint extends string | AccountMeta = string, + TAccountUserAta extends string | AccountMeta = string, + TAccountSystemProgram extends string | AccountMeta = '11111111111111111111111111111111', + TAccountTokenProgram extends string | AccountMeta = string, + TRemainingAccounts extends readonly AccountMeta[] = [], +> = Instruction & + InstructionWithData & + InstructionWithAccounts< + [ + TAccountOwner extends string + ? WritableSignerAccount & AccountSignerMeta + : TAccountOwner, + TAccountSubscriptionAuthority extends string + ? WritableAccount + : TAccountSubscriptionAuthority, + TAccountTokenMint extends string ? ReadonlyAccount : TAccountTokenMint, + TAccountUserAta extends string ? WritableAccount : TAccountUserAta, + TAccountSystemProgram extends string ? ReadonlyAccount : TAccountSystemProgram, + TAccountTokenProgram extends string ? ReadonlyAccount : TAccountTokenProgram, + ...TRemainingAccounts, + ] + >; + +export type InitSubscriptionAuthorityInstructionData = { + discriminator: number; +}; + +export type InitSubscriptionAuthorityInstructionDataArgs = {}; + +export function getInitSubscriptionAuthorityInstructionDataEncoder(): FixedSizeEncoder { + return transformEncoder(getStructEncoder([['discriminator', getU8Encoder()]]), value => ({ + ...value, + discriminator: INIT_SUBSCRIPTION_AUTHORITY_DISCRIMINATOR, + })); +} + +export function getInitSubscriptionAuthorityInstructionDataDecoder(): FixedSizeDecoder { + return getStructDecoder([['discriminator', getU8Decoder()]]); +} + +export function getInitSubscriptionAuthorityInstructionDataCodec(): FixedSizeCodec< + InitSubscriptionAuthorityInstructionDataArgs, + InitSubscriptionAuthorityInstructionData +> { + return combineCodec( + getInitSubscriptionAuthorityInstructionDataEncoder(), + getInitSubscriptionAuthorityInstructionDataDecoder(), + ); +} + +export type InitSubscriptionAuthorityAsyncInput< + TAccountOwner extends string = string, + TAccountSubscriptionAuthority extends string = string, + TAccountTokenMint extends string = string, + TAccountUserAta extends string = string, + TAccountSystemProgram extends string = string, + TAccountTokenProgram extends string = string, +> = { + /** The owner of the subscription-authority program */ + owner: TransactionSigner; + /** The subscription_authority PDA that will be the delegate instance for this token */ + subscriptionAuthority?: Address; + /** The token mint that we are creating a subscription-authority account for */ + tokenMint: Address; + /** The ata that we are setting up delegation for */ + userAta: Address; + /** The system program */ + systemProgram?: Address; + /** Token program */ + tokenProgram: Address; +}; + +export async function getInitSubscriptionAuthorityInstructionAsync< + TAccountOwner extends string, + TAccountSubscriptionAuthority extends string, + TAccountTokenMint extends string, + TAccountUserAta extends string, + TAccountSystemProgram extends string, + TAccountTokenProgram extends string, + TProgramAddress extends Address = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, +>( + input: InitSubscriptionAuthorityAsyncInput< + TAccountOwner, + TAccountSubscriptionAuthority, + TAccountTokenMint, + TAccountUserAta, + TAccountSystemProgram, + TAccountTokenProgram + >, + config?: { programAddress?: TProgramAddress }, +): Promise< + InitSubscriptionAuthorityInstruction< + TProgramAddress, + TAccountOwner, + TAccountSubscriptionAuthority, + TAccountTokenMint, + TAccountUserAta, + TAccountSystemProgram, + TAccountTokenProgram + > +> { + // Program address. + const programAddress = config?.programAddress ?? SUBSCRIPTIONS_PROGRAM_ADDRESS; + + // Original accounts. + const originalAccounts = { + owner: { value: input.owner ?? null, isWritable: true }, + subscriptionAuthority: { + value: input.subscriptionAuthority ?? null, + isWritable: true, + }, + tokenMint: { value: input.tokenMint ?? null, isWritable: false }, + userAta: { value: input.userAta ?? null, isWritable: true }, + systemProgram: { value: input.systemProgram ?? null, isWritable: false }, + tokenProgram: { value: input.tokenProgram ?? null, isWritable: false }, + }; + const accounts = originalAccounts as Record; + + // Resolve default values. + if (!accounts.subscriptionAuthority.value) { + accounts.subscriptionAuthority.value = await findSubscriptionAuthorityPda({ + user: getAddressFromResolvedInstructionAccount('owner', accounts.owner.value), + tokenMint: getAddressFromResolvedInstructionAccount('tokenMint', accounts.tokenMint.value), + }); + } + if (!accounts.systemProgram.value) { + accounts.systemProgram.value = + '11111111111111111111111111111111' as Address<'11111111111111111111111111111111'>; + } + + const getAccountMeta = getAccountMetaFactory(programAddress, 'programId'); + return Object.freeze({ + accounts: [ + getAccountMeta('owner', accounts.owner), + getAccountMeta('subscriptionAuthority', accounts.subscriptionAuthority), + getAccountMeta('tokenMint', accounts.tokenMint), + getAccountMeta('userAta', accounts.userAta), + getAccountMeta('systemProgram', accounts.systemProgram), + getAccountMeta('tokenProgram', accounts.tokenProgram), + ], + data: getInitSubscriptionAuthorityInstructionDataEncoder().encode({}), + programAddress, + } as InitSubscriptionAuthorityInstruction< + TProgramAddress, + TAccountOwner, + TAccountSubscriptionAuthority, + TAccountTokenMint, + TAccountUserAta, + TAccountSystemProgram, + TAccountTokenProgram + >); +} + +export type InitSubscriptionAuthorityInput< + TAccountOwner extends string = string, + TAccountSubscriptionAuthority extends string = string, + TAccountTokenMint extends string = string, + TAccountUserAta extends string = string, + TAccountSystemProgram extends string = string, + TAccountTokenProgram extends string = string, +> = { + /** The owner of the subscription-authority program */ + owner: TransactionSigner; + /** The subscription_authority PDA that will be the delegate instance for this token */ + subscriptionAuthority: Address; + /** The token mint that we are creating a subscription-authority account for */ + tokenMint: Address; + /** The ata that we are setting up delegation for */ + userAta: Address; + /** The system program */ + systemProgram?: Address; + /** Token program */ + tokenProgram: Address; +}; + +export function getInitSubscriptionAuthorityInstruction< + TAccountOwner extends string, + TAccountSubscriptionAuthority extends string, + TAccountTokenMint extends string, + TAccountUserAta extends string, + TAccountSystemProgram extends string, + TAccountTokenProgram extends string, + TProgramAddress extends Address = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, +>( + input: InitSubscriptionAuthorityInput< + TAccountOwner, + TAccountSubscriptionAuthority, + TAccountTokenMint, + TAccountUserAta, + TAccountSystemProgram, + TAccountTokenProgram + >, + config?: { programAddress?: TProgramAddress }, +): InitSubscriptionAuthorityInstruction< + TProgramAddress, + TAccountOwner, + TAccountSubscriptionAuthority, + TAccountTokenMint, + TAccountUserAta, + TAccountSystemProgram, + TAccountTokenProgram +> { + // Program address. + const programAddress = config?.programAddress ?? SUBSCRIPTIONS_PROGRAM_ADDRESS; + + // Original accounts. + const originalAccounts = { + owner: { value: input.owner ?? null, isWritable: true }, + subscriptionAuthority: { + value: input.subscriptionAuthority ?? null, + isWritable: true, + }, + tokenMint: { value: input.tokenMint ?? null, isWritable: false }, + userAta: { value: input.userAta ?? null, isWritable: true }, + systemProgram: { value: input.systemProgram ?? null, isWritable: false }, + tokenProgram: { value: input.tokenProgram ?? null, isWritable: false }, + }; + const accounts = originalAccounts as Record; + + // Resolve default values. + if (!accounts.systemProgram.value) { + accounts.systemProgram.value = + '11111111111111111111111111111111' as Address<'11111111111111111111111111111111'>; + } + + const getAccountMeta = getAccountMetaFactory(programAddress, 'programId'); + return Object.freeze({ + accounts: [ + getAccountMeta('owner', accounts.owner), + getAccountMeta('subscriptionAuthority', accounts.subscriptionAuthority), + getAccountMeta('tokenMint', accounts.tokenMint), + getAccountMeta('userAta', accounts.userAta), + getAccountMeta('systemProgram', accounts.systemProgram), + getAccountMeta('tokenProgram', accounts.tokenProgram), + ], + data: getInitSubscriptionAuthorityInstructionDataEncoder().encode({}), + programAddress, + } as InitSubscriptionAuthorityInstruction< + TProgramAddress, + TAccountOwner, + TAccountSubscriptionAuthority, + TAccountTokenMint, + TAccountUserAta, + TAccountSystemProgram, + TAccountTokenProgram + >); +} + +export type ParsedInitSubscriptionAuthorityInstruction< + TProgram extends string = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, + TAccountMetas extends readonly AccountMeta[] = readonly AccountMeta[], +> = { + programAddress: Address; + accounts: { + /** The owner of the subscription-authority program */ + owner: TAccountMetas[0]; + /** The subscription_authority PDA that will be the delegate instance for this token */ + subscriptionAuthority: TAccountMetas[1]; + /** The token mint that we are creating a subscription-authority account for */ + tokenMint: TAccountMetas[2]; + /** The ata that we are setting up delegation for */ + userAta: TAccountMetas[3]; + /** The system program */ + systemProgram: TAccountMetas[4]; + /** Token program */ + tokenProgram: TAccountMetas[5]; + }; + data: InitSubscriptionAuthorityInstructionData; +}; + +export function parseInitSubscriptionAuthorityInstruction< + TProgram extends string, + TAccountMetas extends readonly AccountMeta[], +>( + instruction: Instruction & + InstructionWithAccounts & + InstructionWithData, +): ParsedInitSubscriptionAuthorityInstruction { + if (instruction.accounts.length < 6) { + throw new SolanaError(SOLANA_ERROR__PROGRAM_CLIENTS__INSUFFICIENT_ACCOUNT_METAS, { + actualAccountMetas: instruction.accounts.length, + expectedAccountMetas: 6, + }); + } + let accountIndex = 0; + const getNextAccount = () => { + const accountMeta = (instruction.accounts as TAccountMetas)[accountIndex]!; + accountIndex += 1; + return accountMeta; + }; + return { + programAddress: instruction.programAddress, + accounts: { + owner: getNextAccount(), + subscriptionAuthority: getNextAccount(), + tokenMint: getNextAccount(), + userAta: getNextAccount(), + systemProgram: getNextAccount(), + tokenProgram: getNextAccount(), + }, + data: getInitSubscriptionAuthorityInstructionDataDecoder().decode(instruction.data), + }; +} diff --git a/typescript/packages/mpp/src/generated/subscriptions/instructions/resumeSubscription.ts b/typescript/packages/mpp/src/generated/subscriptions/instructions/resumeSubscription.ts new file mode 100644 index 000000000..98c71e8bf --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/instructions/resumeSubscription.ts @@ -0,0 +1,325 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getStructDecoder, + getStructEncoder, + getU8Decoder, + getU8Encoder, + SOLANA_ERROR__PROGRAM_CLIENTS__INSUFFICIENT_ACCOUNT_METAS, + SolanaError, + transformEncoder, + type AccountMeta, + type AccountSignerMeta, + type Address, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, + type Instruction, + type InstructionWithAccounts, + type InstructionWithData, + type ReadonlyAccount, + type ReadonlySignerAccount, + type ReadonlyUint8Array, + type TransactionSigner, + type WritableAccount, +} from '@solana/kit'; +import { + getAccountMetaFactory, + getAddressFromResolvedInstructionAccount, + type ResolvedInstructionAccount, +} from '@solana/program-client-core'; +import { findSubscriptionDelegationPda } from '../pdas/index.js'; +import { SUBSCRIPTIONS_PROGRAM_ADDRESS } from '../programs/index.js'; + +export const RESUME_SUBSCRIPTION_DISCRIMINATOR = 13; + +export function getResumeSubscriptionDiscriminatorBytes(): ReadonlyUint8Array { + return getU8Encoder().encode(RESUME_SUBSCRIPTION_DISCRIMINATOR); +} + +export type ResumeSubscriptionInstruction< + TProgram extends string = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, + TAccountSubscriber extends string | AccountMeta = string, + TAccountPlanPda extends string | AccountMeta = string, + TAccountSubscriptionPda extends string | AccountMeta = string, + TAccountEventAuthority extends string | AccountMeta = '3Hnj4BYoDgtpBuqXfiy7Y8cNa3jXaNd4oqgSXBzkMcH7', + TAccountSelfProgram extends string | AccountMeta = 'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44', + TRemainingAccounts extends readonly AccountMeta[] = [], +> = Instruction & + InstructionWithData & + InstructionWithAccounts< + [ + TAccountSubscriber extends string + ? ReadonlySignerAccount & AccountSignerMeta + : TAccountSubscriber, + TAccountPlanPda extends string ? ReadonlyAccount : TAccountPlanPda, + TAccountSubscriptionPda extends string ? WritableAccount : TAccountSubscriptionPda, + TAccountEventAuthority extends string ? ReadonlyAccount : TAccountEventAuthority, + TAccountSelfProgram extends string ? ReadonlyAccount : TAccountSelfProgram, + ...TRemainingAccounts, + ] + >; + +export type ResumeSubscriptionInstructionData = { discriminator: number }; + +export type ResumeSubscriptionInstructionDataArgs = {}; + +export function getResumeSubscriptionInstructionDataEncoder(): FixedSizeEncoder { + return transformEncoder(getStructEncoder([['discriminator', getU8Encoder()]]), value => ({ + ...value, + discriminator: RESUME_SUBSCRIPTION_DISCRIMINATOR, + })); +} + +export function getResumeSubscriptionInstructionDataDecoder(): FixedSizeDecoder { + return getStructDecoder([['discriminator', getU8Decoder()]]); +} + +export function getResumeSubscriptionInstructionDataCodec(): FixedSizeCodec< + ResumeSubscriptionInstructionDataArgs, + ResumeSubscriptionInstructionData +> { + return combineCodec(getResumeSubscriptionInstructionDataEncoder(), getResumeSubscriptionInstructionDataDecoder()); +} + +export type ResumeSubscriptionAsyncInput< + TAccountSubscriber extends string = string, + TAccountPlanPda extends string = string, + TAccountSubscriptionPda extends string = string, + TAccountEventAuthority extends string = string, + TAccountSelfProgram extends string = string, +> = { + /** The subscriber resuming the subscription */ + subscriber: TransactionSigner; + /** The plan PDA for the subscription */ + planPda: Address; + /** The subscription PDA being resumed */ + subscriptionPda?: Address; + /** The event authority PDA */ + eventAuthority?: Address; + /** This program (for self-CPI) */ + selfProgram?: Address; +}; + +export async function getResumeSubscriptionInstructionAsync< + TAccountSubscriber extends string, + TAccountPlanPda extends string, + TAccountSubscriptionPda extends string, + TAccountEventAuthority extends string, + TAccountSelfProgram extends string, + TProgramAddress extends Address = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, +>( + input: ResumeSubscriptionAsyncInput< + TAccountSubscriber, + TAccountPlanPda, + TAccountSubscriptionPda, + TAccountEventAuthority, + TAccountSelfProgram + >, + config?: { programAddress?: TProgramAddress }, +): Promise< + ResumeSubscriptionInstruction< + TProgramAddress, + TAccountSubscriber, + TAccountPlanPda, + TAccountSubscriptionPda, + TAccountEventAuthority, + TAccountSelfProgram + > +> { + // Program address. + const programAddress = config?.programAddress ?? SUBSCRIPTIONS_PROGRAM_ADDRESS; + + // Original accounts. + const originalAccounts = { + subscriber: { value: input.subscriber ?? null, isWritable: false }, + planPda: { value: input.planPda ?? null, isWritable: false }, + subscriptionPda: { value: input.subscriptionPda ?? null, isWritable: true }, + eventAuthority: { value: input.eventAuthority ?? null, isWritable: false }, + selfProgram: { value: input.selfProgram ?? null, isWritable: false }, + }; + const accounts = originalAccounts as Record; + + // Resolve default values. + if (!accounts.subscriptionPda.value) { + accounts.subscriptionPda.value = await findSubscriptionDelegationPda({ + planPda: getAddressFromResolvedInstructionAccount('planPda', accounts.planPda.value), + subscriber: getAddressFromResolvedInstructionAccount('subscriber', accounts.subscriber.value), + }); + } + if (!accounts.eventAuthority.value) { + accounts.eventAuthority.value = + '3Hnj4BYoDgtpBuqXfiy7Y8cNa3jXaNd4oqgSXBzkMcH7' as Address<'3Hnj4BYoDgtpBuqXfiy7Y8cNa3jXaNd4oqgSXBzkMcH7'>; + } + if (!accounts.selfProgram.value) { + accounts.selfProgram.value = + 'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44' as Address<'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44'>; + } + + const getAccountMeta = getAccountMetaFactory(programAddress, 'programId'); + return Object.freeze({ + accounts: [ + getAccountMeta('subscriber', accounts.subscriber), + getAccountMeta('planPda', accounts.planPda), + getAccountMeta('subscriptionPda', accounts.subscriptionPda), + getAccountMeta('eventAuthority', accounts.eventAuthority), + getAccountMeta('selfProgram', accounts.selfProgram), + ], + data: getResumeSubscriptionInstructionDataEncoder().encode({}), + programAddress, + } as ResumeSubscriptionInstruction< + TProgramAddress, + TAccountSubscriber, + TAccountPlanPda, + TAccountSubscriptionPda, + TAccountEventAuthority, + TAccountSelfProgram + >); +} + +export type ResumeSubscriptionInput< + TAccountSubscriber extends string = string, + TAccountPlanPda extends string = string, + TAccountSubscriptionPda extends string = string, + TAccountEventAuthority extends string = string, + TAccountSelfProgram extends string = string, +> = { + /** The subscriber resuming the subscription */ + subscriber: TransactionSigner; + /** The plan PDA for the subscription */ + planPda: Address; + /** The subscription PDA being resumed */ + subscriptionPda: Address; + /** The event authority PDA */ + eventAuthority?: Address; + /** This program (for self-CPI) */ + selfProgram?: Address; +}; + +export function getResumeSubscriptionInstruction< + TAccountSubscriber extends string, + TAccountPlanPda extends string, + TAccountSubscriptionPda extends string, + TAccountEventAuthority extends string, + TAccountSelfProgram extends string, + TProgramAddress extends Address = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, +>( + input: ResumeSubscriptionInput< + TAccountSubscriber, + TAccountPlanPda, + TAccountSubscriptionPda, + TAccountEventAuthority, + TAccountSelfProgram + >, + config?: { programAddress?: TProgramAddress }, +): ResumeSubscriptionInstruction< + TProgramAddress, + TAccountSubscriber, + TAccountPlanPda, + TAccountSubscriptionPda, + TAccountEventAuthority, + TAccountSelfProgram +> { + // Program address. + const programAddress = config?.programAddress ?? SUBSCRIPTIONS_PROGRAM_ADDRESS; + + // Original accounts. + const originalAccounts = { + subscriber: { value: input.subscriber ?? null, isWritable: false }, + planPda: { value: input.planPda ?? null, isWritable: false }, + subscriptionPda: { value: input.subscriptionPda ?? null, isWritable: true }, + eventAuthority: { value: input.eventAuthority ?? null, isWritable: false }, + selfProgram: { value: input.selfProgram ?? null, isWritable: false }, + }; + const accounts = originalAccounts as Record; + + // Resolve default values. + if (!accounts.eventAuthority.value) { + accounts.eventAuthority.value = + '3Hnj4BYoDgtpBuqXfiy7Y8cNa3jXaNd4oqgSXBzkMcH7' as Address<'3Hnj4BYoDgtpBuqXfiy7Y8cNa3jXaNd4oqgSXBzkMcH7'>; + } + if (!accounts.selfProgram.value) { + accounts.selfProgram.value = + 'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44' as Address<'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44'>; + } + + const getAccountMeta = getAccountMetaFactory(programAddress, 'programId'); + return Object.freeze({ + accounts: [ + getAccountMeta('subscriber', accounts.subscriber), + getAccountMeta('planPda', accounts.planPda), + getAccountMeta('subscriptionPda', accounts.subscriptionPda), + getAccountMeta('eventAuthority', accounts.eventAuthority), + getAccountMeta('selfProgram', accounts.selfProgram), + ], + data: getResumeSubscriptionInstructionDataEncoder().encode({}), + programAddress, + } as ResumeSubscriptionInstruction< + TProgramAddress, + TAccountSubscriber, + TAccountPlanPda, + TAccountSubscriptionPda, + TAccountEventAuthority, + TAccountSelfProgram + >); +} + +export type ParsedResumeSubscriptionInstruction< + TProgram extends string = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, + TAccountMetas extends readonly AccountMeta[] = readonly AccountMeta[], +> = { + programAddress: Address; + accounts: { + /** The subscriber resuming the subscription */ + subscriber: TAccountMetas[0]; + /** The plan PDA for the subscription */ + planPda: TAccountMetas[1]; + /** The subscription PDA being resumed */ + subscriptionPda: TAccountMetas[2]; + /** The event authority PDA */ + eventAuthority: TAccountMetas[3]; + /** This program (for self-CPI) */ + selfProgram: TAccountMetas[4]; + }; + data: ResumeSubscriptionInstructionData; +}; + +export function parseResumeSubscriptionInstruction< + TProgram extends string, + TAccountMetas extends readonly AccountMeta[], +>( + instruction: Instruction & + InstructionWithAccounts & + InstructionWithData, +): ParsedResumeSubscriptionInstruction { + if (instruction.accounts.length < 5) { + throw new SolanaError(SOLANA_ERROR__PROGRAM_CLIENTS__INSUFFICIENT_ACCOUNT_METAS, { + actualAccountMetas: instruction.accounts.length, + expectedAccountMetas: 5, + }); + } + let accountIndex = 0; + const getNextAccount = () => { + const accountMeta = (instruction.accounts as TAccountMetas)[accountIndex]!; + accountIndex += 1; + return accountMeta; + }; + return { + programAddress: instruction.programAddress, + accounts: { + subscriber: getNextAccount(), + planPda: getNextAccount(), + subscriptionPda: getNextAccount(), + eventAuthority: getNextAccount(), + selfProgram: getNextAccount(), + }, + data: getResumeSubscriptionInstructionDataDecoder().decode(instruction.data), + }; +} diff --git a/typescript/packages/mpp/src/generated/subscriptions/instructions/revokeDelegation.ts b/typescript/packages/mpp/src/generated/subscriptions/instructions/revokeDelegation.ts new file mode 100644 index 000000000..4cf95f672 --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/instructions/revokeDelegation.ts @@ -0,0 +1,163 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getStructDecoder, + getStructEncoder, + getU8Decoder, + getU8Encoder, + SOLANA_ERROR__PROGRAM_CLIENTS__INSUFFICIENT_ACCOUNT_METAS, + SolanaError, + transformEncoder, + type AccountMeta, + type AccountSignerMeta, + type Address, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, + type Instruction, + type InstructionWithAccounts, + type InstructionWithData, + type ReadonlyUint8Array, + type TransactionSigner, + type WritableAccount, + type WritableSignerAccount, +} from '@solana/kit'; +import { getAccountMetaFactory, type ResolvedInstructionAccount } from '@solana/program-client-core'; +import { SUBSCRIPTIONS_PROGRAM_ADDRESS } from '../programs/index.js'; + +export const REVOKE_DELEGATION_DISCRIMINATOR = 3; + +export function getRevokeDelegationDiscriminatorBytes(): ReadonlyUint8Array { + return getU8Encoder().encode(REVOKE_DELEGATION_DISCRIMINATOR); +} + +export type RevokeDelegationInstruction< + TProgram extends string = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, + TAccountAuthority extends string | AccountMeta = string, + TAccountDelegationAccount extends string | AccountMeta = string, + TRemainingAccounts extends readonly AccountMeta[] = [], +> = Instruction & + InstructionWithData & + InstructionWithAccounts< + [ + TAccountAuthority extends string + ? WritableSignerAccount & AccountSignerMeta + : TAccountAuthority, + TAccountDelegationAccount extends string + ? WritableAccount + : TAccountDelegationAccount, + ...TRemainingAccounts, + ] + >; + +export type RevokeDelegationInstructionData = { discriminator: number }; + +export type RevokeDelegationInstructionDataArgs = {}; + +export function getRevokeDelegationInstructionDataEncoder(): FixedSizeEncoder { + return transformEncoder(getStructEncoder([['discriminator', getU8Encoder()]]), value => ({ + ...value, + discriminator: REVOKE_DELEGATION_DISCRIMINATOR, + })); +} + +export function getRevokeDelegationInstructionDataDecoder(): FixedSizeDecoder { + return getStructDecoder([['discriminator', getU8Decoder()]]); +} + +export function getRevokeDelegationInstructionDataCodec(): FixedSizeCodec< + RevokeDelegationInstructionDataArgs, + RevokeDelegationInstructionData +> { + return combineCodec(getRevokeDelegationInstructionDataEncoder(), getRevokeDelegationInstructionDataDecoder()); +} + +export type RevokeDelegationInput< + TAccountAuthority extends string = string, + TAccountDelegationAccount extends string = string, +> = { + /** The delegator revoking the delegation (receives rent) */ + authority: TransactionSigner; + /** The delegation PDA to close */ + delegationAccount: Address; +}; + +export function getRevokeDelegationInstruction< + TAccountAuthority extends string, + TAccountDelegationAccount extends string, + TProgramAddress extends Address = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, +>( + input: RevokeDelegationInput, + config?: { programAddress?: TProgramAddress }, +): RevokeDelegationInstruction { + // Program address. + const programAddress = config?.programAddress ?? SUBSCRIPTIONS_PROGRAM_ADDRESS; + + // Original accounts. + const originalAccounts = { + authority: { value: input.authority ?? null, isWritable: true }, + delegationAccount: { + value: input.delegationAccount ?? null, + isWritable: true, + }, + }; + const accounts = originalAccounts as Record; + + const getAccountMeta = getAccountMetaFactory(programAddress, 'programId'); + return Object.freeze({ + accounts: [ + getAccountMeta('authority', accounts.authority), + getAccountMeta('delegationAccount', accounts.delegationAccount), + ], + data: getRevokeDelegationInstructionDataEncoder().encode({}), + programAddress, + } as RevokeDelegationInstruction); +} + +export type ParsedRevokeDelegationInstruction< + TProgram extends string = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, + TAccountMetas extends readonly AccountMeta[] = readonly AccountMeta[], +> = { + programAddress: Address; + accounts: { + /** The delegator revoking the delegation (receives rent) */ + authority: TAccountMetas[0]; + /** The delegation PDA to close */ + delegationAccount: TAccountMetas[1]; + }; + data: RevokeDelegationInstructionData; +}; + +export function parseRevokeDelegationInstruction( + instruction: Instruction & + InstructionWithAccounts & + InstructionWithData, +): ParsedRevokeDelegationInstruction { + if (instruction.accounts.length < 2) { + throw new SolanaError(SOLANA_ERROR__PROGRAM_CLIENTS__INSUFFICIENT_ACCOUNT_METAS, { + actualAccountMetas: instruction.accounts.length, + expectedAccountMetas: 2, + }); + } + let accountIndex = 0; + const getNextAccount = () => { + const accountMeta = (instruction.accounts as TAccountMetas)[accountIndex]!; + accountIndex += 1; + return accountMeta; + }; + return { + programAddress: instruction.programAddress, + accounts: { + authority: getNextAccount(), + delegationAccount: getNextAccount(), + }, + data: getRevokeDelegationInstructionDataDecoder().decode(instruction.data), + }; +} diff --git a/typescript/packages/mpp/src/generated/subscriptions/instructions/subscribe.ts b/typescript/packages/mpp/src/generated/subscriptions/instructions/subscribe.ts new file mode 100644 index 000000000..385e76047 --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/instructions/subscribe.ts @@ -0,0 +1,430 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getStructDecoder, + getStructEncoder, + getU8Decoder, + getU8Encoder, + SOLANA_ERROR__PROGRAM_CLIENTS__INSUFFICIENT_ACCOUNT_METAS, + SolanaError, + transformEncoder, + type AccountMeta, + type AccountSignerMeta, + type Address, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, + type Instruction, + type InstructionWithAccounts, + type InstructionWithData, + type ReadonlyAccount, + type ReadonlyUint8Array, + type TransactionSigner, + type WritableAccount, + type WritableSignerAccount, +} from '@solana/kit'; +import { + getAccountMetaFactory, + getAddressFromResolvedInstructionAccount, + type ResolvedInstructionAccount, +} from '@solana/program-client-core'; +import { findSubscriptionDelegationPda } from '../pdas/index.js'; +import { SUBSCRIPTIONS_PROGRAM_ADDRESS } from '../programs/index.js'; +import { + getSubscribeDataDecoder, + getSubscribeDataEncoder, + type SubscribeData, + type SubscribeDataArgs, +} from '../types/index.js'; + +export const SUBSCRIBE_DISCRIMINATOR = 11; + +export function getSubscribeDiscriminatorBytes(): ReadonlyUint8Array { + return getU8Encoder().encode(SUBSCRIBE_DISCRIMINATOR); +} + +export type SubscribeInstruction< + TProgram extends string = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, + TAccountSubscriber extends string | AccountMeta = string, + TAccountMerchant extends string | AccountMeta = string, + TAccountPlanPda extends string | AccountMeta = string, + TAccountSubscriptionPda extends string | AccountMeta = string, + TAccountSubscriptionAuthorityPda extends string | AccountMeta = string, + TAccountSystemProgram extends string | AccountMeta = '11111111111111111111111111111111', + TAccountEventAuthority extends string | AccountMeta = '3Hnj4BYoDgtpBuqXfiy7Y8cNa3jXaNd4oqgSXBzkMcH7', + TAccountSelfProgram extends string | AccountMeta = 'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44', + TRemainingAccounts extends readonly AccountMeta[] = [], +> = Instruction & + InstructionWithData & + InstructionWithAccounts< + [ + TAccountSubscriber extends string + ? WritableSignerAccount & AccountSignerMeta + : TAccountSubscriber, + TAccountMerchant extends string ? ReadonlyAccount : TAccountMerchant, + TAccountPlanPda extends string ? ReadonlyAccount : TAccountPlanPda, + TAccountSubscriptionPda extends string ? WritableAccount : TAccountSubscriptionPda, + TAccountSubscriptionAuthorityPda extends string + ? ReadonlyAccount + : TAccountSubscriptionAuthorityPda, + TAccountSystemProgram extends string ? ReadonlyAccount : TAccountSystemProgram, + TAccountEventAuthority extends string ? ReadonlyAccount : TAccountEventAuthority, + TAccountSelfProgram extends string ? ReadonlyAccount : TAccountSelfProgram, + ...TRemainingAccounts, + ] + >; + +export type SubscribeInstructionData = { + discriminator: number; + subscribeData: SubscribeData; +}; + +export type SubscribeInstructionDataArgs = { subscribeData: SubscribeDataArgs }; + +export function getSubscribeInstructionDataEncoder(): FixedSizeEncoder { + return transformEncoder( + getStructEncoder([ + ['discriminator', getU8Encoder()], + ['subscribeData', getSubscribeDataEncoder()], + ]), + value => ({ ...value, discriminator: SUBSCRIBE_DISCRIMINATOR }), + ); +} + +export function getSubscribeInstructionDataDecoder(): FixedSizeDecoder { + return getStructDecoder([ + ['discriminator', getU8Decoder()], + ['subscribeData', getSubscribeDataDecoder()], + ]); +} + +export function getSubscribeInstructionDataCodec(): FixedSizeCodec< + SubscribeInstructionDataArgs, + SubscribeInstructionData +> { + return combineCodec(getSubscribeInstructionDataEncoder(), getSubscribeInstructionDataDecoder()); +} + +export type SubscribeAsyncInput< + TAccountSubscriber extends string = string, + TAccountMerchant extends string = string, + TAccountPlanPda extends string = string, + TAccountSubscriptionPda extends string = string, + TAccountSubscriptionAuthorityPda extends string = string, + TAccountSystemProgram extends string = string, + TAccountEventAuthority extends string = string, + TAccountSelfProgram extends string = string, +> = { + /** The subscriber creating the subscription (pays rent) */ + subscriber: TransactionSigner; + /** The merchant who owns the plan */ + merchant: Address; + /** The plan PDA to subscribe to */ + planPda: Address; + /** The subscription PDA being created */ + subscriptionPda?: Address; + /** The subscriber's SubscriptionAuthority PDA for the plan's mint */ + subscriptionAuthorityPda: Address; + /** The system program */ + systemProgram?: Address; + /** The event authority PDA */ + eventAuthority?: Address; + /** This program (for self-CPI) */ + selfProgram?: Address; + subscribeData: SubscribeInstructionDataArgs['subscribeData']; +}; + +export async function getSubscribeInstructionAsync< + TAccountSubscriber extends string, + TAccountMerchant extends string, + TAccountPlanPda extends string, + TAccountSubscriptionPda extends string, + TAccountSubscriptionAuthorityPda extends string, + TAccountSystemProgram extends string, + TAccountEventAuthority extends string, + TAccountSelfProgram extends string, + TProgramAddress extends Address = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, +>( + input: SubscribeAsyncInput< + TAccountSubscriber, + TAccountMerchant, + TAccountPlanPda, + TAccountSubscriptionPda, + TAccountSubscriptionAuthorityPda, + TAccountSystemProgram, + TAccountEventAuthority, + TAccountSelfProgram + >, + config?: { programAddress?: TProgramAddress }, +): Promise< + SubscribeInstruction< + TProgramAddress, + TAccountSubscriber, + TAccountMerchant, + TAccountPlanPda, + TAccountSubscriptionPda, + TAccountSubscriptionAuthorityPda, + TAccountSystemProgram, + TAccountEventAuthority, + TAccountSelfProgram + > +> { + // Program address. + const programAddress = config?.programAddress ?? SUBSCRIPTIONS_PROGRAM_ADDRESS; + + // Original accounts. + const originalAccounts = { + subscriber: { value: input.subscriber ?? null, isWritable: true }, + merchant: { value: input.merchant ?? null, isWritable: false }, + planPda: { value: input.planPda ?? null, isWritable: false }, + subscriptionPda: { value: input.subscriptionPda ?? null, isWritable: true }, + subscriptionAuthorityPda: { + value: input.subscriptionAuthorityPda ?? null, + isWritable: false, + }, + systemProgram: { value: input.systemProgram ?? null, isWritable: false }, + eventAuthority: { value: input.eventAuthority ?? null, isWritable: false }, + selfProgram: { value: input.selfProgram ?? null, isWritable: false }, + }; + const accounts = originalAccounts as Record; + + // Original args. + const args = { ...input }; + + // Resolve default values. + if (!accounts.subscriptionPda.value) { + accounts.subscriptionPda.value = await findSubscriptionDelegationPda({ + planPda: getAddressFromResolvedInstructionAccount('planPda', accounts.planPda.value), + subscriber: getAddressFromResolvedInstructionAccount('subscriber', accounts.subscriber.value), + }); + } + if (!accounts.systemProgram.value) { + accounts.systemProgram.value = + '11111111111111111111111111111111' as Address<'11111111111111111111111111111111'>; + } + if (!accounts.eventAuthority.value) { + accounts.eventAuthority.value = + '3Hnj4BYoDgtpBuqXfiy7Y8cNa3jXaNd4oqgSXBzkMcH7' as Address<'3Hnj4BYoDgtpBuqXfiy7Y8cNa3jXaNd4oqgSXBzkMcH7'>; + } + if (!accounts.selfProgram.value) { + accounts.selfProgram.value = + 'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44' as Address<'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44'>; + } + + const getAccountMeta = getAccountMetaFactory(programAddress, 'programId'); + return Object.freeze({ + accounts: [ + getAccountMeta('subscriber', accounts.subscriber), + getAccountMeta('merchant', accounts.merchant), + getAccountMeta('planPda', accounts.planPda), + getAccountMeta('subscriptionPda', accounts.subscriptionPda), + getAccountMeta('subscriptionAuthorityPda', accounts.subscriptionAuthorityPda), + getAccountMeta('systemProgram', accounts.systemProgram), + getAccountMeta('eventAuthority', accounts.eventAuthority), + getAccountMeta('selfProgram', accounts.selfProgram), + ], + data: getSubscribeInstructionDataEncoder().encode(args as SubscribeInstructionDataArgs), + programAddress, + } as SubscribeInstruction< + TProgramAddress, + TAccountSubscriber, + TAccountMerchant, + TAccountPlanPda, + TAccountSubscriptionPda, + TAccountSubscriptionAuthorityPda, + TAccountSystemProgram, + TAccountEventAuthority, + TAccountSelfProgram + >); +} + +export type SubscribeInput< + TAccountSubscriber extends string = string, + TAccountMerchant extends string = string, + TAccountPlanPda extends string = string, + TAccountSubscriptionPda extends string = string, + TAccountSubscriptionAuthorityPda extends string = string, + TAccountSystemProgram extends string = string, + TAccountEventAuthority extends string = string, + TAccountSelfProgram extends string = string, +> = { + /** The subscriber creating the subscription (pays rent) */ + subscriber: TransactionSigner; + /** The merchant who owns the plan */ + merchant: Address; + /** The plan PDA to subscribe to */ + planPda: Address; + /** The subscription PDA being created */ + subscriptionPda: Address; + /** The subscriber's SubscriptionAuthority PDA for the plan's mint */ + subscriptionAuthorityPda: Address; + /** The system program */ + systemProgram?: Address; + /** The event authority PDA */ + eventAuthority?: Address; + /** This program (for self-CPI) */ + selfProgram?: Address; + subscribeData: SubscribeInstructionDataArgs['subscribeData']; +}; + +export function getSubscribeInstruction< + TAccountSubscriber extends string, + TAccountMerchant extends string, + TAccountPlanPda extends string, + TAccountSubscriptionPda extends string, + TAccountSubscriptionAuthorityPda extends string, + TAccountSystemProgram extends string, + TAccountEventAuthority extends string, + TAccountSelfProgram extends string, + TProgramAddress extends Address = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, +>( + input: SubscribeInput< + TAccountSubscriber, + TAccountMerchant, + TAccountPlanPda, + TAccountSubscriptionPda, + TAccountSubscriptionAuthorityPda, + TAccountSystemProgram, + TAccountEventAuthority, + TAccountSelfProgram + >, + config?: { programAddress?: TProgramAddress }, +): SubscribeInstruction< + TProgramAddress, + TAccountSubscriber, + TAccountMerchant, + TAccountPlanPda, + TAccountSubscriptionPda, + TAccountSubscriptionAuthorityPda, + TAccountSystemProgram, + TAccountEventAuthority, + TAccountSelfProgram +> { + // Program address. + const programAddress = config?.programAddress ?? SUBSCRIPTIONS_PROGRAM_ADDRESS; + + // Original accounts. + const originalAccounts = { + subscriber: { value: input.subscriber ?? null, isWritable: true }, + merchant: { value: input.merchant ?? null, isWritable: false }, + planPda: { value: input.planPda ?? null, isWritable: false }, + subscriptionPda: { value: input.subscriptionPda ?? null, isWritable: true }, + subscriptionAuthorityPda: { + value: input.subscriptionAuthorityPda ?? null, + isWritable: false, + }, + systemProgram: { value: input.systemProgram ?? null, isWritable: false }, + eventAuthority: { value: input.eventAuthority ?? null, isWritable: false }, + selfProgram: { value: input.selfProgram ?? null, isWritable: false }, + }; + const accounts = originalAccounts as Record; + + // Original args. + const args = { ...input }; + + // Resolve default values. + if (!accounts.systemProgram.value) { + accounts.systemProgram.value = + '11111111111111111111111111111111' as Address<'11111111111111111111111111111111'>; + } + if (!accounts.eventAuthority.value) { + accounts.eventAuthority.value = + '3Hnj4BYoDgtpBuqXfiy7Y8cNa3jXaNd4oqgSXBzkMcH7' as Address<'3Hnj4BYoDgtpBuqXfiy7Y8cNa3jXaNd4oqgSXBzkMcH7'>; + } + if (!accounts.selfProgram.value) { + accounts.selfProgram.value = + 'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44' as Address<'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44'>; + } + + const getAccountMeta = getAccountMetaFactory(programAddress, 'programId'); + return Object.freeze({ + accounts: [ + getAccountMeta('subscriber', accounts.subscriber), + getAccountMeta('merchant', accounts.merchant), + getAccountMeta('planPda', accounts.planPda), + getAccountMeta('subscriptionPda', accounts.subscriptionPda), + getAccountMeta('subscriptionAuthorityPda', accounts.subscriptionAuthorityPda), + getAccountMeta('systemProgram', accounts.systemProgram), + getAccountMeta('eventAuthority', accounts.eventAuthority), + getAccountMeta('selfProgram', accounts.selfProgram), + ], + data: getSubscribeInstructionDataEncoder().encode(args as SubscribeInstructionDataArgs), + programAddress, + } as SubscribeInstruction< + TProgramAddress, + TAccountSubscriber, + TAccountMerchant, + TAccountPlanPda, + TAccountSubscriptionPda, + TAccountSubscriptionAuthorityPda, + TAccountSystemProgram, + TAccountEventAuthority, + TAccountSelfProgram + >); +} + +export type ParsedSubscribeInstruction< + TProgram extends string = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, + TAccountMetas extends readonly AccountMeta[] = readonly AccountMeta[], +> = { + programAddress: Address; + accounts: { + /** The subscriber creating the subscription (pays rent) */ + subscriber: TAccountMetas[0]; + /** The merchant who owns the plan */ + merchant: TAccountMetas[1]; + /** The plan PDA to subscribe to */ + planPda: TAccountMetas[2]; + /** The subscription PDA being created */ + subscriptionPda: TAccountMetas[3]; + /** The subscriber's SubscriptionAuthority PDA for the plan's mint */ + subscriptionAuthorityPda: TAccountMetas[4]; + /** The system program */ + systemProgram: TAccountMetas[5]; + /** The event authority PDA */ + eventAuthority: TAccountMetas[6]; + /** This program (for self-CPI) */ + selfProgram: TAccountMetas[7]; + }; + data: SubscribeInstructionData; +}; + +export function parseSubscribeInstruction( + instruction: Instruction & + InstructionWithAccounts & + InstructionWithData, +): ParsedSubscribeInstruction { + if (instruction.accounts.length < 8) { + throw new SolanaError(SOLANA_ERROR__PROGRAM_CLIENTS__INSUFFICIENT_ACCOUNT_METAS, { + actualAccountMetas: instruction.accounts.length, + expectedAccountMetas: 8, + }); + } + let accountIndex = 0; + const getNextAccount = () => { + const accountMeta = (instruction.accounts as TAccountMetas)[accountIndex]!; + accountIndex += 1; + return accountMeta; + }; + return { + programAddress: instruction.programAddress, + accounts: { + subscriber: getNextAccount(), + merchant: getNextAccount(), + planPda: getNextAccount(), + subscriptionPda: getNextAccount(), + subscriptionAuthorityPda: getNextAccount(), + systemProgram: getNextAccount(), + eventAuthority: getNextAccount(), + selfProgram: getNextAccount(), + }, + data: getSubscribeInstructionDataDecoder().decode(instruction.data), + }; +} diff --git a/typescript/packages/mpp/src/generated/subscriptions/instructions/transferFixed.ts b/typescript/packages/mpp/src/generated/subscriptions/instructions/transferFixed.ts new file mode 100644 index 000000000..6da7c0a98 --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/instructions/transferFixed.ts @@ -0,0 +1,304 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getStructDecoder, + getStructEncoder, + getU8Decoder, + getU8Encoder, + SOLANA_ERROR__PROGRAM_CLIENTS__INSUFFICIENT_ACCOUNT_METAS, + SolanaError, + transformEncoder, + type AccountMeta, + type AccountSignerMeta, + type Address, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, + type Instruction, + type InstructionWithAccounts, + type InstructionWithData, + type ReadonlyAccount, + type ReadonlySignerAccount, + type ReadonlyUint8Array, + type TransactionSigner, + type WritableAccount, +} from '@solana/kit'; +import { getAccountMetaFactory, type ResolvedInstructionAccount } from '@solana/program-client-core'; +import { SUBSCRIPTIONS_PROGRAM_ADDRESS } from '../programs/index.js'; +import { + getTransferDataDecoder, + getTransferDataEncoder, + type TransferData, + type TransferDataArgs, +} from '../types/index.js'; + +export const TRANSFER_FIXED_DISCRIMINATOR = 4; + +export function getTransferFixedDiscriminatorBytes(): ReadonlyUint8Array { + return getU8Encoder().encode(TRANSFER_FIXED_DISCRIMINATOR); +} + +export type TransferFixedInstruction< + TProgram extends string = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, + TAccountDelegationPda extends string | AccountMeta = string, + TAccountSubscriptionAuthority extends string | AccountMeta = string, + TAccountDelegatorAta extends string | AccountMeta = string, + TAccountReceiverAta extends string | AccountMeta = string, + TAccountTokenMint extends string | AccountMeta = string, + TAccountTokenProgram extends string | AccountMeta = string, + TAccountDelegatee extends string | AccountMeta = string, + TAccountEventAuthority extends string | AccountMeta = '3Hnj4BYoDgtpBuqXfiy7Y8cNa3jXaNd4oqgSXBzkMcH7', + TAccountSelfProgram extends string | AccountMeta = 'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44', + TRemainingAccounts extends readonly AccountMeta[] = [], +> = Instruction & + InstructionWithData & + InstructionWithAccounts< + [ + TAccountDelegationPda extends string ? WritableAccount : TAccountDelegationPda, + TAccountSubscriptionAuthority extends string + ? ReadonlyAccount + : TAccountSubscriptionAuthority, + TAccountDelegatorAta extends string ? WritableAccount : TAccountDelegatorAta, + TAccountReceiverAta extends string ? WritableAccount : TAccountReceiverAta, + TAccountTokenMint extends string ? ReadonlyAccount : TAccountTokenMint, + TAccountTokenProgram extends string ? ReadonlyAccount : TAccountTokenProgram, + TAccountDelegatee extends string + ? ReadonlySignerAccount & AccountSignerMeta + : TAccountDelegatee, + TAccountEventAuthority extends string ? ReadonlyAccount : TAccountEventAuthority, + TAccountSelfProgram extends string ? ReadonlyAccount : TAccountSelfProgram, + ...TRemainingAccounts, + ] + >; + +export type TransferFixedInstructionData = { + discriminator: number; + transferData: TransferData; +}; + +export type TransferFixedInstructionDataArgs = { + transferData: TransferDataArgs; +}; + +export function getTransferFixedInstructionDataEncoder(): FixedSizeEncoder { + return transformEncoder( + getStructEncoder([ + ['discriminator', getU8Encoder()], + ['transferData', getTransferDataEncoder()], + ]), + value => ({ ...value, discriminator: TRANSFER_FIXED_DISCRIMINATOR }), + ); +} + +export function getTransferFixedInstructionDataDecoder(): FixedSizeDecoder { + return getStructDecoder([ + ['discriminator', getU8Decoder()], + ['transferData', getTransferDataDecoder()], + ]); +} + +export function getTransferFixedInstructionDataCodec(): FixedSizeCodec< + TransferFixedInstructionDataArgs, + TransferFixedInstructionData +> { + return combineCodec(getTransferFixedInstructionDataEncoder(), getTransferFixedInstructionDataDecoder()); +} + +export type TransferFixedInput< + TAccountDelegationPda extends string = string, + TAccountSubscriptionAuthority extends string = string, + TAccountDelegatorAta extends string = string, + TAccountReceiverAta extends string = string, + TAccountTokenMint extends string = string, + TAccountTokenProgram extends string = string, + TAccountDelegatee extends string = string, + TAccountEventAuthority extends string = string, + TAccountSelfProgram extends string = string, +> = { + /** The fixed delegation PDA to transfer from */ + delegationPda: Address; + /** The subscription-authority PDA */ + subscriptionAuthority: Address; + /** The delegator's ATA to transfer from */ + delegatorAta: Address; + /** The receiver's ATA to transfer to */ + receiverAta: Address; + /** The token mint */ + tokenMint: Address; + /** Token program */ + tokenProgram: Address; + /** The delegatee signing the transfer */ + delegatee: TransactionSigner; + /** The event authority PDA */ + eventAuthority?: Address; + /** This program (for self-CPI) */ + selfProgram?: Address; + transferData: TransferFixedInstructionDataArgs['transferData']; +}; + +export function getTransferFixedInstruction< + TAccountDelegationPda extends string, + TAccountSubscriptionAuthority extends string, + TAccountDelegatorAta extends string, + TAccountReceiverAta extends string, + TAccountTokenMint extends string, + TAccountTokenProgram extends string, + TAccountDelegatee extends string, + TAccountEventAuthority extends string, + TAccountSelfProgram extends string, + TProgramAddress extends Address = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, +>( + input: TransferFixedInput< + TAccountDelegationPda, + TAccountSubscriptionAuthority, + TAccountDelegatorAta, + TAccountReceiverAta, + TAccountTokenMint, + TAccountTokenProgram, + TAccountDelegatee, + TAccountEventAuthority, + TAccountSelfProgram + >, + config?: { programAddress?: TProgramAddress }, +): TransferFixedInstruction< + TProgramAddress, + TAccountDelegationPda, + TAccountSubscriptionAuthority, + TAccountDelegatorAta, + TAccountReceiverAta, + TAccountTokenMint, + TAccountTokenProgram, + TAccountDelegatee, + TAccountEventAuthority, + TAccountSelfProgram +> { + // Program address. + const programAddress = config?.programAddress ?? SUBSCRIPTIONS_PROGRAM_ADDRESS; + + // Original accounts. + const originalAccounts = { + delegationPda: { value: input.delegationPda ?? null, isWritable: true }, + subscriptionAuthority: { + value: input.subscriptionAuthority ?? null, + isWritable: false, + }, + delegatorAta: { value: input.delegatorAta ?? null, isWritable: true }, + receiverAta: { value: input.receiverAta ?? null, isWritable: true }, + tokenMint: { value: input.tokenMint ?? null, isWritable: false }, + tokenProgram: { value: input.tokenProgram ?? null, isWritable: false }, + delegatee: { value: input.delegatee ?? null, isWritable: false }, + eventAuthority: { value: input.eventAuthority ?? null, isWritable: false }, + selfProgram: { value: input.selfProgram ?? null, isWritable: false }, + }; + const accounts = originalAccounts as Record; + + // Original args. + const args = { ...input }; + + // Resolve default values. + if (!accounts.eventAuthority.value) { + accounts.eventAuthority.value = + '3Hnj4BYoDgtpBuqXfiy7Y8cNa3jXaNd4oqgSXBzkMcH7' as Address<'3Hnj4BYoDgtpBuqXfiy7Y8cNa3jXaNd4oqgSXBzkMcH7'>; + } + if (!accounts.selfProgram.value) { + accounts.selfProgram.value = + 'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44' as Address<'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44'>; + } + + const getAccountMeta = getAccountMetaFactory(programAddress, 'programId'); + return Object.freeze({ + accounts: [ + getAccountMeta('delegationPda', accounts.delegationPda), + getAccountMeta('subscriptionAuthority', accounts.subscriptionAuthority), + getAccountMeta('delegatorAta', accounts.delegatorAta), + getAccountMeta('receiverAta', accounts.receiverAta), + getAccountMeta('tokenMint', accounts.tokenMint), + getAccountMeta('tokenProgram', accounts.tokenProgram), + getAccountMeta('delegatee', accounts.delegatee), + getAccountMeta('eventAuthority', accounts.eventAuthority), + getAccountMeta('selfProgram', accounts.selfProgram), + ], + data: getTransferFixedInstructionDataEncoder().encode(args as TransferFixedInstructionDataArgs), + programAddress, + } as TransferFixedInstruction< + TProgramAddress, + TAccountDelegationPda, + TAccountSubscriptionAuthority, + TAccountDelegatorAta, + TAccountReceiverAta, + TAccountTokenMint, + TAccountTokenProgram, + TAccountDelegatee, + TAccountEventAuthority, + TAccountSelfProgram + >); +} + +export type ParsedTransferFixedInstruction< + TProgram extends string = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, + TAccountMetas extends readonly AccountMeta[] = readonly AccountMeta[], +> = { + programAddress: Address; + accounts: { + /** The fixed delegation PDA to transfer from */ + delegationPda: TAccountMetas[0]; + /** The subscription-authority PDA */ + subscriptionAuthority: TAccountMetas[1]; + /** The delegator's ATA to transfer from */ + delegatorAta: TAccountMetas[2]; + /** The receiver's ATA to transfer to */ + receiverAta: TAccountMetas[3]; + /** The token mint */ + tokenMint: TAccountMetas[4]; + /** Token program */ + tokenProgram: TAccountMetas[5]; + /** The delegatee signing the transfer */ + delegatee: TAccountMetas[6]; + /** The event authority PDA */ + eventAuthority: TAccountMetas[7]; + /** This program (for self-CPI) */ + selfProgram: TAccountMetas[8]; + }; + data: TransferFixedInstructionData; +}; + +export function parseTransferFixedInstruction( + instruction: Instruction & + InstructionWithAccounts & + InstructionWithData, +): ParsedTransferFixedInstruction { + if (instruction.accounts.length < 9) { + throw new SolanaError(SOLANA_ERROR__PROGRAM_CLIENTS__INSUFFICIENT_ACCOUNT_METAS, { + actualAccountMetas: instruction.accounts.length, + expectedAccountMetas: 9, + }); + } + let accountIndex = 0; + const getNextAccount = () => { + const accountMeta = (instruction.accounts as TAccountMetas)[accountIndex]!; + accountIndex += 1; + return accountMeta; + }; + return { + programAddress: instruction.programAddress, + accounts: { + delegationPda: getNextAccount(), + subscriptionAuthority: getNextAccount(), + delegatorAta: getNextAccount(), + receiverAta: getNextAccount(), + tokenMint: getNextAccount(), + tokenProgram: getNextAccount(), + delegatee: getNextAccount(), + eventAuthority: getNextAccount(), + selfProgram: getNextAccount(), + }, + data: getTransferFixedInstructionDataDecoder().decode(instruction.data), + }; +} diff --git a/typescript/packages/mpp/src/generated/subscriptions/instructions/transferRecurring.ts b/typescript/packages/mpp/src/generated/subscriptions/instructions/transferRecurring.ts new file mode 100644 index 000000000..ca5ee09ac --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/instructions/transferRecurring.ts @@ -0,0 +1,307 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getStructDecoder, + getStructEncoder, + getU8Decoder, + getU8Encoder, + SOLANA_ERROR__PROGRAM_CLIENTS__INSUFFICIENT_ACCOUNT_METAS, + SolanaError, + transformEncoder, + type AccountMeta, + type AccountSignerMeta, + type Address, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, + type Instruction, + type InstructionWithAccounts, + type InstructionWithData, + type ReadonlyAccount, + type ReadonlySignerAccount, + type ReadonlyUint8Array, + type TransactionSigner, + type WritableAccount, +} from '@solana/kit'; +import { getAccountMetaFactory, type ResolvedInstructionAccount } from '@solana/program-client-core'; +import { SUBSCRIPTIONS_PROGRAM_ADDRESS } from '../programs/index.js'; +import { + getTransferDataDecoder, + getTransferDataEncoder, + type TransferData, + type TransferDataArgs, +} from '../types/index.js'; + +export const TRANSFER_RECURRING_DISCRIMINATOR = 5; + +export function getTransferRecurringDiscriminatorBytes(): ReadonlyUint8Array { + return getU8Encoder().encode(TRANSFER_RECURRING_DISCRIMINATOR); +} + +export type TransferRecurringInstruction< + TProgram extends string = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, + TAccountDelegationPda extends string | AccountMeta = string, + TAccountSubscriptionAuthority extends string | AccountMeta = string, + TAccountDelegatorAta extends string | AccountMeta = string, + TAccountReceiverAta extends string | AccountMeta = string, + TAccountTokenMint extends string | AccountMeta = string, + TAccountTokenProgram extends string | AccountMeta = string, + TAccountDelegatee extends string | AccountMeta = string, + TAccountEventAuthority extends string | AccountMeta = '3Hnj4BYoDgtpBuqXfiy7Y8cNa3jXaNd4oqgSXBzkMcH7', + TAccountSelfProgram extends string | AccountMeta = 'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44', + TRemainingAccounts extends readonly AccountMeta[] = [], +> = Instruction & + InstructionWithData & + InstructionWithAccounts< + [ + TAccountDelegationPda extends string ? WritableAccount : TAccountDelegationPda, + TAccountSubscriptionAuthority extends string + ? ReadonlyAccount + : TAccountSubscriptionAuthority, + TAccountDelegatorAta extends string ? WritableAccount : TAccountDelegatorAta, + TAccountReceiverAta extends string ? WritableAccount : TAccountReceiverAta, + TAccountTokenMint extends string ? ReadonlyAccount : TAccountTokenMint, + TAccountTokenProgram extends string ? ReadonlyAccount : TAccountTokenProgram, + TAccountDelegatee extends string + ? ReadonlySignerAccount & AccountSignerMeta + : TAccountDelegatee, + TAccountEventAuthority extends string ? ReadonlyAccount : TAccountEventAuthority, + TAccountSelfProgram extends string ? ReadonlyAccount : TAccountSelfProgram, + ...TRemainingAccounts, + ] + >; + +export type TransferRecurringInstructionData = { + discriminator: number; + transferData: TransferData; +}; + +export type TransferRecurringInstructionDataArgs = { + transferData: TransferDataArgs; +}; + +export function getTransferRecurringInstructionDataEncoder(): FixedSizeEncoder { + return transformEncoder( + getStructEncoder([ + ['discriminator', getU8Encoder()], + ['transferData', getTransferDataEncoder()], + ]), + value => ({ ...value, discriminator: TRANSFER_RECURRING_DISCRIMINATOR }), + ); +} + +export function getTransferRecurringInstructionDataDecoder(): FixedSizeDecoder { + return getStructDecoder([ + ['discriminator', getU8Decoder()], + ['transferData', getTransferDataDecoder()], + ]); +} + +export function getTransferRecurringInstructionDataCodec(): FixedSizeCodec< + TransferRecurringInstructionDataArgs, + TransferRecurringInstructionData +> { + return combineCodec(getTransferRecurringInstructionDataEncoder(), getTransferRecurringInstructionDataDecoder()); +} + +export type TransferRecurringInput< + TAccountDelegationPda extends string = string, + TAccountSubscriptionAuthority extends string = string, + TAccountDelegatorAta extends string = string, + TAccountReceiverAta extends string = string, + TAccountTokenMint extends string = string, + TAccountTokenProgram extends string = string, + TAccountDelegatee extends string = string, + TAccountEventAuthority extends string = string, + TAccountSelfProgram extends string = string, +> = { + /** The recurring delegation PDA to transfer from */ + delegationPda: Address; + /** The subscription-authority PDA */ + subscriptionAuthority: Address; + /** The delegator's ATA to transfer from */ + delegatorAta: Address; + /** The receiver's ATA to transfer to */ + receiverAta: Address; + /** The token mint */ + tokenMint: Address; + /** Token program */ + tokenProgram: Address; + /** The delegatee signing the transfer */ + delegatee: TransactionSigner; + /** The event authority PDA */ + eventAuthority?: Address; + /** This program (for self-CPI) */ + selfProgram?: Address; + transferData: TransferRecurringInstructionDataArgs['transferData']; +}; + +export function getTransferRecurringInstruction< + TAccountDelegationPda extends string, + TAccountSubscriptionAuthority extends string, + TAccountDelegatorAta extends string, + TAccountReceiverAta extends string, + TAccountTokenMint extends string, + TAccountTokenProgram extends string, + TAccountDelegatee extends string, + TAccountEventAuthority extends string, + TAccountSelfProgram extends string, + TProgramAddress extends Address = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, +>( + input: TransferRecurringInput< + TAccountDelegationPda, + TAccountSubscriptionAuthority, + TAccountDelegatorAta, + TAccountReceiverAta, + TAccountTokenMint, + TAccountTokenProgram, + TAccountDelegatee, + TAccountEventAuthority, + TAccountSelfProgram + >, + config?: { programAddress?: TProgramAddress }, +): TransferRecurringInstruction< + TProgramAddress, + TAccountDelegationPda, + TAccountSubscriptionAuthority, + TAccountDelegatorAta, + TAccountReceiverAta, + TAccountTokenMint, + TAccountTokenProgram, + TAccountDelegatee, + TAccountEventAuthority, + TAccountSelfProgram +> { + // Program address. + const programAddress = config?.programAddress ?? SUBSCRIPTIONS_PROGRAM_ADDRESS; + + // Original accounts. + const originalAccounts = { + delegationPda: { value: input.delegationPda ?? null, isWritable: true }, + subscriptionAuthority: { + value: input.subscriptionAuthority ?? null, + isWritable: false, + }, + delegatorAta: { value: input.delegatorAta ?? null, isWritable: true }, + receiverAta: { value: input.receiverAta ?? null, isWritable: true }, + tokenMint: { value: input.tokenMint ?? null, isWritable: false }, + tokenProgram: { value: input.tokenProgram ?? null, isWritable: false }, + delegatee: { value: input.delegatee ?? null, isWritable: false }, + eventAuthority: { value: input.eventAuthority ?? null, isWritable: false }, + selfProgram: { value: input.selfProgram ?? null, isWritable: false }, + }; + const accounts = originalAccounts as Record; + + // Original args. + const args = { ...input }; + + // Resolve default values. + if (!accounts.eventAuthority.value) { + accounts.eventAuthority.value = + '3Hnj4BYoDgtpBuqXfiy7Y8cNa3jXaNd4oqgSXBzkMcH7' as Address<'3Hnj4BYoDgtpBuqXfiy7Y8cNa3jXaNd4oqgSXBzkMcH7'>; + } + if (!accounts.selfProgram.value) { + accounts.selfProgram.value = + 'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44' as Address<'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44'>; + } + + const getAccountMeta = getAccountMetaFactory(programAddress, 'programId'); + return Object.freeze({ + accounts: [ + getAccountMeta('delegationPda', accounts.delegationPda), + getAccountMeta('subscriptionAuthority', accounts.subscriptionAuthority), + getAccountMeta('delegatorAta', accounts.delegatorAta), + getAccountMeta('receiverAta', accounts.receiverAta), + getAccountMeta('tokenMint', accounts.tokenMint), + getAccountMeta('tokenProgram', accounts.tokenProgram), + getAccountMeta('delegatee', accounts.delegatee), + getAccountMeta('eventAuthority', accounts.eventAuthority), + getAccountMeta('selfProgram', accounts.selfProgram), + ], + data: getTransferRecurringInstructionDataEncoder().encode(args as TransferRecurringInstructionDataArgs), + programAddress, + } as TransferRecurringInstruction< + TProgramAddress, + TAccountDelegationPda, + TAccountSubscriptionAuthority, + TAccountDelegatorAta, + TAccountReceiverAta, + TAccountTokenMint, + TAccountTokenProgram, + TAccountDelegatee, + TAccountEventAuthority, + TAccountSelfProgram + >); +} + +export type ParsedTransferRecurringInstruction< + TProgram extends string = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, + TAccountMetas extends readonly AccountMeta[] = readonly AccountMeta[], +> = { + programAddress: Address; + accounts: { + /** The recurring delegation PDA to transfer from */ + delegationPda: TAccountMetas[0]; + /** The subscription-authority PDA */ + subscriptionAuthority: TAccountMetas[1]; + /** The delegator's ATA to transfer from */ + delegatorAta: TAccountMetas[2]; + /** The receiver's ATA to transfer to */ + receiverAta: TAccountMetas[3]; + /** The token mint */ + tokenMint: TAccountMetas[4]; + /** Token program */ + tokenProgram: TAccountMetas[5]; + /** The delegatee signing the transfer */ + delegatee: TAccountMetas[6]; + /** The event authority PDA */ + eventAuthority: TAccountMetas[7]; + /** This program (for self-CPI) */ + selfProgram: TAccountMetas[8]; + }; + data: TransferRecurringInstructionData; +}; + +export function parseTransferRecurringInstruction< + TProgram extends string, + TAccountMetas extends readonly AccountMeta[], +>( + instruction: Instruction & + InstructionWithAccounts & + InstructionWithData, +): ParsedTransferRecurringInstruction { + if (instruction.accounts.length < 9) { + throw new SolanaError(SOLANA_ERROR__PROGRAM_CLIENTS__INSUFFICIENT_ACCOUNT_METAS, { + actualAccountMetas: instruction.accounts.length, + expectedAccountMetas: 9, + }); + } + let accountIndex = 0; + const getNextAccount = () => { + const accountMeta = (instruction.accounts as TAccountMetas)[accountIndex]!; + accountIndex += 1; + return accountMeta; + }; + return { + programAddress: instruction.programAddress, + accounts: { + delegationPda: getNextAccount(), + subscriptionAuthority: getNextAccount(), + delegatorAta: getNextAccount(), + receiverAta: getNextAccount(), + tokenMint: getNextAccount(), + tokenProgram: getNextAccount(), + delegatee: getNextAccount(), + eventAuthority: getNextAccount(), + selfProgram: getNextAccount(), + }, + data: getTransferRecurringInstructionDataDecoder().decode(instruction.data), + }; +} diff --git a/typescript/packages/mpp/src/generated/subscriptions/instructions/transferSubscription.ts b/typescript/packages/mpp/src/generated/subscriptions/instructions/transferSubscription.ts new file mode 100644 index 000000000..2cd351a22 --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/instructions/transferSubscription.ts @@ -0,0 +1,327 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getStructDecoder, + getStructEncoder, + getU8Decoder, + getU8Encoder, + SOLANA_ERROR__PROGRAM_CLIENTS__INSUFFICIENT_ACCOUNT_METAS, + SolanaError, + transformEncoder, + type AccountMeta, + type AccountSignerMeta, + type Address, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, + type Instruction, + type InstructionWithAccounts, + type InstructionWithData, + type ReadonlyAccount, + type ReadonlySignerAccount, + type ReadonlyUint8Array, + type TransactionSigner, + type WritableAccount, +} from '@solana/kit'; +import { getAccountMetaFactory, type ResolvedInstructionAccount } from '@solana/program-client-core'; +import { SUBSCRIPTIONS_PROGRAM_ADDRESS } from '../programs/index.js'; +import { + getTransferDataDecoder, + getTransferDataEncoder, + type TransferData, + type TransferDataArgs, +} from '../types/index.js'; + +export const TRANSFER_SUBSCRIPTION_DISCRIMINATOR = 10; + +export function getTransferSubscriptionDiscriminatorBytes(): ReadonlyUint8Array { + return getU8Encoder().encode(TRANSFER_SUBSCRIPTION_DISCRIMINATOR); +} + +export type TransferSubscriptionInstruction< + TProgram extends string = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, + TAccountSubscriptionPda extends string | AccountMeta = string, + TAccountPlanPda extends string | AccountMeta = string, + TAccountSubscriptionAuthority extends string | AccountMeta = string, + TAccountDelegatorAta extends string | AccountMeta = string, + TAccountReceiverAta extends string | AccountMeta = string, + TAccountCaller extends string | AccountMeta = string, + TAccountTokenMint extends string | AccountMeta = string, + TAccountTokenProgram extends string | AccountMeta = string, + TAccountEventAuthority extends string | AccountMeta = '3Hnj4BYoDgtpBuqXfiy7Y8cNa3jXaNd4oqgSXBzkMcH7', + TAccountSelfProgram extends string | AccountMeta = 'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44', + TRemainingAccounts extends readonly AccountMeta[] = [], +> = Instruction & + InstructionWithData & + InstructionWithAccounts< + [ + TAccountSubscriptionPda extends string ? WritableAccount : TAccountSubscriptionPda, + TAccountPlanPda extends string ? ReadonlyAccount : TAccountPlanPda, + TAccountSubscriptionAuthority extends string + ? ReadonlyAccount + : TAccountSubscriptionAuthority, + TAccountDelegatorAta extends string ? WritableAccount : TAccountDelegatorAta, + TAccountReceiverAta extends string ? WritableAccount : TAccountReceiverAta, + TAccountCaller extends string + ? ReadonlySignerAccount & AccountSignerMeta + : TAccountCaller, + TAccountTokenMint extends string ? ReadonlyAccount : TAccountTokenMint, + TAccountTokenProgram extends string ? ReadonlyAccount : TAccountTokenProgram, + TAccountEventAuthority extends string ? ReadonlyAccount : TAccountEventAuthority, + TAccountSelfProgram extends string ? ReadonlyAccount : TAccountSelfProgram, + ...TRemainingAccounts, + ] + >; + +export type TransferSubscriptionInstructionData = { + discriminator: number; + transferData: TransferData; +}; + +export type TransferSubscriptionInstructionDataArgs = { + transferData: TransferDataArgs; +}; + +export function getTransferSubscriptionInstructionDataEncoder(): FixedSizeEncoder { + return transformEncoder( + getStructEncoder([ + ['discriminator', getU8Encoder()], + ['transferData', getTransferDataEncoder()], + ]), + value => ({ + ...value, + discriminator: TRANSFER_SUBSCRIPTION_DISCRIMINATOR, + }), + ); +} + +export function getTransferSubscriptionInstructionDataDecoder(): FixedSizeDecoder { + return getStructDecoder([ + ['discriminator', getU8Decoder()], + ['transferData', getTransferDataDecoder()], + ]); +} + +export function getTransferSubscriptionInstructionDataCodec(): FixedSizeCodec< + TransferSubscriptionInstructionDataArgs, + TransferSubscriptionInstructionData +> { + return combineCodec( + getTransferSubscriptionInstructionDataEncoder(), + getTransferSubscriptionInstructionDataDecoder(), + ); +} + +export type TransferSubscriptionInput< + TAccountSubscriptionPda extends string = string, + TAccountPlanPda extends string = string, + TAccountSubscriptionAuthority extends string = string, + TAccountDelegatorAta extends string = string, + TAccountReceiverAta extends string = string, + TAccountCaller extends string = string, + TAccountTokenMint extends string = string, + TAccountTokenProgram extends string = string, + TAccountEventAuthority extends string = string, + TAccountSelfProgram extends string = string, +> = { + /** The subscription delegation PDA */ + subscriptionPda: Address; + /** The plan PDA */ + planPda: Address; + /** The subscription-authority PDA */ + subscriptionAuthority: Address; + /** The delegator's ATA to transfer from */ + delegatorAta: Address; + /** The receiver's ATA to transfer to */ + receiverAta: Address; + /** The authorized puller (plan owner or whitelisted) */ + caller: TransactionSigner; + /** The token mint */ + tokenMint: Address; + /** Token program */ + tokenProgram: Address; + /** The event authority PDA */ + eventAuthority?: Address; + /** This program (for self-CPI) */ + selfProgram?: Address; + transferData: TransferSubscriptionInstructionDataArgs['transferData']; +}; + +export function getTransferSubscriptionInstruction< + TAccountSubscriptionPda extends string, + TAccountPlanPda extends string, + TAccountSubscriptionAuthority extends string, + TAccountDelegatorAta extends string, + TAccountReceiverAta extends string, + TAccountCaller extends string, + TAccountTokenMint extends string, + TAccountTokenProgram extends string, + TAccountEventAuthority extends string, + TAccountSelfProgram extends string, + TProgramAddress extends Address = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, +>( + input: TransferSubscriptionInput< + TAccountSubscriptionPda, + TAccountPlanPda, + TAccountSubscriptionAuthority, + TAccountDelegatorAta, + TAccountReceiverAta, + TAccountCaller, + TAccountTokenMint, + TAccountTokenProgram, + TAccountEventAuthority, + TAccountSelfProgram + >, + config?: { programAddress?: TProgramAddress }, +): TransferSubscriptionInstruction< + TProgramAddress, + TAccountSubscriptionPda, + TAccountPlanPda, + TAccountSubscriptionAuthority, + TAccountDelegatorAta, + TAccountReceiverAta, + TAccountCaller, + TAccountTokenMint, + TAccountTokenProgram, + TAccountEventAuthority, + TAccountSelfProgram +> { + // Program address. + const programAddress = config?.programAddress ?? SUBSCRIPTIONS_PROGRAM_ADDRESS; + + // Original accounts. + const originalAccounts = { + subscriptionPda: { value: input.subscriptionPda ?? null, isWritable: true }, + planPda: { value: input.planPda ?? null, isWritable: false }, + subscriptionAuthority: { + value: input.subscriptionAuthority ?? null, + isWritable: false, + }, + delegatorAta: { value: input.delegatorAta ?? null, isWritable: true }, + receiverAta: { value: input.receiverAta ?? null, isWritable: true }, + caller: { value: input.caller ?? null, isWritable: false }, + tokenMint: { value: input.tokenMint ?? null, isWritable: false }, + tokenProgram: { value: input.tokenProgram ?? null, isWritable: false }, + eventAuthority: { value: input.eventAuthority ?? null, isWritable: false }, + selfProgram: { value: input.selfProgram ?? null, isWritable: false }, + }; + const accounts = originalAccounts as Record; + + // Original args. + const args = { ...input }; + + // Resolve default values. + if (!accounts.eventAuthority.value) { + accounts.eventAuthority.value = + '3Hnj4BYoDgtpBuqXfiy7Y8cNa3jXaNd4oqgSXBzkMcH7' as Address<'3Hnj4BYoDgtpBuqXfiy7Y8cNa3jXaNd4oqgSXBzkMcH7'>; + } + if (!accounts.selfProgram.value) { + accounts.selfProgram.value = + 'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44' as Address<'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44'>; + } + + const getAccountMeta = getAccountMetaFactory(programAddress, 'programId'); + return Object.freeze({ + accounts: [ + getAccountMeta('subscriptionPda', accounts.subscriptionPda), + getAccountMeta('planPda', accounts.planPda), + getAccountMeta('subscriptionAuthority', accounts.subscriptionAuthority), + getAccountMeta('delegatorAta', accounts.delegatorAta), + getAccountMeta('receiverAta', accounts.receiverAta), + getAccountMeta('caller', accounts.caller), + getAccountMeta('tokenMint', accounts.tokenMint), + getAccountMeta('tokenProgram', accounts.tokenProgram), + getAccountMeta('eventAuthority', accounts.eventAuthority), + getAccountMeta('selfProgram', accounts.selfProgram), + ], + data: getTransferSubscriptionInstructionDataEncoder().encode(args as TransferSubscriptionInstructionDataArgs), + programAddress, + } as TransferSubscriptionInstruction< + TProgramAddress, + TAccountSubscriptionPda, + TAccountPlanPda, + TAccountSubscriptionAuthority, + TAccountDelegatorAta, + TAccountReceiverAta, + TAccountCaller, + TAccountTokenMint, + TAccountTokenProgram, + TAccountEventAuthority, + TAccountSelfProgram + >); +} + +export type ParsedTransferSubscriptionInstruction< + TProgram extends string = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, + TAccountMetas extends readonly AccountMeta[] = readonly AccountMeta[], +> = { + programAddress: Address; + accounts: { + /** The subscription delegation PDA */ + subscriptionPda: TAccountMetas[0]; + /** The plan PDA */ + planPda: TAccountMetas[1]; + /** The subscription-authority PDA */ + subscriptionAuthority: TAccountMetas[2]; + /** The delegator's ATA to transfer from */ + delegatorAta: TAccountMetas[3]; + /** The receiver's ATA to transfer to */ + receiverAta: TAccountMetas[4]; + /** The authorized puller (plan owner or whitelisted) */ + caller: TAccountMetas[5]; + /** The token mint */ + tokenMint: TAccountMetas[6]; + /** Token program */ + tokenProgram: TAccountMetas[7]; + /** The event authority PDA */ + eventAuthority: TAccountMetas[8]; + /** This program (for self-CPI) */ + selfProgram: TAccountMetas[9]; + }; + data: TransferSubscriptionInstructionData; +}; + +export function parseTransferSubscriptionInstruction< + TProgram extends string, + TAccountMetas extends readonly AccountMeta[], +>( + instruction: Instruction & + InstructionWithAccounts & + InstructionWithData, +): ParsedTransferSubscriptionInstruction { + if (instruction.accounts.length < 10) { + throw new SolanaError(SOLANA_ERROR__PROGRAM_CLIENTS__INSUFFICIENT_ACCOUNT_METAS, { + actualAccountMetas: instruction.accounts.length, + expectedAccountMetas: 10, + }); + } + let accountIndex = 0; + const getNextAccount = () => { + const accountMeta = (instruction.accounts as TAccountMetas)[accountIndex]!; + accountIndex += 1; + return accountMeta; + }; + return { + programAddress: instruction.programAddress, + accounts: { + subscriptionPda: getNextAccount(), + planPda: getNextAccount(), + subscriptionAuthority: getNextAccount(), + delegatorAta: getNextAccount(), + receiverAta: getNextAccount(), + caller: getNextAccount(), + tokenMint: getNextAccount(), + tokenProgram: getNextAccount(), + eventAuthority: getNextAccount(), + selfProgram: getNextAccount(), + }, + data: getTransferSubscriptionInstructionDataDecoder().decode(instruction.data), + }; +} diff --git a/typescript/packages/mpp/src/generated/subscriptions/instructions/updatePlan.ts b/typescript/packages/mpp/src/generated/subscriptions/instructions/updatePlan.ts new file mode 100644 index 000000000..042ffb025 --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/instructions/updatePlan.ts @@ -0,0 +1,170 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getStructDecoder, + getStructEncoder, + getU8Decoder, + getU8Encoder, + SOLANA_ERROR__PROGRAM_CLIENTS__INSUFFICIENT_ACCOUNT_METAS, + SolanaError, + transformEncoder, + type AccountMeta, + type AccountSignerMeta, + type Address, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, + type Instruction, + type InstructionWithAccounts, + type InstructionWithData, + type ReadonlySignerAccount, + type ReadonlyUint8Array, + type TransactionSigner, + type WritableAccount, +} from '@solana/kit'; +import { getAccountMetaFactory, type ResolvedInstructionAccount } from '@solana/program-client-core'; +import { SUBSCRIPTIONS_PROGRAM_ADDRESS } from '../programs/index.js'; +import { + getUpdatePlanDataDecoder, + getUpdatePlanDataEncoder, + type UpdatePlanData, + type UpdatePlanDataArgs, +} from '../types/index.js'; + +export const UPDATE_PLAN_DISCRIMINATOR = 8; + +export function getUpdatePlanDiscriminatorBytes(): ReadonlyUint8Array { + return getU8Encoder().encode(UPDATE_PLAN_DISCRIMINATOR); +} + +export type UpdatePlanInstruction< + TProgram extends string = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, + TAccountOwner extends string | AccountMeta = string, + TAccountPlanPda extends string | AccountMeta = string, + TRemainingAccounts extends readonly AccountMeta[] = [], +> = Instruction & + InstructionWithData & + InstructionWithAccounts< + [ + TAccountOwner extends string + ? ReadonlySignerAccount & AccountSignerMeta + : TAccountOwner, + TAccountPlanPda extends string ? WritableAccount : TAccountPlanPda, + ...TRemainingAccounts, + ] + >; + +export type UpdatePlanInstructionData = { + discriminator: number; + updatePlanData: UpdatePlanData; +}; + +export type UpdatePlanInstructionDataArgs = { + updatePlanData: UpdatePlanDataArgs; +}; + +export function getUpdatePlanInstructionDataEncoder(): FixedSizeEncoder { + return transformEncoder( + getStructEncoder([ + ['discriminator', getU8Encoder()], + ['updatePlanData', getUpdatePlanDataEncoder()], + ]), + value => ({ ...value, discriminator: UPDATE_PLAN_DISCRIMINATOR }), + ); +} + +export function getUpdatePlanInstructionDataDecoder(): FixedSizeDecoder { + return getStructDecoder([ + ['discriminator', getU8Decoder()], + ['updatePlanData', getUpdatePlanDataDecoder()], + ]); +} + +export function getUpdatePlanInstructionDataCodec(): FixedSizeCodec< + UpdatePlanInstructionDataArgs, + UpdatePlanInstructionData +> { + return combineCodec(getUpdatePlanInstructionDataEncoder(), getUpdatePlanInstructionDataDecoder()); +} + +export type UpdatePlanInput = { + /** The plan owner updating the plan */ + owner: TransactionSigner; + /** The plan PDA being updated */ + planPda: Address; + updatePlanData: UpdatePlanInstructionDataArgs['updatePlanData']; +}; + +export function getUpdatePlanInstruction< + TAccountOwner extends string, + TAccountPlanPda extends string, + TProgramAddress extends Address = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, +>( + input: UpdatePlanInput, + config?: { programAddress?: TProgramAddress }, +): UpdatePlanInstruction { + // Program address. + const programAddress = config?.programAddress ?? SUBSCRIPTIONS_PROGRAM_ADDRESS; + + // Original accounts. + const originalAccounts = { + owner: { value: input.owner ?? null, isWritable: false }, + planPda: { value: input.planPda ?? null, isWritable: true }, + }; + const accounts = originalAccounts as Record; + + // Original args. + const args = { ...input }; + + const getAccountMeta = getAccountMetaFactory(programAddress, 'programId'); + return Object.freeze({ + accounts: [getAccountMeta('owner', accounts.owner), getAccountMeta('planPda', accounts.planPda)], + data: getUpdatePlanInstructionDataEncoder().encode(args as UpdatePlanInstructionDataArgs), + programAddress, + } as UpdatePlanInstruction); +} + +export type ParsedUpdatePlanInstruction< + TProgram extends string = typeof SUBSCRIPTIONS_PROGRAM_ADDRESS, + TAccountMetas extends readonly AccountMeta[] = readonly AccountMeta[], +> = { + programAddress: Address; + accounts: { + /** The plan owner updating the plan */ + owner: TAccountMetas[0]; + /** The plan PDA being updated */ + planPda: TAccountMetas[1]; + }; + data: UpdatePlanInstructionData; +}; + +export function parseUpdatePlanInstruction( + instruction: Instruction & + InstructionWithAccounts & + InstructionWithData, +): ParsedUpdatePlanInstruction { + if (instruction.accounts.length < 2) { + throw new SolanaError(SOLANA_ERROR__PROGRAM_CLIENTS__INSUFFICIENT_ACCOUNT_METAS, { + actualAccountMetas: instruction.accounts.length, + expectedAccountMetas: 2, + }); + } + let accountIndex = 0; + const getNextAccount = () => { + const accountMeta = (instruction.accounts as TAccountMetas)[accountIndex]!; + accountIndex += 1; + return accountMeta; + }; + return { + programAddress: instruction.programAddress, + accounts: { owner: getNextAccount(), planPda: getNextAccount() }, + data: getUpdatePlanInstructionDataDecoder().decode(instruction.data), + }; +} diff --git a/typescript/packages/mpp/src/generated/subscriptions/pdas/eventAuthority.ts b/typescript/packages/mpp/src/generated/subscriptions/pdas/eventAuthority.ts new file mode 100644 index 000000000..425065520 --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/pdas/eventAuthority.ts @@ -0,0 +1,21 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { getProgramDerivedAddress, getUtf8Encoder, type Address, type ProgramDerivedAddress } from '@solana/kit'; + +export async function findEventAuthorityPda( + config: { programAddress?: Address | undefined } = {}, +): Promise { + const { + programAddress = 'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44' as Address<'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44'>, + } = config; + return await getProgramDerivedAddress({ + programAddress, + seeds: [getUtf8Encoder().encode('event_authority')], + }); +} diff --git a/typescript/packages/mpp/src/generated/subscriptions/pdas/fixedDelegation.ts b/typescript/packages/mpp/src/generated/subscriptions/pdas/fixedDelegation.ts new file mode 100644 index 000000000..e8bbd7f25 --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/pdas/fixedDelegation.ts @@ -0,0 +1,42 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + getAddressEncoder, + getProgramDerivedAddress, + getU64Encoder, + getUtf8Encoder, + type Address, + type ProgramDerivedAddress, +} from '@solana/kit'; + +export type FixedDelegationSeeds = { + subscriptionAuthority: Address; + delegator: Address; + delegatee: Address; + nonce: number | bigint; +}; + +export async function findFixedDelegationPda( + seeds: FixedDelegationSeeds, + config: { programAddress?: Address | undefined } = {}, +): Promise { + const { + programAddress = 'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44' as Address<'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44'>, + } = config; + return await getProgramDerivedAddress({ + programAddress, + seeds: [ + getUtf8Encoder().encode('delegation'), + getAddressEncoder().encode(seeds.subscriptionAuthority), + getAddressEncoder().encode(seeds.delegator), + getAddressEncoder().encode(seeds.delegatee), + getU64Encoder().encode(seeds.nonce), + ], + }); +} diff --git a/typescript/packages/mpp/src/generated/subscriptions/pdas/index.ts b/typescript/packages/mpp/src/generated/subscriptions/pdas/index.ts new file mode 100644 index 000000000..9ea8947eb --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/pdas/index.ts @@ -0,0 +1,14 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +export * from './eventAuthority.js'; +export * from './fixedDelegation.js'; +export * from './plan.js'; +export * from './recurringDelegation.js'; +export * from './subscriptionAuthority.js'; +export * from './subscriptionDelegation.js'; diff --git a/typescript/packages/mpp/src/generated/subscriptions/pdas/plan.ts b/typescript/packages/mpp/src/generated/subscriptions/pdas/plan.ts new file mode 100644 index 000000000..1c3478730 --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/pdas/plan.ts @@ -0,0 +1,38 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + getAddressEncoder, + getProgramDerivedAddress, + getU64Encoder, + getUtf8Encoder, + type Address, + type ProgramDerivedAddress, +} from '@solana/kit'; + +export type PlanSeeds = { + owner: Address; + planId: number | bigint; +}; + +export async function findPlanPda( + seeds: PlanSeeds, + config: { programAddress?: Address | undefined } = {}, +): Promise { + const { + programAddress = 'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44' as Address<'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44'>, + } = config; + return await getProgramDerivedAddress({ + programAddress, + seeds: [ + getUtf8Encoder().encode('plan'), + getAddressEncoder().encode(seeds.owner), + getU64Encoder().encode(seeds.planId), + ], + }); +} diff --git a/typescript/packages/mpp/src/generated/subscriptions/pdas/recurringDelegation.ts b/typescript/packages/mpp/src/generated/subscriptions/pdas/recurringDelegation.ts new file mode 100644 index 000000000..dadcbc64f --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/pdas/recurringDelegation.ts @@ -0,0 +1,42 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + getAddressEncoder, + getProgramDerivedAddress, + getU64Encoder, + getUtf8Encoder, + type Address, + type ProgramDerivedAddress, +} from '@solana/kit'; + +export type RecurringDelegationSeeds = { + subscriptionAuthority: Address; + delegator: Address; + delegatee: Address; + nonce: number | bigint; +}; + +export async function findRecurringDelegationPda( + seeds: RecurringDelegationSeeds, + config: { programAddress?: Address | undefined } = {}, +): Promise { + const { + programAddress = 'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44' as Address<'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44'>, + } = config; + return await getProgramDerivedAddress({ + programAddress, + seeds: [ + getUtf8Encoder().encode('delegation'), + getAddressEncoder().encode(seeds.subscriptionAuthority), + getAddressEncoder().encode(seeds.delegator), + getAddressEncoder().encode(seeds.delegatee), + getU64Encoder().encode(seeds.nonce), + ], + }); +} diff --git a/typescript/packages/mpp/src/generated/subscriptions/pdas/subscriptionAuthority.ts b/typescript/packages/mpp/src/generated/subscriptions/pdas/subscriptionAuthority.ts new file mode 100644 index 000000000..4471d9465 --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/pdas/subscriptionAuthority.ts @@ -0,0 +1,37 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + getAddressEncoder, + getProgramDerivedAddress, + getUtf8Encoder, + type Address, + type ProgramDerivedAddress, +} from '@solana/kit'; + +export type SubscriptionAuthoritySeeds = { + user: Address; + tokenMint: Address; +}; + +export async function findSubscriptionAuthorityPda( + seeds: SubscriptionAuthoritySeeds, + config: { programAddress?: Address | undefined } = {}, +): Promise { + const { + programAddress = 'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44' as Address<'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44'>, + } = config; + return await getProgramDerivedAddress({ + programAddress, + seeds: [ + getUtf8Encoder().encode('SubscriptionAuthority'), + getAddressEncoder().encode(seeds.user), + getAddressEncoder().encode(seeds.tokenMint), + ], + }); +} diff --git a/typescript/packages/mpp/src/generated/subscriptions/pdas/subscriptionDelegation.ts b/typescript/packages/mpp/src/generated/subscriptions/pdas/subscriptionDelegation.ts new file mode 100644 index 000000000..9273b29c0 --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/pdas/subscriptionDelegation.ts @@ -0,0 +1,37 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + getAddressEncoder, + getProgramDerivedAddress, + getUtf8Encoder, + type Address, + type ProgramDerivedAddress, +} from '@solana/kit'; + +export type SubscriptionDelegationSeeds = { + planPda: Address; + subscriber: Address; +}; + +export async function findSubscriptionDelegationPda( + seeds: SubscriptionDelegationSeeds, + config: { programAddress?: Address | undefined } = {}, +): Promise { + const { + programAddress = 'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44' as Address<'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44'>, + } = config; + return await getProgramDerivedAddress({ + programAddress, + seeds: [ + getUtf8Encoder().encode('subscription'), + getAddressEncoder().encode(seeds.planPda), + getAddressEncoder().encode(seeds.subscriber), + ], + }); +} diff --git a/typescript/packages/mpp/src/generated/subscriptions/programs/index.ts b/typescript/packages/mpp/src/generated/subscriptions/programs/index.ts new file mode 100644 index 000000000..7c70421aa --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/programs/index.ts @@ -0,0 +1,9 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +export * from './subscriptions.js'; diff --git a/typescript/packages/mpp/src/generated/subscriptions/programs/subscriptions.ts b/typescript/packages/mpp/src/generated/subscriptions/programs/subscriptions.ts new file mode 100644 index 000000000..28fa118eb --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/programs/subscriptions.ts @@ -0,0 +1,482 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + assertIsInstructionWithAccounts, + containsBytes, + extendClient, + getU8Encoder, + SOLANA_ERROR__PROGRAM_CLIENTS__FAILED_TO_IDENTIFY_INSTRUCTION, + SOLANA_ERROR__PROGRAM_CLIENTS__UNRECOGNIZED_INSTRUCTION_TYPE, + SolanaError, + type Address, + type ClientWithRpc, + type ClientWithTransactionPlanning, + type ClientWithTransactionSending, + type ExtendedClient, + type GetAccountInfoApi, + type GetMultipleAccountsApi, + type Instruction, + type InstructionWithData, + type ReadonlyUint8Array, +} from '@solana/kit'; +import { + addSelfFetchFunctions, + addSelfPlanAndSendFunctions, + type SelfFetchFunctions, + type SelfPlanAndSendFunctions, +} from '@solana/program-client-core'; +import { + getEventAuthorityCodec, + getFixedDelegationCodec, + getPlanCodec, + getRecurringDelegationCodec, + getSubscriptionAuthorityCodec, + getSubscriptionDelegationCodec, + type EventAuthority, + type EventAuthorityArgs, + type FixedDelegation, + type FixedDelegationArgs, + type Plan, + type PlanArgs, + type RecurringDelegation, + type RecurringDelegationArgs, + type SubscriptionAuthority, + type SubscriptionAuthorityArgs, + type SubscriptionDelegation, + type SubscriptionDelegationArgs, +} from '../accounts/index.js'; +import { + getCancelSubscriptionInstructionAsync, + getCloseSubscriptionAuthorityInstruction, + getCreateFixedDelegationInstruction, + getCreatePlanInstruction, + getCreateRecurringDelegationInstruction, + getDeletePlanInstruction, + getInitSubscriptionAuthorityInstructionAsync, + getResumeSubscriptionInstructionAsync, + getRevokeDelegationInstruction, + getSubscribeInstructionAsync, + getTransferFixedInstruction, + getTransferRecurringInstruction, + getTransferSubscriptionInstruction, + getUpdatePlanInstruction, + parseCancelSubscriptionInstruction, + parseCloseSubscriptionAuthorityInstruction, + parseCreateFixedDelegationInstruction, + parseCreatePlanInstruction, + parseCreateRecurringDelegationInstruction, + parseDeletePlanInstruction, + parseInitSubscriptionAuthorityInstruction, + parseResumeSubscriptionInstruction, + parseRevokeDelegationInstruction, + parseSubscribeInstruction, + parseTransferFixedInstruction, + parseTransferRecurringInstruction, + parseTransferSubscriptionInstruction, + parseUpdatePlanInstruction, + type CancelSubscriptionAsyncInput, + type CloseSubscriptionAuthorityInput, + type CreateFixedDelegationInput, + type CreatePlanInput, + type CreateRecurringDelegationInput, + type DeletePlanInput, + type InitSubscriptionAuthorityAsyncInput, + type ParsedCancelSubscriptionInstruction, + type ParsedCloseSubscriptionAuthorityInstruction, + type ParsedCreateFixedDelegationInstruction, + type ParsedCreatePlanInstruction, + type ParsedCreateRecurringDelegationInstruction, + type ParsedDeletePlanInstruction, + type ParsedInitSubscriptionAuthorityInstruction, + type ParsedResumeSubscriptionInstruction, + type ParsedRevokeDelegationInstruction, + type ParsedSubscribeInstruction, + type ParsedTransferFixedInstruction, + type ParsedTransferRecurringInstruction, + type ParsedTransferSubscriptionInstruction, + type ParsedUpdatePlanInstruction, + type ResumeSubscriptionAsyncInput, + type RevokeDelegationInput, + type SubscribeAsyncInput, + type TransferFixedInput, + type TransferRecurringInput, + type TransferSubscriptionInput, + type UpdatePlanInput, +} from '../instructions/index.js'; +import { + findEventAuthorityPda, + findFixedDelegationPda, + findPlanPda, + findRecurringDelegationPda, + findSubscriptionAuthorityPda, + findSubscriptionDelegationPda, +} from '../pdas/index.js'; + +export const SUBSCRIPTIONS_PROGRAM_ADDRESS = + 'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44' as Address<'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44'>; + +export enum SubscriptionsAccount { + FixedDelegation, + Plan, + RecurringDelegation, + SubscriptionAuthority, + SubscriptionDelegation, + EventAuthority, +} + +export enum SubscriptionsInstruction { + InitSubscriptionAuthority, + CreateFixedDelegation, + CreateRecurringDelegation, + RevokeDelegation, + TransferFixed, + TransferRecurring, + CloseSubscriptionAuthority, + CreatePlan, + UpdatePlan, + DeletePlan, + TransferSubscription, + Subscribe, + CancelSubscription, + ResumeSubscription, +} + +export function identifySubscriptionsInstruction( + instruction: { data: ReadonlyUint8Array } | ReadonlyUint8Array, +): SubscriptionsInstruction { + const data = 'data' in instruction ? instruction.data : instruction; + if (containsBytes(data, getU8Encoder().encode(0), 0)) { + return SubscriptionsInstruction.InitSubscriptionAuthority; + } + if (containsBytes(data, getU8Encoder().encode(1), 0)) { + return SubscriptionsInstruction.CreateFixedDelegation; + } + if (containsBytes(data, getU8Encoder().encode(2), 0)) { + return SubscriptionsInstruction.CreateRecurringDelegation; + } + if (containsBytes(data, getU8Encoder().encode(3), 0)) { + return SubscriptionsInstruction.RevokeDelegation; + } + if (containsBytes(data, getU8Encoder().encode(4), 0)) { + return SubscriptionsInstruction.TransferFixed; + } + if (containsBytes(data, getU8Encoder().encode(5), 0)) { + return SubscriptionsInstruction.TransferRecurring; + } + if (containsBytes(data, getU8Encoder().encode(6), 0)) { + return SubscriptionsInstruction.CloseSubscriptionAuthority; + } + if (containsBytes(data, getU8Encoder().encode(7), 0)) { + return SubscriptionsInstruction.CreatePlan; + } + if (containsBytes(data, getU8Encoder().encode(8), 0)) { + return SubscriptionsInstruction.UpdatePlan; + } + if (containsBytes(data, getU8Encoder().encode(9), 0)) { + return SubscriptionsInstruction.DeletePlan; + } + if (containsBytes(data, getU8Encoder().encode(10), 0)) { + return SubscriptionsInstruction.TransferSubscription; + } + if (containsBytes(data, getU8Encoder().encode(11), 0)) { + return SubscriptionsInstruction.Subscribe; + } + if (containsBytes(data, getU8Encoder().encode(12), 0)) { + return SubscriptionsInstruction.CancelSubscription; + } + if (containsBytes(data, getU8Encoder().encode(13), 0)) { + return SubscriptionsInstruction.ResumeSubscription; + } + throw new SolanaError(SOLANA_ERROR__PROGRAM_CLIENTS__FAILED_TO_IDENTIFY_INSTRUCTION, { + instructionData: data, + programName: 'subscriptions', + }); +} + +export type ParsedSubscriptionsInstruction = + | ({ + instructionType: SubscriptionsInstruction.InitSubscriptionAuthority; + } & ParsedInitSubscriptionAuthorityInstruction) + | ({ + instructionType: SubscriptionsInstruction.CreateFixedDelegation; + } & ParsedCreateFixedDelegationInstruction) + | ({ + instructionType: SubscriptionsInstruction.CreateRecurringDelegation; + } & ParsedCreateRecurringDelegationInstruction) + | ({ + instructionType: SubscriptionsInstruction.RevokeDelegation; + } & ParsedRevokeDelegationInstruction) + | ({ + instructionType: SubscriptionsInstruction.TransferFixed; + } & ParsedTransferFixedInstruction) + | ({ + instructionType: SubscriptionsInstruction.TransferRecurring; + } & ParsedTransferRecurringInstruction) + | ({ + instructionType: SubscriptionsInstruction.CloseSubscriptionAuthority; + } & ParsedCloseSubscriptionAuthorityInstruction) + | ({ + instructionType: SubscriptionsInstruction.CreatePlan; + } & ParsedCreatePlanInstruction) + | ({ + instructionType: SubscriptionsInstruction.UpdatePlan; + } & ParsedUpdatePlanInstruction) + | ({ + instructionType: SubscriptionsInstruction.DeletePlan; + } & ParsedDeletePlanInstruction) + | ({ + instructionType: SubscriptionsInstruction.TransferSubscription; + } & ParsedTransferSubscriptionInstruction) + | ({ + instructionType: SubscriptionsInstruction.Subscribe; + } & ParsedSubscribeInstruction) + | ({ + instructionType: SubscriptionsInstruction.CancelSubscription; + } & ParsedCancelSubscriptionInstruction) + | ({ + instructionType: SubscriptionsInstruction.ResumeSubscription; + } & ParsedResumeSubscriptionInstruction); + +export function parseSubscriptionsInstruction( + instruction: Instruction & InstructionWithData, +): ParsedSubscriptionsInstruction { + const instructionType = identifySubscriptionsInstruction(instruction); + switch (instructionType) { + case SubscriptionsInstruction.InitSubscriptionAuthority: { + assertIsInstructionWithAccounts(instruction); + return { + instructionType: SubscriptionsInstruction.InitSubscriptionAuthority, + ...parseInitSubscriptionAuthorityInstruction(instruction), + }; + } + case SubscriptionsInstruction.CreateFixedDelegation: { + assertIsInstructionWithAccounts(instruction); + return { + instructionType: SubscriptionsInstruction.CreateFixedDelegation, + ...parseCreateFixedDelegationInstruction(instruction), + }; + } + case SubscriptionsInstruction.CreateRecurringDelegation: { + assertIsInstructionWithAccounts(instruction); + return { + instructionType: SubscriptionsInstruction.CreateRecurringDelegation, + ...parseCreateRecurringDelegationInstruction(instruction), + }; + } + case SubscriptionsInstruction.RevokeDelegation: { + assertIsInstructionWithAccounts(instruction); + return { + instructionType: SubscriptionsInstruction.RevokeDelegation, + ...parseRevokeDelegationInstruction(instruction), + }; + } + case SubscriptionsInstruction.TransferFixed: { + assertIsInstructionWithAccounts(instruction); + return { + instructionType: SubscriptionsInstruction.TransferFixed, + ...parseTransferFixedInstruction(instruction), + }; + } + case SubscriptionsInstruction.TransferRecurring: { + assertIsInstructionWithAccounts(instruction); + return { + instructionType: SubscriptionsInstruction.TransferRecurring, + ...parseTransferRecurringInstruction(instruction), + }; + } + case SubscriptionsInstruction.CloseSubscriptionAuthority: { + assertIsInstructionWithAccounts(instruction); + return { + instructionType: SubscriptionsInstruction.CloseSubscriptionAuthority, + ...parseCloseSubscriptionAuthorityInstruction(instruction), + }; + } + case SubscriptionsInstruction.CreatePlan: { + assertIsInstructionWithAccounts(instruction); + return { + instructionType: SubscriptionsInstruction.CreatePlan, + ...parseCreatePlanInstruction(instruction), + }; + } + case SubscriptionsInstruction.UpdatePlan: { + assertIsInstructionWithAccounts(instruction); + return { + instructionType: SubscriptionsInstruction.UpdatePlan, + ...parseUpdatePlanInstruction(instruction), + }; + } + case SubscriptionsInstruction.DeletePlan: { + assertIsInstructionWithAccounts(instruction); + return { + instructionType: SubscriptionsInstruction.DeletePlan, + ...parseDeletePlanInstruction(instruction), + }; + } + case SubscriptionsInstruction.TransferSubscription: { + assertIsInstructionWithAccounts(instruction); + return { + instructionType: SubscriptionsInstruction.TransferSubscription, + ...parseTransferSubscriptionInstruction(instruction), + }; + } + case SubscriptionsInstruction.Subscribe: { + assertIsInstructionWithAccounts(instruction); + return { + instructionType: SubscriptionsInstruction.Subscribe, + ...parseSubscribeInstruction(instruction), + }; + } + case SubscriptionsInstruction.CancelSubscription: { + assertIsInstructionWithAccounts(instruction); + return { + instructionType: SubscriptionsInstruction.CancelSubscription, + ...parseCancelSubscriptionInstruction(instruction), + }; + } + case SubscriptionsInstruction.ResumeSubscription: { + assertIsInstructionWithAccounts(instruction); + return { + instructionType: SubscriptionsInstruction.ResumeSubscription, + ...parseResumeSubscriptionInstruction(instruction), + }; + } + default: + throw new SolanaError(SOLANA_ERROR__PROGRAM_CLIENTS__UNRECOGNIZED_INSTRUCTION_TYPE, { + instructionType: instructionType as string, + programName: 'subscriptions', + }); + } +} + +export type SubscriptionsPlugin = { + accounts: SubscriptionsPluginAccounts; + instructions: SubscriptionsPluginInstructions; + pdas: SubscriptionsPluginPdas; + identifyInstruction: typeof identifySubscriptionsInstruction; + parseInstruction: typeof parseSubscriptionsInstruction; +}; + +export type SubscriptionsPluginAccounts = { + fixedDelegation: ReturnType & + SelfFetchFunctions; + plan: ReturnType & SelfFetchFunctions; + recurringDelegation: ReturnType & + SelfFetchFunctions; + subscriptionAuthority: ReturnType & + SelfFetchFunctions; + subscriptionDelegation: ReturnType & + SelfFetchFunctions; + eventAuthority: ReturnType & SelfFetchFunctions; +}; + +export type SubscriptionsPluginInstructions = { + initSubscriptionAuthority: ( + input: InitSubscriptionAuthorityAsyncInput, + ) => ReturnType & SelfPlanAndSendFunctions; + createFixedDelegation: ( + input: CreateFixedDelegationInput, + ) => ReturnType & SelfPlanAndSendFunctions; + createRecurringDelegation: ( + input: CreateRecurringDelegationInput, + ) => ReturnType & SelfPlanAndSendFunctions; + revokeDelegation: ( + input: RevokeDelegationInput, + ) => ReturnType & SelfPlanAndSendFunctions; + transferFixed: ( + input: TransferFixedInput, + ) => ReturnType & SelfPlanAndSendFunctions; + transferRecurring: ( + input: TransferRecurringInput, + ) => ReturnType & SelfPlanAndSendFunctions; + closeSubscriptionAuthority: ( + input: CloseSubscriptionAuthorityInput, + ) => ReturnType & SelfPlanAndSendFunctions; + createPlan: (input: CreatePlanInput) => ReturnType & SelfPlanAndSendFunctions; + updatePlan: (input: UpdatePlanInput) => ReturnType & SelfPlanAndSendFunctions; + deletePlan: (input: DeletePlanInput) => ReturnType & SelfPlanAndSendFunctions; + transferSubscription: ( + input: TransferSubscriptionInput, + ) => ReturnType & SelfPlanAndSendFunctions; + subscribe: ( + input: SubscribeAsyncInput, + ) => ReturnType & SelfPlanAndSendFunctions; + cancelSubscription: ( + input: CancelSubscriptionAsyncInput, + ) => ReturnType & SelfPlanAndSendFunctions; + resumeSubscription: ( + input: ResumeSubscriptionAsyncInput, + ) => ReturnType & SelfPlanAndSendFunctions; +}; + +export type SubscriptionsPluginPdas = { + fixedDelegation: typeof findFixedDelegationPda; + plan: typeof findPlanPda; + recurringDelegation: typeof findRecurringDelegationPda; + subscriptionAuthority: typeof findSubscriptionAuthorityPda; + subscriptionDelegation: typeof findSubscriptionDelegationPda; + eventAuthority: typeof findEventAuthorityPda; +}; + +export type SubscriptionsPluginRequirements = ClientWithRpc & + ClientWithTransactionPlanning & + ClientWithTransactionSending; + +export function subscriptionsProgram() { + return ( + client: T, + ): ExtendedClient => { + return extendClient(client, { + subscriptions: { + accounts: { + fixedDelegation: addSelfFetchFunctions(client, getFixedDelegationCodec()), + plan: addSelfFetchFunctions(client, getPlanCodec()), + recurringDelegation: addSelfFetchFunctions(client, getRecurringDelegationCodec()), + subscriptionAuthority: addSelfFetchFunctions(client, getSubscriptionAuthorityCodec()), + subscriptionDelegation: addSelfFetchFunctions(client, getSubscriptionDelegationCodec()), + eventAuthority: addSelfFetchFunctions(client, getEventAuthorityCodec()), + }, + instructions: { + initSubscriptionAuthority: input => + addSelfPlanAndSendFunctions(client, getInitSubscriptionAuthorityInstructionAsync(input)), + createFixedDelegation: input => + addSelfPlanAndSendFunctions(client, getCreateFixedDelegationInstruction(input)), + createRecurringDelegation: input => + addSelfPlanAndSendFunctions(client, getCreateRecurringDelegationInstruction(input)), + revokeDelegation: input => + addSelfPlanAndSendFunctions(client, getRevokeDelegationInstruction(input)), + transferFixed: input => addSelfPlanAndSendFunctions(client, getTransferFixedInstruction(input)), + transferRecurring: input => + addSelfPlanAndSendFunctions(client, getTransferRecurringInstruction(input)), + closeSubscriptionAuthority: input => + addSelfPlanAndSendFunctions(client, getCloseSubscriptionAuthorityInstruction(input)), + createPlan: input => addSelfPlanAndSendFunctions(client, getCreatePlanInstruction(input)), + updatePlan: input => addSelfPlanAndSendFunctions(client, getUpdatePlanInstruction(input)), + deletePlan: input => addSelfPlanAndSendFunctions(client, getDeletePlanInstruction(input)), + transferSubscription: input => + addSelfPlanAndSendFunctions(client, getTransferSubscriptionInstruction(input)), + subscribe: input => addSelfPlanAndSendFunctions(client, getSubscribeInstructionAsync(input)), + cancelSubscription: input => + addSelfPlanAndSendFunctions(client, getCancelSubscriptionInstructionAsync(input)), + resumeSubscription: input => + addSelfPlanAndSendFunctions(client, getResumeSubscriptionInstructionAsync(input)), + }, + pdas: { + fixedDelegation: findFixedDelegationPda, + plan: findPlanPda, + recurringDelegation: findRecurringDelegationPda, + subscriptionAuthority: findSubscriptionAuthorityPda, + subscriptionDelegation: findSubscriptionDelegationPda, + eventAuthority: findEventAuthorityPda, + }, + identifyInstruction: identifySubscriptionsInstruction, + parseInstruction: parseSubscriptionsInstruction, + }, + }); + }; +} diff --git a/typescript/packages/mpp/src/generated/subscriptions/types/accountDiscriminator.ts b/typescript/packages/mpp/src/generated/subscriptions/types/accountDiscriminator.ts new file mode 100644 index 000000000..45658c5ba --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/types/accountDiscriminator.ts @@ -0,0 +1,38 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getEnumDecoder, + getEnumEncoder, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, +} from '@solana/kit'; + +export enum AccountDiscriminator { + SubscriptionAuthority, + Plan, + FixedDelegation, + RecurringDelegation, + SubscriptionDelegation, +} + +export type AccountDiscriminatorArgs = AccountDiscriminator; + +export function getAccountDiscriminatorEncoder(): FixedSizeEncoder { + return getEnumEncoder(AccountDiscriminator); +} + +export function getAccountDiscriminatorDecoder(): FixedSizeDecoder { + return getEnumDecoder(AccountDiscriminator); +} + +export function getAccountDiscriminatorCodec(): FixedSizeCodec { + return combineCodec(getAccountDiscriminatorEncoder(), getAccountDiscriminatorDecoder()); +} diff --git a/typescript/packages/mpp/src/generated/subscriptions/types/createFixedDelegationData.ts b/typescript/packages/mpp/src/generated/subscriptions/types/createFixedDelegationData.ts new file mode 100644 index 000000000..be6a8f88e --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/types/createFixedDelegationData.ts @@ -0,0 +1,59 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getI64Decoder, + getI64Encoder, + getStructDecoder, + getStructEncoder, + getU64Decoder, + getU64Encoder, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, +} from '@solana/kit'; + +export type CreateFixedDelegationData = { + nonce: bigint; + amount: bigint; + expiryTs: bigint; + expectedSubscriptionAuthorityInitId: bigint; +}; + +export type CreateFixedDelegationDataArgs = { + nonce: number | bigint; + amount: number | bigint; + expiryTs: number | bigint; + expectedSubscriptionAuthorityInitId: number | bigint; +}; + +export function getCreateFixedDelegationDataEncoder(): FixedSizeEncoder { + return getStructEncoder([ + ['nonce', getU64Encoder()], + ['amount', getU64Encoder()], + ['expiryTs', getI64Encoder()], + ['expectedSubscriptionAuthorityInitId', getI64Encoder()], + ]); +} + +export function getCreateFixedDelegationDataDecoder(): FixedSizeDecoder { + return getStructDecoder([ + ['nonce', getU64Decoder()], + ['amount', getU64Decoder()], + ['expiryTs', getI64Decoder()], + ['expectedSubscriptionAuthorityInitId', getI64Decoder()], + ]); +} + +export function getCreateFixedDelegationDataCodec(): FixedSizeCodec< + CreateFixedDelegationDataArgs, + CreateFixedDelegationData +> { + return combineCodec(getCreateFixedDelegationDataEncoder(), getCreateFixedDelegationDataDecoder()); +} diff --git a/typescript/packages/mpp/src/generated/subscriptions/types/createRecurringDelegationData.ts b/typescript/packages/mpp/src/generated/subscriptions/types/createRecurringDelegationData.ts new file mode 100644 index 000000000..5e4637125 --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/types/createRecurringDelegationData.ts @@ -0,0 +1,67 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getI64Decoder, + getI64Encoder, + getStructDecoder, + getStructEncoder, + getU64Decoder, + getU64Encoder, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, +} from '@solana/kit'; + +export type CreateRecurringDelegationData = { + nonce: bigint; + amountPerPeriod: bigint; + periodLengthS: bigint; + startTs: bigint; + expiryTs: bigint; + expectedSubscriptionAuthorityInitId: bigint; +}; + +export type CreateRecurringDelegationDataArgs = { + nonce: number | bigint; + amountPerPeriod: number | bigint; + periodLengthS: number | bigint; + startTs: number | bigint; + expiryTs: number | bigint; + expectedSubscriptionAuthorityInitId: number | bigint; +}; + +export function getCreateRecurringDelegationDataEncoder(): FixedSizeEncoder { + return getStructEncoder([ + ['nonce', getU64Encoder()], + ['amountPerPeriod', getU64Encoder()], + ['periodLengthS', getU64Encoder()], + ['startTs', getI64Encoder()], + ['expiryTs', getI64Encoder()], + ['expectedSubscriptionAuthorityInitId', getI64Encoder()], + ]); +} + +export function getCreateRecurringDelegationDataDecoder(): FixedSizeDecoder { + return getStructDecoder([ + ['nonce', getU64Decoder()], + ['amountPerPeriod', getU64Decoder()], + ['periodLengthS', getU64Decoder()], + ['startTs', getI64Decoder()], + ['expiryTs', getI64Decoder()], + ['expectedSubscriptionAuthorityInitId', getI64Decoder()], + ]); +} + +export function getCreateRecurringDelegationDataCodec(): FixedSizeCodec< + CreateRecurringDelegationDataArgs, + CreateRecurringDelegationData +> { + return combineCodec(getCreateRecurringDelegationDataEncoder(), getCreateRecurringDelegationDataDecoder()); +} diff --git a/typescript/packages/mpp/src/generated/subscriptions/types/header.ts b/typescript/packages/mpp/src/generated/subscriptions/types/header.ts new file mode 100644 index 000000000..edbb52e26 --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/types/header.ts @@ -0,0 +1,71 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getAddressDecoder, + getAddressEncoder, + getI64Decoder, + getI64Encoder, + getStructDecoder, + getStructEncoder, + getU8Decoder, + getU8Encoder, + type Address, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, +} from '@solana/kit'; + +export type Header = { + discriminator: number; + version: number; + bump: number; + delegator: Address; + delegatee: Address; + payer: Address; + initId: bigint; +}; + +export type HeaderArgs = { + discriminator: number; + version: number; + bump: number; + delegator: Address; + delegatee: Address; + payer: Address; + initId: number | bigint; +}; + +export function getHeaderEncoder(): FixedSizeEncoder { + return getStructEncoder([ + ['discriminator', getU8Encoder()], + ['version', getU8Encoder()], + ['bump', getU8Encoder()], + ['delegator', getAddressEncoder()], + ['delegatee', getAddressEncoder()], + ['payer', getAddressEncoder()], + ['initId', getI64Encoder()], + ]); +} + +export function getHeaderDecoder(): FixedSizeDecoder
{ + return getStructDecoder([ + ['discriminator', getU8Decoder()], + ['version', getU8Decoder()], + ['bump', getU8Decoder()], + ['delegator', getAddressDecoder()], + ['delegatee', getAddressDecoder()], + ['payer', getAddressDecoder()], + ['initId', getI64Decoder()], + ]); +} + +export function getHeaderCodec(): FixedSizeCodec { + return combineCodec(getHeaderEncoder(), getHeaderDecoder()); +} diff --git a/typescript/packages/mpp/src/generated/subscriptions/types/index.ts b/typescript/packages/mpp/src/generated/subscriptions/types/index.ts new file mode 100644 index 000000000..d075a7b76 --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/types/index.ts @@ -0,0 +1,18 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +export * from './accountDiscriminator.js'; +export * from './createFixedDelegationData.js'; +export * from './createRecurringDelegationData.js'; +export * from './header.js'; +export * from './planData.js'; +export * from './planStatus.js'; +export * from './planTerms.js'; +export * from './subscribeData.js'; +export * from './transferData.js'; +export * from './updatePlanData.js'; diff --git a/typescript/packages/mpp/src/generated/subscriptions/types/planData.ts b/typescript/packages/mpp/src/generated/subscriptions/types/planData.ts new file mode 100644 index 000000000..671014566 --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/types/planData.ts @@ -0,0 +1,78 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + fixDecoderSize, + fixEncoderSize, + getAddressDecoder, + getAddressEncoder, + getArrayDecoder, + getArrayEncoder, + getI64Decoder, + getI64Encoder, + getStructDecoder, + getStructEncoder, + getU64Decoder, + getU64Encoder, + getUtf8Decoder, + getUtf8Encoder, + type Address, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, +} from '@solana/kit'; +import { getPlanTermsDecoder, getPlanTermsEncoder, type PlanTerms, type PlanTermsArgs } from './index.js'; + +export type PlanData = { + planId: bigint; + mint: Address; + terms: PlanTerms; + endTs: bigint; + destinations: Array
; + pullers: Array
; + metadataUri: string; +}; + +export type PlanDataArgs = { + planId: number | bigint; + mint: Address; + terms: PlanTermsArgs; + endTs: number | bigint; + destinations: Array
; + pullers: Array
; + metadataUri: string; +}; + +export function getPlanDataEncoder(): FixedSizeEncoder { + return getStructEncoder([ + ['planId', getU64Encoder()], + ['mint', getAddressEncoder()], + ['terms', getPlanTermsEncoder()], + ['endTs', getI64Encoder()], + ['destinations', getArrayEncoder(getAddressEncoder(), { size: 4 })], + ['pullers', getArrayEncoder(getAddressEncoder(), { size: 4 })], + ['metadataUri', fixEncoderSize(getUtf8Encoder(), 128)], + ]); +} + +export function getPlanDataDecoder(): FixedSizeDecoder { + return getStructDecoder([ + ['planId', getU64Decoder()], + ['mint', getAddressDecoder()], + ['terms', getPlanTermsDecoder()], + ['endTs', getI64Decoder()], + ['destinations', getArrayDecoder(getAddressDecoder(), { size: 4 })], + ['pullers', getArrayDecoder(getAddressDecoder(), { size: 4 })], + ['metadataUri', fixDecoderSize(getUtf8Decoder(), 128)], + ]); +} + +export function getPlanDataCodec(): FixedSizeCodec { + return combineCodec(getPlanDataEncoder(), getPlanDataDecoder()); +} diff --git a/typescript/packages/mpp/src/generated/subscriptions/types/planStatus.ts b/typescript/packages/mpp/src/generated/subscriptions/types/planStatus.ts new file mode 100644 index 000000000..1e560d3c5 --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/types/planStatus.ts @@ -0,0 +1,35 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getEnumDecoder, + getEnumEncoder, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, +} from '@solana/kit'; + +export enum PlanStatus { + Sunset, + Active, +} + +export type PlanStatusArgs = PlanStatus; + +export function getPlanStatusEncoder(): FixedSizeEncoder { + return getEnumEncoder(PlanStatus); +} + +export function getPlanStatusDecoder(): FixedSizeDecoder { + return getEnumDecoder(PlanStatus); +} + +export function getPlanStatusCodec(): FixedSizeCodec { + return combineCodec(getPlanStatusEncoder(), getPlanStatusDecoder()); +} diff --git a/typescript/packages/mpp/src/generated/subscriptions/types/planTerms.ts b/typescript/packages/mpp/src/generated/subscriptions/types/planTerms.ts new file mode 100644 index 000000000..9d07b2da0 --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/types/planTerms.ts @@ -0,0 +1,52 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getI64Decoder, + getI64Encoder, + getStructDecoder, + getStructEncoder, + getU64Decoder, + getU64Encoder, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, +} from '@solana/kit'; + +export type PlanTerms = { + amount: bigint; + periodHours: bigint; + createdAt: bigint; +}; + +export type PlanTermsArgs = { + amount: number | bigint; + periodHours: number | bigint; + createdAt: number | bigint; +}; + +export function getPlanTermsEncoder(): FixedSizeEncoder { + return getStructEncoder([ + ['amount', getU64Encoder()], + ['periodHours', getU64Encoder()], + ['createdAt', getI64Encoder()], + ]); +} + +export function getPlanTermsDecoder(): FixedSizeDecoder { + return getStructDecoder([ + ['amount', getU64Decoder()], + ['periodHours', getU64Decoder()], + ['createdAt', getI64Decoder()], + ]); +} + +export function getPlanTermsCodec(): FixedSizeCodec { + return combineCodec(getPlanTermsEncoder(), getPlanTermsDecoder()); +} diff --git a/typescript/packages/mpp/src/generated/subscriptions/types/subscribeData.ts b/typescript/packages/mpp/src/generated/subscriptions/types/subscribeData.ts new file mode 100644 index 000000000..9ce1d6a79 --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/types/subscribeData.ts @@ -0,0 +1,73 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getAddressDecoder, + getAddressEncoder, + getI64Decoder, + getI64Encoder, + getStructDecoder, + getStructEncoder, + getU64Decoder, + getU64Encoder, + getU8Decoder, + getU8Encoder, + type Address, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, +} from '@solana/kit'; + +export type SubscribeData = { + planId: bigint; + planBump: number; + expectedMint: Address; + expectedAmount: bigint; + expectedPeriodHours: bigint; + expectedCreatedAt: bigint; + expectedSubscriptionAuthorityInitId: bigint; +}; + +export type SubscribeDataArgs = { + planId: number | bigint; + planBump: number; + expectedMint: Address; + expectedAmount: number | bigint; + expectedPeriodHours: number | bigint; + expectedCreatedAt: number | bigint; + expectedSubscriptionAuthorityInitId: number | bigint; +}; + +export function getSubscribeDataEncoder(): FixedSizeEncoder { + return getStructEncoder([ + ['planId', getU64Encoder()], + ['planBump', getU8Encoder()], + ['expectedMint', getAddressEncoder()], + ['expectedAmount', getU64Encoder()], + ['expectedPeriodHours', getU64Encoder()], + ['expectedCreatedAt', getI64Encoder()], + ['expectedSubscriptionAuthorityInitId', getI64Encoder()], + ]); +} + +export function getSubscribeDataDecoder(): FixedSizeDecoder { + return getStructDecoder([ + ['planId', getU64Decoder()], + ['planBump', getU8Decoder()], + ['expectedMint', getAddressDecoder()], + ['expectedAmount', getU64Decoder()], + ['expectedPeriodHours', getU64Decoder()], + ['expectedCreatedAt', getI64Decoder()], + ['expectedSubscriptionAuthorityInitId', getI64Decoder()], + ]); +} + +export function getSubscribeDataCodec(): FixedSizeCodec { + return combineCodec(getSubscribeDataEncoder(), getSubscribeDataDecoder()); +} diff --git a/typescript/packages/mpp/src/generated/subscriptions/types/transferData.ts b/typescript/packages/mpp/src/generated/subscriptions/types/transferData.ts new file mode 100644 index 000000000..66f20e8e1 --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/types/transferData.ts @@ -0,0 +1,53 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getAddressDecoder, + getAddressEncoder, + getStructDecoder, + getStructEncoder, + getU64Decoder, + getU64Encoder, + type Address, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, +} from '@solana/kit'; + +export type TransferData = { + amount: bigint; + delegator: Address; + mint: Address; +}; + +export type TransferDataArgs = { + amount: number | bigint; + delegator: Address; + mint: Address; +}; + +export function getTransferDataEncoder(): FixedSizeEncoder { + return getStructEncoder([ + ['amount', getU64Encoder()], + ['delegator', getAddressEncoder()], + ['mint', getAddressEncoder()], + ]); +} + +export function getTransferDataDecoder(): FixedSizeDecoder { + return getStructDecoder([ + ['amount', getU64Decoder()], + ['delegator', getAddressDecoder()], + ['mint', getAddressDecoder()], + ]); +} + +export function getTransferDataCodec(): FixedSizeCodec { + return combineCodec(getTransferDataEncoder(), getTransferDataDecoder()); +} diff --git a/typescript/packages/mpp/src/generated/subscriptions/types/updatePlanData.ts b/typescript/packages/mpp/src/generated/subscriptions/types/updatePlanData.ts new file mode 100644 index 000000000..271ef7d03 --- /dev/null +++ b/typescript/packages/mpp/src/generated/subscriptions/types/updatePlanData.ts @@ -0,0 +1,65 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + fixDecoderSize, + fixEncoderSize, + getAddressDecoder, + getAddressEncoder, + getArrayDecoder, + getArrayEncoder, + getI64Decoder, + getI64Encoder, + getStructDecoder, + getStructEncoder, + getU8Decoder, + getU8Encoder, + getUtf8Decoder, + getUtf8Encoder, + type Address, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, +} from '@solana/kit'; + +export type UpdatePlanData = { + status: number; + endTs: bigint; + pullers: Array
; + metadataUri: string; +}; + +export type UpdatePlanDataArgs = { + status: number; + endTs: number | bigint; + pullers: Array
; + metadataUri: string; +}; + +export function getUpdatePlanDataEncoder(): FixedSizeEncoder { + return getStructEncoder([ + ['status', getU8Encoder()], + ['endTs', getI64Encoder()], + ['pullers', getArrayEncoder(getAddressEncoder(), { size: 4 })], + ['metadataUri', fixEncoderSize(getUtf8Encoder(), 128)], + ]); +} + +export function getUpdatePlanDataDecoder(): FixedSizeDecoder { + return getStructDecoder([ + ['status', getU8Decoder()], + ['endTs', getI64Decoder()], + ['pullers', getArrayDecoder(getAddressDecoder(), { size: 4 })], + ['metadataUri', fixDecoderSize(getUtf8Decoder(), 128)], + ]); +} + +export function getUpdatePlanDataCodec(): FixedSizeCodec { + return combineCodec(getUpdatePlanDataEncoder(), getUpdatePlanDataDecoder()); +} diff --git a/typescript/packages/mpp/src/server/Subscription.ts b/typescript/packages/mpp/src/server/Subscription.ts index 01e43de7a..189bd006c 100644 --- a/typescript/packages/mpp/src/server/Subscription.ts +++ b/typescript/packages/mpp/src/server/Subscription.ts @@ -3,24 +3,60 @@ import { createSolanaRpc, getBase64Codec, getCompiledTransactionMessageDecoder, + getSignatureFromTransaction, getTransactionDecoder, isTransactionPartialSigner, type TransactionPartialSigner, } from '@solana/kit'; +import { findAssociatedTokenPda } from '@solana-program/token'; import { Method, Receipt, Store } from 'mppx'; import { + ASSOCIATED_TOKEN_PROGRAM, + COMPUTE_BUDGET_PROGRAM, DEFAULT_RPC_URLS, + MEMO_PROGRAM, SUBSCRIPTIONS_PROGRAM, SUBSCRIPTIONS_SUBSCRIBE_DISCRIMINATOR, SUBSCRIPTIONS_TRANSFER_DISCRIMINATOR, + SYSTEM_PROGRAM, TOKEN_2022_PROGRAM, TOKEN_PROGRAM, } from '../constants.js'; +import { getSubscriptionAuthorityDecoder } from '../generated/subscriptions/accounts/subscriptionAuthority.js'; +import { getSubscriptionDelegationDecoder } from '../generated/subscriptions/accounts/subscriptionDelegation.js'; +import { getSubscribeInstructionDataDecoder } from '../generated/subscriptions/instructions/subscribe.js'; +import { getTransferSubscriptionInstructionDataDecoder } from '../generated/subscriptions/instructions/transferSubscription.js'; +import { findEventAuthorityPda } from '../generated/subscriptions/pdas/eventAuthority.js'; import * as Methods from '../Methods.js'; -import { deriveSubscriptionPda, mapSubscriptionPeriodToHours } from '../shared/subscription.js'; +import { + deriveSubscriptionAuthorityPda, + deriveSubscriptionPda, + mapSubscriptionPeriodToHours, +} from '../shared/subscription.js'; import { coSignBase64Transaction } from '../utils/transactions.js'; +const MAX_COMPUTE_UNIT_LIMIT = 200_000; +const MAX_SPONSORED_COMPUTE_UNIT_PRICE_MICROLAMPORTS = 10_000n; + +export interface SubscriptionReplayStore extends Store.Store { + readonly isDurable?: boolean; + readonly isShared?: boolean; + reserve(key: string, value?: unknown, ttlSeconds?: number): Promise; +} + +async function claimConsumed(store: SubscriptionReplayStore, key: string): Promise { + return await store.reserve(key, true); +} + +async function releaseConsumedBestEffort(store: SubscriptionReplayStore, key: string): Promise { + try { + await store.delete(key); + } catch { + // Keep the reservation when cleanup is unavailable. + } +} + /** * Creates a Solana `subscription` method for usage on the server. * @@ -57,6 +93,10 @@ import { coSignBase64Transaction } from '../utils/transactions.js'; export function subscription(parameters: subscription.Parameters) { const { planId, + planIdNumeric, + planBump, + planCreatedAt, + merchant, mint, decimals, tokenProgram, @@ -67,7 +107,7 @@ export function subscription(parameters: subscription.Parameters) { network = 'mainnet-beta', programId = SUBSCRIPTIONS_PROGRAM, signer, - store = Store.memory(), + store, splits, subscriptionExpires, } = parameters; @@ -76,13 +116,22 @@ export function subscription(parameters: subscription.Parameters) { throw new Error(`tokenProgram must be ${TOKEN_PROGRAM} or ${TOKEN_2022_PROGRAM}`); } - if (signer && !isTransactionPartialSigner(signer)) { - throw new Error('signer must implement signTransactions() for fee payer mode'); + if (!signer || !isTransactionPartialSigner(signer)) { + throw new Error('subscription signer is required and must implement signTransactions()'); + } + if (signer.address !== puller) { + throw new Error('subscription signer address must equal puller'); + } + if (!store || typeof store.reserve !== 'function') { + throw new Error('subscription store must implement atomic reserve(key, value)'); + } + if (network !== 'localnet' && (store.isShared !== true || store.isDurable !== true)) { + throw new Error('subscription store must report isShared=true and isDurable=true outside localnet'); } // Validate the period mapping up front so misconfigured servers fail at boot, // not on the first challenge. - mapSubscriptionPeriodToHours(periodUnit, periodCount); + const expectedPeriodHours = mapSubscriptionPeriodToHours(periodUnit, periodCount); const rpcUrl = parameters.rpcUrl ?? DEFAULT_RPC_URLS[network] ?? DEFAULT_RPC_URLS['mainnet-beta']; @@ -92,8 +141,13 @@ export function subscription(parameters: subscription.Parameters) { currency: mint, methodDetails: { decimals, + expectedCreatedAt: String(planCreatedAt), + expectedPeriodHours: String(expectedPeriodHours), + merchant, mint, + planBump, planId, + planIdNumeric: String(planIdNumeric), puller, tokenProgram, }, @@ -133,14 +187,20 @@ export function subscription(parameters: subscription.Parameters) { currency: mint, methodDetails: { decimals, + expectedCreatedAt: String(planCreatedAt), + expectedPeriodHours: String(expectedPeriodHours), + feePayer: true, + feePayerKey: signer.address, + merchant, mint, network, + planBump, planId, + planIdNumeric: String(planIdNumeric), programId, puller, tokenProgram, - ...(signer ? { feePayer: true, feePayerKey: signer.address } : {}), - ...(splits?.length ? { splits } : {}), + ...(splits !== undefined ? { splits } : {}), ...(recentBlockhash ? { recentBlockhash } : {}), }, periodCount: request.periodCount ?? String(periodCount), @@ -150,84 +210,122 @@ export function subscription(parameters: subscription.Parameters) { }; }, - async verify({ credential }) { + async verify({ credential, request }) { const cred = credential as unknown as CredentialPayload; const challenge = cred.challenge.request; const payloadType = resolvePayloadType(cred.payload); - if (payloadType === 'signature' && challenge.methodDetails.feePayer) { - throw new Error('type="signature" credentials cannot be used with fee sponsorship (feePayer: true)'); + if (payloadType === 'signature') { + throw new Error('Subscription signature-mode activation is unsupported; submit the signed transaction'); } - const subscriberAddress = await settleActivation(cred, challenge, rpcUrl, store, signer, payloadType); - - const subscriptionPda = await deriveSubscriptionPda({ - planPda: address(challenge.methodDetails.planId), - programId: address(challenge.methodDetails.programId ?? SUBSCRIPTIONS_PROGRAM), - subscriber: address(subscriberAddress), + assertSubscriptionCredentialBinding(challenge, request as unknown as ChallengeRequest, { + decimals, + expectedCreatedAt: String(planCreatedAt), + expectedPeriodHours: String(expectedPeriodHours), + merchant, + mint, + network, + periodCount, + periodUnit, + planBump, + planId, + planIdNumeric: String(planIdNumeric), + programId, + puller, + recipient, + signerAddress: signer.address, + splits, + subscriptionExpires, + tokenProgram, }); - const expectedPeriodHours = mapSubscriptionPeriodToHours( - challenge.periodUnit, - Number(challenge.periodCount), + // `settleActivation` atomically claims the activation signature's + // replay marker before broadcasting and hands the claimed key back. + // We now own that reservation: every fallible step below (PDA fetch, + // terms checks) must release it on error so a transient failure does + // not permanently brick a legitimate retry, matching the Rust port's + // release-on-error window. The claim is disarmed (kept) only once a + // receipt is produced on the happy path, so a genuine replay of the + // same activation signature stays rejected. + const { consumedKey, subscriber: subscriberAddress } = await settleActivation( + cred, + challenge, + rpcUrl, + store, + signer, + payloadType, ); - const delegation = await fetchSubscriptionDelegation(rpcUrl, subscriptionPda); - if (!delegation) { - throw new Error('SubscriptionDelegation account not found after activation'); - } + try { + const subscriptionPda = await deriveSubscriptionPda({ + planPda: address(challenge.methodDetails.planId), + programId: address(challenge.methodDetails.programId ?? SUBSCRIPTIONS_PROGRAM), + subscriber: address(subscriberAddress), + }); - if (delegation.planPda !== challenge.methodDetails.planId) { - throw new Error( - `SubscriptionDelegation plan mismatch: expected ${challenge.methodDetails.planId}, got ${delegation.planPda}`, - ); - } - if (delegation.mint !== challenge.methodDetails.mint) { - throw new Error( - `SubscriptionDelegation mint mismatch: expected ${challenge.methodDetails.mint}, got ${delegation.mint}`, - ); - } - if (delegation.amountPerPeriod !== challenge.amount) { - throw new Error( - `SubscriptionDelegation amount mismatch: expected ${challenge.amount}, got ${delegation.amountPerPeriod}`, - ); - } - if (delegation.periodHours !== expectedPeriodHours) { - throw new Error( - `SubscriptionDelegation period mismatch: expected ${expectedPeriodHours}h, got ${delegation.periodHours}h`, + const expectedPeriodHours = mapSubscriptionPeriodToHours( + challenge.periodUnit, + Number(challenge.periodCount), ); - } - if (delegation.amountPulledInPeriod !== challenge.amount) { - throw new Error('Activation transaction did not execute the first-period charge'); - } - const periodLengthSeconds = expectedPeriodHours * 3600; - const periodStartTs = delegation.currentPeriodStartTs; - const periodEndTs = periodStartTs + periodLengthSeconds; - - const subscriptionId = base64UrlEncodeNoPadding(decodeBase58(subscriptionPda.toString())); - - return Receipt.from({ - method: 'solana', - ...(cred.challenge.id ? { challengeId: cred.challenge.id } : {}), - ...(challenge.externalId ? { externalId: challenge.externalId } : {}), - // Subscription-specific receipt extensions live alongside the - // Receipt's standard fields. The mppx framework treats unknown - // fields as opaque metadata. - // @ts-expect-error subscription extensions are not in the base Receipt type - expiresAt: challenge.subscriptionExpires, - - periodEndTs: new Date(periodEndTs * 1000).toISOString(), + const delegation = await fetchSubscriptionDelegation(rpcUrl, subscriptionPda); + if (!delegation) { + throw new Error('SubscriptionDelegation account not found after activation'); + } - periodIndex: '0', + if (delegation.planPda !== challenge.methodDetails.planId) { + throw new Error( + `SubscriptionDelegation plan mismatch: expected ${challenge.methodDetails.planId}, got ${delegation.planPda}`, + ); + } + if (delegation.amountPerPeriod !== challenge.amount) { + throw new Error( + `SubscriptionDelegation amount mismatch: expected ${challenge.amount}, got ${delegation.amountPerPeriod}`, + ); + } + if (delegation.periodHours !== expectedPeriodHours) { + throw new Error( + `SubscriptionDelegation period mismatch: expected ${expectedPeriodHours}h, got ${delegation.periodHours}h`, + ); + } + if (delegation.amountPulledInPeriod !== challenge.amount) { + throw new Error('Activation transaction did not execute the first-period charge'); + } - periodStartTs: new Date(periodStartTs * 1000).toISOString(), - planId: challenge.methodDetails.planId, - reference: subscriptionPda.toString(), - status: 'success', - subscriptionId, - timestamp: new Date().toISOString(), - }); + const periodLengthSeconds = expectedPeriodHours * 3600; + const periodStartTs = delegation.currentPeriodStartTs; + const periodEndTs = periodStartTs + periodLengthSeconds; + + const subscriptionId = base64UrlEncodeNoPadding(decodeBase58(subscriptionPda.toString())); + + const receipt = Receipt.from({ + method: 'solana', + reference: subscriptionPda.toString(), + status: 'success', + timestamp: new Date().toISOString(), + }); + return { + ...receipt, + ...(cred.challenge.id ? { challengeId: cred.challenge.id } : {}), + ...(challenge.externalId ? { externalId: challenge.externalId } : {}), + ...(challenge.subscriptionExpires ? { expiresAt: challenge.subscriptionExpires } : {}), + periodEndTs: new Date(periodEndTs * 1000).toISOString(), + periodIndex: '0', + periodStartTs: new Date(periodStartTs * 1000).toISOString(), + planId: challenge.methodDetails.planId, + subscriptionId, + }; + } catch (err) { + // A post-settlement failure (PDA fetch, on-chain terms mismatch) + // means no receipt was issued, so release the reservation the + // successful claim took. Otherwise a transient RPC error or a + // lagging on-chain read would permanently reject a legitimate + // retry of the same activation. Best-effort: a failed delete + // cannot make the original error any worse. + await releaseConsumedBestEffort(store, consumedKey); + throw err; + } }, }); @@ -246,63 +344,148 @@ function resolvePayloadType(payload: { throw new Error('Missing or invalid payload type: must be "transaction" or "signature"'); } +// Mppx binds only generic payment fields across routes. Subscription authority +// and resource fields must also match the route that is currently verifying. +function assertSubscriptionCredentialBinding( + challenge: ChallengeRequest, + currentRequest: ChallengeRequest, + expected: SubscriptionCredentialBinding, +): void { + const bindings: Array<[field: string, actual: unknown, expected: unknown]> = [ + ['amount', challenge.amount, currentRequest.amount], + ['currency', challenge.currency, expected.mint], + ['externalId', challenge.externalId, currentRequest.externalId], + ['periodCount', challenge.periodCount, String(expected.periodCount)], + ['periodUnit', challenge.periodUnit, expected.periodUnit], + ['recipient', challenge.recipient, expected.recipient], + // `resource` is inside the HMAC-bound request and identifies the route. + ['resource', challenge.resource, currentRequest.resource], + ['subscriptionExpires', challenge.subscriptionExpires, expected.subscriptionExpires], + ['methodDetails.decimals', challenge.methodDetails.decimals, expected.decimals], + ['methodDetails.expectedCreatedAt', challenge.methodDetails.expectedCreatedAt, expected.expectedCreatedAt], + [ + 'methodDetails.expectedPeriodHours', + challenge.methodDetails.expectedPeriodHours, + expected.expectedPeriodHours, + ], + ['methodDetails.feePayer', challenge.methodDetails.feePayer, true], + ['methodDetails.feePayerKey', challenge.methodDetails.feePayerKey, expected.signerAddress], + ['methodDetails.merchant', challenge.methodDetails.merchant, expected.merchant], + ['methodDetails.mint', challenge.methodDetails.mint, expected.mint], + ['methodDetails.network', challenge.methodDetails.network, expected.network], + ['methodDetails.planBump', challenge.methodDetails.planBump, expected.planBump], + ['methodDetails.planId', challenge.methodDetails.planId, expected.planId], + ['methodDetails.planIdNumeric', challenge.methodDetails.planIdNumeric, expected.planIdNumeric], + ['methodDetails.programId', challenge.methodDetails.programId, expected.programId], + ['methodDetails.puller', challenge.methodDetails.puller, expected.puller], + ['methodDetails.tokenProgram', challenge.methodDetails.tokenProgram, expected.tokenProgram], + ]; + + for (const [field, actual, expectedValue] of bindings) { + if (actual !== expectedValue) { + throw new Error(`Subscription credential ${field} does not match the current route`); + } + } + + if (!subscriptionSplitsMatch(challenge.methodDetails.splits, expected.splits)) { + throw new Error('Subscription credential methodDetails.splits does not match the current route'); + } +} + +function subscriptionSplitsMatch( + actual: Array<{ bps: number; recipient: string }> | undefined, + expected: Array<{ bps: number; recipient: string }> | undefined, +): boolean { + if (actual === undefined || expected === undefined) return actual === expected; + return ( + actual.length === expected.length && + actual.every( + (split, index) => split.bps === expected[index]?.bps && split.recipient === expected[index]?.recipient, + ) + ); +} + // ── Activation settlement ── async function settleActivation( credential: CredentialPayload, challenge: ChallengeRequest, rpcUrl: string, - store: Store.Store, - signer: TransactionPartialSigner | undefined, + store: SubscriptionReplayStore, + signer: TransactionPartialSigner, payloadType: 'signature' | 'transaction', -): Promise { +): Promise<{ consumedKey: string; subscriber: string }> { if (payloadType === 'transaction') { const { transaction: clientTxBase64 } = credential.payload; if (!clientTxBase64) { throw new Error('Missing transaction data in credential payload'); } - const subscriber = extractSubscriberFromTransaction(clientTxBase64, challenge); - validateActivationInstructions(clientTxBase64, challenge); + const subscriber = await validateActivationInstructions(clientTxBase64, challenge, rpcUrl); let txToSend = clientTxBase64; - if (signer) { - txToSend = await coSignBase64Transaction(signer, clientTxBase64); + const message = decodeCompiledMessage(clientTxBase64); + if (message.staticAccounts[0] !== signer.address) { + throw new Error( + `Signer ${signer.address} must be the transaction fee payer (account index 0) to be co-signed`, + ); } + txToSend = await coSignBase64Transaction(signer, clientTxBase64); - await simulateTransaction(rpcUrl, txToSend); - const signature = await broadcastTransaction(rpcUrl, txToSend); - await waitForConfirmation(rpcUrl, signature); + // The activation signature is the transaction's own first signature and + // is exactly what `sendTransaction` echoes back, so it is a stable + // replay key known before broadcast. + const signature = signatureFromWireTransaction(txToSend); + const consumedKey = `solana-subscription:consumed:${signature}`; - await store.put(`solana-subscription:consumed:${signature}`, true); - return subscriber; - } + if (!(await claimConsumed(store, consumedKey))) { + throw new Error('Activation signature already consumed'); + } - // ── Push mode (type="signature") ── - const { signature } = credential.payload; - if (!signature) { - throw new Error('Missing signature in credential payload'); - } - const consumedKey = `solana-subscription:consumed:${signature}`; - if (await store.get(consumedKey)) { - throw new Error('Activation signature already consumed'); - } + try { + const subscriptionPda = await deriveSubscriptionPda({ + planPda: address(challenge.methodDetails.planId), + programId: address(challenge.methodDetails.programId ?? SUBSCRIPTIONS_PROGRAM), + subscriber: address(subscriber), + }); + const delegationAlreadyExists = (await fetchSubscriptionDelegation(rpcUrl, subscriptionPda)) !== null; - const tx = await fetchTransactionRaw(rpcUrl, signature); - if (!tx) throw new Error('Transaction not found or not yet confirmed'); - if (tx.meta?.err) throw new Error('Transaction failed on-chain'); + if (delegationAlreadyExists) { + if (!(await isSignatureConfirmed(rpcUrl, signature))) { + throw new Error( + 'Subscription is already active, but the submitted activation signature was not confirmed', + ); + } + } else { + await simulateTransaction(rpcUrl, txToSend); + const broadcastSignature = await broadcastTransaction(rpcUrl, txToSend); + if (broadcastSignature !== signature) { + throw new Error( + 'RPC returned a signature that does not match the submitted activation transaction', + ); + } + await waitForConfirmation(rpcUrl, broadcastSignature); + } + } catch (err) { + await releaseConsumedBestEffort(store, consumedKey); + throw err; + } - // The subscriber is the first signer that is not the fee payer (when - // fee sponsorship is in play) or simply the fee payer otherwise. - const accountKeys = tx.transaction.message.accountKeys ?? []; - if (accountKeys.length === 0) { - throw new Error('Transaction has no account keys'); + return { consumedKey, subscriber }; } - const firstAccount = typeof accountKeys[0] === 'string' ? accountKeys[0] : accountKeys[0].pubkey; - const subscriber = firstAccount; - await store.put(consumedKey, true); - return subscriber; + throw new Error('Subscription signature-mode activation is unsupported; submit the signed transaction'); +} + +/** + * The first (fee-payer) signature of a signed base64 wire transaction, + * base58-encoded. This equals the value `sendTransaction` returns, so it is a + * stable replay key known before broadcast, letting the activation guard claim + * the consumed marker atomically up front rather than after confirmation. + */ +function signatureFromWireTransaction(wireTxBase64: string): string { + const tx = getTransactionDecoder().decode(getBase64Codec().encode(wireTxBase64)); + return getSignatureFromTransaction(tx); } // ── Transaction parsing (lightweight, pre-broadcast) ── @@ -316,49 +499,75 @@ async function settleActivation( type CompiledMessage = { addressTableLookups?: readonly unknown[]; + header: { + numReadonlyNonSignerAccounts: number; + numReadonlySignerAccounts: number; + numSignerAccounts: number; + }; instructions: readonly CompiledInstruction[]; staticAccounts: readonly string[]; + version: number | 'legacy'; }; type CompiledInstruction = { - accountIndices: readonly number[]; + accountIndices?: readonly number[]; data: Uint8Array; programAddressIndex: number; }; +function assertLegacyOrV0Message(version: number | 'legacy', context: string): void { + if (version === 'legacy' || version === 0) { + return; + } + throw new Error( + `${context}: unsupported transaction message version ${version} - only legacy and v0 messages are accepted`, + ); +} + +/** Resolve a static account address by index, throwing on an out-of-range index. */ +function accountAddress(message: CompiledMessage, index: number, label: string): string { + const value = message.staticAccounts[index]; + if (value === undefined) { + throw new Error(`Invalid ${label} index`); + } + return value; +} + function decodeCompiledMessage(clientTxBase64: string): CompiledMessage { + let message: CompiledMessage; try { const txBytes = getBase64Codec().encode(clientTxBase64); const decoded = getTransactionDecoder().decode(txBytes); - return getCompiledTransactionMessageDecoder().decode(decoded.messageBytes) as unknown as CompiledMessage; + message = getCompiledTransactionMessageDecoder().decode(decoded.messageBytes) as unknown as CompiledMessage; } catch (e) { throw new Error(`Invalid transaction: ${e instanceof Error ? e.message : String(e)}`); } + // Reject any versioned message beyond legacy/v0 before touching + // `.instructions` / `.addressTableLookups`. A v1 message decodes to a shape + // that carries neither field, so the ALT guard would be silently skipped + // and the instruction loop would crash with a TypeError on hostile input. + assertLegacyOrV0Message(message.version, 'Activation transaction'); + return message; } function extractSubscriberFromTransaction(clientTxBase64: string, challenge: ChallengeRequest): string { const message = decodeCompiledMessage(clientTxBase64); - if (message.staticAccounts.length === 0) { - throw new Error('Transaction has no static accounts'); + const signers = message.staticAccounts.slice(0, message.header.numSignerAccounts); + const feePayer = challenge.methodDetails.feePayerKey; + if (!feePayer || signers[0] !== feePayer) { + throw new Error('Configured subscription fee payer must be signer account index 0'); } - - // When fee sponsorship is in play, the first signer is the server's fee - // payer; the subscriber is the next signer that is not the puller. - if (challenge.methodDetails.feePayer && challenge.methodDetails.feePayerKey) { - for (const account of message.staticAccounts.slice(1)) { - if (account !== challenge.methodDetails.puller) return account; - } - throw new Error('Could not identify subscriber among transaction signers'); - } - - const firstAccount = message.staticAccounts[0]; - if (challenge.methodDetails.puller && firstAccount === challenge.methodDetails.puller) { - throw new Error('Subscriber cannot be the server puller'); + if (signers.length !== 2 || signers[1] === feePayer) { + throw new Error('Canonical subscription activation requires exactly fee-payer and subscriber signers'); } - return firstAccount; + return signers[1]; } -function validateActivationInstructions(clientTxBase64: string, challenge: ChallengeRequest): void { +async function validateActivationInstructions( + clientTxBase64: string, + challenge: ChallengeRequest, + rpcUrl: string, +): Promise { const message = decodeCompiledMessage(clientTxBase64); if (message.addressTableLookups?.length) { @@ -366,33 +575,287 @@ function validateActivationInstructions(clientTxBase64: string, challenge: Chall } const programId = challenge.methodDetails.programId ?? SUBSCRIPTIONS_PROGRAM; - - let sawSubscribe = false; - let sawTransferSubscription = false; - let subscribeIndex = -1; - let transferIndex = -1; - + const subscriber = extractSubscriberFromTransaction(clientTxBase64, challenge); + const feePayer = challenge.methodDetails.feePayerKey!; + const programAddress = address(programId); + const planPda = address(challenge.methodDetails.planId); + const mint = address(challenge.methodDetails.mint); + const tokenProgram = address(challenge.methodDetails.tokenProgram); + const subscriptionPda = await deriveSubscriptionPda({ + planPda, + programId: programAddress, + subscriber: address(subscriber), + }); + const subscriptionAuthority = await deriveSubscriptionAuthorityPda({ + mint, + programId: programAddress, + subscriber: address(subscriber), + }); + const [subscriberAta] = await findAssociatedTokenPda({ + mint, + owner: address(subscriber), + tokenProgram, + }); + const [recipientAta] = await findAssociatedTokenPda({ + mint, + owner: address(challenge.recipient), + tokenProgram, + }); + const [eventAuthority] = await findEventAuthorityPda({ programAddress }); + + const ataOwners = new Set(); + let subscribeIndex: number | undefined; + let transferIndex: number | undefined; + let memoIndex: number | undefined; + let computeLimitSeen = false; + let computePriceSeen = false; for (const [index, ix] of message.instructions.entries()) { const program = message.staticAccounts[ix.programAddressIndex]; - if (program !== programId) continue; - if (ix.data.length === 0) continue; - if (ix.data[0] === SUBSCRIPTIONS_SUBSCRIBE_DISCRIMINATOR) { - if (sawSubscribe) throw new Error('Multiple subscribe instructions found'); - sawSubscribe = true; - subscribeIndex = index; - } else if (ix.data[0] === SUBSCRIPTIONS_TRANSFER_DISCRIMINATOR) { - if (sawTransferSubscription) throw new Error('Multiple transfer_subscription instructions found'); - sawTransferSubscription = true; - transferIndex = index; + if (program === undefined) { + throw new Error('Activation transaction instruction references an out-of-range program id index'); + } + + if (program === programId) { + const disc = ix.data[0]; + if (disc === SUBSCRIPTIONS_SUBSCRIBE_DISCRIMINATOR) { + if (subscribeIndex !== undefined) throw new Error('Multiple subscribe instructions found'); + subscribeIndex = index; + if (ix.data.length !== 74) throw new Error('Canonical subscribe instruction data must be 74 bytes'); + assertCanonicalAccounts(message, ix, [ + [subscriber, true, true], + [ + challenge.methodDetails.merchant, + challenge.methodDetails.merchant === feePayer, + challenge.methodDetails.merchant === feePayer, + ], + [planPda, false, false], + [subscriptionPda, true, false], + [subscriptionAuthority, false, false], + [SYSTEM_PROGRAM, false, false], + [eventAuthority, false, false], + [programId, false, false], + [feePayer, true, true], + ]); + const decoded = getSubscribeInstructionDataDecoder().decode(ix.data); + const expectedAmount = BigInt(challenge.amount); + const expectedPeriodHours = BigInt(challenge.methodDetails.expectedPeriodHours); + if ( + decoded.subscribeData.planId !== BigInt(challenge.methodDetails.planIdNumeric) || + decoded.subscribeData.planBump !== challenge.methodDetails.planBump || + decoded.subscribeData.expectedMint !== mint || + decoded.subscribeData.expectedAmount !== expectedAmount || + decoded.subscribeData.expectedPeriodHours !== expectedPeriodHours || + decoded.subscribeData.expectedCreatedAt !== BigInt(challenge.methodDetails.expectedCreatedAt) + ) { + throw new Error('SubscribeData does not match the challenged plan snapshot'); + } + const liveInitId = await fetchSubscriptionAuthorityInitId(rpcUrl, subscriptionAuthority); + if (liveInitId === null || decoded.subscribeData.expectedSubscriptionAuthorityInitId !== liveInitId) { + throw new Error('SubscribeData authority init id does not match the live SubscriptionAuthority'); + } + } else if (disc === SUBSCRIPTIONS_TRANSFER_DISCRIMINATOR) { + if (transferIndex !== undefined) throw new Error('Multiple transfer_subscription instructions found'); + transferIndex = index; + if (ix.data.length !== 73) { + throw new Error('Canonical transfer_subscription instruction data must be 73 bytes'); + } + assertCanonicalAccounts(message, ix, [ + [subscriptionPda, true, false], + [planPda, false, false], + [subscriptionAuthority, false, false], + [subscriberAta, true, false], + [recipientAta, true, false], + [challenge.methodDetails.puller, true, true], + [mint, false, false], + [tokenProgram, false, false], + [eventAuthority, false, false], + [programId, false, false], + ]); + const decoded = getTransferSubscriptionInstructionDataDecoder().decode(ix.data); + if ( + decoded.transferData.amount !== BigInt(challenge.amount) || + decoded.transferData.delegator !== subscriber || + decoded.transferData.mint !== mint + ) { + throw new Error('TransferData does not match subscriber, mint, and challenged amount'); + } + } else { + throw new Error( + `Activation transaction contains a disallowed subscriptions-program instruction (discriminator ${disc}); only subscribe and transfer_subscription are allowed`, + ); + } + } else if (program === COMPUTE_BUDGET_PROGRAM || program === MEMO_PROGRAM) { + if (program === COMPUTE_BUDGET_PROGRAM) { + if ((ix.accountIndices?.length ?? 0) !== 0) { + throw new Error('Compute budget instruction must not have accounts'); + } + if (ix.data[0] === 2 && ix.data.length === 5) { + if (computeLimitSeen) throw new Error('Duplicate compute unit limit instruction'); + computeLimitSeen = true; + const units = new DataView(ix.data.buffer, ix.data.byteOffset + 1, 4).getUint32(0, true); + if (units > MAX_COMPUTE_UNIT_LIMIT) { + throw new Error(`Compute unit limit ${units} exceeds maximum ${MAX_COMPUTE_UNIT_LIMIT}`); + } + } else if (ix.data[0] === 3 && ix.data.length === 9) { + if (computePriceSeen) throw new Error('Duplicate compute unit price instruction'); + computePriceSeen = true; + const price = new DataView(ix.data.buffer, ix.data.byteOffset + 1, 8).getBigUint64(0, true); + if (price > MAX_SPONSORED_COMPUTE_UNIT_PRICE_MICROLAMPORTS) { + throw new Error( + `Compute unit price ${price} exceeds maximum ${MAX_SPONSORED_COMPUTE_UNIT_PRICE_MICROLAMPORTS}`, + ); + } + } else { + throw new Error('Unsupported or malformed compute budget instruction'); + } + } else { + if (memoIndex !== undefined || !challenge.externalId) { + throw new Error('Activation memo is duplicated or was not challenged'); + } + if ( + (ix.accountIndices?.length ?? 0) !== 0 || + new TextDecoder().decode(ix.data) !== challenge.externalId + ) { + throw new Error('Activation memo does not match externalId'); + } + memoIndex = index; + } + continue; + } else if (program === ASSOCIATED_TOKEN_PROGRAM) { + const owner = await validateActivationAtaInstruction(message, ix, challenge, feePayer); + if (owner !== subscriber && owner !== challenge.recipient) { + throw new Error('Activation ATA owner must be the subscriber or recipient'); + } + if (ataOwners.has(owner)) throw new Error('Duplicate activation ATA creation'); + ataOwners.add(owner); + continue; + } else { + throw new Error( + `Activation transaction invokes a disallowed program ${program}; the fee payer will not co-sign a transaction outside the subscription activation allowlist`, + ); } } - if (!sawSubscribe) throw new Error('Activation transaction is missing subscribe instruction'); - if (!sawTransferSubscription) + if (subscribeIndex === undefined) throw new Error('Activation transaction is missing subscribe instruction'); + if (transferIndex === undefined) throw new Error('Activation transaction is missing transfer_subscription instruction'); if (transferIndex < subscribeIndex) { throw new Error('subscribe must precede transfer_subscription in activation transaction'); } + if (memoIndex !== undefined && memoIndex < transferIndex) { + throw new Error('Activation memo must follow transfer_subscription'); + } + if (!computeLimitSeen) { + throw new Error('Activation transaction must contain exactly one SetComputeUnitLimit instruction'); + } + if (!ataOwners.has(subscriber) || !ataOwners.has(challenge.recipient) || ataOwners.size !== 2) { + throw new Error('Activation must create subscriber and recipient ATAs idempotently'); + } + return subscriber; +} + +function assertCanonicalAccounts( + message: CompiledMessage, + ix: CompiledInstruction, + expected: ReadonlyArray, +): void { + if ((ix.accountIndices?.length ?? 0) !== expected.length) { + throw new Error( + `Canonical instruction expected ${expected.length} accounts, got ${ix.accountIndices?.length ?? 0}`, + ); + } + expected.forEach(([expectedAddress, writable, signer], position) => { + const accountIndex = ix.accountIndices![position]; + const actual = accountAddress(message, accountIndex, `instruction account ${position}`); + if (actual !== expectedAddress.toString()) { + throw new Error(`Canonical instruction account ${position} mismatch`); + } + if (isSignerIndex(message, accountIndex) !== signer) { + throw new Error(`Canonical instruction account ${position} signer privilege mismatch`); + } + if (isWritableIndex(message, accountIndex) !== writable) { + throw new Error(`Canonical instruction account ${position} writable privilege mismatch`); + } + }); +} + +function isSignerIndex(message: CompiledMessage, index: number): boolean { + return index < message.header.numSignerAccounts; +} + +function isWritableIndex(message: CompiledMessage, index: number): boolean { + if (isSignerIndex(message, index)) { + return index < message.header.numSignerAccounts - message.header.numReadonlySignerAccounts; + } + return index < message.staticAccounts.length - message.header.numReadonlyNonSignerAccounts; +} + +/** + * Validate an Associated-Token-Account `CreateIdempotent` instruction in an + * activation transaction before the fee payer co-signs it. Mirrors the charge + * verifier's `validateCreateAtaIdempotentInstruction`: require the idempotent + * discriminator with the canonical 6-account layout, and assert that the + * funding account is the transaction fee payer, the mint is the plan mint, the + * token program is the configured one, the owner is one the challenge + * authorizes, and that the ATA address re-derives from `(owner, mint, token)`. + */ +async function validateActivationAtaInstruction( + message: CompiledMessage, + ix: CompiledInstruction, + challenge: ChallengeRequest, + expectedPayer: string | undefined, +): Promise { + if (ix.data.length !== 1 || ix.data[0] !== 1) { + throw new Error('Activation transaction may only use idempotent ATA creation'); + } + if (ix.accountIndices?.length !== 6) { + throw new Error('Unexpected ATA creation account layout in activation transaction'); + } + + const payer = accountAddress(message, ix.accountIndices[0], 'ATA payer'); + const ata = accountAddress(message, ix.accountIndices[1], 'ATA address'); + const owner = accountAddress(message, ix.accountIndices[2], 'ATA owner'); + const mint = accountAddress(message, ix.accountIndices[3], 'ATA mint'); + const systemProgram = accountAddress(message, ix.accountIndices[4], 'ATA system program'); + const tokenProgram = accountAddress(message, ix.accountIndices[5], 'ATA token program'); + + if (expectedPayer === undefined || payer !== expectedPayer) { + throw new Error('ATA payer must be the transaction fee payer in activation transaction'); + } + if (mint !== challenge.methodDetails.mint) { + throw new Error('ATA creation mint does not match the plan mint'); + } + if (systemProgram !== SYSTEM_PROGRAM) { + throw new Error('ATA creation must reference the System Program'); + } + if (tokenProgram !== TOKEN_PROGRAM && tokenProgram !== TOKEN_2022_PROGRAM) { + throw new Error('ATA creation uses an unsupported token program'); + } + if (challenge.methodDetails.tokenProgram && tokenProgram !== challenge.methodDetails.tokenProgram) { + throw new Error('ATA creation token program does not match the configured token program'); + } + + const [expectedAta] = await findAssociatedTokenPda({ + mint: address(mint), + owner: address(owner), + tokenProgram: address(tokenProgram), + }); + if (ata !== expectedAta) { + throw new Error('ATA creation address does not match owner/mint/token program'); + } + assertCanonicalAccounts(message, ix, [ + [expectedPayer, true, true], + [expectedAta, true, false], + [ + owner, + owner === expectedPayer || owner === message.staticAccounts[1], + owner === expectedPayer || owner === message.staticAccounts[1], + ], + [mint, false, false], + [SYSTEM_PROGRAM, false, false], + [tokenProgram, false, false], + ]); + return owner; } // ── On-chain SubscriptionDelegation decoding (v0) ── @@ -405,7 +868,6 @@ type SubscriptionDelegation = { amountPerPeriod: string; amountPulledInPeriod: string; currentPeriodStartTs: number; - mint: string; periodHours: number; planPda: string; subscriber: string; @@ -423,88 +885,23 @@ async function fetchSubscriptionDelegation( return decodeSubscriptionDelegation(data); } -// Offsets correspond to the subscriptions program's -// SubscriptionDelegation layout (see /Users/ludo/Coding/solana-program/ -// subscriptions/program/src/state/subscription_delegation.rs). This is a -// minimum-viable decoder: it reads only the fields needed for activation -// verification. Replace with the Codama client in v0.1. -const SUBSCRIPTION_DELEGATION_DISCRIMINATOR_LEN = 1; -const PUBKEY_LEN = 32; - function decodeSubscriptionDelegation(data: Uint8Array): SubscriptionDelegation { - let offset = SUBSCRIPTION_DELEGATION_DISCRIMINATOR_LEN; - // Header: delegator (subscriber), delegatee, payer (sponsor), init_id (u64) - const subscriber = encodeBase58(data.subarray(offset, offset + PUBKEY_LEN)); - offset += PUBKEY_LEN; - // delegatee - offset += PUBKEY_LEN; - // payer - offset += PUBKEY_LEN; - // init_id u64 - offset += 8; - const planPda = encodeBase58(data.subarray(offset, offset + PUBKEY_LEN)); - offset += PUBKEY_LEN; - const mint = encodeBase58(data.subarray(offset, offset + PUBKEY_LEN)); - offset += PUBKEY_LEN; - const amountPerPeriod = readU64Le(data, offset).toString(); - offset += 8; - const periodHours = Number(readU64Le(data, offset)); - offset += 8; - const currentPeriodStartTs = Number(readI64Le(data, offset)); - offset += 8; - const amountPulledInPeriod = readU64Le(data, offset).toString(); + const decoded = getSubscriptionDelegationDecoder().decode(data); return { - amountPerPeriod, - amountPulledInPeriod, - currentPeriodStartTs, - mint, - periodHours, - planPda, - subscriber, + amountPerPeriod: decoded.terms.amount.toString(), + amountPulledInPeriod: decoded.amountPulledInPeriod.toString(), + currentPeriodStartTs: Number(decoded.currentPeriodStartTs), + periodHours: Number(decoded.terms.periodHours), + planPda: decoded.header.delegatee, + subscriber: decoded.header.delegator, }; } -function readU64Le(data: Uint8Array, offset: number): bigint { - let value = 0n; - for (let i = 0; i < 8; i += 1) { - value |= BigInt(data[offset + i]) << BigInt(i * 8); - } - return value; -} - -function readI64Le(data: Uint8Array, offset: number): bigint { - const u = readU64Le(data, offset); - return u >= 1n << 63n ? u - (1n << 64n) : u; -} - // ── Base58/base64url helpers (minimal, dependency-free) ── const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; -function encodeBase58(bytes: Uint8Array): string { - if (bytes.length === 0) return ''; - let leading = 0; - while (leading < bytes.length && bytes[leading] === 0) leading += 1; - const buf: number[] = []; - for (let i = leading; i < bytes.length; i += 1) { - let carry = bytes[i]; - for (let j = 0; j < buf.length; j += 1) { - const x = (buf[j] << 8) + carry; - buf[j] = x % 58; - carry = Math.floor(x / 58); - } - while (carry > 0) { - buf.push(carry % 58); - carry = Math.floor(carry / 58); - } - } - let out = ''; - for (let i = 0; i < leading; i += 1) out += '1'; - for (let i = buf.length - 1; i >= 0; i -= 1) out += BASE58_ALPHABET[buf[i]]; - return out; -} - function decodeBase58(s: string): Uint8Array { if (s.length === 0) return new Uint8Array(); const map: Record = {}; @@ -540,29 +937,40 @@ function base64UrlEncodeNoPadding(bytes: Uint8Array): string { // ── RPC helpers ── -type RawTransaction = { - meta: { err: unknown } | null; - transaction: { - message: { - accountKeys: Array; - }; - }; -}; +async function fetchSubscriptionAuthorityInitId( + rpcUrl: string, + authority: { toString(): string }, +): Promise { + const rpc = createSolanaRpc(rpcUrl); + const account = await rpc.getAccountInfo(address(authority.toString()), { encoding: 'base64' }).send(); + if (!account.value) return null; + const [encoded] = account.value.data; + return getSubscriptionAuthorityDecoder().decode(getBase64Codec().encode(encoded)).initId; +} -async function fetchTransactionRaw(rpcUrl: string, signature: string): Promise { +async function isSignatureConfirmed(rpcUrl: string, signature: string): Promise { const response = await fetch(rpcUrl, { body: JSON.stringify({ id: 1, jsonrpc: '2.0', - method: 'getTransaction', - params: [signature, { commitment: 'confirmed', encoding: 'jsonParsed', maxSupportedTransactionVersion: 0 }], + method: 'getSignatureStatuses', + params: [[signature], { searchTransactionHistory: true }], }), headers: { 'Content-Type': 'application/json' }, method: 'POST', }); - const data = (await response.json()) as { error?: { message: string }; result?: RawTransaction | null }; + const data = (await response.json()) as { + error?: { message: string }; + result?: { value?: Array<{ confirmationStatus?: string; err: unknown } | null> }; + }; if (data.error) throw new Error(`RPC error: ${data.error.message}`); - return data.result ?? null; + const status = data.result?.value?.[0]; + return ( + status !== null && + status !== undefined && + status.err === null && + (status.confirmationStatus === 'confirmed' || status.confirmationStatus === 'finalized') + ); } async function simulateTransaction(rpcUrl: string, base64Tx: string): Promise { @@ -650,15 +1058,19 @@ type CredentialPayload = { type ChallengeRequest = { amount: string; currency: string; - description?: string; externalId?: string; methodDetails: { decimals: number; + expectedCreatedAt: string; + expectedPeriodHours: string; feePayer?: boolean; feePayerKey?: string; + merchant: string; mint: string; network?: string; + planBump: number; planId: string; + planIdNumeric: string; programId?: string; puller: string; recentBlockhash?: string; @@ -668,7 +1080,29 @@ type ChallengeRequest = { periodCount: string; periodUnit: 'day' | 'week'; recipient: string; + resource?: string; + subscriptionExpires?: string; +}; + +type SubscriptionCredentialBinding = { + decimals: number; + expectedCreatedAt: string; + expectedPeriodHours: string; + merchant: string; + mint: string; + network: string; + periodCount: number; + periodUnit: 'day' | 'week'; + planBump: number; + planId: string; + planIdNumeric: string; + programId: string; + puller: string; + recipient: string; + signerAddress: string; + splits?: Array<{ bps: number; recipient: string }>; subscriptionExpires?: string; + tokenProgram: string; }; // ── Test exports ── @@ -680,7 +1114,6 @@ export const __testing = { base64UrlEncodeNoPadding, decodeBase58, decodeSubscriptionDelegation, - encodeBase58, extractSubscriberFromTransaction, validateActivationInstructions, }; @@ -689,6 +1122,8 @@ export declare namespace subscription { type Parameters = { /** Token decimals for the mint. */ decimals: number; + /** Merchant/plan owner used by the canonical subscribe instruction. */ + merchant: string; /** Base58 of the SPL token mint. MUST match the on-chain plan.mint. */ mint: string; /** Solana network. Defaults to `mainnet-beta`. */ @@ -697,8 +1132,14 @@ export declare namespace subscription { periodCount: number; /** Billing period unit. The Solana profile supports `day` and `week` only. */ periodUnit: 'day' | 'week'; + /** Plan PDA bump snapshot. */ + planBump: number; + /** Plan creation timestamp snapshot. */ + planCreatedAt: bigint; /** Base58 of the on-chain Plan PDA. */ planId: string; + /** Numeric plan id used by SubscribeData. */ + planIdNumeric: bigint; /** Base58 of the subscriptions program ID. Defaults to the canonical deployment. */ programId?: string; /** Base58 of the server's puller pubkey (must be in plan.pullers or plan.owner). */ @@ -707,12 +1148,12 @@ export declare namespace subscription { recipient: string; /** Custom RPC URL. Defaults to the public RPC for the selected network. */ rpcUrl?: string; - /** Optional fee-payer signer. When set, the server co-signs activation as fee payer. */ - signer?: TransactionPartialSigner; + /** Required puller/fee-payer signer. Its address must equal puller. */ + signer: TransactionPartialSigner; /** Advisory distribution splits. The on-chain split is governed by plan.destinations. */ splits?: Array<{ bps: number; recipient: string }>; - /** Pluggable key-value store for replay protection. Defaults to in-memory. */ - store?: Store.Store; + /** Replay store with an atomic put-if-absent reserve operation. */ + store: SubscriptionReplayStore; /** Optional {@link https://datatracker.ietf.org/doc/html/rfc3339 | RFC3339} expiry of the recurring authorization. */ subscriptionExpires?: string; /** Base58 of the SPL Token or Token-2022 program ID. */ diff --git a/typescript/packages/mpp/src/server/index.ts b/typescript/packages/mpp/src/server/index.ts index 3a464c5d5..f5eed800b 100644 --- a/typescript/packages/mpp/src/server/index.ts +++ b/typescript/packages/mpp/src/server/index.ts @@ -45,6 +45,6 @@ export { type VoucherVerifyReplayed, type VoucherVerifyResult, } from './session/voucher.js'; -export { subscription } from './Subscription.js'; +export { subscription, type SubscriptionReplayStore } from './Subscription.js'; // Re-export Mppx so consumers can do: import { Mppx, solana } from '@solana/mpp/server' export { Mppx, Expires, Store } from 'mppx/server'; diff --git a/typescript/packages/mpp/src/server/session/store.ts b/typescript/packages/mpp/src/server/session/store.ts index 01261f46e..b49db8975 100644 --- a/typescript/packages/mpp/src/server/session/store.ts +++ b/typescript/packages/mpp/src/server/session/store.ts @@ -106,7 +106,12 @@ export type ChannelMutator = (current: ChannelState | undefined) => ChannelState /** Explicit storage safety declaration for session channel state. */ export type SessionStoreDurability = 'durable-shared' | 'ephemeral'; -const MEMORY_SESSION_STORE = Symbol('solana-mpp-memory-session-store'); +/** + * Brand marking a store as the process-local, non-durable in-memory + * `SessionStore`. Uses the global symbol registry so the mark survives the + * `@solana/mpp` / consumer package boundary without an `instanceof` check. + */ +const MEMORY_SESSION_STORE = Symbol.for('@solana/mpp/server:memory-session-store'); /** * Async store for per-channel state. @@ -134,7 +139,12 @@ export interface SessionStore { updateChannel(channelId: string, mutator: ChannelMutator): Promise; } -/** True only for the built-in process-local implementation. */ +/** + * True when `store` was produced by {@link createMemorySessionStore}. Higher + * level adapters use this to keep process-local session state out of + * production clusters (mirrors the replay store's `isDurable`/`isShared` + * self-report), instead of `instanceof`, which breaks across package copies. + */ export function isMemorySessionStore(store: SessionStore): boolean { return (store as unknown as Record)[MEMORY_SESSION_STORE] === true; } @@ -164,8 +174,7 @@ export function createMemorySessionStore(): SessionStore { return next; } - return { - [MEMORY_SESSION_STORE]: true, + const store: SessionStore = { deleteChannel(channelId) { data.delete(channelId); return Promise.resolve(); @@ -215,4 +224,14 @@ export function createMemorySessionStore(): SessionStore { }); }, }; + // Enumerable so an object spread (`{ ...store }`) copies the brand too: a + // shallow copy of a process-local store is still process-local, so it must + // stay detectable and be rejected off-localnet (fail CLOSED). A symbol key + // is invisible to JSON.stringify and Object.keys regardless of the + // enumerable flag, so nothing leaks into serialized output. + Object.defineProperty(store, MEMORY_SESSION_STORE, { + enumerable: true, + value: true, + }); + return store; } diff --git a/typescript/packages/mpp/src/server/store.ts b/typescript/packages/mpp/src/server/store.ts index 9d8a9c83b..acce0911a 100644 --- a/typescript/packages/mpp/src/server/store.ts +++ b/typescript/packages/mpp/src/server/store.ts @@ -54,12 +54,26 @@ export function createUnsafeMemoryReplayStore(): ReplayStore { return store; } +/** + * Atomic replay-store view: the charge/session `putIfAbsent` contract plus the + * `reserve` alias the subscription server calls. Both name the same + * cross-instance atomic claim, so one injected store (which need only implement + * `putIfAbsent`) serves every MPP settlement method through this view. + */ +export type AtomicReplayStoreView = ReplayStore & { + readonly reserve: (key: string, value?: unknown, ttlSeconds?: number) => Promise; +}; + /** * Adapt an atomic replay store to mppx's Store surface while preserving the - * module-local unsafe-memory brand for pay-kit's low-level handlers. + * module-local unsafe-memory brand for pay-kit's low-level handlers. Exposes + * `reserve` (aliasing `putIfAbsent`) so the subscription server's + * `claimConsumed` reservation runs against the same atomic marker as charge + * replay; the `ttlSeconds` hint is ignored, which fails closed (the replay + * marker never expires early). */ -export function createAtomicReplayStoreView(store: ReplayStore): ReplayStore { - const view: ReplayStore = { +export function createAtomicReplayStoreView(store: ReplayStore): AtomicReplayStoreView { + const view: AtomicReplayStoreView = { delete: key => store.delete(key), get: key => store.get(key), ...(store.isDurable === undefined ? {} : { isDurable: store.isDurable }), @@ -74,6 +88,7 @@ export function createAtomicReplayStoreView(store: ReplayStore): ReplayStore { await store.put(key, value); }, putIfAbsent: (key, value) => store.putIfAbsent(key, value), + reserve: (key, value = true) => store.putIfAbsent(key, value), }; if (isUnsafeMemoryReplayStore(store)) unsafeMemoryReplayStores.add(view); return view; diff --git a/typescript/packages/pay-kit/src/__tests__/config.test.ts b/typescript/packages/pay-kit/src/__tests__/config.test.ts index 098747e42..9bf23db1f 100644 --- a/typescript/packages/pay-kit/src/__tests__/config.test.ts +++ b/typescript/packages/pay-kit/src/__tests__/config.test.ts @@ -6,7 +6,10 @@ import { ConfigurationError, DemoSignerOnMainnetError, ProtocolNotSupportedError import { createUnsafeMemoryReplayStore, declareProductionReplayStore, type ReplayStore } from '../replay-store.js'; import { Signer } from '../signer.js'; -const SECRET = { mpp: { challengeBindingSecret: 'test-secret', allowUnsafeMemoryStore: true } }; +// Off localnet the challenge secret must be at least 32 UTF-8 bytes, so every +// non-localnet fixture uses a 32-byte secret. +const SECRET_32 = 's'.repeat(32); +const SECRET = { mpp: { challengeBindingSecret: SECRET_32, allowUnsafeMemoryStore: true } }; const values = new Map(); const SHARED_STORE: ReplayStore = declareProductionReplayStore({ isDurable: true, @@ -60,7 +63,7 @@ describe('configure', () => { }); it('refuses the demo signer on mainnet', async () => { - const mainnetMpp = { mpp: { challengeBindingSecret: 'test-secret' }, replayStore: SHARED_STORE }; + const mainnetMpp = { mpp: { challengeBindingSecret: SECRET_32 }, replayStore: SHARED_STORE }; await expect(configure({ ...mainnetMpp, network: 'solana_mainnet' })).rejects.toThrow(DemoSignerOnMainnetError); const signer = await Signer.generate(); const config = await configure({ @@ -71,6 +74,20 @@ describe('configure', () => { expect(config.operator.recipient).toBe(signer.pubkey); }); + it('refuses a demo key wrapped with a forged isDemo marker on mainnet', async () => { + const demo = await Signer.demo(); + const signer = { ...demo, isDemo: false }; + + await expect( + configure({ + mpp: { challengeBindingSecret: SECRET_32 }, + network: 'solana_mainnet', + operator: { signer }, + replayStore: SHARED_STORE, + }), + ).rejects.toThrow(DemoSignerOnMainnetError); + }); + it('rejects the unsafe replay-store override on mainnet', async () => { const signer = await Signer.generate(); await expect(configure({ ...SECRET, network: 'solana_mainnet', operator: { signer } })).rejects.toThrow( @@ -117,7 +134,7 @@ describe('configure', () => { await expect( configure({ ...SECRET, - mpp: { challengeBindingSecret: 'test-secret' }, + mpp: { challengeBindingSecret: SECRET_32 }, network: 'solana_devnet', operator: { signer }, replayStore, @@ -141,7 +158,7 @@ describe('configure', () => { await expect( configure({ ...SECRET, - mpp: { allowUnsafeMemoryStore: true, challengeBindingSecret: 'test-secret' }, + mpp: { allowUnsafeMemoryStore: true, challengeBindingSecret: SECRET_32 }, network: 'solana_devnet', operator: { signer: await Signer.generate() }, replayStore, @@ -188,23 +205,73 @@ describe('configure', () => { ); }); + it('rejects expirations that overflow Date while preserving expiresIn=0', async () => { + await expect( + configure({ ...SECRET, mpp: { ...SECRET.mpp, expiresIn: Number.MAX_SAFE_INTEGER } }), + ).rejects.toThrow('mpp.expiresIn must produce a valid expiration date.'); + + await expect(configure({ ...SECRET, mpp: { ...SECRET.mpp, expiresIn: 0 } })).resolves.toMatchObject({ + mpp: { expiresIn: 0 }, + }); + }); + + it('rejects malformed explicit MPP challenge secrets before expiry validation on localnet', async () => { + await expect(configure({ mpp: { challengeBindingSecret: '' } })).rejects.toThrow( + 'mpp.challengeBindingSecret must be a non-empty string.', + ); + await expect(configure({ mpp: { challengeBindingSecret: null as never, expiresIn: -1 } })).rejects.toThrow( + 'mpp.challengeBindingSecret must be a non-empty string.', + ); + await expect(configure({ mpp: { challengeBindingSecret: 1 as never, expiresIn: -1 } })).rejects.toThrow( + 'mpp.challengeBindingSecret must be a non-empty string.', + ); + }); + it('requires a challenge secret outside localnet', async () => { const signer = await Signer.generate(); delete process.env.PAY_KIT_MPP_SECRET; delete process.env.MPP_SECRET_KEY; await expect(configure({ network: 'solana_devnet', operator: { signer } })).rejects.toThrow(ConfigurationError); - process.env.MPP_SECRET_KEY = 'env-secret'; + process.env.MPP_SECRET_KEY = 'e'.repeat(32); const config = await configure({ network: 'solana_devnet', operator: { signer }, replayStore: SHARED_STORE }); - expect(config.mpp.challengeBindingSecret).toBe('env-secret'); + expect(config.mpp.challengeBindingSecret).toBe('e'.repeat(32)); delete process.env.MPP_SECRET_KEY; }); + it('requires a 32-byte UTF-8 challenge secret outside localnet', async () => { + const signer = await Signer.generate(); + await expect( + configure({ + mpp: { challengeBindingSecret: 's'.repeat(31) }, + network: 'solana_devnet', + operator: { signer }, + replayStore: SHARED_STORE, + }), + ).rejects.toThrow('mpp.challengeBindingSecret must be at least 32 UTF-8 bytes outside localnet.'); + + // 8 grinning-face emoji encode to 32 UTF-8 bytes, so the byte count (not + // the string length) is what the boundary measures. + await expect( + configure({ + mpp: { challengeBindingSecret: '😀'.repeat(8) }, + network: 'solana_devnet', + operator: { signer }, + replayStore: SHARED_STORE, + }), + ).resolves.toMatchObject({ network: 'solana_devnet' }); + + // A short secret is still accepted on localnet. + await expect( + configure({ mpp: { allowUnsafeMemoryStore: true, challengeBindingSecret: 'short' } }), + ).resolves.toMatchObject({ network: 'solana_localnet' }); + }); + it('requires an injected replay store outside localnet and accepts both capabilities', async () => { const signer = await Signer.generate(); await expect( configure({ ...SECRET, - mpp: { challengeBindingSecret: 'test-secret' }, + mpp: { challengeBindingSecret: SECRET_32 }, network: 'solana_devnet', operator: { signer }, }), @@ -230,7 +297,7 @@ describe('configure', () => { process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE = '1'; try { const config = await configure({ - mpp: { challengeBindingSecret: 'test-secret' }, + mpp: { challengeBindingSecret: SECRET_32 }, network: 'solana_devnet', operator: { signer: await Signer.generate() }, }); @@ -243,7 +310,7 @@ describe('configure', () => { it('configures from prefixed environment variables', async () => { process.env.PAY_KIT_NETWORK = 'solana_devnet'; - process.env.PAY_KIT_MPP_SECRET = 'env-secret'; + process.env.PAY_KIT_MPP_SECRET = 'e'.repeat(32); process.env.PAY_KIT_MPP_EXPIRES_IN = '60'; process.env.PAY_KIT_STABLECOINS = ''; process.env.PAY_KIT_RPC_URL = 'http://rpc.example'; @@ -251,7 +318,7 @@ describe('configure', () => { try { const config = await configureFromEnv('PAY_KIT_', SHARED_STORE); expect(config.network).toBe('solana_devnet'); - expect(config.mpp.challengeBindingSecret).toBe('env-secret'); + expect(config.mpp.challengeBindingSecret).toBe('e'.repeat(32)); expect(config.mpp.expiresIn).toBe(60); expect(config.rpcUrl).toBe('http://rpc.example'); expect(config.x402).toEqual({}); @@ -267,7 +334,7 @@ describe('configure', () => { it('honors a custom-prefixed unsafe-memory environment opt-in', async () => { process.env.APP_NETWORK = 'solana_devnet'; - process.env.APP_MPP_SECRET = 'app-secret'; + process.env.APP_MPP_SECRET = 'a'.repeat(32); process.env.APP_ALLOW_INMEMORY_REPLAY_STORE = '1'; try { const config = await configureFromEnv('APP_'); @@ -283,7 +350,7 @@ describe('configure', () => { it('does not inherit the default prefix unsafe-memory opt-in', async () => { process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE = '1'; process.env.APP_NETWORK = 'solana_devnet'; - process.env.APP_MPP_SECRET = 'app-secret'; + process.env.APP_MPP_SECRET = 'a'.repeat(32); try { await expect(configureFromEnv('APP_')).rejects.toThrow(/atomic shared replayStore/); } finally { diff --git a/typescript/packages/pay-kit/src/__tests__/gate.test.ts b/typescript/packages/pay-kit/src/__tests__/gate.test.ts index cd652c1aa..86006e333 100644 --- a/typescript/packages/pay-kit/src/__tests__/gate.test.ts +++ b/typescript/packages/pay-kit/src/__tests__/gate.test.ts @@ -10,7 +10,16 @@ const PLATFORM = 'PLatform111111111111111111111111111111111111'; const PLAN = 'PLan11111111111111111111111111111111111111'; const DEFAULTS = { accept: ['mpp'] as const, payTo: SELLER }; const X402_MPP = { accept: ['x402', 'mpp'] as const, payTo: SELLER }; -const SUB = { periodCount: 1, periodUnit: 'day' as const, planId: PLAN, puller: SELLER }; +const SUB = { + merchant: SELLER, + periodCount: 1, + periodUnit: 'day' as const, + planBump: 255, + planCreatedAt: 1_700_000_000n, + planId: PLAN, + planIdNumeric: 1n, + puller: SELLER, +}; describe('Gate', () => { it('inherits payTo and accept from defaults', () => { diff --git a/typescript/packages/pay-kit/src/__tests__/mpp-adapter.test.ts b/typescript/packages/pay-kit/src/__tests__/mpp-adapter.test.ts index c390f7c6c..d74d63fda 100644 --- a/typescript/packages/pay-kit/src/__tests__/mpp-adapter.test.ts +++ b/typescript/packages/pay-kit/src/__tests__/mpp-adapter.test.ts @@ -1,15 +1,24 @@ import { Challenge } from '@solana/mpp/client'; -import { describe, expect, it, vi } from 'vitest'; +import { Credential } from 'mppx'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { createMppAdapter } from '../adapters/mpp.js'; import { configure } from '../config.js'; import { Gate } from '../gate.js'; import { usd } from '../price.js'; +import { subscription } from '../pricing.js'; import { Signer } from '../signer.js'; import { createUnsafeMemoryReplayStore, declareProductionReplayStore } from '../replay-store.js'; const SELLER = 'AyNAa2VPe2t5pgg8M61iE6kqMudkV98zsT4rkAZuU6tj'; const PLATFORM = 'CXG3Pq3DwZb1HVckhPQbVxiwoNGM3jNGYvC2BSdkj1pK'; +const BLOCKHASH = 'EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N'; +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); +}); async function setup() { const config = await configure({ @@ -20,15 +29,15 @@ async function setup() { return { adapter: createMppAdapter(config), config }; } -function createSharedTestReplayStore() { +function createTestReplayStore({ isDurable = true, isShared = true } = {}) { const entries = new Map(); return declareProductionReplayStore({ delete: async (key: string) => { entries.delete(key); }, get: async (key: string) => entries.get(key) ?? null, - isDurable: true as const, - isShared: true as const, + isDurable, + isShared, put: async (key: string, value: unknown) => { entries.set(key, value); }, @@ -40,6 +49,26 @@ function createSharedTestReplayStore() { }); } +function createSubscriptionGate(puller: string) { + return Gate.create( + { + ...subscription(usd('0.10'), { + merchant: SELLER, + periodCount: 1, + periodUnit: 'day', + planBump: 255, + planCreatedAt: 1_700_000_000n, + planId: PLATFORM, + planIdNumeric: 1n, + puller, + }), + name: 'feed', + payTo: SELLER, + }, + { accept: ['mpp'], payTo: SELLER }, + ); +} + function gate(params: Parameters[0]['feeWithin'] = undefined) { return Gate.create( { amount: usd('10.00'), feeWithin: params, name: 'marketplace', payTo: SELLER }, @@ -48,6 +77,200 @@ function gate(params: Parameters[0]['feeWithin'] = undefined } describe('createMppAdapter', () => { + it('binds subscription credentials to the exact request without serializing query values', async () => { + const signer = await Signer.generate(); + const config = await configure({ + accept: ['mpp'], + mpp: { challengeBindingSecret: 'adapter-test-secret', realm: 'Adapter test' }, + operator: { feePayer: true, recipient: SELLER, signer }, + replayStore: createTestReplayStore(), + }); + globalThis.fetch = async () => + new Response(JSON.stringify({ result: { value: { blockhash: BLOCKHASH } } }), { + headers: { 'Content-Type': 'application/json' }, + }); + const subscriptionGate = createSubscriptionGate(signer.pubkey); + const adapter = createMppAdapter(config); + const requested = 'http://t/feed?api_key=secret&tier=basic&tier=preview'; + const gateA = await adapter.challengeHeaders(subscriptionGate, new Request(requested)); + const challenge = Challenge.deserialize(gateA['www-authenticate']); + const resource = challenge.request.resource; + + expect(resource).toMatch(/^pay-kit:mpp-subscription-resource:v1:hmac-sha256:[a-f0-9]{64}$/); + expect(resource).not.toContain('api_key=secret'); + expect(resource).not.toContain('secret'); + + const sameResource = Challenge.deserialize( + (await adapter.challengeHeaders(subscriptionGate, new Request(requested)))['www-authenticate'], + ).request.resource; + expect(sameResource).toBe(resource); + + const mismatchedRequests = [ + 'http://t/feed?api_key=other&tier=basic&tier=preview', + 'http://t/feed?tier=basic&tier=preview&api_key=secret', + 'http://t/feed?api_key=secret&tier=basic', + 'http://t/feed?api_key=sec%72et&tier=basic&tier=preview', + ]; + for (const currentRequest of mismatchedRequests) { + const currentResource = Challenge.deserialize( + (await adapter.challengeHeaders(subscriptionGate, new Request(currentRequest)))['www-authenticate'], + ).request.resource; + expect(currentResource).not.toBe(resource); + } + + const adapterWithDifferentSecret = createMppAdapter( + await configure({ + accept: ['mpp'], + mpp: { challengeBindingSecret: 'different-adapter-test-secret', realm: 'Adapter test' }, + operator: { feePayer: true, recipient: SELLER, signer }, + replayStore: createTestReplayStore(), + }), + ); + const resourceWithDifferentSecret = Challenge.deserialize( + (await adapterWithDifferentSecret.challengeHeaders(subscriptionGate, new Request(requested)))[ + 'www-authenticate' + ], + ).request.resource; + expect(resourceWithDifferentSecret).not.toBe(resource); + + const credential = Credential.serialize({ + challenge, + payload: { transaction: '', type: 'transaction' }, + }); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + // The malformed transaction fails later, proving the exact query has + // passed resource binding without requiring an on-chain fixture. + await expect( + adapter.verifyAndSettle( + subscriptionGate, + new Request(requested, { headers: { authorization: credential } }), + ), + ).rejects.toThrow('Payment verification failed.'); + expect(consoleError).not.toHaveBeenCalledWith( + 'mppx: internal verification error', + expect.objectContaining({ message: 'Subscription credential resource does not match the current route' }), + ); + + for (const currentRequest of mismatchedRequests) { + consoleError.mockClear(); + await expect( + adapter.verifyAndSettle( + subscriptionGate, + new Request(currentRequest, { headers: { authorization: credential } }), + ), + ).rejects.toThrow('Payment verification failed.'); + expect(consoleError).toHaveBeenCalledWith( + 'mppx: internal verification error', + expect.objectContaining({ + message: 'Subscription credential resource does not match the current route', + }), + ); + } + }); + + it.each([ + ['a configured TTL', 45, 45], + ['the Mppx default TTL for expiresIn=0', 0, 300], + ])('uses %s for subscription challenges', async (_label, expiresIn, expectedExpiresIn) => { + const issuedAt = Date.parse('2030-01-01T00:00:00.000Z'); + vi.spyOn(Date, 'now').mockReturnValue(issuedAt); + + const signer = await Signer.generate(); + const config = await configure({ + accept: ['mpp'], + mpp: { challengeBindingSecret: 'adapter-test-secret', expiresIn, realm: 'Adapter test' }, + operator: { feePayer: true, recipient: SELLER, signer }, + replayStore: createTestReplayStore(), + }); + globalThis.fetch = async () => + new Response(JSON.stringify({ result: { value: { blockhash: BLOCKHASH } } }), { + headers: { 'Content-Type': 'application/json' }, + }); + + const headers = await createMppAdapter(config).challengeHeaders( + createSubscriptionGate(signer.pubkey), + new Request('http://t/feed'), + ); + const challenge = Challenge.deserialize(headers['www-authenticate']); + + expect(challenge.expires).toBe(new Date(issuedAt + expectedExpiresIn * 1000).toISOString()); + }); + + it('threads a durable replay store into the subscription adapter outside localnet', async () => { + const signer = await Signer.generate(); + const config = await configure({ + accept: ['mpp'], + mpp: { challengeBindingSecret: 's'.repeat(32), realm: 'Adapter test' }, + network: 'solana_devnet', + operator: { feePayer: true, recipient: SELLER, signer }, + replayStore: createTestReplayStore(), + }); + globalThis.fetch = async () => + new Response(JSON.stringify({ result: { value: { blockhash: BLOCKHASH } } }), { + headers: { 'Content-Type': 'application/json' }, + }); + + const headers = await createMppAdapter(config).challengeHeaders( + createSubscriptionGate(signer.pubkey), + new Request('http://t/feed'), + ); + + expect(headers['www-authenticate']).toMatch(/^Payment /); + // Deserializes to the exact subscription plan, proving the durable store + // reached the adapter and challenge issuance succeeded. + expect(Challenge.deserialize(headers['www-authenticate']).request.resource).toMatch( + /^pay-kit:mpp-subscription-resource:v1:hmac-sha256:[a-f0-9]{64}$/, + ); + }); + + it('fails closed with a clear error for subscription gates on devnet under the in-memory opt-in', async () => { + process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE = '1'; + try { + const signer = await Signer.generate(); + const config = await configure({ + accept: ['mpp'], + mpp: { challengeBindingSecret: 's'.repeat(32), realm: 'Adapter test' }, + network: 'solana_devnet', + operator: { feePayer: true, recipient: SELLER, signer }, + }); + // The opt-in materializes a shared in-memory replay store: it boots the + // charge path AND reaches the subscription adapter, which rejects it as + // non-durable. The error is the durable-store one (not the generic + // no-store one), proving the materialized store was threaded through. + globalThis.fetch = async () => + new Response(JSON.stringify({ result: { value: { blockhash: BLOCKHASH } } }), { + headers: { 'Content-Type': 'application/json' }, + }); + + await expect( + createMppAdapter(config).challengeHeaders( + createSubscriptionGate(signer.pubkey), + new Request('http://t/feed'), + ), + ).rejects.toThrow(/declareProductionReplayStore[\s\S]*does not[\s\S]*cover subscription activation replay/); + } finally { + delete process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE; + } + }); + + it('revalidates expiry at challenge issuance instead of leaking a RangeError', async () => { + const config = await configure({ + mpp: { + allowUnsafeMemoryStore: true, + challengeBindingSecret: 'adapter-test-secret', + expiresIn: 2, + realm: 'Adapter test', + }, + operator: { recipient: SELLER, signer: await Signer.generate() }, + }); + vi.spyOn(Date, 'now').mockReturnValue(8_640_000_000_000_000 - 1_000); + + await expect( + createMppAdapter(config).challengeHeaders(gate(), new Request('http://t/marketplace')), + ).rejects.toThrow('mpp.expiresIn must produce a valid expiration date at challenge issuance.'); + }); + it('does not advertise fee sponsorship for a non-fee-payer signer', async () => { const config = await configure({ mpp: { allowUnsafeMemoryStore: true, challengeBindingSecret: 'adapter-test-secret' }, @@ -64,7 +287,7 @@ describe('createMppAdapter', () => { it('rejects a prebuilt mainnet config carrying the demo signer', async () => { const local = await configure({ mpp: { challengeBindingSecret: 'adapter-test-secret' }, - replayStore: createSharedTestReplayStore(), + replayStore: createTestReplayStore(), }); expect(() => createMppAdapter({ ...local, network: 'solana_mainnet' })).toThrow(/demo signer is public/); }); @@ -158,7 +381,7 @@ describe('createMppAdapter', () => { const config = await configure({ mpp: { challengeBindingSecret: 'adapter-test-secret', realm }, operator: { recipient: SELLER, signer: await Signer.generate() }, - replayStore: createSharedTestReplayStore(), + replayStore: createTestReplayStore(), }); const adapter = createMppAdapter(config); const headers = await adapter.challengeHeaders(gate(), new Request('http://t/marketplace')); @@ -186,7 +409,7 @@ describe('createMppAdapter', () => { realm: 'api.example.com\r\nInjected: evil', }, operator: { recipient: SELLER, signer: await Signer.generate() }, - replayStore: createSharedTestReplayStore(), + replayStore: createTestReplayStore(), }); const adapter = createMppAdapter(config); await expect(adapter.challengeHeaders(gate(), new Request('http://t/marketplace'))).rejects.toThrow( diff --git a/typescript/packages/pay-kit/src/__tests__/mpp-replay-race.test.ts b/typescript/packages/pay-kit/src/__tests__/mpp-replay-race.test.ts index fd9ddeeb6..8efadb46d 100644 --- a/typescript/packages/pay-kit/src/__tests__/mpp-replay-race.test.ts +++ b/typescript/packages/pay-kit/src/__tests__/mpp-replay-race.test.ts @@ -108,7 +108,16 @@ describe('MPP replay-store adapter wiring', () => { kind: 'subscription', name: 'plan', payTo: SELLER, - subscription: { periodCount: 1, periodUnit: 'day', planId: 'plan-1', puller: PULLER }, + subscription: { + merchant: SELLER, + periodCount: 1, + periodUnit: 'day', + planBump: 255, + planCreatedAt: 1_700_000_000n, + planId: 'plan-1', + planIdNumeric: 1n, + puller: PULLER, + }, }, { accept: ['mpp'], payTo: SELLER }, ); @@ -134,7 +143,16 @@ describe('MPP replay-store adapter wiring', () => { kind: 'subscription', name: 'token-2022-plan', payTo: SELLER, - subscription: { periodCount: 1, periodUnit: 'day', planId: 'plan-2', puller: PULLER }, + subscription: { + merchant: SELLER, + periodCount: 1, + periodUnit: 'day', + planBump: 255, + planCreatedAt: 1_700_000_000n, + planId: 'plan-2', + planIdNumeric: 2n, + puller: PULLER, + }, }, { accept: ['mpp'], payTo: SELLER }, ); diff --git a/typescript/packages/pay-kit/src/__tests__/mpp-session.test.ts b/typescript/packages/pay-kit/src/__tests__/mpp-session.test.ts index 1f54a6dd0..8a9993923 100644 --- a/typescript/packages/pay-kit/src/__tests__/mpp-session.test.ts +++ b/typescript/packages/pay-kit/src/__tests__/mpp-session.test.ts @@ -1,16 +1,17 @@ -import { createMemorySessionStore } from '@solana/mpp/server'; +import { createMemorySessionStore, type SessionStore } from '@solana/mpp/server'; import { describe, expect, it, vi } from 'vitest'; import { createSessionEngine } from '../adapters/mpp-session.js'; import { configure } from '../config.js'; import { Gate } from '../gate.js'; +import { createPayKit } from '../paykit.js'; import { usd } from '../price.js'; import { session } from '../pricing.js'; import { declareProductionReplayStore, type ReplayStore } from '../replay-store.js'; import { Signer } from '../signer.js'; -// Satisfies the charge-level replay-store policy so configure() succeeds; the -// session-store assertions below exercise the (base) session engine policy. +// Satisfies the replay-store policy so configure() succeeds; the session-store +// assertions below exercise the session engine's own store policy. function sharedReplayStore(): ReplayStore { const values = new Map(); return declareProductionReplayStore({ @@ -33,16 +34,33 @@ function sharedReplayStore(): ReplayStore { }); } +/** A durable (non in-memory-branded) SessionStore, delegating to the in-memory impl. */ +function createDurableSessionStore(): SessionStore { + const inner = createMemorySessionStore(); + return { + deleteChannel: channelId => inner.deleteChannel(channelId), + getChannel: channelId => inner.getChannel(channelId), + listChannels: filter => inner.listChannels(filter), + markSealed: channelId => inner.markSealed(channelId), + updateChannel: (channelId, mutator) => inner.updateChannel(channelId, mutator), + }; +} + async function setup( options: { - readonly network?: 'solana_devnet' | 'solana_localnet'; - readonly sessionStore?: ReturnType; + readonly network?: 'solana_devnet' | 'solana_localnet' | 'solana_mainnet'; + readonly sessionStore?: SessionStore; } = {}, ) { const signer = await Signer.generate(); const config = await configure({ mpp: { - challengeBindingSecret: 'session-store-test-secret', + // A production replay store is always injected, so the charge-level + // unsafe-memory flag stays off; this keeps the process-wide session + // opt-in env from tripping the mainnet charge-policy guard, letting + // these tests reach the session-store engine assertions. + allowUnsafeMemoryStore: false, + challengeBindingSecret: 's'.repeat(32), ...(options.sessionStore ? { sessionStore: options.sessionStore } : {}), }, network: options.network ?? 'solana_localnet', @@ -67,8 +85,8 @@ describe('createSessionEngine', () => { expect(() => createSessionEngine(config, gate)).toThrow(/mpp\.sessionStore is required outside localnet/); }); - it('uses the injected store outside localnet', async () => { - const store = createMemorySessionStore(); + it('uses a durable injected store outside localnet without the opt-in', async () => { + const store = createDurableSessionStore(); const getChannel = vi.spyOn(store, 'getChannel'); const { config, gate } = await setup({ network: 'solana_devnet', sessionStore: store }); @@ -77,6 +95,44 @@ describe('createSessionEngine', () => { expect(getChannel).toHaveBeenCalledWith('missing'); }); + it('uses an explicit in-memory store outside localnet only under the opt-in', async () => { + process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE = '1'; + try { + const store = createMemorySessionStore(); + const getChannel = vi.spyOn(store, 'getChannel'); + const { config, gate } = await setup({ network: 'solana_devnet', sessionStore: store }); + + const engine = createSessionEngine(config, gate); + await expect(engine.receipt('missing')).resolves.toBeUndefined(); + expect(getChannel).toHaveBeenCalledWith('missing'); + } finally { + delete process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE; + } + }); + + it('rejects an explicit in-memory store on devnet without the opt-in', async () => { + const { config, gate } = await setup({ + network: 'solana_devnet', + sessionStore: createMemorySessionStore(), + }); + + expect(() => createSessionEngine(config, gate)).toThrow(/mpp\.sessionStore is required outside localnet/); + }); + + it('rejects an explicit in-memory store on mainnet even with the opt-in', async () => { + process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE = '1'; + try { + const { config, gate } = await setup({ + network: 'solana_mainnet', + sessionStore: createMemorySessionStore(), + }); + + expect(() => createSessionEngine(config, gate)).toThrow(/mpp\.sessionStore is required outside localnet/); + } finally { + delete process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE; + } + }); + it('retains the localnet and explicit-override in-memory fallbacks', async () => { const localnet = await setup(); expect(() => createSessionEngine(localnet.config, localnet.gate)).not.toThrow(); @@ -89,4 +145,22 @@ describe('createSessionEngine', () => { delete process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE; } }); + + it('rejects a mainnet process-local store override through sessionRoutes', async () => { + process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE = '1'; + try { + const { config } = await setup({ network: 'solana_mainnet' }); + const paykit = await createPayKit({ + config, + pricing: { metered: session(usd('1.00'), { unitPrice: usd('0.0001') }) }, + }); + + expect(() => paykit.sessionRoutes('metered')).toThrow( + 'mpp.sessionStore is required outside localnet; provide a durable shared store or, on devnet only, set ' + + 'PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1 for an explicit in-memory override.', + ); + } finally { + delete process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE; + } + }); }); diff --git a/typescript/packages/pay-kit/src/__tests__/openapi.test.ts b/typescript/packages/pay-kit/src/__tests__/openapi.test.ts index 769bc2a79..0ffc26ebc 100644 --- a/typescript/packages/pay-kit/src/__tests__/openapi.test.ts +++ b/typescript/packages/pay-kit/src/__tests__/openapi.test.ts @@ -48,9 +48,13 @@ async function paykit() { network: 'solana_localnet', pricing: { feed: subscription(usd('0.10'), { + merchant: 'MerChant111111111111111111111111111111111', periodCount: 1, periodUnit: 'day', + planBump: 255, + planCreatedAt: 1_700_000_000n, planId: PLAN, + planIdNumeric: 1n, puller: 'PuLLer11111111111111111111111111111111111', }), joke: { accept: ['x402'], amount: usd('0.001') }, diff --git a/typescript/packages/pay-kit/src/__tests__/paykit.test.ts b/typescript/packages/pay-kit/src/__tests__/paykit.test.ts index ac89b5df8..84ff490d8 100644 --- a/typescript/packages/pay-kit/src/__tests__/paykit.test.ts +++ b/typescript/packages/pay-kit/src/__tests__/paykit.test.ts @@ -7,7 +7,7 @@ import type { Payment } from '../payment.js'; import { createPayKit } from '../paykit.js'; import { usd } from '../price.js'; import { declareProductionReplayStore } from '../replay-store.js'; -import { Signer } from '../signer.js'; +import { Signer, type PayKitSigner } from '../signer.js'; const CREDENTIAL_HEADER = 'x-fake-credential'; @@ -31,6 +31,24 @@ function createSharedTestReplayStore() { }); } +class ClassBasedSigner implements PayKitSigner { + readonly isDemo = false; + readonly isFeePayer = true; + readonly pubkey: string; + readonly signer: PayKitSigner['signer']; + calls = 0; + + constructor(private readonly delegate: PayKitSigner) { + this.pubkey = delegate.pubkey; + this.signer = delegate.signer; + } + + async sign(message: Uint8Array): Promise { + this.calls += 1; + return await this.delegate.sign(message); + } +} + function fakeAdapter(config: PayKitConfig): ProtocolAdapter { return { async acceptsEntry(gate) { @@ -79,6 +97,113 @@ async function setup() { } describe('createPayKit', () => { + it('rejects a frozen prebuilt non-local config with a one-byte challenge binding secret', async () => { + const base = await configure({ mpp: { allowUnsafeMemoryStore: true, challengeBindingSecret: 's'.repeat(32) } }); + const config: PayKitConfig = Object.freeze({ + ...base, + mpp: Object.freeze({ ...base.mpp, challengeBindingSecret: 'x' }), + network: 'solana_devnet', + }); + + await expect(createPayKit({ config })).rejects.toThrow( + 'mpp.challengeBindingSecret must be at least 32 UTF-8 bytes outside localnet.', + ); + }); + + it('revalidates every boot invariant on frozen prebuilt configs', async () => { + const base = await configure({ mpp: { allowUnsafeMemoryStore: true, challengeBindingSecret: 's'.repeat(32) } }); + + await expect(createPayKit({ config: Object.freeze({ ...base, accept: Object.freeze([]) }) })).rejects.toThrow( + 'accept must list at least one protocol.', + ); + await expect( + createPayKit({ + config: Object.freeze({ + ...base, + network: 'solana_testnet' as PayKitConfig['network'], + }), + }), + ).rejects.toThrow('Unknown network "solana_testnet".'); + await expect( + createPayKit({ config: Object.freeze({ ...base, stablecoins: Object.freeze([]) }) }), + ).rejects.toThrow('stablecoins must list at least one coin.'); + }); + + it('rejects a forged demo wrapper in a prebuilt mainnet config', async () => { + const demo = await Signer.demo(); + const forgedSigner = { ...demo, isDemo: false }; + const local = await configure({ + mpp: { allowUnsafeMemoryStore: true, challengeBindingSecret: 's'.repeat(32) }, + operator: { signer: forgedSigner }, + }); + + await expect( + createPayKit({ + config: Object.freeze({ + ...local, + network: 'solana_mainnet', + replayStore: createSharedTestReplayStore(), + }), + }), + ).rejects.toThrow('The demo signer is public and must not be used on mainnet. Provide operator.signer.'); + }); + + it('rejects malformed localnet challenge secrets in prebuilt configs before expiry validation', async () => { + const base = await configure({ mpp: { allowUnsafeMemoryStore: true, challengeBindingSecret: 's'.repeat(32) } }); + const config = Object.freeze({ + ...base, + mpp: Object.freeze({ ...base.mpp, challengeBindingSecret: undefined as never, expiresIn: -1 }), + }); + + await expect(createPayKit({ config })).rejects.toThrow( + 'mpp.challengeBindingSecret must be a non-empty string.', + ); + }); + + it('snapshots mutable prebuilt configuration wrappers before adapters observe them', async () => { + const base = await configure({ mpp: { allowUnsafeMemoryStore: true, challengeBindingSecret: 's'.repeat(32) } }); + const mutableMpp = { ...base.mpp }; + const mutableSigner = { ...base.operator.signer }; + const replayStore = createSharedTestReplayStore(); + const config: PayKitConfig = { + ...base, + mpp: mutableMpp, + operator: { ...base.operator, signer: mutableSigner }, + replayStore, + }; + + const paykit = await createPayKit({ config }); + mutableMpp.realm = 'changed-after-construction'; + mutableSigner.isDemo = false; + mutableSigner.pubkey = 'changed-after-construction'; + + expect(paykit.config.mpp.realm).toBe('App'); + expect(paykit.config.operator.signer.isDemo).toBe(true); + expect(paykit.config.operator.signer.pubkey).toBe(base.operator.signer.pubkey); + expect(paykit.config.operator.signer.signer).toBe(mutableSigner.signer); + expect(paykit.config.replayStore).toBe(replayStore); + expect(Object.isFrozen(paykit.config)).toBe(true); + expect(Object.isFrozen(paykit.config.mpp)).toBe(true); + expect(Object.isFrozen(paykit.config.operator)).toBe(true); + expect(Object.isFrozen(paykit.config.operator.signer)).toBe(true); + }); + + it('retains a class-based signer sign method in an immutable config snapshot', async () => { + const classSigner = new ClassBasedSigner(await Signer.generate()); + const config = await configure({ + mpp: { allowUnsafeMemoryStore: true, challengeBindingSecret: 's'.repeat(32) }, + operator: { signer: classSigner }, + }); + const paykit = await createPayKit({ config }); + + const signature = await paykit.config.operator.signer.sign(new TextEncoder().encode('snapshot compatibility')); + + expect(signature).toHaveLength(64); + expect(classSigner.calls).toBe(1); + expect(paykit.config.operator.signer.signer).toBe(classSigner.signer); + expect(Object.isFrozen(paykit.config.operator.signer)).toBe(true); + }); + it('rejects a prebuilt mainnet config carrying the unsafe replay-store escape', async () => { const local = await configure({ mpp: { challengeBindingSecret: 's3cret', allowUnsafeMemoryStore: true }, diff --git a/typescript/packages/pay-kit/src/adapters/mpp-session.ts b/typescript/packages/pay-kit/src/adapters/mpp-session.ts index 3160bb891..cdb169a78 100644 --- a/typescript/packages/pay-kit/src/adapters/mpp-session.ts +++ b/typescript/packages/pay-kit/src/adapters/mpp-session.ts @@ -12,14 +12,16 @@ */ import { createSolanaRpc } from '@solana/kit'; import { resolveStablecoinMint } from '@solana/mpp'; -import { createMemorySessionStore, Mppx, session, type SessionStore } from '@solana/mpp/server'; +import { createMemorySessionStore, isMemorySessionStore, Mppx, session, type SessionStore } from '@solana/mpp/server'; import { requireMint, resolveCoin } from '../coin.js'; -import type { PayKitConfig } from '../config.js'; +import { inMemoryStoresAllowed, type PayKitConfig } from '../config.js'; import { ConfigurationError } from '../errors.js'; import type { Gate } from '../gate.js'; import { toSolanaNetwork } from '../protocol.js'; +const ALLOW_INMEMORY_REPLAY_STORE_ENV = 'PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE'; + /** Outcome of running the session method on the gated route. */ export type SessionResult = | { readonly challenge: Response; readonly status: 402 } @@ -116,12 +118,15 @@ export function createSessionEngine(config: PayKitConfig, gate: Gate): SessionEn } function resolveSessionStore(config: PayKitConfig): SessionStore { - if (config.mpp.sessionStore) return config.mpp.sessionStore; - if (config.network === 'solana_localnet' || process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE === '1') { - return createMemorySessionStore(); - } + const provided = config.mpp.sessionStore; + // A durable, caller-supplied store is honored everywhere. A process-local + // in-memory store, whether defaulted here or passed explicitly, is subject + // to the same cluster policy, so an explicit createMemorySessionStore() can + // never smuggle process-local session state onto mainnet. + if (provided && !isMemorySessionStore(provided)) return provided; + if (inMemoryStoresAllowed(config.network)) return provided ?? createMemorySessionStore(); throw new ConfigurationError( - 'mpp.sessionStore is required outside localnet; provide a durable shared store or set ' + - 'PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1 for an explicit in-memory override.', + 'mpp.sessionStore is required outside localnet; provide a durable shared store or, on devnet only, set ' + + `${ALLOW_INMEMORY_REPLAY_STORE_ENV}=1 for an explicit in-memory override.`, ); } diff --git a/typescript/packages/pay-kit/src/adapters/mpp.ts b/typescript/packages/pay-kit/src/adapters/mpp.ts index 7a57a023a..9155032cb 100644 --- a/typescript/packages/pay-kit/src/adapters/mpp.ts +++ b/typescript/packages/pay-kit/src/adapters/mpp.ts @@ -1,3 +1,5 @@ +import { createHmac } from 'node:crypto'; + import { defaultTokenProgramForCurrency, guardChallengeValue, resolveStablecoinMint } from '@solana/mpp'; import { Mppx, solana } from '@solana/mpp/server'; import { Receipt } from 'mppx'; @@ -10,10 +12,30 @@ import { ConfigurationError, InvalidProofError } from '../errors.js'; import type { Gate } from '../gate.js'; import type { Payment } from '../payment.js'; import { caip2, toSolanaNetwork } from '../protocol.js'; -import { atomicReplayStoreView, isAtomicReplayStore } from '../replay-store.js'; +import { + atomicReplayStoreView, + isAtomicReplayStore, + isProductionReplayStore, + type ReplayStore, +} from '../replay-store.js'; /** Settlement header mirrored by every PayKit SDK. */ const SETTLEMENT_SIGNATURE_HEADER = 'x-payment-settlement-signature'; +const SUBSCRIPTION_RESOURCE_BINDING_DOMAIN = 'pay-kit:mpp-subscription-resource:v1'; + +/** + * Produces a non-path resource identifier so subscription credentials bind to + * the exact current path and raw query without exposing query values on wire. + */ +function subscriptionResourceFor(request: Request, challengeBindingSecret: string): string { + const url = new URL(request.url); + // Keep the URL parser's path semantics, but retain raw search text rather + // than normalizing through URLSearchParams: ordering, duplicates, and + // percent-encoding are therefore all binding-significant. + const canonical = JSON.stringify([SUBSCRIPTION_RESOURCE_BINDING_DOMAIN, url.pathname, url.search]); + const digest = createHmac('sha256', challengeBindingSecret).update(canonical).digest('hex'); + return `${SUBSCRIPTION_RESOURCE_BINDING_DOMAIN}:hmac-sha256:${digest}`; +} type ChargeResult = | { readonly challenge: Response; readonly status: 402 } @@ -39,6 +61,12 @@ function totalAmount(gate: Gate): bigint { return gate.total().baseUnits(); } +function handlerCacheKey(parts: readonly unknown[]): string { + return JSON.stringify(parts, (_key, value: unknown) => + typeof value === 'bigint' ? `pay-kit:bigint:${value.toString()}` : value, + ); +} + /** The MPP scheme a gate settles through: `subscription` for recurring gates, else `charge`. */ function schemeFor(gate: Gate): 'charge' | 'subscription' { return gate.kind === 'subscription' ? 'subscription' : 'charge'; @@ -56,8 +84,17 @@ export function createMppAdapter(config: PayKitConfig): ProtocolAdapter { if (!isAtomicReplayStore(config.replayStore)) { throw new ConfigurationError('MPP adapter replayStore must implement atomic putIfAbsent(key, value).'); } + // Explicitly typed so the atomic narrowing survives into the nested + // handler closures below (control-flow narrowing does not cross them). + const injectedStore: ReplayStore = config.replayStore; assertReplayStorePolicy(config); - const replayStore = atomicReplayStoreView(config.replayStore); + // One atomic view feeds both charge and subscription methods. The view + // exposes reserve() (aliasing putIfAbsent) so the subscription server's + // claimConsumed reservation runs against the same cross-instance atomic + // marker as charge replay. Production status is tracked on the injected + // store (not the view), so subscription durability is checked on + // injectedStore below. + const replayStore = atomicReplayStoreView(injectedStore); const network = toSolanaNetwork(config.network); const handlers = new Map(); @@ -67,7 +104,7 @@ export function createMppAdapter(config: PayKitConfig): ProtocolAdapter { const splits = splitsFor(gate); // Key on every field a built handler captures, so gates differing only // in amount, description, or externalId get distinct handlers. - const key = JSON.stringify([ + const key = handlerCacheKey([ gate.kind, gate.payTo, mint, @@ -83,22 +120,47 @@ export function createMppAdapter(config: PayKitConfig): ProtocolAdapter { const realm = guardChallengeValue('realm', config.mpp.realm); const description = gate.description ? guardChallengeValue('description', gate.description) : undefined; if (gate.subscription) { - const { periodCount, periodUnit, planId, puller } = gate.subscription; + const { merchant, periodCount, periodUnit, planBump, planCreatedAt, planId, planIdNumeric, puller } = + gate.subscription; + if (!config.operator.feePayer) { + throw new ConfigurationError('Subscription gates require an operator fee payer.'); + } + // Subscription activation replay needs a durable, shared store. + // The nominal marker is #237's production declaration: outside + // localnet the injected store must be declared production via + // declareProductionReplayStore(). The unsafe-memory opt-in + // (createUnsafeMemoryReplayStore) is authorized-unsafe but never + // production, so it is rejected here even under + // PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE: the opt-in covers charge + // replay, never subscription activation replay. + if (config.network !== 'solana_localnet' && !isProductionReplayStore(injectedStore)) { + throw new ConfigurationError( + 'Subscription gates outside localnet require a durable shared replay store declared with ' + + 'declareProductionReplayStore(); the in-memory opt-in (PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE) ' + + 'does not cover subscription activation replay.', + ); + } const mppx = Mppx.create({ methods: [ solana.subscription({ decimals: 6, + merchant, mint, network, periodCount, periodUnit, + planBump, + planCreatedAt, planId, + planIdNumeric, puller, recipient: gate.payTo, rpcUrl: config.rpcUrl, + // feePayer is required for subscriptions (asserted above), so + // the operator signer is always threaded through. + signer: config.operator.signer.signer, store: replayStore, tokenProgram: defaultTokenProgramForCurrency(mint, network), - ...signer, }), ], realm, @@ -106,10 +168,9 @@ export function createMppAdapter(config: PayKitConfig): ProtocolAdapter { }); handler = request => mppx.subscription({ - amount: totalAmount(gate).toString(), + ...optionsFor(gate, description), currency: mint, - ...(description !== undefined ? { description } : {}), - ...(gate.externalId ? { externalId: gate.externalId } : {}), + resource: subscriptionResourceFor(request, config.mpp.challengeBindingSecret), })(request); } else { const mppx = Mppx.create({ @@ -138,13 +199,12 @@ export function createMppAdapter(config: PayKitConfig): ProtocolAdapter { } function optionsFor(gate: Gate, description: string | undefined) { + const expires = expirationFor(config.mpp.expiresIn); return { amount: totalAmount(gate).toString(), ...(description !== undefined ? { description } : {}), ...(gate.externalId ? { externalId: gate.externalId } : {}), - ...(config.mpp.expiresIn > 0 - ? { expires: new Date(Date.now() + config.mpp.expiresIn * 1000).toISOString() } - : {}), + ...(expires === undefined ? {} : { expires }), }; } @@ -229,6 +289,16 @@ export function createMppAdapter(config: PayKitConfig): ProtocolAdapter { }; } +/** Validate the configured TTL at issuance, when the clock actually matters. */ +function expirationFor(expiresIn: number): string | undefined { + if (expiresIn === 0) return undefined; + const expires = new Date(Date.now() + expiresIn * 1000); + if (!Number.isFinite(expires.getTime())) { + throw new ConfigurationError('mpp.expiresIn must produce a valid expiration date at challenge issuance.'); + } + return expires.toISOString(); +} + /** Extracts a canonical (code, detail) pair from an mppx problem-details 402. */ async function problemOf(challenge: Response): Promise<[string, string | undefined]> { try { diff --git a/typescript/packages/pay-kit/src/client/index.ts b/typescript/packages/pay-kit/src/client/index.ts index d099a8bcd..36dc9a68c 100644 --- a/typescript/packages/pay-kit/src/client/index.ts +++ b/typescript/packages/pay-kit/src/client/index.ts @@ -29,6 +29,8 @@ const nativeFetch: typeof fetch = globalThis.fetch.bind(globalThis); export type PayKitClientOptions = { /** Protocols the client will pay with. Defaults to `['x402', 'mpp']`. */ readonly accept?: readonly Protocol[]; + /** Initialize a missing MPP subscription authority. Defaults to true. */ + readonly initializeSubscriptionAuthority?: boolean; /** Progress callback, forwarded to the MPP charge/subscription methods. */ readonly onProgress?: (event: unknown) => void; /** RPC endpoint used to build payments (sign transfers, open channels). */ @@ -99,7 +101,14 @@ export function createPayKitClient(options: PayKitClientOptions): Promise => (subscriptionMppx ??= Mppx.create({ - methods: [solana.subscription({ onProgress: forward, rpcUrl: options.rpcUrl, signer: options.signer })], + methods: [ + solana.subscription({ + initializeSubscriptionAuthority: options.initializeSubscriptionAuthority ?? true, + onProgress: forward, + rpcUrl: options.rpcUrl, + signer: options.signer, + }), + ], })); async function payFetch(input: RequestInfo | URL, init?: RequestInit, protocol?: Protocol): Promise { diff --git a/typescript/packages/pay-kit/src/config.ts b/typescript/packages/pay-kit/src/config.ts index 3dfffa9b6..b67b94775 100644 --- a/typescript/packages/pay-kit/src/config.ts +++ b/typescript/packages/pay-kit/src/config.ts @@ -11,7 +11,7 @@ import { isAuthorizedUnsafeMemoryReplayStore, isProductionReplayStore, } from './replay-store.js'; -import { type KeychainSigner, type PayKitSigner, Signer } from './signer.js'; +import { DEMO_SIGNER_PUBLIC_KEY, type KeychainSigner, type PayKitSigner, Signer } from './signer.js'; /** MPP protocol options. */ export type MppOptions = { @@ -23,7 +23,7 @@ export type MppOptions = { * (with a warning) on localnet only. */ readonly challengeBindingSecret?: string; - /** Challenge TTL in seconds. `0` means never expires (dev only). */ + /** Challenge TTL in seconds. `0` uses Mppx's fail-closed default TTL. */ readonly expiresIn?: number; /** * Serve the interactive HTML payment page (the "Continue with Solana" @@ -107,28 +107,112 @@ export type PayKitConfig = { }; const DEFAULT_EXPIRES_IN_SECONDS = 120; +const MIN_CHALLENGE_BINDING_SECRET_BYTES = 32; const ALLOW_INMEMORY_REPLAY_STORE_ENV = 'PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE'; +const SUPPORTED_NETWORKS: ReadonlySet = new Set(['solana_devnet', 'solana_localnet', 'solana_mainnet']); -function resolveChallengeBindingSecret(network: Network, provided: string | undefined): string { - const secret = provided ?? process.env.PAY_KIT_MPP_SECRET ?? process.env.MPP_SECRET_KEY; - if (secret) return secret; - if (network !== 'solana_localnet') { - throw new ConfigurationError( - 'mpp.challengeBindingSecret is required outside localnet. Provide it in configure() ' + - 'or set PAY_KIT_MPP_SECRET.', +function validateNetwork(network: Network): void { + if (!SUPPORTED_NETWORKS.has(network)) { + throw new ConfigurationError(`Unknown network "${String(network)}".`); + } +} + +function validateAccept(accept: readonly Protocol[]): void { + if (accept.length === 0) throw new ConfigurationError('accept must list at least one protocol.'); + for (const protocol of accept) { + if (protocol !== 'mpp' && protocol !== 'x402') { + throw new ProtocolNotSupportedError( + `Protocol "${String(protocol)}" is not available in the TypeScript SDK yet (MPP and x402 only).`, + ); + } + } +} + +function validateStablecoins(stablecoins: readonly Stablecoin[]): void { + if (stablecoins.length === 0) throw new ConfigurationError('stablecoins must list at least one coin.'); + for (const coin of stablecoins) { + if (!STABLECOINS.includes(coin)) { + throw new ConfigurationError(`Unknown stablecoin "${coin}". Supported: ${STABLECOINS.join(', ')}.`); + } + } +} + +/** + * Refuse the package-shipped demo signer on mainnet, detecting it by its public + * address rather than only the `isDemo` flag so a caller cannot smuggle the + * public demo key past the check by wrapping it with `isDemo=false`. + */ +function validateOperator(network: Network, operator: Operator): void { + if ( + network === 'solana_mainnet' && + (operator.signer.isDemo || operator.signer.signer.address === DEMO_SIGNER_PUBLIC_KEY) + ) { + throw new DemoSignerOnMainnetError( + 'The demo signer is public and must not be used on mainnet. Provide operator.signer.', ); } - console.warn( - '[pay-kit] Generated an ephemeral MPP challenge secret (localnet). Challenges will not survive restarts.', +} + +function validateChallengeBindingSecret(secret: unknown): asserts secret is string { + if (typeof secret !== 'string' || secret.length === 0) { + throw new ConfigurationError('mpp.challengeBindingSecret must be a non-empty string.'); + } +} + +/** + * Whether a process-local, in-memory session store is permitted on this + * cluster: always on localnet, and on devnet only under the explicit + * `PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1` development opt-in. Mainnet never + * qualifies. Consulted by the session adapter so its in-memory fallback policy + * cannot drift from the config-layer stance. + */ +export function inMemoryStoresAllowed(network: Network): boolean { + return ( + network === 'solana_localnet' || + (network === 'solana_devnet' && process.env[ALLOW_INMEMORY_REPLAY_STORE_ENV] === '1') ); - return crypto.randomUUID(); } -/** Revalidate resolved configuration safety when it crosses an API boundary. */ +export function validateMppConfig( + config: Pick & { + readonly mpp: Pick; + }, +): void { + const { challengeBindingSecret, expiresIn } = config.mpp; + if (config.accept.includes('mpp')) validateChallengeBindingSecret(challengeBindingSecret); + if (expiresIn < 0 || !Number.isInteger(expiresIn)) { + throw new ConfigurationError('mpp.expiresIn must be a non-negative integer number of seconds.'); + } + if (expiresIn > 0 && !Number.isFinite(new Date(Date.now() + expiresIn * 1000).getTime())) { + throw new ConfigurationError('mpp.expiresIn must produce a valid expiration date.'); + } + if ( + config.accept.includes('mpp') && + config.network !== 'solana_localnet' && + new TextEncoder().encode(challengeBindingSecret).byteLength < MIN_CHALLENGE_BINDING_SECRET_BYTES + ) { + throw new ConfigurationError( + `mpp.challengeBindingSecret must be at least ${MIN_CHALLENGE_BINDING_SECRET_BYTES} UTF-8 bytes outside localnet.`, + ); + } +} + +/** + * Revalidate resolved configuration safety when it crosses an API boundary. + * + * The atomic/shared replay-store requirement is the nominal policy shared with + * every MPP settlement adapter: an injected store must implement atomic + * `putIfAbsent` and be declared production via `declareProductionReplayStore()`, + * or be the SDK's own unsafe-memory store used only under the explicit + * development opt-in. Unknown stores fail closed. + */ export function assertReplayStorePolicy( config: Pick, ): void { - if (config.network === 'solana_mainnet' && config.operator.signer.isDemo) { + if ( + config.network === 'solana_mainnet' && + (config.operator.signer.isDemo || config.operator.signer.signer.address === DEMO_SIGNER_PUBLIC_KEY) + ) { throw new DemoSignerOnMainnetError( 'The demo signer is public and must not be used on mainnet. Provide operator.signer.', ); @@ -148,6 +232,12 @@ export function assertReplayStorePolicy( 'mpp.allowUnsafeMemoryStore is forbidden on mainnet; inject an atomic shared replayStore.', ); } + // x402 replay coverage is deferred to the x402 adapter's own fail-closed + // fence: its facilitator settles a specifically bound transaction and the + // x402 store uses reserve()/putIfAbsent semantics that do not fit the MPP + // putIfAbsent production marker. Forcing that marker here would reject the + // legacy Store.Store x402 servers still inject, so the config-layer atomic + // requirement stays MPP-scoped. if (!config.accept.includes('mpp')) return; const store = config.replayStore; @@ -173,6 +263,65 @@ export function assertReplayStorePolicy( } } +/** + * Revalidate every security-relevant invariant on a resolved config. The + * replay-store / signer policy runs before the MPP challenge-secret length + * check so a mainnet or missing-store misconfiguration is reported ahead of a + * secret that is merely too short; both are fail-closed either way. + */ +export function validatePayKitConfig(config: PayKitConfig): void { + validateNetwork(config.network); + validateAccept(config.accept); + validateStablecoins(config.stablecoins); + validateOperator(config.network, config.operator); + assertReplayStorePolicy(config); + validateMppConfig(config); +} + +function freezePayKitSigner(signer: PayKitSigner): PayKitSigner { + return Object.freeze({ + isDemo: signer.isDemo, + isFeePayer: signer.isFeePayer, + pubkey: signer.pubkey, + sign: signer.sign.bind(signer), + signer: signer.signer, + }); +} + +/** Take an immutable snapshot so caller mutation cannot drift adapter policy. */ +export function freezePayKitConfig(config: PayKitConfig): PayKitConfig { + return Object.freeze({ + ...config, + accept: Object.freeze([...config.accept]), + mpp: Object.freeze({ ...config.mpp }), + operator: Object.freeze({ + ...config.operator, + signer: freezePayKitSigner(config.operator.signer), + }), + stablecoins: Object.freeze([...config.stablecoins]), + x402: Object.freeze({ ...config.x402 }), + }); +} + +function resolveChallengeBindingSecret(network: Network, provided: unknown): string { + if (provided !== undefined) { + validateChallengeBindingSecret(provided); + return provided; + } + const secret = process.env.PAY_KIT_MPP_SECRET ?? process.env.MPP_SECRET_KEY; + if (secret !== undefined && secret.length > 0) return secret; + if (network !== 'solana_localnet') { + throw new ConfigurationError( + 'mpp.challengeBindingSecret is required outside localnet. Provide it in configure() ' + + 'or set PAY_KIT_MPP_SECRET.', + ); + } + console.warn( + '[pay-kit] Generated an ephemeral MPP challenge secret (localnet). Challenges will not survive restarts.', + ); + return crypto.randomUUID(); +} + /** * Builds and validates the boot configuration. Everything downstream * (pricing, adapters, the dispatcher) derives its defaults from this object. @@ -192,24 +341,13 @@ export function assertReplayStorePolicy( */ export async function configure(params: ConfigureParams = {}): Promise { const network = toNetwork(params.network ?? 'solana_localnet'); + validateNetwork(network); const accept = params.accept ?? ['mpp']; - if (accept.length === 0) throw new ConfigurationError('accept must list at least one protocol.'); - for (const protocol of accept) { - if (protocol !== 'mpp' && protocol !== 'x402') { - throw new ProtocolNotSupportedError( - `Protocol "${String(protocol)}" is not available in the TypeScript SDK yet (MPP and x402 only).`, - ); - } - } + validateAccept(accept); const stablecoins = params.stablecoins ?? ['USDC']; - if (stablecoins.length === 0) throw new ConfigurationError('stablecoins must list at least one coin.'); - for (const coin of stablecoins) { - if (!STABLECOINS.includes(coin)) { - throw new ConfigurationError(`Unknown stablecoin "${coin}". Supported: ${STABLECOINS.join(', ')}.`); - } - } + validateStablecoins(stablecoins); const provided = params.operator?.signer; const signer = @@ -218,16 +356,6 @@ export async function configure(params: ConfigureParams = {}): Promise( pricing: pricingDef, ...configureParams } = options; - const config = prebuilt ?? (await configure(configureParams)); - assertReplayStorePolicy(config); + let config = prebuilt ?? (await configure(configureParams)); + if (prebuilt) { + validatePayKitConfig(prebuilt); + config = freezePayKitConfig(prebuilt); + } const handleSettleFailure = onSettleError ?? warnSettleFailure; const adapters = diff --git a/typescript/packages/pay-kit/src/pricing.ts b/typescript/packages/pay-kit/src/pricing.ts index d24cb7d31..8dcf8b06b 100644 --- a/typescript/packages/pay-kit/src/pricing.ts +++ b/typescript/packages/pay-kit/src/pricing.ts @@ -84,8 +84,14 @@ export function subscription( amount: Price, config: Omit & SubscriptionConfig, ): SubscriptionGateParams { - const { periodCount, periodUnit, planId, puller, ...rest } = config; - return { ...rest, amount, kind: 'subscription', subscription: { periodCount, periodUnit, planId, puller } }; + const { merchant, periodCount, periodUnit, planBump, planCreatedAt, planId, planIdNumeric, puller, ...rest } = + config; + return { + ...rest, + amount, + kind: 'subscription', + subscription: { merchant, periodCount, periodUnit, planBump, planCreatedAt, planId, planIdNumeric, puller }, + }; } /** diff --git a/typescript/packages/pay-kit/src/replay-store.ts b/typescript/packages/pay-kit/src/replay-store.ts index b4a40ef6c..d8138ba6c 100644 --- a/typescript/packages/pay-kit/src/replay-store.ts +++ b/typescript/packages/pay-kit/src/replay-store.ts @@ -68,8 +68,14 @@ export function createUnsafeMemoryReplayStore(): ReplayStore { * * #211 already serializes charge verification inside one process. This view * leaves that lock untouched and upgrades only the final consumed-marker write - * to a cross-instance atomic reservation. + * to a cross-instance atomic reservation. It also exposes `reserve` (aliasing + * `putIfAbsent`) so the same view satisfies the subscription server's store + * contract without the operator having to implement two atomic primitives. */ -export function atomicReplayStoreView(store: ReplayStore): ReplayStore { - return createAtomicReplayStoreView(store) as ReplayStore; +export function atomicReplayStoreView( + store: ReplayStore, +): ReplayStore & { reserve(key: string, value?: unknown, ttlSeconds?: number): Promise } { + return createAtomicReplayStoreView(store) as ReplayStore & { + reserve(key: string, value?: unknown, ttlSeconds?: number): Promise; + }; } diff --git a/typescript/packages/pay-kit/src/signer.ts b/typescript/packages/pay-kit/src/signer.ts index 14903d429..cd0f254db 100644 --- a/typescript/packages/pay-kit/src/signer.ts +++ b/typescript/packages/pay-kit/src/signer.ts @@ -49,6 +49,9 @@ const DEMO_SECRET_BYTES = new Uint8Array([ 102, 250, 198, 30, 191, 232, 236, 147, 167, 41, 178, 151, 26, ]); +/** Public address of the package-shipped demo keypair. */ +export const DEMO_SIGNER_PUBLIC_KEY = 'ALtYSsZuYyKrNSe6GnVCzxj1T2RPMTPzXMe51xhbmXEq'; + const HEX_PATTERN = /^[0-9a-fA-F]{128}$/; function wrap(signer: KeychainSigner, options: { isDemo?: boolean; isFeePayer?: boolean } = {}): PayKitSigner {