Skip to content

fix(go): migrate to solana-go v2 and harden verification#214

Merged
lgalabru merged 66 commits into
solana-foundation:split/pr216-ci-harness-megafrom
EfeDurmaz16:fix/go-idiomatic-cleanup
Jul 13, 2026
Merged

fix(go): migrate to solana-go v2 and harden verification#214
lgalabru merged 66 commits into
solana-foundation:split/pr216-ci-harness-megafrom
EfeDurmaz16:fix/go-idiomatic-cleanup

Conversation

@EfeDurmaz16

@EfeDurmaz16 EfeDurmaz16 commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Go SDK idiomatic cleanup, solana-go v2 migration, and the Go security-hardening delta, delivered as one per-language PR onto #219.

What this PR contains

  • Idiomatic Go cleanup following Effective Go, Uber Go, and existing repository conventions.
  • Full migration to github.com/solana-foundation/solana-go/v2 across the SDK and Go harness modules; gagliardetto/solana-go is not reintroduced.
  • The security changes previously reviewed in fix(go): x402 source-ATA drain guard + canonical-JSON + conformance driver #224: real x402 exact-verifier conformance, managed fee-payer drain protection, RFC 8785 canonical JSON fixes, strict key parsing, and their regression tests.

Stack equivalence

#224 is now subsumed: its final head and this PR's current head are the same commit, 1e8d8d2166657be224d341e1d2de1503f4077e02. This preserves the separate review history while presenting maintainers with the requested single green Go delivery into #219.

Diff note

The cross-language upto files visible against #219 come from already-landed #220 because #219 trails main by that commit; they are not authored by this Go PR.

Verification

  • go test ./...
  • Go client and server harness tests
  • downstream module consumer guard
  • repository-wide gofmt -l clean
  • GitHub Go lint and core lint/format checks

The routed full-language harness failures are integration-base gaps that clear as their corresponding language PRs land into #219; the Go-owned checks are required to remain green.

@greptile-apps

greptile-apps Bot commented Jul 2, 2026

Copy link
Copy Markdown

Greptile Summary

This PR migrates the Go SDK from the vendored gagliardetto/solana-go fork to solana-foundation/solana-go/v2, applies idiomatic Go modernization (slices/maps stdlib, min/max builtins, range-over-int), and delivers the security hardening previously reviewed in #224.

  • Managed-signer drain protection: rejectManagedSourceOwner adds an on-chain RPC check for token-account ownership, and verifyTransfer expands structural guards to cover the full multisig signer tail and ATA-alias paths. The cosign function is locked to signature slot 0 (the fee-payer slot) to prevent arbitrary-slot signing.
  • Atomic 402 challenge assembly: appendChallenge and usageChallenge helpers ensure the accepts[] entry and challenge header are always added together; a build failure on either half now surfaces as a 500 challenge_generation_failed rather than a partial 402 the client cannot pay.
  • Strict key validation: signer.FromBytes now verifies the embedded public-key half matches the seed, rejecting manipulated key material before it is used as an operator identity.

Confidence Score: 5/5

Safe to merge. The security-critical paths (managed-signer drain guard, cosign slot restriction, atomic challenge assembly) are all backed by targeted regression tests, and the solana-go v2 migration is a clean import path swap with no behavioral changes to public APIs.

The three security-hardening additions are each independently defended: rejectManagedSourceOwner fails closed on every infrastructure error, the structural verifier changes are tested against multisig and ATA-alias shapes, and the atomic challenge helpers mirror the existing appendChallenge pattern exactly. No partial-success paths were found that could emit an unpayable 402 or skip operator signing.

No files require special attention. The most security-sensitive paths — exact.go, verify.go, and usage.go — all have direct test coverage for the new code paths added by this PR.

Important Files Changed

