Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ jobs:
PORT: "3002"
NETWORK: localnet
RPC_URL: http://localhost:8899
MPP_SECRET_KEY: playground-ci-secret
MPP_SECRET_KEY: playground-ci-secret-padding-0123456789
run: go run ./examples/playground-api &
- name: Wait for playground server
working-directory: .
Expand Down
2 changes: 1 addition & 1 deletion go/examples/playground-api/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ func newTestServer(t *testing.T) (*httptest.Server, *app) {
network: "localnet",
rpcURL: stub.URL,
recipient: feePayer.PublicKey().String(),
secretKey: "playground-smoke-secret",
secretKey: "playground-smoke-secret-0123456789ab",
feePayer: feePayer,
rpcClient: rpc.New(stub.URL),
repoRoot: t.TempDir(), // empty root: no docs generated, no SPA dist
Expand Down
2 changes: 1 addition & 1 deletion go/examples/playground-api/playground_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ func TestPlaygroundSessionE2ESurfpool(t *testing.T) {
network: "localnet",
rpcURL: sandboxRPCURL(),
recipient: feePayer.PublicKey().String(),
secretKey: "playground-e2e-secret",
secretKey: "playground-e2e-secret-0123456789abc",
feePayer: feePayer,
rpcClient: rpcClient,
repoRoot: t.TempDir(),
Expand Down
23 changes: 23 additions & 0 deletions go/paycore/network_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,26 @@ func TestParseSolanaNetwork(t *testing.T) {
}
}
}

// #37: the boot-time allowlist accepts only canonical slugs and rejects the
// legacy mainnet-beta spelling, empty, and unknown values.
func TestRequireKnownNetwork(t *testing.T) {
for _, ok := range []string{"mainnet", "devnet", "localnet"} {
if got, err := RequireKnownNetwork(ok); err != nil || string(got) != ok {
t.Fatalf("RequireKnownNetwork(%q) = (%q, %v), want accepted", ok, got, err)
}
}
for _, bad := range []string{"mainnet-beta", "testnet", "", "MAINNET"} {
if _, err := RequireKnownNetwork(bad); err == nil {
t.Fatalf("RequireKnownNetwork(%q) should be rejected", bad)
}
}
}

func TestResolveMintCanonicalMainnet(t *testing.T) {
// The canonical "mainnet" slug must resolve known mints just like the
// legacy table key.
if got := ResolveMint("USDC", "mainnet"); got != USDCMainnetMint {
t.Fatalf("ResolveMint(USDC, mainnet) = %q, want %q", got, USDCMainnetMint)
}
}
38 changes: 36 additions & 2 deletions go/paycore/solana.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
// byte-identical across language SDKs.
package paycore

import "strings"
import (
"fmt"
"strings"
)

// Solana program and well-known mint addresses used by the SDK.
// Mirrors the constant tables in rust/src/protocol/solana.rs so the
Expand Down Expand Up @@ -57,6 +60,26 @@ var token2022Stablecoins = map[string]struct{}{
"CASH": {},
}

// RequireKnownNetwork validates a network slug against the canonical
// allowlist {mainnet, devnet, localnet}. The legacy "mainnet-beta" spelling
// (any case) is rejected in favor of the canonical "mainnet"; an empty slug
// or any other value (e.g. "testnet") is also rejected. Mirrors the Rust
// reference validate_network (protocol/solana.rs) so a misconfigured server
// fails fast at boot rather than silently resolving an unknown slug to
// mainnet mints. Returns the canonical slug on success.
func RequireKnownNetwork(network string) (SolanaNetwork, error) {
if strings.TrimSpace(network) == "" {
return "", fmt.Errorf("network is required (one of %s, %s, %s)", NetworkMainnet, NetworkDevnet, NetworkLocalnet)
}
switch network {
case string(NetworkMainnet), string(NetworkDevnet), string(NetworkLocalnet):
return SolanaNetwork(network), nil
default:
return "", fmt.Errorf("unsupported network %q (must be one of %s, %s, %s; the legacy %q spelling is not accepted)",
network, NetworkMainnet, NetworkDevnet, NetworkLocalnet, "mainnet-beta")
}
}

