Skip to content

Latest commit

 

History

History
87 lines (67 loc) · 14.2 KB

File metadata and controls

87 lines (67 loc) · 14.2 KB

SolSharp

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.

Commands

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.

Hard rules

  • 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; public is a deliberate contract. This is a library, so the public surface is an API others depend on — keep it minimal. A type is public only 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 — is internal, and tests reach it through InternalsVisibleTo.
  • Attributes on their own line — never inline with the member, e.g. [JsonPropertyName("id")] goes above the property, not beside it. dotnet format does 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 + Contents entry), 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.md only if the pitch/quick-start changes — it carries no method lists by design); (4) CHANGELOG.md under the release being prepared. Release-only extras: bump Version in Directory.Build.props, refresh PackageReleaseNotes in src/SolSharp/SolSharp.csproj (nuget.org shows only the current version's notes), and update the Status: line here.

Architecture

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 ISigner and the on-curve check).

Rules:

  • Core references 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 in Core. Signature verification is exposed as an extension on PublicKey from Wallet (Core keeps the type, Wallet owns the crypto).

Layout

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

Testing

  • 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.
  • IDE1006 is disabled for tests/** so Method_Scenario_Expectation names 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 of dotnet test. They are tagged [Category("Integration")]; read/streaming tests default to public mainnet (SOLSHARP_RPC_URL / SOLSHARP_WS_URL override), and the write suite (airdrop, transfer, durable nonce) always targets devnet (SOLSHARP_DEVNET_RPC_URL override) — 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 with dotnet test --filter "TestCategory!=Integration".

Security (money-critical)

  • Anything that touches transaction bytes or signing must be tested against known-good vectors before it is trusted.
  • Never commit secrets or private keys. .gitignore covers *.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.

Decisions

  • PublicKey is a readonly struct backed by four ulong words (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-copy AsSpan() by design — use CopyTo / ToBytes.
  • Commitment serializes via a custom JsonConverter applied as a [JsonConverter] attribute (net8 has no JsonStringEnumMemberName). The attribute makes it self-serializing under default options, not just SolanaJsonSerializer.Options.
  • Wire enums/types follow that same pattern: self-serializing via attribute so they hold their wire form regardless of which JsonSerializerOptions are 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); SolanaJsonContext in Rpc/Protocol/ holds ~60 closed root registrations); Core's public SolanaJsonSerializer.Options covers only the Core primitives via the public CoreJsonContext, 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 with NotSupportedException. That is why CommitmentJsonConverter/PublicKeyJsonConverter are public: keep converters of converter-attributed wire types public, and keep CoreJsonContext in the chain. Consequences: request params entries are object-typed and dispatch by exact runtime type, so every boxed shape (configs in Protocol/RpcParams.cs, primitives, arrays — collections are pinned with ToArray()) must be registered in the context; anonymous types cannot be used in requests; a new SendAsync<T>/subscription/batch root type must be added to SolanaJsonContext (unregistered types throw NotSupportedException, which the offline client tests catch); types behind hand-written converters are invisible to the generator's graph walk, so what a converter reads via options.GetTypeInfo<T>() needs explicit registration. All four src projects set IsAotCompatible — the trim/AOT analyzers plus -warnaserror reject RequiresUnreferencedCode/RequiresDynamicCode APIs (e.g. JsonSerializer.Serialize(..., options) overloads, ValidateDataAnnotations).
  • Ed25519 lives in Wallet on BouncyCastle.Cryptography — not the .NET BCL (net8/10 ship no usable cross-platform Ed25519: 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; ISigner keeps the backend swappable.
  • Keypair is one word to match the Solana ecosystem (solana-keygen, web3.js Keypair), not .NET's KeyPair. It stores only the 32-byte seed, derives the public key once, and zeroes the seed on Dispose.
  • Transactions support both the legacy and v0 (versioned) message formats behind a shared ITransactionMessage, so Transaction signs 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 the 0x80 version byte. Both are validated byte-for-byte against solana-sdk (solders).
  • PublicKey.IsOnCurve is direct field arithmetic, not BouncyCastle: BC's public-key validation rejects non-canonical encodings (y ≥ p) that Solana's curve25519-dalek accepts after reducing mod p. It is fuzzed against solders so PDA/ATA derivation matches the network.
  • SPL Token account state uses the fixed-size Pack layout, not Borsh. Mint (82 bytes) and TokenAccount (165 bytes) read a COption as a 4-byte little-endian tag followed by an always-present value (the slot is reserved even when None) — unlike Borsh's 1-byte tag with the value present only when Some. So BorshReader / BorshWriter are for Anchor/Borsh data; the SPL decoders are hand-written against the Pack layout and KAT'd against solders.token.state. (The Token instruction data is different again: a minimal COption of a 1-byte tag plus the value only when Some.)
  • Money-critical encodings (message/transaction serialization, instruction data, PDA/ATA, on-curve) are checked byte-for-byte against solana-sdk (solders) and solana-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/SolSharp facade is packable: it references the four with PrivateAssets="all" and an MSBuild target (BundleProjectReferences) folds their DLLs + XML docs into a single SolSharp package, 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-false IsPackable (overridden only for MSBuildProjectName == SolSharp) keeps every other project from emitting its own package.