Filename Overview
go/paykit/adapters/x402/exact.go Adds rejectManagedSourceOwner on-chain ownership check, locks cosign to slot 0, propagates errors from AcceptsEntry/ChallengeHeaders, and migrates to solana-go/v2. Security-critical path; logic is well-tested by the new TestVerifyAndSettleRejectsManagedOwnerBehindDelegate and TestVerifyAndSettleReportsSourceInspectionFailureSeparately tests.
go/protocols/x402/verify.go Replaces single FeePayer authority check with full ManagedSigners sweep across accounts[3:] and ATA derivation, uses the instruction's actual token program for ATA derivation (matching Rust/TS behavior). The broadened guard is correct for both single-signer and multisig transaction shapes.
go/paykit/usage.go Introduces usageChallenge helper for atomic accepts-entry + challenge-header assembly, mirroring appendChallenge in middleware.go. The previously-flagged partial-402 split failure is now correctly resolved in both writeUsage402 and RequireUsageFunc.
go/paykit/middleware.go Adds appendChallenge for atomic 402 challenge assembly; partial adapter failures are logged and that protocol dropped from the offer, with a 500 raised only if all adapters fail. Moves ContextWithPaymentForTests to export_test.go.
go/paycore/signer/signer.go Adds consistency check in FromBytes to reject 64-byte blobs where the embedded public-key half does not match the seed; prevents misidentification of the operator's on-chain address when loaded from corrupted or tampered key material.
go/paykit/adapters/mpp/adapter.go Fixes signerBridge.PublicKey() to decode the key once at construction (propagating the error) rather than silently swallowing a decode failure on every call. Error propagation for ChallengeHeaders and AcceptsEntry updated to match the revised interface.
go/go.mod Replaces vendored lgalabru/solana-go fork with solana-foundation/solana-go/v2 v2.0.0-rc, dropping the replace directive. Swaps json-iterator for goccy/go-json, upgrades mongo-driver to v2.
go/paykit/client.go Updates Adapter interface: AcceptsEntry and ChallengeHeaders now return (T, error) instead of T. All implementors updated in the same PR.
go/paykit/export_test.go New file consolidating ContextWithPaymentForTests (moved from middleware.go) and ContextWithChargeForTests (moved from usage.go) into a single test-only export file; cleans up the production API surface.
go/paycore/solana.go Adds DefaultDecimalsForCurrency and stablecoinDecimals map, replacing the hardcoded decimalsFor stub in the MPP adapter with a proper lookup path. ResolveMint switch simplified to an if-statement.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Client
    participant Middleware
    participant ExactAdapter
    participant StructuralVerifier
    participant OnChainRPC

    Client->>Middleware: HTTP request with X-Payment header
    Middleware->>ExactAdapter: VerifyAndSettle(req)
    ExactAdapter->>StructuralVerifier: VerifyExactTransaction(tx, reqs)
    StructuralVerifier-->>ExactAdapter: ok / VerifyError

    ExactAdapter->>OnChainRPC: rejectManagedSourceOwner(tx, managedSigners)
    OnChainRPC-->>ExactAdapter: GetAccountInfoWithOpts(source)
    ExactAdapter-->>ExactAdapter: check owner bytes[32:64] vs managed signers

    alt owner is managed signer
        ExactAdapter-->>Middleware: PaymentError code invalid_exact_svm_payload
    else RPC/inspection failure
        ExactAdapter-->>Middleware: PaymentError code source_owner_check_failed
    else ok
        ExactAdapter->>ExactAdapter: cosign slot 0 only
        ExactAdapter->>OnChainRPC: SendEncodedTransaction + awaitConfirmation
        ExactAdapter-->>Middleware: Payment
        Middleware-->>Client: 200 + X-Payment-Response
    end

    note over Middleware,ExactAdapter: 402 challenge path
    Middleware->>ExactAdapter: appendChallenge(gate)
    ExactAdapter->>ExactAdapter: AcceptsEntry(gate) entry or err
    ExactAdapter->>ExactAdapter: ChallengeHeaders(gate) headers or err
    alt both succeed
        ExactAdapter-->>Middleware: entry + headers appended atomically
        Middleware-->>Client: 402 + payment-required header
    else either fails
        ExactAdapter-->>Middleware: error protocol dropped from offer
        Middleware-->>Client: 500 challenge_generation_failed if all adapters fail
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Client
    participant Middleware
    participant ExactAdapter
    participant StructuralVerifier
    participant OnChainRPC

    Client->>Middleware: HTTP request with X-Payment header
    Middleware->>ExactAdapter: VerifyAndSettle(req)
    ExactAdapter->>StructuralVerifier: VerifyExactTransaction(tx, reqs)
    StructuralVerifier-->>ExactAdapter: ok / VerifyError

    ExactAdapter->>OnChainRPC: rejectManagedSourceOwner(tx, managedSigners)
    OnChainRPC-->>ExactAdapter: GetAccountInfoWithOpts(source)
    ExactAdapter-->>ExactAdapter: check owner bytes[32:64] vs managed signers

    alt owner is managed signer
        ExactAdapter-->>Middleware: PaymentError code invalid_exact_svm_payload
    else RPC/inspection failure
        ExactAdapter-->>Middleware: PaymentError code source_owner_check_failed
    else ok
        ExactAdapter->>ExactAdapter: cosign slot 0 only
        ExactAdapter->>OnChainRPC: SendEncodedTransaction + awaitConfirmation
        ExactAdapter-->>Middleware: Payment
        Middleware-->>Client: 200 + X-Payment-Response
    end

    note over Middleware,ExactAdapter: 402 challenge path
    Middleware->>ExactAdapter: appendChallenge(gate)
    ExactAdapter->>ExactAdapter: AcceptsEntry(gate) entry or err
    ExactAdapter->>ExactAdapter: ChallengeHeaders(gate) headers or err
    alt both succeed
        ExactAdapter-->>Middleware: entry + headers appended atomically
        Middleware-->>Client: 402 + payment-required header
    else either fails
        ExactAdapter-->>Middleware: error protocol dropped from offer
        Middleware-->>Client: 500 challenge_generation_failed if all adapters fail
    end
