From 9ed63ac3904b55b4b677636de6bdec6e9af92932 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:27:49 +0300 Subject: [PATCH 1/9] fix(go): require durable shared replay stores for MPP charge servers server.New now enforces the core.SharedStore/IsShared() contract on any injected replay Store: an injected store must affirmatively implement SharedStore and report IsShared() == true on every network, closing the gap where a non-shared custom store passed the prior concrete-type check. AllowUnsafeMemoryStore is an explicit per-server development opt-in. It only ever authorizes the internally created process-local MemoryStore when Config.Store is nil; it never blesses an injected unshared store, and it is rejected outright on mainnet. A nil store without the opt-in fails closed on all networks, including localnet. The high-level paykit MPP adapter threads ReplayStore and the opt-in through and validates lazily at first serverFor(), so the guard surfaces as a challenge/settle error rather than at client construction. --- go/README.md | 10 +- go/docs/snippets/charge.server.go | 5 +- go/examples/playground-api/README.md | 9 +- go/examples/playground-api/charges.go | 19 +- go/examples/playground-api/main.go | 18 +- go/examples/playground-api/main_test.go | 42 +- .../playground-api/playground_e2e_test.go | 15 +- go/examples/simple-server/README.md | 5 + go/examples/simple-server/main.go | 5 +- go/paykit/adapters/mpp/adapter.go | 18 +- .../adapters/mpp/adapter_internal_test.go | 30 +- go/paykit/doc.go | 5 +- go/paykit/paykit_test.go | 1 + go/paykit/types.go | 9 + go/protocols/mpp/core/store.go | 14 +- go/protocols/mpp/server/audit_fixes_test.go | 26 +- go/protocols/mpp/server/errorcodes_test.go | 14 +- go/protocols/mpp/server/html_test.go | 4 +- go/protocols/mpp/server/middleware_test.go | 44 +- go/protocols/mpp/server/server.go | 41 +- go/protocols/mpp/server/server_test.go | 409 ++++++++++-------- harness/go-server/main.go | 3 + 22 files changed, 457 insertions(+), 289 deletions(-) diff --git a/go/README.md b/go/README.md index c74f21338..ef70a9671 100644 --- a/go/README.md +++ b/go/README.md @@ -45,7 +45,10 @@ func main() { Accept: []paykit.Protocol{paykit.X402, paykit.MPP}, MPP: paykit.MPPConfig{ Realm: "MyApp", - ChallengeBindingSecret: []byte("local-dev-secret"), + ChallengeBindingSecret: []byte("local-dev-secret-0123456789abcdef"), + // This quick start is a single-process local demo. Production + // deployments must inject MPP.ReplayStore with IsShared() == true. + AllowUnsafeMemoryStore: true, }, }) if err != nil { @@ -67,6 +70,11 @@ func main() { middleware, so it composes with chi, gorilla, or the stdlib mux. Inside the handler, `paykit.PaymentFrom(ctx)` returns the verified payment. +MPP replay protection is secure by default: without a shared +`MPP.ReplayStore`, MPP construction fails closed. This quick start explicitly +opts into a process-local `MemoryStore` because it is a single-process local +demo; never carry `AllowUnsafeMemoryStore` into a multi-instance deployment. + Zero-config boots on the in-memory demo signer (it logs a warning and defaults to the Surfpool sandbox). For production set `Operator.Signer` and `RPCURL`; mainnet with the demo signer returns diff --git a/go/docs/snippets/charge.server.go b/go/docs/snippets/charge.server.go index bff190c68..51ab6b5bd 100644 --- a/go/docs/snippets/charge.server.go +++ b/go/docs/snippets/charge.server.go @@ -24,7 +24,10 @@ func main() { Accept: []paykit.Protocol{paykit.X402, paykit.MPP}, MPP: paykit.MPPConfig{ Realm: "MyApp", - ChallengeBindingSecret: []byte("local-dev-secret"), + ChallengeBindingSecret: []byte("local-dev-secret-0123456789abcdef"), + // Single-process local demo only; production must inject a shared + // ReplayStore and leave AllowUnsafeMemoryStore false. + AllowUnsafeMemoryStore: true, }, }) if err != nil { diff --git a/go/examples/playground-api/README.md b/go/examples/playground-api/README.md index bc5727bdc..cb98a1344 100644 --- a/go/examples/playground-api/README.md +++ b/go/examples/playground-api/README.md @@ -28,14 +28,14 @@ Payment Sandbox (a hosted test validator, no real funds): ```bash cd go -go run ./examples/playground-api # listens on :3000 +ALLOW_INMEMORY_REPLAY_STORE=1 go run ./examples/playground-api # single-process local demo on :3000 ``` or through the justfile: ```bash -just -f go/Justfile serve-playground # :3000 -just -f go/Justfile serve-playground 3210 # custom port +ALLOW_INMEMORY_REPLAY_STORE=1 just -f go/Justfile serve-playground # :3000 +ALLOW_INMEMORY_REPLAY_STORE=1 just -f go/Justfile serve-playground 3210 # custom port ``` ## Pointing the playground at this server @@ -46,7 +46,7 @@ instead: ```bash # terminal 1: the Go API -cd go && PORT=3210 go run ./examples/playground-api +cd go && ALLOW_INMEMORY_REPLAY_STORE=1 PORT=3210 go run ./examples/playground-api # terminal 2: UI only, proxied to the running API cd playground @@ -65,6 +65,7 @@ Same table as the TypeScript example: | `RECIPIENT` | (auto-generated) | Solana address that receives payments | | `FEE_PAYER_KEY` | (auto-generated) | Base58 fee-payer keypair (server signs as fee payer) | | `MPP_SECRET_KEY` | (random per-boot) | MPP secret key for challenge HMAC | +| `ALLOW_INMEMORY_REPLAY_STORE` | unset | Set to `1` only for explicit single-process development; unset fails closed without a shared replay store | Additional Go-only knobs: `DOCS_ROOT` overrides the generated-docs directory when the binary runs outside the repository checkout, and the standard diff --git a/go/examples/playground-api/charges.go b/go/examples/playground-api/charges.go index 8bc791fb2..683bbd7bc 100644 --- a/go/examples/playground-api/charges.go +++ b/go/examples/playground-api/charges.go @@ -291,15 +291,16 @@ func registerCharges(mux *http.ServeMux, a *app, client *paykit.Client, dualClie // protocol-layer MPP server so the HTML challenge and service worker flow // remain available for the payment-link E2E. fortuneMpp, err := server.New(server.Config{ - Recipient: a.recipient, - Currency: paycore.USDCMainnetMint, - Decimals: usdcDecimals, - Network: a.network, - RPCURL: a.rpcURL, - SecretKey: a.secretKey, - HTML: true, - FeePayerSigner: a.feePayer, - RPC: a.rpcClient, + Recipient: a.recipient, + Currency: paycore.USDCMainnetMint, + Decimals: usdcDecimals, + Network: a.network, + RPCURL: a.rpcURL, + SecretKey: a.secretKey, + HTML: true, + AllowUnsafeMemoryStore: a.allowUnsafeMemoryStore, + FeePayerSigner: a.feePayer, + RPC: a.rpcClient, }) if err != nil { return fmt.Errorf("fortune mpp server: %w", err) diff --git a/go/examples/playground-api/main.go b/go/examples/playground-api/main.go index eb64f17e9..e70aa26d8 100644 --- a/go/examples/playground-api/main.go +++ b/go/examples/playground-api/main.go @@ -53,6 +53,8 @@ type app struct { // repoRoot is the repository checkout root; "" outside a checkout, which // disables the docs browser default root and the SPA file server. repoRoot string + // Explicit development-only opt-in for process-local replay state. + allowUnsafeMemoryStore bool } func main() { @@ -86,13 +88,14 @@ func main() { recipient := envOr("RECIPIENT", feePayer.PublicKey().String()) a := &app{ - network: network, - rpcURL: rpcURL, - recipient: recipient, - secretKey: secretKey, - feePayer: feePayer, - rpcClient: rpc.New(rpcURL), - repoRoot: findRepoRoot(), + network: network, + rpcURL: rpcURL, + recipient: recipient, + secretKey: secretKey, + feePayer: feePayer, + rpcClient: rpc.New(rpcURL), + repoRoot: findRepoRoot(), + allowUnsafeMemoryStore: os.Getenv("ALLOW_INMEMORY_REPLAY_STORE") == "1", } bootstrapFunding(a) @@ -201,6 +204,7 @@ func newPaymentClient(a *app, accept []paykit.Protocol, x402Scheme string) (*pay MPP: paykit.MPPConfig{ Realm: "PayKit Playground", ChallengeBindingSecret: []byte(a.secretKey), + AllowUnsafeMemoryStore: a.allowUnsafeMemoryStore, }, X402: paykit.X402Config{ Scheme: x402Scheme, diff --git a/go/examples/playground-api/main_test.go b/go/examples/playground-api/main_test.go index e190a32ed..9e4049b0b 100644 --- a/go/examples/playground-api/main_test.go +++ b/go/examples/playground-api/main_test.go @@ -76,13 +76,14 @@ func newTestServer(t *testing.T) (*httptest.Server, *app) { stub := newStubRPC(t, blockhash.PublicKey().String()) a := &app{ - network: "localnet", - rpcURL: stub.URL, - recipient: feePayer.PublicKey().String(), - secretKey: "playground-smoke-secret-0123456789ab", - feePayer: feePayer, - rpcClient: rpc.New(stub.URL), - repoRoot: t.TempDir(), // empty root: no docs generated, no SPA dist + network: "localnet", + rpcURL: stub.URL, + recipient: feePayer.PublicKey().String(), + secretKey: "playground-smoke-secret-0123456789ab", + feePayer: feePayer, + rpcClient: rpc.New(stub.URL), + repoRoot: t.TempDir(), // empty root: no docs generated, no SPA dist + allowUnsafeMemoryStore: true, } handler, shutdown, err := newApp(a) if err != nil { @@ -94,6 +95,33 @@ func newTestServer(t *testing.T) (*httptest.Server, *app) { return httpServer, a } +func TestPaymentClientDoesNotDeriveMemoryReplayOptInFromLocalnet(t *testing.T) { + t.Setenv("PAY_KIT_DISABLE_PREFLIGHT", "1") + feePayer, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatalf("generate fee payer: %v", err) + } + a := &app{ + network: "localnet", + rpcURL: "https://api.mainnet-beta.solana.com", + recipient: feePayer.PublicKey().String(), + secretKey: "playground-smoke-secret-0123456789ab", + feePayer: feePayer, + } + + client, err := newPaymentClient(a, []paykit.Protocol{paykit.MPP}, "exact") + if err != nil { + t.Fatalf("newPaymentClient: %v", err) + } + if client.Config.MPP.AllowUnsafeMemoryStore { + t.Fatal("localnet label implicitly enabled memory replay") + } + gate := &paykit.Gate{Amount: paykit.MustParseUSD("0.01")} + if headers, err := client.MppAdapter().ChallengeHeaders(gate); err == nil { + t.Fatalf("MPP challenge was issued without a replay store: %v", headers) + } +} + // doRequest performs a request and returns the response with its body read. func doRequest(t *testing.T, method, url string, body string, header map[string]string) (*http.Response, string) { t.Helper() diff --git a/go/examples/playground-api/playground_e2e_test.go b/go/examples/playground-api/playground_e2e_test.go index 734247072..39e784184 100644 --- a/go/examples/playground-api/playground_e2e_test.go +++ b/go/examples/playground-api/playground_e2e_test.go @@ -80,13 +80,14 @@ func TestPlaygroundSessionE2ESurfpool(t *testing.T) { t.Fatalf("generate fee payer: %v", err) } a := &app{ - network: "localnet", - rpcURL: sandboxRPCURL(), - recipient: feePayer.PublicKey().String(), - secretKey: "playground-e2e-secret-0123456789abc", - feePayer: feePayer, - rpcClient: rpcClient, - repoRoot: t.TempDir(), + network: "localnet", + rpcURL: sandboxRPCURL(), + recipient: feePayer.PublicKey().String(), + secretKey: "playground-e2e-secret-0123456789abc", + feePayer: feePayer, + rpcClient: rpcClient, + repoRoot: t.TempDir(), + allowUnsafeMemoryStore: true, } bootstrapFunding(a) diff --git a/go/examples/simple-server/README.md b/go/examples/simple-server/README.md index e0eaa8d9b..4b07cd770 100644 --- a/go/examples/simple-server/README.md +++ b/go/examples/simple-server/README.md @@ -22,6 +22,11 @@ boots on the in-memory demo signer (it logs a warning) and defaults to Surfpool localnet; pass a real `Operator.Signer` and `RPCURL` in `paykit.Config` for anything beyond a smoke test. +This example also explicitly enables `AllowUnsafeMemoryStore` because it is a +single-process local demo. MPP otherwise fails closed unless `MPP.ReplayStore` +implements `SharedStore` and reports `IsShared() == true`; leave the unsafe +flag disabled when deploying more than one process. + ## DX check In another terminal: diff --git a/go/examples/simple-server/main.go b/go/examples/simple-server/main.go index 61676f627..b7a9529cc 100644 --- a/go/examples/simple-server/main.go +++ b/go/examples/simple-server/main.go @@ -28,7 +28,10 @@ func main() { Preflight: &preflight, MPP: paykit.MPPConfig{ Realm: "Go example", - ChallengeBindingSecret: []byte("local-dev-secret"), + ChallengeBindingSecret: []byte("local-dev-secret-0123456789abcdef"), + // This example is a single-process local demo. Production must + // inject a shared replay store and leave this false. + AllowUnsafeMemoryStore: true, }, }) if err != nil { diff --git a/go/paykit/adapters/mpp/adapter.go b/go/paykit/adapters/mpp/adapter.go index e0ecb8d63..ef6ead581 100644 --- a/go/paykit/adapters/mpp/adapter.go +++ b/go/paykit/adapters/mpp/adapter.go @@ -177,14 +177,16 @@ func (a *Adapter) serverFor(gate *paykit.Gate) (*server.Mpp, error) { feePayer = &signerBridge{signer: a.cfg.Operator.Signer, pub: pub} } srv, err := server.New(server.Config{ - Recipient: string(payTo), - SecretKey: string(a.cfg.MPP.ChallengeBindingSecret), - Currency: coin, - Network: a.cfg.Network.MintsLabel(), - Realm: a.cfg.MPP.Realm, - RPCURL: a.cfg.RPCURL, - Decimals: paycore.DefaultDecimalsForCurrency(coin, a.cfg.Network.MintsLabel()), - FeePayerSigner: feePayer, + Recipient: string(payTo), + SecretKey: string(a.cfg.MPP.ChallengeBindingSecret), + Currency: coin, + Network: a.cfg.Network.MintsLabel(), + Realm: a.cfg.MPP.Realm, + RPCURL: a.cfg.RPCURL, + Decimals: paycore.DefaultDecimalsForCurrency(coin, a.cfg.Network.MintsLabel()), + FeePayerSigner: feePayer, + Store: a.cfg.MPP.ReplayStore, + AllowUnsafeMemoryStore: a.cfg.MPP.AllowUnsafeMemoryStore, }) if err != nil { return nil, err diff --git a/go/paykit/adapters/mpp/adapter_internal_test.go b/go/paykit/adapters/mpp/adapter_internal_test.go index 2451c07c4..341dd37fd 100644 --- a/go/paykit/adapters/mpp/adapter_internal_test.go +++ b/go/paykit/adapters/mpp/adapter_internal_test.go @@ -21,6 +21,10 @@ type errSigner struct { raw []byte // when non-nil, Sign returns this slice without error } +type sharedReplayStore struct{ *core.MemoryStore } + +func (*sharedReplayStore) IsShared() bool { return true } + func (e *errSigner) Pubkey() paykit.Address { return paykit.Address(e.pubkey) } func (e *errSigner) IsDemo() bool { return false } func (e *errSigner) Sign(_ context.Context, _ []byte) ([]byte, error) { @@ -36,8 +40,12 @@ func testCfg() paykit.Config { Network: paykit.SolanaLocalnet, Stablecoins: []paykit.Stablecoin{paykit.USDC}, Operator: paykit.Operator{Signer: demo, Recipient: demo.Pubkey(), FeePayer: true}, - MPP: paykit.MPPConfig{Realm: "Unit", ChallengeBindingSecret: []byte("unit-test-binding-secret-0123456789abcdef")}, - RPCURL: "https://example.invalid", // never dialed in these tests + MPP: paykit.MPPConfig{ + Realm: "Unit", + ChallengeBindingSecret: []byte("unit-test-binding-secret-0123456789abcdef"), + AllowUnsafeMemoryStore: true, + }, + RPCURL: "https://example.invalid", // never dialed in these tests } } @@ -109,6 +117,24 @@ func TestServerForCachesPerKey(t *testing.T) { } } +func TestServerForForwardsInjectedSharedReplayStore(t *testing.T) { + cfg := testCfg() + cfg.MPP.AllowUnsafeMemoryStore = false + cfg.MPP.ReplayStore = &sharedReplayStore{MemoryStore: core.NewMemoryStore()} + if _, err := (&Adapter{cfg: cfg}).serverFor(&paykit.Gate{Amount: paykit.MustParseUSD("0.10")}); err != nil { + t.Fatalf("serverFor() rejected injected shared replay store: %v", err) + } +} + +func TestServerForFailsClosedWithoutReplayStore(t *testing.T) { + cfg := testCfg() + cfg.MPP.AllowUnsafeMemoryStore = false + _, err := (&Adapter{cfg: cfg}).serverFor(&paykit.Gate{Amount: paykit.MustParseUSD("0.10")}) + if err == nil { + t.Fatal("serverFor() accepted MPP without a shared replay store") + } +} + func TestCoinHelpers(t *testing.T) { a := &Adapter{cfg: testCfg()} // Gate with explicit settlement preference wins over config default. diff --git a/go/paykit/doc.go b/go/paykit/doc.go index 0405c7065..f860d95a3 100644 --- a/go/paykit/doc.go +++ b/go/paykit/doc.go @@ -19,7 +19,10 @@ // Preflight: &preflight, // MPP: paykit.MPPConfig{ // Realm: "MyApp", -// ChallengeBindingSecret: []byte("local-dev-secret"), +// ChallengeBindingSecret: []byte("local-dev-secret-0123456789abcdef"), +// // Single-process local demo only; production must inject a shared +// // ReplayStore and leave AllowUnsafeMemoryStore false. +// AllowUnsafeMemoryStore: true, // }, // }) // if err != nil { log.Fatal(err) } diff --git a/go/paykit/paykit_test.go b/go/paykit/paykit_test.go index cf3e3c5e4..89487ea60 100644 --- a/go/paykit/paykit_test.go +++ b/go/paykit/paykit_test.go @@ -274,6 +274,7 @@ func mustClient(t *testing.T) *paykit.Client { Preflight: disabled(), MPP: paykit.MPPConfig{ ChallengeBindingSecret: []byte("test-secret-key-0123456789abcdef"), + AllowUnsafeMemoryStore: true, }, }) if err != nil { diff --git a/go/paykit/types.go b/go/paykit/types.go index 970272599..d08f32041 100644 --- a/go/paykit/types.go +++ b/go/paykit/types.go @@ -6,6 +6,7 @@ import ( "time" "github.com/shopspring/decimal" + core "github.com/solana-foundation/pay-kit/go/protocols/mpp/core" ) // Protocol enumerates the payment protocols the kit speaks. Order matters in @@ -220,6 +221,14 @@ type MPPConfig struct { // ExpiresIn is how long an issued challenge stays valid; sent on the // wire in whole seconds. Zero defaults to 2 minutes. ExpiresIn time.Duration + // ReplayStore must affirmatively implement core.SharedStore and report + // IsShared() == true. Unknown stores fail closed. + ReplayStore core.Store + // AllowUnsafeMemoryStore explicitly opts into an internally-created + // process-local replay store for development/tests when ReplayStore is nil. + // It defaults to false; Network alone never opts in and it never authorizes + // an injected unshared store. + AllowUnsafeMemoryStore bool } // Config is the boot-time configuration passed to [New]. Zero-value diff --git a/go/protocols/mpp/core/store.go b/go/protocols/mpp/core/store.go index 0a129575a..d3919a2e4 100644 --- a/go/protocols/mpp/core/store.go +++ b/go/protocols/mpp/core/store.go @@ -14,7 +14,16 @@ type Store interface { PutIfAbsent(ctx context.Context, key string, value any) (bool, error) } -// MemoryStore is an in-memory Store implementation for tests and small deployments. +// SharedStore is the production replay-store contract. Implementations must +// make PutIfAbsent atomic across every process/host that can accept the same +// credential. A plain Store is deliberately not assumed to be shared. +type SharedStore interface { + Store + IsShared() bool +} + +// MemoryStore is an in-memory Store implementation for tests and explicitly +// opted-in single-process development. type MemoryStore struct { mu sync.RWMutex data map[string]json.RawMessage @@ -25,6 +34,9 @@ func NewMemoryStore() *MemoryStore { return &MemoryStore{data: map[string]json.RawMessage{}} } +// IsShared reports false because MemoryStore is confined to one process. +func (s *MemoryStore) IsShared() bool { return false } + // Get returns a copy of the raw JSON value previously stored under key. // The second return value reports whether the key was found. func (s *MemoryStore) Get(_ context.Context, key string) (json.RawMessage, bool, error) { diff --git a/go/protocols/mpp/server/audit_fixes_test.go b/go/protocols/mpp/server/audit_fixes_test.go index 8a06469ed..1412dac17 100644 --- a/go/protocols/mpp/server/audit_fixes_test.go +++ b/go/protocols/mpp/server/audit_fixes_test.go @@ -41,13 +41,13 @@ func decodeChallengeDetails(t *testing.T, challenge core.PaymentChallenge, out * func validConfig(t *testing.T) Config { t.Helper() return Config{ - Recipient: testutil.NewPrivateKey().PublicKey().String(), - Currency: "USDC", - Decimals: 6, - Network: "localnet", - SecretKey: "test-secret-key-0123456789abcdef", - RPC: testutil.NewFakeRPC(), - Store: core.NewMemoryStore(), + Recipient: testutil.NewPrivateKey().PublicKey().String(), + Currency: "USDC", + Decimals: 6, + Network: "localnet", + SecretKey: "test-secret-key-0123456789abcdef", + RPC: testutil.NewFakeRPC(), + AllowUnsafeMemoryStore: true, } } @@ -76,6 +76,10 @@ func TestNewAcceptsCanonicalNetworks(t *testing.T) { for _, network := range []string{"mainnet", "devnet", "localnet"} { cfg := validConfig(t) cfg.Network = network + if network != "localnet" { + cfg.Store = &sharedTestStore{MemoryStore: core.NewMemoryStore()} + cfg.AllowUnsafeMemoryStore = false + } // USDC resolves from the static table, no RPC fetch needed at boot. if _, err := New(cfg); err != nil { t.Fatalf("expected network %q to be accepted: %v", network, err) @@ -83,6 +87,14 @@ func TestNewAcceptsCanonicalNetworks(t *testing.T) { } } +func TestNewRejectsUnsafeMemoryStoreOnMainnet(t *testing.T) { + cfg := validConfig(t) + cfg.Network = "mainnet" + if _, err := New(cfg); err == nil || !strings.Contains(err.Error(), "forbidden on mainnet") { + t.Fatalf("expected mainnet unsafe-store rejection, got %v", err) + } +} + func TestNewRejectsMainnetBetaSlug(t *testing.T) { cfg := validConfig(t) cfg.Network = "mainnet-beta" diff --git a/go/protocols/mpp/server/errorcodes_test.go b/go/protocols/mpp/server/errorcodes_test.go index 32273c177..35a637bcd 100644 --- a/go/protocols/mpp/server/errorcodes_test.go +++ b/go/protocols/mpp/server/errorcodes_test.go @@ -86,13 +86,13 @@ func TestMiddlewareAmountMismatchEmitsChargeRequestMismatchCode(t *testing.T) { rpcClient := testutil.NewFakeRPC() signer := testutil.NewPrivateKey() m, err := New(Config{ - Recipient: testutil.NewPrivateKey().PublicKey().String(), - Currency: "sol", - Decimals: 9, - Network: "localnet", - SecretKey: "test-secret-key-that-is-long-enough-for-hmac-sha256-operations", - RPC: rpcClient, - Store: core.NewMemoryStore(), + Recipient: testutil.NewPrivateKey().PublicKey().String(), + Currency: "sol", + Decimals: 9, + Network: "localnet", + SecretKey: "test-secret-key-that-is-long-enough-for-hmac-sha256-operations", + RPC: rpcClient, + AllowUnsafeMemoryStore: true, }) if err != nil { t.Fatalf("new mpp: %v", err) diff --git a/go/protocols/mpp/server/html_test.go b/go/protocols/mpp/server/html_test.go index 99e01b89b..e543ab312 100644 --- a/go/protocols/mpp/server/html_test.go +++ b/go/protocols/mpp/server/html_test.go @@ -18,7 +18,7 @@ func newHTMLTestMpp(t *testing.T) *Mpp { SecretKey: "test-secret-key-that-is-long-enough-for-hmac-sha256-operations", Network: "devnet", HTML: true, - Store: core.NewMemoryStore(), + Store: &sharedTestStore{MemoryStore: core.NewMemoryStore()}, }) if err != nil { t.Fatalf("new mpp failed: %v", err) @@ -130,7 +130,7 @@ func TestHTMLEnabled(t *testing.T) { SecretKey: "test-secret-key-that-is-long-enough-for-hmac-sha256-operations", Network: "devnet", HTML: false, - Store: core.NewMemoryStore(), + Store: &sharedTestStore{MemoryStore: core.NewMemoryStore()}, }) if err != nil { t.Fatalf("new mpp failed: %v", err) diff --git a/go/protocols/mpp/server/middleware_test.go b/go/protocols/mpp/server/middleware_test.go index 772dd486d..9dee0f438 100644 --- a/go/protocols/mpp/server/middleware_test.go +++ b/go/protocols/mpp/server/middleware_test.go @@ -17,13 +17,13 @@ func newMiddlewareTestMpp(t *testing.T) *Mpp { t.Helper() rpcClient := testutil.NewFakeRPC() handler, err := New(Config{ - Recipient: testutil.NewPrivateKey().PublicKey().String(), - Currency: "sol", - Decimals: 9, - Network: "localnet", - SecretKey: "test-secret-key-that-is-long-enough-for-hmac-sha256-operations", - RPC: rpcClient, - Store: core.NewMemoryStore(), + Recipient: testutil.NewPrivateKey().PublicKey().String(), + Currency: "sol", + Decimals: 9, + Network: "localnet", + SecretKey: "test-secret-key-that-is-long-enough-for-hmac-sha256-operations", + RPC: rpcClient, + AllowUnsafeMemoryStore: true, }) if err != nil { t.Fatalf("new mpp failed: %v", err) @@ -84,13 +84,13 @@ func TestMiddlewareValidAuth(t *testing.T) { rpcClient := testutil.NewFakeRPC() signer := testutil.NewPrivateKey() m, err := New(Config{ - Recipient: testutil.NewPrivateKey().PublicKey().String(), - Currency: "sol", - Decimals: 9, - Network: "localnet", - SecretKey: "test-secret-key-that-is-long-enough-for-hmac-sha256-operations", - RPC: rpcClient, - Store: core.NewMemoryStore(), + Recipient: testutil.NewPrivateKey().PublicKey().String(), + Currency: "sol", + Decimals: 9, + Network: "localnet", + SecretKey: "test-secret-key-that-is-long-enough-for-hmac-sha256-operations", + RPC: rpcClient, + AllowUnsafeMemoryStore: true, }) if err != nil { t.Fatalf("new mpp failed: %v", err) @@ -166,14 +166,14 @@ func TestMiddlewareInvalidCredential402(t *testing.T) { func TestMiddlewareBrowserHTML402(t *testing.T) { rpcClient := testutil.NewFakeRPC() m, err := New(Config{ - Recipient: testutil.NewPrivateKey().PublicKey().String(), - Currency: "sol", - Decimals: 9, - Network: "localnet", - SecretKey: "test-secret-key-that-is-long-enough-for-hmac-sha256-operations", - RPC: rpcClient, - Store: core.NewMemoryStore(), - HTML: true, + Recipient: testutil.NewPrivateKey().PublicKey().String(), + Currency: "sol", + Decimals: 9, + Network: "localnet", + SecretKey: "test-secret-key-that-is-long-enough-for-hmac-sha256-operations", + RPC: rpcClient, + AllowUnsafeMemoryStore: true, + HTML: true, }) if err != nil { t.Fatalf("new mpp failed: %v", err) diff --git a/go/protocols/mpp/server/server.go b/go/protocols/mpp/server/server.go index f5b854585..9ff3915ad 100644 --- a/go/protocols/mpp/server/server.go +++ b/go/protocols/mpp/server/server.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "log" "math/bits" "os" "strings" @@ -71,12 +72,15 @@ type Config struct { Realm string HTML bool FeePayerSigner solanatx.Signer - // Store holds replay markers. Outside localnet it must be shared and - // durable: an absent store or *core.MemoryStore is rejected unless - // PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1 explicitly permits a - // process-local development store. + // Store persists consumed charge credentials. Construction requires an + // implementation that affirmatively implements SharedStore on every network. Store core.Store RPC solanatx.RPCClient + // AllowUnsafeMemoryStore is an explicit development/test escape hatch. + // When Store is nil, it permits New to create a process-local MemoryStore + // on any network and defaults to false. It never authorizes an injected + // unshared Store. Never enable it in a multi-instance deployment. + AllowUnsafeMemoryStore bool // AcceptPushMode opts in to accepting type="signature" (push mode) // credentials, where the client broadcasts the transaction itself and @@ -160,21 +164,26 @@ func New(config Config) (*Mpp, error) { return nil, core.WrapError(core.ErrCodeInvalidConfig, "invalid network", err) } config.Network = string(canonicalNetwork) - usesMemoryReplayStore := false - if config.Store != nil { - _, usesMemoryReplayStore = config.Store.(*core.MemoryStore) - } - if canonicalNetwork != paycore.NetworkLocalnet && (config.Store == nil || usesMemoryReplayStore) && os.Getenv(allowInMemoryReplayStoreEnvVar) != "1" { - storeDescription := "no replay store" - if usesMemoryReplayStore { - storeDescription = "process-local *core.MemoryStore" - } + if config.AllowUnsafeMemoryStore && canonicalNetwork == paycore.NetworkMainnet { return nil, core.NewError(core.ErrCodeInvalidConfig, - fmt.Sprintf("%s configured for %s; configure a shared replay Store or set %s=1 to allow a process-local development store", - storeDescription, config.Network, allowInMemoryReplayStoreEnvVar)) + "AllowUnsafeMemoryStore is forbidden on mainnet; inject an atomic shared replay store") } - if config.Store == nil { + createdMemoryStore := false + if config.Store == nil && config.AllowUnsafeMemoryStore { + log.Printf("pay-kit: WARNING: MPP server on %s explicitly enabled process-local MemoryStore; replay markers are lost on restart and are not shared across workers", config.Network) config.Store = core.NewMemoryStore() + createdMemoryStore = true + } + if config.Store == nil { + return nil, core.NewError(core.ErrCodeInvalidConfig, + fmt.Sprintf("an atomic shared replay store is required on %s; inject Config.Store implementing SharedStore with IsShared() == true, or explicitly enable AllowUnsafeMemoryStore for a process-local development/test store", config.Network)) + } + if !createdMemoryStore { + shared, sharedOK := config.Store.(core.SharedStore) + if !sharedOK || !shared.IsShared() { + return nil, core.NewError(core.ErrCodeInvalidConfig, + fmt.Sprintf("injected Config.Store must implement SharedStore and report IsShared() == true on %s; AllowUnsafeMemoryStore only authorizes the internally-created MemoryStore when Config.Store is nil", config.Network)) + } } // Derive a per-recipient default realm when none is configured (and reject // an explicitly-empty realm). A shared literal default would let two diff --git a/go/protocols/mpp/server/server_test.go b/go/protocols/mpp/server/server_test.go index f8626da8c..ed2f22913 100644 --- a/go/protocols/mpp/server/server_test.go +++ b/go/protocols/mpp/server/server_test.go @@ -37,13 +37,13 @@ func newTestMpp(t *testing.T) (*Mpp, *testutil.FakeRPC, testutilConfig) { SecretKey: "test-secret-key-0123456789abcdef", } handler, err := New(Config{ - Recipient: cfg.Recipient, - Currency: "sol", - Decimals: 9, - Network: "localnet", - SecretKey: cfg.SecretKey, - RPC: rpcClient, - Store: core.NewMemoryStore(), + Recipient: cfg.Recipient, + Currency: "sol", + Decimals: 9, + Network: "localnet", + SecretKey: cfg.SecretKey, + RPC: rpcClient, + AllowUnsafeMemoryStore: true, // Push-mode (type="signature") credentials are opt-in (#5); the shared // fixture enables them so the signature-flow tests exercise settlement. AcceptPushMode: true, @@ -60,6 +60,21 @@ type testutilConfig struct { SecretKey string } +// sharedTestStore models an operator-injected shared Store. Embedding the +// memory implementation keeps this constructor test focused on store +// selection; production implementations use Redis, Postgres, or equivalent. +type sharedTestStore struct { + *core.MemoryStore +} + +func (*sharedTestStore) IsShared() bool { return true } + +type unsharedTestStore struct { + *core.MemoryStore +} + +func (*unsharedTestStore) IsShared() bool { return false } + func newTestTransaction(t *testing.T, payer solana.PrivateKey, instructions ...solana.Instruction) *solana.Transaction { t.Helper() tx, err := solana.NewTransaction( @@ -224,14 +239,14 @@ func TestVerifyCredentialRejectsSponsoredPushMode(t *testing.T) { recipient := testutil.NewPrivateKey() feePayer := testutil.NewPrivateKey() handler, err := New(Config{ - Recipient: recipient.PublicKey().String(), - Currency: "sol", - Decimals: 9, - Network: "localnet", - SecretKey: "test-secret-key-0123456789abcdef", - RPC: rpcClient, - Store: core.NewMemoryStore(), - FeePayerSigner: feePayer, + Recipient: recipient.PublicKey().String(), + Currency: "sol", + Decimals: 9, + Network: "localnet", + SecretKey: "test-secret-key-0123456789abcdef", + RPC: rpcClient, + AllowUnsafeMemoryStore: true, + FeePayerSigner: feePayer, }) if err != nil { t.Fatalf("new mpp failed: %v", err) @@ -259,14 +274,14 @@ func TestVerifyCredentialTokenSignatureSuccess(t *testing.T) { mint := testutil.NewPrivateKey().PublicKey() rpcClient.MintOwners[mint.String()] = solana.TokenProgramID handler, err := New(Config{ - Recipient: recipient.PublicKey().String(), - Currency: mint.String(), - Decimals: 6, - Network: "localnet", - SecretKey: "test-secret-key-0123456789abcdef", - RPC: rpcClient, - Store: core.NewMemoryStore(), - AcceptPushMode: true, + Recipient: recipient.PublicKey().String(), + Currency: mint.String(), + Decimals: 6, + Network: "localnet", + SecretKey: "test-secret-key-0123456789abcdef", + RPC: rpcClient, + AllowUnsafeMemoryStore: true, + AcceptPushMode: true, }) if err != nil { t.Fatalf("new mpp failed: %v", err) @@ -299,14 +314,14 @@ func TestVerifyCredentialUSDCSymbolSignatureSuccess(t *testing.T) { usdcMint := solana.MustPublicKeyFromBase58("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") rpcClient.MintOwners[usdcMint.String()] = solana.TokenProgramID handler, err := New(Config{ - Recipient: recipient.PublicKey().String(), - Currency: "USDC", - Decimals: 6, - Network: "localnet", - SecretKey: "test-secret-key-0123456789abcdef", - RPC: rpcClient, - Store: core.NewMemoryStore(), - AcceptPushMode: true, + Recipient: recipient.PublicKey().String(), + Currency: "USDC", + Decimals: 6, + Network: "localnet", + SecretKey: "test-secret-key-0123456789abcdef", + RPC: rpcClient, + AllowUnsafeMemoryStore: true, + AcceptPushMode: true, }) if err != nil { t.Fatalf("new mpp failed: %v", err) @@ -622,7 +637,10 @@ func TestNewSecretKeyFromEnv(t *testing.T) { t.Setenv(allowInMemoryReplayStoreEnvVar, "1") recipient := testutil.NewPrivateKey().PublicKey().String() rpcClient := testutil.NewFakeRPC() - handler, err := New(Config{Recipient: recipient, RPC: rpcClient, Store: core.NewMemoryStore()}) + handler, err := New(Config{ + Recipient: recipient, Network: "localnet", RPC: rpcClient, + AllowUnsafeMemoryStore: true, + }) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -631,117 +649,134 @@ func TestNewSecretKeyFromEnv(t *testing.T) { } } -func TestNewRejectsShortEnvSecretKey(t *testing.T) { - // The env-var path shares the >= 32-byte gate with Config.SecretKey (#24). - t.Setenv("MPP_SECRET_KEY", "too-short") +func TestNewRequiresAtomicSharedStore(t *testing.T) { recipient := testutil.NewPrivateKey().PublicKey().String() - rpcClient := testutil.NewFakeRPC() - if _, err := New(Config{Recipient: recipient, RPC: rpcClient}); err == nil { - t.Fatal("expected error for short env secret key") - } -} - -func TestNewDefaultReplayStorePolicy(t *testing.T) { - for _, tt := range []struct { - name string - network string - optIn string - wantErr bool - }{ - {name: "default mainnet", wantErr: true}, - {name: "mainnet", network: "mainnet", wantErr: true}, - {name: "devnet", network: "devnet", wantErr: true}, - {name: "invalid opt-in", network: "mainnet", optIn: "true", wantErr: true}, - {name: "localnet", network: "localnet"}, - {name: "mainnet development opt-in", network: "mainnet", optIn: "1"}, - } { - t.Run(tt.name, func(t *testing.T) { - t.Setenv(allowInMemoryReplayStoreEnvVar, tt.optIn) - handler, err := New(Config{ - Recipient: testutil.NewPrivateKey().PublicKey().String(), + for _, network := range []string{"devnet", "mainnet"} { + t.Run(network, func(t *testing.T) { + _, err := New(Config{ + Recipient: recipient, Currency: "sol", - Decimals: 9, - Network: tt.network, + Network: network, SecretKey: "test-secret-key-0123456789abcdef", RPC: testutil.NewFakeRPC(), }) - if tt.wantErr { - if err == nil { - t.Fatal("expected replay store policy error") - } - var mppErr *core.Error - if !errors.As(err, &mppErr) || mppErr.Code != core.ErrCodeInvalidConfig { - t.Fatalf("expected invalid-config error, got %v", err) - } - if !strings.Contains(err.Error(), allowInMemoryReplayStoreEnvVar) { - t.Fatalf("expected error to name %s, got %v", allowInMemoryReplayStoreEnvVar, err) - } - return - } - if err != nil { - t.Fatalf("New: %v", err) - } - if _, ok := handler.store.(*core.MemoryStore); !ok { - t.Fatalf("default store = %T, want *core.MemoryStore", handler.store) + if err == nil || !strings.Contains(err.Error(), "atomic shared replay store") { + t.Fatalf("New() error = %v, want atomic shared replay store rejection", err) } }) } } -func TestNewExplicitMemoryReplayStorePolicy(t *testing.T) { - for _, tt := range []struct { - name string - network string - optIn string - wantErr bool - }{ - {name: "mainnet rejects memory store", network: "mainnet", wantErr: true}, - {name: "devnet rejects memory store", network: "devnet", wantErr: true}, - {name: "localnet permits memory store", network: "localnet"}, - {name: "mainnet opt-in permits memory store", network: "mainnet", optIn: "1"}, +func TestNewRejectsMemoryStoreOutsideLocalnet(t *testing.T) { + recipient := testutil.NewPrivateKey().PublicKey().String() + _, err := New(Config{ + Recipient: recipient, + Currency: "sol", + Network: "mainnet", + SecretKey: "test-secret-key-0123456789abcdef", + RPC: testutil.NewFakeRPC(), + Store: core.NewMemoryStore(), + }) + if err == nil || !strings.Contains(err.Error(), "IsShared() == true") { + t.Fatalf("New() error = %v, want unknown/local store rejection", err) + } +} + +func TestNewRejectsImplicitLocalnetMemoryStore(t *testing.T) { + recipient := testutil.NewPrivateKey().PublicKey().String() + _, err := New(Config{ + Recipient: recipient, + Currency: "sol", + Network: "localnet", + SecretKey: "test-secret-key-0123456789abcdef", + RPC: testutil.NewFakeRPC(), + }) + if err == nil || !strings.Contains(err.Error(), "atomic shared replay store") { + t.Fatalf("New() localnet error = %v, want explicit-store rejection", err) + } +} + +func TestNewAllowsInternallyCreatedUnsafeMemoryStore(t *testing.T) { + recipient := testutil.NewPrivateKey().PublicKey().String() + handler, err := New(Config{ + Recipient: recipient, + Currency: "sol", + Network: "localnet", + SecretKey: "test-secret-key-0123456789abcdef", + RPC: testutil.NewFakeRPC(), + AllowUnsafeMemoryStore: true, + }) + if err != nil { + t.Fatalf("New() internally-created unsafe development store error = %v", err) + } + if _, ok := handler.store.(*core.MemoryStore); !ok { + t.Fatalf("New() store = %T, want internally-created *core.MemoryStore", handler.store) + } +} + +func TestNewRejectsInjectedUnsharedStoreWithUnsafeOptIn(t *testing.T) { + recipient := testutil.NewPrivateKey().PublicKey().String() + for name, store := range map[string]core.Store{ + "memory": core.NewMemoryStore(), + "custom": &unsharedTestStore{MemoryStore: core.NewMemoryStore()}, } { - t.Run(tt.name, func(t *testing.T) { - t.Setenv(allowInMemoryReplayStoreEnvVar, tt.optIn) - store := core.NewMemoryStore() - handler, err := New(Config{ - Recipient: testutil.NewPrivateKey().PublicKey().String(), - Currency: "sol", - Decimals: 9, - Network: tt.network, - SecretKey: "test-secret-key-0123456789abcdef", - RPC: testutil.NewFakeRPC(), - Store: store, + t.Run(name, func(t *testing.T) { + _, err := New(Config{ + Recipient: recipient, + Currency: "sol", + Network: "localnet", + SecretKey: "test-secret-key-0123456789abcdef", + RPC: testutil.NewFakeRPC(), + Store: store, + AllowUnsafeMemoryStore: true, }) - if tt.wantErr { - if err == nil { - t.Fatal("expected replay store policy error") - } - if !strings.Contains(err.Error(), allowInMemoryReplayStoreEnvVar) || !strings.Contains(err.Error(), "MemoryStore") { - t.Fatalf("error = %v, want memory-store policy error", err) - } - return - } - if err != nil { - t.Fatalf("New: %v", err) - } - if handler.store != store { - t.Fatalf("store = %T, want supplied store", handler.store) + if err == nil || !strings.Contains(err.Error(), "only authorizes the internally-created MemoryStore") { + t.Fatalf("New() error = %v, want injected unshared store rejection", err) } }) } } -func TestChargeToken(t *testing.T) { - rpcClient := testutil.NewFakeRPC() +func TestNewAllowsInjectedStoreOutsideLocalnet(t *testing.T) { recipient := testutil.NewPrivateKey().PublicKey().String() + store := &sharedTestStore{MemoryStore: core.NewMemoryStore()} handler, err := New(Config{ Recipient: recipient, - Currency: "USDC", - Decimals: 6, - Network: "localnet", + Currency: "sol", + Network: "mainnet", SecretKey: "test-secret-key-0123456789abcdef", - RPC: rpcClient, - Store: core.NewMemoryStore(), + RPC: testutil.NewFakeRPC(), + Store: store, + }) + if err != nil { + t.Fatalf("New() injected store error = %v", err) + } + if handler.store != store { + t.Fatal("New() did not preserve the injected replay store") + } +} + +func TestNewRejectsShortEnvSecretKey(t *testing.T) { + // The env-var path shares the >= 32-byte gate with Config.SecretKey (#24). + t.Setenv("MPP_SECRET_KEY", "too-short") + recipient := testutil.NewPrivateKey().PublicKey().String() + rpcClient := testutil.NewFakeRPC() + if _, err := New(Config{Recipient: recipient, RPC: rpcClient}); err == nil { + t.Fatal("expected error for short env secret key") + } +} + +func TestChargeToken(t *testing.T) { + rpcClient := testutil.NewFakeRPC() + recipient := testutil.NewPrivateKey().PublicKey().String() + handler, err := New(Config{ + Recipient: recipient, + Currency: "USDC", + Decimals: 6, + Network: "localnet", + SecretKey: "test-secret-key-0123456789abcdef", + RPC: rpcClient, + AllowUnsafeMemoryStore: true, }) if err != nil { t.Fatalf("new mpp failed: %v", err) @@ -898,13 +933,13 @@ func TestVerifyCredentialGetTxFailure(t *testing.T) { recipient := testutil.NewPrivateKey() clientSigner := testutil.NewPrivateKey() handler, err := New(Config{ - Recipient: recipient.PublicKey().String(), - Currency: "sol", - Decimals: 9, - Network: "localnet", - SecretKey: "test-secret-key-0123456789abcdef", - RPC: rpcClient, - Store: core.NewMemoryStore(), + Recipient: recipient.PublicKey().String(), + Currency: "sol", + Decimals: 9, + Network: "localnet", + SecretKey: "test-secret-key-0123456789abcdef", + RPC: rpcClient, + AllowUnsafeMemoryStore: true, }) if err != nil { t.Fatalf("new mpp failed: %v", err) @@ -943,14 +978,14 @@ func TestChargeWithFeePayer(t *testing.T) { feePayer := testutil.NewPrivateKey() recipient := testutil.NewPrivateKey() handler, err := New(Config{ - Recipient: recipient.PublicKey().String(), - Currency: "sol", - Decimals: 9, - Network: "localnet", - SecretKey: "test-secret-key-0123456789abcdef", - RPC: rpcClient, - Store: core.NewMemoryStore(), - FeePayerSigner: feePayer, + Recipient: recipient.PublicKey().String(), + Currency: "sol", + Decimals: 9, + Network: "localnet", + SecretKey: "test-secret-key-0123456789abcdef", + RPC: rpcClient, + AllowUnsafeMemoryStore: true, + FeePayerSigner: feePayer, }) if err != nil { t.Fatalf("new mpp failed: %v", err) @@ -983,7 +1018,7 @@ func TestNewWithDefaultValues(t *testing.T) { Recipient: recipient, SecretKey: "test-secret-key-0123456789abcdef", RPC: rpcClient, - Store: core.NewMemoryStore(), + Store: &sharedTestStore{MemoryStore: core.NewMemoryStore()}, }) if err != nil { t.Fatalf("new mpp failed: %v", err) @@ -1019,7 +1054,7 @@ func TestChargeKnownStablecoinTokenPrograms(t *testing.T) { Network: "mainnet", SecretKey: "test-secret-key-0123456789abcdef", RPC: rpcClient, - Store: core.NewMemoryStore(), + Store: &sharedTestStore{MemoryStore: core.NewMemoryStore()}, }) if err != nil { t.Fatalf("new mpp failed: %v", err) @@ -1049,13 +1084,13 @@ func TestVerifyCredentialTokenTransactionSuccess(t *testing.T) { mint := testutil.NewPrivateKey().PublicKey() rpcClient.MintOwners[mint.String()] = solana.TokenProgramID handler, err := New(Config{ - Recipient: recipient.PublicKey().String(), - Currency: mint.String(), - Decimals: 6, - Network: "localnet", - SecretKey: "test-secret-key-0123456789abcdef", - RPC: rpcClient, - Store: core.NewMemoryStore(), + Recipient: recipient.PublicKey().String(), + Currency: mint.String(), + Decimals: 6, + Network: "localnet", + SecretKey: "test-secret-key-0123456789abcdef", + RPC: rpcClient, + AllowUnsafeMemoryStore: true, }) if err != nil { t.Fatalf("new mpp failed: %v", err) @@ -1105,7 +1140,7 @@ func TestRPCURL(t *testing.T) { SecretKey: "test-secret-key-0123456789abcdef", Network: "devnet", RPC: rpcClient, - Store: core.NewMemoryStore(), + Store: &sharedTestStore{MemoryStore: core.NewMemoryStore()}, }) if err != nil { t.Fatalf("new mpp failed: %v", err) @@ -1121,14 +1156,14 @@ func TestVerifyCredentialTransactionWithFeePayerSigner(t *testing.T) { feePayer := testutil.NewPrivateKey() clientSigner := testutil.NewPrivateKey() handler, err := New(Config{ - Recipient: recipient.PublicKey().String(), - Currency: "sol", - Decimals: 9, - Network: "localnet", - SecretKey: "test-secret-key-0123456789abcdef", - RPC: rpcClient, - Store: core.NewMemoryStore(), - FeePayerSigner: feePayer, + Recipient: recipient.PublicKey().String(), + Currency: "sol", + Decimals: 9, + Network: "localnet", + SecretKey: "test-secret-key-0123456789abcdef", + RPC: rpcClient, + AllowUnsafeMemoryStore: true, + FeePayerSigner: feePayer, }) if err != nil { t.Fatalf("new mpp failed: %v", err) @@ -1162,14 +1197,14 @@ func TestVerifyCredentialRejectsTamperedTransferBeforeBroadcast(t *testing.T) { feePayer := testutil.NewPrivateKey() clientSigner := testutil.NewPrivateKey() handler, err := New(Config{ - Recipient: recipient.PublicKey().String(), - Currency: "sol", - Decimals: 9, - Network: "localnet", - SecretKey: "test-secret-key-0123456789abcdef", - RPC: rpcClient, - Store: core.NewMemoryStore(), - FeePayerSigner: feePayer, + Recipient: recipient.PublicKey().String(), + Currency: "sol", + Decimals: 9, + Network: "localnet", + SecretKey: "test-secret-key-0123456789abcdef", + RPC: rpcClient, + AllowUnsafeMemoryStore: true, + FeePayerSigner: feePayer, }) if err != nil { t.Fatalf("new mpp failed: %v", err) @@ -1662,7 +1697,7 @@ func TestVerifyTransactionSimulateError(t *testing.T) { Network: "localnet", SecretKey: "test-secret-key-0123456789abcdef", RPC: wrapped, - Store: core.NewMemoryStore(), + Store: &sharedTestStore{MemoryStore: core.NewMemoryStore()}, }) if err != nil { t.Fatalf("new: %v", err) @@ -1697,13 +1732,13 @@ func TestVerifyTransactionSendError(t *testing.T) { wrapped := &rpcSendErrRPC{FakeRPC: rpcClient} recipient := testutil.NewPrivateKey() handler, err := New(Config{ - Recipient: recipient.PublicKey().String(), - Currency: "sol", - Decimals: 9, - Network: "localnet", - SecretKey: "test-secret-key-0123456789abcdef", - RPC: wrapped, - Store: core.NewMemoryStore(), + Recipient: recipient.PublicKey().String(), + Currency: "sol", + Decimals: 9, + Network: "localnet", + SecretKey: "test-secret-key-0123456789abcdef", + RPC: wrapped, + AllowUnsafeMemoryStore: true, }) if err != nil { t.Fatalf("new: %v", err) @@ -1738,13 +1773,13 @@ func TestVerifyOnChainTransactionNotFound(t *testing.T) { wrapped := &rpcGetTxErr{FakeRPC: rpcClient} recipient := testutil.NewPrivateKey() handler, err := New(Config{ - Recipient: recipient.PublicKey().String(), - Currency: "sol", - Decimals: 9, - Network: "localnet", - SecretKey: "test-secret-key-0123456789abcdef", - RPC: wrapped, - Store: core.NewMemoryStore(), + Recipient: recipient.PublicKey().String(), + Currency: "sol", + Decimals: 9, + Network: "localnet", + SecretKey: "test-secret-key-0123456789abcdef", + RPC: wrapped, + AllowUnsafeMemoryStore: true, }) if err != nil { t.Fatalf("new: %v", err) @@ -1769,6 +1804,8 @@ func TestVerifyOnChainTransactionNotFound(t *testing.T) { // errStore is a Store implementation that errors on PutIfAbsent. type errStore struct{} +func (errStore) IsShared() bool { return true } + func (errStore) PutIfAbsent(_ context.Context, _ string, _ any) (bool, error) { return false, errors.New("store down") } @@ -1815,13 +1852,13 @@ func TestVerifyTransactionMissingPrimarySignature(t *testing.T) { rpcClient := testutil.NewFakeRPC() recipient := testutil.NewPrivateKey() handler, err := New(Config{ - Recipient: recipient.PublicKey().String(), - Currency: "sol", - Decimals: 9, - Network: "localnet", - SecretKey: "test-secret-key-0123456789abcdef", - RPC: rpcClient, - Store: core.NewMemoryStore(), + Recipient: recipient.PublicKey().String(), + Currency: "sol", + Decimals: 9, + Network: "localnet", + SecretKey: "test-secret-key-0123456789abcdef", + RPC: rpcClient, + AllowUnsafeMemoryStore: true, }) if err != nil { t.Fatalf("new: %v", err) @@ -1859,7 +1896,7 @@ func TestVerifyTransactionWrongNetworkBlockhash(t *testing.T) { Network: "mainnet", SecretKey: "test-secret-key-0123456789abcdef", RPC: rpcClient, - Store: core.NewMemoryStore(), + Store: &sharedTestStore{MemoryStore: core.NewMemoryStore()}, }) if err != nil { t.Fatalf("new: %v", err) diff --git a/harness/go-server/main.go b/harness/go-server/main.go index fc7634955..789cf37ea 100644 --- a/harness/go-server/main.go +++ b/harness/go-server/main.go @@ -246,6 +246,9 @@ func mountMPP(mux *http.ServeMux, resourcePath, settlementHeader string) error { Realm: "go-paykit", FeePayerSigner: walletSignerFor(feePayer), RPC: rpcClient, + // The cross-SDK harness is a single-process test server. Production + // callers must inject a shared replay store instead. + AllowUnsafeMemoryStore: true, }) if err != nil { return fmt.Errorf("create MPP server: %w", err) From bd33ac6782081a93ded98dcc8ce062453c43503c Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:30:13 +0300 Subject: [PATCH 2/9] fix(php): require durable shared replay stores for MPP charge adapter Adds a ReplayStoreValidator that fails closed unless an injected store affirmatively declares a durable/shared replay capability via DurableStore or ReplayStoreCapability (and every declared capability method returns true). The MPP Adapter now enforces it: an injected store must pass the validator, allowUnsafeMemoryStore only permits an internally created process-local MemoryStore, that opt-in is rejected on mainnet, and a missing store fails closed on every network including localnet. Threads allowUnsafeMemoryStore through MppConfig, the middleware, and the Laravel/Symfony wiring, and updates the cross-SDK PHP harness to opt into the process-local store explicitly. --- harness/php-server/server.php | 2 +- .../Laravel/PayKitServiceProvider.php | 13 + .../Laravel/RequirePaymentMiddleware.php | 8 +- php/src/Frameworks/Laravel/config/paykit.php | 3 + .../DependencyInjection/PayKitExtension.php | 19 ++ .../EventListener/RequirePaymentListener.php | 12 +- php/src/Middleware/RequirePayment.php | 33 ++- php/src/Protocols/Mpp/Adapter.php | 49 ++-- php/src/Protocols/Mpp/MppConfig.php | 2 + .../Mpp/Server/SolanaChargeHandler.php | 44 ++-- php/src/Store/DurableStore.php | 19 ++ php/src/Store/ReplayStoreValidator.php | 32 +++ .../Symfony/PayKitExtensionTest.php | 91 +++++++ php/tests/Middleware/RequirePaymentTest.php | 36 +++ php/tests/Protocols/Mpp/AdapterTest.php | 234 ++++++++++++++++-- .../SolanaChargeHandlerInternalsTest.php | 1 + .../Mpp/Server/SolanaChargeHandlerTest.php | 165 +++++++++++- 17 files changed, 689 insertions(+), 74 deletions(-) create mode 100644 php/src/Store/DurableStore.php create mode 100644 php/src/Store/ReplayStoreValidator.php create mode 100644 php/tests/Frameworks/Symfony/PayKitExtensionTest.php diff --git a/harness/php-server/server.php b/harness/php-server/server.php index 959c9d54a..8972db8e2 100644 --- a/harness/php-server/server.php +++ b/harness/php-server/server.php @@ -163,7 +163,7 @@ function secret_key_from_json(string $raw): string feePayer: $feePayer, network: $networkRaw, settlementHeader: $settlementHeader, - replayStore: new FileStore(sys_get_temp_dir() . '/mpp-php-harness-replay-' . getmypid()), + allowUnsafeMemoryStore: true, // Audit #5 made push-mode credentials opt-in (default off). The // charge-push conformance scenario drives this server in push mode, // so enable acceptance only when the harness asks for it. diff --git a/php/src/Frameworks/Laravel/PayKitServiceProvider.php b/php/src/Frameworks/Laravel/PayKitServiceProvider.php index 5f11c5f33..764d4ca81 100644 --- a/php/src/Frameworks/Laravel/PayKitServiceProvider.php +++ b/php/src/Frameworks/Laravel/PayKitServiceProvider.php @@ -15,6 +15,8 @@ use PayKit\Pricing; use PayKit\Protocol; use PayKit\Protocols\Mpp\MppConfig; +use PayKit\Protocols\Mpp\Adapter as MppAdapter; +use PayKit\Store\Store; use PayKit\Protocols\X402\X402Config; use PayKit\Signer; use PayKit\PayCore\Stablecoin; @@ -32,6 +34,8 @@ */ final class PayKitServiceProvider extends ServiceProvider { + public const MPP_REPLAY_STORE = 'paykit.mpp_replay_store'; + public function register(): void { $this->mergeConfigFrom(__DIR__ . '/config/paykit.php', 'paykit'); @@ -41,6 +45,14 @@ public function register(): void $cfg = $app['config']->get('paykit', []); return new PayKit(self::buildConfig($cfg)); }); + $this->app->singleton(MppAdapter::class, function (Application $app): MppAdapter { + $client = $app->make(PayKit::class); + $store = $app->bound(self::MPP_REPLAY_STORE) ? $app->make(self::MPP_REPLAY_STORE) : null; + if ($store !== null && !$store instanceof Store) { + throw new \LogicException(self::MPP_REPLAY_STORE . ' must implement ' . Store::class); + } + return new MppAdapter($client->config, $store); + }); } public function boot(Router $router): void @@ -83,6 +95,7 @@ public static function buildConfig(array $cfg): Config ? (string) $cfg['mpp_challenge_binding_secret'] : null, expiresIn: MppConfig::resolveExpiresIn($cfg['mpp']['expires_in'] ?? null), + allowUnsafeMemoryStore: (bool) ($cfg['mpp']['allow_unsafe_memory_store'] ?? false), ); $x402 = new X402Config( facilitatorUrl: isset($cfg['x402_facilitator_url']) && $cfg['x402_facilitator_url'] !== '' diff --git a/php/src/Frameworks/Laravel/RequirePaymentMiddleware.php b/php/src/Frameworks/Laravel/RequirePaymentMiddleware.php index 5553678d4..90ad6ed51 100644 --- a/php/src/Frameworks/Laravel/RequirePaymentMiddleware.php +++ b/php/src/Frameworks/Laravel/RequirePaymentMiddleware.php @@ -13,6 +13,7 @@ use PayKit\PayCore\HttpFactory; use PayKit\Payment; use PayKit\Pricing; +use PayKit\Protocols\Mpp\Adapter as MppAdapter; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\StreamFactoryInterface; use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; @@ -81,7 +82,12 @@ public function handle(\Psr\Http\Message\ServerRequestInterface $request): \Psr\ } }; - $mw = new RequirePayment($this->client, $gateRef, $pricing); + $mw = new RequirePayment( + client: $this->client, + gateRef: $gateRef, + pricing: $pricing, + mppFactory: fn (): MppAdapter => $this->container->make(MppAdapter::class), + ); $psrResponse = $mw->process($psrRequest, $handler); if ($psrResponse->getStatusCode() === 402) { diff --git a/php/src/Frameworks/Laravel/config/paykit.php b/php/src/Frameworks/Laravel/config/paykit.php index 4abae2b47..a640053b9 100644 --- a/php/src/Frameworks/Laravel/config/paykit.php +++ b/php/src/Frameworks/Laravel/config/paykit.php @@ -19,6 +19,9 @@ // PAY_KIT_MPP_REALM only when you want an explicit, app-specific realm. 'realm' => env('PAY_KIT_MPP_REALM'), 'expires_in' => 120, + // Development-only. Production must bind paykit.mpp_replay_store to + // an atomic DurableStore shared by every application worker. + 'allow_unsafe_memory_store' => env('PAY_KIT_MPP_ALLOW_UNSAFE_MEMORY_STORE', false), ], 'preflight' => env('PAY_KIT_PREFLIGHT', true), ]; diff --git a/php/src/Frameworks/Symfony/DependencyInjection/PayKitExtension.php b/php/src/Frameworks/Symfony/DependencyInjection/PayKitExtension.php index 69e2745cd..fa6ec7250 100644 --- a/php/src/Frameworks/Symfony/DependencyInjection/PayKitExtension.php +++ b/php/src/Frameworks/Symfony/DependencyInjection/PayKitExtension.php @@ -10,12 +10,14 @@ use PayKit\Operator; use PayKit\Protocol; use PayKit\Protocols\Mpp\MppConfig; +use PayKit\Protocols\Mpp\Adapter as MppAdapter; use PayKit\Protocols\X402\X402Config; use PayKit\Signer; use PayKit\PayCore\Stablecoin; use PayKit\Frameworks\Symfony\EventListener\RequirePaymentListener; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; +use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\Extension; use Symfony\Component\DependencyInjection\Reference; @@ -36,11 +38,25 @@ public function load(array $configs, ContainerBuilder $container): void $client = new PayKit($payKitConfig); $container->set(PayKit::class, $client); + $mppFactory = null; + if (in_array(Protocol::Mpp, $payKitConfig->accept, true)) { + $storeService = $config['mpp_replay_store_service'] ?? null; + $definition = $container->register(MppAdapter::class) + ->setArgument('$config', $payKitConfig) + ->setPublic(true); + if (is_string($storeService) && $storeService !== '') { + $definition->setArgument('$replayStore', new Reference($storeService)); + } + $mppFactory = new ServiceClosureArgument(new Reference(MppAdapter::class)); + } + $listener = $container->register(RequirePaymentListener::class) ->setArgument('$client', new Reference(PayKit::class)) ->setArgument('$pricing', null) ->setArgument('$psrFactory', new Reference('paykit.psr_http_factory')) ->setArgument('$httpFactory', new Reference('paykit.http_foundation_factory')) + ->setArgument('$mpp', null) + ->setArgument('$mppFactory', $mppFactory) ->setAutowired(true) ->setPublic(true); $listener->addTag('kernel.event_listener', [ @@ -78,6 +94,8 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->scalarNode('x402_facilitator_url')->defaultNull()->end() ->scalarNode('mpp_challenge_binding_secret')->defaultNull()->end() + ->scalarNode('mpp_replay_store_service')->defaultNull()->end() + ->booleanNode('mpp_allow_unsafe_memory_store')->defaultFalse()->end() ->booleanNode('preflight')->defaultTrue()->end() ->end(); return $tree; @@ -135,6 +153,7 @@ public static function buildConfig(array $cfg): Config challengeBindingSecret: isset($cfg['mpp_challenge_binding_secret']) && $cfg['mpp_challenge_binding_secret'] !== '' ? (string) $cfg['mpp_challenge_binding_secret'] : null, + allowUnsafeMemoryStore: (bool) ($cfg['mpp_allow_unsafe_memory_store'] ?? false), ), preflight: (bool) ($cfg['preflight'] ?? true), ); diff --git a/php/src/Frameworks/Symfony/EventListener/RequirePaymentListener.php b/php/src/Frameworks/Symfony/EventListener/RequirePaymentListener.php index 6700d8f92..6a5a8cddd 100644 --- a/php/src/Frameworks/Symfony/EventListener/RequirePaymentListener.php +++ b/php/src/Frameworks/Symfony/EventListener/RequirePaymentListener.php @@ -4,9 +4,11 @@ namespace PayKit\Frameworks\Symfony\EventListener; +use Closure; use PayKit\PayKit; use PayKit\Middleware\RequirePayment as PsrRequirePayment; use PayKit\Pricing; +use PayKit\Protocols\Mpp\Adapter as MppAdapter; use PayKit\Frameworks\Symfony\Attribute\RequirePayment; use ReflectionMethod; use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; @@ -26,6 +28,8 @@ public function __construct( private readonly ?Pricing $pricing, private readonly PsrHttpFactory $psrFactory, private readonly HttpFoundationFactory $httpFactory, + private readonly ?MppAdapter $mpp = null, + private readonly ?Closure $mppFactory = null, ) { } @@ -37,7 +41,13 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo return; } $psrRequest = $this->psrFactory->createRequest($event->getRequest()); - $middleware = new PsrRequirePayment($this->client, $attribute->gate, $this->pricing); + $middleware = new PsrRequirePayment( + client: $this->client, + gateRef: $attribute->gate, + pricing: $this->pricing, + mpp: $this->mpp, + mppFactory: $this->mppFactory, + ); $psrResponse = $middleware->process( $psrRequest, new class () implements \Psr\Http\Server\RequestHandlerInterface { diff --git a/php/src/Middleware/RequirePayment.php b/php/src/Middleware/RequirePayment.php index 394b4d0d0..61dd479d6 100644 --- a/php/src/Middleware/RequirePayment.php +++ b/php/src/Middleware/RequirePayment.php @@ -39,11 +39,13 @@ */ final class RequirePayment implements MiddlewareInterface { - private MppAdapter $mpp; + private ?MppAdapter $mpp; private ?X402Adapter $x402; /** * @param Gate|string|Closure(ServerRequestInterface):Gate $gateRef + * @param ?Closure():MppAdapter $mppFactory Lazy factory for framework-owned + * MPP adapters, invoked only when the resolved gate accepts MPP. */ public function __construct( private readonly PayKit $client, @@ -51,8 +53,9 @@ public function __construct( private readonly ?Pricing $pricing = null, ?MppAdapter $mpp = null, ?X402Adapter $x402 = null, + private readonly ?Closure $mppFactory = null, ) { - $this->mpp = $mpp ?? new MppAdapter($client->config); + $this->mpp = $mpp; // Auto-wire the X402 adapter when the client's accept list // includes Protocol::X402. Callers can still pass an explicit // adapter to override (e.g. with an offline blockhash provider). @@ -119,7 +122,10 @@ private function pickAdapter(Gate $gate, ServerRequestInterface $request): ?obje return $this->x402; } if ($protocol === Protocol::Mpp && $auth !== '' && stripos($auth, 'payment ') === 0) { - return $this->mpp; + $mpp = $this->mppFor($gate); + if ($mpp !== null) { + return $mpp; + } } } return null; @@ -135,9 +141,10 @@ private function build402(Gate $gate, ServerRequestInterface $request): Response $accepts[] = $this->x402->acceptsEntry($gate, $request); $headers = array_merge($headers, $this->x402->challengeHeaders($gate, $request)); } - if (in_array(Protocol::Mpp, $accept, true)) { - $accepts[] = $this->mpp->acceptsEntry($gate, $request); - $headers = array_merge($headers, $this->mpp->challengeHeaders($gate, $request)); + $mpp = $this->mppFor($gate); + if ($mpp !== null) { + $accepts[] = $mpp->acceptsEntry($gate, $request); + $headers = array_merge($headers, $mpp->challengeHeaders($gate, $request)); } $body = [ @@ -161,4 +168,18 @@ private function build402(Gate $gate, ServerRequestInterface $request): Response $stream = HttpFactory::streamFactory()->createStream(json_encode($body, JSON_THROW_ON_ERROR)); return $resp->withBody($stream); } + + private function mppFor(Gate $gate): ?MppAdapter + { + $accept = $gate->accept ?? $this->client->config->accept; + if (!in_array(Protocol::Mpp, $accept, true)) { + return null; + } + if ($this->mpp === null) { + $this->mpp = $this->mppFactory !== null + ? ($this->mppFactory)() + : new MppAdapter($this->client->config); + } + return $this->mpp; + } } diff --git a/php/src/Protocols/Mpp/Adapter.php b/php/src/Protocols/Mpp/Adapter.php index 3d8754e04..7f05b806e 100644 --- a/php/src/Protocols/Mpp/Adapter.php +++ b/php/src/Protocols/Mpp/Adapter.php @@ -5,10 +5,10 @@ namespace PayKit\Protocols\Mpp; use PayKit\Config; +use PayKit\Exception\ConfigurationException; use PayKit\Exception\InvalidProofException; use PayKit\Gate; use PayKit\Payment; -use PayKit\PayCore\Network; use PayKit\Price; use PayKit\Protocol; use PayKit\Protocols\Mpp\Intent\ChargeRequest; @@ -16,8 +16,9 @@ use PayKit\Protocols\Mpp\Server\ChargeSettlement; use PayKit\Protocols\Mpp\Server\PaymentRequiredResponse; use PayKit\Protocols\Mpp\Server\SolanaChargeHandler; +use PayKit\PayCore\Network; use PayKit\Store\MemoryStore; -use PayKit\Store\ReplayStoreCapability; +use PayKit\Store\ReplayStoreValidator; use PayKit\Store\Store; use Psr\Http\Message\ServerRequestInterface; use SolanaPhpSdk\Keypair\Keypair; @@ -42,38 +43,41 @@ final class Adapter /** * @param ?Store $replayStore Replay-protection store shared across every - * {@see SolanaChargeHandler} this adapter builds. When null, the - * MPP config's replayStore is used. Localnet may fall back to an - * in-process {@see MemoryStore}; every other network requires a - * store that explicitly declares durable shared replay protection. + * {@see SolanaChargeHandler} this adapter builds. Null falls back + * to a warned in-process {@see MemoryStore} only when the explicit + * unsafe development opt-in is enabled. Otherwise a shared store + * with an affirmative durable replay capability is required. */ public function __construct( private readonly Config $config, ?Store $replayStore = null, ) { + if ($config->network === Network::SolanaMainnet && $config->mpp->allowUnsafeMemoryStore) { + throw new ConfigurationException( + 'pay_kit: allowUnsafeMemoryStore is forbidden on mainnet; inject an atomic shared replay store', + ); + } $replayStore ??= $config->mpp->replayStore; - if ($replayStore === null) { - if ($config->network !== Network::SolanaLocalnet) { - throw new \InvalidArgumentException( - 'pay_kit: MPP replayStore is required outside localnet; ' - . 'inject a durable shared Store (for example Redis or Postgres)', - ); - } + if ($replayStore === null && $config->mpp->allowUnsafeMemoryStore) { if (function_exists('error_log')) { error_log( - 'pay_kit: WARN: mpp adapter using in-memory replay store; ' - . 'allowed only on localnet.', + 'pay_kit: WARN: MPP explicitly enabled a process-local replay store; ' + . 'markers are lost on restart and are not shared across workers.', ); } $replayStore = new MemoryStore(); } - if ( - $config->network !== Network::SolanaLocalnet - && (!$replayStore instanceof ReplayStoreCapability - || !$replayStore->providesDurableSharedReplayProtection()) - ) { - throw new \InvalidArgumentException( - 'pay_kit: MPP replayStore must explicitly declare durable shared replay protection outside localnet', + if ($replayStore === null) { + throw new ConfigurationException( + 'pay_kit: MPP requires an injected atomic durable/shared replay store; ' + . 'allowUnsafeMemoryStore is development-only', + ); + } + $unsafeMemoryStore = $config->mpp->allowUnsafeMemoryStore && $replayStore instanceof MemoryStore; + if (!$unsafeMemoryStore && !ReplayStoreValidator::isDurableShared($replayStore)) { + throw new ConfigurationException( + 'pay_kit: MPP replay store does not affirm durable/shared capability; ' + . 'implement DurableStore::isDurable() or ReplayStoreCapability and return true', ); } $this->replayStore = $replayStore; @@ -248,6 +252,7 @@ private function serverFor(Gate $gate): array network: $this->config->network->mintsLabel(), replayStore: $this->replayStore, acceptPushMode: $this->config->mpp->acceptPushMode, + allowUnsafeMemoryStore: $this->config->mpp->allowUnsafeMemoryStore, ); $this->handlerCache[$key] = [$charges, $handler]; return $this->handlerCache[$key]; diff --git a/php/src/Protocols/Mpp/MppConfig.php b/php/src/Protocols/Mpp/MppConfig.php index 057e3bca8..b92b99741 100644 --- a/php/src/Protocols/Mpp/MppConfig.php +++ b/php/src/Protocols/Mpp/MppConfig.php @@ -39,6 +39,7 @@ public function __construct( public int $expiresIn = 120, public bool $acceptPushMode = false, public ?Store $replayStore = null, + public bool $allowUnsafeMemoryStore = false, ) { if ($expiresIn < 0) { throw new ConfigurationException( @@ -63,6 +64,7 @@ public function withChallengeBindingSecret(string $secret): self $this->expiresIn, $this->acceptPushMode, $this->replayStore, + $this->allowUnsafeMemoryStore, ); } diff --git a/php/src/Protocols/Mpp/Server/SolanaChargeHandler.php b/php/src/Protocols/Mpp/Server/SolanaChargeHandler.php index 541150be1..3cf284dd5 100644 --- a/php/src/Protocols/Mpp/Server/SolanaChargeHandler.php +++ b/php/src/Protocols/Mpp/Server/SolanaChargeHandler.php @@ -5,6 +5,7 @@ namespace PayKit\Protocols\Mpp\Server; use InvalidArgumentException; +use PayKit\Exception\ConfigurationException; use RuntimeException; use Throwable; use PayKit\PayCore\Rpc\RpcGateway; @@ -12,7 +13,7 @@ use PayKit\Protocols\Mpp\Core\Credential; use PayKit\Protocols\Mpp\Intent\ChargeRequest; use PayKit\Store\MemoryStore; -use PayKit\Store\ReplayStoreCapability; +use PayKit\Store\ReplayStoreValidator; use PayKit\Store\Store; use SolanaPhpSdk\Keypair\Keypair; use SolanaPhpSdk\Rpc\RpcClient; @@ -67,9 +68,10 @@ final class SolanaChargeHandler * `getSignatureStatuses` before giving up. 40 attempts at the * default delay = 10 seconds. * @param int $confirmationDelayMicros Sleep between polls in microseconds. - * @param ?Store $replayStore Replay-protection store. Localnet defaults to - * an in-process {@see MemoryStore}; all other networks require a - * store that explicitly declares durable shared replay protection. + * @param ?Store $replayStore Atomic shared replay-protection store. + * Process-local memory is available only with the separate unsafe + * development flag; otherwise the store must affirm durable/shared + * replay protection. */ private readonly RpcGateway $rpc; @@ -85,30 +87,40 @@ public function __construct( private readonly int $confirmationDelayMicros = 250_000, ?Store $replayStore = null, bool $acceptPushMode = false, + bool $allowUnsafeMemoryStore = false, ) { $this->rpc = $rpc instanceof RpcGateway ? $rpc : new SolanaRpcGateway($rpc); + if ($allowUnsafeMemoryStore && in_array($network, ['mainnet', 'mainnet-beta', 'solana_mainnet'], true)) { + throw new ConfigurationException( + 'pay_kit: allowUnsafeMemoryStore is forbidden on mainnet; inject an atomic shared replay store', + ); + } // Push mode (§13.5) is off by default; the default verifier is built // with the route's opt-in so a non-opting route rejects push-mode // credentials at verification (audit #5). $this->verifier = $verifier ?? new SolanaChargeTransactionVerifier(acceptPushMode: $acceptPushMode); $this->transactionVerifier = $transactionVerifier ?? ($this->verifier instanceof TransactionPayloadVerifier ? $this->verifier : new SolanaChargeTransactionVerifier(acceptPushMode: $acceptPushMode)); - if ($replayStore === null) { - if ($network !== 'localnet') { - throw new InvalidArgumentException( - 'pay_kit: MPP replayStore is required outside localnet; ' - . 'inject a durable shared Store (for example Redis or Postgres)', + if ($replayStore === null && $allowUnsafeMemoryStore) { + if (function_exists('error_log')) { + error_log( + 'pay_kit: WARN: MPP explicitly enabled a process-local replay store; ' + . 'markers are lost on restart and are not shared across workers.', ); } $replayStore = new MemoryStore(); } - if ( - $network !== 'localnet' - && (!$replayStore instanceof ReplayStoreCapability - || !$replayStore->providesDurableSharedReplayProtection()) - ) { - throw new InvalidArgumentException( - 'pay_kit: MPP replayStore must explicitly declare durable shared replay protection outside localnet', + if ($replayStore === null) { + throw new ConfigurationException( + 'pay_kit: MPP requires an injected atomic durable/shared replay store; ' + . 'allowUnsafeMemoryStore is development-only', + ); + } + $unsafeMemoryStore = $allowUnsafeMemoryStore && $replayStore instanceof MemoryStore; + if (!$unsafeMemoryStore && !ReplayStoreValidator::isDurableShared($replayStore)) { + throw new ConfigurationException( + 'pay_kit: MPP replay store does not affirm durable/shared capability; ' + . 'implement DurableStore::isDurable() or ReplayStoreCapability and return true', ); } $this->replayStore = $replayStore; diff --git a/php/src/Store/DurableStore.php b/php/src/Store/DurableStore.php new file mode 100644 index 000000000..6471fdbb3 --- /dev/null +++ b/php/src/Store/DurableStore.php @@ -0,0 +1,19 @@ +isDurable()) { + return false; + } + } + + if ($store instanceof ReplayStoreCapability) { + $declared = true; + if (!$store->providesDurableSharedReplayProtection()) { + return false; + } + } + + return $declared; + } +} diff --git a/php/tests/Frameworks/Symfony/PayKitExtensionTest.php b/php/tests/Frameworks/Symfony/PayKitExtensionTest.php new file mode 100644 index 000000000..02855c163 --- /dev/null +++ b/php/tests/Frameworks/Symfony/PayKitExtensionTest.php @@ -0,0 +1,91 @@ + */ + private array $values = []; + + public function putIfAbsent(string $key, mixed $value): bool + { + if (array_key_exists($key, $this->values)) { + return false; + } + $this->values[$key] = $value; + return true; + } + + public function providesDurableSharedReplayProtection(): bool + { + return true; + } +} + +final class PayKitExtensionTest extends TestCase +{ + public function testProductionMppBootWiresConfiguredReplayStoreService(): void + { + $container = new ContainerBuilder(); + $container->register('app.mpp_replay_store', SymfonySharedReplayStore::class)->setPublic(true); + $container->register('paykit.psr_http_factory', \stdClass::class)->setPublic(true); + $container->register('paykit.http_foundation_factory', \stdClass::class)->setPublic(true); + (new PayKitExtension())->load([[ + 'network' => 'solana_devnet', + 'accept' => ['mpp'], + 'mpp_challenge_binding_secret' => 'test-secret-0123456789abcdef-0123456789', + 'mpp_replay_store_service' => 'app.mpp_replay_store', + 'preflight' => false, + ]], $container); + + $store = $container->getDefinition(Adapter::class)->getArgument('$replayStore'); + self::assertInstanceOf(Reference::class, $store); + self::assertSame('app.mpp_replay_store', (string) $store); + $mppFactory = $container->getDefinition(RequirePaymentListener::class)->getArgument('$mppFactory'); + self::assertInstanceOf(ServiceClosureArgument::class, $mppFactory); + self::assertInstanceOf(Reference::class, $mppFactory->getValues()[0]); + self::assertSame(Adapter::class, (string) $mppFactory->getValues()[0]); + self::assertNull($container->getDefinition(RequirePaymentListener::class)->getArgument('$mpp')); + $container->compile(); + self::assertInstanceOf(Adapter::class, $container->get(Adapter::class)); + } + + public function testListenerRetainsLegacyAdapterArgumentBeforeLazyFactory(): void + { + $parameters = (new \ReflectionMethod(RequirePaymentListener::class, '__construct'))->getParameters(); + + self::assertSame('mpp', $parameters[4]->getName()); + self::assertTrue($parameters[4]->allowsNull()); + self::assertInstanceOf(\ReflectionNamedType::class, $parameters[4]->getType()); + self::assertSame(Adapter::class, $parameters[4]->getType()->getName()); + + self::assertSame('mppFactory', $parameters[5]->getName()); + self::assertTrue($parameters[5]->allowsNull()); + self::assertInstanceOf(\ReflectionNamedType::class, $parameters[5]->getType()); + self::assertSame('Closure', $parameters[5]->getType()->getName()); + } + + public function testX402OnlyBootDoesNotRegisterMppAdapter(): void + { + $container = new ContainerBuilder(); + (new PayKitExtension())->load([[ + 'network' => 'solana_devnet', + 'accept' => ['x402'], + 'preflight' => false, + ]], $container); + + self::assertFalse($container->hasDefinition(Adapter::class)); + } +} diff --git a/php/tests/Middleware/RequirePaymentTest.php b/php/tests/Middleware/RequirePaymentTest.php index c124f4ef0..5ee765417 100644 --- a/php/tests/Middleware/RequirePaymentTest.php +++ b/php/tests/Middleware/RequirePaymentTest.php @@ -31,6 +31,42 @@ final class RequirePaymentTest extends TestCase private PayKit $client; private Psr17Factory $factory; + public function testX402OnlyConstructionDoesNotRequireMppReplayStore(): void + { + $client = new PayKit(new Config( + network: Network::SolanaDevnet, + accept: [Protocol::X402], + operator: new Operator(recipient: Signer::generate()->pubkey(), signer: Signer::generate()), + preflight: false, + )); + $middleware = new RequirePayment($client, new Gate(amount: Price::usd('0.10'))); + self::assertInstanceOf(RequirePayment::class, $middleware); + } + + public function testX402OnlyGateDoesNotConstructMppReplayStore(): void + { + $client = new PayKit(new Config( + network: Network::SolanaDevnet, + accept: [Protocol::X402, Protocol::Mpp], + operator: new Operator(recipient: Signer::generate()->pubkey(), signer: Signer::generate()), + preflight: false, + mpp: new MppConfig( + challengeBindingSecret: 'unit-test-secret-0123456789abcdef-01', + ), + )); + $middleware = new RequirePayment( + $client, + new Gate(amount: Price::usd('0.10'), accept: [Protocol::X402]), + ); + + $response = $middleware->process( + $this->factory->createServerRequest('GET', '/paid'), + $this->nextHandler(), + ); + + self::assertSame(402, $response->getStatusCode()); + } + protected function setUp(): void { $this->client = new PayKit(new Config( diff --git a/php/tests/Protocols/Mpp/AdapterTest.php b/php/tests/Protocols/Mpp/AdapterTest.php index cbffd7b1d..bb29a71ab 100644 --- a/php/tests/Protocols/Mpp/AdapterTest.php +++ b/php/tests/Protocols/Mpp/AdapterTest.php @@ -6,6 +6,7 @@ use Nyholm\Psr7\Factory\Psr17Factory; use PayKit\Config; +use PayKit\Exception\ConfigurationException; use PayKit\PayCore\Currency; use PayKit\Gate; use PayKit\PayCore\Network; @@ -17,17 +18,38 @@ use PayKit\Protocols\Mpp\MppConfig; use PayKit\Signer; use PayKit\PayCore\Stablecoin; +use PayKit\Store\DurableStore; use PayKit\Store\MemoryStore; use PayKit\Store\ReplayStoreCapability; use PayKit\Store\Store; use PHPUnit\Framework\TestCase; +final class SharedAdapterReplayStore implements Store, ReplayStoreCapability +{ + /** @var array */ + private array $values = []; + + public function putIfAbsent(string $key, mixed $value): bool + { + if (array_key_exists($key, $this->values)) { + return false; + } + $this->values[$key] = $value; + return true; + } + + public function providesDurableSharedReplayProtection(): bool + { + return true; + } +} + final class AdapterTest extends TestCase { - private function makeConfig(): Config + private function makeConfig(Network $network = Network::SolanaDevnet): Config { return new Config( - network: Network::SolanaDevnet, + network: $network, operator: new Operator( recipient: Signer::generate()->pubkey(), signer: Signer::generate(), @@ -49,9 +71,26 @@ public function testNonLocalnetRejectsMissingReplayStore(): void mpp: new MppConfig(challengeBindingSecret: 'unit-test-secret-0123456789abcdef-01'), ); - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('replayStore is required outside localnet'); + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('atomic durable/shared replay store'); + + new Adapter($config); + } + + public function testMainnetRejectsUnsafeMemoryStoreOverride(): void + { + $config = new Config( + network: Network::SolanaMainnet, + operator: new Operator(signer: Signer::generate()), + preflight: false, + mpp: new MppConfig( + challengeBindingSecret: 'unit-test-secret-0123456789abcdef-01', + allowUnsafeMemoryStore: true, + ), + ); + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('forbidden on mainnet'); new Adapter($config); } @@ -66,16 +105,131 @@ public function testNonLocalnetRejectsStoreWithoutDurableSharedCapability(): voi ), ); - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('must explicitly declare durable shared replay protection'); + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('does not affirm durable/shared capability'); + + new Adapter($config); + } + + private function adapter(Config $config): Adapter + { + return new Adapter($config, new SharedAdapterReplayStore()); + } + + public function testNonLocalnetRequiresInjectedReplayStore(): void + { + $config = new Config( + network: Network::SolanaDevnet, + preflight: false, + mpp: new MppConfig( + challengeBindingSecret: 'unit-test-secret-0123456789abcdef-01', + ), + ); + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('atomic durable/shared replay store'); new Adapter($config); } + public function testNonLocalnetRejectsMemoryStore(): void + { + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('does not affirm durable/shared capability'); + new Adapter($this->makeConfig(), new MemoryStore()); + } + + public function testNonLocalnetAcceptsDurableStoreContract(): void + { + self::assertInstanceOf( + Adapter::class, + new Adapter($this->makeConfig(), new AdapterDurableStore()), + ); + } + + public function testNonLocalnetRejectsDurableStoreThatDoesNotAffirmDurability(): void + { + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('does not affirm durable/shared capability'); + new Adapter($this->makeConfig(), new AdapterDurableStore(false)); + } + + public function testNonLocalnetRejectsConflictingReplayStoreDeclarations(): void + { + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('does not affirm durable/shared capability'); + new Adapter($this->makeConfig(), new AdapterConflictingReplayStore()); + } + + public function testLocalnetStillRequiresExplicitUnsafeOptIn(): void + { + $config = new Config( + network: Network::SolanaLocalnet, + preflight: false, + mpp: new MppConfig( + challengeBindingSecret: 'unit-test-secret-0123456789abcdef-01', + ), + ); + + $this->expectException(ConfigurationException::class); + new Adapter($config); + } + + public function testExplicitUnsafeDevelopmentMemoryStoreIsAllowed(): void + { + $config = $this->makeConfig(Network::SolanaLocalnet); + $config = new Config( + network: $config->network, + operator: $config->operator, + preflight: false, + mpp: new MppConfig( + challengeBindingSecret: 'unit-test-secret-0123456789abcdef-01', + allowUnsafeMemoryStore: true, + ), + ); + $adapter = new Adapter($config); + self::assertInstanceOf(Adapter::class, $adapter); + } + + public function testUnsafeOptInDoesNotAuthorizeArbitraryCustomStore(): void + { + $config = new Config( + network: Network::SolanaLocalnet, + operator: new Operator(signer: Signer::generate()), + preflight: false, + mpp: new MppConfig( + challengeBindingSecret: 'unit-test-secret-0123456789abcdef-01', + allowUnsafeMemoryStore: true, + ), + ); + + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('does not affirm durable/shared capability'); + new Adapter($config, new AdapterDurableStore(false)); + } + + public function testNonLocalnetAllowsInjectedStore(): void + { + self::assertInstanceOf(Adapter::class, $this->adapter($this->makeConfig())); + } + + public function testNonLocalnetUsesStoreFromMppConfig(): void + { + $config = new Config( + network: Network::SolanaDevnet, + operator: new Operator(signer: Signer::generate()), + preflight: false, + mpp: new MppConfig( + challengeBindingSecret: 'unit-test-secret-0123456789abcdef-01', + replayStore: new SharedAdapterReplayStore(), + ), + ); + self::assertInstanceOf(Adapter::class, new Adapter($config)); + } + public function testAcceptsEntryShape(): void { $cfg = $this->makeConfig(); - $adapter = new Adapter($cfg); + $adapter = $this->adapter($cfg); $gate = new Gate(amount: Price::usd('0.10')); $req = (new Psr17Factory())->createServerRequest('GET', '/paid'); $entry = $adapter->acceptsEntry($gate, $req); @@ -89,7 +243,7 @@ public function testAcceptsEntryShape(): void public function testAcceptsEntryIncludesSplitsForFeeBearingGate(): void { $cfg = $this->makeConfig(); - $adapter = new Adapter($cfg); + $adapter = $this->adapter($cfg); $platform = Signer::generate()->pubkey(); $gate = new Gate( amount: Price::usd('10.00'), @@ -111,7 +265,7 @@ public function testChargeRequestAmountIsGateTotalForFeeOnTop(): void // share as amount - sum(splits), so the merchant was undercharged the // fee. The expected (and issued) charge request must use gate->total(). $cfg = $this->makeConfig(); - $adapter = new Adapter($cfg); + $adapter = $this->adapter($cfg); $platform = Signer::generate()->pubkey(); $gate = new Gate( amount: Price::usd('10.00'), @@ -135,7 +289,7 @@ public function testChargeRequestAmountUnchangedForFeeWithin(): void { // fee-within gates keep total == base, so the total switch is a no-op. $cfg = $this->makeConfig(); - $adapter = new Adapter($cfg); + $adapter = $this->adapter($cfg); $platform = Signer::generate()->pubkey(); $gate = new Gate( amount: Price::usd('10.00'), @@ -152,7 +306,7 @@ public function testChargeRequestAmountUnchangedForFeeWithin(): void public function testChallengeHeadersHaveWwwAuthenticate(): void { $cfg = $this->makeConfig(); - $adapter = new Adapter($cfg); + $adapter = $this->adapter($cfg); $gate = new Gate(amount: Price::usd('0.10')); $req = (new Psr17Factory())->createServerRequest('GET', '/paid'); $headers = $adapter->challengeHeaders($gate, $req); @@ -163,7 +317,7 @@ public function testChallengeHeadersHaveWwwAuthenticate(): void public function testVerifyAndSettleWithoutAuthorizationRaises(): void { $cfg = $this->makeConfig(); - $adapter = new Adapter($cfg); + $adapter = $this->adapter($cfg); $gate = new Gate(amount: Price::usd('0.10')); $req = (new Psr17Factory())->createServerRequest('GET', '/paid'); $this->expectException(\PayKit\Exception\InvalidProofException::class); @@ -189,7 +343,7 @@ private function serverFor(Adapter $adapter, Gate $gate): \PayKit\Protocols\Mpp\ public function testAdapterPathIssuesValidChallengeForOnRouteRequest(): void { $cfg = $this->makeConfig(); - $adapter = new Adapter($cfg); + $adapter = $this->adapter($cfg); $gate = new Gate(amount: Price::usd('0.10')); // The on-route request built by the adapter must pass the (now-active) @@ -205,7 +359,7 @@ public function testAdapterPathIssuesValidChallengeForOnRouteRequest(): void public function testAdapterPathRejectsMismatchedCurrencyAtIssuance(): void { $cfg = $this->makeConfig(); - $adapter = new Adapter($cfg); + $adapter = $this->adapter($cfg); $gate = new Gate(amount: Price::usd('0.10')); $charges = $this->serverFor($adapter, $gate); @@ -222,7 +376,7 @@ public function testAdapterPathRejectsMismatchedCurrencyAtIssuance(): void public function testAdapterPathRejectsMismatchedRecipientAtIssuance(): void { $cfg = $this->makeConfig(); - $adapter = new Adapter($cfg); + $adapter = $this->adapter($cfg); $gate = new Gate(amount: Price::usd('0.10')); $charges = $this->serverFor($adapter, $gate); @@ -239,7 +393,7 @@ public function testAdapterPathRejectsMismatchedRecipientAtIssuance(): void public function testAdapterPathRejectsMismatchedNetworkAtIssuance(): void { $cfg = $this->makeConfig(); - $adapter = new Adapter($cfg); + $adapter = $this->adapter($cfg); $gate = new Gate(amount: Price::usd('0.10')); $charges = $this->serverFor($adapter, $gate); @@ -278,7 +432,7 @@ public function testChallengeWiresMppExpiresIntoIssuance(): void // issued challenge as an RFC 3339 expires. Previously the adapter // issued challenges with no expiry, so they never expired. $cfg = $this->makeConfigWithExpiresIn(120); - $adapter = new Adapter($cfg); + $adapter = $this->adapter($cfg); $gate = new Gate(amount: Price::usd('0.10')); $req = (new Psr17Factory())->createServerRequest('GET', '/paid'); @@ -302,7 +456,7 @@ public function testChallengeIsRejectedAfterExpiryWindow(): void // The wired expiry must actually drive isExpired(): a challenge // issued with a short TTL is expired once that window elapses. $cfg = $this->makeConfigWithExpiresIn(1); - $adapter = new Adapter($cfg); + $adapter = $this->adapter($cfg); $gate = new Gate(amount: Price::usd('0.10')); $req = (new Psr17Factory())->createServerRequest('GET', '/paid'); @@ -318,7 +472,7 @@ public function testExpiresInZeroIsNeverExpiresOptOut(): void // expiresIn = 0 is the documented dev-only opt-out: the challenge // is issued with no expires and never expires. $cfg = $this->makeConfigWithExpiresIn(0); - $adapter = new Adapter($cfg); + $adapter = $this->adapter($cfg); $gate = new Gate(amount: Price::usd('0.10')); $req = (new Psr17Factory())->createServerRequest('GET', '/paid'); @@ -350,3 +504,45 @@ public function providesDurableSharedReplayProtection(): bool return true; } } + +final class AdapterDurableStore implements DurableStore +{ + /** @var array */ + private array $values = []; + + public function __construct(private bool $durable = true) + { + } + + public function putIfAbsent(string $key, mixed $value): bool + { + if (array_key_exists($key, $this->values)) { + return false; + } + $this->values[$key] = $value; + return true; + } + + public function isDurable(): bool + { + return $this->durable; + } +} + +final class AdapterConflictingReplayStore implements DurableStore, ReplayStoreCapability +{ + public function putIfAbsent(string $key, mixed $value): bool + { + return true; + } + + public function isDurable(): bool + { + return true; + } + + public function providesDurableSharedReplayProtection(): bool + { + return false; + } +} diff --git a/php/tests/Protocols/Mpp/Server/SolanaChargeHandlerInternalsTest.php b/php/tests/Protocols/Mpp/Server/SolanaChargeHandlerInternalsTest.php index 1c22b385c..371c4eef7 100644 --- a/php/tests/Protocols/Mpp/Server/SolanaChargeHandlerInternalsTest.php +++ b/php/tests/Protocols/Mpp/Server/SolanaChargeHandlerInternalsTest.php @@ -33,6 +33,7 @@ private function handlerWith(FakeRpcGateway $rpc, int $confirmationAttempts = 3, verifier: new SolanaChargeTransactionVerifier(), confirmationAttempts: $confirmationAttempts, confirmationDelayMicros: $confirmationDelayMicros, + allowUnsafeMemoryStore: true, ); } diff --git a/php/tests/Protocols/Mpp/Server/SolanaChargeHandlerTest.php b/php/tests/Protocols/Mpp/Server/SolanaChargeHandlerTest.php index a6b556bda..15f1212c6 100644 --- a/php/tests/Protocols/Mpp/Server/SolanaChargeHandlerTest.php +++ b/php/tests/Protocols/Mpp/Server/SolanaChargeHandlerTest.php @@ -5,6 +5,7 @@ namespace PayKit\Tests; use PHPUnit\Framework\TestCase; +use PayKit\Exception\ConfigurationException; use PayKit\Protocols\Mpp\Core\Challenge; use PayKit\Protocols\Mpp\Core\Credential; use PayKit\Protocols\Mpp\Intent\ChargeRequest; @@ -16,6 +17,7 @@ use PayKit\Protocols\Mpp\Server\TransactionPayloadVerifier; use PayKit\Protocols\Mpp\Server\VerificationResult; use PayKit\Store\FileStore; +use PayKit\Store\DurableStore; use PayKit\Store\MemoryStore; use PayKit\Store\ReplayStoreCapability; use PayKit\Store\Store; @@ -29,6 +31,109 @@ final class SolanaChargeHandlerTest extends TestCase { + public function testNonLocalnetRequiresInjectedReplayStore(): void + { + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('atomic durable/shared replay store'); + new SolanaChargeHandler( + challenges: new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'), + rpc: new RpcClient('http://unused.invalid', new NullHttpClient()), + network: 'mainnet', + ); + } + + public function testNonLocalnetRejectsMemoryStore(): void + { + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('does not affirm durable/shared capability'); + new SolanaChargeHandler( + challenges: new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'), + rpc: new RpcClient('http://unused.invalid', new NullHttpClient()), + network: 'mainnet', + replayStore: new MemoryStore(), + ); + } + + public function testLocalnetStillRequiresExplicitUnsafeOptIn(): void + { + $this->expectException(ConfigurationException::class); + new SolanaChargeHandler( + challenges: new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'), + rpc: new RpcClient('http://unused.invalid', new NullHttpClient()), + network: 'localnet', + ); + } + + public function testExplicitUnsafeDevelopmentMemoryStoreIsAllowed(): void + { + $handler = new SolanaChargeHandler( + challenges: new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'), + rpc: new RpcClient('http://unused.invalid', new NullHttpClient()), + network: 'localnet', + allowUnsafeMemoryStore: true, + ); + self::assertInstanceOf(SolanaChargeHandler::class, $handler); + } + + public function testMainnetRejectsUnsafeMemoryStoreOverride(): void + { + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('forbidden on mainnet'); + new SolanaChargeHandler( + challenges: new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'), + rpc: new RpcClient('http://unused.invalid', new NullHttpClient()), + network: 'mainnet', + allowUnsafeMemoryStore: true, + ); + } + + public function testMainnetBetaAliasRejectsUnsafeMemoryStoreOverride(): void + { + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('forbidden on mainnet'); + new SolanaChargeHandler( + challenges: new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'), + rpc: new RpcClient('http://unused.invalid', new NullHttpClient()), + network: 'mainnet-beta', + allowUnsafeMemoryStore: true, + ); + } + + public function testUnsafeOptInDoesNotAuthorizeArbitraryCustomStore(): void + { + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('does not affirm durable/shared capability'); + new SolanaChargeHandler( + challenges: new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'), + rpc: new RpcClient('http://unused.invalid', new NullHttpClient()), + network: 'localnet', + replayStore: new HandlerUnspecifiedReplayStore(), + allowUnsafeMemoryStore: true, + ); + } + + public function testNonLocalnetAllowsInjectedStore(): void + { + $handler = new SolanaChargeHandler( + challenges: new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'), + rpc: new RpcClient('http://unused.invalid', new NullHttpClient()), + network: 'mainnet', + replayStore: new SharedHandlerReplayStore(), + ); + self::assertInstanceOf(SolanaChargeHandler::class, $handler); + } + + public function testNonLocalnetAcceptsDurableStoreContract(): void + { + $handler = new SolanaChargeHandler( + challenges: new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'), + rpc: new RpcClient('http://unused.invalid', new NullHttpClient()), + network: 'mainnet', + replayStore: new HandlerDurableStore(), + ); + self::assertInstanceOf(SolanaChargeHandler::class, $handler); + } + public function testReturns402WhenAuthorizationMissing(): void { $handler = $this->handler(); @@ -189,24 +294,28 @@ public function testReturns402WhenSurfpoolBlockhashOnNonLocalnet(): void public function testNonLocalnetRejectsMissingReplayStore(): void { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('replayStore is required outside localnet'); + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('atomic durable/shared replay store'); - $this->handler(network: 'devnet'); + new SolanaChargeHandler( + challenges: new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'), + rpc: new RpcClient('http://unused.invalid', new NullHttpClient()), + network: 'devnet', + ); } public function testNonLocalnetRejectsMemoryReplayStore(): void { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('must explicitly declare durable shared replay protection'); + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('does not affirm durable/shared capability'); $this->handler(network: 'devnet', replayStore: new MemoryStore()); } public function testNonLocalnetRejectsStoreWithoutReplayCapability(): void { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('must explicitly declare durable shared replay protection'); + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('does not affirm durable/shared capability'); $this->handler(network: 'devnet', replayStore: new HandlerUnspecifiedReplayStore()); } @@ -632,7 +741,7 @@ private function handler( transactionVerifier: $transactionVerifier, confirmationAttempts: $confirmationAttempts, confirmationDelayMicros: 0, - replayStore: $replayStore, + replayStore: $replayStore ?? new SharedHandlerReplayStore(), acceptPushMode: $acceptPushMode, ); } @@ -692,6 +801,26 @@ private function chargeRequest(string $amount = '1000'): ChargeRequest } } +final class SharedHandlerReplayStore implements Store, ReplayStoreCapability +{ + /** @var array */ + private array $values = []; + + public function putIfAbsent(string $key, mixed $value): bool + { + if (array_key_exists($key, $this->values)) { + return false; + } + $this->values[$key] = $value; + return true; + } + + public function providesDurableSharedReplayProtection(): bool + { + return true; + } +} + final class HandlerUnspecifiedReplayStore implements Store { private MemoryStore $store; @@ -727,6 +856,26 @@ public function providesDurableSharedReplayProtection(): bool } } +final class HandlerDurableStore implements DurableStore +{ + private MemoryStore $store; + + public function __construct() + { + $this->store = new MemoryStore(); + } + + public function putIfAbsent(string $key, mixed $value): bool + { + return $this->store->putIfAbsent($key, $value); + } + + public function isDurable(): bool + { + return true; + } +} + /** * Fake HTTP transport that fails on any actual call. Suitable for tests where * the handler must return 402 before reaching the RPC. From 035674dc2da591863a986fbbb025415320b1b9b4 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:49:59 +0300 Subject: [PATCH 3/9] fix(ts): add charge-level atomic replay store for MPP Introduces an atomic charge replay store distinct from the session store. mpp/server/store.ts adds ReplayStore plus resolveReplayStore, which fails closed unless an injected store reports isShared and isDurable or the explicit allowUnsafeMemoryStore flag authorizes an SDK-created process-local store; mainnet plus that flag is rejected. solana.charge and the push-mode path now reserve the consumed-signature marker with putIfAbsent after every fallible settlement check and raise VerificationFailedError (mapped to 402) on collision, so a broadcast ambiguity or timeout never burns a legitimate retry and only one concurrent caller obtains a receipt. pay-kit/replay-store.ts bridges the store into pay-kit with an identity-based production brand (declareProductionReplayStore) that copying capability booleans onto an unknown store cannot forge. configure() resolves and validates the store, and assertReplayStorePolicy revalidates it at every adapter boundary. The MPP adapter forwards the atomic view to both charge and subscription construction so subscription consumed-markers also upgrade to atomic putIfAbsent. Session-store durability policy (Session.ts, session/store.ts) and the subscription store threading stay with the subscription delivery. --- .../src/fixtures/typescript/charge-server.ts | 3 + harness/test/onchain.e2e.test.ts | 5 +- typescript/README.md | 22 +- typescript/examples/playground-api/index.ts | 6 +- .../packages/mpp/src/__tests__/charge.test.ts | 226 +++++++++++++++-- .../src/__tests__/cross-route-replay.test.ts | 18 +- .../mpp/src/__tests__/integration.test.ts | 5 + .../mpp/src/__tests__/server-exports.test.ts | 6 +- .../mpp/src/__tests__/server-store.test.ts | 66 +++++ typescript/packages/mpp/src/server/Charge.ts | 87 ++++--- typescript/packages/mpp/src/server/Methods.ts | 6 +- typescript/packages/mpp/src/server/index.ts | 8 + typescript/packages/mpp/src/server/store.ts | 118 +++++++++ .../src/__tests__/buffered-settle.test.ts | 2 +- .../pay-kit/src/__tests__/config.test.ts | 233 ++++++++++++++++-- .../pay-kit/src/__tests__/middleware.test.ts | 2 +- .../pay-kit/src/__tests__/mpp-adapter.test.ts | 51 +++- .../src/__tests__/mpp-replay-race.test.ts | 145 +++++++++++ .../pay-kit/src/__tests__/mpp-session.test.ts | 28 ++- .../pay-kit/src/__tests__/openapi.test.ts | 2 +- .../pay-kit/src/__tests__/paykit.test.ts | 73 +++++- .../pay-kit/src/__tests__/x402.test.ts | 19 +- .../packages/pay-kit/src/adapters/mpp.ts | 21 +- .../pay-kit/src/adapters/x402-upto.ts | 3 +- .../packages/pay-kit/src/adapters/x402.ts | 3 +- typescript/packages/pay-kit/src/config.ts | 110 +++++++-- typescript/packages/pay-kit/src/index.ts | 1 + typescript/packages/pay-kit/src/paykit.ts | 3 +- .../packages/pay-kit/src/replay-store.ts | 75 ++++++ 29 files changed, 1224 insertions(+), 123 deletions(-) create mode 100644 typescript/packages/mpp/src/__tests__/server-store.test.ts create mode 100644 typescript/packages/mpp/src/server/store.ts create mode 100644 typescript/packages/pay-kit/src/__tests__/mpp-replay-race.test.ts create mode 100644 typescript/packages/pay-kit/src/replay-store.ts diff --git a/harness/src/fixtures/typescript/charge-server.ts b/harness/src/fixtures/typescript/charge-server.ts index 79bb9ac63..844ca12ff 100644 --- a/harness/src/fixtures/typescript/charge-server.ts +++ b/harness/src/fixtures/typescript/charge-server.ts @@ -71,6 +71,9 @@ async function main() { network: environment.network, rpcUrl: environment.rpcUrl, ...(pushMode ? {} : { signer: feePayerSigner }), + // The conformance fixture is a single-process test server. Production + // applications must inject a durable shared replay store instead. + allowUnsafeMemoryStore: true, splits: environment.splits, }), ], diff --git a/harness/test/onchain.e2e.test.ts b/harness/test/onchain.e2e.test.ts index 6bd9d9c8c..7ced56d97 100644 --- a/harness/test/onchain.e2e.test.ts +++ b/harness/test/onchain.e2e.test.ts @@ -48,7 +48,10 @@ describe("on-chain datasource RPC config", () => { async function startServer(): Promise { const pay = await createPayKit({ accept: ["x402", "mpp"], - mpp: { challengeBindingSecret: crypto.randomBytes(32).toString("hex") }, + mpp: { + allowUnsafeMemoryStore: true, + challengeBindingSecret: crypto.randomBytes(32).toString("hex"), + }, network: "localnet", operator: { recipient: operator.address, signer: operator }, pricing: { diff --git a/typescript/README.md b/typescript/README.md index 8c5337e5b..1586cc19c 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -93,17 +93,26 @@ challenge secret, and a persistent replay store. The route handlers are unchanged — only the config grows. ```ts -import { createPayKit, Signer, Store, usd } from '@solana/pay-kit' +import { createPayKit, declareProductionReplayStore, type ReplayStore, Signer, Store, usd } from '@solana/pay-kit' const PLATFORM = 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY' +const replayStore: ReplayStore = declareProductionReplayStore({ + ...Store.redis(redisClient), + isDurable: true, + isShared: true, + async putIfAbsent(key, value) { + return (await redisClient.set(key, JSON.stringify(value), { NX: true })) === 'OK' + }, +}) + const pay = await createPayKit({ network: 'solana_mainnet', stablecoins: ['USDC', 'PYUSD'], operator: { signer: await Signer.file('config/operator.json') }, rpcUrl: 'https://mainnet.helius-rpc.com/?api-key=YOUR_HELIUS_KEY', mpp: { challengeBindingSecret: process.env.PAY_KIT_MPP_SECRET! }, - replayStore: Store.redis(redisClient), // any ioredis / node-redis / Valkey client + replayStore, pricing: { report: { amount: usd('0.10'), description: 'Premium report' }, @@ -115,6 +124,15 @@ const pay = await createPayKit({ }) ``` +`Store.redis()` supplies the serialized `get`/`put`/`delete` surface only. +The explicit `putIfAbsent` must be one Redis `SET key value NX` command, as in +the node-redis example above; never compose it from `get` then `put`. Use the +equivalent atomic command shape for ioredis or Valkey clients. +`declareProductionReplayStore()` records this application's explicit assertion +that the store is atomic, shared by every worker, and durable across restarts. +It is not a capability probe, and process-local memory stores cannot be +declared production-safe. + Two safety rails fire at boot: - `solana_mainnet` plus the published demo signer throws diff --git a/typescript/examples/playground-api/index.ts b/typescript/examples/playground-api/index.ts index e460d154c..95e9fe291 100644 --- a/typescript/examples/playground-api/index.ts +++ b/typescript/examples/playground-api/index.ts @@ -50,7 +50,11 @@ 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 }, + mpp: { + challengeBindingSecret: SECRET_KEY, + html: true, + ...(NETWORK === 'localnet' ? { allowUnsafeMemoryStore: true } : {}), + }, network: NETWORK, operator: { recipient: RECIPIENT, signer: operator }, pricing: { diff --git a/typescript/packages/mpp/src/__tests__/charge.test.ts b/typescript/packages/mpp/src/__tests__/charge.test.ts index 3b1504397..15e4022d9 100644 --- a/typescript/packages/mpp/src/__tests__/charge.test.ts +++ b/typescript/packages/mpp/src/__tests__/charge.test.ts @@ -30,7 +30,7 @@ import { type Blockhash, } from '@solana/kit'; import { buildChargeTransaction } from '../client/Charge.js'; -import { charge, interpretPostTimeoutStatus, verifyChargeTransaction } from '../server/Charge.js'; +import { charge as rawCharge, interpretPostTimeoutStatus, verifyChargeTransaction } from '../server/Charge.js'; import { ASSOCIATED_TOKEN_PROGRAM, CASH, @@ -49,6 +49,10 @@ const USDC_MINT = '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'; const SIGNATURE = '5UfDuX6nSqMzMR8W7n6K3b1GKLmaqEisBFCcYPRLjNHrCbVQJF3BVjkE7aQJMQ2Kx'; const BLOCKHASH = 'EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N' as Blockhash; +function charge(parameters: Parameters[0]) { + return rawCharge({ ...parameters, store: parameters.store ?? createSharedMemoryStore() }); +} + type TestCompiledInstruction = { accountIndices?: readonly unknown[]; accounts?: readonly unknown[]; @@ -443,9 +447,35 @@ function ataCreateIx({ let originalFetch: typeof globalThis.fetch; let store: Store.Store; +function createSharedMemoryStore(): Store.Store { + const values = new Map(); + return Object.assign(Store.memory(), { + isDurable: true, + isShared: true, + delete(key: string) { + values.delete(key); + return Promise.resolve(); + }, + get(key: string) { + return Promise.resolve((values.get(key) ?? null) as never); + }, + put(key: string, value: unknown) { + values.set(key, value); + return Promise.resolve(); + }, + putIfAbsent(key: string, value: unknown) { + // No await may separate has() from set(): this models the atomic + // cross-replica primitive required from production replay stores. + if (values.has(key)) return Promise.resolve(false); + values.set(key, value); + return Promise.resolve(true); + }, + }); +} + beforeEach(() => { originalFetch = globalThis.fetch; - store = Store.memory(); + store = createSharedMemoryStore(); }); afterEach(() => { @@ -454,6 +484,43 @@ afterEach(() => { // ── Parameter validation ── +test('charge() requires explicit replay storage or an explicit unsafe override', () => { + expect(() => rawCharge({ recipient: RECIPIENT, network: 'devnet' })).toThrow(/explicit replay store/); + expect(() => rawCharge({ allowUnsafeMemoryStore: true, recipient: RECIPIENT, network: 'devnet' })).not.toThrow(); +}); + +test('charge() rejects the unsafe replay-store override on mainnet', () => { + for (const network of ['mainnet', 'mainnet-beta', 'MAINNET', 'MAINNET-BETA']) { + expect(() => + rawCharge({ allowUnsafeMemoryStore: true, network: network as 'mainnet', recipient: RECIPIENT }), + ).toThrow(/forbidden on mainnet/); + } +}); + +test('charge() does not let the unsafe override bypass a declared unshared store', () => { + const unsharedStore = Object.assign(Store.memory(), { isShared: false }); + + expect(() => + rawCharge({ + allowUnsafeMemoryStore: true, + network: 'devnet', + recipient: RECIPIENT, + store: unsharedStore, + }), + ).toThrow(/atomic putIfAbsent/); +}); + +test('charge() does not let the unsafe override authorize an unknown store', () => { + expect(() => + rawCharge({ + allowUnsafeMemoryStore: true, + network: 'devnet', + recipient: RECIPIENT, + store: Store.memory(), + }), + ).toThrow(/atomic putIfAbsent/); +}); + test('charge() throws when currency is a mint but decimals is missing', () => { expect(() => charge({ @@ -569,13 +636,16 @@ test('signature: accepts valid native SOL transfer', async () => { globalThis.fetch = async () => rpcSuccess(solTransferTx(RECIPIENT, 1000000)); + const credential = signatureCredential(SIGNATURE, { amount: '1000000' }); + credential.challenge.id = 'charge-challenge'; const receipt = await method.verify({ - credential: signatureCredential(SIGNATURE, { amount: '1000000' }), + credential, request: {} as any, }); expect(receipt.status).toBe('success'); expect(receipt.reference).toBe(SIGNATURE); + expect(receipt).toMatchObject({ challengeId: 'charge-challenge' }); }); test('signature: rejects SOL transfer with wrong recipient', async () => { @@ -1503,7 +1573,8 @@ test('signature: rejects ATA creation for top-level recipient', async () => { // ── Replay prevention (type="signature") ── -test('signature: rejects already-consumed transaction signature', async () => { +test('signature: reports a structured verification rejection for a preseeded consumed signature', async () => { + await store.put(`solana-charge:consumed:${SIGNATURE}`, true); const method = charge({ recipient: RECIPIENT, network: 'devnet', @@ -1511,21 +1582,17 @@ test('signature: rejects already-consumed transaction signature', async () => { store, }); - globalThis.fetch = async () => rpcSuccess(solTransferTx(RECIPIENT, 1000000)); - - // First call succeeds - await method.verify({ - credential: signatureCredential(SIGNATURE, { amount: '1000000' }), - request: {} as any, - }); - - // Second call with same signature is rejected await expect( method.verify({ credential: signatureCredential(SIGNATURE, { amount: '1000000' }), - request: {} as any, + request: {} as never, }), - ).rejects.toThrow(/already consumed/); + ).rejects.toMatchObject({ + message: 'Payment verification failed: Transaction signature already consumed.', + name: 'VerificationFailedError', + status: 402, + type: 'https://paymentauth.org/problems/verification-failed', + }); }); test('signature: concurrent requests with the same signature settle at most once', async () => { @@ -1553,12 +1620,18 @@ test('signature: concurrent requests with the same signature settle at most once const results = await Promise.allSettled(Array.from({ length: 8 }, verifyOnce)); const settled = results.filter(r => r.status === 'fulfilled'); - const rejectedConsumed = results.filter( - r => r.status === 'rejected' && /already consumed/.test(String((r as PromiseRejectedResult).reason)), - ); + const rejectedConsumed = results.filter(r => r.status === 'rejected'); expect(settled).toHaveLength(1); expect(rejectedConsumed).toHaveLength(7); + for (const result of rejectedConsumed) { + expect(result.reason).toMatchObject({ + message: 'Payment verification failed: Transaction signature already consumed.', + name: 'VerificationFailedError', + status: 402, + type: 'https://paymentauth.org/problems/verification-failed', + }); + } }); // ── RPC error handling (type="signature") ── @@ -1728,6 +1801,123 @@ test('pull: accepts valid native SOL transfer', async () => { expect(receipt.reference).toBe(SIGNATURE); }); +test('pull: reports a structured verification rejection for a preseeded consumed signature', async () => { + const replayedSignature = 'replayedPullSignature000000000000000000000000000000'; + const replayStore = createSharedMemoryStore(); + await replayStore.put(`solana-charge:consumed:${replayedSignature}`, true); + const method = charge({ + recipient: RECIPIENT, + network: 'devnet', + rpcUrl: 'https://mock-rpc', + store: replayStore, + }); + + mockServerBroadcastFetch(solTransferTx(RECIPIENT, 1000000), replayedSignature); + + await expect( + method.verify({ + credential: transactionCredential(await buildSolPaymentTxBase64(RECIPIENT, 1000000), { + amount: '1000000', + }), + request: {} as never, + }), + ).rejects.toMatchObject({ + message: 'Payment verification failed: Transaction signature already consumed.', + name: 'VerificationFailedError', + status: 402, + type: 'https://paymentauth.org/problems/verification-failed', + }); +}); + +test('pull: concurrent calls issue one receipt and typed replay rejections', async () => { + const concurrentStore = createSharedMemoryStore(); + const method = charge({ + recipient: RECIPIENT, + network: 'devnet', + rpcUrl: 'https://mock-rpc', + store: concurrentStore, + }); + const transaction = await buildSolPaymentTxBase64(RECIPIENT, 1000000); + + globalThis.fetch = async (_url: 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(SIGNATURE); + case 'getSignatureStatuses': + return rpcSuccess({ value: [{ confirmationStatus: 'confirmed', err: null }] }); + case 'getTransaction': + // Let every caller finish validation before the final atomic commit. + await new Promise(resolve => setTimeout(resolve, 20)); + return rpcSuccess(solTransferTx(RECIPIENT, 1000000)); + default: + throw new Error(`Unexpected RPC method: ${body.method}`); + } + }; + + const verifyOnce = () => + method.verify({ + credential: transactionCredential(transaction, { amount: '1000000' }), + request: {} as never, + }); + const results = await Promise.allSettled(Array.from({ length: 8 }, verifyOnce)); + const fulfilled = results.filter(result => result.status === 'fulfilled'); + const rejected = results.filter(result => result.status === 'rejected'); + + expect(fulfilled).toHaveLength(1); + expect(rejected).toHaveLength(7); + for (const result of rejected) { + expect(result.reason).toMatchObject({ + message: 'Payment verification failed: Transaction signature already consumed.', + name: 'VerificationFailedError', + status: 402, + type: 'https://paymentauth.org/problems/verification-failed', + }); + } +}); + +test('pull: retries after post-broadcast on-chain verification failure without burning the transaction', async () => { + const retryStore = createSharedMemoryStore(); + const method = charge({ + recipient: RECIPIENT, + network: 'devnet', + rpcUrl: 'https://mock-rpc', + store: retryStore, + }); + const credential = transactionCredential(await buildSolPaymentTxBase64(RECIPIENT, 1000000), { + amount: '1000000', + }); + let verificationAttempts = 0; + + globalThis.fetch = async (_url: 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(SIGNATURE); + case 'getSignatureStatuses': + return rpcSuccess({ value: [{ confirmationStatus: 'confirmed', err: null }] }); + case 'getTransaction': + verificationAttempts += 1; + return rpcSuccess(solTransferTx(RECIPIENT, verificationAttempts === 1 ? 999999 : 1000000)); + default: + throw new Error(`Unexpected RPC method: ${body.method}`); + } + }; + + await expect(method.verify({ credential, request: {} as never })).rejects.toThrow( + /No system transfer instruction found/, + ); + await expect(method.verify({ credential, request: {} as never })).resolves.toMatchObject({ + reference: SIGNATURE, + status: 'success', + }); + expect(await retryStore.get(`solana-charge:consumed:${SIGNATURE}`)).toBe(true); +}); + test('pull: accepts native SOL externalId memo pre-broadcast and on-chain', async () => { const method = charge({ recipient: RECIPIENT, diff --git a/typescript/packages/mpp/src/__tests__/cross-route-replay.test.ts b/typescript/packages/mpp/src/__tests__/cross-route-replay.test.ts index 2fd636362..3961c382b 100644 --- a/typescript/packages/mpp/src/__tests__/cross-route-replay.test.ts +++ b/typescript/packages/mpp/src/__tests__/cross-route-replay.test.ts @@ -24,7 +24,14 @@ const REALM = 'api.example.com'; function makeHandler() { return Mppx.create({ - methods: [charge({ recipient: RECIPIENT, network: 'devnet', rpcUrl: 'https://mock-rpc' })], + methods: [ + charge({ + allowUnsafeMemoryStore: true, + recipient: RECIPIENT, + network: 'devnet', + rpcUrl: 'https://mock-rpc', + }), + ], realm: REALM, secretKey: SECRET_KEY, }); @@ -73,7 +80,14 @@ test('cross-route: credential issued for /cheap is rejected at /expensive (diffe test('cross-route: credential issued for one currency is rejected at another', async () => { // Two routes on the same handler, different currencies. const handler = Mppx.create({ - methods: [charge({ recipient: RECIPIENT, network: 'devnet', rpcUrl: 'https://mock-rpc' })], + methods: [ + charge({ + allowUnsafeMemoryStore: true, + recipient: RECIPIENT, + network: 'devnet', + rpcUrl: 'https://mock-rpc', + }), + ], realm: REALM, secretKey: SECRET_KEY, }); diff --git a/typescript/packages/mpp/src/__tests__/integration.test.ts b/typescript/packages/mpp/src/__tests__/integration.test.ts index 87199665c..f31090108 100644 --- a/typescript/packages/mpp/src/__tests__/integration.test.ts +++ b/typescript/packages/mpp/src/__tests__/integration.test.ts @@ -99,6 +99,7 @@ beforeAll(async () => { secretKey, methods: [ serverSolana.charge({ + allowUnsafeMemoryStore: true, recipient: recipientSigner.address, network: 'localnet', rpcUrl: RPC_URL, @@ -267,6 +268,7 @@ test('e2e: fee payer mode — server co-signs and pays fees', async () => { secretKey, methods: [ serverSolana.charge({ + allowUnsafeMemoryStore: true, recipient: recipientSigner.address, network: 'localnet', rpcUrl: RPC_URL, @@ -364,6 +366,7 @@ test('e2e: USDC charge via pull mode with fee payer', async () => { secretKey, methods: [ serverSolana.charge({ + allowUnsafeMemoryStore: true, recipient: recipientSigner.address, network: 'localnet', rpcUrl: RPC_URL, @@ -435,6 +438,7 @@ test('e2e: USDC charge with splits (platform fee)', async () => { secretKey, methods: [ serverSolana.charge({ + allowUnsafeMemoryStore: true, recipient: recipientSigner.address, network: 'localnet', rpcUrl: RPC_URL, @@ -507,6 +511,7 @@ test('e2e: native SOL charge with splits', async () => { secretKey, methods: [ serverSolana.charge({ + allowUnsafeMemoryStore: true, recipient: recipientSigner.address, network: 'localnet', rpcUrl: RPC_URL, diff --git a/typescript/packages/mpp/src/__tests__/server-exports.test.ts b/typescript/packages/mpp/src/__tests__/server-exports.test.ts index 99d4784ba..bb20512c2 100644 --- a/typescript/packages/mpp/src/__tests__/server-exports.test.ts +++ b/typescript/packages/mpp/src/__tests__/server-exports.test.ts @@ -3,8 +3,6 @@ * * Validates that the server barrel modules expose the expected public API. */ -import { Store } from 'mppx/server'; - import { solana } from '../server/Methods.js'; import { charge, solana as solanaFromIndex } from '../server/index.js'; @@ -23,7 +21,7 @@ describe('server/Methods.ts', () => { const method = solana({ recipient: RECIPIENT, network: 'devnet', - store: Store.memory(), + allowUnsafeMemoryStore: true, }); expect(method).toBeDefined(); @@ -34,7 +32,7 @@ describe('server/Methods.ts', () => { const method = solana.charge({ recipient: RECIPIENT, network: 'devnet', - store: Store.memory(), + allowUnsafeMemoryStore: true, }); expect(method).toBeDefined(); diff --git a/typescript/packages/mpp/src/__tests__/server-store.test.ts b/typescript/packages/mpp/src/__tests__/server-store.test.ts new file mode 100644 index 000000000..4bcc1536c --- /dev/null +++ b/typescript/packages/mpp/src/__tests__/server-store.test.ts @@ -0,0 +1,66 @@ +import type { Store } from 'mppx'; +import { describe, expect, it } from 'vitest'; + +import { + createAtomicReplayStoreView, + type ReplayStore, + type ReplayStoreCapability, + resolveReplayStore, +} from '../server/store.js'; + +function replayStore(capabilities: ReplayStoreCapability = {}): ReplayStore { + const values = new Map(); + return { + delete: async key => { + values.delete(key); + }, + get: async key => (values.get(key) ?? null) as never, + ...capabilities, + put: async (key, value) => { + values.set(key, value); + }, + putIfAbsent: async (key, value) => { + if (values.has(key)) return false; + values.set(key, value); + return true; + }, + }; +} + +const LEGACY_STORE: Store.Store = { + async delete() {}, + async get() { + return null; + }, + async put() {}, +}; + +describe('createAtomicReplayStoreView', () => { + it('preserves capability declarations and atomic reservation', async () => { + const view = createAtomicReplayStoreView(replayStore({ isDurable: true, isShared: true })); + + expect(view.isDurable).toBe(true); + expect(view.isShared).toBe(true); + await expect(view.putIfAbsent('reservation', true)).resolves.toBe(true); + await expect(view.putIfAbsent('reservation', true)).resolves.toBe(false); + }); +}); + +describe('resolveReplayStore', () => { + it.each([ + ['isShared-only', replayStore({ isShared: true })], + ['isDurable-only', replayStore({ isDurable: true })], + ['unknown capabilities', replayStore()], + ['non-atomic store', LEGACY_STORE], + ])('rejects a production store with %s', (_label, store) => { + expect(() => resolveReplayStore(store, false, 'charge')).toThrow( + /atomic putIfAbsent and report isShared=true and isDurable=true/, + ); + }); + + it('accepts an atomic store with both production capabilities', () => { + const store = replayStore({ isDurable: true, isShared: true }); + + expect(resolveReplayStore(store, false, 'subscription')).toBe(store); + }); +}); diff --git a/typescript/packages/mpp/src/server/Charge.ts b/typescript/packages/mpp/src/server/Charge.ts index 8359564d2..e77d65d14 100644 --- a/typescript/packages/mpp/src/server/Charge.ts +++ b/typescript/packages/mpp/src/server/Charge.ts @@ -7,7 +7,7 @@ import { type TransactionPartialSigner, } from '@solana/kit'; import { findAssociatedTokenPda } from '@solana-program/token'; -import { Method, Receipt, Store } from 'mppx'; +import { Errors, Method, Receipt, Store } from 'mppx'; import { ASSOCIATED_TOKEN_PROGRAM, @@ -15,6 +15,7 @@ import { DEFAULT_RPC_URLS, defaultTokenProgramForCurrency, MEMO_PROGRAM, + normalizeNetwork, resolveStablecoinMint, stablecoinSymbolForCurrency, SYSTEM_PROGRAM, @@ -27,6 +28,7 @@ import { coSignBase64Transaction } from '../utils/transactions.js'; import { PAYMENT_UI_JS } from './html-assets.gen.js'; import { withKeyLock } from './keyLock.js'; import { checkNetworkBlockhash } from './network-check.js'; +import { type ReplayStore, resolveReplayStore } from './store.js'; /** * Creates a Solana `charge` method for usage on the server. @@ -52,6 +54,8 @@ import { checkNetworkBlockhash } from './network-check.js'; * spl: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', * decimals: 6, * network: 'devnet', + * // Local development only; production must inject a shared store. + * allowUnsafeMemoryStore: true, * })], * }) * @@ -70,15 +74,19 @@ export function charge(parameters: charge.Parameters) { html: htmlEnabled = false, tokenProgram: configuredTokenProgram, network = 'mainnet', - store = Store.memory(), + store: configuredStore, + allowUnsafeMemoryStore, splits, signer, } = parameters; - // Reject unknown network slugs at boot (spec: mainnet | devnet | localnet), // rather than silently falling back to mainnet for a typo. The legacy // `mainnet-beta` spelling is accepted as an alias and normalized below. validateNetwork(network); + if (normalizeNetwork(network) === 'mainnet' && allowUnsafeMemoryStore === true) { + throw new Error('solana.charge allowUnsafeMemoryStore is forbidden on mainnet'); + } + const store = resolveReplayStore(configuredStore, allowUnsafeMemoryStore, 'charge'); const isSplToken = currency !== undefined && currency !== 'sol'; const tokenProgram = configuredTokenProgram ?? defaultTokenProgramForCurrency(currency, network); @@ -737,7 +745,7 @@ async function verifyTransaction( challenge: ChallengeRequest, rpcUrl: string, recipient: string, - store: Store.Store, + store: ReplayStore, signer: TransactionPartialSigner | undefined, network: string, ) { @@ -775,28 +783,34 @@ async function verifyTransaction( // Broadcast the (now fully-signed) transaction. const signature = await broadcastTransaction(rpcUrl, txToSend); - // Audit #3: reserve the signature BETWEEN broadcast and confirmation polling. - // If we only marked it consumed after confirmation+verify (as before), a tx - // that landed during a confirmation-poll timeout could be lost — the user - // pays but the signature is never recorded, so a retry re-broadcasts (double - // charge) or replays. Reserving here closes the replay window; the - // post-timeout status recovery below rescues the false-negative case. - await store.put(`solana-charge:consumed:${signature}`, true); - // Wait for on-chain confirmation (with a definitive post-timeout status check). await waitForConfirmation(rpcUrl, signature); // Verify the confirmed transaction matches the challenge. await verifyOnChain(rpcUrl, signature, challenge, recipient); - return Receipt.from({ - method: 'solana', + const receipt = { + ...Receipt.from({ + method: 'solana', + ...(challenge.externalId ? { externalId: challenge.externalId } : {}), + reference: signature, + status: 'success', + timestamp: new Date().toISOString(), + }), ...(credential.challenge.id ? { challengeId: credential.challenge.id } : {}), - reference: signature, - ...(challenge.externalId ? { externalId: challenge.externalId } : {}), - status: 'success', - timestamp: new Date().toISOString(), - }); + }; + + // Commit only after every fallible settlement check succeeds. Re-broadcasting + // identical signed Solana transaction bytes is idempotent at the transaction + // level, while this atomic final commit is the receipt-level single-winner + // gate. A broadcast ambiguity, timeout, or verification failure therefore + // leaves no marker to burn a legitimate retry; a committed marker is never + // removed and only one concurrent caller can obtain a receipt. + if (!(await store.putIfAbsent(`solana-charge:consumed:${signature}`, true))) { + throw new Errors.VerificationFailedError({ reason: 'Transaction signature already consumed' }); + } + + return receipt; } // ── Push mode (type="signature") ── @@ -806,7 +820,7 @@ async function verifySignature( challenge: ChallengeRequest, rpcUrl: string, recipient: string, - store: Store.Store, + store: ReplayStore, ) { const { signature } = credential.payload; if (!signature) { @@ -826,14 +840,14 @@ async function verifySignature( // Scope: single Node process. Multi-process/replica deployments sharing one // Store must back the consumed marker with an atomic reserve. See SECURITY.md. if (await store.get(consumedKey)) { - throw new Error('Transaction signature already consumed'); + throw new Errors.VerificationFailedError({ reason: 'Transaction signature already consumed' }); } return await withKeyLock(consumedKey, async () => { // Re-check inside the lock: a concurrent request in this process may // have consumed the signature since the read above. if (await store.get(consumedKey)) { - throw new Error('Transaction signature already consumed'); + throw new Errors.VerificationFailedError({ reason: 'Transaction signature already consumed' }); } // Fetch and verify the transaction on-chain. @@ -844,18 +858,24 @@ async function verifySignature( const instructions = tx.transaction.message.instructions; await verifyInstructions(instructions, challenge, recipient); + const receipt = { + ...Receipt.from({ + method: 'solana', + ...(challenge.externalId ? { externalId: challenge.externalId } : {}), + reference: signature, + status: 'success', + timestamp: new Date().toISOString(), + }), + ...(credential.challenge.id ? { challengeId: credential.challenge.id } : {}), + }; + // Mark consumed only after a successful verify, so a failed verify never // burns a legitimately-retryable signature. - await store.put(consumedKey, true); + if (!(await store.putIfAbsent(consumedKey, true))) { + throw new Errors.VerificationFailedError({ reason: 'Transaction signature already consumed' }); + } - return Receipt.from({ - method: 'solana', - ...(credential.challenge.id ? { challengeId: credential.challenge.id } : {}), - reference: signature, - ...(challenge.externalId ? { externalId: challenge.externalId } : {}), - status: 'success', - timestamp: new Date().toISOString(), - }); + return receipt; }); } @@ -1438,6 +1458,8 @@ async function fetchPostTimeoutStatus(rpcUrl: string, signature: string): Promis export declare namespace charge { type Parameters = { + /** Explicitly allow an internal process-local replay store for development/tests. */ + allowUnsafeMemoryStore?: boolean; /** * Currency identifier. "sol" (lowercase) for native SOL, or a * base58-encoded SPL token mint address. Defaults to "sol". @@ -1496,7 +1518,8 @@ export declare namespace charge { }>; /** * Pluggable key-value store for consumed-signature tracking (replay prevention). - * Defaults to in-memory. Use a persistent store in production. + * Required unless `allowUnsafeMemoryStore` explicitly enables an internal + * process-local store for development. */ store?: Store.Store; /** Token program hint. If omitted, clients fetch the mint owner and fail closed on lookup errors. */ diff --git a/typescript/packages/mpp/src/server/Methods.ts b/typescript/packages/mpp/src/server/Methods.ts index 2f20d1e16..97cefaa78 100644 --- a/typescript/packages/mpp/src/server/Methods.ts +++ b/typescript/packages/mpp/src/server/Methods.ts @@ -10,7 +10,11 @@ import { subscription as subscription_ } from './Subscription.js'; * import { Mppx, solana } from '@solana/mpp/server' * * const mppx = Mppx.create({ - * methods: [solana.charge({ recipient: '...', network: 'devnet' })], + * methods: [solana.charge({ + * recipient: '...', + * network: 'devnet', + * allowUnsafeMemoryStore: true, // local development only + * })], * }) * ``` */ diff --git a/typescript/packages/mpp/src/server/index.ts b/typescript/packages/mpp/src/server/index.ts index 70e05e13b..8bdd24322 100644 --- a/typescript/packages/mpp/src/server/index.ts +++ b/typescript/packages/mpp/src/server/index.ts @@ -11,6 +11,14 @@ export { type PendingDelivery, type SessionStore, } from './session/store.js'; +export { + createAtomicReplayStoreView, + createUnsafeMemoryReplayStore, + isUnsafeMemoryReplayStore, + resolveReplayStore, + type ReplayStore, + type ReplayStoreCapability, +} from './store.js'; export { buildReclaimInstruction, encodeVoucherMessageBytes, diff --git a/typescript/packages/mpp/src/server/store.ts b/typescript/packages/mpp/src/server/store.ts new file mode 100644 index 000000000..9d8a9c83b --- /dev/null +++ b/typescript/packages/mpp/src/server/store.ts @@ -0,0 +1,118 @@ +import { Errors, Store } from 'mppx'; + +/** + * Optional capability declaration for replay stores. + * + * Production stores must affirm both capabilities. Unknown injected stores are + * rejected; the explicit development + * escape hatch can authorize only SDK-created memory stores. + */ +export type ReplayStoreCapability = { + readonly isDurable?: boolean; + readonly isShared?: boolean; +}; + +export type ReplayStore = ReplayStoreCapability & + Store.Store & { + readonly putIfAbsent: (key: string, value: unknown) => Promise; + }; + +const unsafeMemoryReplayStores = new WeakSet(); + +function isProductionReplayStore(store: Store.Store): boolean { + const candidate = store as ReplayStore; + return candidate.isShared === true && candidate.isDurable === true; +} + +/** True only for memory stores created by this SDK module. */ +export function isUnsafeMemoryReplayStore(store: Store.Store): boolean { + return unsafeMemoryReplayStores.has(store); +} + +/** Create the only process-local replay store that the unsafe flag can authorize. */ +export function createUnsafeMemoryReplayStore(): ReplayStore { + const values = new Map(); + const store: ReplayStore = { + delete(key) { + values.delete(key); + return Promise.resolve(); + }, + get(key) { + return Promise.resolve((values.get(key) ?? null) as never); + }, + put(key, value) { + values.set(key, value); + return Promise.resolve(); + }, + putIfAbsent(key, value) { + if (values.has(key)) return Promise.resolve(false); + values.set(key, value); + return Promise.resolve(true); + }, + }; + unsafeMemoryReplayStores.add(store); + return store; +} + +/** + * 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. + */ +export function createAtomicReplayStoreView(store: ReplayStore): ReplayStore { + const view: ReplayStore = { + delete: key => store.delete(key), + get: key => store.get(key), + ...(store.isDurable === undefined ? {} : { isDurable: store.isDurable }), + ...(store.isShared === undefined ? {} : { isShared: store.isShared }), + async put(key, value) { + if (key.startsWith('solana-charge:consumed:') || key.startsWith('solana-subscription:consumed:')) { + if (!(await store.putIfAbsent(key, value))) { + throw new Errors.VerificationFailedError({ reason: 'MPP replay key is already reserved' }); + } + return; + } + await store.put(key, value); + }, + putIfAbsent: (key, value) => store.putIfAbsent(key, value), + }; + if (isUnsafeMemoryReplayStore(store)) unsafeMemoryReplayStores.add(view); + return view; +} + +/** Resolve replay storage without silently falling back to process-local memory. */ +export function resolveReplayStore( + store: Store.Store | undefined, + allowUnsafeMemoryStore: boolean | undefined, + methodName: 'charge' | 'subscription', +): ReplayStore { + if (store === undefined) { + if (allowUnsafeMemoryStore === true) { + console.warn( + `[solana-mpp] ${methodName} explicitly enabled a process-local replay store. ` + + 'Replay markers are lost on restart and are not shared across workers.', + ); + return createUnsafeMemoryReplayStore(); + } + throw new Error( + `solana.${methodName} requires an explicit replay store; ` + + 'provide store or set allowUnsafeMemoryStore=true for development.', + ); + } + + if (isUnsafeMemoryReplayStore(store)) { + if (allowUnsafeMemoryStore === true) return store as ReplayStore; + throw new Error( + `solana.${methodName} replay store is process-local memory; ` + + 'set allowUnsafeMemoryStore=true for explicit development use.', + ); + } + + if (!isProductionReplayStore(store) || !('putIfAbsent' in store) || typeof store.putIfAbsent !== 'function') { + throw new Error( + `solana.${methodName} replay store must implement atomic putIfAbsent and report ` + + 'isShared=true and isDurable=true.', + ); + } + + return store as ReplayStore; +} diff --git a/typescript/packages/pay-kit/src/__tests__/buffered-settle.test.ts b/typescript/packages/pay-kit/src/__tests__/buffered-settle.test.ts index 3033d9d35..cc52001a8 100644 --- a/typescript/packages/pay-kit/src/__tests__/buffered-settle.test.ts +++ b/typescript/packages/pay-kit/src/__tests__/buffered-settle.test.ts @@ -57,7 +57,7 @@ const { usd } = await import('../price.js'); async function startServer(): Promise<{ base: string; server: Server }> { const pay = await createPayKit({ accept: ['x402'], - mpp: { challengeBindingSecret: 's' }, + mpp: { challengeBindingSecret: 's', allowUnsafeMemoryStore: true }, network: 'solana_localnet', pricing: { summarize: usage(usd('1.00')) }, }); diff --git a/typescript/packages/pay-kit/src/__tests__/config.test.ts b/typescript/packages/pay-kit/src/__tests__/config.test.ts index 69030b3e5..098747e42 100644 --- a/typescript/packages/pay-kit/src/__tests__/config.test.ts +++ b/typescript/packages/pay-kit/src/__tests__/config.test.ts @@ -1,10 +1,48 @@ +import type { Store } from 'mppx'; import { describe, expect, it } from 'vitest'; import { configure, configureFromEnv } from '../config.js'; import { ConfigurationError, DemoSignerOnMainnetError, ProtocolNotSupportedError } from '../errors.js'; +import { createUnsafeMemoryReplayStore, declareProductionReplayStore, type ReplayStore } from '../replay-store.js'; import { Signer } from '../signer.js'; -const SECRET = { mpp: { challengeBindingSecret: 'test-secret' } }; +const SECRET = { mpp: { challengeBindingSecret: 'test-secret', allowUnsafeMemoryStore: true } }; +const values = new Map(); +const SHARED_STORE: ReplayStore = declareProductionReplayStore({ + isDurable: true, + isShared: true, + async delete(key) { + values.delete(key); + }, + async get(key) { + return (values.get(key) ?? null) as never; + }, + async put(key, value) { + values.set(key, value); + }, + async putIfAbsent(key, value) { + if (values.has(key)) return false; + values.set(key, value); + return true; + }, +}); +const UNKNOWN_ATOMIC_STORE: ReplayStore = { + async delete() {}, + async get() { + return null; + }, + async put() {}, + async putIfAbsent() { + return true; + }, +}; +const LEGACY_STORE: Store.Store = { + async delete() {}, + async get() { + return null; + }, + async put() {}, +}; describe('configure', () => { it('applies the canonical defaults', async () => { @@ -22,15 +60,22 @@ describe('configure', () => { }); it('refuses the demo signer on mainnet', async () => { - await expect(configure({ ...SECRET, network: 'solana_mainnet' })).rejects.toThrow(DemoSignerOnMainnetError); + const mainnetMpp = { mpp: { challengeBindingSecret: 'test-secret' }, replayStore: SHARED_STORE }; + await expect(configure({ ...mainnetMpp, network: 'solana_mainnet' })).rejects.toThrow(DemoSignerOnMainnetError); const signer = await Signer.generate(); - process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE = '1'; - try { - const config = await configure({ ...SECRET, network: 'solana_mainnet', operator: { signer } }); - expect(config.operator.recipient).toBe(signer.pubkey); - } finally { - delete process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE; - } + const config = await configure({ + ...mainnetMpp, + network: 'solana_mainnet', + operator: { signer }, + }); + expect(config.operator.recipient).toBe(signer.pubkey); + }); + + 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( + /forbidden on mainnet/, + ); }); it('accepts the shipped protocols (mpp + x402)', async () => { @@ -38,6 +83,99 @@ describe('configure', () => { expect(config.accept).toEqual(['x402', 'mpp']); }); + it('does not require or construct an MPP replay store for x402-only config', async () => { + // Store.Store remains accepted by the public input type for x402 compatibility. + const config = await configure({ accept: ['x402'], replayStore: LEGACY_STORE }); + expect(config.accept).toEqual(['x402']); + expect(config.replayStore).toBe(LEGACY_STORE); + }); + + it('does not reject the MPP-only unsafe-memory flag for x402-only mainnet config', async () => { + const config = await configure({ + accept: ['x402'], + mpp: { allowUnsafeMemoryStore: true }, + network: 'solana_mainnet', + operator: { signer: await Signer.generate() }, + }); + expect(config.mpp.allowUnsafeMemoryStore).toBe(true); + }); + + it('fails closed at runtime for a legacy non-atomic MPP store', async () => { + await expect(configure({ ...SECRET, replayStore: LEGACY_STORE as ReplayStore })).rejects.toThrow( + /atomic putIfAbsent/, + ); + }); + + it.each([ + ['isShared-only', { ...SHARED_STORE, isDurable: false }], + ['isDurable-only', { ...SHARED_STORE, isShared: false }], + ['unknown capabilities', UNKNOWN_ATOMIC_STORE], + ] satisfies readonly [string, ReplayStore][])( + 'rejects an undeclared atomic store with %s', + async (_label, replayStore) => { + const signer = await Signer.generate(); + await expect( + configure({ + ...SECRET, + mpp: { challengeBindingSecret: 'test-secret' }, + network: 'solana_devnet', + operator: { signer }, + replayStore, + }), + ).rejects.toThrow(/declareProductionReplayStore/); + }, + ); + + it('requires external production stores to affirm capabilities before declaration', () => { + expect(() => declareProductionReplayStore(UNKNOWN_ATOMIC_STORE)).toThrow(/isShared=true and isDurable=true/); + }); + + it('rejects a spread-cloned memory store even when it claims production capabilities', async () => { + const replayStore = { + ...createUnsafeMemoryReplayStore(), + isDurable: true, + isShared: true, + }; + + expect(() => declareProductionReplayStore(createUnsafeMemoryReplayStore())).toThrow(/Process-local memory/); + await expect( + configure({ + ...SECRET, + mpp: { allowUnsafeMemoryStore: true, challengeBindingSecret: 'test-secret' }, + network: 'solana_devnet', + operator: { signer: await Signer.generate() }, + replayStore, + }), + ).rejects.toThrow(/declareProductionReplayStore/); + }); + + it('derives MPP sponsorship from a raw non-fee-payer signer', async () => { + const signer = (await Signer.generate()).signer; + const config = await configure({ + mpp: { allowUnsafeMemoryStore: true, challengeBindingSecret: 'test-secret' }, + operator: { feePayer: false, signer }, + }); + expect(config.operator.feePayer).toBe(false); + expect(config.operator.signer.isFeePayer).toBe(false); + }); + + it('rejects a prewrapped signer configured to sponsor when it cannot', async () => { + const signer = Signer.from((await Signer.generate()).signer, { feePayer: false }); + await expect( + configure({ + mpp: { allowUnsafeMemoryStore: true, challengeBindingSecret: 'test-secret' }, + operator: { feePayer: true, signer }, + }), + ).rejects.toThrow(/permits fee sponsorship/); + }); + + it('rejects an x402 configuration without a sponsoring operator', async () => { + const signer = Signer.from((await Signer.generate()).signer, { feePayer: false }); + await expect(configure({ accept: ['x402'], operator: { signer } })).rejects.toThrow( + /x402 requires an operator fee payer/, + ); + }); + it('rejects protocols this SDK does not ship', async () => { await expect(configure({ ...SECRET, accept: ['stripe' as never] })).rejects.toThrow(ProtocolNotSupportedError); await expect(configure({ ...SECRET, accept: [] })).rejects.toThrow(ConfigurationError); @@ -56,35 +194,48 @@ describe('configure', () => { 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.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE = '1'; - try { - const config = await configure({ network: 'solana_devnet', operator: { signer } }); - expect(config.mpp.challengeBindingSecret).toBe('env-secret'); - } finally { - delete process.env.MPP_SECRET_KEY; - delete process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE; - } + const config = await configure({ network: 'solana_devnet', operator: { signer }, replayStore: SHARED_STORE }); + expect(config.mpp.challengeBindingSecret).toBe('env-secret'); + delete process.env.MPP_SECRET_KEY; }); - it('requires a shared MPP replay store outside localnet unless explicitly opted in', async () => { + 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' }, network: 'solana_devnet', operator: { signer }, }), - ).rejects.toThrow(/PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE/); + ).rejects.toThrow(/atomic shared replayStore/); + + await expect(configure({ mpp: { challengeBindingSecret: 'test-secret' } })).rejects.toThrow( + /atomic shared replayStore/, + ); + const local = await configure(SECRET); + expect(local.replayStore).toBeDefined(); + + const production = await configure({ + ...SECRET, + network: 'solana_devnet', + operator: { signer }, + replayStore: SHARED_STORE, + }); + expect(production.replayStore).toBe(SHARED_STORE); + }); + + it('honors the explicit in-memory replay-store environment opt-in', async () => { process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE = '1'; try { - await expect( - configure({ - ...SECRET, - network: 'solana_devnet', - operator: { signer }, - }), - ).resolves.toMatchObject({ network: 'solana_devnet' }); + const config = await configure({ + mpp: { challengeBindingSecret: 'test-secret' }, + network: 'solana_devnet', + operator: { signer: await Signer.generate() }, + }); + expect(config.mpp.allowUnsafeMemoryStore).toBe(true); + expect(config.replayStore).toBeDefined(); } finally { delete process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE; } @@ -98,7 +249,7 @@ describe('configure', () => { process.env.PAY_KIT_RPC_URL = 'http://rpc.example'; process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE = '1'; try { - const config = await configureFromEnv(); + const config = await configureFromEnv('PAY_KIT_', SHARED_STORE); expect(config.network).toBe('solana_devnet'); expect(config.mpp.challengeBindingSecret).toBe('env-secret'); expect(config.mpp.expiresIn).toBe(60); @@ -113,4 +264,32 @@ describe('configure', () => { delete process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE; } }); + + 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_ALLOW_INMEMORY_REPLAY_STORE = '1'; + try { + const config = await configureFromEnv('APP_'); + expect(config.mpp.allowUnsafeMemoryStore).toBe(true); + expect(config.replayStore).toBeDefined(); + } finally { + delete process.env.APP_NETWORK; + delete process.env.APP_MPP_SECRET; + delete process.env.APP_ALLOW_INMEMORY_REPLAY_STORE; + } + }); + + 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'; + try { + await expect(configureFromEnv('APP_')).rejects.toThrow(/atomic shared replayStore/); + } finally { + delete process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE; + delete process.env.APP_NETWORK; + delete process.env.APP_MPP_SECRET; + } + }); }); diff --git a/typescript/packages/pay-kit/src/__tests__/middleware.test.ts b/typescript/packages/pay-kit/src/__tests__/middleware.test.ts index cf9376205..c90c7654c 100644 --- a/typescript/packages/pay-kit/src/__tests__/middleware.test.ts +++ b/typescript/packages/pay-kit/src/__tests__/middleware.test.ts @@ -47,7 +47,7 @@ const fakeAdapter: ProtocolAdapter = { }; async function setup(): Promise { - const config = await configure({ mpp: { challengeBindingSecret: 's3cret' } }); + const config = await configure({ mpp: { challengeBindingSecret: 's3cret', allowUnsafeMemoryStore: true } }); return createPayKit({ adapters: [fakeAdapter], config, pricing: { report: { amount: usd('0.10') } } }); } 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 328f42c3d..c390f7c6c 100644 --- a/typescript/packages/pay-kit/src/__tests__/mpp-adapter.test.ts +++ b/typescript/packages/pay-kit/src/__tests__/mpp-adapter.test.ts @@ -1,26 +1,28 @@ import { Challenge } from '@solana/mpp/client'; -import { describe, expect, it } from 'vitest'; +import { 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 { Signer } from '../signer.js'; +import { createUnsafeMemoryReplayStore, declareProductionReplayStore } from '../replay-store.js'; const SELLER = 'AyNAa2VPe2t5pgg8M61iE6kqMudkV98zsT4rkAZuU6tj'; const PLATFORM = 'CXG3Pq3DwZb1HVckhPQbVxiwoNGM3jNGYvC2BSdkj1pK'; async function setup() { const config = await configure({ - mpp: { challengeBindingSecret: 'adapter-test-secret', realm: 'Adapter test' }, + mpp: { challengeBindingSecret: 'adapter-test-secret', realm: 'Adapter test', allowUnsafeMemoryStore: true }, operator: { recipient: SELLER, signer: await Signer.generate() }, + replayStore: createUnsafeMemoryReplayStore(), }); return { adapter: createMppAdapter(config), config }; } function createSharedTestReplayStore() { const entries = new Map(); - return { + return declareProductionReplayStore({ delete: async (key: string) => { entries.delete(key); }, @@ -35,7 +37,7 @@ function createSharedTestReplayStore() { entries.set(key, value); return true; }, - }; + }); } function gate(params: Parameters[0]['feeWithin'] = undefined) { @@ -46,6 +48,47 @@ function gate(params: Parameters[0]['feeWithin'] = undefined } describe('createMppAdapter', () => { + it('does not advertise fee sponsorship for a non-fee-payer signer', async () => { + const config = await configure({ + mpp: { allowUnsafeMemoryStore: true, challengeBindingSecret: 'adapter-test-secret' }, + operator: { recipient: SELLER, signer: Signer.from((await Signer.generate()).signer, { feePayer: false }) }, + }); + const adapter = createMppAdapter(config); + const headers = await adapter.challengeHeaders(gate(), new Request('http://t/marketplace')); + const challenge = Challenge.deserialize(headers['www-authenticate'] as string); + const methodDetails = challenge.request.methodDetails as { feePayer?: boolean }; + expect(methodDetails.feePayer).toBeUndefined(); + expect(config.operator.feePayer).toBe(false); + }); + + it('rejects a prebuilt mainnet config carrying the demo signer', async () => { + const local = await configure({ + mpp: { challengeBindingSecret: 'adapter-test-secret' }, + replayStore: createSharedTestReplayStore(), + }); + expect(() => createMppAdapter({ ...local, network: 'solana_mainnet' })).toThrow(/demo signer is public/); + }); + + it('rejects a hand-built non-local config without a replay store', async () => { + const { config } = await setup(); + expect(() => createMppAdapter({ ...config, network: 'solana_devnet', replayStore: undefined })).toThrow( + /replayStore resolved by configure/, + ); + }); + + it('requires an explicit unsafe flag before allocating a process-local store', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + await configure({ + mpp: { challengeBindingSecret: 'adapter-test-secret', allowUnsafeMemoryStore: true }, + operator: { recipient: SELLER, signer: await Signer.generate() }, + }); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('process-local replay store')); + } finally { + warn.mockRestore(); + } + }); + it('detects MPP payment credentials', async () => { const { adapter } = await setup(); expect(adapter.detect(new Request('http://t/', { headers: { authorization: 'Payment abc' } }))).toBe(true); 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 new file mode 100644 index 000000000..fd9ddeeb6 --- /dev/null +++ b/typescript/packages/pay-kit/src/__tests__/mpp-replay-race.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const captured = vi.hoisted(() => ({ + charge: [] as any[], + subscription: [] as any[], + subscriptionTokenPrograms: [] as string[], +})); + +vi.mock('@solana/mpp/server', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + Mppx: { + create: () => ({ + charge: () => async () => ({ + challenge: new Response(null, { headers: { 'www-authenticate': 'Payment test' }, status: 402 }), + status: 402, + }), + subscription: () => async () => ({ + challenge: new Response(null, { headers: { 'www-authenticate': 'Payment test' }, status: 402 }), + status: 402, + }), + }), + }, + solana: { + ...actual.solana, + charge: (options: any) => { + captured.charge.push(options.store); + return options; + }, + subscription: (options: any) => { + captured.subscription.push(options.store); + captured.subscriptionTokenPrograms.push(options.tokenProgram); + return options; + }, + }, + }; +}); + +import { createMppAdapter } from '../adapters/mpp.js'; +import { configure } from '../config.js'; +import { Gate } from '../gate.js'; +import { usd } from '../price.js'; +import { declareProductionReplayStore, type ReplayStore } from '../replay-store.js'; +import { Signer } from '../signer.js'; + +const SELLER = 'AyNAa2VPe2t5pgg8M61iE6kqMudkV98zsT4rkAZuU6tj'; +const PULLER = 'CXG3Pq3DwZb1HVckhPQbVxiwoNGM3jNGYvC2BSdkj1pK'; + +function sharedStore(): ReplayStore { + const values = new Map(); + return declareProductionReplayStore({ + isDurable: true, + isShared: true, + async delete(key) { + values.delete(key); + }, + async get(key) { + return (values.get(key) ?? null) as never; + }, + async put(key, value) { + values.set(key, value); + }, + async putIfAbsent(key, value) { + if (values.has(key)) return false; + values.set(key, value); + return true; + }, + }); +} + +describe('MPP replay-store adapter wiring', () => { + beforeEach(() => { + captured.charge.length = 0; + captured.subscription.length = 0; + captured.subscriptionTokenPrograms.length = 0; + }); + + it('atomically reserves charge markers across two independent adapters', async () => { + const replayStore = sharedStore(); + const config = await configure({ + mpp: { challengeBindingSecret: 'race-test-secret' }, + operator: { recipient: SELLER, signer: await Signer.generate() }, + replayStore, + }); + const gate = Gate.create( + { amount: usd('1.00'), name: 'charge', payTo: SELLER }, + { accept: ['mpp'], payTo: SELLER }, + ); + const adapters = [createMppAdapter(config), createMppAdapter(config)]; + await Promise.all(adapters.map(adapter => adapter.challengeHeaders(gate, new Request('http://test/charge')))); + + const results = await Promise.allSettled( + captured.charge.map(store => store.put('solana-charge:consumed:signature', true)), + ); + expect(results.map(result => result.status).sort()).toEqual(['fulfilled', 'rejected']); + }); + + it('forwards the atomic replay store into subscription construction', async () => { + const config = await configure({ + mpp: { challengeBindingSecret: 'subscription-store-test' }, + operator: { recipient: SELLER, signer: await Signer.generate() }, + replayStore: sharedStore(), + }); + const gate = Gate.create( + { + amount: usd('1.00'), + kind: 'subscription', + name: 'plan', + payTo: SELLER, + subscription: { periodCount: 1, periodUnit: 'day', planId: 'plan-1', puller: PULLER }, + }, + { accept: ['mpp'], payTo: SELLER }, + ); + await createMppAdapter(config).challengeHeaders(gate, new Request('http://test/subscription')); + expect(captured.subscription).toHaveLength(1); + await expect(captured.subscription[0].put('solana-subscription:consumed:id', true)).resolves.toBeUndefined(); + await expect(captured.subscription[0].put('solana-subscription:consumed:id', true)).rejects.toMatchObject({ + message: expect.stringContaining('MPP replay key is already reserved'), + name: 'VerificationFailedError', + }); + }); + + it('uses Token-2022 for a Token-2022 subscription mint', async () => { + const config = await configure({ + mpp: { challengeBindingSecret: 'subscription-token-program-test' }, + operator: { recipient: SELLER, signer: await Signer.generate() }, + replayStore: sharedStore(), + stablecoins: ['PYUSD'], + }); + const gate = Gate.create( + { + amount: usd('1.00', 'PYUSD'), + kind: 'subscription', + name: 'token-2022-plan', + payTo: SELLER, + subscription: { periodCount: 1, periodUnit: 'day', planId: 'plan-2', puller: PULLER }, + }, + { accept: ['mpp'], payTo: SELLER }, + ); + + await createMppAdapter(config).challengeHeaders(gate, new Request('http://test/token-2022-subscription')); + expect(captured.subscriptionTokenPrograms).toEqual(['TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb']); + }); +}); 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 b875afbdd..1f54a6dd0 100644 --- a/typescript/packages/pay-kit/src/__tests__/mpp-session.test.ts +++ b/typescript/packages/pay-kit/src/__tests__/mpp-session.test.ts @@ -1,5 +1,4 @@ import { createMemorySessionStore } from '@solana/mpp/server'; -import { Store } from 'mppx'; import { describe, expect, it, vi } from 'vitest'; import { createSessionEngine } from '../adapters/mpp-session.js'; @@ -7,8 +6,33 @@ import { configure } from '../config.js'; import { Gate } from '../gate.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. +function sharedReplayStore(): ReplayStore { + const values = new Map(); + return declareProductionReplayStore({ + isDurable: true, + isShared: true, + async delete(key) { + values.delete(key); + }, + async get(key) { + return (values.get(key) ?? null) as never; + }, + async put(key, value) { + values.set(key, value); + }, + async putIfAbsent(key, value) { + if (values.has(key)) return false; + values.set(key, value); + return true; + }, + }); +} + async function setup( options: { readonly network?: 'solana_devnet' | 'solana_localnet'; @@ -23,7 +47,7 @@ async function setup( }, network: options.network ?? 'solana_localnet', operator: { signer }, - replayStore: Store.memory(), + replayStore: sharedReplayStore(), }); const gate = Gate.create( { diff --git a/typescript/packages/pay-kit/src/__tests__/openapi.test.ts b/typescript/packages/pay-kit/src/__tests__/openapi.test.ts index 0c9a3c30d..769bc2a79 100644 --- a/typescript/packages/pay-kit/src/__tests__/openapi.test.ts +++ b/typescript/packages/pay-kit/src/__tests__/openapi.test.ts @@ -44,7 +44,7 @@ const PLAN = 'PLan11111111111111111111111111111111111111'; async function paykit() { return createPayKit({ accept: ['x402', 'mpp'], - mpp: { challengeBindingSecret: 'openapi-test-secret' }, + mpp: { challengeBindingSecret: 'openapi-test-secret', allowUnsafeMemoryStore: true }, network: 'solana_localnet', pricing: { feed: subscription(usd('0.10'), { diff --git a/typescript/packages/pay-kit/src/__tests__/paykit.test.ts b/typescript/packages/pay-kit/src/__tests__/paykit.test.ts index a6be56aed..ac89b5df8 100644 --- a/typescript/packages/pay-kit/src/__tests__/paykit.test.ts +++ b/typescript/packages/pay-kit/src/__tests__/paykit.test.ts @@ -6,9 +6,31 @@ import { ConfigurationError, InvalidProofError, UnknownGateError } from '../erro 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'; const CREDENTIAL_HEADER = 'x-fake-credential'; +function createSharedTestReplayStore() { + 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, + put: async (key: string, value: unknown) => { + entries.set(key, value); + }, + putIfAbsent: async (key: string, value: unknown) => { + if (entries.has(key)) return false; + entries.set(key, value); + return true; + }, + }); +} + function fakeAdapter(config: PayKitConfig): ProtocolAdapter { return { async acceptsEntry(gate) { @@ -45,7 +67,7 @@ function fakeAdapter(config: PayKitConfig): ProtocolAdapter { } async function setup() { - const config = await configure({ mpp: { challengeBindingSecret: 's3cret' } }); + const config = await configure({ mpp: { challengeBindingSecret: 's3cret', allowUnsafeMemoryStore: true } }); return createPayKit({ adapters: [fakeAdapter(config)], config, @@ -57,6 +79,49 @@ async function setup() { } describe('createPayKit', () => { + it('rejects a prebuilt mainnet config carrying the unsafe replay-store escape', async () => { + const local = await configure({ + mpp: { challengeBindingSecret: 's3cret', allowUnsafeMemoryStore: true }, + operator: { signer: await Signer.generate() }, + }); + await expect( + createPayKit({ + config: { ...local, network: 'solana_mainnet' }, + pricing: { report: usd('0.10') }, + }), + ).rejects.toThrow(/forbidden on mainnet/); + }); + + it('rejects a prebuilt mainnet config that hides its process-local replay store', async () => { + const local = await configure({ + mpp: { challengeBindingSecret: 's3cret', allowUnsafeMemoryStore: true }, + operator: { signer: await Signer.generate() }, + }); + await expect( + createPayKit({ + config: { + ...local, + mpp: { ...local.mpp, allowUnsafeMemoryStore: false }, + network: 'solana_mainnet', + }, + pricing: { report: usd('0.10') }, + }), + ).rejects.toThrow(/declareProductionReplayStore/); + }); + + it('rejects a prebuilt mainnet config carrying the demo signer', async () => { + const local = await configure({ + mpp: { challengeBindingSecret: 's3cret' }, + replayStore: createSharedTestReplayStore(), + }); + await expect( + createPayKit({ + config: { ...local, network: 'solana_mainnet' }, + pricing: { report: usd('0.10') }, + }), + ).rejects.toThrow(/demo signer is public/); + }); + it('renders a 402 challenge when no credential is present', async () => { const paykit = await setup(); const request = new Request('http://api.test/report'); @@ -111,7 +176,9 @@ describe('createPayKit', () => { }); it('serves a protocol-owned response for browser/worker requests, JSON 402 for API', async () => { - const config = await configure({ mpp: { challengeBindingSecret: 's3cret', html: true } }); + const config = await configure({ + mpp: { challengeBindingSecret: 's3cret', html: true, allowUnsafeMemoryStore: true }, + }); const htmlAdapter: ProtocolAdapter = { ...fakeAdapter(config), async respond(_gate, request) { @@ -180,7 +247,7 @@ describe('createPayKit', () => { }); it('requires a pricing catalogue for name references', async () => { - const config = await configure({ mpp: { challengeBindingSecret: 's3cret' } }); + const config = await configure({ mpp: { challengeBindingSecret: 's3cret', allowUnsafeMemoryStore: true } }); const paykit = await createPayKit({ adapters: [fakeAdapter(config)], config }); await expect(paykit.requirePayment(new Request('http://api.test/x'), 'report')).rejects.toThrow( ConfigurationError, diff --git a/typescript/packages/pay-kit/src/__tests__/x402.test.ts b/typescript/packages/pay-kit/src/__tests__/x402.test.ts index f83ab51fd..539056b33 100644 --- a/typescript/packages/pay-kit/src/__tests__/x402.test.ts +++ b/typescript/packages/pay-kit/src/__tests__/x402.test.ts @@ -34,7 +34,7 @@ import { usd } from '../price.js'; import { gateDefaults } from '../pricing.js'; async function testConfig(): Promise { - return await configure({ mpp: { challengeBindingSecret: 'x402-test-secret' }, network: 'solana_localnet' }); + return await configure({ accept: ['x402'], network: 'solana_localnet' }); } function gateFor(config: PayKitConfig, amount = usd('0.10')): Gate { @@ -42,6 +42,16 @@ function gateFor(config: PayKitConfig, amount = usd('0.10')): Gate { } describe('x402 exact adapter', () => { + it('rejects a prebuilt config that disables its required fee payer', async () => { + const config = await testConfig(); + expect(() => + createX402ExactAdapter({ + ...config, + operator: { ...config.operator, feePayer: false }, + }), + ).toThrow(/x402 requires an operator fee payer/); + }); + it('advertises a canonical x402 accepts entry', async () => { const config = await testConfig(); const adapter = createX402ExactAdapter(config); @@ -76,6 +86,13 @@ describe('x402 exact adapter', () => { }); describe('x402 upto engine', () => { + it('rejects a prebuilt config that disables its required fee payer', async () => { + const config = await testConfig(); + expect(() => new X402Upto({ ...config, operator: { ...config.operator, feePayer: false } })).toThrow( + /x402 requires an operator fee payer/, + ); + }); + it('advertises an upto accepts entry with role bindings', async () => { const config = await testConfig(); const upto = new X402Upto(config); diff --git a/typescript/packages/pay-kit/src/adapters/mpp.ts b/typescript/packages/pay-kit/src/adapters/mpp.ts index 3e5813b13..7a57a023a 100644 --- a/typescript/packages/pay-kit/src/adapters/mpp.ts +++ b/typescript/packages/pay-kit/src/adapters/mpp.ts @@ -1,15 +1,16 @@ -import { guardChallengeValue, resolveStablecoinMint, TOKEN_PROGRAM } from '@solana/mpp'; +import { defaultTokenProgramForCurrency, guardChallengeValue, resolveStablecoinMint } from '@solana/mpp'; import { Mppx, solana } from '@solana/mpp/server'; import { Receipt } from 'mppx'; import type { ProtocolAdapter } from '../adapter.js'; import type { AcceptsEntry } from '../challenge.js'; import { requireMint, resolveCoin } from '../coin.js'; -import type { PayKitConfig } from '../config.js'; -import { InvalidProofError } from '../errors.js'; +import { assertReplayStorePolicy, type PayKitConfig } from '../config.js'; +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'; /** Settlement header mirrored by every PayKit SDK. */ const SETTLEMENT_SIGNATURE_HEADER = 'x-payment-settlement-signature'; @@ -49,6 +50,14 @@ function schemeFor(gate: Gate): 'charge' | 'subscription' { * distinct (recipient, splits) shape and cached. */ export function createMppAdapter(config: PayKitConfig): ProtocolAdapter { + if (config.replayStore === undefined) { + throw new ConfigurationError('MPP adapter requires the replayStore resolved by configure().'); + } + if (!isAtomicReplayStore(config.replayStore)) { + throw new ConfigurationError('MPP adapter replayStore must implement atomic putIfAbsent(key, value).'); + } + assertReplayStorePolicy(config); + const replayStore = atomicReplayStoreView(config.replayStore); const network = toSolanaNetwork(config.network); const handlers = new Map(); @@ -87,7 +96,8 @@ export function createMppAdapter(config: PayKitConfig): ProtocolAdapter { puller, recipient: gate.payTo, rpcUrl: config.rpcUrl, - tokenProgram: TOKEN_PROGRAM, + store: replayStore, + tokenProgram: defaultTokenProgramForCurrency(mint, network), ...signer, }), ], @@ -105,6 +115,7 @@ export function createMppAdapter(config: PayKitConfig): ProtocolAdapter { const mppx = Mppx.create({ methods: [ solana.charge({ + allowUnsafeMemoryStore: config.mpp.allowUnsafeMemoryStore === true, currency: mint, decimals: 6, ...(config.mpp.html ? { html: true } : {}), @@ -113,7 +124,7 @@ export function createMppAdapter(config: PayKitConfig): ProtocolAdapter { rpcUrl: config.rpcUrl, ...signer, ...(splits.length > 0 ? { splits: [...splits] } : {}), - ...(config.replayStore ? { store: config.replayStore } : {}), + store: replayStore, }), ], realm, diff --git a/typescript/packages/pay-kit/src/adapters/x402-upto.ts b/typescript/packages/pay-kit/src/adapters/x402-upto.ts index fbea74dcc..195280ba3 100644 --- a/typescript/packages/pay-kit/src/adapters/x402-upto.ts +++ b/typescript/packages/pay-kit/src/adapters/x402-upto.ts @@ -16,7 +16,7 @@ import { getStablecoinTokenProgram, resolveStablecoinMint } from '@x402/svm'; import { UptoSvmScheme as UptoSvmFacilitator } from '@x402/svm/upto/facilitator'; import { requireMint, resolveCoin } from '../coin.js'; -import type { PayKitConfig } from '../config.js'; +import { assertReplayStorePolicy, type PayKitConfig } from '../config.js'; import { InvalidProofError } from '../errors.js'; import type { Price } from '../price.js'; import { caip2 } from '../protocol.js'; @@ -107,6 +107,7 @@ export class X402Upto { readonly #stablecoins: readonly string[]; constructor(config: PayKitConfig) { + assertReplayStorePolicy(config); this.#network = caip2(config.network) as Network; this.#feePayer = config.operator.signer.pubkey; this.#receiverAuthorizer = config.operator.signer.pubkey; diff --git a/typescript/packages/pay-kit/src/adapters/x402.ts b/typescript/packages/pay-kit/src/adapters/x402.ts index 74d7d720b..d5d9fa7e6 100644 --- a/typescript/packages/pay-kit/src/adapters/x402.ts +++ b/typescript/packages/pay-kit/src/adapters/x402.ts @@ -12,7 +12,7 @@ import { ExactSvmScheme as ExactSvmFacilitator } from '@x402/svm/exact/facilitat import type { ProtocolAdapter } from '../adapter.js'; import type { AcceptsEntry } from '../challenge.js'; import { requireMint, resolveCoin } from '../coin.js'; -import type { PayKitConfig } from '../config.js'; +import { assertReplayStorePolicy, type PayKitConfig } from '../config.js'; import { InvalidProofError } from '../errors.js'; import type { Gate } from '../gate.js'; import type { Payment } from '../payment.js'; @@ -37,6 +37,7 @@ const PAYMENT_REQUIRED_HEADER = 'payment-required'; * `X-PAYMENT` (or `PAYMENT-SIGNATURE`), matching the x402 HTTP convention. */ export function createX402ExactAdapter(config: PayKitConfig): ProtocolAdapter { + assertReplayStorePolicy(config); const network = caip2(config.network) as Network; const operator = config.operator.signer.pubkey; diff --git a/typescript/packages/pay-kit/src/config.ts b/typescript/packages/pay-kit/src/config.ts index fa80c506e..3dfffa9b6 100644 --- a/typescript/packages/pay-kit/src/config.ts +++ b/typescript/packages/pay-kit/src/config.ts @@ -5,10 +5,18 @@ import type { Store } from 'mppx'; import { ConfigurationError, DemoSignerOnMainnetError, ProtocolNotSupportedError } from './errors.js'; import { type Stablecoin, STABLECOINS } from './price.js'; import { type Network, type NetworkSlug, type Protocol, toNetwork, toSolanaNetwork } from './protocol.js'; +import { + createUnsafeMemoryReplayStore, + isAtomicReplayStore, + isAuthorizedUnsafeMemoryReplayStore, + isProductionReplayStore, +} from './replay-store.js'; import { type KeychainSigner, type PayKitSigner, Signer } from './signer.js'; /** MPP protocol options. */ export type MppOptions = { + /** Explicitly permit process-local replay state for development/tests. */ + readonly allowUnsafeMemoryStore?: boolean; /** * HMAC secret binding challenges to their contents. Resolved from * `PAY_KIT_MPP_SECRET` or `MPP_SECRET_KEY` when omitted; auto-generated @@ -37,7 +45,11 @@ export type X402Options = Record; /** Merchant identity: where money lands and which key signs. */ export type OperatorParams = { - /** Whether the operator signer sponsors transaction fees. */ + /** + * Whether the operator signer sponsors transaction fees. Defaults to the + * signer's `isFeePayer` capability and cannot be true when it is false. + * x402 SVM methods require sponsorship. + */ readonly feePayer?: boolean; /** Settlement address. Defaults to the signer's public key. */ readonly recipient?: string; @@ -65,7 +77,7 @@ export type ConfigureParams = { readonly operator?: OperatorParams; /** Run boot-time safety checks. */ readonly preflight?: boolean; - /** Replay-protection store. Use a persistent backend in production. */ + /** Replay-protection store. MPP validates atomic/shared capability at runtime. */ readonly replayStore?: Store.Store; /** Defaults to the public RPC endpoint for the network. */ readonly rpcUrl?: string; @@ -78,6 +90,7 @@ export type ConfigureParams = { export type PayKitConfig = { readonly accept: readonly Protocol[]; readonly mpp: { + readonly allowUnsafeMemoryStore: boolean; readonly challengeBindingSecret: string; readonly expiresIn: number; readonly html: boolean; @@ -111,6 +124,55 @@ function resolveChallengeBindingSecret(network: Network, provided: string | unde return crypto.randomUUID(); } +/** Revalidate resolved configuration safety when it crosses an API boundary. */ +export function assertReplayStorePolicy( + config: Pick, +): void { + if (config.network === 'solana_mainnet' && config.operator.signer.isDemo) { + throw new DemoSignerOnMainnetError( + 'The demo signer is public and must not be used on mainnet. Provide operator.signer.', + ); + } + if (config.operator.feePayer && !config.operator.signer.isFeePayer) { + throw new ConfigurationError( + 'operator.feePayer=true requires an operator signer that permits fee sponsorship.', + ); + } + if (config.accept.includes('x402') && !config.operator.feePayer) { + throw new ConfigurationError( + 'x402 requires an operator fee payer; its SVM challenge cannot be completed without one.', + ); + } + if (config.network === 'solana_mainnet' && config.accept.includes('mpp') && config.mpp.allowUnsafeMemoryStore) { + throw new ConfigurationError( + 'mpp.allowUnsafeMemoryStore is forbidden on mainnet; inject an atomic shared replayStore.', + ); + } + if (!config.accept.includes('mpp')) return; + + const store = config.replayStore; + if (store === undefined) { + throw new ConfigurationError( + 'MPP requires an injected atomic shared replayStore; ' + + 'mpp.allowUnsafeMemoryStore or PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1 is development-only.', + ); + } + if (!isAtomicReplayStore(store)) { + throw new ConfigurationError( + 'MPP replayStore must implement atomic putIfAbsent(key, value); legacy non-atomic stores fail closed.', + ); + } + if ( + !isProductionReplayStore(store) && + !(config.mpp.allowUnsafeMemoryStore && isAuthorizedUnsafeMemoryReplayStore(store)) + ) { + throw new ConfigurationError( + 'MPP replayStore must be declared with declareProductionReplayStore() after verifying atomic shared durability; ' + + 'unknown stores fail closed.', + ); + } +} + /** * Builds and validates the boot configuration. Everything downstream * (pricing, adapters, the dispatcher) derives its defaults from this object. @@ -161,8 +223,13 @@ export async function configure(params: ConfigureParams = {}): Promise { +export async function configureFromEnv(prefix = 'PAY_KIT_', replayStore?: Store.Store): Promise { const env = (name: string) => process.env[`${prefix}${name}`]?.trim() || undefined; const list = (value: string | undefined) => value?.split(',').map(entry => entry.trim()) ?? undefined; + const allowUnsafeMemoryStore = env('ALLOW_INMEMORY_REPLAY_STORE'); const expiresIn = env('MPP_EXPIRES_IN'); return await configure({ accept: list(env('ACCEPT')) as readonly Protocol[] | undefined, mpp: { + allowUnsafeMemoryStore: allowUnsafeMemoryStore === '1', challengeBindingSecret: env('MPP_SECRET'), expiresIn: expiresIn === undefined ? undefined : Number(expiresIn), realm: env('MPP_REALM'), @@ -234,6 +315,7 @@ export async function configureFromEnv(prefix = 'PAY_KIT_'): Promise( ...configureParams } = options; const config = prebuilt ?? (await configure(configureParams)); + assertReplayStorePolicy(config); const handleSettleFailure = onSettleError ?? warnSettleFailure; const adapters = diff --git a/typescript/packages/pay-kit/src/replay-store.ts b/typescript/packages/pay-kit/src/replay-store.ts new file mode 100644 index 000000000..b4a40ef6c --- /dev/null +++ b/typescript/packages/pay-kit/src/replay-store.ts @@ -0,0 +1,75 @@ +import { + createAtomicReplayStoreView, + createUnsafeMemoryReplayStore as createMppUnsafeMemoryReplayStore, + isUnsafeMemoryReplayStore as isMppUnsafeMemoryReplayStore, +} from '@solana/mpp/server'; +import type { Store } from 'mppx'; + +const productionReplayStores = new WeakSet(); +const unsafeMemoryReplayStores = new WeakSet(); + +/** Atomic replay-store contract required by MPP server construction. */ +export type ReplayStore = Store.Store & { + readonly isDurable?: boolean; + readonly isShared?: boolean; + putIfAbsent(key: string, value: unknown): Promise; +}; + +/** Narrow the legacy Store.Store surface to the atomic replay contract. */ +export function isAtomicReplayStore(store: Store.Store): store is ReplayStore { + return 'putIfAbsent' in store && typeof store.putIfAbsent === 'function'; +} + +/** + * Declare an externally provided replay store safe for production use. + * + * This is an affirmative application trust boundary, not a runtime probe: + * call it only after verifying that the backend is shared across every + * worker, durable across restarts, and implements `putIfAbsent` atomically. + * The declaration is retained by object identity, so copying capability + * booleans onto an unknown or process-local store cannot grant it trust. + */ +export function declareProductionReplayStore(store: T): T { + if (isUnsafeMemoryReplayStore(store)) { + throw new TypeError('Process-local memory stores cannot be declared production replay stores.'); + } + if (!isAtomicReplayStore(store)) { + throw new TypeError('Production replay stores must implement atomic putIfAbsent(key, value).'); + } + if (store.isShared !== true || store.isDurable !== true) { + throw new TypeError('Production replay stores must set isShared=true and isDurable=true.'); + } + productionReplayStores.add(store); + return store; +} + +export function isProductionReplayStore(store: ReplayStore): boolean { + return productionReplayStores.has(store); +} + +export function isUnsafeMemoryReplayStore(store: Store.Store): boolean { + return unsafeMemoryReplayStores.has(store) || isMppUnsafeMemoryReplayStore(store); +} + +/** Only a built-in unsafe memory store may use the explicit development escape. */ +export function isAuthorizedUnsafeMemoryReplayStore(store: Store.Store): boolean { + return unsafeMemoryReplayStores.has(store); +} + +/** Explicit process-local store used only behind the unsafe development flag. */ +export function createUnsafeMemoryReplayStore(): ReplayStore { + const store = createMppUnsafeMemoryReplayStore(); + unsafeMemoryReplayStores.add(store); + return store; +} + +/** + * Adapt pay-kit's atomic store to the upstream get/put Store surface. + * + * #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. + */ +export function atomicReplayStoreView(store: ReplayStore): ReplayStore { + return createAtomicReplayStoreView(store) as ReplayStore; +} From 631d36e791ce2bb0d7f48d21b6a7939db5ea0636 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:05:01 +0300 Subject: [PATCH 4/9] fix(ts): fail closed on process-local @solana/mpp session stores The low-level session() method silently fell back to a process-local in-memory SessionStore on any network, including mainnet. A direct @solana/mpp consumer that bypasses the pay-kit adapter therefore lost every open channel on restart and never shared channel state across workers, letting cumulative vouchers double-spend. resolveSessionStore now mirrors the replay-store policy: a durable-shared store is honored everywhere, localnet may use a process-local store freely, devnet only behind the PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE (or allowUnsafeMemoryStore) dev opt-in, and mainnet forbids it outright. A mainnet session() with no store or a memory store throws a ConfigurationError. Adds the isMemorySessionStore marker and SessionStoreDurability type consumed by the adapter layer, plus regression tests covering the mainnet-reject, devnet-opt-in, localnet, and durable-store paths. --- .../mpp/src/__tests__/session-server.test.ts | 17 +++- .../session-store-durability.test.ts | 91 +++++++++++++++++++ typescript/packages/mpp/src/server/Session.ts | 79 +++++++++++++++- typescript/packages/mpp/src/server/index.ts | 4 +- .../packages/mpp/src/server/session/store.ts | 16 ++++ 5 files changed, 201 insertions(+), 6 deletions(-) create mode 100644 typescript/packages/mpp/src/__tests__/session-store-durability.test.ts diff --git a/typescript/packages/mpp/src/__tests__/session-server.test.ts b/typescript/packages/mpp/src/__tests__/session-server.test.ts index 6395a588a..9197b13bd 100644 --- a/typescript/packages/mpp/src/__tests__/session-server.test.ts +++ b/typescript/packages/mpp/src/__tests__/session-server.test.ts @@ -6,7 +6,7 @@ // canonical Methods.ts schema so future schema drifts are caught here. import { generateKeyPairSigner, getBase58Decoder, type KeyPairSigner } from '@solana/kit'; -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; import * as Methods from '../Methods.js'; import { session } from '../server/Session.js'; @@ -19,6 +19,18 @@ import { encodeVoucherMessage } from '../shared/voucher.js'; const OPERATOR = '9xAXssX9j7vuK99c7cFwqbixzL3bFrzPy9PUhCtDPAYJ'; const RECIPIENT = '5fKb5cF22cFybZB1H4hLDydFhwoQy9JzKzRWaSbMkB6h'; +// These unit tests build devnet sessions that intentionally rely on the SDK's +// process-local store. Opt in to it explicitly; the durability guard is +// exercised on its own in session-store-durability.test.ts. +const priorInMemoryOptIn = process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE; +beforeAll(() => { + process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE = '1'; +}); +afterAll(() => { + if (priorInMemoryOptIn === undefined) delete process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE; + else process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE = priorInMemoryOptIn; +}); + /** * Minimal RPC mock exposing `getSignatureStatuses` driven by a lookup * table. Unknown signatures resolve to `null` (not found). @@ -627,6 +639,7 @@ describe('session() verify() commit', () => { const routes = session.routes({ cap: 1_000_000n, currency: 'USDC', + network: 'devnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -662,6 +675,7 @@ describe('session.routes()', () => { const routes = session.routes({ cap: 1_000n, currency: 'USDC', + network: 'devnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -705,6 +719,7 @@ describe('session.routes()', () => { const routes = session.routes({ cap: 1_000_000n, currency: 'USDC', + network: 'devnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, diff --git a/typescript/packages/mpp/src/__tests__/session-store-durability.test.ts b/typescript/packages/mpp/src/__tests__/session-store-durability.test.ts new file mode 100644 index 000000000..19ce1ced1 --- /dev/null +++ b/typescript/packages/mpp/src/__tests__/session-store-durability.test.ts @@ -0,0 +1,91 @@ +// Regression tests for the low-level session() store durability guard. +// +// A direct `@solana/mpp` `solana.session()` consumer must never silently get a +// process-local in-memory store outside a single-process dev network. The guard +// mirrors the replay-store policy: durable-shared everywhere, localnet free, +// devnet behind the PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE opt-in, mainnet closed. + +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; + +import { ConfigurationError, session } from '../server/Session.js'; +import { createMemorySessionStore, type SessionStore } from '../server/session/store.js'; + +const OPERATOR = '9xAXssX9j7vuK99c7cFwqbixzL3bFrzPy9PUhCtDPAYJ'; +const RECIPIENT = '5fKb5cF22cFybZB1H4hLDydFhwoQy9JzKzRWaSbMkB6h'; + +function baseParams(network: string) { + return { + cap: 1_000_000n, + currency: 'USDC', + decimals: 6, + network, + operator: OPERATOR, + pricing: {}, + recipient: RECIPIENT, + } as const; +} + +/** A store that affirms durable shared storage without being the SDK memory store. */ +function durableSessionStore(): SessionStore { + const inner = createMemorySessionStore(); + return { + deleteChannel: id => inner.deleteChannel(id), + getChannel: id => inner.getChannel(id), + listChannels: filter => inner.listChannels(filter), + markSealed: id => inner.markSealed(id), + sessionStoreDurability: 'durable-shared', + updateChannel: (id, mutator) => inner.updateChannel(id, mutator), + }; +} + +describe('session() store durability guard', () => { + const prior = process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE; + beforeEach(() => { + delete process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE; + }); + afterEach(() => { + if (prior === undefined) delete process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE; + else process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE = prior; + }); + + test('mainnet session() with no store fails closed', () => { + for (const network of ['mainnet', 'mainnet-beta', 'MAINNET']) { + expect(() => session(baseParams(network))).toThrow(ConfigurationError); + expect(() => session(baseParams(network))).toThrow(/durable shared session store on mainnet/); + } + }); + + test('mainnet session() rejects an explicit process-local memory store', () => { + expect(() => session({ ...baseParams('mainnet'), store: createMemorySessionStore() })).toThrow( + ConfigurationError, + ); + // The env/param opt-in cannot relax mainnet. + process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE = '1'; + expect(() => session({ ...baseParams('mainnet'), store: createMemorySessionStore() })).toThrow( + ConfigurationError, + ); + expect(() => session({ ...baseParams('mainnet'), allowUnsafeMemoryStore: true })).toThrow(ConfigurationError); + }); + + test('devnet with no store fails closed without the opt-in', () => { + expect(() => session(baseParams('devnet'))).toThrow(/durable shared session store outside localnet/); + }); + + test('devnet permits the process-local store under the env opt-in', () => { + process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE = '1'; + expect(() => session(baseParams('devnet'))).not.toThrow(); + }); + + test('devnet permits the process-local store under the param opt-in', () => { + expect(() => session({ ...baseParams('devnet'), allowUnsafeMemoryStore: true })).not.toThrow(); + }); + + test('localnet permits a process-local store with no opt-in', () => { + expect(() => session(baseParams('localnet'))).not.toThrow(); + expect(() => session({ ...baseParams('localnet'), store: createMemorySessionStore() })).not.toThrow(); + }); + + test('a durable-shared store is permitted on mainnet', () => { + expect(() => session({ ...baseParams('mainnet'), store: durableSessionStore() })).not.toThrow(); + }); +}); diff --git a/typescript/packages/mpp/src/server/Session.ts b/typescript/packages/mpp/src/server/Session.ts index 9c9582335..0b16b40a3 100644 --- a/typescript/packages/mpp/src/server/Session.ts +++ b/typescript/packages/mpp/src/server/Session.ts @@ -8,7 +8,12 @@ import { } from '@solana/kit'; import { Method, Receipt } from 'mppx'; -import { DEFAULT_RPC_URLS, defaultTokenProgramForCurrency, resolveStablecoinMint } from '../constants.js'; +import { + DEFAULT_RPC_URLS, + defaultTokenProgramForCurrency, + normalizeNetwork, + resolveStablecoinMint, +} from '../constants.js'; import * as Methods from '../Methods.js'; import type { CommitReceipt, @@ -53,8 +58,22 @@ const DEFAULT_DIRECTIVE_EXPIRES_AT = 4_102_444_800; // opened through the method handler. const defaultStores = new WeakMap(); -function resolveSessionStore(parameters: session.Parameters): SessionStore { - if (parameters.store) return parameters.store; +/** Configuration error thrown when a session is wired with an unsafe store. */ +export class ConfigurationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ConfigurationError'; + } +} + +/** Development escape hatch: the process env twin of `allowUnsafeMemoryStore`. */ +function inMemorySessionStoreOptIn(parameters: session.Parameters): boolean { + if (parameters.allowUnsafeMemoryStore === true) return true; + const env = process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE; + return env === '1' || env?.toLowerCase() === 'true'; +} + +function defaultMemorySessionStore(parameters: session.Parameters): SessionStore { const existing = defaultStores.get(parameters); if (existing) return existing; const created = createMemorySessionStore(); @@ -62,6 +81,52 @@ function resolveSessionStore(parameters: session.Parameters): SessionStore { return created; } +/** + * Resolve the per-channel session store, failing closed on process-local memory. + * + * A `session()` that silently falls back to an in-memory store loses every open + * channel on restart and never shares state across workers, so cumulative + * vouchers can double-spend. This mirrors the replay-store policy: + * + * - a `durable-shared` store is honored on every network; + * - localnet may use a process-local store freely (single-process dev); + * - devnet may use one only behind the `PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE` + * env (or `allowUnsafeMemoryStore`) opt-in, or when the caller injects a + * store explicitly and owns that choice; + * - mainnet forbids a process-local store outright; the opt-in cannot relax it. + */ +function resolveSessionStore(parameters: session.Parameters): SessionStore { + const network = normalizeNetwork(parameters.network ?? 'mainnet'); + const store = parameters.store; + + // A caller-declared durable shared store is safe on every network. + if (store?.sessionStoreDurability === 'durable-shared') return store; + + if (network === 'localnet') { + return store ?? defaultMemorySessionStore(parameters); + } + + if (network === 'mainnet') { + throw new ConfigurationError( + 'solana.session requires a durable shared session store on mainnet; a process-local ' + + 'in-memory store is forbidden (allowUnsafeMemoryStore / PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE ' + + 'cannot relax this on mainnet). Inject a store that declares sessionStoreDurability="durable-shared".', + ); + } + + // devnet (and any other non-mainnet dev network): an explicitly injected + // store is the caller's responsibility; an auto-fallback needs the opt-in. + if (store) return store; + if (inMemorySessionStoreOptIn(parameters)) { + return defaultMemorySessionStore(parameters); + } + throw new ConfigurationError( + 'solana.session requires a durable shared session store outside localnet; inject one, or set ' + + 'PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1 (allowUnsafeMemoryStore=true) for an explicit ' + + 'development-only in-memory store.', + ); +} + /** * Creates a Solana `session` MPP method for the server. * @@ -1284,6 +1349,12 @@ export declare namespace session { } interface Parameters { + /** + * Explicitly allow the SDK's process-local session store for + * development/tests. Honored on localnet/devnet only; forbidden on + * mainnet. The `PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE` env is its twin. + */ + readonly allowUnsafeMemoryStore?: boolean; /** Maximum session cap the server will offer (base units). */ readonly cap: bigint; /** Idle-close delay in ms. 0 (default) disables the watchdog. */ @@ -1335,7 +1406,7 @@ export declare namespace session { readonly signer?: TransactionPartialSigner; /** Optional basis-point splits distributed at close. Max 8. */ readonly splits?: readonly SessionSplit[]; - /** Pluggable session store. Defaults to in-memory. */ + /** Pluggable session store. Production stores must declare durable sharing. */ readonly store?: SessionStore; /** SPL token program (TOKEN_PROGRAM or TOKEN_2022_PROGRAM). Defaults from currency/network. */ readonly tokenProgram?: string; diff --git a/typescript/packages/mpp/src/server/index.ts b/typescript/packages/mpp/src/server/index.ts index 8bdd24322..3a464c5d5 100644 --- a/typescript/packages/mpp/src/server/index.ts +++ b/typescript/packages/mpp/src/server/index.ts @@ -1,15 +1,17 @@ export * from '../constants.js'; export { type ChallengeRequest, charge, verifyChargeTransaction } from './Charge.js'; export { solana } from './Methods.js'; -export { type RpcLike, session, type SubmitOpenRpc, type VerifyOpenRpc } from './Session.js'; +export { ConfigurationError, type RpcLike, session, type SubmitOpenRpc, type VerifyOpenRpc } from './Session.js'; export { type ChannelMutator, type ChannelState, type CommittedDelivery, createMemorySessionStore, + isMemorySessionStore, type ListChannelsFilter, type PendingDelivery, type SessionStore, + type SessionStoreDurability, } from './session/store.js'; export { createAtomicReplayStoreView, diff --git a/typescript/packages/mpp/src/server/session/store.ts b/typescript/packages/mpp/src/server/session/store.ts index 4a69ad036..01261f46e 100644 --- a/typescript/packages/mpp/src/server/session/store.ts +++ b/typescript/packages/mpp/src/server/session/store.ts @@ -103,6 +103,11 @@ export interface ListChannelsFilter { */ export type ChannelMutator = (current: ChannelState | undefined) => ChannelState | Promise; +/** Explicit storage safety declaration for session channel state. */ +export type SessionStoreDurability = 'durable-shared' | 'ephemeral'; + +const MEMORY_SESSION_STORE = Symbol('solana-mpp-memory-session-store'); + /** * Async store for per-channel state. * @@ -111,6 +116,7 @@ export type ChannelMutator = (current: ChannelState | undefined) => ChannelState * read-modify-write to avoid double-spend under concurrent vouchers. */ export interface SessionStore { + readonly [MEMORY_SESSION_STORE]?: true; /** Remove a channel from the store. */ deleteChannel(channelId: string): Promise; /** Read a channel. Returns `undefined` if it doesn't exist. */ @@ -122,10 +128,17 @@ export interface SessionStore { * not found, matching the Rust behavior. */ markSealed(channelId: string): Promise; + /** Off localnet, production stores must explicitly declare durable sharing. */ + readonly sessionStoreDurability?: SessionStoreDurability | undefined; /** Atomically read-modify-write a channel's state. */ updateChannel(channelId: string, mutator: ChannelMutator): Promise; } +/** True only for the built-in process-local implementation. */ +export function isMemorySessionStore(store: SessionStore): boolean { + return (store as unknown as Record)[MEMORY_SESSION_STORE] === true; +} + /** * In-memory `SessionStore`. Per-channel async locking via a promise * chain keyed on channel id — so `updateChannel(id, …)` calls for the @@ -152,6 +165,7 @@ export function createMemorySessionStore(): SessionStore { } return { + [MEMORY_SESSION_STORE]: true, deleteChannel(channelId) { data.delete(channelId); return Promise.resolve(); @@ -190,6 +204,8 @@ export function createMemorySessionStore(): SessionStore { }); }, + sessionStoreDurability: 'ephemeral' as const, + async updateChannel(channelId, mutator) { return await withLock(channelId, async () => { const current = data.get(channelId); From 293366119e5912f928d2ca464c0815f703dd0b5b Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Tue, 14 Jul 2026 01:54:12 +0300 Subject: [PATCH 5/9] fix(go): keep localnet MPP replay-store policy permissive The charge-server store guard required a shared replay store (or the AllowUnsafeMemoryStore opt-in) on every network including localnet, diverging from the base, the TypeScript, and the Python SDKs and breaking the Go playground E2E: the server refused to boot on localnet with no injected store, so payment-link pages hit ERR_CONNECTION_REFUSED. Localnet is single-process development with no multi-replica or restart-persistence replay risk, so it now permissively defaults to a process-local MemoryStore with no opt-in and no IsShared requirement, matching the other SDKs. Off localnet the shared-store requirement and the AllowUnsafeMemoryStore opt-in are unchanged. Tests that asserted the over-strict localnet rejection now assert the permissive default and the off-localnet fail-closed path. --- go/examples/playground-api/main_test.go | 9 ++++-- .../adapters/mpp/adapter_internal_test.go | 4 +++ go/protocols/mpp/server/server.go | 25 +++++++++++----- go/protocols/mpp/server/server_test.go | 29 ++++++++++++++++--- 4 files changed, 53 insertions(+), 14 deletions(-) diff --git a/go/examples/playground-api/main_test.go b/go/examples/playground-api/main_test.go index 9e4049b0b..a7279b657 100644 --- a/go/examples/playground-api/main_test.go +++ b/go/examples/playground-api/main_test.go @@ -116,9 +116,14 @@ func TestPaymentClientDoesNotDeriveMemoryReplayOptInFromLocalnet(t *testing.T) { if client.Config.MPP.AllowUnsafeMemoryStore { t.Fatal("localnet label implicitly enabled memory replay") } + // The opt-in flag stays off (asserted above): the localnet label must not + // derive the memory-replay opt-in. The challenge still succeeds because the + // MPP server permissively provisions a process-local MemoryStore inside + // New() on localnet (single-process dev, matching the TypeScript and Python + // SDKs). Fail-closed store policy applies off localnet, not here. gate := &paykit.Gate{Amount: paykit.MustParseUSD("0.01")} - if headers, err := client.MppAdapter().ChallengeHeaders(gate); err == nil { - t.Fatalf("MPP challenge was issued without a replay store: %v", headers) + if _, err := client.MppAdapter().ChallengeHeaders(gate); err != nil { + t.Fatalf("localnet MPP challenge should succeed with the permissive default store: %v", err) } } diff --git a/go/paykit/adapters/mpp/adapter_internal_test.go b/go/paykit/adapters/mpp/adapter_internal_test.go index 341dd37fd..976d40868 100644 --- a/go/paykit/adapters/mpp/adapter_internal_test.go +++ b/go/paykit/adapters/mpp/adapter_internal_test.go @@ -127,7 +127,11 @@ func TestServerForForwardsInjectedSharedReplayStore(t *testing.T) { } func TestServerForFailsClosedWithoutReplayStore(t *testing.T) { + // Off localnet, serverFor must fail closed without a shared replay store. + // (Localnet is single-process dev and permissively defaults to a + // MemoryStore, matching the TypeScript and Python SDKs.) cfg := testCfg() + cfg.Network = paykit.SolanaDevnet cfg.MPP.AllowUnsafeMemoryStore = false _, err := (&Adapter{cfg: cfg}).serverFor(&paykit.Gate{Amount: paykit.MustParseUSD("0.10")}) if err == nil { diff --git a/go/protocols/mpp/server/server.go b/go/protocols/mpp/server/server.go index 9ff3915ad..5e634984d 100644 --- a/go/protocols/mpp/server/server.go +++ b/go/protocols/mpp/server/server.go @@ -168,17 +168,26 @@ func New(config Config) (*Mpp, error) { return nil, core.NewError(core.ErrCodeInvalidConfig, "AllowUnsafeMemoryStore is forbidden on mainnet; inject an atomic shared replay store") } + // Localnet is single-process development: a process-local MemoryStore is + // the permissive default (matching the TypeScript and Python SDKs), so no + // opt-in is required and an injected store need not report IsShared. Off + // localnet the store must be a shared replay backend, or the caller must + // explicitly opt in to a process-local store via AllowUnsafeMemoryStore. + isLocalnet := canonicalNetwork == paycore.NetworkLocalnet createdMemoryStore := false - if config.Store == nil && config.AllowUnsafeMemoryStore { - log.Printf("pay-kit: WARNING: MPP server on %s explicitly enabled process-local MemoryStore; replay markers are lost on restart and are not shared across workers", config.Network) - config.Store = core.NewMemoryStore() - createdMemoryStore = true - } if config.Store == nil { - return nil, core.NewError(core.ErrCodeInvalidConfig, - fmt.Sprintf("an atomic shared replay store is required on %s; inject Config.Store implementing SharedStore with IsShared() == true, or explicitly enable AllowUnsafeMemoryStore for a process-local development/test store", config.Network)) + if isLocalnet || config.AllowUnsafeMemoryStore { + if !isLocalnet { + log.Printf("pay-kit: WARNING: MPP server on %s explicitly enabled process-local MemoryStore; replay markers are lost on restart and are not shared across workers", config.Network) + } + config.Store = core.NewMemoryStore() + createdMemoryStore = true + } else { + return nil, core.NewError(core.ErrCodeInvalidConfig, + fmt.Sprintf("an atomic shared replay store is required on %s; inject Config.Store implementing SharedStore with IsShared() == true, or explicitly enable AllowUnsafeMemoryStore for a process-local development/test store", config.Network)) + } } - if !createdMemoryStore { + if !createdMemoryStore && !isLocalnet { shared, sharedOK := config.Store.(core.SharedStore) if !sharedOK || !shared.IsShared() { return nil, core.NewError(core.ErrCodeInvalidConfig, diff --git a/go/protocols/mpp/server/server_test.go b/go/protocols/mpp/server/server_test.go index ed2f22913..5e90808f6 100644 --- a/go/protocols/mpp/server/server_test.go +++ b/go/protocols/mpp/server/server_test.go @@ -682,17 +682,35 @@ func TestNewRejectsMemoryStoreOutsideLocalnet(t *testing.T) { } } -func TestNewRejectsImplicitLocalnetMemoryStore(t *testing.T) { +func TestNewAcceptsImplicitLocalnetMemoryStore(t *testing.T) { + // Localnet is single-process development: it has no multi-replica or + // restart-persistence replay risk, so a process-local MemoryStore is the + // permissive default with no opt-in, matching the TypeScript and Python + // SDKs and the base behavior the Go playground relies on. Fail-closed store + // policy applies off localnet (see TestNewRejectsImplicitOffLocalnetStore). recipient := testutil.NewPrivateKey().PublicKey().String() - _, err := New(Config{ + if _, err := New(Config{ Recipient: recipient, Currency: "sol", Network: "localnet", SecretKey: "test-secret-key-0123456789abcdef", RPC: testutil.NewFakeRPC(), + }); err != nil { + t.Fatalf("New() localnet error = %v, want implicit MemoryStore accepted", err) + } +} + +func TestNewRejectsImplicitOffLocalnetStore(t *testing.T) { + recipient := testutil.NewPrivateKey().PublicKey().String() + _, err := New(Config{ + Recipient: recipient, + Currency: "sol", + Network: "devnet", + SecretKey: "test-secret-key-0123456789abcdef", + RPC: testutil.NewFakeRPC(), }) if err == nil || !strings.Contains(err.Error(), "atomic shared replay store") { - t.Fatalf("New() localnet error = %v, want explicit-store rejection", err) + t.Fatalf("New() devnet error = %v, want explicit-store rejection", err) } } @@ -721,10 +739,13 @@ func TestNewRejectsInjectedUnsharedStoreWithUnsafeOptIn(t *testing.T) { "custom": &unsharedTestStore{MemoryStore: core.NewMemoryStore()}, } { t.Run(name, func(t *testing.T) { + // Off localnet an injected store must be shared; AllowUnsafeMemoryStore + // authorizes only the internally-created MemoryStore, never an injected + // unshared one. (Localnet is permissive, so this is asserted on devnet.) _, err := New(Config{ Recipient: recipient, Currency: "sol", - Network: "localnet", + Network: "devnet", SecretKey: "test-secret-key-0123456789abcdef", RPC: testutil.NewFakeRPC(), Store: store, From 1eadd5a0694a7f44c4e5a38d035f536727b378e2 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:37:54 +0300 Subject: [PATCH 6/9] test(harness): give the on-chain settlement suite an atomic production replay store This leaf's replay-store policy requires an atomic putIfAbsent, and the mainnet-fork settlement fixture injected a spread of the legacy get/put store (which drops the prototype method), so createPayKit fails closed in beforeAll and every gated settlement assertion errors. Bring forward the reconciled fixture: a real Map-backed store (get/put/delete + atomic putIfAbsent, isShared+isDurable) declared production, matching the SDK's own createSharedTestReplayStore pattern, with no unsafe-memory escape hatch. The gate travels with the policy that created its need. --- harness/test/onchain.e2e.test.ts | 38 ++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/harness/test/onchain.e2e.test.ts b/harness/test/onchain.e2e.test.ts index 7ced56d97..80422a0de 100644 --- a/harness/test/onchain.e2e.test.ts +++ b/harness/test/onchain.e2e.test.ts @@ -14,7 +14,7 @@ * (same as the rest of the surfpool CI; defaults to public mainnet). */ import { generateKeyPairSigner, type KeyPairSigner } from "@solana/kit"; -import { createPayKit, usage, usd } from "@solana/pay-kit"; +import { createPayKit, declareProductionReplayStore, usage, usd } from "@solana/pay-kit"; import { createPayKitClient } from "@solana/pay-kit/client"; import express, { type Request, type Response } from "express"; import type { Server } from "node:http"; @@ -43,13 +43,46 @@ describe("on-chain datasource RPC config", () => { expect(resolveDatasourceRpc(" ")).toBe("https://api.mainnet-beta.solana.com"); expect(resolveDatasourceRpc(" https://rpc.example.test ")).toBe("https://rpc.example.test"); }); + + it("uses the supported replay-store API for the single-process harness", async () => { + const replayStore = createHarnessReplayStore(); + await replayStore.put("onchain-harness", { ready: true }); + await expect(replayStore.get("onchain-harness")).resolves.toEqual({ ready: true }); + expect(replayStore.isShared).toBe(true); + }); }); +function createHarnessReplayStore() { + // The forked on-chain suite owns a single server process, so a process-local + // Map IS shared + durable for the run's lifetime. Declare it production so it + // satisfies the SDK's MPP replay-store gate (atomic putIfAbsent + isShared + + // isDurable); a plain `Store.memory()` spread has no putIfAbsent and fails + // closed. declareProductionReplayStore retains trust by object identity, so + // the exact returned instance must be the one injected into createPayKit. + 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, + put: async (key: string, value: unknown) => { + entries.set(key, value); + }, + putIfAbsent: async (key: string, value: unknown) => { + if (entries.has(key)) return false; + entries.set(key, value); + return true; + }, + }); +} + async function startServer(): Promise { + const replayStore = createHarnessReplayStore(); const pay = await createPayKit({ accept: ["x402", "mpp"], mpp: { - allowUnsafeMemoryStore: true, challengeBindingSecret: crypto.randomBytes(32).toString("hex"), }, network: "localnet", @@ -61,6 +94,7 @@ async function startServer(): Promise { // Fixed charge baseline (MPP / x402 exact — SPL transfer). fortune: { amount: usd("0.01"), description: "A fortune cookie" }, }, + replayStore, rpcUrl: net.rpcUrl, }); From 48713e879980a02fdaa0863fc2e32ec7c659eaae Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:41:40 +0300 Subject: [PATCH 7/9] test(boot-policy): carry the opt-in contract that matches this leaf's store policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This leaf tightens the opt-in semantics (an in-memory replay store is forbidden on mainnet even with PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1; the opt-in remains honored off-mainnet), but the boot-policy contract shipped here still asserted the old opt-in-boots-on-mainnet behavior — an internally inconsistent pair that went unnoticed because the TS-harness leg died earlier on the Rust runner build on every PR. Bring forward the reconciled contract (and the createPayKit boot fixture it drives) from the subscription leaf, which asserts exactly this policy: fail closed on mainnet regardless of opt-in, boot with the opt-in on devnet, native fail-closed guards probed by subject. Green here: 10 passed, 8 pending-skips with owned reasons. --- .../src/fixtures/typescript/paykit-boot.ts | 65 ++++++ harness/test/boot-policy.test.ts | 219 ++++++++++-------- 2 files changed, 188 insertions(+), 96 deletions(-) create mode 100644 harness/src/fixtures/typescript/paykit-boot.ts diff --git a/harness/src/fixtures/typescript/paykit-boot.ts b/harness/src/fixtures/typescript/paykit-boot.ts new file mode 100644 index 000000000..6c80304e3 --- /dev/null +++ b/harness/src/fixtures/typescript/paykit-boot.ts @@ -0,0 +1,65 @@ +import http from "node:http"; +import { createKeyPairSignerFromBytes } from "@solana/kit"; +import { createPayKit, type NetworkSlug, toNetwork, usd } from "@solana/pay-kit"; +import { readHarnessEnvironment } from "./shared"; + +async function main(): Promise { + const environment = readHarnessEnvironment(); + const signer = await createKeyPairSignerFromBytes( + environment.feePayerSecretKey, + ); + + // Deliberately omit replayStore. This fixture exercises the real PayKit + // configuration/adapter boundary: mainnet must reject, while devnet may use + // process-local state only with PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1. + const paykit = await createPayKit({ + accept: ["mpp"], + mpp: { challengeBindingSecret: environment.secretKey }, + network: toNetwork(environment.network as NetworkSlug), + operator: { recipient: environment.payTo, signer }, + preflight: false, + pricing: { boot: usd("0.001") }, + }); + const result = await paykit.requirePayment( + new Request("http://boot.test/protected"), + "boot", + ); + if (!("challenge" in result) || result.status !== 402) { + throw new Error( + "TypeScript PayKit boot fixture did not issue an MPP challenge", + ); + } + + const server = http.createServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ ok: true })); + }); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Failed to bind TypeScript PayKit boot fixture"); + } + console.log( + JSON.stringify({ + type: "ready", + implementation: "typescript", + role: "server", + port: address.port, + capabilities: ["mpp"], + }), + ); + }); + + const shutdown = () => server.close(() => process.exit(0)); + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); +} + +void main().catch((error: unknown) => { + // Keep the policy signature at the end of stderr so the harness's bounded + // child-process stderr capture retains it even when the SDK adds a long stack. + console.error( + `[paykit-boot] ${error instanceof Error ? error.message : String(error)}`, + ); + process.exitCode = 1; +}); diff --git a/harness/test/boot-policy.test.ts b/harness/test/boot-policy.test.ts index c34406671..8d35ad448 100644 --- a/harness/test/boot-policy.test.ts +++ b/harness/test/boot-policy.test.ts @@ -31,15 +31,13 @@ import { startServer, stopServer } from "../src/process"; // 1. Constructed off-localnet (network=mainnet) with NO shared store and // WITHOUT PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1 => it MUST fail CLOSED // (the process errors/throws before readiness). -// 2. With the opt-in (PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1) it boots to -// `ready` — proving the opt-in is honored, not merely that boot is broken. +// 2. On devnet, with the opt-in +// (PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1), it boots to `ready`, proving +// the development escape is honored without weakening mainnet policy. // -// RED-EXPECTED-PENDING: the TS and Python fail-closed remediations are landing -// in parallel in this same worktree. Until they land, the `typescript` and -// `python` fail-closed cases boot to `ready` (fail-OPEN) and their tests go -// RED on purpose — that is the pending signal. They go GREEN once the SDK -// constructors reject an in-memory replay/session store off-localnet without -// the opt-in. Go already fails closed and is GREEN now. +// These are blocking regression probes: Go, TypeScript, and Python must reject +// unsafe off-localnet construction, while the explicit devnet escape remains +// usable. Any SDK that silently falls back to process-local state goes RED. // // FALSE-GREEN GUARD: the fail-closed assertion does not merely check "the boot // failed" — a missing toolchain, unbuilt binary, or bad RPC would fail boot for @@ -82,10 +80,43 @@ const OPT_IN_ENV = "PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE"; // The canonical fail-closed signature shared across SDKs. The opt-in env-var // name is the cross-SDK remediation string SECURITY.md guarantees every SDK -// emits; the "no shared … store configured" wording is the Go/TS phrasing. A -// toolchain/binary/RPC failure will NOT match either, so it cannot false-green. +// emits; the "no shared … store configured" wording is the Go/TS phrasing; and +// "forbidden on mainnet" is the wording emitted when an SDK is handed the +// unsafe-memory store on mainnet and refuses it (the Go harness fixture always +// sets that flag, so its mainnet boot fails on this branch). A +// toolchain/binary/RPC failure will NOT match any of these, so it cannot +// false-green. const FAIL_CLOSED_SIGNATURE = - /PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE|no shared[^\n]*store configured/i; + /PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE|no shared[^\n]*store configured|forbidden on mainnet/i; + +// Repo root: two dirs up from harness/test/. Used to git-grep an SDK's tracked +// source for the fail-closed guard marker (the opt-in env-var name). A covered +// probe is only REQUIRED once its SDK actually carries the guard IN THIS TREE: +// go + typescript ship it here; python's lands via its own PR (#228), so until +// that source merges the python probe asserts-SKIP instead of red-failing this +// leaf. When #228 lands, the same grep sees the marker and auto-promotes python +// to a required probe with no edit here. +const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); + +function sdkFilesReferencingOptIn(sdkDir: string): string[] { + try { + const out = execFileSync("git", ["grep", "-l", OPT_IN_ENV, "--", sdkDir], { + cwd: REPO_ROOT, + encoding: "utf8", + }); + return out.split("\n").filter(Boolean); + } catch (error) { + const status = (error as { status?: number }).status; + const stdout = (error as { stdout?: string }).stdout ?? ""; + // git grep exits 1 with empty output when there are no matches -> honest skip. + if (status === 1 && stdout.trim() === "") return []; + throw error; + } +} + +function sdkImplementsGuard(sdkDir: string): boolean { + return sdkFilesReferencingOptIn(sdkDir).length > 0; +} type RunningServer = Awaited>; @@ -103,7 +134,9 @@ afterEach(async () => { function commandExists(cmd: string): boolean { try { // Pass cmd as $1 so it is never interpolated into the shell script. - execFileSync("sh", ["-c", 'command -v "$1"', "sh", cmd], { stdio: "ignore" }); + execFileSync("sh", ["-c", 'command -v "$1"', "sh", cmd], { + stdio: "ignore", + }); return true; } catch { return false; @@ -143,6 +176,9 @@ type CoveredProbe = { implementation: ImplementationDefinition; // Per-SDK env used to reach the MPP charge store-construction gate. mppEnv: Record; + // Tracked SDK source dir git-grepped for the fail-closed guard marker. The + // probe only becomes REQUIRED once this dir references OPT_IN_ENV in-tree. + guardSourceDir: string; }; function mppEnv(mint: string): Record { @@ -178,34 +214,6 @@ function serverImpl( }; } -// Subject probe (gate activates with its subject): the shared fail-closed -// store contract for a given SDK arrives with that SDK's #216 redelivery leaf -// (typescript -> the mpp replay-store leaf, python -> the python hardening -// leaf). Until the leaf is in this tree the SDK has no opt-in reference and -// the boot probe reports itself pending instead of failing on a guard that -// does not exist yet. Self-contained (no forward references) because it runs -// during coveredProbes initialization. -function sdkImplementsOptInGuard(sdkDir: string, marker: string = OPT_IN_ENV): boolean { - const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); - try { - const out = execFileSync("git", ["grep", "-l", marker, "--", sdkDir], { - cwd: repoRoot, - encoding: "utf8", - }); - return out.split("\n").filter(Boolean).length > 0; - } catch (error) { - const status = (error as { status?: number }).status; - const stdout = (error as { stdout?: string }).stdout ?? ""; - if (status === 1 && stdout.trim() === "") return false; - throw error; - } -} - -// TS references the opt-in env var pre-hardening (docs + a permissive reader), -// so the TS probe keys on the fail-closed policy surface itself -// (declareProductionReplayStore), which only the mpp replay-store leaf adds. -const TS_GUARD_MARKER = "declareProductionReplayStore"; - const coveredProbes: CoveredProbe[] = [ { id: "go", @@ -222,40 +230,33 @@ const coveredProbes: CoveredProbe[] = [ "go-paykit", ), mppEnv: mppEnv("USDC"), + // Go SDK guard lives in go/protocols/mpp/server/*.go. + guardSourceDir: "go", }, { id: "typescript", - label: "TypeScript Mppx.create / solana.charge server", - available: commandExists("pnpm") && sdkImplementsOptInGuard("typescript/packages/pay-kit/src", TS_GUARD_MARKER), - unavailableReason: !commandExists("pnpm") - ? "pnpm missing" - : sdkImplementsOptInGuard("typescript/packages/pay-kit/src", TS_GUARD_MARKER) - ? undefined - : "PENDING: TS fail-closed store guard is not in this tree yet; the probe activates when the mpp replay-store leaf lands", + label: "TypeScript PayKit high-level MPP adapter (createPayKit)", + available: true, implementation: serverImpl( "typescript", - "TypeScript Mppx.create / solana.charge server", + "TypeScript PayKit high-level MPP adapter (createPayKit)", [ - "pnpm", - "exec", - "node", + process.execPath, "--import", "tsx", - "src/fixtures/typescript/charge-server.ts", + "src/fixtures/typescript/paykit-boot.ts", ], "typescript", ), mppEnv: mppEnv("USDC"), + // TS guard lives in the pay-kit config + mpp adapters. + guardSourceDir: "typescript/packages/pay-kit/src", }, { id: "python", label: "Python solana_pay_kit high-level MppAdapter (MppAdapter.__init__)", - available: commandExists("uv") && sdkImplementsOptInGuard("python/src"), - unavailableReason: !commandExists("uv") - ? "uv missing" - : sdkImplementsOptInGuard("python/src") - ? undefined - : "PENDING: python fail-closed store guard is not in this tree yet; the probe activates when the python hardening leaf lands", + available: commandExists("uv"), + unavailableReason: commandExists("uv") ? undefined : "uv missing", // Drive the HIGH-LEVEL adapter constructor, not the harness `server.py` // MPP fixture. That fixture builds the lower-level `charge.Mpp` with an // explicit `store=MemoryStore()`, so it never reaches the adapter's default @@ -266,14 +267,61 @@ const coveredProbes: CoveredProbe[] = [ implementation: serverImpl( "python", "Python solana_pay_kit high-level MppAdapter (MppAdapter.__init__)", - ["uv", "run", "--project", "../python", "python", "python-server/mpp-adapter-boot.py"], + [ + "uv", + "run", + "--project", + "../python", + "python", + "python-server/mpp-adapter-boot.py", + ], "python", ), // Python MPP runs in pubkey mode: the literal mint pubkey is the currency. mppEnv: mppEnv(USDC_MINT), + // Python SDK guard lands via its own PR (#228). Until that source merges, + // this dir has no OPT_IN_ENV reference, so the probe asserts-SKIP here. + guardSourceDir: "python", }, ]; +// Resolve each covered probe against THIS tree: a probe is REQUIRED only when +// its toolchain is available AND its SDK actually implements the fail-closed +// guard in-tree (grep the SDK source for the opt-in marker). This one runtime +// signal keeps the leaf green today (python guard not here yet) and stays +// correct once python's remediation merges (grep then sees it -> required). +type ResolvedProbe = CoveredProbe & { + guardImplemented: boolean; + shouldRun: boolean; +}; + +const resolvedProbes: ResolvedProbe[] = coveredProbes.map((probe) => { + const guardImplemented = sdkImplementsGuard(probe.guardSourceDir); + return { + ...probe, + guardImplemented, + shouldRun: probe.available && guardImplemented, + }; +}); + +// Loud note: surface each covered probe's status so a skip is never silent. +for (const probe of resolvedProbes) { + if (!probe.guardImplemented) { + // eslint-disable-next-line no-console + console.warn( + `[boot-policy] PENDING ${probe.id}: SDK source (${probe.guardSourceDir}) ` + + `carries no ${OPT_IN_ENV} guard in this tree yet, so its boot probes ` + + `ASSERT-SKIP. This is not fixed here; it converges at the ${probe.id} ` + + `remediation PR, after which this same grep auto-promotes it to REQUIRED.`, + ); + } else if (!probe.available) { + // eslint-disable-next-line no-console + console.warn( + `[boot-policy] SKIP ${probe.id} boot probes: ${probe.unavailableReason}`, + ); + } +} + // SDKs whose server boot surface does NOT implement the shared // PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE fail-closed contract at all (verified: no // reference to the opt-in var anywhere in the SDK source). There is no @@ -298,7 +346,8 @@ const unimplementedProbes: Array<{ id: string; reason: string }> = [ }, { id: "lua", - reason: "Lua resty.pay_kit exposes no in-memory-store fail-closed boot guard", + reason: + "Lua resty.pay_kit exposes no in-memory-store fail-closed boot guard", }, { id: "kotlin", @@ -350,12 +399,14 @@ async function assertFailsClosed(probe: CoveredProbe): Promise { ); } -// Boot the SAME SDK/env but WITH the opt-in and assert it reaches `ready` — the -// only difference from the fail-closed run is the single env var, so a green -// here proves the opt-in (not a broken boot) is what gates the store. +// Boot the same SDK on devnet WITH the opt-in and assert it reaches `ready`. +// Mainnet is intentionally not used for this positive path: SDKs may and should +// reject process-local replay state there even when the development escape is +// set. async function assertBootsWithOptIn(probe: CoveredProbe): Promise { const server = await startServer(probe.implementation, { ...probe.mppEnv, + MPP_HARNESS_NETWORK: "devnet", [OPT_IN_ENV]: "1", }); runningServers.push(server); @@ -365,14 +416,8 @@ async function assertBootsWithOptIn(probe: CoveredProbe): Promise { } describe("boot-policy conformance: fail-CLOSED off-localnet without opt-in", () => { - for (const probe of coveredProbes) { - if (!probe.available) { - // eslint-disable-next-line no-console - console.warn( - `[boot-policy] SKIP ${probe.id} fail-closed probe: ${probe.unavailableReason}`, - ); - } - it.skipIf(!probe.available)( + for (const probe of resolvedProbes) { + it.skipIf(!probe.shouldRun)( `${probe.id}: fails closed at network=mainnet with no ${OPT_IN_ENV}`, async () => { await assertFailsClosed(probe); @@ -382,9 +427,9 @@ describe("boot-policy conformance: fail-CLOSED off-localnet without opt-in", () }); describe("boot-policy conformance: boots with the opt-in", () => { - for (const probe of coveredProbes) { - it.skipIf(!probe.available)( - `${probe.id}: boots to ready at network=mainnet with ${OPT_IN_ENV}=1`, + for (const probe of resolvedProbes) { + it.skipIf(!probe.shouldRun)( + `${probe.id}: boots to ready at network=devnet with ${OPT_IN_ENV}=1`, async () => { await assertBootsWithOptIn(probe); }, @@ -395,9 +440,7 @@ describe("boot-policy conformance: boots with the opt-in", () => { describe("boot-policy conformance: SDKs without the store fail-closed contract", () => { for (const probe of unimplementedProbes) { // eslint-disable-next-line no-console - console.warn( - `[boot-policy] ASSERT-SKIP ${probe.id}: ${probe.reason}`, - ); + console.warn(`[boot-policy] ASSERT-SKIP ${probe.id}: ${probe.reason}`); it.skip(`${probe.id}: ${probe.reason}`, () => { // Intentionally skipped: no boot-policy contract to conform to yet. }); @@ -410,9 +453,9 @@ describe("boot-policy conformance: SDKs without the store fail-closed contract", // (someone starts wiring the fail-closed contract) this REDs, forcing that SDK // to be promoted from an asserted-skip to a LIVE boot-policy probe that actually // asserts fail-closed / opt-in boot, rather than lingering half-implemented and -// silently skipped. Uses `git grep` so .gitignored build/vendor trees (target/, -// vendor/, .build/) are excluded automatically. -const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +// silently skipped. Uses `git grep` (via sdkFilesReferencingOptIn, defined +// above) so .gitignored build/vendor trees (target/, vendor/, .build/) are +// excluded automatically. const SDK_SOURCE_DIR: Record = { rust: "rust", php: "php", @@ -422,22 +465,6 @@ const SDK_SOURCE_DIR: Record = { swift: "swift", }; -function sdkFilesReferencingOptIn(sdkDir: string): string[] { - try { - const out = execFileSync("git", ["grep", "-l", OPT_IN_ENV, "--", sdkDir], { - cwd: REPO_ROOT, - encoding: "utf8", - }); - return out.split("\n").filter(Boolean); - } catch (error) { - const status = (error as { status?: number }).status; - const stdout = (error as { stdout?: string }).stdout ?? ""; - // git grep exits 1 with empty output when there are no matches -> honest skip. - if (status === 1 && stdout.trim() === "") return []; - throw error; - } -} - describe("boot-policy: asserted-skip roster stays honest (no half-implemented contract)", () => { for (const probe of unimplementedProbes) { const sdkDir = SDK_SOURCE_DIR[probe.id]; From 22c7cfbf581c8e6bf4306a89539fe3bc0cf9999f Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:42:25 +0300 Subject: [PATCH 8/9] test(harness): route the over-ceiling upto fixture through an x402-only accept MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This leaf's replay-store policy validates the MPP store contract whenever mpp is accepted; the over-ceiling fixture built its upto server with the default accept list, so the policy rejected its reserve-only store before the ceiling assertions ran. Bring forward the reconciled fixture, which pins accept:['x402'] — the surface actually under test — so the ceiling gate runs against the real X402Upto.settle verifier. --- harness/test/x402-upto-over-ceiling.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/harness/test/x402-upto-over-ceiling.test.ts b/harness/test/x402-upto-over-ceiling.test.ts index 0d7ee72f6..d8ad5a279 100644 --- a/harness/test/x402-upto-over-ceiling.test.ts +++ b/harness/test/x402-upto-over-ceiling.test.ts @@ -38,8 +38,11 @@ const CEILING = 1_000_000n; async function makeUpto(): Promise { // A minimal, offline config (no live RPC needed: the ceiling guard precedes - // all network work). Mirrors the SDK unit test's testConfig(). + // all network work). Mirrors the SDK unit test's testConfig(). accept pins + // x402 so the MPP replay-store policy (which requires a declared atomic + // store) does not apply to this pure-upto fixture. const config = await configure({ + accept: ["x402"], mpp: { challengeBindingSecret: "x402-upto-ceiling-secret" }, network: "solana_localnet", }); From 13df4d617a8764c8e746fcde487392bf5241daa1 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:46:03 +0300 Subject: [PATCH 9/9] ci(harness): carry the PayKit boot fixture's build and typecheck wiring The reconciled boot-policy contract this leaf carries drives the high-level createPayKit fixture, which needs three support pieces that lived one leaf later: the setup-harness build-pay-kit input (ordered @solana/mpp -> @solana/pay-kit build) wired into the TS-harness job, the harness package declaring the pay-kit file dependency, and the tsconfig exclusion that keeps the shared cross-language typecheck on its @solana/mpp-only baseline (the fixture is typechecked by the dedicated paths that actually build pay-kit). Without them the shared Build-harness-deps job typechecks a module that is never built there and every downstream leg dies. --- .github/actions/setup-harness/action.yml | 28 +- .github/workflows/ci.yml | 3 + harness/package.json | 2 +- harness/pnpm-lock.yaml | 896 +++++------------------ harness/tsconfig.json | 8 +- 5 files changed, 212 insertions(+), 725 deletions(-) diff --git a/.github/actions/setup-harness/action.yml b/.github/actions/setup-harness/action.yml index 478fdb5e8..52e668574 100644 --- a/.github/actions/setup-harness/action.yml +++ b/.github/actions/setup-harness/action.yml @@ -1,9 +1,10 @@ name: Setup PayKit harness description: >- Shared setup for the cross-language PayKit harness jobs: pnpm + Node, build - @solana/mpp, (optionally) build the Rust harness adapter binaries, install the - harness, and typecheck it. Each language workflow adds only its own toolchain, - adapter build, and smoke run on top of this. + @solana/mpp (and opt into @solana/pay-kit only for its high-level fixture), + optionally build the Rust harness adapter binaries, install the harness, and + typecheck it. Each language workflow adds only its own toolchain, adapter + build, and smoke run. inputs: cargo-bins: @@ -19,6 +20,13 @@ inputs: description: Language id used to namespace the cargo cache key. required: false default: harness + build-pay-kit: + description: >- + Build @solana/pay-kit after @solana/mpp for a job that executes the + high-level PayKit harness fixture. The default keeps cross-language + harness setup on its @solana/mpp-only baseline. + required: false + default: "false" runs: using: composite @@ -40,6 +48,11 @@ runs: working-directory: typescript shell: bash run: pnpm --filter @solana/mpp build + - name: Build @solana/pay-kit for the high-level boot fixture + if: inputs.build-pay-kit == 'true' + working-directory: typescript + shell: bash + run: pnpm --filter @solana/pay-kit build - name: Set up Rust toolchain if: inputs.cargo-bins != '' @@ -80,3 +93,12 @@ runs: working-directory: harness shell: bash run: pnpm typecheck + - name: Typecheck high-level PayKit boot fixture + if: inputs.build-pay-kit == 'true' + working-directory: harness + shell: bash + run: >- + pnpm exec tsc --noEmit --target ES2022 --module ESNext + --moduleResolution Bundler --strict --esModuleInterop --skipLibCheck + --resolveJsonModule --isolatedModules --types node + src/fixtures/typescript/paykit-boot.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb7f5a088..697102c8a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -606,6 +606,9 @@ jobs: with: cargo-bins: "paykit-harness-bins:mpp_harness_client,mpp_harness_server" cargo-cache-key: ts + # boot-policy executes the real createPayKit + requirePayment fixture, + # so it needs the ordered @solana/mpp -> @solana/pay-kit build. + build-pay-kit: "true" # Gate self-activation: same subject rule as the rust-conformance job — # the runner examples arrive with the rust hardening leaf; until then the # Rust vector steps below report pending instead of failing on a missing diff --git a/harness/package.json b/harness/package.json index 163e7e21a..3dbe7bacb 100644 --- a/harness/package.json +++ b/harness/package.json @@ -11,7 +11,7 @@ "typecheck:onchain": "tsc --noEmit -p tsconfig.onchain.json" }, "dependencies": { - "@solana/kit": "^6.5.0", + "@solana/kit": "^6.10.0", "@solana/mpp": "file:../typescript/packages/mpp", "@solana/pay-kit": "file:../typescript/packages/pay-kit", "@solana/surfpool": "1.4.0", diff --git a/harness/pnpm-lock.yaml b/harness/pnpm-lock.yaml index 9e274f6b4..29aba6ca4 100644 --- a/harness/pnpm-lock.yaml +++ b/harness/pnpm-lock.yaml @@ -12,14 +12,14 @@ importers: .: dependencies: '@solana/kit': - specifier: ^6.5.0 - version: 6.8.0(typescript@5.9.3) + specifier: ^6.10.0 + version: 6.10.0(typescript@5.9.3) '@solana/mpp': specifier: file:../typescript/packages/mpp - version: file:../typescript/packages/mpp(@solana/kit@6.8.0(typescript@5.9.3))(mppx@0.5.17(express@4.22.2)(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.3.6)))(typescript@5.9.3) + version: file:../typescript/packages/mpp(@solana/kit@6.10.0(typescript@5.9.3))(mppx@0.5.17(express@4.22.2)(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.3.6)))(typescript@5.9.3) '@solana/pay-kit': specifier: file:../typescript/packages/pay-kit - version: file:../typescript/packages/pay-kit(@solana/addresses@6.10.0(typescript@5.9.3))(@solana/codecs-core@6.10.0(typescript@5.9.3))(@solana/codecs-strings@6.10.0(typescript@5.9.3))(@solana/keys@6.10.0(typescript@5.9.3))(@solana/signers@6.10.0(typescript@5.9.3))(@solana/sysvars@6.8.0(typescript@5.9.3))(@solana/transactions@6.10.0(typescript@5.9.3))(express@4.22.2)(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.3.6)) + version: file:../typescript/packages/pay-kit(@solana/addresses@6.10.0(typescript@5.9.3))(@solana/codecs-core@6.10.0(typescript@5.9.3))(@solana/codecs-strings@6.10.0(typescript@5.9.3))(@solana/keys@6.10.0(typescript@5.9.3))(@solana/signers@6.10.0(typescript@5.9.3))(@solana/sysvars@6.10.0(typescript@5.9.3))(@solana/transactions@6.10.0(typescript@5.9.3))(express@4.22.2)(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.3.6)) '@solana/surfpool': specifier: 1.4.0 version: 1.4.0 @@ -29,7 +29,7 @@ importers: devDependencies: '@solana-program/token': specifier: ^0.11.0 - version: 0.11.0(@solana/kit@6.8.0(typescript@5.9.3)) + version: 0.11.0(@solana/kit@6.10.0(typescript@5.9.3)) '@types/express': specifier: ^5.0.0 version: 5.0.6 @@ -407,15 +407,6 @@ packages: typescript: optional: true - '@solana/accounts@6.8.0': - resolution: {integrity: sha512-rXjFYVopaEw1H2PTBQbRjKr+0i4EFuBEhRT5E0dI4cMaabSb4KKypC2gaf47+6cjU3hMlM1AcsyIs72/MqAVBw==} - engines: {node: '>=20.18.0'} - peerDependencies: - typescript: '>=5.0.0' - peerDependenciesMeta: - typescript: - optional: true - '@solana/addresses@6.10.0': resolution: {integrity: sha512-vEoCGBTxG0HCERAn84KXkrJjl+pDaNzOpZ0qbgcPS98fYxP5yzbKB8SNOY2bzrbkRUmmw5Q3hqTRERemUN2Gcw==} engines: {node: '>=20.18.0'} @@ -425,15 +416,6 @@ packages: typescript: optional: true - '@solana/addresses@6.8.0': - resolution: {integrity: sha512-xVlA0DNX1LVfTueVsbhxDDoqr1VxeXvgJEh2GcIN/vcJPhY3GE3AYtjTbJJmTDgPrzOccI0t6ElVb1gelJH/PQ==} - engines: {node: '>=20.18.0'} - peerDependencies: - typescript: '>=5.0.0' - peerDependenciesMeta: - typescript: - optional: true - '@solana/assertions@6.10.0': resolution: {integrity: sha512-lKSAdVo+P/6Lp4vs6shstXmFOpvxrABwn4o1462tb7sKkNapk6o9pPFVPGw4DUgPS3WqWRs1j2tmpuVjhQRntg==} engines: {node: '>=20.18.0'} @@ -443,15 +425,6 @@ packages: typescript: optional: true - '@solana/assertions@6.8.0': - resolution: {integrity: sha512-OU6prCq39fSvGL8xY1C/9vhghasvAkMiRlituzJxzJpZRfpVRrwhzLd6P5NPAPoQ28qKcenA50kFdw9+ZyneJQ==} - engines: {node: '>=20.18.0'} - peerDependencies: - typescript: '>=5.0.0' - peerDependenciesMeta: - typescript: - optional: true - '@solana/codecs-core@6.10.0': resolution: {integrity: sha512-nfAl9OMGo4HanIMxGsQoVB7BxMoqBCYEUxl8oEAZZ09pDxnaXQZkTRXEwPPccag37XfW1ciPd1vWPKwB2b0HHQ==} engines: {node: '>=20.18.0'} @@ -461,15 +434,6 @@ packages: typescript: optional: true - '@solana/codecs-core@6.8.0': - resolution: {integrity: sha512-udFO8TrvzgROonwX3rY3E2SG675RehILNb4ZYcKlf1mL7vkDJ9bEJnBxi87AEwl8RWZFTl+MhT0MmrJnbpvdug==} - engines: {node: '>=20.18.0'} - peerDependencies: - typescript: '>=5.0.0' - peerDependenciesMeta: - typescript: - optional: true - '@solana/codecs-data-structures@6.10.0': resolution: {integrity: sha512-CNasJW3bq5u+632Zt5aJ8rOjAjv2HyenpV8o9kAIqdmV4CBpjCCoBnKn8LkuR/sbeREZxJYfhKTXO/9ruAkw7A==} engines: {node: '>=20.18.0'} @@ -479,15 +443,6 @@ packages: typescript: optional: true - '@solana/codecs-data-structures@6.8.0': - resolution: {integrity: sha512-lHr0F+nNwgm9c+tWQX398yzYh1qDi7QSCJpY9MQ2azW4FfY2IyPSo7bqzTaWNnJh9pmJx3ZI6jHfXBnLD5k/SQ==} - engines: {node: '>=20.18.0'} - peerDependencies: - typescript: '>=5.0.0' - peerDependenciesMeta: - typescript: - optional: true - '@solana/codecs-numbers@6.10.0': resolution: {integrity: sha512-CcM+wX4zOiA9zkh8A7t1787A0Ehgmu5+6Z2tKoHew6cNw/dkaUTPa8JnNHbvfsLC8dfHC1BhAEJl86sKmRsfkQ==} engines: {node: '>=20.18.0'} @@ -497,15 +452,6 @@ packages: typescript: optional: true - '@solana/codecs-numbers@6.8.0': - resolution: {integrity: sha512-ebf4f1D19EAe0uhdUYOCEYnn5+EellsBxbJ42tM2yYEoIBVz5FoBBC0gSsq+UTNbQHFa7XagyBT3LewxXttiTQ==} - engines: {node: '>=20.18.0'} - peerDependencies: - typescript: '>=5.0.0' - peerDependenciesMeta: - typescript: - optional: true - '@solana/codecs-strings@6.10.0': resolution: {integrity: sha512-zlaqkg7K6F6IN4V/Ec8TWkTn054gxv7ZLagvGkuEyAdPQ6BzzsehOm2TqCuyXgJJTCGPLY1bEk6yH9NxANe0kA==} engines: {node: '>=20.18.0'} @@ -518,23 +464,11 @@ packages: typescript: optional: true - '@solana/codecs-strings@6.8.0': - resolution: {integrity: sha512-Rpk5NVhbKYcPnE7wz3IpTp0GVNVs0IYKdmyzByiimgPTiII8eb8ay4wQiYHGHrpYh62hD14Qy3GiGDFgipRKqA==} - engines: {node: '>=20.18.0'} - peerDependencies: - fastestsmallesttextencoderdecoder: ^1.0.22 - typescript: '>=5.0.0' - peerDependenciesMeta: - fastestsmallesttextencoderdecoder: - optional: true - typescript: - optional: true - - '@solana/codecs@6.8.0': - resolution: {integrity: sha512-qCSAaw1qszeQflavkIM7c21qJ3BHReP/qgDelZbhsEXpZc852CCZM00FOIWuxePr6X+JjSNqJquxwdDSoZe7Bw==} + '@solana/codecs@6.10.0': + resolution: {integrity: sha512-lLVuxod4ChWp9i7OvpgIykYG8Q9OGPVXKnHM9VlzDDLylsx7Y1FoQL00sHa7PqFkJVmkBufaA6dcGbQ7FU+lAQ==} engines: {node: '>=20.18.0'} peerDependencies: - typescript: '>=5.0.0' + typescript: '>=5.4.0' peerDependenciesMeta: typescript: optional: true @@ -549,21 +483,11 @@ packages: typescript: optional: true - '@solana/errors@6.8.0': - resolution: {integrity: sha512-HRTrLgTn0c99GKz4v4IKgz2+6soaRY1mh2tLW4sk1Fe4Zzv85Q6ZLK1mXrVGL73z1apyHDrr9/Sd/9ZhUsUvpA==} + '@solana/fast-stable-stringify@6.10.0': + resolution: {integrity: sha512-iCNed27wk6PKSS3QUtHovRfMWF/jbVWogs2vB4tukKUCsqG4rDfDInIwZ6ur/nY6XTrgi2gMMdZq9GAUlWsbfw==} engines: {node: '>=20.18.0'} - hasBin: true peerDependencies: - typescript: '>=5.0.0' - peerDependenciesMeta: - typescript: - optional: true - - '@solana/fast-stable-stringify@6.8.0': - resolution: {integrity: sha512-lZa3Qnsn+9ew6rHTXkPc+uqSa3i+AWqSBhV6oYxxBc+smvuxovItU4TPIs30cTfA7lAP+j+oYAQtUDu2dLy0hA==} - engines: {node: '>=20.18.0'} - peerDependencies: - typescript: '>=5.0.0' + typescript: '>=5.4.0' peerDependenciesMeta: typescript: optional: true @@ -586,15 +510,6 @@ packages: typescript: optional: true - '@solana/functional@6.8.0': - resolution: {integrity: sha512-oMSAD/8w9ujx7OplvwRWwHHFnaaxi/Xrji1XH3xAB+gzxupUpBbOmgxQ+e84x+9VN8QWk5aU3L7gmCqdTAR6OA==} - engines: {node: '>=20.18.0'} - peerDependencies: - typescript: '>=5.0.0' - peerDependenciesMeta: - typescript: - optional: true - '@solana/instruction-plans@6.10.0': resolution: {integrity: sha512-YG7mo4zykzdc6ZTV0BuN6pveK9qeBySzlYYerq578A4eQu3xcypMAYRGAvhMZtWTanjjmD6CKtM0M7kVp0TNxg==} engines: {node: '>=20.18.0'} @@ -604,15 +519,6 @@ packages: typescript: optional: true - '@solana/instruction-plans@6.8.0': - resolution: {integrity: sha512-osAsY8ozqohrcTcHlG1EmO3i9flc0eESMIy9akTHyVvqk915gZgkaTmt4IjcYSwBGt7i+Rh8TmLj27RrTpCKvg==} - engines: {node: '>=20.18.0'} - peerDependencies: - typescript: '>=5.0.0' - peerDependenciesMeta: - typescript: - optional: true - '@solana/instructions@6.10.0': resolution: {integrity: sha512-0TToYF+8LXQ3ofPMx+yF6yaM9l4YJvcAPMy0qV5JsrBUFlWXBSANRuudKBQLHMvb+a3OiUTq5X7omuorKMBB3A==} engines: {node: '>=20.18.0'} @@ -622,15 +528,6 @@ packages: typescript: optional: true - '@solana/instructions@6.8.0': - resolution: {integrity: sha512-dTtykhS9IeN3npCfnd7wSS6KmKAh54+g90JRtLYy5/31L2Zvunf3AJz2QUk58vgsAGZ5fuoiMyhCxRJm4rHUBQ==} - engines: {node: '>=20.18.0'} - peerDependencies: - typescript: '>=5.0.0' - peerDependenciesMeta: - typescript: - optional: true - '@solana/keychain-core@1.4.0': resolution: {integrity: sha512-WhNYopIPdHAEwJ3RDQ7VtUbKrOa2sEV9Z/Q9RxQNlfVDykzC7j/hQrlMcl5Uc3HTTA1J1/3Hg3wbnZPshSy6xg==} peerDependencies: @@ -659,20 +556,11 @@ packages: typescript: optional: true - '@solana/keys@6.8.0': - resolution: {integrity: sha512-Wo8CnbrVfCP1Jbsb3ElMej/3dmMrl4ArPhI1mDcqIIz/O4j4HmxZYbn2BCWtnV9V/LPM638EMO2r1x6GzDNrPA==} - engines: {node: '>=20.18.0'} - peerDependencies: - typescript: '>=5.0.0' - peerDependenciesMeta: - typescript: - optional: true - - '@solana/kit@6.8.0': - resolution: {integrity: sha512-+McC1aCgcUBdM7Cd7U6k2ZHJ9OKCy5mzpb0XWrhkrgsFxT0QoRr0AcWJc85o6tIDfG6Jz7vVhbS3l8ugYz2Vzw==} + '@solana/kit@6.10.0': + resolution: {integrity: sha512-/WnnQp3uARh2JCFSfAakejTAqwmXVuMVTcRn5r2yDwY2yzZ4R6mt/Cl59VPimVLNSoTyN/KsEwhv9omr3ERazQ==} engines: {node: '>=20.18.0'} peerDependencies: - typescript: '>=5.0.0' + typescript: '>=5.4.0' peerDependenciesMeta: typescript: optional: true @@ -692,15 +580,6 @@ packages: typescript: optional: true - '@solana/nominal-types@6.8.0': - resolution: {integrity: sha512-mLmHr92pM4mEfe49GUmZ5Ry0RMqtMuFQqZYnxQqhDKMcl+Wtt820ezxYgwPhqcMxRzfqaQSO3ZxpSB0RlLBa/Q==} - engines: {node: '>=20.18.0'} - peerDependencies: - typescript: '>=5.0.0' - peerDependenciesMeta: - typescript: - optional: true - '@solana/offchain-messages@6.10.0': resolution: {integrity: sha512-RiEgAueeMkFMC1suOXBIcmCZgtXRxy24yk0DldPB37bB4zwOF1SAaRjNRPjIkGK8RhCYrEpPosnzLyavw9ueRg==} engines: {node: '>=20.18.0'} @@ -710,20 +589,11 @@ packages: typescript: optional: true - '@solana/offchain-messages@6.8.0': - resolution: {integrity: sha512-HoniTs2uoCHGicD0dTTJ3YBhLZC9URxdXXUf0CHalLFwAidF9iNuB8dsuKk16Euu68L4/ERKKGfyC0QobBvahw==} + '@solana/options@6.10.0': + resolution: {integrity: sha512-RO9UT3UYD8/Cu2uM6ZXbKvLeMnVD42+g9JRds7Pfs4AhiOyg4R4TJrQUAppTgavPTO3PBRlWtWOC05ZH/yAIbg==} engines: {node: '>=20.18.0'} peerDependencies: - typescript: '>=5.0.0' - peerDependenciesMeta: - typescript: - optional: true - - '@solana/options@6.8.0': - resolution: {integrity: sha512-T5441HHeucFaLtaMAJQJl79T7mX007oAFPunpPebBphRvCXGv+qQwQvqa4HkYct6Jf2O0aKLBL9GSe/kfdCk9A==} - engines: {node: '>=20.18.0'} - peerDependencies: - typescript: '>=5.0.0' + typescript: '>=5.4.0' peerDependenciesMeta: typescript: optional: true @@ -731,11 +601,11 @@ packages: '@solana/pay-kit@file:../typescript/packages/pay-kit': resolution: {directory: ../typescript/packages/pay-kit, type: directory} - '@solana/plugin-core@6.8.0': - resolution: {integrity: sha512-kdqFIhQvJP2BDUsMOIbor35esj8u78SO33Xv0Wmo+uTRg6yKONKVK53ghw235pWrinOT4f0VnVe6MN6ciYiQVA==} + '@solana/plugin-core@6.10.0': + resolution: {integrity: sha512-JE70YTQOfFACVFGvoJon4Scc/eHUWjMu8Ovo35CcV2kHTAHYMCd4UkBd2gmlhK0vRMMomsQi1ZLPlAlTq0OoUQ==} engines: {node: '>=20.18.0'} peerDependencies: - typescript: '>=5.0.0' + typescript: '>=5.4.0' peerDependenciesMeta: typescript: optional: true @@ -749,15 +619,6 @@ packages: typescript: optional: true - '@solana/plugin-interfaces@6.8.0': - resolution: {integrity: sha512-4olaMKGUVA7wG6BBWM5A31bQsUWBlfcL1pjhq6ZTqVEJ7vshHXGwHVlWYXYyYn9ixozGDpGSl553yaRY9jQwWw==} - engines: {node: '>=20.18.0'} - peerDependencies: - typescript: '>=5.0.0' - peerDependenciesMeta: - typescript: - optional: true - '@solana/program-client-core@6.10.0': resolution: {integrity: sha512-4PPbTLdC1ylHIuvhOFDP8RnSkXPCFjNFWGslzc+UFKnoR4ajzBcByX94jmaruDMk5ncxgj7tr9pzJTvfGHIaMA==} engines: {node: '>=20.18.0'} @@ -767,20 +628,11 @@ packages: typescript: optional: true - '@solana/program-client-core@6.8.0': - resolution: {integrity: sha512-eOZtEnwl+vdiy9x/rFF89NDtnvt+Q3H04A/0u4GoHnt+fFkQG3JS+ChWG9c77izmpmRuz5C1GptOPDGNDnIUgQ==} + '@solana/programs@6.10.0': + resolution: {integrity: sha512-qn/HeLP5KGUJXVub3fyGe69/rWaLX4jzwm6V/1pNxJDbdF+MBdgn18hP6F+VmhfdNmwK0lue3J/1HQ1UTMuQeQ==} engines: {node: '>=20.18.0'} peerDependencies: - typescript: '>=5.0.0' - peerDependenciesMeta: - typescript: - optional: true - - '@solana/programs@6.8.0': - resolution: {integrity: sha512-8hSKGfPTLX9Sm7KGV/UtiGCeSzptT/9vcjbodE+ZGHKFefo5vES4UAW+qD01LjL7IumGtMJvnfhCWt81qT/jbQ==} - engines: {node: '>=20.18.0'} - peerDependencies: - typescript: '>=5.0.0' + typescript: '>=5.4.0' peerDependenciesMeta: typescript: optional: true @@ -794,15 +646,6 @@ packages: typescript: optional: true - '@solana/promises@6.8.0': - resolution: {integrity: sha512-kIypZG83ZbADbrAq9/LS7LuWlVxlgJSzIpic75+9IuAfC3k5/KSus8LrvggBkCzfAyIslrUh70iz4JcnzUZrOw==} - engines: {node: '>=20.18.0'} - peerDependencies: - typescript: '>=5.0.0' - peerDependenciesMeta: - typescript: - optional: true - '@solana/rpc-api@6.10.0': resolution: {integrity: sha512-RjPIVsAb/85P1ptoO3WpC0x7QG6gG/e4q/3lo6gbSznUZOcoM+8sSBnCX7BwP1ZkCDS6NK/ClXLnhhhYZx+OGg==} engines: {node: '>=20.18.0'} @@ -812,15 +655,6 @@ packages: typescript: optional: true - '@solana/rpc-api@6.8.0': - resolution: {integrity: sha512-v8ZKWgPtKbF6HeJcfC4ciwI8mwDCizBtRLYYjjHOu+9S9IJYyefQzsQxL5P8OjJPpI4gFauT6gsjQLo76BoojA==} - engines: {node: '>=20.18.0'} - peerDependencies: - typescript: '>=5.0.0' - peerDependenciesMeta: - typescript: - optional: true - '@solana/rpc-parsed-types@6.10.0': resolution: {integrity: sha512-5275mvSV1mxhwvrMVa+K7BU/nAetpHfcb+8Ql9rtA8RRf6DyiimFQFZUukE4Ez6XJihEpCHNy98yhkgai9wytQ==} engines: {node: '>=20.18.0'} @@ -830,15 +664,6 @@ packages: typescript: optional: true - '@solana/rpc-parsed-types@6.8.0': - resolution: {integrity: sha512-jYddZviBSUYbuUKqvNthet7KbJVI7me6xfRH2znv1SjIpmvhSPJcGN5QrlHVOasHdzEWSpvZa5VYDfnqH3aYvA==} - engines: {node: '>=20.18.0'} - peerDependencies: - typescript: '>=5.0.0' - peerDependenciesMeta: - typescript: - optional: true - '@solana/rpc-spec-types@6.10.0': resolution: {integrity: sha512-NDZrKyZrJk4HaMFhTE/lAiMB824cWAodKqDHyKi0UteHU9pyRmil3BN1jt7e+j08mwMWwfklSgyrTaq52g6DIQ==} engines: {node: '>=20.18.0'} @@ -848,15 +673,6 @@ packages: typescript: optional: true - '@solana/rpc-spec-types@6.8.0': - resolution: {integrity: sha512-ebCWgiQbIgFOehU7PdRFmYCzda3Azc/qa2Y3P8gexSHSsDAO27VwS4E05XSY+a7cIL5MYmvUa1vpDynl1Rkakw==} - engines: {node: '>=20.18.0'} - peerDependencies: - typescript: '>=5.0.0' - peerDependenciesMeta: - typescript: - optional: true - '@solana/rpc-spec@6.10.0': resolution: {integrity: sha512-yQdbWw5mZEWrwsunHR9NHkuhMXIB9sPOObwm18D53v5tAJnxTB0IcHvO647XqFDLTK/yQ4AdDtlYD1vsY07AMQ==} engines: {node: '>=20.18.0'} @@ -866,29 +682,20 @@ packages: typescript: optional: true - '@solana/rpc-spec@6.8.0': - resolution: {integrity: sha512-kE5uOspxCVFJKNUu73hlebGiAFosjfYXbbTXAbGKfksPzy84u1oJFC2IVIobLRnqUCw1x7oJcvfnX00Zs0Itpg==} - engines: {node: '>=20.18.0'} - peerDependencies: - typescript: '>=5.0.0' - peerDependenciesMeta: - typescript: - optional: true - - '@solana/rpc-subscriptions-api@6.8.0': - resolution: {integrity: sha512-cPJOsydyoqkztW3msEH09wPDYqxJcMvO6DBlvrboq6wGu1UjeP66w2eApzQ8POoQHxhyw+CfEXl1Gbu6kKwuMQ==} + '@solana/rpc-subscriptions-api@6.10.0': + resolution: {integrity: sha512-CRPQoTtT1cOwOQUsqS7jgo7wYdAj7jB5ab/UmMPWVpecf2FNMhWhgvxP2s82M7VkDGTGl13qaQ0WySmi7Egrlg==} engines: {node: '>=20.18.0'} peerDependencies: - typescript: '>=5.0.0' + typescript: '>=5.4.0' peerDependenciesMeta: typescript: optional: true - '@solana/rpc-subscriptions-channel-websocket@6.8.0': - resolution: {integrity: sha512-c3PpkorYwhAz1iuUfM5sLpZQi8xtZFGbaPbaPRELVeDjFSRzoa12KFnuQs4i9fbVbLy5Cnt1t23tf0bL2snZCQ==} + '@solana/rpc-subscriptions-channel-websocket@6.10.0': + resolution: {integrity: sha512-KkqP1186HELPlJftA88SNAT2znR8knCVzsUipXVzY4zfW8sN3LOa0ePMzh9VZ/V+J+raTt55laR87ovAO0n+zw==} engines: {node: '>=20.18.0'} peerDependencies: - typescript: '>=5.0.0' + typescript: '>=5.4.0' peerDependenciesMeta: typescript: optional: true @@ -902,20 +709,11 @@ packages: typescript: optional: true - '@solana/rpc-subscriptions-spec@6.8.0': - resolution: {integrity: sha512-+t4L5q9qE6IVfunW3n1amA/3EswJr64pVqRF7234vCUuVUz4PgYfbqtEBV3KkA1o0NwEHHM3pXuofT63nBb8Bg==} - engines: {node: '>=20.18.0'} - peerDependencies: - typescript: '>=5.0.0' - peerDependenciesMeta: - typescript: - optional: true - - '@solana/rpc-subscriptions@6.8.0': - resolution: {integrity: sha512-9CotreNZmKAP2z07FY1I7TPPvylKLFF5p4mujB5ZFMHQPp5JVQFVCmMIhSj5voZHAeYx7jdwJ2Kf0RDeClqJzA==} + '@solana/rpc-subscriptions@6.10.0': + resolution: {integrity: sha512-6mfuHp/K7unFKCOTCCBC9ziEGnxe2tyJ74EbR51QUnBeCUdYD7Hhdpxic1WRSJ3UeNW/mG4OzFM6z8Wi64Eh9Q==} engines: {node: '>=20.18.0'} peerDependencies: - typescript: '>=5.0.0' + typescript: '>=5.4.0' peerDependenciesMeta: typescript: optional: true @@ -929,20 +727,11 @@ packages: typescript: optional: true - '@solana/rpc-transformers@6.8.0': - resolution: {integrity: sha512-GzcFkllym7eXbw7grdE41MCb15CjkibrXtr7EFsf4d6LD9DRvzFj2ZRYywS2FB2ibVP0LUXXGk3vmtkZJjfajA==} + '@solana/rpc-transport-http@6.10.0': + resolution: {integrity: sha512-JrdNuYi0nBbD3X8JUtgX1dQJwIwz/WJvmigDdELysXfGB2bTJpfjqGDLhCLOz2sRl66FASIEqgG/LVa2C9VXcA==} engines: {node: '>=20.18.0'} peerDependencies: - typescript: '>=5.0.0' - peerDependenciesMeta: - typescript: - optional: true - - '@solana/rpc-transport-http@6.8.0': - resolution: {integrity: sha512-jw/L0q2motGcx7yo6KvkKJd2HGVg9gvViXatFloLl1XmHbkwE7+97YYmG17WRuM5xauzI/UGYOXNW7cEB+Uaxw==} - engines: {node: '>=20.18.0'} - peerDependencies: - typescript: '>=5.0.0' + typescript: '>=5.4.0' peerDependenciesMeta: typescript: optional: true @@ -956,20 +745,11 @@ packages: typescript: optional: true - '@solana/rpc-types@6.8.0': - resolution: {integrity: sha512-vACMV9VR2JsZGDcgaMOFN/dwLK57CsE+erassxxtF12sSPXJooz+Vu1vyY2Yp2EkCc7mDf7BNkTKvSXajbt+Qw==} - engines: {node: '>=20.18.0'} - peerDependencies: - typescript: '>=5.0.0' - peerDependenciesMeta: - typescript: - optional: true - - '@solana/rpc@6.8.0': - resolution: {integrity: sha512-+jW4n9TDmBttY3bO3PdUo54GAnwFrd7UJsyfXoMgl/lWGQq5uddYDgnzQLtHOBP5zKslkR8h0RKkic0GZhMZrQ==} + '@solana/rpc@6.10.0': + resolution: {integrity: sha512-EwxsqoD+NXV+m+iobnWNtATD93gTgaNsOiQOzYB1/2e+8S6fl6obdNPB55yfXgtl4jt6GV6/ae4xuPhLv76vvg==} engines: {node: '>=20.18.0'} peerDependencies: - typescript: '>=5.0.0' + typescript: '>=5.4.0' peerDependenciesMeta: typescript: optional: true @@ -983,15 +763,6 @@ packages: typescript: optional: true - '@solana/signers@6.8.0': - resolution: {integrity: sha512-7E1cAXBLOcz9kmHhzWdu5m3UJlJzxfwOl8irOMLJI6NnKB2EmU0B0h4I+Mlfs9w8Bfj0WQpUei21ammbNBq39g==} - engines: {node: '>=20.18.0'} - peerDependencies: - typescript: '>=5.0.0' - peerDependenciesMeta: - typescript: - optional: true - '@solana/subscribable@6.10.0': resolution: {integrity: sha512-VsR6XMwkiDBkZJUcoGkEOhf397pOV75gKCL9Bx8bpi2T3Bbs0CxUpMn4yaUgAnRba3eXmjbXMNCXjttfa6sKbw==} engines: {node: '>=20.18.0'} @@ -1001,15 +772,6 @@ packages: typescript: optional: true - '@solana/subscribable@6.8.0': - resolution: {integrity: sha512-yj41Q97MiWrOmLj1iRFobvTdtU6H5wz5BlH5FHJg9lyapy1YQyaYF37MZx4LiUj4Ww0V3ReluIZTWWDBOJ53Jg==} - engines: {node: '>=20.18.0'} - peerDependencies: - typescript: '>=5.0.0' - peerDependenciesMeta: - typescript: - optional: true - '@solana/surfpool-darwin-arm64@1.4.0': resolution: {integrity: sha512-/sh7zJEzulPtBbB7ykUmhhrox+X1BJ1ff/OH81C7X/W0usXZtIRuonSKA2KgWHFFUVFHYRemMv6GmkmbbPlTjQ==} engines: {node: '>=18'} @@ -1032,20 +794,20 @@ packages: resolution: {integrity: sha512-YAHZz4Gkq4JKg82pVt4yENs+zxvcUliyS1Nz5JVBYiww3LZp9SG0Fsf/vHakQdgyuEaBwPuLVhS/PritsEhUbA==} engines: {node: '>=18'} - '@solana/sysvars@6.8.0': - resolution: {integrity: sha512-pwfMpMNL6MSmm07eHQYdTdRdzmPOd+EuVCCaNLSYdWGpYcocVJiaLiNWRV3cXA5wPj/ZFkoUGtc1bo0v7H50lw==} + '@solana/sysvars@6.10.0': + resolution: {integrity: sha512-cG13p1+onxz+20iWjwWQr1Z1jQwPm0fnjoW75fqZq7p4rVCie3L2sXvaJsYPjWKrUvpOzOIEHnqZGkG05rCpjg==} engines: {node: '>=20.18.0'} peerDependencies: - typescript: '>=5.0.0' + typescript: '>=5.4.0' peerDependenciesMeta: typescript: optional: true - '@solana/transaction-confirmation@6.8.0': - resolution: {integrity: sha512-R6rj8y/+kZqYJr8FR/fWxgi3Pw3eCiacUyjCPTVtdVe6i+hIiBApTGLzXrSRJmAMdpZrjYBZU1cG8C6oAb+B2A==} + '@solana/transaction-confirmation@6.10.0': + resolution: {integrity: sha512-ULvtg65qfenh4T/GYcIlKSUv5EqDcng9UN0dxbHU4kuZdR2e0B8HN2xDC4WhcFQVeFJSbTZmaYFkeTY/Y4gfGQ==} engines: {node: '>=20.18.0'} peerDependencies: - typescript: '>=5.0.0' + typescript: '>=5.4.0' peerDependenciesMeta: typescript: optional: true @@ -1059,15 +821,6 @@ packages: typescript: optional: true - '@solana/transaction-messages@6.8.0': - resolution: {integrity: sha512-jsJu9mAcN1x7onKOeC4WEvYP04UVcnkOYu/9bMe+S9jqjL+3DMy9kFZpV5FBl+TPuTNJrtOqc6Gc28hUWyyp1A==} - engines: {node: '>=20.18.0'} - peerDependencies: - typescript: '>=5.0.0' - peerDependenciesMeta: - typescript: - optional: true - '@solana/transactions@6.10.0': resolution: {integrity: sha512-VADSqP9OTYmhrox4pcgDd4+RjVmednXSE0+8Y7SPK4PN1pK5Az2RJ0nSsy0xcTnaOr8mF/crwFktqPrRQwSbQA==} engines: {node: '>=20.18.0'} @@ -1077,15 +830,6 @@ packages: typescript: optional: true - '@solana/transactions@6.8.0': - resolution: {integrity: sha512-Q46m+o3C1yL2EIZBAP5B8ou2VZwHN9wTi+muIS6/giCKO3jwUtnTEbWcZEDMj2vxUb7P2WfwTluZb/VAWxlx7Q==} - engines: {node: '>=20.18.0'} - peerDependencies: - typescript: '>=5.0.0' - peerDependenciesMeta: - typescript: - optional: true - '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -1223,10 +967,6 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - commander@14.0.3: - resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} - engines: {node: '>=20'} - commander@15.0.0: resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} engines: {node: '>=22.12.0'} @@ -1710,8 +1450,8 @@ packages: undici-types@7.19.2: resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} - undici-types@8.1.0: - resolution: {integrity: sha512-JlLXdMmH4kxyn2JPtGK/cajzKY7F15OKYG8sO5HfkIC1AC09sLUeptGFKjnMWnprDQ2EwzYDO3kgzkK3aaoHCA==} + undici-types@8.7.0: + resolution: {integrity: sha512-gbsS+hAjHg9iV+T8XWdFqnOZEk4f5xrrX3eb9Y1GDe+B5u9H68P6SzYXXUw/rkRvJHRgKIdNfAcrcVj/JvayVA==} unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} @@ -2035,35 +1775,35 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 - '@solana-program/compute-budget@0.15.0(@solana/kit@6.8.0(typescript@5.9.3))': + '@solana-program/compute-budget@0.15.0(@solana/kit@6.10.0(typescript@5.9.3))': dependencies: - '@solana/kit': 6.8.0(typescript@5.9.3) + '@solana/kit': 6.10.0(typescript@5.9.3) - '@solana-program/system@0.12.0(@solana/kit@6.8.0(typescript@5.9.3))': + '@solana-program/system@0.12.0(@solana/kit@6.10.0(typescript@5.9.3))': dependencies: - '@solana/kit': 6.8.0(typescript@5.9.3) + '@solana/kit': 6.10.0(typescript@5.9.3) - '@solana-program/token-2022@0.11.0(@solana/kit@6.8.0(typescript@5.9.3))(@solana/sysvars@6.8.0(typescript@5.9.3))': + '@solana-program/token-2022@0.11.0(@solana/kit@6.10.0(typescript@5.9.3))(@solana/sysvars@6.10.0(typescript@5.9.3))': dependencies: '@noble/curves': 1.9.7 - '@solana-program/zk-elgamal-proof': 0.1.0(@solana/kit@6.8.0(typescript@5.9.3)) - '@solana/kit': 6.8.0(typescript@5.9.3) - '@solana/sysvars': 6.8.0(typescript@5.9.3) + '@solana-program/zk-elgamal-proof': 0.1.0(@solana/kit@6.10.0(typescript@5.9.3)) + '@solana/kit': 6.10.0(typescript@5.9.3) + '@solana/sysvars': 6.10.0(typescript@5.9.3) - '@solana-program/token@0.11.0(@solana/kit@6.8.0(typescript@5.9.3))': + '@solana-program/token@0.11.0(@solana/kit@6.10.0(typescript@5.9.3))': dependencies: - '@solana-program/system': 0.12.0(@solana/kit@6.8.0(typescript@5.9.3)) - '@solana/kit': 6.8.0(typescript@5.9.3) + '@solana-program/system': 0.12.0(@solana/kit@6.10.0(typescript@5.9.3)) + '@solana/kit': 6.10.0(typescript@5.9.3) - '@solana-program/token@0.13.0(@solana/kit@6.8.0(typescript@5.9.3))': + '@solana-program/token@0.13.0(@solana/kit@6.10.0(typescript@5.9.3))': dependencies: - '@solana-program/system': 0.12.0(@solana/kit@6.8.0(typescript@5.9.3)) - '@solana/kit': 6.8.0(typescript@5.9.3) + '@solana-program/system': 0.12.0(@solana/kit@6.10.0(typescript@5.9.3)) + '@solana/kit': 6.10.0(typescript@5.9.3) - '@solana-program/zk-elgamal-proof@0.1.0(@solana/kit@6.8.0(typescript@5.9.3))': + '@solana-program/zk-elgamal-proof@0.1.0(@solana/kit@6.10.0(typescript@5.9.3))': dependencies: - '@solana-program/system': 0.12.0(@solana/kit@6.8.0(typescript@5.9.3)) - '@solana/kit': 6.8.0(typescript@5.9.3) + '@solana-program/system': 0.12.0(@solana/kit@6.10.0(typescript@5.9.3)) + '@solana/kit': 6.10.0(typescript@5.9.3) '@solana/accounts@6.10.0(typescript@5.9.3)': dependencies: @@ -2078,19 +1818,6 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder - '@solana/accounts@6.8.0(typescript@5.9.3)': - dependencies: - '@solana/addresses': 6.8.0(typescript@5.9.3) - '@solana/codecs-core': 6.8.0(typescript@5.9.3) - '@solana/codecs-strings': 6.8.0(typescript@5.9.3) - '@solana/errors': 6.8.0(typescript@5.9.3) - '@solana/rpc-spec': 6.8.0(typescript@5.9.3) - '@solana/rpc-types': 6.8.0(typescript@5.9.3) - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - fastestsmallesttextencoderdecoder - '@solana/addresses@6.10.0(typescript@5.9.3)': dependencies: '@solana/assertions': 6.10.0(typescript@5.9.3) @@ -2103,42 +1830,18 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder - '@solana/addresses@6.8.0(typescript@5.9.3)': - dependencies: - '@solana/assertions': 6.8.0(typescript@5.9.3) - '@solana/codecs-core': 6.8.0(typescript@5.9.3) - '@solana/codecs-strings': 6.8.0(typescript@5.9.3) - '@solana/errors': 6.8.0(typescript@5.9.3) - '@solana/nominal-types': 6.8.0(typescript@5.9.3) - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - fastestsmallesttextencoderdecoder - '@solana/assertions@6.10.0(typescript@5.9.3)': dependencies: '@solana/errors': 6.10.0(typescript@5.9.3) optionalDependencies: typescript: 5.9.3 - '@solana/assertions@6.8.0(typescript@5.9.3)': - dependencies: - '@solana/errors': 6.8.0(typescript@5.9.3) - optionalDependencies: - typescript: 5.9.3 - '@solana/codecs-core@6.10.0(typescript@5.9.3)': dependencies: '@solana/errors': 6.10.0(typescript@5.9.3) optionalDependencies: typescript: 5.9.3 - '@solana/codecs-core@6.8.0(typescript@5.9.3)': - dependencies: - '@solana/errors': 6.8.0(typescript@5.9.3) - optionalDependencies: - typescript: 5.9.3 - '@solana/codecs-data-structures@6.10.0(typescript@5.9.3)': dependencies: '@solana/codecs-core': 6.10.0(typescript@5.9.3) @@ -2147,14 +1850,6 @@ snapshots: optionalDependencies: typescript: 5.9.3 - '@solana/codecs-data-structures@6.8.0(typescript@5.9.3)': - dependencies: - '@solana/codecs-core': 6.8.0(typescript@5.9.3) - '@solana/codecs-numbers': 6.8.0(typescript@5.9.3) - '@solana/errors': 6.8.0(typescript@5.9.3) - optionalDependencies: - typescript: 5.9.3 - '@solana/codecs-numbers@6.10.0(typescript@5.9.3)': dependencies: '@solana/codecs-core': 6.10.0(typescript@5.9.3) @@ -2162,13 +1857,6 @@ snapshots: optionalDependencies: typescript: 5.9.3 - '@solana/codecs-numbers@6.8.0(typescript@5.9.3)': - dependencies: - '@solana/codecs-core': 6.8.0(typescript@5.9.3) - '@solana/errors': 6.8.0(typescript@5.9.3) - optionalDependencies: - typescript: 5.9.3 - '@solana/codecs-strings@6.10.0(typescript@5.9.3)': dependencies: '@solana/codecs-core': 6.10.0(typescript@5.9.3) @@ -2177,21 +1865,14 @@ snapshots: optionalDependencies: typescript: 5.9.3 - '@solana/codecs-strings@6.8.0(typescript@5.9.3)': - dependencies: - '@solana/codecs-core': 6.8.0(typescript@5.9.3) - '@solana/codecs-numbers': 6.8.0(typescript@5.9.3) - '@solana/errors': 6.8.0(typescript@5.9.3) - optionalDependencies: - typescript: 5.9.3 - - '@solana/codecs@6.8.0(typescript@5.9.3)': + '@solana/codecs@6.10.0(typescript@5.9.3)': dependencies: - '@solana/codecs-core': 6.8.0(typescript@5.9.3) - '@solana/codecs-data-structures': 6.8.0(typescript@5.9.3) - '@solana/codecs-numbers': 6.8.0(typescript@5.9.3) - '@solana/codecs-strings': 6.8.0(typescript@5.9.3) - '@solana/options': 6.8.0(typescript@5.9.3) + '@solana/codecs-core': 6.10.0(typescript@5.9.3) + '@solana/codecs-data-structures': 6.10.0(typescript@5.9.3) + '@solana/codecs-numbers': 6.10.0(typescript@5.9.3) + '@solana/codecs-strings': 6.10.0(typescript@5.9.3) + '@solana/fixed-points': 6.10.0(typescript@5.9.3) + '@solana/options': 6.10.0(typescript@5.9.3) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -2204,14 +1885,7 @@ snapshots: optionalDependencies: typescript: 5.9.3 - '@solana/errors@6.8.0(typescript@5.9.3)': - dependencies: - chalk: 5.6.2 - commander: 14.0.3 - optionalDependencies: - typescript: 5.9.3 - - '@solana/fast-stable-stringify@6.8.0(typescript@5.9.3)': + '@solana/fast-stable-stringify@6.10.0(typescript@5.9.3)': optionalDependencies: typescript: 5.9.3 @@ -2226,10 +1900,6 @@ snapshots: optionalDependencies: typescript: 5.9.3 - '@solana/functional@6.8.0(typescript@5.9.3)': - optionalDependencies: - typescript: 5.9.3 - '@solana/instruction-plans@6.10.0(typescript@5.9.3)': dependencies: '@solana/errors': 6.10.0(typescript@5.9.3) @@ -2243,19 +1913,6 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder - '@solana/instruction-plans@6.8.0(typescript@5.9.3)': - dependencies: - '@solana/errors': 6.8.0(typescript@5.9.3) - '@solana/instructions': 6.8.0(typescript@5.9.3) - '@solana/keys': 6.8.0(typescript@5.9.3) - '@solana/promises': 6.8.0(typescript@5.9.3) - '@solana/transaction-messages': 6.8.0(typescript@5.9.3) - '@solana/transactions': 6.8.0(typescript@5.9.3) - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - fastestsmallesttextencoderdecoder - '@solana/instructions@6.10.0(typescript@5.9.3)': dependencies: '@solana/codecs-core': 6.10.0(typescript@5.9.3) @@ -2263,13 +1920,6 @@ snapshots: optionalDependencies: typescript: 5.9.3 - '@solana/instructions@6.8.0(typescript@5.9.3)': - dependencies: - '@solana/codecs-core': 6.8.0(typescript@5.9.3) - '@solana/errors': 6.8.0(typescript@5.9.3) - optionalDependencies: - typescript: 5.9.3 - '@solana/keychain-core@1.4.0(@solana/addresses@6.10.0(typescript@5.9.3))(@solana/codecs-core@6.10.0(typescript@5.9.3))(@solana/codecs-strings@6.10.0(typescript@5.9.3))(@solana/keys@6.10.0(typescript@5.9.3))(@solana/signers@6.10.0(typescript@5.9.3))(@solana/transactions@6.10.0(typescript@5.9.3))': dependencies: '@solana/addresses': 6.10.0(typescript@5.9.3) @@ -2303,46 +1953,33 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder - '@solana/keys@6.8.0(typescript@5.9.3)': + '@solana/kit@6.10.0(typescript@5.9.3)': dependencies: - '@solana/assertions': 6.8.0(typescript@5.9.3) - '@solana/codecs-core': 6.8.0(typescript@5.9.3) - '@solana/codecs-strings': 6.8.0(typescript@5.9.3) - '@solana/errors': 6.8.0(typescript@5.9.3) - '@solana/nominal-types': 6.8.0(typescript@5.9.3) - '@solana/promises': 6.8.0(typescript@5.9.3) - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - fastestsmallesttextencoderdecoder - - '@solana/kit@6.8.0(typescript@5.9.3)': - dependencies: - '@solana/accounts': 6.8.0(typescript@5.9.3) - '@solana/addresses': 6.8.0(typescript@5.9.3) - '@solana/codecs': 6.8.0(typescript@5.9.3) - '@solana/errors': 6.8.0(typescript@5.9.3) - '@solana/functional': 6.8.0(typescript@5.9.3) - '@solana/instruction-plans': 6.8.0(typescript@5.9.3) - '@solana/instructions': 6.8.0(typescript@5.9.3) - '@solana/keys': 6.8.0(typescript@5.9.3) - '@solana/offchain-messages': 6.8.0(typescript@5.9.3) - '@solana/plugin-core': 6.8.0(typescript@5.9.3) - '@solana/plugin-interfaces': 6.8.0(typescript@5.9.3) - '@solana/program-client-core': 6.8.0(typescript@5.9.3) - '@solana/programs': 6.8.0(typescript@5.9.3) - '@solana/rpc': 6.8.0(typescript@5.9.3) - '@solana/rpc-api': 6.8.0(typescript@5.9.3) - '@solana/rpc-parsed-types': 6.8.0(typescript@5.9.3) - '@solana/rpc-spec-types': 6.8.0(typescript@5.9.3) - '@solana/rpc-subscriptions': 6.8.0(typescript@5.9.3) - '@solana/rpc-types': 6.8.0(typescript@5.9.3) - '@solana/signers': 6.8.0(typescript@5.9.3) - '@solana/subscribable': 6.8.0(typescript@5.9.3) - '@solana/sysvars': 6.8.0(typescript@5.9.3) - '@solana/transaction-confirmation': 6.8.0(typescript@5.9.3) - '@solana/transaction-messages': 6.8.0(typescript@5.9.3) - '@solana/transactions': 6.8.0(typescript@5.9.3) + '@solana/accounts': 6.10.0(typescript@5.9.3) + '@solana/addresses': 6.10.0(typescript@5.9.3) + '@solana/codecs': 6.10.0(typescript@5.9.3) + '@solana/errors': 6.10.0(typescript@5.9.3) + '@solana/functional': 6.10.0(typescript@5.9.3) + '@solana/instruction-plans': 6.10.0(typescript@5.9.3) + '@solana/instructions': 6.10.0(typescript@5.9.3) + '@solana/keys': 6.10.0(typescript@5.9.3) + '@solana/offchain-messages': 6.10.0(typescript@5.9.3) + '@solana/plugin-core': 6.10.0(typescript@5.9.3) + '@solana/plugin-interfaces': 6.10.0(typescript@5.9.3) + '@solana/program-client-core': 6.10.0(typescript@5.9.3) + '@solana/programs': 6.10.0(typescript@5.9.3) + '@solana/rpc': 6.10.0(typescript@5.9.3) + '@solana/rpc-api': 6.10.0(typescript@5.9.3) + '@solana/rpc-parsed-types': 6.10.0(typescript@5.9.3) + '@solana/rpc-spec-types': 6.10.0(typescript@5.9.3) + '@solana/rpc-subscriptions': 6.10.0(typescript@5.9.3) + '@solana/rpc-types': 6.10.0(typescript@5.9.3) + '@solana/signers': 6.10.0(typescript@5.9.3) + '@solana/subscribable': 6.10.0(typescript@5.9.3) + '@solana/sysvars': 6.10.0(typescript@5.9.3) + '@solana/transaction-confirmation': 6.10.0(typescript@5.9.3) + '@solana/transaction-messages': 6.10.0(typescript@5.9.3) + '@solana/transactions': 6.10.0(typescript@5.9.3) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -2350,12 +1987,12 @@ snapshots: - fastestsmallesttextencoderdecoder - utf-8-validate - '@solana/mpp@file:../typescript/packages/mpp(@solana/kit@6.8.0(typescript@5.9.3))(mppx@0.5.17(express@4.22.2)(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.3.6)))(typescript@5.9.3)': + '@solana/mpp@file:../typescript/packages/mpp(@solana/kit@6.10.0(typescript@5.9.3))(mppx@0.5.17(express@4.22.2)(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.3.6)))(typescript@5.9.3)': dependencies: - '@solana-program/compute-budget': 0.15.0(@solana/kit@6.8.0(typescript@5.9.3)) - '@solana-program/system': 0.12.0(@solana/kit@6.8.0(typescript@5.9.3)) - '@solana-program/token': 0.11.0(@solana/kit@6.8.0(typescript@5.9.3)) - '@solana/kit': 6.8.0(typescript@5.9.3) + '@solana-program/compute-budget': 0.15.0(@solana/kit@6.10.0(typescript@5.9.3)) + '@solana-program/system': 0.12.0(@solana/kit@6.10.0(typescript@5.9.3)) + '@solana-program/token': 0.11.0(@solana/kit@6.10.0(typescript@5.9.3)) + '@solana/kit': 6.10.0(typescript@5.9.3) '@solana/program-client-core': 6.10.0(typescript@5.9.3) mppx: 0.5.17(express@4.22.2)(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.3.6)) transitivePeerDependencies: @@ -2366,10 +2003,6 @@ snapshots: optionalDependencies: typescript: 5.9.3 - '@solana/nominal-types@6.8.0(typescript@5.9.3)': - optionalDependencies: - typescript: 5.9.3 - '@solana/offchain-messages@6.10.0(typescript@5.9.3)': dependencies: '@solana/addresses': 6.10.0(typescript@5.9.3) @@ -2385,40 +2018,25 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder - '@solana/offchain-messages@6.8.0(typescript@5.9.3)': - dependencies: - '@solana/addresses': 6.8.0(typescript@5.9.3) - '@solana/codecs-core': 6.8.0(typescript@5.9.3) - '@solana/codecs-data-structures': 6.8.0(typescript@5.9.3) - '@solana/codecs-numbers': 6.8.0(typescript@5.9.3) - '@solana/codecs-strings': 6.8.0(typescript@5.9.3) - '@solana/errors': 6.8.0(typescript@5.9.3) - '@solana/keys': 6.8.0(typescript@5.9.3) - '@solana/nominal-types': 6.8.0(typescript@5.9.3) - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - fastestsmallesttextencoderdecoder - - '@solana/options@6.8.0(typescript@5.9.3)': + '@solana/options@6.10.0(typescript@5.9.3)': dependencies: - '@solana/codecs-core': 6.8.0(typescript@5.9.3) - '@solana/codecs-data-structures': 6.8.0(typescript@5.9.3) - '@solana/codecs-numbers': 6.8.0(typescript@5.9.3) - '@solana/codecs-strings': 6.8.0(typescript@5.9.3) - '@solana/errors': 6.8.0(typescript@5.9.3) + '@solana/codecs-core': 6.10.0(typescript@5.9.3) + '@solana/codecs-data-structures': 6.10.0(typescript@5.9.3) + '@solana/codecs-numbers': 6.10.0(typescript@5.9.3) + '@solana/codecs-strings': 6.10.0(typescript@5.9.3) + '@solana/errors': 6.10.0(typescript@5.9.3) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: - fastestsmallesttextencoderdecoder - '@solana/pay-kit@file:../typescript/packages/pay-kit(@solana/addresses@6.10.0(typescript@5.9.3))(@solana/codecs-core@6.10.0(typescript@5.9.3))(@solana/codecs-strings@6.10.0(typescript@5.9.3))(@solana/keys@6.10.0(typescript@5.9.3))(@solana/signers@6.10.0(typescript@5.9.3))(@solana/sysvars@6.8.0(typescript@5.9.3))(@solana/transactions@6.10.0(typescript@5.9.3))(express@4.22.2)(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.3.6))': + '@solana/pay-kit@file:../typescript/packages/pay-kit(@solana/addresses@6.10.0(typescript@5.9.3))(@solana/codecs-core@6.10.0(typescript@5.9.3))(@solana/codecs-strings@6.10.0(typescript@5.9.3))(@solana/keys@6.10.0(typescript@5.9.3))(@solana/signers@6.10.0(typescript@5.9.3))(@solana/sysvars@6.10.0(typescript@5.9.3))(@solana/transactions@6.10.0(typescript@5.9.3))(express@4.22.2)(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.3.6))': dependencies: - '@solana-program/compute-budget': 0.15.0(@solana/kit@6.8.0(typescript@5.9.3)) - '@solana-program/token': 0.13.0(@solana/kit@6.8.0(typescript@5.9.3)) - '@solana-program/token-2022': 0.11.0(@solana/kit@6.8.0(typescript@5.9.3))(@solana/sysvars@6.8.0(typescript@5.9.3)) + '@solana-program/compute-budget': 0.15.0(@solana/kit@6.10.0(typescript@5.9.3)) + '@solana-program/token': 0.13.0(@solana/kit@6.10.0(typescript@5.9.3)) + '@solana-program/token-2022': 0.11.0(@solana/kit@6.10.0(typescript@5.9.3))(@solana/sysvars@6.10.0(typescript@5.9.3)) '@solana/keychain-memory': 1.4.0(@solana/addresses@6.10.0(typescript@5.9.3))(@solana/codecs-core@6.10.0(typescript@5.9.3))(@solana/codecs-strings@6.10.0(typescript@5.9.3))(@solana/keys@6.10.0(typescript@5.9.3))(@solana/signers@6.10.0(typescript@5.9.3))(@solana/transactions@6.10.0(typescript@5.9.3)) - '@solana/kit': 6.8.0(typescript@5.9.3) + '@solana/kit': 6.10.0(typescript@5.9.3) mppx: 0.5.17(express@4.22.2)(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.3.6)) transitivePeerDependencies: - '@modelcontextprotocol/sdk' @@ -2439,7 +2057,7 @@ snapshots: - utf-8-validate - viem - '@solana/plugin-core@6.8.0(typescript@5.9.3)': + '@solana/plugin-core@6.10.0(typescript@5.9.3)': optionalDependencies: typescript: 5.9.3 @@ -2457,20 +2075,6 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder - '@solana/plugin-interfaces@6.8.0(typescript@5.9.3)': - dependencies: - '@solana/addresses': 6.8.0(typescript@5.9.3) - '@solana/instruction-plans': 6.8.0(typescript@5.9.3) - '@solana/keys': 6.8.0(typescript@5.9.3) - '@solana/rpc-spec': 6.8.0(typescript@5.9.3) - '@solana/rpc-subscriptions-spec': 6.8.0(typescript@5.9.3) - '@solana/rpc-types': 6.8.0(typescript@5.9.3) - '@solana/signers': 6.8.0(typescript@5.9.3) - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - fastestsmallesttextencoderdecoder - '@solana/program-client-core@6.10.0(typescript@5.9.3)': dependencies: '@solana/accounts': 6.10.0(typescript@5.9.3) @@ -2487,26 +2091,10 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder - '@solana/program-client-core@6.8.0(typescript@5.9.3)': - dependencies: - '@solana/accounts': 6.8.0(typescript@5.9.3) - '@solana/addresses': 6.8.0(typescript@5.9.3) - '@solana/codecs-core': 6.8.0(typescript@5.9.3) - '@solana/errors': 6.8.0(typescript@5.9.3) - '@solana/instruction-plans': 6.8.0(typescript@5.9.3) - '@solana/instructions': 6.8.0(typescript@5.9.3) - '@solana/plugin-interfaces': 6.8.0(typescript@5.9.3) - '@solana/rpc-api': 6.8.0(typescript@5.9.3) - '@solana/signers': 6.8.0(typescript@5.9.3) - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - fastestsmallesttextencoderdecoder - - '@solana/programs@6.8.0(typescript@5.9.3)': + '@solana/programs@6.10.0(typescript@5.9.3)': dependencies: - '@solana/addresses': 6.8.0(typescript@5.9.3) - '@solana/errors': 6.8.0(typescript@5.9.3) + '@solana/addresses': 6.10.0(typescript@5.9.3) + '@solana/errors': 6.10.0(typescript@5.9.3) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -2516,10 +2104,6 @@ snapshots: optionalDependencies: typescript: 5.9.3 - '@solana/promises@6.8.0(typescript@5.9.3)': - optionalDependencies: - typescript: 5.9.3 - '@solana/rpc-api@6.10.0(typescript@5.9.3)': dependencies: '@solana/addresses': 6.10.0(typescript@5.9.3) @@ -2538,40 +2122,14 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder - '@solana/rpc-api@6.8.0(typescript@5.9.3)': - dependencies: - '@solana/addresses': 6.8.0(typescript@5.9.3) - '@solana/codecs-core': 6.8.0(typescript@5.9.3) - '@solana/codecs-strings': 6.8.0(typescript@5.9.3) - '@solana/errors': 6.8.0(typescript@5.9.3) - '@solana/keys': 6.8.0(typescript@5.9.3) - '@solana/rpc-parsed-types': 6.8.0(typescript@5.9.3) - '@solana/rpc-spec': 6.8.0(typescript@5.9.3) - '@solana/rpc-transformers': 6.8.0(typescript@5.9.3) - '@solana/rpc-types': 6.8.0(typescript@5.9.3) - '@solana/transaction-messages': 6.8.0(typescript@5.9.3) - '@solana/transactions': 6.8.0(typescript@5.9.3) - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - fastestsmallesttextencoderdecoder - '@solana/rpc-parsed-types@6.10.0(typescript@5.9.3)': optionalDependencies: typescript: 5.9.3 - '@solana/rpc-parsed-types@6.8.0(typescript@5.9.3)': - optionalDependencies: - typescript: 5.9.3 - '@solana/rpc-spec-types@6.10.0(typescript@5.9.3)': optionalDependencies: typescript: 5.9.3 - '@solana/rpc-spec-types@6.8.0(typescript@5.9.3)': - optionalDependencies: - typescript: 5.9.3 - '@solana/rpc-spec@6.10.0(typescript@5.9.3)': dependencies: '@solana/errors': 6.10.0(typescript@5.9.3) @@ -2580,33 +2138,26 @@ snapshots: optionalDependencies: typescript: 5.9.3 - '@solana/rpc-spec@6.8.0(typescript@5.9.3)': - dependencies: - '@solana/errors': 6.8.0(typescript@5.9.3) - '@solana/rpc-spec-types': 6.8.0(typescript@5.9.3) - optionalDependencies: - typescript: 5.9.3 - - '@solana/rpc-subscriptions-api@6.8.0(typescript@5.9.3)': + '@solana/rpc-subscriptions-api@6.10.0(typescript@5.9.3)': dependencies: - '@solana/addresses': 6.8.0(typescript@5.9.3) - '@solana/keys': 6.8.0(typescript@5.9.3) - '@solana/rpc-subscriptions-spec': 6.8.0(typescript@5.9.3) - '@solana/rpc-transformers': 6.8.0(typescript@5.9.3) - '@solana/rpc-types': 6.8.0(typescript@5.9.3) - '@solana/transaction-messages': 6.8.0(typescript@5.9.3) - '@solana/transactions': 6.8.0(typescript@5.9.3) + '@solana/addresses': 6.10.0(typescript@5.9.3) + '@solana/keys': 6.10.0(typescript@5.9.3) + '@solana/rpc-subscriptions-spec': 6.10.0(typescript@5.9.3) + '@solana/rpc-transformers': 6.10.0(typescript@5.9.3) + '@solana/rpc-types': 6.10.0(typescript@5.9.3) + '@solana/transaction-messages': 6.10.0(typescript@5.9.3) + '@solana/transactions': 6.10.0(typescript@5.9.3) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: - fastestsmallesttextencoderdecoder - '@solana/rpc-subscriptions-channel-websocket@6.8.0(typescript@5.9.3)': + '@solana/rpc-subscriptions-channel-websocket@6.10.0(typescript@5.9.3)': dependencies: - '@solana/errors': 6.8.0(typescript@5.9.3) - '@solana/functional': 6.8.0(typescript@5.9.3) - '@solana/rpc-subscriptions-spec': 6.8.0(typescript@5.9.3) - '@solana/subscribable': 6.8.0(typescript@5.9.3) + '@solana/errors': 6.10.0(typescript@5.9.3) + '@solana/functional': 6.10.0(typescript@5.9.3) + '@solana/rpc-subscriptions-spec': 6.10.0(typescript@5.9.3) + '@solana/subscribable': 6.10.0(typescript@5.9.3) ws: 8.20.1 optionalDependencies: typescript: 5.9.3 @@ -2623,28 +2174,19 @@ snapshots: optionalDependencies: typescript: 5.9.3 - '@solana/rpc-subscriptions-spec@6.8.0(typescript@5.9.3)': + '@solana/rpc-subscriptions@6.10.0(typescript@5.9.3)': dependencies: - '@solana/errors': 6.8.0(typescript@5.9.3) - '@solana/promises': 6.8.0(typescript@5.9.3) - '@solana/rpc-spec-types': 6.8.0(typescript@5.9.3) - '@solana/subscribable': 6.8.0(typescript@5.9.3) - optionalDependencies: - typescript: 5.9.3 - - '@solana/rpc-subscriptions@6.8.0(typescript@5.9.3)': - dependencies: - '@solana/errors': 6.8.0(typescript@5.9.3) - '@solana/fast-stable-stringify': 6.8.0(typescript@5.9.3) - '@solana/functional': 6.8.0(typescript@5.9.3) - '@solana/promises': 6.8.0(typescript@5.9.3) - '@solana/rpc-spec-types': 6.8.0(typescript@5.9.3) - '@solana/rpc-subscriptions-api': 6.8.0(typescript@5.9.3) - '@solana/rpc-subscriptions-channel-websocket': 6.8.0(typescript@5.9.3) - '@solana/rpc-subscriptions-spec': 6.8.0(typescript@5.9.3) - '@solana/rpc-transformers': 6.8.0(typescript@5.9.3) - '@solana/rpc-types': 6.8.0(typescript@5.9.3) - '@solana/subscribable': 6.8.0(typescript@5.9.3) + '@solana/errors': 6.10.0(typescript@5.9.3) + '@solana/fast-stable-stringify': 6.10.0(typescript@5.9.3) + '@solana/functional': 6.10.0(typescript@5.9.3) + '@solana/promises': 6.10.0(typescript@5.9.3) + '@solana/rpc-spec-types': 6.10.0(typescript@5.9.3) + '@solana/rpc-subscriptions-api': 6.10.0(typescript@5.9.3) + '@solana/rpc-subscriptions-channel-websocket': 6.10.0(typescript@5.9.3) + '@solana/rpc-subscriptions-spec': 6.10.0(typescript@5.9.3) + '@solana/rpc-transformers': 6.10.0(typescript@5.9.3) + '@solana/rpc-types': 6.10.0(typescript@5.9.3) + '@solana/subscribable': 6.10.0(typescript@5.9.3) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -2664,24 +2206,12 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder - '@solana/rpc-transformers@6.8.0(typescript@5.9.3)': - dependencies: - '@solana/errors': 6.8.0(typescript@5.9.3) - '@solana/functional': 6.8.0(typescript@5.9.3) - '@solana/nominal-types': 6.8.0(typescript@5.9.3) - '@solana/rpc-spec-types': 6.8.0(typescript@5.9.3) - '@solana/rpc-types': 6.8.0(typescript@5.9.3) - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - fastestsmallesttextencoderdecoder - - '@solana/rpc-transport-http@6.8.0(typescript@5.9.3)': + '@solana/rpc-transport-http@6.10.0(typescript@5.9.3)': dependencies: - '@solana/errors': 6.8.0(typescript@5.9.3) - '@solana/rpc-spec': 6.8.0(typescript@5.9.3) - '@solana/rpc-spec-types': 6.8.0(typescript@5.9.3) - undici-types: 8.1.0 + '@solana/errors': 6.10.0(typescript@5.9.3) + '@solana/rpc-spec': 6.10.0(typescript@5.9.3) + '@solana/rpc-spec-types': 6.10.0(typescript@5.9.3) + undici-types: 8.7.0 optionalDependencies: typescript: 5.9.3 @@ -2699,30 +2229,17 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder - '@solana/rpc-types@6.8.0(typescript@5.9.3)': + '@solana/rpc@6.10.0(typescript@5.9.3)': dependencies: - '@solana/addresses': 6.8.0(typescript@5.9.3) - '@solana/codecs-core': 6.8.0(typescript@5.9.3) - '@solana/codecs-numbers': 6.8.0(typescript@5.9.3) - '@solana/codecs-strings': 6.8.0(typescript@5.9.3) - '@solana/errors': 6.8.0(typescript@5.9.3) - '@solana/nominal-types': 6.8.0(typescript@5.9.3) - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - fastestsmallesttextencoderdecoder - - '@solana/rpc@6.8.0(typescript@5.9.3)': - dependencies: - '@solana/errors': 6.8.0(typescript@5.9.3) - '@solana/fast-stable-stringify': 6.8.0(typescript@5.9.3) - '@solana/functional': 6.8.0(typescript@5.9.3) - '@solana/rpc-api': 6.8.0(typescript@5.9.3) - '@solana/rpc-spec': 6.8.0(typescript@5.9.3) - '@solana/rpc-spec-types': 6.8.0(typescript@5.9.3) - '@solana/rpc-transformers': 6.8.0(typescript@5.9.3) - '@solana/rpc-transport-http': 6.8.0(typescript@5.9.3) - '@solana/rpc-types': 6.8.0(typescript@5.9.3) + '@solana/errors': 6.10.0(typescript@5.9.3) + '@solana/fast-stable-stringify': 6.10.0(typescript@5.9.3) + '@solana/functional': 6.10.0(typescript@5.9.3) + '@solana/rpc-api': 6.10.0(typescript@5.9.3) + '@solana/rpc-spec': 6.10.0(typescript@5.9.3) + '@solana/rpc-spec-types': 6.10.0(typescript@5.9.3) + '@solana/rpc-transformers': 6.10.0(typescript@5.9.3) + '@solana/rpc-transport-http': 6.10.0(typescript@5.9.3) + '@solana/rpc-types': 6.10.0(typescript@5.9.3) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -2744,22 +2261,6 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder - '@solana/signers@6.8.0(typescript@5.9.3)': - dependencies: - '@solana/addresses': 6.8.0(typescript@5.9.3) - '@solana/codecs-core': 6.8.0(typescript@5.9.3) - '@solana/errors': 6.8.0(typescript@5.9.3) - '@solana/instructions': 6.8.0(typescript@5.9.3) - '@solana/keys': 6.8.0(typescript@5.9.3) - '@solana/nominal-types': 6.8.0(typescript@5.9.3) - '@solana/offchain-messages': 6.8.0(typescript@5.9.3) - '@solana/transaction-messages': 6.8.0(typescript@5.9.3) - '@solana/transactions': 6.8.0(typescript@5.9.3) - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - fastestsmallesttextencoderdecoder - '@solana/subscribable@6.10.0(typescript@5.9.3)': dependencies: '@solana/errors': 6.10.0(typescript@5.9.3) @@ -2767,12 +2268,6 @@ snapshots: optionalDependencies: typescript: 5.9.3 - '@solana/subscribable@6.8.0(typescript@5.9.3)': - dependencies: - '@solana/errors': 6.8.0(typescript@5.9.3) - optionalDependencies: - typescript: 5.9.3 - '@solana/surfpool-darwin-arm64@1.4.0': optional: true @@ -2788,31 +2283,31 @@ snapshots: '@solana/surfpool-darwin-x64': 1.4.0 '@solana/surfpool-linux-x64-gnu': 1.4.0 - '@solana/sysvars@6.8.0(typescript@5.9.3)': + '@solana/sysvars@6.10.0(typescript@5.9.3)': dependencies: - '@solana/accounts': 6.8.0(typescript@5.9.3) - '@solana/codecs-core': 6.8.0(typescript@5.9.3) - '@solana/codecs-data-structures': 6.8.0(typescript@5.9.3) - '@solana/codecs-numbers': 6.8.0(typescript@5.9.3) - '@solana/errors': 6.8.0(typescript@5.9.3) - '@solana/rpc-types': 6.8.0(typescript@5.9.3) + '@solana/accounts': 6.10.0(typescript@5.9.3) + '@solana/codecs-core': 6.10.0(typescript@5.9.3) + '@solana/codecs-data-structures': 6.10.0(typescript@5.9.3) + '@solana/codecs-numbers': 6.10.0(typescript@5.9.3) + '@solana/errors': 6.10.0(typescript@5.9.3) + '@solana/rpc-types': 6.10.0(typescript@5.9.3) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: - fastestsmallesttextencoderdecoder - '@solana/transaction-confirmation@6.8.0(typescript@5.9.3)': - dependencies: - '@solana/addresses': 6.8.0(typescript@5.9.3) - '@solana/codecs-strings': 6.8.0(typescript@5.9.3) - '@solana/errors': 6.8.0(typescript@5.9.3) - '@solana/keys': 6.8.0(typescript@5.9.3) - '@solana/promises': 6.8.0(typescript@5.9.3) - '@solana/rpc': 6.8.0(typescript@5.9.3) - '@solana/rpc-subscriptions': 6.8.0(typescript@5.9.3) - '@solana/rpc-types': 6.8.0(typescript@5.9.3) - '@solana/transaction-messages': 6.8.0(typescript@5.9.3) - '@solana/transactions': 6.8.0(typescript@5.9.3) + '@solana/transaction-confirmation@6.10.0(typescript@5.9.3)': + dependencies: + '@solana/addresses': 6.10.0(typescript@5.9.3) + '@solana/codecs-strings': 6.10.0(typescript@5.9.3) + '@solana/errors': 6.10.0(typescript@5.9.3) + '@solana/keys': 6.10.0(typescript@5.9.3) + '@solana/promises': 6.10.0(typescript@5.9.3) + '@solana/rpc': 6.10.0(typescript@5.9.3) + '@solana/rpc-subscriptions': 6.10.0(typescript@5.9.3) + '@solana/rpc-types': 6.10.0(typescript@5.9.3) + '@solana/transaction-messages': 6.10.0(typescript@5.9.3) + '@solana/transactions': 6.10.0(typescript@5.9.3) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -2836,22 +2331,6 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder - '@solana/transaction-messages@6.8.0(typescript@5.9.3)': - dependencies: - '@solana/addresses': 6.8.0(typescript@5.9.3) - '@solana/codecs-core': 6.8.0(typescript@5.9.3) - '@solana/codecs-data-structures': 6.8.0(typescript@5.9.3) - '@solana/codecs-numbers': 6.8.0(typescript@5.9.3) - '@solana/errors': 6.8.0(typescript@5.9.3) - '@solana/functional': 6.8.0(typescript@5.9.3) - '@solana/instructions': 6.8.0(typescript@5.9.3) - '@solana/nominal-types': 6.8.0(typescript@5.9.3) - '@solana/rpc-types': 6.8.0(typescript@5.9.3) - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - fastestsmallesttextencoderdecoder - '@solana/transactions@6.10.0(typescript@5.9.3)': dependencies: '@solana/addresses': 6.10.0(typescript@5.9.3) @@ -2871,25 +2350,6 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder - '@solana/transactions@6.8.0(typescript@5.9.3)': - dependencies: - '@solana/addresses': 6.8.0(typescript@5.9.3) - '@solana/codecs-core': 6.8.0(typescript@5.9.3) - '@solana/codecs-data-structures': 6.8.0(typescript@5.9.3) - '@solana/codecs-numbers': 6.8.0(typescript@5.9.3) - '@solana/codecs-strings': 6.8.0(typescript@5.9.3) - '@solana/errors': 6.8.0(typescript@5.9.3) - '@solana/functional': 6.8.0(typescript@5.9.3) - '@solana/instructions': 6.8.0(typescript@5.9.3) - '@solana/keys': 6.8.0(typescript@5.9.3) - '@solana/nominal-types': 6.8.0(typescript@5.9.3) - '@solana/rpc-types': 6.8.0(typescript@5.9.3) - '@solana/transaction-messages': 6.8.0(typescript@5.9.3) - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - fastestsmallesttextencoderdecoder - '@standard-schema/spec@1.1.0': {} '@toon-format/toon@2.1.0': {} @@ -3049,8 +2509,6 @@ snapshots: chalk@5.6.2: {} - commander@14.0.3: {} - commander@15.0.0: {} content-disposition@0.5.4: @@ -3553,7 +3011,7 @@ snapshots: undici-types@7.19.2: {} - undici-types@8.1.0: {} + undici-types@8.7.0: {} unpipe@1.0.0: {} diff --git a/harness/tsconfig.json b/harness/tsconfig.json index 4315bd619..ab302dcd3 100644 --- a/harness/tsconfig.json +++ b/harness/tsconfig.json @@ -12,6 +12,10 @@ "types": ["node", "vitest/globals"] }, "include": ["src", "test"], - "//": "The on-chain suite imports @solana/pay-kit (a file: dep built separately) and only runs against a forked validator. Excluded from the base typecheck so the cross-language CI jobs (which build only @solana/mpp) stay green; typecheck it via tsconfig.onchain.json where pay-kit is built.", - "exclude": ["src/onchain", "test/onchain.e2e.test.ts"] + "//": "The on-chain suite and the high-level PayKit boot fixture require @solana/pay-kit, which is built only by their dedicated CI paths. Keep the shared cross-language typecheck on its @solana/mpp-only baseline.", + "exclude": [ + "src/fixtures/typescript/paykit-boot.ts", + "src/onchain", + "test/onchain.e2e.test.ts" + ] }