A lean, modern .NET SDK for Solana: RPC + WebSocket streaming, wire-level transaction signing/building. Optimised for low latency and a small dependency footprint — it is a deliberate, focused alternative to the heavier general-purpose SDKs, not a clone of them.
Status: 1.3.0, stable release line (semver compatibility promise now applies to the public API). All four projects are in place: Core primitives (incl. a Borsh reader/writer), the Rpc client (reads + typed account state via Mint/TokenAccount/NonceAccount + Token-2022 extension decoding (TokenExtensionSet), jsonParsed transaction/block/account reads, the full current JSON-RPC HTTP read surface (deprecated getStakeActivation excluded), send/simulate with coherent confirmed preflight defaults, single-pass JSON-RPC envelope validation, JSON-RPC batching via RpcBatch, typed transaction errors, full WebSocket subscription surface (incl. unstable voteSubscribe/slotsUpdatesSubscribe) with auto-reconnect and a bounded transport (message-size cap, per-subscription buffers, opt-in receive timeout), DI + resilience), the Wallet (Ed25519 keys, signing, verification, key parsing, BIP-39/SLIP-0010 mnemonic derivation; span-based Ed25519 hot paths), and Programs (System/Token/ATA/Compute Budget/Memo + the complete Address Lookup Table program incl. freeze, the full Token-2022 AuthorityType set, PDA/ATA, legacy + v0 transaction building/signing/parsing with Solana's sanitize checks on deserialize, durable-nonce builder support, and instruction decompilation). As of 0.7.0 all JSON is source-generated (no reflection) and every assembly is Native AOT compatible (IsAotCompatible), with an AOT smoke sample published and run in CI. A separate live integration suite exercises the read and streaming paths against a real cluster.
Run from the repo root (where SolSharp.sln lives):
dotnet build— code style is enforced on build (EnforceCodeStyleInBuild), so style violations surface as warnings.dotnet test— NUnit suite.dotnet format— auto-applies the style. Note: it cannot auto-fix naming (IDE1006); fix those by hand.
- Never use
ConfigureAwait. Do not write.ConfigureAwait(false)or.ConfigureAwait(true)anywhere — not in library code, not in tests. This is a deliberate, permanent project choice; do not suggest adding it. - English only — code, comments, identifiers, test names, docs.
- Comments earn their place. Explain why — non-obvious rationale, wire-format quirks, gotchas — never restate what the code already says. No filler, decorative, or obvious comments. Public API carries full XML docs (summary, every
<param>,<returns>, and thrown<exception>); inline noise does not. - Default to
internal;publicis a deliberate contract. This is a library, so the public surface is an API others depend on — keep it minimal. A type ispubliconly when a consumer constructs, receives, or catches it (i.e. it appears in a public signature). Everything else — request/response plumbing, converters, sinks, internal helpers — isinternal, and tests reach it throughInternalsVisibleTo. - Attributes on their own line — never inline with the member, e.g.
[JsonPropertyName("id")]goes above the property, not beside it.dotnet formatdoes not enforce this (only Rider does), so write it that way by hand. - Target framework is
net8.0. Do not use net9-only APIs (e.g.JsonStringEnumMemberName,InlineArray-based span tricks that need newer ref-safety). - Modern C# only. File-scoped namespaces,
var, collection expressions[], primary constructors, switch expressions, pattern matching,is null/is not null. The full rule set lives in.editorconfig+Directory.Build.props— follow the analyzers, don't fight them. Do not restate style rules here. - A feature is not done until it is documented. Every user-facing addition or change lands in the same commit with all four documentation layers: (1) XML docs on the public API (enforced by CS1591 anyway); (2)
docs/USAGE.md— a runnable example in the matching section (or a new section +Contentsentry), with every snippet checked against the real signatures and model properties, not written from memory; (3)README.md— the wire-method list, feature bullets, and Layout if the shape of the repo changed (README.nuget.mdonly if the pitch/quick-start changes — it carries no method lists by design); (4)CHANGELOG.mdunder the release being prepared. Release-only extras: bumpVersioninDirectory.Build.props, refreshPackageReleaseNotesinsrc/SolSharp/SolSharp.csproj(nuget.org shows only the current version's notes), and update theStatus:line here.
Layering (dependencies point downward; no cycles):
- Core — byte-level types and codecs. No I/O, no crypto engine. Only dependency:
SimpleBase. - Wallet — the Ed25519 engine: sign, keygen, verify. Depends on Core.
- Rpc — HTTP JSON-RPC + WebSocket streaming client. Depends on Core.
- Programs — instruction builders, PDA/ATA derivation, message compilation, transaction building. Depends on Core and Wallet (for
ISignerand the on-curve check).
Rules:
Corereferences no other SolSharp project and pulls no network/crypto package. Litmus for "is it Core?": a pure type/constant/codec that everyone needs, with no I/O and no knowledge of a specific program/DEX.- Folder = namespace.
- Ed25519 / signing belongs in
Wallet, never inCore. Signature verification is exposed as an extension onPublicKeyfromWallet(Core keeps the type, Wallet owns the crypto).
SolSharp/
src/SolSharp.Core/ Encoding/ Primitives/ Converters/ Constants/
src/SolSharp.Rpc/ Protocol/ Models/ Streaming/ + client, options, DI
src/SolSharp.Wallet/ Keypair (+ parsing), ISigner, PublicKeyExtensions, Ed25519Curve
src/SolSharp.Programs/ AccountMeta/Instruction, Message + MessageV0, Transaction, TransactionBuilder, program builders (System/Token/ATA/Compute Budget/Memo/ALT), PDA/ATA
src/SolSharp/ packaging facade: bundles the four assemblies into the single SolSharp NuGet package (no source of its own)
tests/ SolSharp.{Core,Rpc,Wallet,Programs}.Tests (nested fixtures, mirroring src) + SolSharp.IntegrationTests (live cluster)
benchmarks/ SolSharp.Benchmarks: a standalone BenchmarkDotNet harness, outside the solution (run with dotnet run -c Release --project benchmarks/SolSharp.Benchmarks)
samples/ SolSharp.AotSmoke: the Native AOT smoke sample, part of the solution (so regular builds compile it); CI additionally publishes it with PublishAot and runs the binary
- NUnit + FluentAssertions + NSubstitute. NSubstitute only where there are real collaborators (pure utilities have nothing to mock).
- Every public member is done only when it has both full XML docs and a test. Don't skip a test because the method resembles one already covered — cover each distinct response/parse shape and each request param shape.
- One nested fixture per method under test:
public static class XTests { [TestFixture] public sealed class Method { ... } }. - Wire formats and crypto are money-critical: cover them with known vectors (RFC 8032 for signing, canonical compact-u16 / base58 vectors), not just round-trips.
IDE1006is disabled fortests/**soMethod_Scenario_Expectationnames are allowed.- For constructor-throws-only tests use an explicit discard:
Action act = () => _ = new T(...);. - Arrange / Act / Assert comments. Mark the three phases with
// Arrange,// Act,// Assert. When the call under test and its check are a single fluent statement (exception delegates,(await …).Should()…), use one// Act & Assert. Skip the labels on expression-bodied or single-statement[TestCase]tests where there is nothing to separate — never restructure a test body just to fit them. - Integration tests live in
SolSharp.IntegrationTests, hit a real cluster, and run as part ofdotnet test. They are tagged[Category("Integration")]; read/streaming tests default to public mainnet (SOLSHARP_RPC_URL/SOLSHARP_WS_URLoverride), and the write suite (airdrop, transfer, durable nonce) always targets devnet (SOLSHARP_DEVNET_RPC_URLoverride) — never mainnet. No key is ever committed. They report inconclusive — not failed — on rate limits or transport errors, so a busy node never reddens the suite. Skip them for a fast offline run withdotnet test --filter "TestCategory!=Integration".
- Anything that touches transaction bytes or signing must be tested against known-good vectors before it is trusted.
- Never commit secrets or private keys.
.gitignorecovers*.key,.env,secrets.json,appsettings.*.local.json. - Never hand a raw private key to a third-party library. Build with theirs if needed, but sign with our own signer; simulate and assert instructions/amounts/destination before sending.
PublicKeyis areadonly structbacked by fourulongwords (32 bytes inline, value equality, no per-key heap allocation). Base58 is cached only when the key is built from a string; from-bytes stays allocation-free. No zero-copyAsSpan()by design — useCopyTo/ToBytes.Commitmentserializes via a customJsonConverterapplied as a[JsonConverter]attribute (net8 has noJsonStringEnumMemberName). The attribute makes it self-serializing under default options, not justSolanaJsonSerializer.Options.- Wire enums/types follow that same pattern: self-serializing via attribute so they hold their wire form regardless of which
JsonSerializerOptionsare in play. - JSON is source-generated; reflection serialization is banned in src. All RPC/WS paths go through the internal
RpcJson.Options(resolver:JsonTypeInfoResolver.Combine(SolanaJsonContext, CoreJsonContext);SolanaJsonContextinRpc/Protocol/holds ~60 closed root registrations); Core's publicSolanaJsonSerializer.Optionscovers only the Core primitives via the publicCoreJsonContext, with no reflection fallback. GOTCHA: a source-gen context can only materialize a converter-attributed type if it can construct the converter - an inaccessible converter makes the generator drop the type (SYSLIB1220 + SYSLIB1030; warnings locally, errors under CI's-warnaserror) and every use fails at runtime withNotSupportedException. That is whyCommitmentJsonConverter/PublicKeyJsonConverterare public: keep converters of converter-attributed wire types public, and keepCoreJsonContextin the chain. Consequences: requestparamsentries are object-typed and dispatch by exact runtime type, so every boxed shape (configs inProtocol/RpcParams.cs, primitives, arrays — collections are pinned withToArray()) must be registered in the context; anonymous types cannot be used in requests; a newSendAsync<T>/subscription/batch root type must be added toSolanaJsonContext(unregistered types throwNotSupportedException, which the offline client tests catch); types behind hand-written converters are invisible to the generator's graph walk, so what a converter reads viaoptions.GetTypeInfo<T>()needs explicit registration. All four src projects setIsAotCompatible— the trim/AOT analyzers plus-warnaserrorrejectRequiresUnreferencedCode/RequiresDynamicCodeAPIs (e.g.JsonSerializer.Serialize(..., options)overloads,ValidateDataAnnotations). - Ed25519 lives in
WalletonBouncyCastle.Cryptography— not the .NET BCL (net8/10 ship no usable cross-platformEd25519: Windows unsupported, Apple's is non-conformant) and not a hand-rolled curve. Pure-managed/portable was chosen over libsodium/NSec's native dependency, since signing throughput is not the bottleneck;ISignerkeeps the backend swappable. Keypairis one word to match the Solana ecosystem (solana-keygen, web3.jsKeypair), not .NET'sKeyPair. It stores only the 32-byte seed, derives the public key once, and zeroes the seed onDispose.- Transactions support both the legacy and v0 (versioned) message formats behind a shared
ITransactionMessage, soTransactionsigns and serializes either. Account ordering matches Solana's compilation exactly (fee payer first, then accounts sorted by public-key bytes within the writable-signer / readonly-signer / writable / readonly classes). v0 additionally drains non-signer, non-program accounts found in a supplied lookup table into a table lookup and prefixes the0x80version byte. Both are validated byte-for-byte againstsolana-sdk(solders). PublicKey.IsOnCurveis direct field arithmetic, not BouncyCastle: BC's public-key validation rejects non-canonical encodings (y ≥ p) that Solana'scurve25519-dalekaccepts after reducing mod p. It is fuzzed against solders so PDA/ATA derivation matches the network.- SPL Token account state uses the fixed-size
Packlayout, not Borsh.Mint(82 bytes) andTokenAccount(165 bytes) read aCOptionas a 4-byte little-endian tag followed by an always-present value (the slot is reserved even whenNone) — unlike Borsh's 1-byte tag with the value present only whenSome. SoBorshReader/BorshWriterare for Anchor/Borsh data; the SPL decoders are hand-written against the Pack layout and KAT'd againstsolders.token.state. (The Token instruction data is different again: a minimalCOptionof a 1-byte tag plus the value only whenSome.) - Money-critical encodings (message/transaction serialization, instruction data, PDA/ATA, on-curve) are checked byte-for-byte against
solana-sdk(solders) andsolana-py, not just round-trips. - Ships as one NuGet package. The source stays four layered projects (so the compiler keeps Core crypto/IO-free, Wallet owns Ed25519, etc.), but only the
src/SolSharpfacade is packable: it references the four withPrivateAssets="all"and an MSBuild target (BundleProjectReferences) folds their DLLs + XML docs into a singleSolSharppackage, re-declaring the real third-party deps (kept in sync by hand). PDBs are embedded (DebugType=embedded) so symbols ride inside the bundled DLLs rather than a near-empty.snupkg. Default-falseIsPackable(overridden only forMSBuildProjectName == SolSharp) keeps every other project from emitting its own package.