Loading

Reviews (24): Last reviewed commit: "fix(go): distinguish source owner check ..." | Re-trigger Greptile

Comment thread go/protocols/x402/upto_test.go Outdated
MemoryChannelStore.DeleteChannel deleted from s.data but never s.locks, so the
per-channel mutex map grew without bound for the process lifetime; delete from
both under the same mu.

BuildCredential silently skipped the compute-unit price/limit instructions when
their builders errored (if ix, err := ...; err == nil), so a charge could be
signed without a compute-unit limit. Propagate the error like the surrounding
code does.
…ckages

strconv.FormatUint/ParseUint over fmt.Sprintf/Sscanf for integer
conversion, drop single-case switch and redundant []byte and import
aliases, and MixedCaps/initialism fixes. No behavior change.
Collapse NewChallengeWithSecret/NewChallengeWithSecretFull into one
constructor whose optional expires/digest/description/opaque fields are
set via named ChallengeOption values, so the three same-typed optional
strings can no longer be transposed silently at a call site. Also fix
the wire package doc comment (was 'core') and rename the cap parameter
that shadowed the builtin.
PaymentIdentifierInfo.Id becomes ID; the JSON tag stays 'id' so the
wire shape is unchanged.
Drop the test-only SetRPCForTests method from the public X402Upto and
inject the RPC client through a new UptoConfig.RPCClient field instead.
Extract the adapter's paykit.Config to UptoConfig mapping into
buildUptoConfig so the adapter test can inject a deterministic client
without a public test seam.
Adapter.AcceptsEntry/ChallengeHeaders and UsageAdapter now return
(value, error) so a base58 or serialization failure surfaces to the
caller instead of being swallowed. The 402 challenge writer logs and
drops the offending protocol rather than emitting a half-built header,
and signerBridge decodes the operator pubkey once at construction where
the error can propagate. Move ContextWith*ForTests helpers out of the
production surface into export_test.go.
The interface refactor added error-return branches (adapter accepts/
challenge builders, RPC config, binding checks) that dropped statement
coverage below the 91% CI gate. Add in-package tests for the exact and
mpp adapters (Protocol, ChallengeHeaders, VerifyAcceptedBinding,
normalizeNetwork, missing-secret and bad-recipient constructors) and the
402 challenge-writer error-drop paths so each new branch is exercised.
The upto tests set engine.rpc directly (an unexported field) after
construction, which bypassed the public RPCClient config field this PR
introduced to replace SetRPCForTests. Pass RPCClient in the UptoConfig
literal so the tests exercise the same injection path real callers use.
Ran go fix across the Go SDK and harness modules: interface{} -> any, the
slices/maps stdlib helpers, strings.SplitSeq iteration, min/max builtins, and
range-over-int/loopvar rewrites. Mechanical, no behavior change; the generated
codama client under protocols/programs/paymentchannels is left untouched.
build, vet, gofmt, and the full go test suite pass.
Add scripts/check-go-fix.sh and run it in the Go lint job so CI fails
when the Go 1.26 language/stdlib modernizers (go fix -inline=false) would
still change any non-generated source, keeping the SDK on newer idioms
(any, min/max, new(expr), range-over-int, slices/maps, SplitSeq, wg.Go)
without manual review comments. The inline analyzer is excluded because
it applies opt-in //go:fix inline directives and is cache sensitive;
generated codama clients are excluded by their AUTOGENERATED header. On
failure the gate prints the suggested diff so the fix is copy-pasteable.
@EfeDurmaz16
EfeDurmaz16 force-pushed the fix/go-idiomatic-cleanup branch from 94ee9db to 6ff0a6c Compare July 5, 2026 08:27
@EfeDurmaz16

