Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
7875fd7
fix(mpp): harden subscription activation
EfeDurmaz16 Jul 10, 2026
1865dfb
fix(mpp): satisfy repository hygiene guard
EfeDurmaz16 Jul 10, 2026
af6adfd
fix(mpp): unify subscription and charge replay reservations
EfeDurmaz16 Jul 10, 2026
b9e8b62
test(rust): name subscription mutation callback
EfeDurmaz16 Jul 10, 2026
f9d46f7
fix(mpp): recover missing subscription blockhashes
EfeDurmaz16 Jul 11, 2026
d0fa0d2
Merge remote-tracking branch 'origin/split/pr216-ci-harness-mega' int…
EfeDurmaz16 Jul 11, 2026
2bcee2d
fix(mpp): harden subscription activation state
EfeDurmaz16 Jul 11, 2026
d15f31a
fix(mpp): harden subscription activation
EfeDurmaz16 Jul 12, 2026
23d4b2c
fix(ts): satisfy subscription lint ordering
EfeDurmaz16 Jul 12, 2026
3bbf3f2
fix(mpp): preserve subscription receipt metadata
EfeDurmaz16 Jul 13, 2026
c530325
fix(mpp): bind subscription credentials to current gate
EfeDurmaz16 Jul 13, 2026
a2d73bd
fix(mpp): bind subscription query resources
EfeDurmaz16 Jul 13, 2026
684bab2
fix(mpp): HMAC subscription resource binding
EfeDurmaz16 Jul 13, 2026
0033d4e
test(mpp): simplify subscription resource assertions
EfeDurmaz16 Jul 13, 2026
9ef6b90
Merge remote-tracking branch 'origin/split/pr216-ci-harness-mega' int…
EfeDurmaz16 Jul 13, 2026
8041e8f
fix(mpp): harden subscription configuration
EfeDurmaz16 Jul 13, 2026
e418ce5
fix(mpp): validate prebuilt challenge configuration
EfeDurmaz16 Jul 13, 2026
50a8df3
fix(mpp): validate all prebuilt config invariants
EfeDurmaz16 Jul 13, 2026
2a00d59
fix(mpp): harden session/subscription stores to fail closed off-localnet
EfeDurmaz16 Jul 13, 2026
0fe7b3c
fix(pay-kit): fail closed on in-memory replay/session stores off-loca…
EfeDurmaz16 Jul 13, 2026
598cac3
test(harness): gate boot-policy probes on in-tree fail-closed guard
EfeDurmaz16 Jul 13, 2026
5678538
style(mpp): remove em-dash characters from comments
EfeDurmaz16 Jul 13, 2026
c50c76e
Merge fix/mpp-replay-store-hardening into fix/mpp-subscription-final
EfeDurmaz16 Jul 13, 2026
e93f6e2
Merge branch 'fix/mpp-replay-store-hardening' of https://github.com/s…
EfeDurmaz16 Jul 13, 2026
ee9dd33
refactor(harness): reuse pay-kit's toNetwork in the boot fixture
EfeDurmaz16 Jul 14, 2026
e648022
refactor(mpp): name the associated-token-program id in the subscripti…
EfeDurmaz16 Jul 14, 2026
89dd1fe
feat(mpp): route subscription client blockhash fetches through a shar…
EfeDurmaz16 Jul 14, 2026
36614b3
Merge remote-tracking branch 'origin/fix/mpp-replay-store-hardening' …
EfeDurmaz16 Jul 15, 2026
6f742f6
style(rust): satisfy clippy deny-warnings across the subscription har…
EfeDurmaz16 Jul 15, 2026
624c9ce
Merge remote-tracking branch 'origin/fix/mpp-replay-store-hardening' …
EfeDurmaz16 Jul 15, 2026
63b0b04
Merge remote-tracking branch 'origin/fix/mpp-replay-store-hardening' …
EfeDurmaz16 Jul 15, 2026
efe7239
Merge remote-tracking branch 'origin/fix/mpp-replay-store-hardening' …
EfeDurmaz16 Jul 15, 2026
d93df44
Merge remote-tracking branch 'origin/fix/mpp-replay-store-hardening' …
EfeDurmaz16 Jul 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/harness.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
19 changes: 17 additions & 2 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
1 change: 1 addition & 0 deletions rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions rust/crates/kit/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
81 changes: 80 additions & 1 deletion rust/crates/kit/src/core/blockhash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<CachedBlockhash, String> {
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<Mutex<HashMap<String, BlockhashCache>>> = 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
Expand Down Expand Up @@ -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());
}
}
5 changes: 3 additions & 2 deletions rust/crates/kit/src/mpp/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Loading
Loading