// DefaultRPCURL returns the default RPC endpoint for a Solana network.
func DefaultRPCURL(network string) string {
switch network {
Expand All @@ -69,6 +92,17 @@ func DefaultRPCURL(network string) string {
}
}

// mintNetworkKey folds a network slug onto the key used in the knownMints
// table. The table is keyed by the legacy "mainnet-beta" spelling for
// historical reasons; the canonical "mainnet" slug (and unknown slugs) map
// to it so the wire-format mint tables stay stable.
func mintNetworkKey(network string) string {
if string(ParseSolanaNetwork(network)) == string(NetworkMainnet) {
return "mainnet-beta"
}
return network
}

// ResolveMint converts a symbolic currency into a mint address.
// Returns an empty string for native SOL.
func ResolveMint(currency string, network string) string {
Expand All @@ -78,7 +112,7 @@ func ResolveMint(currency string, network string) string {
return ""
}
if mints, ok := knownMints[normalized]; ok {
if mint, ok := mints[network]; ok {
if mint, ok := mints[mintNetworkKey(network)]; ok {
return mint
}
return mints["mainnet-beta"]
Expand Down
2 changes: 1 addition & 1 deletion go/paykit/paykit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ func mustClient(t *testing.T) *paykit.Client {
Network: paykit.SolanaLocalnet,
Preflight: disabled(),
MPP: paykit.MPPConfig{
ChallengeBindingSecret: []byte("test-secret"),
ChallengeBindingSecret: []byte("test-secret-key-0123456789abcdef"),
},
})
if err != nil {
Expand Down
219 changes: 219 additions & 0 deletions go/protocols/mpp/client/audit_fixes_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
package client

import (
"context"
"strings"
"testing"

solana "github.com/gagliardetto/solana-go"

"github.com/solana-foundation/pay-kit/go/internal/testutil"
"github.com/solana-foundation/pay-kit/go/paycore"
"github.com/solana-foundation/pay-kit/go/paycore/solanatx"
core "github.com/solana-foundation/pay-kit/go/protocols/mpp/core"
)

// solChallenge builds a minimal solana/charge challenge for the credential
// header builder tests, with optional expiry.
func solChallenge(t *testing.T, amount, network, expires string) core.PaymentChallenge {
t.Helper()
req, err := core.NewBase64URLJSONValue(map[string]any{
"amount": amount,
"currency": "sol",
"recipient": testutil.NewPrivateKey().PublicKey().String(),
"methodDetails": map[string]any{"network": network},
})
if err != nil {
t.Fatalf("encode request: %v", err)
}
return core.NewChallengeWithSecretFull(
"binding-secret-0123456789abcdef01234567",
"realm", core.NewMethodName("solana"), core.NewIntentName("charge"),
req, expires, "", "", nil,
)
}

// --- #42 decimals required for SPL ---

func TestBuildChargeRejectsMissingDecimalsForSPL(t *testing.T) {
rpcClient := testutil.NewFakeRPC()
signer := testutil.NewPrivateKey()
recipient := testutil.NewPrivateKey().PublicKey().String()
mint := testutil.NewPrivateKey().PublicKey()
rpcClient.MintOwners[mint.String()] = solana.TokenProgramID

_, err := BuildChargeTransaction(context.Background(), signer, rpcClient, "1000", mint.String(), recipient, paycore.MethodDetails{
// Decimals omitted on purpose.
}, BuildOptions{})
if err == nil || !strings.Contains(err.Error(), "decimals") {
t.Fatalf("expected missing-decimals rejection, got %v", err)
}
}

// --- #26 unknown Token-2022 mint gated ---

func TestBuildChargeRefusesUnknownToken2022WithoutOptIn(t *testing.T) {
rpcClient := testutil.NewFakeRPC()
signer := testutil.NewPrivateKey()
recipient := testutil.NewPrivateKey().PublicKey().String()
mint := testutil.NewPrivateKey().PublicKey()
rpcClient.MintOwners[mint.String()] = solana.MustPublicKeyFromBase58(paycore.Token2022Program)
decimals := uint8(6)

_, err := BuildChargeTransaction(context.Background(), signer, rpcClient, "1000", mint.String(), recipient, paycore.MethodDetails{
Decimals: &decimals,
}, BuildOptions{})
if err == nil || !strings.Contains(err.Error(), "Token-2022") {
t.Fatalf("expected unknown Token-2022 rejection, got %v", err)
}
}

func TestBuildChargeAllowsUnknownVanillaTokenMint(t *testing.T) {
rpcClient := testutil.NewFakeRPC()
signer := testutil.NewPrivateKey()
recipient := testutil.NewPrivateKey().PublicKey().String()
mint := testutil.NewPrivateKey().PublicKey()
rpcClient.MintOwners[mint.String()] = solana.TokenProgramID
decimals := uint8(6)

if _, err := BuildChargeTransaction(context.Background(), signer, rpcClient, "1000", mint.String(), recipient, paycore.MethodDetails{
Decimals: &decimals,
}, BuildOptions{}); err != nil {
t.Fatalf("expected unknown vanilla Token mint to be allowed: %v", err)
}
}

// --- #20 split ATA only created when flagged ---

func TestBuildChargeSplitATAOnlyWhenFlagged(t *testing.T) {
rpcClient := testutil.NewFakeRPC()
signer := testutil.NewPrivateKey()
recipient := testutil.NewPrivateKey().PublicKey().String()
split := testutil.NewPrivateKey().PublicKey().String()
mint := testutil.NewPrivateKey().PublicKey()
rpcClient.MintOwners[mint.String()] = solana.TokenProgramID
decimals := uint8(6)
required := true

// Flagged split => an ATA-create instruction is included.
payload, err := BuildChargeTransaction(context.Background(), signer, rpcClient, "1000", mint.String(), recipient, paycore.MethodDetails{
Decimals: &decimals,
Splits: []paycore.Split{{Recipient: split, Amount: "100", AtaCreationRequired: &required}},
}, BuildOptions{})
if err != nil {
t.Fatalf("build flagged: %v", err)
}
flaggedCount := ataCreateCount(t, payload.Transaction)

// Unflagged split => no ATA-create instruction in client-paid mode.
payload2, err := BuildChargeTransaction(context.Background(), signer, rpcClient, "1000", mint.String(), recipient, paycore.MethodDetails{
Decimals: &decimals,
Splits: []paycore.Split{{Recipient: split, Amount: "100"}},
}, BuildOptions{})
if err != nil {
t.Fatalf("build unflagged: %v", err)
}
unflaggedCount := ataCreateCount(t, payload2.Transaction)

if flaggedCount <= unflaggedCount {
t.Fatalf("expected flagged split to create more ATAs (%d) than unflagged (%d)", flaggedCount, unflaggedCount)
}
if unflaggedCount != 0 {
t.Fatalf("expected no split ATA creation when unflagged, got %d", unflaggedCount)
}
}

func ataCreateCount(t *testing.T, encoded string) int {
t.Helper()
ataProgram := solana.MustPublicKeyFromBase58(paycore.AssociatedTokenProgram)
tx, err := solanatx.DecodeTransactionBase64(encoded)
if err != nil {
t.Fatalf("decode tx: %v", err)
}
count := 0
for _, ix := range tx.Message.Instructions {
if tx.Message.AccountKeys[ix.ProgramIDIndex].Equals(ataProgram) {
count++
}
}
return count
}

// --- #10 max amount / expected network / expiry ---

func TestBuildCredentialHeaderRejectsAmountAboveMax(t *testing.T) {
rpcClient := testutil.NewFakeRPC()
signer := testutil.NewPrivateKey()
challenge := solChallenge(t, "1000", "localnet", "")
_, err := BuildCredentialHeaderWithOptions(context.Background(), signer, rpcClient, challenge, BuildOptions{
MaxAmountBaseUnits: 999,
})
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "maximum") {
t.Fatalf("expected amount-above-max rejection, got %v", err)
}
}

func TestBuildCredentialHeaderAcceptsAmountAtMax(t *testing.T) {
rpcClient := testutil.NewFakeRPC()
signer := testutil.NewPrivateKey()
challenge := solChallenge(t, "1000", "localnet", "")
if _, err := BuildCredentialHeaderWithOptions(context.Background(), signer, rpcClient, challenge, BuildOptions{
MaxAmountBaseUnits: 1000,
}); err != nil {
t.Fatalf("expected at-cap amount to be accepted: %v", err)
}
}

func TestBuildCredentialHeaderRejectsUnexpectedNetwork(t *testing.T) {
rpcClient := testutil.NewFakeRPC()
signer := testutil.NewPrivateKey()
challenge := solChallenge(t, "1000", "devnet", "")
_, err := BuildCredentialHeaderWithOptions(context.Background(), signer, rpcClient, challenge, BuildOptions{
ExpectedNetwork: "localnet",
})
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "network") {
t.Fatalf("expected network mismatch rejection, got %v", err)
}
}

func TestBuildCredentialHeaderRejectsExpiredChallenge(t *testing.T) {
rpcClient := testutil.NewFakeRPC()
signer := testutil.NewPrivateKey()
challenge := solChallenge(t, "1000", "localnet", "2000-01-01T00:00:00Z")
_, err := BuildCredentialHeaderWithOptions(context.Background(), signer, rpcClient, challenge, BuildOptions{})
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "expired") {
t.Fatalf("expected expired-challenge rejection, got %v", err)
}
}

// --- #17 method/intent gate ---

func TestBuildCredentialHeaderRejectsNonSolanaMethod(t *testing.T) {
rpcClient := testutil.NewFakeRPC()
signer := testutil.NewPrivateKey()
req, _ := core.NewBase64URLJSONValue(map[string]any{
"amount": "1000", "currency": "sol",
"recipient": testutil.NewPrivateKey().PublicKey().String(),
"methodDetails": map[string]any{"network": "localnet"},
})
challenge := core.NewChallengeWithSecret("binding-secret-0123456789abcdef01234567", "realm",
core.NewMethodName("stripe"), core.NewIntentName("charge"), req)
if _, err := BuildCredentialHeaderWithOptions(context.Background(), signer, rpcClient, challenge, BuildOptions{}); err == nil {
t.Fatal("expected non-solana method to be rejected")
}
}

func TestBuildCredentialHeaderRejectsNonChargeIntent(t *testing.T) {
rpcClient := testutil.NewFakeRPC()
signer := testutil.NewPrivateKey()
req, _ := core.NewBase64URLJSONValue(map[string]any{
"amount": "1000", "currency": "sol",
"recipient": testutil.NewPrivateKey().PublicKey().String(),
"methodDetails": map[string]any{"network": "localnet"},
})
challenge := core.NewChallengeWithSecret("binding-secret-0123456789abcdef01234567", "realm",
core.NewMethodName("solana"), core.NewIntentName("session"), req)
if _, err := BuildCredentialHeaderWithOptions(context.Background(), signer, rpcClient, challenge, BuildOptions{}); err == nil {
t.Fatal("expected non-charge intent to be rejected")
}
}
Loading
Loading