Copy link
Copy Markdown
Collaborator Author

@greptile-apps review

Comment thread go/paykit/adapters/mpp/adapter.go Outdated
Comment thread go/protocols/mpp/client/payment_channels.go Outdated
@EfeDurmaz16

Copy link
Copy Markdown
Collaborator Author

@greptile-apps please run a fresh full review of the latest commit.

@lgalabru

lgalabru commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Merge conflicts

Efe Baran Durmaz added 2 commits July 8, 2026 22:06
…eanup

# Conflicts:
#	go/paykit/adapters/x402/upto.go
#	go/protocols/x402/upto.go
#	go/protocols/x402/upto_test.go
@EfeDurmaz16

Copy link
Copy Markdown
Collaborator Author

@greptile-apps please review

…pdates

DeleteChannel deleted s.locks[channelID] alongside s.data[channelID]. A
concurrent UpdateChannel that already obtained the channel's mutex from
channelLock would then serialize against a different mutex minted by the
next channelLock call for the same id (the old one having been deleted),
so two updaters could interleave their read-modify-write and lose an update.
Keep the per-channel lock for the process lifetime instead; the lock map is
bounded by the number of distinct channel ids ever seen.

Adds a regression test asserting channelLock returns a stable mutex across
DeleteChannel (fails if the lock-delete is reintroduced).
check-go-fix.sh captured 'go fix -diff' stdout with 2>/dev/null and treated an
empty diff as passing. Because go fix -diff exits non-zero whenever it finds
diffs (like gofmt -l), the exit code cannot distinguish that from a real error,
and a build/parse failure printed only to the discarded stderr, yielding an
empty diff and a green gate -- a fail-open on the harness/go-client's only CI
signal. Capture stderr instead and fail the gate closed when it is non-empty.
@EfeDurmaz16

Copy link
Copy Markdown
Collaborator Author

