Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions .github/actions/setup-harness/action.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
Expand All @@ -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 != ''
Expand Down Expand Up @@ -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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
5 changes: 4 additions & 1 deletion go/docs/snippets/charge.server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 5 additions & 4 deletions go/examples/playground-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
19 changes: 10 additions & 9 deletions go/examples/playground-api/charges.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 11 additions & 7 deletions go/examples/playground-api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
47 changes: 40 additions & 7 deletions go/examples/playground-api/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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()
Expand Down
15 changes: 8 additions & 7 deletions go/examples/playground-api/playground_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
5 changes: 5 additions & 0 deletions go/examples/simple-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion go/examples/simple-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
18 changes: 10 additions & 8 deletions go/paykit/adapters/mpp/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 32 additions & 2 deletions go/paykit/adapters/mpp/adapter_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
}
}

Expand Down Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion go/paykit/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down
Loading
Loading