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/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..a7279b657 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,38 @@ 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") + } + // 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 _, err := client.MppAdapter().ChallengeHeaders(gate); err != nil { + t.Fatalf("localnet MPP challenge should succeed with the permissive default store: %v", err) + } +} + // 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..976d40868 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,28 @@ 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) { + // 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 { + 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..5e634984d 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,35 @@ 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") + } + // 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.Store = core.NewMemoryStore() + 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 && !isLocalnet { + 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..5e90808f6 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,155 @@ 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 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() + 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() devnet 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) { + // 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: "devnet", + 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 +954,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 +999,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 +1039,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 +1075,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 +1105,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 +1161,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 +1177,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 +1218,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 +1718,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 +1753,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 +1794,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 +1825,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 +1873,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 +1917,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) 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/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/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/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/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 0f4ec65b6..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,39 +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"; - -// Probe availability, computed once (the git-grep subject probes fork a -// subprocess; per-field recomputation doubled that cost for no benefit). -const tsGuardImplemented = sdkImplementsOptInGuard("typescript/packages/pay-kit/src", TS_GUARD_MARKER); -const pythonGuardImplemented = sdkImplementsOptInGuard("python/src"); - const coveredProbes: CoveredProbe[] = [ { id: "go", @@ -227,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") && tsGuardImplemented, - unavailableReason: !commandExists("pnpm") - ? "pnpm missing" - : tsGuardImplemented - ? 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") && pythonGuardImplemented, - unavailableReason: !commandExists("uv") - ? "uv missing" - : pythonGuardImplemented - ? 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 @@ -271,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 @@ -303,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", @@ -355,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); @@ -370,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); @@ -387,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); }, @@ -400,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. }); @@ -415,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", @@ -427,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]; diff --git a/harness/test/onchain.e2e.test.ts b/harness/test/onchain.e2e.test.ts index 6bd9d9c8c..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,12 +43,48 @@ 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: { challengeBindingSecret: crypto.randomBytes(32).toString("hex") }, + mpp: { + challengeBindingSecret: crypto.randomBytes(32).toString("hex"), + }, network: "localnet", operator: { recipient: operator.address, signer: operator }, pricing: { @@ -58,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, }); 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", }); 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" + ] } 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. 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/__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/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/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 70e05e13b..3a464c5d5 100644 --- a/typescript/packages/mpp/src/server/index.ts +++ b/typescript/packages/mpp/src/server/index.ts @@ -1,16 +1,26 @@ 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, + createUnsafeMemoryReplayStore, + isUnsafeMemoryReplayStore, + resolveReplayStore, + type ReplayStore, + type ReplayStoreCapability, +} from './store.js'; export { buildReclaimInstruction, encodeVoucherMessageBytes, 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); 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; +}