Folded two fixes on top of the cleanup (kept the standalone attempt out — the solana-go -> solana-foundation/solana-go/v2 migration is already on this branch, so #221 was redundant and is closed):

  • 16db24c fix(go): keep the per-channel lock in DeleteChannel. DeleteChannel deleted s.locks[channelID] alongside the data, so a concurrent UpdateChannel holding the old mutex would serialize against a fresh mutex minted for the same id -> interleaved read-modify-write, lost update. The per-channel lock is now kept for the process lifetime (bounded by distinct channel ids). A regression test asserts channelLock returns a stable mutex across DeleteChannel (it fails if the lock-delete is reintroduced). Full server package green under -race.
  • d70920b fix(go): make the go-fix CI gate fail closed. check-go-fix.sh captured go fix -diff with 2>/dev/null and treated an empty diff as passing. Since go fix -diff exits non-zero whenever it finds diffs (like gofmt -l), the exit code cannot distinguish that from a real error, and a build/parse failure printed only to the discarded stderr -> empty diff -> green gate (a fail-open on harness/go-client's only CI signal). It now captures stderr and fails closed when non-empty.

go build ./... and go test ./... (21 packages) pass.

@EfeDurmaz16

Copy link
Copy Markdown
Collaborator Author

@greptile-apps please review

@EfeDurmaz16

Copy link
Copy Markdown
Collaborator Author

@greptile-apps please re-review. The prior 4/5's only finding (P2: upto_test.go bypassing the RPCClient config) is resolved on the current head — the tests use UptoConfig.RPCClient now (details inline). Two fixes were also folded in since: a DeleteChannel per-channel-lock race and a fail-closed go fix CI gate.

@EfeDurmaz16 EfeDurmaz16 changed the title refactor(go): idiomatic Go cleanup across the SDK fix(go): migrate to solana-go v2 and harden verification Jul 10, 2026
@EfeDurmaz16

Copy link
Copy Markdown
Collaborator Author

@greptile-apps please review the current head.

@EfeDurmaz16

Copy link
Copy Markdown
Collaborator Author

@greptile-apps please re-review the final combined Go head. Adversarial review added off-localnet open transaction binding, confirmed/finalized gates, durable top-up replay consumption, delegated managed-source ownership checks, safe transaction re-marshalling, fail-closed challenge generation, and clone-isolation tests; full/race/lint/consumer guards are green.

@EfeDurmaz16

Copy link
Copy Markdown
Collaborator Author

@greptile-apps please re-review exact head 86602a9; the prior request preceded the fork-head update.

@EfeDurmaz16

Copy link
Copy Markdown
Collaborator Author

@greptile-apps please review

@EfeDurmaz16

Copy link
Copy Markdown
Collaborator Author

@greptile-apps please review

Comment thread go/paykit/adapters/x402/exact.go
@EfeDurmaz16

Copy link
Copy Markdown
Collaborator Author

@greptile-apps please review

@lgalabru
lgalabru merged commit e68ca71 into solana-foundation:split/pr216-ci-harness-mega Jul 13, 2026
17 checks passed
EfeDurmaz16 added a commit that referenced this pull request Jul 13, 2026
Rebasing this leaf onto the harness base tip pulls in PR #214 (solana-go v2), which rewrites the four go dependency manifests (harness/go-{client,server}/go.{mod,sum}). Their delivery-head blob no longer matches the 216 source, so the ledger reconciles them from integrated to open_pr owned by #214 and repoints the surviving integrated evidence at the current delivery head. Produced by scripts/reconcile-pr216-open-deliveries.mjs; validate-pr216-ledger passes.
EfeDurmaz16 added a commit that referenced this pull request Jul 14, 2026
Reconcile #239's on-chain Channel-account state binding onto the base's
solana-go v2 migration and hardened open/top-up verification (#214). Adds the
authoritative state-binding layer that no sibling or the base delivers:
fetchAndBindChannelAccount, NewOpenStateTxVerifier, bindConfirmedOpenChannel,
validateBoundOpenChannel, NewTopUpStateTxVerifier and confirmedTransactionSlot.
Session open/top-up state is bound to the on-chain Channel account read at the
confirmed slot; an unconfirmed or economically mismatched channel is rejected
(fail closed). Base store-safety (env-gated) is preserved; the config-field
guard reconciles to honor the same PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE opt-in.

Ports #239's v1 solana-go usage to v2 and keeps base's structural verifiers +
concurrency/replay hardening; drops #239's duplicate top-up-fence and legacy
verifier redefinitions superseded by the base.
EfeDurmaz16 added a commit that referenced this pull request Jul 14, 2026
Give the shared test operator a real signing key (sessionTestOperatorKey backs
sessionTestRecipient) and co-sign the rentPayer slot so signature-only open
fixtures reproduce a fully signed confirmed transaction. Base #214 verifies open
signatures cryptographically, so the prior dummy-signature fixtures no longer
pass. Also restores base's env-gated nil-store default (a merge artifact had
re-added an unconditional off-localnet rejection that ignored the opt-in).
EfeDurmaz16 added a commit that referenced this pull request Jul 14, 2026
Base #214 hardened open verification (real wire-bound signatures, no
placeholder acceptance) and the session store policy became env-gated.
Update the stale Go test fixtures/assertions to the reconciled behavior:

- flip TestNewOpenTxVerifierKeepsStructuralPlaceholderAcceptance to
  TestNewOpenTxVerifierRejectsPlaceholderSignature: the seam now rejects the
  pending placeholder ("must be a real transaction signature")
- assert the real confirmed signature and derived PDA in the signature-only
  open test rather than the dummy reference the caller supplied
- thread the derived channel PDA into the top-up test and reflect the
  post-top-up deposit in the authoritative on-chain account
- match the reconciled off-localnet store-policy message, and enforce the
  unmarked-store rejection at the runtime safety gate (where it now lives)
  rather than at construction
- rename the idle-close no-signer test and assert the no-op: closeOnIdle bails
  before touching the channel when no merchant signer is configured

Test-only; no production source changed.
EfeDurmaz16 added a commit that referenced this pull request Jul 14, 2026
…216 equivalence ledger

Those five redelivery PRs landed on main rather than the #219 line, so their
open_pr followUps are corrected from 'Land PR #X' to note the merge and the
revalidation that follows when #219 rebases onto main. Validator + self-test
still pass (113 commits, 237 paths accounted for).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants